From b74b9ab1d03a294ccd4850fa3cd9f009895a3f9e Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 16 Sep 2026 15:45:50 +0000 Subject: [PATCH] test(web): add intent-plan dual-tab Playwright acceptance Gate on RUN_UI_ACCEPTANCE=1; seed awaiting PG plan via SQL without raising MULTI_INTENT_PLAN_TIER; assert one confirm wins and reload/relogin match PG. Co-authored-by: Cursor --- web/e2e/intent-plan-cards.spec.ts | 418 +++++++++++++++++++++++++++++- 1 file changed, 413 insertions(+), 5 deletions(-) diff --git a/web/e2e/intent-plan-cards.spec.ts b/web/e2e/intent-plan-cards.spec.ts index 9d3286e1..d4db8063 100644 --- a/web/e2e/intent-plan-cards.spec.ts +++ b/web/e2e/intent-plan-cards.spec.ts @@ -1,14 +1,422 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type Page, type BrowserContext } from '@playwright/test'; +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import { currentProjectBannerName } from '../src/lib/currentProjectRestore'; /** - * Dual-tab / reload live check is gated. Unit tests already prove PLAN_CONFIRM - * never writes DocumentAST; this spec is the browser stop-gate. + * Intent-plan PLAN_CONFIRM dual-tab / reload live acceptance. + * Gated: set RUN_UI_ACCEPTANCE=1 against the real dev stack (no mocks). + * Seeds an awaiting PG plan via SQL so MULTI_INTENT_PLAN_TIER stays legacy. */ +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const runAcceptance = process.env.RUN_UI_ACCEPTANCE === '1'; +const ASSETS = process.env.UI_ACCEPTANCE_ASSETS_DIR + ? path.resolve(process.env.UI_ACCEPTANCE_ASSETS_DIR) + : path.resolve( + __dirname, + '../../.doc/test-reports/assets/2026-09-16-intent-plan-playwright-dual-tab', + ); +const SCREENSHOTS = path.join(ASSETS, 'screenshots'); +fs.mkdirSync(SCREENSHOTS, { recursive: true }); + +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 PG_HOST = process.env.INTENT_PLAN_PG_HOST || '127.0.0.1'; +const PG_PORT = process.env.INTENT_PLAN_PG_PORT || '5433'; +const PG_USER = process.env.INTENT_PLAN_PG_USER || 'app'; +const PG_PASS = process.env.INTENT_PLAN_PG_PASSWORD || 'dev_secret_db_pass'; +const PG_DB = process.env.INTENT_PLAN_PG_DB || 'agent_system_dev'; +const suffix = `${Date.now().toString(36)}`; +const PROJECT_NAME = `PlanDual-${suffix}`; test.skip(!runAcceptance, 'set RUN_UI_ACCEPTANCE=1 for live dual-tab plan confirm'); -test('plan confirm dual-tab does not locally succeed', async ({ page }) => { +type DecisionProbe = { status: number; body: string; tab: string }; + +async function shot(page: Page, name: string) { + await page.screenshot({ path: path.join(SCREENSHOTS, name), fullPage: true }); +} + +/** After confirm/reload: card may stay executing (buttons disabled) or leave active. */ +async function expectPlanNoLongerConfirmable(page: Page) { + const card = page.getByTestId('plan-confirm-card'); + await expect(card).toBeVisible({ timeout: 20_000 }); + const status = (await card.getAttribute('data-card-status')) || ''; + expect(status).toMatch(/executing|resolved|outdated|cancelled|expired/); + const confirm = page.getByRole('button', { name: '确认执行' }); + const count = await confirm.count(); + if (count === 0) return; + expect(status).not.toBe('active'); + await expect(confirm).toBeDisabled(); +} + +async function login(page: Page, username: string, password: string) { await page.goto('/'); - await expect(page.locator('body')).toBeVisible(); + await page.fill('input[placeholder*="系统用户名"]', username); + await page.fill('input[placeholder*="登录密码"]', password); + await page.click('button:has-text("进入医疗协同工作台")'); + 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 createProject(page: Page, name: string) { + 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(name); + await modal.locator('textarea').fill('Playwright intent-plan dual-tab acceptance project.'); + await modal.getByRole('button', { name: /确认立项/ }).click(); + await expect(currentProjectBannerName(page, name)).toBeVisible({ timeout: 30_000 }); +} + +async function readScope(page: Page): Promise<{ projectId: number; sessionId: string; userId: number }> { + const scope = await page.evaluate(() => { + const sessionRaw = window.localStorage.getItem('smdf_auth_session'); + const sessionsRaw = window.localStorage.getItem('smdf_agent_workbench_v6_sessions'); + const activeRaw = window.localStorage.getItem('smdf_agent_workbench_v6_active_session') || ''; + const auth = sessionRaw ? (JSON.parse(sessionRaw) as { user?: { id?: number } }) : {}; + const userId = Number(auth.user?.id || 0); + const activeSessionId = activeRaw.replace(/^"|"$/g, ''); + let projectId = 0; + let sessionId = activeSessionId; + try { + const sessions = sessionsRaw + ? (JSON.parse(sessionsRaw) as Array<{ sessionId: string; projectId?: number }>) + : []; + const cur = sessions.find((s) => s.sessionId === activeSessionId) || sessions[0]; + if (cur) { + sessionId = cur.sessionId; + projectId = Number(cur.projectId || 0); + } + } catch { + /* ignore */ + } + const m = sessionId.match(/^sess-p(\d+)-u\d+$/); + if ((!projectId || projectId <= 0) && m) projectId = Number(m[1]); + return { projectId, sessionId, userId }; + }); + if (!scope.projectId || !scope.sessionId || !scope.userId) { + throw new Error(`unable to resolve scope: ${JSON.stringify(scope)}`); + } + if (/143/.test(String(scope.projectId))) { + throw new Error('project 143 is read-only'); + } + return scope; +} + +function psql(sql: string): string { + // Prefer host docker→PG container; then local psql; then dockerized psql over host network. + const viaDocker = process.env.INTENT_PLAN_PG_DOCKER || 'shuimu-context-pg-dev'; + try { + if (viaDocker && viaDocker !== '0') { + return execFileSync( + 'docker', + [ + 'exec', + '-e', + `PGPASSWORD=${PG_PASS}`, + viaDocker, + 'psql', + '-U', + PG_USER, + '-d', + PG_DB, + '-v', + 'ON_ERROR_STOP=1', + '-t', + '-A', + '-c', + sql, + ], + { encoding: 'utf8', timeout: 30_000 }, + ).trim(); + } + } catch { + /* fall through */ + } + try { + return execFileSync( + 'psql', + [ + '-h', + PG_HOST, + '-p', + PG_PORT, + '-U', + PG_USER, + '-d', + PG_DB, + '-v', + 'ON_ERROR_STOP=1', + '-t', + '-A', + '-c', + sql, + ], + { + env: { ...process.env, PGPASSWORD: PG_PASS }, + encoding: 'utf8', + timeout: 30_000, + }, + ).trim(); + } catch { + return execFileSync( + 'docker', + [ + 'run', + '--rm', + '--network', + 'host', + '-e', + `PGPASSWORD=${PG_PASS}`, + 'postgres:16-alpine', + 'psql', + '-h', + PG_HOST, + '-p', + PG_PORT, + '-U', + PG_USER, + '-d', + PG_DB, + '-v', + 'ON_ERROR_STOP=1', + '-t', + '-A', + '-c', + sql, + ], + { encoding: 'utf8', timeout: 60_000 }, + ).trim(); + } +} + +function seedAwaitingPlan(input: { + planId: string; + projectId: number; + sessionId: string; + userId: number; + turn: string; +}): void { + const { planId, projectId, sessionId, userId, turn } = input; + const step1 = `stp_${suffix}_1`; + const step2 = `stp_${suffix}_2`; + psql(` +INSERT INTO intent_plans ( + plan_id, environment, project_id, session_id, user_id, client_turn_id, request_digest, + segmenter_version, policy_version, status, revision, expires_at, last_fencing_token, + write_plan, execution_mode, safe_summary, display_snapshot, created_at, updated_at +) VALUES ( + '${planId}', 'dev', ${projectId}, '${sessionId}', ${userId}, '${turn}', '${turn}-digest', + 'e2e', 'e2e', 'awaiting_confirmation', 1, now() + interval '2 hours', 0, + false, 'validated_only', 'Playwright dual-tab: query then summarize', NULL, now(), now() +); +INSERT INTO intent_plan_steps ( + step_id, plan_id, ordinal, action_key, slots, depends_on, status, expected_resource_version, + action_digest, idempotency_key, attempt, result_ref, result_digest, safe_summary, + lease_owner, lease_until, fencing_token, version, execution_mode, created_at, updated_at +) VALUES + ('${step1}', '${planId}', 0, 'regulation.query', '{}'::jsonb, '{}', 'pending', '', + 'q1', '', 0, '', '', '查询法规', '', NULL, 0, 1, 'validated_only', now(), now()), + ('${step2}', '${planId}', 1, 'regulation.query', '{}'::jsonb, '{}', 'pending', '', + 'q2', '', 0, '', '', '总结要点', '', NULL, 0, 1, 'validated_only', now(), now()); +`); +} + +function pgPlanStatus(planId: string): { status: string; revision: string } { + const row = psql( + `SELECT status || '|' || revision::text FROM intent_plans WHERE plan_id='${planId}'`, + ); + const [status, revision] = row.split('|'); + return { status: status || '', revision: revision || '' }; +} + +async function fetchActivePlan( + page: Page, + projectId: number, + sessionId: string, + token: string, +): Promise<{ status: number; plan: { plan_id?: string; status?: string; revision?: number } | null }> { + const res = await page.request.get( + `${BASE}/api/v1/intent-plans/active?project_id=${projectId}&session_id=${encodeURIComponent(sessionId)}`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + let body: { plan?: { plan_id?: string; status?: string; revision?: number } | null } = {}; + try { + body = (await res.json()) as typeof body; + } catch { + /* ignore */ + } + return { status: res.status(), plan: body.plan ?? null }; +} + +function attachDecisionProbe(page: Page, tab: string, bag: DecisionProbe[]) { + page.on('response', async (res) => { + if (!res.url().includes('/intent-plans/') || !res.url().includes('/decisions')) return; + if (res.request().method() !== 'POST') return; + bag.push({ + status: res.status(), + body: await res.text().catch(() => ''), + tab, + }); + }); +} + +test.describe.serial('intent-plan PLAN_CONFIRM dual-tab', () => { + test('dual-tab confirm mutual exclusion + reload/relogin matches PG', async ({ browser }) => { + if (!ADMIN_USER || !ADMIN_PASS) { + throw new Error('ADMIN_USERNAME and ADMIN_PASSWORD are required'); + } + + const decisions: DecisionProbe[] = []; + const context: BrowserContext = await browser.newContext(); + const tabA = await context.newPage(); + attachDecisionProbe(tabA, 'A', decisions); + + await login(tabA, ADMIN_USER, ADMIN_PASS); + await createProject(tabA, PROJECT_NAME); + const scope = await readScope(tabA); + const planId = `pln_e2e_${suffix}`; + seedAwaitingPlan({ + planId, + projectId: scope.projectId, + sessionId: scope.sessionId, + userId: scope.userId, + turn: `turn-e2e-${suffix}`, + }); + + await tabA.reload(); + await expect(currentProjectBannerName(tabA, PROJECT_NAME)).toBeVisible({ timeout: 30_000 }); + const cardA = tabA.getByTestId('plan-confirm-card'); + await expect(cardA).toBeVisible({ timeout: 20_000 }); + await expect(cardA).toHaveAttribute('data-plan-id', planId); + await expect(cardA).toHaveAttribute('data-card-status', 'active'); + await expect(tabA.getByRole('button', { name: '确认执行' })).toBeVisible(); + await shot(tabA, '01-tab-a-awaiting.png'); + + const tabB = await context.newPage(); + attachDecisionProbe(tabB, 'B', decisions); + await tabB.goto('/'); + await expect(currentProjectBannerName(tabB, PROJECT_NAME)).toBeVisible({ timeout: 30_000 }); + const cardB = tabB.getByTestId('plan-confirm-card'); + await expect(cardB).toBeVisible({ timeout: 20_000 }); + await expect(cardB).toHaveAttribute('data-plan-id', planId); + await expect(cardB).toHaveAttribute('data-card-status', 'active'); + await shot(tabB, '02-tab-b-awaiting.png'); + + await Promise.all([ + tabA.getByRole('button', { name: '确认执行' }).click(), + tabB.getByRole('button', { name: '确认执行' }).click(), + ]); + + await expect + .poll(() => decisions.length, { timeout: 20_000 }) + .toBeGreaterThanOrEqual(2); + + const ok = decisions.filter((d) => d.status === 200); + const conflict = decisions.filter((d) => d.status === 409); + expect(ok.length, JSON.stringify(decisions)).toBe(1); + expect(conflict.length, JSON.stringify(decisions)).toBe(1); + expect(conflict[0].body).toMatch(/PLAN_STALE_REVISION|PLAN_DECISION_CONFLICT/); + + const winnerTab = ok[0].tab; + const loserTab = conflict[0].tab; + const winnerPage = winnerTab === 'A' ? tabA : tabB; + const loserPage = loserTab === 'A' ? tabA : tabB; + + await expect(winnerPage.getByTestId('plan-confirm-card')).toHaveAttribute( + 'data-card-status', + /executing|resolved/, + { timeout: 15_000 }, + ); + await expect(loserPage.getByTestId('plan-confirm-card')).toHaveAttribute( + 'data-card-status', + 'outdated', + { timeout: 15_000 }, + ); + await expect(loserPage.getByText(/文档状态已变更,操作失效|计划已更新/)).toBeVisible({ + timeout: 10_000, + }); + await expect(loserPage.getByRole('button', { name: '确认执行' })).toHaveCount(0); + await shot(winnerPage, '03-winner-after-confirm.png'); + await shot(loserPage, '04-loser-outdated.png'); + + const pgAfter = pgPlanStatus(planId); + expect(pgAfter.status).toMatch(/executing|partially_completed|completed/); + expect(Number(pgAfter.revision)).toBeGreaterThanOrEqual(2); + + await tabB.reload(); + await expect(currentProjectBannerName(tabB, PROJECT_NAME)).toBeVisible({ timeout: 30_000 }); + const afterReload = tabB.getByTestId('plan-confirm-card'); + await expect(afterReload).toBeVisible({ timeout: 20_000 }); + await expect(afterReload).toHaveAttribute('data-plan-id', planId); + await expectPlanNoLongerConfirmable(tabB); + await shot(tabB, '05-tab-b-after-reload.png'); + + const tokenBefore = await sessionToken(tabA); + const activeBefore = await fetchActivePlan(tabA, scope.projectId, scope.sessionId, tokenBefore); + expect(activeBefore.status).toBe(200); + expect(activeBefore.plan?.plan_id).toBe(planId); + expect(activeBefore.plan?.status).toBe(pgAfter.status); + + await logoutViaUI(tabA); + await login(tabA, ADMIN_USER, ADMIN_PASS); + await tabA.getByRole('complementary').getByTitle(PROJECT_NAME, { exact: true }).click({ force: true }); + await expect(currentProjectBannerName(tabA, PROJECT_NAME)).toBeVisible({ timeout: 30_000 }); + const afterRelogin = tabA.getByTestId('plan-confirm-card'); + await expect(afterRelogin).toBeVisible({ timeout: 20_000 }); + await expect(afterRelogin).toHaveAttribute('data-plan-id', planId); + await expectPlanNoLongerConfirmable(tabA); + await shot(tabA, '06-tab-a-after-relogin.png'); + + const tokenAfter = await sessionToken(tabA); + const activeAfter = await fetchActivePlan(tabA, scope.projectId, scope.sessionId, tokenAfter); + expect(activeAfter.status).toBe(200); + expect(activeAfter.plan?.status).toBe(pgPlanStatus(planId).status); + + fs.writeFileSync( + path.join(ASSETS, 'playwright-network.json'), + JSON.stringify( + { + project: PROJECT_NAME, + scope, + planId, + decisions, + pgAfter, + activeBefore, + activeAfter, + }, + null, + 2, + ), + ); + + await context.close(); + }); });