Files
memind/scripts/run-scenario-test.mjs
T
john 7f8d692d16 fix(agent): recover stale runs and improve new-user OA delivery
Fix DEV logout cookie clearing, materialize selected MindSpace OA assets before agent runs, and recover zombie runs from synced workspace pages. Add client run wait timeout, harness retry limits, page-edit asset forwarding, and logout/john2 scenario tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 20:05:52 +08:00

240 lines
6.6 KiB
JavaScript

#!/usr/bin/env node
/**
* Memind 场景模拟测试入口。
*
* Usage:
* node scripts/run-scenario-test.mjs --scenario suzhou-page
* node scripts/run-scenario-test.mjs --list
* JOHN_PASSWORD=888888 node scripts/run-scenario-test.mjs
*/
import { loadH5Environment } from './load-env.mjs';
import {
createAgentRun,
createReporter,
listScenarios,
loadScenario,
loginViaApi,
logoutViaApi,
resolvePortalBase,
snapshotPublicHtml,
verifyPageAccess,
verifySurveyDelivery,
waitForAssistantGrowth,
waitForRunTerminal,
extractAssistantTexts,
getSession,
} from './scenario-test-lib.mjs';
loadH5Environment(import.meta.dirname);
function usage() {
console.log(`Usage:
node scripts/run-scenario-test.mjs [--scenario <id>] [--list] [--port <port>]
Examples:
node scripts/run-scenario-test.mjs --scenario suzhou-page
node scripts/run-scenario-test.mjs --list`);
}
function parseArgs(argv) {
let scenarioId = 'suzhou-page';
let listOnly = false;
let port = Number(process.env.H5_PORT ?? 8081);
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--scenario' && argv[i + 1]) {
scenarioId = argv[++i];
} else if (arg === '--port' && argv[i + 1]) {
port = Number(argv[++i]);
} else if (arg === '--list') {
listOnly = true;
} else if (arg === '-h' || arg === '--help') {
usage();
process.exit(0);
} else {
throw new Error(`未知参数: ${arg}`);
}
}
return { scenarioId, listOnly, port };
}
async function ensurePortalReady(baseUrl) {
const response = await fetch(`${baseUrl}/auth/status`);
if (!response.ok) {
throw new Error(`Portal 未就绪: ${baseUrl}/auth/status -> ${response.status}`);
}
}
async function runScenario(scenario, port) {
const reporter = createReporter();
const baseUrl = resolvePortalBase(port);
const account = {
username: scenario.account?.username ?? 'john',
password:
process.env.JOHN_PASSWORD
?? process.env.H5_ACCESS_PASSWORD
?? scenario.account?.password
?? '888888',
};
console.log(`==> 场景: ${scenario.name ?? scenario.id}`);
console.log(` Portal: ${baseUrl}`);
console.log(` 账户: ${account.username}\n`);
await ensurePortalReady(baseUrl);
let auth = null;
let sessionId = null;
let assistantCount = 0;
let assistantCombinedLength = 0;
let publishKey = null;
let htmlBefore = [];
for (const step of scenario.steps ?? []) {
const label = step.label ?? step.action;
console.log(`\n--- ${label} ---`);
if (step.action === 'login' || (step.action === 'chat' && !auth && step.login !== false)) {
if (!auth) {
auth = await loginViaApi(baseUrl, account, reporter);
publishKey = auth.user?.id ?? auth.user?.publishSlug ?? account.username;
}
if (step.action === 'login') {
continue;
}
}
if (step.action === 'logout') {
if (!auth) {
throw new Error('logout 步骤前必须先 login');
}
await logoutViaApi(baseUrl, auth.cookie, reporter);
auth = null;
continue;
}
if (step.action === 'chat') {
if (!auth) {
throw new Error('chat 步骤前必须先 login');
}
if (step.expect?.page) {
htmlBefore = await snapshotPublicHtml(publishKey);
}
const run = await createAgentRun(baseUrl, auth.cookie, {
message: step.message,
sessionId,
selectedChatSkill: step.selectedChatSkill ?? null,
selectedAssetIds: step.selectedAssetIds ?? null,
});
reporter.pass('提交消息', `"${step.message}" → run ${run.runId}`);
const terminal = await waitForRunTerminal(
baseUrl,
auth.cookie,
run.runId,
step.expect?.timeoutMs ?? 300_000,
);
sessionId = terminal.sessionId ?? terminal.agent_session_id ?? run.sessionId ?? sessionId;
if (terminal.status === 'failed') {
reporter.fail('run 终态', terminal.error ?? 'failed');
continue;
}
reporter.pass('run 终态', terminal.status);
if (!sessionId) {
reporter.fail('session', 'run 未回填 sessionId');
continue;
}
const reply = await waitForAssistantGrowth(baseUrl, auth.cookie, sessionId, {
previousCount: assistantCount,
previousCombinedLength: assistantCombinedLength,
minChars: step.expect?.assistantMinChars ?? 1,
timeoutMs: step.expect?.timeoutMs ?? 120_000,
});
if (!reply) {
reporter.fail('assistant 回复', '超时未收到新回复');
continue;
}
assistantCount = reply.count;
assistantCombinedLength = reply.combined.length;
reporter.pass('assistant 回复', `${reply.combined.length} 字 / ${reply.elapsedMs}ms`);
console.log('\n--- 回复摘要 ---');
console.log(reply.combined.slice(0, 600));
const keywords = step.expect?.replyKeywords ?? [];
if (keywords.length) {
const hit = keywords.filter((word) => reply.combined.includes(word));
if (hit.length === 0) {
reporter.fail('回复关键词', `未命中: ${keywords.join(', ')}`);
} else {
reporter.pass('回复关键词', hit.join(', '));
}
}
if (step.expect?.page) {
await verifyPageAccess({
baseUrl,
cookie: auth.cookie,
publishKey,
replyText: reply.combined,
htmlBefore,
expect: step.expect.page,
reporter,
});
}
if (step.expect?.survey) {
await verifySurveyDelivery({
publishKey,
replyText: reply.combined,
expect: step.expect.survey,
reporter,
});
}
const forbidReply = step.expect?.forbidReplyPatterns ?? [];
for (const pattern of forbidReply) {
if (pattern && reply.combined.includes(pattern)) {
reporter.fail('回复禁用模式', `命中 ${pattern}`);
}
}
continue;
}
reporter.fail('未知步骤', step.action ?? 'missing action');
}
return reporter.summary();
}
async function main() {
const { scenarioId, listOnly, port } = parseArgs(process.argv);
if (listOnly) {
const scenarios = await listScenarios();
console.log('可用场景:');
for (const item of scenarios) {
console.log(`- ${item.id}: ${item.name}${item.description ? ` — ${item.description}` : ''}`);
}
return;
}
const scenario = await loadScenario(scenarioId);
const code = await runScenario(scenario, port);
process.exit(code);
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack ?? error.message : error);
process.exit(1);
});