#!/usr/bin/env node /** * E2E: 验证页面 / 问卷(Page Data) / Excel 分析默认路由到 Cursor executor * * Usage: * JOHN_PASSWORD=... node scripts/test-cursor-task-routing-e2e.mjs * node scripts/test-cursor-task-routing-e2e.mjs --routing-only */ import crypto from 'node:crypto'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { createDbPool } from '../db.mjs'; import { loadH5Environment } from './load-env.mjs'; import { createAgentRun, createReporter, extractAssistantTexts, extractPublicLinks, loginViaApi, resolvePortalBase, sleep, snapshotPublicHtml, waitForAssistantGrowth, waitForRunTerminal, } from './scenario-test-lib.mjs'; loadH5Environment(path.dirname(fileURLToPath(import.meta.url))); const args = process.argv.slice(2); const routingOnly = args.includes('--routing-only'); 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_TASK_ROUTING_E2E_WAIT_MS ?? 20 * 60 * 1000, ); const routingWaitMs = Number( args.find((item) => item.startsWith('--routing-wait-ms='))?.slice('--routing-wait-ms='.length) ?? 5 * 60 * 1000, ); const CASES = [ { id: 'page_generation', label: '页面生成', message: () => `帮我做一个页面 routing-e2e-${Date.now()}`, expectTaskKind: 'page_generation', fullDelivery: true, }, { id: 'page_data', label: '调查问卷', message: () => `帮我做一个简单调查问卷(2 道题即可),结果存 PG,页面名 survey-routing-e2e-${Date.now()}`, expectTaskKind: 'page_data', fullDelivery: false, }, { id: 'excel_analysis', label: 'Excel 分析', message: () => '帮我分析工作区里 Excel 表格的数据趋势,并做成结果页面', expectTaskKind: 'excel_analysis', fullDelivery: false, }, ]; async function fetchRunEvents(pool, runId) { const [rows] = await pool.query( `SELECT event_type, data_json, created_at FROM h5_agent_run_events WHERE run_id = ? ORDER BY created_at ASC`, [runId], ); return rows.map((row) => ({ eventType: row.event_type, data: typeof row.data_json === 'string' ? JSON.parse(row.data_json) : (row.data_json ?? null), createdAt: Number(row.created_at), })); } async function waitForCursorRouting(pool, runId, timeoutMs) { const started = Date.now(); let lastEvents = []; while (Date.now() - started < timeoutMs) { lastEvents = await fetchRunEvents(pool, runId); const result = lastEvents.find((item) => item.eventType === 'tool_gateway_result'); if (result?.data?.executor === 'cursor') { return { ok: true, events: lastEvents, result: result.data }; } const gooseChat = lastEvents.find((item) => item.eventType === 'direct_chat_completed'); if (gooseChat) { return { ok: false, events: lastEvents, reason: '走了 direct_chat(Goose)而非 Cursor' }; } await sleep(2000); } const dispatch = lastEvents.find((item) => item.eventType === 'tool_gateway_dispatch'); const result = lastEvents.find((item) => item.eventType === 'tool_gateway_result'); return { ok: false, events: lastEvents, reason: dispatch ? `已 dispatch 但 executor=${result?.data?.executor ?? 'pending'}` : '未看到 tool_gateway_dispatch', }; } async function verifyCase({ baseUrl, cookie, userId, pool, reporter, testCase, }) { console.log(`\n--- ${testCase.label} (${testCase.id}) ---`); const message = testCase.message(); console.log(`消息: ${message.slice(0, 80)}${message.length > 80 ? '…' : ''}`); const htmlBefore = testCase.fullDelivery ? await snapshotPublicHtml(userId) : []; const run = await createAgentRun(baseUrl, cookie, { message }); reporter.pass(`${testCase.label} 提交 run`, run.runId); const routing = await waitForCursorRouting(pool, run.runId, routingWaitMs); if (routing.ok) { reporter.pass(`${testCase.label} 路由`, 'tool_gateway → cursor'); } else { reporter.fail(`${testCase.label} 路由`, routing.reason ?? 'unknown'); if (routing.events?.length) { const types = routing.events.map((item) => item.eventType).join(', '); console.log(` events: ${types}`); } return; } if (routingOnly || !testCase.fullDelivery) { reporter.pass(`${testCase.label} 路由验证`, '仅验证 Cursor 路由(未等待完整交付)'); return; } const terminal = await waitForRunTerminal(baseUrl, cookie, run.runId, waitMs); reporter.pass(`${testCase.label} run 终态`, `${terminal.status}${terminal.error ? ` (${terminal.error})` : ''}`); if (terminal.status !== 'succeeded') { return; } const htmlAfter = await snapshotPublicHtml(userId); const created = htmlAfter.filter((item) => !htmlBefore.some((before) => before.fullPath === item.fullPath)) .sort((a, b) => b.mtimeMs - a.mtimeMs)[0] ?? null; if (created) { reporter.pass(`${testCase.label} MindSpace HTML`, created.relativePublicPath); } else { reporter.fail(`${testCase.label} MindSpace HTML`, '未检测到新 public HTML'); } const sessionId = terminal.sessionId ?? terminal.agent_session_id ?? run.sessionId ?? null; if (!sessionId) { reporter.fail(`${testCase.label} sessionId`, 'run 未回填 sessionId'); return; } const reply = await waitForAssistantGrowth(baseUrl, cookie, sessionId, { minChars: 10, timeoutMs: 120_000, }); if (reply?.combined) { const links = extractPublicLinks(reply.combined, baseUrl); reporter.pass(`${testCase.label} assistant 回复`, `${reply.combined.length} 字`); if (/cursor/i.test(reply.combined)) { reporter.pass(`${testCase.label} 回复含 cursor`, '已由 cursor 完成执行'); } if (links.length > 0) { reporter.pass(`${testCase.label} 公开链接`, links[0]); } } else { reporter.fail(`${testCase.label} assistant 回复`, '无新 assistant 消息'); } } 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 ?? '981122tj', }; console.log('==> Cursor 任务路由 E2E(页面 / 问卷 / Excel)'); console.log(` Portal: ${baseUrl}`); console.log(` 模式: ${routingOnly ? '仅路由验证' : '页面全量 + 问卷/Excel 路由'}\n`); const statusResponse = await fetch(`${baseUrl}/auth/status`); if (!statusResponse.ok) { throw new Error(`Portal 未就绪: ${statusResponse.status}`); } const auth = await loginViaApi(baseUrl, account, reporter); const userId = auth.user?.id; if (!userId) { reporter.fail('登录', '缺少 userId'); process.exit(reporter.summary()); } const pool = createDbPool(); try { for (const testCase of CASES) { await verifyCase({ baseUrl, cookie: auth.cookie, userId, pool, reporter, testCase, }); } } finally { await pool.end(); } process.exit(reporter.summary()); } main().catch((error) => { console.error(error); process.exit(1); });