a15c4177de
Memind CI / Test, build, and release guards (push) Successful in 10m3s
Add dry-run, e2e, and poem-page remediation scripts for cursor executor and help escalation verification. Co-authored-by: Cursor <cursoragent@cursor.com>
462 lines
16 KiB
JavaScript
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);
|
|
});
|