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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 23:21:09 +00:00
co-authored by Cursor
parent b824948a5a
commit 3c26fcb2f8
7 changed files with 153 additions and 6 deletions
+2
View File
@@ -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-*/
+7 -4
View File
@@ -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 });
+5 -2
View File
@@ -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',
+68
View File
@@ -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 }],
]);
});
});
+31
View File
@@ -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' };
}
@@ -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);
});
});
+18
View File
@@ -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,
};
}