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
@@ -76,4 +76,83 @@ describe("shadow-claw-toast", () => {
76
76
  await new Promise((r) => setTimeout(r, 50));
77
77
  expect(onClick).toHaveBeenCalled();
78
78
  });
79
+
80
+ it("dismisses toast on Escape key press", async () => {
81
+ toastStore.show("Dismiss on esc", { type: "info" });
82
+ await el.render();
83
+
84
+ const toastEl = el.shadowRoot?.querySelector(".toast");
85
+ expect(toastEl).not.toBeNull();
86
+
87
+ const escEvent = new KeyboardEvent("keydown", {
88
+ key: "Escape",
89
+ bubbles: true,
90
+ cancelable: true,
91
+ });
92
+ toastEl?.dispatchEvent(escEvent);
93
+
94
+ await new Promise((r) => setTimeout(r, 250));
95
+ expect(toastStore.toasts.length).toBe(0);
96
+ });
97
+
98
+ it("ignores non-Escape keydown or invalid targets", async () => {
99
+ toastStore.show("Stay open", { type: "info" });
100
+ await el.render();
101
+
102
+ const enterEvent = new KeyboardEvent("keydown", {
103
+ key: "Enter",
104
+ bubbles: true,
105
+ cancelable: true,
106
+ });
107
+ el.shadowRoot?.dispatchEvent(enterEvent);
108
+
109
+ // Event on non-toast element
110
+ const escOnRoot = new KeyboardEvent("keydown", {
111
+ key: "Escape",
112
+ bubbles: false,
113
+ });
114
+ el.shadowRoot?.dispatchEvent(escOnRoot);
115
+
116
+ expect(toastStore.toasts.length).toBe(1);
117
+ });
118
+
119
+ it("pauses and resumes on mouseenter / mouseleave", async () => {
120
+ const pauseSpy = jest.spyOn(toastStore, "pause");
121
+ const resumeSpy = jest.spyOn(toastStore, "resume");
122
+
123
+ const id = toastStore.show("Hover me", { type: "warning" });
124
+ await el.render();
125
+
126
+ const toastEl = el.shadowRoot?.querySelector(".toast");
127
+ toastEl?.dispatchEvent(new MouseEvent("mouseenter"));
128
+ expect(pauseSpy).toHaveBeenCalledWith(id);
129
+
130
+ toastEl?.dispatchEvent(new MouseEvent("mouseleave"));
131
+ expect(resumeSpy).toHaveBeenCalledWith(id);
132
+ });
133
+
134
+ it("renders appropriate icons for all toast types", () => {
135
+ expect(el.iconForType("success")).toBe("✓");
136
+ expect(el.iconForType("error")).toBe("!");
137
+ expect(el.iconForType("warning")).toBe("⚠");
138
+ expect(el.iconForType("info")).toBe("i");
139
+ });
140
+
141
+ it("delegates show() to toastStore.show", () => {
142
+ const id = el.show("Via helper", { type: "success" });
143
+ expect(id).toBeDefined();
144
+ expect(toastStore.toasts.some((t) => t.message === "Via helper")).toBe(
145
+ true,
146
+ );
147
+ });
148
+
149
+ it("returns early if toast is already exiting", async () => {
150
+ const id = toastStore.show("Exiting toast", { type: "info" });
151
+ await el.render();
152
+
153
+ el.exitingToasts.add(id);
154
+ await el.dismissWithAnimation(id);
155
+ // Should not re-add or throw
156
+ expect(el.exitingToasts.has(id)).toBe(true);
157
+ });
79
158
  });
@@ -120,7 +120,8 @@ const { ShadowClawTools } = await import("./shadow-claw-tools.js");
120
120
  const { toolsStore } = (await import("../../stores/tools.js")) as any;
121
121
  const { orchestratorStore } =
122
122
  (await import("../../stores/orchestrator.js")) as any;
