synthos 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +215 -65
- package/default-pages/application.json +1 -0
- package/default-pages/json_tools.json +1 -1
- package/default-pages/oregon_trail.html +321 -0
- package/default-pages/oregon_trail.json +12 -0
- package/default-pages/sidebar_page.json +1 -0
- package/default-pages/solar_explorer.html +10 -18
- package/default-pages/solar_explorer.json +2 -2
- package/default-pages/two-panel_page.json +1 -0
- package/default-pages/us_map.html +192 -0
- package/default-pages/us_map.json +12 -0
- package/default-pages/us_map_1850.html +325 -0
- package/default-pages/us_map_1850.json +12 -0
- package/default-pages/western_cities_1850.html +526 -0
- package/default-pages/western_cities_1850.json +12 -0
- package/default-themes/{nebula-dawn.css → nebula-dawn.v2.css} +24 -0
- package/default-themes/{nebula-dusk.css → nebula-dusk.v2.css} +24 -0
- package/dist/agents/a2a/a2aProvider.d.ts +3 -0
- package/dist/agents/a2a/a2aProvider.d.ts.map +1 -0
- package/dist/agents/a2a/a2aProvider.js +126 -0
- package/dist/agents/a2a/a2aProvider.js.map +1 -0
- package/dist/agents/discovery.d.ts +30 -0
- package/dist/agents/discovery.d.ts.map +1 -0
- package/dist/agents/discovery.js +52 -0
- package/dist/agents/discovery.js.map +1 -0
- package/dist/agents/index.d.ts +7 -0
- package/dist/agents/index.d.ts.map +1 -0
- package/dist/agents/index.js +19 -0
- package/dist/agents/index.js.map +1 -0
- package/dist/agents/openclaw/gatewayManager.d.ts +113 -0
- package/dist/agents/openclaw/gatewayManager.d.ts.map +1 -0
- package/dist/agents/openclaw/gatewayManager.js +470 -0
- package/dist/agents/openclaw/gatewayManager.js.map +1 -0
- package/dist/agents/openclaw/openclawProvider.d.ts +3 -0
- package/dist/agents/openclaw/openclawProvider.d.ts.map +1 -0
- package/dist/agents/openclaw/openclawProvider.js +239 -0
- package/dist/agents/openclaw/openclawProvider.js.map +1 -0
- package/dist/agents/openclaw/sshTunnelManager.d.ts +23 -0
- package/dist/agents/openclaw/sshTunnelManager.d.ts.map +1 -0
- package/dist/agents/openclaw/sshTunnelManager.js +340 -0
- package/dist/agents/openclaw/sshTunnelManager.js.map +1 -0
- package/dist/agents/types.d.ts +64 -0
- package/dist/agents/types.d.ts.map +1 -0
- package/dist/agents/types.js +6 -0
- package/dist/agents/types.js.map +1 -0
- package/dist/connectors/airtable/connector.json +27 -0
- package/dist/connectors/alpha-vantage/connector.json +26 -0
- package/dist/connectors/brave-search/connector.json +26 -0
- package/dist/connectors/cloudinary/connector.json +27 -0
- package/dist/connectors/deepl/connector.json +28 -0
- package/dist/connectors/elevenlabs/connector.json +30 -0
- package/dist/connectors/giphy/connector.json +27 -0
- package/dist/connectors/github/connector.json +29 -0
- package/dist/connectors/huggingface/connector.json +27 -0
- package/dist/connectors/imgur/connector.json +29 -0
- package/dist/connectors/index.d.ts +1 -1
- package/dist/connectors/index.d.ts.map +1 -1
- package/dist/connectors/instagram/connector.json +43 -0
- package/dist/connectors/jira/connector.json +28 -0
- package/dist/connectors/mapbox/connector.json +26 -0
- package/dist/connectors/nasa/connector.json +27 -0
- package/dist/connectors/newsapi/connector.json +27 -0
- package/dist/connectors/notion/connector.json +28 -0
- package/dist/connectors/open-exchange-rates/connector.json +27 -0
- package/dist/connectors/openweathermap/connector.json +26 -0
- package/dist/connectors/pexels/connector.json +27 -0
- package/dist/connectors/registry.d.ts.map +1 -1
- package/dist/connectors/registry.js +42 -96
- package/dist/connectors/registry.js.map +1 -1
- package/dist/connectors/resend/connector.json +29 -0
- package/dist/connectors/rss2json/connector.json +27 -0
- package/dist/connectors/sendgrid/connector.json +27 -0
- package/dist/connectors/spoonacular/connector.json +28 -0
- package/dist/connectors/stability-ai/connector.json +27 -0
- package/dist/connectors/twilio/connector.json +28 -0
- package/dist/connectors/types.d.ts +23 -0
- package/dist/connectors/types.d.ts.map +1 -1
- package/dist/connectors/unsplash/connector.json +27 -0
- package/dist/connectors/wolfram-alpha/connector.json +26 -0
- package/dist/connectors/youtube-data/connector.json +30 -0
- package/dist/files.d.ts +1 -0
- package/dist/files.d.ts.map +1 -1
- package/dist/files.js +16 -1
- package/dist/files.js.map +1 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +28 -0
- package/dist/init.js.map +1 -1
- package/dist/migrations.d.ts +3 -2
- package/dist/migrations.d.ts.map +1 -1
- package/dist/migrations.js +122 -138
- package/dist/migrations.js.map +1 -1
- package/dist/models/anthropic.d.ts +22 -0
- package/dist/models/anthropic.d.ts.map +1 -0
- package/dist/models/anthropic.js +76 -0
- package/dist/models/anthropic.js.map +1 -0
- package/dist/models/chainOfThought.d.ts +12 -0
- package/dist/models/chainOfThought.d.ts.map +1 -0
- package/dist/models/chainOfThought.js +45 -0
- package/dist/models/chainOfThought.js.map +1 -0
- package/dist/models/fireworksai.d.ts +30 -0
- package/dist/models/fireworksai.d.ts.map +1 -0
- package/dist/models/fireworksai.js +133 -0
- package/dist/models/fireworksai.js.map +1 -0
- package/dist/models/index.d.ts +7 -1
- package/dist/models/index.d.ts.map +1 -1
- package/dist/models/index.js +19 -1
- package/dist/models/index.js.map +1 -1
- package/dist/models/logCompletePrompt.d.ts +3 -0
- package/dist/models/logCompletePrompt.d.ts.map +1 -0
- package/dist/models/logCompletePrompt.js +23 -0
- package/dist/models/logCompletePrompt.js.map +1 -0
- package/dist/models/openai.d.ts +24 -0
- package/dist/models/openai.d.ts.map +1 -0
- package/dist/models/openai.js +80 -0
- package/dist/models/openai.js.map +1 -0
- package/dist/models/providers.d.ts +1 -0
- package/dist/models/providers.d.ts.map +1 -1
- package/dist/models/providers.js +12 -4
- package/dist/models/providers.js.map +1 -1
- package/dist/models/types.d.ts +34 -2
- package/dist/models/types.d.ts.map +1 -1
- package/dist/models/types.js +16 -0
- package/dist/models/types.js.map +1 -1
- package/dist/models/utils.d.ts +6 -0
- package/dist/models/utils.d.ts.map +1 -0
- package/dist/models/utils.js +21 -0
- package/dist/models/utils.js.map +1 -0
- package/dist/scripts.d.ts +2 -1
- package/dist/scripts.d.ts.map +1 -1
- package/dist/scripts.js +4 -3
- package/dist/scripts.js.map +1 -1
- package/dist/service/createCompletePrompt.d.ts +1 -1
- package/dist/service/createCompletePrompt.d.ts.map +1 -1
- package/dist/service/createCompletePrompt.js +9 -6
- package/dist/service/createCompletePrompt.js.map +1 -1
- package/dist/service/generateImage.d.ts +1 -1
- package/dist/service/generateImage.d.ts.map +1 -1
- package/dist/service/generateImage.js +3 -3
- package/dist/service/generateImage.js.map +1 -1
- package/dist/service/server.d.ts.map +1 -1
- package/dist/service/server.js +3 -0
- package/dist/service/server.js.map +1 -1
- package/dist/service/transformPage.d.ts +4 -2
- package/dist/service/transformPage.d.ts.map +1 -1
- package/dist/service/transformPage.js +74 -6
- package/dist/service/transformPage.js.map +1 -1
- package/dist/service/useAgentRoutes.d.ts +4 -0
- package/dist/service/useAgentRoutes.d.ts.map +1 -0
- package/dist/service/useAgentRoutes.js +389 -0
- package/dist/service/useAgentRoutes.js.map +1 -0
- package/dist/service/useApiRoutes.d.ts.map +1 -1
- package/dist/service/useApiRoutes.js +157 -16
- package/dist/service/useApiRoutes.js.map +1 -1
- package/dist/service/useConnectorRoutes.d.ts.map +1 -1
- package/dist/service/useConnectorRoutes.js +14 -3
- package/dist/service/useConnectorRoutes.js.map +1 -1
- package/dist/service/useGatewayRoutes.d.ts +4 -0
- package/dist/service/useGatewayRoutes.d.ts.map +1 -0
- package/dist/service/useGatewayRoutes.js +168 -0
- package/dist/service/useGatewayRoutes.js.map +1 -0
- package/dist/service/usePageRoutes.d.ts.map +1 -1
- package/dist/service/usePageRoutes.js +16 -5
- package/dist/service/usePageRoutes.js.map +1 -1
- package/dist/settings.d.ts +2 -1
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js +4 -8
- package/dist/settings.js.map +1 -1
- package/dist/themes.d.ts +14 -0
- package/dist/themes.d.ts.map +1 -1
- package/dist/themes.js +86 -13
- package/dist/themes.js.map +1 -1
- package/package.json +10 -5
- package/page-scripts/helpers-v2.js +222 -0
- package/page-scripts/page-v2.js +656 -0
- package/required-pages/builder.html +1 -27
- package/required-pages/pages.html +745 -22
- package/required-pages/settings.html +819 -21
- package/required-pages/synthos_apis.html +56 -1
- package/src/agents/a2a/a2aProvider.ts +110 -0
- package/src/agents/discovery.ts +74 -0
- package/src/agents/index.ts +6 -0
- package/src/agents/openclaw/gatewayManager.ts +559 -0
- package/src/agents/openclaw/openclawProvider.ts +261 -0
- package/src/agents/openclaw/sshTunnelManager.ts +385 -0
- package/src/agents/types.ts +82 -0
- package/src/connectors/airtable/connector.json +27 -0
- package/src/connectors/alpha-vantage/connector.json +26 -0
- package/src/connectors/brave-search/connector.json +26 -0
- package/src/connectors/cloudinary/connector.json +27 -0
- package/src/connectors/deepl/connector.json +28 -0
- package/src/connectors/elevenlabs/connector.json +30 -0
- package/src/connectors/giphy/connector.json +27 -0
- package/src/connectors/github/connector.json +29 -0
- package/src/connectors/huggingface/connector.json +27 -0
- package/src/connectors/imgur/connector.json +29 -0
- package/src/connectors/index.ts +2 -0
- package/src/connectors/instagram/connector.json +43 -0
- package/src/connectors/jira/connector.json +28 -0
- package/src/connectors/mapbox/connector.json +26 -0
- package/src/connectors/nasa/connector.json +27 -0
- package/src/connectors/newsapi/connector.json +27 -0
- package/src/connectors/notion/connector.json +28 -0
- package/src/connectors/open-exchange-rates/connector.json +27 -0
- package/src/connectors/openweathermap/connector.json +26 -0
- package/src/connectors/pexels/connector.json +27 -0
- package/src/connectors/registry.ts +21 -97
- package/src/connectors/resend/connector.json +29 -0
- package/src/connectors/rss2json/connector.json +27 -0
- package/src/connectors/sendgrid/connector.json +27 -0
- package/src/connectors/spoonacular/connector.json +28 -0
- package/src/connectors/stability-ai/connector.json +27 -0
- package/src/connectors/twilio/connector.json +28 -0
- package/src/connectors/types.ts +25 -0
- package/src/connectors/unsplash/connector.json +27 -0
- package/src/connectors/wolfram-alpha/connector.json +26 -0
- package/src/connectors/youtube-data/connector.json +30 -0
- package/src/files.ts +14 -0
- package/src/init.ts +27 -0
- package/src/migrations.ts +121 -138
- package/src/models/anthropic.ts +89 -0
- package/src/models/chainOfThought.ts +56 -0
- package/src/models/fireworksai.ts +136 -0
- package/src/models/index.ts +7 -1
- package/src/models/logCompletePrompt.ts +25 -0
- package/src/models/openai.ts +90 -0
- package/src/models/providers.ts +12 -3
- package/src/models/types.ts +67 -2
- package/src/models/utils.ts +16 -0
- package/src/scripts.ts +2 -2
- package/src/service/createCompletePrompt.ts +3 -1
- package/src/service/generateImage.ts +2 -2
- package/src/service/server.ts +4 -0
- package/src/service/transformPage.ts +81 -8
- package/src/service/useAgentRoutes.ts +423 -0
- package/src/service/useApiRoutes.ts +173 -18
- package/src/service/useConnectorRoutes.ts +14 -3
- package/src/service/usePageRoutes.ts +20 -6
- package/src/settings.ts +6 -10
- package/src/themes.ts +84 -12
- package/tests/README.md +12 -0
- package/tests/anthropic.spec.ts +84 -0
- package/tests/chainOfThought.spec.ts +108 -0
- package/tests/ensureScripts.spec.ts +82 -0
- package/tests/files.spec.ts +233 -0
- package/tests/fireworksai.spec.ts +92 -0
- package/tests/logCompletePrompt.spec.ts +74 -0
- package/tests/migrations.spec.ts +169 -0
- package/tests/openai.spec.ts +71 -0
- package/tests/pages.spec.ts +328 -0
- package/tests/providers.spec.ts +144 -0
- package/tests/scripts.spec.ts +209 -0
- package/tests/transformPage.spec.ts +931 -0
- package/tests/types.spec.ts +23 -0
- package/default-pages/app_builder.json +0 -1
- package/default-pages/sidebar_builder.json +0 -1
- package/default-pages/two-panel_builder.json +0 -1
- package/images/home.png +0 -0
- package/images/page-management.png +0 -0
- package/images/settings.png +0 -0
- package/images/synthos-square.png +0 -0
- /package/default-pages/{app_builder.html → application.html} +0 -0
- /package/default-pages/{sidebar_builder.html → sidebar_page.html} +0 -0
- /package/default-pages/{two-panel_builder.html → two-panel_page.html} +0 -0
|
@@ -0,0 +1,931 @@
|
|
|
1
|
+
import assert from 'assert';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import {
|
|
6
|
+
assignNodeIds,
|
|
7
|
+
stripNodeIds,
|
|
8
|
+
applyChangeList,
|
|
9
|
+
parseChangeList,
|
|
10
|
+
injectError,
|
|
11
|
+
deduplicateInlineScripts,
|
|
12
|
+
transformPage,
|
|
13
|
+
ChangeList,
|
|
14
|
+
TransformPageArgs,
|
|
15
|
+
} from '../src/service/transformPage';
|
|
16
|
+
import { AgentCompletion, PromptCompletionArgs } from '../src/models/types';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// assignNodeIds
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
describe('assignNodeIds', () => {
|
|
23
|
+
it('assigns sequential data-node-id to every element', () => {
|
|
24
|
+
const html = '<html><head></head><body><div><p>Hello</p></div></body></html>';
|
|
25
|
+
const { html: result, nodeCount } = assignNodeIds(html);
|
|
26
|
+
assert.ok(result.includes('data-node-id="0"'));
|
|
27
|
+
assert.ok(nodeCount > 0);
|
|
28
|
+
// Every tag should have an id — count occurrences
|
|
29
|
+
const ids = result.match(/data-node-id="/g);
|
|
30
|
+
assert.strictEqual(ids?.length, nodeCount);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('returns nodeCount matching the number of tags', () => {
|
|
34
|
+
const html = '<div><span>A</span><span>B</span></div>';
|
|
35
|
+
const { nodeCount } = assignNodeIds(html);
|
|
36
|
+
// cheerio wraps in <html><head></head><body>...</body> so count includes those
|
|
37
|
+
assert.ok(nodeCount >= 4); // html, head, body, div, span, span = 6
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('assigns data-node-id to script and style elements', () => {
|
|
41
|
+
const html = '<html><head><style>.a{color:red}</style><script>var x=1;</script></head><body><script src="/app.js"></script></body></html>';
|
|
42
|
+
const { html: result, nodeCount } = assignNodeIds(html);
|
|
43
|
+
// All 6 elements should have ids: html, head, style, script(inline), body, script(src)
|
|
44
|
+
const ids = result.match(/data-node-id="/g);
|
|
45
|
+
assert.strictEqual(ids?.length, nodeCount);
|
|
46
|
+
assert.strictEqual(nodeCount, 6);
|
|
47
|
+
// Verify the style and script tags specifically got ids
|
|
48
|
+
assert.ok(result.match(/<style[^>]+data-node-id="/), 'style element should have data-node-id');
|
|
49
|
+
assert.ok(result.match(/<script[^>]+data-node-id="/), 'script element should have data-node-id');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// stripNodeIds
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
describe('stripNodeIds', () => {
|
|
58
|
+
it('removes all data-node-id attributes', () => {
|
|
59
|
+
const html = '<div data-node-id="0"><p data-node-id="1">Hi</p></div>';
|
|
60
|
+
const result = stripNodeIds(html);
|
|
61
|
+
assert.ok(!result.includes('data-node-id'));
|
|
62
|
+
assert.ok(result.includes('<p>Hi</p>'));
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// assignNodeIds -> stripNodeIds roundtrip
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
describe('assignNodeIds -> stripNodeIds roundtrip', () => {
|
|
71
|
+
it('produces HTML without data-node-id attributes', () => {
|
|
72
|
+
const original = '<html><head></head><body><div><p>Hello</p></div></body></html>';
|
|
73
|
+
const { html: annotated } = assignNodeIds(original);
|
|
74
|
+
assert.ok(annotated.includes('data-node-id'));
|
|
75
|
+
const stripped = stripNodeIds(annotated);
|
|
76
|
+
assert.ok(!stripped.includes('data-node-id'));
|
|
77
|
+
assert.ok(stripped.includes('<p>Hello</p>'));
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// applyChangeList
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
describe('applyChangeList', () => {
|
|
86
|
+
// Helper: wrap content in a minimal annotated structure
|
|
87
|
+
const annotated = '<html><head></head><body>' +
|
|
88
|
+
'<div data-node-id="10"><p data-node-id="11">Old text</p></div>' +
|
|
89
|
+
'</body></html>';
|
|
90
|
+
|
|
91
|
+
it('applies "update" — replaces innerHTML', () => {
|
|
92
|
+
const changes: ChangeList = [
|
|
93
|
+
{ op: 'update', nodeId: '11', html: 'New text' },
|
|
94
|
+
];
|
|
95
|
+
const result = applyChangeList(annotated, changes);
|
|
96
|
+
assert.ok(result.includes('<p data-node-id="11">New text</p>'));
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('applies "replace" — replaces outerHTML', () => {
|
|
100
|
+
const changes: ChangeList = [
|
|
101
|
+
{ op: 'replace', nodeId: '11', html: '<span>Replaced</span>' },
|
|
102
|
+
];
|
|
103
|
+
const result = applyChangeList(annotated, changes);
|
|
104
|
+
assert.ok(result.includes('<span>Replaced</span>'));
|
|
105
|
+
assert.ok(!result.includes('data-node-id="11"'));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('applies "delete" — removes element', () => {
|
|
109
|
+
const changes: ChangeList = [
|
|
110
|
+
{ op: 'delete', nodeId: '11' },
|
|
111
|
+
];
|
|
112
|
+
const result = applyChangeList(annotated, changes);
|
|
113
|
+
assert.ok(!result.includes('data-node-id="11"'));
|
|
114
|
+
assert.ok(!result.includes('Old text'));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('applies "insert" with position "append"', () => {
|
|
118
|
+
const changes: ChangeList = [
|
|
119
|
+
{ op: 'insert', parentId: '10', position: 'append', html: '<em>Appended</em>' },
|
|
120
|
+
];
|
|
121
|
+
const result = applyChangeList(annotated, changes);
|
|
122
|
+
assert.ok(result.includes('<em>Appended</em>'));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('applies "insert" with position "prepend"', () => {
|
|
126
|
+
const changes: ChangeList = [
|
|
127
|
+
{ op: 'insert', parentId: '10', position: 'prepend', html: '<em>Prepended</em>' },
|
|
128
|
+
];
|
|
129
|
+
const result = applyChangeList(annotated, changes);
|
|
130
|
+
// Prepended element should appear before the <p>
|
|
131
|
+
const prependIdx = result.indexOf('<em>Prepended</em>');
|
|
132
|
+
const pIdx = result.indexOf('<p data-node-id="11">');
|
|
133
|
+
assert.ok(prependIdx < pIdx);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('applies "insert" with position "before"', () => {
|
|
137
|
+
const changes: ChangeList = [
|
|
138
|
+
{ op: 'insert', parentId: '11', position: 'before', html: '<em>Before</em>' },
|
|
139
|
+
];
|
|
140
|
+
const result = applyChangeList(annotated, changes);
|
|
141
|
+
const beforeIdx = result.indexOf('<em>Before</em>');
|
|
142
|
+
const pIdx = result.indexOf('<p data-node-id="11">');
|
|
143
|
+
assert.ok(beforeIdx < pIdx);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('applies "insert" with position "after"', () => {
|
|
147
|
+
const changes: ChangeList = [
|
|
148
|
+
{ op: 'insert', parentId: '11', position: 'after', html: '<em>After</em>' },
|
|
149
|
+
];
|
|
150
|
+
const result = applyChangeList(annotated, changes);
|
|
151
|
+
const afterIdx = result.indexOf('<em>After</em>');
|
|
152
|
+
const pIdx = result.indexOf('<p data-node-id="11">');
|
|
153
|
+
assert.ok(afterIdx > pIdx);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('warns but does not throw on missing node for update/replace/delete', () => {
|
|
157
|
+
const changes: ChangeList = [
|
|
158
|
+
{ op: 'update', nodeId: '999', html: 'Ghost' },
|
|
159
|
+
];
|
|
160
|
+
// Should not throw
|
|
161
|
+
const result = applyChangeList(annotated, changes);
|
|
162
|
+
assert.ok(!result.includes('Ghost'));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('throws on missing parent for insert', () => {
|
|
166
|
+
const changes: ChangeList = [
|
|
167
|
+
{ op: 'insert', parentId: '999', position: 'append', html: '<em>Fail</em>' },
|
|
168
|
+
];
|
|
169
|
+
assert.throws(() => applyChangeList(annotated, changes), /not found/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('applies "style-element" — sets style attribute on unlocked element', () => {
|
|
173
|
+
const changes: ChangeList = [
|
|
174
|
+
{ op: 'style-element', nodeId: '11', style: 'color: red; font-size: 16px' },
|
|
175
|
+
];
|
|
176
|
+
const result = applyChangeList(annotated, changes);
|
|
177
|
+
assert.ok(result.includes('style="color: red; font-size: 16px"'));
|
|
178
|
+
assert.ok(result.includes('data-node-id="11"'));
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('skips "style-element" on a data-locked element', () => {
|
|
182
|
+
const lockedHtml = '<html><head></head><body>' +
|
|
183
|
+
'<div data-node-id="10"><p data-node-id="11" data-locked>Locked text</p></div>' +
|
|
184
|
+
'</body></html>';
|
|
185
|
+
const changes: ChangeList = [
|
|
186
|
+
{ op: 'style-element', nodeId: '11', style: 'color: red' },
|
|
187
|
+
];
|
|
188
|
+
const result = applyChangeList(lockedHtml, changes);
|
|
189
|
+
assert.ok(!result.includes('style="color: red"'));
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('warns but does not throw on missing node for style-element', () => {
|
|
193
|
+
const changes: ChangeList = [
|
|
194
|
+
{ op: 'style-element', nodeId: '999', style: 'color: red' },
|
|
195
|
+
];
|
|
196
|
+
const result = applyChangeList(annotated, changes);
|
|
197
|
+
assert.ok(!result.includes('color: red'));
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('allows delete of unlocked child inside a data-locked parent', () => {
|
|
201
|
+
const lockedParentHtml = '<html><head></head><body>' +
|
|
202
|
+
'<div data-node-id="10" data-locked="true">' +
|
|
203
|
+
'<p data-node-id="11">Child message</p>' +
|
|
204
|
+
'<p data-node-id="12">Another child</p>' +
|
|
205
|
+
'</div></body></html>';
|
|
206
|
+
const changes: ChangeList = [
|
|
207
|
+
{ op: 'delete', nodeId: '11' },
|
|
208
|
+
];
|
|
209
|
+
const result = applyChangeList(lockedParentHtml, changes);
|
|
210
|
+
assert.ok(!result.includes('Child message'));
|
|
211
|
+
assert.ok(result.includes('Another child'));
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('blocks delete of element that itself has data-locked', () => {
|
|
215
|
+
const lockedHtml = '<html><head></head><body>' +
|
|
216
|
+
'<div data-node-id="10"><p data-node-id="11" data-locked="true">Locked</p></div>' +
|
|
217
|
+
'</body></html>';
|
|
218
|
+
const changes: ChangeList = [
|
|
219
|
+
{ op: 'delete', nodeId: '11' },
|
|
220
|
+
];
|
|
221
|
+
const result = applyChangeList(lockedHtml, changes);
|
|
222
|
+
assert.ok(result.includes('Locked'));
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('allows replace of unlocked child inside a data-locked parent', () => {
|
|
226
|
+
const lockedParentHtml = '<html><head></head><body>' +
|
|
227
|
+
'<div data-node-id="10" data-locked="true">' +
|
|
228
|
+
'<p data-node-id="11">Old child</p>' +
|
|
229
|
+
'</div></body></html>';
|
|
230
|
+
const changes: ChangeList = [
|
|
231
|
+
{ op: 'replace', nodeId: '11', html: '<span>New child</span>' },
|
|
232
|
+
];
|
|
233
|
+
const result = applyChangeList(lockedParentHtml, changes);
|
|
234
|
+
assert.ok(result.includes('<span>New child</span>'));
|
|
235
|
+
assert.ok(!result.includes('Old child'));
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('warns but does not throw on missing node for replace', () => {
|
|
239
|
+
const changes: ChangeList = [
|
|
240
|
+
{ op: 'replace', nodeId: '999', html: '<span>Ghost</span>' },
|
|
241
|
+
];
|
|
242
|
+
const result = applyChangeList(annotated, changes);
|
|
243
|
+
assert.ok(!result.includes('Ghost'));
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('skips replace on a data-locked element', () => {
|
|
247
|
+
const lockedHtml = '<html><head></head><body>' +
|
|
248
|
+
'<div data-node-id="10"><p data-node-id="11" data-locked>Locked</p></div>' +
|
|
249
|
+
'</body></html>';
|
|
250
|
+
const changes: ChangeList = [
|
|
251
|
+
{ op: 'replace', nodeId: '11', html: '<span>Replaced</span>' },
|
|
252
|
+
];
|
|
253
|
+
const result = applyChangeList(lockedHtml, changes);
|
|
254
|
+
assert.ok(result.includes('Locked'));
|
|
255
|
+
assert.ok(!result.includes('Replaced'));
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('warns but does not throw on missing node for delete', () => {
|
|
259
|
+
const changes: ChangeList = [
|
|
260
|
+
{ op: 'delete', nodeId: '999' },
|
|
261
|
+
];
|
|
262
|
+
const result = applyChangeList(annotated, changes);
|
|
263
|
+
// Should not throw, original content preserved
|
|
264
|
+
assert.ok(result.includes('Old text'));
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('throws on unknown insert position', () => {
|
|
268
|
+
const changes = [
|
|
269
|
+
{ op: 'insert', parentId: '10', position: 'sideways', html: '<em>Oops</em>' },
|
|
270
|
+
] as unknown as ChangeList;
|
|
271
|
+
assert.throws(() => applyChangeList(annotated, changes), /unknown position/);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('throws on unknown op', () => {
|
|
275
|
+
const changes = [{ op: 'explode', nodeId: '10' }] as unknown as ChangeList;
|
|
276
|
+
assert.throws(() => applyChangeList(annotated, changes), /Unknown change op/);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
// parseChangeList
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
describe('parseChangeList', () => {
|
|
285
|
+
it('parses raw JSON array', () => {
|
|
286
|
+
const input = '[{"op":"update","nodeId":"1","html":"Hi"}]';
|
|
287
|
+
const result = parseChangeList(input);
|
|
288
|
+
assert.strictEqual(result.length, 1);
|
|
289
|
+
assert.strictEqual(result[0].op, 'update');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('parses markdown-fenced JSON', () => {
|
|
293
|
+
const input = '```json\n[{"op":"delete","nodeId":"2"}]\n```';
|
|
294
|
+
const result = parseChangeList(input);
|
|
295
|
+
assert.strictEqual(result.length, 1);
|
|
296
|
+
assert.strictEqual(result[0].op, 'delete');
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('extracts JSON array embedded in text', () => {
|
|
300
|
+
const input = 'Here are the changes:\n[{"op":"delete","nodeId":"3"}]\nDone.';
|
|
301
|
+
const result = parseChangeList(input);
|
|
302
|
+
assert.strictEqual(result.length, 1);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('throws on invalid JSON', () => {
|
|
306
|
+
assert.throws(() => parseChangeList('not json at all'), /Failed to parse/);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('throws on non-array JSON', () => {
|
|
310
|
+
assert.throws(() => parseChangeList('{"op":"update"}'), /Failed to parse/);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// injectError
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
describe('injectError', () => {
|
|
319
|
+
it('injects error script block into body', () => {
|
|
320
|
+
const html = '<html><head></head><body><p>Page</p></body></html>';
|
|
321
|
+
const result = injectError(html, 'Oops', 'details here');
|
|
322
|
+
assert.ok(result.includes('<script id="error" type="application/json">'));
|
|
323
|
+
assert.ok(result.includes('"message":"Oops"'));
|
|
324
|
+
assert.ok(result.includes('"details":"details here"'));
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('replaces existing error block', () => {
|
|
328
|
+
const html = '<html><head></head><body><script id="error" type="application/json">{"message":"old"}</script></body></html>';
|
|
329
|
+
const result = injectError(html, 'New', 'new detail');
|
|
330
|
+
// Should have exactly one error script
|
|
331
|
+
const matches = result.match(/<script id="error"/g);
|
|
332
|
+
assert.strictEqual(matches?.length, 1);
|
|
333
|
+
assert.ok(result.includes('"message":"New"'));
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('appends to end if no body tag', () => {
|
|
337
|
+
const html = '<div>No body</div>';
|
|
338
|
+
const result = injectError(html, 'Err', 'det');
|
|
339
|
+
assert.ok(result.includes('<script id="error"'));
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
// deduplicateInlineScripts
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
describe('deduplicateInlineScripts', () => {
|
|
348
|
+
it('removes the first of two exact-duplicate inline scripts', () => {
|
|
349
|
+
const script = `let count = 0;\nlet name = "test";\nfunction init() {}\nfunction render() {}`;
|
|
350
|
+
const html = `<html><head></head><body><script>${script}</script><script>${script}</script></body></html>`;
|
|
351
|
+
const result = deduplicateInlineScripts(html);
|
|
352
|
+
// Should keep exactly one script
|
|
353
|
+
const matches = result.match(/<script>/g);
|
|
354
|
+
assert.strictEqual(matches?.length, 1);
|
|
355
|
+
assert.ok(result.includes('function render'));
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('removes the first script when declarations overlap >= 60%', () => {
|
|
359
|
+
const scriptOld = `let count = 0;\nlet name = "old";\nfunction init() {}\nfunction render() {}\nfunction oldHelper() {}`;
|
|
360
|
+
const scriptNew = `let count = 0;\nlet name = "new";\nfunction init() {}\nfunction render() {}\nfunction newHelper() {}`;
|
|
361
|
+
const html = `<html><head></head><body><script>${scriptOld}</script><script>${scriptNew}</script></body></html>`;
|
|
362
|
+
const result = deduplicateInlineScripts(html);
|
|
363
|
+
const matches = result.match(/<script>/g);
|
|
364
|
+
assert.strictEqual(matches?.length, 1);
|
|
365
|
+
// Should keep the second (newer) script
|
|
366
|
+
assert.ok(result.includes('newHelper'));
|
|
367
|
+
assert.ok(!result.includes('oldHelper'));
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('preserves both scripts when declarations do not overlap', () => {
|
|
371
|
+
const scriptA = `let alpha = 1;\nlet beta = 2;\nfunction doA() {}`;
|
|
372
|
+
const scriptB = `let gamma = 3;\nlet delta = 4;\nfunction doB() {}`;
|
|
373
|
+
const html = `<html><head></head><body><script>${scriptA}</script><script>${scriptB}</script></body></html>`;
|
|
374
|
+
const result = deduplicateInlineScripts(html);
|
|
375
|
+
const matches = result.match(/<script>/g);
|
|
376
|
+
assert.strictEqual(matches?.length, 2);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('never touches scripts with id attribute', () => {
|
|
380
|
+
const script = `let count = 0;\nlet name = "test";\nfunction init() {}\nfunction render() {}`;
|
|
381
|
+
const html = `<html><head></head><body><script id="app">${script}</script><script>${script}</script></body></html>`;
|
|
382
|
+
const result = deduplicateInlineScripts(html);
|
|
383
|
+
// Both should be preserved — the id-script is exempt from dedup
|
|
384
|
+
assert.ok(result.includes('id="app"'));
|
|
385
|
+
const scriptTags = result.match(/<script/g);
|
|
386
|
+
assert.strictEqual(scriptTags?.length, 2);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('never touches scripts with src attribute', () => {
|
|
390
|
+
const html = `<html><head></head><body><script src="/app.js"></script><script src="/app.js"></script></body></html>`;
|
|
391
|
+
const result = deduplicateInlineScripts(html);
|
|
392
|
+
const matches = result.match(/<script/g);
|
|
393
|
+
assert.strictEqual(matches?.length, 2);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('never touches scripts with type="application/json"', () => {
|
|
397
|
+
const json = `{"key": "value"}`;
|
|
398
|
+
const html = `<html><head></head><body><script type="application/json">${json}</script><script type="application/json">${json}</script></body></html>`;
|
|
399
|
+
const result = deduplicateInlineScripts(html);
|
|
400
|
+
const matches = result.match(/<script/g);
|
|
401
|
+
assert.strictEqual(matches?.length, 2);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('does not remove scripts with fewer than 2 declarations', () => {
|
|
405
|
+
const scriptA = `let onlyOne = 1;`;
|
|
406
|
+
const scriptB = `let onlyOne = 1;`;
|
|
407
|
+
const html = `<html><head></head><body><script>${scriptA}</script><script>${scriptB}</script></body></html>`;
|
|
408
|
+
const result = deduplicateInlineScripts(html);
|
|
409
|
+
const matches = result.match(/<script>/g);
|
|
410
|
+
assert.strictEqual(matches?.length, 2);
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
// deduplicateInlineScripts — ID-based dedup (Pass 1)
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
describe('deduplicateInlineScripts — ID-based dedup', () => {
|
|
419
|
+
it('removes the first of two scripts with the same id', () => {
|
|
420
|
+
const html = `<html><head></head><body><script id="app">let x = 1;</script><script id="app">let x = 2;</script></body></html>`;
|
|
421
|
+
const result = deduplicateInlineScripts(html);
|
|
422
|
+
const matches = result.match(/id="app"/g);
|
|
423
|
+
assert.strictEqual(matches?.length, 1);
|
|
424
|
+
assert.ok(result.includes('let x = 2'));
|
|
425
|
+
assert.ok(!result.includes('let x = 1'));
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('keeps only the last of three scripts with the same id', () => {
|
|
429
|
+
const html = `<html><head></head><body><script id="logic">let v = 1;</script><script id="logic">let v = 2;</script><script id="logic">let v = 3;</script></body></html>`;
|
|
430
|
+
const result = deduplicateInlineScripts(html);
|
|
431
|
+
const matches = result.match(/id="logic"/g);
|
|
432
|
+
assert.strictEqual(matches?.length, 1);
|
|
433
|
+
assert.ok(result.includes('let v = 3'));
|
|
434
|
+
assert.ok(!result.includes('let v = 1'));
|
|
435
|
+
assert.ok(!result.includes('let v = 2'));
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('preserves scripts with different ids', () => {
|
|
439
|
+
const html = `<html><head></head><body><script id="alpha">let a = 1;</script><script id="beta">let b = 2;</script></body></html>`;
|
|
440
|
+
const result = deduplicateInlineScripts(html);
|
|
441
|
+
assert.ok(result.includes('id="alpha"'));
|
|
442
|
+
assert.ok(result.includes('id="beta"'));
|
|
443
|
+
assert.ok(result.includes('let a = 1'));
|
|
444
|
+
assert.ok(result.includes('let b = 2'));
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('never removes scripts with system ids even if duplicated', () => {
|
|
448
|
+
const systemIds = ['page-info', 'page-helpers', 'page-script', 'error'];
|
|
449
|
+
for (const sysId of systemIds) {
|
|
450
|
+
const html = `<html><head></head><body><script id="${sysId}">content1</script><script id="${sysId}">content2</script></body></html>`;
|
|
451
|
+
const result = deduplicateInlineScripts(html);
|
|
452
|
+
const matches = result.match(new RegExp(`id="${sysId}"`, 'g'));
|
|
453
|
+
assert.strictEqual(matches?.length, 2, `Expected 2 scripts with id="${sysId}" to be preserved`);
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// ---------------------------------------------------------------------------
|
|
459
|
+
// transformPage (integration with stub completePrompt)
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
|
|
462
|
+
describe('transformPage', () => {
|
|
463
|
+
let tmpDir: string;
|
|
464
|
+
|
|
465
|
+
// Minimal page with a viewer-panel and thoughts div
|
|
466
|
+
const testPage = `<html><head></head><body>
|
|
467
|
+
<div class="chat-panel" data-locked>
|
|
468
|
+
<div id="chatMessages"><div class="chat-message"><p><strong>SynthOS:</strong> Welcome!</p></div></div>
|
|
469
|
+
</div>
|
|
470
|
+
<div class="viewer-panel"><p id="content">Hello world</p></div>
|
|
471
|
+
<div id="thoughts" style="display: none;"></div>
|
|
472
|
+
</body></html>`;
|
|
473
|
+
|
|
474
|
+
beforeEach(async () => {
|
|
475
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'synthos-tp-test-'));
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
afterEach(async () => {
|
|
479
|
+
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
/** Extract the data-node-id for an element identified by a CSS-style attribute (e.g. id="content") from annotated HTML. */
|
|
483
|
+
function findNodeId(annotatedHtml: string, idAttr: string): string {
|
|
484
|
+
// Match a tag that contains both data-node-id="X" and the target id, in either order
|
|
485
|
+
const pattern1 = new RegExp(`data-node-id="(\\d+)"[^>]*${idAttr}`);
|
|
486
|
+
const pattern2 = new RegExp(`${idAttr}[^>]*data-node-id="(\\d+)"`);
|
|
487
|
+
const m = annotatedHtml.match(pattern1) || annotatedHtml.match(pattern2);
|
|
488
|
+
return m ? m[1] : '99999';
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function makeArgs(stub: (args: PromptCompletionArgs) => Promise<AgentCompletion<string>>, pageState?: string): TransformPageArgs {
|
|
492
|
+
return {
|
|
493
|
+
completePrompt: stub,
|
|
494
|
+
pagesFolder: tmpDir,
|
|
495
|
+
pageState: pageState ?? testPage,
|
|
496
|
+
message: 'Change the content',
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
it('happy path — applies valid change list and returns transformed HTML', async () => {
|
|
501
|
+
const stub = async (args: PromptCompletionArgs): Promise<AgentCompletion<string>> => {
|
|
502
|
+
const sys = args.system?.content ?? '';
|
|
503
|
+
const nodeId = findNodeId(sys, 'id="content"');
|
|
504
|
+
return {
|
|
505
|
+
completed: true,
|
|
506
|
+
value: JSON.stringify([
|
|
507
|
+
{ op: 'update', nodeId, html: 'Updated content' },
|
|
508
|
+
]),
|
|
509
|
+
};
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
const result = await transformPage(makeArgs(stub));
|
|
513
|
+
assert.strictEqual(result.completed, true);
|
|
514
|
+
assert.ok(result.value);
|
|
515
|
+
assert.ok(result.value.html.includes('Updated content'));
|
|
516
|
+
assert.strictEqual(result.value.changeCount, 1);
|
|
517
|
+
// Should not contain data-node-id attributes
|
|
518
|
+
assert.ok(!result.value.html.includes('data-node-id'));
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('returns error when completePrompt fails', async () => {
|
|
522
|
+
const stub = async (): Promise<AgentCompletion<string>> => ({
|
|
523
|
+
completed: false,
|
|
524
|
+
error: new Error('API quota exceeded'),
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
const result = await transformPage(makeArgs(stub));
|
|
528
|
+
assert.strictEqual(result.completed, false);
|
|
529
|
+
assert.strictEqual(result.error?.message, 'API quota exceeded');
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('injects error block when response is not valid JSON', async () => {
|
|
533
|
+
const stub = async (): Promise<AgentCompletion<string>> => ({
|
|
534
|
+
completed: true,
|
|
535
|
+
value: 'I cannot help with that request.',
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
const result = await transformPage(makeArgs(stub));
|
|
539
|
+
// Should still complete (error is injected into HTML, not thrown)
|
|
540
|
+
assert.strictEqual(result.completed, true);
|
|
541
|
+
assert.ok(result.value);
|
|
542
|
+
assert.strictEqual(result.value.changeCount, 0);
|
|
543
|
+
assert.ok(result.value.html.includes('id="error"'));
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it('handles failed ops and triggers repair pass', async () => {
|
|
547
|
+
let callCount = 0;
|
|
548
|
+
const stub = async (args: PromptCompletionArgs): Promise<AgentCompletion<string>> => {
|
|
549
|
+
callCount++;
|
|
550
|
+
const sys = args.system?.content ?? '';
|
|
551
|
+
if (callCount === 1) {
|
|
552
|
+
const contentNodeId = findNodeId(sys, 'id="content"');
|
|
553
|
+
return {
|
|
554
|
+
completed: true,
|
|
555
|
+
value: JSON.stringify([
|
|
556
|
+
{ op: 'update', nodeId: contentNodeId, html: 'First pass change' },
|
|
557
|
+
{ op: 'update', nodeId: '9999', html: 'Ghost node' }, // will fail
|
|
558
|
+
]),
|
|
559
|
+
};
|
|
560
|
+
} else {
|
|
561
|
+
// Repair call: target an element that exists in re-annotated HTML
|
|
562
|
+
const thoughtsNodeId = findNodeId(sys, 'id="thoughts"');
|
|
563
|
+
return {
|
|
564
|
+
completed: true,
|
|
565
|
+
value: JSON.stringify([
|
|
566
|
+
{ op: 'update', nodeId: thoughtsNodeId, html: 'Repaired content' },
|
|
567
|
+
]),
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
const result = await transformPage(makeArgs(stub));
|
|
573
|
+
assert.strictEqual(result.completed, true);
|
|
574
|
+
assert.ok(result.value);
|
|
575
|
+
// Should have made 2 calls (initial + repair)
|
|
576
|
+
assert.strictEqual(callCount, 2);
|
|
577
|
+
// changeCount should be 2 (1 success from first pass + 1 from repair)
|
|
578
|
+
assert.strictEqual(result.value.changeCount, 2);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it('keeps partial result when repair pass LLM call fails', async () => {
|
|
582
|
+
let callCount = 0;
|
|
583
|
+
const stub = async (args: PromptCompletionArgs): Promise<AgentCompletion<string>> => {
|
|
584
|
+
callCount++;
|
|
585
|
+
if (callCount === 1) {
|
|
586
|
+
const sys = args.system?.content ?? '';
|
|
587
|
+
const contentNodeId = findNodeId(sys, 'id="content"');
|
|
588
|
+
return {
|
|
589
|
+
completed: true,
|
|
590
|
+
value: JSON.stringify([
|
|
591
|
+
{ op: 'update', nodeId: contentNodeId, html: 'Partial update' },
|
|
592
|
+
{ op: 'update', nodeId: '9999', html: 'Ghost' },
|
|
593
|
+
]),
|
|
594
|
+
};
|
|
595
|
+
} else {
|
|
596
|
+
// Repair call fails
|
|
597
|
+
return { completed: false, error: new Error('Repair failed') };
|
|
598
|
+
}
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
const result = await transformPage(makeArgs(stub));
|
|
602
|
+
assert.strictEqual(result.completed, true);
|
|
603
|
+
assert.ok(result.value);
|
|
604
|
+
assert.ok(result.value.html.includes('Partial update'));
|
|
605
|
+
assert.strictEqual(result.value.changeCount, 1);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('handles repair pass returning empty array (no repairs needed)', async () => {
|
|
609
|
+
let callCount = 0;
|
|
610
|
+
const stub = async (args: PromptCompletionArgs): Promise<AgentCompletion<string>> => {
|
|
611
|
+
callCount++;
|
|
612
|
+
if (callCount === 1) {
|
|
613
|
+
return {
|
|
614
|
+
completed: true,
|
|
615
|
+
value: JSON.stringify([
|
|
616
|
+
{ op: 'update', nodeId: '9999', html: 'Ghost' },
|
|
617
|
+
]),
|
|
618
|
+
};
|
|
619
|
+
} else {
|
|
620
|
+
return { completed: true, value: '[]' };
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
const result = await transformPage(makeArgs(stub));
|
|
625
|
+
assert.strictEqual(result.completed, true);
|
|
626
|
+
assert.strictEqual(callCount, 2);
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it('includes instructions and modelInstructions in prompt', async () => {
|
|
630
|
+
let capturedPrompt: string | undefined;
|
|
631
|
+
const stub = async (args: PromptCompletionArgs): Promise<AgentCompletion<string>> => {
|
|
632
|
+
capturedPrompt = args.prompt.content;
|
|
633
|
+
return { completed: true, value: '[]' };
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
await transformPage({
|
|
637
|
+
...makeArgs(stub),
|
|
638
|
+
instructions: 'Be creative',
|
|
639
|
+
modelInstructions: 'Return concise JSON',
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
assert.ok(capturedPrompt);
|
|
643
|
+
assert.ok(capturedPrompt.includes('Be creative'));
|
|
644
|
+
assert.ok(capturedPrompt.includes('Return concise JSON'));
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it('includes theme info in system prompt when provided', async () => {
|
|
648
|
+
let capturedSystem: string | undefined;
|
|
649
|
+
const stub = async (args: PromptCompletionArgs): Promise<AgentCompletion<string>> => {
|
|
650
|
+
capturedSystem = args.system?.content;
|
|
651
|
+
return { completed: true, value: '[]' };
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
await transformPage({
|
|
655
|
+
...makeArgs(stub),
|
|
656
|
+
themeInfo: {
|
|
657
|
+
mode: 'dark',
|
|
658
|
+
colors: { 'accent-primary': '#ff0000', 'text-primary': '#ffffff' },
|
|
659
|
+
},
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
assert.ok(capturedSystem);
|
|
663
|
+
assert.ok(capturedSystem.includes('Mode: dark'));
|
|
664
|
+
assert.ok(capturedSystem.includes('--accent-primary: #ff0000'));
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it('includes connector info in system prompt when provided', async () => {
|
|
668
|
+
let capturedSystem: string | undefined;
|
|
669
|
+
const stub = async (args: PromptCompletionArgs): Promise<AgentCompletion<string>> => {
|
|
670
|
+
capturedSystem = args.system?.content;
|
|
671
|
+
return { completed: true, value: '[]' };
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
await transformPage({
|
|
675
|
+
...makeArgs(stub),
|
|
676
|
+
configuredConnectors: {
|
|
677
|
+
'brave-search': { enabled: true, apiKey: 'test-key' } as any,
|
|
678
|
+
},
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
assert.ok(capturedSystem);
|
|
682
|
+
assert.ok(capturedSystem.includes('CONFIGURED_CONNECTORS'));
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it('includes agent info in system prompt when provided', async () => {
|
|
686
|
+
let capturedSystem: string | undefined;
|
|
687
|
+
const stub = async (args: PromptCompletionArgs): Promise<AgentCompletion<string>> => {
|
|
688
|
+
capturedSystem = args.system?.content;
|
|
689
|
+
return { completed: true, value: '[]' };
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
await transformPage({
|
|
693
|
+
...makeArgs(stub),
|
|
694
|
+
configuredAgents: [{
|
|
695
|
+
id: 'test-agent',
|
|
696
|
+
name: 'Test Agent',
|
|
697
|
+
description: 'A test agent',
|
|
698
|
+
url: 'http://localhost:3000',
|
|
699
|
+
enabled: true,
|
|
700
|
+
provider: 'a2a' as any,
|
|
701
|
+
}],
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
assert.ok(capturedSystem);
|
|
705
|
+
assert.ok(capturedSystem.includes('CONFIGURED_AGENTS'));
|
|
706
|
+
assert.ok(capturedSystem.includes('Test Agent'));
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it('includes agent capabilities and skills in system prompt', async () => {
|
|
710
|
+
let capturedSystem: string | undefined;
|
|
711
|
+
const stub = async (args: PromptCompletionArgs): Promise<AgentCompletion<string>> => {
|
|
712
|
+
capturedSystem = args.system?.content;
|
|
713
|
+
return { completed: true, value: '[]' };
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
await transformPage({
|
|
717
|
+
...makeArgs(stub),
|
|
718
|
+
configuredAgents: [{
|
|
719
|
+
id: 'agent-1',
|
|
720
|
+
name: 'Streaming Agent',
|
|
721
|
+
description: 'Agent with streaming',
|
|
722
|
+
url: 'http://localhost:3000',
|
|
723
|
+
enabled: true,
|
|
724
|
+
provider: 'a2a',
|
|
725
|
+
capabilities: { streaming: true },
|
|
726
|
+
skills: [
|
|
727
|
+
{ id: 'summarize', name: 'summarize', description: 'Summarizes text', tags: [] },
|
|
728
|
+
],
|
|
729
|
+
}],
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
assert.ok(capturedSystem);
|
|
733
|
+
assert.ok(capturedSystem.includes('Supports streaming: yes'));
|
|
734
|
+
assert.ok(capturedSystem.includes('summarize'));
|
|
735
|
+
assert.ok(capturedSystem.includes('Summarizes text'));
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it('exercises replace, delete, insert, and style-element ops through pipeline', async () => {
|
|
739
|
+
const stub = async (args: PromptCompletionArgs): Promise<AgentCompletion<string>> => {
|
|
740
|
+
const sys = args.system?.content ?? '';
|
|
741
|
+
const contentNodeId = findNodeId(sys, 'id="content"');
|
|
742
|
+
const viewerNodeId = findNodeId(sys, 'class="viewer-panel"');
|
|
743
|
+
return {
|
|
744
|
+
completed: true,
|
|
745
|
+
value: JSON.stringify([
|
|
746
|
+
{ op: 'replace', nodeId: contentNodeId, html: '<p id="content">Replaced</p>' },
|
|
747
|
+
{ op: 'insert', parentId: viewerNodeId, position: 'append', html: '<span>Appended</span>' },
|
|
748
|
+
{ op: 'style-element', nodeId: viewerNodeId, style: 'background: blue' },
|
|
749
|
+
]),
|
|
750
|
+
};
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
const result = await transformPage(makeArgs(stub));
|
|
754
|
+
assert.strictEqual(result.completed, true);
|
|
755
|
+
assert.ok(result.value);
|
|
756
|
+
assert.ok(result.value.html.includes('Replaced'));
|
|
757
|
+
assert.ok(result.value.html.includes('Appended'));
|
|
758
|
+
assert.ok(result.value.html.includes('background: blue'));
|
|
759
|
+
assert.strictEqual(result.value.changeCount, 3);
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
it('reports failed replace on locked element and triggers repair', async () => {
|
|
763
|
+
// Use a page where the content is data-locked
|
|
764
|
+
const lockedPage = `<html><head></head><body>
|
|
765
|
+
<div class="chat-panel" data-locked>
|
|
766
|
+
<div id="chatMessages"><div class="chat-message"><p><strong>SynthOS:</strong> Welcome!</p></div></div>
|
|
767
|
+
</div>
|
|
768
|
+
<div class="viewer-panel"><p id="content" data-locked>Locked content</p></div>
|
|
769
|
+
<div id="thoughts" style="display: none;"></div>
|
|
770
|
+
</body></html>`;
|
|
771
|
+
|
|
772
|
+
let callCount = 0;
|
|
773
|
+
const stub = async (args: PromptCompletionArgs): Promise<AgentCompletion<string>> => {
|
|
774
|
+
callCount++;
|
|
775
|
+
const sys = args.system?.content ?? '';
|
|
776
|
+
if (callCount === 1) {
|
|
777
|
+
const contentNodeId = findNodeId(sys, 'id="content"');
|
|
778
|
+
return {
|
|
779
|
+
completed: true,
|
|
780
|
+
value: JSON.stringify([
|
|
781
|
+
{ op: 'replace', nodeId: contentNodeId, html: '<p>Should fail</p>' },
|
|
782
|
+
]),
|
|
783
|
+
};
|
|
784
|
+
} else {
|
|
785
|
+
// Repair: return empty array (nothing to fix)
|
|
786
|
+
return { completed: true, value: '[]' };
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
const result = await transformPage(makeArgs(stub, lockedPage));
|
|
791
|
+
assert.strictEqual(result.completed, true);
|
|
792
|
+
assert.strictEqual(callCount, 2);
|
|
793
|
+
// Original locked content should still be present
|
|
794
|
+
assert.ok(result.value!.html.includes('Locked content'));
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it('handles repair pass that throws an error', async () => {
|
|
798
|
+
let callCount = 0;
|
|
799
|
+
const stub = async (args: PromptCompletionArgs): Promise<AgentCompletion<string>> => {
|
|
800
|
+
callCount++;
|
|
801
|
+
if (callCount === 1) {
|
|
802
|
+
return {
|
|
803
|
+
completed: true,
|
|
804
|
+
value: JSON.stringify([
|
|
805
|
+
{ op: 'update', nodeId: '9999', html: 'Ghost' },
|
|
806
|
+
]),
|
|
807
|
+
};
|
|
808
|
+
} else {
|
|
809
|
+
// Repair call returns invalid JSON that will cause parseChangeList to throw
|
|
810
|
+
return { completed: true, value: 'not valid json at all' };
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
const result = await transformPage(makeArgs(stub));
|
|
815
|
+
assert.strictEqual(result.completed, true);
|
|
816
|
+
assert.strictEqual(callCount, 2);
|
|
817
|
+
// Should have kept partial result from first pass
|
|
818
|
+
assert.ok(result.value);
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
it('reports failed insert on missing parent through pipeline', async () => {
|
|
822
|
+
let callCount = 0;
|
|
823
|
+
const stub = async (args: PromptCompletionArgs): Promise<AgentCompletion<string>> => {
|
|
824
|
+
callCount++;
|
|
825
|
+
if (callCount === 1) {
|
|
826
|
+
return {
|
|
827
|
+
completed: true,
|
|
828
|
+
value: JSON.stringify([
|
|
829
|
+
{ op: 'insert', parentId: '9999', position: 'append', html: '<span>Ghost</span>' },
|
|
830
|
+
]),
|
|
831
|
+
};
|
|
832
|
+
} else {
|
|
833
|
+
return { completed: true, value: '[]' };
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
const result = await transformPage(makeArgs(stub));
|
|
838
|
+
assert.strictEqual(result.completed, true);
|
|
839
|
+
assert.strictEqual(callCount, 2);
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it('reports failed delete on locked element through pipeline', async () => {
|
|
843
|
+
const lockedPage = `<html><head></head><body>
|
|
844
|
+
<div class="chat-panel" data-locked>
|
|
845
|
+
<div id="chatMessages"><div class="chat-message"><p><strong>SynthOS:</strong> Welcome!</p></div></div>
|
|
846
|
+
</div>
|
|
847
|
+
<div class="viewer-panel"><p id="content" data-locked>Locked</p></div>
|
|
848
|
+
<div id="thoughts" style="display: none;"></div>
|
|
849
|
+
</body></html>`;
|
|
850
|
+
|
|
851
|
+
let callCount = 0;
|
|
852
|
+
const stub = async (args: PromptCompletionArgs): Promise<AgentCompletion<string>> => {
|
|
853
|
+
callCount++;
|
|
854
|
+
const sys = args.system?.content ?? '';
|
|
855
|
+
if (callCount === 1) {
|
|
856
|
+
const contentNodeId = findNodeId(sys, 'id="content"');
|
|
857
|
+
return {
|
|
858
|
+
completed: true,
|
|
859
|
+
value: JSON.stringify([
|
|
860
|
+
{ op: 'delete', nodeId: contentNodeId },
|
|
861
|
+
]),
|
|
862
|
+
};
|
|
863
|
+
} else {
|
|
864
|
+
return { completed: true, value: '[]' };
|
|
865
|
+
}
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
const result = await transformPage(makeArgs(stub, lockedPage));
|
|
869
|
+
assert.strictEqual(result.completed, true);
|
|
870
|
+
assert.ok(result.value!.html.includes('Locked'));
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
it('reports failed style-element on locked element through pipeline', async () => {
|
|
874
|
+
const lockedPage = `<html><head></head><body>
|
|
875
|
+
<div class="chat-panel" data-locked>
|
|
876
|
+
<div id="chatMessages"><div class="chat-message"><p><strong>SynthOS:</strong> Welcome!</p></div></div>
|
|
877
|
+
</div>
|
|
878
|
+
<div class="viewer-panel"><p id="content" data-locked>Styled</p></div>
|
|
879
|
+
<div id="thoughts" style="display: none;"></div>
|
|
880
|
+
</body></html>`;
|
|
881
|
+
|
|
882
|
+
let callCount = 0;
|
|
883
|
+
const stub = async (args: PromptCompletionArgs): Promise<AgentCompletion<string>> => {
|
|
884
|
+
callCount++;
|
|
885
|
+
const sys = args.system?.content ?? '';
|
|
886
|
+
if (callCount === 1) {
|
|
887
|
+
const contentNodeId = findNodeId(sys, 'id="content"');
|
|
888
|
+
return {
|
|
889
|
+
completed: true,
|
|
890
|
+
value: JSON.stringify([
|
|
891
|
+
{ op: 'style-element', nodeId: contentNodeId, style: 'color: red' },
|
|
892
|
+
]),
|
|
893
|
+
};
|
|
894
|
+
} else {
|
|
895
|
+
return { completed: true, value: '[]' };
|
|
896
|
+
}
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
const result = await transformPage(makeArgs(stub, lockedPage));
|
|
900
|
+
assert.strictEqual(result.completed, true);
|
|
901
|
+
// Locked element should not have the style applied
|
|
902
|
+
assert.ok(!result.value!.html.includes('color: red'));
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it('repair pass with remaining failures keeps partial result', async () => {
|
|
906
|
+
let callCount = 0;
|
|
907
|
+
const stub = async (args: PromptCompletionArgs): Promise<AgentCompletion<string>> => {
|
|
908
|
+
callCount++;
|
|
909
|
+
if (callCount === 1) {
|
|
910
|
+
return {
|
|
911
|
+
completed: true,
|
|
912
|
+
value: JSON.stringify([
|
|
913
|
+
{ op: 'update', nodeId: '9999', html: 'Ghost' },
|
|
914
|
+
]),
|
|
915
|
+
};
|
|
916
|
+
} else {
|
|
917
|
+
// Repair also fails — targets non-existent node
|
|
918
|
+
return {
|
|
919
|
+
completed: true,
|
|
920
|
+
value: JSON.stringify([
|
|
921
|
+
{ op: 'update', nodeId: '8888', html: 'Still ghost' },
|
|
922
|
+
]),
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
};
|
|
926
|
+
|
|
927
|
+
const result = await transformPage(makeArgs(stub));
|
|
928
|
+
assert.strictEqual(result.completed, true);
|
|
929
|
+
assert.strictEqual(callCount, 2);
|
|
930
|
+
});
|
|
931
|
+
});
|