fix(page-data): deliver MindSpace workspace URLs instead of /u/.../pages/ routes.
Page Data questionnaires were returning publication slugs to users; password-mode pages always fell back to /u/ routes. Prefer MindSpace paths at publish time and rewrite mistaken delivery links in WeChat and Portal chat. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* 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: [],
|
||||
});
|
||||
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, 'database_unconfigured');
|
||||
|
||||
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: [],
|
||||
});
|
||||
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: [],
|
||||
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].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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user