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.
Files changed (255) hide show
  1. package/README.md +42 -5
  2. package/bin/cli.mjs +166 -0
  3. package/bin/cli.test.mjs +60 -0
  4. package/bin/commands/backup.mjs +70 -0
  5. package/bin/commands/clients.mjs +41 -0
  6. package/bin/commands/peer-id.mjs +76 -0
  7. package/bin/commands/peer-id.test.mjs +104 -0
  8. package/bin/commands/send.mjs +51 -0
  9. package/bin/commands/tasks.mjs +59 -0
  10. package/bin/commands/webrtc-listen.mjs +254 -0
  11. package/bin/prerender-dsd-shell/prerender-dsd-shell.test.mjs +46 -0
  12. package/bin/rolldown.config.test.mjs +401 -8
  13. package/bin/utils/control-client.mjs +243 -0
  14. package/bin/utils/control-client.test.mjs +83 -0
  15. package/bin/utils/ulid.mjs +45 -0
  16. package/bin/utils/webrtc-control-client.mjs +699 -0
  17. package/bin/utils/webrtc-control-client.test.mjs +318 -0
  18. package/dist/electron/main.cjs +1 -1
  19. package/dist/public/AGENTS.md +5 -3
  20. package/dist/public/README.md +42 -5
  21. package/dist/public/agent.worker.js +1 -1
  22. package/dist/public/backup-controller-C_s9in2t.js +1 -0
  23. package/dist/public/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.css +1 -0
  24. package/dist/public/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.html +1 -0
  25. package/dist/public/components/settings/shadow-claw-task-server/shadow-claw-task-server.css +1 -1
  26. package/dist/public/components/settings/shadow-claw-task-server/shadow-claw-task-server.html +1 -1
  27. package/dist/public/components/shadow-claw-settings/shadow-claw-settings.html +1 -1
  28. package/dist/public/config-CrHchneq.js +1 -0
  29. package/dist/public/{configurePeerJs-B4Ym9VMj.js → configurePeerJs-CxpZeDJJ.js} +1 -1
  30. package/dist/public/{connections-BEn6JYdu.js → connections-DvbgdkaB.js} +1 -1
  31. package/dist/public/{custom-element-security-DdYlUozq.js → custom-element-security-NCqQ5lmC.js} +1 -1
  32. package/dist/public/docs/README.md +3 -1
  33. package/dist/public/docs/architecture/overview.md +12 -12
  34. package/dist/public/docs/decisions/bundled-typescript-architecture.md +1 -1
  35. package/dist/public/docs/decisions/indexeddb-and-opfs-storage.md +2 -2
  36. package/dist/public/docs/decisions/native-web-components-and-signals.md +1 -1
  37. package/dist/public/docs/example/article/index.html +1 -1
  38. package/dist/public/docs/guides/configuring-messaging-channels.md +41 -6
  39. package/dist/public/docs/guides/server-development-configuration.md +24 -0
  40. package/dist/public/docs/publishing/index.html +1 -1
  41. package/dist/public/docs/skill-creator/index.html +1 -1
  42. package/dist/public/docs/subsystems/backup.md +56 -0
  43. package/dist/public/docs/subsystems/cli.md +124 -0
  44. package/dist/public/docs/subsystems/control-plane.md +158 -0
  45. package/dist/public/docs/subsystems/notifications.md +54 -2
  46. package/dist/public/docs/subsystems/reactive-ui.md +1 -0
  47. package/dist/public/downloadGroupFile-By0GR6eV.js +1 -0
  48. package/dist/public/e2e/README.md +5 -1
  49. package/dist/public/{e2e-bridge-BQ4KzUt5.js → e2e-bridge-IGWsRl4i.js} +1 -1
  50. package/dist/public/file-viewer-D0eflgt6.js +1 -0
  51. package/dist/public/getAllTasks-vpBlgGsc.js +1 -0
  52. package/dist/public/getGroupDir-P1h9wl6S.js +1 -0
  53. package/dist/public/{git-DEUvQTUA.js → git-7Vq_SJSp.js} +2 -2
  54. package/dist/public/{iframe-sanitizer-0NS4XLOd.js → iframe-sanitizer-y2Z1pPwY.js} +1 -1
  55. package/dist/public/index.html +1 -1
  56. package/dist/public/index.js +1 -1
  57. package/dist/public/{initChatSplitResize-llGkSJXg.js → initChatSplitResize-CzV2E7Ul.js} +1 -1
  58. package/dist/public/initControlPlane-C9jpnWev.js +1 -0
  59. package/dist/public/llms.txt +1 -1
  60. package/dist/public/main/index.html +1 -1
  61. package/dist/public/main/memory/index.html +1 -1
  62. package/dist/public/{markdown-CyYR4App.js → markdown-BnpKxSxl.js} +1 -1
  63. package/dist/public/{mcp-reconnect-CSx4aMwy.js → mcp-reconnect-9emsqGRe.js} +1 -1
  64. package/dist/public/{memoryStorage-Dwg8NhAZ.js → memoryStorage-C0KvLNUp.js} +1 -1
  65. package/dist/public/{orchestrator-DRfEudXk.js → orchestrator-3h9xxHUm.js} +676 -676
  66. package/dist/public/orchestrator-nJj59RqD.js +15 -0
  67. package/dist/public/{peerjs-CurBuKvk.js → peerjs-BP4laMXj.js} +1 -1
  68. package/dist/public/{push-client-Dii-vPEh.js → push-client-CrjEXK3U.js} +1 -1
  69. package/dist/public/service-worker/push-handler.js +1 -1
  70. package/dist/public/service-worker.js +1 -1
  71. package/dist/public/{shadow-claw-5IHaDDMJ.js → shadow-claw-CbwETBWf.js} +2 -2
  72. package/dist/public/{shadow-claw-accounts-CZvz4iQz.js → shadow-claw-accounts-B4sBTQEj.js} +1 -1
  73. package/dist/public/{shadow-claw-channels-BKXFyRS3.js → shadow-claw-channels-BDWG5Qa3.js} +1 -1
  74. package/dist/public/{shadow-claw-chat-CCLuhWdI.js → shadow-claw-chat-Crv0DTCY.js} +6 -6
  75. package/dist/public/shadow-claw-control-plane-CX1IGbh9.js +199 -0
  76. package/dist/public/{shadow-claw-conversations-DV-87UUh.js → shadow-claw-conversations-_9qAaRXk.js} +1 -1
  77. package/dist/public/shadow-claw-dialog-BrOwAOdk.js +1 -0
  78. package/dist/public/{shadow-claw-file-viewer-Dk2dbAUd.js → shadow-claw-file-viewer-Blf6yGVD.js} +1 -1
  79. package/dist/public/shadow-claw-files-CYySBDCl.js +840 -0
  80. package/dist/public/{shadow-claw-git-DRA7_qck.js → shadow-claw-git-v8S0JjuR.js} +2 -2
  81. package/dist/public/{shadow-claw-integrations-1Xu5snuV.js → shadow-claw-integrations-D7ufpwhX.js} +1 -1
  82. package/dist/public/{shadow-claw-llm-6Aci1BDP.js → shadow-claw-llm-GWgoVfhI.js} +2 -2
  83. package/dist/public/{shadow-claw-mcp-remote-BUjAeVXC.js → shadow-claw-mcp-remote-kY4LMFNN.js} +1 -1
  84. package/dist/public/{shadow-claw-networking-FQe4nCp3.js → shadow-claw-networking-qHbP95z9.js} +1 -1
  85. package/dist/public/{shadow-claw-notifications-DV2wibL3.js → shadow-claw-notifications-YyHOK2Fr.js} +1 -1
  86. package/dist/public/{shadow-claw-page-header-B63S4HTU.js → shadow-claw-page-header-DMKbkv3s.js} +1 -1
  87. package/dist/public/{shadow-claw-pages-DE3jWj5K.js → shadow-claw-pages-DFZHhzFZ.js} +1 -1
  88. package/dist/public/{shadow-claw-settings-S6DAKfYx.js → shadow-claw-settings-DhxrKIfA.js} +10 -3
  89. package/dist/public/{shadow-claw-storage-DaacA3_K.js → shadow-claw-storage-BFynbP0j.js} +2 -2
  90. package/dist/public/{shadow-claw-task-server-IjKX042p.js → shadow-claw-task-server-3BIeb5oZ.js} +25 -4
  91. package/dist/public/{shadow-claw-tasks-Cy0JYmGU.js → shadow-claw-tasks-Caxc9atL.js} +1 -1
  92. package/dist/public/{shadow-claw-terminal-znAt7HyP.js → shadow-claw-terminal-Beb5qF-U.js} +1 -1
  93. package/dist/public/{shadow-claw-tools-Cutv9Si5.js → shadow-claw-tools-BfJx3A9W.js} +1 -1
  94. package/dist/public/{shadow-claw-webvm-BH8mPDEu.js → shadow-claw-webvm-BCS8ykeP.js} +1 -1
  95. package/dist/public/{syncWebMcpRegistration-BqJxaMGR.js → syncWebMcpRegistration-B_M2fCjH.js} +1 -1
  96. package/dist/server.js +286 -189
  97. package/package.json +2 -1
  98. package/src/__mocks__/jszip.cjs +15 -5
  99. package/src/components/settings/shadow-claw-accounts/shadow-claw-accounts.test.ts +35 -0
  100. package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.css +117 -0
  101. package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.html +81 -0
  102. package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.test.ts +127 -0
  103. package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.ts +292 -0
  104. package/src/components/settings/shadow-claw-git/shadow-claw-git.test.ts +72 -0
  105. package/src/components/settings/shadow-claw-integrations/connection-test-auth.test.ts +20 -30
  106. package/src/components/settings/shadow-claw-integrations/shadow-claw-integrations.test.ts +116 -0
  107. package/src/components/settings/shadow-claw-llm/model-ranking.test.ts +143 -51
  108. package/src/components/settings/shadow-claw-llm/shadow-claw-llm.test.ts +307 -4
  109. package/src/components/settings/shadow-claw-llm/shadow-claw-llm.ts +8 -8
  110. package/src/components/settings/shadow-claw-mcp-remote/shadow-claw-mcp-remote.test.ts +31 -0
  111. package/src/components/settings/shadow-claw-peerjs/shadow-claw-peerjs.test.ts +186 -0
  112. package/src/components/settings/shadow-claw-storage/shadow-claw-storage.test.ts +76 -0
  113. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.css +23 -2
  114. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.html +1 -1
  115. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.test.ts +19 -0
  116. package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.ts +2 -1
  117. package/src/components/shadow-claw/utils/initChatSplitResize.test.ts +180 -13
  118. package/src/components/shadow-claw/utils/setupEffects.test.ts +39 -0
  119. package/src/components/shadow-claw-a2ui/catalog/basic/shared.test.ts +117 -0
  120. package/src/components/shadow-claw-a2ui/shadow-claw-a2ui.test.ts +1159 -1
  121. package/src/components/shadow-claw-chat/shadow-claw-chat.test.ts +1523 -19
  122. package/src/components/shadow-claw-chat/utils/escapeHtml.test.ts +13 -0
  123. package/src/components/shadow-claw-chat/utils/getPeerChatDisplayStatus.test.ts +20 -0
  124. package/src/components/shadow-claw-conversations/shadow-claw-conversations.test.ts +494 -0
  125. package/src/components/shadow-claw-dialog/shadow-claw-dialog.ts +13 -2
  126. package/src/components/shadow-claw-file-viewer/shadow-claw-file-viewer.test.ts +273 -0
  127. package/src/components/shadow-claw-files/shadow-claw-files.test.ts +408 -1
  128. package/src/components/shadow-claw-pdf-viewer/utils/installGetOrInsertComputedPolyfill.test.ts +65 -0
  129. package/src/components/shadow-claw-settings/shadow-claw-settings.html +7 -0
  130. package/src/components/shadow-claw-settings/shadow-claw-settings.test.ts +221 -0
  131. package/src/components/shadow-claw-settings/shadow-claw-settings.ts +2 -0
  132. package/src/components/shadow-claw-tasks/shadow-claw-tasks.test.ts +407 -0
  133. package/src/components/shadow-claw-terminal/utils/consumeEscapeSequence.test.ts +58 -0
  134. package/src/components/shadow-claw-tools/shadow-claw-tools.test.ts +80 -0
  135. package/src/config/config.ts +10 -0
  136. package/src/core/backup-controller.test.ts +101 -0
  137. package/src/core/backup-controller.ts +137 -0
  138. package/src/core/control-plane-client.test.ts +408 -0
  139. package/src/core/control-plane-client.ts +425 -0
  140. package/src/core/orchestrator/orchestrator.ts +4 -0
  141. package/src/core/orchestrator/utils/compactContext.test.ts +78 -0
  142. package/src/core/orchestrator/utils/operations/channel.test.ts +254 -9
  143. package/src/core/orchestrator/utils/operations/provider.test.ts +326 -34
  144. package/src/core/orchestrator/utils/operations/vm.test.ts +114 -2
  145. package/src/core/orchestrator/utils/syncProxyConfigToServiceWorker.test.ts +60 -0
  146. package/src/core/utils/initControlPlane.test.ts +276 -0
  147. package/src/core/utils/initControlPlane.ts +437 -0
  148. package/src/core/utils/initializeApp.ts +18 -0
  149. package/src/db/getSession.test.ts +13 -1
  150. package/src/server/app.test.ts +12 -0
  151. package/src/server/app.ts +18 -8
  152. package/src/server/client-registry.test.ts +250 -0
  153. package/src/server/client-registry.ts +477 -0
  154. package/src/server/config.ts +15 -0
  155. package/src/server/control-plane-types.ts +120 -0
  156. package/src/server/control-plane.test.ts +472 -0
  157. package/src/server/control-plane.ts +581 -0
  158. package/src/server/routes/backup.test.ts +263 -0
  159. package/src/server/routes/backup.ts +262 -0
  160. package/src/server/routes/integrations-email.test.ts +532 -0
  161. package/src/server/routes/llamafile.test.ts +172 -48
  162. package/src/server/routes/mesh-llm.test.ts +192 -0
  163. package/src/server/routes/ollama.test.ts +49 -0
  164. package/src/server/server-peer.test.ts +86 -0
  165. package/src/server/server-peer.ts +330 -0
  166. package/src/server/server.ts +35 -0
  167. package/src/server/services/llamafile-manager.test.ts +230 -6
  168. package/src/server/services/transformers-runtime.test.ts +186 -18
  169. package/src/server/webrtc.test.ts +21 -0
  170. package/src/server/webrtc.ts +72 -0
  171. package/src/service-worker/fetch-proxy.test.ts +64 -1
  172. package/src/service-worker/push-handler.ts +39 -0
  173. package/src/shell/vm.test.ts +744 -12
  174. package/src/storage/ensureMainGroupIndex.test.ts +153 -0
  175. package/src/storage/getGroupFile.test.ts +66 -0
  176. package/src/storage/memoryStorage.test.ts +82 -2
  177. package/src/storage/writeGroupFileBytes.test.ts +143 -0
  178. package/src/stores/chat-ui.test.ts +20 -0
  179. package/src/stores/orchestrator.test.ts +134 -0
  180. package/src/stores/toast.test.ts +50 -0
  181. package/src/stores/tools.test.ts +147 -10
  182. package/src/subsystems/channels/imessage.test.ts +59 -0
  183. package/src/subsystems/channels/peerjs.test.ts +500 -62
  184. package/src/subsystems/channels/peerjs.ts +71 -4
  185. package/src/subsystems/channels/telegram.test.ts +177 -0
  186. package/src/subsystems/email/catalog.test.ts +1 -0
  187. package/src/subsystems/email/connections.test.ts +39 -1
  188. package/src/subsystems/git/credentials.test.ts +160 -825
  189. package/src/subsystems/git/git.test.ts +463 -0
  190. package/src/subsystems/notifications/__mocks__/node-sqlite.cjs +12 -1
  191. package/src/subsystems/notifications/push-routes.test.ts +33 -0
  192. package/src/subsystems/notifications/push-routes.ts +23 -0
  193. package/src/subsystems/notifications/push-store.ts +1 -1
  194. package/src/subsystems/providers/builtin-ai-tasks.test.ts +74 -0
  195. package/src/subsystems/providers/litert-lm-provider.test.ts +212 -51
  196. package/src/subsystems/providers/model-registry.test.ts +115 -0
  197. package/src/subsystems/providers/providers.test.ts +94 -1
  198. package/src/subsystems/providers/transformers-js-provider.test.ts +279 -0
  199. package/src/subsystems/providers/utils/createModelCacheFetch.test.ts +2 -2
  200. package/src/subsystems/providers/utils/downloadModelToCache.test.ts +2 -2
  201. package/src/subsystems/providers/utils/loadModelStream.test.ts +2 -2
  202. package/src/subsystems/skills/activateSkill.test.ts +101 -0
  203. package/src/subsystems/skills/discoverSkills.test.ts +251 -0
  204. package/src/subsystems/tools/cron.test.ts +64 -0
  205. package/src/subsystems/tools/declarative.test.ts +255 -89
  206. package/src/testing/assets/v86.9pfs/libv86.mjs +63 -0
  207. package/src/testing/assets/v86.ext2/libv86.mjs +3 -0
  208. package/src/testing/assets_missing_v86/assets/v86.9pfs/libv86.mjs +1 -0
  209. package/src/testing/jest-setup.ts +328 -40
  210. package/src/testing/mock-v86.mjs +49 -0
  211. package/src/ui/a2ui/types.ts +4 -3
  212. package/src/ui/a2ui/utils/normaliseComponentsToMap.test.ts +102 -0
  213. package/src/ui/a2ui/utils/resolveChildIds.test.ts +63 -0
  214. package/src/ui/a2ui/utils/resolveDynamicNumber.test.ts +13 -1
  215. package/src/worker/tools/bash/utils/waitForVMReady.test.ts +40 -0
  216. package/src/worker/tools/email/email.test.ts +193 -8
  217. package/src/worker/tools/email/utils/asEmailAttachmentInputs.test.ts +47 -0
  218. package/src/worker/tools/git/git.test.ts +214 -3
  219. package/src/worker/tools/rooms/invite-to-room.test.ts +54 -0
  220. package/src/worker/tools/rooms/leave-room.test.ts +40 -0
  221. package/src/worker/tools/rooms/list-room-members.test.ts +75 -0
  222. package/src/worker/tools/tasks/tasks-utils.test.ts +22 -0
  223. package/src/worker/tools/ui/ask-user.test.ts +50 -0
  224. package/src/worker/tools/ui/clear-chat.test.ts +19 -0
  225. package/src/worker/tools/ui/get-current-time.test.ts +19 -0
  226. package/src/worker/tools/ui/javascript.test.ts +116 -0
  227. package/src/worker/tools/ui/list-components.test.ts +10 -0
  228. package/src/worker/tools/ui/list-tool-profiles.test.ts +42 -0
  229. package/src/worker/tools/ui/manage-tools.test.ts +34 -0
  230. package/src/worker/tools/ui/render-component.test.ts +346 -0
  231. package/src/worker/tools/ui/send-notification.test.ts +40 -0
  232. package/src/worker/tools/ui/utils/getAllowFullInternetAccess.test.ts +31 -0
  233. package/src/worker/tools/ui/utils/parseBooleanConfig.test.ts +25 -0
  234. package/src/worker/tools/workspace/attach-file.test.ts +84 -0
  235. package/src/worker/tools/workspace/copy-file.test.ts +69 -0
  236. package/src/worker/tools/workspace/create-directory.test.ts +40 -0
  237. package/src/worker/tools/workspace/delete-file.test.ts +73 -0
  238. package/src/worker/tools/workspace/diff-files.test.ts +94 -0
  239. package/src/worker/tools/workspace/list-files.test.ts +36 -0
  240. package/src/worker/tools/workspace/move-file.test.ts +66 -0
  241. package/src/worker/tools/workspace/open-file.test.ts +57 -0
  242. package/src/worker/tools/workspace/patch-file.test.ts +81 -0
  243. package/src/worker/tools/workspace/read-file.test.ts +116 -0
  244. package/src/worker/tools/workspace/send-file.test.ts +82 -0
  245. package/src/worker/tools/workspace/update-memory.test.ts +29 -0
  246. package/src/worker/tools/workspace/write-file.test.ts +28 -0
  247. package/src/worker/utils/rate-limit.test.ts +199 -0
  248. package/src/worker/utils/tool-state.test.ts +29 -0
  249. package/src/worker/utils/transformers-js-utils.test.ts +59 -0
  250. package/dist/public/config-BYAuxx3j.js +0 -1
  251. package/dist/public/downloadGroupFile-Drdvg1Ev.js +0 -1
  252. package/dist/public/file-viewer-CcZYw-PS.js +0 -1
  253. package/dist/public/orchestrator-o9Fj67BN.js +0 -15
  254. package/dist/public/shadow-claw-dialog-ByyHgxM9.js +0 -1
  255. package/dist/public/shadow-claw-files-BpfpJIuP.js +0 -840
@@ -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
- return {
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
  });