shadow-claw 1.52.0 → 1.52.1

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 (112) hide show
  1. package/.well-known/mcp/server-card.json +1 -1
  2. package/.well-known/mcp.json +1 -1
  3. package/README.md +9 -9
  4. package/dist/cli/build/build.js +1 -1
  5. package/dist/cli/{chunk-DYq-orHK.js → chunk-BgTv9w0K.js} +1 -1
  6. package/dist/cli/{chunk-C6jii2Fl.js → chunk-d5hIhj8t.js} +1 -1
  7. package/dist/cli/cli.js +1 -1
  8. package/dist/lib/components/index.js +1 -1
  9. package/dist/lib/components/shadow-claw-pages.js +1 -1
  10. package/dist/lib/index.js +1 -1
  11. package/dist/lib/{shadow-claw-pages-kA2qa-4B.js → shadow-claw-pages-CjHS50HN.js} +2 -2
  12. package/dist/lib/{shadow-claw-pages-kA2qa-4B.js.map → shadow-claw-pages-CjHS50HN.js.map} +1 -1
  13. package/dist/public/.well-known/mcp/server-card.json +1 -1
  14. package/dist/public/.well-known/mcp.json +1 -1
  15. package/dist/public/AGENTS.md +1 -1
  16. package/dist/public/README.md +9 -9
  17. package/dist/public/docs/README.md +1 -1
  18. package/dist/public/docs/architecture/orchestrator.md +2 -2
  19. package/dist/public/docs/architecture/overview.md +3 -3
  20. package/dist/public/docs/architecture/worker-protocol.md +29 -29
  21. package/dist/public/docs/example/article/index.html +1 -1
  22. package/dist/public/docs/example/article.html +1 -1
  23. package/dist/public/docs/publishing/index.html +1 -1
  24. package/dist/public/docs/publishing.html +1 -1
  25. package/dist/public/docs/skill-creator/index.html +1 -1
  26. package/dist/public/docs/skill-creator.html +1 -1
  27. package/dist/public/docs/subsystems/local-models.md +1 -1
  28. package/dist/public/docs/subsystems/providers.md +11 -4
  29. package/dist/public/e2e/README.md +2 -2
  30. package/dist/public/index.html +1 -1
  31. package/dist/public/index.js +1 -1
  32. package/dist/public/llms.txt +1 -1
  33. package/dist/public/main/index.html +1 -1
  34. package/dist/public/main/memory/index.html +1 -1
  35. package/dist/public/main/memory.html +1 -1
  36. package/dist/public/main.html +1 -1
  37. package/dist/public/service-worker.js +1 -1
  38. package/dist/public/{shadow-claw-Ba2ZFE9m.js → shadow-claw-T9oPpqxT.js} +1 -1
  39. package/dist/public/{shadow-claw-pages-Cy6T0G9y.js → shadow-claw-pages-IW_LNEL_.js} +1 -1
  40. package/package.json +1 -1
  41. package/src/cli/build/build.test.ts +95 -0
  42. package/src/cli/build/build.ts +5 -1
  43. package/src/cli/commands/agent.test.ts +134 -0
  44. package/src/cli/commands/backup.test.ts +85 -0
  45. package/src/cli/commands/clients.test.ts +32 -0
  46. package/src/cli/commands/mcp.test.ts +260 -1
  47. package/src/cli/commands/send-file.test.ts +73 -0
  48. package/src/cli/commands/send.test.ts +58 -0
  49. package/src/cli/commands/tasks.test.ts +78 -0
  50. package/src/cli/commands/webrtc-listen.test.ts +74 -0
  51. package/src/cli/tools/assert-version-bump.test.ts +30 -1
  52. package/src/cli/tools/file-search-replace.test.ts +33 -1
  53. package/src/cli/tools/patch-service-worker-trusted-types.test.ts +24 -1
  54. package/src/cli/tools/touch-nojekyll.test.ts +12 -0
  55. package/src/cli/tools/version.test.ts +109 -5
  56. package/src/cli/utils/agent-core.test.ts +40 -0
  57. package/src/cli/utils/control-client.test.ts +239 -2
  58. package/src/cli/utils/load-workspace-config.test.ts +63 -0
  59. package/src/cli/utils/local-models.test.ts +98 -0
  60. package/src/cli/utils/progress-bar.test.ts +41 -2
  61. package/src/cli/utils/resolve-cache-dir.test.ts +61 -0
  62. package/src/cli/utils/resolve-project-root.test.ts +17 -0
  63. package/src/cli/utils/stdin.test.ts +38 -0
  64. package/src/cli/utils/stdio.test.ts +51 -1
  65. package/src/cli/utils/suppress-warnings.test.ts +9 -0
  66. package/src/cli/utils/webrtc-control-client.test.ts +569 -0
  67. package/src/common/utils/frontmatter.test.ts +117 -0
  68. package/src/components/common/help/providers.test.ts +67 -0
  69. package/src/components/common/shadow-claw-provider-model-picker/shadow-claw-provider-model-picker.test.ts +121 -0
  70. package/src/components/settings/shadow-claw-accounts/shadow-claw-accounts.test.ts +131 -0
  71. package/src/components/settings/shadow-claw-git/shadow-claw-git.test.ts +130 -0
  72. package/src/components/settings/shadow-claw-integrations/connection-test-auth.test.ts +12 -0
  73. package/src/components/settings/shadow-claw-integrations/shadow-claw-integrations.test.ts +328 -0
  74. package/src/components/settings/shadow-claw-llm/shadow-claw-llm.test.ts +530 -8
  75. package/src/components/settings/shadow-claw-mcp-remote/shadow-claw-mcp-remote.test.ts +459 -0
  76. package/src/components/settings/shadow-claw-networking/shadow-claw-networking.test.ts +88 -0
  77. package/src/components/settings/shadow-claw-notifications/shadow-claw-notifications.test.ts +92 -0
  78. package/src/components/settings/shadow-claw-peerjs/shadow-claw-peerjs.test.ts +127 -0
  79. package/src/components/settings/shadow-claw-storage/shadow-claw-storage.test.ts +173 -0
  80. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.test.ts +62 -0
  81. package/src/components/settings/shadow-claw-webvm/shadow-claw-webvm.test.ts +104 -0
  82. package/src/components/shadow-claw/shadow-claw.test.ts +112 -0
  83. package/src/components/shadow-claw-a2ui/shadow-claw-a2ui.test.ts +109 -0
  84. package/src/components/shadow-claw-conversations/shadow-claw-conversations.test.ts +361 -0
  85. package/src/components/shadow-claw-dialog/shadow-claw-dialog.test.ts +76 -0
  86. package/src/components/shadow-claw-file-viewer/shadow-claw-file-viewer.test.ts +307 -0
  87. package/src/components/shadow-claw-files/shadow-claw-files.test.ts +398 -1
  88. package/src/components/shadow-claw-pages/shadow-claw-pages.test.ts +85 -0
  89. package/src/components/shadow-claw-pages/shadow-claw-pages.ts +2 -1
  90. package/src/components/shadow-claw-pdf-viewer/shadow-claw-pdf-viewer.test.ts +229 -0
  91. package/src/components/shadow-claw-settings/shadow-claw-settings.test.ts +233 -0
  92. package/src/components/shadow-claw-tasks/shadow-claw-tasks.test.ts +223 -1
  93. package/src/components/shadow-claw-toast/shadow-claw-toast.test.ts +79 -0
  94. package/src/components/shadow-claw-tools/shadow-claw-tools.test.ts +229 -1
  95. package/src/server/logger.test.ts +57 -0
  96. package/src/server/peer.test.ts +47 -0
  97. package/src/server/routes/gemini.test.ts +244 -0
  98. package/src/server/server-peer.test.ts +245 -11
  99. package/src/server/services/llamafile-manager.test.ts +668 -0
  100. package/src/share-target/pending-shares.test.ts +90 -0
  101. package/src/shell/fs.test.ts +125 -0
  102. package/src/shell/shell.test.ts +8 -0
  103. package/src/subsystems/git/credentials.test.ts +260 -0
  104. package/src/subsystems/git/git.test.ts +64 -0
  105. package/src/subsystems/providers/executeNativeAiTask.test.ts +78 -0
  106. package/src/subsystems/providers/utils/parseLocalModelToolCall.test.ts +57 -0
  107. package/src/worker/tools/builtin-ai/builtin-ai.test.ts +88 -0
  108. package/src/worker/tools/email/email.test.ts +358 -0
  109. package/src/worker/tools/email/utils/resolveIntegrationEmailAuth.test.ts +120 -0
  110. package/src/worker/tools/email/utils/resolveUniqueAttachmentPath.test.ts +80 -0
  111. package/src/worker/tools/fetch-file/fetch-file.test.ts +111 -0
  112. package/src/worker/tools/fetch-url/fetch-url.test.ts +295 -0