123
- const { showSuccess } = (await import("../../ui/toast.js")) as any;
123
+ const { showSuccess, showInfo, showError } =
124
+ (await import("../../ui/toast.js")) as any;
124
125
 
125
126
  describe("shadow-claw-tools", () => {
126
127
  beforeEach(() => {
@@ -636,4 +637,231 @@ describe("shadow-claw-tools", () => {
636
637
 
637
638
  document.body.removeChild(el);
638
639
  });
640
+
641
+ it("handles back button, add button, restore button, and select all/none", async () => {
642
+ const el = new ShadowClawTools();
643
+ document.body.appendChild(el);
644
+ await new Promise((r) => setTimeout(r, 50));
645
+ await el.render();
646
+
647
+ const root = el.shadowRoot!;
648
+
649
+ // 1. Back button
650
+ let backNavigated = false;
651
+ el.addEventListener("navigate-back", () => {
652
+ backNavigated = true;
653
+ });
654
+ const backBtn = root.querySelector(".tools__back-btn") as HTMLButtonElement;
655
+ backBtn?.dispatchEvent(new Event("click"));
656
+ expect(backNavigated).toBe(true);
657
+
658
+ // 2. Add button
659
+ const dialog = root.querySelector("dialog") as HTMLDialogElement;
660
+ dialog.showModal = jest.fn();
661
+ const addBtn = root.querySelector(".tools__add-btn") as HTMLButtonElement;
662
+ addBtn?.dispatchEvent(new Event("click"));
663
+ expect(dialog.showModal).toHaveBeenCalled();
664
+
665
+ // 3. Restore button
666
+ const restoreBtn = root.querySelector(
667
+ ".tools__restore-btn",
668
+ ) as HTMLButtonElement;
669
+ const restoreInput = root.querySelector(
670
+ ".tools__hidden-restore",
671
+ ) as HTMLInputElement;
672
+ const clickSpy = jest
673
+ .spyOn(restoreInput, "click")
674
+ .mockImplementation(() => {});
675
+ restoreBtn?.dispatchEvent(new Event("click"));
676
+ expect(clickSpy).toHaveBeenCalled();
677
+ clickSpy.mockRestore();
678
+
679
+ // 4. Select all / none
680
+ const selectAllBtn = root.querySelector(
681
+ ".tools__select-all-btn",
682
+ ) as HTMLButtonElement;
683
+ selectAllBtn?.dispatchEvent(new Event("click"));
684
+ expect(toolsStore.setAllEnabled).toHaveBeenCalled();
685
+ expect(showInfo).toHaveBeenCalledWith("All tools enabled");
686
+
687
+ const selectNoneBtn = root.querySelector(
688
+ ".tools__select-none-btn",
689
+ ) as HTMLButtonElement;
690
+ selectNoneBtn?.dispatchEvent(new Event("click"));
691
+ expect(showInfo).toHaveBeenCalledWith("All tools disabled");
692
+
693
+ document.body.removeChild(el);
694
+ });
695
+
696
+ it("handles WebMCP, internet access, and Web Search toggles", async () => {
697
+ const el = new ShadowClawTools();
698
+ document.body.appendChild(el);
699
+ await new Promise((r) => setTimeout(r, 50));
700
+ await el.render();
701
+
702
+ const root = el.shadowRoot!;
703
+
704
+ // 1. WebMCP toggle
705
+ const webMcpToggle = root.querySelector(
706
+ ".tools__webmcp-toggle",
707
+ ) as HTMLInputElement;
708
+ if (webMcpToggle) {
709
+ webMcpToggle.checked = true;
710
+ webMcpToggle.dispatchEvent(new Event("change"));
711
+ await new Promise((r) => setTimeout(r, 20));
712
+ expect(showInfo).toHaveBeenCalledWith("WebMCP tool registration enabled");
713
+
714
+ // Without orchestrator
715
+ el.orchestrator = null;
716
+ (orchestratorStore as any).orchestrator = null;
717
+ webMcpToggle.checked = true;
718
+ webMcpToggle.dispatchEvent(new Event("change"));
719
+ await new Promise((r) => setTimeout(r, 20));
720
+ expect(showError).toHaveBeenCalledWith("Orchestrator is not ready");
721
+
722
+ // Restore orchestrator
723
+ (orchestratorStore as any).orchestrator = {
724
+ getWebMcpToolsEnabled: jest.fn(() => true),
725
+ getWebMcpMode: jest.fn(() => "native"),
726
+ setWebMcpToolsEnabled: jest.fn(),
727
+ setWebMcpMode: jest.fn(),
728
+ };
729
+ }
730
+
731
+ // 2. Internet access toggle
732
+ const internetToggle = root.querySelector(
733
+ ".tools__internet-access-toggle",
734
+ ) as HTMLInputElement;
735
+ if (internetToggle) {
736
+ internetToggle.checked = true;
737
+ internetToggle.dispatchEvent(new Event("change"));
738
+ await new Promise((r) => setTimeout(r, 20));
739
+ expect(orchestratorStore.setVMBashFullInternetAccess).toHaveBeenCalled();
740
+ expect(showInfo).toHaveBeenCalledWith(
741
+ "Full internet access enabled for Bash and JavaScript tools",
742
+ );
743
+ }
744
+
745
+ // 3. Web Search proxy toggle
746
+ const proxyToggle = root.querySelector(
747
+ ".tools__websearch-proxy-toggle",
748
+ ) as HTMLInputElement;
749
+ if (proxyToggle) {
750
+ proxyToggle.checked = true;
751
+ proxyToggle.dispatchEvent(new Event("change"));
752
+ await new Promise((r) => setTimeout(r, 20));
753
+ expect(toolsStore.setWebSearchUseProxy).toHaveBeenCalled();
754
+ expect(showInfo).toHaveBeenCalledWith("Web Search local proxy enabled");
755
+ }
756
+
757
+ document.body.removeChild(el);
758
+ });
759
+
760
+ it("handles prompt override save, clear, and dialog controls", async () => {
761
+ const el = new ShadowClawTools();
762
+ document.body.appendChild(el);
763
+ await new Promise((r) => setTimeout(r, 50));
764
+ await el.render();
765
+
766
+ const root = el.shadowRoot!;
767
+
768
+ // 1. Save and Clear prompt
769
+ const textarea = root.querySelector(
770
+ ".tools__prompt-area",
771
+ ) as HTMLTextAreaElement;
772
+ if (textarea) textarea.value = "Custom system instructions";
773
+
774
+ const savePromptBtn = root.querySelector(
775
+ ".tools__save-prompt-btn",
776
+ ) as HTMLButtonElement;
777
+ savePromptBtn?.dispatchEvent(new Event("click"));
778
+ expect(toolsStore.setSystemPromptOverride).toHaveBeenCalled();
779
+ expect(showSuccess).toHaveBeenCalledWith("System prompt override saved");
780
+
781
+ const clearPromptBtn = root.querySelector(
782
+ ".tools__clear-prompt-btn",
783
+ ) as HTMLButtonElement;
784
+ clearPromptBtn?.dispatchEvent(new Event("click"));
785
+ expect(showSuccess).toHaveBeenCalledWith("System prompt override cleared");
786
+
787
+ // 2. Dialog controls (close, cancel, backdrop)
788
+ const dialog = root.querySelector(".tools__dialog") as HTMLDialogElement;
789
+ if (dialog) {
790
+ dialog.close = jest.fn();
791
+ const closeBtn = dialog.querySelector(
792
+ ".tools__dialog-close",
793
+ ) as HTMLButtonElement;
794
+ closeBtn?.dispatchEvent(new Event("click"));
795
+ expect(dialog.close).toHaveBeenCalledTimes(1);
796
+
797
+ const cancelBtn = dialog.querySelector(
798
+ ".tools__btn-cancel",
799
+ ) as HTMLButtonElement;
800
+ cancelBtn?.dispatchEvent(new Event("click"));
801
+ expect(dialog.close).toHaveBeenCalledTimes(2);
802
+
803
+ Object.defineProperty(dialog, "target", { value: dialog });
804
+ dialog.dispatchEvent(new MouseEvent("click", { bubbles: true }));
805
+ expect(dialog.close).toHaveBeenCalledTimes(3);
806
+ }
807
+
808
+ // 3. Clone dialog controls
809
+ const cloneDialog = root.querySelector(
810
+ ".tools__clone-dialog",
811
+ ) as HTMLDialogElement;
812
+ if (cloneDialog) {
813
+ cloneDialog.close = jest.fn();
814
+ const cloneCloseBtn = root.querySelector(
815
+ ".tools__clone-dialog-close",
816
+ ) as HTMLButtonElement;
817
+ cloneCloseBtn?.dispatchEvent(new Event("click"));
818
+ expect(cloneDialog.close).toHaveBeenCalledTimes(1);
819
+
820
+ const cloneCancelBtn = root.querySelector(
821
+ ".tools__clone-cancel-btn",
822
+ ) as HTMLButtonElement;
823
+ cloneCancelBtn?.dispatchEvent(new Event("click"));
824
+ expect(cloneDialog.close).toHaveBeenCalledTimes(2);
825
+ }
826
+
827
+ // 4. Profile dialog controls and Profile select
828
+ const profileDialog = root.querySelector(
829
+ ".tools__profile-dialog",
830
+ ) as HTMLDialogElement;
831
+ if (profileDialog) {
832
+ profileDialog.close = jest.fn();
833
+ const profileCloseBtn = root.querySelector(
834
+ ".tools__profile-dialog-close",
835
+ ) as HTMLButtonElement;
836
+ profileCloseBtn?.dispatchEvent(new Event("click"));
837
+ expect(profileDialog.close).toHaveBeenCalledTimes(1);
838
+
839
+ const profileCancelBtn = root.querySelector(
840
+ ".tools__profile-cancel-btn",
841
+ ) as HTMLButtonElement;
842
+ profileCancelBtn?.dispatchEvent(new Event("click"));
843
+ expect(profileDialog.close).toHaveBeenCalledTimes(2);
844
+
845
+ Object.defineProperty(profileDialog, "target", { value: profileDialog });
846
+ profileDialog.dispatchEvent(new MouseEvent("click", { bubbles: true }));
847
+ expect(profileDialog.close).toHaveBeenCalledTimes(3);
848
+ }
849
+
850
+ const profileSelect = root.querySelector(
851
+ ".tools__profile-select",
852
+ ) as HTMLSelectElement;
853
+ if (profileSelect) {
854
+ profileSelect.value = "custom_profile";
855
+ profileSelect.dispatchEvent(new Event("change"));
856
+ expect(toolsStore.activateProfile).toHaveBeenCalled();
857
+ expect(showInfo).toHaveBeenCalledWith("Profile activated");
858
+
859
+ profileSelect.value = "";
860
+ profileSelect.dispatchEvent(new Event("change"));
861
+ expect(toolsStore.deactivateProfile).toHaveBeenCalled();
862
+ expect(showInfo).toHaveBeenCalledWith("Profile deactivated");
863
+ }
864
+
865
+ document.body.removeChild(el);
866
+ });
639
867
  });
@@ -0,0 +1,57 @@
1
+ import { jest } from "@jest/globals";
2
+ import { createLogger } from "./logger.js";
3
+
4
+ describe("createLogger", () => {
5
+ let logSpy: any;
6
+ let errorSpy: any;
7
+
8
+ beforeEach(() => {
9
+ logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
10
+ errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
11
+ });
12
+
13
+ afterEach(() => {
14
+ logSpy.mockRestore();
15
+ errorSpy.mockRestore();
16
+ });
17
+
18
+ it("logs DEFAULT level messages in non-verbose mode", () => {
19
+ const logger = createLogger(false);
20
+ logger.log("DEFAULT", "standard log");
21
+ expect(logSpy).toHaveBeenCalledWith(
22
+ expect.stringContaining("[LOG]"),
23
+ "standard log",
24
+ );
25
+
26
+ logger.log("VERBOSE", "hidden log");
27
+ expect(logSpy).toHaveBeenCalledTimes(1);
28
+ });
29
+
30
+ it("logs VERBOSE level messages when verbose is true", () => {
31
+ const logger = createLogger(true);
32
+ logger.log("VERBOSE", "verbose log");
33
+ expect(logSpy).toHaveBeenCalledWith(
34
+ expect.stringContaining("[VERBOSE]"),
35
+ "verbose log",
36
+ );
37
+ });
38
+
39
+ it("logs errors in DEFAULT and VERBOSE levels", () => {
40
+ const logger = createLogger(false);
41
+ logger.error("DEFAULT", "standard error");
42
+ expect(errorSpy).toHaveBeenCalledWith(
43
+ expect.stringContaining("[ERROR]"),
44
+ "standard error",
45
+ );
46
+
47
+ logger.error("VERBOSE", "hidden error");
48
+ expect(errorSpy).toHaveBeenCalledTimes(1);
49
+
50
+ const verboseLogger = createLogger(true);
51
+ verboseLogger.error("VERBOSE", "verbose error");
52
+ expect(errorSpy).toHaveBeenCalledWith(
53
+ expect.stringContaining("[VERBOSE ERROR]"),
54
+ "verbose error",
55
+ );
56
+ });
57
+ });
@@ -0,0 +1,47 @@
1
+ import { jest } from "@jest/globals";
2
+ import { EventEmitter } from "node:events";
3
+
4
+ describe("attachPeerServer", () => {
5
+ it("attaches ExpressPeerServer and listens to connection and disconnect events", async () => {
6
+ const mockPeerServer: any = new EventEmitter();
7
+ const expressPeerServerMock = jest.fn(() => mockPeerServer);
8
+
9
+ jest.unstable_mockModule("peer", () => ({
10
+ ExpressPeerServer: expressPeerServerMock,
11
+ }));
12
+
13
+ const { attachPeerServer } = await import("./peer.js");
14
+
15
+ const mockHttpServer: any = {};
16
+ const mockApp: any = {
17
+ use: jest.fn(),
18
+ };
19
+
20
+ const logSpy = jest.spyOn(console, "log").mockImplementation(() => {});
21
+
22
+ try {
23
+ attachPeerServer(mockHttpServer, mockApp);
24
+
25
+ expect(expressPeerServerMock).toHaveBeenCalledWith(mockHttpServer, {
26
+ path: "/",
27
+ allow_discovery: false,
28
+ });
29
+ expect(mockApp.use).toHaveBeenCalledWith(mockPeerServer);
30
+
31
+ const mockClient = { getId: () => "peer-client-123" };
32
+ mockPeerServer.emit("connection", mockClient);
33
+ expect(logSpy).toHaveBeenCalledWith(
34
+ expect.stringContaining("PeerJS: client connected — peer-client-123"),
35
+ );
36
+
37
+ mockPeerServer.emit("disconnect", mockClient);
38
+ expect(logSpy).toHaveBeenCalledWith(
39
+ expect.stringContaining(
40
+ "PeerJS: client disconnected — peer-client-123",
41
+ ),
42
+ );
43
+ } finally {
44
+ logSpy.mockRestore();
45
+ }
46
+ });
47
+ });
@@ -339,4 +339,248 @@ describe("Gemini proxy", () => {
339
339
  }),
340
340
  );
341
341
  });
