256 lines
7.0 KiB
JavaScript
256 lines
7.0 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:
|
|
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);
|
|
});
|