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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-16 15:45:50 +00:00
co-authored by Cursor
parent cc8557b8c6
commit b74b9ab1d0
+413 -5
View File
@@ -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<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 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();
});
});