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
@@ -0,0 +1,80 @@
1
+ import { jest } from "@jest/globals";
2
+
3
+ let resolveUniqueAttachmentPath: any;
4
+ let mockGroupFileExists: any;
5
+ let mockUlid: any;
6
+
7
+ describe("resolveUniqueAttachmentPath", () => {
8
+ beforeEach(async () => {
9
+ jest.resetModules();
10
+ mockGroupFileExists = jest.fn();
11
+ mockUlid = jest.fn(() => "mock-ulid-123");
12
+
13
+ jest.unstable_mockModule("../../../../storage/groupFileExists.js", () => ({
14
+ groupFileExists: mockGroupFileExists,
15
+ }));
16
+ jest.unstable_mockModule("../../../../utils/ulid.js", () => ({
17
+ ulid: mockUlid,
18
+ }));
19
+
20
+ const mod = await import("./resolveUniqueAttachmentPath.js");
21
+ resolveUniqueAttachmentPath = mod.resolveUniqueAttachmentPath;
22
+ });
23
+
24
+ it("returns base path when file does not exist", async () => {
25
+ mockGroupFileExists.mockResolvedValueOnce(false);
26
+ const result = await resolveUniqueAttachmentPath(
27
+ {} as any,
28
+ "group-1",
29
+ "downloads/email",
30
+ "file.pdf",
31
+ );
32
+ expect(result).toBe("downloads/email/file.pdf");
33
+ });
34
+
35
+ it("returns incremented name when initial exists", async () => {
36
+ mockGroupFileExists
37
+ .mockResolvedValueOnce(true)
38
+ .mockResolvedValueOnce(false);
39
+ const result = await resolveUniqueAttachmentPath(
40
+ {} as any,
41
+ "group-1",
42
+ "downloads/email",
43
+ "file.pdf",
44
+ );
45
+ expect(result).toBe("downloads/email/file-2.pdf");
46
+ });
47
+
48
+ it("handles empty directory", async () => {
49
+ mockGroupFileExists.mockResolvedValueOnce(false);
50
+ const result = await resolveUniqueAttachmentPath(
51
+ {} as any,
52
+ "group-1",
53
+ " ",
54
+ "file.txt",
55
+ );
56
+ expect(result).toBe("file.txt");
57
+ });
58
+
59
+ it("falls back to ulid when all 1000 candidate names exist", async () => {
60
+ mockGroupFileExists.mockResolvedValue(true);
61
+ const result = await resolveUniqueAttachmentPath(
62
+ {} as any,
63
+ "group-1",
64
+ "downloads",
65
+ "file.txt",
66
+ );
67
+ expect(result).toBe("downloads/mock-ulid-123-file.txt");
68
+ });
69
+
70
+ it("falls back to ulid when all 1000 candidate names exist in root directory", async () => {
71
+ mockGroupFileExists.mockResolvedValue(true);
72
+ const result = await resolveUniqueAttachmentPath(
73
+ {} as any,
74
+ "group-1",
75
+ "",
76
+ "file.txt",
77
+ );
78
+ expect(result).toBe("mock-ulid-123-file.txt");
79
+ });
80
+ });
@@ -492,4 +492,115 @@ describe("worker/tools/fetch-file — bind safety", () => {
492
492
 
493
493
  expect(result).toContain("Saved");
494
494
  });