@@ -139,4 +139,233 @@ describe("shadow-claw-pdf-viewer", () => {
139
139
 
140
140
  expect(panWrap.classList.contains("is-panning")).toBe(false);
141
141
  });
142
+
143
+ it("handles pagination and zoom button interactions", async () => {
144
+ const renderTask: any = {
145
+ promise: Promise.resolve(),
146
+ cancel: jest.fn(),
147
+ };
148
+ const page: any = {
149
+ getViewport: ({ scale }: any) => ({
150
+ width: 200 * scale,
151
+ height: 300 * scale,
152
+ }),
153
+ render: jest.fn(() => renderTask),
154
+ };
155
+ const pdfDocument: any = {
156
+ numPages: 3,
157
+ getPage: jest.fn(async () => page),
158
+ destroy: jest.fn(),
159
+ };
160
+
161
+ (getDocumentMock as any).mockImplementation(() => ({
162
+ promise: Promise.resolve(pdfDocument),
163
+ destroy: jest.fn(),
164
+ }));
165
+
166
+ const viewer = new ShadowClawPdfViewer();
167
+ document.body.appendChild(viewer);
168
+
169
+ viewer.file = {
170
+ name: "sample.pdf",
171
+ binaryContent: new Uint8Array([1, 2, 3, 4]),
172
+ };
173
+ await new Promise((r) => setTimeout(r, 50));
174
+
175
+ const nextBtn = viewer.shadowRoot?.querySelector(
176
+ ".pdf-next",
177
+ ) as HTMLButtonElement;
178
+ const prevBtn = viewer.shadowRoot?.querySelector(
179
+ ".pdf-prev",
180
+ ) as HTMLButtonElement;
181
+ const zoomInBtn = viewer.shadowRoot?.querySelector(
182
+ ".pdf-zoom-in",
183
+ ) as HTMLButtonElement;
184
+ const zoomOutBtn = viewer.shadowRoot?.querySelector(
185
+ ".pdf-zoom-out",
186
+ ) as HTMLButtonElement;
187
+ const pageInfo = viewer.shadowRoot?.querySelector(".pdf-page");
188
+
189
+ expect(viewer._pageNumber).toBe(1);
190
+ expect(pageInfo?.textContent).toContain("Page 1 of 3");
191
+
192
+ // Click Next
193
+ nextBtn.dispatchEvent(new Event("click"));
194
+ await new Promise((r) => setTimeout(r, 50));
195
+ expect(viewer._pageNumber).toBe(2);
196
+
197
+ // Click Next again
198
+ nextBtn.dispatchEvent(new Event("click"));
199
+ await new Promise((r) => setTimeout(r, 50));
200
+ expect(viewer._pageNumber).toBe(3);
201
+
202
+ // Clicking Next when at max pages does not exceed max
203
+ nextBtn.dispatchEvent(new Event("click"));
204
+ await new Promise((r) => setTimeout(r, 50));
205
+ expect(viewer._pageNumber).toBe(3);
206
+
207
+ // Click Prev
208
+ prevBtn.dispatchEvent(new Event("click"));
209
+ await new Promise((r) => setTimeout(r, 50));
210
+ expect(viewer._pageNumber).toBe(2);
211
+
212
+ // Click Prev to 1
213
+ prevBtn.dispatchEvent(new Event("click"));
214
+ await new Promise((r) => setTimeout(r, 50));
215
+ expect(viewer._pageNumber).toBe(1);
216
+
217
+ // Clicking Prev at page 1 does not decrement
218
+ prevBtn.dispatchEvent(new Event("click"));
219
+ await new Promise((r) => setTimeout(r, 50));
220
+ expect(viewer._pageNumber).toBe(1);
221
+
222
+ // Zoom in
223
+ const initialScale = viewer._scale;
224
+ zoomInBtn.dispatchEvent(new Event("click"));
225
+ await new Promise((r) => setTimeout(r, 50));
226
+ expect(viewer._scale).toBeGreaterThan(initialScale);
227
+
228
+ // Zoom out
229
+ zoomOutBtn.dispatchEvent(new Event("click"));
230
+ await new Promise((r) => setTimeout(r, 50));
231
+ expect(viewer._scale).toBeCloseTo(initialScale);
232
+ });
233
+
234
+ it("handles touch panning", async () => {
235
+ const viewer = new ShadowClawPdfViewer();
236
+ document.body.appendChild(viewer);
237
+
238
+ viewer.file = {
239
+ name: "sample.pdf",
240
+ binaryContent: new Uint8Array([1, 2, 3, 4]),
241
+ };
242
+ await Promise.resolve();
243
+ await Promise.resolve();
244
+
245
+ const wrap = viewer.shadowRoot?.querySelector(
246
+ ".pdf-canvas-wrap",
247
+ ) as HTMLElement;
248
+ wrap.scrollLeft = 50;
249
+ wrap.scrollTop = 50;
250
+
251
+ const touchStartEvent = new CustomEvent("touchstart", {
252
+ bubbles: true,
253
+ cancelable: true,
254
+ });
255
+ Object.defineProperty(touchStartEvent, "touches", {
256
+ value: [{ clientX: 100, clientY: 100 }],
257
+ });
258
+ wrap.dispatchEvent(touchStartEvent);
259
+
260
+ expect(viewer._isPanning).toBe(true);
261
+
262
+ const touchMoveEvent = new CustomEvent("touchmove", {
263
+ bubbles: true,
264
+ cancelable: true,
265
+ });
266
+ Object.defineProperty(touchMoveEvent, "touches", {
267
+ value: [{ clientX: 70, clientY: 60 }],
268
+ });
269
+ globalThis.dispatchEvent(touchMoveEvent);
270
+
271
+ expect(wrap.scrollLeft).toBe(80);
272
+ expect(wrap.scrollTop).toBe(90);
273
+
274
+ const touchEndEvent = new CustomEvent("touchend", {
275
+ bubbles: true,
276
+ cancelable: true,
277
+ });
278
+ globalThis.dispatchEvent(touchEndEvent);
279
+
280
+ expect(viewer._isPanning).toBe(false);
281
+ });
282
+
283
+ it("handles empty or unavailable PDF data", async () => {
284
+ const viewer = new ShadowClawPdfViewer();
285
+ document.body.appendChild(viewer);
286
+
287
+ viewer.file = {
288
+ name: "empty.pdf",
289
+ binaryContent: new Uint8Array(0),
290
+ };
291
+ await Promise.resolve();
292
+
293
+ const status = viewer.shadowRoot?.querySelector(".pdf-status");
294
+ expect(status?.textContent).toBe("PDF data unavailable.");
295
+ });
296
+
297
+ it("handles getDocument rejection during PDF load", async () => {
298
+ (getDocumentMock as any).mockImplementationOnce(() => ({
299
+ promise: Promise.reject(new Error("Corrupt PDF")),
300
+ destroy: jest.fn(),
301
+ }));
302
+
303
+ const viewer = new ShadowClawPdfViewer();
304
+ document.body.appendChild(viewer);
305
+
306
+ viewer.file = {
307
+ name: "corrupt.pdf",
308
+ binaryContent: new Uint8Array([1, 2, 3]),
309
+ };
310
+ await Promise.resolve();
311
+ await Promise.resolve();
312
+ await Promise.resolve();
313
+
314
+ const status = viewer.shadowRoot?.querySelector(".pdf-status");
315
+ expect(status?.textContent).toBe("Failed to render PDF preview.");
316
+ });
317
+
318
+ it("handles RenderingCancelledException gracefully", async () => {
319
+ const renderTask: any = {
320
+ promise: Promise.reject({ name: "RenderingCancelledException" }),
321
+ cancel: jest.fn(),
322
+ };
323
+ const page: any = {
324
+ getViewport: ({ scale }: any) => ({
325
+ width: 200 * scale,
326
+ height: 300 * scale,
327
+ }),
328
+ render: jest.fn(() => renderTask),
329
+ };
330
+ const pdfDocument: any = {
331
+ numPages: 1,
332
+ getPage: jest.fn(async () => page),
333
+ destroy: jest.fn(),
334
+ };
335
+
336
+ (getDocumentMock as any).mockImplementationOnce(() => ({
337
+ promise: Promise.resolve(pdfDocument),
338
+ destroy: jest.fn(),
339
+ }));
340
+
341
+ const viewer = new ShadowClawPdfViewer();
342
+ document.body.appendChild(viewer);
343
+
344
+ viewer.file = {
345
+ name: "sample.pdf",
346
+ binaryContent: new Uint8Array([1, 2, 3, 4]),
347
+ };
348
+ await Promise.resolve();
349
+ await Promise.resolve();
350
+ await Promise.resolve();
351
+
352
+ // Should not show error status on cancelled rendering
353
+ const status = viewer.shadowRoot?.querySelector(".pdf-status");
354
+ expect(status?.textContent).not.toBe("Failed to render PDF page.");
355
+ });
356
+
357
+ it("cleans up on disconnectedCallback", async () => {
358
+ const viewer = new ShadowClawPdfViewer();
359
+ document.body.appendChild(viewer);
360
+
361
+ viewer.file = {
362
+ name: "sample.pdf",
363
+ binaryContent: new Uint8Array([1, 2, 3, 4]),
364
+ };
365
+ await Promise.resolve();
366
+ await Promise.resolve();
367
+
368
+ viewer.remove();
369
+ expect(viewer._pdfDocument).toBeNull();
370
+ });
142
371
  });
