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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user