1400 lines
39 KiB
JavaScript
1400 lines
39 KiB
JavaScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import fs from 'node:fs';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
|
||
import {
|
||
hasRecentOwnPublicHtmlReference,
|
||
hasPublicHtmlWriteRequestInSessionEvent,
|
||
isStubPublicHtmlContent,
|
||
collectOwnPublicHtmlArtifactRefs,
|
||
collectOwnPublicHtmlRelativePaths,
|
||
materializeMissingPublicHtmlWrites,
|
||
materializePublicHtmlWritesFromSessionEvent,
|
||
syncPublicDocxDownloads,
|
||
syncPublicHtmlAfterFinish,
|
||
} from './mindspace-public-finish-sync.mjs';
|
||
import { createMindSpacePublicFinishService } from './mindspace-public-finish-service.mjs';
|
||
|
||
const CURRENT_USER = { id: 'a6fb1e97-2b0f-447b-b138-4561d8e5c53e', username: 'john' };
|
||
|
||
test('detects a recent own public html link', () => {
|
||
const messages = [
|
||
{
|
||
role: 'assistant',
|
||
content: [
|
||
{
|
||
type: 'text',
|
||
text: '[Hello](https://m.tkmind.cn/MindSpace/a6fb1e97-2b0f-447b-b138-4561d8e5c53e/public/hello.html)',
|
||
},
|
||
],
|
||
metadata: { userVisible: true },
|
||
},
|
||
];
|
||
assert.equal(hasRecentOwnPublicHtmlReference(messages, CURRENT_USER), true);
|
||
});
|
||
|
||
test('detects a recent public html tool write hint without a full url', () => {
|
||
const messages = [
|
||
{
|
||
role: 'user',
|
||
content: [
|
||
{
|
||
type: 'toolResponse',
|
||
toolResult: {
|
||
value: {
|
||
content: [{ type: 'text', text: '已写入 public/guizhou-guide.html(862 行)' }],
|
||
},
|
||
},
|
||
},
|
||
],
|
||
metadata: { userVisible: true, displayText: '已写入 public/guizhou-guide.html(862 行)' },
|
||
},
|
||
];
|
||
assert.equal(hasRecentOwnPublicHtmlReference(messages, CURRENT_USER), true);
|
||
});
|
||
|
||
test('detects a public html tool request even when the message has no text', () => {
|
||
const messages = [
|
||
{
|
||
role: 'assistant',
|
||
content: [
|
||
{
|
||
type: 'toolRequest',
|
||
toolCall: {
|
||
value: {
|
||
name: 'sandbox-fs__write_file',
|
||
arguments: {
|
||
path: 'public/winter-melon-soup-recipe.html',
|
||
content: '<!doctype html><title>Winter melon soup</title>',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
],
|
||
metadata: { userVisible: true },
|
||
},
|
||
];
|
||
assert.equal(hasRecentOwnPublicHtmlReference(messages, CURRENT_USER), true);
|
||
});
|
||
|
||
test('stream event prefilter detects public HTML writes without filesystem access', () => {
|
||
assert.equal(
|
||
hasPublicHtmlWriteRequestInSessionEvent({
|
||
type: 'Message',
|
||
message: {
|
||
role: 'assistant',
|
||
content: [
|
||
{
|
||
type: 'toolRequest',
|
||
toolCall: {
|
||
value: {
|
||
name: 'edit_file',
|
||
arguments: {
|
||
path: 'public/page.html',
|
||
old_str: 'before',
|
||
new_str: 'after',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
],
|
||
},
|
||
}),
|
||
true,
|
||
);
|
||
assert.equal(
|
||
hasPublicHtmlWriteRequestInSessionEvent({
|
||
type: 'Message',
|
||
message: {
|
||
role: 'assistant',
|
||
content: [{ type: 'text', text: 'done' }],
|
||
},
|
||
}),
|
||
false,
|
||
);
|
||
});
|
||
|
||
test('detects public html references beyond the old eight message window', () => {
|
||
const messages = [
|
||
{
|
||
role: 'assistant',
|
||
content: [
|
||
{
|
||
type: 'toolRequest',
|
||
toolCall: {
|
||
value: {
|
||
name: 'sandbox-fs__write_file',
|
||
arguments: {
|
||
path: 'public/long-flow.html',
|
||
content: '<!doctype html><title>Long flow</title>',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
],
|
||
},
|
||
...Array.from({ length: 12 }, (_, index) => ({
|
||
role: index % 2 === 0 ? 'assistant' : 'user',
|
||
content: [{ type: 'text', text: `validation step ${index + 1}` }],
|
||
})),
|
||
];
|
||
assert.equal(hasRecentOwnPublicHtmlReference(messages, CURRENT_USER), true);
|
||
});
|
||
|
||
test('ignores other users public html links', () => {
|
||
const messages = [
|
||
{
|
||
role: 'assistant',
|
||
content: [
|
||
{
|
||
type: 'text',
|
||
text: '[Hello](https://m.tkmind.cn/MindSpace/other-user/public/hello.html)',
|
||
},
|
||
],
|
||
metadata: { userVisible: true },
|
||
},
|
||
];
|
||
assert.equal(hasRecentOwnPublicHtmlReference(messages, CURRENT_USER), false);
|
||
});
|
||
|
||
test('materializeMissingPublicHtmlWrites writes html from sandbox write_file tool calls', () => {
|
||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-'));
|
||
try {
|
||
const messages = [
|
||
{
|
||
id: 'msg-public-1',
|
||
role: 'assistant',
|
||
content: [
|
||
{
|
||
type: 'toolRequest',
|
||
toolCall: {
|
||
value: {
|
||
name: 'sandbox-fs__write_file',
|
||
arguments: {
|
||
path: 'public/summer-essay.html',
|
||
content: '<!doctype html><title>Summer</title>',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
],
|
||
metadata: { userVisible: true },
|
||
},
|
||
];
|
||
const result = materializeMissingPublicHtmlWrites({ messages, publishDir });
|
||
assert.deepEqual(result.materialized, ['public/summer-essay.html']);
|
||
assert.equal(
|
||
fs.readFileSync(path.join(publishDir, 'public/summer-essay.html'), 'utf8'),
|
||
'<!doctype html><title>Summer</title>',
|
||
);
|
||
} finally {
|
||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('syncPublicHtmlAfterFinish forwards session source to workspace sync', async () => {
|
||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-'));
|
||
try {
|
||
const calls = [];
|
||
const messages = [
|
||
{
|
||
id: 'msg-public-1',
|
||
role: 'assistant',
|
||
content: [
|
||
{
|
||
type: 'toolRequest',
|
||
toolCall: {
|
||
value: {
|
||
name: 'write_file',
|
||
arguments: {
|
||
path: 'public/session-page.html',
|
||
content: '<!doctype html><title>Session page</title>',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
],
|
||
},
|
||
];
|
||
const result = await syncPublicHtmlAfterFinish({
|
||
messages,
|
||
currentUser: CURRENT_USER,
|
||
publishDir,
|
||
sessionId: 'session-1',
|
||
syncWorkspaceAssets: async (...args) => {
|
||
calls.push(args);
|
||
},
|
||
});
|
||
|
||
assert.equal(result.synced, true);
|
||
assert.deepEqual(calls, [[
|
||
CURRENT_USER.id,
|
||
{
|
||
categoryCode: 'public',
|
||
sourceSessionId: 'session-1',
|
||
sourceMessageId: 'msg-public-1',
|
||
onlyRelativePaths: ['public/session-page.html'],
|
||
},
|
||
]]);
|
||
} finally {
|
||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('syncPublicHtmlAfterFinish registers existing public html artifacts for the session', async () => {
|
||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-'));
|
||
try {
|
||
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
||
fs.writeFileSync(path.join(publishDir, 'public/123.html'), '<!doctype html><title>一二三</title>');
|
||
const calls = [];
|
||
const messages = [
|
||
{
|
||
id: 'msg-public-1',
|
||
role: 'assistant',
|
||
content: [
|
||
{
|
||
type: 'text',
|
||
text: `[一二三](https://m.tkmind.cn/MindSpace/${CURRENT_USER.id}/public/123.html)`,
|
||
},
|
||
],
|
||
},
|
||
];
|
||
|
||
const result = await syncPublicHtmlAfterFinish({
|
||
messages,
|
||
currentUser: CURRENT_USER,
|
||
publishDir,
|
||
sessionId: 'session-1',
|
||
syncWorkspaceAssets: async () => {},
|
||
registerPublicHtmlArtifacts: async (...args) => {
|
||
calls.push(args);
|
||
},
|
||
});
|
||
|
||
assert.deepEqual(result.publicHtmlRelativePaths, ['public/123.html']);
|
||
assert.deepEqual(calls, [
|
||
[
|
||
CURRENT_USER.id,
|
||
{
|
||
sessionId: 'session-1',
|
||
relativePaths: ['public/123.html'],
|
||
artifactRefs: [{ relativePath: 'public/123.html', messageId: 'msg-public-1' }],
|
||
},
|
||
],
|
||
]);
|
||
} finally {
|
||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('syncPublicHtmlAfterFinish materializes private image assets into public html files', async () => {
|
||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-assets-'));
|
||
const publishDir = path.join(root, 'MindSpace', CURRENT_USER.id);
|
||
const storageRoot = path.join(root, 'storage');
|
||
const assetId = '55555555-5555-4555-8555-555555555555';
|
||
const storageKey = `users/${CURRENT_USER.id}/assets/${assetId}/versions/v1`;
|
||
fs.mkdirSync(path.dirname(path.join(storageRoot, storageKey)), { recursive: true });
|
||
fs.writeFileSync(path.join(storageRoot, storageKey), Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
|
||
try {
|
||
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
||
fs.writeFileSync(
|
||
path.join(publishDir, 'public/tea.html'),
|
||
`<!doctype html><img src="/api/mindspace/v1/assets/${assetId}/download?inline=1">`,
|
||
);
|
||
const messages = [
|
||
{
|
||
id: 'msg-tea-1',
|
||
role: 'assistant',
|
||
content: [
|
||
{
|
||
type: 'text',
|
||
text: `[茶馆](https://m.tkmind.cn/MindSpace/${CURRENT_USER.id}/public/tea.html)`,
|
||
},
|
||
],
|
||
},
|
||
];
|
||
const pool = {
|
||
query: async () => [
|
||
[{ id: assetId, mime_type: 'image/jpeg', original_filename: 'tea.jpg', storage_key: storageKey }],
|
||
],
|
||
};
|
||
|
||
const result = await syncPublicHtmlAfterFinish({
|
||
messages,
|
||
currentUser: CURRENT_USER,
|
||
publishDir,
|
||
sessionId: 'session-tea',
|
||
pool,
|
||
storageRoot,
|
||
h5Root: root,
|
||
syncWorkspaceAssets: async () => {},
|
||
});
|
||
|
||
assert.equal(result.assetMaterialization.materialized.length, 1);
|
||
const saved = fs.readFileSync(path.join(publishDir, 'public/tea.html'), 'utf8');
|
||
assert.match(saved, /\.tmp-images\/55555555-5555-4555-8555-555555555555\.jpg/);
|
||
assert.ok(fs.existsSync(path.join(publishDir, 'public/.tmp-images', `${assetId}.jpg`)));
|
||
} finally {
|
||
fs.rmSync(root, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('collectOwnPublicHtmlArtifactRefs includes source message ids when available', () => {
|
||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-'));
|
||
try {
|
||
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
||
fs.writeFileSync(path.join(publishDir, 'public/own.html'), '<!doctype html><title>Own</title>');
|
||
const refs = collectOwnPublicHtmlArtifactRefs({
|
||
publishDir,
|
||
currentUser: CURRENT_USER,
|
||
messages: [
|
||
{
|
||
id: 'assistant-msg-1',
|
||
content: [
|
||
{
|
||
type: 'text',
|
||
text: `https://m.tkmind.cn/MindSpace/${CURRENT_USER.id}/public/own.html`,
|
||
},
|
||
],
|
||
},
|
||
],
|
||
});
|
||
assert.deepEqual(refs, [{ relativePath: 'public/own.html', messageId: 'assistant-msg-1' }]);
|
||
} finally {
|
||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('collectOwnPublicHtmlRelativePaths ignores missing and other-user public html links', () => {
|
||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-'));
|
||
try {
|
||
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
||
fs.writeFileSync(path.join(publishDir, 'public/own.html'), '<!doctype html><title>Own</title>');
|
||
const paths = collectOwnPublicHtmlRelativePaths({
|
||
publishDir,
|
||
currentUser: CURRENT_USER,
|
||
messages: [
|
||
{
|
||
content: [
|
||
{
|
||
type: 'text',
|
||
text: [
|
||
`https://m.tkmind.cn/MindSpace/${CURRENT_USER.id}/public/own.html`,
|
||
`https://m.tkmind.cn/MindSpace/${CURRENT_USER.id}/public/missing.html`,
|
||
'https://m.tkmind.cn/MindSpace/other-user/public/other.html',
|
||
].join('\n'),
|
||
},
|
||
],
|
||
},
|
||
],
|
||
});
|
||
assert.deepEqual(paths, ['public/own.html']);
|
||
} finally {
|
||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('materializeMissingPublicHtmlWrites writes html from developer write tool calls', () => {
|
||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-'));
|
||
try {
|
||
const messages = [
|
||
{
|
||
role: 'assistant',
|
||
content: [
|
||
{
|
||
type: 'toolRequest',
|
||
toolCall: {
|
||
value: {
|
||
name: 'write',
|
||
arguments: {
|
||
path: 'public/guizhou-guide.html',
|
||
content: '<!doctype html><title>Guizhou</title>',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
],
|
||
},
|
||
];
|
||
const result = materializeMissingPublicHtmlWrites({ messages, publishDir });
|
||
assert.deepEqual(result.materialized, ['public/guizhou-guide.html']);
|
||
assert.equal(
|
||
fs.readFileSync(path.join(publishDir, 'public/guizhou-guide.html'), 'utf8'),
|
||
'<!doctype html><title>Guizhou</title>',
|
||
);
|
||
} finally {
|
||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('materializeMissingPublicHtmlWrites replaces stub placeholder html', () => {
|
||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-'));
|
||
try {
|
||
const stubPath = path.join(publishDir, 'public/guide.html');
|
||
fs.mkdirSync(path.dirname(stubPath), { recursive: true });
|
||
fs.writeFileSync(stubPath, '<html>根据你的要求临时补出的简版页面</html>', 'utf8');
|
||
assert.equal(isStubPublicHtmlContent(fs.readFileSync(stubPath, 'utf8')), true);
|
||
|
||
const messages = [
|
||
{
|
||
role: 'assistant',
|
||
content: [
|
||
{
|
||
type: 'toolRequest',
|
||
toolCall: {
|
||
value: {
|
||
name: 'write_file',
|
||
arguments: {
|
||
path: 'public/guide.html',
|
||
content: '<!doctype html><title>Real Guide</title><body>Full page</body>',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
],
|
||
},
|
||
];
|
||
const result = materializeMissingPublicHtmlWrites({ messages, publishDir });
|
||
assert.deepEqual(result.materialized, ['public/guide.html']);
|
||
assert.match(fs.readFileSync(stubPath, 'utf8'), /Real Guide/);
|
||
} finally {
|
||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('syncPublicDocxDownloads copies newer complete oa docx into public download targets', () => {
|
||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-docx-sync-'));
|
||
try {
|
||
const oaDir = path.join(publishDir, 'oa');
|
||
const publicDir = path.join(publishDir, 'public');
|
||
fs.mkdirSync(oaDir, { recursive: true });
|
||
fs.mkdirSync(publicDir, { recursive: true });
|
||
fs.writeFileSync(path.join(oaDir, 'latest-plan.docx'), 'x'.repeat(9000));
|
||
fs.writeFileSync(path.join(publicDir, 'download.html'), '<a href="old-plan.docx">download</a>');
|
||
fs.writeFileSync(path.join(publicDir, 'old-plan.docx'), 'stub');
|
||
|
||
const result = syncPublicDocxDownloads({ publishDir, minCompleteSize: 8000 });
|
||
assert.deepEqual(result.synced, ['public/old-plan.docx']);
|
||
assert.equal(result.source, 'latest-plan.docx');
|
||
assert.equal(fs.readFileSync(path.join(publicDir, 'old-plan.docx'), 'utf8').length, 9000);
|
||
} finally {
|
||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('syncPublicDocxDownloads skips public docx that is already up to date', () => {
|
||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-docx-sync-'));
|
||
try {
|
||
const oaDir = path.join(publishDir, 'oa');
|
||
const publicDir = path.join(publishDir, 'public');
|
||
fs.mkdirSync(oaDir, { recursive: true });
|
||
fs.mkdirSync(publicDir, { recursive: true });
|
||
fs.writeFileSync(path.join(oaDir, 'latest-plan.docx'), 'x'.repeat(9000));
|
||
fs.writeFileSync(path.join(publicDir, 'download.html'), '<a href="old-plan.docx">download</a>');
|
||
fs.writeFileSync(path.join(publicDir, 'old-plan.docx'), 'x'.repeat(9000));
|
||
|
||
const result = syncPublicDocxDownloads({ publishDir, minCompleteSize: 8000 });
|
||
assert.deepEqual(result.synced, []);
|
||
assert.deepEqual(result.skipped, ['public/old-plan.docx']);
|
||
} finally {
|
||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('syncPublicDocxDownloads copies same-named oa docx even when it is small', () => {
|
||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-docx-sync-'));
|
||
try {
|
||
const oaDir = path.join(publishDir, 'oa');
|
||
const publicDir = path.join(publishDir, 'public');
|
||
fs.mkdirSync(oaDir, { recursive: true });
|
||
fs.mkdirSync(publicDir, { recursive: true });
|
||
fs.writeFileSync(path.join(oaDir, 'industry-distribution-report.docx'), 'x'.repeat(3294));
|
||
fs.writeFileSync(
|
||
path.join(publicDir, 'industry-distribution-report.html'),
|
||
'<a href="industry-distribution-report.docx" download>download</a>',
|
||
);
|
||
|
||
const result = syncPublicDocxDownloads({ publishDir, minCompleteSize: 1024 });
|
||
assert.deepEqual(result.synced, ['public/industry-distribution-report.docx']);
|
||
assert.deepEqual(result.missing, []);
|
||
assert.equal(result.source, 'industry-distribution-report.docx');
|
||
assert.equal(
|
||
fs.readFileSync(path.join(publicDir, 'industry-distribution-report.docx'), 'utf8').length,
|
||
3294,
|
||
);
|
||
} finally {
|
||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('syncPublicDocxDownloads reports missing public docx when no safe source exists', () => {
|
||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-docx-sync-'));
|
||
try {
|
||
const oaDir = path.join(publishDir, 'oa');
|
||
const publicDir = path.join(publishDir, 'public');
|
||
fs.mkdirSync(oaDir, { recursive: true });
|
||
fs.mkdirSync(publicDir, { recursive: true });
|
||
fs.writeFileSync(path.join(oaDir, '暑假计划.docx'), 'x'.repeat(4084));
|
||
fs.writeFileSync(path.join(oaDir, '韩国旅游攻略.docx'), 'x'.repeat(5889));
|
||
fs.writeFileSync(
|
||
path.join(publicDir, 'industry-distribution-report.html'),
|
||
'<a href="industry-distribution-report.docx" download>download</a>',
|
||
);
|
||
|
||
const result = syncPublicDocxDownloads({ publishDir, minCompleteSize: 1024 });
|
||
assert.deepEqual(result.synced, []);
|
||
assert.deepEqual(result.missing, ['public/industry-distribution-report.docx']);
|
||
assert.equal(
|
||
fs.existsSync(path.join(publicDir, 'industry-distribution-report.docx')),
|
||
false,
|
||
);
|
||
} finally {
|
||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('materializeMissingPublicHtmlWrites replaces existing real html when content changed', () => {
|
||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-'));
|
||
try {
|
||
const htmlPath = path.join(publishDir, 'public/guide.html');
|
||
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
|
||
fs.writeFileSync(htmlPath, '<!doctype html><title>Existing</title>', 'utf8');
|
||
const result = materializeMissingPublicHtmlWrites({
|
||
messages: [
|
||
{
|
||
role: 'assistant',
|
||
content: [
|
||
{
|
||
type: 'toolRequest',
|
||
toolCall: {
|
||
value: {
|
||
name: 'write_file',
|
||
arguments: {
|
||
path: 'public/guide.html',
|
||
content: '<!doctype html><title>Replacement</title>',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
],
|
||
},
|
||
],
|
||
publishDir,
|
||
});
|
||
assert.deepEqual(result.materialized, ['public/guide.html']);
|
||
assert.deepEqual(result.skipped, []);
|
||
assert.match(fs.readFileSync(htmlPath, 'utf8'), /Replacement/);
|
||
} finally {
|
||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('materializeMissingPublicHtmlWrites skips existing real html when content is unchanged', () => {
|
||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-'));
|
||
try {
|
||
const htmlPath = path.join(publishDir, 'public/guide.html');
|
||
const html = '<!doctype html><title>Existing</title>';
|
||
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
|
||
fs.writeFileSync(htmlPath, html, 'utf8');
|
||
const result = materializeMissingPublicHtmlWrites({
|
||
messages: [
|
||
{
|
||
role: 'assistant',
|
||
content: [
|
||
{
|
||
type: 'toolRequest',
|
||
toolCall: {
|
||
value: {
|
||
name: 'write_file',
|
||
arguments: {
|
||
path: 'public/guide.html',
|
||
content: html,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
],
|
||
},
|
||
],
|
||
publishDir,
|
||
});
|
||
assert.deepEqual(result.materialized, []);
|
||
assert.deepEqual(result.skipped, ['public/guide.html']);
|
||
assert.match(fs.readFileSync(htmlPath, 'utf8'), /Existing/);
|
||
} finally {
|
||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('detects a recent edit_file tool request on an existing public html page', () => {
|
||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-'));
|
||
try {
|
||
const htmlPath = path.join(publishDir, 'public/guide.html');
|
||
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
|
||
fs.writeFileSync(htmlPath, '<body>一二三</body>', 'utf8');
|
||
const messages = [
|
||
{
|
||
role: 'assistant',
|
||
content: [
|
||
{
|
||
type: 'toolRequest',
|
||
toolCall: {
|
||
value: {
|
||
name: 'edit_file',
|
||
arguments: {
|
||
path: 'public/guide.html',
|
||
old_str: '一二三',
|
||
new_str: '二三四',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
],
|
||
metadata: { userVisible: true },
|
||
},
|
||
];
|
||
assert.equal(hasRecentOwnPublicHtmlReference(messages, CURRENT_USER, { publishDir }), true);
|
||
} finally {
|
||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('materializeMissingPublicHtmlWrites applies edit_file patches onto existing public html', () => {
|
||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-'));
|
||
try {
|
||
const htmlPath = path.join(publishDir, 'public/guide.html');
|
||
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
|
||
fs.writeFileSync(htmlPath, '<!doctype html><title>Existing</title><body>一二三</body>', 'utf8');
|
||
const result = materializeMissingPublicHtmlWrites({
|
||
messages: [
|
||
{
|
||
role: 'assistant',
|
||
content: [
|
||
{
|
||
type: 'toolRequest',
|
||
toolCall: {
|
||
value: {
|
||
name: 'edit_file',
|
||
arguments: {
|
||
path: 'public/guide.html',
|
||
old_str: '一二三',
|
||
new_str: '二三四',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
],
|
||
},
|
||
],
|
||
publishDir,
|
||
});
|
||
assert.deepEqual(result.materialized, ['public/guide.html']);
|
||
assert.match(fs.readFileSync(htmlPath, 'utf8'), /二三四/);
|
||
assert.doesNotMatch(fs.readFileSync(htmlPath, 'utf8'), /一二三/);
|
||
} finally {
|
||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('materializeMissingPublicHtmlWrites chains multiple edit_file patches for the same html', () => {
|
||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-'));
|
||
try {
|
||
const htmlPath = path.join(publishDir, 'public/guide.html');
|
||
fs.mkdirSync(path.dirname(htmlPath), { recursive: true });
|
||
fs.writeFileSync(htmlPath, '<body>一 · 二三</body>', 'utf8');
|
||
const result = materializeMissingPublicHtmlWrites({
|
||
messages: [
|
||
{
|
||
role: 'assistant',
|
||
content: [
|
||
{
|
||
type: 'toolRequest',
|
||
toolCall: {
|
||
value: {
|
||
name: 'edit_file',
|
||
arguments: {
|
||
path: 'public/guide.html',
|
||
old_str: '一',
|
||
new_str: '二',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
type: 'toolRequest',
|
||
toolCall: {
|
||
value: {
|
||
name: 'edit_file',
|
||
arguments: {
|
||
path: 'public/guide.html',
|
||
old_str: '二三',
|
||
new_str: '三四',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
],
|
||
},
|
||
],
|
||
publishDir,
|
||
});
|
||
assert.deepEqual(result.materialized, ['public/guide.html']);
|
||
assert.match(fs.readFileSync(htmlPath, 'utf8'), /二 · 三四/);
|
||
} finally {
|
||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
test('materializePublicHtmlWritesFromSessionEvent writes public html from message event before finish', () => {
|
||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-event-sync-'));
|
||
try {
|
||
const result = materializePublicHtmlWritesFromSessionEvent(
|
||
{
|
||
type: 'Message',
|
||
message: {
|
||
id: 'assistant-1',
|
||
role: 'assistant',
|
||
metadata: { userVisible: true },
|
||
content: [
|
||
{
|
||
type: 'toolRequest',
|
||
toolCall: {
|
||
value: {
|
||
name: 'write',
|
||
arguments: {
|
||
path: 'public/shenwu-gate.html',
|
||
content: '<!doctype html><html><body>神口门</body></html>',
|
||
},
|
||
},
|
||
},
|
||
},
|
||
],
|
||
},
|
||
},
|
||
{ publishDir },
|
||
);
|
||
|
||
assert.deepEqual(result.materialized, ['public/shenwu-gate.html']);
|
||
assert.match(
|
||
fs.readFileSync(path.join(publishDir, 'public/shenwu-gate.html'), 'utf8'),
|
||
/神口门/,
|
||
);
|
||
} finally {
|
||
fs.rmSync(publishDir, { recursive: true, force: true });
|
||
}
|
||
});
|
||
|
||
function createPublicFinishService(overrides = {}) {
|
||
const calls = [];
|
||
const service = createMindSpacePublicFinishService({
|
||
pool: { query: async () => [[]] },
|
||
h5Root: '/srv/h5',
|
||
storageRoot: '/srv/storage',
|
||
env: {
|
||
H5_PUBLIC_BASE_URL: 'https://example.com',
|
||
},
|
||
conversationArtifactService: {
|
||
async registerPublicHtmlArtifacts(input) {
|
||
calls.push({ kind: 'artifacts', input });
|
||
return [];
|
||
},
|
||
},
|
||
resolveMindSpaceUserPublishDirFn() {
|
||
return '/srv/h5/MindSpace/user-1';
|
||
},
|
||
buildMindSpacePublicUrlForUserFn({
|
||
user,
|
||
relativePath,
|
||
}) {
|
||
return `https://example.com/MindSpace/${user.id}/${relativePath}`;
|
||
},
|
||
evaluateH5HtmlFinishGuardFn(input) {
|
||
calls.push({
|
||
kind: 'delivery-evaluation',
|
||
input,
|
||
});
|
||
return {
|
||
needsRepair: false,
|
||
missingHtml: [],
|
||
missingAssets: [],
|
||
browserStorageViolations: [],
|
||
htmlGenerationNeedsRetry: false,
|
||
linkExists() {
|
||
return true;
|
||
},
|
||
};
|
||
},
|
||
async preparePageDataAfterFinishFn(
|
||
input,
|
||
) {
|
||
calls.push({
|
||
kind: 'page-data-preparation',
|
||
input,
|
||
});
|
||
return {
|
||
autoBind: {
|
||
bound: [],
|
||
skipped: [],
|
||
errors: [],
|
||
},
|
||
evaluation: {
|
||
structuralPageData: false,
|
||
relevantFiles: [
|
||
{
|
||
relativePath:
|
||
'public/page.html',
|
||
absolutePath:
|
||
'/srv/h5/MindSpace/user-1/public/page.html',
|
||
content: '<html></html>',
|
||
},
|
||
],
|
||
htmlIssues: [],
|
||
unboundFiles: [],
|
||
needsRepair: false,
|
||
},
|
||
};
|
||
},
|
||
...overrides,
|
||
});
|
||
return { calls, service };
|
||
}
|
||
|
||
test('public finish service resolves stream writes and canonical URLs inside MindSpace', async () => {
|
||
const calls = [];
|
||
const setup = createPublicFinishService({
|
||
materializeSessionEventFn(event, options) {
|
||
calls.push({ kind: 'materialize', event, options });
|
||
return {
|
||
materialized: ['public/page.html'],
|
||
skipped: [],
|
||
};
|
||
},
|
||
collectArtifactRefsFn(input) {
|
||
calls.push({ kind: 'collect', input });
|
||
return [
|
||
{
|
||
relativePath: 'public/page.html',
|
||
messageId: 'message-1',
|
||
},
|
||
];
|
||
},
|
||
});
|
||
const event = {
|
||
type: 'Message',
|
||
message: {
|
||
id: 'message-1',
|
||
role: 'assistant',
|
||
},
|
||
};
|
||
|
||
const result =
|
||
await setup.service.materializeSessionEvent({
|
||
userId: 'user-1',
|
||
event,
|
||
});
|
||
|
||
assert.equal(
|
||
calls[0].options.publishDir,
|
||
'/srv/h5/MindSpace/user-1',
|
||
);
|
||
assert.deepEqual(result.publicHtmlRelativePaths, [
|
||
'public/page.html',
|
||
]);
|
||
assert.equal(
|
||
result.publicHtmlArtifacts[0].canonicalUrl,
|
||
'https://example.com/MindSpace/user-1/public/page.html',
|
||
);
|
||
});
|
||
|
||
test('public finish service owns WeChat HTML preparation, sync, and package registration', async () => {
|
||
const calls = [];
|
||
const setup = createPublicFinishService({
|
||
prepareWechatHtmlDeliveryAtWorkspaceFn(
|
||
input,
|
||
) {
|
||
calls.push({
|
||
kind: 'prepare-html',
|
||
input,
|
||
});
|
||
return {
|
||
materialization: {
|
||
materialized: [
|
||
'public/page.html',
|
||
],
|
||
skipped: [],
|
||
},
|
||
publishedArtifacts: [
|
||
{
|
||
relativePath:
|
||
'public/page.html',
|
||
url:
|
||
input.buildCanonicalUrl(
|
||
'public/page.html',
|
||
),
|
||
exists: true,
|
||
isStub: false,
|
||
},
|
||
],
|
||
expectedArtifacts: [],
|
||
recentArtifacts: [],
|
||
confirmedArtifacts: [
|
||
{
|
||
relativePath:
|
||
'public/page.html',
|
||
url:
|
||
input.buildCanonicalUrl(
|
||
'public/page.html',
|
||
),
|
||
exists: true,
|
||
isStub: false,
|
||
},
|
||
],
|
||
verifiedArtifacts: [],
|
||
validReplyUrls: [],
|
||
hasValidReplyLink: false,
|
||
};
|
||
},
|
||
async syncAfterFinishFn(input) {
|
||
calls.push({
|
||
kind: 'finish-sync',
|
||
input,
|
||
});
|
||
return { synced: true };
|
||
},
|
||
async syncWorkspaceAssets(
|
||
userId,
|
||
options,
|
||
) {
|
||
calls.push({
|
||
kind: 'asset-sync',
|
||
userId,
|
||
options,
|
||
});
|
||
},
|
||
conversationArtifactService: {
|
||
async registerPublicHtmlArtifacts(
|
||
input,
|
||
) {
|
||
calls.push({
|
||
kind: 'artifact-register',
|
||
input,
|
||
});
|
||
return [];
|
||
},
|
||
},
|
||
});
|
||
|
||
const result =
|
||
await setup.service
|
||
.prepareWechatHtmlDelivery({
|
||
userId: 'user-1',
|
||
sessionId: 'session-1',
|
||
reply: {
|
||
text: 'done',
|
||
messages: [
|
||
{
|
||
role: 'assistant',
|
||
},
|
||
],
|
||
},
|
||
intent: {
|
||
agentText: 'build page',
|
||
},
|
||
});
|
||
|
||
assert.equal(
|
||
calls[0].input.publishDir,
|
||
'/srv/h5/MindSpace/user-1',
|
||
);
|
||
assert.equal(
|
||
result.confirmedArtifacts[0].url,
|
||
'https://example.com/MindSpace/user-1/public/page.html',
|
||
);
|
||
assert.equal(
|
||
Object.hasOwn(
|
||
result.confirmedArtifacts[0],
|
||
'localPath',
|
||
),
|
||
false,
|
||
);
|
||
assert.ok(
|
||
calls.some(
|
||
(call) =>
|
||
call.kind === 'asset-sync' &&
|
||
call.options.onlyRelativePaths
|
||
.includes('public/page.html'),
|
||
),
|
||
);
|
||
assert.ok(
|
||
calls.some(
|
||
(call) =>
|
||
call.kind ===
|
||
'artifact-register' &&
|
||
call.input.sessionId ===
|
||
'session-1',
|
||
),
|
||
);
|
||
});
|
||
|
||
test('public finish service owns WeChat fresh thumbnail rendering and metadata sync', async () => {
|
||
const calls = [];
|
||
const setup = createPublicFinishService({
|
||
async ensureWechatFreshPageThumbnailsAtWorkspaceFn(
|
||
input,
|
||
) {
|
||
calls.push({
|
||
kind: 'fresh-thumbnail',
|
||
input,
|
||
});
|
||
return {
|
||
ok: true,
|
||
reason: null,
|
||
matchRelativePaths: [
|
||
'public/page.html',
|
||
],
|
||
thumbnailRelativePaths: [
|
||
'public/page.thumbnail.svg',
|
||
],
|
||
};
|
||
},
|
||
async syncWorkspaceAssets(
|
||
userId,
|
||
options,
|
||
) {
|
||
calls.push({
|
||
kind: 'asset-sync',
|
||
userId,
|
||
options,
|
||
});
|
||
},
|
||
conversationArtifactService: {
|
||
async registerPublicHtmlArtifacts(
|
||
input,
|
||
) {
|
||
calls.push({
|
||
kind: 'artifact-register',
|
||
input,
|
||
});
|
||
return [];
|
||
},
|
||
},
|
||
});
|
||
|
||
const result =
|
||
await setup.service
|
||
.ensureWechatFreshPageThumbnails({
|
||
userId: 'user-1',
|
||
sessionId: 'session-1',
|
||
artifacts: [
|
||
{
|
||
relativePath:
|
||
'public/page.html',
|
||
},
|
||
],
|
||
images: [
|
||
{
|
||
jobId: 'job-1',
|
||
},
|
||
],
|
||
repairEnabled: true,
|
||
});
|
||
|
||
assert.equal(result.ok, true);
|
||
assert.deepEqual(
|
||
calls.find(
|
||
(call) =>
|
||
call.kind === 'asset-sync',
|
||
).options.onlyRelativePaths,
|
||
[
|
||
'public/page.html',
|
||
'public/page.thumbnail.svg',
|
||
],
|
||
);
|
||
assert.deepEqual(
|
||
calls.find(
|
||
(call) =>
|
||
call.kind ===
|
||
'artifact-register',
|
||
).input.relativePaths,
|
||
['public/page.html'],
|
||
);
|
||
});
|
||
|
||
test('public finish service owns WeChat Page Data delivery preparation', async () => {
|
||
const calls = [];
|
||
let outcomeCount = 0;
|
||
const setup = createPublicFinishService({
|
||
createPageServiceFn(pool, options) {
|
||
calls.push({
|
||
kind: 'page-service',
|
||
pool,
|
||
options,
|
||
});
|
||
return {
|
||
async findPageByRelativePath() {
|
||
return { id: 'page-1' };
|
||
},
|
||
};
|
||
},
|
||
async resolvePageDataCollectOutcomeAsyncFn(
|
||
input,
|
||
) {
|
||
calls.push({ kind: 'outcome', input });
|
||
outcomeCount += 1;
|
||
return outcomeCount === 1
|
||
? { action: 'retry' }
|
||
: {
|
||
action: 'send',
|
||
evaluation: {
|
||
relevantFiles: [
|
||
{
|
||
relativePath:
|
||
'public/page.html',
|
||
absolutePath:
|
||
'/srv/h5/MindSpace/user-1/public/page.html',
|
||
content: '<html></html>',
|
||
},
|
||
],
|
||
},
|
||
};
|
||
},
|
||
async maybeAutoBindPageDataHtmlPagesFn(
|
||
input,
|
||
) {
|
||
calls.push({ kind: 'auto-bind', input });
|
||
return {
|
||
bound: [
|
||
{
|
||
relativePath: 'public/page.html',
|
||
workspaceUrl:
|
||
'https://example.com/MindSpace/user-1/public/page.html',
|
||
},
|
||
],
|
||
skipped: [],
|
||
errors: [],
|
||
};
|
||
},
|
||
buildPageDataDeliveryArtifactsFromBindResultFn(
|
||
autoBind,
|
||
publishDir,
|
||
options,
|
||
) {
|
||
calls.push({
|
||
kind: 'artifacts',
|
||
autoBind,
|
||
publishDir,
|
||
options,
|
||
});
|
||
return [
|
||
{
|
||
relativePath: 'public/page.html',
|
||
localPath:
|
||
'/srv/h5/MindSpace/user-1/public/page.html',
|
||
url: 'https://example.com/MindSpace/user-1/public/page.html',
|
||
},
|
||
];
|
||
},
|
||
async ensurePageDataDeliveryReadyFn(input) {
|
||
calls.push({
|
||
kind: 'delivery-check',
|
||
input,
|
||
});
|
||
return { ok: true, failures: [] };
|
||
},
|
||
rewritePageDataDeliveryLinksFn(
|
||
text,
|
||
artifacts,
|
||
) {
|
||
calls.push({
|
||
kind: 'rewrite',
|
||
text,
|
||
artifacts,
|
||
});
|
||
return '已发布:workspace-url';
|
||
},
|
||
});
|
||
|
||
const result =
|
||
await setup.service
|
||
.prepareWechatPageDataDelivery({
|
||
userId: 'user-1',
|
||
reply: {
|
||
text: '已发布:legacy-url',
|
||
messages: [],
|
||
},
|
||
intent: {
|
||
agentText: 'build survey',
|
||
},
|
||
publicBaseUrl:
|
||
'https://example.com',
|
||
requestStartedAt: 123,
|
||
});
|
||
|
||
assert.deepEqual(
|
||
calls
|
||
.map((call) => call.kind),
|
||
[
|
||
'outcome',
|
||
'page-service',
|
||
'auto-bind',
|
||
'outcome',
|
||
'artifacts',
|
||
'delivery-check',
|
||
'rewrite',
|
||
],
|
||
);
|
||
assert.equal(
|
||
calls[0].input.publishDir,
|
||
'/srv/h5/MindSpace/user-1',
|
||
);
|
||
assert.equal(
|
||
calls[2].input.storageRoot,
|
||
'/srv/storage',
|
||
);
|
||
assert.equal(result.outcome.action, 'send');
|
||
assert.equal(result.deliveryCheck.ok, true);
|
||
assert.equal(
|
||
result.rewrittenText,
|
||
'已发布:workspace-url',
|
||
);
|
||
assert.equal(
|
||
Object.hasOwn(
|
||
result.deliveryArtifacts[0],
|
||
'localPath',
|
||
),
|
||
false,
|
||
);
|
||
assert.equal(
|
||
Object.hasOwn(
|
||
result.outcome.evaluation
|
||
.relevantFiles[0],
|
||
'absolutePath',
|
||
),
|
||
false,
|
||
);
|
||
});
|
||
|
||
test('public finish service keeps workspace sync and artifact registration inside MindSpace', async () => {
|
||
const calls = [];
|
||
const setup = createPublicFinishService({
|
||
async syncWorkspaceAssets(userId, options) {
|
||
calls.push({
|
||
kind: 'workspace-assets',
|
||
userId,
|
||
options,
|
||
});
|
||
},
|
||
async syncAfterFinishFn(input) {
|
||
calls.push({ kind: 'finish', input });
|
||
await input.syncWorkspaceAssets('user-1', {
|
||
categoryCode: 'public',
|
||
});
|
||
await input.registerPublicHtmlArtifacts(
|
||
'user-1',
|
||
{
|
||
sessionId: 'session-1',
|
||
relativePaths: ['public/page.html'],
|
||
artifactRefs: [
|
||
{
|
||
relativePath: 'public/page.html',
|
||
messageId: 'message-1',
|
||
},
|
||
],
|
||
},
|
||
);
|
||
return {
|
||
materialized: ['public/page.html'],
|
||
skipped: [],
|
||
publicHtmlRelativePaths: [
|
||
'public/page.html',
|
||
],
|
||
publicHtmlArtifactRefs: [
|
||
{
|
||
relativePath: 'public/page.html',
|
||
messageId: 'message-1',
|
||
},
|
||
],
|
||
};
|
||
},
|
||
});
|
||
|
||
const result = await setup.service.syncAfterFinish({
|
||
userId: 'user-1',
|
||
sessionId: 'session-1',
|
||
messages: [],
|
||
});
|
||
|
||
assert.equal(calls[0].kind, 'finish');
|
||
assert.equal(
|
||
calls[0].input.publishDir,
|
||
'/srv/h5/MindSpace/user-1',
|
||
);
|
||
assert.equal(calls[0].input.storageRoot, '/srv/storage');
|
||
assert.equal(calls[1].kind, 'workspace-assets');
|
||
assert.equal(setup.calls[0].kind, 'artifacts');
|
||
assert.equal(
|
||
setup.calls[0].input.userId,
|
||
'user-1',
|
||
);
|
||
assert.equal(
|
||
result.publicHtmlArtifacts[0].canonicalUrl,
|
||
'https://example.com/MindSpace/user-1/public/page.html',
|
||
);
|
||
assert.equal(
|
||
result.deliveryEvaluation.needsRepair,
|
||
false,
|
||
);
|
||
assert.equal(
|
||
Object.hasOwn(
|
||
result.deliveryEvaluation,
|
||
'linkExists',
|
||
),
|
||
false,
|
||
);
|
||
const pageDataPreparation =
|
||
await setup.service.preparePageDataAfterFinish({
|
||
userId: 'user-1',
|
||
messages: [],
|
||
userText: 'build page',
|
||
});
|
||
assert.equal(
|
||
pageDataPreparation.evaluation
|
||
.relevantFiles[0].relativePath,
|
||
'public/page.html',
|
||
);
|
||
assert.equal(
|
||
Object.hasOwn(
|
||
pageDataPreparation.evaluation
|
||
.relevantFiles[0],
|
||
'absolutePath',
|
||
),
|
||
false,
|
||
);
|
||
assert.equal(
|
||
Object.hasOwn(
|
||
pageDataPreparation.evaluation
|
||
.relevantFiles[0],
|
||
'content',
|
||
),
|
||
false,
|
||
);
|
||
const preparationCall = setup.calls.find(
|
||
(call) =>
|
||
call.kind === 'page-data-preparation',
|
||
);
|
||
assert.equal(
|
||
preparationCall.input.publishDir,
|
||
'/srv/h5/MindSpace/user-1',
|
||
);
|
||
assert.equal(
|
||
preparationCall.input.storageRoot,
|
||
'/srv/storage',
|
||
);
|
||
assert.equal(
|
||
preparationCall.input.userText,
|
||
'build page',
|
||
);
|
||
});
|