353 lines
13 KiB
JavaScript
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 });
|
|
}
|
|
});
|