@@ -1103,4 +1103,237 @@ describe("shadow-claw-settings", () => {
1103
1103
  await el.confirmRestore();
1104
1104
  expect(el.replaceConfigEntries).toHaveBeenCalled();
1105
1105
  });
1106
+
1107
+ it("handles tab keyboard navigation (arrows, home, end)", async () => {
1108
+ const el = new ShadowClawSettings();
1109
+ document.body.appendChild(el);
1110
+ await el.render();
1111
+
1112
+ const buttons = Array.from(
1113
+ el.shadowRoot?.querySelectorAll<HTMLButtonElement>("[data-tab-target]") ||
1114
+ [],
1115
+ );
1116
+ expect(buttons.length).toBeGreaterThan(1);
1117
+
1118
+ const firstBtn = buttons[0];
1119
+ const lastBtn = buttons[buttons.length - 1];
1120
+
1121
+ // ArrowRight / ArrowDown from first button moves to second
1122
+ const arrowRightEvt = new KeyboardEvent("keydown", {
1123
+ key: "ArrowRight",
1124
+ cancelable: true,
1125
+ });
1126
+ el.handleTabKeydown(arrowRightEvt, firstBtn);
1127
+ expect(arrowRightEvt.defaultPrevented).toBe(true);
1128
+
1129
+ // ArrowLeft / ArrowUp from first button moves to last
1130
+ const arrowLeftEvt = new KeyboardEvent("keydown", {
1131
+ key: "ArrowLeft",
1132
+ cancelable: true,
1133
+ });
1134
+ el.handleTabKeydown(arrowLeftEvt, firstBtn);
1135
+ expect(arrowLeftEvt.defaultPrevented).toBe(true);
1136
+
1137
+ // End key moves to last button
1138
+ const endEvt = new KeyboardEvent("keydown", {
1139
+ key: "End",
1140
+ cancelable: true,
1141
+ });
1142
+ el.handleTabKeydown(endEvt, firstBtn);
1143
+ expect(endEvt.defaultPrevented).toBe(true);
1144
+
1145
+ // Home key moves to first button
1146
+ const homeEvt = new KeyboardEvent("keydown", {
1147
+ key: "Home",
1148
+ cancelable: true,
1149
+ });
1150
+ el.handleTabKeydown(homeEvt, lastBtn);
1151
+ expect(homeEvt.defaultPrevented).toBe(true);
1152
+
1153
+ // Non-navigation key is ignored
1154
+ const tabEvt = new KeyboardEvent("keydown", {
1155
+ key: "Tab",
1156
+ cancelable: true,
1157
+ });
1158
+ el.handleTabKeydown(tabEvt, firstBtn);
1159
+ expect(tabEvt.defaultPrevented).toBe(false);
1160
+
1161
+ document.body.removeChild(el);
1162
+ });
1163
+
1164
+ it("handles settings UI action buttons and dialog cancellation", async () => {
1165
+ const el = new ShadowClawSettings();
1166
+ document.body.appendChild(el);
1167
+ await el.render();
1168
+
1169
+ // Backup button opens backup dialog
1170
+ const backupBtn = el.shadowRoot?.querySelector(
1171
+ '[data-action="backup-settings"]',
1172
+ ) as HTMLElement;
1173
+ backupBtn?.click();
1174
+ expect(
1175
+ el.shadowRoot
1176
+ ?.querySelector(".settings__backup-dialog")
1177
+ ?.hasAttribute("open"),
1178
+ ).toBe(true);
1179
+
1180
+ // Cancel backup dialog
1181
+ const cancelBackupBtn = el.shadowRoot?.querySelector(
1182
+ '[data-action="cancel-backup-settings"]',
1183
+ ) as HTMLElement;
1184
+ cancelBackupBtn?.click();
1185
+
1186
+ // Clear settings button opens clear dialog
1187
+ const clearBtn = el.shadowRoot?.querySelector(
1188
+ '[data-action="clear-settings"]',
1189
+ ) as HTMLElement;
1190
+ clearBtn?.click();
1191
+ expect(
1192
+ el.shadowRoot
1193
+ ?.querySelector(".settings__clear-dialog")
1194
+ ?.hasAttribute("open"),
1195
+ ).toBe(true);
1196
+
1197
+ // Cancel clear dialog
1198
+ const cancelClearBtn = el.shadowRoot?.querySelector(
1199
+ '[data-action="cancel-clear-settings"]',
1200
+ ) as HTMLElement;
1201
+ cancelClearBtn?.click();
1202
+
1203
+ // Restore button triggers restoreInput.click()
1204
+ const restoreBtn = el.shadowRoot?.querySelector(
1205
+ '[data-action="restore-settings"]',
1206
+ ) as HTMLElement;
1207
+ const restoreInput = el.shadowRoot?.querySelector(
1208
+ ".settings__restore-input",
1209
+ ) as HTMLInputElement;
1210
+ const inputClickSpy = jest.spyOn(restoreInput, "click");
1211
+ restoreBtn?.click();
1212
+ expect(inputClickSpy).toHaveBeenCalled();
1213
+
1214
+ // restoreInput change event opens restore dialog
1215
+ const mockFile = new File(["{}"], "my-backup.json", {
1216
+ type: "application/json",
1217
+ });
1218
+ Object.defineProperty(restoreInput, "files", {
1219
+ value: [mockFile],
1220
+ configurable: true,
1221
+ });
1222
+ restoreInput.dispatchEvent(new Event("change"));
1223
+ expect(el.pendingRestoreFile).toBe(mockFile);
1224
+
1225
+ // Cancel restore dialog clears pendingRestoreFile
1226
+ const cancelRestoreBtn = el.shadowRoot?.querySelector(
1227
+ '[data-action="cancel-restore-settings"]',
1228
+ ) as HTMLElement;
1229
+ cancelRestoreBtn?.click();
1230
+ expect(el.pendingRestoreFile).toBeNull();
1231
+
1232
+ // Check chat-split-view-toggle
1233
+ const splitViewToggle = el.shadowRoot?.querySelector(
1234
+ '[data-setting="chat-split-view-toggle"]',
1235
+ ) as HTMLInputElement;
1236
+ if (splitViewToggle) {
1237
+ splitViewToggle.checked = true;
1238
+ splitViewToggle.dispatchEvent(new Event("change"));
1239
+ }
1240
+
1241
+ // Check pages-auto-refresh-input
1242
+ const refreshInput = el.shadowRoot?.querySelector(
1243
+ '[data-setting="pages-auto-refresh-input"]',
1244
+ ) as HTMLInputElement;
1245
+ if (refreshInput) {
1246
+ refreshInput.value = "60";
1247
+ refreshInput.dispatchEvent(new Event("change"));
1248
+ }
1249
+
1250
+ // Save dom allowed iframe hosts
1251
+ const saveHostsBtn = el.shadowRoot?.querySelector(
1252
+ '[data-action="save-dom-allowed-iframe-hosts"]',
1253
+ ) as HTMLElement;
1254
+ saveHostsBtn?.click();
1255
+
1256
+ document.body.removeChild(el);
1257
+ });
1258
+
1259
+ it("handles SQLite database operations for getAllConfigEntries and replaceConfigEntries", async () => {
1260
+ const el = new ShadowClawSettings();
1261
+ const mockAll = jest.fn(() => [
1262
+ { key: "site_name", value: "My Site" },
1263
+ { key: "count", value: 42 },
1264
+ null,
1265
+ ]);
1266
+ const mockRun = jest.fn();
1267
+ const mockPrepare = jest.fn((_sql: string) => ({
1268
+ all: mockAll,
1269
+ run: mockRun,
1270
+ }));
1271
+
1272
+ el.db = {
1273
+ __brand: "ShadowClawSqliteDatabase",
1274
+ db: { prepare: mockPrepare },
1275
+ } as any;
1276
+
1277
+ const entries = await el.getAllConfigEntries();
1278
+ expect(entries).toEqual([
1279
+ { key: "site_name", value: "My Site" },
1280
+ { key: "count", value: 42 },
1281
+ ]);
1282
+
1283
+ await el.replaceConfigEntries([
1284
+ { key: "k1", value: "v1" },
1285
+ { key: "k2", value: { obj: true } },
1286
+ ]);
1287
+ expect(mockPrepare).toHaveBeenCalledWith("DELETE FROM config");
1288
+ expect(mockRun).toHaveBeenCalledWith("k1", "v1");
1289
+ expect(mockRun).toHaveBeenCalledWith("k2", JSON.stringify({ obj: true }));
1290
+ });
1291
+
1292
+ it("handles saveAssistantName edge cases", async () => {
1293
+ const el = new ShadowClawSettings();
1294
+ document.body.appendChild(el);
1295
+ await el.render();
1296
+
1297
+ // Empty assistant name gives warning
1298
+ const nameInput = el.shadowRoot?.querySelector(
1299
+ '[data-setting="assistant-name-input"]',
1300
+ ) as HTMLInputElement;
1301
+ nameInput.value = " ";
1302
+ await el.saveAssistantName();
1303
+
1304
+ // Non-empty assistant name saves successfully
1305
+ nameInput.value = "ShadowClaw Master";
1306
+ await el.saveAssistantName();
1307
+
1308
+ document.body.removeChild(el);
1309
+ });
1310
+
1311
+ it("handles null db and error edge cases in confirmBackup, confirmClear, confirmRestore", async () => {
1312
+ const el = new ShadowClawSettings();
1313
+ el.db = null;
1314
+
1315
+ // confirmBackup with null db
1316
+ await el.confirmBackup();
1317
+
1318
+ // confirmClear with null db
1319
+ await el.confirmClear();
1320
+
1321
+ // confirmRestore with null db
1322
+ await el.confirmRestore();
1323
+
1324
+ // confirmRestore without file
1325
+ el.db = {} as any;
1326
+ el.pendingRestoreFile = null;
1327
+ await el.confirmRestore();
1328
+
1329
+ // confirmRestore with invalid JSON file
1330
+ el.pendingRestoreFile = {
1331
+ name: "broken.json",
1332
+ text: async () => "invalid-json{",
1333
+ } as any;
1334
+ await el.confirmRestore();
1335
+
1336
+ // activateTab for integrations
1337
+ await el.activateTab("integrations");
1338
+ });
1106
1339
  });
