#!/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); });