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.
- package/AGENTS.md +362 -0
- package/CHANGELOG.md +33 -0
- package/CONTRIBUTING.md +91 -0
- package/DESIGN.md +402 -0
- package/Dockerfile +61 -0
- package/LICENSE +656 -17
- package/README.md +174 -2
- package/SECURITY.md +205 -0
- package/dist/absorb/brane-id.js +11 -0
- package/dist/absorb/chatgpt.js +161 -0
- package/dist/absorb/claude-code.js +504 -0
- package/dist/absorb/index.js +543 -0
- package/dist/absorb/organelle.js +345 -0
- package/dist/absorb/run.js +520 -0
- package/dist/absorb/sources.js +168 -0
- package/dist/absorb/types.js +3 -0
- package/dist/agent-env.js +45 -0
- package/dist/agents.js +1168 -0
- package/dist/claude-live-session.js +124 -0
- package/dist/cli.js +50 -0
- package/dist/codex-tool-log.js +144 -0
- package/dist/commands/absorb.js +291 -0
- package/dist/commands/attach.js +31 -0
- package/dist/commands/certify.js +62 -0
- package/dist/commands/config.js +116 -0
- package/dist/commands/cost.js +126 -0
- package/dist/commands/daemon.js +350 -0
- package/dist/commands/doctor.js +262 -0
- package/dist/commands/fold.js +95 -0
- package/dist/commands/list.js +89 -0
- package/dist/commands/mcp.js +84 -0
- package/dist/commands/meld.js +332 -0
- package/dist/commands/memory.js +215 -0
- package/dist/commands/revive.js +74 -0
- package/dist/commands/run.js +85 -0
- package/dist/commands/spawn.js +1094 -0
- package/dist/commands/token.js +153 -0
- package/dist/copilot-tool-log.js +145 -0
- package/dist/cost/pricing.js +116 -0
- package/dist/cost/reprice.js +136 -0
- package/dist/crush-tool-log.js +293 -0
- package/dist/daemon/agent-scope.js +116 -0
- package/dist/daemon/awaiting.js +54 -0
- package/dist/daemon/cert-manager.js +349 -0
- package/dist/daemon/client.js +262 -0
- package/dist/daemon/gated-routes.js +112 -0
- package/dist/daemon/keep-awake.js +103 -0
- package/dist/daemon/local-socket.js +145 -0
- package/dist/daemon/presence-gate.js +206 -0
- package/dist/daemon/server.js +6349 -0
- package/dist/daemon/signal-source.js +35 -0
- package/dist/daemon/tailscale-rebind.js +58 -0
- package/dist/daemon/token-ring.js +263 -0
- package/dist/daemon/token-routes.js +190 -0
- package/dist/db/events.js +152 -0
- package/dist/db/mentions.js +179 -0
- package/dist/db/schema.js +633 -0
- package/dist/db/tokens.js +583 -0
- package/dist/db/tool-uses.js +647 -0
- package/dist/diagram/index.js +85 -0
- package/dist/diagram/label.js +455 -0
- package/dist/diagram/layout.js +476 -0
- package/dist/diagram/links.js +50 -0
- package/dist/diagram/metrics.js +152 -0
- package/dist/diagram/parse.js +487 -0
- package/dist/diagram/svg.js +389 -0
- package/dist/diagram/types.js +9 -0
- package/dist/export.js +629 -0
- package/dist/files/archive-types.js +2 -0
- package/dist/files/archive.js +182 -0
- package/dist/files/browse.js +327 -0
- package/dist/files/bytes.js +162 -0
- package/dist/files/codecs.js +33 -0
- package/dist/files/find.js +212 -0
- package/dist/files/tar.js +223 -0
- package/dist/files/zip.js +209 -0
- package/dist/folds.js +72 -0
- package/dist/grammars/ebnf.js +92 -0
- package/dist/highlight.js +250 -0
- package/dist/ids.js +24 -0
- package/dist/markdown-diagnostics.js +233 -0
- package/dist/math.js +168 -0
- package/dist/mcp/server.js +886 -0
- package/dist/memory/embedder.js +145 -0
- package/dist/memory/embedding-worker.js +71 -0
- package/dist/memory/embeddings.js +91 -0
- package/dist/memory/index.js +232 -0
- package/dist/memory/storage.js +587 -0
- package/dist/memory/wiki.js +688 -0
- package/dist/memory/wordpiece.js +247 -0
- package/dist/mentions-scan.js +40 -0
- package/dist/presence.js +259 -0
- package/dist/session-affinity.js +185 -0
- package/dist/shared/api-request.js +124 -0
- package/dist/shared/block-glyphs.js +114 -0
- package/dist/shared/diagram-css.js +17 -0
- package/dist/shared/export-selection.js +101 -0
- package/dist/shared/find-in-text.js +110 -0
- package/dist/shared/glow-hues.js +225 -0
- package/dist/shared/hex-dump.js +62 -0
- package/dist/shared/hex-pages.js +52 -0
- package/dist/shared/hit-drum.js +79 -0
- package/dist/shared/known-nids.js +58 -0
- package/dist/shared/list-scroll-keys.js +56 -0
- package/dist/shared/markdown-lists.js +72 -0
- package/dist/shared/markdown-math.js +485 -0
- package/dist/shared/markdown-mentions.js +79 -0
- package/dist/shared/markdown-nids.js +41 -0
- package/dist/shared/markdown-protocols.js +53 -0
- package/dist/shared/markdown-refs.js +123 -0
- package/dist/shared/membrane-invasion.js +342 -0
- package/dist/shared/membrane-motion.js +83 -0
- package/dist/shared/membrane-palette.js +253 -0
- package/dist/shared/membrane-terrain.js +212 -0
- package/dist/shared/message-selection.js +123 -0
- package/dist/shared/mo-glyph.js +57 -0
- package/dist/shared/narrative-format.js +237 -0
- package/dist/shared/narrative-menu.js +82 -0
- package/dist/shared/narrative-sort.js +47 -0
- package/dist/shared/narrative-status.js +52 -0
- package/dist/shared/no-pan.js +85 -0
- package/dist/shared/pane-layout.js +50 -0
- package/dist/shared/payload-inline.js +83 -0
- package/dist/shared/speaker-color.js +61 -0
- package/dist/shared/token-rotation.js +14 -0
- package/dist/shared/view-url.js +197 -0
- package/dist/shared/wiki-links.js +70 -0
- package/dist/shared/wiki-window.js +4 -0
- package/dist/tailscale.js +55 -0
- package/dist/test/absorb-claude.test.js +892 -0
- package/dist/test/agent-scope.test.js +193 -0
- package/dist/test/agents.test.js +55 -0
- package/dist/test/api-request.test.js +122 -0
- package/dist/test/awaiting-glow.test.js +54 -0
- package/dist/test/awaiting.test.js +122 -0
- package/dist/test/bind-token.test.js +88 -0
- package/dist/test/block-glyphs.test.js +68 -0
- package/dist/test/cert-manager.test.js +262 -0
- package/dist/test/claude-live-session.test.js +110 -0
- package/dist/test/codex-tool-log.test.js +568 -0
- package/dist/test/config.test.js +71 -0
- package/dist/test/copilot-tool-log.test.js +93 -0
- package/dist/test/cors-preflight.test.js +49 -0
- package/dist/test/cost-linger.test.js +31 -0
- package/dist/test/cost-pricing.test.js +72 -0
- package/dist/test/cost-reprice.test.js +139 -0
- package/dist/test/cost-sessions.test.js +301 -0
- package/dist/test/crush-tool-log.test.js +289 -0
- package/dist/test/daemon-client.test.js +109 -0
- package/dist/test/daemon-socket.test.js +293 -0
- package/dist/test/diagram-arrows.test.js +205 -0
- package/dist/test/diagram-export.test.js +210 -0
- package/dist/test/diagram-labels.test.js +313 -0
- package/dist/test/diagram-parse.test.js +191 -0
- package/dist/test/diagram-render.test.js +175 -0
- package/dist/test/diagram-review.test.js +288 -0
- package/dist/test/diagram-self-loop-labels.test.js +303 -0
- package/dist/test/embedder.test.js +84 -0
- package/dist/test/events.test.js +123 -0
- package/dist/test/export-selection.test.js +102 -0
- package/dist/test/export.test.js +505 -0
- package/dist/test/files-browse.test.js +393 -0
- package/dist/test/files-find.test.js +272 -0
- package/dist/test/find-in-text.test.js +86 -0
- package/dist/test/first-paint-viewport.test.js +59 -0
- package/dist/test/fixtures/embedding-golden.js +23 -0
- package/dist/test/fixtures/wordpiece-golden.js +167 -0
- package/dist/test/glow-hues.test.js +202 -0
- package/dist/test/harness-cost.test.js +423 -0
- package/dist/test/helpers.js +21 -0
- package/dist/test/hex-pages.test.js +68 -0
- package/dist/test/highlight.test.js +69 -0
- package/dist/test/hit-drum.test.js +71 -0
- package/dist/test/hook-config.test.js +262 -0
- package/dist/test/json-gzip.test.js +70 -0
- package/dist/test/key-routing.test.js +58 -0
- package/dist/test/known-nids.test.js +106 -0
- package/dist/test/link-preview.test.js +67 -0
- package/dist/test/list-scroll-keys.test.js +66 -0
- package/dist/test/listen-own-words.test.js +147 -0
- package/dist/test/listen-timeout.test.js +33 -0
- package/dist/test/markdown-diagnostics.test.js +121 -0
- package/dist/test/markdown-lists.test.js +182 -0
- package/dist/test/markdown-math.test.js +256 -0
- package/dist/test/markdown-nids.test.js +146 -0
- package/dist/test/markdown-protocols.test.js +139 -0
- package/dist/test/mcp-bridge-cancel.test.js +98 -0
- package/dist/test/mcp-bridge-reconnect.test.js +121 -0
- package/dist/test/mcp-narrative.test.js +193 -0
- package/dist/test/mcp.test.js +548 -0
- package/dist/test/meld.test.js +85 -0
- package/dist/test/membrane-invasion.test.js +482 -0
- package/dist/test/membrane-motion.test.js +101 -0
- package/dist/test/membrane-palette.test.js +166 -0
- package/dist/test/membrane-terrain.test.js +170 -0
- package/dist/test/memory-log.test.js +202 -0
- package/dist/test/mentions-e2e.test.js +261 -0
- package/dist/test/mentions-ledger.test.js +265 -0
- package/dist/test/mentions-listen.test.js +265 -0
- package/dist/test/mentions.test.js +238 -0
- package/dist/test/message-selection.test.js +114 -0
- package/dist/test/mo-glyph.test.js +22 -0
- package/dist/test/model-resolution.test.js +166 -0
- package/dist/test/narrate-validate.test.js +77 -0
- package/dist/test/narrate-wait.test.js +160 -0
- package/dist/test/narrative-format.test.js +219 -0
- package/dist/test/narrative-menu.test.js +137 -0
- package/dist/test/narrative-sort.test.js +75 -0
- package/dist/test/no-pan.test.js +60 -0
- package/dist/test/pane-layout.test.js +153 -0
- package/dist/test/paths.test.js +37 -0
- package/dist/test/payload-inline.test.js +91 -0
- package/dist/test/precache-fonts.test.js +72 -0
- package/dist/test/presence-gate.test.js +440 -0
- package/dist/test/presence.test.js +160 -0
- package/dist/test/read-events.test.js +220 -0
- package/dist/test/remote-meld.test.js +115 -0
- package/dist/test/revive-cli.test.js +93 -0
- package/dist/test/search-floor.test.js +206 -0
- package/dist/test/search-fusion.test.js +168 -0
- package/dist/test/search.test.js +205 -0
- package/dist/test/security.test.js +48 -0
- package/dist/test/selection-wiring.test.js +61 -0
- package/dist/test/session-affinity.test.js +295 -0
- package/dist/test/signal-source.test.js +32 -0
- package/dist/test/socket-account.test.js +268 -0
- package/dist/test/spawn-toml.test.js +72 -0
- package/dist/test/speaker-color.test.js +90 -0
- package/dist/test/tailscale-rebind.test.js +79 -0
- package/dist/test/terminal-cursor.test.js +61 -0
- package/dist/test/terminal-resync.test.js +23 -0
- package/dist/test/thin-cli.test.js +382 -0
- package/dist/test/token-bootstrap.test.js +272 -0
- package/dist/test/token-devices.test.js +587 -0
- package/dist/test/token-lapse.test.js +94 -0
- package/dist/test/token-ring.test.js +188 -0
- package/dist/test/token-rotate-cli.test.js +58 -0
- package/dist/test/token-routes.test.js +95 -0
- package/dist/test/tool-uses.test.js +1006 -0
- package/dist/test/usage-ledger.test.js +126 -0
- package/dist/test/view-url.test.js +581 -0
- package/dist/test/wiki-links.test.js +57 -0
- package/dist/test/wiki-sources.test.js +85 -0
- package/dist/test/wiki-window.test.js +31 -0
- package/dist/test/wiki.test.js +399 -0
- package/dist/test/wordpiece.test.js +68 -0
- package/dist/tls.js +148 -0
- package/dist/tmux.js +202 -0
- package/dist/user-agent.js +46 -0
- package/docker-entrypoint.sh +141 -0
- package/docs/README.md +3 -0
- package/docs/absorb.md +231 -0
- package/docs/cost-ledger.md +125 -0
- package/docs/daemon-api.md +189 -0
- package/docs/diagrams.md +163 -0
- package/docs/doctor.md +36 -0
- package/docs/export-scope.md +69 -0
- package/docs/files-view.md +133 -0
- package/docs/link-previews.md +38 -0
- package/docs/math.md +32 -0
- package/docs/mcp.md +69 -0
- package/docs/memory-search.md +75 -0
- package/docs/mentions.md +101 -0
- package/docs/multi-brane.md +14 -0
- package/docs/passkeys.md +165 -0
- package/docs/presence.md +202 -0
- package/docs/releasing.md +53 -0
- package/docs/rendering-warnings.md +22 -0
- package/docs/request-layer.md +72 -0
- package/docs/selection-store.md +50 -0
- package/docs/session-affinity.md +61 -0
- package/docs/terminal-mirroring.md +211 -0
- package/docs/token-rotation.md +413 -0
- package/docs/view-urls.md +114 -0
- package/docs/web-app.md +23 -0
- package/docs/wiki.md +43 -0
- package/expo/dist/_expo/static/js/web/entry-e56efc1291dca7258ea00b8108b033a4.js +1097 -0
- package/expo/dist/apple-touch-icon.png +0 -0
- package/expo/dist/favicon.svg +3 -0
- package/expo/dist/index.html +177 -0
- package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/expo/dist/katex/katex.min.css +1 -0
- package/expo/dist/manifest.json +16 -0
- package/expo/dist/metadata.json +1 -0
- package/expo/dist/og/membrane.v1.png +0 -0
- package/expo/dist/pdf.min.mjs +28 -0
- package/expo/dist/pdf.worker.min.mjs +28 -0
- package/expo/dist/sw.js +154 -0
- package/package.json +81 -23
- package/protocols/LIVE_loop.md +407 -0
- package/protocols/bebrief.md +41 -0
- package/protocols/digest.md +231 -0
- package/protocols/fly.md +266 -0
- package/protocols/idle.md +81 -0
- package/protocols/keeper.md +829 -0
- package/protocols/markdown.md +80 -0
- package/protocols/memory.md +419 -0
- package/protocols/mentions.md +93 -0
- package/protocols/payloads.md +53 -0
- package/protocols/prelude.md +91 -0
- package/protocols/ramify.md +105 -0
- package/protocols/review.md +356 -0
- package/protocols/rrr.md +232 -0
- package/protocols/scribe.md +429 -0
- package/protocols/summarize.md +203 -0
- package/protocols/twin.md +174 -0
- package/protocols/wiki.md +438 -0
- package/protocols/worker.md +38 -0
- package/wiki/shared/append-only-event-log.md +148 -0
- package/wiki/shared/index.md +48 -0
- package/wiki/shared/owning-your-narratives.md +161 -0
- package/wiki/shared/premature-ontologies.md +435 -0
- package/wiki/shared/sedimentary-superintelligence.md +128 -0
- package/wiki/shared/sequestering-the-narrative.md +143 -0
- package/wiki/shared/why-wikis-work.md +216 -0
- package/.npmignore +0 -2
- package/.travis.yml +0 -5
- package/index.js +0 -8
- package/index.js~ +0 -12
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { createTestDb } from "./helpers.js";
|
|
5
|
+
import { buildDaemonBaseUrl, readDaemonAddress, daemonRequest, } from "../daemon/client.js";
|
|
6
|
+
const TS_HOST = "myhost.tailnet.ts.net";
|
|
7
|
+
function setMeta(db, key, value) {
|
|
8
|
+
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)").run(key, value);
|
|
9
|
+
}
|
|
10
|
+
describe("buildDaemonBaseUrl", () => {
|
|
11
|
+
it("uses loopback on the primary port when TLS is off", () => {
|
|
12
|
+
assert.equal(buildDaemonBaseUrl({ port: "7777", tls: false }), "http://127.0.0.1:7777");
|
|
13
|
+
});
|
|
14
|
+
it("prefers the HTTP fallback port under TLS (the cert never covers 127.0.0.1)", () => {
|
|
15
|
+
assert.equal(buildDaemonBaseUrl({ port: "8443", tls: true, httpPort: "9001", tailscaleHost: TS_HOST }), "http://127.0.0.1:9001");
|
|
16
|
+
});
|
|
17
|
+
it("falls back to the Tailscale hostname when no HTTP port is recorded yet", () => {
|
|
18
|
+
assert.equal(buildDaemonBaseUrl({ port: "8443", tls: true, tailscaleHost: TS_HOST }), `https://${TS_HOST}:8443`);
|
|
19
|
+
});
|
|
20
|
+
it("refuses to guess when TLS is on and neither route is available", () => {
|
|
21
|
+
assert.throws(() => buildDaemonBaseUrl({ port: "8443", tls: true }), /TLS/);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
describe("readDaemonAddress", () => {
|
|
25
|
+
let db;
|
|
26
|
+
before(() => {
|
|
27
|
+
db = createTestDb();
|
|
28
|
+
});
|
|
29
|
+
after(() => {
|
|
30
|
+
db.close();
|
|
31
|
+
});
|
|
32
|
+
it("reports a plain-HTTP daemon and ignores any stale fallback port", () => {
|
|
33
|
+
setMeta(db, "daemon_tls", "false");
|
|
34
|
+
setMeta(db, "daemon_http_port", "9001");
|
|
35
|
+
const addr = readDaemonAddress(db, "7777", { tailscaleHost: TS_HOST });
|
|
36
|
+
assert.equal(addr.tls, false);
|
|
37
|
+
assert.equal(addr.httpPort, undefined);
|
|
38
|
+
assert.equal(addr.tailscaleHost, undefined);
|
|
39
|
+
});
|
|
40
|
+
it("carries the fallback port and cert hostname for a TLS daemon", () => {
|
|
41
|
+
setMeta(db, "daemon_tls", "true");
|
|
42
|
+
setMeta(db, "daemon_http_port", "9001");
|
|
43
|
+
const addr = readDaemonAddress(db, "8443", { tailscaleHost: TS_HOST });
|
|
44
|
+
assert.deepEqual(addr, {
|
|
45
|
+
port: "8443",
|
|
46
|
+
tls: true,
|
|
47
|
+
httpPort: "9001",
|
|
48
|
+
tailscaleHost: TS_HOST,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
describe("daemonRequest", () => {
|
|
53
|
+
let server;
|
|
54
|
+
let endpoint;
|
|
55
|
+
const seen = [];
|
|
56
|
+
before(async () => {
|
|
57
|
+
server = createServer((req, res) => {
|
|
58
|
+
let body = "";
|
|
59
|
+
req.on("data", (c) => (body += c));
|
|
60
|
+
req.on("end", () => {
|
|
61
|
+
seen.push({
|
|
62
|
+
method: req.method ?? "",
|
|
63
|
+
url: req.url ?? "",
|
|
64
|
+
auth: req.headers.authorization,
|
|
65
|
+
body,
|
|
66
|
+
});
|
|
67
|
+
if (req.url === "/api/spawn") {
|
|
68
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
69
|
+
res.end(JSON.stringify({ narrative: { id: "claude-abc12345" } }));
|
|
70
|
+
}
|
|
71
|
+
else if (req.url === "/api/boom") {
|
|
72
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
73
|
+
res.end(JSON.stringify({ error: "Not a fold: ~/nope. Run `brane fold` first." }));
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
77
|
+
res.end("nope");
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
82
|
+
const port = server.address().port;
|
|
83
|
+
endpoint = { baseUrl: `http://127.0.0.1:${port}`, token: "tok", port: String(port) };
|
|
84
|
+
});
|
|
85
|
+
after(async () => {
|
|
86
|
+
await new Promise((r) => server.close(() => r()));
|
|
87
|
+
});
|
|
88
|
+
it("POSTs JSON with a bearer token and returns the parsed body", async () => {
|
|
89
|
+
const result = await daemonRequest(endpoint, "/api/spawn", {
|
|
90
|
+
method: "POST",
|
|
91
|
+
body: { agent: "claude", fold_path: "~/dev/brane" },
|
|
92
|
+
});
|
|
93
|
+
assert.equal(result.narrative.id, "claude-abc12345");
|
|
94
|
+
const req = seen.find((r) => r.url === "/api/spawn");
|
|
95
|
+
assert.equal(req?.method, "POST");
|
|
96
|
+
assert.equal(req?.auth, "Bearer tok");
|
|
97
|
+
assert.deepEqual(JSON.parse(req?.body ?? "{}"), {
|
|
98
|
+
agent: "claude",
|
|
99
|
+
fold_path: "~/dev/brane",
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
it("surfaces the daemon's own error string verbatim", async () => {
|
|
103
|
+
await assert.rejects(() => daemonRequest(endpoint, "/api/boom", { method: "POST", body: {} }), /^Error: Not a fold: ~\/nope\. Run `brane fold` first\.$/);
|
|
104
|
+
});
|
|
105
|
+
it("falls back to a status-code message when the body carries no error", async () => {
|
|
106
|
+
await assert.rejects(() => daemonRequest(endpoint, "/api/missing"), /404/);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
//# sourceMappingURL=daemon-client.test.js.map
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `daemon.sock`: the CLI reaches the daemon over its Unix socket when it has
|
|
3
|
+
* one, falls back to TCP only when the socket refuses the connection, and a
|
|
4
|
+
* request that arrives over the socket counts as loopback. Stale-socket
|
|
5
|
+
* cleanup removes only a socket nothing answers on.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, after } from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { createServer as createHttpServer } from "node:http";
|
|
10
|
+
import { createServer as createNetServer, connect as netConnect } from "node:net";
|
|
11
|
+
import Database from "better-sqlite3";
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync, lstatSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
17
|
+
import { daemonRequest, daemonStream, readDaemonSocket } from "../daemon/client.js";
|
|
18
|
+
import { mcpDialTarget } from "../commands/mcp.js";
|
|
19
|
+
import { SCHEMA_SQL, applyMigrations } from "../db/schema.js";
|
|
20
|
+
import { createTestDb } from "./helpers.js";
|
|
21
|
+
import { clearStaleSocket, connectionLabel, getSocketPath, listenOnLocalSocket, markUnixConnection, MAX_SOCKET_PATH } from "../daemon/local-socket.js";
|
|
22
|
+
import { samePlace, UNIX_SOCKET_SIGHTING } from "../db/tokens.js";
|
|
23
|
+
import { isLoopbackRequest } from "../daemon/server.js";
|
|
24
|
+
function tempDir() {
|
|
25
|
+
return mkdtempSync(join(tmpdir(), "brane-sock-"));
|
|
26
|
+
}
|
|
27
|
+
/** A socket file whose process exited without closing it, as a crash
|
|
28
|
+
* leaves one: connects to it are refused. */
|
|
29
|
+
function crashedSocket(path = join(tempDir(), "daemon.sock")) {
|
|
30
|
+
const r = spawnSync(process.execPath, [
|
|
31
|
+
"-e",
|
|
32
|
+
`require("node:net").createServer().listen(${JSON.stringify(path)}, () => process.exit(0))`,
|
|
33
|
+
]);
|
|
34
|
+
assert.equal(r.status, 0);
|
|
35
|
+
return path;
|
|
36
|
+
}
|
|
37
|
+
function listenUnix(path, handler) {
|
|
38
|
+
const server = createHttpServer((req, res) => {
|
|
39
|
+
let body = "";
|
|
40
|
+
req.on("data", (c) => (body += c));
|
|
41
|
+
req.on("end", () => {
|
|
42
|
+
const [status, out] = handler(req, body);
|
|
43
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
44
|
+
res.end(out);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
server.on("connection", (socket) => markUnixConnection(socket));
|
|
48
|
+
return new Promise((r) => server.listen(path, () => r(server)));
|
|
49
|
+
}
|
|
50
|
+
describe("the CLI over daemon.sock", () => {
|
|
51
|
+
const closers = [];
|
|
52
|
+
after(async () => {
|
|
53
|
+
for (const close of closers)
|
|
54
|
+
await close();
|
|
55
|
+
});
|
|
56
|
+
it("sends requests to the socket, with the bearer token, never to baseUrl", async () => {
|
|
57
|
+
const socketPath = join(tempDir(), "daemon.sock");
|
|
58
|
+
const seen = [];
|
|
59
|
+
const server = await listenUnix(socketPath, (req, body) => {
|
|
60
|
+
seen.push({ url: req.url, auth: req.headers.authorization, body, loopback: isLoopbackRequest(req) });
|
|
61
|
+
return [200, JSON.stringify({ ok: "yes" })];
|
|
62
|
+
});
|
|
63
|
+
closers.push(() => new Promise((r) => server.close(() => r())));
|
|
64
|
+
// Port 1 answers nothing: success means the socket carried it.
|
|
65
|
+
const endpoint = { baseUrl: "http://127.0.0.1:1", token: "tok", port: "1", socketPath };
|
|
66
|
+
const result = await daemonRequest(endpoint, "/api/folds", { method: "POST", body: { path: "/x" } });
|
|
67
|
+
assert.equal(result.ok, "yes");
|
|
68
|
+
assert.deepEqual(seen, [{ url: "/api/folds", auth: "Bearer tok", body: '{"path":"/x"}', loopback: true }]);
|
|
69
|
+
});
|
|
70
|
+
it("reports the daemon's error string from the socket as it does over TCP", async () => {
|
|
71
|
+
const socketPath = join(tempDir(), "daemon.sock");
|
|
72
|
+
const server = await listenUnix(socketPath, () => [403, JSON.stringify({ error: "nope" })]);
|
|
73
|
+
closers.push(() => new Promise((r) => server.close(() => r())));
|
|
74
|
+
const endpoint = { baseUrl: "http://127.0.0.1:1", token: "tok", port: "1", socketPath };
|
|
75
|
+
await assert.rejects(daemonRequest(endpoint, "/api/x"), /^Error: nope$/);
|
|
76
|
+
});
|
|
77
|
+
it("streams NDJSON from the socket", async () => {
|
|
78
|
+
const socketPath = join(tempDir(), "daemon.sock");
|
|
79
|
+
const server = await listenUnix(socketPath, () => [200, '{"n":1}\n{"n":2}\n']);
|
|
80
|
+
closers.push(() => new Promise((r) => server.close(() => r())));
|
|
81
|
+
const endpoint = { baseUrl: "http://127.0.0.1:1", token: "tok", port: "1", socketPath };
|
|
82
|
+
const lines = [];
|
|
83
|
+
await daemonStream(endpoint, "/api/absorb", { method: "POST", body: {} }, (l) => lines.push(l));
|
|
84
|
+
assert.deepEqual(lines, [{ n: 1 }, { n: 2 }]);
|
|
85
|
+
});
|
|
86
|
+
it("falls back to TCP when the socket is gone", async () => {
|
|
87
|
+
const tcp = createHttpServer((req, res) => {
|
|
88
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
89
|
+
res.end(JSON.stringify({ via: "tcp", loopback: isLoopbackRequest(req) }));
|
|
90
|
+
});
|
|
91
|
+
await new Promise((r) => tcp.listen(0, "127.0.0.1", r));
|
|
92
|
+
closers.push(() => new Promise((r) => tcp.close(() => r())));
|
|
93
|
+
const port = tcp.address().port;
|
|
94
|
+
const endpoint = {
|
|
95
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
96
|
+
token: "tok",
|
|
97
|
+
port: String(port),
|
|
98
|
+
socketPath: join(tempDir(), "daemon.sock"),
|
|
99
|
+
};
|
|
100
|
+
assert.deepEqual(await daemonRequest(endpoint, "/api/status"), { via: "tcp", loopback: true });
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe("the MCP bridge's dial target", () => {
|
|
104
|
+
it("keeps the socket path out of the URL, so any home directory dials", async () => {
|
|
105
|
+
// A space, non-ASCII and a colon: each broke the old ws+unix URL.
|
|
106
|
+
const dir = mkdtempSync(join(tmpdir(), "brane sock josé:x-"));
|
|
107
|
+
const socketPath = join(dir, "daemon.sock");
|
|
108
|
+
const target = mcpDialTarget({ port: "4000", tls: true, token: "t/k", socketPath });
|
|
109
|
+
assert.deepEqual(target, { url: "ws://localhost/ws/mcp?token=t%2Fk", socketPath });
|
|
110
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
111
|
+
const server = createHttpServer();
|
|
112
|
+
const seen = [];
|
|
113
|
+
server.on("connection", (socket) => markUnixConnection(socket));
|
|
114
|
+
server.on("upgrade", (req, socket, head) => {
|
|
115
|
+
seen.push({ url: req.url, loopback: isLoopbackRequest(req) });
|
|
116
|
+
wss.handleUpgrade(req, socket, head, (ws) => ws.close());
|
|
117
|
+
});
|
|
118
|
+
await new Promise((r) => server.listen(socketPath, r));
|
|
119
|
+
try {
|
|
120
|
+
// What the bridge's connect() does with it: append its params, swap in
|
|
121
|
+
// a session token, and hand the socket to ws as the connection.
|
|
122
|
+
const u = new URL(`${target.url}&nid=claude-x`);
|
|
123
|
+
u.searchParams.set("token", "agent");
|
|
124
|
+
await new Promise((resolve, reject) => {
|
|
125
|
+
const ws = new WebSocket(u.toString(), { createConnection: () => netConnect(target.socketPath) });
|
|
126
|
+
ws.on("close", () => resolve());
|
|
127
|
+
ws.on("error", reject);
|
|
128
|
+
});
|
|
129
|
+
assert.deepEqual(seen, [{ url: "/ws/mcp?token=agent&nid=claude-x", loopback: true }]);
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
await new Promise((r) => server.close(() => r()));
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
it("dials TCP on loopback when there is no socket", () => {
|
|
136
|
+
assert.deepEqual(mcpDialTarget({ port: "4000", tls: true, token: "t" }), { url: "wss://localhost:4000/ws/mcp?token=t" });
|
|
137
|
+
assert.deepEqual(mcpDialTarget({ port: "4000", tls: false, token: "t" }), { url: "ws://localhost:4000/ws/mcp?token=t" });
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
describe("falling back to TCP before anything was sent", () => {
|
|
141
|
+
async function tcpEndpoint(socketPath) {
|
|
142
|
+
const tcp = createHttpServer((_req, res) => {
|
|
143
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
144
|
+
res.end(JSON.stringify({ via: "tcp" }));
|
|
145
|
+
});
|
|
146
|
+
await new Promise((r) => tcp.listen(0, "127.0.0.1", r));
|
|
147
|
+
after(() => new Promise((r) => tcp.close(() => r())));
|
|
148
|
+
const port = tcp.address().port;
|
|
149
|
+
return { baseUrl: `http://127.0.0.1:${port}`, token: "tok", port: String(port), socketPath };
|
|
150
|
+
}
|
|
151
|
+
it("when the socket refuses (a crashed daemon's file)", async () => {
|
|
152
|
+
const path = crashedSocket();
|
|
153
|
+
assert.deepEqual(await daemonRequest(await tcpEndpoint(path), "/api/status"), { via: "tcp" });
|
|
154
|
+
});
|
|
155
|
+
it("when the path is not a socket", async () => {
|
|
156
|
+
const path = join(tempDir(), "daemon.sock");
|
|
157
|
+
writeFileSync(path, "not a socket");
|
|
158
|
+
assert.deepEqual(await daemonRequest(await tcpEndpoint(path), "/api/status"), { via: "tcp" });
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
describe("a failure after the socket connected", () => {
|
|
162
|
+
it("is reported, never retried over TCP, so a POST cannot run twice", async () => {
|
|
163
|
+
let tcpHits = 0;
|
|
164
|
+
const tcp = createHttpServer((_req, res) => {
|
|
165
|
+
tcpHits++;
|
|
166
|
+
res.end("{}");
|
|
167
|
+
});
|
|
168
|
+
await new Promise((r) => tcp.listen(0, "127.0.0.1", r));
|
|
169
|
+
const port = tcp.address().port;
|
|
170
|
+
const socketPath = join(tempDir(), "daemon.sock");
|
|
171
|
+
// Accepts, reads the request, and drops the connection without answering.
|
|
172
|
+
const dropper = createNetServer((sock) => sock.once("data", () => sock.destroy()));
|
|
173
|
+
await new Promise((r) => dropper.listen(socketPath, r));
|
|
174
|
+
try {
|
|
175
|
+
const endpoint = { baseUrl: `http://127.0.0.1:${port}`, token: "t", port: String(port), socketPath };
|
|
176
|
+
await assert.rejects(daemonRequest(endpoint, "/api/spawn", { method: "POST", body: { agent: "claude" } }));
|
|
177
|
+
assert.equal(tcpHits, 0);
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
await new Promise((r) => dropper.close(() => r()));
|
|
181
|
+
await new Promise((r) => tcp.close(() => r()));
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
describe("readDaemonSocket", () => {
|
|
186
|
+
it("names the socket only while its file is there", () => {
|
|
187
|
+
const db = createTestDb();
|
|
188
|
+
const path = join(tempDir(), "daemon.sock");
|
|
189
|
+
db.prepare("INSERT INTO meta (key, value) VALUES ('daemon_socket', ?)").run(path);
|
|
190
|
+
assert.equal(readDaemonSocket(db), undefined);
|
|
191
|
+
writeFileSync(path, "");
|
|
192
|
+
assert.equal(readDaemonSocket(db), path);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
describe("connectionLabel", () => {
|
|
196
|
+
it("is daemon.sock on the socket, else the remote address", () => {
|
|
197
|
+
const unix = {};
|
|
198
|
+
markUnixConnection(unix);
|
|
199
|
+
assert.equal(connectionLabel(unix), UNIX_SOCKET_SIGHTING);
|
|
200
|
+
assert.equal(connectionLabel({ remoteAddress: "100.64.0.7" }), "100.64.0.7");
|
|
201
|
+
assert.equal(connectionLabel({}), null);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
describe("listenOnLocalSocket", () => {
|
|
205
|
+
function fileDb(dir = tempDir()) {
|
|
206
|
+
const db = new Database(join(dir, "brane.db"));
|
|
207
|
+
db.exec(SCHEMA_SQL);
|
|
208
|
+
applyMigrations(db);
|
|
209
|
+
return db;
|
|
210
|
+
}
|
|
211
|
+
it("listens 0600 beside the database, advertises it, marks its connections, and removes it on close", async () => {
|
|
212
|
+
const db = fileDb();
|
|
213
|
+
const path = getSocketPath(db);
|
|
214
|
+
const seen = [];
|
|
215
|
+
const server = createHttpServer((req, res) => {
|
|
216
|
+
seen.push(isLoopbackRequest(req));
|
|
217
|
+
res.end("{}");
|
|
218
|
+
});
|
|
219
|
+
assert.equal(await listenOnLocalSocket(server, db), true);
|
|
220
|
+
assert.equal(lstatSync(path).mode & 0o777, 0o600);
|
|
221
|
+
assert.equal(readDaemonSocket(db), path);
|
|
222
|
+
await daemonRequest({ baseUrl: "http://127.0.0.1:1", token: "t", port: "1", socketPath: path }, "/api/status");
|
|
223
|
+
assert.deepEqual(seen, [true]);
|
|
224
|
+
await new Promise((r) => server.close(() => r()));
|
|
225
|
+
assert.equal(existsSync(path), false);
|
|
226
|
+
});
|
|
227
|
+
it("takes over a crashed daemon's socket", async () => {
|
|
228
|
+
const dir = tempDir();
|
|
229
|
+
crashedSocket(join(dir, "daemon.sock"));
|
|
230
|
+
const db = fileDb(dir);
|
|
231
|
+
const server = createHttpServer();
|
|
232
|
+
assert.equal(await listenOnLocalSocket(server, db), true);
|
|
233
|
+
await new Promise((r) => server.close(() => r()));
|
|
234
|
+
});
|
|
235
|
+
it("leaves anything else at the path alone, and says so", async () => {
|
|
236
|
+
const dir = tempDir();
|
|
237
|
+
writeFileSync(join(dir, "daemon.sock"), "mine");
|
|
238
|
+
const db = fileDb(dir);
|
|
239
|
+
const lines = [];
|
|
240
|
+
assert.equal(await listenOnLocalSocket(createHttpServer(), db, (l) => lines.push(l)), false);
|
|
241
|
+
assert.equal(readFileSync(join(dir, "daemon.sock"), "utf8"), "mine");
|
|
242
|
+
assert.equal(readDaemonSocket(db), undefined);
|
|
243
|
+
assert.match(lines[0], /in use or not a socket/);
|
|
244
|
+
});
|
|
245
|
+
it("skips a path too long for bind(2)", async () => {
|
|
246
|
+
let dir = tempDir();
|
|
247
|
+
while (Buffer.byteLength(join(dir, "daemon.sock")) <= MAX_SOCKET_PATH) {
|
|
248
|
+
dir = join(dir, "d".repeat(20));
|
|
249
|
+
mkdirSync(dir);
|
|
250
|
+
}
|
|
251
|
+
const db = fileDb(dir);
|
|
252
|
+
const lines = [];
|
|
253
|
+
assert.equal(await listenOnLocalSocket(createHttpServer(), db, (l) => lines.push(l)), false);
|
|
254
|
+
assert.match(lines[0], /too long/);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
describe("clearStaleSocket", () => {
|
|
258
|
+
it("frees a path with nothing at it", async () => {
|
|
259
|
+
assert.equal(await clearStaleSocket(join(tempDir(), "daemon.sock")), true);
|
|
260
|
+
});
|
|
261
|
+
it("removes a socket nothing answers on, as a crashed daemon leaves", async () => {
|
|
262
|
+
const path = crashedSocket();
|
|
263
|
+
assert.equal(lstatSync(path).isSocket(), true);
|
|
264
|
+
assert.equal(await clearStaleSocket(path), true);
|
|
265
|
+
assert.equal(existsSync(path), false);
|
|
266
|
+
});
|
|
267
|
+
it("leaves a live socket alone", async () => {
|
|
268
|
+
const path = join(tempDir(), "daemon.sock");
|
|
269
|
+
const live = createNetServer((s) => s.end());
|
|
270
|
+
await new Promise((r) => live.listen(path, r));
|
|
271
|
+
try {
|
|
272
|
+
assert.equal(await clearStaleSocket(path), false);
|
|
273
|
+
assert.equal(existsSync(path), true);
|
|
274
|
+
}
|
|
275
|
+
finally {
|
|
276
|
+
await new Promise((r) => live.close(() => r()));
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
it("never touches a file that is not a socket", async () => {
|
|
280
|
+
const path = join(tempDir(), "daemon.sock");
|
|
281
|
+
writeFileSync(path, "not a socket");
|
|
282
|
+
assert.equal(await clearStaleSocket(path), false);
|
|
283
|
+
assert.equal(existsSync(path), true);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
describe("a token seen on daemon.sock and on loopback TCP", () => {
|
|
287
|
+
it("is seen in one place, not flagged as a copy", () => {
|
|
288
|
+
assert.equal(samePlace(UNIX_SOCKET_SIGHTING, "127.0.0.1"), true);
|
|
289
|
+
assert.equal(samePlace("::1", UNIX_SOCKET_SIGHTING), true);
|
|
290
|
+
assert.equal(samePlace(UNIX_SOCKET_SIGHTING, "100.64.0.7"), false);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
//# sourceMappingURL=daemon-socket.test.js.map
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { parseDiagram } from "../diagram/parse.js";
|
|
4
|
+
import { layoutDiagram, projectAttachment, faceOffset } from "../diagram/layout.js";
|
|
5
|
+
import { basisPath } from "../diagram/svg.js";
|
|
6
|
+
// Why these assertions exist.
|
|
7
|
+
//
|
|
8
|
+
// dagre picks an edge's terminal point by intersecting the node's box with the
|
|
9
|
+
// chord from the node's *centre* to the first bend. That chord is diagonal, so
|
|
10
|
+
// the edge is handed a diagonal start and the arrowhead arrives tilted — before
|
|
11
|
+
// any curve is fitted. On the LIVE loop diagram it attaches `Listen ->
|
|
12
|
+
// Interpret` 28px to the left of where the edge is actually heading.
|
|
13
|
+
//
|
|
14
|
+
// Four curve-side fixes were built and measured against it. Every one bought a
|
|
15
|
+
// square arrival by adding inflections, and inflections are what make a diagram
|
|
16
|
+
// read as busy: clamped-tangent Hermite reached 3.3° mean arrival, basis with
|
|
17
|
+
// normal stubs reached 0.0°, and both looked worse than untouched `curveBasis`
|
|
18
|
+
// at 31.4°. Moving the *attachment point* and leaving the curve alone reaches
|
|
19
|
+
// 8.7° with the inflection count unchanged at zero.
|
|
20
|
+
//
|
|
21
|
+
// So the property under test is not "the curve is smooth" — it is "the terminal
|
|
22
|
+
// point lines up with the bend the edge is heading for". That is what these
|
|
23
|
+
// pin, because it is the thing that would silently regress if someone
|
|
24
|
+
// reasonably decided to hand dagre's own points straight to the renderer.
|
|
25
|
+
const LIVE = [
|
|
26
|
+
"flowchart TD",
|
|
27
|
+
" L[Listen] --> I[Interpret]",
|
|
28
|
+
" I --> V[Vocalize] & E[Execute]",
|
|
29
|
+
" V --> E",
|
|
30
|
+
" E --> V",
|
|
31
|
+
" V --> L",
|
|
32
|
+
].join("\n");
|
|
33
|
+
function place(src) {
|
|
34
|
+
const p = parseDiagram(src);
|
|
35
|
+
assert.equal(p.ok, true);
|
|
36
|
+
return layoutDiagram(p.ok ? p.ir : undefined);
|
|
37
|
+
}
|
|
38
|
+
const node = (over = {}) => ({
|
|
39
|
+
id: "N", lines: ["N"], runs: [[{ text: "N" }]], shape: "rect", classes: [],
|
|
40
|
+
x: 100, y: 100, width: 80, height: 40, ...over,
|
|
41
|
+
});
|
|
42
|
+
describe("faceOffset follows the shape, not the bounding box", () => {
|
|
43
|
+
it("is flat across a rectangle", () => {
|
|
44
|
+
assert.equal(faceOffset("rect", 20, 40, 0), 20);
|
|
45
|
+
assert.equal(faceOffset("rect", 20, 40, 39), 20);
|
|
46
|
+
});
|
|
47
|
+
it("falls away linearly on a diamond, so an edge lands on the slope", () => {
|
|
48
|
+
assert.equal(faceOffset("diamond", 20, 40, 0), 20);
|
|
49
|
+
assert.equal(faceOffset("diamond", 20, 40, 20), 10);
|
|
50
|
+
assert.equal(faceOffset("diamond", 20, 40, 40), 0);
|
|
51
|
+
});
|
|
52
|
+
it("follows the ellipse on a circle", () => {
|
|
53
|
+
assert.equal(faceOffset("circle", 20, 40, 0), 20);
|
|
54
|
+
assert.ok(Math.abs(faceOffset("circle", 20, 40, 40)) < 1e-9);
|
|
55
|
+
assert.ok(faceOffset("circle", 20, 40, 20) > 17);
|
|
56
|
+
});
|
|
57
|
+
it("never returns a negative offset, whatever the lateral overshoot", () => {
|
|
58
|
+
for (const shape of ["rect", "diamond", "circle", "hexagon"]) {
|
|
59
|
+
assert.ok(faceOffset(shape, 20, 40, 1000) >= 0, shape);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
describe("projectAttachment lines the terminal point up with the bend", () => {
|
|
64
|
+
it("slides along the face toward the bend", () => {
|
|
65
|
+
const n = node(); // box 60..140 x 80..120
|
|
66
|
+
const dagrePoint = { x: 112, y: 120 }; // dagre's chord result
|
|
67
|
+
const bend = { x: 132, y: 150 }; // where the edge is going
|
|
68
|
+
const p = projectAttachment(n, dagrePoint, bend);
|
|
69
|
+
assert.equal(p.y, 120, "stays on the face it was on");
|
|
70
|
+
assert.equal(p.x, 132, "moves under the bend");
|
|
71
|
+
});
|
|
72
|
+
it("holds off the corner rather than hanging an edge from the tip", () => {
|
|
73
|
+
const n = node();
|
|
74
|
+
const p = projectAttachment(n, { x: 139, y: 120 }, { x: 400, y: 150 });
|
|
75
|
+
assert.ok(p.x < 140, "inside the right edge");
|
|
76
|
+
assert.ok(p.x >= 130, "but not pulled far in");
|
|
77
|
+
});
|
|
78
|
+
it("uses the vertical face when that is the one the point sits on", () => {
|
|
79
|
+
const n = node();
|
|
80
|
+
const p = projectAttachment(n, { x: 140, y: 104 }, { x: 260, y: 96 });
|
|
81
|
+
assert.equal(p.x, 140, "stays on the right face");
|
|
82
|
+
assert.equal(p.y, 96, "moves level with the bend");
|
|
83
|
+
});
|
|
84
|
+
it("puts a diamond's attachment on the slope, not floating off it", () => {
|
|
85
|
+
const n = node({ shape: "diamond" });
|
|
86
|
+
const p = projectAttachment(n, { x: 100, y: 120 }, { x: 120, y: 160 });
|
|
87
|
+
// Half-way out along the bottom face of a diamond is half-way up its side.
|
|
88
|
+
assert.equal(p.x, 120);
|
|
89
|
+
assert.ok(p.y > 100 && p.y < 120, `expected the slope, got y=${p.y}`);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
/** The angle, in degrees, between an edge's final direction and the inward
|
|
93
|
+
* normal of the face it lands on. 0 is a perfectly square arrival. */
|
|
94
|
+
function arrivalAngle(placed, e) {
|
|
95
|
+
const dst = placed.nodes.find((n) => n.id === e.to);
|
|
96
|
+
const end = e.points[e.points.length - 1];
|
|
97
|
+
const prev = e.points[e.points.length - 2];
|
|
98
|
+
const dx = end.x - prev.x, dy = end.y - prev.y;
|
|
99
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
100
|
+
// Inward normal of the nearest face.
|
|
101
|
+
const hx = dst.width / 2, hy = dst.height / 2;
|
|
102
|
+
const cand = [
|
|
103
|
+
{ n: { x: 0, y: 1 }, d: Math.abs(end.y - (dst.y - hy)) },
|
|
104
|
+
{ n: { x: 0, y: -1 }, d: Math.abs(end.y - (dst.y + hy)) },
|
|
105
|
+
{ n: { x: 1, y: 0 }, d: Math.abs(end.x - (dst.x - hx)) },
|
|
106
|
+
{ n: { x: -1, y: 0 }, d: Math.abs(end.x - (dst.x + hx)) },
|
|
107
|
+
].sort((a, b) => a.d - b.d)[0].n;
|
|
108
|
+
const dot = (dx / len) * cand.x + (dy / len) * cand.y;
|
|
109
|
+
return (Math.acos(Math.max(-1, Math.min(1, dot))) * 180) / Math.PI;
|
|
110
|
+
}
|
|
111
|
+
describe("the LIVE loop diagram, laid out", () => {
|
|
112
|
+
const placed = place(LIVE);
|
|
113
|
+
it("places all four nodes and routes all six edges", () => {
|
|
114
|
+
assert.equal(placed.nodes.length, 4);
|
|
115
|
+
assert.equal(placed.edges.length, 6);
|
|
116
|
+
for (const e of placed.edges)
|
|
117
|
+
assert.ok(e.points.length >= 2, `${e.from}->${e.to}`);
|
|
118
|
+
});
|
|
119
|
+
it("gives every edge a finite path inside a finite canvas", () => {
|
|
120
|
+
assert.ok(placed.width > 0 && Number.isFinite(placed.width));
|
|
121
|
+
assert.ok(placed.height > 0 && Number.isFinite(placed.height));
|
|
122
|
+
for (const e of placed.edges) {
|
|
123
|
+
for (const p of e.points) {
|
|
124
|
+
assert.ok(Number.isFinite(p.x) && Number.isFinite(p.y), `${e.from}->${e.to}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
it("arrives square on every bent edge", () => {
|
|
129
|
+
// The measured figure for face-projected attachment on this diagram is
|
|
130
|
+
// 8.7° mean / 32.6° worst, against dagre's own 31.4° / 52.0°. The bound is
|
|
131
|
+
// set above the former and well below the latter, so it catches a
|
|
132
|
+
// regression to raw dagre points without being a brittle pin on a number.
|
|
133
|
+
const bent = placed.edges.filter((e) => !e.selfLoop && e.points.length > 2);
|
|
134
|
+
assert.ok(bent.length >= 3, "expected the diagram to have bent edges");
|
|
135
|
+
const angles = bent.map((e) => arrivalAngle(placed, e));
|
|
136
|
+
const mean = angles.reduce((a, b) => a + b, 0) / angles.length;
|
|
137
|
+
assert.ok(mean < 20, `mean arrival ${mean.toFixed(1)}° should be well under dagre's 31.4°`);
|
|
138
|
+
});
|
|
139
|
+
it("ends every edge on the boundary of its target, not inside it", () => {
|
|
140
|
+
for (const e of placed.edges) {
|
|
141
|
+
if (e.selfLoop)
|
|
142
|
+
continue;
|
|
143
|
+
const dst = placed.nodes.find((n) => n.id === e.to);
|
|
144
|
+
const end = e.points[e.points.length - 1];
|
|
145
|
+
const inX = Math.abs(end.x - dst.x) < dst.width / 2 - 0.5;
|
|
146
|
+
const inY = Math.abs(end.y - dst.y) < dst.height / 2 - 0.5;
|
|
147
|
+
assert.ok(!(inX && inY), `${e.from}->${e.to} ends inside ${e.to}`);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
describe("self-loops", () => {
|
|
152
|
+
// dagre returns a degenerate path for `A --> A`: duplicated points that
|
|
153
|
+
// double back and start outside the node. It is never given one.
|
|
154
|
+
const placed = place("flowchart TD\n A --> A\n A --> B");
|
|
155
|
+
it("draws a loop with real geometry", () => {
|
|
156
|
+
const loop = placed.edges.find((e) => e.selfLoop);
|
|
157
|
+
assert.ok(loop, "the self-loop survived layout");
|
|
158
|
+
assert.ok(loop.points.length >= 4);
|
|
159
|
+
const xs = new Set(loop.points.map((p) => `${p.x},${p.y}`));
|
|
160
|
+
assert.equal(xs.size, loop.points.length, "no duplicated points");
|
|
161
|
+
});
|
|
162
|
+
it("keeps the loop inside the reported canvas", () => {
|
|
163
|
+
const loop = placed.edges.find((e) => e.selfLoop);
|
|
164
|
+
for (const p of loop.points) {
|
|
165
|
+
assert.ok(p.x <= placed.width && p.y <= placed.height, `loop point ${p.x},${p.y} outside ${placed.width}x${placed.height}`);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
describe("basisPath is d3's curveBasis", () => {
|
|
170
|
+
// Ported rather than depended on — 25 lines against a package — so the
|
|
171
|
+
// equivalence is worth stating. These expectations come from d3-shape's own
|
|
172
|
+
// output for the same inputs, including two real corridors dagre produced
|
|
173
|
+
// for the LIVE loop diagram.
|
|
174
|
+
it("draws a straight line through two points", () => {
|
|
175
|
+
assert.equal(basisPath([{ x: 0, y: 0 }, { x: 10, y: 10 }]), "M0,0L10,10");
|
|
176
|
+
});
|
|
177
|
+
it("matches d3 on a three-point corridor", () => {
|
|
178
|
+
// d3-shape emits
|
|
179
|
+
// M128,44L132.667,48.167C137.333,52.333,146.667,60.667,151.333,69
|
|
180
|
+
// C156,77.333,156,85.667,156,89.833L156,94
|
|
181
|
+
// for this input. Ours is that to 2dp — coordinates are rounded on the way
|
|
182
|
+
// out, because a diagram is not measured in thousandths of a pixel and the
|
|
183
|
+
// saved bytes go into every event that carries a fence.
|
|
184
|
+
assert.equal(basisPath([{ x: 128, y: 44 }, { x: 156, y: 69 }, { x: 156, y: 94 }]), "M128,44L132.67,48.17C137.33,52.33,146.67,60.67,151.33,69C156,77.33,156,85.67,156,89.83L156,94");
|
|
185
|
+
});
|
|
186
|
+
it("matches d3 on a four-point corridor", () => {
|
|
187
|
+
assert.equal(basisPath([{ x: 160, y: 130 }, { x: 166, y: 155 }, { x: 166, y: 198 }, { x: 134, y: 266 }]), "M160,130L161,134.17C162,138.33,164,146.67,165,158C166,169.33,166,183.67,160.67,202.17" +
|
|
188
|
+
"C155.33,220.67,144.67,243.33,139.33,254.67L134,266");
|
|
189
|
+
});
|
|
190
|
+
it("reaches both endpoints exactly", () => {
|
|
191
|
+
// A basis spline does *not* pass through its interior control points, and
|
|
192
|
+
// it is often said not to reach its ends either. d3 duplicates the
|
|
193
|
+
// terminals, so it does — which is why the tilt this module's layout
|
|
194
|
+
// corrects was never the curve's fault.
|
|
195
|
+
const pts = [{ x: 160, y: 130 }, { x: 166, y: 155 }, { x: 166, y: 198 }, { x: 134, y: 266 }];
|
|
196
|
+
const d = basisPath(pts);
|
|
197
|
+
assert.ok(d.startsWith("M160,130"), d.slice(0, 20));
|
|
198
|
+
assert.ok(d.endsWith("L134,266"), d.slice(-20));
|
|
199
|
+
});
|
|
200
|
+
it("degrades safely on empty and single-point input", () => {
|
|
201
|
+
assert.equal(basisPath([]), "");
|
|
202
|
+
assert.equal(basisPath([{ x: 3, y: 4 }]), "M3,4");
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
//# sourceMappingURL=diagram-arrows.test.js.map
|