brane 0.1.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (352) hide show
  1. package/AGENTS.md +367 -0
  2. package/CHANGELOG.md +66 -0
  3. package/CONTRIBUTING.md +101 -0
  4. package/DESIGN.md +405 -0
  5. package/Dockerfile +61 -0
  6. package/LICENSE +656 -17
  7. package/README.md +174 -2
  8. package/SECURITY.md +213 -0
  9. package/dist/absorb/brane-id.js +11 -0
  10. package/dist/absorb/chatgpt.js +161 -0
  11. package/dist/absorb/claude-code.js +504 -0
  12. package/dist/absorb/index.js +543 -0
  13. package/dist/absorb/organelle.js +345 -0
  14. package/dist/absorb/run.js +520 -0
  15. package/dist/absorb/sources.js +168 -0
  16. package/dist/absorb/types.js +3 -0
  17. package/dist/agent-env.js +45 -0
  18. package/dist/agents.js +1168 -0
  19. package/dist/claude-live-session.js +124 -0
  20. package/dist/cli.js +50 -0
  21. package/dist/codex-tool-log.js +144 -0
  22. package/dist/commands/absorb.js +291 -0
  23. package/dist/commands/attach.js +31 -0
  24. package/dist/commands/certify.js +62 -0
  25. package/dist/commands/config.js +133 -0
  26. package/dist/commands/cost.js +126 -0
  27. package/dist/commands/daemon.js +350 -0
  28. package/dist/commands/doctor.js +262 -0
  29. package/dist/commands/fold.js +95 -0
  30. package/dist/commands/list.js +89 -0
  31. package/dist/commands/mcp.js +84 -0
  32. package/dist/commands/meld.js +360 -0
  33. package/dist/commands/memory.js +215 -0
  34. package/dist/commands/revive.js +74 -0
  35. package/dist/commands/run.js +85 -0
  36. package/dist/commands/spawn.js +1101 -0
  37. package/dist/commands/token.js +154 -0
  38. package/dist/copilot-tool-log.js +145 -0
  39. package/dist/cost/pricing.js +116 -0
  40. package/dist/cost/reprice.js +136 -0
  41. package/dist/crush-tool-log.js +293 -0
  42. package/dist/daemon/agent-scope.js +116 -0
  43. package/dist/daemon/awaiting.js +54 -0
  44. package/dist/daemon/cert-manager.js +349 -0
  45. package/dist/daemon/client.js +262 -0
  46. package/dist/daemon/gated-routes.js +112 -0
  47. package/dist/daemon/keep-awake.js +103 -0
  48. package/dist/daemon/local-socket.js +145 -0
  49. package/dist/daemon/presence-gate.js +206 -0
  50. package/dist/daemon/server.js +6353 -0
  51. package/dist/daemon/signal-source.js +35 -0
  52. package/dist/daemon/tailscale-rebind.js +58 -0
  53. package/dist/daemon/token-ring.js +263 -0
  54. package/dist/daemon/token-routes.js +190 -0
  55. package/dist/db/events.js +152 -0
  56. package/dist/db/mentions.js +179 -0
  57. package/dist/db/schema.js +643 -0
  58. package/dist/db/tokens.js +624 -0
  59. package/dist/db/tool-uses.js +647 -0
  60. package/dist/diagram/index.js +85 -0
  61. package/dist/diagram/label.js +455 -0
  62. package/dist/diagram/layout.js +476 -0
  63. package/dist/diagram/links.js +50 -0
  64. package/dist/diagram/metrics.js +152 -0
  65. package/dist/diagram/parse.js +487 -0
  66. package/dist/diagram/svg.js +389 -0
  67. package/dist/diagram/types.js +9 -0
  68. package/dist/export.js +629 -0
  69. package/dist/files/archive-types.js +2 -0
  70. package/dist/files/archive.js +182 -0
  71. package/dist/files/browse.js +327 -0
  72. package/dist/files/bytes.js +162 -0
  73. package/dist/files/codecs.js +33 -0
  74. package/dist/files/find.js +212 -0
  75. package/dist/files/tar.js +223 -0
  76. package/dist/files/zip.js +209 -0
  77. package/dist/folds.js +72 -0
  78. package/dist/grammars/ebnf.js +92 -0
  79. package/dist/highlight.js +250 -0
  80. package/dist/ids.js +24 -0
  81. package/dist/markdown-diagnostics.js +233 -0
  82. package/dist/math.js +168 -0
  83. package/dist/mcp/server.js +886 -0
  84. package/dist/memory/embedder.js +145 -0
  85. package/dist/memory/embedding-worker.js +71 -0
  86. package/dist/memory/embeddings.js +91 -0
  87. package/dist/memory/index.js +232 -0
  88. package/dist/memory/storage.js +587 -0
  89. package/dist/memory/wiki.js +688 -0
  90. package/dist/memory/wordpiece.js +247 -0
  91. package/dist/mentions-scan.js +40 -0
  92. package/dist/presence.js +259 -0
  93. package/dist/session-affinity.js +185 -0
  94. package/dist/shared/api-request.js +124 -0
  95. package/dist/shared/app-origin.js +26 -0
  96. package/dist/shared/block-glyphs.js +114 -0
  97. package/dist/shared/diagram-css.js +17 -0
  98. package/dist/shared/export-selection.js +101 -0
  99. package/dist/shared/find-in-text.js +110 -0
  100. package/dist/shared/glow-hues.js +225 -0
  101. package/dist/shared/hex-dump.js +62 -0
  102. package/dist/shared/hex-pages.js +52 -0
  103. package/dist/shared/hit-drum.js +79 -0
  104. package/dist/shared/known-nids.js +58 -0
  105. package/dist/shared/list-scroll-keys.js +56 -0
  106. package/dist/shared/markdown-lists.js +72 -0
  107. package/dist/shared/markdown-math.js +485 -0
  108. package/dist/shared/markdown-mentions.js +79 -0
  109. package/dist/shared/markdown-nids.js +41 -0
  110. package/dist/shared/markdown-protocols.js +53 -0
  111. package/dist/shared/markdown-refs.js +123 -0
  112. package/dist/shared/membrane-invasion.js +342 -0
  113. package/dist/shared/membrane-motion.js +83 -0
  114. package/dist/shared/membrane-palette.js +253 -0
  115. package/dist/shared/membrane-terrain.js +212 -0
  116. package/dist/shared/message-selection.js +123 -0
  117. package/dist/shared/mo-glyph.js +57 -0
  118. package/dist/shared/narrative-format.js +237 -0
  119. package/dist/shared/narrative-menu.js +84 -0
  120. package/dist/shared/narrative-sort.js +47 -0
  121. package/dist/shared/narrative-status.js +52 -0
  122. package/dist/shared/no-pan.js +85 -0
  123. package/dist/shared/pane-layout.js +50 -0
  124. package/dist/shared/payload-inline.js +83 -0
  125. package/dist/shared/speaker-color.js +61 -0
  126. package/dist/shared/token-rotation.js +14 -0
  127. package/dist/shared/view-url.js +216 -0
  128. package/dist/shared/wiki-links.js +70 -0
  129. package/dist/shared/wiki-window.js +4 -0
  130. package/dist/tailscale.js +55 -0
  131. package/dist/test/absorb-claude.test.js +892 -0
  132. package/dist/test/agent-scope.test.js +193 -0
  133. package/dist/test/agents.test.js +55 -0
  134. package/dist/test/api-request.test.js +122 -0
  135. package/dist/test/app-host-literal.test.js +53 -0
  136. package/dist/test/awaiting-glow.test.js +54 -0
  137. package/dist/test/awaiting.test.js +122 -0
  138. package/dist/test/bind-token.test.js +88 -0
  139. package/dist/test/block-glyphs.test.js +68 -0
  140. package/dist/test/cert-manager.test.js +262 -0
  141. package/dist/test/claude-live-session.test.js +110 -0
  142. package/dist/test/codex-tool-log.test.js +568 -0
  143. package/dist/test/config.test.js +71 -0
  144. package/dist/test/copilot-tool-log.test.js +93 -0
  145. package/dist/test/cors-preflight.test.js +56 -0
  146. package/dist/test/cost-linger.test.js +31 -0
  147. package/dist/test/cost-pricing.test.js +72 -0
  148. package/dist/test/cost-reprice.test.js +139 -0
  149. package/dist/test/cost-sessions.test.js +301 -0
  150. package/dist/test/crush-tool-log.test.js +289 -0
  151. package/dist/test/daemon-client.test.js +109 -0
  152. package/dist/test/daemon-socket.test.js +293 -0
  153. package/dist/test/diagram-arrows.test.js +205 -0
  154. package/dist/test/diagram-export.test.js +210 -0
  155. package/dist/test/diagram-labels.test.js +313 -0
  156. package/dist/test/diagram-parse.test.js +191 -0
  157. package/dist/test/diagram-render.test.js +175 -0
  158. package/dist/test/diagram-review.test.js +288 -0
  159. package/dist/test/diagram-self-loop-labels.test.js +303 -0
  160. package/dist/test/embedder.test.js +84 -0
  161. package/dist/test/events.test.js +123 -0
  162. package/dist/test/export-selection.test.js +102 -0
  163. package/dist/test/export.test.js +505 -0
  164. package/dist/test/files-browse.test.js +393 -0
  165. package/dist/test/files-find.test.js +272 -0
  166. package/dist/test/find-in-text.test.js +86 -0
  167. package/dist/test/first-paint-viewport.test.js +59 -0
  168. package/dist/test/fixtures/embedding-golden.js +23 -0
  169. package/dist/test/fixtures/wordpiece-golden.js +167 -0
  170. package/dist/test/glow-hues.test.js +202 -0
  171. package/dist/test/harness-cost.test.js +423 -0
  172. package/dist/test/helpers.js +21 -0
  173. package/dist/test/hex-pages.test.js +68 -0
  174. package/dist/test/highlight.test.js +69 -0
  175. package/dist/test/hit-drum.test.js +71 -0
  176. package/dist/test/hook-config.test.js +299 -0
  177. package/dist/test/json-gzip.test.js +70 -0
  178. package/dist/test/key-routing.test.js +58 -0
  179. package/dist/test/known-nids.test.js +106 -0
  180. package/dist/test/link-preview.test.js +67 -0
  181. package/dist/test/list-scroll-keys.test.js +66 -0
  182. package/dist/test/listen-own-words.test.js +147 -0
  183. package/dist/test/listen-timeout.test.js +33 -0
  184. package/dist/test/markdown-diagnostics.test.js +121 -0
  185. package/dist/test/markdown-lists.test.js +182 -0
  186. package/dist/test/markdown-math.test.js +256 -0
  187. package/dist/test/markdown-nids.test.js +146 -0
  188. package/dist/test/markdown-protocols.test.js +139 -0
  189. package/dist/test/mcp-bridge-cancel.test.js +98 -0
  190. package/dist/test/mcp-bridge-reconnect.test.js +121 -0
  191. package/dist/test/mcp-narrative.test.js +193 -0
  192. package/dist/test/mcp.test.js +548 -0
  193. package/dist/test/meld.test.js +143 -0
  194. package/dist/test/membrane-invasion.test.js +482 -0
  195. package/dist/test/membrane-motion.test.js +101 -0
  196. package/dist/test/membrane-palette.test.js +166 -0
  197. package/dist/test/membrane-terrain.test.js +170 -0
  198. package/dist/test/memory-log.test.js +202 -0
  199. package/dist/test/mentions-e2e.test.js +261 -0
  200. package/dist/test/mentions-ledger.test.js +265 -0
  201. package/dist/test/mentions-listen.test.js +265 -0
  202. package/dist/test/mentions.test.js +238 -0
  203. package/dist/test/message-selection.test.js +114 -0
  204. package/dist/test/mo-glyph.test.js +22 -0
  205. package/dist/test/model-resolution.test.js +166 -0
  206. package/dist/test/narrate-validate.test.js +77 -0
  207. package/dist/test/narrate-wait.test.js +160 -0
  208. package/dist/test/narrative-format.test.js +219 -0
  209. package/dist/test/narrative-menu.test.js +150 -0
  210. package/dist/test/narrative-sort.test.js +75 -0
  211. package/dist/test/no-pan.test.js +60 -0
  212. package/dist/test/pane-layout.test.js +153 -0
  213. package/dist/test/paths.test.js +37 -0
  214. package/dist/test/payload-inline.test.js +91 -0
  215. package/dist/test/precache-fonts.test.js +72 -0
  216. package/dist/test/presence-gate.test.js +440 -0
  217. package/dist/test/presence.test.js +160 -0
  218. package/dist/test/read-events.test.js +220 -0
  219. package/dist/test/remote-meld.test.js +128 -0
  220. package/dist/test/revive-cli.test.js +93 -0
  221. package/dist/test/search-floor.test.js +206 -0
  222. package/dist/test/search-fusion.test.js +168 -0
  223. package/dist/test/search.test.js +205 -0
  224. package/dist/test/security.test.js +48 -0
  225. package/dist/test/selection-wiring.test.js +61 -0
  226. package/dist/test/session-affinity.test.js +295 -0
  227. package/dist/test/signal-source.test.js +32 -0
  228. package/dist/test/socket-account.test.js +268 -0
  229. package/dist/test/spawn-toml.test.js +72 -0
  230. package/dist/test/speaker-color.test.js +90 -0
  231. package/dist/test/tailscale-rebind.test.js +79 -0
  232. package/dist/test/terminal-cursor.test.js +61 -0
  233. package/dist/test/terminal-resync.test.js +23 -0
  234. package/dist/test/terminal-size.test.js +36 -0
  235. package/dist/test/thin-cli.test.js +382 -0
  236. package/dist/test/token-bootstrap.test.js +272 -0
  237. package/dist/test/token-devices.test.js +646 -0
  238. package/dist/test/token-lapse.test.js +94 -0
  239. package/dist/test/token-ring.test.js +188 -0
  240. package/dist/test/token-rotate-cli.test.js +58 -0
  241. package/dist/test/token-routes.test.js +95 -0
  242. package/dist/test/tool-uses.test.js +1006 -0
  243. package/dist/test/usage-ledger.test.js +126 -0
  244. package/dist/test/view-url.test.js +627 -0
  245. package/dist/test/wiki-links.test.js +57 -0
  246. package/dist/test/wiki-sources.test.js +85 -0
  247. package/dist/test/wiki-window.test.js +31 -0
  248. package/dist/test/wiki.test.js +399 -0
  249. package/dist/test/wordpiece.test.js +68 -0
  250. package/dist/test/wrangler-route.test.js +27 -0
  251. package/dist/tls.js +148 -0
  252. package/dist/tmux.js +213 -0
  253. package/dist/user-agent.js +46 -0
  254. package/docker-entrypoint.sh +154 -0
  255. package/docs/README.md +3 -0
  256. package/docs/absorb.md +231 -0
  257. package/docs/cost-ledger.md +125 -0
  258. package/docs/daemon-api.md +189 -0
  259. package/docs/diagrams.md +163 -0
  260. package/docs/doctor.md +36 -0
  261. package/docs/export-scope.md +69 -0
  262. package/docs/files-view.md +133 -0
  263. package/docs/link-previews.md +38 -0
  264. package/docs/math.md +32 -0
  265. package/docs/mcp.md +69 -0
  266. package/docs/memory-search.md +75 -0
  267. package/docs/mentions.md +101 -0
  268. package/docs/multi-brane.md +14 -0
  269. package/docs/passkeys.md +165 -0
  270. package/docs/presence.md +202 -0
  271. package/docs/releasing.md +104 -0
  272. package/docs/rendering-warnings.md +22 -0
  273. package/docs/request-layer.md +72 -0
  274. package/docs/selection-store.md +50 -0
  275. package/docs/session-affinity.md +61 -0
  276. package/docs/terminal-mirroring.md +243 -0
  277. package/docs/token-rotation.md +431 -0
  278. package/docs/view-urls.md +134 -0
  279. package/docs/web-app.md +23 -0
  280. package/docs/wiki.md +43 -0
  281. package/expo/dist/_expo/static/js/web/entry-e94247f119fdcd3b424a1764233a41d2.js +1099 -0
  282. package/expo/dist/apple-touch-icon.png +0 -0
  283. package/expo/dist/favicon.svg +3 -0
  284. package/expo/dist/index.html +177 -0
  285. package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  286. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  287. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  288. package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  289. package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  290. package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  291. package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  292. package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  293. package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  294. package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  295. package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  296. package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  297. package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  298. package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  299. package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  300. package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  301. package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  302. package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  303. package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  304. package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  305. package/expo/dist/katex/katex.min.css +1 -0
  306. package/expo/dist/manifest.json +16 -0
  307. package/expo/dist/metadata.json +1 -0
  308. package/expo/dist/og/membrane.v1.png +0 -0
  309. package/expo/dist/pdf.min.mjs +28 -0
  310. package/expo/dist/pdf.worker.min.mjs +28 -0
  311. package/expo/dist/sw.js +154 -0
  312. package/package.json +79 -27
  313. package/protocols/LIVE_loop.md +407 -0
  314. package/protocols/bebrief.md +41 -0
  315. package/protocols/digest.md +231 -0
  316. package/protocols/fly.md +266 -0
  317. package/protocols/idle.md +81 -0
  318. package/protocols/keeper.md +829 -0
  319. package/protocols/markdown.md +80 -0
  320. package/protocols/memory.md +419 -0
  321. package/protocols/mentions.md +93 -0
  322. package/protocols/payloads.md +53 -0
  323. package/protocols/prelude.md +91 -0
  324. package/protocols/ramify.md +105 -0
  325. package/protocols/review.md +356 -0
  326. package/protocols/rrr.md +232 -0
  327. package/protocols/scribe.md +429 -0
  328. package/protocols/summarize.md +203 -0
  329. package/protocols/twin.md +174 -0
  330. package/protocols/wiki.md +438 -0
  331. package/protocols/worker.md +38 -0
  332. package/wiki/shared/append-only-event-log.md +148 -0
  333. package/wiki/shared/index.md +53 -0
  334. package/wiki/shared/owning-your-narratives.md +161 -0
  335. package/wiki/shared/premature-ontologies.md +435 -0
  336. package/wiki/shared/sedimentary-superintelligence.md +128 -0
  337. package/wiki/shared/sequestering-the-narrative.md +143 -0
  338. package/wiki/shared/why-wikis-work.md +216 -0
  339. package/.travis.yml +0 -7
  340. package/lib/brane.cjs.js +0 -12
  341. package/lib/brane.cjs.js.map +0 -1
  342. package/lib/brane.d.ts +0 -11
  343. package/lib/brane.js +0 -6
  344. package/lib/brane.js.map +0 -1
  345. package/lib/tests.d.ts +0 -1
  346. package/lib/tests.js +0 -8
  347. package/lib/tests.js.map +0 -1
  348. package/rollup.config.js +0 -21
  349. package/scripts/test.sh +0 -8
  350. package/src/brane.ts +0 -21
  351. package/src/tests.ts +0 -8
  352. package/tsconfig.json +0 -16
