344 lines
15 KiB
JavaScript
344 lines
15 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
import {
|
|
buildPageDataCollectFailureText,
|
|
buildPageDataCollectRepairPrompt,
|
|
collectPageDataDeliveryArtifacts,
|
|
evaluatePageDataFinishGuard,
|
|
evaluatePageDataFinishGuardAsync,
|
|
evaluatePageDataHtmlContent,
|
|
extractRecentPageDataBindTargets,
|
|
extractRecentPageDataHtmlWrites,
|
|
inferPageDataBindAccessMode,
|
|
maybeAutoBindPageDataHtmlPages,
|
|
rewritePageDataDeliveryLinks,
|
|
shouldRetryPageDataCollectReply,
|
|
} from './mindspace-page-data-finish-guard.mjs';
|
|
import { writePageAccessPolicy } from './page-data-policy-store.mjs';
|
|
import { createUserDataSpaceService } from './user-data-space-service.mjs';
|
|
|
|
const 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', {});
|
|
</script></body></html>`;
|
|
|
|
const BAD_SURVEY_HTML = `<!doctype html><html><body><script>
|
|
async function save(data) {
|
|
if (typeof MindSpacePageData !== 'undefined') {
|
|
await MindSpacePageData.createClient({ apiBase: '/api' }).insertRow('diet_survey', data);
|
|
return;
|
|
}
|
|
localStorage.setItem('diet_survey_data', JSON.stringify([data]));
|
|
}
|
|
</script></body></html>`;
|
|
|
|
const LEGACY_API_HTML = `<!doctype html><html><body><script>
|
|
fetch('/api/page-data/daily_todo', { method: 'POST', body: JSON.stringify({ name: 'test' }) });
|
|
</script></body></html>`;
|
|
|
|
test('evaluatePageDataHtmlContent flags missing client script and localStorage fallback', () => {
|
|
const ok = evaluatePageDataHtmlContent(SURVEY_HTML, { relativePath: 'public/diet-survey.html' });
|
|
assert.deepEqual(ok.issues, []);
|
|
|
|
const bad = evaluatePageDataHtmlContent(BAD_SURVEY_HTML, { relativePath: 'public/children-diet-survey.html' });
|
|
assert.ok(bad.issues.includes('missing_page_data_client_script'));
|
|
assert.ok(bad.issues.includes('forbidden_local_storage'));
|
|
|
|
const indexed = evaluatePageDataHtmlContent(
|
|
'<script>sessionStorage.setItem("draft", "x"); indexedDB.open("drafts")</script>',
|
|
{ relativePath: 'public/draft.html' },
|
|
);
|
|
assert.ok(indexed.issues.includes('forbidden_browser_storage'));
|
|
|
|
const legacyOwnerApi = evaluatePageDataHtmlContent(
|
|
`<script>
|
|
const PG_API = '/api/page-data';
|
|
fetch(PG_API + '/query', { method: 'POST' });
|
|
</script>`,
|
|
{ relativePath: 'public/sticky-notes.html' },
|
|
);
|
|
assert.equal(legacyOwnerApi.usesPageDataApi, true);
|
|
assert.ok(legacyOwnerApi.issues.includes('missing_page_data_client_script'));
|
|
assert.ok(legacyOwnerApi.issues.includes('forbidden_legacy_owner_api'));
|
|
});
|
|
|
|
test('evaluatePageDataHtmlContent flags forbidden /api/page-data passthrough', () => {
|
|
const bad = evaluatePageDataHtmlContent(LEGACY_API_HTML, { relativePath: 'public/daily-todo.html' });
|
|
assert.ok(bad.issues.includes('forbidden_legacy_page_data_api'));
|
|
assert.ok(bad.issues.includes('missing_page_data_client_script'));
|
|
});
|
|
|
|
test('finish guard blocks delivery when html uses legacy page-data API', () => {
|
|
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-legacy-'));
|
|
try {
|
|
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
|
fs.writeFileSync(path.join(publishDir, 'public', 'daily-todo.html'), LEGACY_API_HTML, 'utf8');
|
|
const evaluation = evaluatePageDataFinishGuard({
|
|
publishDir,
|
|
agentText: '帮我设计一个调查问卷,要加一个后台',
|
|
messages: [{ content: [{ type: 'toolRequest', toolCall: { value: { name: 'write_file', arguments: { path: 'public/daily-todo.html' } } } }] }],
|
|
});
|
|
assert.equal(evaluation.pageDataIntent, true);
|
|
assert.ok(evaluation.htmlIssues.some((item) => item.issue === 'forbidden_legacy_page_data_api'));
|
|
assert.equal(evaluation.needsRepair, true);
|
|
} finally {
|
|
fs.rmSync(publishDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('inferPageDataBindAccessMode chooses password for admin html', () => {
|
|
assert.equal(
|
|
inferPageDataBindAccessMode('public/diet-survey-admin.html', SURVEY_HTML),
|
|
'password',
|
|
);
|
|
assert.equal(inferPageDataBindAccessMode('public/diet-survey.html', SURVEY_HTML), 'public');
|
|
assert.equal(
|
|
inferPageDataBindAccessMode(
|
|
'public/private-tracker.html',
|
|
'<script>const client = MindSpacePageData.createClient(); client.authenticate(password); client.listRows(\'entries\'); client.insertRow(\'entries\', {});</script>',
|
|
),
|
|
'password',
|
|
);
|
|
});
|
|
|
|
test('evaluatePageDataFinishGuard detects unbound page data html', () => {
|
|
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-'));
|
|
try {
|
|
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
|
fs.writeFileSync(path.join(publishDir, 'public', 'diet-survey.html'), SURVEY_HTML, 'utf8');
|
|
const evaluation = evaluatePageDataFinishGuard({
|
|
publishDir,
|
|
agentText: '帮我设计一个调查问卷,要加一个后台',
|
|
messages: [{ content: [{ type: 'toolRequest', toolCall: { value: { name: 'write_file', arguments: { path: 'public/diet-survey.html' } } } }] }],
|
|
});
|
|
assert.equal(evaluation.pageDataIntent, true);
|
|
assert.equal(evaluation.unboundFiles.length, 1);
|
|
assert.equal(evaluation.needsRepair, true);
|
|
} finally {
|
|
fs.rmSync(publishDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('explicit PG choice enables the delivery gate without relying on intent text', () => {
|
|
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-explicit-pg-'));
|
|
try {
|
|
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
|
const evaluation = evaluatePageDataFinishGuard({
|
|
publishDir,
|
|
agentText: '做一个很好看的介绍页面',
|
|
messages: [{
|
|
role: 'user',
|
|
metadata: { memindRun: { pgRequired: true } },
|
|
content: [{ type: 'text', text: '做一个很好看的介绍页面' }],
|
|
}],
|
|
});
|
|
assert.equal(evaluation.pgRequired, true);
|
|
assert.equal(evaluation.pageDataIntent, true);
|
|
assert.equal(evaluation.needsRepair, true);
|
|
} finally {
|
|
fs.rmSync(publishDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('finish guard blocks localStorage ledger generated from ordinary user language', () => {
|
|
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-ledger-'));
|
|
const text = '帮我做一个家庭记账页面,我每天可以记录日期、收入、支出、分类和备注,日期默认当天。再做一个管理页面,可以查看所有记录和收支汇总。';
|
|
try {
|
|
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
|
fs.writeFileSync(path.join(publishDir, 'public', 'family-ledger.html'), `<!doctype html><script>
|
|
localStorage.setItem('tk_family_ledger', JSON.stringify([]));
|
|
</script>`, 'utf8');
|
|
const evaluation = evaluatePageDataFinishGuard({
|
|
publishDir,
|
|
agentText: text,
|
|
messages: [{ role: 'assistant', content: [{ type: 'toolRequest', toolCall: { value: {
|
|
name: 'write_file',
|
|
arguments: { path: 'public/family-ledger.html' },
|
|
} } }] }],
|
|
});
|
|
assert.equal(evaluation.pageDataIntent, true);
|
|
assert.equal(evaluation.needsRepair, true);
|
|
assert.equal(
|
|
evaluation.htmlIssues.some((item) => (
|
|
item.issue === 'forbidden_local_storage' && item.relativePath === 'public/family-ledger.html'
|
|
)),
|
|
true,
|
|
);
|
|
} finally {
|
|
fs.rmSync(publishDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('finish guard ignores unrelated historical broken Page Data html', async () => {
|
|
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-history-'));
|
|
try {
|
|
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
|
fs.writeFileSync(path.join(publishDir, 'public', 'historical-admin.html'), BAD_SURVEY_HTML, 'utf8');
|
|
fs.writeFileSync(path.join(publishDir, 'public', 'current-survey.html'), SURVEY_HTML, 'utf8');
|
|
const dataSpace = createUserDataSpaceService({ workspaceRoot: publishDir });
|
|
await dataSpace.executeSql('CREATE TABLE diet_survey (id INTEGER PRIMARY KEY AUTOINCREMENT);');
|
|
await dataSpace.upsertDataset({
|
|
name: 'diet_survey',
|
|
table: 'diet_survey',
|
|
actions: ['read', 'insert'],
|
|
columns: { read: ['id'], insert: [] },
|
|
});
|
|
writePageAccessPolicy(publishDir, {
|
|
pageId: 'current-page',
|
|
ownerUserId: 'user-1',
|
|
accessMode: 'public',
|
|
datasets: { diet_survey: { insert: true, read: false, columns: { insert: [] } } },
|
|
});
|
|
const evaluation = evaluatePageDataFinishGuard({
|
|
publishDir,
|
|
agentText: '调查问卷和后台',
|
|
messages: [{ content: [{ type: 'toolRequest', toolCall: { value: {
|
|
name: 'sandbox-fs__write_file',
|
|
arguments: { path: 'public/current-survey.html' },
|
|
} } }] }],
|
|
});
|
|
assert.deepEqual(evaluation.relevantFiles.map((file) => file.relativePath), ['public/current-survey.html']);
|
|
assert.deepEqual(evaluation.htmlIssues, []);
|
|
assert.deepEqual(evaluation.unboundFiles, []);
|
|
} finally {
|
|
fs.rmSync(publishDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('recent Page Data target extraction supports namespaced writes and bind-only delivery', () => {
|
|
const messages = [{ content: [
|
|
{ type: 'toolRequest', toolCall: { value: { name: 'sandbox-fs__write_file', arguments: { path: 'public/form.html' } } } },
|
|
{ type: 'toolRequest', toolCall: { value: { name: 'sandbox-fs__private_data_bind_workspace_page', arguments: { relativePath: 'public/form-admin.html' } } } },
|
|
] }];
|
|
assert.deepEqual(extractRecentPageDataHtmlWrites(messages), ['public/form.html']);
|
|
assert.deepEqual(extractRecentPageDataBindTargets(messages), ['public/form-admin.html']);
|
|
});
|
|
|
|
test('shouldRetryPageDataCollectReply retries when localStorage fallback exists', () => {
|
|
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-retry-'));
|
|
try {
|
|
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
|
fs.writeFileSync(path.join(publishDir, 'public', 'children-diet-survey.html'), BAD_SURVEY_HTML, 'utf8');
|
|
assert.equal(
|
|
shouldRetryPageDataCollectReply({
|
|
reply: { text: '问卷已发布', messages: [{ content: [{ type: 'toolRequest', toolCall: { value: { name: 'write_file', arguments: { path: 'public/children-diet-survey.html' } } } }] }] },
|
|
intent: { agentText: '帮我设计一个调查问卷,要加一个后台' },
|
|
publishDir,
|
|
}),
|
|
true,
|
|
);
|
|
} finally {
|
|
fs.rmSync(publishDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('buildPageDataCollectRepairPrompt mentions bind and forbids localStorage', () => {
|
|
const prompt = buildPageDataCollectRepairPrompt({
|
|
htmlIssues: [{ relativePath: 'public/x.html', issue: 'forbidden_local_storage' }],
|
|
unboundFiles: [{ relativePath: 'public/x.html' }],
|
|
});
|
|
assert.match(prompt, /private_data_bind_workspace_page/);
|
|
assert.match(prompt, /localStorage/);
|
|
assert.match(buildPageDataCollectFailureText(), /page-data-collect/);
|
|
});
|
|
|
|
test('maybeAutoBindPageDataHtmlPages skips invalid html', async () => {
|
|
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-bind-'));
|
|
try {
|
|
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
|
fs.writeFileSync(path.join(publishDir, 'public', 'bad.html'), BAD_SURVEY_HTML, 'utf8');
|
|
const result = await maybeAutoBindPageDataHtmlPages({
|
|
pool: null,
|
|
userId: 'user-1',
|
|
publishDir,
|
|
h5Root: publishDir,
|
|
storageRoot: publishDir,
|
|
});
|
|
assert.equal(result.bound.length, 0);
|
|
assert.equal(result.errors[0]?.code, 'missing_context');
|
|
} finally {
|
|
fs.rmSync(publishDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('evaluatePageDataFinishGuard flags html when dataset is not registered', async () => {
|
|
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-bound-'));
|
|
try {
|
|
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
|
fs.writeFileSync(path.join(publishDir, 'public', 'diet-survey.html'), SURVEY_HTML, 'utf8');
|
|
writePageAccessPolicy(publishDir, {
|
|
pageId: 'page-1',
|
|
ownerUserId: 'user-1',
|
|
accessMode: 'public',
|
|
datasets: {
|
|
diet_survey: {
|
|
insert: true,
|
|
read: false,
|
|
columns: { insert: ['child_age'] },
|
|
},
|
|
},
|
|
});
|
|
const evaluation = await evaluatePageDataFinishGuardAsync({
|
|
publishDir,
|
|
userId: 'user-1',
|
|
agentText: '调查问卷和后台',
|
|
messages: [{ content: [{ type: 'toolRequest', toolCall: { value: { name: 'write_file', arguments: { path: 'public/diet-survey.html' } } } }] }],
|
|
});
|
|
assert.equal(evaluation.unboundFiles.length, 1);
|
|
assert.equal(evaluation.htmlIssues.length, 0);
|
|
} finally {
|
|
fs.rmSync(publishDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('collectPageDataDeliveryArtifacts uses Portal base when H5_PUBLIC_BASE_URL points at Vite', () => {
|
|
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-portal-url-'));
|
|
const previousBase = process.env.H5_PUBLIC_BASE_URL;
|
|
const previousDelivery = process.env.MEMIND_PAGE_DATA_DELIVERY_BASE_URL;
|
|
delete process.env.MEMIND_PAGE_DATA_DELIVERY_BASE_URL;
|
|
process.env.H5_PUBLIC_BASE_URL = 'http://127.0.0.1:5173';
|
|
process.env.H5_PORT = '8081';
|
|
try {
|
|
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
|
fs.writeFileSync(path.join(publishDir, 'public', 'form.html'), SURVEY_HTML, 'utf8');
|
|
const artifacts = collectPageDataDeliveryArtifacts(publishDir);
|
|
assert.equal(artifacts.length, 1);
|
|
assert.match(artifacts[0].url, /^http:\/\/127\.0\.0\.1:8081\/MindSpace\//);
|
|
} finally {
|
|
if (previousBase == null) delete process.env.H5_PUBLIC_BASE_URL;
|
|
else process.env.H5_PUBLIC_BASE_URL = previousBase;
|
|
if (previousDelivery == null) delete process.env.MEMIND_PAGE_DATA_DELIVERY_BASE_URL;
|
|
else process.env.MEMIND_PAGE_DATA_DELIVERY_BASE_URL = previousDelivery;
|
|
fs.rmSync(publishDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('rewritePageDataDeliveryLinks rewrites publication route urls to MindSpace workspace urls', () => {
|
|
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-guard-urls-'));
|
|
const previousBase = process.env.H5_PUBLIC_BASE_URL;
|
|
process.env.H5_PUBLIC_BASE_URL = 'https://m.tkmind.cn';
|
|
try {
|
|
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
|
fs.writeFileSync(path.join(publishDir, 'public', 'zhiqu-survey.html'), SURVEY_HTML, 'utf8');
|
|
fs.writeFileSync(path.join(publishDir, 'public', 'zhiqu-survey-admin.html'), SURVEY_HTML, 'utf8');
|
|
const artifacts = collectPageDataDeliveryArtifacts(publishDir);
|
|
assert.equal(artifacts.length, 2);
|
|
const text = [
|
|
'问卷:https://m.tkmind.cn/u/john/pages/zhiqu-58bccceb',
|
|
'后台:https://m.tkmind.cn/u/john/pages/zhiqu-admin-98f11681',
|
|
].join('\n');
|
|
const next = rewritePageDataDeliveryLinks(text, artifacts);
|
|
assert.doesNotMatch(next, /\/u\/john\/pages\//);
|
|
assert.match(next, /\/MindSpace\/.+\/public\/zhiqu-survey\.html/);
|
|
assert.match(next, /\/MindSpace\/.+\/public\/zhiqu-survey-admin\.html/);
|
|
} finally {
|
|
if (previousBase == null) delete process.env.H5_PUBLIC_BASE_URL;
|
|
else process.env.H5_PUBLIC_BASE_URL = previousBase;
|
|
fs.rmSync(publishDir, { recursive: true, force: true });
|
|
}
|
|
});
|