@nanobpm/nano-workforce 0.53.0 → 0.55.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.
@@ -0,0 +1,331 @@
1
+ // Browser adapter for the SUPPLY-only agentic cockpit (H5 / #148).
2
+ //
3
+ // This is the ONE wiring both the standalone shell and the console App-View embed call — they differ
4
+ // only in the host element they pass, so the supply cockpit renders identically embedded and
5
+ // standalone. It supplies the browser capabilities the injection-based core needs: the real
6
+ // `document`, a `fetch`-based supply report source, a `WebSocket` relay socket factory, and an
7
+ // xterm.js terminal sink.
8
+ //
9
+ // The genuinely reusable, correctness-critical parts — the relay client and the resume-from-offset
10
+ // terminal session — are REUSED from `@nanobpm/agentic/cockpit` (resolved via the host page's import
11
+ // map). Only the SUPPLY projection + render + poll orchestration the package does NOT provide is
12
+ // re-expressed here in plain browser ESM (the app has no build step, so the typed core under
13
+ // `app/agentic/cockpit/` cannot be imported directly by the browser). It is kept faithful to that
14
+ // tested TypeScript core: same DOM shape (`data-worker`, `data-liveness`, `data-stream`, …), same
15
+ // liveness grading, same self-scheduling poll + persistent-terminal discipline.
16
+ //
17
+ // It renders the SUPPLY worker list ONLY — NOT the packaged demand×supply matrix / missing-agent reds
18
+ // / diversity-SLO light (deferred to enrolment epic #152).
19
+ import { RelayChannelClient, TerminalSession } from "@nanobpm/agentic/cockpit";
20
+ import { Terminal } from "@xterm/xterm";
21
+
22
+ const DEFAULT_REFRESH_MS = 2000;
23
+ const DEFAULT_STALE_AFTER_MS = 15_000;
24
+
25
+ function isPosInt(value) {
26
+ return Number.isSafeInteger(value) && value > 0;
27
+ }
28
+
29
+ // ── supply projection (mirrors app/agentic/cockpit/supply-view.ts) ─────────────────────────────
30
+
31
+ function liveness(worker, staleAfterMs) {
32
+ if (!worker.live) return "down";
33
+ return worker.staleMs >= staleAfterMs ? "stale" : "live";
34
+ }
35
+
36
+ function workerView(worker, staleAfterMs) {
37
+ const jobKeys = [...(worker.jobKeys ?? [])].sort((a, b) => a.localeCompare(b));
38
+ return {
39
+ instance: worker.instance,
40
+ identity: worker.identity,
41
+ stream: worker.stream,
42
+ family: worker.family ?? "\u2014",
43
+ host: worker.host ?? "\u2014",
44
+ jobKeys,
45
+ jobs: jobKeys.length,
46
+ liveness: liveness(worker, staleAfterMs),
47
+ staleMs: worker.staleMs,
48
+ };
49
+ }
50
+
51
+ function supplyView(report, staleAfterMs) {
52
+ const byInstance = (a, b) => a.instance.localeCompare(b.instance);
53
+ const leaves = (report.leaves ?? [])
54
+ .map((leaf) => {
55
+ const workers = leaf.workers.map((w) => workerView(w, staleAfterMs)).sort(byInstance);
56
+ return {
57
+ token: leaf.token,
58
+ workers,
59
+ liveCount: workers.filter((w) => w.liveness === "live").length,
60
+ total: workers.length,
61
+ };
62
+ })
63
+ .sort((a, b) => a.token.localeCompare(b.token));
64
+ const workers = (report.workers ?? []).map((w) => workerView(w, staleAfterMs)).sort(byInstance);
65
+ return { leaves, workers, count: workers.length, live: workers.filter((w) => w.liveness === "live").length };
66
+ }
67
+
68
+ // ── supply render (mirrors app/agentic/cockpit/supply-render.ts) ───────────────────────────────
69
+
70
+ function el(doc, tag, className, text) {
71
+ const node = doc.createElement(tag);
72
+ if (className !== undefined) node.className = className;
73
+ if (text !== undefined) node.textContent = text;
74
+ return node;
75
+ }
76
+
77
+ function dot(doc, live) {
78
+ const node = el(doc, "span", "cockpit-dot");
79
+ node.setAttribute("data-liveness", live);
80
+ return node;
81
+ }
82
+
83
+ function workerRow(doc, worker, onDrill) {
84
+ const row = el(doc, "tr", "cockpit-supply-worker");
85
+ row.setAttribute("data-worker", worker.instance);
86
+ row.setAttribute("data-liveness", worker.liveness);
87
+ row.setAttribute("data-stream", worker.stream);
88
+
89
+ const nameCell = el(doc, "td", "cockpit-td cockpit-supply-name");
90
+ nameCell.appendChild(dot(doc, worker.liveness));
91
+ const button = el(doc, "button", "cockpit-worker", worker.instance);
92
+ button.setAttribute("type", "button");
93
+ button.setAttribute("data-stream", worker.stream);
94
+ if (onDrill) button.addEventListener("click", () => onDrill(worker.stream));
95
+ nameCell.appendChild(button);
96
+ row.appendChild(nameCell);
97
+
98
+ row.appendChild(el(doc, "td", "cockpit-td cockpit-supply-family", worker.family));
99
+ row.appendChild(el(doc, "td", "cockpit-td cockpit-supply-host", worker.host));
100
+ const jobsCell = el(doc, "td", "cockpit-td cockpit-supply-jobs", worker.jobs === 0 ? "\u2014" : worker.jobKeys.join(", "));
101
+ jobsCell.setAttribute("data-jobs", String(worker.jobs));
102
+ row.appendChild(jobsCell);
103
+ const livenessCell = el(doc, "td", "cockpit-td cockpit-supply-liveness", worker.liveness);
104
+ livenessCell.setAttribute("data-liveness", worker.liveness);
105
+ row.appendChild(livenessCell);
106
+ return row;
107
+ }
108
+
109
+ function leafSection(doc, leaf, onDrill) {
110
+ const section = el(doc, "section", "cockpit-leaf");
111
+ section.setAttribute("data-leaf", leaf.token);
112
+ const header = el(doc, "div", "cockpit-leaf-head");
113
+ header.appendChild(el(doc, "span", "cockpit-leaf-name", leaf.token));
114
+ header.appendChild(el(doc, "span", "cockpit-leaf-count", `${leaf.liveCount}/${leaf.total} live`));
115
+ section.appendChild(header);
116
+ const table = el(doc, "table", "cockpit-supply-table");
117
+ const thead = el(doc, "thead", "cockpit-supply-thead");
118
+ const head = el(doc, "tr", "cockpit-supply-head");
119
+ for (const label of ["worker", "family", "host", "jobs", "liveness"]) head.appendChild(el(doc, "th", "cockpit-th", label));
120
+ thead.appendChild(head);
121
+ table.appendChild(thead);
122
+ const tbody = el(doc, "tbody", "cockpit-supply-tbody");
123
+ for (const worker of leaf.workers) tbody.appendChild(workerRow(doc, worker, onDrill));
124
+ table.appendChild(tbody);
125
+ section.appendChild(table);
126
+ return section;
127
+ }
128
+
129
+ function renderSupply(host, doc, view, onDrill) {
130
+ host.replaceChildren();
131
+ const root = el(doc, "div", "cockpit-supply");
132
+ root.setAttribute("data-worker-count", String(view.count));
133
+ root.setAttribute("data-live-count", String(view.live));
134
+ const header = el(doc, "header", "cockpit-header");
135
+ header.appendChild(el(doc, "h1", "cockpit-title", "Workers — supply"));
136
+ const summary = el(doc, "span", "cockpit-supply-summary", `${view.live}/${view.count} live`);
137
+ summary.setAttribute("data-summary", "supply");
138
+ header.appendChild(summary);
139
+ root.appendChild(header);
140
+ if (view.count === 0) {
141
+ const empty = el(doc, "div", "cockpit-supply-empty", "No workers connected.");
142
+ empty.setAttribute("data-empty", "true");
143
+ root.appendChild(empty);
144
+ host.appendChild(root);
145
+ return;
146
+ }
147
+ const list = el(doc, "div", "cockpit-supply-list");
148
+ for (const leaf of view.leaves) list.appendChild(leafSection(doc, leaf, onDrill));
149
+ root.appendChild(list);
150
+ host.appendChild(root);
151
+ }
152
+
153
+ // ── boot orchestration (mirrors app/agentic/cockpit/supply-boot.ts) ────────────────────────────
154
+
155
+ /** An xterm.js-backed terminal sink mounted into `host`. */
156
+ function xtermSink(host) {
157
+ const term = new Terminal({ convertEol: true, fontFamily: "ui-monospace, monospace", fontSize: 13 });
158
+ term.open(host);
159
+ return { write: (chunk) => term.write(chunk), dispose: () => term.dispose() };
160
+ }
161
+
162
+ /** A WebSocket relay socket factory for the agentic channel at `url`. */
163
+ function relaySocketFactory(url) {
164
+ return () => {
165
+ const ws = new WebSocket(url);
166
+ ws.binaryType = "arraybuffer";
167
+ return {
168
+ send: (bytes) => ws.send(bytes),
169
+ close: () => ws.close(),
170
+ onMessage: (cb) => ws.addEventListener("message", (event) => cb(new Uint8Array(event.data))),
171
+ onOpen: (cb) => ws.addEventListener("open", () => cb()),
172
+ onClose: (cb) => ws.addEventListener("close", () => cb()),
173
+ };
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Mount the SUPPLY cockpit into `host` and start polling.
179
+ *
180
+ * @param {Element} host — where the cockpit renders (standalone: document.body; embedded: the App-View host).
181
+ * @param {object} [opts]
182
+ * @param {string} [opts.reportUrl] — the supply JSON endpoint the app serves.
183
+ * @param {string} [opts.relayUrl] — the agentic channel WebSocket URL (with auth token + capability query).
184
+ * @param {string} [opts.hookSecret] — shared secret sent as `x-hook-secret` on the report fetch when the
185
+ * app's supply endpoint is guarded by NANO_PR_WEBHOOK_SECRET (omit for open deployments).
186
+ * @param {string} [opts.relayToken] — identity token appended to the default relay URL as `?token=…`.
187
+ * @param {string} [opts.relayCapability] — capability credential appended to the default relay URL as `&capability=…`.
188
+ * @param {number} [opts.refreshMs] — poll interval (default 2000).
189
+ * @param {number} [opts.staleAfterMs] — a worker is rendered "stale" once its last heartbeat is at
190
+ * least this many ms old (default 15000).
191
+ * @returns a handle with `.dispose()`.
192
+ */
193
+ export function mountCockpit(host, opts = {}) {
194
+ if (host == null || typeof host.replaceChildren !== "function") {
195
+ throw new Error(
196
+ `mountCockpit(host): host must be a mounted DOM element (got ${host === null ? "null" : typeof host}).`,
197
+ );
198
+ }
199
+ const doc = document;
200
+ const reportUrl = opts.reportUrl ?? "/app/api/agentic/supply";
201
+ const hookSecret = opts.hookSecret;
202
+ const relayUrl = opts.relayUrl ?? defaultRelayUrl(opts.relayToken, opts.relayCapability);
203
+ const refreshMs = opts.refreshMs ?? DEFAULT_REFRESH_MS;
204
+ // refreshMs feeds setTimeout as a poll delay. A negative/NaN/fractional/unsafe value silently
205
+ // collapses to a ~0ms delay, turning the poll into a hot loop that hammers the supply endpoint.
206
+ // Require a positive safe integer up-front (mirroring the TS boot layer) so a bad opt fails loudly.
207
+ if (!isPosInt(refreshMs)) {
208
+ throw new RangeError(`mountCockpit(opts.refreshMs): must be a positive safe integer, got ${refreshMs}.`);
209
+ }
210
+ const staleAfterMs = opts.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
211
+ const connectRelay = relaySocketFactory(relayUrl);
212
+ const onError = (err) => console.error("[cockpit]", err);
213
+
214
+ // Stable skeleton: a volatile list region the poll re-renders + a PERSISTENT terminal region a
215
+ // refresh never touches (so a drilled-in terminal survives a list refresh).
216
+ host.replaceChildren();
217
+ const shell = el(doc, "div", "cockpit-shell");
218
+ const listRegion = el(doc, "div", "cockpit-supply-region");
219
+ const terminalPanel = el(doc, "section", "cockpit-terminal");
220
+ terminalPanel.appendChild(el(doc, "h2", "cockpit-panel-title", "Worker terminal"));
221
+ const terminalHost = el(doc, "div", "cockpit-terminal-host");
222
+ terminalHost.setAttribute("data-terminal", "host");
223
+ terminalPanel.appendChild(terminalHost);
224
+ shell.appendChild(listRegion);
225
+ shell.appendChild(terminalPanel);
226
+ host.appendChild(shell);
227
+
228
+ let running = false;
229
+ let disposed = false;
230
+ let timer;
231
+ let generation = 0;
232
+ let drill; // { stream, client }
233
+ let terminal; // the current xterm sink
234
+
235
+ function drillInto(stream) {
236
+ if (disposed || drill?.stream === stream) return;
237
+ drill?.client.close();
238
+ drill = undefined;
239
+ terminal?.dispose?.();
240
+ terminal = undefined;
241
+ try {
242
+ terminalHost.replaceChildren();
243
+ const sink = xtermSink(terminalHost);
244
+ terminal = sink;
245
+ let session;
246
+ const client = new RelayChannelClient({
247
+ connect: connectRelay,
248
+ onRelay: (message) => session?.handle(message),
249
+ onOpen: () => session?.attach(),
250
+ onError,
251
+ });
252
+ session = new TerminalSession({ stream, sink, send: (message) => client.sendRelay(message) });
253
+ client.open();
254
+ drill = { stream, client };
255
+ } catch (err) {
256
+ onError(err);
257
+ }
258
+ }
259
+
260
+ async function refresh() {
261
+ if (disposed) return;
262
+ let report;
263
+ try {
264
+ const headers = { accept: "application/json" };
265
+ if (hookSecret) headers["x-hook-secret"] = hookSecret;
266
+ const res = await fetch(reportUrl, { headers });
267
+ if (!res.ok) throw new Error(`supply fetch failed: ${res.status}`);
268
+ report = await res.json();
269
+ } catch (err) {
270
+ onError(err);
271
+ return;
272
+ }
273
+ if (disposed) return;
274
+ try {
275
+ renderSupply(listRegion, doc, supplyView(report, staleAfterMs), drillInto);
276
+ } catch (err) {
277
+ onError(err);
278
+ }
279
+ }
280
+
281
+ function tick(gen) {
282
+ void refresh().finally(() => {
283
+ if (gen !== generation || !running || disposed) return;
284
+ timer = setTimeout(() => tick(gen), refreshMs);
285
+ });
286
+ }
287
+
288
+ function start() {
289
+ if (disposed || running) return;
290
+ running = true;
291
+ tick(++generation);
292
+ }
293
+ function stop() {
294
+ running = false;
295
+ generation++;
296
+ if (timer !== undefined) {
297
+ clearTimeout(timer);
298
+ timer = undefined;
299
+ }
300
+ }
301
+ function dispose() {
302
+ if (disposed) return;
303
+ disposed = true;
304
+ stop();
305
+ drill?.client.close();
306
+ drill = undefined;
307
+ terminal?.dispose?.();
308
+ terminal = undefined;
309
+ }
310
+
311
+ start();
312
+ return { start, stop, dispose, refresh, drill: drillInto };
313
+ }
314
+
315
+ /**
316
+ * Derive the channel WebSocket URL from the current origin (path `/agentic`).
317
+ *
318
+ * The agentic hub authenticates upgrades with `sharedSecretAuthenticator({ requireCredential: true })`,
319
+ * so a bare `ws(s)://host/agentic` is rejected (4401/4403). When a `token` (and optional `capability`)
320
+ * are supplied, they are appended as the `?token=…&capability=…` query the hub requires; without them
321
+ * the default URL cannot authenticate and drill-in will be refused — pass credentials (or an explicit
322
+ * `relayUrl`) for secured deployments.
323
+ */
324
+ function defaultRelayUrl(token, capability) {
325
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
326
+ const base = `${proto}//${location.host}/agentic`;
327
+ if (!token) return base;
328
+ const query = new URLSearchParams({ token });
329
+ if (capability) query.set("capability", capability);
330
+ return `${base}?${query}`;
331
+ }
@@ -0,0 +1,46 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
6
+ <title>Agent cockpit — supply</title>
7
+ <link rel="stylesheet" href="./cockpit.css" />
8
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5/css/xterm.min.css" />
9
+ <style>
10
+ html, body { margin: 0; height: 100%; background: #0b0f14; }
11
+ </style>
12
+ <!--
13
+ Resolve the reusable cockpit ESM (the relay client + resume-from-offset terminal session) and
14
+ xterm.js. Only these correctness-critical primitives are imported from the package; the supply
15
+ projection/render/poll lives inline in ./mount.js (the app has no build step to share its typed
16
+ core with the browser). The SAME ./mount.js the console App-View embed uses is loaded here, so
17
+ the standalone phone view and the embedded console view render identically.
18
+ -->
19
+ <script type="importmap">
20
+ {
21
+ "imports": {
22
+ "@nanobpm/agentic/cockpit": "https://cdn.jsdelivr.net/npm/@nanobpm/agentic@0.1.0/dist/cockpit/index.js",
23
+ "@xterm/xterm": "https://cdn.jsdelivr.net/npm/@xterm/xterm@5/+esm"
24
+ }
25
+ }
26
+ </script>
27
+ </head>
28
+ <body>
29
+ <main id="cockpit-root"></main>
30
+ <script type="module">
31
+ import { mountCockpit } from "./mount.js";
32
+
33
+ // Standalone: mount into the page body. Endpoints default to the current origin; override via
34
+ // ?report= and ?relay= for a remote app. For a secured deployment, pass the report guard secret
35
+ // via ?secret= (sent as x-hook-secret) and the relay credentials via ?token= and ?capability=.
36
+ const params = new URLSearchParams(location.search);
37
+ mountCockpit(document.getElementById("cockpit-root"), {
38
+ reportUrl: params.get("report") ?? undefined,
39
+ relayUrl: params.get("relay") ?? undefined,
40
+ hookSecret: params.get("secret") ?? undefined,
41
+ relayToken: params.get("token") ?? undefined,
42
+ relayCapability: params.get("capability") ?? undefined,
43
+ });
44
+ </script>
45
+ </body>
46
+ </html>
@@ -0,0 +1,46 @@
1
+ {
2
+ "schemaVersion": "1.0",
3
+ "title": "Cockpit",
4
+ "nodes": [
5
+ {
6
+ "type": "nav",
7
+ "id": "nav",
8
+ "props": {
9
+ "variant": "bar",
10
+ "title": "Nano Workforce",
11
+ "items": [
12
+ { "label": "Convergence", "page": "home" },
13
+ { "label": "Epics", "page": "epic" },
14
+ { "label": "Cockpit", "page": "cockpit" }
15
+ ],
16
+ "sticky": true
17
+ }
18
+ },
19
+ {
20
+ "type": "text",
21
+ "id": "title",
22
+ "props": {
23
+ "text": "Agent cockpit — live supply",
24
+ "variant": "heading"
25
+ }
26
+ },
27
+ {
28
+ "type": "text",
29
+ "id": "intro",
30
+ "props": {
31
+ "text": "The live worker/supply view: every connected worker grouped by leaf token, with family, host, current jobs, and liveness — sourced from the agentic presence registry. Drill into a worker to stream its terminal live over the relay; the terminal stays mounted across a list refresh and re-attaches (resume-from-offset) on reconnect. The same view renders embedded here (App View) and standalone on a phone. (The demand×supply matrix, missing-agent-type lights, and the diversity SLO are the enrolment epic's board — not shown here.)",
32
+ "variant": "sub"
33
+ }
34
+ },
35
+ {
36
+ "type": "appView",
37
+ "id": "cockpit",
38
+ "props": {
39
+ "title": "Live supply",
40
+ "embed": "./cockpit/embed.html",
41
+ "standalone": "./cockpit/standalone.html",
42
+ "fill": true
43
+ }
44
+ }
45
+ ]
46
+ }
@@ -11,7 +11,8 @@
11
11
  "title": "Nano Workforce",
12
12
  "items": [
13
13
  { "label": "Convergence", "page": "home" },
14
- { "label": "Epics", "page": "epic" }
14
+ { "label": "Epics", "page": "epic" },
15
+ { "label": "Cockpit", "page": "cockpit" }
15
16
  ]
16
17
  }
17
18
  },
@@ -11,7 +11,8 @@
11
11
  "title": "Nano Workforce",
12
12
  "items": [
13
13
  { "label": "Convergence", "page": "home" },
14
- { "label": "Epics", "page": "epic" }
14
+ { "label": "Epics", "page": "epic" },
15
+ { "label": "Cockpit", "page": "cockpit" }
15
16
  ]
16
17
  }
