test(cursor): add executor routing and help remediation scenarios
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>
This commit is contained in:
john
2026-08-27 09:33:51 +08:00
parent 8bdb6e2442
commit a15c4177de
8 changed files with 1333 additions and 0 deletions
+29
View File
@@ -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=<uuid> --direct-remediate --no-goose"
],
"steps": [
{
"action": "login",
"label": "登录"
},
{
"action": "chat",
"label": "写诗并做 HTML 页面",
"message": "帮我写一首关于春日的短诗(四段即可),并做成一个精美的 HTML 页面",
"expect": {
"assistantMinChars": 20,
"timeoutMs": 600000,
"replyKeywords": ["春"]
}
}
]
}
+88
View File
@@ -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);
}
+103
View File
@@ -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);
});
+64
View File
@@ -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;
}
+226
View File
@@ -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 E2EGoose 编排 + 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);
});
+127
View File
@@ -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();
}
+235
View File
@@ -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_chatGoose)而非 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);
});
+461
View File
@@ -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);
});