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, + }; +}