diff --git a/scenarios/help-poem-page-remediation.json b/scenarios/help-poem-page-remediation.json new file mode 100644 index 0000000..7f1e7fc --- /dev/null +++ b/scenarios/help-poem-page-remediation.json @@ -0,0 +1,29 @@ +{ + "id": "help-poem-page-remediation", + "name": "写诗做页 → 链接失败 → help → Cursor 补救", + "description": "用户通过 Goose 写诗做页,模拟链接未送达,发送 help 后由 Cursor 修复 delivery 并经 Goose/通知回传链接", + "account": { + "username": "john", + "password": "888888" + }, + "notes": [ + "完整 E2E 请运行: JOHN_PASSWORD=你的密码 node scripts/test-help-poem-page-remediation.mjs", + "无登录补救链路验证: node scripts/test-help-poem-page-remediation.mjs --skip-poem --no-login --user-id= --direct-remediate --no-goose" + ], + "steps": [ + { + "action": "login", + "label": "登录" + }, + { + "action": "chat", + "label": "写诗并做 HTML 页面", + "message": "帮我写一首关于春日的短诗(四段即可),并做成一个精美的 HTML 页面", + "expect": { + "assistantMinChars": 20, + "timeoutMs": 600000, + "replyKeywords": ["春"] + } + } + ] +} diff --git a/scripts/run-cursor-help-real-test.mjs b/scripts/run-cursor-help-real-test.mjs new file mode 100644 index 0000000..92cc6d4 --- /dev/null +++ b/scripts/run-cursor-help-real-test.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +/** + * Enqueue one help ticket and process it with real Cursor agent CLI (--once). + * Usage: node scripts/run-cursor-help-real-test.mjs + */ +import crypto from 'node:crypto'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { setTimeout as sleep } from 'node:timers/promises'; + +import { createDbPool, migrateSchema } from '../db.mjs'; +import { + HELP_ESCALATION_STATUS, + createHelpEscalationService, +} from '../help-escalation.mjs'; +import { processOneHelpEscalation } from '../cursor-help-worker.mjs'; +import { createScheduleService } from '../schedule-service.mjs'; +import { loadH5Environment } from './load-env.mjs'; + +loadH5Environment(path.dirname(fileURLToPath(import.meta.url))); + +const TEST_MARKER = '[CURSOR-REAL-TEST]'; +const USER_ID = process.env.MEMIND_HELP_TEST_USER_ID ?? '1c99b83b-0454-474f-a5d2-129d34506a32'; +const PAGE = process.env.MEMIND_HELP_TEST_PAGE ?? 'public/tang-dynasty.html'; + +const pool = createDbPool(); +await migrateSchema(pool); + +const helpEscalationService = createHelpEscalationService({ pool }); +if (!helpEscalationService.enabled) { + console.error('MEMIND_CURSOR_HELP_ENABLED is off'); + process.exit(1); +} + +const helpText = [ + `help ${TEST_MARKER} 我刚做了诗歌/主题 HTML 页面,但聊天里没有收到可打开的链接。`, + `页面文件: ${PAGE}`, + '请检查 delivery contract、修复交付,并给用户可打开的 Portal 链接(8081,不要用 5173)。', + '完成后在 JSON userMessage 里给出链接。', +].join('\n'); + +const escalation = await helpEscalationService.enqueue({ + userId: USER_ID, + channel: 'h5', + userText: helpText, + context: { + origin: 'cursor-real-test', + testMarker: TEST_MARKER, + relativePath: PAGE, + requestId: `cursor-real-${Date.now()}`, + }, +}); + +console.log('enqueued:', { id: escalation.id, status: escalation.status }); +console.log('spawning Cursor agent via processOneHelpEscalation...'); +console.log('agent bin:', process.env.MEMIND_CURSOR_HELP_AGENT_BIN ?? '~/.local/bin/agent'); + +const scheduleService = createScheduleService(pool, { + defaultTimezone: process.env.MEMIND_DEFAULT_TIMEZONE ?? 'Asia/Shanghai', +}); + +const started = Date.now(); +const result = await processOneHelpEscalation({ + helpEscalationService, + scheduleService, + workerId: 'cursor-real-test-script', + logger: console, +}); + +const elapsedSec = Math.round((Date.now() - started) / 1000); +const finalRow = await helpEscalationService.getById(escalation.id); + +console.log('\n=== Cursor Real Test Result ==='); +console.log(JSON.stringify({ + elapsedSec, + id: finalRow?.id, + status: finalRow?.status, + wasClaimed: finalRow?.startedAt != null, + resultPreview: String(finalRow?.resultText ?? '').slice(0, 200), + agentOutputPreview: String(finalRow?.agentOutput ?? '').slice(0, 300), + errorMessage: finalRow?.errorMessage ?? null, +}, null, 2)); + +await pool.end(); + +if (finalRow?.status !== HELP_ESCALATION_STATUS.SUCCEEDED || finalRow?.startedAt == null) { + process.exit(1); +} diff --git a/scripts/setup-product-order-page.mjs b/scripts/setup-product-order-page.mjs new file mode 100644 index 0000000..3f398f3 --- /dev/null +++ b/scripts/setup-product-order-page.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +/** + * 为 1c99b83b 工作区搭建 product-order Page Data + */ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadH5Environment } from './load-env.mjs'; +import { createDbPool } from '../db.mjs'; +import { bindWorkspaceHtmlForPageData } from '../page-data-workspace-bind.mjs'; +import { resolveMindSpaceStorageRoot } from '../mindspace-runtime-config.mjs'; +import { createUserDataSpaceService } from '../user-data-space-service.mjs'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +loadH5Environment(import.meta.dirname); + +const USER_ID = '1c99b83b-0454-474f-a5d2-129d34506a32'; +const WORKSPACE_ROOT = path.join(root, 'MindSpace', USER_ID); +const ADMIN_PASSWORD = '88888888'; +const DATASET = 'product_orders'; + +const FORM_POLICY = { + accessMode: 'public', + datasets: { + product_orders: { + insert: true, + read: false, + columns: { + insert: ['customer_name', 'phone', 'product_items', 'total_amount', 'address', 'remark', 'status'], + }, + }, + }, +}; + +const ADMIN_POLICY = { + accessMode: 'password', + datasets: { + product_orders: { + insert: false, + read: true, + columns: { + read: ['id', 'customer_name', 'phone', 'product_items', 'total_amount', 'address', 'remark', 'status', 'created_at'], + }, + }, + }, +}; + +async function main() { + const dataSpace = createUserDataSpaceService({ workspaceRoot: WORKSPACE_ROOT, userId: USER_ID }); + await dataSpace.executeSql(`CREATE TABLE IF NOT EXISTS product_orders ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + customer_name TEXT NOT NULL DEFAULT '', + phone TEXT NOT NULL DEFAULT '', + product_items TEXT NOT NULL DEFAULT '', + total_amount TEXT NOT NULL DEFAULT '0', + address TEXT NOT NULL DEFAULT '', + remark TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT '待处理', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + );`); + await dataSpace.upsertDataset({ + name: DATASET, + table: DATASET, + description: '精选好物下单页订单表', + actions: ['read', 'insert'], + columns: { + insert: FORM_POLICY.datasets.product_orders.columns.insert, + read: ADMIN_POLICY.datasets.product_orders.columns.read, + }, + }); + + const pool = createDbPool(); + const storageRoot = resolveMindSpaceStorageRoot(root); + const pages = [ + { relativePath: 'public/product-order.html', accessMode: 'public', password: null, policy: FORM_POLICY }, + { relativePath: 'public/product-order-admin.html', accessMode: 'password', password: ADMIN_PASSWORD, policy: ADMIN_POLICY }, + ]; + + console.log('==> product-order Page Data 搭建\n'); + for (const page of pages) { + const result = await bindWorkspaceHtmlForPageData({ + pool, + h5Root: root, + storageRoot, + userId: USER_ID, + workspaceRoot: WORKSPACE_ROOT, + relativePath: page.relativePath, + accessMode: page.accessMode, + password: page.password, + pageDataPolicy: page.policy, + }); + console.log(`✓ ${page.relativePath}`); + console.log(` pageId: ${result.pageId}`); + console.log(` URL: ${result.workspaceUrl}\n`); + } + + console.log('后台口令:', ADMIN_PASSWORD); + await pool.end(); +} + +main().catch((err) => { + console.error(err instanceof Error ? err.stack ?? err.message : err); + process.exit(1); +}); diff --git a/scripts/test-cursor-executor-dry-run.mjs b/scripts/test-cursor-executor-dry-run.mjs new file mode 100644 index 0000000..025626e --- /dev/null +++ b/scripts/test-cursor-executor-dry-run.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/** + * Dry-run Cursor executor launch plan via Tool Gateway (no agent spawn). + * + * Usage: + * MEMIND_CURSOR_EXECUTOR_ENABLED=1 \ + * MEMIND_TOOL_GATEWAY_ENABLED=1 \ + * MEMIND_TOOL_GATEWAY_DRY_RUN=1 \ + * node scripts/test-cursor-executor-dry-run.mjs + */ + +import { buildCursorExecutorLaunchPlan } from '../cursor-agent-launch.mjs'; +import { createToolGateway } from '../tool-gateway.mjs'; + +const cwd = process.argv[2] ?? process.cwd(); +const instruction = process.argv[3] + ?? '写一首春日诗,生成精美 HTML 页面 public/spring-poem.html'; + +process.env.MEMIND_CURSOR_EXECUTOR_ENABLED = process.env.MEMIND_CURSOR_EXECUTOR_ENABLED ?? '1'; +process.env.MEMIND_TOOL_GATEWAY_ENABLED = process.env.MEMIND_TOOL_GATEWAY_ENABLED ?? '1'; +process.env.MEMIND_TOOL_GATEWAY_DRY_RUN = process.env.MEMIND_TOOL_GATEWAY_DRY_RUN ?? '1'; + +const gateway = createToolGateway({ + env: process.env, + llmProviderService: { + async getExecutorLaunchPlan(executor, options) { + return buildCursorExecutorLaunchPlan({ + cwd: options.cwd, + instruction: options.instruction, + env: process.env, + }); + }, + }, +}); + +const status = gateway.getStatus(); +console.log('[cursor-executor-dry-run] gateway status:', JSON.stringify(status, null, 2)); + +const result = await gateway.executeJob({ + runId: 'dry-run-cursor', + requestId: 'dry-run-req', + userId: 'local-user', + cwd, + taskType: 'h5_chat_code_task', + userMessage: { + content: [{ type: 'text', text: instruction }], + metadata: { + memindRun: { + executor: 'cursor', + toolMode: 'code', + taskType: 'h5_chat_code_task', + validation: { + expectedFile: { path: 'public/spring-poem.html' }, + }, + }, + }, + }, +}); + +console.log('[cursor-executor-dry-run] result:', JSON.stringify(result, null, 2)); + +if (!result?.ok || result.executor !== 'cursor') { + process.exitCode = 1; +} diff --git a/scripts/test-cursor-executor-e2e.mjs b/scripts/test-cursor-executor-e2e.mjs new file mode 100644 index 0000000..18725ea --- /dev/null +++ b/scripts/test-cursor-executor-e2e.mjs @@ -0,0 +1,226 @@ +#!/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); +}); diff --git a/scripts/test-cursor-help-e2e.mjs b/scripts/test-cursor-help-e2e.mjs new file mode 100644 index 0000000..3357c0d --- /dev/null +++ b/scripts/test-cursor-help-e2e.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { createDbPool, migrateSchema } from '../db.mjs'; +import { + HELP_ESCALATION_STATUS, + createHelpEscalationService, + isHelpEscalationIntent, +} from '../help-escalation.mjs'; +import { processOneHelpEscalation } from '../cursor-help-worker.mjs'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function loadEnvFile(filePath) { + if (!fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq < 0) continue; + const key = trimmed.slice(0, eq).trim(); + const value = trimmed.slice(eq + 1).trim(); + if (!process.env[key]) process.env[key] = value; + } +} + +loadEnvFile(path.join(root, '.env')); +loadEnvFile(path.join(root, '.env.local')); + +const args = process.argv.slice(2); +const enqueueOnly = args.includes('--enqueue-only'); +const processLocally = args.includes('--process-locally'); +const userIdArg = args.find((item) => item.startsWith('--user-id='))?.slice('--user-id='.length)?.trim() ?? ''; +const waitMs = Number( + args.find((item) => item.startsWith('--wait-ms='))?.slice('--wait-ms='.length) + ?? process.env.MEMIND_CURSOR_HELP_E2E_WAIT_MS + ?? 10 * 60 * 1000, +); + +const pool = createDbPool(); + +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('waiting for worker...', { + id: escalationId, + status: current?.status ?? 'missing', + }); + await sleep(2000); + } + throw new Error(`Timed out after ${waitMs}ms waiting for escalation ${escalationId}`); +} + +try { + await migrateSchema(pool); + + const helpEscalationService = createHelpEscalationService({ pool }); + if (!helpEscalationService.enabled) { + console.error('MEMIND_CURSOR_HELP_ENABLED is not enabled'); + process.exitCode = 1; + } else { + const sampleText = 'help 本机端到端测试:请检查 Portal /api/status 是否正常,并回复一行 JSON 结果。'; + console.log('help intent:', isHelpEscalationIntent(sampleText)); + + let userId = userIdArg; + if (!userId) { + const [rows] = await pool.query('SELECT id, username FROM h5_users ORDER BY created_at ASC LIMIT 1'); + userId = rows[0]?.id; + } + if (!userId) { + console.error('No h5_users row found for e2e test'); + process.exitCode = 1; + } else { + const escalation = await helpEscalationService.enqueue({ + userId, + channel: 'h5', + userText: sampleText, + context: { + origin: 'e2e-test', + requestId: `help-e2e-${Date.now()}`, + }, + }); + + console.log('enqueued:', { + id: escalation.id, + userId: escalation.userId, + status: escalation.status, + }); + + if (!enqueueOnly) { + let completed = null; + if (processLocally) { + console.log('processing locally with agent CLI (portal worker should be disabled)...'); + completed = await processOneHelpEscalation({ + helpEscalationService, + logger: console, + }); + } else { + console.log('waiting for portal/standalone cursor-help worker...'); + completed = await waitForEscalation(helpEscalationService, escalation.id); + } + + console.log('completed:', { + id: completed?.id ?? null, + status: completed?.status ?? null, + resultText: completed?.resultText ?? null, + errorMessage: completed?.errorMessage ?? null, + }); + + if (!completed || completed.status !== HELP_ESCALATION_STATUS.SUCCEEDED) { + process.exitCode = 1; + } + } + } + } +} finally { + await pool.end(); +} diff --git a/scripts/test-cursor-task-routing-e2e.mjs b/scripts/test-cursor-task-routing-e2e.mjs new file mode 100644 index 0000000..7cad3e6 --- /dev/null +++ b/scripts/test-cursor-task-routing-e2e.mjs @@ -0,0 +1,235 @@ +#!/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); +}); diff --git a/scripts/test-help-poem-page-remediation.mjs b/scripts/test-help-poem-page-remediation.mjs new file mode 100644 index 0000000..d033c0b --- /dev/null +++ b/scripts/test-help-poem-page-remediation.mjs @@ -0,0 +1,461 @@ +#!/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); +});