From 7e26b459bc817c78a2db910bc057bcdccbee2a3b Mon Sep 17 00:00:00 2001 From: john Date: Sun, 2 Aug 2026 20:32:03 +0800 Subject: [PATCH] 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 --- mindspace-page-data-finish-guard.mjs | 12 +- scripts/run-wechat-scenario-test.mjs | 349 +++++++++++++++++++++++++++ 2 files changed, 357 insertions(+), 4 deletions(-) create mode 100644 scripts/run-wechat-scenario-test.mjs diff --git a/mindspace-page-data-finish-guard.mjs b/mindspace-page-data-finish-guard.mjs index ec74f76..964940e 100644 --- a/mindspace-page-data-finish-guard.mjs +++ b/mindspace-page-data-finish-guard.mjs @@ -114,10 +114,14 @@ export function buildPageDataDeliveryArtifactsFromBindResult(autoBind, publishDi .filter((item) => item?.relativePath && item?.workspaceUrl) .map((item) => [item.relativePath, item.workspaceUrl]), ); - return collectPageDataDeliveryArtifacts(publishDir, options).map((artifact) => ({ - ...artifact, - url: boundUrls.get(artifact.relativePath) ?? artifact.url, - })); + // Only verify/deliver pages bound in this auto-bind pass. Scanning the whole + // workspace lets one stale historical Page Data page fail delivery for a new survey. + return collectPageDataDeliveryArtifacts(publishDir, options) + .filter((artifact) => boundUrls.has(artifact.relativePath)) + .map((artifact) => ({ + ...artifact, + url: boundUrls.get(artifact.relativePath) ?? artifact.url, + })); } export function evaluatePageDataHtmlContent(html, { relativePath = '' } = {}) { diff --git a/scripts/run-wechat-scenario-test.mjs b/scripts/run-wechat-scenario-test.mjs new file mode 100644 index 0000000..09bd6c8 --- /dev/null +++ b/scripts/run-wechat-scenario-test.mjs @@ -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 [ + '', + ``, + ``, + '1710000000', + '', + ``, + `${msgId}`, + '', + ].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); +});