@isothrum/editor 0.0.0-stage → 0.1.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 (293) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +8 -0
  3. package/PUBLISHING.md +93 -0
  4. package/README.md +106 -2
  5. package/desktop-editor/editor.css +11 -0
  6. package/desktop-editor/index.html +14 -0
  7. package/desktop-editor/main.tsx +26 -0
  8. package/dist/desktop/editor.css +1 -0
  9. package/dist/desktop/editor.js +181 -0
  10. package/dist/desktop/fonts/geist-cyrillic-ext-wght-normal-RO72NYA2.woff2 +0 -0
  11. package/dist/desktop/fonts/geist-cyrillic-wght-normal-CK2XY27K.woff2 +0 -0
  12. package/dist/desktop/fonts/geist-latin-ext-wght-normal-XEQ56JUF.woff2 +0 -0
  13. package/dist/desktop/fonts/geist-latin-wght-normal-T72VVCUW.woff2 +0 -0
  14. package/dist/desktop/fonts/geist-mono-cyrillic-ext-wght-normal-QXKVIKFD.woff2 +0 -0
  15. package/dist/desktop/fonts/geist-mono-cyrillic-wght-normal-GOIAQAGU.woff2 +0 -0
  16. package/dist/desktop/fonts/geist-mono-latin-ext-wght-normal-HU3ZAWN6.woff2 +0 -0
  17. package/dist/desktop/fonts/geist-mono-latin-wght-normal-CJNQKXUG.woff2 +0 -0
  18. package/dist/desktop/fonts/geist-mono-symbols2-wght-normal-WZJJVLQO.woff2 +0 -0
  19. package/dist/desktop/fonts/geist-mono-vietnamese-wght-normal-KW5NM6YV.woff2 +0 -0
  20. package/dist/desktop/fonts/geist-vietnamese-wght-normal-Y2R7JZKV.woff2 +0 -0
  21. package/dist/desktop/fonts/shantell-sans-cyrillic-ext-wght-normal-ILPQWRJJ.woff2 +0 -0
  22. package/dist/desktop/fonts/shantell-sans-cyrillic-wght-normal-RKC6NDPM.woff2 +0 -0
  23. package/dist/desktop/fonts/shantell-sans-latin-ext-wght-normal-SNKHG3LM.woff2 +0 -0
  24. package/dist/desktop/fonts/shantell-sans-latin-wght-normal-7CHHGC3N.woff2 +0 -0
  25. package/dist/desktop/fonts/shantell-sans-vietnamese-wght-normal-IPFAQSBA.woff2 +0 -0
  26. package/dist/desktop/index.html +14 -0
  27. package/dist/desktop/styles.css +2 -0
  28. package/dist/desktop/wasm/isothrum_wasm.d.ts +60 -0
  29. package/dist/desktop/wasm/isothrum_wasm.js +312 -0
  30. package/dist/desktop/wasm/isothrum_wasm_bg.wasm +0 -0
  31. package/dist/index.js +7059 -0
  32. package/dist/index.js.map +7 -0
  33. package/dist/model.js +745 -0
  34. package/dist/model.js.map +7 -0
  35. package/dist/types/components/tool/canvas/cable-edge.d.ts +4 -0
  36. package/dist/types/components/tool/canvas/contexts.d.ts +12 -0
  37. package/dist/types/components/tool/canvas/device-icon.d.ts +17 -0
  38. package/dist/types/components/tool/canvas/inline-text.d.ts +15 -0
  39. package/dist/types/components/tool/canvas/lan-node.d.ts +8 -0
  40. package/dist/types/components/tool/canvas/machine-addresses.d.ts +14 -0
  41. package/dist/types/components/tool/canvas/machine-node.d.ts +4 -0
  42. package/dist/types/components/tool/canvas/note-node.d.ts +7 -0
  43. package/dist/types/components/tool/canvas/port-led.d.ts +10 -0
  44. package/dist/types/components/tool/canvas/router-node.d.ts +8 -0
  45. package/dist/types/components/tool/canvas/sketch.d.ts +50 -0
  46. package/dist/types/components/tool/canvas/status-dot.d.ts +5 -0
  47. package/dist/types/components/tool/canvas/switch-node.d.ts +8 -0
  48. package/dist/types/components/tool/canvas/use-measured-ports.d.ts +7 -0
  49. package/dist/types/components/tool/context-menus.d.ts +5 -0
  50. package/dist/types/components/tool/device-block.d.ts +12 -0
  51. package/dist/types/components/tool/editor-menu.d.ts +28 -0
  52. package/dist/types/components/tool/export/copy-button.d.ts +9 -0
  53. package/dist/types/components/tool/export/export-panel.d.ts +17 -0
  54. package/dist/types/components/tool/export/target-list.d.ts +15 -0
  55. package/dist/types/components/tool/floating-window.d.ts +13 -0
  56. package/dist/types/components/tool/flow-canvas.d.ts +6 -0
  57. package/dist/types/components/tool/hooks/use-cabling.d.ts +23 -0
  58. package/dist/types/components/tool/hooks/use-captures.d.ts +9 -0
  59. package/dist/types/components/tool/hooks/use-clipboard.d.ts +12 -0
  60. package/dist/types/components/tool/hooks/use-context-menus.d.ts +25 -0
  61. package/dist/types/components/tool/hooks/use-design.d.ts +10 -0
  62. package/dist/types/components/tool/hooks/use-desktop.d.ts +50 -0
  63. package/dist/types/components/tool/hooks/use-devices.d.ts +17 -0
  64. package/dist/types/components/tool/hooks/use-downloads.d.ts +9 -0
  65. package/dist/types/components/tool/hooks/use-editor.d.ts +609 -0
  66. package/dist/types/components/tool/hooks/use-engine.d.ts +9 -0
  67. package/dist/types/components/tool/hooks/use-history.d.ts +9 -0
  68. package/dist/types/components/tool/hooks/use-lab-actions.d.ts +25 -0
  69. package/dist/types/components/tool/hooks/use-lab-run.d.ts +25 -0
  70. package/dist/types/components/tool/hooks/use-lab.d.ts +18 -0
  71. package/dist/types/components/tool/hooks/use-lan-carry.d.ts +8 -0
  72. package/dist/types/components/tool/hooks/use-machine-actions.d.ts +26 -0
  73. package/dist/types/components/tool/hooks/use-placement.d.ts +27 -0
  74. package/dist/types/components/tool/hooks/use-project.d.ts +25 -0
  75. package/dist/types/components/tool/hooks/use-projects.d.ts +34 -0
  76. package/dist/types/components/tool/hooks/use-shared-link.d.ts +23 -0
  77. package/dist/types/components/tool/hooks/use-toast.d.ts +2 -0
  78. package/dist/types/components/tool/hooks/use-tools.d.ts +17 -0
  79. package/dist/types/components/tool/lab-canvas.d.ts +18 -0
  80. package/dist/types/components/tool/overlays.d.ts +5 -0
  81. package/dist/types/components/tool/projects-list.d.ts +9 -0
  82. package/dist/types/components/tool/text.d.ts +418 -0
  83. package/dist/types/components/tool/toolbar.d.ts +5 -0
  84. package/dist/types/components/tool/top-bars.d.ts +10 -0
  85. package/dist/types/components/tool/windows/conditions-fields.d.ts +6 -0
  86. package/dist/types/components/tool/windows/editor-windows.d.ts +5 -0
  87. package/dist/types/components/tool/windows/fields.d.ts +4 -0
  88. package/dist/types/components/tool/windows/lab-status.d.ts +6 -0
  89. package/dist/types/components/tool/windows/lab-windows.d.ts +13 -0
  90. package/dist/types/components/tool/windows/link-controls.d.ts +21 -0
  91. package/dist/types/components/tool/windows/machine-controls.d.ts +16 -0
  92. package/dist/types/components/tool/windows/machine-inspector.d.ts +11 -0
  93. package/dist/types/components/tool/windows/runs-fields.d.ts +18 -0
  94. package/dist/types/components/tool/windows/selected-windows.d.ts +21 -0
  95. package/dist/types/components/ui/button.d.ts +8 -0
  96. package/dist/types/components/ui/dropdown-menu.d.ts +21 -0
  97. package/dist/types/index.d.ts +7 -0
  98. package/dist/types/lib/auth.d.ts +38 -0
  99. package/dist/types/lib/brand.d.ts +19 -0
  100. package/dist/types/lib/i18n.d.ts +19 -0
  101. package/dist/types/lib/tool/blocks.d.ts +35 -0
  102. package/dist/types/lib/tool/cables.d.ts +48 -0
  103. package/dist/types/lib/tool/clone.d.ts +12 -0
  104. package/dist/types/lib/tool/conditions.d.ts +31 -0
  105. package/dist/types/lib/tool/create.d.ts +22 -0
  106. package/dist/types/lib/tool/desktop.d.ts +63 -0
  107. package/dist/types/lib/tool/draft.d.ts +20 -0
  108. package/dist/types/lib/tool/engine.d.ts +33 -0
  109. package/dist/types/lib/tool/examples.d.ts +11 -0
  110. package/dist/types/lib/tool/export-targets.d.ts +21 -0
  111. package/dist/types/lib/tool/facing.d.ts +9 -0
  112. package/dist/types/lib/tool/geometry.d.ts +36 -0
  113. package/dist/types/lib/tool/gns3-cables.d.ts +17 -0
  114. package/dist/types/lib/tool/gns3-import.d.ts +15 -0
  115. package/dist/types/lib/tool/gns3-parts.d.ts +25 -0
  116. package/dist/types/lib/tool/issues.d.ts +12 -0
  117. package/dist/types/lib/tool/layout.d.ts +52 -0
  118. package/dist/types/lib/tool/live.d.ts +21 -0
  119. package/dist/types/lib/tool/logos.d.ts +7 -0
  120. package/dist/types/lib/tool/model.d.ts +173 -0
  121. package/dist/types/lib/tool/os.d.ts +20 -0
  122. package/dist/types/lib/tool/pins.d.ts +5 -0
  123. package/dist/types/lib/tool/placement.d.ts +21 -0
  124. package/dist/types/lib/tool/ports.d.ts +21 -0
  125. package/dist/types/lib/tool/presets.d.ts +21 -0
  126. package/dist/types/lib/tool/projects-api.d.ts +37 -0
  127. package/dist/types/lib/tool/router-ports.d.ts +22 -0
  128. package/dist/types/lib/tool/selection.d.ts +19 -0
  129. package/dist/types/lib/tool/storage.d.ts +25 -0
  130. package/dist/types/lib/tool/switch-edit.d.ts +17 -0
  131. package/dist/types/lib/tool/text/de.d.ts +2 -0
  132. package/dist/types/lib/tool/text/en.d.ts +429 -0
  133. package/dist/types/lib/tool/text/es.d.ts +2 -0
  134. package/dist/types/lib/tool/text/fr.d.ts +2 -0
  135. package/dist/types/lib/tool/text/index.d.ts +419 -0
  136. package/dist/types/lib/tool/topology.d.ts +2 -0
  137. package/dist/types/lib/tool/vlans.d.ts +24 -0
  138. package/dist/types/lib/utils.d.ts +2 -0
  139. package/dist/types/model.d.ts +13 -0
  140. package/package.json +88 -4
  141. package/scripts/build-desktop-editor.mjs +43 -0
  142. package/scripts/build-lib.mjs +38 -0
  143. package/src/components/tool/canvas/cable-edge.tsx +112 -0
  144. package/src/components/tool/canvas/contexts.ts +18 -0
  145. package/src/components/tool/canvas/device-icon.tsx +105 -0
  146. package/src/components/tool/canvas/inline-text.tsx +69 -0
  147. package/src/components/tool/canvas/lan-node.tsx +148 -0
  148. package/src/components/tool/canvas/machine-addresses.tsx +117 -0
  149. package/src/components/tool/canvas/machine-node.tsx +114 -0
  150. package/src/components/tool/canvas/note-node.tsx +109 -0
  151. package/src/components/tool/canvas/port-led.tsx +21 -0
  152. package/src/components/tool/canvas/router-node.tsx +114 -0
  153. package/src/components/tool/canvas/sketch.tsx +130 -0
  154. package/src/components/tool/canvas/status-dot.tsx +29 -0
  155. package/src/components/tool/canvas/switch-node.tsx +143 -0
  156. package/src/components/tool/canvas/use-measured-ports.ts +25 -0
  157. package/src/components/tool/context-menus.tsx +142 -0
  158. package/src/components/tool/device-block.tsx +107 -0
  159. package/src/components/tool/editor-menu.tsx +191 -0
  160. package/src/components/tool/export/copy-button.tsx +45 -0
  161. package/src/components/tool/export/export-panel.tsx +127 -0
  162. package/src/components/tool/export/target-list.tsx +87 -0
  163. package/src/components/tool/floating-window.tsx +69 -0
  164. package/src/components/tool/flow-canvas.tsx +138 -0
  165. package/src/components/tool/hooks/use-cabling.ts +66 -0
  166. package/src/components/tool/hooks/use-captures.ts +24 -0
  167. package/src/components/tool/hooks/use-clipboard.ts +54 -0
  168. package/src/components/tool/hooks/use-context-menus.ts +37 -0
  169. package/src/components/tool/hooks/use-design.ts +24 -0
  170. package/src/components/tool/hooks/use-desktop.ts +100 -0
  171. package/src/components/tool/hooks/use-devices.ts +58 -0
  172. package/src/components/tool/hooks/use-downloads.ts +42 -0
  173. package/src/components/tool/hooks/use-editor.ts +114 -0
  174. package/src/components/tool/hooks/use-engine.ts +29 -0
  175. package/src/components/tool/hooks/use-history.ts +107 -0
  176. package/src/components/tool/hooks/use-lab-actions.ts +57 -0
  177. package/src/components/tool/hooks/use-lab-run.ts +61 -0
  178. package/src/components/tool/hooks/use-lab.ts +32 -0
  179. package/src/components/tool/hooks/use-lan-carry.ts +43 -0
  180. package/src/components/tool/hooks/use-machine-actions.ts +57 -0
  181. package/src/components/tool/hooks/use-placement.ts +80 -0
  182. package/src/components/tool/hooks/use-project.ts +112 -0
  183. package/src/components/tool/hooks/use-projects.ts +85 -0
  184. package/src/components/tool/hooks/use-shared-link.ts +64 -0
  185. package/src/components/tool/hooks/use-toast.ts +16 -0
  186. package/src/components/tool/hooks/use-tools.ts +60 -0
  187. package/src/components/tool/lab-canvas.tsx +96 -0
  188. package/src/components/tool/overlays.tsx +104 -0
  189. package/src/components/tool/projects-list.tsx +96 -0
  190. package/src/components/tool/text.ts +9 -0
  191. package/src/components/tool/toolbar.tsx +106 -0
  192. package/src/components/tool/top-bars.tsx +90 -0
  193. package/src/components/tool/windows/conditions-fields.tsx +43 -0
  194. package/src/components/tool/windows/editor-windows.tsx +73 -0
  195. package/src/components/tool/windows/fields.ts +6 -0
  196. package/src/components/tool/windows/lab-status.tsx +68 -0
  197. package/src/components/tool/windows/lab-windows.tsx +130 -0
  198. package/src/components/tool/windows/link-controls.tsx +69 -0
  199. package/src/components/tool/windows/machine-controls.tsx +116 -0
  200. package/src/components/tool/windows/machine-inspector.tsx +83 -0
  201. package/src/components/tool/windows/runs-fields.tsx +127 -0
  202. package/src/components/tool/windows/selected-windows.tsx +147 -0
  203. package/src/components/ui/button.tsx +59 -0
  204. package/src/components/ui/dropdown-menu.tsx +185 -0
  205. package/src/index.ts +21 -0
  206. package/src/lib/auth.tsx +43 -0
  207. package/src/lib/brand.tsx +46 -0
  208. package/src/lib/i18n.ts +53 -0
  209. package/src/lib/tool/blocks.test.ts +64 -0
  210. package/src/lib/tool/blocks.ts +78 -0
  211. package/src/lib/tool/cable-wiring.test.ts +74 -0
  212. package/src/lib/tool/cables.test.ts +75 -0
  213. package/src/lib/tool/cables.ts +215 -0
  214. package/src/lib/tool/clone.test.ts +54 -0
  215. package/src/lib/tool/clone.ts +48 -0
  216. package/src/lib/tool/conditions.test.ts +170 -0
  217. package/src/lib/tool/conditions.ts +132 -0
  218. package/src/lib/tool/create.test.ts +67 -0
  219. package/src/lib/tool/create.ts +83 -0
  220. package/src/lib/tool/desktop.test.ts +59 -0
  221. package/src/lib/tool/desktop.ts +99 -0
  222. package/src/lib/tool/draft.test.ts +150 -0
  223. package/src/lib/tool/draft.ts +109 -0
  224. package/src/lib/tool/engine.test.ts +39 -0
  225. package/src/lib/tool/engine.ts +58 -0
  226. package/src/lib/tool/examples.test.ts +35 -0
  227. package/src/lib/tool/examples.ts +71 -0
  228. package/src/lib/tool/export-targets.test.ts +47 -0
  229. package/src/lib/tool/export-targets.ts +84 -0
  230. package/src/lib/tool/facing.test.ts +72 -0
  231. package/src/lib/tool/facing.ts +100 -0
  232. package/src/lib/tool/geometry.test.ts +80 -0
  233. package/src/lib/tool/geometry.ts +109 -0
  234. package/src/lib/tool/gns3-cables.test.ts +47 -0
  235. package/src/lib/tool/gns3-cables.ts +81 -0
  236. package/src/lib/tool/gns3-import.test.ts +102 -0
  237. package/src/lib/tool/gns3-import.ts +198 -0
  238. package/src/lib/tool/gns3-layout.test.ts +62 -0
  239. package/src/lib/tool/gns3-parts.test.ts +47 -0
  240. package/src/lib/tool/gns3-parts.ts +68 -0
  241. package/src/lib/tool/issues.test.ts +35 -0
  242. package/src/lib/tool/issues.ts +60 -0
  243. package/src/lib/tool/layout.test.ts +128 -0
  244. package/src/lib/tool/layout.ts +229 -0
  245. package/src/lib/tool/live.test.ts +46 -0
  246. package/src/lib/tool/live.ts +49 -0
  247. package/src/lib/tool/logos.test.ts +30 -0
  248. package/src/lib/tool/logos.ts +99 -0
  249. package/src/lib/tool/model.test.ts +128 -0
  250. package/src/lib/tool/model.ts +238 -0
  251. package/src/lib/tool/os.test.ts +45 -0
  252. package/src/lib/tool/os.ts +57 -0
  253. package/src/lib/tool/parity.test.ts +145 -0
  254. package/src/lib/tool/pins.test.ts +24 -0
  255. package/src/lib/tool/pins.ts +11 -0
  256. package/src/lib/tool/placement.test.ts +79 -0
  257. package/src/lib/tool/placement.ts +60 -0
  258. package/src/lib/tool/ports.test.ts +39 -0
  259. package/src/lib/tool/ports.ts +48 -0
  260. package/src/lib/tool/presets.test.ts +50 -0
  261. package/src/lib/tool/presets.ts +77 -0
  262. package/src/lib/tool/projects-api.test.ts +59 -0
  263. package/src/lib/tool/projects-api.ts +47 -0
  264. package/src/lib/tool/router-ports.test.ts +63 -0
  265. package/src/lib/tool/router-ports.ts +88 -0
  266. package/src/lib/tool/selection.test.ts +44 -0
  267. package/src/lib/tool/selection.ts +34 -0
  268. package/src/lib/tool/storage.test.ts +123 -0
  269. package/src/lib/tool/storage.ts +127 -0
  270. package/src/lib/tool/switch-edit.test.ts +49 -0
  271. package/src/lib/tool/switch-edit.ts +38 -0
  272. package/src/lib/tool/text/de.ts +358 -0
  273. package/src/lib/tool/text/en.ts +371 -0
  274. package/src/lib/tool/text/es.ts +358 -0
  275. package/src/lib/tool/text/fr.ts +358 -0
  276. package/src/lib/tool/text/index.ts +21 -0
  277. package/src/lib/tool/text/text.test.ts +33 -0
  278. package/src/lib/tool/topology.test.ts +108 -0
  279. package/src/lib/tool/topology.ts +116 -0
  280. package/src/lib/tool/vlans.test.ts +64 -0
  281. package/src/lib/tool/vlans.ts +79 -0
  282. package/src/lib/utils.ts +6 -0
  283. package/src/model.ts +13 -0
  284. package/src/test/arbitrary.ts +41 -0
  285. package/src/test/engine.ts +48 -0
  286. package/src/test/lab.ts +101 -0
  287. package/styles/globals.css +258 -0
  288. package/styles/theme.css +192 -0
  289. package/tsconfig.build.json +12 -0
  290. package/tsconfig.json +22 -0
  291. package/wasm/isothrum_wasm.d.ts +60 -0
  292. package/wasm/isothrum_wasm.js +312 -0
  293. package/wasm/isothrum_wasm_bg.wasm +0 -0