@@ -0,0 +1,627 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { CANONICAL_TABS, isIsolated, parseActiveTab, parsePinnedTabs, serializePinned, viewParams, parseLineSpec, buildIsolateUrl, buildMeldUrl, terminalOnlyHref, withoutConnectionHash, withoutConnectionParams, connectionFragment, } from "../shared/view-url.js";
4
+ // These pin the URL contract that the Isolate menu item depends on. It opens
5
+ // the same narrative on the *other* origin — a shared app, or a daemon's own
6
+ // bundle — and the tab layout and open file live in sessionStorage, which is
7
+ // origin-scoped. So the query string is the only thing that crosses, and every
8
+ // assertion below is really asking the same question: does the view you were
9
+ // looking at survive the hop?
10
+ //
11
+ // The app carries no host of its own: "isolated" is `location.host` equal to
12
+ // the connection's host, and the way out of a daemon's bundle is whatever the
13
+ // daemon reports as its app host. So the shared host here is deliberately not
14
+ // brane's; any host serving the bundle is a shared app.
15
+ const CONN = { host: "chalk.tail1234.ts.net", token: "tok-abc123" };
16
+ const NID = "claude-0b9okmy2";
17
+ /** The page's host while on a shared app. */
18
+ const SHARED_SPA_HOST = "shell.example.test";
19
+ /** What the daemon reports as its `app_domain`: where a daemon's own bundle
20
+ * hops out to, which need not be the shared app the operator came from. */
21
+ const APP_HOST = "app.example.test";
22
+ /** Every subset of the tab set, as a Set — 8 of them. */
23
+ function pinPowerset() {
24
+ const out = [];
25
+ for (let mask = 0; mask < 1 << CANONICAL_TABS.length; mask++) {
26
+ const pins = new Set();
27
+ CANONICAL_TABS.forEach((t, i) => { if (mask & (1 << i))
28
+ pins.add(t); });
29
+ out.push(pins);
30
+ }
31
+ return out;
32
+ }
33
+ const LINE_CASES = [
34
+ {},
35
+ { line: 10 },
36
+ { line: 10, lineEnd: 20 },
37
+ { line: 10, lineEnd: 10 }, // degenerate range
38
+ { line: 10, lineEnd: 5 }, // end before start
39
+ { lineEnd: 20 }, // end with no start
40
+ ];
41
+ const PATH_CASES = [
42
+ undefined,
43
+ "",
44
+ "src/daemon/server.ts",
45
+ "docs/a file with spaces.md",
46
+ "wiki/why-tailscale.md",
47
+ ];
48
+ /** The full grid: 3 tabs × 8 pin subsets × 5 paths × 6 line specs = 720. */
49
+ function viewCases() {
50
+ const out = [];
51
+ for (const tab of CANONICAL_TABS) {
52
+ for (const pins of pinPowerset()) {
53
+ for (const path of PATH_CASES) {
54
+ for (const line of LINE_CASES) {
55
+ out.push({ tab, pins, path, ...line });
56
+ }
57
+ }
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+ const CASES = viewCases();
63
+ function params(c) {
64
+ return viewParams(c.tab, c.pins, c.path, c.line, c.lineEnd);
65
+ }
66
+ /** How a case should read once it has been through a URL and back. */
67
+ function expectedLine(c) {
68
+ if (!c.line)
69
+ return undefined;
70
+ return c.lineEnd && c.lineEnd > c.line ? `${c.line}-${c.lineEnd}` : String(c.line);
71
+ }
72
+ function label(c) {
73
+ return `${c.tab} pins=[${serializePinned(c.pins) || "-"}] path=${c.path ?? "-"} line=${c.line ?? "-"}${c.lineEnd ? `-${c.lineEnd}` : ""}`;
74
+ }
75
+ describe("viewParams", () => {
76
+ it("emits undefined for every default, never an empty string", () => {
77
+ // undefined is what clears a param, both in router.setParams and in
78
+ // buildIsolateUrl's overlay. "" would write `?tab=` and stick.
79
+ const p = viewParams("chat", new Set());
80
+ assert.deepEqual(p, { tab: undefined, pinned: undefined, path: undefined, line: undefined });
81
+ for (const c of CASES) {
82
+ for (const [key, value] of Object.entries(params(c))) {
83
+ assert.notEqual(value, "", `${label(c)} → ${key} was empty string`);
84
+ }
85
+ }
86
+ });
87
+ it("omits the chat tab and names every other one", () => {
88
+ for (const c of CASES) {
89
+ assert.equal(params(c).tab, c.tab === "chat" ? undefined : c.tab, label(c));
90
+ }
91
+ });
92
+ it("serializes pins in canonical order, whatever order they went in", () => {
93
+ for (const c of CASES) {
94
+ const p = params(c);
95
+ if (c.pins.size === 0) {
96
+ assert.equal(p.pinned, undefined, label(c));
97
+ continue;
98
+ }
99
+ const expected = CANONICAL_TABS.filter((t) => c.pins.has(t)).join(",");
100
+ assert.equal(p.pinned, expected, label(c));
101
+ // Same pins added back-to-front produce the same string.
102
+ const reversed = new Set([...c.pins].reverse());
103
+ assert.equal(serializePinned(reversed), expected, label(c));
104
+ }
105
+ });
106
+ it("carries a path only when there is one", () => {
107
+ for (const c of CASES) {
108
+ assert.equal(params(c).path, c.path ? c.path : undefined, label(c));
109
+ }
110
+ });
111
+ it("writes a range only when the end is past the start", () => {
112
+ for (const c of CASES) {
113
+ assert.equal(params(c).line, expectedLine(c), label(c));
114
+ }
115
+ assert.equal(viewParams("files", new Set(), "a.ts", 10, 20).line, "10-20");
116
+ assert.equal(viewParams("files", new Set(), "a.ts", 10, 10).line, "10");
117
+ assert.equal(viewParams("files", new Set(), "a.ts", 10, 5).line, "10");
118
+ assert.equal(viewParams("files", new Set(), "a.ts", undefined, 20).line, undefined);
119
+ });
120
+ it("round-trips through the parsers it is read back with", () => {
121
+ for (const c of CASES) {
122
+ const p = params(c);
123
+ assert.equal(parseActiveTab(p.tab), c.tab, label(c));
124
+ assert.deepEqual([...parsePinnedTabs(p.pinned)].sort(), [...c.pins].sort(), label(c));
125
+ const spec = parseLineSpec(p.line);
126
+ assert.equal(spec.line, c.line, label(c));
127
+ // The one asymmetry, and it is deliberate: a range whose end equals its
128
+ // start is written bare, so it reads back as a single line. Same lines.
129
+ const expectedEnd = c.line && c.lineEnd && c.lineEnd > c.line ? c.lineEnd : undefined;
130
+ assert.equal(spec.lineEnd, expectedEnd, label(c));
131
+ }
132
+ });
133
+ });
134
+ describe("parseActiveTab / parsePinnedTabs", () => {
135
+ it("falls back to chat for anything unrecognized", () => {
136
+ for (const bad of [undefined, "", " ", "Chat", "wiki", "files,terminal", "0", "null"]) {
137
+ assert.equal(parseActiveTab(bad), "chat", JSON.stringify(bad));
138
+ }
139
+ for (const tab of CANONICAL_TABS)
140
+ assert.equal(parseActiveTab(tab), tab);
141
+ });
142
+ it("keeps the tabs it knows and drops the rest", () => {
143
+ const cases = [
144
+ [undefined, []],
145
+ ["", []],
146
+ ["files", ["files"]],
147
+ ["files,terminal", ["files", "terminal"]],
148
+ [" files , terminal ", ["files", "terminal"]],
149
+ ["files,wiki,terminal", ["files", "terminal"]],
150
+ ["wiki", []],
151
+ ["files,files", ["files"]],
152
+ ["chat,terminal,files", ["chat", "terminal", "files"]],
153
+ ];
154
+ for (const [input, expected] of cases) {
155
+ assert.deepEqual([...parsePinnedTabs(input)].sort(), [...expected].sort(), JSON.stringify(input));
156
+ }
157
+ });
158
+ });
159
+ describe("isIsolated", () => {
160
+ it("is true only when the page is served by the daemon it is connected to", () => {
161
+ assert.equal(isIsolated(CONN.host, CONN.host), true);
162
+ assert.equal(isIsolated("CHALK.tail1234.TS.NET", CONN.host), true, "hosts are case-insensitive");
163
+ for (const host of [SHARED_SPA_HOST, "brane.organelle.dev", "main.brane.organelle.dev", "localhost:8081", "127.0.0.1:8081"]) {
164
+ assert.equal(isIsolated(host, CONN.host), false, host);
165
+ }
166
+ });
167
+ it("counts the port: a daemon's bundle is reached on its port", () => {
168
+ assert.equal(isIsolated("192.168.1.20:7777", "192.168.1.20:7777"), true);
169
+ assert.equal(isIsolated("192.168.1.20:7777", "192.168.1.20:8888"), false);
170
+ assert.equal(isIsolated("192.168.1.20", "192.168.1.20:7777"), false);
171
+ });
172
+ });
173
+ /** Parse a built URL back into something assertable. */
174
+ /** A built URL, taken apart the way the app takes it apart: `params` is the
175
+ * query string (the view), `conn` is the fragment (the connection). */
176
+ function open(url) {
177
+ const u = new URL(url);
178
+ return { origin: u.origin, pathname: u.pathname, params: u.searchParams, conn: new URLSearchParams(u.hash.slice(1)) };
179
+ }
180
+ describe("withoutConnectionParams", () => {
181
+ // The shared SPA cleans the meld params out of the URL once it has used
182
+ // them. It used to do that by resetting to the bare pathname, which threw
183
+ // away the view an Isolate hop had just carried across — so these are the
184
+ // assertions that keep the cleaning surgical.
185
+ it("drops the connection params and nothing else", () => {
186
+ const cases = [
187
+ ["?host=h:1&token=t", ""],
188
+ ["?token=t", ""],
189
+ ["?host=h:1", ""],
190
+ ["", ""],
191
+ ["?", ""],
192
+ ["?tab=files&host=h:1&token=t", "?tab=files"],
193
+ ["?host=h:1&tab=files&token=t&pinned=terminal", "?tab=files&pinned=terminal"],
194
+ ["?scrollToSeq=42&token=t", "?scrollToSeq=42"],
195
+ ["?tab=files&pinned=chat%2Cfiles&path=src%2Fa.ts&line=10-20",
196
+ "?tab=files&pinned=chat%2Cfiles&path=src%2Fa.ts&line=10-20"],
197
+ // No leading ? is fine, and the return value always has one when non-empty.
198
+ ["tab=files&token=t", "?tab=files"],
199
+ ];
200
+ for (const [input, expected] of cases) {
201
+ assert.equal(withoutConnectionParams(input), expected, JSON.stringify(input));
202
+ }
203
+ });
204
+ it("removes repeated connection params, not just the first", () => {
205
+ assert.equal(withoutConnectionParams("?token=a&token=b&tab=files"), "?tab=files");
206
+ assert.equal(withoutConnectionParams("?host=a&host=b"), "");
207
+ });
208
+ });
209
+ describe("withoutConnectionHash", () => {
210
+ // The fragment is where links carry the connection now, so the cleaner has
211
+ // a fragment half. Same surgery: the connection goes, anything else stays.
212
+ it("drops the connection params and nothing else", () => {
213
+ const cases = [
214
+ ["#host=h:1&token=t", ""],
215
+ ["#token=t", ""],
216
+ ["#host=h%3A1&token=t", ""],
217
+ ["", ""],
218
+ ["#", ""],
219
+ ["#other=1&token=t", "#other=1"],
220
+ ["host=h:1&token=t", ""],
221
+ ["#token=a&token=b&x=1", "#x=1"],
222
+ ];
223
+ for (const [input, expected] of cases) {
224
+ assert.equal(withoutConnectionHash(input), expected, JSON.stringify(input));
225
+ }
226
+ });
227
+ });
228
+ describe("connectionFragment", () => {
229
+ it("carries host and token for the shared SPA, token alone for a daemon's own bundle", () => {
230
+ const shared = new URLSearchParams(connectionFragment(CONN, true).slice(1));
231
+ assert.equal(shared.get("host"), CONN.host);
232
+ assert.equal(shared.get("token"), CONN.token);
233
+ const own = new URLSearchParams(connectionFragment(CONN, false).slice(1));
234
+ assert.equal(own.get("host"), null);
235
+ assert.equal(own.get("token"), CONN.token);
236
+ });
237
+ it("round-trips a host with a port through the fragment", () => {
238
+ const conn = { host: "127.0.0.1:58353", token: "t" };
239
+ const frag = connectionFragment(conn, true);
240
+ assert.ok(frag.startsWith("#"));
241
+ assert.equal(new URLSearchParams(frag.slice(1)).get("host"), conn.host);
242
+ });
243
+ it("is what the cleaner removes, leaving nothing", () => {
244
+ assert.equal(withoutConnectionHash(connectionFragment(CONN, true)), "");
245
+ assert.equal(withoutConnectionHash(connectionFragment(CONN, false)), "");
246
+ });
247
+ it("leaves a view intact all the way from Isolate to the cleaned URL", () => {
248
+ // The end-to-end shape: build the hop, then clean it the way the SPA does
249
+ // on arrival. What's left is the view, and only the view.
250
+ for (const c of CASES) {
251
+ const view = params(c);
252
+ const url = new URL(buildIsolateUrl({
253
+ host: CONN.host,
254
+ appHost: APP_HOST,
255
+ protocol: "https:",
256
+ search: "?scrollToSeq=42",
257
+ path: `/${NID}`,
258
+ conn: CONN,
259
+ view,
260
+ }));
261
+ const cleaned = new URLSearchParams(withoutConnectionParams(url.search));
262
+ assert.equal(cleaned.get("token"), null, label(c));
263
+ assert.equal(cleaned.get("host"), null, label(c));
264
+ assert.equal(withoutConnectionHash(url.hash), "", label(c));
265
+ // The query string never carried the connection in the first place.
266
+ assert.equal(url.searchParams.get("token"), null, label(c));
267
+ assert.equal(url.searchParams.get("host"), null, label(c));
268
+ assert.equal(cleaned.get("scrollToSeq"), "42", label(c));
269
+ for (const key of ["tab", "pinned", "path", "line"]) {
270
+ assert.equal(cleaned.get(key), view[key] ?? null, `${label(c)} → ${key}`);
271
+ }
272
+ }
273
+ });
274
+ });
275
+ describe("buildIsolateUrl", () => {
276
+ it("targets the shared SPA when leaving a daemon, and melds it back", () => {
277
+ const { origin, pathname, params, conn } = open(buildIsolateUrl({
278
+ host: CONN.host,
279
+ appHost: APP_HOST,
280
+ protocol: "https:",
281
+ search: "?token=old-token",
282
+ path: `/${NID}`,
283
+ conn: CONN,
284
+ view: viewParams("files", new Set(["terminal"]), "src/cli.ts", 4, 9),
285
+ }));
286
+ // Out of a daemon's bundle, the target is what the daemon reported, not
287
+ // any host the app knows.
288
+ assert.equal(origin, `https://${APP_HOST}`);
289
+ assert.equal(pathname, `/${NID}`);
290
+ // The connection rides in the fragment, never the query string: a query
291
+ // string is sent to the SPA's host, a fragment is not.
292
+ assert.equal(conn.get("host"), CONN.host);
293
+ assert.equal(conn.get("token"), CONN.token);
294
+ assert.equal(params.get("host"), null);
295
+ assert.equal(params.get("token"), null);
296
+ assert.equal(params.get("tab"), "files");
297
+ assert.equal(params.get("pinned"), "terminal");
298
+ assert.equal(params.get("path"), "src/cli.ts");
299
+ assert.equal(params.get("line"), "4-9");
300
+ });
301
+ it("targets the daemon's own bundle when leaving the SPA, with no host param", () => {
302
+ const { origin, pathname, params, conn } = open(buildIsolateUrl({
303
+ host: SHARED_SPA_HOST,
304
+ protocol: "https:",
305
+ search: `?host=${CONN.host}&token=${CONN.token}`,
306
+ path: `/${NID}`,
307
+ conn: CONN,
308
+ view: viewParams("terminal", new Set(["chat"])),
309
+ }));
310
+ assert.equal(origin, `https://${CONN.host}`);
311
+ assert.equal(pathname, `/${NID}`);
312
+ // The daemon's own bundle knows which daemon it is; host would be noise.
313
+ assert.equal(conn.get("host"), null);
314
+ assert.equal(conn.get("token"), CONN.token);
315
+ assert.equal(params.get("token"), null);
316
+ assert.equal(params.get("tab"), "terminal");
317
+ assert.equal(params.get("pinned"), "chat");
318
+ });
319
+ it("follows the page's scheme to the daemon, so plain http stays http", () => {
320
+ for (const [protocol, scheme] of [["https:", "https"], ["http:", "http"]]) {
321
+ const { origin } = open(buildIsolateUrl({
322
+ host: SHARED_SPA_HOST,
323
+ protocol,
324
+ search: "",
325
+ path: `/${NID}`,
326
+ conn: CONN,
327
+ view: viewParams("chat", new Set()),
328
+ }));
329
+ assert.equal(origin, `${scheme}://${CONN.host}`, protocol);
330
+ }
331
+ // The hosted shell is https-only, whatever the daemon's bundle you left
332
+ // was served over (a LAN session is plain http).
333
+ const { origin } = open(buildIsolateUrl({
334
+ host: "192.168.1.20:7777",
335
+ protocol: "http:",
336
+ search: "",
337
+ path: `/${NID}`,
338
+ conn: { host: "192.168.1.20:7777", token: CONN.token },
339
+ appHost: APP_HOST,
340
+ view: viewParams("chat", new Set()),
341
+ }));
342
+ assert.equal(origin, `https://${APP_HOST}`);
343
+ });
344
+ it("has nowhere to go from a daemon's bundle that reported no app host, and says so", () => {
345
+ // A daemon from before `app_domain` rode in /api/status, or one the
346
+ // status probe could not reach: the menu hides Isolate rather than
347
+ // handing out a link to a host the app made up.
348
+ for (const appHost of [undefined, null, ""]) {
349
+ assert.equal(buildIsolateUrl({ host: CONN.host, protocol: "https:", search: "", path: `/${NID}`, conn: CONN, appHost, view: {} }), null, String(appHost));
350
+ }
351
+ // From a shared app the way *in* needs no app host at all.
352
+ assert.ok(buildIsolateUrl({ host: SHARED_SPA_HOST, protocol: "https:", search: "", path: `/${NID}`, conn: CONN, view: {} }));
353
+ });
354
+ it("treats any host serving the bundle as a shared app, and hops in to the daemon from it", () => {
355
+ for (const host of ["brane.organelle.dev", "main.brane.organelle.dev", "fork.example", "localhost:8081"]) {
356
+ const { origin, conn } = open(buildIsolateUrl({ host, protocol: "https:", search: "", path: `/${NID}`, conn: CONN, view: {} }));
357
+ assert.equal(origin, `https://${CONN.host}`, host);
358
+ assert.equal(conn.get("host"), null, host);
359
+ }
360
+ });
361
+ it("never carries the departing origin's credentials", () => {
362
+ // A stale host/token in the search string is the danger case: the SPA's
363
+ // meld params must not follow you onto the daemon's own origin, where the
364
+ // host is wrong and the URL would carry a second copy of the token.
365
+ for (const host of [SHARED_SPA_HOST, CONN.host]) {
366
+ const { params, conn } = open(buildIsolateUrl({
367
+ host,
368
+ appHost: APP_HOST,
369
+ protocol: "https:",
370
+ search: "?host=stale.example.com&token=stale-token&tab=files",
371
+ path: `/${NID}`,
372
+ conn: CONN,
373
+ view: viewParams("files", new Set()),
374
+ }));
375
+ assert.deepEqual(params.getAll("host"), [], host);
376
+ assert.deepEqual(params.getAll("token"), [], host);
377
+ assert.notEqual(conn.get("host"), "stale.example.com", host);
378
+ assert.deepEqual(conn.getAll("token"), [CONN.token], host);
379
+ }
380
+ });
381
+ it("keeps params it doesn't model, like the scroll target", () => {
382
+ const { params } = open(buildIsolateUrl({
383
+ host: SHARED_SPA_HOST,
384
+ protocol: "https:",
385
+ search: "?scrollToSeq=42&scrollTo=1787255699631&theme=dark",
386
+ path: `/${NID}`,
387
+ conn: CONN,
388
+ view: viewParams("chat", new Set()),
389
+ }));
390
+ assert.equal(params.get("scrollToSeq"), "42");
391
+ assert.equal(params.get("scrollTo"), "1787255699631");
392
+ assert.equal(params.get("theme"), "dark");
393
+ });
394
+ it("lets live state overwrite a stale param in the search string", () => {
395
+ // This is why the URL alone isn't enough: what's in the address bar can be
396
+ // older than what you're looking at.
397
+ const { params } = open(buildIsolateUrl({
398
+ host: SHARED_SPA_HOST,
399
+ protocol: "https:",
400
+ search: "?tab=chat&pinned=chat&path=old/file.ts&line=1",
401
+ path: `/${NID}`,
402
+ conn: CONN,
403
+ view: viewParams("files", new Set(["terminal", "files"]), "new/file.ts", 30, 40),
404
+ }));
405
+ assert.equal(params.get("tab"), "files");
406
+ assert.equal(params.get("pinned"), "terminal,files");
407
+ assert.equal(params.get("path"), "new/file.ts");
408
+ assert.equal(params.get("line"), "30-40");
409
+ });
410
+ it("lets live state at its default clear a stale param", () => {
411
+ const { params } = open(buildIsolateUrl({
412
+ host: SHARED_SPA_HOST,
413
+ protocol: "https:",
414
+ search: "?tab=files&pinned=terminal&path=old/file.ts&line=1-9",
415
+ path: `/${NID}`,
416
+ conn: CONN,
417
+ view: viewParams("chat", new Set()),
418
+ }));
419
+ for (const key of ["tab", "pinned", "path", "line"]) {
420
+ assert.equal(params.get(key), null, key);
421
+ }
422
+ });
423
+ it("carries the layout a sessionStorage restore left out of the URL", () => {
424
+ // The restore path deliberately skips router.setParams, so this is the
425
+ // case the whole overlay exists for: an empty search string, a full view.
426
+ const { params } = open(buildIsolateUrl({
427
+ host: SHARED_SPA_HOST,
428
+ protocol: "https:",
429
+ search: "",
430
+ path: `/${NID}`,
431
+ conn: CONN,
432
+ view: viewParams("files", new Set(["chat", "files"]), "src/shared/view-url.ts", 12),
433
+ }));
434
+ assert.equal(params.get("tab"), "files");
435
+ assert.equal(params.get("pinned"), "chat,files");
436
+ assert.equal(params.get("path"), "src/shared/view-url.ts");
437
+ assert.equal(params.get("line"), "12");
438
+ });
439
+ it("accepts a search string with or without its leading ?", () => {
440
+ const view = viewParams("files", new Set(), "a.ts");
441
+ const withQ = open(buildIsolateUrl({ host: SHARED_SPA_HOST, protocol: "https:", search: "?scrollToSeq=7", path: `/${NID}`, conn: CONN, view }));
442
+ const without = open(buildIsolateUrl({ host: SHARED_SPA_HOST, protocol: "https:", search: "scrollToSeq=7", path: `/${NID}`, conn: CONN, view }));
443
+ assert.equal(withQ.params.get("scrollToSeq"), "7");
444
+ assert.deepEqual([...withQ.params], [...without.params]);
445
+ });
446
+ it("encodes paths that would otherwise break the URL", () => {
447
+ for (const path of ["docs/a file with spaces.md", "src/c#sharp.ts", "a/b/c/d.ts", "notes/100% done.md", "x?y=z.ts", "π/unicode.ts"]) {
448
+ const url = buildIsolateUrl({
449
+ host: SHARED_SPA_HOST,
450
+ protocol: "https:",
451
+ search: "",
452
+ path: `/${NID}`,
453
+ conn: CONN,
454
+ view: viewParams("files", new Set(), path),
455
+ });
456
+ assert.equal(open(url).params.get("path"), path, path);
457
+ }
458
+ });
459
+ it("preserves every view in the grid, in both directions", () => {
460
+ for (const c of CASES) {
461
+ const view = params(c);
462
+ for (const host of [SHARED_SPA_HOST, CONN.host]) {
463
+ const { params: got, conn } = open(buildIsolateUrl({
464
+ host, appHost: APP_HOST, protocol: "https:", search: "", path: `/${NID}`, conn: CONN, view,
465
+ }));
466
+ assert.equal(got.get("tab"), view.tab ?? null, label(c));
467
+ assert.equal(got.get("pinned"), view.pinned ?? null, label(c));
468
+ assert.equal(got.get("path"), view.path ?? null, label(c));
469
+ assert.equal(got.get("line"), view.line ?? null, label(c));
470
+ assert.equal(conn.get("token"), CONN.token, label(c));
471
+ assert.equal(got.get("token"), null, label(c));
472
+ }
473
+ }
474
+ });
475
+ it("round-trips: isolate, unisolate, and you are where you started", () => {
476
+ // The invariant behind both halves of the menu item. Leave the SPA, come
477
+ // back, and the view — plus the scroll target the screen doesn't model —
478
+ // is intact.
479
+ for (const c of CASES) {
480
+ const view = params(c);
481
+ const out = new URL(buildIsolateUrl({
482
+ host: SHARED_SPA_HOST,
483
+ protocol: "https:",
484
+ search: "?scrollToSeq=42",
485
+ path: `/${NID}`,
486
+ conn: CONN,
487
+ view,
488
+ }));
489
+ const back = new URL(buildIsolateUrl({
490
+ host: out.host,
491
+ protocol: out.protocol,
492
+ // The daemon's bundle is told where out is, as /api/status would.
493
+ appHost: APP_HOST,
494
+ search: out.search,
495
+ path: `/${NID}`,
496
+ conn: CONN,
497
+ // Re-derived on the far side from the params it was handed, exactly as
498
+ // the screen does when it mounts.
499
+ view: viewParams(parseActiveTab(out.searchParams.get("tab") ?? undefined), parsePinnedTabs(out.searchParams.get("pinned") ?? undefined), out.searchParams.get("path") ?? undefined, parseLineSpec(out.searchParams.get("line")).line, parseLineSpec(out.searchParams.get("line")).lineEnd),
500
+ }));
501
+ // Back out lands where the daemon says, which is the page we left only
502
+ // if its app_domain names it; here it names APP_HOST.
503
+ assert.equal(back.origin, `https://${APP_HOST}`, label(c));
504
+ assert.equal(back.pathname, `/${NID}`, label(c));
505
+ assert.equal(back.searchParams.get("scrollToSeq"), "42", label(c));
506
+ const conn = new URLSearchParams(back.hash.slice(1));
507
+ assert.equal(conn.get("host"), CONN.host, label(c));
508
+ assert.equal(conn.get("token"), CONN.token, label(c));
509
+ assert.equal(back.searchParams.get("host"), null, label(c));
510
+ assert.equal(back.searchParams.get("token"), null, label(c));
511
+ for (const key of ["tab", "pinned", "path", "line"]) {
512
+ assert.equal(back.searchParams.get(key), view[key] ?? null, `${label(c)} → ${key}`);
513
+ }
514
+ }
515
+ });
516
+ });
517
+ describe("buildMeldUrl", () => {
518
+ // Meld aims at another device, not another origin. Isolate flips the origin
519
+ // kind; this has to stay on it — hand a phone a shared-SPA link when the
520
+ // operator is reaching the daemon directly and the phone loads an app that
521
+ // has never heard of that daemon.
522
+ it("stays on the shared SPA when that's where you are", () => {
523
+ const url = new URL(buildMeldUrl({
524
+ host: SHARED_SPA_HOST,
525
+ protocol: "https:",
526
+ search: "",
527
+ path: `/${NID}`,
528
+ conn: CONN,
529
+ view: viewParams("files", new Set(["terminal"]), "src/cli.ts", 4, 9),
530
+ }));
531
+ assert.equal(url.origin, `https://${SHARED_SPA_HOST}`);
532
+ assert.equal(url.pathname, `/${NID}`);
533
+ // The scanning device has never met this daemon, so it needs both — in
534
+ // the fragment, which the QR carries and no server ever receives.
535
+ const conn = new URLSearchParams(url.hash.slice(1));
536
+ assert.equal(conn.get("host"), CONN.host);
537
+ assert.equal(conn.get("token"), CONN.token);
538
+ assert.equal(url.searchParams.get("host"), null);
539
+ assert.equal(url.searchParams.get("token"), null);
540
+ assert.equal(url.searchParams.get("tab"), "files");
541
+ assert.equal(url.searchParams.get("pinned"), "terminal");
542
+ assert.equal(url.searchParams.get("path"), "src/cli.ts");
543
+ assert.equal(url.searchParams.get("line"), "4-9");
544
+ });
545
+ it("stays on the daemon's own bundle when that's where you are", () => {
546
+ const url = new URL(buildMeldUrl({
547
+ host: CONN.host,
548
+ appHost: APP_HOST,
549
+ protocol: "https:",
550
+ search: "",
551
+ path: `/${NID}`,
552
+ conn: CONN,
553
+ view: viewParams("terminal", new Set()),
554
+ }));
555
+ assert.equal(url.origin, `https://${CONN.host}`);
556
+ const conn = new URLSearchParams(url.hash.slice(1));
557
+ assert.equal(conn.get("host"), null);
558
+ assert.equal(conn.get("token"), CONN.token);
559
+ assert.equal(url.searchParams.get("token"), null);
560
+ assert.equal(url.searchParams.get("tab"), "terminal");
561
+ });
562
+ it("goes the opposite way from Isolate, from either origin", () => {
563
+ // The whole difference between the two menu items, stated once.
564
+ for (const host of [SHARED_SPA_HOST, CONN.host]) {
565
+ const opts = {
566
+ host,
567
+ appHost: APP_HOST,
568
+ protocol: "https:",
569
+ search: "",
570
+ path: `/${NID}`,
571
+ conn: CONN,
572
+ view: viewParams("chat", new Set()),
573
+ };
574
+ const meld = new URL(buildMeldUrl(opts));
575
+ const isolate = new URL(buildIsolateUrl(opts));
576
+ assert.equal(meld.host, host, `meld stays put from ${host}`);
577
+ assert.notEqual(isolate.host, host, `isolate flips from ${host}`);
578
+ }
579
+ });
580
+ it("carries the same view Isolate would, over the whole grid", () => {
581
+ for (const c of CASES) {
582
+ const view = params(c);
583
+ for (const host of [SHARED_SPA_HOST, CONN.host]) {
584
+ const url = new URL(buildMeldUrl({
585
+ host, appHost: APP_HOST, protocol: "https:", search: "?scrollToSeq=42", path: `/${NID}`, conn: CONN, view,
586
+ }));
587
+ assert.equal(url.searchParams.get("scrollToSeq"), "42", label(c));
588
+ assert.equal(new URLSearchParams(url.hash.slice(1)).get("token"), CONN.token, label(c));
589
+ assert.equal(url.searchParams.get("token"), null, label(c));
590
+ for (const key of ["tab", "pinned", "path", "line"]) {
591
+ assert.equal(url.searchParams.get(key), view[key] ?? null, `${label(c)} -> ${key}`);
592
+ }
593
+ }
594
+ }
595
+ });
596
+ it("hands out an http link when the page itself is http", () => {
597
+ // A LAN session without TLS must not emit https links the phone can't load.
598
+ const url = new URL(buildMeldUrl({
599
+ host: "192.168.1.20:7777",
600
+ protocol: "http:",
601
+ search: "",
602
+ path: `/${NID}`,
603
+ conn: { host: "192.168.1.20:7777", token: CONN.token },
604
+ view: viewParams("chat", new Set()),
605
+ }));
606
+ assert.equal(url.protocol, "http:");
607
+ });
608
+ });
609
+ describe("terminalOnlyHref", () => {
610
+ it("names the terminal and pins nothing", () => {
611
+ const url = new URL(terminalOnlyHref(NID), "https://example.test");
612
+ assert.equal(url.pathname, `/${NID}`);
613
+ assert.equal(url.searchParams.get("tab"), "terminal");
614
+ assert.equal(url.searchParams.get("pinned"), null);
615
+ assert.equal(url.searchParams.get("path"), null);
616
+ assert.equal(url.searchParams.get("line"), null);
617
+ });
618
+ it("reads back as terminal-only through the same parsers the screen uses", () => {
619
+ // The point of the href is the view it produces, so assert the round trip
620
+ // rather than the string: a `tab` param the screen wouldn't parse back to
621
+ // "terminal" would land a revive or an End in the chat tab, silently.
622
+ const url = new URL(terminalOnlyHref(NID), "https://example.test");
623
+ assert.equal(parseActiveTab(url.searchParams.get("tab") ?? undefined), "terminal");
624
+ assert.equal(parsePinnedTabs(url.searchParams.get("pinned") ?? undefined).size, 0);
625
+ });
626
+ });
627
+ //# sourceMappingURL=view-url.test.js.map