@desplega.ai/agent-swarm 1.79.0 → 1.79.2

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 (46) hide show
  1. package/README.md +2 -0
  2. package/openapi.json +559 -1
  3. package/package.json +4 -4
  4. package/plugin/skills/kv-storage/SKILL.md +168 -0
  5. package/plugin/skills/pages/SKILL.md +149 -0
  6. package/src/artifact-sdk/browser-sdk.ts +292 -0
  7. package/src/be/db.ts +309 -0
  8. package/src/be/migrations/061_kv_store.sql +34 -0
  9. package/src/be/migrations/062_pages_view_count.sql +9 -0
  10. package/src/commands/provider-credentials.ts +1 -1
  11. package/src/http/index.ts +2 -0
  12. package/src/http/kv.ts +658 -0
  13. package/src/http/page-proxy.ts +5 -0
  14. package/src/http/pages-public.ts +50 -6
  15. package/src/http/status.ts +1 -1
  16. package/src/providers/claude-adapter.ts +138 -7
  17. package/src/providers/pi-mono-adapter.ts +3 -3
  18. package/src/providers/pi-mono-extension.ts +1 -1
  19. package/src/server.ts +20 -1
  20. package/src/tasks/context-key.ts +28 -0
  21. package/src/telemetry.ts +65 -1
  22. package/src/tests/claude-adapter-binary.test.ts +628 -0
  23. package/src/tests/context-key.test.ts +17 -0
  24. package/src/tests/kv-http.test.ts +331 -0
  25. package/src/tests/kv-namespace-resolution.test.ts +172 -0
  26. package/src/tests/kv-page-proxy.test.ts +212 -0
  27. package/src/tests/kv-storage.test.ts +227 -0
  28. package/src/tests/kv-tool.test.ts +217 -0
  29. package/src/tests/page-proxy.test.ts +5 -1
  30. package/src/tests/page-session.test.ts +10 -5
  31. package/src/tests/pages-authed-mode.test.ts +5 -1
  32. package/src/tests/pages-public-html.test.ts +10 -1
  33. package/src/tests/pages-view-count.test.ts +220 -0
  34. package/src/tests/swarm-diff.test.ts +303 -0
  35. package/src/tests/telemetry-init.test.ts +149 -0
  36. package/src/tools/kv/index.ts +5 -0
  37. package/src/tools/kv/kv-delete.ts +89 -0
  38. package/src/tools/kv/kv-get.ts +64 -0
  39. package/src/tools/kv/kv-incr.ts +116 -0
  40. package/src/tools/kv/kv-list.ts +81 -0
  41. package/src/tools/kv/kv-set.ts +194 -0
  42. package/src/tools/kv/resolve-namespace.ts +58 -0
  43. package/src/tools/tool-config.ts +7 -0
  44. package/src/types.ts +53 -0
  45. package/src/utils/internal-ai/complete-structured.ts +7 -10
  46. package/src/utils/internal-ai/credentials.ts +3 -3
