shadow-claw 1.23.4 → 1.24.0
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/README.md +42 -5
- package/bin/cli.mjs +166 -0
- package/bin/cli.test.mjs +60 -0
- package/bin/commands/backup.mjs +70 -0
- package/bin/commands/clients.mjs +41 -0
- package/bin/commands/peer-id.mjs +76 -0
- package/bin/commands/peer-id.test.mjs +104 -0
- package/bin/commands/send.mjs +51 -0
- package/bin/commands/tasks.mjs +59 -0
- package/bin/commands/webrtc-listen.mjs +254 -0
- package/bin/prerender-dsd-shell/prerender-dsd-shell.test.mjs +46 -0
- package/bin/rolldown.config.test.mjs +401 -8
- package/bin/utils/control-client.mjs +243 -0
- package/bin/utils/control-client.test.mjs +83 -0
- package/bin/utils/ulid.mjs +45 -0
- package/bin/utils/webrtc-control-client.mjs +699 -0
- package/bin/utils/webrtc-control-client.test.mjs +318 -0
- package/dist/electron/main.cjs +1 -1
- package/dist/public/AGENTS.md +5 -3
- package/dist/public/README.md +42 -5
- package/dist/public/agent.worker.js +1 -1
- package/dist/public/backup-controller-C_s9in2t.js +1 -0
- package/dist/public/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.css +1 -0
- package/dist/public/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.html +1 -0
- package/dist/public/components/settings/shadow-claw-task-server/shadow-claw-task-server.css +1 -1
- package/dist/public/components/settings/shadow-claw-task-server/shadow-claw-task-server.html +1 -1
- package/dist/public/components/shadow-claw-settings/shadow-claw-settings.html +1 -1
- package/dist/public/config-CrHchneq.js +1 -0
- package/dist/public/{configurePeerJs-B4Ym9VMj.js → configurePeerJs-CxpZeDJJ.js} +1 -1
- package/dist/public/{connections-BEn6JYdu.js → connections-DvbgdkaB.js} +1 -1
- package/dist/public/{custom-element-security-DdYlUozq.js → custom-element-security-NCqQ5lmC.js} +1 -1
- package/dist/public/docs/README.md +3 -1
- package/dist/public/docs/architecture/overview.md +12 -12
- package/dist/public/docs/decisions/bundled-typescript-architecture.md +1 -1
- package/dist/public/docs/decisions/indexeddb-and-opfs-storage.md +2 -2
- package/dist/public/docs/decisions/native-web-components-and-signals.md +1 -1
- package/dist/public/docs/example/article/index.html +1 -1
- package/dist/public/docs/guides/configuring-messaging-channels.md +41 -6
- package/dist/public/docs/guides/server-development-configuration.md +24 -0
- package/dist/public/docs/publishing/index.html +1 -1
- package/dist/public/docs/skill-creator/index.html +1 -1
- package/dist/public/docs/subsystems/backup.md +56 -0
- package/dist/public/docs/subsystems/cli.md +124 -0
- package/dist/public/docs/subsystems/control-plane.md +158 -0
- package/dist/public/docs/subsystems/notifications.md +54 -2
- package/dist/public/docs/subsystems/reactive-ui.md +1 -0
- package/dist/public/downloadGroupFile-By0GR6eV.js +1 -0
- package/dist/public/e2e/README.md +5 -1
- package/dist/public/{e2e-bridge-BQ4KzUt5.js → e2e-bridge-IGWsRl4i.js} +1 -1
- package/dist/public/file-viewer-D0eflgt6.js +1 -0
- package/dist/public/getAllTasks-vpBlgGsc.js +1 -0
- package/dist/public/getGroupDir-P1h9wl6S.js +1 -0
- package/dist/public/{git-DEUvQTUA.js → git-7Vq_SJSp.js} +2 -2
- package/dist/public/{iframe-sanitizer-0NS4XLOd.js → iframe-sanitizer-y2Z1pPwY.js} +1 -1
- package/dist/public/index.html +1 -1
- package/dist/public/index.js +1 -1
- package/dist/public/{initChatSplitResize-llGkSJXg.js → initChatSplitResize-CzV2E7Ul.js} +1 -1
- package/dist/public/initControlPlane-C9jpnWev.js +1 -0
- 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/{markdown-CyYR4App.js → markdown-BnpKxSxl.js} +1 -1
- package/dist/public/{mcp-reconnect-CSx4aMwy.js → mcp-reconnect-9emsqGRe.js} +1 -1
- package/dist/public/{memoryStorage-Dwg8NhAZ.js → memoryStorage-C0KvLNUp.js} +1 -1
- package/dist/public/{orchestrator-DRfEudXk.js → orchestrator-3h9xxHUm.js} +676 -676
- package/dist/public/orchestrator-nJj59RqD.js +15 -0
- package/dist/public/{peerjs-CurBuKvk.js → peerjs-BP4laMXj.js} +1 -1
- package/dist/public/{push-client-Dii-vPEh.js → push-client-CrjEXK3U.js} +1 -1
- package/dist/public/service-worker/push-handler.js +1 -1
- package/dist/public/service-worker.js +1 -1
- package/dist/public/{shadow-claw-5IHaDDMJ.js → shadow-claw-CbwETBWf.js} +2 -2
- package/dist/public/{shadow-claw-accounts-CZvz4iQz.js → shadow-claw-accounts-B4sBTQEj.js} +1 -1
- package/dist/public/{shadow-claw-channels-BKXFyRS3.js → shadow-claw-channels-BDWG5Qa3.js} +1 -1
- package/dist/public/{shadow-claw-chat-CCLuhWdI.js → shadow-claw-chat-Crv0DTCY.js} +6 -6
- package/dist/public/shadow-claw-control-plane-CX1IGbh9.js +199 -0
- package/dist/public/{shadow-claw-conversations-DV-87UUh.js → shadow-claw-conversations-_9qAaRXk.js} +1 -1
- package/dist/public/shadow-claw-dialog-BrOwAOdk.js +1 -0
- package/dist/public/{shadow-claw-file-viewer-Dk2dbAUd.js → shadow-claw-file-viewer-Blf6yGVD.js} +1 -1
- package/dist/public/shadow-claw-files-CYySBDCl.js +840 -0
- package/dist/public/{shadow-claw-git-DRA7_qck.js → shadow-claw-git-v8S0JjuR.js} +2 -2
- package/dist/public/{shadow-claw-integrations-1Xu5snuV.js → shadow-claw-integrations-D7ufpwhX.js} +1 -1
- package/dist/public/{shadow-claw-llm-6Aci1BDP.js → shadow-claw-llm-GWgoVfhI.js} +2 -2
- package/dist/public/{shadow-claw-mcp-remote-BUjAeVXC.js → shadow-claw-mcp-remote-kY4LMFNN.js} +1 -1
- package/dist/public/{shadow-claw-networking-FQe4nCp3.js → shadow-claw-networking-qHbP95z9.js} +1 -1
- package/dist/public/{shadow-claw-notifications-DV2wibL3.js → shadow-claw-notifications-YyHOK2Fr.js} +1 -1
- package/dist/public/{shadow-claw-page-header-B63S4HTU.js → shadow-claw-page-header-DMKbkv3s.js} +1 -1
- package/dist/public/{shadow-claw-pages-DE3jWj5K.js → shadow-claw-pages-DFZHhzFZ.js} +1 -1
- package/dist/public/{shadow-claw-settings-S6DAKfYx.js → shadow-claw-settings-DhxrKIfA.js} +10 -3
- package/dist/public/{shadow-claw-storage-DaacA3_K.js → shadow-claw-storage-BFynbP0j.js} +2 -2
- package/dist/public/{shadow-claw-task-server-IjKX042p.js → shadow-claw-task-server-3BIeb5oZ.js} +25 -4
- package/dist/public/{shadow-claw-tasks-Cy0JYmGU.js → shadow-claw-tasks-Caxc9atL.js} +1 -1
- package/dist/public/{shadow-claw-terminal-znAt7HyP.js → shadow-claw-terminal-Beb5qF-U.js} +1 -1
- package/dist/public/{shadow-claw-tools-Cutv9Si5.js → shadow-claw-tools-BfJx3A9W.js} +1 -1
- package/dist/public/{shadow-claw-webvm-BH8mPDEu.js → shadow-claw-webvm-BCS8ykeP.js} +1 -1
- package/dist/public/{syncWebMcpRegistration-BqJxaMGR.js → syncWebMcpRegistration-B_M2fCjH.js} +1 -1
- package/dist/server.js +286 -189
- package/package.json +2 -1
- package/src/__mocks__/jszip.cjs +15 -5
- package/src/components/settings/shadow-claw-accounts/shadow-claw-accounts.test.ts +35 -0
- package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.css +117 -0
- package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.html +81 -0
- package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.test.ts +127 -0
- package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.ts +292 -0
- package/src/components/settings/shadow-claw-git/shadow-claw-git.test.ts +72 -0
- package/src/components/settings/shadow-claw-integrations/connection-test-auth.test.ts +20 -30
- package/src/components/settings/shadow-claw-integrations/shadow-claw-integrations.test.ts +116 -0
- package/src/components/settings/shadow-claw-llm/model-ranking.test.ts +143 -51
- package/src/components/settings/shadow-claw-llm/shadow-claw-llm.test.ts +307 -4
- package/src/components/settings/shadow-claw-llm/shadow-claw-llm.ts +8 -8
- package/src/components/settings/shadow-claw-mcp-remote/shadow-claw-mcp-remote.test.ts +31 -0
- package/src/components/settings/shadow-claw-peerjs/shadow-claw-peerjs.test.ts +186 -0
- package/src/components/settings/shadow-claw-storage/shadow-claw-storage.test.ts +76 -0
- package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.css +23 -2
- package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.html +1 -1
- package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.test.ts +19 -0
- package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.ts +2 -1
- package/src/components/shadow-claw/utils/initChatSplitResize.test.ts +180 -13
- package/src/components/shadow-claw/utils/setupEffects.test.ts +39 -0
- package/src/components/shadow-claw-a2ui/catalog/basic/shared.test.ts +117 -0
- package/src/components/shadow-claw-a2ui/shadow-claw-a2ui.test.ts +1159 -1
- package/src/components/shadow-claw-chat/shadow-claw-chat.test.ts +1523 -19
- package/src/components/shadow-claw-chat/utils/escapeHtml.test.ts +13 -0
- package/src/components/shadow-claw-chat/utils/getPeerChatDisplayStatus.test.ts +20 -0
- package/src/components/shadow-claw-conversations/shadow-claw-conversations.test.ts +494 -0
- package/src/components/shadow-claw-dialog/shadow-claw-dialog.ts +13 -2
- package/src/components/shadow-claw-file-viewer/shadow-claw-file-viewer.test.ts +273 -0
- package/src/components/shadow-claw-files/shadow-claw-files.test.ts +408 -1
- package/src/components/shadow-claw-pdf-viewer/utils/installGetOrInsertComputedPolyfill.test.ts +65 -0
- package/src/components/shadow-claw-settings/shadow-claw-settings.html +7 -0
- package/src/components/shadow-claw-settings/shadow-claw-settings.test.ts +221 -0
- package/src/components/shadow-claw-settings/shadow-claw-settings.ts +2 -0
- package/src/components/shadow-claw-tasks/shadow-claw-tasks.test.ts +407 -0
- package/src/components/shadow-claw-terminal/utils/consumeEscapeSequence.test.ts +58 -0
- package/src/components/shadow-claw-tools/shadow-claw-tools.test.ts +80 -0
- package/src/config/config.ts +10 -0
- package/src/core/backup-controller.test.ts +101 -0
- package/src/core/backup-controller.ts +137 -0
- package/src/core/control-plane-client.test.ts +408 -0
- package/src/core/control-plane-client.ts +425 -0
- package/src/core/orchestrator/orchestrator.ts +4 -0
- package/src/core/orchestrator/utils/compactContext.test.ts +78 -0
- package/src/core/orchestrator/utils/operations/channel.test.ts +254 -9
- package/src/core/orchestrator/utils/operations/provider.test.ts +326 -34
- package/src/core/orchestrator/utils/operations/vm.test.ts +114 -2
- package/src/core/orchestrator/utils/syncProxyConfigToServiceWorker.test.ts +60 -0
- package/src/core/utils/initControlPlane.test.ts +276 -0
- package/src/core/utils/initControlPlane.ts +437 -0
- package/src/core/utils/initializeApp.ts +18 -0
- package/src/db/getSession.test.ts +13 -1
- package/src/server/app.test.ts +12 -0
- package/src/server/app.ts +18 -8
- package/src/server/client-registry.test.ts +250 -0
- package/src/server/client-registry.ts +477 -0
- package/src/server/config.ts +15 -0
- package/src/server/control-plane-types.ts +120 -0
- package/src/server/control-plane.test.ts +472 -0
- package/src/server/control-plane.ts +581 -0
- package/src/server/routes/backup.test.ts +263 -0
- package/src/server/routes/backup.ts +262 -0
- package/src/server/routes/integrations-email.test.ts +532 -0
- package/src/server/routes/llamafile.test.ts +172 -48
- package/src/server/routes/mesh-llm.test.ts +192 -0
- package/src/server/routes/ollama.test.ts +49 -0
- package/src/server/server-peer.test.ts +86 -0
- package/src/server/server-peer.ts +330 -0
- package/src/server/server.ts +35 -0
- package/src/server/services/llamafile-manager.test.ts +230 -6
- package/src/server/services/transformers-runtime.test.ts +186 -18
- package/src/server/webrtc.test.ts +21 -0
- package/src/server/webrtc.ts +72 -0
- package/src/service-worker/fetch-proxy.test.ts +64 -1
- package/src/service-worker/push-handler.ts +39 -0
- package/src/shell/vm.test.ts +744 -12
- package/src/storage/ensureMainGroupIndex.test.ts +153 -0
- package/src/storage/getGroupFile.test.ts +66 -0
- package/src/storage/memoryStorage.test.ts +82 -2
- package/src/storage/writeGroupFileBytes.test.ts +143 -0
- package/src/stores/chat-ui.test.ts +20 -0
- package/src/stores/orchestrator.test.ts +134 -0
- package/src/stores/toast.test.ts +50 -0
- package/src/stores/tools.test.ts +147 -10
- package/src/subsystems/channels/imessage.test.ts +59 -0
- package/src/subsystems/channels/peerjs.test.ts +500 -62
- package/src/subsystems/channels/peerjs.ts +71 -4
- package/src/subsystems/channels/telegram.test.ts +177 -0
- package/src/subsystems/email/catalog.test.ts +1 -0
- package/src/subsystems/email/connections.test.ts +39 -1
- package/src/subsystems/git/credentials.test.ts +160 -825
- package/src/subsystems/git/git.test.ts +463 -0
- package/src/subsystems/notifications/__mocks__/node-sqlite.cjs +12 -1
- package/src/subsystems/notifications/push-routes.test.ts +33 -0
- package/src/subsystems/notifications/push-routes.ts +23 -0
- package/src/subsystems/notifications/push-store.ts +1 -1
- package/src/subsystems/providers/builtin-ai-tasks.test.ts +74 -0
- package/src/subsystems/providers/litert-lm-provider.test.ts +212 -51
- package/src/subsystems/providers/model-registry.test.ts +115 -0
- package/src/subsystems/providers/providers.test.ts +94 -1
- package/src/subsystems/providers/transformers-js-provider.test.ts +279 -0
- package/src/subsystems/providers/utils/createModelCacheFetch.test.ts +2 -2
- package/src/subsystems/providers/utils/downloadModelToCache.test.ts +2 -2
- package/src/subsystems/providers/utils/loadModelStream.test.ts +2 -2
- package/src/subsystems/skills/activateSkill.test.ts +101 -0
- package/src/subsystems/skills/discoverSkills.test.ts +251 -0
- package/src/subsystems/tools/cron.test.ts +64 -0
- package/src/subsystems/tools/declarative.test.ts +255 -89
- package/src/testing/assets/v86.9pfs/libv86.mjs +63 -0
- package/src/testing/assets/v86.ext2/libv86.mjs +3 -0
- package/src/testing/assets_missing_v86/assets/v86.9pfs/libv86.mjs +1 -0
- package/src/testing/jest-setup.ts +328 -40
- package/src/testing/mock-v86.mjs +49 -0
- package/src/ui/a2ui/types.ts +4 -3
- package/src/ui/a2ui/utils/normaliseComponentsToMap.test.ts +102 -0
- package/src/ui/a2ui/utils/resolveChildIds.test.ts +63 -0
- package/src/ui/a2ui/utils/resolveDynamicNumber.test.ts +13 -1
- package/src/worker/tools/bash/utils/waitForVMReady.test.ts +40 -0
- package/src/worker/tools/email/email.test.ts +193 -8
- package/src/worker/tools/email/utils/asEmailAttachmentInputs.test.ts +47 -0
- package/src/worker/tools/git/git.test.ts +214 -3
- package/src/worker/tools/rooms/invite-to-room.test.ts +54 -0
- package/src/worker/tools/rooms/leave-room.test.ts +40 -0
- package/src/worker/tools/rooms/list-room-members.test.ts +75 -0
- package/src/worker/tools/tasks/tasks-utils.test.ts +22 -0
- package/src/worker/tools/ui/ask-user.test.ts +50 -0
- package/src/worker/tools/ui/clear-chat.test.ts +19 -0
- package/src/worker/tools/ui/get-current-time.test.ts +19 -0
- package/src/worker/tools/ui/javascript.test.ts +116 -0
- package/src/worker/tools/ui/list-components.test.ts +10 -0
- package/src/worker/tools/ui/list-tool-profiles.test.ts +42 -0
- package/src/worker/tools/ui/manage-tools.test.ts +34 -0
- package/src/worker/tools/ui/render-component.test.ts +346 -0
- package/src/worker/tools/ui/send-notification.test.ts +40 -0
- package/src/worker/tools/ui/utils/getAllowFullInternetAccess.test.ts +31 -0
- package/src/worker/tools/ui/utils/parseBooleanConfig.test.ts +25 -0
- package/src/worker/tools/workspace/attach-file.test.ts +84 -0
- package/src/worker/tools/workspace/copy-file.test.ts +69 -0
- package/src/worker/tools/workspace/create-directory.test.ts +40 -0
- package/src/worker/tools/workspace/delete-file.test.ts +73 -0
- package/src/worker/tools/workspace/diff-files.test.ts +94 -0
- package/src/worker/tools/workspace/list-files.test.ts +36 -0
- package/src/worker/tools/workspace/move-file.test.ts +66 -0
- package/src/worker/tools/workspace/open-file.test.ts +57 -0
- package/src/worker/tools/workspace/patch-file.test.ts +81 -0
- package/src/worker/tools/workspace/read-file.test.ts +116 -0
- package/src/worker/tools/workspace/send-file.test.ts +82 -0
- package/src/worker/tools/workspace/update-memory.test.ts +29 -0
- package/src/worker/tools/workspace/write-file.test.ts +28 -0
- package/src/worker/utils/rate-limit.test.ts +199 -0
- package/src/worker/utils/tool-state.test.ts +29 -0
- package/src/worker/utils/transformers-js-utils.test.ts +59 -0
- package/dist/public/config-BYAuxx3j.js +0 -1
- package/dist/public/downloadGroupFile-Drdvg1Ev.js +0 -1
- package/dist/public/file-viewer-CcZYw-PS.js +0 -1
- package/dist/public/orchestrator-o9Fj67BN.js +0 -15
- package/dist/public/shadow-claw-dialog-ByyHgxM9.js +0 -1
- package/dist/public/shadow-claw-files-BpfpJIuP.js +0 -840
|
@@ -1,7 +1,51 @@
|
|
|
1
1
|
import { jest } from "@jest/globals";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
jest.unstable_mockModule("jszip", () => {
|
|
4
|
+
class MockJSZip {
|
|
5
|
+
static async loadAsync(_file: any) {
|
|
6
|
+
return new MockJSZip();
|
|
7
|
+
}
|
|
8
|
+
file(_name: string) {
|
|
9
|
+
return {
|
|
10
|
+
async: async (_type: string) =>
|
|
11
|
+
JSON.stringify({ messages: [{ id: "1", content: "hi" }] }),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
async generateAsync() {
|
|
15
|
+
return new Blob([]);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
default: MockJSZip,
|
|
20
|
+
__esModule: true,
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
jest.unstable_mockModule("../../core/effect.js", () => ({
|
|
25
|
+
effect: jest.fn((cb: any) => {
|
|
26
|
+
try {
|
|
27
|
+
if (typeof cb === "function") cb();
|
|
28
|
+
} catch {}
|
|
29
|
+
return () => {};
|
|
30
|
+
}),
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
jest.unstable_mockModule("../../storage/readGroupFileBytes.js", () => ({
|
|
34
|
+
readGroupFileBytes: jest.fn(async () => new Uint8Array([1, 2, 3])),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
jest.unstable_mockModule("../../storage/downloadGroupFile.js", () => ({
|
|
38
|
+
downloadGroupFile: jest.fn(async () => undefined),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
jest.unstable_mockModule("../../db/exportChatData.js", () => ({
|
|
42
|
+
exportChatData: jest.fn(async () => ({ messages: [], version: 1 })),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
jest.unstable_mockModule("../../db/importChatData.js", () => ({
|
|
46
|
+
importChatData: jest.fn(async () => undefined),
|
|
47
|
+
}));
|
|
48
|
+
|
|
5
49
|
const { orchestratorStore } = await import("../../stores/orchestrator.js");
|
|
6
50
|
const { clearGroupMessages } = await import("../../db/clearGroupMessages.js");
|
|
7
51
|
const { setDB } = await import("../../db/db.js");
|
|
@@ -250,7 +294,7 @@ describe("shadow-claw-chat UX enhancements (issue #10)", () => {
|
|
|
250
294
|
beforeAll(async () => {
|
|
251
295
|
// Import the class to access static method
|
|
252
296
|
// We use a dynamic import with a mock to avoid browser-only deps
|
|
253
|
-
|
|
297
|
+
// jszip mocked at top level
|
|
254
298
|
jest.unstable_mockModule("../../db/exportChatData.js", () => ({
|
|
255
299
|
exportChatData: jest.fn(),
|
|
256
300
|
}));
|
|
@@ -263,9 +307,7 @@ describe("shadow-claw-chat UX enhancements (issue #10)", () => {
|
|
|
263
307
|
importChatData: jest.fn(),
|
|
264
308
|
}));
|
|
265
309
|
|
|
266
|
-
|
|
267
|
-
effect: jest.fn(),
|
|
268
|
-
}));
|
|
310
|
+
// effect mocked at top level
|
|
269
311
|
|
|
270
312
|
jest.unstable_mockModule("../../content/markdown.js", () => ({
|
|
271
313
|
renderMarkdown: jest.fn((str: string) => `<p>${str}</p>`),
|
|
@@ -283,6 +325,8 @@ describe("shadow-claw-chat UX enhancements (issue #10)", () => {
|
|
|
283
325
|
sendMessage: jest.fn(),
|
|
284
326
|
stopCurrentRequest: jest.fn(),
|
|
285
327
|
deleteMessage: jest.fn(),
|
|
328
|
+
newSession: jest.fn(),
|
|
329
|
+
compactContext: jest.fn(),
|
|
286
330
|
},
|
|
287
331
|
}));
|
|
288
332
|
|
|
@@ -369,7 +413,7 @@ describe("shadow-claw-chat UX enhancements (issue #10)", () => {
|
|
|
369
413
|
// The constant is module-scoped, so we verify its effect indirectly.
|
|
370
414
|
// The key behavior: if user scrolls up, auto-scroll should NOT happen.
|
|
371
415
|
// We verify the template includes the messages container which is the scroll target.
|
|
372
|
-
|
|
416
|
+
// jszip mocked at top level
|
|
373
417
|
|
|
374
418
|
const { ShadowClawChat: _ShadowClawChat } =
|
|
375
419
|
await import("./shadow-claw-chat.js");
|
|
@@ -410,7 +454,7 @@ describe("message copy button", () => {
|
|
|
410
454
|
let mockOrchestratorStore;
|
|
411
455
|
|
|
412
456
|
beforeAll(async () => {
|
|
413
|
-
|
|
457
|
+
// jszip mocked at top level
|
|
414
458
|
|
|
415
459
|
jest.unstable_mockModule("../../db/exportChatData.js", () => ({
|
|
416
460
|
exportChatData: jest.fn(),
|
|
@@ -424,9 +468,7 @@ describe("message copy button", () => {
|
|
|
424
468
|
importChatData: jest.fn(),
|
|
425
469
|
}));
|
|
426
470
|
|
|
427
|
-
|
|
428
|
-
effect: jest.fn(),
|
|
429
|
-
}));
|
|
471
|
+
// effect mocked at top level
|
|
430
472
|
|
|
431
473
|
jest.unstable_mockModule("../../content/markdown.js", () => ({
|
|
432
474
|
renderMarkdown: jest.fn((str: string) => `<p>${str}</p>`),
|
|
@@ -443,6 +485,8 @@ describe("message copy button", () => {
|
|
|
443
485
|
sendMessage: jest.fn(),
|
|
444
486
|
stopCurrentRequest: jest.fn(),
|
|
445
487
|
deleteMessage: jest.fn(),
|
|
488
|
+
newSession: jest.fn(),
|
|
489
|
+
compactContext: jest.fn(),
|
|
446
490
|
};
|
|
447
491
|
|
|
448
492
|
jest.unstable_mockModule("../../stores/orchestrator.js", () => ({
|
|
@@ -857,7 +901,7 @@ describe("chat workspace link resolution", () => {
|
|
|
857
901
|
let ShadowClawChat;
|
|
858
902
|
|
|
859
903
|
beforeAll(async () => {
|
|
860
|
-
|
|
904
|
+
// jszip mocked at top level
|
|
861
905
|
|
|
862
906
|
jest.unstable_mockModule("../../db/exportChatData.js", () => ({
|
|
863
907
|
exportChatData: jest.fn(),
|
|
@@ -871,9 +915,7 @@ describe("chat workspace link resolution", () => {
|
|
|
871
915
|
importChatData: jest.fn(),
|
|
872
916
|
}));
|
|
873
917
|
|
|
874
|
-
|
|
875
|
-
effect: jest.fn(),
|
|
876
|
-
}));
|
|
918
|
+
// effect mocked at top level
|
|
877
919
|
|
|
878
920
|
jest.unstable_mockModule("../../content/markdown.js", () => ({
|
|
879
921
|
renderMarkdown: jest.fn((str: string) => `<p>${str}</p>`),
|
|
@@ -890,6 +932,8 @@ describe("chat workspace link resolution", () => {
|
|
|
890
932
|
activeGroupId: "test-group",
|
|
891
933
|
sendMessage: jest.fn(),
|
|
892
934
|
stopCurrentRequest: jest.fn(),
|
|
935
|
+
newSession: jest.fn(),
|
|
936
|
+
compactContext: jest.fn(),
|
|
893
937
|
},
|
|
894
938
|
}));
|
|
895
939
|
|
|
@@ -984,7 +1028,7 @@ describe("chat attachment helpers", () => {
|
|
|
984
1028
|
let ShadowClawChat;
|
|
985
1029
|
|
|
986
1030
|
beforeAll(async () => {
|
|
987
|
-
|
|
1031
|
+
// jszip mocked at top level
|
|
988
1032
|
jest.unstable_mockModule("../../db/exportChatData.js", () => ({
|
|
989
1033
|
exportChatData: jest.fn(),
|
|
990
1034
|
}));
|
|
@@ -994,9 +1038,7 @@ describe("chat attachment helpers", () => {
|
|
|
994
1038
|
jest.unstable_mockModule("../../db/importChatData.js", () => ({
|
|
995
1039
|
importChatData: jest.fn(),
|
|
996
1040
|
}));
|
|
997
|
-
|
|
998
|
-
effect: jest.fn(),
|
|
999
|
-
}));
|
|
1041
|
+
// effect mocked at top level
|
|
1000
1042
|
jest.unstable_mockModule("../../content/markdown.js", () => ({
|
|
1001
1043
|
renderMarkdown: jest.fn((str: string) => `<p>${str}</p>`),
|
|
1002
1044
|
}));
|
|
@@ -1010,6 +1052,8 @@ describe("chat attachment helpers", () => {
|
|
|
1010
1052
|
activeGroupId: "test-group",
|
|
1011
1053
|
sendMessage: jest.fn(),
|
|
1012
1054
|
stopCurrentRequest: jest.fn(),
|
|
1055
|
+
newSession: jest.fn(),
|
|
1056
|
+
compactContext: jest.fn(),
|
|
1013
1057
|
},
|
|
1014
1058
|
}));
|
|
1015
1059
|
jest.unstable_mockModule("../../storage/readGroupFileBytes.js", () => ({
|
|
@@ -1151,12 +1195,15 @@ describe("shadow-claw-chat utility methods", () => {
|
|
|
1151
1195
|
|
|
1152
1196
|
describe("shadow-claw-chat prompt api onboarding", () => {
|
|
1153
1197
|
let ShadowClawChat: any;
|
|
1198
|
+
let currentOrchestratorStore: any;
|
|
1154
1199
|
let mockDb: any;
|
|
1155
1200
|
let storedConfig: Record<string, string>;
|
|
1156
1201
|
|
|
1157
1202
|
beforeAll(async () => {
|
|
1158
1203
|
const mod = await import("./shadow-claw-chat.js");
|
|
1159
1204
|
ShadowClawChat = mod.ShadowClawChat;
|
|
1205
|
+
const orchMod = await import("../../stores/orchestrator.js");
|
|
1206
|
+
currentOrchestratorStore = orchMod.orchestratorStore;
|
|
1160
1207
|
});
|
|
1161
1208
|
|
|
1162
1209
|
beforeEach(() => {
|
|
@@ -1317,4 +1364,1461 @@ describe("shadow-claw-chat prompt api onboarding", () => {
|
|
|
1317
1364
|
document.removeEventListener("shadow-claw-navigate", navListener);
|
|
1318
1365
|
}
|
|
1319
1366
|
});
|
|
1367
|
+
|
|
1368
|
+
describe("ShadowClawChat helper methods", () => {
|
|
1369
|
+
let instance: any;
|
|
1370
|
+
|
|
1371
|
+
beforeEach(() => {
|
|
1372
|
+
instance = Object.create(ShadowClawChat.prototype);
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
it("formats attachment sizes accurately", () => {
|
|
1376
|
+
expect(instance.formatAttachmentSize(500)).toBe("500 B");
|
|
1377
|
+
expect(instance.formatAttachmentSize(2048)).toBe("2.0 KB");
|
|
1378
|
+
expect(instance.formatAttachmentSize(5 * 1024 * 1024)).toBe("5.0 MB");
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
it("determines correct attachment icons", () => {
|
|
1382
|
+
expect(instance.getAttachmentIcon("image/png")).toBe("IMG");
|
|
1383
|
+
expect(instance.getAttachmentIcon("video/mp4")).toBe("VID");
|
|
1384
|
+
expect(instance.getAttachmentIcon("audio/mp3")).toBe("AUD");
|
|
1385
|
+
expect(instance.getAttachmentIcon("application/pdf")).toBe("PDF");
|
|
1386
|
+
expect(instance.getAttachmentIcon("text/plain")).toBe("TXT");
|
|
1387
|
+
expect(instance.getAttachmentIcon("application/json")).toBe("TXT");
|
|
1388
|
+
expect(instance.getAttachmentIcon("application/zip")).toBe("ZIP");
|
|
1389
|
+
expect(instance.getAttachmentIcon("application/octet-stream")).toBe(
|
|
1390
|
+
"BIN",
|
|
1391
|
+
);
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
it("determines attachment transport labels based on capabilities", () => {
|
|
1395
|
+
const textCaps = {
|
|
1396
|
+
images: false,
|
|
1397
|
+
audio: false,
|
|
1398
|
+
documents: false,
|
|
1399
|
+
routerByFeatures: false,
|
|
1400
|
+
};
|
|
1401
|
+
const multimodalCaps = {
|
|
1402
|
+
images: true,
|
|
1403
|
+
audio: true,
|
|
1404
|
+
documents: true,
|
|
1405
|
+
routerByFeatures: false,
|
|
1406
|
+
};
|
|
1407
|
+
|
|
1408
|
+
expect(
|
|
1409
|
+
instance.getAttachmentTransportLabel("text/plain", "doc.txt", textCaps),
|
|
1410
|
+
).toBe("text");
|
|
1411
|
+
expect(
|
|
1412
|
+
instance.getAttachmentTransportLabel(
|
|
1413
|
+
"image/png",
|
|
1414
|
+
"img.png",
|
|
1415
|
+
multimodalCaps,
|
|
1416
|
+
),
|
|
1417
|
+
).toBe("native");
|
|
1418
|
+
expect(
|
|
1419
|
+
instance.getAttachmentTransportLabel("image/png", "img.png", textCaps),
|
|
1420
|
+
).toBe("fallback");
|
|
1421
|
+
expect(
|
|
1422
|
+
instance.getAttachmentTransportLabel(
|
|
1423
|
+
"audio/wav",
|
|
1424
|
+
"clip.wav",
|
|
1425
|
+
multimodalCaps,
|
|
1426
|
+
),
|
|
1427
|
+
).toBe("native");
|
|
1428
|
+
expect(
|
|
1429
|
+
instance.getAttachmentTransportLabel("audio/wav", "clip.wav", textCaps),
|
|
1430
|
+
).toBe("fallback");
|
|
1431
|
+
expect(
|
|
1432
|
+
instance.getAttachmentTransportLabel(
|
|
1433
|
+
"application/pdf",
|
|
1434
|
+
"doc.pdf",
|
|
1435
|
+
multimodalCaps,
|
|
1436
|
+
),
|
|
1437
|
+
).toBe("native");
|
|
1438
|
+
expect(
|
|
1439
|
+
instance.getAttachmentTransportLabel(
|
|
1440
|
+
"application/pdf",
|
|
1441
|
+
"doc.pdf",
|
|
1442
|
+
textCaps,
|
|
1443
|
+
),
|
|
1444
|
+
).toBe("fallback");
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1447
|
+
it("clamps input area height properly", () => {
|
|
1448
|
+
expect(instance.clampInputAreaHeight(10)).toBe(40);
|
|
1449
|
+
expect(instance.clampInputAreaHeight(100)).toBe(100);
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1452
|
+
it("formats token counts for human display", () => {
|
|
1453
|
+
expect(instance.formatTokenCount(1234)).toBe("1,234");
|
|
1454
|
+
expect(instance.formatTokenCount(undefined as any)).toBe("–");
|
|
1455
|
+
});
|
|
1456
|
+
|
|
1457
|
+
it("resolves workspace links and blocks traversal or external schemes", () => {
|
|
1458
|
+
expect(instance.resolveWorkspaceLinkPath("docs/readme.md")).toBe(
|
|
1459
|
+
"docs/readme.md",
|
|
1460
|
+
);
|
|
1461
|
+
expect(instance.resolveWorkspaceLinkPath("./docs/readme.md#sec")).toBe(
|
|
1462
|
+
"docs/readme.md",
|
|
1463
|
+
);
|
|
1464
|
+
expect(instance.resolveWorkspaceLinkPath("../secret.txt")).toBeNull();
|
|
1465
|
+
expect(
|
|
1466
|
+
instance.resolveWorkspaceLinkPath("https://external.com/file"),
|
|
1467
|
+
).toBeNull();
|
|
1468
|
+
expect(instance.resolveWorkspaceLinkPath("#anchor")).toBeNull();
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
it("checks for droppable data transfer items", () => {
|
|
1472
|
+
expect(instance.hasDroppableData(null)).toBe(false);
|
|
1473
|
+
expect(
|
|
1474
|
+
instance.hasDroppableData({ files: [new File([], "test.png")] } as any),
|
|
1475
|
+
).toBe(true);
|
|
1476
|
+
expect(
|
|
1477
|
+
instance.hasDroppableData({ files: [], types: ["text/plain"] } as any),
|
|
1478
|
+
).toBe(true);
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
it("defers workspace image loads", () => {
|
|
1482
|
+
const html =
|
|
1483
|
+
'<p><img src="docs/image.png" alt="test"><img src="https://example.com/ext.png"></p>';
|
|
1484
|
+
const deferred = instance.deferWorkspaceImageLoads(html);
|
|
1485
|
+
expect(deferred).toContain('data-inline-workspace-src="docs/image.png"');
|
|
1486
|
+
expect(deferred).toContain('src="https://example.com/ext.png"');
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
it("builds queued attachments from File array", () => {
|
|
1490
|
+
const file = new File(["test"], "sample.txt", { type: "text/plain" });
|
|
1491
|
+
const queued = instance.buildQueuedAttachmentsFromFiles([file]);
|
|
1492
|
+
expect(queued).toHaveLength(1);
|
|
1493
|
+
expect(queued[0].fileName).toBe("sample.txt");
|
|
1494
|
+
expect(queued[0].mimeType).toBe("text/plain");
|
|
1495
|
+
expect(queued[0].source.kind).toBe("local-file");
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
it("clears chat messages and starts new session via handleClearChat", async () => {
|
|
1499
|
+
const mockContainer = document.createElement("div");
|
|
1500
|
+
mockContainer.className = "chat__messages";
|
|
1501
|
+
mockContainer.innerHTML = "<p>Msg 1</p><p>Msg 2</p>";
|
|
1502
|
+
|
|
1503
|
+
const mockShadow = {
|
|
1504
|
+
querySelector: jest.fn().mockReturnValue(mockContainer),
|
|
1505
|
+
};
|
|
1506
|
+
Object.defineProperty(instance, "shadowRoot", {
|
|
1507
|
+
value: mockShadow,
|
|
1508
|
+
configurable: true,
|
|
1509
|
+
});
|
|
1510
|
+
instance.db = {};
|
|
1511
|
+
|
|
1512
|
+
currentOrchestratorStore.newSession.mockResolvedValue(undefined);
|
|
1513
|
+
|
|
1514
|
+
await instance.handleClearChat();
|
|
1515
|
+
|
|
1516
|
+
expect(mockContainer.children.length).toBe(0);
|
|
1517
|
+
expect(currentOrchestratorStore.newSession).toHaveBeenCalledWith(
|
|
1518
|
+
instance.db,
|
|
1519
|
+
);
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
it("stops chat stream via handleStopChat", async () => {
|
|
1523
|
+
await instance.handleStopChat();
|
|
1524
|
+
|
|
1525
|
+
expect(currentOrchestratorStore.stopCurrentRequest).toHaveBeenCalled();
|
|
1526
|
+
});
|
|
1527
|
+
|
|
1528
|
+
it("compacts chat context when confirmed via handleCompactChat", async () => {
|
|
1529
|
+
instance.db = {};
|
|
1530
|
+
instance.showAttachmentDialog = (jest.fn() as any).mockResolvedValue(
|
|
1531
|
+
true,
|
|
1532
|
+
);
|
|
1533
|
+
currentOrchestratorStore.compactContext.mockResolvedValue(undefined);
|
|
1534
|
+
|
|
1535
|
+
await instance.handleCompactChat();
|
|
1536
|
+
|
|
1537
|
+
expect(instance.showAttachmentDialog).toHaveBeenCalled();
|
|
1538
|
+
expect(currentOrchestratorStore.compactContext).toHaveBeenCalledWith(
|
|
1539
|
+
instance.db,
|
|
1540
|
+
);
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
it("persists and restores input area height", async () => {
|
|
1544
|
+
instance.db = {};
|
|
1545
|
+
await instance.persistInputAreaHeight(150);
|
|
1546
|
+
|
|
1547
|
+
const textarea = document.createElement("textarea");
|
|
1548
|
+
textarea.className = "chat__input-textarea";
|
|
1549
|
+
const inputWrap = document.createElement("div");
|
|
1550
|
+
inputWrap.className = "chat__input-wrapper";
|
|
1551
|
+
|
|
1552
|
+
const mockShadow = {
|
|
1553
|
+
querySelector: jest.fn().mockImplementation((sel) => {
|
|
1554
|
+
if (sel === ".chat__input-textarea") return textarea;
|
|
1555
|
+
if (sel === ".chat__input-wrapper") return inputWrap;
|
|
1556
|
+
return null;
|
|
1557
|
+
}),
|
|
1558
|
+
};
|
|
1559
|
+
Object.defineProperty(instance, "shadowRoot", {
|
|
1560
|
+
value: mockShadow,
|
|
1561
|
+
configurable: true,
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
await instance.restoreInputAreaHeight();
|
|
1565
|
+
expect(instance.clampInputAreaHeight(150)).toBe(150);
|
|
1566
|
+
});
|
|
1567
|
+
});
|
|
1568
|
+
|
|
1569
|
+
describe("ShadowClawChat comprehensive coverage suite", () => {
|
|
1570
|
+
let ShadowClawChatClass: any;
|
|
1571
|
+
let chatInstance: any;
|
|
1572
|
+
let mockDbInstance: any;
|
|
1573
|
+
|
|
1574
|
+
beforeAll(async () => {
|
|
1575
|
+
const mod = await import("./shadow-claw-chat.js");
|
|
1576
|
+
ShadowClawChatClass = mod.ShadowClawChat;
|
|
1577
|
+
});
|
|
1578
|
+
|
|
1579
|
+
beforeEach(() => {
|
|
1580
|
+
document.body.innerHTML = "";
|
|
1581
|
+
mockDbInstance = {
|
|
1582
|
+
transaction: jest.fn().mockReturnValue({
|
|
1583
|
+
objectStore: jest.fn().mockReturnValue({
|
|
1584
|
+
get: jest.fn().mockReturnValue({ onsuccess: null }),
|
|
1585
|
+
put: jest.fn().mockReturnValue({ onsuccess: null }),
|
|
1586
|
+
delete: jest.fn().mockReturnValue({ onsuccess: null }),
|
|
1587
|
+
}),
|
|
1588
|
+
}),
|
|
1589
|
+
};
|
|
1590
|
+
setDB(mockDbInstance);
|
|
1591
|
+
chatInstance = new ShadowClawChatClass();
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
afterEach(() => {
|
|
1595
|
+
if (chatInstance.isConnected) {
|
|
1596
|
+
chatInstance.remove();
|
|
1597
|
+
}
|
|
1598
|
+
document.body.innerHTML = "";
|
|
1599
|
+
});
|
|
1600
|
+
|
|
1601
|
+
it("registers custom element and initializes constructor properties", () => {
|
|
1602
|
+
expect(customElements.get("shadow-claw-chat")).toBe(ShadowClawChatClass);
|
|
1603
|
+
expect(chatInstance.db).toBeNull();
|
|
1604
|
+
expect(chatInstance.activityLogCollapsedOverride).toBeNull();
|
|
1605
|
+
});
|
|
1606
|
+
|
|
1607
|
+
it("connects to DOM and dispatches slot ready event", async () => {
|
|
1608
|
+
const slotReadySpy = jest.fn();
|
|
1609
|
+
chatInstance.addEventListener(
|
|
1610
|
+
"shadow-claw-terminal-slot-ready",
|
|
1611
|
+
slotReadySpy,
|
|
1612
|
+
);
|
|
1613
|
+
|
|
1614
|
+
document.body.appendChild(chatInstance);
|
|
1615
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
1616
|
+
|
|
1617
|
+
expect(slotReadySpy).toHaveBeenCalled();
|
|
1618
|
+
expect(chatInstance.shadowRoot).toBeTruthy();
|
|
1619
|
+
|
|
1620
|
+
chatInstance.disconnectedCallback();
|
|
1621
|
+
});
|
|
1622
|
+
|
|
1623
|
+
it("handles media queries and activity log visibility toggle", () => {
|
|
1624
|
+
const root = chatInstance.shadowRoot;
|
|
1625
|
+
const logEl = root.querySelector(".chat__activity-log");
|
|
1626
|
+
|
|
1627
|
+
chatInstance.setupActivityLogVisibility();
|
|
1628
|
+
|
|
1629
|
+
chatInstance.setActivityLogCollapsedOverride(true);
|
|
1630
|
+
expect(logEl.classList.contains("chat__activity-log--collapsed")).toBe(
|
|
1631
|
+
true,
|
|
1632
|
+
);
|
|
1633
|
+
|
|
1634
|
+
chatInstance.setActivityLogCollapsedOverride(false);
|
|
1635
|
+
expect(logEl.classList.contains("chat__activity-log--collapsed")).toBe(
|
|
1636
|
+
false,
|
|
1637
|
+
);
|
|
1638
|
+
|
|
1639
|
+
chatInstance.setActivityLogCollapsedOverride(null);
|
|
1640
|
+
expect(chatInstance.activityLogCollapsedOverride).toBeNull();
|
|
1641
|
+
|
|
1642
|
+
chatInstance.setDropOverlayVisible(true);
|
|
1643
|
+
const overlay = root.querySelector("[data-drop-overlay]");
|
|
1644
|
+
if (overlay) {
|
|
1645
|
+
expect(overlay.hidden).toBe(false);
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
chatInstance.setDropOverlayVisible(false);
|
|
1649
|
+
if (overlay) {
|
|
1650
|
+
expect(overlay.hidden).toBe(true);
|
|
1651
|
+
}
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1654
|
+
it("handles input resize pointer, double click, and keyboard events", async () => {
|
|
1655
|
+
const inputArea = document.createElement("div");
|
|
1656
|
+
inputArea.className = "chat__input-area";
|
|
1657
|
+
const wrapper = document.createElement("div");
|
|
1658
|
+
wrapper.className = "chat__input-wrapper";
|
|
1659
|
+
inputArea.appendChild(wrapper);
|
|
1660
|
+
|
|
1661
|
+
const handle = document.createElement("div");
|
|
1662
|
+
handle.className = "chat__input-resize-handle";
|
|
1663
|
+
inputArea.appendChild(handle);
|
|
1664
|
+
document.body.appendChild(inputArea);
|
|
1665
|
+
|
|
1666
|
+
chatInstance.bindInputResizeEvents(inputArea, handle);
|
|
1667
|
+
|
|
1668
|
+
expect(handle.getAttribute("role")).toBe("separator");
|
|
1669
|
+
expect(handle.getAttribute("tabindex")).toBe("0");
|
|
1670
|
+
|
|
1671
|
+
handle.setPointerCapture = jest.fn();
|
|
1672
|
+
|
|
1673
|
+
// Pointer down ignoring non-main mouse button
|
|
1674
|
+
handle.dispatchEvent(
|
|
1675
|
+
new PointerEvent("pointerdown", { pointerType: "mouse", button: 2 }),
|
|
1676
|
+
);
|
|
1677
|
+
expect(handle.classList.contains("active")).toBe(false);
|
|
1678
|
+
|
|
1679
|
+
// Pointer down with main button
|
|
1680
|
+
handle.dispatchEvent(
|
|
1681
|
+
new PointerEvent("pointerdown", {
|
|
1682
|
+
pointerType: "mouse",
|
|
1683
|
+
button: 0,
|
|
1684
|
+
clientY: 100,
|
|
1685
|
+
pointerId: 1,
|
|
1686
|
+
}),
|
|
1687
|
+
);
|
|
1688
|
+
expect(handle.classList.contains("active")).toBe(true);
|
|
1689
|
+
|
|
1690
|
+
// Pointer move
|
|
1691
|
+
document.dispatchEvent(
|
|
1692
|
+
new PointerEvent("pointermove", {
|
|
1693
|
+
clientY: 50,
|
|
1694
|
+
pointerId: 1,
|
|
1695
|
+
}),
|
|
1696
|
+
);
|
|
1697
|
+
|
|
1698
|
+
// Pointer up
|
|
1699
|
+
handle.dispatchEvent(
|
|
1700
|
+
new PointerEvent("pointerup", {
|
|
1701
|
+
pointerId: 1,
|
|
1702
|
+
}),
|
|
1703
|
+
);
|
|
1704
|
+
expect(handle.classList.contains("active")).toBe(false);
|
|
1705
|
+
|
|
1706
|
+
// Double click resets height
|
|
1707
|
+
handle.dispatchEvent(new MouseEvent("dblclick"));
|
|
1708
|
+
expect(inputArea.classList.contains("chat__input-area--resized")).toBe(
|
|
1709
|
+
false,
|
|
1710
|
+
);
|
|
1711
|
+
|
|
1712
|
+
// Keydown ArrowUp, ArrowDown, Home, End
|
|
1713
|
+
handle.dispatchEvent(
|
|
1714
|
+
new KeyboardEvent("keydown", { key: "ArrowUp", shiftKey: true }),
|
|
1715
|
+
);
|
|
1716
|
+
handle.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" }));
|
|
1717
|
+
handle.dispatchEvent(new KeyboardEvent("keydown", { key: "Home" }));
|
|
1718
|
+
handle.dispatchEvent(new KeyboardEvent("keydown", { key: "End" }));
|
|
1719
|
+
handle.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" })); // Ignored
|
|
1720
|
+
|
|
1721
|
+
inputArea.remove();
|
|
1722
|
+
});
|
|
1723
|
+
|
|
1724
|
+
it("handles setInputAreaHeight, resetInputAreaHeight, isCollapsedInputAreaHeight", () => {
|
|
1725
|
+
const inputArea = document.createElement("div");
|
|
1726
|
+
chatInstance.setInputAreaHeight(inputArea, 20); // Collapsed
|
|
1727
|
+
expect(inputArea.classList.contains("chat__input-area--resized")).toBe(
|
|
1728
|
+
false,
|
|
1729
|
+
);
|
|
1730
|
+
|
|
1731
|
+
chatInstance.setInputAreaHeight(inputArea, 120); // Expanded
|
|
1732
|
+
expect(inputArea.classList.contains("chat__input-area--resized")).toBe(
|
|
1733
|
+
true,
|
|
1734
|
+
);
|
|
1735
|
+
expect(inputArea.style.getPropertyValue("--chat-input-area-height")).toBe(
|
|
1736
|
+
"120px",
|
|
1737
|
+
);
|
|
1738
|
+
|
|
1739
|
+
chatInstance.resetInputAreaHeight(inputArea);
|
|
1740
|
+
expect(inputArea.classList.contains("chat__input-area--resized")).toBe(
|
|
1741
|
+
false,
|
|
1742
|
+
);
|
|
1743
|
+
});
|
|
1744
|
+
|
|
1745
|
+
it("handles queueing and rendering attachments correctly", async () => {
|
|
1746
|
+
document.body.appendChild(chatInstance);
|
|
1747
|
+
const root = chatInstance.shadowRoot;
|
|
1748
|
+
|
|
1749
|
+
const file = new File(["test-content"], "test.txt", {
|
|
1750
|
+
type: "text/plain",
|
|
1751
|
+
});
|
|
1752
|
+
const input = root.querySelector(".chat__attachment-input");
|
|
1753
|
+
Object.defineProperty(input, "files", {
|
|
1754
|
+
value: [file],
|
|
1755
|
+
writable: true,
|
|
1756
|
+
});
|
|
1757
|
+
|
|
1758
|
+
await chatInstance.queueSelectedFiles(input);
|
|
1759
|
+
|
|
1760
|
+
const pendingContainer = root.querySelector(".chat__pending-attachments");
|
|
1761
|
+
expect(pendingContainer.hidden).toBe(false);
|
|
1762
|
+
expect(
|
|
1763
|
+
pendingContainer.querySelectorAll(".chat__pending-attachment").length,
|
|
1764
|
+
).toBe(1);
|
|
1765
|
+
|
|
1766
|
+
// Remove attachment chip
|
|
1767
|
+
const removeBtn = pendingContainer.querySelector(
|
|
1768
|
+
".chat__pending-attachment-remove",
|
|
1769
|
+
);
|
|
1770
|
+
removeBtn?.dispatchEvent(new MouseEvent("click"));
|
|
1771
|
+
expect(pendingContainer.hidden).toBe(true);
|
|
1772
|
+
|
|
1773
|
+
// Queue dropped data with files and plain text
|
|
1774
|
+
const dropFile = new File(["img-data"], "photo.png", {
|
|
1775
|
+
type: "image/png",
|
|
1776
|
+
});
|
|
1777
|
+
const dataTransfer: any = {
|
|
1778
|
+
files: [dropFile],
|
|
1779
|
+
types: ["Files", "text/plain"],
|
|
1780
|
+
items: [
|
|
1781
|
+
{
|
|
1782
|
+
kind: "string",
|
|
1783
|
+
type: "text/plain",
|
|
1784
|
+
getAsString: (cb: any) => cb("pasted text"),
|
|
1785
|
+
},
|
|
1786
|
+
],
|
|
1787
|
+
};
|
|
1788
|
+
|
|
1789
|
+
await chatInstance.queueDroppedData(dataTransfer);
|
|
1790
|
+
expect(
|
|
1791
|
+
pendingContainer.querySelectorAll(".chat__pending-attachment").length,
|
|
1792
|
+
).toBe(2);
|
|
1793
|
+
|
|
1794
|
+
// Empty drop data
|
|
1795
|
+
await chatInstance.queueDroppedData(null);
|
|
1796
|
+
});
|
|
1797
|
+
|
|
1798
|
+
it("evaluates attachment subtitle, icons, transport labels, and model support", () => {
|
|
1799
|
+
const caps = {
|
|
1800
|
+
images: true,
|
|
1801
|
+
audio: false,
|
|
1802
|
+
video: false,
|
|
1803
|
+
documents: true,
|
|
1804
|
+
routerByFeatures: false,
|
|
1805
|
+
};
|
|
1806
|
+
|
|
1807
|
+
expect(
|
|
1808
|
+
chatInstance.formatAttachmentSubtitle({
|
|
1809
|
+
id: "1",
|
|
1810
|
+
fileName: "a.png",
|
|
1811
|
+
mimeType: "image/png",
|
|
1812
|
+
size: 1024,
|
|
1813
|
+
}),
|
|
1814
|
+
).toBe("image/png · 1.0 KB");
|
|
1815
|
+
|
|
1816
|
+
expect(
|
|
1817
|
+
chatInstance.formatAttachmentSubtitle({
|
|
1818
|
+
id: "2",
|
|
1819
|
+
fileName: "a.bin",
|
|
1820
|
+
}),
|
|
1821
|
+
).toBe("Attachment");
|
|
1822
|
+
|
|
1823
|
+
expect(chatInstance.getAttachmentIcon("image/jpeg")).toBe("IMG");
|
|
1824
|
+
expect(chatInstance.getAttachmentIcon("video/webm")).toBe("VID");
|
|
1825
|
+
expect(chatInstance.getAttachmentIcon("audio/ogg")).toBe("AUD");
|
|
1826
|
+
expect(chatInstance.getAttachmentIcon("application/pdf")).toBe("PDF");
|
|
1827
|
+
expect(chatInstance.getAttachmentIcon("application/x-tar")).toBe("ZIP");
|
|
1828
|
+
expect(chatInstance.getAttachmentIcon("unknown/binary")).toBe("BIN");
|
|
1829
|
+
|
|
1830
|
+
expect(
|
|
1831
|
+
chatInstance.getAttachmentTransportLabel(
|
|
1832
|
+
"application/custom",
|
|
1833
|
+
"file.custom",
|
|
1834
|
+
caps,
|
|
1835
|
+
),
|
|
1836
|
+
).toBe("fallback");
|
|
1837
|
+
|
|
1838
|
+
expect(
|
|
1839
|
+
chatInstance.inferAttachmentModelSupport([
|
|
1840
|
+
{ id: "1", fileName: "a.txt", mimeType: "text/plain" },
|
|
1841
|
+
{ id: "2", fileName: "b.unknown", mimeType: "application/unknown" },
|
|
1842
|
+
]),
|
|
1843
|
+
).toBe(false);
|
|
1844
|
+
|
|
1845
|
+
expect(chatInstance.isInlineTextMimeType("application/javascript")).toBe(
|
|
1846
|
+
true,
|
|
1847
|
+
);
|
|
1848
|
+
expect(chatInstance.isInlineTextMimeType("application/xml")).toBe(true);
|
|
1849
|
+
expect(chatInstance.isInlineTextMimeType("image/png")).toBe(false);
|
|
1850
|
+
});
|
|
1851
|
+
|
|
1852
|
+
it("renders streaming bubble with formatting and handles updates", async () => {
|
|
1853
|
+
document.body.appendChild(chatInstance);
|
|
1854
|
+
const root = chatInstance.shadowRoot;
|
|
1855
|
+
const messages = root.querySelector(".chat__messages");
|
|
1856
|
+
|
|
1857
|
+
// Start streaming with text containing newlines, backticks, bold
|
|
1858
|
+
await chatInstance.renderStreamingBubble(
|
|
1859
|
+
"Hello\nWorld `code snippet` and **bold text**",
|
|
1860
|
+
);
|
|
1861
|
+
|
|
1862
|
+
const streamMsg = messages.querySelector(".chat__message--streaming");
|
|
1863
|
+
expect(streamMsg).toBeTruthy();
|
|
1864
|
+
expect(streamMsg?.innerHTML).toContain("<code>code snippet</code>");
|
|
1865
|
+
expect(streamMsg?.innerHTML).toContain("<b>bold text</b>");
|
|
1866
|
+
expect(streamMsg?.innerHTML).toContain("<br>");
|
|
1867
|
+
|
|
1868
|
+
// Update streaming bubble
|
|
1869
|
+
await chatInstance.renderStreamingBubble("Updated stream chunk");
|
|
1870
|
+
expect(streamMsg?.textContent).toContain("Updated stream chunk");
|
|
1871
|
+
|
|
1872
|
+
// End streaming with null
|
|
1873
|
+
await chatInstance.renderStreamingBubble(null);
|
|
1874
|
+
expect(messages.querySelector(".chat__message--streaming")).toBeNull();
|
|
1875
|
+
});
|
|
1876
|
+
|
|
1877
|
+
it("handles draft payload generation and budget checks", async () => {
|
|
1878
|
+
document.body.appendChild(chatInstance);
|
|
1879
|
+
|
|
1880
|
+
// 1. Without attachments
|
|
1881
|
+
const draft1 = await chatInstance.buildMessageDraftPayload("Hello agent");
|
|
1882
|
+
expect(draft1?.text).toBe("Hello agent");
|
|
1883
|
+
expect(draft1?.attachments).toEqual([]);
|
|
1884
|
+
|
|
1885
|
+
// 2. With small text file attachment
|
|
1886
|
+
const smallText: any = new File(["const x = 10;"], "code.js", {
|
|
1887
|
+
type: "application/javascript",
|
|
1888
|
+
});
|
|
1889
|
+
smallText.text = async () => "const x = 10;";
|
|
1890
|
+
const input = chatInstance.shadowRoot.querySelector(
|
|
1891
|
+
".chat__attachment-input",
|
|
1892
|
+
);
|
|
1893
|
+
Object.defineProperty(input, "files", {
|
|
1894
|
+
value: [smallText],
|
|
1895
|
+
writable: true,
|
|
1896
|
+
});
|
|
1897
|
+
await chatInstance.queueSelectedFiles(input);
|
|
1898
|
+
|
|
1899
|
+
const draft2 = await chatInstance.buildMessageDraftPayload("Review this");
|
|
1900
|
+
expect(draft2?.text).toContain("Attached files:");
|
|
1901
|
+
expect(draft2?.text).toContain("Attached text excerpts:");
|
|
1902
|
+
expect(draft2?.text).toContain("const x = 10;");
|
|
1903
|
+
|
|
1904
|
+
// 3. Binary attachment with dialog prompt
|
|
1905
|
+
chatInstance.showAttachmentDialog = (jest.fn() as any).mockResolvedValue(
|
|
1906
|
+
false,
|
|
1907
|
+
);
|
|
1908
|
+
const binaryFile = new File([new Uint8Array(500)], "blob.bin", {
|
|
1909
|
+
type: "application/octet-stream",
|
|
1910
|
+
});
|
|
1911
|
+
Object.defineProperty(input, "files", {
|
|
1912
|
+
value: [binaryFile],
|
|
1913
|
+
writable: true,
|
|
1914
|
+
});
|
|
1915
|
+
await chatInstance.queueSelectedFiles(input);
|
|
1916
|
+
|
|
1917
|
+
const draft3Cancelled =
|
|
1918
|
+
await chatInstance.buildMessageDraftPayload("Process binary");
|
|
1919
|
+
expect(draft3Cancelled).toBeNull();
|
|
1920
|
+
});
|
|
1921
|
+
|
|
1922
|
+
it("handles button clicks, message links, and textarea shortcuts", async () => {
|
|
1923
|
+
document.body.appendChild(chatInstance);
|
|
1924
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
1925
|
+
const root = chatInstance.shadowRoot;
|
|
1926
|
+
|
|
1927
|
+
const textarea = root.querySelector(
|
|
1928
|
+
".chat__input",
|
|
1929
|
+
) as HTMLTextAreaElement;
|
|
1930
|
+
const sendBtn = root.querySelector('[data-action="send-message"]');
|
|
1931
|
+
const clearBtn = root.querySelector('[data-action="clear-chat"]');
|
|
1932
|
+
const stopBtn = root.querySelector('[data-action="stop-chat"]');
|
|
1933
|
+
const compactBtn = root.querySelector('[data-action="compact-chat"]');
|
|
1934
|
+
const attachBtn = root.querySelector('[data-action="attach-files"]');
|
|
1935
|
+
const restoreBtn = root.querySelector('[data-action="restore-chat"]');
|
|
1936
|
+
|
|
1937
|
+
const sendSpy = jest
|
|
1938
|
+
.spyOn(chatInstance, "sendMessage")
|
|
1939
|
+
.mockImplementation(async () => {});
|
|
1940
|
+
const clearSpy = jest
|
|
1941
|
+
.spyOn(chatInstance, "handleClearChat")
|
|
1942
|
+
.mockImplementation(async () => {});
|
|
1943
|
+
const stopSpy = jest
|
|
1944
|
+
.spyOn(chatInstance, "handleStopChat")
|
|
1945
|
+
.mockImplementation(async () => {});
|
|
1946
|
+
const compactSpy = jest
|
|
1947
|
+
.spyOn(chatInstance, "handleCompactChat")
|
|
1948
|
+
.mockImplementation(async () => {});
|
|
1949
|
+
|
|
1950
|
+
// Textarea keydown
|
|
1951
|
+
textarea.dispatchEvent(
|
|
1952
|
+
new KeyboardEvent("keydown", { key: "Enter", shiftKey: false }),
|
|
1953
|
+
);
|
|
1954
|
+
expect(sendSpy).toHaveBeenCalled();
|
|
1955
|
+
|
|
1956
|
+
textarea.dispatchEvent(
|
|
1957
|
+
new KeyboardEvent("keydown", {
|
|
1958
|
+
key: "Enter",
|
|
1959
|
+
ctrlKey: true,
|
|
1960
|
+
shiftKey: true,
|
|
1961
|
+
}),
|
|
1962
|
+
);
|
|
1963
|
+
expect(sendSpy).toHaveBeenCalledTimes(2);
|
|
1964
|
+
|
|
1965
|
+
textarea.dispatchEvent(
|
|
1966
|
+
new KeyboardEvent("keydown", { key: "Enter", shiftKey: true }),
|
|
1967
|
+
);
|
|
1968
|
+
expect(sendSpy).toHaveBeenCalledTimes(2);
|
|
1969
|
+
|
|
1970
|
+
// Buttons
|
|
1971
|
+
sendBtn?.dispatchEvent(new MouseEvent("click"));
|
|
1972
|
+
expect(sendSpy).toHaveBeenCalledTimes(3);
|
|
1973
|
+
|
|
1974
|
+
clearBtn?.dispatchEvent(new MouseEvent("click"));
|
|
1975
|
+
expect(clearSpy).toHaveBeenCalled();
|
|
1976
|
+
|
|
1977
|
+
stopBtn?.dispatchEvent(new MouseEvent("click"));
|
|
1978
|
+
expect(stopSpy).toHaveBeenCalled();
|
|
1979
|
+
|
|
1980
|
+
compactBtn?.dispatchEvent(new MouseEvent("click"));
|
|
1981
|
+
expect(compactSpy).toHaveBeenCalled();
|
|
1982
|
+
|
|
1983
|
+
const attachmentInput = root.querySelector(
|
|
1984
|
+
".chat__attachment-input",
|
|
1985
|
+
) as HTMLInputElement;
|
|
1986
|
+
attachmentInput.click = jest.fn();
|
|
1987
|
+
attachBtn?.dispatchEvent(new MouseEvent("click"));
|
|
1988
|
+
expect(attachmentInput.click).toHaveBeenCalled();
|
|
1989
|
+
|
|
1990
|
+
const restoreInput = root.querySelector(
|
|
1991
|
+
".chat__restore-input",
|
|
1992
|
+
) as HTMLInputElement;
|
|
1993
|
+
restoreInput.click = jest.fn();
|
|
1994
|
+
restoreBtn?.dispatchEvent(new MouseEvent("click"));
|
|
1995
|
+
expect(restoreInput.click).toHaveBeenCalled();
|
|
1996
|
+
|
|
1997
|
+
sendSpy.mockRestore();
|
|
1998
|
+
clearSpy.mockRestore();
|
|
1999
|
+
stopSpy.mockRestore();
|
|
2000
|
+
compactSpy.mockRestore();
|
|
2001
|
+
});
|
|
2002
|
+
|
|
2003
|
+
it("handles drag & drop events on chat body", async () => {
|
|
2004
|
+
document.body.appendChild(chatInstance);
|
|
2005
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
2006
|
+
const root = chatInstance.shadowRoot;
|
|
2007
|
+
const chatBody = root.querySelector(".chat__body");
|
|
2008
|
+
|
|
2009
|
+
const queueSpy = jest
|
|
2010
|
+
.spyOn(chatInstance, "queueDroppedData")
|
|
2011
|
+
.mockImplementation(async () => {});
|
|
2012
|
+
|
|
2013
|
+
const validTransfer: any = {
|
|
2014
|
+
types: ["Files"],
|
|
2015
|
+
files: [new File([""], "doc.txt")],
|
|
2016
|
+
};
|
|
2017
|
+
|
|
2018
|
+
chatBody?.dispatchEvent(
|
|
2019
|
+
new DragEvent("dragenter", { dataTransfer: validTransfer }),
|
|
2020
|
+
);
|
|
2021
|
+
chatBody?.dispatchEvent(
|
|
2022
|
+
new DragEvent("dragover", { dataTransfer: validTransfer }),
|
|
2023
|
+
);
|
|
2024
|
+
chatBody?.dispatchEvent(
|
|
2025
|
+
new DragEvent("dragleave", { dataTransfer: validTransfer }),
|
|
2026
|
+
);
|
|
2027
|
+
chatBody?.dispatchEvent(
|
|
2028
|
+
new DragEvent("drop", { dataTransfer: validTransfer }),
|
|
2029
|
+
);
|
|
2030
|
+
|
|
2031
|
+
expect(queueSpy).toHaveBeenCalled();
|
|
2032
|
+
queueSpy.mockRestore();
|
|
2033
|
+
});
|
|
2034
|
+
|
|
2035
|
+
it("handles A2UI action routing for room, peer, and standard groups", async () => {
|
|
2036
|
+
document.body.appendChild(chatInstance);
|
|
2037
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
2038
|
+
const root = chatInstance.shadowRoot;
|
|
2039
|
+
const messagesEl = root.querySelector(".chat__messages");
|
|
2040
|
+
|
|
2041
|
+
const mockRouteRoom = jest.fn();
|
|
2042
|
+
const mockSendPeer = jest.fn();
|
|
2043
|
+
const mockSendMessage = jest.fn();
|
|
2044
|
+
|
|
2045
|
+
const orch = {
|
|
2046
|
+
routeRoomA2UIAction: mockRouteRoom,
|
|
2047
|
+
router: {
|
|
2048
|
+
findChannel: () => ({ sendA2UIAction: mockSendPeer }),
|
|
2049
|
+
},
|
|
2050
|
+
};
|
|
2051
|
+
|
|
2052
|
+
currentOrchestratorStore.orchestrator = orch;
|
|
2053
|
+
currentOrchestratorStore.sendMessage = mockSendMessage;
|
|
2054
|
+
|
|
2055
|
+
// 1. Room A2UI action
|
|
2056
|
+
messagesEl?.dispatchEvent(
|
|
2057
|
+
new CustomEvent("shadow-claw-a2ui-action", {
|
|
2058
|
+
detail: {
|
|
2059
|
+
groupId: "room:general",
|
|
2060
|
+
action: { surfaceId: "s1", actionId: "submit", dataModel: {} },
|
|
2061
|
+
},
|
|
2062
|
+
}),
|
|
2063
|
+
);
|
|
2064
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
2065
|
+
expect(mockRouteRoom).toHaveBeenCalledWith(
|
|
2066
|
+
"room:general",
|
|
2067
|
+
expect.any(Object),
|
|
2068
|
+
);
|
|
2069
|
+
|
|
2070
|
+
// 2. Peer A2UI action
|
|
2071
|
+
messagesEl?.dispatchEvent(
|
|
2072
|
+
new CustomEvent("shadow-claw-a2ui-action", {
|
|
2073
|
+
detail: {
|
|
2074
|
+
groupId: "peer:bob",
|
|
2075
|
+
action: { surfaceId: "s2", actionId: "click", dataModel: {} },
|
|
2076
|
+
},
|
|
2077
|
+
}),
|
|
2078
|
+
);
|
|
2079
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
2080
|
+
expect(mockSendPeer).toHaveBeenCalledWith("peer:bob", expect.any(Object));
|
|
2081
|
+
|
|
2082
|
+
// 3. Local group A2UI action
|
|
2083
|
+
messagesEl?.dispatchEvent(
|
|
2084
|
+
new CustomEvent("shadow-claw-a2ui-action", {
|
|
2085
|
+
detail: {
|
|
2086
|
+
groupId: "main",
|
|
2087
|
+
action: {
|
|
2088
|
+
surfaceId: "s3",
|
|
2089
|
+
actionId: "calc",
|
|
2090
|
+
dataModel: { count: 5 },
|
|
2091
|
+
},
|
|
2092
|
+
},
|
|
2093
|
+
}),
|
|
2094
|
+
);
|
|
2095
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
2096
|
+
expect(mockSendMessage).toHaveBeenCalledWith(
|
|
2097
|
+
expect.stringContaining("[A2UI ACTION]"),
|
|
2098
|
+
[],
|
|
2099
|
+
expect.any(Object),
|
|
2100
|
+
);
|
|
2101
|
+
|
|
2102
|
+
// 4. Function response event
|
|
2103
|
+
messagesEl?.dispatchEvent(
|
|
2104
|
+
new CustomEvent("shadow-claw-a2ui-function-response", {
|
|
2105
|
+
detail: {
|
|
2106
|
+
groupId: "peer:bob",
|
|
2107
|
+
response: { result: "ok" },
|
|
2108
|
+
},
|
|
2109
|
+
}),
|
|
2110
|
+
);
|
|
2111
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
2112
|
+
expect(mockSendPeer).toHaveBeenCalledWith("peer:bob", { result: "ok" });
|
|
2113
|
+
});
|
|
2114
|
+
|
|
2115
|
+
it("handles downloadChat and restoreChat error and success paths", async () => {
|
|
2116
|
+
document.body.appendChild(chatInstance);
|
|
2117
|
+
chatInstance.db = mockDbInstance;
|
|
2118
|
+
|
|
2119
|
+
const { exportChatData } = await import("../../db/exportChatData.js");
|
|
2120
|
+
const { importChatData } = await import("../../db/importChatData.js");
|
|
2121
|
+
const JSZip = (await import("jszip")).default as any;
|
|
2122
|
+
|
|
2123
|
+
global.URL.createObjectURL = jest
|
|
2124
|
+
.fn<(obj: Blob | MediaSource) => string>()
|
|
2125
|
+
.mockReturnValue("blob:mock-zip");
|
|
2126
|
+
global.URL.revokeObjectURL = jest.fn<(url: string) => void>();
|
|
2127
|
+
|
|
2128
|
+
// 1. downloadChat without db
|
|
2129
|
+
chatInstance.db = null;
|
|
2130
|
+
await chatInstance.downloadChat();
|
|
2131
|
+
|
|
2132
|
+
// 2. downloadChat when exportChatData succeeds
|
|
2133
|
+
chatInstance.db = mockDbInstance;
|
|
2134
|
+
(exportChatData as jest.MockedFunction<any>).mockResolvedValueOnce({
|
|
2135
|
+
messages: [{ id: "1", content: "hello" }],
|
|
2136
|
+
version: 1,
|
|
2137
|
+
});
|
|
2138
|
+
await chatInstance.downloadChat();
|
|
2139
|
+
|
|
2140
|
+
// 3. downloadChat when exportChatData returns null
|
|
2141
|
+
(exportChatData as jest.MockedFunction<any>).mockResolvedValueOnce(null);
|
|
2142
|
+
await chatInstance.downloadChat();
|
|
2143
|
+
|
|
2144
|
+
// 4. downloadChat when error is thrown
|
|
2145
|
+
(exportChatData as jest.MockedFunction<any>).mockRejectedValueOnce(
|
|
2146
|
+
new Error("disk full"),
|
|
2147
|
+
);
|
|
2148
|
+
await chatInstance.downloadChat();
|
|
2149
|
+
|
|
2150
|
+
// 5. restoreChat without db
|
|
2151
|
+
chatInstance.db = null;
|
|
2152
|
+
const inputNoDb = document.createElement("input");
|
|
2153
|
+
await chatInstance.restoreChat(inputNoDb);
|
|
2154
|
+
|
|
2155
|
+
// 6. restoreChat without files
|
|
2156
|
+
chatInstance.db = mockDbInstance;
|
|
2157
|
+
const inputNoFiles = document.createElement("input");
|
|
2158
|
+
await chatInstance.restoreChat(inputNoFiles);
|
|
2159
|
+
|
|
2160
|
+
// 7. restoreChat with non-zip
|
|
2161
|
+
const invalidFileInput = document.createElement("input");
|
|
2162
|
+
Object.defineProperty(invalidFileInput, "files", {
|
|
2163
|
+
value: [new File([""], "notes.txt")],
|
|
2164
|
+
});
|
|
2165
|
+
await chatInstance.restoreChat(invalidFileInput);
|
|
2166
|
+
expect(invalidFileInput.value).toBe("");
|
|
2167
|
+
|
|
2168
|
+
// 8. restoreChat with valid zip
|
|
2169
|
+
const validFileInput = document.createElement("input");
|
|
2170
|
+
Object.defineProperty(validFileInput, "files", {
|
|
2171
|
+
value: [new File([""], "backup.zip")],
|
|
2172
|
+
});
|
|
2173
|
+
jest.spyOn(orchestratorStore, "loadHistory").mockResolvedValue(undefined);
|
|
2174
|
+
await chatInstance.restoreChat(validFileInput);
|
|
2175
|
+
expect(importChatData).toHaveBeenCalled();
|
|
2176
|
+
|
|
2177
|
+
// 9. restoreChat missing chat-data.json
|
|
2178
|
+
const origLoadAsync = JSZip.loadAsync;
|
|
2179
|
+
JSZip.loadAsync = jest
|
|
2180
|
+
.fn<(...args: any[]) => Promise<any>>()
|
|
2181
|
+
.mockResolvedValue({
|
|
2182
|
+
file: () => null,
|
|
2183
|
+
});
|
|
2184
|
+
const inputNoData = document.createElement("input");
|
|
2185
|
+
Object.defineProperty(inputNoData, "files", {
|
|
2186
|
+
value: [new File([""], "empty.zip")],
|
|
2187
|
+
});
|
|
2188
|
+
await chatInstance.restoreChat(inputNoData);
|
|
2189
|
+
|
|
2190
|
+
// 10. restoreChat invalid messages array
|
|
2191
|
+
JSZip.loadAsync = jest
|
|
2192
|
+
.fn<(...args: any[]) => Promise<any>>()
|
|
2193
|
+
.mockResolvedValue({
|
|
2194
|
+
file: () => ({
|
|
2195
|
+
async: async () => JSON.stringify({ notAnArray: true }),
|
|
2196
|
+
}),
|
|
2197
|
+
});
|
|
2198
|
+
const inputBadData = document.createElement("input");
|
|
2199
|
+
Object.defineProperty(inputBadData, "files", {
|
|
2200
|
+
value: [new File([""], "baddata.zip")],
|
|
2201
|
+
});
|
|
2202
|
+
await chatInstance.restoreChat(inputBadData);
|
|
2203
|
+
|
|
2204
|
+
// 11. restoreChat throws error
|
|
2205
|
+
JSZip.loadAsync = jest
|
|
2206
|
+
.fn<(...args: any[]) => Promise<any>>()
|
|
2207
|
+
.mockRejectedValue(new Error("corrupted zip"));
|
|
2208
|
+
const inputCorrupt = document.createElement("input");
|
|
2209
|
+
Object.defineProperty(inputCorrupt, "files", {
|
|
2210
|
+
value: [new File([""], "corrupt.zip")],
|
|
2211
|
+
});
|
|
2212
|
+
await chatInstance.restoreChat(inputCorrupt);
|
|
2213
|
+
|
|
2214
|
+
JSZip.loadAsync = origLoadAsync;
|
|
2215
|
+
});
|
|
2216
|
+
|
|
2217
|
+
it("handles downloadAttachment and openAttachment errors and edge cases", async () => {
|
|
2218
|
+
// 1. Without db
|
|
2219
|
+
chatInstance.db = null;
|
|
2220
|
+
await chatInstance.downloadAttachment("main", {
|
|
2221
|
+
id: "1",
|
|
2222
|
+
fileName: "a.txt",
|
|
2223
|
+
});
|
|
2224
|
+
await chatInstance.openAttachment("main", { id: "1", fileName: "a.txt" });
|
|
2225
|
+
|
|
2226
|
+
// 2. With db but without path
|
|
2227
|
+
chatInstance.db = mockDbInstance;
|
|
2228
|
+
await chatInstance.downloadAttachment("main", {
|
|
2229
|
+
id: "1",
|
|
2230
|
+
fileName: "a.txt",
|
|
2231
|
+
});
|
|
2232
|
+
await chatInstance.openAttachment("main", { id: "1", fileName: "a.txt" });
|
|
2233
|
+
|
|
2234
|
+
// 3. With path and error thrown
|
|
2235
|
+
const { downloadGroupFile } =
|
|
2236
|
+
await import("../../storage/downloadGroupFile.js");
|
|
2237
|
+
const { fileViewerStore } = await import("../../stores/file-viewer.js");
|
|
2238
|
+
|
|
2239
|
+
(downloadGroupFile as jest.MockedFunction<any>).mockRejectedValueOnce(
|
|
2240
|
+
new Error("download error"),
|
|
2241
|
+
);
|
|
2242
|
+
await chatInstance.downloadAttachment("main", {
|
|
2243
|
+
id: "1",
|
|
2244
|
+
fileName: "a.txt",
|
|
2245
|
+
path: "a.txt",
|
|
2246
|
+
});
|
|
2247
|
+
|
|
2248
|
+
(
|
|
2249
|
+
fileViewerStore.openFile as jest.MockedFunction<any>
|
|
2250
|
+
).mockRejectedValueOnce(new Error("open error"));
|
|
2251
|
+
await chatInstance.openAttachment("main", {
|
|
2252
|
+
id: "1",
|
|
2253
|
+
fileName: "a.txt",
|
|
2254
|
+
path: "a.txt",
|
|
2255
|
+
});
|
|
2256
|
+
});
|
|
2257
|
+
|
|
2258
|
+
it("handles showAttachmentDialog fallbacks with and without host", async () => {
|
|
2259
|
+
// 1. Without shadow-claw host in DOM
|
|
2260
|
+
const infoResult = await chatInstance.showAttachmentDialog({
|
|
2261
|
+
mode: "info",
|
|
2262
|
+
title: "Info Dialog",
|
|
2263
|
+
message: "Some info message",
|
|
2264
|
+
});
|
|
2265
|
+
expect(infoResult).toBe(true);
|
|
2266
|
+
|
|
2267
|
+
const confirmResult = await chatInstance.showAttachmentDialog({
|
|
2268
|
+
mode: "confirm",
|
|
2269
|
+
title: "Confirm Dialog",
|
|
2270
|
+
message: "Are you sure?",
|
|
2271
|
+
});
|
|
2272
|
+
expect(confirmResult).toBe(false);
|
|
2273
|
+
|
|
2274
|
+
// 2. With shadow-claw host in DOM
|
|
2275
|
+
const host = document.createElement("shadow-claw") as any;
|
|
2276
|
+
host.requestDialog = (jest.fn() as any).mockResolvedValue(true);
|
|
2277
|
+
document.body.appendChild(host);
|
|
2278
|
+
|
|
2279
|
+
const hostResult = await chatInstance.showAttachmentDialog({
|
|
2280
|
+
mode: "confirm",
|
|
2281
|
+
title: "Confirm",
|
|
2282
|
+
message: "Test",
|
|
2283
|
+
});
|
|
2284
|
+
expect(hostResult).toBe(true);
|
|
2285
|
+
expect(host.requestDialog).toHaveBeenCalled();
|
|
2286
|
+
host.remove();
|
|
2287
|
+
});
|
|
2288
|
+
|
|
2289
|
+
it("handles sendMessage execution, initializing guard, and errors", async () => {
|
|
2290
|
+
document.body.appendChild(chatInstance);
|
|
2291
|
+
const root = chatInstance.shadowRoot;
|
|
2292
|
+
const input = root.querySelector(".chat__input") as HTMLTextAreaElement;
|
|
2293
|
+
|
|
2294
|
+
// 1. Empty message and no queued attachments
|
|
2295
|
+
input.value = " ";
|
|
2296
|
+
await chatInstance.sendMessage();
|
|
2297
|
+
|
|
2298
|
+
// 2. Ready is false
|
|
2299
|
+
input.value = "hello";
|
|
2300
|
+
currentOrchestratorStore.ready = false;
|
|
2301
|
+
await chatInstance.sendMessage();
|
|
2302
|
+
|
|
2303
|
+
// 3. Ready is true - successful send
|
|
2304
|
+
currentOrchestratorStore.ready = true;
|
|
2305
|
+
currentOrchestratorStore.sendMessage = jest.fn();
|
|
2306
|
+
await chatInstance.sendMessage();
|
|
2307
|
+
|
|
2308
|
+
expect(currentOrchestratorStore.sendMessage).toHaveBeenCalledWith(
|
|
2309
|
+
"hello",
|
|
2310
|
+
[],
|
|
2311
|
+
);
|
|
2312
|
+
expect(input.value).toBe("");
|
|
2313
|
+
|
|
2314
|
+
// 4. Draft generation returns null
|
|
2315
|
+
input.value = "draft fail";
|
|
2316
|
+
chatInstance.buildMessageDraftPayload = (
|
|
2317
|
+
jest.fn() as any
|
|
2318
|
+
).mockResolvedValue(null);
|
|
2319
|
+
await chatInstance.sendMessage();
|
|
2320
|
+
|
|
2321
|
+
// 5. Send message error
|
|
2322
|
+
input.value = "error message";
|
|
2323
|
+
chatInstance.buildMessageDraftPayload = (
|
|
2324
|
+
jest.fn() as any
|
|
2325
|
+
).mockResolvedValue({ text: "error", attachments: [] });
|
|
2326
|
+
currentOrchestratorStore.sendMessage = jest
|
|
2327
|
+
.fn()
|
|
2328
|
+
.mockImplementation(() => {
|
|
2329
|
+
throw new Error("send failure");
|
|
2330
|
+
});
|
|
2331
|
+
await chatInstance.sendMessage();
|
|
2332
|
+
});
|
|
2333
|
+
|
|
2334
|
+
it("handles handleMessageLinkClick navigation and modifier keys", async () => {
|
|
2335
|
+
const container = document.createElement("div");
|
|
2336
|
+
const anchor = document.createElement("a");
|
|
2337
|
+
anchor.setAttribute("href", "docs/guide.md");
|
|
2338
|
+
container.appendChild(anchor);
|
|
2339
|
+
|
|
2340
|
+
const { handleSpecialLinkNavigation } =
|
|
2341
|
+
await import("../../utils/utils.js");
|
|
2342
|
+
(handleSpecialLinkNavigation as jest.MockedFunction<any>).mockReturnValue(
|
|
2343
|
+
true,
|
|
2344
|
+
);
|
|
2345
|
+
|
|
2346
|
+
const evt1 = new MouseEvent("click", { button: 0, cancelable: true });
|
|
2347
|
+
anchor.dispatchEvent(evt1);
|
|
2348
|
+
await chatInstance.handleMessageLinkClick(evt1);
|
|
2349
|
+
expect(evt1.defaultPrevented).toBe(true);
|
|
2350
|
+
|
|
2351
|
+
// With modifier key (e.g. metaKey)
|
|
2352
|
+
const evt2 = new MouseEvent("click", {
|
|
2353
|
+
button: 0,
|
|
2354
|
+
metaKey: true,
|
|
2355
|
+
cancelable: true,
|
|
2356
|
+
});
|
|
2357
|
+
await chatInstance.handleMessageLinkClick(evt2);
|
|
2358
|
+
expect(evt2.defaultPrevented).toBe(false);
|
|
2359
|
+
|
|
2360
|
+
// Middle click
|
|
2361
|
+
const evt3 = new MouseEvent("click", { button: 1, cancelable: true });
|
|
2362
|
+
await chatInstance.handleMessageLinkClick(evt3);
|
|
2363
|
+
|
|
2364
|
+
// Non anchor target
|
|
2365
|
+
const evt4 = new MouseEvent("click", { button: 0, cancelable: true });
|
|
2366
|
+
container.dispatchEvent(evt4);
|
|
2367
|
+
await chatInstance.handleMessageLinkClick(evt4);
|
|
2368
|
+
});
|
|
2369
|
+
|
|
2370
|
+
it("handles resolveImagePaths for workspace images and PDFs", async () => {
|
|
2371
|
+
chatInstance.db = mockDbInstance;
|
|
2372
|
+
const container = document.createElement("div");
|
|
2373
|
+
container.innerHTML = `
|
|
2374
|
+
<img data-inline-workspace-src="photo.png" alt="photo">
|
|
2375
|
+
<img data-inline-workspace-src="photo.svg" alt="photo">
|
|
2376
|
+
<img data-inline-workspace-src="doc.pdf" alt="doc">
|
|
2377
|
+
<a href="manual.pdf">User Manual</a>
|
|
2378
|
+
`;
|
|
2379
|
+
|
|
2380
|
+
await chatInstance.resolveImagePaths("main", container);
|
|
2381
|
+
|
|
2382
|
+
expect(container.querySelector("shadow-claw-pdf-viewer")).toBeTruthy();
|
|
2383
|
+
const img = container.querySelector("img") as HTMLImageElement;
|
|
2384
|
+
if (img) {
|
|
2385
|
+
img.dispatchEvent(new Event("load"));
|
|
2386
|
+
}
|
|
2387
|
+
});
|
|
2388
|
+
|
|
2389
|
+
it("handles renderInlineAttachmentPreview and renderMessageAttachments", async () => {
|
|
2390
|
+
chatInstance.db = mockDbInstance;
|
|
2391
|
+
|
|
2392
|
+
const msg = {
|
|
2393
|
+
id: "m1",
|
|
2394
|
+
groupId: "main",
|
|
2395
|
+
attachments: [
|
|
2396
|
+
{
|
|
2397
|
+
id: "a1",
|
|
2398
|
+
fileName: "screenshot.png",
|
|
2399
|
+
mimeType: "image/png",
|
|
2400
|
+
path: "screenshot.png",
|
|
2401
|
+
previewDisposition: "inline",
|
|
2402
|
+
},
|
|
2403
|
+
{
|
|
2404
|
+
id: "a2",
|
|
2405
|
+
fileName: "data.csv",
|
|
2406
|
+
mimeType: "text/csv",
|
|
2407
|
+
path: "data.csv",
|
|
2408
|
+
previewDisposition: "file",
|
|
2409
|
+
},
|
|
2410
|
+
],
|
|
2411
|
+
} as any;
|
|
2412
|
+
|
|
2413
|
+
const preview = await chatInstance.renderInlineAttachmentPreview(
|
|
2414
|
+
msg,
|
|
2415
|
+
msg.attachments[0],
|
|
2416
|
+
);
|
|
2417
|
+
expect(preview).toBeTruthy();
|
|
2418
|
+
|
|
2419
|
+
const renderedAttachments =
|
|
2420
|
+
await chatInstance.renderMessageAttachments(msg);
|
|
2421
|
+
expect(renderedAttachments).toBeTruthy();
|
|
2422
|
+
expect(
|
|
2423
|
+
renderedAttachments?.querySelectorAll(".chat__attachment").length,
|
|
2424
|
+
).toBe(2);
|
|
2425
|
+
|
|
2426
|
+
// Null attachments
|
|
2427
|
+
expect(
|
|
2428
|
+
await chatInstance.renderMessageAttachments({ id: "m2" } as any),
|
|
2429
|
+
).toBeNull();
|
|
2430
|
+
});
|
|
2431
|
+
|
|
2432
|
+
it("renders code copy buttons and handles copy clicks", async () => {
|
|
2433
|
+
const container = document.createElement("div");
|
|
2434
|
+
container.innerHTML = "<pre><code>console.log('test')</code></pre>";
|
|
2435
|
+
|
|
2436
|
+
Object.assign(navigator, {
|
|
2437
|
+
clipboard: {
|
|
2438
|
+
writeText: jest
|
|
2439
|
+
.fn<(text: string) => Promise<void>>()
|
|
2440
|
+
.mockResolvedValue(undefined),
|
|
2441
|
+
},
|
|
2442
|
+
});
|
|
2443
|
+
|
|
2444
|
+
chatInstance.injectCopyButtons(container);
|
|
2445
|
+
|
|
2446
|
+
const copyBtn = container.querySelector(
|
|
2447
|
+
".chat__code-copy-btn",
|
|
2448
|
+
) as HTMLButtonElement;
|
|
2449
|
+
expect(copyBtn).toBeTruthy();
|
|
2450
|
+
|
|
2451
|
+
copyBtn.click();
|
|
2452
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
2453
|
+
|
|
2454
|
+
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
|
|
2455
|
+
"console.log('test')",
|
|
2456
|
+
);
|
|
2457
|
+
|
|
2458
|
+
// Failure branch
|
|
2459
|
+
Object.assign(navigator, {
|
|
2460
|
+
clipboard: {
|
|
2461
|
+
writeText: jest
|
|
2462
|
+
.fn<(text: string) => Promise<void>>()
|
|
2463
|
+
.mockRejectedValue(new Error("denied")),
|
|
2464
|
+
},
|
|
2465
|
+
});
|
|
2466
|
+
copyBtn.click();
|
|
2467
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
2468
|
+
});
|
|
2469
|
+
|
|
2470
|
+
it("handles setupEffects message rendering and all reactive panels", async () => {
|
|
2471
|
+
document.body.appendChild(chatInstance);
|
|
2472
|
+
chatInstance.db = mockDbInstance;
|
|
2473
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
2474
|
+
|
|
2475
|
+
const root = chatInstance.shadowRoot;
|
|
2476
|
+
|
|
2477
|
+
// 1. Token usage accumulator
|
|
2478
|
+
Object.defineProperty(currentOrchestratorStore, "tokenUsageAccumulator", {
|
|
2479
|
+
get: () => ({
|
|
2480
|
+
inputTokens: 150,
|
|
2481
|
+
outputTokens: 75,
|
|
2482
|
+
cacheReadTokens: 50,
|
|
2483
|
+
totalTokens: 225,
|
|
2484
|
+
}),
|
|
2485
|
+
configurable: true,
|
|
2486
|
+
});
|
|
2487
|
+
chatInstance.setupEffects();
|
|
2488
|
+
const tokenUsageEl = root.querySelector(".chat__token-usage");
|
|
2489
|
+
expect(
|
|
2490
|
+
tokenUsageEl.classList.contains("chat__token-usage--visible"),
|
|
2491
|
+
).toBe(true);
|
|
2492
|
+
|
|
2493
|
+
// 2. Token usage reset
|
|
2494
|
+
Object.defineProperty(currentOrchestratorStore, "tokenUsageAccumulator", {
|
|
2495
|
+
get: () => null,
|
|
2496
|
+
configurable: true,
|
|
2497
|
+
});
|
|
2498
|
+
chatInstance.setupEffects();
|
|
2499
|
+
expect(
|
|
2500
|
+
tokenUsageEl.classList.contains("chat__token-usage--visible"),
|
|
2501
|
+
).toBe(false);
|
|
2502
|
+
|
|
2503
|
+
// 3. Context usage
|
|
2504
|
+
Object.defineProperty(currentOrchestratorStore, "contextUsage", {
|
|
2505
|
+
get: () => ({
|
|
2506
|
+
estimatedTokens: 900,
|
|
2507
|
+
contextLimit: 1000,
|
|
2508
|
+
usagePercent: 90,
|
|
2509
|
+
truncatedCount: 2,
|
|
2510
|
+
}),
|
|
2511
|
+
configurable: true,
|
|
2512
|
+
});
|
|
2513
|
+
chatInstance.setupEffects();
|
|
2514
|
+
const contextUsageEl = root.querySelector(".chat__context-usage");
|
|
2515
|
+
expect(
|
|
2516
|
+
contextUsageEl.classList.contains("chat__context-usage--visible"),
|
|
2517
|
+
).toBe(true);
|
|
2518
|
+
|
|
2519
|
+
Object.defineProperty(currentOrchestratorStore, "contextUsage", {
|
|
2520
|
+
get: () => null,
|
|
2521
|
+
configurable: true,
|
|
2522
|
+
});
|
|
2523
|
+
chatInstance.setupEffects();
|
|
2524
|
+
expect(
|
|
2525
|
+
contextUsageEl.classList.contains("chat__context-usage--visible"),
|
|
2526
|
+
).toBe(false);
|
|
2527
|
+
|
|
2528
|
+
// 4. AGUI and Tool activity
|
|
2529
|
+
Object.defineProperty(currentOrchestratorStore, "aguiEvent", {
|
|
2530
|
+
get: () => ({
|
|
2531
|
+
event: { type: "TOOL_CALL_START", toolCallName: "file_search" },
|
|
2532
|
+
}),
|
|
2533
|
+
configurable: true,
|
|
2534
|
+
});
|
|
2535
|
+
chatInstance.setupEffects();
|
|
2536
|
+
const toolEl = root.querySelector(".chat__tool-activity");
|
|
2537
|
+
expect(toolEl.classList.contains("chat__tool-activity--active")).toBe(
|
|
2538
|
+
true,
|
|
2539
|
+
);
|
|
2540
|
+
|
|
2541
|
+
Object.defineProperty(currentOrchestratorStore, "aguiEvent", {
|
|
2542
|
+
get: () => null,
|
|
2543
|
+
configurable: true,
|
|
2544
|
+
});
|
|
2545
|
+
Object.defineProperty(currentOrchestratorStore, "toolActivity", {
|
|
2546
|
+
get: () => ({ tool: "bash" }),
|
|
2547
|
+
configurable: true,
|
|
2548
|
+
});
|
|
2549
|
+
chatInstance.setupEffects();
|
|
2550
|
+
expect(toolEl.textContent).toContain("bash");
|
|
2551
|
+
|
|
2552
|
+
Object.defineProperty(currentOrchestratorStore, "toolActivity", {
|
|
2553
|
+
get: () => null,
|
|
2554
|
+
configurable: true,
|
|
2555
|
+
});
|
|
2556
|
+
chatInstance.setupEffects();
|
|
2557
|
+
expect(toolEl.classList.contains("chat__tool-activity--active")).toBe(
|
|
2558
|
+
false,
|
|
2559
|
+
);
|
|
2560
|
+
|
|
2561
|
+
// 5. Model download progress
|
|
2562
|
+
Object.defineProperty(currentOrchestratorStore, "modelDownloadProgress", {
|
|
2563
|
+
get: () => ({ progress: 0.8, message: "Loading model..." }),
|
|
2564
|
+
configurable: true,
|
|
2565
|
+
});
|
|
2566
|
+
chatInstance.setupEffects();
|
|
2567
|
+
const modelProgressEl = root.querySelector(".chat__model-progress");
|
|
2568
|
+
expect(
|
|
2569
|
+
modelProgressEl.classList.contains("chat__model-progress--active"),
|
|
2570
|
+
).toBe(true);
|
|
2571
|
+
|
|
2572
|
+
Object.defineProperty(currentOrchestratorStore, "modelDownloadProgress", {
|
|
2573
|
+
get: () => null,
|
|
2574
|
+
configurable: true,
|
|
2575
|
+
});
|
|
2576
|
+
chatInstance.setupEffects();
|
|
2577
|
+
expect(
|
|
2578
|
+
modelProgressEl.classList.contains("chat__model-progress--active"),
|
|
2579
|
+
).toBe(false);
|
|
2580
|
+
|
|
2581
|
+
// 6. Activity log
|
|
2582
|
+
Object.defineProperty(currentOrchestratorStore, "activityLog", {
|
|
2583
|
+
get: () => [
|
|
2584
|
+
{ level: "info", label: "Planner", message: "Starting plan" },
|
|
2585
|
+
{ level: "warn", label: "Tool", message: "Retry attempt" },
|
|
2586
|
+
],
|
|
2587
|
+
configurable: true,
|
|
2588
|
+
});
|
|
2589
|
+
chatInstance.setupEffects();
|
|
2590
|
+
const actLogEl = root.querySelector(".chat__activity-log");
|
|
2591
|
+
expect(actLogEl.classList.contains("chat__activity-log--active")).toBe(
|
|
2592
|
+
true,
|
|
2593
|
+
);
|
|
2594
|
+
const copyActBtn = actLogEl.querySelector(
|
|
2595
|
+
".chat__activity-log__copy-btn",
|
|
2596
|
+
) as HTMLButtonElement;
|
|
2597
|
+
copyActBtn?.click();
|
|
2598
|
+
|
|
2599
|
+
Object.defineProperty(currentOrchestratorStore, "activityLog", {
|
|
2600
|
+
get: () => [],
|
|
2601
|
+
configurable: true,
|
|
2602
|
+
});
|
|
2603
|
+
chatInstance.setupEffects();
|
|
2604
|
+
expect(actLogEl.classList.contains("chat__activity-log--active")).toBe(
|
|
2605
|
+
false,
|
|
2606
|
+
);
|
|
2607
|
+
|
|
2608
|
+
// 7. Shared state
|
|
2609
|
+
currentOrchestratorStore.getPeerState = jest
|
|
2610
|
+
.fn()
|
|
2611
|
+
.mockReturnValue({ topic: "AI" });
|
|
2612
|
+
chatInstance.setupEffects();
|
|
2613
|
+
const sharedStateEl = root.querySelector(".chat__shared-state");
|
|
2614
|
+
expect(sharedStateEl.hidden).toBe(false);
|
|
2615
|
+
|
|
2616
|
+
currentOrchestratorStore.getPeerState = jest.fn().mockReturnValue({});
|
|
2617
|
+
chatInstance.setupEffects();
|
|
2618
|
+
expect(sharedStateEl.hidden).toBe(true);
|
|
2619
|
+
|
|
2620
|
+
// 8. Orchestrator states: thinking, error
|
|
2621
|
+
Object.defineProperty(currentOrchestratorStore, "state", {
|
|
2622
|
+
get: () => "thinking",
|
|
2623
|
+
configurable: true,
|
|
2624
|
+
});
|
|
2625
|
+
chatInstance.setupEffects();
|
|
2626
|
+
const statusText = root.querySelector(".chat__status-text");
|
|
2627
|
+
expect(statusText.textContent).toBe("Thinking");
|
|
2628
|
+
|
|
2629
|
+
Object.defineProperty(currentOrchestratorStore, "state", {
|
|
2630
|
+
get: () => "error",
|
|
2631
|
+
configurable: true,
|
|
2632
|
+
});
|
|
2633
|
+
chatInstance.setupEffects();
|
|
2634
|
+
expect(statusText.textContent).toBe("Error");
|
|
2635
|
+
|
|
2636
|
+
// 9. Error toast
|
|
2637
|
+
currentOrchestratorStore.clearError = jest.fn();
|
|
2638
|
+
Object.defineProperty(currentOrchestratorStore, "error", {
|
|
2639
|
+
get: () => "Connection lost",
|
|
2640
|
+
configurable: true,
|
|
2641
|
+
});
|
|
2642
|
+
chatInstance.setupEffects();
|
|
2643
|
+
expect(currentOrchestratorStore.clearError).toHaveBeenCalled();
|
|
2644
|
+
|
|
2645
|
+
Object.defineProperty(currentOrchestratorStore, "error", {
|
|
2646
|
+
get: () => null,
|
|
2647
|
+
configurable: true,
|
|
2648
|
+
});
|
|
2649
|
+
|
|
2650
|
+
// 10. Messages rendering effect with user, assistant, a2ui, attachments
|
|
2651
|
+
Object.defineProperty(currentOrchestratorStore, "messages", {
|
|
2652
|
+
get: () => [
|
|
2653
|
+
{
|
|
2654
|
+
id: "m-user",
|
|
2655
|
+
groupId: "main",
|
|
2656
|
+
isFromMe: false,
|
|
2657
|
+
sender: "User",
|
|
2658
|
+
content: "Hello assistant",
|
|
2659
|
+
timestamp: 1700000000000,
|
|
2660
|
+
},
|
|
2661
|
+
{
|
|
2662
|
+
id: "m-asst",
|
|
2663
|
+
groupId: "main",
|
|
2664
|
+
isFromMe: true,
|
|
2665
|
+
content: "```js\nconsole.log(1)\n```\nHere is response",
|
|
2666
|
+
timestamp: 1700000001000,
|
|
2667
|
+
attachments: [
|
|
2668
|
+
{
|
|
2669
|
+
id: "att-1",
|
|
2670
|
+
fileName: "data.txt",
|
|
2671
|
+
mimeType: "text/plain",
|
|
2672
|
+
path: "data.txt",
|
|
2673
|
+
},
|
|
2674
|
+
],
|
|
2675
|
+
a2uiEnvelopes: [{ surfaceId: "s1", component: "card" }],
|
|
2676
|
+
},
|
|
2677
|
+
{
|
|
2678
|
+
id: "m-a2ui",
|
|
2679
|
+
groupId: "main",
|
|
2680
|
+
isFromMe: false,
|
|
2681
|
+
a2uiAction: { surfaceId: "s1" },
|
|
2682
|
+
content: "[A2UI ACTION] dummy",
|
|
2683
|
+
},
|
|
2684
|
+
],
|
|
2685
|
+
configurable: true,
|
|
2686
|
+
});
|
|
2687
|
+
chatInstance.setupEffects();
|
|
2688
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
2689
|
+
|
|
2690
|
+
const messagesContainer = root.querySelector(".chat__messages");
|
|
2691
|
+
expect(
|
|
2692
|
+
messagesContainer.querySelectorAll(".chat__message").length,
|
|
2693
|
+
).toBeGreaterThanOrEqual(2);
|
|
2694
|
+
|
|
2695
|
+
// Test copy and delete buttons injected on message
|
|
2696
|
+
const msgCopyBtn = messagesContainer.querySelector(
|
|
2697
|
+
".chat__message-copy-btn",
|
|
2698
|
+
) as HTMLButtonElement;
|
|
2699
|
+
msgCopyBtn?.click();
|
|
2700
|
+
|
|
2701
|
+
const msgDeleteBtn = messagesContainer.querySelector(
|
|
2702
|
+
".chat__message-delete-btn",
|
|
2703
|
+
) as HTMLButtonElement;
|
|
2704
|
+
chatInstance.showAttachmentDialog = jest
|
|
2705
|
+
.fn<(...args: any[]) => Promise<boolean>>()
|
|
2706
|
+
.mockResolvedValue(true);
|
|
2707
|
+
msgDeleteBtn?.click();
|
|
2708
|
+
});
|
|
2709
|
+
|
|
2710
|
+
it("handles restoreInputAreaHeight with saved configurations", async () => {
|
|
2711
|
+
document.body.appendChild(chatInstance);
|
|
2712
|
+
|
|
2713
|
+
const createConfigDb = (val: any) => ({
|
|
2714
|
+
transaction: jest.fn().mockReturnValue({
|
|
2715
|
+
objectStore: jest.fn().mockReturnValue({
|
|
2716
|
+
get: jest
|
|
2717
|
+
.fn<(...args: any[]) => any>()
|
|
2718
|
+
.mockImplementation((...getArgs: any[]) => {
|
|
2719
|
+
const key = getArgs[0];
|
|
2720
|
+
const req: any = {
|
|
2721
|
+
result: { key, value: val },
|
|
2722
|
+
};
|
|
2723
|
+
queueMicrotask(() => req.onsuccess?.({ target: req }));
|
|
2724
|
+
return req;
|
|
2725
|
+
}),
|
|
2726
|
+
}),
|
|
2727
|
+
}),
|
|
2728
|
+
});
|
|
2729
|
+
|
|
2730
|
+
const inputArea =
|
|
2731
|
+
chatInstance.shadowRoot.querySelector(".chat__input-area");
|
|
2732
|
+
|
|
2733
|
+
// 1. Without db
|
|
2734
|
+
chatInstance.db = null;
|
|
2735
|
+
await chatInstance.restoreInputAreaHeight();
|
|
2736
|
+
|
|
2737
|
+
// 2. With finite positive height > 40
|
|
2738
|
+
chatInstance.db = createConfigDb(120);
|
|
2739
|
+
await chatInstance.restoreInputAreaHeight();
|
|
2740
|
+
expect(inputArea.style.getPropertyValue("--chat-input-area-height")).toBe(
|
|
2741
|
+
"120px",
|
|
2742
|
+
);
|
|
2743
|
+
|
|
2744
|
+
// 3. With collapsed height (<= 40)
|
|
2745
|
+
chatInstance.db = createConfigDb(30);
|
|
2746
|
+
await chatInstance.restoreInputAreaHeight();
|
|
2747
|
+
expect(inputArea.style.getPropertyValue("--chat-input-area-height")).toBe(
|
|
2748
|
+
"",
|
|
2749
|
+
);
|
|
2750
|
+
});
|
|
2751
|
+
|
|
2752
|
+
it("handles large text attachment budget exceed and large file skips in draft", async () => {
|
|
2753
|
+
document.body.appendChild(chatInstance);
|
|
2754
|
+
|
|
2755
|
+
// 1. Text attachment larger than INLINE_ATTACHMENT_MAX_BYTES (128KB)
|
|
2756
|
+
const largeText: any = new File(["x".repeat(130 * 1024)], "large.txt", {
|
|
2757
|
+
type: "text/plain",
|
|
2758
|
+
});
|
|
2759
|
+
largeText.text = async () => "x".repeat(130 * 1024);
|
|
2760
|
+
|
|
2761
|
+
const input = chatInstance.shadowRoot.querySelector(
|
|
2762
|
+
".chat__attachment-input",
|
|
2763
|
+
);
|
|
2764
|
+
Object.defineProperty(input, "files", {
|
|
2765
|
+
value: [largeText],
|
|
2766
|
+
writable: true,
|
|
2767
|
+
});
|
|
2768
|
+
await chatInstance.queueSelectedFiles(input);
|
|
2769
|
+
|
|
2770
|
+
const draft = await chatInstance.buildMessageDraftPayload("Prompt");
|
|
2771
|
+
expect(draft?.text).toContain(
|
|
2772
|
+
"Skipped inline content because the file is larger than",
|
|
2773
|
+
);
|
|
2774
|
+
|
|
2775
|
+
// 2. Budget exceeded dialog (> 80_000 chars)
|
|
2776
|
+
chatInstance.showAttachmentDialog = (jest.fn() as any).mockResolvedValue(
|
|
2777
|
+
true,
|
|
2778
|
+
);
|
|
2779
|
+
const mediumText: any = new File(["a".repeat(85000)], "med.txt", {
|
|
2780
|
+
type: "text/plain",
|
|
2781
|
+
});
|
|
2782
|
+
mediumText.text = async () => "a".repeat(85000);
|
|
2783
|
+
|
|
2784
|
+
Object.defineProperty(input, "files", {
|
|
2785
|
+
value: [mediumText],
|
|
2786
|
+
writable: true,
|
|
2787
|
+
});
|
|
2788
|
+
await chatInstance.queueSelectedFiles(input);
|
|
2789
|
+
|
|
2790
|
+
const draftOverBudget =
|
|
2791
|
+
await chatInstance.buildMessageDraftPayload("Prompt 2");
|
|
2792
|
+
expect(draftOverBudget).toBeNull();
|
|
2793
|
+
expect(chatInstance.showAttachmentDialog).toHaveBeenCalled();
|
|
2794
|
+
});
|
|
2795
|
+
|
|
2796
|
+
it("exercises checkPromptApiOnboarding, confirmPromptApiOnboarding, bypassPromptApiOnboardingToSettings", async () => {
|
|
2797
|
+
document.body.appendChild(chatInstance);
|
|
2798
|
+
chatInstance.db = mockDbInstance;
|
|
2799
|
+
|
|
2800
|
+
const checkSpy = jest
|
|
2801
|
+
.spyOn(chatInstance, "checkPromptApiOnboarding")
|
|
2802
|
+
.mockImplementation(async () => {});
|
|
2803
|
+
const confirmSpy = jest
|
|
2804
|
+
.spyOn(chatInstance, "confirmPromptApiOnboarding")
|
|
2805
|
+
.mockImplementation(async () => {});
|
|
2806
|
+
const bypassSpy = jest
|
|
2807
|
+
.spyOn(chatInstance, "bypassPromptApiOnboardingToSettings")
|
|
2808
|
+
.mockImplementation(async () => {});
|
|
2809
|
+
|
|
2810
|
+
await chatInstance.checkPromptApiOnboarding();
|
|
2811
|
+
expect(checkSpy).toHaveBeenCalled();
|
|
2812
|
+
|
|
2813
|
+
await chatInstance.confirmPromptApiOnboarding();
|
|
2814
|
+
expect(confirmSpy).toHaveBeenCalled();
|
|
2815
|
+
|
|
2816
|
+
await chatInstance.bypassPromptApiOnboardingToSettings();
|
|
2817
|
+
expect(bypassSpy).toHaveBeenCalled();
|
|
2818
|
+
|
|
2819
|
+
checkSpy.mockRestore();
|
|
2820
|
+
confirmSpy.mockRestore();
|
|
2821
|
+
bypassSpy.mockRestore();
|
|
2822
|
+
});
|
|
2823
|
+
});
|
|
1320
2824
|
});
|