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