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.
- package/.well-known/mcp/server-card.json +1 -1
- package/.well-known/mcp.json +1 -1
- package/README.md +9 -9
- package/dist/cli/build/build.js +1 -1
- package/dist/cli/{chunk-DYq-orHK.js → chunk-BgTv9w0K.js} +1 -1
- package/dist/cli/{chunk-C6jii2Fl.js → chunk-d5hIhj8t.js} +1 -1
- package/dist/cli/cli.js +1 -1
- package/dist/lib/components/index.js +1 -1
- package/dist/lib/components/shadow-claw-pages.js +1 -1
- package/dist/lib/index.js +1 -1
- package/dist/lib/{shadow-claw-pages-kA2qa-4B.js → shadow-claw-pages-CjHS50HN.js} +2 -2
- package/dist/lib/{shadow-claw-pages-kA2qa-4B.js.map → shadow-claw-pages-CjHS50HN.js.map} +1 -1
- package/dist/public/.well-known/mcp/server-card.json +1 -1
- package/dist/public/.well-known/mcp.json +1 -1
- package/dist/public/AGENTS.md +1 -1
- package/dist/public/README.md +9 -9
- package/dist/public/docs/README.md +1 -1
- package/dist/public/docs/architecture/orchestrator.md +2 -2
- package/dist/public/docs/architecture/overview.md +3 -3
- package/dist/public/docs/architecture/worker-protocol.md +29 -29
- package/dist/public/docs/example/article/index.html +1 -1
- package/dist/public/docs/example/article.html +1 -1
- package/dist/public/docs/publishing/index.html +1 -1
- package/dist/public/docs/publishing.html +1 -1
- package/dist/public/docs/skill-creator/index.html +1 -1
- package/dist/public/docs/skill-creator.html +1 -1
- package/dist/public/docs/subsystems/local-models.md +1 -1
- package/dist/public/docs/subsystems/providers.md +11 -4
- package/dist/public/e2e/README.md +2 -2
- package/dist/public/index.html +1 -1
- package/dist/public/index.js +1 -1
- package/dist/public/llms.txt +1 -1
- package/dist/public/main/index.html +1 -1
- package/dist/public/main/memory/index.html +1 -1
- package/dist/public/main/memory.html +1 -1
- package/dist/public/main.html +1 -1
- package/dist/public/service-worker.js +1 -1
- package/dist/public/{shadow-claw-Ba2ZFE9m.js → shadow-claw-T9oPpqxT.js} +1 -1
- package/dist/public/{shadow-claw-pages-Cy6T0G9y.js → shadow-claw-pages-IW_LNEL_.js} +1 -1
- package/package.json +1 -1
- package/src/cli/build/build.test.ts +95 -0
- package/src/cli/build/build.ts +5 -1
- package/src/cli/commands/agent.test.ts +134 -0
- package/src/cli/commands/backup.test.ts +85 -0
- package/src/cli/commands/clients.test.ts +32 -0
- package/src/cli/commands/mcp.test.ts +260 -1
- package/src/cli/commands/send-file.test.ts +73 -0
- package/src/cli/commands/send.test.ts +58 -0
- package/src/cli/commands/tasks.test.ts +78 -0
- package/src/cli/commands/webrtc-listen.test.ts +74 -0
- package/src/cli/tools/assert-version-bump.test.ts +30 -1
- package/src/cli/tools/file-search-replace.test.ts +33 -1
- package/src/cli/tools/patch-service-worker-trusted-types.test.ts +24 -1
- package/src/cli/tools/touch-nojekyll.test.ts +12 -0
- package/src/cli/tools/version.test.ts +109 -5
- package/src/cli/utils/agent-core.test.ts +40 -0
- package/src/cli/utils/control-client.test.ts +239 -2
- package/src/cli/utils/load-workspace-config.test.ts +63 -0
- package/src/cli/utils/local-models.test.ts +98 -0
- package/src/cli/utils/progress-bar.test.ts +41 -2
- package/src/cli/utils/resolve-cache-dir.test.ts +61 -0
- package/src/cli/utils/resolve-project-root.test.ts +17 -0
- package/src/cli/utils/stdin.test.ts +38 -0
- package/src/cli/utils/stdio.test.ts +51 -1
- package/src/cli/utils/suppress-warnings.test.ts +9 -0
- package/src/cli/utils/webrtc-control-client.test.ts +569 -0
- package/src/common/utils/frontmatter.test.ts +117 -0
- package/src/components/common/help/providers.test.ts +67 -0
- package/src/components/common/shadow-claw-provider-model-picker/shadow-claw-provider-model-picker.test.ts +121 -0
- package/src/components/settings/shadow-claw-accounts/shadow-claw-accounts.test.ts +131 -0
- package/src/components/settings/shadow-claw-git/shadow-claw-git.test.ts +130 -0
- package/src/components/settings/shadow-claw-integrations/connection-test-auth.test.ts +12 -0
- package/src/components/settings/shadow-claw-integrations/shadow-claw-integrations.test.ts +328 -0
- package/src/components/settings/shadow-claw-llm/shadow-claw-llm.test.ts +530 -8
- package/src/components/settings/shadow-claw-mcp-remote/shadow-claw-mcp-remote.test.ts +459 -0
- package/src/components/settings/shadow-claw-networking/shadow-claw-networking.test.ts +88 -0
- package/src/components/settings/shadow-claw-notifications/shadow-claw-notifications.test.ts +92 -0
- package/src/components/settings/shadow-claw-peerjs/shadow-claw-peerjs.test.ts +127 -0
- package/src/components/settings/shadow-claw-storage/shadow-claw-storage.test.ts +173 -0
- package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.test.ts +62 -0
- package/src/components/settings/shadow-claw-webvm/shadow-claw-webvm.test.ts +104 -0
- package/src/components/shadow-claw/shadow-claw.test.ts +112 -0
- package/src/components/shadow-claw-a2ui/shadow-claw-a2ui.test.ts +109 -0
- package/src/components/shadow-claw-conversations/shadow-claw-conversations.test.ts +361 -0
- package/src/components/shadow-claw-dialog/shadow-claw-dialog.test.ts +76 -0
- package/src/components/shadow-claw-file-viewer/shadow-claw-file-viewer.test.ts +307 -0
- package/src/components/shadow-claw-files/shadow-claw-files.test.ts +398 -1
- package/src/components/shadow-claw-pages/shadow-claw-pages.test.ts +85 -0
- package/src/components/shadow-claw-pages/shadow-claw-pages.ts +2 -1
- package/src/components/shadow-claw-pdf-viewer/shadow-claw-pdf-viewer.test.ts +229 -0
- package/src/components/shadow-claw-settings/shadow-claw-settings.test.ts +233 -0
- package/src/components/shadow-claw-tasks/shadow-claw-tasks.test.ts +223 -1
- package/src/components/shadow-claw-toast/shadow-claw-toast.test.ts +79 -0
- package/src/components/shadow-claw-tools/shadow-claw-tools.test.ts +229 -1
- package/src/server/logger.test.ts +57 -0
- package/src/server/peer.test.ts +47 -0
- package/src/server/routes/gemini.test.ts +244 -0
- package/src/server/server-peer.test.ts +245 -11
- package/src/server/services/llamafile-manager.test.ts +668 -0
- package/src/share-target/pending-shares.test.ts +90 -0
- package/src/shell/fs.test.ts +125 -0
- package/src/shell/shell.test.ts +8 -0
- package/src/subsystems/git/credentials.test.ts +260 -0
- package/src/subsystems/git/git.test.ts +64 -0
- package/src/subsystems/providers/executeNativeAiTask.test.ts +78 -0
- package/src/subsystems/providers/utils/parseLocalModelToolCall.test.ts +57 -0
- package/src/worker/tools/builtin-ai/builtin-ai.test.ts +88 -0
- package/src/worker/tools/email/email.test.ts +358 -0
- package/src/worker/tools/email/utils/resolveIntegrationEmailAuth.test.ts +120 -0
- package/src/worker/tools/email/utils/resolveUniqueAttachmentPath.test.ts +80 -0
- package/src/worker/tools/fetch-file/fetch-file.test.ts +111 -0
- 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 } =
|
|
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
|
});
|