Files
memind/scripts/run-scenario-test.mjs
T
john 32fb2cdeaf feat: chat uploads, vision turn isolation, and MindSpace agent improvements
Add chat file/image upload UX, attachment proxying, vision thumbnails, and per-turn image scoping so agents only use the current upload. Extend MindSpace asset context, billing token state, OA/scenario verify scripts, and related runtime config.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 00:23:01 +08:00

250 lines
6.9 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,
verifyChildrenHobbyDietSurvey,
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,
});
}
if (step.expect?.surveyDietUpdate) {
await verifyChildrenHobbyDietSurvey({
publishKey,
baseUrl,
reporter,
testInsert: step.expect.testInsert !== false,
});
}
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);
});