Files
memind/scripts/run-wechat-scenario-test.mjs
T
john 7e26b459bc
Memind CI / Test, build, and release guards (push) Failing after 4s
fix(page-data): scope WeChat delivery verification to newly bound pages
Historical workspace Page Data HTML was failing delivery smoke checks and
blocking new survey links from reaching WeChat users. Add a webhook scenario
test script for 103 regression runs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 20:32:03 +08:00

350 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 模拟微信服务号 webhook 场景测试(打本地 Portal /webhooks/wechat-mp/messages)。
*
* Usage:
* node scripts/run-wechat-scenario-test.mjs --list
* node scripts/run-wechat-scenario-test.mjs --scenario survey-page-data
* node scripts/run-wechat-scenario-test.mjs --all --openid ooil-0VFj68QK1tkHl39uL610et8
*/
import crypto from 'node:crypto';
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import mysql from 'mysql2/promise';
function loadDotEnv(envPath) {
if (!fs.existsSync(envPath)) return;
for (const line of fs.readFileSync(envPath, 'utf8').split(/\n/)) {
if (!line || line.startsWith('#') || !line.includes('=')) continue;
const i = line.indexOf('=');
const key = line.slice(0, i);
const val = line.slice(i + 1);
if (!(key in process.env)) process.env[key] = val;
}
}
loadDotEnv(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '.env'));
const SCENARIOS = [
{
id: 'survey-page-data',
name: '问卷 + 口令后台(Page Data',
message:
'帮我做一个公司员工建议收集问卷(匿名提交),再做一个口令后台查看数据,后台密码 admin888。',
timeoutMs: 900_000,
expect: {
forbidReply: [
'没有完成 Page Data API 绑定',
'公众号消息处理超时',
'computercontroller',
],
requireGoosedTools: ['sandbox-fs__private_data', 'sandbox-fs__write_file'],
replyKeywords: ['问卷', '后台'],
},
},
{
id: 'poem-page',
name: '写诗 → 做成页面',
message: '帮我写一首关于八月午后的短诗,然后做成精美 HTML 页面',
timeoutMs: 600_000,
expect: {
forbidReply: ['没有按服务号页面技能', '没能可靠确认'],
requireGoosedTools: ['sandbox-fs__write_file'],
replyKeywords: ['八月', '页面'],
requireLink: true,
},
},
{
id: 'chat-general',
name: '普通聊天(不应触发 Page Data 闸门)',
message: '你好,今天天气怎么样?',
timeoutMs: 420_000,
expect: {
forbidReply: ['Page Data API 绑定'],
minReplyChars: 10,
},
},
];
function sha1(parts) {
return crypto.createHash('sha1').update([...parts].sort().join('')).digest('hex');
}
function buildInboundXml({ fromUser, toUser, content, msgId }) {
return [
'<xml>',
`<ToUserName><![CDATA[${toUser}]]></ToUserName>`,
`<FromUserName><![CDATA[${fromUser}]]></FromUserName>`,
'<CreateTime>1710000000</CreateTime>',
'<MsgType><![CDATA[text]]></MsgType>',
`<Content><![CDATA[${content}]]></Content>`,
`<MsgId>${msgId}</MsgId>`,
'</xml>',
].join('');
}
async function postWechatMessage({
baseUrl,
token,
appId,
openid,
ghId,
content,
msgId,
}) {
const timestamp = String(Math.floor(Date.now() / 1000));
const nonce = crypto.randomBytes(8).toString('hex');
const signature = sha1([token, timestamp, nonce]);
const xml = buildInboundXml({
fromUser: openid,
toUser: ghId || appId,
content,
msgId,
});
const url = `${baseUrl}/webhooks/wechat-mp/messages?signature=${signature}&timestamp=${timestamp}&nonce=${nonce}`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'text/xml; charset=utf-8' },
body: xml,
});
const body = await response.text();
return { status: response.status, body: body.slice(0, 200) };
}
async function fetchAssistantReply(pool, sessionId, sinceCreatedAt = 0) {
if (!sessionId) return '';
const [rows] = await pool.query(
`SELECT text
FROM h5_conversation_messages
WHERE agent_session_id = ? AND role = 'assistant' AND created_at >= ?
ORDER BY sequence_no DESC
LIMIT 10`,
[sessionId, sinceCreatedAt],
);
const texts = rows.map((row) => String(row.text ?? '').trim()).filter(Boolean);
return texts.sort((a, b) => b.length - a.length)[0] ?? '';
}
async function waitForMessageStatus(pool, { appId, openid, msgId }, timeoutMs) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const [rows] = await pool.query(
`SELECT status, agent_session_id, updated_at
FROM h5_wechat_mp_messages
WHERE app_id = ? AND openid = ? AND msg_id = ? LIMIT 1`,
[appId, openid, msgId],
);
const row = rows[0];
if (row && (row.status === 'done' || row.status === 'failed')) {
const [details] = await pool.query(
`SELECT display_text, agent_text
FROM h5_wechat_mp_message_details
WHERE app_id = ? AND openid = ? AND msg_id = ?
ORDER BY created_at DESC LIMIT 1`,
[appId, openid, msgId],
);
const assistantReply = await fetchAssistantReply(pool, row.agent_session_id, row.created_at);
return {
...row,
displayText: details[0]?.display_text ?? '',
agentText: details[0]?.agent_text ?? '',
assistantReply,
elapsedMs: Date.now() - started,
};
}
await new Promise((r) => setTimeout(r, 5000));
}
return {
status: 'timeout',
agent_session_id: null,
displayText: '',
agentText: '',
assistantReply: '',
elapsedMs: timeoutMs,
};
}
function grepGoosedSessionLogs(sessionId) {
if (!sessionId) return { tools: [], sandboxFsFailed: false, lines: [], logFile: null };
const logGlob = `${process.env.HOME}/Library/Logs/goosed-native-*.log`;
let loadLine = '';
let logFile = '';
try {
loadLine = execSync(
`grep -H "Session loaded.*session_id: ${sessionId}" ${logGlob} 2>/dev/null | tail -1 || true`,
{ encoding: 'utf8' },
).trim();
} catch {
loadLine = '';
}
if (loadLine.includes(':')) {
logFile = loadLine.split(':')[0];
}
let raw = '';
if (logFile) {
try {
raw = execSync(
`awk '/Session loaded.*session_id: ${sessionId}/{found=1; next} found && /Session loaded/{exit} found' "${logFile}" | grep "tool_name:" || true`,
{ encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 },
);
} catch {
raw = '';
}
}
const lines = raw.split('\n').filter(Boolean);
const tools = [...new Set(lines.map((l) => l.match(/tool_name: ([^,]+)/)?.[1]).filter(Boolean))];
let sandboxFsFailed = false;
if (logFile) {
try {
const failRaw = execSync(
`awk '/Session loaded.*session_id: ${sessionId}/{found=1} found && /Failed to load extension sandbox-fs/{print; exit}' "${logFile}" || true`,
{ encoding: 'utf8' },
);
sandboxFsFailed = failRaw.trim().length > 0;
} catch {
sandboxFsFailed = false;
}
}
return { tools, sandboxFsFailed, lines: lines.slice(-20), logFile: logFile || null };
}
function parseArgs(argv) {
let scenarioId = 'survey-page-data';
let listOnly = false;
let runAll = false;
let port = Number(process.env.H5_PORT ?? 8081);
let openid = process.env.WECHAT_SCENARIO_OPENID ?? 'ooil-0VFj68QK1tkHl39uL610et8';
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 === '--list') listOnly = true;
else if (arg === '--all') runAll = true;
else if (arg === '--port' && argv[i + 1]) port = Number(argv[++i]);
else if (arg === '--openid' && argv[i + 1]) openid = argv[++i];
else if (arg === '-h' || arg === '--help') {
console.log(`Usage: node scripts/run-wechat-scenario-test.mjs [--scenario id] [--all] [--openid openid] [--list]`);
process.exit(0);
} else throw new Error(`未知参数: ${arg}`);
}
return { scenarioId, listOnly, runAll, port, openid };
}
async function runScenario(scenario, ctx) {
const msgId = `sim_${scenario.id}_${Date.now()}`;
console.log(`\n==> 场景: ${scenario.name} (${scenario.id})`);
console.log(` 消息: ${scenario.message.slice(0, 60)}`);
console.log(` msgId: ${msgId}`);
const post = await postWechatMessage({
baseUrl: ctx.baseUrl,
token: ctx.token,
appId: ctx.appId,
openid: ctx.openid,
ghId: ctx.ghId,
content: scenario.message,
msgId,
});
if (post.status !== 200) {
console.error(`✘ webhook 返回 ${post.status}: ${post.body}`);
return false;
}
console.log(`✔ webhook 已接收 (${post.status})`);
const outcome = await waitForMessageStatus(
ctx.pool,
{ appId: ctx.appId, openid: ctx.openid, msgId },
scenario.timeoutMs,
);
console.log(` 状态: ${outcome.status}, session: ${outcome.agent_session_id ?? '-'}, ${Math.round(outcome.elapsedMs / 1000)}s`);
const reply = String(outcome.assistantReply || outcome.displayText || outcome.agentText || '');
if (reply) console.log(` 回复摘要: ${reply.slice(0, 120).replace(/\s+/g, ' ')}`);
if (outcome.displayText && !outcome.assistantReply) {
console.log(` (用户消息: ${String(outcome.displayText).slice(0, 60)}…)`);
}
const goosed = grepGoosedSessionLogs(outcome.agent_session_id);
if (goosed.sandboxFsFailed) {
console.error('✘ goosed: sandbox-fs 扩展加载失败');
} else if (goosed.tools.some((t) => t.startsWith('sandbox-fs__'))) {
console.log(`✔ goosed 工具: ${goosed.tools.filter((t) => t.startsWith('sandbox-fs__')).join(', ')}`);
} else {
console.error(`✘ goosed 未使用 sandbox-fs 工具 (实际: ${goosed.tools.slice(0, 8).join(', ') || '无'})`);
}
let ok = outcome.status === 'done';
if (outcome.status === 'failed' || outcome.status === 'timeout') ok = false;
for (const pattern of scenario.expect?.forbidReply ?? []) {
if (reply.includes(pattern)) {
console.error(`✘ 回复含禁止文案: ${pattern}`);
ok = false;
}
}
for (const kw of scenario.expect?.replyKeywords ?? []) {
if (!reply.includes(kw)) {
console.error(`✘ 回复缺少关键词: ${kw}`);
ok = false;
}
}
if (scenario.expect?.requireLink && !/https?:\/\/[^\s]+\/MindSpace\//.test(reply)) {
console.error('✘ 回复未含 MindSpace 公网链接');
ok = false;
}
if (scenario.expect?.minReplyChars && reply.length < scenario.expect.minReplyChars) {
console.error(`✘ 回复过短: ${reply.length}`);
ok = false;
}
for (const toolPrefix of scenario.expect?.requireGoosedTools ?? []) {
if (!goosed.tools.some((t) => t.startsWith(toolPrefix))) {
console.error(`✘ goosed 缺少工具前缀: ${toolPrefix}`);
ok = false;
}
}
if (goosed.sandboxFsFailed) ok = false;
console.log(ok ? '✔ 场景通过' : '✘ 场景失败');
return ok;
}
async function main() {
const { scenarioId, listOnly, runAll, port, openid } = parseArgs(process.argv);
if (listOnly) {
for (const s of SCENARIOS) console.log(`${s.id}\t${s.name}`);
return;
}
const token = process.env.H5_WECHAT_MP_TOKEN;
const appId = process.env.H5_WECHAT_MP_APP_ID;
if (!token || !appId) throw new Error('缺少 H5_WECHAT_MP_TOKEN / H5_WECHAT_MP_APP_ID');
const baseUrl = `http://127.0.0.1:${port}`;
const status = await fetch(`${baseUrl}/api/status`);
if (!status.ok) throw new Error(`Portal 未就绪: ${status.status}`);
const pool = await mysql.createConnection({ uri: process.env.DATABASE_URL, connectTimeout: 10000 });
const ctx = { baseUrl, token, appId, openid, ghId: appId, pool };
const selected = runAll
? SCENARIOS
: SCENARIOS.filter((s) => s.id === scenarioId);
if (!selected.length) throw new Error(`未知场景: ${scenarioId}`);
console.log(`Portal: ${baseUrl}, openid: ${openid.slice(0, 10)}`);
let passed = 0;
for (const scenario of selected) {
if (await runScenario(scenario, ctx)) passed += 1;
}
await pool.end();
console.log(`\n=== 汇总: ${passed}/${selected.length} 通过 ===`);
process.exit(passed === selected.length ? 0 : 1);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});