17
18
  },
@@ -16,6 +16,10 @@
16
16
  {
17
17
  "label": "Epics",
18
18
  "page": "epic"
19
+ },
20
+ {
21
+ "label": "Cockpit",
22
+ "page": "cockpit"
19
23
  }
20
24
  ],
21
25
  "sticky": true
@@ -0,0 +1,136 @@
1
+ // Test-only doubles for the SUPPLY cockpit core (H5 / #148): an in-memory DOM that satisfies the
2
+ // renderer's structural {@link ElementLike} / {@link DocumentLike} types, and a fake relay socket that
3
+ // satisfies {@link RawSocket}. They let the supply renderer + boot layer be exercised on Node with no
4
+ // DOM library, no real WebSocket, and no `as` cast — the fakes are STRUCTURALLY the same subsets the
5
+ // browser's real `Document`/`Element`/`WebSocket` provide (mirrors the packaged cockpit's own fakes).
6
+ import { decodeFrame, encodeFrame, type Frame } from "@nanobpm/agentic/protocol";
7
+ import type { DocumentLike, ElementLike, RawSocket } from "@nanobpm/agentic/cockpit";
8
+
9
+ export class FakeElement implements ElementLike {
10
+ className = "";
11
+ textContent: string | null = null;
12
+ readonly tagName: string;
13
+ readonly attributes = new Map<string, string>();
14
+ readonly children: FakeElement[] = [];
15
+ readonly #listeners = new Map<string, Array<() => void>>();
16
+
17
+ constructor(tagName: string) {
18
+ this.tagName = tagName;
19
+ }
20
+
21
+ setAttribute(name: string, value: string): void {
22
+ this.attributes.set(name, value);
23
+ }
24
+
25
+ getAttribute(name: string): string | undefined {
26
+ return this.attributes.get(name);
27
+ }
28
+
29
+ appendChild(child: ElementLike): ElementLike {
30
+ if (child instanceof FakeElement) this.children.push(child);
31
+ return child;
32
+ }
33
+
34
+ replaceChildren(): void {
35
+ this.children.length = 0;
36
+ }
37
+
38
+ addEventListener(type: string, handler: () => void): void {
39
+ const list = this.#listeners.get(type) ?? [];
40
+ list.push(handler);
41
+ this.#listeners.set(type, list);
42
+ }
43
+
44
+ /** Fire every listener registered for `type` (test driver for clicks). */
45
+ dispatch(type: string): void {
46
+ for (const handler of this.#listeners.get(type) ?? []) handler();
47
+ }
48
+
49
+ /** Depth-first walk of this element and its descendants. */
50
+ *walk(): IterableIterator<FakeElement> {
51
+ yield this;
52
+ for (const child of this.children) yield* child.walk();
53
+ }
54
+
55
+ /** Every descendant (and self) whose className contains `cls`. */
56
+ byClass(cls: string): FakeElement[] {
57
+ const out: FakeElement[] = [];
58
+ for (const node of this.walk()) {
59
+ if (node.className.split(/\s+/).includes(cls)) out.push(node);
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /** Every descendant (and self) with `data-<key>` equal to `value` (any value if omitted). */
65
+ byData(key: string, value?: string): FakeElement[] {
66
+ const attr = `data-${key}`;
67
+ const out: FakeElement[] = [];
68
+ for (const node of this.walk()) {
69
+ const got = node.attributes.get(attr);
70
+ if (got !== undefined && (value === undefined || got === value)) out.push(node);
71
+ }
72
+ return out;
73
+ }
74
+
75
+ /** The concatenated text content of this subtree (own text then children). */
76
+ text(): string {
77
+ let out = this.textContent ?? "";
78
+ for (const child of this.children) out += child.text();
79
+ return out;
80
+ }
81
+ }
82
+
83
+ export class FakeDocument implements DocumentLike {
84
+ createElement(tagName: string): ElementLike {
85
+ return new FakeElement(tagName);
86
+ }
87
+ }
88
+
89
+ /** A fake relay socket: records sent frames and lets a test drive open/close/deliver by hand. */
90
+ export class FakeSocket implements RawSocket {
91
+ readonly sent: Uint8Array[] = [];
92
+ closed = false;
93
+ #onMessage: ((bytes: Uint8Array) => void) | undefined;
94
+ #onOpen: (() => void) | undefined;
95
+ #onClose: (() => void) | undefined;
96
+
97
+ send(bytes: Uint8Array): void {
98
+ this.sent.push(bytes);
99
+ }
100
+
101
+ close(): void {
102
+ this.closed = true;
103
+ }
104
+
105
+ onMessage(listener: (bytes: Uint8Array) => void): void {
106
+ this.#onMessage = listener;
107
+ }
108
+
109
+ onOpen(listener: () => void): void {
110
+ this.#onOpen = listener;
111
+ }
112
+
113
+ onClose(listener: () => void): void {
114
+ this.#onClose = listener;
115
+ }
116
+
117
+ /** Fire the open listener (a successful connect). */
118
+ fireOpen(): void {
119
+ this.#onOpen?.();
120
+ }
121
+
122
+ /** Fire the close listener (the socket dropped). */
123
+ fireClose(): void {
124
+ this.#onClose?.();
125
+ }
126
+
127
+ /** Deliver one inbound frame to the client. */
128
+ deliver(frame: Frame): void {
129
+ this.#onMessage?.(encodeFrame(frame));
130
+ }
131
+
132
+ /** The relay-family frames this socket sent, decoded. */
133
+ subscribeFrames(): Frame[] {
134
+ return this.sent.map(decodeFrame).filter((f) => f.family === "relay");
135
+ }
136
+ }