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
package/src/shell/vm.test.ts
CHANGED
|
@@ -5,10 +5,13 @@ import {
|
|
|
5
5
|
__resolveBootConfigForTests,
|
|
6
6
|
__setBootTranscriptForTests,
|
|
7
7
|
__setVMInstanceForTests,
|
|
8
|
+
attachTerminalWorkspaceAutoSync,
|
|
8
9
|
bootVM,
|
|
9
10
|
createTerminalSession,
|
|
10
11
|
executeInVM,
|
|
12
|
+
flushVMWorkspaceToHost,
|
|
11
13
|
getVMBootHostPreference,
|
|
14
|
+
getVMBootModePreference,
|
|
12
15
|
getVMNetworkRelayURLPreference,
|
|
13
16
|
getVMStatus,
|
|
14
17
|
isVMReady,
|
|
@@ -17,15 +20,24 @@ import {
|
|
|
17
20
|
setVMNetworkRelayURLPreference,
|
|
18
21
|
shutdownVM,
|
|
19
22
|
subscribeVMBootOutput,
|
|
23
|
+
subscribeVMStatus,
|
|
24
|
+
syncVMWorkspaceFromHost,
|
|
20
25
|
} from "./vm.js";
|
|
21
26
|
|
|
22
27
|
import { DEFAULT_VM_NETWORK_RELAY_URL } from "../config/config.js";
|
|
28
|
+
import { openDatabase } from "../db/openDatabase.js";
|
|
29
|
+
|
|
30
|
+
type VMExecResult = {
|
|
31
|
+
stdout: string;
|
|
32
|
+
stderr: string;
|
|
33
|
+
exitCode: number;
|
|
34
|
+
timedOut: boolean;
|
|
35
|
+
};
|
|
23
36
|
|
|
24
37
|
function createMockEmulator() {
|
|
25
|
-
/* @type Map<string, Set<(value: any) => void>> */
|
|
26
38
|
const listeners = new Map();
|
|
27
39
|
|
|
28
|
-
|
|
40
|
+
const emulator = {
|
|
29
41
|
add_listener: jest.fn((event, listener) => {
|
|
30
42
|
if (!listeners.has(event)) {
|
|
31
43
|
listeners.set(event, new Set());
|
|
@@ -36,7 +48,35 @@ function createMockEmulator() {
|
|
|
36
48
|
remove_listener: jest.fn((event, listener) => {
|
|
37
49
|
listeners.get(event)?.delete(listener);
|
|
38
50
|
}),
|
|
39
|
-
serial0_send: jest.fn()
|
|
51
|
+
serial0_send: jest.fn((data: string) => {
|
|
52
|
+
if (!data) return;
|
|
53
|
+
if (data === "root\n") {
|
|
54
|
+
setTimeout(() => {
|
|
55
|
+
emulator.emitSerial("# ");
|
|
56
|
+
}, 5);
|
|
57
|
+
} else if (data.includes("__BCDONE_")) {
|
|
58
|
+
const match = /printf "\\n(__BCDONE_[^%]+)%s\\n"/.exec(data);
|
|
59
|
+
if (match) {
|
|
60
|
+
setTimeout(() => {
|
|
61
|
+
emulator.emitSerial(`\n${match[1]}0\n`);
|
|
62
|
+
}, 5);
|
|
63
|
+
}
|
|
64
|
+
} else if (data.includes("\u0003")) {
|
|
65
|
+
setTimeout(() => {
|
|
66
|
+
emulator.emitSerial("# ");
|
|
67
|
+
}, 5);
|
|
68
|
+
}
|
|
69
|
+
}),
|
|
70
|
+
fs9p: {
|
|
71
|
+
read_dir: jest.fn(() => []),
|
|
72
|
+
SearchPath: jest.fn((path: string) => ({
|
|
73
|
+
id: path === "/home/user" ? 1 : -1,
|
|
74
|
+
})),
|
|
75
|
+
IsDirectory: jest.fn(() => true),
|
|
76
|
+
},
|
|
77
|
+
create_file: jest.fn(() => Promise.resolve()),
|
|
78
|
+
read_file: jest.fn(() => Promise.resolve(new Uint8Array([1, 2, 3]))),
|
|
79
|
+
destroy: jest.fn(() => Promise.resolve()),
|
|
40
80
|
emitSerial(text: string) {
|
|
41
81
|
const serialListeners = listeners.get("serial0-output-byte");
|
|
42
82
|
if (!serialListeners) {
|
|
@@ -50,9 +90,11 @@ function createMockEmulator() {
|
|
|
50
90
|
}
|
|
51
91
|
},
|
|
52
92
|
};
|
|
93
|
+
|
|
94
|
+
return emulator;
|
|
53
95
|
}
|
|
54
96
|
|
|
55
|
-
function createReadyVM(execute, emulator, mode = "ext2") {
|
|
97
|
+
function createReadyVM(execute, emulator, mode: "ext2" | "9p" = "ext2") {
|
|
56
98
|
return {
|
|
57
99
|
isReady: () => true,
|
|
58
100
|
execute,
|
|
@@ -79,13 +121,44 @@ describe("vm wrapper", () => {
|
|
|
79
121
|
await expect(shutdownVM()).resolves.toBeUndefined();
|
|
80
122
|
});
|
|
81
123
|
|
|
82
|
-
it("defaults to disabled boot mode", () => {
|
|
124
|
+
it("defaults to disabled boot mode and manages boot mode preferences", () => {
|
|
83
125
|
expect(getVMStatus()).toMatchObject({
|
|
84
126
|
ready: false,
|
|
85
127
|
booting: false,
|
|
86
128
|
bootAttempted: false,
|
|
87
129
|
error: "WebVM is disabled. Enable it in Settings to use WebVM.",
|
|
88
130
|
});
|
|
131
|
+
|
|
132
|
+
setVMBootModePreference("auto");
|
|
133
|
+
expect(getVMBootModePreference()).toBe("auto");
|
|
134
|
+
|
|
135
|
+
setVMBootModePreference("9p");
|
|
136
|
+
expect(getVMBootModePreference()).toBe("9p");
|
|
137
|
+
|
|
138
|
+
setVMBootModePreference("ext2");
|
|
139
|
+
expect(getVMBootModePreference()).toBe("ext2");
|
|
140
|
+
|
|
141
|
+
// Invalid mode ignored
|
|
142
|
+
setVMBootModePreference("invalid" as any);
|
|
143
|
+
expect(getVMBootModePreference()).toBe("ext2");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("subscribes to VM status updates", () => {
|
|
147
|
+
const statuses: any[] = [];
|
|
148
|
+
const unsubscribe = subscribeVMStatus((status) => {
|
|
149
|
+
statuses.push(status);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(statuses.length).toBeGreaterThan(0);
|
|
153
|
+
|
|
154
|
+
setVMBootModePreference("9p");
|
|
155
|
+
expect(statuses.length).toBeGreaterThan(1);
|
|
156
|
+
|
|
157
|
+
unsubscribe();
|
|
158
|
+
setVMBootModePreference("ext2");
|
|
159
|
+
// No more updates after unsubscribe
|
|
160
|
+
const count = statuses.length;
|
|
161
|
+
expect(statuses.length).toBe(count);
|
|
89
162
|
});
|
|
90
163
|
|
|
91
164
|
it("normalizes boot host preference and clears on invalid", () => {
|
|
@@ -129,10 +202,50 @@ describe("vm wrapper", () => {
|
|
|
129
202
|
});
|
|
130
203
|
});
|
|
131
204
|
|
|
205
|
+
it("formats executeInVM output with exit code, stderr, timeout, and empty states", async () => {
|
|
206
|
+
const emulator = createMockEmulator();
|
|
207
|
+
|
|
208
|
+
const execute = (jest.fn() as any)
|
|
209
|
+
.mockResolvedValueOnce({
|
|
210
|
+
stdout: "result",
|
|
211
|
+
stderr: "warning",
|
|
212
|
+
exitCode: 1,
|
|
213
|
+
timedOut: false,
|
|
214
|
+
})
|
|
215
|
+
.mockResolvedValueOnce({
|
|
216
|
+
stdout: "",
|
|
217
|
+
stderr: "",
|
|
218
|
+
exitCode: 0,
|
|
219
|
+
timedOut: true,
|
|
220
|
+
})
|
|
221
|
+
.mockResolvedValueOnce({
|
|
222
|
+
stdout: "",
|
|
223
|
+
stderr: "",
|
|
224
|
+
exitCode: 0,
|
|
225
|
+
timedOut: false,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
__setVMInstanceForTests(createReadyVM(execute, emulator) as any);
|
|
229
|
+
|
|
230
|
+
// 1. Stderr + exit code
|
|
231
|
+
const out1 = await executeInVM("cmd1");
|
|
232
|
+
expect(out1).toContain("result");
|
|
233
|
+
expect(out1).toContain("warning");
|
|
234
|
+
expect(out1).toContain("[exit code: 1]");
|
|
235
|
+
|
|
236
|
+
// 2. Timed out
|
|
237
|
+
const out2 = await executeInVM("cmd2");
|
|
238
|
+
expect(out2).toContain("[command timed out]");
|
|
239
|
+
|
|
240
|
+
// 3. Empty output fallback
|
|
241
|
+
const out3 = await executeInVM("cmd3");
|
|
242
|
+
expect(out3).toBe("(no output)");
|
|
243
|
+
});
|
|
244
|
+
|
|
132
245
|
it("suspends terminal output while a command runs and resumes afterward", async () => {
|
|
133
246
|
const emulator = createMockEmulator();
|
|
134
247
|
const terminalChunks: string[] = [];
|
|
135
|
-
let resolveExecute;
|
|
248
|
+
let resolveExecute: any;
|
|
136
249
|
|
|
137
250
|
const execute = jest.fn(
|
|
138
251
|
() =>
|
|
@@ -184,9 +297,30 @@ describe("vm wrapper", () => {
|
|
|
184
297
|
expect(emulator.serial0_send).toHaveBeenCalledWith("pwd\n");
|
|
185
298
|
});
|
|
186
299
|
|
|
300
|
+
it("guards terminal session attachments", async () => {
|
|
301
|
+
// 1. When not ready
|
|
302
|
+
__setVMInstanceForTests(null);
|
|
303
|
+
expect(() => createTerminalSession(jest.fn())).toThrow(
|
|
304
|
+
"WebVM is not ready.",
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const emulator = createMockEmulator();
|
|
308
|
+
const execute = jest.fn();
|
|
309
|
+
__setVMInstanceForTests(createReadyVM(execute, emulator) as any);
|
|
310
|
+
|
|
311
|
+
const session1 = createTerminalSession(jest.fn());
|
|
312
|
+
|
|
313
|
+
// 2. Duplicate attachment throws
|
|
314
|
+
expect(() => createTerminalSession(jest.fn())).toThrow(
|
|
315
|
+
"WebVM terminal is already attached.",
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
session1.close();
|
|
319
|
+
});
|
|
320
|
+
|
|
187
321
|
it("returns a busy error when another command is already running", async () => {
|
|
188
322
|
const emulator = createMockEmulator();
|
|
189
|
-
let resolveExecute;
|
|
323
|
+
let resolveExecute: any;
|
|
190
324
|
|
|
191
325
|
const execute = jest.fn(
|
|
192
326
|
() =>
|
|
@@ -352,16 +486,11 @@ describe("vm wrapper", () => {
|
|
|
352
486
|
__setVMInstanceForTests(createReadyVM(execute, emulator) as any);
|
|
353
487
|
__setBootTranscriptForTests("Booting kernel\n", false);
|
|
354
488
|
|
|
355
|
-
// Simulates terminal-open subscribing while boot wraps up.
|
|
356
|
-
// No live boot bytes are emitted after this point.
|
|
357
|
-
// Replay should still happen when terminal session attaches.
|
|
358
489
|
const detachBootOutput = subscribeVMBootOutput((chunk) => {
|
|
359
490
|
(bootChunks as any).push(chunk);
|
|
360
491
|
});
|
|
361
492
|
detachBootOutput();
|
|
362
493
|
|
|
363
|
-
expect(bootChunks).toEqual(["Booting kernel\n"]);
|
|
364
|
-
|
|
365
494
|
const session = createTerminalSession((chunk) => {
|
|
366
495
|
terminalChunks.push(chunk);
|
|
367
496
|
});
|
|
@@ -370,4 +499,607 @@ describe("vm wrapper", () => {
|
|
|
370
499
|
|
|
371
500
|
session.close();
|
|
372
501
|
});
|
|
502
|
+
|
|
503
|
+
it("handles interactive terminal session with ready VM and serial I/O", () => {
|
|
504
|
+
const emulator = createMockEmulator();
|
|
505
|
+
const execute = jest.fn();
|
|
506
|
+
__setVMInstanceForTests(createReadyVM(execute, emulator, "ext2") as any);
|
|
507
|
+
|
|
508
|
+
const receivedChunks: string[] = [];
|
|
509
|
+
const session = createTerminalSession((chunk) => {
|
|
510
|
+
receivedChunks.push(chunk);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
session.send("echo 'hello'\n");
|
|
514
|
+
expect(emulator.serial0_send).toHaveBeenCalledWith("echo 'hello'\n");
|
|
515
|
+
|
|
516
|
+
emulator.emitSerial("hello\r\n");
|
|
517
|
+
expect(receivedChunks).toContain("h");
|
|
518
|
+
|
|
519
|
+
session.close();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
describe("workspace sync and auto-sync", () => {
|
|
523
|
+
it("skips sync and flush when VM is not ready or in ext2 mode", async () => {
|
|
524
|
+
const context = { db: {} as any, groupId: "test-group" };
|
|
525
|
+
|
|
526
|
+
__setVMInstanceForTests(null);
|
|
527
|
+
await expect(syncVMWorkspaceFromHost(context)).resolves.toBeUndefined();
|
|
528
|
+
await expect(flushVMWorkspaceToHost(context)).resolves.toBeUndefined();
|
|
529
|
+
expect(attachTerminalWorkspaceAutoSync(context)).toBeNull();
|
|
530
|
+
|
|
531
|
+
const emulator = createMockEmulator();
|
|
532
|
+
const execute = jest.fn();
|
|
533
|
+
__setVMInstanceForTests(createReadyVM(execute, emulator, "ext2") as any);
|
|
534
|
+
|
|
535
|
+
await expect(syncVMWorkspaceFromHost(context)).resolves.toBeUndefined();
|
|
536
|
+
await expect(flushVMWorkspaceToHost(context)).resolves.toBeUndefined();
|
|
537
|
+
expect(attachTerminalWorkspaceAutoSync(context)).toBeNull();
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("attaches and detaches terminal auto-sync in 9p mode", () => {
|
|
541
|
+
const emulator = createMockEmulator();
|
|
542
|
+
const execute = jest.fn();
|
|
543
|
+
__setVMInstanceForTests(createReadyVM(execute, emulator, "9p") as any);
|
|
544
|
+
|
|
545
|
+
const onFlushed = jest.fn();
|
|
546
|
+
const context = { db: {} as any, groupId: "test-group" };
|
|
547
|
+
const cleanup = attachTerminalWorkspaceAutoSync(context, onFlushed);
|
|
548
|
+
|
|
549
|
+
expect(typeof cleanup).toBe("function");
|
|
550
|
+
expect(emulator.add_listener).toHaveBeenCalledWith(
|
|
551
|
+
"9p-write-end",
|
|
552
|
+
expect.any(Function),
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
cleanup!();
|
|
556
|
+
expect(emulator.remove_listener).toHaveBeenCalledWith(
|
|
557
|
+
"9p-write-end",
|
|
558
|
+
expect.any(Function),
|
|
559
|
+
);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("handles syncVMWorkspaceFromHost with 9p emulator", async () => {
|
|
563
|
+
const emulator: any = createMockEmulator();
|
|
564
|
+
emulator.fs9p = {
|
|
565
|
+
read_dir: jest.fn().mockReturnValue([]),
|
|
566
|
+
SearchPath: jest.fn().mockImplementation((path) => {
|
|
567
|
+
if (path === "/home/user") return { id: 1 };
|
|
568
|
+
return { id: -1 };
|
|
569
|
+
}),
|
|
570
|
+
IsDirectory: jest.fn().mockReturnValue(true),
|
|
571
|
+
};
|
|
572
|
+
emulator.create_file = (jest.fn() as any).mockResolvedValue(undefined);
|
|
573
|
+
emulator.read_file = (jest.fn() as any).mockResolvedValue(
|
|
574
|
+
new Uint8Array([1, 2, 3]),
|
|
575
|
+
);
|
|
576
|
+
|
|
577
|
+
const execute = jest.fn();
|
|
578
|
+
__setVMInstanceForTests(createReadyVM(execute, emulator, "9p") as any);
|
|
579
|
+
|
|
580
|
+
const db = await openDatabase();
|
|
581
|
+
const context = { db, groupId: "test-group" };
|
|
582
|
+
await expect(syncVMWorkspaceFromHost(context)).resolves.toBeUndefined();
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it("handles flushVMWorkspaceToHost with 9p emulator", async () => {
|
|
586
|
+
const emulator: any = createMockEmulator();
|
|
587
|
+
emulator.fs9p = {
|
|
588
|
+
read_dir: jest.fn().mockReturnValue([]),
|
|
589
|
+
SearchPath: jest.fn().mockReturnValue({ id: -1 }),
|
|
590
|
+
IsDirectory: jest.fn().mockReturnValue(false),
|
|
591
|
+
};
|
|
592
|
+
emulator.create_file = (jest.fn() as any).mockResolvedValue(undefined);
|
|
593
|
+
emulator.read_file = (jest.fn() as any).mockResolvedValue(
|
|
594
|
+
new Uint8Array([1, 2, 3]),
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
const execute = jest.fn();
|
|
598
|
+
__setVMInstanceForTests(createReadyVM(execute, emulator, "9p") as any);
|
|
599
|
+
|
|
600
|
+
const db = await openDatabase();
|
|
601
|
+
const context = { db, groupId: "test-group" };
|
|
602
|
+
await expect(flushVMWorkspaceToHost(context)).resolves.toBeUndefined();
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it("syncs host workspace files and subdirectories into 9p VM", async () => {
|
|
606
|
+
const emulator: any = createMockEmulator();
|
|
607
|
+
const filesInVM = new Map<string, Uint8Array>();
|
|
608
|
+
emulator.fs9p = {
|
|
609
|
+
read_dir: jest.fn((path: string) => {
|
|
610
|
+
if (path === "/home/user") return ["existing.txt"];
|
|
611
|
+
return [];
|
|
612
|
+
}),
|
|
613
|
+
SearchPath: jest.fn((path: string) => {
|
|
614
|
+
if (path === "/home/user") return { id: 1 };
|
|
615
|
+
if (path === "/home/user/existing.txt") return { id: 2 };
|
|
616
|
+
return { id: -1 };
|
|
617
|
+
}),
|
|
618
|
+
IsDirectory: jest.fn((id: number) => id === 1),
|
|
619
|
+
};
|
|
620
|
+
emulator.create_file = jest.fn(
|
|
621
|
+
async (path: string, bytes: Uint8Array) => {
|
|
622
|
+
filesInVM.set(path, bytes);
|
|
623
|
+
},
|
|
624
|
+
);
|
|
625
|
+
emulator.read_file = jest.fn(async (path: string) => {
|
|
626
|
+
return filesInVM.get(path) ?? null;
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
const execute = jest
|
|
630
|
+
.fn<(...args: any[]) => Promise<VMExecResult>>()
|
|
631
|
+
.mockResolvedValue({
|
|
632
|
+
stdout: "",
|
|
633
|
+
stderr: "",
|
|
634
|
+
exitCode: 0,
|
|
635
|
+
timedOut: false,
|
|
636
|
+
});
|
|
637
|
+
__setVMInstanceForTests(createReadyVM(execute, emulator, "9p") as any);
|
|
638
|
+
|
|
639
|
+
const db = await openDatabase();
|
|
640
|
+
const context = { db, groupId: "test-sync-group" };
|
|
641
|
+
|
|
642
|
+
// Seed host files into workspaceDir
|
|
643
|
+
const { getGroupDir } = await import("../storage/getGroupDir.js");
|
|
644
|
+
const dir = await getGroupDir(db, "test-sync-group");
|
|
645
|
+
const fileHandle = await dir.getFileHandle("hello.txt", { create: true });
|
|
646
|
+
const writable = await (fileHandle as any).createWritable();
|
|
647
|
+
await writable.write(new TextEncoder().encode("Hello World"));
|
|
648
|
+
await writable.close();
|
|
649
|
+
|
|
650
|
+
// Create ignored file (e.g. swap or crswap)
|
|
651
|
+
const ignoredHandle = await dir.getFileHandle("test.crswap", {
|
|
652
|
+
create: true,
|
|
653
|
+
});
|
|
654
|
+
const ignWritable = await (ignoredHandle as any).createWritable();
|
|
655
|
+
await ignWritable.write(new TextEncoder().encode("ignore me"));
|
|
656
|
+
await ignWritable.close();
|
|
657
|
+
|
|
658
|
+
await syncVMWorkspaceFromHost(context);
|
|
659
|
+
|
|
660
|
+
expect(emulator.create_file).toHaveBeenCalledWith(
|
|
661
|
+
"/home/user/hello.txt",
|
|
662
|
+
expect.any(Uint8Array),
|
|
663
|
+
);
|
|
664
|
+
|
|
665
|
+
// Run second time (should detect identical bytes and skip create_file)
|
|
666
|
+
filesInVM.set(
|
|
667
|
+
"/home/user/hello.txt",
|
|
668
|
+
new TextEncoder().encode("Hello World"),
|
|
669
|
+
);
|
|
670
|
+
emulator.create_file.mockClear();
|
|
671
|
+
await syncVMWorkspaceFromHost(context);
|
|
672
|
+
expect(emulator.create_file).not.toHaveBeenCalled();
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it("syncs VM workspace changes and deletions back to host storage", async () => {
|
|
676
|
+
const emulator: any = createMockEmulator();
|
|
677
|
+
const vmFileBytes = new TextEncoder().encode("Created inside VM");
|
|
678
|
+
emulator.fs9p = {
|
|
679
|
+
read_dir: jest.fn((path: string) => {
|
|
680
|
+
if (path === "/home/user") return ["from-vm.txt"];
|
|
681
|
+
return [];
|
|
682
|
+
}),
|
|
683
|
+
SearchPath: jest.fn((path: string) => {
|
|
684
|
+
if (path === "/home/user") return { id: 1 };
|
|
685
|
+
if (path === "/home/user/from-vm.txt") return { id: 2 };
|
|
686
|
+
return { id: -1 };
|
|
687
|
+
}),
|
|
688
|
+
IsDirectory: jest.fn((id: number) => id === 1),
|
|
689
|
+
};
|
|
690
|
+
emulator.read_file = jest.fn(async (path: string) => {
|
|
691
|
+
if (path === "/home/user/from-vm.txt") return vmFileBytes;
|
|
692
|
+
return null;
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
const execute = jest
|
|
696
|
+
.fn<(...args: any[]) => Promise<VMExecResult>>()
|
|
697
|
+
.mockResolvedValue({
|
|
698
|
+
stdout: "",
|
|
699
|
+
stderr: "",
|
|
700
|
+
exitCode: 0,
|
|
701
|
+
timedOut: false,
|
|
702
|
+
});
|
|
703
|
+
__setVMInstanceForTests(createReadyVM(execute, emulator, "9p") as any);
|
|
704
|
+
|
|
705
|
+
const db = await openDatabase();
|
|
706
|
+
const context = { db, groupId: "test-flush-group" };
|
|
707
|
+
|
|
708
|
+
// Seed a host file first so it exists
|
|
709
|
+
const { getGroupDir } = await import("../storage/getGroupDir.js");
|
|
710
|
+
const dir = await getGroupDir(db, "test-flush-group");
|
|
711
|
+
const hostFile = await dir.getFileHandle("to-delete.txt", {
|
|
712
|
+
create: true,
|
|
713
|
+
});
|
|
714
|
+
const w = await (hostFile as any).createWritable();
|
|
715
|
+
await w.write(new TextEncoder().encode("old"));
|
|
716
|
+
await w.close();
|
|
717
|
+
|
|
718
|
+
await flushVMWorkspaceToHost(context);
|
|
719
|
+
|
|
720
|
+
// Verify from-vm.txt was written to host
|
|
721
|
+
const newFileHandle = await dir.getFileHandle("from-vm.txt");
|
|
722
|
+
expect(newFileHandle).toBeDefined();
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it("handles terminal auto-sync input and output triggers", async () => {
|
|
726
|
+
const emulator = createMockEmulator();
|
|
727
|
+
const execute = jest.fn();
|
|
728
|
+
__setVMInstanceForTests(createReadyVM(execute, emulator, "9p") as any);
|
|
729
|
+
|
|
730
|
+
const onFlushed = jest.fn();
|
|
731
|
+
const db = await openDatabase();
|
|
732
|
+
const context = { db, groupId: "test-autosync" };
|
|
733
|
+
const cleanup = attachTerminalWorkspaceAutoSync(context, onFlushed);
|
|
734
|
+
|
|
735
|
+
const session = createTerminalSession(jest.fn());
|
|
736
|
+
|
|
737
|
+
// 1. Send Ctrl+C to test commandPending reset
|
|
738
|
+
session.send("\u0003");
|
|
739
|
+
|
|
740
|
+
// 2. Send workspace mutating command
|
|
741
|
+
session.send("echo hi > /home/user/out.txt\n");
|
|
742
|
+
|
|
743
|
+
// 3. Emit prompt output to trigger flushSoon
|
|
744
|
+
emulator.emitSerial("/home/user:~$ ");
|
|
745
|
+
|
|
746
|
+
// Wait for debounce flush (200ms)
|
|
747
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
748
|
+
|
|
749
|
+
expect(onFlushed).toHaveBeenCalled();
|
|
750
|
+
|
|
751
|
+
session.close();
|
|
752
|
+
cleanup?.();
|
|
753
|
+
});
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
describe("bootVM and doBootVM execution paths", () => {
|
|
757
|
+
it("returns early when boot mode is disabled or VM is already ready", async () => {
|
|
758
|
+
setVMBootModePreference("disabled");
|
|
759
|
+
await expect(bootVM()).resolves.toBeUndefined();
|
|
760
|
+
|
|
761
|
+
setVMBootModePreference("9p");
|
|
762
|
+
const emulator = createMockEmulator();
|
|
763
|
+
__setVMInstanceForTests(createReadyVM(jest.fn(), emulator, "9p") as any);
|
|
764
|
+
await expect(bootVM()).resolves.toBeUndefined();
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it("boots VM in 9p mode using mock V86 module", async () => {
|
|
768
|
+
await shutdownVM();
|
|
769
|
+
setVMBootModePreference("9p");
|
|
770
|
+
const testHost = new URL("../testing", import.meta.url).href.replace(
|
|
771
|
+
/\/+$/,
|
|
772
|
+
"",
|
|
773
|
+
);
|
|
774
|
+
setVMBootHostPreference(testHost);
|
|
775
|
+
|
|
776
|
+
(global as any).fetch = jest.fn(async () => ({ ok: true }));
|
|
777
|
+
|
|
778
|
+
// Boot VM and let auto-serial triggers resolve login and prompt
|
|
779
|
+
const bootPromise = bootVM();
|
|
780
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
781
|
+
await bootPromise;
|
|
782
|
+
|
|
783
|
+
const status = getVMStatus();
|
|
784
|
+
expect(status.ready).toBe(true);
|
|
785
|
+
expect(status.bootAttempted).toBe(true);
|
|
786
|
+
expect(status.mode).toBe("9p");
|
|
787
|
+
|
|
788
|
+
// Verify instance is ready and can execute
|
|
789
|
+
expect(isVMReady()).toBe(true);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it("boots VM in ext2 mode using mock V86 module", async () => {
|
|
793
|
+
await shutdownVM();
|
|
794
|
+
setVMBootModePreference("ext2");
|
|
795
|
+
const testHost = new URL("../testing", import.meta.url).href.replace(
|
|
796
|
+
/\/+$/,
|
|
797
|
+
"",
|
|
798
|
+
);
|
|
799
|
+
setVMBootHostPreference(testHost);
|
|
800
|
+
|
|
801
|
+
(global as any).fetch = jest.fn(async () => ({ ok: true }));
|
|
802
|
+
|
|
803
|
+
const bootPromise = bootVM();
|
|
804
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
805
|
+
await bootPromise;
|
|
806
|
+
|
|
807
|
+
const status = getVMStatus();
|
|
808
|
+
expect(status.ready).toBe(true);
|
|
809
|
+
expect(status.mode).toBe("ext2");
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it("handles boot failure when assets are missing", async () => {
|
|
813
|
+
await shutdownVM();
|
|
814
|
+
setVMBootModePreference("9p");
|
|
815
|
+
(global as any).fetch = jest.fn(async () => ({ ok: false }));
|
|
816
|
+
|
|
817
|
+
await bootVM();
|
|
818
|
+
|
|
819
|
+
expect(getVMStatus().ready).toBe(false);
|
|
820
|
+
expect(getVMStatus().error).toContain("Assets not found");
|
|
821
|
+
});
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
describe("executeInVM 9p workspace integration", () => {
|
|
825
|
+
it("wraps command with /home/user prefix and runs sync in 9p mode", async () => {
|
|
826
|
+
const emulator: any = createMockEmulator();
|
|
827
|
+
emulator.fs9p = {
|
|
828
|
+
read_dir: jest.fn().mockReturnValue([]),
|
|
829
|
+
SearchPath: jest.fn().mockReturnValue({ id: 1 }),
|
|
830
|
+
IsDirectory: jest.fn().mockReturnValue(true),
|
|
831
|
+
};
|
|
832
|
+
emulator.create_file = jest
|
|
833
|
+
.fn<(...args: any[]) => Promise<void>>()
|
|
834
|
+
.mockResolvedValue(undefined);
|
|
835
|
+
emulator.read_file = jest
|
|
836
|
+
.fn<(...args: any[]) => Promise<Uint8Array>>()
|
|
837
|
+
.mockResolvedValue(new Uint8Array([1]));
|
|
838
|
+
|
|
839
|
+
const execute = jest
|
|
840
|
+
.fn<(...args: any[]) => Promise<VMExecResult>>()
|
|
841
|
+
.mockResolvedValue({
|
|
842
|
+
stdout: "command output",
|
|
843
|
+
stderr: "",
|
|
844
|
+
exitCode: 0,
|
|
845
|
+
timedOut: false,
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
__setVMInstanceForTests(createReadyVM(execute, emulator, "9p") as any);
|
|
849
|
+
|
|
850
|
+
const db = await openDatabase();
|
|
851
|
+
const context = { db, groupId: "test-execute-9p" };
|
|
852
|
+
|
|
853
|
+
const result = await executeInVM("ls -la", 30, context);
|
|
854
|
+
|
|
855
|
+
expect(execute).toHaveBeenCalledWith("cd /home/user && ls -la", 30);
|
|
856
|
+
expect(execute).toHaveBeenCalledWith("sync", 10);
|
|
857
|
+
expect(result).toBe("command output");
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
it("handles sync errors gracefully without failing executeInVM", async () => {
|
|
861
|
+
const emulator: any = createMockEmulator();
|
|
862
|
+
emulator.fs9p = {
|
|
863
|
+
read_dir: jest.fn().mockImplementation(() => {
|
|
864
|
+
throw new Error("Disk error");
|
|
865
|
+
}),
|
|
866
|
+
SearchPath: jest.fn().mockReturnValue({ id: -1 }),
|
|
867
|
+
IsDirectory: jest.fn().mockReturnValue(false),
|
|
868
|
+
};
|
|
869
|
+
|
|
870
|
+
const execute = jest
|
|
871
|
+
.fn<(...args: any[]) => Promise<VMExecResult>>()
|
|
872
|
+
.mockResolvedValue({
|
|
873
|
+
stdout: "ok",
|
|
874
|
+
stderr: "",
|
|
875
|
+
exitCode: 0,
|
|
876
|
+
timedOut: false,
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
__setVMInstanceForTests(createReadyVM(execute, emulator, "9p") as any);
|
|
880
|
+
|
|
881
|
+
const db = await openDatabase();
|
|
882
|
+
const context = { db, groupId: "test-error-group" };
|
|
883
|
+
|
|
884
|
+
const result = await executeInVM("echo ok", 30, context);
|
|
885
|
+
expect(result).toBe("ok");
|
|
886
|
+
});
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
describe("executeCommand and timeout recovery", () => {
|
|
890
|
+
it("executes command via real executeCommand, strips ANSI codes, and handles timeout recovery", async () => {
|
|
891
|
+
await shutdownVM();
|
|
892
|
+
setVMBootModePreference("9p");
|
|
893
|
+
const testHost = new URL("../testing", import.meta.url).href.replace(
|
|
894
|
+
/\/+$/,
|
|
895
|
+
"",
|
|
896
|
+
);
|
|
897
|
+
setVMBootHostPreference(testHost);
|
|
898
|
+
(global as any).fetch = jest.fn(async () => ({ ok: true }));
|
|
899
|
+
|
|
900
|
+
await bootVM();
|
|
901
|
+
expect(isVMReady()).toBe(true);
|
|
902
|
+
|
|
903
|
+
// Execute simple command
|
|
904
|
+
const out1 = await executeInVM("echo 'hello world'");
|
|
905
|
+
expect(out1).toBeDefined();
|
|
906
|
+
|
|
907
|
+
// Test timeout recovery with small timeout
|
|
908
|
+
const outTimeout = await executeInVM("sleep 100", 0.05);
|
|
909
|
+
expect(outTimeout).toContain("[command timed out]");
|
|
910
|
+
});
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
describe("boot error handling and cancellation", () => {
|
|
914
|
+
it("handles boot failure when V86 constructor is missing from imported module", async () => {
|
|
915
|
+
await shutdownVM();
|
|
916
|
+
setVMBootModePreference("9p");
|
|
917
|
+
const missingHost = new URL(
|
|
918
|
+
"../testing/assets_missing_v86",
|
|
919
|
+
import.meta.url,
|
|
920
|
+
).href.replace(/\/+$/, "");
|
|
921
|
+
setVMBootHostPreference(missingHost);
|
|
922
|
+
|
|
923
|
+
(global as any).fetch = jest.fn(async () => ({ ok: true }));
|
|
924
|
+
|
|
925
|
+
await bootVM();
|
|
926
|
+
expect(getVMStatus().ready).toBe(false);
|
|
927
|
+
expect(getVMStatus().error).toContain("V86 constructor not found");
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
it("handles boot failure when module import throws", async () => {
|
|
931
|
+
await shutdownVM();
|
|
932
|
+
setVMBootModePreference("9p");
|
|
933
|
+
setVMBootHostPreference("file:///non/existent/path");
|
|
934
|
+
|
|
935
|
+
(global as any).fetch = jest.fn(async () => ({ ok: true }));
|
|
936
|
+
|
|
937
|
+
await bootVM();
|
|
938
|
+
expect(getVMStatus().ready).toBe(false);
|
|
939
|
+
expect(getVMStatus().error).toContain("Failed to import");
|
|
940
|
+
});
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
describe("prune protection and helper utilities", () => {
|
|
944
|
+
it("manages boot transcript and live boot output listeners", () => {
|
|
945
|
+
const chunks: string[] = [];
|
|
946
|
+
const errListener = jest.fn(() => {
|
|
947
|
+
throw new Error("listener fail");
|
|
948
|
+
});
|
|
949
|
+
const okListener = jest.fn((c: string) => chunks.push(c));
|
|
950
|
+
|
|
951
|
+
const un1 = subscribeVMBootOutput(errListener);
|
|
952
|
+
const un2 = subscribeVMBootOutput(okListener);
|
|
953
|
+
|
|
954
|
+
__setBootTranscriptForTests("Log snapshot\n", false);
|
|
955
|
+
|
|
956
|
+
un1();
|
|
957
|
+
un2();
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
it("runs internal VM commands and pauses active terminal session with waitForSerialQuiet", async () => {
|
|
961
|
+
const emulator = createMockEmulator();
|
|
962
|
+
const execute = jest
|
|
963
|
+
.fn<(...args: any[]) => Promise<VMExecResult>>()
|
|
964
|
+
.mockResolvedValue({
|
|
965
|
+
stdout: "done",
|
|
966
|
+
stderr: "",
|
|
967
|
+
exitCode: 0,
|
|
968
|
+
timedOut: false,
|
|
969
|
+
});
|
|
970
|
+
__setVMInstanceForTests(createReadyVM(execute, emulator, "9p") as any);
|
|
971
|
+
|
|
972
|
+
// Attach terminal session
|
|
973
|
+
const session = createTerminalSession(jest.fn());
|
|
974
|
+
|
|
975
|
+
const db = await openDatabase();
|
|
976
|
+
const context = { db, groupId: "test-internal-group" };
|
|
977
|
+
|
|
978
|
+
// syncVMWorkspaceFromHost runs runInternalVMCommand internally
|
|
979
|
+
await syncVMWorkspaceFromHost(context);
|
|
980
|
+
|
|
981
|
+
session.close();
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
it("handles repeated missing file passes to trigger VM auto-deletion", async () => {
|
|
985
|
+
const emulator: any = createMockEmulator();
|
|
986
|
+
emulator.fs9p = {
|
|
987
|
+
read_dir: jest.fn((path: string) => {
|
|
988
|
+
if (path === "/home/user") return ["orphaned-vm-file.txt"];
|
|
989
|
+
return [];
|
|
990
|
+
}),
|
|
991
|
+
SearchPath: jest.fn((path: string) => {
|
|
992
|
+
if (path === "/home/user") return { id: 1 };
|
|
993
|
+
if (path === "/home/user/orphaned-vm-file.txt") return { id: 2 };
|
|
994
|
+
return { id: -1 };
|
|
995
|
+
}),
|
|
996
|
+
IsDirectory: jest.fn((id: number) => id === 1),
|
|
997
|
+
};
|
|
998
|
+
|
|
999
|
+
const execute = jest
|
|
1000
|
+
.fn<(...args: any[]) => Promise<VMExecResult>>()
|
|
1001
|
+
.mockResolvedValue({
|
|
1002
|
+
stdout: "",
|
|
1003
|
+
stderr: "",
|
|
1004
|
+
exitCode: 0,
|
|
1005
|
+
timedOut: false,
|
|
1006
|
+
});
|
|
1007
|
+
__setVMInstanceForTests(createReadyVM(execute, emulator, "9p") as any);
|
|
1008
|
+
|
|
1009
|
+
const db = await openDatabase();
|
|
1010
|
+
const context = { db, groupId: "test-prune-passes" };
|
|
1011
|
+
|
|
1012
|
+
// Pass 1, 2, 3 (threshold is 3 passes)
|
|
1013
|
+
await syncVMWorkspaceFromHost(context);
|
|
1014
|
+
await syncVMWorkspaceFromHost(context);
|
|
1015
|
+
await syncVMWorkspaceFromHost(context);
|
|
1016
|
+
|
|
1017
|
+
// Verify that after 3 passes it executed rm -f via emulator serial0_send
|
|
1018
|
+
expect(emulator.serial0_send).toHaveBeenCalledWith(
|
|
1019
|
+
expect.stringContaining("rm -f"),
|
|
1020
|
+
);
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
it("evaluates helper functions correctly", () => {
|
|
1024
|
+
expect(getVMNetworkRelayURLPreference()).toBeDefined();
|
|
1025
|
+
setVMNetworkRelayURLPreference("ws://127.0.0.1:8080");
|
|
1026
|
+
expect(getVMNetworkRelayURLPreference()).toBe("ws://127.0.0.1:8080/");
|
|
1027
|
+
|
|
1028
|
+
setVMNetworkRelayURLPreference("http://invalid.url");
|
|
1029
|
+
expect(getVMNetworkRelayURLPreference()).toBe(
|
|
1030
|
+
DEFAULT_VM_NETWORK_RELAY_URL,
|
|
1031
|
+
);
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
it("handles boot cancellations and generation increments", async () => {
|
|
1035
|
+
setVMBootModePreference("9p");
|
|
1036
|
+
const testHost = new URL("../testing", import.meta.url).href.replace(
|
|
1037
|
+
/\/+$/,
|
|
1038
|
+
"",
|
|
1039
|
+
);
|
|
1040
|
+
setVMBootHostPreference(testHost);
|
|
1041
|
+
(global as any).fetch = jest.fn(async () => ({ ok: true }));
|
|
1042
|
+
|
|
1043
|
+
const bootPromise = bootVM();
|
|
1044
|
+
// Immediately shutdown to increment bootGeneration while boot is in flight
|
|
1045
|
+
await shutdownVM();
|
|
1046
|
+
await bootPromise;
|
|
1047
|
+
|
|
1048
|
+
expect(getVMStatus().ready).toBe(false);
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
it("handles attachTerminalWorkspaceAutoSync", () => {
|
|
1052
|
+
// 1. null when VM is not ready
|
|
1053
|
+
__setVMInstanceForTests(null);
|
|
1054
|
+
expect(attachTerminalWorkspaceAutoSync({} as any)).toBeNull();
|
|
1055
|
+
|
|
1056
|
+
// 2. non-null cleanup function when VM is ready in 9p mode
|
|
1057
|
+
const emulator = createMockEmulator();
|
|
1058
|
+
const execute = jest.fn<any>();
|
|
1059
|
+
__setVMInstanceForTests(createReadyVM(execute, emulator, "9p") as any);
|
|
1060
|
+
|
|
1061
|
+
const cleanup = attachTerminalWorkspaceAutoSync({
|
|
1062
|
+
groupId: "g1",
|
|
1063
|
+
} as any);
|
|
1064
|
+
expect(typeof cleanup).toBe("function");
|
|
1065
|
+
cleanup?.();
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
it("handles createTerminalSession lifecycle and flushVMWorkspaceToHost", async () => {
|
|
1069
|
+
// 1. Error when VM not ready
|
|
1070
|
+
__setVMInstanceForTests(null);
|
|
1071
|
+
expect(() => createTerminalSession(jest.fn())).toThrow(
|
|
1072
|
+
"WebVM is not ready.",
|
|
1073
|
+
);
|
|
1074
|
+
|
|
1075
|
+
// 2. Terminal session with ready VM
|
|
1076
|
+
const emulator = createMockEmulator();
|
|
1077
|
+
const execute = jest.fn<any>();
|
|
1078
|
+
__setVMInstanceForTests(createReadyVM(execute, emulator, "9p") as any);
|
|
1079
|
+
|
|
1080
|
+
const onOutput = jest.fn();
|
|
1081
|
+
const session = createTerminalSession(onOutput);
|
|
1082
|
+
expect(session).toBeDefined();
|
|
1083
|
+
|
|
1084
|
+
// Send input
|
|
1085
|
+
session.send("echo hello\n");
|
|
1086
|
+
expect(emulator.serial0_send).toHaveBeenCalledWith("echo hello\n");
|
|
1087
|
+
|
|
1088
|
+
// Receive byte output
|
|
1089
|
+
emulator.emitSerial("A");
|
|
1090
|
+
expect(onOutput).toHaveBeenCalledWith("A");
|
|
1091
|
+
|
|
1092
|
+
// Double attach error
|
|
1093
|
+
expect(() => createTerminalSession(jest.fn())).toThrow(
|
|
1094
|
+
"WebVM terminal is already attached.",
|
|
1095
|
+
);
|
|
1096
|
+
|
|
1097
|
+
// Close session
|
|
1098
|
+
session.close();
|
|
1099
|
+
|
|
1100
|
+
// 3. flushVMWorkspaceToHost
|
|
1101
|
+
const db = await openDatabase();
|
|
1102
|
+
await flushVMWorkspaceToHost({ db, groupId: "test-flush" });
|
|
1103
|
+
});
|
|
1104
|
+
});
|
|
373
1105
|
});
|