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'; /** * 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'); 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 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(); }); });