b93c92a3e2
Memind CI / Test, build, and release guards (push) Failing after 14m27s
The read_image poisoning fix had no end-to-end guard because scenarios could only send text. Add an upload_images step that renders and uploads visually distinct images, let chat steps attach them the same way the WeChat channel does, and assert the generated page actually embeds every upload. Co-authored-by: Cursor <cursoragent@cursor.com>
277 lines
7.6 KiB
JavaScript
277 lines
7.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 {
|
|
buildAssetImageUrl,
|
|
createAgentRun,
|
|
createReporter,
|
|
listScenarios,
|
|
loadScenario,
|
|
loginViaApi,
|
|
logoutViaApi,
|
|
resolvePortalBase,
|
|
snapshotPublicHtml,
|
|
uploadImageAssets,
|
|
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 = [];
|
|
let uploadedAssetIds = [];
|
|
|
|
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 === 'upload_images') {
|
|
if (!auth) {
|
|
throw new Error('upload_images 步骤前必须先 login');
|
|
}
|
|
const assets = await uploadImageAssets(
|
|
baseUrl,
|
|
auth.cookie,
|
|
{ images: step.images ?? [], categoryCode: step.categoryCode ?? 'oa' },
|
|
reporter,
|
|
);
|
|
uploadedAssetIds = assets.map((asset) => asset.id);
|
|
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,
|
|
imageUrls: step.useUploadedAssets
|
|
? uploadedAssetIds.map(buildAssetImageUrl)
|
|
: 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,
|
|
uploadedAssetIds,
|
|
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);
|
|
});
|