342
+
343
+ it("returns 401 when API key is missing", async () => {
344
+ const oldKey = process.env.GEMINI_API_KEY;
345
+ delete process.env.GEMINI_API_KEY;
346
+ try {
347
+ const handler = routes.get("POST /gemini-proxy/chat/completions");
348
+ const req = { body: {}, headers: {} } as any;
349
+ const res = {
350
+ status: jest.fn().mockReturnThis(),
351
+ json: jest.fn(),
352
+ } as any;
353
+
354
+ await handler(req, res);
355
+ expect(res.status).toHaveBeenCalledWith(401);
356
+
357
+ const modelsHandler = routes.get("GET /gemini-proxy/models");
358
+ await modelsHandler(req, res);
359
+ expect(res.status).toHaveBeenCalledWith(401);
360
+ } finally {
361
+ if (oldKey) process.env.GEMINI_API_KEY = oldKey;
362
+ }
363
+ });
364
+
365
+ it("handles multimodal content and tools in non-streaming completions", async () => {
366
+ const handler = routes.get("POST /gemini-proxy/chat/completions");
367
+
368
+ mockGenerateContent.mockResolvedValue({
369
+ get text() {
370
+ return "";
371
+ },
372
+ candidates: [
373
+ {
374
+ content: {
375
+ parts: [
376
+ {
377
+ functionCall: {
378
+ name: "fetch_url",
379
+ args: { url: "https://example.com" },
380
+ },
381
+ },
382
+ ],
383
+ },
384
+ },
385
+ ],
386
+ usageMetadata: {
387
+ promptTokenCount: 5,
388
+ candidatesTokenCount: 10,
389
+ totalTokenCount: 15,
390
+ },
391
+ });
392
+
393
+ const req = {
394
+ body: {
395
+ model: "gemini-2.0-flash",
396
+ messages: [
397
+ { role: "system", content: "You are helpful" },
398
+ {
399
+ role: "user",
400
+ content: [
401
+ { type: "text", text: "Look at this" },
402
+ {
403
+ type: "image_url",
404
+ image_url: { url: "data:image/png;base64,iVBORw0KGgo=" },
405
+ },
406
+ { type: "tool_use", name: "calculator", input: { a: 1 } },
407
+ { type: "tool_result", name: "calculator", content: "2" },
408
+ { type: "unknown" },
409
+ ],
410
+ },
411
+ { role: "assistant", content: "working" },
412
+ { role: "tool", content: "result" },
413
+ ],
414
+ tools: [
415
+ {
416
+ function: {
417
+ name: "fetch_url",
418
+ description: "Fetch URL",
419
+ parameters: { type: "object" },
420
+ },
421
+ },
422
+ ],
423
+ },
424
+ headers: { "x-goog-api-key": "test-key" },
425
+ } as any;
426
+
427
+ const res = {
428
+ status: jest.fn().mockReturnThis(),
429
+ json: jest.fn(),
430
+ } as any;
431
+
432
+ await handler(req, res);
433
+
434
+ expect(res.json).toHaveBeenCalledWith(
435
+ expect.objectContaining({
436
+ choices: [
437
+ expect.objectContaining({
438
+ finish_reason: "tool_calls",
439
+ message: expect.objectContaining({
440
+ role: "assistant",
441
+ tool_calls: [
442
+ expect.objectContaining({
443
+ function: expect.objectContaining({
444
+ name: "fetch_url",
445
+ }),
446
+ }),
447
+ ],
448
+ }),
449
+ }),
450
+ ],
451
+ }),
452
+ );
453
+ });
454
+
455
+ it("handles streaming chunks with reasoning and function calls", async () => {
456
+ const handler = routes.get("POST /gemini-proxy/chat/completions");
457
+
458
+ const asyncIterable = (async function* () {
459
+ yield {
460
+ text: "Thinking...",
461
+ candidates: [
462
+ {
463
+ content: {
464
+ parts: [
465
+ { thought: true, text: "Let me check..." },
466
+ {
467
+ functionCall: {
468
+ name: "test_tool",
469
+ args: { x: 1 },
470
+ },
471
+ },
472
+ ],
473
+ },
474
+ },
475
+ ],
476
+ };
477
+ yield {
478
+ text: "Done!",
479
+ };
480
+ })();
481
+
482
+ mockGenerateContentStream.mockResolvedValue(asyncIterable);
483
+
484
+ const written: string[] = [];
485
+ const req = {
486
+ body: {
487
+ stream: true,
488
+ messages: [{ role: "user", content: "run test" }],
489
+ },
490
+ headers: { "x-goog-api-key": "test-key" },
491
+ } as any;
492
+
493
+ const res = {
494
+ setHeader: jest.fn(),
495
+ flushHeaders: jest.fn(),
496
+ write: jest.fn((chunk: string) => written.push(chunk)),
497
+ end: jest.fn(),
498
+ } as any;
499
+
500
+ await handler(req, res);
501
+
502
+ expect(res.setHeader).toHaveBeenCalledWith(
503
+ "Content-Type",
504
+ "text/event-stream",
505
+ );
506
+ expect(res.end).toHaveBeenCalled();
507
+ expect(written.length).toBeGreaterThan(0);
508
+ });
509
+
510
+ it("handles errors during completion with JSON error payload", async () => {
511
+ const handler = routes.get("POST /gemini-proxy/chat/completions");
512
+
513
+ mockGenerateContent.mockRejectedValue(
514
+ new Error(
515
+ JSON.stringify({ error: { code: 429, message: "Resource exhausted" } }),
516
+ ),
517
+ );
518
+
519
+ const req = {
520
+ body: { messages: [{ role: "user", content: "hi" }] },
521
+ headers: { "x-goog-api-key": "test-key" },
522
+ } as any;
523
+
524
+ const res = {
525
+ headersSent: false,
526
+ status: jest.fn().mockReturnThis(),
527
+ json: jest.fn(),
528
+ } as any;
529
+
530
+ await handler(req, res);
531
+ expect(res.status).toHaveBeenCalledWith(429);
532
+ });
533
+
534
+ it("provides fallback models when listing fails during iteration or yields empty list", async () => {
535
+ const handler = routes.get("GET /gemini-proxy/models");
536
+
537
+ const throwingIterable = (async function* () {
538
+ throw new Error("Quota exceeded during iteration");
539
+ })();
540
+ mockListModels.mockResolvedValue(throwingIterable);
541
+
542
+ const req = {
543
+ headers: { "x-goog-api-key": "test-key" },
544
+ } as any;
545
+
546
+ const res = {
547
+ status: jest.fn().mockReturnThis(),
548
+ json: jest.fn(),
549
+ } as any;
550
+
551
+ await handler(req, res);
552
+
553
+ expect(res.json).toHaveBeenCalledWith(
554
+ expect.objectContaining({
555
+ data: expect.arrayContaining([
556
+ expect.objectContaining({ id: "gemini-2.0-flash" }),
557
+ expect.objectContaining({ id: "gemini-1.5-flash" }),
558
+ ]),
559
+ }),
560
+ );
561
+ });
562
+
563
+ it("returns 502 when models.list() itself throws", async () => {
564
+ const handler = routes.get("GET /gemini-proxy/models");
565
+
566
+ mockListModels.mockRejectedValue(new Error("Network failure"));
567
+
568
+ const req = {
569
+ headers: { "x-goog-api-key": "test-key" },
570
+ } as any;
571
+
572
+ const res = {
573
+ status: jest.fn().mockReturnThis(),
574
+ json: jest.fn(),
575
+ } as any;
576
+
577
+ await handler(req, res);
578
+
579
+ expect(res.status).toHaveBeenCalledWith(502);
580
+ expect(res.json).toHaveBeenCalledWith(
581
+ expect.objectContaining({
582
+ error: expect.stringContaining("Network failure"),
583
+ }),
584
+ );
585
+ });
342
586
  });