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 { buildH5HtmlRepairPrompt, collectMissingPublicAssetReferences, collectMissingPublicHtmlPaths, evaluateH5HtmlFinishGuard, isH5HtmlFinishGuardEnabled, maybeRepairH5HtmlAfterFinish, resetH5HtmlFinishGuardAttempts, } from './mindspace-h5-html-finish-guard.mjs'; const USER = { id: 'user-1', username: 'john' }; test('collectMissingPublicAssetReferences finds missing svg referenced by html', () => { const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'h5-html-guard-')); try { fs.mkdirSync(path.join(publishDir, 'public', 'assets'), { recursive: true }); fs.writeFileSync( path.join(publishDir, 'public', 'kids-posture-business.html'), 'hero', 'utf8', ); const missing = collectMissingPublicAssetReferences(publishDir); assert.deepEqual(missing, [{ relativePath: 'public/assets/kids-posture-hero.svg', htmlRelativePath: 'public/kids-posture-business.html', }]); } finally { fs.rmSync(publishDir, { recursive: true, force: true }); } }); test('collectMissingPublicHtmlPaths detects assistant link without disk file', () => { const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'h5-html-guard-')); try { const messages = [{ role: 'assistant', content: [{ type: 'text', text: '[页面](https://m.tkmind.cn/MindSpace/user-1/public/missing-page.html) 已生成', }], }]; const missing = collectMissingPublicHtmlPaths(messages, USER, publishDir); assert.deepEqual(missing, ['public/missing-page.html']); } finally { fs.rmSync(publishDir, { recursive: true, force: true }); } }); test('evaluateH5HtmlFinishGuard flags missing assets after html exists', () => { const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'h5-html-guard-')); try { fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true }); fs.writeFileSync( path.join(publishDir, 'public', 'page.html'), '', 'utf8', ); const evaluation = evaluateH5HtmlFinishGuard({ messages: [], currentUser: USER, publishDir, }); assert.equal(evaluation.needsRepair, true); assert.equal(evaluation.missingAssets.length, 1); } finally { fs.rmSync(publishDir, { recursive: true, force: true }); } }); test('evaluateH5HtmlFinishGuard blocks browser storage in any touched public page', () => { const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'h5-html-storage-ban-')); try { fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true }); fs.writeFileSync( path.join(publishDir, 'public', 'ledger.html'), '', 'utf8', ); const evaluation = evaluateH5HtmlFinishGuard({ messages: [{ role: 'assistant', content: [{ type: 'toolRequest', toolCall: { value: { name: 'write_file', arguments: { path: 'public/ledger.html' }, } } }] }], currentUser: USER, publishDir, }); assert.equal(evaluation.needsRepair, true); assert.deepEqual(evaluation.browserStorageViolations, [{ relativePath: 'public/ledger.html', apis: ['sessionStorage', 'IndexedDB'], }]); assert.match(buildH5HtmlRepairPrompt(evaluation), /当前用户专属 PostgreSQL schema/); } finally { fs.rmSync(publishDir, { recursive: true, force: true }); } }); test('evaluateH5HtmlFinishGuard blocks computed localStorage obfuscation', () => { const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'h5-html-storage-obfuscation-')); try { fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true }); fs.writeFileSync( path.join(publishDir, 'public', 'bypass.html'), ``, 'utf8', ); const evaluation = evaluateH5HtmlFinishGuard({ messages: [{ role: 'assistant', content: [{ type: 'toolRequest', toolCall: { value: { name: 'write_file', arguments: { path: 'public/bypass.html' }, } } }] }], currentUser: USER, publishDir, }); assert.equal(evaluation.needsRepair, true); assert.deepEqual(evaluation.browserStorageViolations, [{ relativePath: 'public/bypass.html', apis: ['localStorage'], }]); } finally { fs.rmSync(publishDir, { recursive: true, force: true }); } }); test('buildH5HtmlRepairPrompt lists html and asset targets', () => { const prompt = buildH5HtmlRepairPrompt({ missingHtml: ['public/page.html'], missingAssets: [{ relativePath: 'public/assets/icon.svg', htmlRelativePath: 'public/page.html' }], }); assert.match(prompt, /public\/page\.html/); assert.match(prompt, /public\/assets\/icon\.svg/); assert.match(prompt, /write_file/); }); test('maybeRepairH5HtmlAfterFinish triggers proxy reply when enabled', async () => { const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'h5-html-guard-')); const previous = process.env.MEMIND_H5_HTML_FINISH_GUARD; process.env.MEMIND_H5_HTML_FINISH_GUARD = '1'; resetH5HtmlFinishGuardAttempts('session-1'); const calls = []; try { fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true }); fs.writeFileSync( path.join(publishDir, 'public', 'page.html'), '', 'utf8', ); const result = await maybeRepairH5HtmlAfterFinish({ sessionId: 'session-1', userId: 'user-1', currentUser: USER, messages: [], publishDir, tkmindProxy: { async submitSessionReplyForUser(userId, sessionId, requestId, userMessage) { calls.push({ userId, sessionId, requestId, userMessage }); }, }, }); assert.equal(result.repaired, true); assert.equal(calls.length, 1); assert.match(calls[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(calls[0].userMessage.content[0].text, /icon\.svg/); } finally { if (previous == null) delete process.env.MEMIND_H5_HTML_FINISH_GUARD; else process.env.MEMIND_H5_HTML_FINISH_GUARD = previous; resetH5HtmlFinishGuardAttempts('session-1'); fs.rmSync(publishDir, { recursive: true, force: true }); } }); test('maybeRepairH5HtmlAfterFinish stays off when flag disabled', async () => { const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'h5-html-guard-')); const previous = process.env.MEMIND_H5_HTML_FINISH_GUARD; delete process.env.MEMIND_H5_HTML_FINISH_GUARD; try { fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true }); fs.writeFileSync( path.join(publishDir, 'public', 'page.html'), '', 'utf8', ); const result = await maybeRepairH5HtmlAfterFinish({ sessionId: 'session-2', userId: 'user-1', currentUser: USER, messages: [], publishDir, tkmindProxy: { async submitSessionReplyForUser() { throw new Error('should not call proxy when disabled'); }, }, }); assert.equal(result.skipped, 'disabled'); assert.equal(isH5HtmlFinishGuardEnabled(), false); } finally { if (previous == null) delete process.env.MEMIND_H5_HTML_FINISH_GUARD; else process.env.MEMIND_H5_HTML_FINISH_GUARD = previous; fs.rmSync(publishDir, { recursive: true, force: true }); } });