Files
memind/scripts/test-help-poem-page-remediation.mjs
T
john a15c4177de
Memind CI / Test, build, and release guards (push) Successful in 10m3s
test(cursor): add executor routing and help remediation scenarios
Add dry-run, e2e, and poem-page remediation scripts for cursor executor
and help escalation verification.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 09:33:51 +08:00

462 lines
16 KiB
JavaScript

#!/usr/bin/env node
/**
* E2E: poem page via Goose → simulate link delivery failure → help → Cursor remediate → user gets link.
*
* Usage:
* node scripts/test-help-poem-page-remediation.mjs
* node scripts/test-help-poem-page-remediation.mjs --skip-poem # reuse latest public html
* node scripts/test-help-poem-page-remediation.mjs --direct-remediate # skip cursor agent wait
*/
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { setTimeout as sleep } from 'node:timers/promises';
import { spawn } from 'node:child_process';
import { createDbPool, migrateSchema } from '../db.mjs';
import {
getPageDeliveryContract,
normalizeDeliveryRelativePath,
preparePageDeliveryContract,
} from '../mindspace-delivery-contract.mjs';
import {
HELP_ESCALATION_STATUS,
createHelpEscalationService,
} from '../help-escalation.mjs';
import { PUBLISH_ROOT_DIR } from '../user-publish.mjs';
import { loadH5Environment } from './load-env.mjs';
import {
createAgentRun,
createReporter,
extractAssistantTexts,
extractPublicLinks,
getSession,
loginViaApi,
resolvePortalBase,
snapshotPublicHtml,
verifyPageAccess,
waitForAssistantGrowth,
waitForRunTerminal,
} from './scenario-test-lib.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
loadH5Environment(path.dirname(fileURLToPath(import.meta.url)));
const args = process.argv.slice(2);
const skipPoem = args.includes('--skip-poem');
const directRemediate = args.includes('--direct-remediate');
const noLogin = args.includes('--no-login');
const userIdArg = args.find((item) => item.startsWith('--user-id='))?.slice('--user-id='.length)?.trim() ?? '';
const sessionIdArg = args.find((item) => item.startsWith('--session-id='))?.slice('--session-id='.length)?.trim() ?? '';
const noGoose = args.includes('--no-goose');
const port = Number(
args.find((item) => item.startsWith('--port='))?.slice('--port='.length)
?? process.env.H5_PORT
?? 8081,
);
const waitMs = Number(
args.find((item) => item.startsWith('--wait-ms='))?.slice('--wait-ms='.length)
?? process.env.MEMIND_CURSOR_HELP_E2E_WAIT_MS
?? 20 * 60 * 1000,
);
const POEM_PAGE_MESSAGE =
'帮我写一首关于春日的短诗(四段即可),并做成一个精美的 HTML 页面';
async function listLatestHtml(publishKey) {
const files = await snapshotPublicHtml(publishKey);
return files.sort((a, b) => b.mtimeMs - a.mtimeMs)[0] ?? null;
}
async function simulateLinkDeliveryFailure(pool, userId, relativePath, requestId) {
await preparePageDeliveryContract({
pool,
userId,
requestId: requestId ?? `help-e2e-sim-${Date.now()}`,
relativePath,
pgRequired: false,
});
}
async function sendHelpEscalation(baseUrl, cookie, {
sessionId,
userId,
relativePath,
pageUrl,
}) {
const helpText = [
'help 我刚做了春日诗歌 HTML 页面,但聊天里没有收到可打开的页面链接。',
`会话 ID: ${sessionId}`,
`页面文件: ${relativePath}`,
pageUrl ? `预期链接: ${pageUrl}` : '',
'请修复交付并通过 Goose 原会话把链接补发给我。',
].filter(Boolean).join('\n');
const requestId = crypto.randomUUID();
const response = await fetch(`${baseUrl}/api/agent/runs`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: cookie,
},
body: JSON.stringify({
session_id: sessionId,
request_id: requestId,
user_message: {
id: crypto.randomUUID(),
role: 'user',
content: [{ type: 'text', text: helpText }],
metadata: { userVisible: true, displayText: helpText },
},
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`help POST failed ${response.status}: ${JSON.stringify(payload)}`);
}
return payload;
}
async function waitForEscalation(helpEscalationService, escalationId) {
const deadline = Date.now() + waitMs;
while (Date.now() < deadline) {
const current = await helpEscalationService.getById(escalationId);
if (
current?.status === HELP_ESCALATION_STATUS.SUCCEEDED
|| current?.status === HELP_ESCALATION_STATUS.FAILED
) {
return current;
}
console.log('[help-e2e] waiting for cursor worker...', {
id: escalationId,
status: current?.status ?? 'missing',
});
await sleep(3000);
}
throw new Error(`Timed out after ${waitMs}ms waiting for escalation ${escalationId}`);
}
async function runDirectRemediation({ userId, sessionId, relativePath, port: remediatePort, viaGoose }) {
const cliArgs = [
path.join(root, 'scripts/help-remediate-page-delivery.mjs'),
`--user-id=${userId}`,
`--page=${relativePath}`,
`--port=${remediatePort}`,
];
if (sessionId) cliArgs.push(`--session-id=${sessionId}`);
if (viaGoose) cliArgs.push('--via-goose');
return await new Promise((resolve, reject) => {
const child = spawn(process.execPath, cliArgs, {
cwd: root,
stdio: ['ignore', 'pipe', 'pipe'],
env: process.env,
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => {
stdout += String(chunk);
});
child.stderr.on('data', (chunk) => {
stderr += String(chunk);
});
child.on('error', reject);
child.on('close', (code) => {
if (code !== 0) {
reject(new Error(stderr.trim() || stdout.trim() || `remediation exited ${code}`));
return;
}
try {
const jsonLine = stdout.trim().split('\n').reverse().find((line) => line.startsWith('{'));
resolve(jsonLine ? JSON.parse(jsonLine) : { stdout });
} catch {
resolve({ stdout });
}
});
});
}
async function fetchLatestHelpNotification(pool, userId) {
const [rows] = await pool.query(
`SELECT id, title, body, notification_type, created_at
FROM h5_user_notifications
WHERE user_id = ? AND notification_type = 'help_escalation_result'
ORDER BY created_at DESC
LIMIT 1`,
[userId],
);
return rows[0] ?? null;
}
async function main() {
const reporter = createReporter();
const baseUrl = resolvePortalBase(port);
const account = {
username: process.env.RELEASE_GATE_SCENARIO_USERNAME ?? 'john',
password: process.env.JOHN_PASSWORD ?? process.env.H5_ACCESS_PASSWORD ?? '888888',
};
console.log('==> Help 诗歌页面补救 E2E');
console.log(` Portal: ${baseUrl}`);
console.log(` directRemediate: ${directRemediate}`);
console.log(` skipPoem: ${skipPoem}`);
console.log(` noLogin: ${noLogin}\n`);
const statusResponse = await fetch(`${baseUrl}/auth/status`);
if (!statusResponse.ok) {
throw new Error(`Portal 未就绪: ${statusResponse.status}`);
}
let auth = null;
let userId = userIdArg;
let publishKey = userIdArg || account.username;
if (noLogin) {
if (!userIdArg) {
throw new Error('--no-login 需要同时提供 --user-id=');
}
if (!skipPoem) {
throw new Error('--no-login 仅支持 --skip-poem(阶段 1 仍需 HTTP 登录)');
}
reporter.pass('鉴权', `no-login / user-id=${userIdArg}`);
} else {
auth = await loginViaApi(baseUrl, account, reporter);
userId = auth.user?.id;
publishKey = userId ?? account.username;
if (!userId) {
throw new Error('登录后缺少 userId');
}
}
let sessionId = sessionIdArg || null;
let relativePath = null;
let replyCombined = '';
let htmlBefore = [];
if (!skipPoem) {
htmlBefore = await snapshotPublicHtml(publishKey);
console.log('\n--- 阶段 1: Goose 写诗并做页面 ---');
const run = await createAgentRun(baseUrl, auth.cookie, {
message: POEM_PAGE_MESSAGE,
sessionId: null,
});
if (run.kind === 'help_escalation') {
throw new Error(`意外进入 help 分支: ${run.message}`);
}
reporter.pass('提交写诗做页', POEM_PAGE_MESSAGE.slice(0, 40));
const terminal = await waitForRunTerminal(
baseUrl,
auth.cookie,
run.runId,
600_000,
);
sessionId = terminal.sessionId ?? terminal.agent_session_id ?? run.sessionId ?? null;
if (terminal.status === 'failed') {
reporter.fail('Goose run', terminal.error ?? 'failed');
process.exit(reporter.summary());
}
reporter.pass('Goose run 终态', terminal.status);
if (!sessionId) {
reporter.fail('sessionId', 'run 未回填 sessionId');
process.exit(reporter.summary());
}
const reply = await waitForAssistantGrowth(baseUrl, auth.cookie, sessionId, {
previousCount: 0,
previousCombinedLength: 0,
minChars: 20,
timeoutMs: 120_000,
});
if (!reply) {
reporter.fail('assistant 回复', '超时');
process.exit(reporter.summary());
}
replyCombined = reply.combined;
reporter.pass('assistant 回复', `${reply.combined.length}`);
console.log(reply.combined.slice(0, 500));
const htmlAfter = await snapshotPublicHtml(publishKey);
const beforeSet = new Set(htmlBefore.map((item) => item.fullPath));
const fresh = htmlAfter
.filter((item) => !beforeSet.has(item.fullPath))
.sort((a, b) => b.mtimeMs - a.mtimeMs);
if (!fresh[0]) {
reporter.fail('页面落盘', 'public/ 无新 HTML');
process.exit(reporter.summary());
}
relativePath = fresh[0].relativePublicPath;
reporter.pass('页面落盘', relativePath);
} else {
const latest = await listLatestHtml(publishKey);
if (!latest) {
throw new Error('--skip-poem 但 public/ 无 HTML,请先跑完整流程');
}
relativePath = latest.relativePublicPath;
if (!sessionId && auth?.cookie) {
const sessionsResponse = await fetch(`${baseUrl}/api/sessions?limit=5`, {
headers: { Cookie: auth.cookie },
});
const sessionsPayload = await sessionsResponse.json().catch(() => ({}));
sessionId = sessionsPayload?.sessions?.[0]?.id ?? null;
}
reporter.pass('复用最新页面', `${relativePath} / session ${sessionId ?? '无'}`);
}
relativePath = normalizeDeliveryRelativePath(relativePath);
if (!relativePath) {
throw new Error(`无效页面路径: ${relativePath}`);
}
console.log('\n--- 阶段 2: 模拟「链接未送达」---');
const pool = createDbPool();
await migrateSchema(pool);
await simulateLinkDeliveryFailure(pool, userId, relativePath, `help-e2e-${Date.now()}`);
const stuckContract = await getPageDeliveryContract({ pool, userId, relativePath });
if (stuckContract?.status === 'preparing') {
reporter.pass('模拟交付失败', `contract=${stuckContract.status}`);
} else {
reporter.fail('模拟交付失败', `contract=${stuckContract?.status ?? 'missing'}`);
}
const linksBeforeHelp = extractPublicLinks(replyCombined, baseUrl);
if (linksBeforeHelp.length) {
reporter.pass('原始回复含链接', '仍模拟用户未收到,继续 help 补救');
} else {
reporter.pass('原始回复无链接', '符合链接发送失败场景');
}
console.log('\n--- 阶段 3: 用户发送 help ---');
let escalationId = null;
if (noLogin) {
const helpEscalationService = createHelpEscalationService({ pool });
const helpText = [
'help 我刚做了诗歌 HTML 页面,但聊天里没有收到可打开的页面链接。',
sessionId ? `会话 ID: ${sessionId}` : '',
`页面文件: ${relativePath}`,
'请修复交付并通过 Goose 原会话把链接补发给我。',
].filter(Boolean).join('\n');
const escalation = await helpEscalationService.enqueue({
userId,
channel: 'h5',
userText: helpText,
context: {
origin: 'help-e2e-no-login',
sessionId,
requestId: `help-e2e-${Date.now()}`,
},
agentSessionId: sessionId,
});
escalationId = escalation.id;
reporter.pass('help 入队 (DB)', escalationId);
} else {
const helpPayload = await sendHelpEscalation(baseUrl, auth.cookie, {
sessionId,
userId,
relativePath,
pageUrl: linksBeforeHelp[0] ?? null,
});
if (helpPayload.needs_details) {
reporter.fail('help 入队', `被判定缺少问题描述: ${helpPayload.message}`);
process.exit(reporter.summary());
}
if (!helpPayload.help_escalation || !helpPayload.escalation_id) {
reporter.fail('help 入队', JSON.stringify(helpPayload));
process.exit(reporter.summary());
}
escalationId = helpPayload.escalation_id;
reporter.pass('help 入队', escalationId);
}
console.log('\n--- 阶段 4: Cursor 接管补救 ---');
let escalation = null;
if (directRemediate || noLogin) {
reporter.pass('补救模式', directRemediate ? 'direct-remediate' : 'no-login auto-remediate');
const remediateResult = await runDirectRemediation({
userId,
sessionId,
relativePath,
port,
viaGoose: !noGoose && Boolean(sessionId && auth?.cookie),
});
console.log(remediateResult);
const helpEscalationService = createHelpEscalationService({ pool });
await helpEscalationService.complete(escalationId, {
status: HELP_ESCALATION_STATUS.SUCCEEDED,
resultText: remediateResult.userMessage ?? '页面链接已补发',
agentOutput: JSON.stringify(remediateResult),
errorMessage: null,
});
escalation = await helpEscalationService.getById(escalationId);
} else {
const helpEscalationService = createHelpEscalationService({ pool });
escalation = await waitForEscalation(helpEscalationService, escalationId);
}
if (escalation?.status !== HELP_ESCALATION_STATUS.SUCCEEDED) {
reporter.fail('Cursor 工单', escalation?.errorMessage ?? escalation?.status ?? 'failed');
console.log(escalation?.agentOutput?.slice?.(0, 800) ?? '');
process.exit(reporter.summary());
}
reporter.pass('Cursor 工单', escalation.resultText?.slice(0, 80) ?? 'succeeded');
console.log('\n--- 阶段 5: 验收 ---');
const readyContract = await getPageDeliveryContract({ pool, userId, relativePath });
if (readyContract?.status === 'ready') {
reporter.pass('delivery contract', 'ready');
} else {
reporter.fail('delivery contract', readyContract?.status ?? 'missing');
}
const notification = await fetchLatestHelpNotification(pool, userId);
if (notification?.body?.includes('MindSpace') || notification?.body?.includes('.html')) {
reporter.pass('Web 通知', notification.title ?? notification.id);
} else {
reporter.fail('Web 通知', '未找到含页面链接的通知');
}
if (sessionId && auth?.cookie) {
const sessionAfter = await getSession(baseUrl, auth.cookie, sessionId);
const texts = sessionAfter.ok ? extractAssistantTexts(sessionAfter.session) : [];
const combined = texts.join('\n');
const linksAfter = extractPublicLinks(combined, baseUrl);
if (linksAfter.some((url) => url.includes(relativePath.replace(/^public\//, '')))) {
reporter.pass('Goose 会话链接', '会话中已出现页面链接');
} else if (directRemediate) {
reporter.fail('Goose 会话链接', 'direct 模式下应通过 --via-goose 写回会话');
} else {
reporter.pass('Goose 会话链接', '依赖 Cursor 脚本;若缺失请检查 agent 是否执行 help-remediate');
}
}
const pageHtml = fs.readFileSync(
path.join(root, PUBLISH_ROOT_DIR, publishKey, relativePath),
'utf8',
);
const pageKeyword = pageHtml.includes('春') ? '春' : (pageHtml.match(/[\u4e00-\u9fff]{2,4}/u)?.[0] ?? 'html');
await verifyPageAccess({
baseUrl,
cookie: auth?.cookie ?? '',
publishKey,
replyText: escalation.resultText ?? '',
htmlBefore: [],
expect: {
keywords: [pageKeyword],
requirePublicLink: true,
requireHttp200: true,
},
reporter,
});
await pool.end();
process.exit(reporter.summary());
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack ?? error.message : error);
process.exit(1);
});