test(e2e): cover 143 reload, fail-closed GET, and new-project compile

Live M1 sign-off needs browser proof that missing projection is not 404,
outsiders share DOCUMENT_NOT_FOUND, and a real PI compile survives reload.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-14 14:34:37 +00:00
co-authored by Cursor
parent f34bec40f5
commit 3e4abc51cd
2 changed files with 384 additions and 39 deletions
+1 -1
View File
@@ -60,7 +60,7 @@
| holder | phase | since | scope | queue |
|--------|-------|-------|-------|-------|
| Cursor | merging | 2026-09-14 | DOC-RELIABILITY M1 live | - |
| Cursor | e2e | 2026-09-14 | DOC-RELIABILITY M1 live | - |
**phase**: `idle` | `merging` | `smoke` | `e2e`
+383 -38
View File
@@ -1,7 +1,17 @@
import { test, expect, type Page } from '@playwright/test';
import { test, expect, type Page, type Browser } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { GOVERNANCE_BLOCKED_HINT } from '../src/lib/blockedGovernanceCopy';
import { currentProjectBannerName } from '../src/lib/currentProjectRestore';
import {
ANY_TERMINAL_STATUS_RE,
evaluateSquadObservation,
} from '../src/lib/squadCompletionObservation';
import {
CATEGORY_TRIGGER_LABEL,
requiredCategoryForDocOpen,
} from '../src/lib/workflowCategoryNavigation';
/**
* M1 live: ledger-first GET. Projection missing must not become 404/409.
@@ -22,9 +32,24 @@ const ADMIN_USER = process.env.ADMIN_USERNAME?.trim() || '';
const ADMIN_PASS = process.env.ADMIN_PASSWORD?.trim() || '';
const BASE = process.env.BASE_URL || 'http://127.0.0.1:8080';
const TARGET_DOC = 'doc_proj143_intake';
const suffix = `${Date.now().toString(36)}`;
const USER_B = `m1b_${suffix}`;
const USER_C = `m1c_${suffix}`;
const USER_D = `m1d_${suffix}`;
const INIT_PASS = `Init-${suffix}9`;
const FINAL_PASS_B = `PassB-${suffix}9`;
const FINAL_PASS_C = `PassC-${suffix}9`;
const FINAL_PASS_D = `PassD-${suffix}9`;
const PROJECT_NAME = `M1活验-${suffix}`;
test.skip(!runAcceptance, 'set RUN_UI_ACCEPTANCE=1 against the rebuilt dev stack');
const state: {
newDocId?: string;
newMarkdown?: string;
outsiderReady?: boolean;
} = {};
async function login(page: Page, username: string, password: string) {
await page.goto('/');
await page.fill('input[placeholder*="系统用户名"]', username);
@@ -33,6 +58,144 @@ async function login(page: Page, username: string, password: string) {
await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 });
}
async function changePasswordFirstLogin(page: Page, oldPass: string, newPass: string) {
await expect(page.getByPlaceholder('请输入初始密码')).toBeVisible({ timeout: 15_000 });
await page.fill('input[placeholder="请输入初始密码"]', oldPass);
await page.fill('input[placeholder*="至少 6 位"]', newPass);
await page.fill('input[placeholder="再次输入新密码"]', newPass);
await page.getByRole('button', { name: /确认重置并进入系统/ }).click();
await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 });
}
async function logoutViaUI(page: Page) {
await page.locator('button[title="个人资料与系统偏好设置"]').click({ timeout: 15_000 });
const logout = page.getByRole('button', { name: '退出当前登录' });
await expect(logout).toBeVisible({ timeout: 10_000 });
await logout.click();
await expect(page.getByPlaceholder(/系统用户名/)).toBeVisible({ timeout: 20_000 });
}
async function sessionToken(page: Page): Promise<string> {
const token = await page.evaluate(() => {
try {
const raw = window.localStorage.getItem('smdf_auth_session');
if (!raw) return '';
const session = JSON.parse(raw) as { token?: string };
return session.token || '';
} catch {
return '';
}
});
if (!token) {
throw new Error('login did not persist smdf_auth_session.token');
}
return token;
}
async function getContent(page: Page, docId: string, token?: string) {
const headers = token ? { Authorization: `Bearer ${token}` } : {};
const res = await page.request.get(`${BASE}/api/v1/docgov/documents/${docId}/content`, {
headers,
});
let body: { markdown?: string; consistency?: string; error_code?: string } = {};
try {
body = (await res.json()) as typeof body;
} catch {
/* ignore */
}
return { status: res.status(), body };
}
function expectClosed404(status: number, body: { error_code?: string }, label: string) {
expect(status, `${label} ${JSON.stringify(body)}`).toBe(404);
expect(body.error_code, label).toBe('DOCUMENT_NOT_FOUND');
}
async function goToDutyStep(page: Page) {
const approval = page.locator('label', { hasText: '纳入人工审批' }).first();
const selectHint = page.getByText('选择要激活的已发布工作流版本。激活后将冻结配置,运行中不可切换。');
await expect(selectHint.or(approval)).toBeVisible({ timeout: 20_000 });
if (await approval.isVisible().catch(() => false)) {
return;
}
await page.getByRole('button', { name: /下一步/ }).click();
await expect(approval).toBeVisible({ timeout: 20_000 });
}
function dutyRoleSelect(page: Page, placeholder: '作者' | '审核' | '批准') {
return page
.locator('select')
.filter({ has: page.getByRole('option', { name: placeholder, exact: true }) })
.first();
}
function turnAssistantLocator(page: Page, userPromptSnippet: string) {
const userBubble = page
.locator('[data-chat-role="user"]')
.filter({ hasText: userPromptSnippet })
.last();
return {
userBubble,
assistant: userBubble.locator(
'xpath=ancestor::*[@data-index][1]/following-sibling::*[@data-index][1]//*[@data-chat-role="assistant"]',
),
};
}
async function waitForSquadCompletion(page: Page, userPromptSnippet: string, timeoutMs = 420_000) {
const { userBubble, assistant } = turnAssistantLocator(page, userPromptSnippet);
await expect(userBubble).toBeVisible({ timeout: 30_000 });
await expect(assistant).toBeVisible({ timeout: 30_000 });
const connErr = page.locator('text=连接异常');
const observe = async () =>
evaluateSquadObservation(
{
userPromptMatched: true,
assistantAdjacent: true,
status: (await assistant.getAttribute('data-chat-status')) || '',
hasVisibleError: await connErr.first().isVisible().catch(() => false),
},
{ requireInflight: false },
);
let verdict = await observe();
if (verdict.action === 'reject') {
throw new Error(`squad observation rejected: ${verdict.reason}`);
}
if (verdict.action === 'accept') {
return;
}
await expect(assistant).toHaveAttribute('data-chat-status', ANY_TERMINAL_STATUS_RE, {
timeout: timeoutMs,
});
verdict = await observe();
if (verdict.action !== 'accept') {
throw new Error(`squad terminal not accepted: ${verdict.reason}`);
}
}
async function openWorkflowDocByLabel(page: Page, label: RegExp) {
const category = requiredCategoryForDocOpen({ kind: 'workflow-label' });
const catTrigger = page.getByTestId('doc-category-trigger');
await expect(catTrigger).toBeVisible({ timeout: 15_000 });
await catTrigger.click();
const catItem = page.locator(`.dropdown-menu button[data-category="${category}"]`).first();
await catItem.click();
await expect(catTrigger).toContainText(CATEGORY_TRIGGER_LABEL[category], { timeout: 10_000 });
const trigger = page.getByTestId('doc-selector-trigger');
await expect(trigger).toBeVisible({ timeout: 15_000 });
await trigger.click();
const item = page.locator('.dropdown-menu button[data-doc-id]').filter({ hasText: label }).first();
if ((await item.count()) === 0) {
throw new Error(`workflow slot not in doc-selector menu: ${label}`);
}
const docId = (await item.getAttribute('data-doc-id')) || '';
await item.click();
if (!docId) {
throw new Error(`selected menu item missing data-doc-id for ${label}`);
}
return docId;
}
test.describe.serial('controlled document reload M1 live', () => {
test('project 143 content is 200 with consistency; anonymous is 401; unknown is 404', async ({
browser,
@@ -62,53 +225,235 @@ test.describe.serial('controlled document reload M1 live', () => {
await expect(page.getByRole('banner')).toContainText('对话协同会话', { timeout: 20_000 });
await page.screenshot({ path: path.join(SCREENSHOTS, '01-project-143.png'), fullPage: true });
const token = await page.evaluate(() => {
try {
const raw = window.localStorage.getItem('smdf_auth_session');
if (!raw) return '';
const session = JSON.parse(raw) as { token?: string };
return session.token || '';
} catch {
return '';
}
});
if (!token) {
throw new Error('login did not persist smdf_auth_session.token');
}
const authed = await page.request.get(`${BASE}/api/v1/docgov/documents/${TARGET_DOC}/content`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
const authedBody = await authed.json();
expect(authed.status(), `content ${JSON.stringify(authedBody)}`).toBe(200);
expect(authedBody.markdown, 'ledger markdown required').toBeTruthy();
expect(['aligned', 'missing', 'drift', 'empty', 'ledger_missing']).toContain(authedBody.consistency);
expect(authedBody.error_code ?? '').not.toMatch(/PROJECTION/);
const token = await sessionToken(page);
const authed = await getContent(page, TARGET_DOC, token);
expect(authed.status, `content ${JSON.stringify(authed.body)}`).toBe(200);
expect(authed.body.markdown, 'ledger markdown required').toBeTruthy();
expect(['aligned', 'missing', 'drift', 'empty', 'ledger_missing']).toContain(authed.body.consistency);
expect(authed.body.error_code ?? '').not.toMatch(/PROJECTION/);
await expect(page.getByText('加载失败,请点击重试')).toHaveCount(0);
await page.reload();
await expect(page.getByRole('banner')).toContainText('对话协同会话', { timeout: 20_000 });
const afterReload = await page.request.get(`${BASE}/api/v1/docgov/documents/${TARGET_DOC}/content`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
expect(afterReload.status()).toBe(200);
const reloadBody = await afterReload.json();
expect(reloadBody.markdown).toBeTruthy();
const afterReload = await getContent(page, TARGET_DOC, token);
expect(afterReload.status).toBe(200);
expect(afterReload.body.markdown).toBeTruthy();
await expect(page.getByText('加载失败,请点击重试')).toHaveCount(0);
await page.screenshot({ path: path.join(SCREENSHOTS, '02-after-reload.png'), fullPage: true });
const anon = await page.request.get(`${BASE}/api/v1/docgov/documents/${TARGET_DOC}/content`);
expect(anon.status()).toBe(401);
await logoutViaUI(page);
await login(page, ADMIN_USER, ADMIN_PASS);
await page.getByRole('complementary').getByTitle('对话协同会话', { exact: true }).click({ force: true });
await expect(page.getByRole('banner')).toContainText('对话协同会话', { timeout: 20_000 });
const afterRelogin = await getContent(page, TARGET_DOC, await sessionToken(page));
expect(afterRelogin.status).toBe(200);
expect(afterRelogin.body.markdown).toBeTruthy();
await page.screenshot({ path: path.join(SCREENSHOTS, '03-after-relogin.png'), fullPage: true });
const missing = await page.request.get(`${BASE}/api/v1/docgov/documents/doc_does_not_exist_m1/content`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
expect(missing.status()).toBe(404);
const missingBody = await missing.json();
expect(missingBody.error_code).toBe('DOCUMENT_NOT_FOUND');
const anon = await getContent(page, TARGET_DOC);
expect(anon.status).toBe(401);
const missing = await getContent(page, 'doc_does_not_exist_m1', await sessionToken(page));
expectClosed404(missing.status, missing.body, 'unknown doc');
const projection409 = contentHits.filter(
(h) => h.status === 409 || String(h.error || '').includes('PROJECTION'),
);
expect(projection409, JSON.stringify(contentHits)).toHaveLength(0);
await page.close();
});
test('non-member GET of project 143 is the same 404 as unknown', async ({ browser }) => {
if (!ADMIN_USER || !ADMIN_PASS) {
throw new Error('ADMIN_USERNAME and ADMIN_PASSWORD are required');
}
const admin = await browser.newPage();
await login(admin, ADMIN_USER, ADMIN_PASS);
await admin.locator('button[title*="系统管理与准入治理"]').click();
for (const [user, name, role] of [
[USER_B, `审核协作者-${suffix}`, 'role-engineer'],
[USER_C, `批准协作者-${suffix}`, 'role-engineer'],
[USER_D, `局外人-${suffix}`, 'role-guest'],
] as const) {
await admin.getByRole('button', { name: '开通受控员工账号' }).click();
const form = admin.locator('form').filter({ hasText: '用户名 (系统账号)' });
await form.locator('input[placeholder="如 li_si"]').fill(user);
await form.locator('input[placeholder="如 李四"]').fill(name);
const passBox = form.locator('xpath=.//label[contains(.,"临时初始密码")]/following::input[1]');
await passBox.fill(INIT_PASS);
await form.locator('select').selectOption(role);
await form.getByRole('button', { name: '确认开通账号' }).click();
await expect(form).toBeHidden({ timeout: 15_000 });
}
const adminHeading = admin.getByText('系统管理控制台', { exact: true });
const adminModal = adminHeading.locator('xpath=ancestor::div[contains(@style,"position: fixed")][1]');
await adminModal.locator('button').first().click();
await expect(adminHeading).toBeHidden({ timeout: 10_000 });
await admin.close();
const outsider = await browser.newPage();
await outsider.goto('/');
await outsider.fill('input[placeholder*="系统用户名"]', USER_D);
await outsider.fill('input[placeholder*="登录密码"]', INIT_PASS);
await outsider.click('button:has-text("进入医疗协同工作台")');
await changePasswordFirstLogin(outsider, INIT_PASS, FINAL_PASS_D);
const token = await sessionToken(outsider);
const closed = await getContent(outsider, TARGET_DOC, token);
expectClosed404(closed.status, closed.body, 'non-member 143');
const unknown = await getContent(outsider, 'doc_does_not_exist_m1', token);
expectClosed404(unknown.status, unknown.body, 'outsider unknown');
await outsider.screenshot({ path: path.join(SCREENSHOTS, '04-outsider-143.png'), fullPage: true });
await outsider.close();
state.outsiderReady = true;
});
test('new project PI compile then reload keeps ledger markdown', async ({ browser }) => {
if (!ADMIN_USER || !ADMIN_PASS) {
throw new Error('ADMIN_USERNAME and ADMIN_PASSWORD are required');
}
const reviewer = await browser.newPage();
await reviewer.goto('/');
await reviewer.fill('input[placeholder*="系统用户名"]', USER_B);
await reviewer.fill('input[placeholder*="登录密码"]', INIT_PASS);
await reviewer.click('button:has-text("进入医疗协同工作台")');
await changePasswordFirstLogin(reviewer, INIT_PASS, FINAL_PASS_B);
await reviewer.close();
const page = await browser.newPage();
const runCreates: { status: number; body: string }[] = [];
page.on('response', async (res) => {
if (res.url().includes('/api/v1/tasks/runs') && res.request().method() === 'POST') {
runCreates.push({ status: res.status(), body: await res.text().catch(() => '') });
}
});
await login(page, ADMIN_USER, ADMIN_PASS);
await page.getByRole('button', { name: '新建立项会话' }).click();
const heading = page.getByText('发起医疗器械新项目立项', { exact: true });
await expect(heading).toBeVisible({ timeout: 20_000 });
const modal = heading.locator('xpath=ancestor::div[contains(@style,"position: fixed")][1]');
await modal.locator('input[placeholder*="无线便携式"]').fill(PROJECT_NAME);
await modal.locator('textarea').fill('M1 活验:新建项目编制一条受控文档后刷新。');
for (const name of [`审核协作者-${suffix}`, `批准协作者-${suffix}`]) {
const memberName = modal.getByText(name, { exact: true });
await expect(memberName).toBeVisible({ timeout: 15_000 });
await memberName.locator('xpath=ancestor::div[contains(@style,"cursor: pointer")][1]').click();
}
await modal.getByRole('button', { name: /确认立项/ }).click();
await expect(currentProjectBannerName(page, PROJECT_NAME)).toBeVisible({ timeout: 30_000 });
await page.screenshot({ path: path.join(SCREENSHOTS, '05-new-project.png'), fullPage: true });
const generatePrompt = '请编制用户初始需求收集表';
const input = page.getByPlaceholder(/向项目助手提问/);
await input.fill(generatePrompt);
await page.getByRole('button', { name: /发送/ }).click();
await expect(page.getByText(generatePrompt)).toBeVisible({ timeout: 20_000 });
await expect(page.getByText(GOVERNANCE_BLOCKED_HINT)).toBeVisible({ timeout: 45_000 });
const blocked = runCreates.find((row) => row.status === 202 && row.body.includes('blocked_pending_governance'));
if (!blocked) {
throw new Error(`expected 202 blocked run before activation, got ${JSON.stringify(runCreates)}`);
}
await page.getByRole('button', { name: '配置工作流' }).click();
await goToDutyStep(page);
const toggles = page.locator('label', { hasText: '纳入人工审批' });
const toggleCount = await toggles.count();
for (let i = 1; i < toggleCount; i += 1) {
const box = toggles.nth(i).locator('input[type="checkbox"]');
if (await box.isChecked()) await box.uncheck();
}
const firstToggle = toggles.first().locator('input[type="checkbox"]');
if (!(await firstToggle.isChecked())) await firstToggle.check();
const author = dutyRoleSelect(page, '作者');
await expect(author).toBeVisible({ timeout: 10_000 });
const values = (
await author.locator('option').evaluateAll((nodes) => nodes.map((n) => (n as HTMLOptionElement).value))
).filter(Boolean);
if (values.length < 3) {
throw new Error(`need 3 members for full duties, got ${values.length}`);
}
await author.selectOption(values[0]);
await dutyRoleSelect(page, '审核').selectOption(values[1]);
await dutyRoleSelect(page, '批准').selectOption(values[2]);
await page.getByRole('button', { name: /校验并预览/ }).click();
await expect(page.getByText('激活预览')).toBeVisible({ timeout: 20_000 });
await page.getByRole('button', { name: '确认激活' }).click();
await expect(page.getByText('运行中')).toBeVisible({ timeout: 20_000 });
await page.screenshot({ path: path.join(SCREENSHOTS, '06-activated.png'), fullPage: true });
const compilePrompt = '请基于本项目编制首轮《用户需求》摸底文档,覆盖预期用途、关键性能与适用法规框架,直接生成受控交付物。';
const taskRunWait = page.waitForResponse(
(r) => {
try {
return new URL(r.url()).pathname.includes('/api/v1/tasks/runs') && r.request().method() === 'POST';
} catch {
return false;
}
},
{ timeout: 60_000 },
);
await input.fill(compilePrompt);
await page.getByRole('button', { name: /发送/ }).click();
const taskRes = await taskRunWait;
if (taskRes.status() < 200 || taskRes.status() >= 300) {
throw new Error(`compile tasks/runs HTTP ${taskRes.status()}`);
}
await waitForSquadCompletion(page, '请基于本项目编制首轮', 420_000);
const docId = await openWorkflowDocByLabel(page, /用户初始需求|需求摸底|intake/i);
if (!/^doc_proj\d+_/.test(docId)) {
throw new Error(`expected server doc_proj* slot, got ${docId}`);
}
state.newDocId = docId;
const token = await sessionToken(page);
const first = await getContent(page, docId, token);
expect(first.status, JSON.stringify(first.body)).toBe(200);
expect(first.body.markdown).toBeTruthy();
expect(['aligned', 'missing', 'drift', 'empty', 'ledger_missing']).toContain(first.body.consistency);
expect(first.body.error_code ?? '').not.toMatch(/PROJECTION/);
state.newMarkdown = String(first.body.markdown);
await expect(page.getByText('加载失败,请点击重试')).toHaveCount(0);
await page.screenshot({ path: path.join(SCREENSHOTS, '07-new-doc.png'), fullPage: true });
await page.reload();
await expect(currentProjectBannerName(page, PROJECT_NAME)).toBeVisible({ timeout: 30_000 });
const afterReload = await getContent(page, docId, token);
expect(afterReload.status).toBe(200);
expect(afterReload.body.markdown).toBe(state.newMarkdown);
await expect(page.getByText('加载失败,请点击重试')).toHaveCount(0);
await page.screenshot({ path: path.join(SCREENSHOTS, '08-new-doc-reload.png'), fullPage: true });
await logoutViaUI(page);
await login(page, ADMIN_USER, ADMIN_PASS);
await page.getByRole('complementary').getByTitle(PROJECT_NAME, { exact: true }).click({ force: true });
await expect(currentProjectBannerName(page, PROJECT_NAME)).toBeVisible({ timeout: 30_000 });
const afterRelogin = await getContent(page, docId, await sessionToken(page));
expect(afterRelogin.status).toBe(200);
expect(afterRelogin.body.markdown).toBe(state.newMarkdown);
await page.screenshot({ path: path.join(SCREENSHOTS, '09-new-doc-relogin.png'), fullPage: true });
await page.close();
});
test('cross-project and wrong-stage GET stay DOCUMENT_NOT_FOUND', async ({ browser }: { browser: Browser }) => {
if (!state.newDocId) {
throw new Error('new project document id missing; compile test did not leave a ledger');
}
const outsider = await browser.newPage();
await login(outsider, USER_D, FINAL_PASS_D);
const tokenD = await sessionToken(outsider);
const cross143 = await getContent(outsider, TARGET_DOC, tokenD);
expectClosed404(cross143.status, cross143.body, 'D vs 143');
const crossNew = await getContent(outsider, state.newDocId, tokenD);
expectClosed404(crossNew.status, crossNew.body, 'D vs new project doc');
await outsider.close();
const reviewer = await browser.newPage();
await login(reviewer, USER_B, FINAL_PASS_B);
const tokenB = await sessionToken(reviewer);
const otherStage = state.newDocId.replace(/_[a-z0-9]+$/i, '_design_control');
const wrongDuty = await getContent(reviewer, otherStage, tokenB);
expectClosed404(wrongDuty.status, wrongDuty.body, 'B vs wrong stage-duty');
const stillClosed143 = await getContent(reviewer, TARGET_DOC, tokenB);
expectClosed404(stillClosed143.status, stillClosed143.body, 'B vs 143');
await reviewer.screenshot({ path: path.join(SCREENSHOTS, '10-fail-closed.png'), fullPage: true });
await reviewer.close();
});
});