/** * Page Data 问卷交付闸门集成测试:微信通道 + Finish guard 与本地/生产对齐。 */ import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { createWechatMpService } from './wechat-mp.mjs'; import { buildWechatAgentPrompt } from './wechat/prompts/chat-general.mjs'; import { evaluatePageDataFinishGuard, maybeAutoBindPageDataHtmlPages, maybeRepairPageDataAfterFinish, } from './mindspace-page-data-finish-guard.mjs'; import { createUserDataSpaceService } from './user-data-space-service.mjs'; import { writePageAccessPolicy } from './page-data-policy-store.mjs'; const SURVEY_USER_TEXT = '帮我设计一个调查问卷,关于儿童饮食偏好方面,做三个问题吧,要加一个后台'; const VALID_SURVEY_HTML = `儿童饮食问卷 `; const BAD_SURVEY_HTML = `儿童饮食问卷 `; function signatureFor(token, timestamp, nonce) { return crypto.createHash('sha1').update([token, timestamp, nonce].sort().join('')).digest('hex'); } function inboundXml({ content = SURVEY_USER_TEXT } = {}) { return [ '', '', '', '1710000000', '', ``, '10001', '', ].join(''); } function jsonEscapeHtml(html) { return String(html).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); } function createWechatUserAuth(workspaceRoot) { return { async findWechatUserByOpenid() { return { userId: 'user-page-data', status: 'active', nickname: '唐' }; }, async getWechatAgentRoute() { return { agentSessionId: 'session-page-data-1' }; }, async clearWechatAgentRoute() {}, async canUseChat() { return { ok: true }; }, async resolveWorkingDir() { return workspaceRoot; }, async getAgentSessionPolicy() { return { enableContextMemory: false, extensionOverrides: [], unrestricted: true }; }, async getUserPublishLayout() { return { publishDir: workspaceRoot, displayName: '唐', username: 'wx_test', slug: 'wx_test', constraints: null, }; }, async billSessionUsage() {}, async recordWechatMpMessage() { return { inserted: true }; }, async finishWechatMpMessage() {}, async insertWechatMpMessageDetail() {}, async upsertWechatAgentRoute() {}, async registerAgentSession() {}, }; } function createWechatFetchRecorder(wechatCalls) { return async (url, init = {}) => { wechatCalls.push([url, init.method ?? 'GET', init.body ?? null]); if (String(url).includes('/cgi-bin/stable_token')) { return new Response(JSON.stringify({ access_token: 'access-1', expires_in: 7200 }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } if (String(url).includes('/cgi-bin/message/custom/send')) { return new Response(JSON.stringify({ errcode: 0, errmsg: 'ok' }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); } throw new Error(`unexpected wechat url: ${url}`); }; } async function setupSurveyWorkspace(workspaceRoot) { fs.mkdirSync(path.join(workspaceRoot, 'public'), { recursive: true }); const dataSpace = createUserDataSpaceService({ workspaceRoot }); await dataSpace.executeSql(`CREATE TABLE diet_survey ( id INTEGER PRIMARY KEY AUTOINCREMENT, child_age TEXT NOT NULL DEFAULT '', veggie_habit TEXT NOT NULL DEFAULT '', snack_type TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT '' );`); await dataSpace.upsertDataset({ name: 'diet_survey', table: 'diet_survey', description: '儿童饮食偏好调查问卷数据', actions: ['read', 'insert'], columns: { read: ['id', 'child_age', 'veggie_habit', 'snack_type', 'created_at'], insert: ['child_age', 'veggie_habit', 'snack_type'], }, }); } test('buildWechatAgentPrompt injects page-data-collect requirements for survey requests', () => { const prompt = buildWechatAgentPrompt( { msgType: 'text', agentText: SURVEY_USER_TEXT }, { grantedSkills: ['page-data-collect', 'static-page-publish'] }, ); assert.match(prompt, /page-data-collect/); assert.match(prompt, /禁止 localStorage/); assert.match(prompt, /private_data_bind_workspace_page/); }); test('integration: wechat mp blocks localStorage survey delivery with page-data failure notice', async () => { const token = 'token'; const timestamp = '1710000000'; const nonce = 'nonce'; const wechatCalls = []; const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-page-data-block-')); const htmlPath = path.join(workspaceRoot, 'public', 'children-diet-survey.html'); fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); fs.writeFileSync(htmlPath, BAD_SURVEY_HTML, 'utf8'); const service = createWechatMpService({ config: { enabled: true, appId: 'wx123', appSecret: 'secret', token, publicBaseUrl: 'https://m.tkmind.cn', bindPath: '/auth/wechat/authorize?intent=login', ackText: 'ack', unsupportedText: 'unsupported', unboundTextPrefix: '请先绑定', progressDelayMs: 0, }, userAuth: createWechatUserAuth(workspaceRoot), pageDataFinishGuard: null, sessionApiFetch: async (sessionId, pathname) => { if (pathname === `/sessions/${sessionId}/events`) { return new Response( [ `data: {"type":"Message","request_id":"req-page-data-bad","message":{"id":"assistant-tool","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"toolRequest","toolCall":{"value":{"name":"sandbox-fs__write_file","arguments":{"path":"public/children-diet-survey.html","content":"${jsonEscapeHtml(BAD_SURVEY_HTML)}"}}}}]}}\n\n`, 'data: {"type":"Message","request_id":"req-page-data-bad","message":{"id":"assistant-1","role":"assistant","metadata":{"userVisible":true},"content":[{"type":"text","text":"问卷已发布:https://m.tkmind.cn/MindSpace/user-page-data/public/children-diet-survey.html"}]}}\n\n', 'data: {"type":"Finish","request_id":"req-page-data-bad","token_state":{"inputTokens":1,"outputTokens":1}}\n\n', ].join(''), { status: 200, headers: { 'Content-Type': 'text/event-stream' } }, ); } if (pathname === `/sessions/${sessionId}/reply`) { return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); } if (pathname === '/agent/harness_remember' || pathname === '/agent/harness_bootstrap') { return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } }); } throw new Error(`unexpected session api path: ${sessionId} ${pathname}`); }, wechatFetch: createWechatFetchRecorder(wechatCalls), }); const originalRandomUuid = crypto.randomUUID; crypto.randomUUID = () => 'req-page-data-bad'; try { const result = await service.handleInboundMessage(inboundXml(), { timestamp, nonce, signature: signatureFor(token, timestamp, nonce), }); assert.equal(result.status, 200); await result.task; assert.ok(fs.existsSync(htmlPath), 'bad survey html should remain on disk for inspection'); const sendCall = wechatCalls.find(([url]) => String(url).includes('/cgi-bin/message/custom/send')); assert.ok(sendCall, 'wechat should send a customer service message'); const payload = JSON.parse(sendCall[2]); assert.match(payload.text.content, /page-data-collect|Page Data API|localStorage/i); assert.doesNotMatch( payload.text.content, /https:\/\/m\.tkmind\.cn\/MindSpace\/user-page-data\/public\/children-diet-survey\.html/, ); } finally { crypto.randomUUID = originalRandomUuid; fs.rmSync(workspaceRoot, { recursive: true, force: true }); } }); test('integration: finish guard auto-bind clears unbound state for valid survey html', async () => { const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-autobind-')); try { await setupSurveyWorkspace(workspaceRoot); fs.writeFileSync(path.join(workspaceRoot, 'public', 'diet-survey.html'), VALID_SURVEY_HTML, 'utf8'); const before = evaluatePageDataFinishGuard({ publishDir: workspaceRoot, agentText: SURVEY_USER_TEXT, messages: [{ createdAt: Date.now(), content: [{ type: 'toolRequest', toolCall: { value: { name: 'write_file', arguments: { path: 'public/diet-survey.html' } } }, }], }], }); assert.equal(before.unboundFiles.length, 1); const autoBind = await maybeAutoBindPageDataHtmlPages({ pool: null, userId: 'user-page-data', publishDir: workspaceRoot, h5Root: workspaceRoot, storageRoot: workspaceRoot, }); assert.equal(autoBind.bound.length, 0); assert.equal(autoBind.errors[0]?.code, 'missing_context'); writePageAccessPolicy(workspaceRoot, { pageId: 'page-diet-survey', ownerUserId: 'user-page-data', accessMode: 'public', datasets: { diet_survey: { insert: true, read: false, columns: { insert: ['child_age', 'veggie_habit', 'snack_type'] }, }, }, }); const after = evaluatePageDataFinishGuard({ publishDir: workspaceRoot, agentText: SURVEY_USER_TEXT, messages: [{ createdAt: Date.now(), content: [{ type: 'toolRequest', toolCall: { value: { name: 'write_file', arguments: { path: 'public/diet-survey.html' } } }, }], }], }); assert.equal(after.unboundFiles.length, 0); assert.equal(after.htmlIssues.length, 0); assert.equal(after.needsRepair, false); } finally { fs.rmSync(workspaceRoot, { recursive: true, force: true }); } }); test('integration: H5 finish guard triggers repair prompt for invalid survey html', async () => { const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-h5-repair-')); const submitCalls = []; try { await setupSurveyWorkspace(workspaceRoot); fs.writeFileSync(path.join(workspaceRoot, 'public', 'children-diet-survey.html'), BAD_SURVEY_HTML, 'utf8'); const result = await maybeRepairPageDataAfterFinish({ sessionId: 'session-h5-page-data', userId: 'user-page-data', publishDir: workspaceRoot, messages: [{ createdAt: Date.now(), content: [{ type: 'toolRequest', toolCall: { value: { name: 'write_file', arguments: { path: 'public/children-diet-survey.html' } } }, }], }], pool: null, h5Root: workspaceRoot, storageRoot: workspaceRoot, userText: SURVEY_USER_TEXT, tkmindProxy: { async submitSessionReplyForUser(userId, sessionId, requestId, userMessage) { submitCalls.push({ userId, sessionId, requestId, userMessage }); }, }, }); assert.equal(result.repaired, true); assert.equal(result.triggered, true); assert.equal(submitCalls.length, 1); assert.match(submitCalls[0].requestId, /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); assert.match(submitCalls[0].userMessage.content[0].text, /localStorage/); assert.match(submitCalls[0].userMessage.content[0].text, /private_data_bind_workspace_page/); } finally { fs.rmSync(workspaceRoot, { recursive: true, force: true }); } }); test('integration: finish guard does not repair historical Page Data html without a current write', async () => { const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-historical-skip-')); try { await setupSurveyWorkspace(workspaceRoot); fs.writeFileSync(path.join(workspaceRoot, 'public', 'children-diet-survey.html'), VALID_SURVEY_HTML, 'utf8'); const result = await maybeRepairPageDataAfterFinish({ sessionId: 'session-with-history', userId: 'user-page-data', publishDir: workspaceRoot, messages: [], pool: null, h5Root: workspaceRoot, storageRoot: workspaceRoot, userText: '确认发布', tkmindProxy: { async submitSessionReplyForUser() { throw new Error('must not repair history'); } }, }); assert.equal(result.structuralPageData, false); assert.equal(result.relevantFiles.length, 0); assert.equal(result.needsRepair, false); assert.equal(result.triggered, undefined); } finally { fs.rmSync(workspaceRoot, { recursive: true, force: true }); } });