Files
memind/mindspace-page-data-finish-guard.integration.test.mjs

353 lines
13 KiB
JavaScript

/**
* 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 = `<!doctype html><html><head><title>儿童饮食问卷</title></head><body>
<script src="/assets/page-data-client.js"></script>
<script>
MindSpacePageData.createClient({ apiBase: '/api' }).insertRow('diet_survey', {
child_age: '6-8',
veggie_habit: '一般',
snack_type: '水果',
});
</script></body></html>`;
const BAD_SURVEY_HTML = `<!doctype html><html><head><title>儿童饮食问卷</title></head><body>
<script>
async function save(data) {
try {
if (typeof MindSpacePageData !== 'undefined') {
await MindSpacePageData.createClient({ apiBase: '/api' }).insertRow('diet_survey', data);
return;
}
} catch (e) {}
localStorage.setItem('diet_survey_data', JSON.stringify([data]));
}
</script></body></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 [
'<xml>',
'<ToUserName><![CDATA[gh_test]]></ToUserName>',
'<FromUserName><![CDATA[openid-page-data]]></FromUserName>',
'<CreateTime>1710000000</CreateTime>',
'<MsgType><![CDATA[text]]></MsgType>',
`<Content><![CDATA[${content}]]></Content>`,
'<MsgId>10001</MsgId>',
'</xml>',
].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 });
}
});