@@ -197,7 +197,11 @@ describe("GET /p/:id — authed mode cookie gate (step-4)", () => {
197
197
  const exp = Math.floor(Date.now() / 1000) + 3600;
198
198
  const good = await signPageSession({ pageId: id, exp });
199
199
  const [head, sig] = good.split(".");
200
- const tamperedSig = `${sig!.slice(0, -1)}${sig!.slice(-1) === "A" ? "B" : "A"}`;
200
+ // Flip a decoded HMAC byte rather than a base64url char — flipping the
201
+ // last char is flaky (see src/tests/page-session.test.ts for why).
202
+ const sigBytes = Buffer.from(sig!, "base64url");
203
+ sigBytes[0] ^= 0x01;
204
+ const tamperedSig = sigBytes.toString("base64url").replace(/=/g, "");
201
205
  const bad = `${head}.${tamperedSig}`;
202
206
  const res = await fetch(`${BASE}/p/${id}`, {
203
207
  headers: { Cookie: `page_session=${bad}` },
@@ -83,7 +83,16 @@ describe("GET /p/:id — HTML public path", () => {
83
83
  const res = await fetch(`${BASE}/p/${id}`);
84
84
  expect(res.status).toBe(200);
85
85
  expect(res.headers.get("content-type")?.toLowerCase()).toContain("text/html");
86
- expect(res.headers.get("content-security-policy")).toBeTruthy();
86
+ const csp = res.headers.get("content-security-policy");
87
+ expect(csp).toBeTruthy();
88
+ // jsdelivr + unpkg are allowlisted so pages can <script src="…"> common
89
+ // viz libs (Chart.js, ApexCharts, D3, …) instead of inlining bundles.
90
+ const scriptSrc = csp?.split(";").find((d) => d.trim().startsWith("script-src ")) ?? "";
91
+ expect(scriptSrc).toContain("https://cdn.jsdelivr.net");
92
+ expect(scriptSrc).toContain("https://unpkg.com");
93
+ const styleSrc = csp?.split(";").find((d) => d.trim().startsWith("style-src ")) ?? "";
94
+ expect(styleSrc).toContain("https://cdn.jsdelivr.net");
95
+ expect(styleSrc).toContain("https://unpkg.com");
87
96
  const text = await res.text();
88
97
  expect(text).toContain("<h1>Hello</h1>");
89
98
  expect(text).toContain("class SwarmSDK"); // BROWSER_SDK_JS sentinel
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Verifies the per-page view counter:
3
+ * - `view_count` bumps on every 200 from `GET /p/:id` and `GET /p/:id.json`
4
+ * - 401/403/404 responses do NOT bump
5
+ * - Bumps survive across requests (writes are committed)
6
+ * - Counter surfaces on `GET /api/pages` listing and `GET /api/pages/:id`
7
+ *
8
+ * No dedup by viewer — that's the explicit design (per Taras: "super simple
9
+ * counter field, that's it"). If someone wants unique views later, that's a
10
+ * follow-up.
11
+ */
12
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
13
+ import crypto from "node:crypto";
14
+ import { unlink } from "node:fs/promises";
15
+ import {
16
+ createServer as createHttpServer,
17
+ type IncomingMessage,
18
+ type Server,
19
+ type ServerResponse,
20
+ } from "node:http";
21
+ import { closeDb, initDb } from "../be/db";
22
+ import { handlePages } from "../http/pages";
23
+ import { handlePagesPublic } from "../http/pages-public";
24
+ import { getPathSegments, parseQueryParams } from "../http/utils";
25
+
26
+ const TEST_DB_PATH = "./test-pages-view-count.sqlite";
27
+ const TEST_PORT = 13095;
28
+ const BASE = `http://localhost:${TEST_PORT}`;
29
+
30
+ function createTestServer(): Server {
31
+ return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
32
+ const pathSegments = getPathSegments(req.url || "");
33
+ const queryParams = parseQueryParams(req.url || "");
34
+ const myAgentId = req.headers["x-agent-id"] as string | undefined;
35
+ if (await handlePagesPublic(req, res, pathSegments, queryParams)) return;
36
+ if (await handlePages(req, res, pathSegments, queryParams, myAgentId)) return;
37
+ res.writeHead(404);
38
+ res.end("not found");
39
+ });
40
+ }
41
+
42
+ async function getViewCount(id: string, agentId: string): Promise<number> {
43
+ const res = await fetch(`${BASE}/api/pages/${id}`, {
44
+ headers: { "X-Agent-ID": agentId },
45
+ });
46
+ expect(res.status).toBe(200);
47
+ const json = (await res.json()) as { viewCount?: number };
48
+ return typeof json.viewCount === "number" ? json.viewCount : 0;
49
+ }
50
+
51
+ describe("Pages — view_count counter", () => {
52
+ let server: Server;
53
+ const agentId = crypto.randomUUID();
54
+ const headers = { "Content-Type": "application/json", "X-Agent-ID": agentId };
55
+
56
+ beforeAll(async () => {
57
+ for (const suffix of ["", "-wal", "-shm"]) {
58
+ try {
59
+ await unlink(`${TEST_DB_PATH}${suffix}`);
60
+ } catch {}
61
+ }
62
+ initDb(TEST_DB_PATH);
63
+ server = createTestServer();
64
+ await new Promise<void>((resolve) => server.listen(TEST_PORT, () => resolve()));
65
+ });
66
+
67
+ afterAll(async () => {
68
+ await new Promise<void>((resolve) => server.close(() => resolve()));
69
+ closeDb();
70
+ for (const suffix of ["", "-wal", "-shm"]) {
71
+ try {
72
+ await unlink(`${TEST_DB_PATH}${suffix}`);
73
+ } catch {}
74
+ }
75
+ });
76
+
77
+ test("public HTML page: 3 fetches → view_count = 3", async () => {
78
+ const post = await fetch(`${BASE}/api/pages`, {
79
+ method: "POST",
80
+ headers,
81
+ body: JSON.stringify({
82
+ slug: "view-count-html",
83
+ title: "View Count HTML",
84
+ contentType: "text/html",
85
+ authMode: "public",
86
+ body: "<h1>hi</h1>",
87
+ }),
88
+ });
89
+ expect(post.status).toBe(201);
90
+ const { id } = (await post.json()) as { id: string };
91
+
92
+ expect(await getViewCount(id, agentId)).toBe(0);
93
+
94
+ for (let i = 0; i < 3; i++) {
95
+ const r = await fetch(`${BASE}/p/${id}`);
96
+ expect(r.status).toBe(200);
97
+ }
98
+
99
+ expect(await getViewCount(id, agentId)).toBe(3);
100
+ });
101
+
102
+ test("/p/:id.json fetches also bump the counter", async () => {
103
+ const post = await fetch(`${BASE}/api/pages`, {
104
+ method: "POST",
105
+ headers,
106
+ body: JSON.stringify({
107
+ slug: "view-count-json-path",
108
+ title: "View Count JSON Path",
109
+ contentType: "text/html",
110
+ authMode: "public",
111
+ body: "<h1>hi</h1>",
112
+ }),
113
+ });
114
+ expect(post.status).toBe(201);
115
+ const { id } = (await post.json()) as { id: string };
116
+
117
+ for (let i = 0; i < 2; i++) {
118
+ const r = await fetch(`${BASE}/p/${id}.json`);
119
+ expect(r.status).toBe(200);
120
+ }
121
+ // One additional HTML fetch — both paths bump the same counter.
122
+ expect((await fetch(`${BASE}/p/${id}`)).status).toBe(200);
123
+ expect(await getViewCount(id, agentId)).toBe(3);
124
+ });
125
+
126
+ test("404 on unknown page id does NOT crash and does not touch any counter", async () => {
127
+ const bogus = "0".repeat(32);
128
+ const r = await fetch(`${BASE}/p/${bogus}`);
129
+ expect(r.status).toBe(404);
130
+ });
131
+
132
+ test("password-protected page: 401 without unlock does NOT bump", async () => {
133
+ const post = await fetch(`${BASE}/api/pages`, {
134
+ method: "POST",
135
+ headers,
136
+ body: JSON.stringify({
137
+ slug: "view-count-pw",
138
+ title: "View Count Password",
139
+ contentType: "text/html",
140
+ authMode: "password",
141
+ password: "letmein",
142
+ body: "<h1>secret</h1>",
143
+ }),
144
+ });
145
+ expect(post.status).toBe(201);
146
+ const { id } = (await post.json()) as { id: string };
147
+
148
+ // No `?key=`, no Basic header → 401, no counter bump.
149
+ const r1 = await fetch(`${BASE}/p/${id}`);
150
+ expect(r1.status).toBe(401);
151
+ const r2 = await fetch(`${BASE}/p/${id}.json`);
152
+ expect(r2.status).toBe(401);
153
+
154
+ expect(await getViewCount(id, agentId)).toBe(0);
155
+
156
+ // After unlocking via ?key= → counter bumps.
157
+ const ok = await fetch(`${BASE}/p/${id}?key=letmein`);
158
+ expect(ok.status).toBe(200);
159
+ expect(await getViewCount(id, agentId)).toBe(1);
160
+ });
161
+
162
+ test("authed page: 401 without cookie does NOT bump", async () => {
163
+ const post = await fetch(`${BASE}/api/pages`, {
164
+ method: "POST",
165
+ headers,
166
+ body: JSON.stringify({
167
+ slug: "view-count-authed",
168
+ title: "View Count Authed",
169
+ contentType: "text/html",
170
+ authMode: "authed",
171
+ body: "<h1>members only</h1>",
172
+ }),
173
+ });
174
+ expect(post.status).toBe(201);
175
+ const { id } = (await post.json()) as { id: string };
176
+
177
+ // No cookie → 401.
178
+ const r = await fetch(`${BASE}/p/${id}`);
179
+ expect(r.status).toBe(401);
180
+ expect(await getViewCount(id, agentId)).toBe(0);
181
+ });
182
+
183
+ test("JSON content-type page: 302→SPA does NOT double-count (only .json bumps)", async () => {
184
+ const post = await fetch(`${BASE}/api/pages`, {
185
+ method: "POST",
186
+ headers,
187
+ body: JSON.stringify({
188
+ slug: "view-count-jsonct",
189
+ title: "JSON Content Page",
190
+ contentType: "application/json",
191
+ authMode: "public",
192
+ body: JSON.stringify({ kind: "spec" }),
193
+ }),
194
+ });
195
+ expect(post.status).toBe(201);
196
+ const { id } = (await post.json()) as { id: string };
197
+
198
+ // /p/:id 302s — should NOT bump.
199
+ const redir = await fetch(`${BASE}/p/${id}`, { redirect: "manual" });
200
+ expect(redir.status).toBe(302);
201
+ expect(await getViewCount(id, agentId)).toBe(0);
202
+
203
+ // /p/:id.json bumps.
204
+ const j = await fetch(`${BASE}/p/${id}.json`);
205
+ expect(j.status).toBe(200);
206
+ expect(await getViewCount(id, agentId)).toBe(1);
207
+ });
208
+
209
+ test("listing endpoint exposes viewCount", async () => {
210
+ const res = await fetch(`${BASE}/api/pages`, {
211
+ headers: { "X-Agent-ID": agentId },
212
+ });
213
+ expect(res.status).toBe(200);
214
+ const body = (await res.json()) as { pages: Array<{ id: string; viewCount?: number }> };
215
+ expect(body.pages.length).toBeGreaterThan(0);
216
+ for (const p of body.pages) {
217
+ expect(typeof p.viewCount).toBe("number");
218
+ }
219
+ });
220
+ });
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Smoke tests for the `<swarm-diff>` custom element shipped inside
3
+ * `SWARM_UI_JS`. Existing browser-side tests in this repo are pure string-
4
+ * content checks against the SDK constant; we don't have happy-dom or jsdom
5
+ * in deps. To still verify the element produces sane DOM output, we evaluate
6
+ * the JS in a hand-rolled stub `window` / `HTMLElement` / `customElements`
7
+ * scaffold — minimal but enough to assert structural properties (row counts,
8
+ * anchor ids, severity badges) without dragging in a real DOM lib.
9
+ */
10
+ import { describe, expect, test } from "bun:test";
11
+ import { SWARM_UI_JS } from "../artifact-sdk/browser-sdk";
12
+
13
+ const EXAMPLE_HUNK = {
14
+ hunks: [
15
+ {
16
+ old_start: 10,
17
+ old_lines: 3,
18
+ new_start: 10,
19
+ new_lines: 4,
20
+ lines: [
21
+ { type: "context", text: " const x = 1;" },
22
+ { type: "del", text: "- console.log(x);" },
23
+ { type: "add", text: "+ logger.info({ x });" },
24
+ { type: "add", text: "+ return x;" },
25
+ ],
26
+ annotations: [{ line: 12, severity: "warn", text: "Avoid raw console.log" }],
27
+ },
28
+ ],
29
+ };
30
+
31
+ type StubInstance = {
32
+ innerHTML: string;
33
+ textContent: string;
34
+ isConnected: boolean;
35
+ connectedCallback?: () => void;
36
+ dispatchEvent: (evt: unknown) => boolean;
37
+ };
38
+
39
+ /**
40
+ * Build a minimal stub window with just enough surface area to load
41
+ * SWARM_UI_JS, register the custom element, and exercise the render path.
42
+ *
43
+ * Returns both the element constructor and a microtask-flush helper so
44
+ * callers can simulate the real-browser parse-order race
45
+ * (`connectedCallback` fires → children get appended → microtask drains).
46
+ */
47
+ function makeRig(): {
48
+ Ctor: new () => StubInstance;
49
+ setText: (el: StubInstance, attrs: Record<string, string>, text: string) => void;
50
+ flushMicrotasks: () => Promise<void>;
51
+ } {
52
+ const registry = new Map<string, new () => StubInstance>();
53
+
54
+ class StubHTMLElement {
55
+ innerHTML = "";
56
+ textContent = "";
57
+ isConnected = true;
58
+ _attrs: Record<string, string> = {};
59
+ getAttribute(name: string): string | null {
60
+ return this._attrs[name] ?? null;
61
+ }
62
+ setAttribute(name: string, value: string): void {
63
+ this._attrs[name] = value;
64
+ }
65
+ connectedCallback?(): void;
66
+ closest(_selector: string): null {
67
+ return null;
68
+ }
69
+ querySelectorAll(_selector: string): unknown[] {
70
+ return [];
71
+ }
72
+ dispatchEvent(_evt: unknown): boolean {
73
+ return true;
74
+ }
75
+ }
76
+
77
+ const customElements = {
78
+ define(name: string, ctor: new () => StubInstance) {
79
+ registry.set(name, ctor);
80
+ },
81
+ get(name: string) {
82
+ return registry.get(name);
83
+ },
84
+ };
85
+
86
+ const win = {
87
+ customElements,
88
+ swarmUi: undefined as { renderDiff?: (rootEl: unknown, data: unknown) => void } | undefined,
89
+ HTMLElement: StubHTMLElement,
90
+ CustomEvent: class {
91
+ constructor(
92
+ public type: string,
93
+ public init?: { bubbles?: boolean; detail?: unknown },
94
+ ) {}
95
+ },
96
+ };
97
+
98
+ // Provide `document` stubs the jump-list path uses.
99
+ const doc = {
100
+ querySelectorAll: () => [],
101
+ addEventListener: () => {},
102
+ removeEventListener: () => {},
103
+ };
104
+
105
+ // Evaluate SWARM_UI_JS with our stubs in scope. The IIFE inside the
106
+ // constant captures `window`, `customElements`, `document`, `HTMLElement`,
107
+ // `CustomEvent`, and `queueMicrotask` — provide each as a free variable.
108
+ const factory = new Function(
109
+ "window",
110
+ "customElements",
111
+ "document",
112
+ "HTMLElement",
113
+ "CustomEvent",
114
+ "queueMicrotask",
115
+ "console",
116
+ `${SWARM_UI_JS}\nreturn window.customElements.get('swarm-diff');`,
117
+ );
118
+ const Ctor = factory(
119
+ win,
120
+ customElements,
121
+ doc,
122
+ StubHTMLElement,
123
+ win.CustomEvent,
124
+ queueMicrotask,
125
+ console,
126
+ ) as (new () => StubInstance) | undefined;
127
+ if (!Ctor) throw new Error("custom element did not register");
128
+
129
+ return {
130
+ Ctor,
131
+ setText(el, attrs, text) {
132
+ (el as unknown as { _attrs: Record<string, string> })._attrs = attrs;
133
+ el.textContent = text;
134
+ },
135
+ async flushMicrotasks() {
136
+ // Two yields: one drains the connectedCallback microtask, the next one
137
+ // drains anything queued by the render path itself.
138
+ await Promise.resolve();
139
+ await Promise.resolve();
140
+ },
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Convenience wrapper for the existing "happy path" tests: build the rig,
146
+ * pre-set textContent + attrs, fire connectedCallback, flush microtasks,
147
+ * return innerHTML.
148
+ */
149
+ async function renderViaStub(text: string, attrs: Record<string, string>): Promise<string> {
150
+ const rig = makeRig();
151
+ const el = new rig.Ctor();
152
+ rig.setText(el, attrs, text);
153
+ if (typeof el.connectedCallback === "function") el.connectedCallback();
154
+ await rig.flushMicrotasks();
155
+ return el.innerHTML;
156
+ }
157
+
158
+ describe("SWARM_UI_JS", () => {
159
+ test("is a non-empty string", () => {
160
+ expect(typeof SWARM_UI_JS).toBe("string");
161
+ expect(SWARM_UI_JS.length).toBeGreaterThan(500);
162
+ });
163
+
164
+ test("defines swarm-diff + swarm-diff-jumps custom elements", () => {
165
+ expect(SWARM_UI_JS).toContain("customElements.define('swarm-diff'");
166
+ expect(SWARM_UI_JS).toContain("customElements.define('swarm-diff-jumps'");
167
+ });
168
+
169
+ test("exposes window.swarmUi.renderDiff as a programmatic entry point", () => {
170
+ expect(SWARM_UI_JS).toContain("window.swarmUi");
171
+ expect(SWARM_UI_JS).toContain("renderDiff");
172
+ });
173
+ });
174
+
175
+ describe("<swarm-diff> render", () => {
176
+ test("constructs and renders without throwing on the example input", async () => {
177
+ const html = await renderViaStub(JSON.stringify(EXAMPLE_HUNK), {
178
+ file: "src/foo.ts",
179
+ "base-sha": "abc123",
180
+ "head-sha": "def456",
181
+ });
182
+ expect(html.length).toBeGreaterThan(0);
183
+ });
184
+
185
+ test("renders one <tr> per line in each hunk", async () => {
186
+ const html = await renderViaStub(JSON.stringify(EXAMPLE_HUNK), { file: "src/foo.ts" });
187
+ // 4 lines in the example hunk → 4 <tr> rows.
188
+ const trMatches = html.match(/<tr\b/g) || [];
189
+ expect(trMatches.length).toBe(4);
190
+ });
191
+
192
+ test("renders file header and SHA range", async () => {
193
+ const html = await renderViaStub(JSON.stringify(EXAMPLE_HUNK), {
194
+ file: "src/foo.ts",
195
+ "base-sha": "abc123",
196
+ "head-sha": "def456",
197
+ });
198
+ expect(html).toContain("src/foo.ts");
199
+ expect(html).toContain("abc123");
200
+ expect(html).toContain("def456");
201
+ });
202
+
203
+ test("renders deterministic anchor id per hunk", async () => {
204
+ const html = await renderViaStub(JSON.stringify(EXAMPLE_HUNK), { file: "src/foo.ts" });
205
+ expect(html).toContain('id="swarm-diff-src-foo-ts-10"');
206
+ });
207
+
208
+ test("renders severity annotation badge on annotated line", async () => {
209
+ const html = await renderViaStub(JSON.stringify(EXAMPLE_HUNK), { file: "src/foo.ts" });
210
+ expect(html).toContain("WARN");
211
+ expect(html).toContain("Avoid raw console.log");
212
+ });
213
+
214
+ test("handles empty/missing JSON body gracefully (no rows, no throw)", async () => {
215
+ const html = await renderViaStub("", { file: "empty.ts" });
216
+ // Should still render an outer container with the file name.
217
+ expect(html).toContain("empty.ts");
218
+ // But no <tr> rows.
219
+ expect(html.match(/<tr\b/g) ?? []).toHaveLength(0);
220
+ });
221
+
222
+ test("escapes user-controlled text content to prevent injection", async () => {
223
+ const xssHunk = {
224
+ hunks: [
225
+ {
226
+ old_start: 1,
227
+ old_lines: 1,
228
+ new_start: 1,
229
+ new_lines: 1,
230
+ lines: [{ type: "add", text: "<script>alert('xss')</script>" }],
231
+ annotations: [],
232
+ },
233
+ ],
234
+ };
235
+ const html = await renderViaStub(JSON.stringify(xssHunk), { file: "<bad>" });
236
+ expect(html).not.toContain("<script>alert(");
237
+ expect(html).toContain("&lt;script&gt;");
238
+ expect(html).toContain("&lt;bad&gt;");
239
+ });
240
+ });
241
+
242
+ describe("<swarm-diff> parse-order regression (Bug #479-1)", () => {
243
+ // Real browsers fire connectedCallback when the parser sees the opening
244
+ // tag, BEFORE the JSON text children are parsed. The element MUST defer
245
+ // its parseHunks/render so it reads textContent AFTER the parser appends
246
+ // the children. Without the queueMicrotask defer in connectedCallback,
247
+ // the element renders an empty header and the JSON stays visible as
248
+ // orphan text — that's the production bug PR #479 shipped initially.
249
+
250
+ test("does NOT render synchronously inside connectedCallback (defer required)", () => {
251
+ const rig = makeRig();
252
+ const el = new rig.Ctor();
253
+ // Simulate the real-browser parse order: connectedCallback fires while
254
+ // textContent is still empty. The element must NOT have rendered yet
255
+ // — if it does, it's reading textContent too early.
256
+ rig.setText(el, { file: "src/foo.ts" }, "");
257
+ if (typeof el.connectedCallback === "function") el.connectedCallback();
258
+ expect(el.innerHTML).toBe("");
259
+ });
260
+
261
+ test("renders correctly when textContent is appended AFTER connectedCallback but BEFORE microtask drain", async () => {
262
+ const rig = makeRig();
263
+ const el = new rig.Ctor();
264
+ // Parse order: connectedCallback fires with empty textContent, children
265
+ // get appended, then microtask drains.
266
+ rig.setText(el, { file: "src/foo.ts" }, "");
267
+ if (typeof el.connectedCallback === "function") el.connectedCallback();
268
+ // Parser would now append JSON children. Simulate by setting textContent.
269
+ el.textContent = JSON.stringify(EXAMPLE_HUNK);
270
+ await rig.flushMicrotasks();
271
+ // After the microtask drains, the element must have rendered against
272
+ // the post-callback textContent.
273
+ expect(el.innerHTML).toContain("src/foo.ts");
274
+ expect(el.innerHTML.match(/<tr\b/g) ?? []).toHaveLength(4);
275
+ expect(el.innerHTML).toContain("WARN");
276
+ });
277
+
278
+ test("re-renders cleanly on reconnection (connectedCallback fires again)", async () => {
279
+ const rig = makeRig();
280
+ const el = new rig.Ctor();
281
+ rig.setText(el, { file: "src/foo.ts" }, JSON.stringify(EXAMPLE_HUNK));
282
+ if (typeof el.connectedCallback === "function") el.connectedCallback();
283
+ await rig.flushMicrotasks();
284
+ const firstRender = el.innerHTML;
285
+ expect(firstRender).toContain("src/foo.ts");
286
+ // Re-fire (element was moved or detached + reattached).
287
+ if (typeof el.connectedCallback === "function") el.connectedCallback();
288
+ await rig.flushMicrotasks();
289
+ expect(el.innerHTML).toContain("src/foo.ts");
290
+ expect(el.innerHTML.match(/<tr\b/g) ?? []).toHaveLength(4);
291
+ });
292
+
293
+ test("aborts render if element disconnected before microtask drains", async () => {
294
+ const rig = makeRig();
295
+ const el = new rig.Ctor();
296
+ rig.setText(el, { file: "src/foo.ts" }, JSON.stringify(EXAMPLE_HUNK));
297
+ if (typeof el.connectedCallback === "function") el.connectedCallback();
298
+ // Element gets removed from the DOM before our microtask runs.
299
+ el.isConnected = false;
300
+ await rig.flushMicrotasks();
301
+ expect(el.innerHTML).toBe("");
302
+ });
303
+ });