@alloy-js/core 0.23.0-dev.10 → 0.23.0-dev.11

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 (131) hide show
  1. package/dist/devtools/index.html +29 -17
  2. package/dist/src/binder.d.ts.map +1 -1
  3. package/dist/src/binder.js +5 -0
  4. package/dist/src/binder.js.map +1 -1
  5. package/dist/src/components/For.d.ts.map +1 -1
  6. package/dist/src/components/For.js +1 -1
  7. package/dist/src/components/For.js.map +1 -1
  8. package/dist/src/components/List.d.ts.map +1 -1
  9. package/dist/src/components/List.js +1 -1
  10. package/dist/src/components/List.js.map +1 -1
  11. package/dist/src/components/Switch.d.ts.map +1 -1
  12. package/dist/src/components/Switch.js +1 -1
  13. package/dist/src/components/Switch.js.map +1 -1
  14. package/dist/src/debug/diagnostics.test.js +3 -2
  15. package/dist/src/debug/diagnostics.test.js.map +1 -1
  16. package/dist/src/debug/effects.d.ts +12 -4
  17. package/dist/src/debug/effects.d.ts.map +1 -1
  18. package/dist/src/debug/effects.js +182 -52
  19. package/dist/src/debug/effects.js.map +1 -1
  20. package/dist/src/debug/effects.test.js +213 -41
  21. package/dist/src/debug/effects.test.js.map +1 -1
  22. package/dist/src/debug/files.d.ts.map +1 -1
  23. package/dist/src/debug/files.js +7 -18
  24. package/dist/src/debug/files.js.map +1 -1
  25. package/dist/src/debug/files.test.js +13 -36
  26. package/dist/src/debug/files.test.js.map +1 -1
  27. package/dist/src/debug/index.d.ts +4 -2
  28. package/dist/src/debug/index.d.ts.map +1 -1
  29. package/dist/src/debug/index.js +4 -2
  30. package/dist/src/debug/index.js.map +1 -1
  31. package/dist/src/debug/message-format.test.d.ts +2 -0
  32. package/dist/src/debug/message-format.test.d.ts.map +1 -0
  33. package/dist/src/debug/message-format.test.js +700 -0
  34. package/dist/src/debug/message-format.test.js.map +1 -0
  35. package/dist/src/debug/render-tree-orphans.test.d.ts +2 -0
  36. package/dist/src/debug/render-tree-orphans.test.d.ts.map +1 -0
  37. package/dist/src/debug/render-tree-orphans.test.js +297 -0
  38. package/dist/src/debug/render-tree-orphans.test.js.map +1 -0
  39. package/dist/src/debug/render.d.ts.map +1 -1
  40. package/dist/src/debug/render.js +83 -130
  41. package/dist/src/debug/render.js.map +1 -1
  42. package/dist/src/debug/render.test.js +91 -128
  43. package/dist/src/debug/render.test.js.map +1 -1
  44. package/dist/src/debug/symbols.d.ts +6 -5
  45. package/dist/src/debug/symbols.d.ts.map +1 -1
  46. package/dist/src/debug/symbols.js +46 -23
  47. package/dist/src/debug/symbols.js.map +1 -1
  48. package/dist/src/debug/symbols.test.js +15 -26
  49. package/dist/src/debug/symbols.test.js.map +1 -1
  50. package/dist/src/debug/trace-writer.d.ts +55 -0
  51. package/dist/src/debug/trace-writer.d.ts.map +1 -0
  52. package/dist/src/debug/trace-writer.js +658 -0
  53. package/dist/src/debug/trace-writer.js.map +1 -0
  54. package/dist/src/debug/trace.d.ts +10 -10
  55. package/dist/src/debug/trace.d.ts.map +1 -1
  56. package/dist/src/debug/trace.js +23 -20
  57. package/dist/src/debug/trace.js.map +1 -1
  58. package/dist/src/devtools/devtools-protocol.d.ts +318 -161
  59. package/dist/src/devtools/devtools-protocol.d.ts.map +1 -1
  60. package/dist/src/devtools/devtools-server.browser.d.ts +0 -5
  61. package/dist/src/devtools/devtools-server.browser.d.ts.map +1 -1
  62. package/dist/src/devtools/devtools-server.browser.js +0 -3
  63. package/dist/src/devtools/devtools-server.browser.js.map +1 -1
  64. package/dist/src/devtools/devtools-server.d.ts +0 -6
  65. package/dist/src/devtools/devtools-server.d.ts.map +1 -1
  66. package/dist/src/devtools/devtools-server.js +212 -24
  67. package/dist/src/devtools/devtools-server.js.map +1 -1
  68. package/dist/src/devtools/devtools-transport.d.ts +2 -2
  69. package/dist/src/devtools/devtools-transport.d.ts.map +1 -1
  70. package/dist/src/devtools/devtools-transport.js +2 -2
  71. package/dist/src/devtools/devtools-transport.js.map +1 -1
  72. package/dist/src/devtools-entry.browser.d.ts +1 -1
  73. package/dist/src/devtools-entry.browser.d.ts.map +1 -1
  74. package/dist/src/devtools-entry.browser.js.map +1 -1
  75. package/dist/src/devtools-entry.d.ts +1 -1
  76. package/dist/src/devtools-entry.d.ts.map +1 -1
  77. package/dist/src/devtools-entry.js.map +1 -1
  78. package/dist/src/diagnostics.d.ts.map +1 -1
  79. package/dist/src/diagnostics.js +5 -5
  80. package/dist/src/diagnostics.js.map +1 -1
  81. package/dist/src/reactivity.d.ts +13 -2
  82. package/dist/src/reactivity.d.ts.map +1 -1
  83. package/dist/src/reactivity.js +96 -13
  84. package/dist/src/reactivity.js.map +1 -1
  85. package/dist/src/render.d.ts.map +1 -1
  86. package/dist/src/render.js +84 -30
  87. package/dist/src/render.js.map +1 -1
  88. package/dist/src/scheduler.d.ts +5 -0
  89. package/dist/src/scheduler.d.ts.map +1 -1
  90. package/dist/src/scheduler.js +94 -23
  91. package/dist/src/scheduler.js.map +1 -1
  92. package/dist/src/utils.d.ts.map +1 -1
  93. package/dist/src/utils.js +11 -5
  94. package/dist/src/utils.js.map +1 -1
  95. package/dist/testing/devtools-utils.d.ts +12 -3
  96. package/dist/testing/devtools-utils.d.ts.map +1 -1
  97. package/dist/testing/devtools-utils.js +26 -4
  98. package/dist/testing/devtools-utils.js.map +1 -1
  99. package/dist/tsconfig.tsbuildinfo +1 -1
  100. package/package.json +1 -1
  101. package/src/binder.ts +47 -38
  102. package/src/components/For.tsx +14 -10
  103. package/src/components/List.tsx +7 -4
  104. package/src/components/Switch.tsx +11 -7
  105. package/src/debug/diagnostics.test.tsx +3 -2
  106. package/src/debug/effects.test.tsx +248 -36
  107. package/src/debug/effects.ts +276 -62
  108. package/src/debug/files.test.tsx +15 -35
  109. package/src/debug/files.ts +11 -11
  110. package/src/debug/index.ts +4 -0
  111. package/src/debug/message-format.test.tsx +759 -0
  112. package/src/debug/render-tree-orphans.test.tsx +344 -0
  113. package/src/debug/render.test.tsx +96 -118
  114. package/src/debug/render.ts +183 -124
  115. package/src/debug/symbols.test.tsx +19 -20
  116. package/src/debug/symbols.ts +106 -23
  117. package/src/debug/trace-writer.ts +969 -0
  118. package/src/debug/trace.ts +25 -28
  119. package/src/devtools/devtools-protocol.ts +361 -176
  120. package/src/devtools/devtools-server.browser.ts +0 -9
  121. package/src/devtools/devtools-server.ts +210 -32
  122. package/src/devtools/devtools-transport.ts +4 -4
  123. package/src/devtools-entry.browser.ts +11 -15
  124. package/src/devtools-entry.ts +9 -15
  125. package/src/diagnostics.ts +14 -5
  126. package/src/reactivity.ts +113 -17
  127. package/src/render.ts +104 -30
  128. package/src/scheduler.ts +145 -26
  129. package/src/utils.tsx +7 -4
  130. package/temp/api.json +142 -20
  131. package/testing/devtools-utils.ts +46 -4
