From 3c26fcb2f8fdb7ea47a2902cc746026b7533d83e Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 23:21:09 +0000 Subject: [PATCH] fix(e2e): assert restored project in the banner and isolate phase artifacts Page-wide project-name text hits both the top bar and the session rail after restart. Click the complementary exact title, then assert the banner exact name. Split Playwright output and HTML reports by UI_PHASE so post-restart cannot wipe main traces. Co-authored-by: Cursor --- .gitignore | 2 + web/e2e/document-revision-runtime.spec.ts | 11 ++-- web/playwright.config.ts | 7 +- web/src/lib/currentProjectRestore.test.ts | 68 ++++++++++++++++++++ web/src/lib/currentProjectRestore.ts | 31 +++++++++ web/src/lib/playwrightPhaseArtifacts.test.ts | 22 +++++++ web/src/lib/playwrightPhaseArtifacts.ts | 18 ++++++ 7 files changed, 153 insertions(+), 6 deletions(-) create mode 100644 web/src/lib/currentProjectRestore.test.ts create mode 100644 web/src/lib/currentProjectRestore.ts create mode 100644 web/src/lib/playwrightPhaseArtifacts.test.ts create mode 100644 web/src/lib/playwrightPhaseArtifacts.ts diff --git a/.gitignore b/.gitignore index 18115fbc..bb8cac06 100644 --- a/.gitignore +++ b/.gitignore @@ -63,4 +63,6 @@ regulations/ # Playwright UI acceptance local secrets / runtime output .doc/test-reports/assets/**/phase-secrets.local.json .doc/test-reports/assets/**/playwright-output/ +.doc/test-reports/assets/**/playwright-output-*/ .doc/test-reports/assets/**/playwright-report/ +.doc/test-reports/assets/**/playwright-report-*/ diff --git a/web/e2e/document-revision-runtime.spec.ts b/web/e2e/document-revision-runtime.spec.ts index a17862e4..30310f6d 100644 --- a/web/e2e/document-revision-runtime.spec.ts +++ b/web/e2e/document-revision-runtime.spec.ts @@ -19,6 +19,10 @@ import { DOC_DUTY_SELECT_TESTID, evaluateReviewerDuty, } from '../src/lib/dutyCellAssertion'; +import { + currentProjectBannerName, + visibleProjectEntry, +} from '../src/lib/currentProjectRestore'; /** * Document revision runtime — real UI acceptance (UI-01..UI-16). @@ -621,9 +625,8 @@ test.describe('document-revision-runtime UI @post-restart', () => { const st = readPhaseState(); const secrets = readPhaseSecrets(); await login(page, st.userA, secrets.passA); - // Locate project by visible name. - await page.getByText(st.projectName, { exact: false }).first().click(); - await expect(page.getByText(st.projectName)).toBeVisible({ timeout: 20_000 }); + await visibleProjectEntry(page, st.projectName).click(); + await expect(currentProjectBannerName(page, st.projectName)).toBeVisible({ timeout: 20_000 }); if (st.controlledDocId) { await openWorkflowDocById(page, st.controlledDocId); } else if (st.controlledDocTitle) { @@ -765,7 +768,7 @@ test.describe.serial('document-revision-runtime UI acceptance', () => { await expect(memberRole).toHaveValue('reviewer'); await projectModal.getByRole('button', { name: /确认立项/ }).click(); await expect(projectModalHeading).toBeHidden({ timeout: 15_000 }); - await expect(page.getByRole('banner').getByText(PROJECT_NAME)).toBeVisible({ timeout: 30_000 }); + await expect(currentProjectBannerName(page, PROJECT_NAME)).toBeVisible({ timeout: 30_000 }); await expect(page.locator('aside')).toBeVisible(); // Template doc selected: must show non-error hint, never 账本未就绪 await expect(page.getByTestId('doc-rev-template-hint')).toBeVisible({ timeout: 15_000 }); diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 47a7b40e..9ba999c4 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -1,11 +1,13 @@ import { defineConfig, devices } from '@playwright/test'; import * as path from 'path'; import { fileURLToPath } from 'url'; +import { playwrightArtifactDirNames } from './src/lib/playwrightPhaseArtifacts'; /** * Ubuntu Server headless UI acceptance config (DOCREV runtime). * Enable only when RUN_UI_ACCEPTANCE=1 against a real dev stack — no mocks. * Script never touches docker; UI_PHASE=post-restart is a second run after host restart. + * outputDir / HTML report are split by UI_PHASE so post-restart cannot wipe main traces. */ const runAcceptance = process.env.RUN_UI_ACCEPTANCE === '1'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -13,6 +15,7 @@ const evidenceRoot = path.resolve( __dirname, '../.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui', ); +const phaseDirs = playwrightArtifactDirNames(process.env.UI_PHASE); export default defineConfig({ testDir: './e2e', @@ -21,10 +24,10 @@ export default defineConfig({ retries: 0, workers: 1, timeout: 600_000, - outputDir: path.join(evidenceRoot, 'playwright-output'), + outputDir: path.join(evidenceRoot, phaseDirs.outputDirName), reporter: [ ['list'], - ['html', { open: 'never', outputFolder: path.join(evidenceRoot, 'playwright-report') }], + ['html', { open: 'never', outputFolder: path.join(evidenceRoot, phaseDirs.reportDirName) }], ], use: { baseURL: process.env.BASE_URL || 'http://127.0.0.1:8080', diff --git a/web/src/lib/currentProjectRestore.test.ts b/web/src/lib/currentProjectRestore.test.ts new file mode 100644 index 00000000..68486e34 --- /dev/null +++ b/web/src/lib/currentProjectRestore.test.ts @@ -0,0 +1,68 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + currentProjectBannerName, + evaluatePageWideProjectNameHits, + requireProjectName, + visibleProjectEntry, +} from './currentProjectRestore'; + +describe('currentProjectRestore', () => { + it('rejects empty project names', () => { + assert.throws(() => requireProjectName(' '), /empty_project_name/); + }); + + it('rejects page-wide project name when banner and sidebar both match', () => { + const verdict = evaluatePageWideProjectNameHits(2); + assert.equal(verdict.ok, false); + assert.match(verdict.reason, /page_wide_project_name_not_unique:2/); + }); + + it('scopes the visible entry to complementary exact title', () => { + const calls: unknown[] = []; + const page = { + getByRole(role: 'banner' | 'complementary') { + calls.push(['role', role]); + return { + getByText() { + throw new Error('entry must use title, not page text'); + }, + getByTitle(name: string, options?: { exact?: boolean }) { + calls.push(['title', name, options]); + return 'entry'; + }, + }; + }, + }; + const loc = visibleProjectEntry(page, ' UI验收-超声微创系统-mu0eq020 '); + assert.equal(loc, 'entry'); + assert.deepEqual(calls, [ + ['role', 'complementary'], + ['title', 'UI验收-超声微创系统-mu0eq020', { exact: true }], + ]); + }); + + it('scopes the current project assertion to banner exact text', () => { + const calls: unknown[] = []; + const page = { + getByRole(role: 'banner' | 'complementary') { + calls.push(['role', role]); + return { + getByText(name: string, options?: { exact?: boolean }) { + calls.push(['text', name, options]); + return 'banner'; + }, + getByTitle() { + throw new Error('banner must use exact text, not title'); + }, + }; + }, + }; + const loc = currentProjectBannerName(page, 'UI验收-超声微创系统-mu0eq020'); + assert.equal(loc, 'banner'); + assert.deepEqual(calls, [ + ['role', 'banner'], + ['text', 'UI验收-超声微创系统-mu0eq020', { exact: true }], + ]); + }); +}); diff --git a/web/src/lib/currentProjectRestore.ts b/web/src/lib/currentProjectRestore.ts new file mode 100644 index 00000000..994645a3 --- /dev/null +++ b/web/src/lib/currentProjectRestore.ts @@ -0,0 +1,31 @@ +export type ProjectRestorePage = { + getByRole(role: 'banner' | 'complementary'): { + getByText(name: string, options?: { exact?: boolean }): unknown; + getByTitle(name: string, options?: { exact?: boolean }): unknown; + }; +}; + +export function requireProjectName(projectName: string): string { + const name = projectName.trim(); + if (!name) { + throw new Error('empty_project_name'); + } + return name; +} + +/** 侧栏可见项目入口:complementary 内 exact title,禁止页面级 getByText / .first()。 */ +export function visibleProjectEntry(page: ProjectRestorePage, projectName: string) { + return page.getByRole('complementary').getByTitle(requireProjectName(projectName), { exact: true }); +} + +/** 顶栏当前项目:banner 内 exact 文本,语义为已激活项目而非侧栏副本。 */ +export function currentProjectBannerName(page: ProjectRestorePage, projectName: string) { + return page.getByRole('banner').getByText(requireProjectName(projectName), { exact: true }); +} + +export function evaluatePageWideProjectNameHits(hits: number): { ok: boolean; reason: string } { + if (hits !== 1) { + return { ok: false, reason: `page_wide_project_name_not_unique:${hits}` }; + } + return { ok: true, reason: 'unique' }; +} diff --git a/web/src/lib/playwrightPhaseArtifacts.test.ts b/web/src/lib/playwrightPhaseArtifacts.test.ts new file mode 100644 index 00000000..d92bc377 --- /dev/null +++ b/web/src/lib/playwrightPhaseArtifacts.test.ts @@ -0,0 +1,22 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { playwrightArtifactDirNames, playwrightPhaseSlug } from './playwrightPhaseArtifacts'; + +describe('playwrightPhaseArtifacts', () => { + it('defaults unknown or empty UI_PHASE to main', () => { + assert.equal(playwrightPhaseSlug(undefined), 'main'); + assert.equal(playwrightPhaseSlug(''), 'main'); + assert.equal(playwrightPhaseSlug('MAIN'), 'main'); + }); + + it('keeps post-restart distinct from main so the second run cannot overwrite traces', () => { + const main = playwrightArtifactDirNames('main'); + const restart = playwrightArtifactDirNames('post-restart'); + assert.equal(main.outputDirName, 'playwright-output-main'); + assert.equal(main.reportDirName, 'playwright-report-main'); + assert.equal(restart.outputDirName, 'playwright-output-post-restart'); + assert.equal(restart.reportDirName, 'playwright-report-post-restart'); + assert.notEqual(main.outputDirName, restart.outputDirName); + assert.notEqual(main.reportDirName, restart.reportDirName); + }); +}); diff --git a/web/src/lib/playwrightPhaseArtifacts.ts b/web/src/lib/playwrightPhaseArtifacts.ts new file mode 100644 index 00000000..7a2f4660 --- /dev/null +++ b/web/src/lib/playwrightPhaseArtifacts.ts @@ -0,0 +1,18 @@ +export type PlaywrightUiPhase = 'main' | 'post-restart'; + +export function playwrightPhaseSlug(uiPhase?: string): PlaywrightUiPhase { + return uiPhase?.trim() === 'post-restart' ? 'post-restart' : 'main'; +} + +export function playwrightArtifactDirNames(uiPhase?: string): { + outputDirName: string; + reportDirName: string; + slug: PlaywrightUiPhase; +} { + const slug = playwrightPhaseSlug(uiPhase); + return { + outputDirName: `playwright-output-${slug}`, + reportDirName: `playwright-report-${slug}`, + slug, + }; +}