From 3e4abc51cd7d02707e3037370e21a2d8799a4edf Mon Sep 17 00:00:00 2001 From: weichen Date: Mon, 14 Sep 2026 14:34:37 +0000 Subject: [PATCH] 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 --- .doc/ops/CURRENT.md | 2 +- web/e2e/controlled-document-reload.spec.ts | 421 +++++++++++++++++++-- 2 files changed, 384 insertions(+), 39 deletions(-) diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 13467960..84fb78d6 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -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` diff --git a/web/e2e/controlled-document-reload.spec.ts b/web/e2e/controlled-document-reload.spec.ts index c363c5ac..a809b144 100644 --- a/web/e2e/controlled-document-reload.spec.ts +++ b/web/e2e/controlled-document-reload.spec.ts @@ -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 { + 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(); }); });