@@ -0,0 +1,759 @@
1
+ /**
2
+ * Tests that verify the WebSocket message format produced by the server
3
+ * matches what the devtools frontend expects. Each test renders something,
4
+ * collects the raw JSON messages over the WebSocket, and checks the shape.
5
+ *
6
+ * Tests subscribe to specific channels to validate the subscription mechanism
7
+ * and avoid filtering noise from unrelated channels.
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
10
+ import WebSocket from "ws";
11
+ import {
12
+ createMessageCollector,
13
+ type DevtoolsMessage,
14
+ } from "../../testing/devtools-utils.js";
15
+ import { Declaration } from "../components/Declaration.js";
16
+ import { For } from "../components/For.js";
17
+ import { Output } from "../components/Output.jsx";
18
+ import { Scope } from "../components/Scope.js";
19
+ import { SourceFile } from "../components/SourceFile.js";
20
+ import {
21
+ enableDevtools,
22
+ resetDevtoolsServerForTests,
23
+ } from "../devtools/devtools-server.js";
24
+ import { effect, ref } from "../reactivity.js";
25
+ import { renderAsync } from "../render.js";
26
+ import { flushJobsAsync } from "../scheduler.js";
27
+ import { debug } from "./index.js";
28
+
29
+ let socket: WebSocket | undefined;
30
+ let port: number;
31
+
32
+ beforeEach(async () => {
33
+ debug.effect.reset();
34
+ const server = await enableDevtools({ port: 0 });
35
+ port = server.port;
36
+ socket = new WebSocket(`ws://127.0.0.1:${port}`);
37
+ await new Promise<void>((resolve, reject) => {
38
+ socket?.once("open", resolve);
39
+ socket?.once("error", reject);
40
+ });
41
+ });
42
+
43
+ afterEach(async () => {
44
+ if (socket) {
45
+ socket.close();
46
+ socket = undefined;
47
+ }
48
+ await resetDevtoolsServerForTests();
49
+ debug.effect.reset();
50
+ });
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Subscriptions
54
+ // ---------------------------------------------------------------------------
55
+
56
+ describe("subscriptions", () => {
57
+ it("only receives messages for subscribed channels", async () => {
58
+ // Subscribe to render only — should NOT see effects, refs, etc.
59
+ const collector = await createMessageCollector(socket!, ["render"]);
60
+ await renderAsync(<Output>hello</Output>);
61
+ const messages = await collector.waitForRender();
62
+ collector.stop();
63
+
64
+ const types = new Set(messages.map((m) => m.type));
65
+ // Should have render messages
66
+ expect(types.has("render:node_added")).toBe(true);
67
+ // Should NOT have effect/ref/symbol/scope/edge messages
68
+ expect(types.has("effect:added")).toBe(false);
69
+ expect(types.has("ref:added")).toBe(false);
70
+ expect(types.has("symbol:added")).toBe(false);
71
+ expect(types.has("scope:added")).toBe(false);
72
+ });
73
+
74
+ it("receives messages from multiple subscribed channels", async () => {
75
+ const collector = await createMessageCollector(socket!, [
76
+ "render",
77
+ "effects",
78
+ "refs",
79
+ ]);
80
+ await renderAsync(<Output>hello</Output>);
81
+ const messages = await collector.waitForRender();
82
+ collector.stop();
83
+
84
+ const types = new Set(messages.map((m) => m.type));
85
+ expect(types.has("render:node_added")).toBe(true);
86
+ expect(types.has("effect:added")).toBe(true);
87
+ expect(types.has("ref:added")).toBe(true);
88
+ // Not subscribed to these
89
+ expect(types.has("symbol:added")).toBe(false);
90
+ expect(types.has("scope:added")).toBe(false);
91
+ });
92
+
93
+ it("lifecycle signals are received regardless of subscription", async () => {
94
+ // Subscribe to nothing useful — lifecycle signals bypass subscriptions
95
+ const collector = await createMessageCollector(socket!, ["diagnostics"]);
96
+ await renderAsync(<Output />);
97
+ const messages = await collector.waitForRender();
98
+ collector.stop();
99
+
100
+ expect(messages.some((m) => m.type === "render:complete")).toBe(true);
101
+ });
102
+ });
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // render channel
106
+ // ---------------------------------------------------------------------------
107
+
108
+ describe("render:node_added", () => {
109
+ it("root node has null parent_id and kind 'root'", async () => {
110
+ const collector = await createMessageCollector(socket!, ["render"]);
111
+ await renderAsync(<Output />);
112
+ const messages = await collector.waitForRender();
113
+ collector.stop();
114
+
115
+ const root = messages.find(
116
+ (m) => m.type === "render:node_added" && m.kind === "root",
117
+ );
118
+ expect(root).toMatchObject({
119
+ type: "render:node_added",
120
+ id: expect.any(Number),
121
+ parent_id: null,
122
+ kind: "root",
123
+ seq: expect.any(Number),
124
+ });
125
+ });
126
+
127
+ it("component node has numeric id, parent_id, and name", async () => {
128
+ const collector = await createMessageCollector(socket!, ["render"]);
129
+ await renderAsync(<Output />);
130
+ const messages = await collector.waitForRender();
131
+ collector.stop();
132
+
133
+ const output = messages.find(
134
+ (m) => m.type === "render:node_added" && m.name === "Output",
135
+ );
136
+ expect(output).toMatchObject({
137
+ type: "render:node_added",
138
+ id: expect.any(Number),
139
+ parent_id: expect.any(Number),
140
+ kind: "component",
141
+ name: "Output",
142
+ });
143
+ });
144
+
145
+ it("text node has string value", async () => {
146
+ const collector = await createMessageCollector(socket!, ["render"]);
147
+ await renderAsync(<Output>hello</Output>);
148
+ const messages = await collector.waitForRender();
149
+ collector.stop();
150
+
151
+ const text = messages.find(
152
+ (m) => m.type === "render:node_added" && m.kind === "text",
153
+ );
154
+ expect(text).toBeDefined();
155
+ expect(text!.value).toBe("hello");
156
+ expect(text!.parent_id).toEqual(expect.any(Number));
157
+ });
158
+
159
+ it("non-text node has null value (not undefined)", async () => {
160
+ const collector = await createMessageCollector(socket!, ["render"]);
161
+ await renderAsync(<Output>hello</Output>);
162
+ const messages = await collector.waitForRender();
163
+ collector.stop();
164
+
165
+ const component = messages.find(
166
+ (m) => m.type === "render:node_added" && m.kind === "component",
167
+ );
168
+ expect(component!.value).toBeNull();
169
+ });
170
+ });
171
+
172
+ describe("render:node_removed", () => {
173
+ it("is emitted when a reactive component removes children", async () => {
174
+ const show = ref(true);
175
+ const collector = await createMessageCollector(socket!, ["render"]);
176
+
177
+ function Conditional() {
178
+ return () => (show.value ? "visible" : "");
179
+ }
180
+
181
+ await renderAsync(
182
+ <Output>
183
+ <Conditional />
184
+ </Output>,
185
+ );
186
+ const renderMessages = await collector.waitForRender();
187
+
188
+ // Verify the text node "visible" was added
189
+ const textAdded = renderMessages.find(
190
+ (m) => m.type === "render:node_added" && m.value === "visible",
191
+ );
192
+ expect(textAdded).toBeDefined();
193
+
194
+ // Toggle visibility to trigger removal
195
+ show.value = false;
196
+ await flushJobsAsync();
197
+ const flushMessages = await collector.waitForFlush();
198
+ collector.stop();
199
+
200
+ const removed = flushMessages.filter(
201
+ (m) => m.type === "render:node_removed",
202
+ );
203
+ expect(removed.length).toBeGreaterThan(0);
204
+ expect(removed[0]).toMatchObject({
205
+ type: "render:node_removed",
206
+ id: expect.any(Number),
207
+ });
208
+ });
209
+ });
210
+
211
+ describe("render:node_updated", () => {
212
+ it("emits node_added during reactive list growth", async () => {
213
+ const items = ref(["a"]);
214
+ const collector = await createMessageCollector(socket!, ["render"]);
215
+ await renderAsync(
216
+ <Output>
217
+ <For each={items}>{(item) => <>{item}</>}</For>
218
+ </Output>,
219
+ );
220
+ await collector.waitForRender();
221
+
222
+ // Add an item to trigger reactive update
223
+ items.value = [...items.value, "b"];
224
+ await flushJobsAsync();
225
+ const flushMessages = await collector.waitForFlush();
226
+ collector.stop();
227
+
228
+ // Reactive updates produce new node_added messages for new content
229
+ const added = flushMessages.filter((m) => m.type === "render:node_added");
230
+ expect(added.length).toBeGreaterThan(0);
231
+ // Should contain the new "b" text node
232
+ const bNode = added.find((m) => m.value === "b");
233
+ expect(bNode).toBeDefined();
234
+ });
235
+ });
236
+
237
+ describe("render:reset", () => {
238
+ it("is sent before node_added messages", async () => {
239
+ const collector = await createMessageCollector(socket!, ["render"]);
240
+ await renderAsync(<Output />);
241
+ const messages = await collector.waitForRender();
242
+ collector.stop();
243
+
244
+ const renderMessages = messages.filter(
245
+ (m) =>
246
+ m.type === "render:reset" ||
247
+ m.type === "render:node_added" ||
248
+ m.type === "render:complete",
249
+ );
250
+ expect(renderMessages[0]).toMatchObject({ type: "render:reset" });
251
+ });
252
+ });
253
+
254
+ describe("render:complete", () => {
255
+ it("is sent after all node_added messages", async () => {
256
+ const collector = await createMessageCollector(socket!, ["render"]);
257
+ await renderAsync(<Output />);
258
+ const messages = await collector.waitForRender();
259
+ collector.stop();
260
+
261
+ const renderMessages = messages.filter(
262
+ (m) =>
263
+ m.type === "render:reset" ||
264
+ m.type === "render:node_added" ||
265
+ m.type === "render:complete",
266
+ );
267
+ const last = renderMessages[renderMessages.length - 1];
268
+ expect(last).toMatchObject({ type: "render:complete" });
269
+ });
270
+ });
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // effects channel
274
+ // ---------------------------------------------------------------------------
275
+
276
+ describe("effect:added", () => {
277
+ it("has id, effect_type, and seq", async () => {
278
+ const collector = await createMessageCollector(socket!, ["effects"]);
279
+ await renderAsync(<Output>hello</Output>);
280
+ const messages = await collector.waitForRender();
281
+ collector.stop();
282
+
283
+ const effects = messages.filter((m) => m.type === "effect:added");
284
+ expect(effects.length).toBeGreaterThan(0);
285
+
286
+ const e = effects[0];
287
+ expect(e).toMatchObject({
288
+ type: "effect:added",
289
+ id: expect.any(Number),
290
+ seq: expect.any(Number),
291
+ });
292
+ expect("effect_type" in e).toBe(true);
293
+ });
294
+ });
295
+
296
+ describe("effect:updated", () => {
297
+ it("is emitted when an effect's component is set", async () => {
298
+ const collector = await createMessageCollector(socket!, ["effects"]);
299
+ await renderAsync(<Output>hello</Output>);
300
+ const messages = await collector.waitForRender();
301
+ collector.stop();
302
+
303
+ const updated = messages.filter((m) => m.type === "effect:updated");
304
+ // Effects get updated with component info during render
305
+ if (updated.length > 0) {
306
+ expect(updated[0]).toMatchObject({
307
+ type: "effect:updated",
308
+ });
309
+ }
310
+ });
311
+ });
312
+
313
+ // ---------------------------------------------------------------------------
314
+ // refs channel
315
+ // ---------------------------------------------------------------------------
316
+
317
+ describe("ref:added", () => {
318
+ it("has id, kind, and seq", async () => {
319
+ const collector = await createMessageCollector(socket!, ["refs"]);
320
+ await renderAsync(<Output>hello</Output>);
321
+ const messages = await collector.waitForRender();
322
+ collector.stop();
323
+
324
+ const refs = messages.filter((m) => m.type === "ref:added");
325
+ expect(refs.length).toBeGreaterThan(0);
326
+
327
+ const r = refs[0];
328
+ expect(r).toMatchObject({
329
+ type: "ref:added",
330
+ id: expect.any(Number),
331
+ seq: expect.any(Number),
332
+ });
333
+ // kind is nullable
334
+ expect("kind" in r).toBe(true);
335
+ });
336
+ });
337
+
338
+ // ---------------------------------------------------------------------------
339
+ // edges channel
340
+ // ---------------------------------------------------------------------------
341
+
342
+ describe("edge:track and edge:trigger", () => {
343
+ it("emits edge messages with effect_id and ref_id", async () => {
344
+ const collector = await createMessageCollector(socket!, ["edges"]);
345
+ await renderAsync(<Output>hello</Output>);
346
+ const messages = await collector.waitForRender();
347
+ collector.stop();
348
+
349
+ const edges = messages.filter(
350
+ (m) => m.type === "edge:track" || m.type === "edge:trigger",
351
+ );
352
+ expect(edges.length).toBeGreaterThan(0);
353
+
354
+ const edge = edges[0];
355
+ expect(edge).toMatchObject({
356
+ seq: expect.any(Number),
357
+ effect_id: expect.any(Number),
358
+ });
359
+ // ref_id is present (may be null for some edge types)
360
+ expect("ref_id" in edge).toBe(true);
361
+ });
362
+ });
363
+
364
+ // ---------------------------------------------------------------------------
365
+ // symbols channel
366
+ // ---------------------------------------------------------------------------
367
+
368
+ describe("symbol:added", () => {
369
+ it("has id, name, boolean flags, and seq", async () => {
370
+ const collector = await createMessageCollector(socket!, ["symbols"]);
371
+ await renderAsync(
372
+ <Output>
373
+ <Scope name="myScope">
374
+ <Declaration name="Foo">foo content</Declaration>
375
+ </Scope>
376
+ </Output>,
377
+ );
378
+ const messages = await collector.waitForRender();
379
+ collector.stop();
380
+
381
+ const symbols = messages.filter((m) => m.type === "symbol:added");
382
+ expect(symbols.length).toBeGreaterThan(0);
383
+
384
+ const sym = symbols.find((m) => m.name === "Foo");
385
+ expect(sym).toBeDefined();
386
+ expect(sym).toMatchObject({
387
+ type: "symbol:added",
388
+ id: expect.any(Number),
389
+ name: "Foo",
390
+ seq: expect.any(Number),
391
+ });
392
+ // Boolean flags are 0/1 integers
393
+ expect([0, 1]).toContain(sym!.is_member);
394
+ expect([0, 1]).toContain(sym!.is_transient);
395
+ expect([0, 1]).toContain(sym!.is_alias);
396
+ // Nullable fields exist as keys
397
+ expect("scope_id" in sym!).toBe(true);
398
+ expect("owner_symbol_id" in sym!).toBe(true);
399
+ expect("metadata" in sym!).toBe(true);
400
+ });
401
+ });
402
+
403
+ // ---------------------------------------------------------------------------
404
+ // scopes channel
405
+ // ---------------------------------------------------------------------------
406
+
407
+ describe("scope:added", () => {
408
+ it("has id, name, is_member_scope, and seq", async () => {
409
+ const collector = await createMessageCollector(socket!, ["scopes"]);
410
+ await renderAsync(
411
+ <Output>
412
+ <Scope name="TestScope">content</Scope>
413
+ </Output>,
414
+ );
415
+ const messages = await collector.waitForRender();
416
+ collector.stop();
417
+
418
+ const scopes = messages.filter((m) => m.type === "scope:added");
419
+ expect(scopes.length).toBeGreaterThan(0);
420
+
421
+ const scope = scopes.find((s) => s.name === "TestScope");
422
+ expect(scope).toBeDefined();
423
+ expect(scope).toMatchObject({
424
+ type: "scope:added",
425
+ id: expect.any(Number),
426
+ name: "TestScope",
427
+ seq: expect.any(Number),
428
+ });
429
+ expect([0, 1]).toContain(scope!.is_member_scope);
430
+ expect("parent_id" in scope!).toBe(true);
431
+ expect("metadata" in scope!).toBe(true);
432
+ });
433
+ });
434
+
435
+ // ---------------------------------------------------------------------------
436
+ // files channel
437
+ // ---------------------------------------------------------------------------
438
+
439
+ describe("file:added", () => {
440
+ it("has path, filetype, and seq", async () => {
441
+ const collector = await createMessageCollector(socket!, ["files"]);
442
+ await renderAsync(
443
+ <Output>
444
+ <SourceFile path="test.ts" filetype="typescript">
445
+ content
446
+ </SourceFile>
447
+ </Output>,
448
+ );
449
+ const messages = await collector.waitForRender();
450
+ collector.stop();
451
+
452
+ const files = messages.filter((m) => m.type === "file:added");
453
+ expect(files.length).toBeGreaterThan(0);
454
+
455
+ const file = files.find((f) => (f.path as string).endsWith("test.ts"));
456
+ expect(file).toBeDefined();
457
+ expect(file).toMatchObject({
458
+ type: "file:added",
459
+ path: expect.stringContaining("test.ts"),
460
+ filetype: "typescript",
461
+ seq: expect.any(Number),
462
+ });
463
+ });
464
+ });
465
+
466
+ // ---------------------------------------------------------------------------
467
+ // directories channel
468
+ // ---------------------------------------------------------------------------
469
+
470
+ describe("directory:added", () => {
471
+ it("has path and seq", async () => {
472
+ const collector = await createMessageCollector(socket!, ["directories"]);
473
+ await renderAsync(<Output>hello</Output>);
474
+ const messages = await collector.waitForRender();
475
+ collector.stop();
476
+
477
+ const dirs = messages.filter((m) => m.type === "directory:added");
478
+ expect(dirs.length).toBeGreaterThan(0);
479
+
480
+ expect(dirs[0]).toMatchObject({
481
+ type: "directory:added",
482
+ path: expect.any(String),
483
+ seq: expect.any(Number),
484
+ });
485
+ });
486
+ });
487
+
488
+ // ---------------------------------------------------------------------------
489
+ // scheduler channel
490
+ // ---------------------------------------------------------------------------
491
+
492
+ describe("scheduler:job", () => {
493
+ it("is emitted during synchronous flush", async () => {
494
+ // Scheduler events are only traced during synchronous flushJobs(),
495
+ // not flushJobsAsync(). Subscribe to scheduler + render to see them.
496
+ const collector = await createMessageCollector(socket!, [
497
+ "scheduler",
498
+ "render",
499
+ ]);
500
+ // A simple render triggers flushJobs internally
501
+ await renderAsync(<Output>hello</Output>);
502
+ const messages = await collector.waitForRender();
503
+ collector.stop();
504
+
505
+ const jobs = messages.filter((m) => m.type === "scheduler:job");
506
+ // Scheduler events occur during the synchronous flush inside renderAsync
507
+ if (jobs.length > 0) {
508
+ expect(jobs[0]).toMatchObject({
509
+ type: "scheduler:job",
510
+ seq: expect.any(Number),
511
+ });
512
+ expect("event" in jobs[0] || "jobs_run" in jobs[0]).toBe(true);
513
+ }
514
+ });
515
+ });
516
+
517
+ // ---------------------------------------------------------------------------
518
+ // lifecycle channel
519
+ // ---------------------------------------------------------------------------
520
+
521
+ describe("effect:lifecycle", () => {
522
+ it("is emitted for effect lifecycle events", async () => {
523
+ const collector = await createMessageCollector(socket!, ["lifecycle"]);
524
+
525
+ const r = ref(0);
526
+ effect(() => {
527
+ void r.value;
528
+ });
529
+ await renderAsync(<Output>hello</Output>);
530
+ const messages = await collector.waitForRender();
531
+
532
+ // Trigger a lifecycle event
533
+ r.value = 1;
534
+ await flushJobsAsync();
535
+ const flushMessages = await collector.waitForFlush();
536
+ collector.stop();
537
+
538
+ const all = [...messages, ...flushMessages];
539
+ const lifecycle = all.filter((m) => m.type === "effect:lifecycle");
540
+ if (lifecycle.length > 0) {
541
+ expect(lifecycle[0]).toMatchObject({
542
+ type: "effect:lifecycle",
543
+ seq: expect.any(Number),
544
+ });
545
+ expect("effect_id" in lifecycle[0]).toBe(true);
546
+ expect("event" in lifecycle[0]).toBe(true);
547
+ }
548
+ });
549
+ });
550
+
551
+ // ---------------------------------------------------------------------------
552
+ // diagnostics channel
553
+ // ---------------------------------------------------------------------------
554
+
555
+ describe("diagnostics:report", () => {
556
+ it("has message, severity, and seq", async () => {
557
+ const { insertDiagnostic } = await import("./trace-writer.js");
558
+ const collector = await createMessageCollector(socket!, ["diagnostics"]);
559
+
560
+ // Insert a diagnostic directly through the trace writer
561
+ insertDiagnostic(
562
+ "test warning",
563
+ "warning",
564
+ undefined,
565
+ undefined,
566
+ undefined,
567
+ undefined,
568
+ );
569
+
570
+ // Render to get a completion signal for the collector
571
+ await renderAsync(<Output />);
572
+ const messages = await collector.waitForRender();
573
+ collector.stop();
574
+
575
+ const diags = messages.filter((m) => m.type === "diagnostics:report");
576
+ expect(diags.length).toBeGreaterThan(0);
577
+
578
+ expect(diags[0]).toMatchObject({
579
+ type: "diagnostics:report",
580
+ message: "test warning",
581
+ severity: "warning",
582
+ seq: expect.any(Number),
583
+ });
584
+ // Nullable source location fields exist
585
+ expect("source_file" in diags[0]).toBe(true);
586
+ expect("source_line" in diags[0]).toBe(true);
587
+ });
588
+ });
589
+
590
+ // ---------------------------------------------------------------------------
591
+ // errors channel
592
+ // ---------------------------------------------------------------------------
593
+
594
+ describe("render:error", () => {
595
+ it("has name, message, and seq", async () => {
596
+ const { insertRenderError } = await import("./trace-writer.js");
597
+ const collector = await createMessageCollector(socket!, ["errors"]);
598
+
599
+ // Insert an error directly through the trace writer
600
+ insertRenderError("Error", "kaboom", "Error: kaboom\n at ...", undefined);
601
+
602
+ // Render to get a completion signal
603
+ await renderAsync(<Output />);
604
+ const messages = await collector.waitForRender();
605
+ collector.stop();
606
+
607
+ const errors = messages.filter((m) => m.type === "render:error");
608
+ expect(errors.length).toBeGreaterThan(0);
609
+
610
+ expect(errors[0]).toMatchObject({
611
+ type: "render:error",
612
+ name: "Error",
613
+ message: "kaboom",
614
+ seq: expect.any(Number),
615
+ });
616
+ expect("stack" in errors[0]).toBe(true);
617
+ expect("component_stack" in errors[0]).toBe(true);
618
+ });
619
+ });
620
+
621
+ // ---------------------------------------------------------------------------
622
+ // flushJobs:complete lifecycle signal
623
+ // ---------------------------------------------------------------------------
624
+
625
+ describe("flushJobs:complete", () => {
626
+ it("is emitted after a reactive flush", async () => {
627
+ const items = ref(["a"]);
628
+ const collector = await createMessageCollector(socket!, ["render"]);
629
+
630
+ await renderAsync(
631
+ <Output>
632
+ <For each={items}>{(item) => <>{item}</>}</For>
633
+ </Output>,
634
+ );
635
+ await collector.waitForRender();
636
+
637
+ items.value = ["a", "b"];
638
+ await flushJobsAsync();
639
+ const flushMessages = await collector.waitForFlush();
640
+ collector.stop();
641
+
642
+ // waitForFlush resolves on flushJobs:complete, so if we get here it was received
643
+ expect(flushMessages.some((m) => m.type === "flushJobs:complete")).toBe(
644
+ true,
645
+ );
646
+ });
647
+ });
648
+
649
+ // ---------------------------------------------------------------------------
650
+ // debugger:info (connection handshake)
651
+ // ---------------------------------------------------------------------------
652
+
653
+ describe("debugger:info", () => {
654
+ it("is sent on connection before any other messages", async () => {
655
+ // Close the existing socket so the server accepts a new one
656
+ socket!.close();
657
+ await new Promise((resolve) => setTimeout(resolve, 100));
658
+
659
+ const freshSocket = new WebSocket(`ws://127.0.0.1:${port}`);
660
+ const firstMessage = new Promise<DevtoolsMessage>((resolve, reject) => {
661
+ const timeout = setTimeout(
662
+ () => reject(new Error("No debugger:info received")),
663
+ 3000,
664
+ );
665
+ freshSocket.once("message", (data) => {
666
+ clearTimeout(timeout);
667
+ resolve(JSON.parse(String(data)));
668
+ });
669
+ });
670
+
671
+ await new Promise<void>((resolve, reject) => {
672
+ freshSocket.once("open", resolve);
673
+ freshSocket.once("error", reject);
674
+ });
675
+
676
+ const first = await firstMessage;
677
+ freshSocket.close();
678
+ // Replace socket so afterEach doesn't close an already-closed socket
679
+ socket = undefined;
680
+
681
+ expect(first).toMatchObject({
682
+ type: "debugger:info",
683
+ version: expect.any(String),
684
+ });
685
+ });
686
+ });
687
+
688
+ // ---------------------------------------------------------------------------
689
+ // null vs undefined contract
690
+ // ---------------------------------------------------------------------------
691
+
692
+ describe("null vs undefined contract", () => {
693
+ it("nullable fields arrive as null (not undefined or missing)", async () => {
694
+ const collector = await createMessageCollector(socket!, ["render"]);
695
+ await renderAsync(<Output>hello</Output>);
696
+ const messages = await collector.waitForRender();
697
+ collector.stop();
698
+
699
+ // Root node: parent_id should be explicit null
700
+ const root = messages.find(
701
+ (m: DevtoolsMessage) =>
702
+ m.type === "render:node_added" && m.kind === "root",
703
+ );
704
+ expect(root).toBeDefined();
705
+ expect(root!.parent_id).toBeNull();
706
+ expect("parent_id" in root!).toBe(true);
707
+
708
+ // Component node: value should be null, not undefined
709
+ const component = messages.find(
710
+ (m: DevtoolsMessage) =>
711
+ m.type === "render:node_added" && m.kind === "component",
712
+ );
713
+ expect(component).toBeDefined();
714
+ expect(component!.value).toBeNull();
715
+ expect("value" in component!).toBe(true);
716
+
717
+ // source_file is nullable
718
+ expect("source_file" in root!).toBe(true);
719
+ });
720
+
721
+ it("non-null fields have correct types", async () => {
722
+ const collector = await createMessageCollector(socket!, ["render"]);
723
+ await renderAsync(<Output>hello</Output>);
724
+ const messages = await collector.waitForRender();
725
+ collector.stop();
726
+
727
+ const text = messages.find(
728
+ (m: DevtoolsMessage) =>
729
+ m.type === "render:node_added" && m.kind === "text",
730
+ );
731
+ expect(text).toBeDefined();
732
+ // value is a string for text nodes
733
+ expect(typeof text!.value).toBe("string");
734
+ // id is always a number
735
+ expect(typeof text!.id).toBe("number");
736
+ // parent_id is a number for non-root nodes
737
+ expect(typeof text!.parent_id).toBe("number");
738
+ });
739
+ });
740
+
741
+ // ---------------------------------------------------------------------------
742
+ // seq ordering
743
+ // ---------------------------------------------------------------------------
744
+
745
+ describe("seq ordering", () => {
746
+ it("messages have monotonically increasing seq", async () => {
747
+ const collector = await createMessageCollector(socket!, ["render"]);
748
+ await renderAsync(<Output>hello</Output>);
749
+ const messages = await collector.waitForRender();
750
+ collector.stop();
751
+
752
+ const seqs = messages
753
+ .filter((m) => typeof m.seq === "number")
754
+ .map((m) => m.seq as number);
755
+ for (let i = 1; i < seqs.length; i++) {
756
+ expect(seqs[i]).toBeGreaterThan(seqs[i - 1]);
757
+ }
758
+ });
759
+ });