fix(page-data): scope WeChat delivery verification to newly bound pages
Memind CI / Test, build, and release guards (push) Failing after 4s
Memind CI / Test, build, and release guards (push) Failing after 4s
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>
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
#!/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}×tamp=${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);
|
||||
});
|
||||
Reference in New Issue
Block a user