495
+
496
+ it("handles OAuth Git re-auth required on 401", async () => {
497
+ const deps = makeDeps({
498
+ fetchImpl: jest.fn(async () =>
499
+ createResponse({
500
+ status: 401,
501
+ statusText: "Unauthorized",
502
+ contentType: "text/plain",
503
+ body: "auth required",
504
+ }),
505
+ ),
506
+ resolveGitCredentials: jest
507
+ .fn()
508
+ .mockResolvedValueOnce({
509
+ token: "old-token",
510
+ authMode: "oauth",
511
+ accountId: "git-1",
512
+ } as any)
513
+ .mockResolvedValueOnce({
514
+ reauthRequired: true,
515
+ provider: "github",
516
+ } as any),
517
+ buildAuthHeaders: jest.fn(() => ({ Authorization: "Bearer old-token" })),
518
+ });
519
+
520
+ const result = await executeFetchFileTool(
521
+ {} as any,
522
+ {
523
+ url: "https://api.github.com/repos/org/repo/raw",
524
+ path: "out/file.txt",
525
+ use_git_auth: true,
526
+ },
527
+ "group-1",
528
+ deps,
529
+ );
530
+
531
+ expect(result).toContain("OAuth Git account reconnect required for github");
532
+ });
533
+
534
+ it("refreshes OAuth Git token and retries on 401", async () => {
535
+ let callCount = 0;
536
+ const deps = makeDeps({
537
+ fetchImpl: jest.fn(async () => {
538
+ callCount++;
539
+ if (callCount === 1) {
540
+ return createResponse({
541
+ status: 401,
542
+ statusText: "Unauthorized",
543
+ contentType: "text/plain",
544
+ body: "unauthorized",
545
+ });
546
+ }
547
+ return createResponse({
548
+ status: 200,
549
+ statusText: "OK",
550
+ contentType: "text/plain",
551
+ body: "success content",
552
+ });
553
+ }),
554
+ resolveGitCredentials: jest
555
+ .fn()
556
+ .mockResolvedValueOnce({
557
+ token: "old-token",
558
+ authMode: "oauth",
559
+ accountId: "git-1",
560
+ } as any)
561
+ .mockResolvedValueOnce({
562
+ token: "new-token",
563
+ authMode: "oauth",
564
+ accountId: "git-1",
565
+ } as any),
566
+ buildAuthHeaders: jest
567
+ .fn()
568
+ .mockReturnValueOnce({ Authorization: "Bearer old-token" })
569
+ .mockReturnValueOnce({ Authorization: "Bearer new-token" }),
570
+ });
571
+
572
+ const result = await executeFetchFileTool(
573
+ {} as any,
574
+ {
575
+ url: "https://api.github.com/repos/org/repo/raw",
576
+ path: "out/file.txt",
577
+ use_git_auth: true,
578
+ },
579
+ "group-1",
580
+ deps,
581
+ );
582
+
583
+ expect(deps.fetchImpl).toHaveBeenCalledTimes(2);
584
+ expect(result).toContain("Saved");
585
+ });
586
+
587
+ it("handles generic network fetch errors", async () => {
588
+ const deps = makeDeps({
589
+ fetchImpl: jest.fn(async () => {
590
+ throw new Error("Connection refused");
591
+ }),
592
+ });
593
+
594
+ const result = await executeFetchFileTool(
595
+ {} as any,
596
+ { url: "https://unreachable.test", path: "out/file.txt" },
597
+ "group-1",
598
+ deps,
599
+ );
600
+
601
+ expect(result).toContain(
602
+ "Network Error: Failed to fetch https://unreachable.test",
603
+ );
604
+ expect(result).toContain("Connection refused");
605
+ });
495
606
  });
@@ -332,4 +332,299 @@ describe("worker/tools/fetch-url", () => {
332
332
  expect(statusIndex).toBeGreaterThanOrEqual(0);
333
333
  expect(beginIndex).toBeGreaterThan(statusIndex);
334
334
  });
