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
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  createFrontmatterDetailsElement,
3
+ createFrontmatterElement,
3
4
  PostFrontmatter,
4
5
  renderFrontmatterMarkup,
5
6
  splitFrontmatter,
@@ -24,6 +25,60 @@ describe("frontmatter utilities", () => {
24
25
  expect(parsed.content.trim()).toBe("# Heading");
25
26
  });
26
27
 
28
+ it("handles markdown with no frontmatter or empty input in splitFrontmatter", () => {
29
+ const emptyParsed = splitFrontmatter("");
30
+ expect(emptyParsed.data).toEqual({});
31
+ expect(emptyParsed.content).toBe("");
32
+
33
+ const plainParsed = splitFrontmatter("Just plain markdown");
34
+ expect(plainParsed.data).toEqual({});
35
+ expect(plainParsed.content).toBe("Just plain markdown");
36
+ });
37
+
38
+ describe("createFrontmatterElement", () => {
39
+ it("renders header with title and updated timestamp", () => {
40
+ const element = createFrontmatterElement(
41
+ {
42
+ title: "My Article",
43
+ updated: "2026-03-15T10:00:00Z",
44
+ created: "2026-01-01T10:00:00Z",
45
+ } as PostFrontmatter,
46
+ document,
47
+ );
48
+
49
+ expect(element.tagName.toLowerCase()).toBe("header");
50
+ expect(element.getAttribute("class")).toBe("markdown-frontmatter");
51
+
52
+ const title = element.querySelector("h1");
53
+ expect(title?.textContent).toBe("My Article");
54
+ expect(title?.getAttribute("class")).toBe("markdown-frontmatter__title");
55
+
56
+ const time = element.querySelector("time");
57
+ expect(time?.getAttribute("datetime")).toBe("2026-03-15T10:00:00Z");
58
+ expect(time?.textContent).toBeDefined();
59
+ });
60
+
61
+ it("falls back to created timestamp when updated is missing", () => {
62
+ const element = createFrontmatterElement(
63
+ {
64
+ created: "2026-01-01T10:00:00Z",
65
+ } as PostFrontmatter,
66
+ document,
67
+ );
68
+
69
+ const title = element.querySelector("h1");
70
+ expect(title).toBeNull();
71
+
72
+ const time = element.querySelector("time");
73
+ expect(time?.getAttribute("datetime")).toBe("2026-01-01T10:00:00Z");
74
+ });
75
+
76
+ it("renders empty header container when no title or timestamp is provided", () => {
77
+ const element = createFrontmatterElement({} as PostFrontmatter, document);
78
+ expect(element.children.length).toBe(0);
79
+ });
80
+ });
81
+
27
82
  it("builds frontmatter DOM element using createElement semantics", () => {
28
83
  const element = createFrontmatterDetailsElement(
29
84
  { title: "Post", slug: "post" } as PostFrontmatter,
@@ -43,6 +98,16 @@ describe("frontmatter utilities", () => {
43
98
  expect(code?.textContent).toContain('"slug": "post"');
44
99
  });
45
100
 
101
+ it("throws in createFrontmatterDetailsElement if documentInstance is invalid", () => {
102
+ expect(() =>
103
+ createFrontmatterDetailsElement({} as PostFrontmatter, null as any),
104
+ ).toThrow("A valid documentInstance is required");
105
+
106
+ expect(() =>
107
+ createFrontmatterDetailsElement({} as PostFrontmatter, {} as any),
108
+ ).toThrow("A valid documentInstance is required");
109
+ });
110
+
46
111
  it("serializes markup in browser via DOM outerHTML", () => {
47
112
  const html = renderFrontmatterMarkup(
48
113
  { title: "Browser Title" } as PostFrontmatter,
@@ -58,6 +123,58 @@ describe("frontmatter utilities", () => {
58
123
  expect(html).toContain(""title": "Browser Title"");
59
124
  });
60
125
 
126
+ it("uses global document by default in renderFrontmatterMarkup", () => {
127
+ const html = renderFrontmatterMarkup(
128
+ { title: "Default Document" } as PostFrontmatter,
129
+ createFrontmatterElement,
130
+ );
131
+ expect(html).toContain("markdown-frontmatter");
132
+ expect(html).toContain("Default Document");
133
+ });
134
+
135
+ it("falls back to XMLSerializer when outerHTML is not available", () => {
136
+ const customRenderer = (_data: any, doc: Document) => {
137
+ const el = doc.createElement("section");
138
+ el.setAttribute("class", "xml-fallback");
139
+ Object.defineProperty(el, "outerHTML", {
140
+ value: undefined,
141
+ configurable: true,
142
+ });
143
+ return el;
144
+ };
145
+
146
+ const html = renderFrontmatterMarkup(
147
+ {} as PostFrontmatter,
148
+ customRenderer,
149
+ { documentInstance: document },
150
+ );
151
+ expect(html).toContain('class="xml-fallback"');
152
+ });
153
+
154
+ it("throws when no serializer is available", () => {
155
+ const customRenderer = () => {
156
+ const el = { outerHTML: undefined };
157
+ return el as any;
158
+ };
159
+
160
+ const fakeDoc = {
161
+ createElement: () => ({}),
162
+ defaultView: {},
163
+ } as any;
164
+
165
+ const originalXml = (globalThis as any).XMLSerializer;
166
+ try {
167
+ delete (globalThis as any).XMLSerializer;
168
+ expect(() =>
169
+ renderFrontmatterMarkup({} as PostFrontmatter, customRenderer, {
170
+ documentInstance: fakeDoc,
171
+ }),
172
+ ).toThrow("No serializer available for frontmatter DOM element.");
173
+ } finally {
174
+ (globalThis as any).XMLSerializer = originalXml;
175
+ }
176
+ });
177
+
61
178
  it("serializes markup via injected serializer for xmldom-style usage", () => {
62
179
  const fakeDocument = {
63
180
  createTextNode(text: string) {
@@ -89,4 +89,71 @@ describe("provider help", () => {
89
89
  dialog.details?.some((line) => line.includes("Runtime missing")),
90
90
  ).toBe(true);
91
91
  });
92
+
93
+ it("checks isLlamafileResolutionError", async () => {
94
+ const { isLlamafileResolutionError } = await import("./llamafile.js");
95
+ expect(isLlamafileResolutionError("assets/cache/llamafile not found")).toBe(
96
+ true,
97
+ );
98
+ expect(
99
+ isLlamafileResolutionError(
100
+ "missing or invalid 'model' parameter for cli mode",
101
+ ),
102
+ ).toBe(true);
103
+ expect(
104
+ isLlamafileResolutionError("failed to list llamafile binaries"),
105
+ ).toBe(true);
106
+ expect(isLlamafileResolutionError("some random error")).toBe(false);
107
+ });
108
+
109
+ it("checks isTransformersJsResolutionError", async () => {
110
+ const { isTransformersJsResolutionError } =
111
+ await import("./transformers.js");
112
+ expect(
113
+ isTransformersJsResolutionError(
114
+ "transformers.js runtime is not installed",
115
+ ),
116
+ ).toBe(true);
117
+ expect(
118
+ isTransformersJsResolutionError("transformers.js runtime is unavailable"),
119
+ ).toBe(true);
120
+ expect(
121
+ isTransformersJsResolutionError(
122
+ "no supported transformers.js model loader",
123
+ ),
124
+ ).toBe(true);
125
+ expect(
126
+ isTransformersJsResolutionError(
127
+ "cannot find package @huggingface/transformers",
128
+ ),
129
+ ).toBe(true);
130
+ expect(isTransformersJsResolutionError("network error")).toBe(false);
131
+ });
132
+
133
+ it("builds dialogs for api-key-invalid and provider-unreachable", () => {
134
+ const invalidDialog = buildProviderHelpDialogOptions(
135
+ "openrouter",
136
+ "api-key-invalid",
137
+ );
138
+ expect(invalidDialog.title).toContain("Authentication Failed");
139
+
140
+ const localUnreachable = buildProviderHelpDialogOptions(
141
+ "transformers_js_local",
142
+ "provider-unreachable",
143
+ );
144
+ expect(localUnreachable.title).toContain("Not Reachable");
145
+ expect(localUnreachable.details?.some((d) => d.includes("npm start"))).toBe(
146
+ true,
147
+ );
148
+
149
+ const remoteUnreachable = buildProviderHelpDialogOptions(
150
+ "openrouter",
151
+ "provider-unreachable",
152
+ );
153
+ expect(
154
+ remoteUnreachable.details?.some((d) =>
155
+ d.includes("internet connectivity"),
156
+ ),
157
+ ).toBe(true);
158
+ });
92
159
  });
@@ -158,4 +158,125 @@ describe("shadow-claw-provider-model-picker", () => {
158
158
 
159
159
  el.remove();
160
160
  });
161
+
162
+ it("handles getValue, setLabels, and invalidateProviderModels", async () => {
163
+ const el = new ShadowClawProviderModelPicker();
164
+ document.body.appendChild(el);
165
+
166
+ el.setValue({ providerId: "prov-1", modelId: "mod-1" });
167
+ expect(el.getValue()).toEqual({ providerId: "prov-1", modelId: "mod-1" });
168
+
169
+ el.setLabels({
170
+ providerLabel: "Custom Provider Label",
171
+ modelLabel: "Custom Model Label",
172
+ });
173
+ const root = el.shadowRoot;
174
+ expect(root?.textContent).toContain("Custom Provider Label");
175
+
176
+ el.invalidateProviderModels("prov-1");
177
+ el.invalidateProviderModels();
178
+
179
+ el.remove();
180
+ });
181
+
182
+ it("handles various model tool badges and context window structures", async () => {
183
+ const el = new ShadowClawProviderModelPicker();
184
+ document.body.appendChild(el);
185
+
186
+ el.setProviders([
187
+ {
188
+ id: "multi-models",
189
+ name: "Multi Models",
190
+ models: [
191
+ {
192
+ id: "no-tools",
193
+ name: "No Tools",
194
+ supports_tools: false,
195
+ context_window: 4096,
196
+ },
197
+ {
198
+ id: "supported-params",
199
+ name: "Params",
200
+ supported_parameters: ["tools"],
201
+ context_length: 8192,
202
+ },
203
+ {
204
+ id: "no-params",
205
+ name: "No Params",
206
+ supported_parameters: ["temperature"],
207
+ },
208
+ {
209
+ id: "provider-list",
210
+ name: "With Provider List",
211
+ providers: [{ supports_tools: false, context_length: 16384 }],
212
+ },
213
+ {
214
+ id: "provider-tools",
215
+ name: "With Provider Tools",
216
+ providers: [{ supports_tools: true, context_length: 32768 }],
217
+ },
218
+ { name: "Only Name" },
219
+ ],
220
+ } as any,
221
+ ]);
222
+
223
+ el.setValue({ providerId: "multi-models", modelId: "no-tools" });
224
+ await el.render();
225
+
226
+ const root = el.shadowRoot;
227
+ const modelSelect = root?.querySelector(
228
+ '[data-role="model-select"]',
229
+ ) as HTMLSelectElement;
230
+ expect(modelSelect).not.toBeNull();
231
+ const options = Array.from(modelSelect.options).map((o) => o.textContent);
232
+ expect(options.some((t) => t?.includes("🚫🛠️"))).toBe(true);
233
+ expect(options.some((t) => t?.includes("🛠️"))).toBe(true);
234
+
235
+ el.remove();
236
+ });
237
+
238
+ it("handles loader failure and custom model input events", async () => {
239
+ const el = new ShadowClawProviderModelPicker();
240
+ document.body.appendChild(el);
241
+
242
+ const failingLoader = jest
243
+ .fn<() => Promise<any[]>>()
244
+ .mockRejectedValue(new Error("Network fail"));
245
+ el.setModelLoader(failingLoader as any);
246
+ el.setProviders([
247
+ { id: "dynamic", name: "Dynamic", modelsUrl: "https://fail.test" } as any,
248
+ ]);
249
+
250
+ el.setValue({ providerId: "dynamic", modelId: null });
251
+ await el.render();
252
+ await Promise.resolve();
253
+ await Promise.resolve();
254
+
255
+ const root = el.shadowRoot;
256
+ const modelSelect = root?.querySelector(
257
+ '[data-role="model-select"]',
258
+ ) as HTMLSelectElement;
259
+ if (modelSelect) {
260
+ modelSelect.value = "__custom__";
261
+ modelSelect.dispatchEvent(new Event("change"));
262
+ }
263
+
264
+ const customInput = root?.querySelector(
265
+ '[data-role="custom-model-input"]',
266
+ ) as HTMLInputElement;
267
+ const listener = jest.fn();
268
+ el.addEventListener("provider-model-change", listener as EventListener);
269
+
270
+ if (customInput) {
271
+ customInput.value = "typed-model";
272
+ customInput.dispatchEvent(new Event("input"));
273
+ expect(listener).toHaveBeenCalledWith(
274
+ expect.objectContaining({
275
+ detail: { providerId: "dynamic", modelId: "typed-model" },
276
+ }),
277
+ );
278
+ }
279
+
280
+ el.remove();
281
+ });
161
282
  });
@@ -423,4 +423,135 @@ describe("shadow-claw-accounts", () => {
423
423
 
424
424
  document.body.removeChild(el);
425
425
  });
426
+
427
+ it("updates an existing account and supports basic auth", async () => {
428
+ const el = new ShadowClawAccounts();
429
+ document.body.appendChild(el);
430
+ await new Promise((resolve) => setTimeout(resolve, 50));
431
+ await el.render();
432
+ el.db = {} as any;
433
+
434
+ el.accounts = [
435
+ {
436
+ id: "acct-existing",
437
+ label: "Old Label",
438
+ service: "GitHub",
439
+ hostPattern: "api.github.com",
440
+ authMode: "token",
441
+ token: "old-token",
442
+ } as any,
443
+ ];
444
+
445
+ el.showAccountForm("acct-existing");
446
+
447
+ const root = el.shadowRoot!;
448
+ (
449
+ root.querySelector('[data-field="acct-label"]') as HTMLInputElement
450
+ ).value = "Updated Label";
451
+ (
452
+ root.querySelector('[data-field="acct-auth-mode"]') as HTMLSelectElement
453
+ ).value = "basic";
454
+ (
455
+ root.querySelector(
456
+ '[data-field="acct-basic-username"]',
457
+ ) as HTMLInputElement
458
+ ).value = "admin";
459
+ (
460
+ root.querySelector('[data-field="acct-token"]') as HTMLInputElement
461
+ ).value = "secret-pass";
462
+
463
+ await el.saveAccountForm();
464
+
465
+ expect(showSuccess).toHaveBeenCalledWith("Account updated", 3000);
466
+ const updated = el.accounts.find((a) => a.id === "acct-existing");
467
+ expect(updated?.label).toBe("Updated Label");
468
+ expect(updated?.authMode).toBe("basic");
469
+ expect(updated?.basicUsername).toBe("admin");
470
+
471
+ document.body.removeChild(el);
472
+ });
473
+
474
+ it("handles validation errors when saving account", async () => {
475
+ const el = new ShadowClawAccounts();
476
+ document.body.appendChild(el);
477
+ await new Promise((resolve) => setTimeout(resolve, 50));
478
+ await el.render();
479
+ el.db = {} as any;
480
+
481
+ el.showAccountForm("new");
482
+ const root = el.shadowRoot!;
483
+
484
+ // 1. Missing label/service/host
485
+ (
486
+ root.querySelector('[data-field="acct-label"]') as HTMLInputElement
487
+ ).value = "";
488
+ await el.saveAccountForm();
489
+ expect(showError).toHaveBeenCalledWith(
490
+ "Label, Service, and Host Pattern are required.",
491
+ 4000,
492
+ );
493
+
494
+ // Fill required base fields
495
+ (
496
+ root.querySelector('[data-field="acct-label"]') as HTMLInputElement
497
+ ).value = "Test";
498
+ (
499
+ root.querySelector('[data-field="acct-service"]') as HTMLInputElement
500
+ ).value = "Service";
501
+ (root.querySelector('[data-field="acct-host"]') as HTMLInputElement).value =
502
+ "example.com";
503
+ (
504
+ root.querySelector('[data-field="acct-auth-mode"]') as HTMLSelectElement
505
+ ).value = "oauth";
506
+
507
+ // 2. OAuth mode missing client ID
508
+ (
509
+ root.querySelector(
510
+ '[data-field="acct-oauth-provider"]',
511
+ ) as HTMLSelectElement
512
+ ).value = "github";
513
+ (
514
+ root.querySelector(
515
+ '[data-field="acct-oauth-client-id"]',
516
+ ) as HTMLInputElement
517
+ ).value = "";
518
+ await el.saveAccountForm();
519
+ expect(showError).toHaveBeenCalledWith(
520
+ "OAuth client ID is required for OAuth mode.",
521
+ 4000,
522
+ );
523
+
524
+ document.body.removeChild(el);
525
+ });
526
+
527
+ it("handles error when setConfig fails during saveAccountForm", async () => {
528
+ const el = new ShadowClawAccounts();
529
+ document.body.appendChild(el);
530
+ await new Promise((resolve) => setTimeout(resolve, 50));
531
+ await el.render();
532
+ el.db = {} as any;
533
+
534
+ el.showAccountForm("new");
535
+ const root = el.shadowRoot!;
536
+ (
537
+ root.querySelector('[data-field="acct-label"]') as HTMLInputElement
538
+ ).value = "Faulty";
539
+ (
540
+ root.querySelector('[data-field="acct-service"]') as HTMLInputElement
541
+ ).value = "Service";
542
+ (root.querySelector('[data-field="acct-host"]') as HTMLInputElement).value =
543
+ "example.com";
544
+
545
+ (setConfig as jest.Mock).mockRejectedValueOnce(
546
+ new Error("Database write error"),
547
+ );
548
+ await el.saveAccountForm();
549
+
550
+ expect(showError).toHaveBeenCalledWith(
551
+ expect.stringContaining("Database write error"),
552
+ 6000,
553
+ );
554
+
555
+ document.body.removeChild(el);
556
+ });
426
557
  });
@@ -7,6 +7,9 @@ jest.unstable_mockModule("../../../config/config.js", () => ({
7
7
  GIT_CORS_PROXY: "git_cors_proxy",
8
8
  GIT_ACCOUNTS: "git_accounts",
9
9
  GIT_DEFAULT_ACCOUNT: "git_default_account",
10
+ GIT_TOKEN: "git_token",
11
+ GIT_USERNAME: "git_username",
12
+ GIT_PASSWORD: "git_password",
10
13
  },
11
14
  getProviderTokenAuthScheme: (
12
15
  providerId: string,
@@ -454,4 +457,131 @@ describe("shadow-claw-git", () => {
454
457
 
455
458
  document.body.removeChild(el);
456
459
  });
460
+
461
+ it("handles saveAccountForm validation, edit existing account, and oauth missing token", async () => {
462
+ const el = new ShadowClawGit();
463
+ document.body.appendChild(el);
464
+ await new Promise((resolve) => setTimeout(resolve, 50));
465
+ await el.render();
466
+ el.db = {} as any;
467
+
468
+ // 1. Missing label and hostPattern
469
+ el.showAccountForm("new");
470
+ const slot = el.shadowRoot?.querySelector(
471
+ '[data-region="account-form-slot"]',
472
+ ) as HTMLElement;
473
+ expect(slot).toBeDefined();
474
+
475
+ const labelInput = slot.querySelector(
476
+ '[data-field="acct-label"]',
477
+ ) as HTMLInputElement;
478
+ const hostInput = slot.querySelector(
479
+ '[data-field="acct-host"]',
480
+ ) as HTMLInputElement;
481
+ const authModeSelect = slot.querySelector(
482
+ '[data-field="acct-auth-mode"]',
483
+ ) as HTMLSelectElement;
484
+
485
+ labelInput.value = "";
486
+ hostInput.value = "";
487
+ await el.saveAccountForm();
488
+
489
+ // 2. OAuth mode with no token
490
+ labelInput.value = "OAuth Acc";
491
+ hostInput.value = "github.com";
492
+ authModeSelect.value = "oauth";
493
+ await el.saveAccountForm();
494
+
495
+ el.accounts = [
496
+ {
497
+ id: "acc-edit",
498
+ label: "Old Label",
499
+ hostPattern: "github.com",
500
+ authMode: "token",
501
+ token: "old-token",
502
+ } as any,
503
+ ];
504
+ el.showAccountForm("acc-edit");
505
+
506
+ const editLabelInput = slot.querySelector(
507
+ '[data-field="acct-label"]',
508
+ ) as HTMLInputElement;
509
+ const editHostInput = slot.querySelector(
510
+ '[data-field="acct-host"]',
511
+ ) as HTMLInputElement;
512
+ const editAuthModeSelect = slot.querySelector(
513
+ '[data-field="acct-auth-mode"]',
514
+ ) as HTMLSelectElement;
515
+ const editTokenInput = slot.querySelector(
516
+ '[data-field="acct-token"]',
517
+ ) as HTMLInputElement;
518
+ const editPassInput = slot.querySelector(
519
+ '[data-field="acct-password"]',
520
+ ) as HTMLInputElement;
521
+ const editUserInput = slot.querySelector(
522
+ '[data-field="acct-username"]',
523
+ ) as HTMLInputElement;
524
+
525
+ editLabelInput.value = "New Label";
526
+ editHostInput.value = "github.com/org";
527
+ editAuthModeSelect.value = "token";
528
+ editTokenInput.value = "new-token-raw";
529
+ editPassInput.value = "new-pass-raw";
530
+ editUserInput.value = "new-user";
531
+
532
+ await el.saveAccountForm();
533
+
534
+ const edited = el.accounts.find((a) => a.id === "acc-edit");
535
+ expect(edited).toBeDefined();
536
+ expect(edited?.label).toBe("New Label");
537
+ expect(edited?.hostPattern).toBe("github.com/org");
538
+ expect(edited?.username).toBe("new-user");
539
+
540
+ document.body.removeChild(el);
541
+ });
542
+
543
+ it("migrates legacy account configuration when accounts list is empty", async () => {
544
+ const { getConfig } = await import("../../../db/getConfig.js");
545
+ const { setConfig } = await import("../../../db/setConfig.js");
546
+
547
+ (getConfig as any).mockImplementation(async (_db: any, key: string) => {
548
+ if (key === "git_token") return "legacy-enc-token";
549
+ if (key === "git_username") return "legacy-user";
550
+ if (key === "git_password") return "legacy-enc-pass";
551
+ return undefined;
552
+ });
553
+
554
+ const el = new ShadowClawGit();
555
+ document.body.appendChild(el);
556
+ await new Promise((resolve) => setTimeout(resolve, 50));
557
+ el.db = {} as any;
558
+ el.accounts = [];
559
+
560
+ await el.migrateLegacyAccount();
561
+
562
+ expect(el.accounts).toHaveLength(1);
563
+ expect(el.accounts[0].token).toBe("legacy-enc-token");
564
+ expect(el.accounts[0].username).toBe("legacy-user");
565
+ expect(el.defaultAccountId).toBe("legacy-migrated");
566
+ expect(setConfig).toHaveBeenCalledWith(el.db, "git_accounts", el.accounts);
567
+
568
+ document.body.removeChild(el);
569
+ });
570
+
571
+ it("handles deleteAccount when removing the only account", async () => {
572
+ const el = new ShadowClawGit();
573
+ document.body.appendChild(el);
574
+ await new Promise((resolve) => setTimeout(resolve, 50));
575
+ el.db = {} as any;
576
+ el.accounts = [
577
+ { id: "single-acc", label: "Single", hostPattern: "gh.com" } as any,
578
+ ];
579
+ el.defaultAccountId = "single-acc";
580
+
581
+ await el.deleteAccount("single-acc");
582
+ expect(el.accounts).toHaveLength(0);
583
+ expect(el.defaultAccountId).toBe("");
584
+
585
+ document.body.removeChild(el);
586
+ });
457
587
  });
@@ -42,4 +42,16 @@ describe("resolveConnectionTestAuth", () => {
42
42
  });
43
43
  expect(result.error).toContain("Password/app password is missing");
44
44
  });
45
+
46
+ it("handles undefined pendingOauthAccessToken and passwordInput safely", () => {
47
+ const resOAuth: any = resolveConnectionTestAuth({
48
+ authMode: "oauth",
49
+ } as any);
50
+ expect(resOAuth.error).toContain("OAuth access token is missing");
51
+
52
+ const resPass: any = resolveConnectionTestAuth({
53
+ authMode: "password",
54
+ } as any);
55
+ expect(resPass.error).toContain("Password/app password is missing");
56
+ });
45
57
  });