#!/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 ] [--list] [--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: process.env.RELEASE_GATE_SCENARIO_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 (process.env.RELEASE_GATE_ALLOW_LOOPBACK_REPLY === '1' && pattern === '127.0.0.1:') { continue; } 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); });