335
+
336
+ it("handles binary image and pdf responses and uploads to workspace", async () => {
337
+ const deps = makeDeps({
338
+ fetchImpl: jest.fn(async () => ({
339
+ ...createResponse({
340
+ status: 200,
341
+ statusText: "OK",
342
+ contentType: "image/jpeg",
343
+ body: "image-bytes",
344
+ }),
345
+ headers: new Map([["content-type", "image/jpeg"]]),
346
+ })),
347
+ });
348
+
349
+ const imgResult = await executeFetchUrlTool(
350
+ {} as any,
351
+ {
352
+ url: "https://example.com/photo",
353
+ include_headers: true,
354
+ headers: { "X-Test": "1" },
355
+ },
356
+ "group-1",
357
+ deps,
358
+ );
359
+
360
+ expect(imgResult).toContain("Successfully downloaded binary file");
361
+ expect(imgResult).toContain("![Attachment](downloads/photo.jpg)");
362
+ expect(deps.uploadGroupFile).toHaveBeenCalledWith(
363
+ {},
364
+ "group-1",
365
+ "downloads/photo.jpg",
366
+ expect.any(Blob),
367
+ );
368
+
369
+ // PDF binary
370
+ deps.fetchImpl = jest.fn(async () => ({
371
+ ...createResponse({
372
+ status: 200,
373
+ statusText: "OK",
374
+ contentType: "application/pdf",
375
+ body: "pdf-bytes",
376
+ }),
377
+ headers: new Map([["content-type", "application/pdf"]]),
378
+ }));
379
+
380
+ const pdfResult = await executeFetchUrlTool(
381
+ {} as any,
382
+ { url: "https://example.com/manual" },
383
+ "group-1",
384
+ deps,
385
+ );
386
+
387
+ expect(pdfResult).toContain("[Attachment](downloads/manual.pdf)");
388
+ });
389
+
390
+ it("truncates long response bodies exceeding fetchMaxResponse", async () => {
391
+ const deps = makeDeps({
392
+ fetchMaxResponse: 20,
393
+ fetchImpl: jest.fn(async () =>
394
+ createResponse({
395
+ status: 200,
396
+ statusText: "OK",
397
+ contentType: "text/plain",
398
+ body: "01234567890123456789extra_content_here",
399
+ }),
400
+ ),
401
+ });
402
+
403
+ const result = await executeFetchUrlTool(
404
+ {} as any,
405
+ { url: "https://example.com/large" },
406
+ "group-1",
407
+ deps,
408
+ );
409
+
410
+ expect(result).toContain(
411
+ "--- Response truncated (showed 20 of 38 chars) ---",
412
+ );
413
+ });
414
+
415
+ it("handles git auth, reauth requirement, and 401 retry", async () => {
416
+ // 1. Initial reauthRequired
417
+ const deps1 = makeDeps({
418
+ resolveGitCredentials: jest.fn(async () => ({
419
+ token: undefined,
420
+ reauthRequired: true,
421
+ hostPattern: "github.com",
422
+ })),
423
+ });
424
+
425
+ const res1 = await executeFetchUrlTool(
426
+ {} as any,
427
+ { url: "https://github.com/org/repo", use_git_auth: true },
428
+ "group-1",
429
+ deps1,
430
+ );
431
+ expect(res1).toContain(
432
+ "OAuth Git account reconnect required for github.com",
433
+ );
434
+
435
+ // 2. 401 on git auth with refreshed credentials needing reauth
436
+ const deps2 = makeDeps({
437
+ resolveGitCredentials: jest
438
+ .fn<any>()
439
+ .mockResolvedValueOnce({
440
+ token: "old-token",
441
+ authMode: "oauth",
442
+ hostPattern: "github.com",
443
+ })
444
+ .mockResolvedValueOnce({
445
+ token: undefined,
446
+ authMode: "oauth",
447
+ reauthRequired: true,
448
+ hostPattern: "github.com",
449
+ }),
450
+ buildAuthHeaders: jest.fn(() => ({ Authorization: "Bearer old-token" })),
451
+ fetchImpl: jest.fn(async () =>
452
+ createResponse({
453
+ status: 401,
454
+ statusText: "Unauthorized",
455
+ contentType: "text/plain",
456
+ body: "Bad credentials",
457
+ }),
458
+ ),
459
+ });
460
+
461
+ const res2 = await executeFetchUrlTool(
462
+ {} as any,
463
+ { url: "https://github.com/org/repo", use_git_auth: true },
464
+ "group-1",
465
+ deps2,
466
+ );
467
+ expect(res2).toContain(
468
+ "OAuth Git account reconnect required for github.com",
469
+ );
470
+
471
+ // 3. 401 on git auth with headers updated succeeds on second try
472
+ const deps3 = makeDeps({
473
+ resolveGitCredentials: jest
474
+ .fn<any>()
475
+ .mockResolvedValueOnce({
476
+ token: "token1",
477
+ authMode: "oauth",
478
+ })
479
+ .mockResolvedValueOnce({
480
+ token: "token2",
481
+ authMode: "oauth",
482
+ }),
483
+ buildAuthHeaders: jest
484
+ .fn<any>()
485
+ .mockReturnValueOnce({ Authorization: "Bearer token1" })
486
+ .mockReturnValueOnce({ Authorization: "Bearer token2" }),
487
+ fetchImpl: jest
488
+ .fn<any>()
489
+ .mockResolvedValueOnce(
490
+ createResponse({
491
+ status: 401,
492
+ statusText: "Unauthorized",
493
+ contentType: "text/plain",
494
+ body: "stale",
495
+ }),
496
+ )
497
+ .mockResolvedValueOnce(
498
+ createResponse({
499
+ status: 200,
500
+ statusText: "OK",
501
+ contentType: "text/plain",
502
+ body: "refreshed git data",
503
+ }),
504
+ ),
505
+ });
506
+
507
+ const res3 = await executeFetchUrlTool(
508
+ {} as any,
509
+ { url: "https://github.com/org/repo", use_git_auth: true },
510
+ "group-1",
511
+ deps3,
512
+ );
513
+ expect(res3).toContain("refreshed git data");
514
+ });
515
+
516
+ it("provides helpful hints on 401 and 404 responses", async () => {
517
+ // 401 on github without use_git_auth
518
+ const deps = makeDeps({
519
+ fetchImpl: jest.fn(async () =>
520
+ createResponse({
521
+ status: 401,
522
+ statusText: "Unauthorized",
523
+ contentType: "text/plain",
524
+ body: "Need auth",
525
+ }),
526
+ ),
527
+ resolveServiceCredentials: jest.fn(
528
+ async () =>
529
+ ({
530
+ service: "Slack",
531
+ hostPattern: "slack.com",
532
+ }) as any,
533
+ ),
534
+ });
535
+
536
+ const gitHintRes = await executeFetchUrlTool(
537
+ {} as any,
538
+ { url: "https://github.com/private/repo" },
539
+ "group-1",
540
+ deps,
541
+ );
542
+ expect(gitHintRes).toContain(
543
+ "This looks like a Git host and you received a 401",
544
+ );
545
+ expect(gitHintRes).toContain("use_git_auth: true");
546
+
547
+ // 401 on non-git host with saved account
548
+ const svcHintRes = await executeFetchUrlTool(
549
+ {} as any,
550
+ { url: "https://slack.com/api/test" },
551
+ "group-1",
552
+ deps,
553
+ );
554
+ expect(svcHintRes).toContain(
555
+ 'A saved account for "Slack" (slack.com) was found',
556
+ );
557
+ expect(svcHintRes).toContain("use_account_auth: true");
558
+
559
+ // Generic 404 error
560
+ deps.fetchImpl = jest.fn(async () =>
561
+ createResponse({
562
+ status: 404,
563
+ statusText: "Not Found",
564
+ contentType: "text/plain",
565
+ body: "Missing page",
566
+ }),
567
+ );
568
+ const notFoundRes = await executeFetchUrlTool(
569
+ {} as any,
570
+ { url: "https://example.com/notfound" },
571
+ "group-1",
572
+ deps,
573
+ );
574
+ expect(notFoundRes).toContain("[HTTP 404 Not Found]");
575
+ expect(notFoundRes).toContain(
576
+ "Error fetching URL. Content preview:\nMissing page",
577
+ );
578
+ });
579
+
580
+ it("handles retryable error toast and exceptions", async () => {
581
+ const deps = makeDeps({
582
+ withRetry: jest.fn(async (fn: any, options: any) => {
583
+ // Trigger onRetry callback
584
+ options.onRetry(1, 3, 1000, new Error("Temporary timeout"));
585
+ return fn();
586
+ }),
587
+ fetchImpl: jest.fn(async () => {
588
+ throw new TypeError("Failed to fetch");
589
+ }),
590
+ });
591
+
592
+ const networkErr = await executeFetchUrlTool(
593
+ {} as any,
594
+ { url: "https://broken.domain" },
595
+ "group-1",
596
+ deps,
597
+ );
598
+
599
+ expect(deps.post).toHaveBeenCalledWith(
600
+ expect.objectContaining({
601
+ type: "show-toast",
602
+ payload: expect.objectContaining({
603
+ message: expect.stringContaining("Temporary timeout"),
604
+ }),
605
+ }),
606
+ );
607
+ expect(networkErr).toContain(
608
+ "Network Error: Failed to fetch https://broken.domain",
609
+ );
610
+
611
+ // HttpError instance thrown during fetch
612
+ const { HttpError } = await import("../utils/HttpError.js");
613
+ deps.fetchImpl = jest.fn(async () => {
614
+ throw new HttpError(
615
+ 503,
616
+ "Service Unavailable",
617
+ "Over capacity",
618
+ "X-Retry-After: 5\n",
619
+ );
620
+ });
621
+ const httpErr = await executeFetchUrlTool(
622
+ {} as any,
623
+ { url: "https://overloaded.domain" },
624
+ "group-1",
625
+ deps,
626
+ );
627
+ expect(httpErr).toContain("[HTTP 503 Service Unavailable]");
628
+ expect(httpErr).toContain("Over capacity");
629
+ });
335
630
  });