@@ -8,7 +8,9 @@ jest.unstable_mockModule("../../db/saveTask.js", () => ({
8
8
  saveTask: jest.fn(),
9
9
  }));
10
10
 
11
- jest.unstable_mockModule("../../core/effect.js", () => ({ effect: jest.fn() }));
11
+ jest.unstable_mockModule("../../core/effect.js", () => ({
12
+ effect: jest.fn(() => () => {}),
13
+ }));
12
14
 
13
15
  jest.unstable_mockModule("../../content/markdown.js", () => ({
14
16
  renderMarkdown: jest.fn((x) => x),
@@ -808,5 +810,225 @@ describe("shadow-claw-tasks", () => {
808
810
 
809
811
  expect(list.innerHTML).toContain("Push");
810
812
  });
813
+
814
+ it("handles dialog close buttons and backdrop clicks", () => {
815
+ const component = new ShadowClawTasks();
816
+ document.body.appendChild(component);
817
+ const dialog = component.shadowRoot?.querySelector(
818
+ "dialog",
819
+ ) as HTMLDialogElement;
820
+ dialog.close = jest.fn();
821
+
822
+ const closeBtn = component.shadowRoot?.querySelector(
823
+ ".tasks__dialog-close",
824
+ ) as HTMLButtonElement;
825
+ closeBtn?.dispatchEvent(new Event("click"));
826
+ expect(dialog.close).toHaveBeenCalledTimes(1);
827
+
828
+ const cancelBtn = component.shadowRoot?.querySelector(
829
+ ".tasks__btn-cancel",
830
+ ) as HTMLButtonElement;
831
+ cancelBtn?.dispatchEvent(new Event("click"));
832
+ expect(dialog.close).toHaveBeenCalledTimes(2);
833
+
834
+ // Click on dialog backdrop
835
+ Object.defineProperty(dialog, "target", { value: dialog });
836
+ dialog.dispatchEvent(new MouseEvent("click", { bubbles: true }));
837
+ expect(dialog.close).toHaveBeenCalledTimes(3);
838
+
839
+ document.body.removeChild(component);
840
+ });
841
+
842
+ it("handles restore button triggering file input click", () => {
843
+ const component = new ShadowClawTasks();
844
+ document.body.appendChild(component);
845
+ const restoreBtn = component.shadowRoot?.querySelector(
846
+ ".tasks__restore-btn",
847
+ ) as HTMLButtonElement;
848
+ const restoreInput = component.shadowRoot?.querySelector(
849
+ ".tasks__hidden-restore",
850
+ ) as HTMLInputElement;
851
+
852
+ const clickSpy = jest
853
+ .spyOn(restoreInput, "click")
854
+ .mockImplementation(() => {});
855
+ restoreBtn?.dispatchEvent(new Event("click"));
856
+ expect(clickSpy).toHaveBeenCalled();
857
+ clickSpy.mockRestore();
858
+
859
+ document.body.removeChild(component);
860
+ });
861
+
862
+ it("handles task item action buttons and drag-and-drop lifecycle", async () => {
863
+ const component = new ShadowClawTasks();
864
+ document.body.appendChild(component);
865
+ const editSpy = jest
866
+ .spyOn(component, "handleEdit")
867
+ .mockImplementation(() => {});
868
+ const deleteSpy = jest
869
+ .spyOn(component, "handleDelete")
870
+ .mockImplementation(async () => {});
871
+ const runSpy = jest
872
+ .spyOn(component, "handleRun")
873
+ .mockImplementation(async () => {});
874
+ const reorderSpy = jest
875
+ .spyOn(component, "handleReorder")
876
+ .mockImplementation(async () => {});
877
+
878
+ (orchestratorStore as any).tasks = [
879
+ {
880
+ id: "task-1",
881
+ name: "Task 1",
882
+ schedule: "0 9 * * *",
883
+ prompt: "Echo 1",
884
+ enabled: true,
885
+ },
886
+ {
887
+ id: "task-2",
888
+ name: "Task 2",
889
+ schedule: "0 10 * * *",
890
+ prompt: "Echo 2",
891
+ enabled: true,
892
+ },
893
+ ];
894
+
895
+ await component.updateTaskList({} as any);
896
+
897
+ const items = component.shadowRoot?.querySelectorAll(".tasks__item");
898
+ expect(items?.length).toBe(2);
899
+
900
+ const firstItem = items![0] as HTMLElement;
901
+ const secondItem = items![1] as HTMLElement;
902
+
903
+ // Edit, delete, run clicks
904
+ const runBtn = firstItem.querySelector(".tasks__run-btn");
905
+ runBtn?.dispatchEvent(new Event("click"));
906
+ expect(runSpy).toHaveBeenCalled();
907
+
908
+ const editBtn = firstItem.querySelector(".tasks__edit-btn");
909
+ editBtn?.dispatchEvent(new Event("click"));
910
+ expect(editSpy).toHaveBeenCalled();
911
+
912
+ const deleteBtn = firstItem.querySelector(".tasks__delete-btn");
913
+ deleteBtn?.dispatchEvent(new Event("click"));
914
+ expect(deleteSpy).toHaveBeenCalled();
915
+
916
+ // Drag and drop events
917
+ const handleEl = firstItem.querySelector(
918
+ ".tasks__drag-handle",
919
+ ) as HTMLElement;
920
+ const dataTransfer = { setData: jest.fn() };
921
+ const dragStartEvent = new Event("dragstart");
922
+ Object.defineProperty(dragStartEvent, "dataTransfer", {
923
+ value: dataTransfer,
924
+ });
925
+ handleEl?.dispatchEvent(dragStartEvent);
926
+ expect(firstItem.classList.contains("dragging")).toBe(true);
927
+
928
+ // Dragover on second item
929
+ const dragOverEvent = new Event("dragover", { cancelable: true });
930
+ secondItem.dispatchEvent(dragOverEvent);
931
+ expect(secondItem.classList.contains("drag-over")).toBe(true);
932
+
933
+ // Dragleave on second item
934
+ secondItem.dispatchEvent(new Event("dragleave"));
935
+ expect(secondItem.classList.contains("drag-over")).toBe(false);
936
+
937
+ // Dragover again then drop
938
+ secondItem.dispatchEvent(dragOverEvent);
939
+ const dropEvent = new Event("drop", { cancelable: true });
940
+ secondItem.dispatchEvent(dropEvent);
941
+ expect(reorderSpy).toHaveBeenCalledWith("task-1", "task-2");
942
+
943
+ // Dragend
944
+ handleEl?.dispatchEvent(new Event("dragend"));
945
+ expect(firstItem.classList.contains("dragging")).toBe(false);
946
+
947
+ // Touch drag
948
+ const touchStartEvent = new CustomEvent("touchstart", {
949
+ cancelable: true,
950
+ });
951
+ Object.defineProperty(touchStartEvent, "touches", {
952
+ value: [{ identifier: 1, clientX: 10, clientY: 20 }],
953
+ });
954
+ handleEl?.dispatchEvent(touchStartEvent);
955
+ expect(firstItem.classList.contains("dragging")).toBe(true);
956
+
957
+ document.body.removeChild(component);
958
+ });
959
+
960
+ it("updates auto scroll speed correctly", () => {
961
+ const component = new ShadowClawTasks();
962
+ document.body.appendChild(component);
963
+ const content = component.shadowRoot?.querySelector(
964
+ ".tasks__content",
965
+ ) as HTMLElement;
966
+ if (content) {
967
+ jest.spyOn(content, "getBoundingClientRect").mockReturnValue({
968
+ top: 100,
969
+ bottom: 500,
970
+ left: 0,
971
+ right: 300,
972
+ height: 400,
973
+ width: 300,
974
+ x: 0,
975
+ y: 100,
976
+ toJSON: () => {},
977
+ });
978
+ }
979
+
980
+ // Near top (top is 100, clientY = 110)
981
+ (component as any)._updateAutoScrollSpeed(110);
982
+ expect((component as any)._autoScrollSpeed).toBeLessThan(0);
983
+
984
+ // Near bottom (bottom is 500, clientY = 490)
985
+ (component as any)._updateAutoScrollSpeed(490);
986
+ expect((component as any)._autoScrollSpeed).toBeGreaterThan(0);
987
+
988
+ // Middle (clientY = 300)
989
+ (component as any)._updateAutoScrollSpeed(300);
990
+ expect((component as any)._autoScrollSpeed).toBe(0);
991
+
992
+ // Stop auto scroll
993
+ (component as any)._stopAutoScroll();
994
+ expect((component as any)._autoScrollActive).toBe(false);
995
+ expect((component as any)._autoScrollSpeed).toBe(0);
996
+
997
+ document.body.removeChild(component);
998
+ });
999
+
1000
+ it("handles switching task type radios between prompt and tools", async () => {
1001
+ const component = new ShadowClawTasks();
1002
+ document.body.appendChild(component);
1003
+ const typeRadios = component.shadowRoot?.querySelectorAll(
1004
+ "input[name='taskType']",
1005
+ );
1006
+ const toolsRadio = Array.from(typeRadios || []).find(
1007
+ (r: any) => r.value === "tools",
1008
+ ) as HTMLInputElement;
1009
+ const promptRadio = Array.from(typeRadios || []).find(
1010
+ (r: any) => r.value === "prompt",
1011
+ ) as HTMLInputElement;
1012
+
1013
+ if (toolsRadio) {
1014
+ toolsRadio.checked = true;
1015
+ toolsRadio.dispatchEvent(new Event("change", { bubbles: true }));
1016
+ const promptGroup = component.shadowRoot?.querySelector(
1017
+ ".tasks__prompt-group",
1018
+ );
1019
+ expect(promptGroup?.getAttribute("style")).toContain("display: none");
1020
+ }
1021
+
1022
+ if (promptRadio) {
1023
+ promptRadio.checked = true;
1024
+ promptRadio.dispatchEvent(new Event("change", { bubbles: true }));
1025
+ const toolsGroup = component.shadowRoot?.querySelector(
1026
+ ".tasks__tools-group",
1027
+ );
1028
+ expect(toolsGroup?.getAttribute("style")).toContain("display: none");
1029
+ }
1030
+
1031
+ document.body.removeChild(component);
1032
+ });
811
1033
  });
812
1034
  });