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 = `问卷 `; const BAD_SURVEY_HTML = ``; const LEGACY_API_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( '', { relativePath: 'public/draft.html' }, ); assert.ok(indexed.issues.includes('forbidden_browser_storage')); const legacyOwnerApi = evaluatePageDataHtmlContent( ``, { 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', '', ), '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'), ``, '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 }); } });