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.
Files changed (263) hide show
  1. package/README.md +215 -65
  2. package/default-pages/application.json +1 -0
  3. package/default-pages/json_tools.json +1 -1
  4. package/default-pages/oregon_trail.html +321 -0
  5. package/default-pages/oregon_trail.json +12 -0
  6. package/default-pages/sidebar_page.json +1 -0
  7. package/default-pages/solar_explorer.html +10 -18
  8. package/default-pages/solar_explorer.json +2 -2
  9. package/default-pages/two-panel_page.json +1 -0
  10. package/default-pages/us_map.html +192 -0
  11. package/default-pages/us_map.json +12 -0
  12. package/default-pages/us_map_1850.html +325 -0
  13. package/default-pages/us_map_1850.json +12 -0
  14. package/default-pages/western_cities_1850.html +526 -0
  15. package/default-pages/western_cities_1850.json +12 -0
  16. package/default-themes/{nebula-dawn.css → nebula-dawn.v2.css} +24 -0
  17. package/default-themes/{nebula-dusk.css → nebula-dusk.v2.css} +24 -0
  18. package/dist/agents/a2a/a2aProvider.d.ts +3 -0
  19. package/dist/agents/a2a/a2aProvider.d.ts.map +1 -0
  20. package/dist/agents/a2a/a2aProvider.js +126 -0
  21. package/dist/agents/a2a/a2aProvider.js.map +1 -0
  22. package/dist/agents/discovery.d.ts +30 -0
  23. package/dist/agents/discovery.d.ts.map +1 -0
  24. package/dist/agents/discovery.js +52 -0
  25. package/dist/agents/discovery.js.map +1 -0
  26. package/dist/agents/index.d.ts +7 -0
  27. package/dist/agents/index.d.ts.map +1 -0
  28. package/dist/agents/index.js +19 -0
  29. package/dist/agents/index.js.map +1 -0
  30. package/dist/agents/openclaw/gatewayManager.d.ts +113 -0
  31. package/dist/agents/openclaw/gatewayManager.d.ts.map +1 -0
  32. package/dist/agents/openclaw/gatewayManager.js +470 -0
  33. package/dist/agents/openclaw/gatewayManager.js.map +1 -0
  34. package/dist/agents/openclaw/openclawProvider.d.ts +3 -0
  35. package/dist/agents/openclaw/openclawProvider.d.ts.map +1 -0
  36. package/dist/agents/openclaw/openclawProvider.js +239 -0
  37. package/dist/agents/openclaw/openclawProvider.js.map +1 -0
  38. package/dist/agents/openclaw/sshTunnelManager.d.ts +23 -0
  39. package/dist/agents/openclaw/sshTunnelManager.d.ts.map +1 -0
  40. package/dist/agents/openclaw/sshTunnelManager.js +340 -0
  41. package/dist/agents/openclaw/sshTunnelManager.js.map +1 -0
  42. package/dist/agents/types.d.ts +64 -0
  43. package/dist/agents/types.d.ts.map +1 -0
  44. package/dist/agents/types.js +6 -0
  45. package/dist/agents/types.js.map +1 -0
  46. package/dist/connectors/airtable/connector.json +27 -0
  47. package/dist/connectors/alpha-vantage/connector.json +26 -0
  48. package/dist/connectors/brave-search/connector.json +26 -0
  49. package/dist/connectors/cloudinary/connector.json +27 -0
  50. package/dist/connectors/deepl/connector.json +28 -0
  51. package/dist/connectors/elevenlabs/connector.json +30 -0
  52. package/dist/connectors/giphy/connector.json +27 -0
  53. package/dist/connectors/github/connector.json +29 -0
  54. package/dist/connectors/huggingface/connector.json +27 -0
  55. package/dist/connectors/imgur/connector.json +29 -0
  56. package/dist/connectors/index.d.ts +1 -1
  57. package/dist/connectors/index.d.ts.map +1 -1
  58. package/dist/connectors/instagram/connector.json +43 -0
  59. package/dist/connectors/jira/connector.json +28 -0
  60. package/dist/connectors/mapbox/connector.json +26 -0
  61. package/dist/connectors/nasa/connector.json +27 -0
  62. package/dist/connectors/newsapi/connector.json +27 -0
  63. package/dist/connectors/notion/connector.json +28 -0
  64. package/dist/connectors/open-exchange-rates/connector.json +27 -0
  65. package/dist/connectors/openweathermap/connector.json +26 -0
  66. package/dist/connectors/pexels/connector.json +27 -0
  67. package/dist/connectors/registry.d.ts.map +1 -1
  68. package/dist/connectors/registry.js +42 -96
  69. package/dist/connectors/registry.js.map +1 -1
  70. package/dist/connectors/resend/connector.json +29 -0
  71. package/dist/connectors/rss2json/connector.json +27 -0
  72. package/dist/connectors/sendgrid/connector.json +27 -0
  73. package/dist/connectors/spoonacular/connector.json +28 -0
  74. package/dist/connectors/stability-ai/connector.json +27 -0
  75. package/dist/connectors/twilio/connector.json +28 -0
  76. package/dist/connectors/types.d.ts +23 -0
  77. package/dist/connectors/types.d.ts.map +1 -1
  78. package/dist/connectors/unsplash/connector.json +27 -0
  79. package/dist/connectors/wolfram-alpha/connector.json +26 -0
  80. package/dist/connectors/youtube-data/connector.json +30 -0
  81. package/dist/files.d.ts +1 -0
  82. package/dist/files.d.ts.map +1 -1
  83. package/dist/files.js +16 -1
  84. package/dist/files.js.map +1 -1
  85. package/dist/init.d.ts.map +1 -1
  86. package/dist/init.js +28 -0
  87. package/dist/init.js.map +1 -1
  88. package/dist/migrations.d.ts +3 -2
  89. package/dist/migrations.d.ts.map +1 -1
  90. package/dist/migrations.js +122 -138
  91. package/dist/migrations.js.map +1 -1
  92. package/dist/models/anthropic.d.ts +22 -0
  93. package/dist/models/anthropic.d.ts.map +1 -0
  94. package/dist/models/anthropic.js +76 -0
  95. package/dist/models/anthropic.js.map +1 -0
  96. package/dist/models/chainOfThought.d.ts +12 -0
  97. package/dist/models/chainOfThought.d.ts.map +1 -0
  98. package/dist/models/chainOfThought.js +45 -0
  99. package/dist/models/chainOfThought.js.map +1 -0
  100. package/dist/models/fireworksai.d.ts +30 -0
  101. package/dist/models/fireworksai.d.ts.map +1 -0
  102. package/dist/models/fireworksai.js +133 -0
  103. package/dist/models/fireworksai.js.map +1 -0
  104. package/dist/models/index.d.ts +7 -1
  105. package/dist/models/index.d.ts.map +1 -1
  106. package/dist/models/index.js +19 -1
  107. package/dist/models/index.js.map +1 -1
  108. package/dist/models/logCompletePrompt.d.ts +3 -0
  109. package/dist/models/logCompletePrompt.d.ts.map +1 -0
  110. package/dist/models/logCompletePrompt.js +23 -0
  111. package/dist/models/logCompletePrompt.js.map +1 -0
  112. package/dist/models/openai.d.ts +24 -0
  113. package/dist/models/openai.d.ts.map +1 -0
  114. package/dist/models/openai.js +80 -0
  115. package/dist/models/openai.js.map +1 -0
  116. package/dist/models/providers.d.ts +1 -0
  117. package/dist/models/providers.d.ts.map +1 -1
  118. package/dist/models/providers.js +12 -4
  119. package/dist/models/providers.js.map +1 -1
  120. package/dist/models/types.d.ts +34 -2
  121. package/dist/models/types.d.ts.map +1 -1
  122. package/dist/models/types.js +16 -0
  123. package/dist/models/types.js.map +1 -1
  124. package/dist/models/utils.d.ts +6 -0
  125. package/dist/models/utils.d.ts.map +1 -0
  126. package/dist/models/utils.js +21 -0
  127. package/dist/models/utils.js.map +1 -0
  128. package/dist/scripts.d.ts +2 -1
  129. package/dist/scripts.d.ts.map +1 -1
  130. package/dist/scripts.js +4 -3
  131. package/dist/scripts.js.map +1 -1
  132. package/dist/service/createCompletePrompt.d.ts +1 -1
  133. package/dist/service/createCompletePrompt.d.ts.map +1 -1
  134. package/dist/service/createCompletePrompt.js +9 -6
  135. package/dist/service/createCompletePrompt.js.map +1 -1
  136. package/dist/service/generateImage.d.ts +1 -1
  137. package/dist/service/generateImage.d.ts.map +1 -1
  138. package/dist/service/generateImage.js +3 -3
  139. package/dist/service/generateImage.js.map +1 -1
  140. package/dist/service/server.d.ts.map +1 -1
  141. package/dist/service/server.js +3 -0
  142. package/dist/service/server.js.map +1 -1
  143. package/dist/service/transformPage.d.ts +4 -2
  144. package/dist/service/transformPage.d.ts.map +1 -1
  145. package/dist/service/transformPage.js +74 -6
  146. package/dist/service/transformPage.js.map +1 -1
  147. package/dist/service/useAgentRoutes.d.ts +4 -0
  148. package/dist/service/useAgentRoutes.d.ts.map +1 -0
  149. package/dist/service/useAgentRoutes.js +389 -0
  150. package/dist/service/useAgentRoutes.js.map +1 -0
  151. package/dist/service/useApiRoutes.d.ts.map +1 -1
  152. package/dist/service/useApiRoutes.js +157 -16
  153. package/dist/service/useApiRoutes.js.map +1 -1
  154. package/dist/service/useConnectorRoutes.d.ts.map +1 -1
  155. package/dist/service/useConnectorRoutes.js +14 -3
  156. package/dist/service/useConnectorRoutes.js.map +1 -1
  157. package/dist/service/useGatewayRoutes.d.ts +4 -0
  158. package/dist/service/useGatewayRoutes.d.ts.map +1 -0
  159. package/dist/service/useGatewayRoutes.js +168 -0
  160. package/dist/service/useGatewayRoutes.js.map +1 -0
  161. package/dist/service/usePageRoutes.d.ts.map +1 -1
  162. package/dist/service/usePageRoutes.js +16 -5
  163. package/dist/service/usePageRoutes.js.map +1 -1
  164. package/dist/settings.d.ts +2 -1
  165. package/dist/settings.d.ts.map +1 -1
  166. package/dist/settings.js +4 -8
  167. package/dist/settings.js.map +1 -1
  168. package/dist/themes.d.ts +14 -0
  169. package/dist/themes.d.ts.map +1 -1
  170. package/dist/themes.js +86 -13
  171. package/dist/themes.js.map +1 -1
  172. package/package.json +10 -5
  173. package/page-scripts/helpers-v2.js +222 -0
  174. package/page-scripts/page-v2.js +656 -0
  175. package/required-pages/builder.html +1 -27
  176. package/required-pages/pages.html +745 -22
  177. package/required-pages/settings.html +819 -21
  178. package/required-pages/synthos_apis.html +56 -1
  179. package/src/agents/a2a/a2aProvider.ts +110 -0
  180. package/src/agents/discovery.ts +74 -0
  181. package/src/agents/index.ts +6 -0
  182. package/src/agents/openclaw/gatewayManager.ts +559 -0
  183. package/src/agents/openclaw/openclawProvider.ts +261 -0
  184. package/src/agents/openclaw/sshTunnelManager.ts +385 -0
  185. package/src/agents/types.ts +82 -0
  186. package/src/connectors/airtable/connector.json +27 -0
  187. package/src/connectors/alpha-vantage/connector.json +26 -0
  188. package/src/connectors/brave-search/connector.json +26 -0
  189. package/src/connectors/cloudinary/connector.json +27 -0
  190. package/src/connectors/deepl/connector.json +28 -0
  191. package/src/connectors/elevenlabs/connector.json +30 -0
  192. package/src/connectors/giphy/connector.json +27 -0
  193. package/src/connectors/github/connector.json +29 -0
  194. package/src/connectors/huggingface/connector.json +27 -0
  195. package/src/connectors/imgur/connector.json +29 -0
  196. package/src/connectors/index.ts +2 -0
  197. package/src/connectors/instagram/connector.json +43 -0
  198. package/src/connectors/jira/connector.json +28 -0
  199. package/src/connectors/mapbox/connector.json +26 -0
  200. package/src/connectors/nasa/connector.json +27 -0
  201. package/src/connectors/newsapi/connector.json +27 -0
  202. package/src/connectors/notion/connector.json +28 -0
  203. package/src/connectors/open-exchange-rates/connector.json +27 -0
  204. package/src/connectors/openweathermap/connector.json +26 -0
  205. package/src/connectors/pexels/connector.json +27 -0
  206. package/src/connectors/registry.ts +21 -97
  207. package/src/connectors/resend/connector.json +29 -0
  208. package/src/connectors/rss2json/connector.json +27 -0
  209. package/src/connectors/sendgrid/connector.json +27 -0
  210. package/src/connectors/spoonacular/connector.json +28 -0
  211. package/src/connectors/stability-ai/connector.json +27 -0
  212. package/src/connectors/twilio/connector.json +28 -0
  213. package/src/connectors/types.ts +25 -0
  214. package/src/connectors/unsplash/connector.json +27 -0
  215. package/src/connectors/wolfram-alpha/connector.json +26 -0
  216. package/src/connectors/youtube-data/connector.json +30 -0
  217. package/src/files.ts +14 -0
  218. package/src/init.ts +27 -0
  219. package/src/migrations.ts +121 -138
  220. package/src/models/anthropic.ts +89 -0
  221. package/src/models/chainOfThought.ts +56 -0
  222. package/src/models/fireworksai.ts +136 -0
  223. package/src/models/index.ts +7 -1
  224. package/src/models/logCompletePrompt.ts +25 -0
  225. package/src/models/openai.ts +90 -0
  226. package/src/models/providers.ts +12 -3
  227. package/src/models/types.ts +67 -2
  228. package/src/models/utils.ts +16 -0
  229. package/src/scripts.ts +2 -2
  230. package/src/service/createCompletePrompt.ts +3 -1
  231. package/src/service/generateImage.ts +2 -2
  232. package/src/service/server.ts +4 -0
  233. package/src/service/transformPage.ts +81 -8
  234. package/src/service/useAgentRoutes.ts +423 -0
  235. package/src/service/useApiRoutes.ts +173 -18
  236. package/src/service/useConnectorRoutes.ts +14 -3
  237. package/src/service/usePageRoutes.ts +20 -6
  238. package/src/settings.ts +6 -10
  239. package/src/themes.ts +84 -12
  240. package/tests/README.md +12 -0
  241. package/tests/anthropic.spec.ts +84 -0
  242. package/tests/chainOfThought.spec.ts +108 -0
  243. package/tests/ensureScripts.spec.ts +82 -0
  244. package/tests/files.spec.ts +233 -0
  245. package/tests/fireworksai.spec.ts +92 -0
  246. package/tests/logCompletePrompt.spec.ts +74 -0
  247. package/tests/migrations.spec.ts +169 -0
  248. package/tests/openai.spec.ts +71 -0
  249. package/tests/pages.spec.ts +328 -0
  250. package/tests/providers.spec.ts +144 -0
  251. package/tests/scripts.spec.ts +209 -0
  252. package/tests/transformPage.spec.ts +931 -0
  253. package/tests/types.spec.ts +23 -0
  254. package/default-pages/app_builder.json +0 -1
  255. package/default-pages/sidebar_builder.json +0 -1
  256. package/default-pages/two-panel_builder.json +0 -1
  257. package/images/home.png +0 -0
  258. package/images/page-management.png +0 -0
  259. package/images/settings.png +0 -0
  260. package/images/synthos-square.png +0 -0
  261. /package/default-pages/{app_builder.html → application.html} +0 -0
  262. /package/default-pages/{sidebar_builder.html → sidebar_page.html} +0 -0
  263. /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
+ });