#!/usr/bin/env node /** * E2E: Goose 编排 → Cursor executor → MindSpace public/*.html → 会话链接回传 * * Usage: * node scripts/test-cursor-executor-e2e.mjs * node scripts/test-cursor-executor-e2e.mjs --wait-ms=900000 */ 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 { createReporter, extractAssistantTexts, extractPublicLinks, getSession, loginViaApi, resolvePortalBase, snapshotPublicHtml, waitForAssistantGrowth, waitForRunTerminal, } from './scenario-test-lib.mjs'; loadH5Environment(path.dirname(fileURLToPath(import.meta.url))); const args = process.argv.slice(2); 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_EXECUTOR_E2E_WAIT_MS ?? 20 * 60 * 1000, ); const PAGE_BASENAME = `cursor-poem-e2e-${Date.now()}.html`; const RELATIVE_PATH = `public/${PAGE_BASENAME}`; const POEM_MESSAGE = [ '帮我写一首关于春日的短诗(四段即可),并做成一个精美的 HTML 页面。', `产物路径必须是 ${RELATIVE_PATH}。`, ].join('\n'); 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 createCursorCodeRun(baseUrl, cookie, { message, relativePath }) { const requestId = crypto.randomUUID(); const body = { request_id: requestId, user_message: { id: crypto.randomUUID(), role: 'user', content: [{ type: 'text', text: message }], metadata: { userVisible: true, displayText: message, memindRun: { selectedChatSkill: 'aider-development', validation: { expectedFile: { path: relativePath }, }, }, }, }, }; const response = await fetch(`${baseUrl}/api/agent/runs`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie, }, body: JSON.stringify(body), }); const payload = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(`POST /api/agent/runs ${response.status}: ${JSON.stringify(payload)}`); } const run = payload.run ?? payload; return { runId: run.id, requestId, sessionId: run.sessionId ?? run.agent_session_id ?? null, status: run.status, payload, }; } 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('==> Cursor Executor E2E(Goose 编排 + Cursor 落盘)'); console.log(` Portal: ${baseUrl}`); console.log(` 目标文件: ${RELATIVE_PATH}`); console.log(` 超时: ${waitMs}ms\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 htmlBefore = await snapshotPublicHtml(userId); const run = await createCursorCodeRun(baseUrl, auth.cookie, { message: POEM_MESSAGE, relativePath: RELATIVE_PATH, }); reporter.pass('提交 code run', run.runId); const terminal = await waitForRunTerminal(baseUrl, auth.cookie, run.runId, waitMs); const sessionId = terminal.sessionId ?? terminal.agent_session_id ?? run.sessionId ?? null; reporter.pass('run 终态', `${terminal.status}${terminal.error ? ` (${terminal.error})` : ''}`); const pool = createDbPool(); try { const events = await fetchRunEvents(pool, run.runId); const dispatch = events.find((item) => item.eventType === 'tool_gateway_dispatch'); const result = events.find((item) => item.eventType === 'tool_gateway_result'); const validation = events.find((item) => item.eventType === 'tool_gateway_validation'); if (dispatch?.data) { reporter.pass('tool_gateway_dispatch', JSON.stringify(dispatch.data)); } else { reporter.fail('tool_gateway_dispatch', '未找到 dispatch 事件(可能未走 code executor 路径)'); } if (result?.data?.executor === 'cursor') { reporter.pass('executor', 'cursor'); } else { reporter.fail('executor', `期望 cursor,实际 ${result?.data?.executor ?? 'unknown'}`); } if (validation?.data) { reporter.pass('validation', JSON.stringify(validation.data)); } else if (terminal.status === 'succeeded') { reporter.fail('validation', '缺少 tool_gateway_validation 事件'); } } finally { await pool.end(); } if (terminal.status !== 'succeeded') { process.exit(reporter.summary()); } const htmlAfter = await snapshotPublicHtml(userId); const created = htmlAfter.find((item) => item.relativePublicPath === RELATIVE_PATH) ?? htmlAfter.filter((item) => !htmlBefore.some((before) => before.fullPath === item.fullPath)) .sort((a, b) => b.mtimeMs - a.mtimeMs)[0] ?? null; if (created) { reporter.pass('MindSpace HTML', created.relativePublicPath); } else { reporter.fail('MindSpace HTML', `未找到 ${RELATIVE_PATH}`); } if (sessionId) { const reply = await waitForAssistantGrowth(baseUrl, auth.cookie, sessionId, { minChars: 20, timeoutMs: 120_000, }); if (reply?.combined) { const links = extractPublicLinks(reply.combined, baseUrl); reporter.pass('assistant 回复', `${reply.combined.length} 字 / ${links.length} 链接`); if (links.length > 0) { for (const link of links.slice(0, 3)) { const pageResponse = await fetch(link, { headers: cookie ? { Cookie: cookie } : {}, redirect: 'follow', }); const body = await pageResponse.text(); const hit = ['春', '诗', 'html', 'poem', 'spring'].some((kw) => body.includes(kw)); reporter.pass( `页面 ${link}`, `HTTP ${pageResponse.status}${hit ? ' / 含诗歌关键词' : ''}`, ); } } else { reporter.fail('公开链接', '回复中未找到 MindSpace 链接'); } } else { reporter.fail('assistant 回复', '会话无新 assistant 消息'); } } else { reporter.fail('sessionId', 'run 未回填 sessionId'); } const exitCode = reporter.summary(); process.exit(exitCode); } main().catch((error) => { console.error(error); process.exit(1); });