@@ -0,0 +1,67 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { lanBelow, newLan, newMachine, newNote, newSwitch, twoVlans, usedThirds } from "./create";
3
+ import { LAN_BEHAVIOR, LAN_H, LAN_W } from "./model";
4
+ import { PRESETS, type Preset } from "./presets";
5
+ import { lan, machine } from "@/test/lab";
6
+
7
+ /** The built-in device with this id. */
8
+ const preset = (id: string): Preset => {
9
+ const p = PRESETS.find((x) => x.id === id);
10
+ if (!p) throw new Error(`no preset ${id}`);
11
+ return p;
12
+ };
13
+
14
+ describe("newMachine", () => {
15
+ test("names a machine after its device, with the first free number", () => {
16
+ expect(newMachine(preset("web"), { x: 0, y: 0 }, [machine("a", { name: "web1" })]).data.name).toBe("web2");
17
+ });
18
+
19
+ test("gives a VM a provisioning script of its own: a shell script, PowerShell on Windows", () => {
20
+ expect(newMachine(preset("linux-vm"), { x: 0, y: 0 }, []).data.provision).toBe("provision/vm1.sh");
21
+ expect(newMachine(preset("windows-vm"), { x: 0, y: 0 }, []).data.provision).toBe("provision/win1.ps1");
22
+ });
23
+
24
+ test("gives the access VM and containers no provisioning", () => {
25
+ expect(newMachine(preset("workstation"), { x: 0, y: 0 }, []).data.provision).toBe("");
26
+ expect(newMachine(preset("container"), { x: 0, y: 0 }, []).data.provision).toBe("");
27
+ });
28
+
29
+ test("places the machine where it is told, under a new id", () => {
30
+ const m = newMachine(preset("web"), { x: 5, y: 7 }, []);
31
+ expect(m.position).toEqual({ x: 5, y: 7 });
32
+ expect(m.id).toMatch(/^m-/);
33
+ });
34
+ });
35
+
36
+ describe("LANs", () => {
37
+ test("usedThirds reads the third number of each LAN's 10.10.<n>.0/24", () => {
38
+ expect(usedThirds([lan("a", { cidr: "10.10.3.0/24" }), lan("b", { cidr: "192.168.1.0/24" }), machine("m")])).toEqual(new Set([3]));
39
+ });
40
+
41
+ test("a new LAN takes the next free block and name, centered where it is placed", () => {
42
+ const l = newLan({ x: 500, y: 400 }, [lan("lan1", { cidr: "10.10.1.0/24" }), lan("x", { name: "x", cidr: "10.10.3.0/24" })]);
43
+ expect(l.data).toEqual({ name: "lan2", cidr: "10.10.2.0/24", internet: true });
44
+ expect(l).toMatchObject({ ...LAN_BEHAVIOR, position: { x: 500 - LAN_W / 2, y: 400 - LAN_H / 2 }, width: LAN_W, height: LAN_H });
45
+ });
46
+
47
+ test("a LAN below a device puts the device on its top edge, toward the right", () => {
48
+ const l = lanBelow({ x: 1000, y: 100 }, [], { name: "vlan10" }, { w: 600, h: 300 });
49
+ expect(l).toMatchObject({ position: { x: 1000 - 600 * 0.74, y: 100 }, width: 600, height: 300, data: { name: "vlan10", cidr: "10.10.1.0/24" } });
50
+ });
51
+ });
52
+
53
+ describe("switches and notes", () => {
54
+ test("a new switch has vlan10 on ports 1–4 and vlan20 on ports 5–8", () => {
55
+ expect(twoVlans().ports).toEqual([10, 10, 10, 10, 20, 20, 20, 20]);
56
+ expect(twoVlans().vlans.map((v) => v.cidr)).toEqual(["10.10.10.0/24", "10.10.20.0/24"]);
57
+ });
58
+
59
+ test("a new switch is centered on the point and takes the next free name", () => {
60
+ const sw = newSwitch({ x: 200, y: 100 }, [machine("a", { name: "sw1" })]);
61
+ expect(sw).toMatchObject({ type: "switch", position: { x: 30, y: 60 }, data: { name: "sw2" } });
62
+ });
63
+
64
+ test("a new note is empty text, selected, at the point", () => {
65
+ expect(newNote({ x: 1, y: 2 })).toMatchObject({ type: "note", position: { x: 1, y: 2 }, selected: true, data: { text: "", shape: "text", color: "yellow" } });
66
+ });
67
+ });
@@ -0,0 +1,83 @@
1
+ import type { XYPosition } from "@xyflow/react";
2
+ import { LAN_BEHAVIOR, LAN_H, LAN_W, freeName, isLan, uid, type LabNode, type LanData, type LanNode, type MachineData, type MachineNode, type NoteNode, type SwitchNode } from "./model";
3
+ import type { Preset } from "./presets";
4
+
5
+ /** New nodes, as the toolbar places them. */
6
+
7
+ export function newNote(position: XYPosition): NoteNode {
8
+ return { id: uid("n"), type: "note", position, width: 200, height: 90, selected: true, data: { text: "", shape: "text", color: "yellow" } };
9
+ }
10
+
11
+ /** vlan10 and vlan20: a new switch's VLANs (ports 1–4 and 5–8, both on the trunk). */
12
+ export function twoVlans(): SwitchNode["data"] {
13
+ return {
14
+ name: "",
15
+ vlans: [
16
+ { id: 10, cidr: "10.10.10.0/24" },
17
+ { id: 20, cidr: "10.10.20.0/24" },
18
+ ],
19
+ ports: [10, 10, 10, 10, 20, 20, 20, 20],
20
+ };
21
+ }
22
+
23
+ /** A new switch with vlan10 on ports 1–4, vlan20 on ports 5–8, both on the trunk. */
24
+ export function newSwitch(center: XYPosition, nodes: LabNode[]): SwitchNode {
25
+ return {
26
+ id: uid("s"),
27
+ type: "switch",
28
+ position: { x: center.x - 170, y: center.y - 40 },
29
+ data: { ...twoVlans(), name: freeName("sw", nodes) },
30
+ };
31
+ }
32
+
33
+ export function newMachine(preset: Preset, position: XYPosition, nodes: LabNode[]): MachineNode {
34
+ const name = freeName(preset.prefix, nodes);
35
+ const data: MachineData = { ...preset.data, name };
36
+ // Isoloom needs a VM's provisioning steps, except on the access machine (the runner supplies it).
37
+ if (data.kind === "vm" && !data.access && !data.provision) {
38
+ data.provision = `provision/${name}.${data.os.startsWith("windows") ? "ps1" : "sh"}`;
39
+ }
40
+ return { id: uid("m"), type: "machine", position, data };
41
+ }
42
+
43
+ /** The third number of each LAN's 10.10.<n>.0/24 block. */
44
+ export function usedThirds(nodes: LabNode[]): Set<number> {
45
+ return new Set(
46
+ nodes
47
+ .filter(isLan)
48
+ .map((n) => /^10\.10\.(\d+)\./.exec(n.data.cidr)?.[1])
49
+ .filter(Boolean)
50
+ .map(Number),
51
+ );
52
+ }
53
+
54
+ /** A new LAN centered on `center`, with the next free 10.10.<n>.0/24. */
55
+ export function newLan(center: XYPosition, nodes: LabNode[]): LanNode {
56
+ const used = usedThirds(nodes);
57
+ let third = 1;
58
+ while (used.has(third)) third++;
59
+ return {
60
+ ...LAN_BEHAVIOR,
61
+ id: uid("l"),
62
+ type: "lan",
63
+ position: { x: center.x - LAN_W / 2, y: center.y - LAN_H / 2 },
64
+ width: LAN_W,
65
+ height: LAN_H,
66
+ data: { name: freeName("lan", nodes), cidr: `10.10.${String(third)}.0/24`, internet: true },
67
+ };
68
+ }
69
+
70
+ /**
71
+ * A LAN that hangs under a device placed at `center` (a router or a switch): the device sits on
72
+ * its top edge, toward the right, so the LAN's label (top left) stays clear and clickable.
73
+ */
74
+ export function lanBelow(center: XYPosition, nodes: LabNode[], data?: Partial<LanData>, size = { w: LAN_W, h: LAN_H }): LanNode {
75
+ const lan = newLan({ x: center.x, y: center.y + size.h / 2 }, nodes);
76
+ return {
77
+ ...lan,
78
+ width: size.w,
79
+ height: size.h,
80
+ position: { x: center.x - size.w * 0.74, y: center.y },
81
+ data: { ...lan.data, ...data },
82
+ };
83
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import { type Invoke, desktop, machineStatus, runningServices, runsOnVms } from "./desktop";
3
+
4
+ describe("desktop", () => {
5
+ test("has no commands in a browser, or without a window", () => {
6
+ expect(desktop()).toBeNull();
7
+ vi.stubGlobal("window", {});
8
+ expect(desktop()).toBeNull();
9
+ });
10
+
11
+ test("gives the desktop app's invoke when the window has one", () => {
12
+ const invoke: Invoke = () => Promise.reject(new Error("unused"));
13
+ vi.stubGlobal("window", { __TAURI__: { core: { invoke } } });
14
+ expect(desktop()).toBe(invoke);
15
+ });
16
+ });
17
+
18
+ describe("runsOnVms", () => {
19
+ const plan = (paths: string[]) => ({ files: paths.map((path) => ({ path })) });
20
+
21
+ test("a lab with VM nodes that Isoloom generates for Docker runs on Docker", () => {
22
+ expect(runsOnVms(true, plan([".isoloom/docker/compose.yml", ".isoloom/vagrant/Vagrantfile"]))).toBe(false);
23
+ });
24
+
25
+ test("a lab whose VMs have no container form runs on VMs", () => {
26
+ expect(runsOnVms(true, plan([".isoloom/hybrid/Vagrantfile"]))).toBe(true);
27
+ expect(runsOnVms(true, null)).toBe(true);
28
+ });
29
+
30
+ test("a lab without VM nodes never needs VMs", () => {
31
+ expect(runsOnVms(false, plan([]))).toBe(false);
32
+ });
33
+ });
34
+
35
+ describe("machineStatus", () => {
36
+ test("a running machine is up, unless its check says unhealthy or starting", () => {
37
+ expect(machineStatus({ state: "running", health: "" })).toBe("up");
38
+ expect(machineStatus({ state: "running", health: "unhealthy" })).toBe("unhealthy");
39
+ expect(machineStatus({ state: "running", health: "starting" })).toBe("starting");
40
+ });
41
+
42
+ test("a machine being created or restarting is starting", () => {
43
+ expect([machineStatus({ state: "created", health: "" }), machineStatus({ state: "restarting", health: "" })]).toEqual(["starting", "starting"]);
44
+ });
45
+
46
+ test("a machine that exited, or isn't there, is down", () => {
47
+ expect([machineStatus({ state: "exited", health: "" }), machineStatus(undefined)]).toEqual(["down", "down"]);
48
+ });
49
+ });
50
+
51
+ describe("runningServices", () => {
52
+ test("counts the running services of docker compose ps, one JSON object per line", () => {
53
+ expect(runningServices('{"State":"running"}\n x \n{"State":"exited"}\n\n{"State":"running"}')).toBe(2);
54
+ });
55
+
56
+ test("counts none in empty output", () => {
57
+ expect(runningServices("")).toBe(0);
58
+ });
59
+ });
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The desktop app's commands (Isothrum desktop loads this editor in its window and exposes them
3
+ * on `window.__TAURI__`). In a browser there is no such object, and the desktop-only controls
4
+ * (Run, Stop, Capture) stay hidden.
5
+ */
6
+ export type Invoke = <T>(command: string, args?: Record<string, unknown>) => Promise<T>;
7
+
8
+ interface TauriWindow {
9
+ __TAURI__?: { core?: { invoke?: Invoke } };
10
+ }
11
+
12
+ export function desktop(): Invoke | null {
13
+ if (typeof window === "undefined") return null;
14
+ const tauri = (window as unknown as TauriWindow).__TAURI__;
15
+ return tauri?.core?.invoke ?? null;
16
+ }
17
+
18
+ /**
19
+ * Whether the desktop app runs the lab on VMs (with Vagrant): it runs it on Docker whenever Isoloom
20
+ * generates its Docker files, which a machine drawn as a Linux VM has (its OS and steps build a
21
+ * container); only a lab with a machine that is a VM only (Windows, a playbook step) needs VMs.
22
+ */
23
+ export function runsOnVms(hasVmNodes: boolean, plan: { files: { path: string }[] } | null): boolean {
24
+ return hasVmNodes && !(plan?.files.some((f) => f.path === ".isoloom/docker/compose.yml") ?? false);
25
+ }
26
+
27
+ /** One machine now (desktop: `lab_live`): Docker's state, and its health when it has a check. */
28
+ export interface MachineLive {
29
+ state: string;
30
+ health: string;
31
+ }
32
+ /** One machine's port on one network now: up, down, or unknown (stopped, or a Cisco OS's). */
33
+ interface PortLive {
34
+ machine: string;
35
+ network: string;
36
+ up: boolean | null;
37
+ interface: string | null;
38
+ }
39
+ /** The running lab now. `probed`: the ports were read from the machines (not inferred). */
40
+ export interface LabLive {
41
+ machines: Record<string, MachineLive>;
42
+ ports: PortLive[];
43
+ probed: boolean;
44
+ }
45
+
46
+ /** A machine at a glance: up, on its way (starting, restarting), unhealthy, or down. */
47
+ export type Status = "up" | "starting" | "unhealthy" | "down";
48
+
49
+ export function machineStatus(m: MachineLive | undefined): Status {
50
+ if (!m) return "down";
51
+ if (m.state === "restarting" || m.state === "created") return "starting";
52
+ if (m.state !== "running") return "down";
53
+ if (m.health === "unhealthy") return "unhealthy";
54
+ return m.health === "starting" ? "starting" : "up";
55
+ }
56
+
57
+ /** A link cut while the lab runs: `machine`'s port on `network`, down. */
58
+ export interface SuspendedLink {
59
+ network: string;
60
+ machine: string;
61
+ }
62
+
63
+ /** A machine saved as an image (desktop: `docker commit`). */
64
+ export interface Snapshot {
65
+ id: string;
66
+ machine: string;
67
+ image: string;
68
+ label: string;
69
+ created: number;
70
+ in_use: boolean;
71
+ }
72
+
73
+ export interface DesktopInfo {
74
+ version: string;
75
+ docker: boolean;
76
+ vagrant?: boolean;
77
+ wireshark: boolean;
78
+ live_capture: boolean;
79
+ labs: string;
80
+ }
81
+
82
+ /** A line of `docker compose ps --format json` (nothing in particular when it isn't one). */
83
+ function serviceLine(line: string): { State?: string } {
84
+ try {
85
+ return JSON.parse(line) as { State?: string };
86
+ } catch {
87
+ return {};
88
+ }
89
+ }
90
+
91
+ /** `docker compose ps --format json`: one JSON object per line. */
92
+ export function runningServices(ps: string): number {
93
+ return ps
94
+ .split("\n")
95
+ .map((l) => l.trim())
96
+ .filter(Boolean)
97
+ .map(serviceLine)
98
+ .filter((s) => s.State === "running").length;
99
+ }
@@ -0,0 +1,150 @@
1
+ import fc from "fast-check";
2
+ import { describe, expect, test } from "vitest";
3
+ import { decodeLab, encodeLab, isDesign, normalizeDraft, plainDraft, storedEdges, storedNodes } from "./draft";
4
+ import { LAN_BEHAVIOR, cleanName, type CableEdge, type Draft } from "./model";
5
+ import { arbitraryLab } from "@/test/arbitrary";
6
+ import { autoRouter, cable, lan, machine } from "@/test/lab";
7
+
8
+ /** A lab with UI state on it: a selected, measured machine, a selected cable with conditions. */
9
+ const lab = (): Draft => ({
10
+ name: "office-lab",
11
+ nodes: [{ ...lan("office"), ...LAN_BEHAVIOR }, { ...machine("web"), selected: true, dragging: false, measured: { width: 120, height: 116 } }, autoRouter("r")],
12
+ edges: [{ ...cable("c1", "web", "office", { sourceHandle: "top", tc: { delay: 5 }, color: "#000" }), selected: true }],
13
+ });
14
+
15
+ test("isDesign knows the canvas's two looks", () => {
16
+ expect([isDesign("sketch"), isDesign("business"), isDesign("neon"), isDesign(undefined)]).toEqual([true, true, false, false]);
17
+ });
18
+
19
+ describe("what a lab keeps", () => {
20
+ test("stored nodes keep the lab only: id, type, position, data, size", () => {
21
+ expect(storedNodes(lab().nodes)[1]).toEqual({ id: "web", type: "machine", position: { x: 0, y: 0 }, data: machine("web").data, width: undefined, height: undefined });
22
+ });
23
+
24
+ test("stored cables keep their ends and their data, or the data they are given", () => {
25
+ const [e] = storedEdges(lab().edges, () => undefined);
26
+ expect(e).toEqual({ id: "c1", type: "cable", source: "web", sourceHandle: "top", target: "office", targetHandle: null, data: undefined });
27
+ });
28
+
29
+ test("a plain draft has no selection, drag or measured size", () => {
30
+ const plain = plainDraft(lab());
31
+ expect(plain.nodes.some((n) => "selected" in n || "dragging" in n || "measured" in n)).toBe(false);
32
+ expect(plain.edges.some((e) => "selected" in e)).toBe(false);
33
+ });
34
+ });
35
+
36
+ describe("normalizeDraft", () => {
37
+ test("refuses what has no lists of nodes and edges", () => {
38
+ for (const bad of [null, 1, "x", [], {}, { nodes: [], edges: {} }]) expect(normalizeDraft(bad)).toBeNull();
39
+ });
40
+
41
+ test("is null, not an error, for a null node or cable", () => {
42
+ expect(normalizeDraft({ name: "x", nodes: [null], edges: [] })).toBeNull();
43
+ expect(normalizeDraft({ name: "x", nodes: [], edges: [null] })).toBeNull();
44
+ });
45
+
46
+ test("refuses a node without its id, position or data, a cable without its ends, or a name that isn't text", () => {
47
+ const m = machine("m");
48
+ for (const bad of [{ ...m, id: 1 }, { ...m, position: null }, { ...m, data: undefined }]) expect(normalizeDraft({ name: "x", nodes: [bad], edges: [] })).toBeNull();
49
+ for (const bad of [{ id: "c", source: "m" }, { id: "c", target: "m" }, { source: "m", target: "m" }]) expect(normalizeDraft({ name: "x", nodes: [m], edges: [bad] })).toBeNull();
50
+ expect(normalizeDraft({ name: 5, nodes: [], edges: [] })).toBeNull();
51
+ });
52
+
53
+ test("makes LANs frames again and moves cables onto today's handles", () => {
54
+ const stored = { name: "x", nodes: [lan("office"), autoRouter("r")], edges: [cable("c", "r", "office", { sourceHandle: "left" })] };
55
+ const d = normalizeDraft(stored);
56
+ expect(d?.nodes[0]).toMatchObject(LAN_BEHAVIOR);
57
+ expect(d?.edges[0].sourceHandle).toBe("port");
58
+ });
59
+
60
+ test("names a stored machine's OS and Cisco OS as Isoloom does now", () => {
61
+ const vm = machine("pc", { kind: "vm", os: "ubuntu-24.04" });
62
+ const sw = machine("sw", { appliance: "cisco-viosl2" as never });
63
+ const d = normalizeDraft({ name: "x", nodes: [vm, sw], edges: [] });
64
+ expect(d?.nodes.map((n) => n.data)).toMatchObject([{ os: "ubuntu@24.04" }, { appliance: "cisco-vios-l2" }]);
65
+ });
66
+ });
67
+
68
+ describe("normalizeDraft, for anything stored", () => {
69
+ /** Something stored as a node or a cable: any of their fields, each of any JSON value or a likely one. */
70
+ const ids = fc.constantFrom("a", "b");
71
+ const field = <T>(likely: fc.Arbitrary<T>) => fc.oneof(likely, fc.jsonValue());
72
+ const nodeish = fc.record(
73
+ { id: field(ids), type: field(fc.constantFrom("machine", "lan", "switch", "note")), position: field(fc.constant({ x: 0, y: 0 })), data: field(fc.constant({ icon: "router" })) },
74
+ { requiredKeys: [] },
75
+ );
76
+ const cableish = fc.record({ id: field(ids), source: field(ids), target: field(ids), sourceHandle: fc.jsonValue(), data: fc.jsonValue() }, { requiredKeys: [] });
77
+ const stored = fc.oneof(fc.jsonValue(), fc.record({ name: field(fc.string()), nodes: fc.array(field(nodeish)), edges: fc.array(field(cableish)) }));
78
+
79
+ test("never throws: it is a draft or null", () => {
80
+ fc.assert(
81
+ fc.property(stored, (v) => {
82
+ expect(() => normalizeDraft(v)).not.toThrow();
83
+ }),
84
+ );
85
+ });
86
+
87
+ test("opens any lab the editor draws, as storage keeps it", () => {
88
+ fc.assert(
89
+ fc.property(arbitraryLab, (drawn) => {
90
+ expect(normalizeDraft(JSON.parse(JSON.stringify(drawn)))?.nodes.map((n) => n.id)).toEqual(drawn.nodes.map((n) => n.id));
91
+ }),
92
+ );
93
+ });
94
+ });
95
+
96
+ describe("a lab in a link", () => {
97
+ test("is base64url, which a link carries as it is", async () => {
98
+ expect(await encodeLab(lab())).toMatch(/^[A-Za-z0-9_-]+$/);
99
+ });
100
+
101
+ test("comes back with its nodes, cables and ports", async () => {
102
+ const d = await decodeLab(await encodeLab(lab()));
103
+ expect(d?.nodes.map((n) => n.id)).toEqual(["office", "web", "r"]);
104
+ expect(d?.edges[0]).toMatchObject({ source: "web", sourceHandle: "top", target: "office", data: { aPort: "eth0", bPort: "eth0" } });
105
+ });
106
+
107
+ test("comes back with its machines' OS as Isoloom names it now", async () => {
108
+ const old = { ...lab(), nodes: [machine("pc", { kind: "vm", os: "debian-12" })], edges: [] };
109
+ expect((await decodeLab(await encodeLab(old)))?.nodes[0].data).toMatchObject({ os: "debian@12" });
110
+ });
111
+
112
+ test("keeps the cables' conditions", async () => {
113
+ const d = await decodeLab(await encodeLab(lab()));
114
+ expect(d?.edges[0].data?.tc).toEqual({ delay: 5 });
115
+ });
116
+
117
+ test("leaves the UI state and the cables' drawing color behind", async () => {
118
+ const d = await decodeLab(await encodeLab(lab()));
119
+ expect(d?.nodes[1]).not.toHaveProperty("selected");
120
+ expect(d?.edges[0].data).toEqual({ aPort: "eth0", bPort: "eth0", tc: { delay: 5 } });
121
+ });
122
+
123
+ test("carries the look only when it isn't the default sketch", async () => {
124
+ expect((await decodeLab(await encodeLab({ ...lab(), design: "business" })))?.design).toBe("business");
125
+ expect((await decodeLab(await encodeLab({ ...lab(), design: "sketch" })))?.design).toBeUndefined();
126
+ });
127
+
128
+ test("cleans the lab's name, or calls it shared-lab", async () => {
129
+ expect((await decodeLab(await encodeLab({ ...lab(), name: "My Lab!" })))?.name).toBe("mylab");
130
+ expect((await decodeLab(await encodeLab({ ...lab(), name: "" })))?.name).toBe("shared-lab");
131
+ });
132
+
133
+ test("is nothing when the code isn't a lab", async () => {
134
+ for (const code of ["", "abc", "!!"]) expect(await decodeLab(code)).toBeNull();
135
+ });
136
+ });
137
+
138
+ describe("any lab in a link", () => {
139
+ test("comes back with its name, look, nodes and cables: their ends, ports and conditions", async () => {
140
+ await fc.assert(
141
+ fc.asyncProperty(arbitraryLab, async (drawn) => {
142
+ const d = await decodeLab(await encodeLab(drawn));
143
+ expect([d?.name, d?.design]).toEqual([cleanName(drawn.name) || "shared-lab", drawn.design === "business" ? "business" : undefined]);
144
+ expect(d?.nodes.map((n) => [n.id, n.type, n.position, n.data])).toEqual(drawn.nodes.map((n) => [n.id, n.type, n.position, n.data]));
145
+ const ends = (e: CableEdge) => [e.id, e.source, e.target, { aPort: e.data?.aPort, bPort: e.data?.bPort, tc: e.data?.tc }];
146
+ expect(d?.edges.map((e) => [e.id, e.source, e.target, e.data])).toEqual(drawn.edges.map(ends));
147
+ }),
148
+ );
149
+ });
150
+ });
@@ -0,0 +1,109 @@
1
+ import { migrateEdges } from "./cables";
2
+ import { DESIGNS, LAN_BEHAVIOR, cleanName, type CableEdge, type Design, type Draft, type LabNode } from "./model";
3
+ import { currentMachine } from "./os";
4
+
5
+ /** A lab as it is kept: in storage, in a saved project, or in a share link. */
6
+
7
+ /** Whether `v` is one of the canvas's looks. */
8
+ export const isDesign = (v: unknown): v is Design => DESIGNS.some((d) => d === v);
9
+
10
+ /** A lab's nodes and cables as stored: the lab only, no UI state. */
11
+ export function storedNodes(nodes: LabNode[]): Pick<LabNode, "id" | "type" | "position" | "data" | "width" | "height">[] {
12
+ return nodes.map(({ id, type, position, data, width, height }) => ({ id, type, position, data, width, height }));
13
+ }
14
+
15
+ export function storedEdges(edges: CableEdge[], data: (e: CableEdge) => CableEdge["data"] = (e) => e.data): CableEdge[] {
16
+ return edges.map((e) => ({ id: e.id, type: e.type, source: e.source, sourceHandle: e.sourceHandle, target: e.target, targetHandle: e.targetHandle, data: data(e) }));
17
+ }
18
+
19
+ /** LANs behave as frames again, machines name their OS as Isoloom does, and cables move onto today's handles. */
20
+ function current(nodes: LabNode[], edges: CableEdge[]): { nodes: LabNode[]; edges: CableEdge[] } {
21
+ const withBehavior = nodes.map((n): LabNode => {
22
+ if (n.type === "lan") return { ...n, ...LAN_BEHAVIOR };
23
+ return n.type === "machine" ? { ...n, data: currentMachine(n.data) } : n;
24
+ });
25
+ return { nodes: withBehavior, edges: migrateEdges(edges, withBehavior) };
26
+ }
27
+
28
+ /** Whether `v` is a JSON object: not null, not a list. */
29
+ const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
30
+
31
+ /** Whether a stored node has what the editor reads of every node: an id, a position and its data. */
32
+ const isNode = (v: unknown): v is LabNode => isRecord(v) && typeof v.id === "string" && isRecord(v.position) && isRecord(v.data);
33
+
34
+ /** Whether a stored cable has what the editor reads of every cable: an id and its two ends. */
35
+ const isCable = (v: unknown): v is CableEdge => isRecord(v) && typeof v.id === "string" && typeof v.source === "string" && typeof v.target === "string";
36
+
37
+ /** Whether a parsed value has the shape of a draft: a name (if any) in text, lists of nodes and cables. */
38
+ function hasLists(d: unknown): d is { name?: string; nodes: LabNode[]; edges: CableEdge[]; design?: unknown } {
39
+ if (!isRecord(d)) return false;
40
+ const { name, nodes, edges } = d;
41
+ if (name !== undefined && typeof name !== "string") return false;
42
+ return Array.isArray(nodes) && Array.isArray(edges) && nodes.every(isNode) && edges.every(isCable);
43
+ }
44
+
45
+ /**
46
+ * A draft from storage or a saved project, made current: LAN behavior, machines' OS names, today's cable handles.
47
+ * Null when it isn't a lab's shape (a node or a cable that isn't one, as a damaged store holds).
48
+ */
49
+ export function normalizeDraft(d: unknown): Draft | null {
50
+ if (!hasLists(d)) return null;
51
+ return { ...(d as Draft), ...current(d.nodes, d.edges) };
52
+ }
53
+
54
+ /** A draft without what is only UI state (selection, drag, measured sizes): what a save keeps. */
55
+ export function plainDraft(d: Draft): Draft {
56
+ return {
57
+ ...d,
58
+ nodes: d.nodes.map((n) => {
59
+ const c = { ...n };
60
+ delete c.selected;
61
+ delete c.dragging;
62
+ delete c.measured;
63
+ return c;
64
+ }),
65
+ edges: d.edges.map((e) => {
66
+ const c = { ...e };
67
+ delete c.selected;
68
+ return c;
69
+ }),
70
+ };
71
+ }
72
+
73
+ /** Base64url without padding, as a link carries it. */
74
+ function toBase64Url(bytes: Uint8Array): string {
75
+ let bin = "";
76
+ for (const b of bytes) bin += String.fromCharCode(b);
77
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
78
+ }
79
+
80
+ /**
81
+ * A lab in a link: its JSON, deflated, in base64url, after `#lab=`. The part after `#` never
82
+ * reaches a server, so a shared lab stays as private as the editor (the link is the only copy).
83
+ */
84
+ export async function encodeLab(draft: Draft): Promise<string> {
85
+ const plain = {
86
+ name: draft.name,
87
+ // The look goes with the link, so the reader sees the diagram as it was drawn.
88
+ ...(draft.design && draft.design !== "sketch" ? { design: draft.design } : {}),
89
+ nodes: storedNodes(draft.nodes),
90
+ // A cable keeps its ports and conditions; its color is only drawing, worked out again.
91
+ edges: storedEdges(draft.edges, (e) => (e.data ? { aPort: e.data.aPort, bPort: e.data.bPort, tc: e.data.tc } : e.data)),
92
+ };
93
+ const stream = new Blob([JSON.stringify(plain)]).stream().pipeThrough(new CompressionStream("deflate-raw"));
94
+ return toBase64Url(new Uint8Array(await new Response(stream).arrayBuffer()));
95
+ }
96
+
97
+ export async function decodeLab(code: string): Promise<Draft | null> {
98
+ try {
99
+ const bin = atob(code.replace(/-/g, "+").replace(/_/g, "/"));
100
+ const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0));
101
+ const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
102
+ const d: unknown = JSON.parse(await new Response(stream).text());
103
+ if (!hasLists(d)) return null;
104
+ const design = isDesign(d.design) ? d.design : undefined;
105
+ return { name: cleanName(d.name ?? "") || "shared-lab", ...current(d.nodes, d.edges), design };
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
@@ -0,0 +1,39 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { type Engine, ownFiles, planOf } from "./engine";
3
+
4
+ /** An engine whose plan is `plan` (a function, to fail like a crash). */
5
+ function fakeEngine(plan: () => string, files = "[]"): Engine {
6
+ return {
7
+ default: () => Promise.resolve(),
8
+ plan_json: plan,
9
+ bundle: () => new Uint8Array(),
10
+ own_files_json: () => files,
11
+ bundle_target: () => new Uint8Array(),
12
+ };
13
+ }
14
+
15
+ const failed = (e: string) => `engine failed: ${e}`;
16
+
17
+ describe("planOf", () => {
18
+ test("reads the engine's plan", () => {
19
+ const plan = { ok: true, error: null, isoloom_yml: "name: lab\n", problems: [], files: [], skipped: [], addresses: { web: [["office", "10.10.1.10"]] } };
20
+ expect(planOf(fakeEngine(() => JSON.stringify(plan)), "{}", failed)).toEqual(plan);
21
+ });
22
+
23
+ test("gives no isoloom.yml when the engine leaves it out", () => {
24
+ const plan = { ok: false, error: "bad", problems: ["x"], files: [], skipped: [], addresses: {} };
25
+ expect(planOf(fakeEngine(() => JSON.stringify(plan)), "{}", failed).isoloom_yml).toBeNull();
26
+ });
27
+
28
+ test("turns an engine crash into a failed plan, in the editor's words", () => {
29
+ const crash = fakeEngine(() => {
30
+ throw new Error("unreachable");
31
+ });
32
+ expect(planOf(crash, "{}", failed)).toEqual({ ok: false, error: "engine failed: Error: unreachable", isoloom_yml: null, problems: [], files: [], skipped: [], addresses: {} });
33
+ });
34
+ });
35
+
36
+ test("ownFiles reads the lab's own files from the engine", () => {
37
+ const files = [{ path: "provision/db.sh", contents: "#!/bin/sh\n" }];
38
+ expect(ownFiles(fakeEngine(() => "{}", JSON.stringify(files)), "{}")).toEqual(files);
39
+ });
@@ -0,0 +1,58 @@
1
+ import type { Addresses } from "./router-ports";
2
+
3
+ /**
4
+ * The engine's JavaScript surface, from `public/wasm/isothrum_wasm.js` (built from
5
+ * `crates/isothrum-wasm`). Everything runs in the browser: nothing is sent to a server.
6
+ */
7
+ export interface Engine {
8
+ default: () => Promise<unknown>;
9
+ plan_json: (topology: string) => string;
10
+ bundle: (topology: string) => Uint8Array;
11
+ /** The lab's own files (provisioning starters, appliance configurations), as JSON. */
12
+ own_files_json: (topology: string) => string;
13
+ bundle_target: (topology: string, targetDir: string) => Uint8Array;
14
+ }
15
+
16
+ export interface GeneratedFile {
17
+ path: string;
18
+ contents: string;
19
+ }
20
+
21
+ /** What the engine makes of a lab: isoloom.yml and every target's files, or what is wrong. */
22
+ export interface Plan {
23
+ ok: boolean;
24
+ error: string | null;
25
+ isoloom_yml: string | null;
26
+ problems: string[];
27
+ files: GeneratedFile[];
28
+ skipped: string[];
29
+ addresses: Addresses;
30
+ }
31
+
32
+ /** Served from `public/wasm/`; loaded at runtime, not bundled. */
33
+ const ENGINE_URL: string = "/wasm/isothrum_wasm.js";
34
+
35
+ /** Loads and starts the engine. */
36
+ export async function loadEngine(): Promise<Engine> {
37
+ const m = (await import(/* webpackIgnore: true */ /* turbopackIgnore: true */ ENGINE_URL)) as Engine;
38
+ await m.default();
39
+ return m;
40
+ }
41
+
42
+ /** The plan as the engine writes it: isoloom.yml may be left out when there is none. */
43
+ type RawPlan = Omit<Plan, "isoloom_yml"> & { isoloom_yml?: string | null };
44
+
45
+ /** The engine's plan for a topology; an engine crash becomes a plan with `error` (from `failed`). */
46
+ export function planOf(engine: Engine, topology: string, failed: (error: string) => string): Plan {
47
+ try {
48
+ const raw = JSON.parse(engine.plan_json(topology)) as RawPlan;
49
+ return { ...raw, isoloom_yml: raw.isoloom_yml ?? null };
50
+ } catch (e) {
51
+ return { ok: false, error: failed(String(e)), isoloom_yml: null, problems: [], files: [], skipped: [], addresses: {} };
52
+ }
53
+ }
54
+
55
+ /** The lab's own files (provisioning starters, appliance configurations). */
56
+ export function ownFiles(engine: Engine, topology: string): GeneratedFile[] {
57
+ return JSON.parse(engine.own_files_json(topology)) as GeneratedFile[];
58
+ }