test(cursor): add executor routing and help remediation scenarios
Memind CI / Test, build, and release guards (push) Successful in 10m3s
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:
@@ -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": ["春"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user