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,150 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { narrativeMenuItems, revivesAsTwin, } from "../shared/narrative-menu.js";
4
+ // Every handler distinct, so a test can tell which item was wired to what — the
5
+ // failure this guards against is not a missing item but a mislabeled one.
6
+ function options(over = {}) {
7
+ return {
8
+ onMeld: () => { },
9
+ onIsolate: () => { },
10
+ onExport: () => { },
11
+ onRecolor: () => { },
12
+ onTwin: () => { },
13
+ onRevive: () => { },
14
+ onEnd: () => { },
15
+ onArchiveToggle: () => { },
16
+ ...over,
17
+ };
18
+ }
19
+ const labels = (over = {}) => narrativeMenuItems(options(over)).map((i) => i.label);
20
+ describe("narrativeMenuItems — Export scope", () => {
21
+ const exportItems = (over = {}) => narrativeMenuItems(options(over)).filter((i) => i.label.startsWith("Export"));
22
+ const exportLabels = (over = {}) => exportItems(over).map((i) => i.label);
23
+ it("is a single plain Export with no selection", () => {
24
+ assert.deepEqual(exportLabels(), ["Export"]);
25
+ assert.deepEqual(exportLabels({ selectedCount: 0 }), ["Export"]);
26
+ });
27
+ it("offers both sides of a selection, each naming its count", () => {
28
+ assert.deepEqual(exportLabels({ selectedCount: 1 }), [
29
+ "Export 1 selected",
30
+ "Export 1 excluded",
31
+ ]);
32
+ assert.deepEqual(exportLabels({ selectedCount: 12 }), [
33
+ "Export 12 selected",
34
+ "Export 12 excluded",
35
+ ]);
36
+ });
37
+ it("gives the two items distinct icons", () => {
38
+ // They sit adjacent in the menu and differ only in a word; an operator
39
+ // scanning icons should not have to read the label to tell them apart.
40
+ const icons = exportItems({ selectedCount: 3 }).map((i) => i.icon);
41
+ assert.deepEqual(icons, ["📨", "📤"]);
42
+ });
43
+ it("tells the press which scope was asked for", () => {
44
+ // The screen exports the selection or its complement off this argument
45
+ // alone — a handler that ignored it would export the same file twice.
46
+ const scopes = [];
47
+ for (const item of exportItems({ selectedCount: 2, onExport: (s) => scopes.push(s) })) {
48
+ item.onPress();
49
+ }
50
+ assert.deepEqual(scopes, ["selected", "excluded"]);
51
+ });
52
+ it("asks for the whole conversation when nothing is selected", () => {
53
+ const scopes = [];
54
+ exportItems({ onExport: (s) => scopes.push(s) })[0].onPress();
55
+ assert.deepEqual(scopes, ["all"]);
56
+ });
57
+ it("changes nothing else about the menu", () => {
58
+ assert.deepEqual(labels({ status: "active", mcpConnected: true, selectedCount: 2 }).filter((l) => !l.startsWith("Export")), labels({ status: "active", mcpConnected: true }).filter((l) => !l.startsWith("Export")));
59
+ });
60
+ });
61
+ describe("narrativeMenuItems", () => {
62
+ it("offers the same menu on a running narrative from either surface", () => {
63
+ // The builder exists so the detail header and the list card can't drift.
64
+ // This is that claim: one input, one menu, no surface-dependent branch.
65
+ assert.deepEqual(labels({ status: "active", mcpConnected: true }), [
66
+ "Meld",
67
+ "Isolate",
68
+ "Export",
69
+ "Recolor",
70
+ "Twin",
71
+ "End",
72
+ "Archive",
73
+ ]);
74
+ });
75
+ it("swaps End for Revive once the session is over", () => {
76
+ assert.ok(labels({ status: "active", mcpConnected: true }).includes("End"));
77
+ assert.ok(labels({ status: "ended" }).includes("Revive"));
78
+ assert.ok(!labels({ status: "ended" }).includes("End"));
79
+ });
80
+ it("treats an ended row still holding an MCP bridge as running", () => {
81
+ // A stale `ended` row that the daemon hasn't healed yet still has an agent
82
+ // on the bridge — offering Revive there would restart a live session.
83
+ assert.ok(labels({ status: "ended", mcpConnected: true }).includes("End"));
84
+ });
85
+ it("gives an external narrative no in-place lifecycle item at all", () => {
86
+ // No tmux process to end or resume; Twin is its only lifecycle action.
87
+ const external = labels({ role: "external", status: "ended" });
88
+ assert.ok(!external.includes("Revive"));
89
+ assert.ok(!external.includes("End"));
90
+ assert.ok(external.includes("Twin"));
91
+ });
92
+ it("keeps Revive on an absorbed import, which reaches Twin through it", () => {
93
+ assert.ok(labels({ role: "absorbed", status: "ended" }).includes("Revive"));
94
+ });
95
+ it("flips the archive item both ways", () => {
96
+ assert.ok(labels({ archived: false }).includes("Archive"));
97
+ assert.ok(labels({ archived: true }).includes("Unarchive"));
98
+ });
99
+ it("flips isolate by where the app is served from", () => {
100
+ assert.ok(labels({ isIsolated: false }).includes("Isolate"));
101
+ assert.ok(labels({ isIsolated: true }).includes("Unisolate"));
102
+ });
103
+ it("wires each item to its own handler", () => {
104
+ const fired = [];
105
+ const items = narrativeMenuItems(options({
106
+ status: "ended",
107
+ onMeld: () => fired.push("meld"),
108
+ onIsolate: () => fired.push("isolate"),
109
+ onExport: () => fired.push("export"),
110
+ onRecolor: () => fired.push("recolor"),
111
+ onTwin: () => fired.push("twin"),
112
+ onRevive: () => fired.push("revive"),
113
+ onArchiveToggle: () => fired.push("archive"),
114
+ }));
115
+ for (const item of items)
116
+ item.onPress();
117
+ assert.deepEqual(fired, ["meld", "isolate", "export", "recolor", "twin", "revive", "archive"]);
118
+ });
119
+ it("gives every item an icon and a unique label", () => {
120
+ // ContextMenu keys its buttons by label, so a duplicate would drop a row.
121
+ const items = narrativeMenuItems(options());
122
+ assert.ok(items.every((i) => i.icon));
123
+ assert.equal(new Set(items.map((i) => i.label)).size, items.length);
124
+ });
125
+ });
126
+ describe("revivesAsTwin", () => {
127
+ it("names the roles with nothing to resume", () => {
128
+ assert.equal(revivesAsTwin("external"), true);
129
+ assert.equal(revivesAsTwin("absorbed"), true);
130
+ });
131
+ it("leaves every ordinary role to revive in place", () => {
132
+ for (const role of ["worker", "twin", "keeper", "utility", null, undefined]) {
133
+ assert.equal(revivesAsTwin(role), false, `role: ${role}`);
134
+ }
135
+ });
136
+ });
137
+ describe("narrativeMenuItems — Isolate", () => {
138
+ it("reads Isolate on a shared app and Unisolate on a daemon's own bundle", () => {
139
+ assert.ok(labels().includes("Isolate"));
140
+ assert.ok(labels({ isIsolated: true }).includes("Unisolate"));
141
+ });
142
+ it("is left out when there is nowhere to go, and nothing else moves", () => {
143
+ // A daemon's own bundle whose daemon reported no app host: offering the
144
+ // item would mean a link to a host the app made up.
145
+ const without = labels({ isIsolated: true, canIsolate: false });
146
+ assert.ok(!without.includes("Isolate") && !without.includes("Unisolate"));
147
+ assert.deepEqual(without, labels({ isIsolated: true }).filter((l) => l !== "Unisolate"));
148
+ });
149
+ });
150
+ //# sourceMappingURL=narrative-menu.test.js.map
@@ -0,0 +1,75 @@
1
+ import { test, describe } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, join } from "node:path";
6
+ import { sortNarratives } from "../shared/narrative-sort.js";
7
+ const row = (id, status, created_at, mcp_connected = false) => ({ id, status, created_at, mcp_connected });
8
+ const ids = (rows) => sortNarratives(rows).map((n) => n.id);
9
+ describe("sortNarratives", () => {
10
+ test("sinks ended below everything else", () => {
11
+ const rows = [
12
+ row("dead-old", "ended", 100),
13
+ row("live", "active", 200),
14
+ row("dead-new", "ended", 900),
15
+ row("idle", "idle", 300),
16
+ ];
17
+ assert.deepEqual(ids(rows), ["idle", "live", "dead-new", "dead-old"]);
18
+ });
19
+ test("newest first within each group", () => {
20
+ const rows = [row("a", "active", 100), row("c", "active", 300), row("b", "active", 200)];
21
+ assert.deepEqual(ids(rows), ["c", "b", "a"]);
22
+ });
23
+ // The whole point of the change this file guards: a bridge dropping must not
24
+ // reorder anything. Same rows, `mcp_connected` inverted — same order.
25
+ test("mcp_connected does not affect order", () => {
26
+ const rows = [
27
+ row("first", "active", 300, false),
28
+ row("second", "active", 200, true),
29
+ row("third", "active", 100, false),
30
+ ];
31
+ const flipped = rows.map((n) => ({ ...n, mcp_connected: !n.mcp_connected }));
32
+ assert.deepEqual(ids(rows), ["first", "second", "third"]);
33
+ assert.deepEqual(ids(flipped), ["first", "second", "third"]);
34
+ });
35
+ // A connected-but-ended narrative gets the dead badge, so it must sort with
36
+ // the dead. This is the case where the old three-way rank disagreed with the
37
+ // badge outright: it ranked such a row *first*.
38
+ test("ended sinks even while still bridged", () => {
39
+ const rows = [row("bridged-dead", "ended", 900, true), row("live", "active", 100)];
40
+ assert.deepEqual(ids(rows), ["live", "bridged-dead"]);
41
+ });
42
+ test("does not mutate its input", () => {
43
+ const rows = [row("a", "ended", 300), row("b", "active", 100)];
44
+ const before = rows.map((n) => n.id);
45
+ sortNarratives(rows);
46
+ assert.deepEqual(rows.map((n) => n.id), before);
47
+ });
48
+ test("empty and single-element lists are fine", () => {
49
+ assert.deepEqual(sortNarratives([]), []);
50
+ assert.deepEqual(ids([row("only", "ended", 1)]), ["only"]);
51
+ });
52
+ });
53
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
54
+ const read = (rel) => readFileSync(join(repoRoot, rel), "utf8");
55
+ // Lints source the tarball doesn't ship (`expo/dist/` ships, the `.tsx` does
56
+ // not). From an installed package — `brane doctor` — these skip with a reason
57
+ // instead of failing on ENOENT.
58
+ const skip = existsSync(join(repoRoot, "expo", "app", "index.tsx"))
59
+ ? false
60
+ : "expo source not shipped";
61
+ // Every screen that lists narratives, and the local name each one had for its
62
+ // own copy of the rule before the extraction.
63
+ const LIST_SCREENS = ["expo/app/index.tsx", "expo/app/[nid].tsx"];
64
+ describe("narrative-listing screens share one ordering rule", () => {
65
+ for (const file of LIST_SCREENS) {
66
+ test(file, { skip }, () => {
67
+ const src = read(file);
68
+ assert.match(src, /import \{ sortNarratives \} from "@shared\/narrative-sort"/, `${file} must order narratives through @shared/narrative-sort`);
69
+ // A local `rank` over `mcp_connected` is exactly the shape that drifted.
70
+ assert.doesNotMatch(src, /rank\s*=\s*\([^)]*\)\s*=>[\s\S]{0,80}?mcp_connected/, `${file} must not rank narratives on mcp_connected — use sortNarratives`);
71
+ assert.doesNotMatch(src, /function sortNarratives\b/, `${file} must not define its own sortNarratives`);
72
+ });
73
+ }
74
+ });
75
+ //# sourceMappingURL=narrative-sort.test.js.map
@@ -0,0 +1,60 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { TAP_FOCUS_GRACE_MS, dragAllowed, isTextEntry, layoutHeight, mustBlur, scrollBoxOf } from "../shared/no-pan.js";
4
+ const box = (scrollTop, clientHeight = 100, scrollHeight = 300, overflowY = "auto", reversed = false) => scrollBoxOf({ overflowY, reversed, scrollTop, clientHeight, scrollHeight });
5
+ const plain = { scrolls: false, roomAbove: 0, roomBelow: 0 };
6
+ describe("no-pan", () => {
7
+ it("takes over taps on text fields only", () => {
8
+ assert.equal(isTextEntry({ tagName: "INPUT", type: "search" }), true);
9
+ assert.equal(isTextEntry({ tagName: "input" }), true, "no type is text");
10
+ assert.equal(isTextEntry({ tagName: "TEXTAREA" }), true);
11
+ assert.equal(isTextEntry({ tagName: "DIV", isContentEditable: true }), true);
12
+ assert.equal(isTextEntry({ tagName: "INPUT", type: "checkbox" }), false);
13
+ assert.equal(isTextEntry({ tagName: "INPUT", type: "submit" }), false);
14
+ assert.equal(isTextEntry({ tagName: "INPUT", type: "text", disabled: true }), false);
15
+ assert.equal(isTextEntry({ tagName: "TEXTAREA", readOnly: true }), false);
16
+ assert.equal(isTextEntry({ tagName: "BUTTON" }), false);
17
+ });
18
+ it("refuses a vertical drag nothing under the finger can scroll — the header, a bar", () => {
19
+ assert.equal(dragAllowed(0, 30, [plain, plain]), false);
20
+ assert.equal(dragAllowed(0, -30, [plain]), false);
21
+ });
22
+ it("lets a scroller take the drag while it can move that way", () => {
23
+ assert.equal(dragAllowed(0, 30, [plain, box(50)]), true, "finger down, content can go toward its top");
24
+ assert.equal(dragAllowed(0, -30, [plain, box(50)]), true);
25
+ });
26
+ it("refuses the drag a scroller at its end would hand on to the page", () => {
27
+ assert.equal(dragAllowed(0, 30, [box(0)]), false, "at the top, pulling down");
28
+ assert.equal(dragAllowed(0, -30, [box(200)]), false, "at the bottom, pushing up");
29
+ assert.equal(dragAllowed(0, -30, [box(200), box(10)]), true, "an outer scroller can still take it");
30
+ });
31
+ it("ignores boxes that are not scrollers, or have nothing to scroll", () => {
32
+ assert.equal(dragAllowed(0, -30, [box(0, 100, 300, "hidden")]), false);
33
+ assert.equal(dragAllowed(0, -30, [box(0, 100, 100)]), false);
34
+ });
35
+ it("reads a column-reverse scroller (the chat) the right way round", () => {
36
+ // At its bottom a reversed scroller's scrollTop is 0 and history is above.
37
+ assert.equal(dragAllowed(0, 30, [box(0, 100, 300, "auto", true)]), true, "at the bottom, pulling down into history");
38
+ assert.equal(dragAllowed(0, -30, [box(0, 100, 300, "auto", true)]), false, "nothing below the bottom");
39
+ assert.equal(dragAllowed(0, 30, [box(-200, 100, 300, "auto", true)]), false, "at the top of history");
40
+ assert.equal(dragAllowed(0, -30, [box(-200, 100, 300, "auto", true)]), true);
41
+ });
42
+ it("never guards a horizontal drag", () => {
43
+ assert.equal(dragAllowed(40, 10, [plain]), true);
44
+ });
45
+ it("sizes the layout to the visible area, and freezes it while zoomed", () => {
46
+ assert.equal(layoutHeight(412.6, 1, null), 413);
47
+ assert.equal(layoutHeight(200, 2, 766), 766);
48
+ assert.equal(layoutHeight(200, 2, null), null);
49
+ });
50
+ it("never leaves a text field focused with the keyboard down on a touch screen", () => {
51
+ const base = { touch: true, textEntryFocused: true, keyboardUp: false, msSinceTapFocus: 60_000 };
52
+ assert.equal(mustBlur(base), true, "swiped away, autofocused, or focused from code");
53
+ assert.equal(mustBlur({ ...base, keyboardUp: true }), false, "typing");
54
+ assert.equal(mustBlur({ ...base, msSinceTapFocus: 200 }), false, "our tap, keyboard still rising");
55
+ assert.equal(mustBlur({ ...base, msSinceTapFocus: TAP_FOCUS_GRACE_MS + 1 }), true, "our tap, but the keyboard never came");
56
+ assert.equal(mustBlur({ ...base, textEntryFocused: false }), false, "nothing to blur");
57
+ assert.equal(mustBlur({ ...base, touch: false }), false, "a desktop has no soft keyboard to wait for");
58
+ });
59
+ });
60
+ //# sourceMappingURL=no-pan.test.js.map
@@ -0,0 +1,153 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, join } from "node:path";
6
+ import { CANONICAL_TABS } from "../shared/view-url.js";
7
+ import { visiblePanes, selectPane } from "../shared/pane-layout.js";
8
+ // The bug these pin is one a rendering test would not have caught, because the
9
+ // render is correct: tapping the terminal in a Chat+Terminal split really did
10
+ // select the terminal, and really did draw it full width. What broke was the
11
+ // *tap* — the pane grew under the finger between pointerdown and pointerup, so
12
+ // no click was ever dispatched and the quick key sent nothing. The invariant
13
+ // that prevents it is about the visible set, not about pixels: selecting a
14
+ // pane you can already see must not change which panes are visible.
15
+ /** Every subset of the tab set, as a Set — 8 of them. */
16
+ function pinPowerset() {
17
+ const out = [];
18
+ for (let mask = 0; mask < 1 << CANONICAL_TABS.length; mask++) {
19
+ const pins = new Set();
20
+ CANONICAL_TABS.forEach((t, i) => { if (mask & (1 << i))
21
+ pins.add(t); });
22
+ out.push(pins);
23
+ }
24
+ return out;
25
+ }
26
+ const show = (active, pins) => `${active}+[${[...pins].sort().join(",")}]`;
27
+ describe("visiblePanes", () => {
28
+ it("is the active pane plus the pinned ones, in tab-bar order", () => {
29
+ assert.deepEqual(visiblePanes("terminal", new Set()), ["terminal"]);
30
+ assert.deepEqual(visiblePanes("terminal", new Set(["chat"])), ["chat", "terminal"]);
31
+ // Pinning the active pane is not a second pane.
32
+ assert.deepEqual(visiblePanes("chat", new Set(["chat"])), ["chat"]);
33
+ assert.deepEqual(visiblePanes("files", new Set(["terminal", "chat"])), ["chat", "terminal", "files"]);
34
+ });
35
+ });
36
+ describe("selectPane", () => {
37
+ it("selecting a visible pane leaves the visible set untouched", () => {
38
+ for (const pins of pinPowerset()) {
39
+ for (const active of CANONICAL_TABS) {
40
+ const before = visiblePanes(active, pins);
41
+ for (const tapped of before) {
42
+ const next = selectPane(active, pins, tapped);
43
+ assert.deepEqual(visiblePanes(next.active, next.pinned), before, `${show(active, pins)} tapping ${tapped}`);
44
+ assert.equal(next.active, tapped, `${show(active, pins)} tapping ${tapped}`);
45
+ }
46
+ }
47
+ }
48
+ });
49
+ it("the split that motivated this: Chat active, Terminal pinned", () => {
50
+ // The ordinary way an operator gets a split — and the case where the naive
51
+ // `setActiveTab` dropped Chat, because Chat was visible only by being
52
+ // active. Both panes must survive a tap on the terminal.
53
+ const next = selectPane("chat", new Set(["terminal"]), "terminal");
54
+ assert.deepEqual(visiblePanes(next.active, next.pinned), ["chat", "terminal"]);
55
+ assert.equal(next.active, "terminal");
56
+ // Chat stays by being pinned on the way past; the terminal keeps the pin
57
+ // the operator gave it.
58
+ assert.deepEqual([...next.pinned].sort(), ["chat", "terminal"]);
59
+ });
60
+ it("never unpins anything: a pin comes off only through its toggle", () => {
61
+ // Tapping into a pinned pane used to unpin it, since being active shows
62
+ // it anyway. The visible set survived that tap, but the pin did not, so
63
+ // the next tab change dropped a pane the operator had pinned.
64
+ for (const pins of pinPowerset()) {
65
+ for (const active of CANONICAL_TABS) {
66
+ for (const tapped of CANONICAL_TABS) {
67
+ const next = selectPane(active, pins, tapped);
68
+ for (const pin of pins) {
69
+ assert.ok(next.pinned.has(pin), `${show(active, pins)} tapping ${tapped} unpinned ${pin}`);
70
+ }
71
+ }
72
+ }
73
+ }
74
+ });
75
+ it("Terminal and Files pinned: tapping between them keeps both pins", () => {
76
+ const pins = new Set(["terminal", "files"]);
77
+ const toFiles = selectPane("terminal", pins, "files");
78
+ assert.deepEqual([...toFiles.pinned].sort(), ["files", "terminal"]);
79
+ const back = selectPane(toFiles.active, toFiles.pinned, "terminal");
80
+ assert.deepEqual([...back.pinned].sort(), ["files", "terminal"]);
81
+ assert.deepEqual(visiblePanes(back.active, back.pinned), ["terminal", "files"]);
82
+ });
83
+ it("never shrinks the visible set, even when the tapped pane was hidden", () => {
84
+ // The reveal case (`revealTerminal`): a hidden pane joins the visible set
85
+ // rather than replacing it.
86
+ for (const pins of pinPowerset()) {
87
+ for (const active of CANONICAL_TABS) {
88
+ for (const tapped of CANONICAL_TABS) {
89
+ const before = new Set(visiblePanes(active, pins));
90
+ const next = selectPane(active, pins, tapped);
91
+ for (const pane of before) {
92
+ assert.ok(visiblePanes(next.active, next.pinned).includes(pane), `${show(active, pins)} tapping ${tapped} dropped ${pane}`);
93
+ }
94
+ }
95
+ }
96
+ }
97
+ });
98
+ it("is idempotent and does not mutate its input", () => {
99
+ const pins = new Set(["terminal"]);
100
+ const once = selectPane("chat", pins, "terminal");
101
+ assert.deepEqual([...pins], ["terminal"], "input set was mutated");
102
+ const twice = selectPane(once.active, once.pinned, "terminal");
103
+ assert.equal(twice.active, once.active);
104
+ assert.deepEqual([...twice.pinned].sort(), [...once.pinned].sort());
105
+ });
106
+ });
107
+ // The rest of the fix is a DOM rule that has no pure form: the pane-select
108
+ // listener must ignore presses that land on a control. Reading the source is
109
+ // how `api-request.test.ts` and `key-routing.test.ts` pin their invisible
110
+ // invariants, and this one is invisible in the same way — put `activateTab`
111
+ // back in the handler and every test still passes while the quick bar goes
112
+ // back to eating every other tap.
113
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
114
+ const screen = join(repoRoot, "expo", "app", "[nid].tsx");
115
+ // `expo/dist` ships in the tarball; the .tsx does not. From an install this
116
+ // skips with a reason rather than failing on ENOENT — see `brane doctor`.
117
+ const skip = !existsSync(screen) ? "expo source not shipped" : false;
118
+ describe("the pane-select listener", () => {
119
+ it("selects through selectPane, not through activateTab", { skip }, () => {
120
+ const src = readFileSync(screen, "utf8");
121
+ const start = src.indexOf('document.addEventListener("pointerdown"');
122
+ assert.notEqual(start, -1, "pointerdown pane-select listener not found");
123
+ const handler = src.slice(src.lastIndexOf("const handler", start), start);
124
+ assert.match(handler, /selectVisiblePane\(pane\)/);
125
+ assert.doesNotMatch(handler, /activateTab\(/, "pane selection must preserve the visible set (selectPane), not just set the tab");
126
+ });
127
+ it("listens only while its screen is the foreground route", { skip }, () => {
128
+ // The Stack keeps screens behind the top one mounted. A hidden screen's
129
+ // listener heard taps on the screen in front, selected a pane in its own
130
+ // layout (pinning its Chat), and router.setParams — which writes to the
131
+ // focused route — put that layout into the visible screen's URL.
132
+ const src = readFileSync(screen, "utf8");
133
+ const start = src.indexOf('document.addEventListener("pointerdown"');
134
+ const effect = src.slice(src.lastIndexOf("useEffect(", start), start);
135
+ assert.match(effect, /if \(!screenFocused\) return;/);
136
+ const deps = src.slice(start, src.indexOf("]);", start));
137
+ assert.match(deps, /\[screenFocused,/, "screenFocused must be an effect dependency");
138
+ });
139
+ it("ignores presses that land on a control", { skip }, () => {
140
+ const src = readFileSync(screen, "utf8");
141
+ const start = src.indexOf('document.addEventListener("pointerdown"');
142
+ const handler = src.slice(src.lastIndexOf("const handler", start), start);
143
+ assert.match(handler, /closest\?\.\(INTERACTIVE_SELECTOR\)\) return;/);
144
+ // The bail must come before the pane is read, or it bails after the work.
145
+ assert.ok(handler.indexOf("INTERACTIVE_SELECTOR") < handler.indexOf('closest?.("[data-pane]")'), "the control bail must precede the pane lookup");
146
+ // Text fields are not controls for this purpose: clicking into the
147
+ // composer is a request to type there, and the tab should follow.
148
+ const selector = src.match(/const INTERACTIVE_SELECTOR =\s*([^;]+);/);
149
+ assert.ok(selector, "INTERACTIVE_SELECTOR not found");
150
+ assert.doesNotMatch(selector[1], /input|textarea/);
151
+ });
152
+ });
153
+ //# sourceMappingURL=pane-layout.test.js.map
@@ -0,0 +1,37 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { homedir } from "node:os";
4
+ import { toPortablePath, fromPortablePath } from "../db/schema.js";
5
+ describe("toPortablePath", () => {
6
+ it("converts home directory to ~", () => {
7
+ assert.equal(toPortablePath(homedir()), "~");
8
+ });
9
+ it("converts path under home to ~/relative", () => {
10
+ const home = homedir();
11
+ assert.equal(toPortablePath(`${home}/dev/myproject`), "~/dev/myproject");
12
+ });
13
+ it("leaves paths outside home unchanged", () => {
14
+ assert.equal(toPortablePath("/tmp/something"), "/tmp/something");
15
+ });
16
+ it("does not match partial home prefix", () => {
17
+ const home = homedir();
18
+ // e.g., /Users/benjamin should not match /Users/ben
19
+ assert.equal(toPortablePath(`${home}zzz/extra`), `${home}zzz/extra`);
20
+ });
21
+ });
22
+ describe("fromPortablePath", () => {
23
+ it("resolves ~ to home directory", () => {
24
+ assert.equal(fromPortablePath("~"), homedir());
25
+ });
26
+ it("resolves ~/relative to absolute path", () => {
27
+ assert.equal(fromPortablePath("~/dev/myproject"), `${homedir()}/dev/myproject`);
28
+ });
29
+ it("leaves absolute paths unchanged", () => {
30
+ assert.equal(fromPortablePath("/tmp/something"), "/tmp/something");
31
+ });
32
+ it("round-trips through toPortablePath", () => {
33
+ const original = `${homedir()}/dev/test-project`;
34
+ assert.equal(fromPortablePath(toPortablePath(original)), original);
35
+ });
36
+ });
37
+ //# sourceMappingURL=paths.test.js.map
@@ -0,0 +1,91 @@
1
+ import { test, describe } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, join } from "node:path";
6
+ import { isInlinePayload, attachmentDisposition } from "../shared/payload-inline.js";
7
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
8
+ describe("isInlinePayload", () => {
9
+ test("images render inline", () => {
10
+ for (const p of ["a.png", "a.JPG", "a.jpeg", "a.gif", "a.webp", "a.bmp", "a.avif", "a.svg"]) {
11
+ assert.equal(isInlinePayload(p), true, p);
12
+ }
13
+ });
14
+ test("everything else is a file, including what a browser could display", () => {
15
+ // The question is not "can the browser render it" — it is "does brane
16
+ // render it inline". A `.pdf` or `.html` a browser would happily make a
17
+ // page of is exactly the case this exists to catch.
18
+ for (const p of ["a.pdf", "a.html", "a.txt", "a.json", "a.zip", "a"]) {
19
+ assert.equal(isInlinePayload(p), false, p);
20
+ }
21
+ });
22
+ test("the extension must end the name", () => {
23
+ assert.equal(isInlinePayload("a.png.html"), false);
24
+ assert.equal(isInlinePayload("png"), false);
25
+ });
26
+ test("a full payload path answers the same as its basename", () => {
27
+ assert.equal(isInlinePayload("claude-4c7289d1/shot.png"), true);
28
+ assert.equal(isInlinePayload("claude-4c7289d1/notes.md"), false);
29
+ });
30
+ test("a dot in a directory name is not the file's extension", () => {
31
+ assert.equal(isInlinePayload("shots.png/README"), false);
32
+ });
33
+ });
34
+ describe("attachmentDisposition", () => {
35
+ test("a plain name is a quoted attachment", () => {
36
+ assert.equal(attachmentDisposition("notes.md"), 'attachment; filename="notes.md"');
37
+ });
38
+ test("a quote in the name cannot end the quoted string early", () => {
39
+ // Otherwise `"; filename="evil.exe` is an injection into the parameter
40
+ // list, and the browser saves under the second name.
41
+ assert.equal(attachmentDisposition('a".txt'), 'attachment; filename="a\\".txt"');
42
+ // A backslash never survives as itself: it is a path separator on the
43
+ // other kind of machine, so it is neutralized before quoting rather than
44
+ // escaped into the header.
45
+ assert.equal(attachmentDisposition("a\\b.txt"), 'attachment; filename="a_b.txt"');
46
+ });
47
+ test("a non-ASCII name sends both parameters", () => {
48
+ const d = attachmentDisposition("réponse.txt");
49
+ assert.match(d, /^attachment; filename="r_ponse\.txt"; filename\*=UTF-8''/);
50
+ assert.ok(d.endsWith("r%C3%A9ponse.txt"), d);
51
+ });
52
+ test("path separators and control characters never reach the header", () => {
53
+ // A payload filename is whatever an agent narrated: a CR would end the
54
+ // header, and a slash would suggest a directory to save into.
55
+ assert.equal(attachmentDisposition("../../etc/passwd"), 'attachment; filename=".._.._etc_passwd"');
56
+ assert.equal(attachmentDisposition("a\r\nX-Evil: 1"), 'attachment; filename="aX-Evil: 1"');
57
+ });
58
+ test("a name that survives none of that still yields something saveable", () => {
59
+ assert.equal(attachmentDisposition(""), 'attachment; filename="download"');
60
+ assert.equal(attachmentDisposition(" "), 'attachment; filename=" "');
61
+ });
62
+ });
63
+ // The rule is only as good as its call sites, and this is a regression use
64
+ // cannot show you: opening a tokened URL as a page looks perfectly fine until
65
+ // someone saves it. Same shape as the source lint in `api-request.test.ts`.
66
+ const chatSource = join(repoRoot, "expo", "app", "[nid].tsx");
67
+ const filesSource = join(repoRoot, "expo", "components", "FilesView.tsx");
68
+ const sourcesPresent = existsSync(chatSource) && existsSync(filesSource);
69
+ describe("the app never navigates to a tokened daemon URL", {
70
+ skip: sourcesPresent ? false : "expo source not shipped",
71
+ }, () => {
72
+ const sources = sourcesPresent
73
+ ? [
74
+ { path: "expo/app/[nid].tsx", text: readFileSync(chatSource, "utf8") },
75
+ { path: "expo/components/FilesView.tsx", text: readFileSync(filesSource, "utf8") },
76
+ ]
77
+ : [];
78
+ test("no window.open on a payload or raw-file URL", () => {
79
+ for (const { path, text } of sources) {
80
+ for (const m of text.matchAll(/window\.open\(([^)]*)\)/g)) {
81
+ assert.ok(!/payloadUrl|filesRawUrl/.test(m[1]), `${path}: window.open(${m[1]}) — download it instead; the URL carries the token`);
82
+ }
83
+ }
84
+ });
85
+ test("both files reach those URLs through the download helper", () => {
86
+ for (const { path, text } of sources) {
87
+ assert.match(text, /from "@\/lib\/download"/, `${path} should import the helper`);
88
+ }
89
+ });
90
+ });
91
+ //# sourceMappingURL=payload-inline.test.js.map
@@ -0,0 +1,72 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, join } from "node:path";
6
+ // The service worker's KaTeX split, held to the stylesheet it serves.
7
+ //
8
+ // gen-precache.mjs precaches the faces of the two families nearly every
9
+ // equation uses and leaves the rest to sw.js's on-demand VENDOR_CACHE. Its own
10
+ // build-time guard catches a *kept* face disappearing. This catches the other
11
+ // direction: a KaTeX upgrade that adds a face to a kept family, which would
12
+ // silently land in the tail and render in fallback glyphs offline.
13
+ //
14
+ // Reads source, which the npm tarball doesn't ship, so an installed package
15
+ // skips rather than failing (`brane doctor` runs this suite).
16
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
17
+ const CSS = join(repoRoot, "expo", "public", "katex", "katex.min.css");
18
+ const GEN = join(repoRoot, "expo", "scripts", "gen-precache.mjs");
19
+ const SW = join(repoRoot, "expo", "public", "sw.js");
20
+ const skip = [CSS, GEN, SW].some((p) => !existsSync(p)) ? "expo source not shipped" : false;
21
+ /** family → the woff2 files its @font-face rules name. */
22
+ function faces() {
23
+ const out = new Map();
24
+ const css = readFileSync(CSS, "utf8");
25
+ // KaTeX writes some family names quoted (`font-family:"KaTeX_SansSerif"`);
26
+ // an unquoted-only pattern skipped those rules, and a face added to a kept
27
+ // family under a quoted name would have made "kept whole" pass vacuously.
28
+ for (const m of css.matchAll(/@font-face\{[^}]*?font-family:"?([\w-]+)"?;[^}]*?url\(fonts\/([\w-]+\.woff2)\)/g)) {
29
+ out.set(m[1], [...(out.get(m[1]) ?? []), m[2]]);
30
+ }
31
+ const rules = (css.match(/@font-face\{/g) ?? []).length;
32
+ const parsed = [...out.values()].reduce((n, fs) => n + fs.length, 0);
33
+ assert.equal(parsed, rules, "every @font-face rule was parsed — none skipped by the pattern");
34
+ return out;
35
+ }
36
+ function shellFonts() {
37
+ const block = /const SHELL_FONTS = \[([\s\S]*?)\];/.exec(readFileSync(GEN, "utf8"));
38
+ assert.ok(block, "gen-precache.mjs declares SHELL_FONTS");
39
+ return [...block[1].matchAll(/"([^"]+)"/g)].map((m) => m[1]);
40
+ }
41
+ describe("the KaTeX precache split", () => {
42
+ it("every kept face is one the stylesheet uses", { skip }, () => {
43
+ const all = new Set([...faces().values()].flat());
44
+ assert.ok(all.size > 0, "parsed the stylesheet's @font-face rules");
45
+ for (const f of shellFonts())
46
+ assert.ok(all.has(f), `${f} is not in katex.min.css`);
47
+ });
48
+ it("a kept family is kept whole", { skip }, () => {
49
+ const kept = new Set(shellFonts());
50
+ const byFamily = faces();
51
+ const families = new Set([...byFamily].filter(([, fs]) => fs.some((f) => kept.has(f))).map(([fam]) => fam));
52
+ assert.deepEqual([...families].sort(), ["KaTeX_Main", "KaTeX_Math"]);
53
+ for (const fam of families) {
54
+ for (const f of byFamily.get(fam))
55
+ assert.ok(kept.has(f), `${f} (${fam}) would fall to the on-demand tail`);
56
+ }
57
+ });
58
+ it("an HTML answer to a static request is never cached", { skip }, () => {
59
+ // The servers answer an unknown path with the SPA shell and a 200; kept
60
+ // under a font's URL in VENDOR_CACHE, that would outlive every build.
61
+ const sw = readFileSync(SW, "utf8");
62
+ assert.match(sw, /response\.ok && !isShellFallback\(response\)/);
63
+ assert.match(sw, /function isShellFallback[\s\S]*?text\/html/);
64
+ });
65
+ it("the on-demand tail lives in a cache activate keeps", { skip }, () => {
66
+ const sw = readFileSync(SW, "utf8");
67
+ assert.match(sw, /__BRANE_VENDOR_CACHE__/, "the generator fills VENDOR_CACHE");
68
+ assert.match(sw, /k !== CACHE_NAME && k !== VENDOR_CACHE/, "activate spares the vendor cache");
69
+ assert.match(sw, /isVendor\(url\.pathname\) \? VENDOR_CACHE : CACHE_NAME/, "on-demand vendored files are stored there");
70
+ });
71
+ });
72
+ //# sourceMappingURL=precache-fonts.test.js.map