@swifttui/web 0.0.6

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 (39) hide show
  1. package/AGENTS.md +52 -0
  2. package/README.md +116 -0
  3. package/cli.ts +168 -0
  4. package/index.html +50 -0
  5. package/index.ts +8 -0
  6. package/manifest.ts +1 -0
  7. package/package.json +33 -0
  8. package/src/AccessibilityTree.ts +262 -0
  9. package/src/BoxDrawingRenderer.ts +585 -0
  10. package/src/PublicEntrypointBoundary.test.ts +20 -0
  11. package/src/WebHostApp.test.ts +222 -0
  12. package/src/WebHostApp.ts +269 -0
  13. package/src/WebHostSceneManifest.test.ts +38 -0
  14. package/src/WebHostSceneManifest.ts +156 -0
  15. package/src/WebHostSceneRuntime.test.ts +1752 -0
  16. package/src/WebHostSceneRuntime.ts +955 -0
  17. package/src/WebHostSurfaceTransport.test.ts +362 -0
  18. package/src/WebHostSurfaceTransport.ts +648 -0
  19. package/src/WebHostTerminalStyle.test.ts +123 -0
  20. package/src/WebHostTerminalStyle.ts +471 -0
  21. package/src/WebHostTestFixtures.ts +10 -0
  22. package/src/WebSocketSceneBridge.test.ts +198 -0
  23. package/src/WebSocketSceneBridge.ts +233 -0
  24. package/src/browser.ts +59 -0
  25. package/src/wasi/BrowserWASIBridge.test.ts +168 -0
  26. package/src/wasi/BrowserWASIBridge.ts +167 -0
  27. package/src/wasi/SharedInputQueue.test.ts +146 -0
  28. package/src/wasi/SharedInputQueue.ts +199 -0
  29. package/src/wasi/StdIOPipe.ts +72 -0
  30. package/src/wasi/WasiPollScheduler.test.ts +176 -0
  31. package/src/wasi/WasiPollScheduler.ts +305 -0
  32. package/src/wasi/WasmSceneRuntime.ts +205 -0
  33. package/src/wasi/WasmSceneWorker.ts +182 -0
  34. package/style.css +15 -0
  35. package/testing.ts +1 -0
  36. package/tsconfig.json +29 -0
  37. package/wasi-worker.ts +1 -0
  38. package/wasi.ts +4 -0
  39. package/websocket.ts +1 -0
@@ -0,0 +1,1752 @@
1
+ import { expect, test } from "bun:test";
2
+
3
+ import {
4
+ BrowserWASIBridge,
5
+ encodeRenderStyleControlMessage,
6
+ encodeResizeControlMessage,
7
+ } from "./wasi/BrowserWASIBridge.ts";
8
+ import { SharedInputQueueReader } from "./wasi/SharedInputQueue.ts";
9
+ import { createWasmSceneRuntimeFactory } from "./wasi/WasmSceneRuntime.ts";
10
+ import { WebHostSceneRuntime } from "./WebHostSceneRuntime.ts";
11
+ import { transportFixture } from "./WebHostTestFixtures.ts";
12
+
13
+ const encoder = new TextEncoder();
14
+ const decoder = new TextDecoder();
15
+
16
+ test("hidden scenes stay out of layout even after style updates", () => {
17
+ const dom = installFakeDOM();
18
+ try {
19
+ const mount = new FakeElement("div");
20
+ const runtime = new WebHostSceneRuntime({
21
+ mount: mount as unknown as HTMLElement,
22
+ descriptor: { id: "details", title: "Details", isDefault: false },
23
+ style: {},
24
+ onInput: () => {},
25
+ });
26
+
27
+ expect(runtime.element.hidden).toBe(true);
28
+ expect(runtime.element.style.getPropertyValue("display")).toBe("none");
29
+ expect(runtime.element.style.getPropertyPriority("display")).toBe("important");
30
+
31
+ runtime.setStyle({ fontSize: 18 });
32
+ expect(runtime.element.hidden).toBe(true);
33
+ expect(runtime.element.style.getPropertyValue("display")).toBe("none");
34
+
35
+ runtime.setVisible(true);
36
+ expect(runtime.element.hidden).toBe(false);
37
+ expect(runtime.element.style.getPropertyValue("display")).toBe("grid");
38
+
39
+ runtime.setVisible(false);
40
+ expect(runtime.element.hidden).toBe(true);
41
+ expect(runtime.element.style.getPropertyValue("display")).toBe("none");
42
+ } finally {
43
+ dom.restore();
44
+ }
45
+ });
46
+
47
+ test("runtime draws decoded surface frames into the canvas", async () => {
48
+ const dom = installFakeDOM({ devicePixelRatio: 2 });
49
+ try {
50
+ const bridge = new BrowserWASIBridge({
51
+ sceneId: "main",
52
+ columns: 4,
53
+ rows: 2,
54
+ });
55
+ const mount = new FakeElement("div");
56
+ const runtime = new WebHostSceneRuntime({
57
+ mount: mount as unknown as HTMLElement,
58
+ descriptor: { id: "main", title: "Main", isDefault: true },
59
+ style: {
60
+ fontSize: 20,
61
+ fontFamily: "Test Mono",
62
+ theme: {
63
+ foreground: "#eeeeee",
64
+ background: "#101820",
65
+ },
66
+ },
67
+ bridge,
68
+ onInput: () => {},
69
+ });
70
+
71
+ await runtime.mount();
72
+
73
+ expect(dom.canvases).toHaveLength(1);
74
+ const canvas = dom.canvases[0]!;
75
+ const context = canvas.context;
76
+
77
+ context.operations = [];
78
+ bridge.stdout.write(encoder.encode(transportFixture("web-surface-styled")));
79
+
80
+ expect(canvas.width).toBe(80);
81
+ expect(canvas.height).toBe(108);
82
+ expect(canvas.style.width).toBe("40px");
83
+ expect(canvas.style.height).toBe("54px");
84
+
85
+ expect(context.operations).toContainEqual({
86
+ type: "clearRect",
87
+ x: 0,
88
+ y: 0,
89
+ width: 40,
90
+ height: 54,
91
+ });
92
+ expect(context.operations).toContainEqual({
93
+ type: "fillRect",
94
+ x: 0,
95
+ y: 0,
96
+ width: 40,
97
+ height: 54,
98
+ fillStyle: "rgba(16, 24, 32, 1)",
99
+ globalAlpha: 1,
100
+ });
101
+
102
+ expect(fillTextOperations(context, "A")).toEqual([
103
+ {
104
+ type: "fillText",
105
+ text: "A",
106
+ x: 0,
107
+ y: 21,
108
+ fillStyle: "#000000FF",
109
+ font: "italic 700 20px Test Mono",
110
+ globalAlpha: 0.75,
111
+ },
112
+ ]);
113
+ expect(fillTextOperations(context, "界")).toHaveLength(1);
114
+ expect(fillRectOperations(context, "#E05757FF")[0]).toMatchObject({
115
+ x: 0,
116
+ y: 0,
117
+ width: 10,
118
+ height: 27,
119
+ globalAlpha: 0.75,
120
+ });
121
+ expect(fillRectOperations(context, "#61C67BFF")[0]).toMatchObject({
122
+ x: 10,
123
+ y: 0,
124
+ width: 20,
125
+ height: 27,
126
+ globalAlpha: 0.5,
127
+ });
128
+
129
+ const strokes = context.operations.filter((operation) => operation.type === "stroke");
130
+ expect(strokes).toContainEqual({
131
+ type: "stroke",
132
+ strokeStyle: "#EBB33CFF",
133
+ lineWidth: 1,
134
+ lineDash: [4, 3],
135
+ path: [["moveTo", 0, 25], ["lineTo", 10, 25]],
136
+ });
137
+ expect(strokes).toContainEqual({
138
+ type: "stroke",
139
+ strokeStyle: "#E05757FF",
140
+ lineWidth: 1,
141
+ lineDash: [1, 3],
142
+ path: [["moveTo", 0, 13], ["lineTo", 10, 13]],
143
+ });
144
+ expect(strokes.some((operation) => operation.lineWidth === 2)).toBe(true);
145
+ } finally {
146
+ dom.restore();
147
+ }
148
+ });
149
+
150
+ test("runtime redraws only damaged cells when a compatible frame includes damage", async () => {
151
+ const dom = installFakeDOM();
152
+ try {
153
+ const bridge = new BrowserWASIBridge({
154
+ sceneId: "main",
155
+ columns: 4,
156
+ rows: 2,
157
+ });
158
+ const mount = new FakeElement("div");
159
+ const runtime = new WebHostSceneRuntime({
160
+ mount: mount as unknown as HTMLElement,
161
+ descriptor: { id: "main", title: "Main", isDefault: true },
162
+ style: {
163
+ fontSize: 20,
164
+ fontFamily: "Test Mono",
165
+ },
166
+ bridge,
167
+ onInput: () => {},
168
+ });
169
+
170
+ await runtime.mount();
171
+
172
+ const canvas = dom.canvases[0]!;
173
+ const context = canvas.context;
174
+ bridge.stdout.write(encoder.encode(surfaceRecord({
175
+ version: 1,
176
+ width: 4,
177
+ height: 2,
178
+ styles: [null],
179
+ rows: [
180
+ [[0, "A", 1, 0], [1, "B", 1, 0]],
181
+ [[0, "C", 1, 0], [1, "D", 1, 0]],
182
+ ],
183
+ images: [],
184
+ })));
185
+
186
+ context.operations = [];
187
+ bridge.stdout.write(encoder.encode(surfaceRecord({
188
+ version: 1,
189
+ width: 4,
190
+ height: 2,
191
+ styles: [null],
192
+ rows: [
193
+ [[0, "A", 1, 0], [1, "B", 1, 0]],
194
+ [[0, "X", 1, 0], [1, "D", 1, 0]],
195
+ ],
196
+ images: [],
197
+ damage: {
198
+ textRows: [[1, [[0, 1]]]],
199
+ requiresFullTextRepaint: false,
200
+ requiresFullGraphicsReplay: false,
201
+ },
202
+ })));
203
+
204
+ expect(context.operations).toContainEqual({
205
+ type: "clearRect",
206
+ x: 0,
207
+ y: 27,
208
+ width: 10,
209
+ height: 27,
210
+ });
211
+ expect(fillTextOperations(context, "X")).toHaveLength(1);
212
+ expect(fillTextOperations(context, "A")).toEqual([]);
213
+ expect(fillTextOperations(context, "D")).toEqual([]);
214
+ } finally {
215
+ dom.restore();
216
+ }
217
+ });
218
+
219
+ test("runtime clears stale overlay text when dirty rects remove an overlay", async () => {
220
+ const dom = installFakeDOM();
221
+ try {
222
+ const bridge = new BrowserWASIBridge({
223
+ sceneId: "main",
224
+ columns: 24,
225
+ rows: 4,
226
+ });
227
+ const mount = new FakeElement("div");
228
+ const runtime = new WebHostSceneRuntime({
229
+ mount: mount as unknown as HTMLElement,
230
+ descriptor: { id: "main", title: "Main", isDefault: true },
231
+ style: {
232
+ fontSize: 20,
233
+ fontFamily: "Test Mono",
234
+ },
235
+ bridge,
236
+ onInput: () => {},
237
+ });
238
+
239
+ await runtime.mount();
240
+
241
+ const canvas = dom.canvases[0]!;
242
+ const context = canvas.context;
243
+ context.operations = [];
244
+
245
+ bridge.stdout.write(encoder.encode(surfaceRecord({
246
+ version: 1,
247
+ width: 24,
248
+ height: 4,
249
+ styles: [null],
250
+ rows: [
251
+ [[0, "Base content", 12, 0]],
252
+ [],
253
+ [],
254
+ [],
255
+ ],
256
+ images: [],
257
+ })));
258
+ bridge.stdout.write(encoder.encode(surfaceRecord({
259
+ version: 1,
260
+ width: 24,
261
+ height: 4,
262
+ styles: [null],
263
+ rows: [
264
+ [[0, "Base content", 12, 0]],
265
+ [[0, "Command palette", 15, 0]],
266
+ [[0, "Search actions", 14, 0]],
267
+ [],
268
+ ],
269
+ images: [],
270
+ damage: {
271
+ textRows: [
272
+ [1, [[0, 24]]],
273
+ [2, [[0, 24]]],
274
+ ],
275
+ requiresFullTextRepaint: false,
276
+ requiresFullGraphicsReplay: false,
277
+ },
278
+ })));
279
+
280
+ const overlayText = readCanvasTextLikePixels(canvas);
281
+ expect(overlayText).toContain("Command palette");
282
+ expect(overlayText).toContain("Search actions");
283
+
284
+ bridge.stdout.write(encoder.encode(surfaceRecord({
285
+ version: 1,
286
+ width: 24,
287
+ height: 4,
288
+ styles: [null],
289
+ rows: [
290
+ [[0, "Base content", 12, 0]],
291
+ [],
292
+ [],
293
+ [],
294
+ ],
295
+ images: [],
296
+ damage: {
297
+ textRows: [
298
+ [1, [[0, 24]]],
299
+ [2, [[0, 24]]],
300
+ ],
301
+ requiresFullTextRepaint: false,
302
+ requiresFullGraphicsReplay: false,
303
+ },
304
+ })));
305
+
306
+ const dismissedText = readCanvasTextLikePixels(canvas);
307
+ expect(dismissedText).not.toContain("Command palette");
308
+ expect(dismissedText).not.toContain("Search actions");
309
+ } finally {
310
+ dom.restore();
311
+ }
312
+ });
313
+
314
+ test("runtime skips canvas drawing for compatible empty damage", async () => {
315
+ const dom = installFakeDOM();
316
+ try {
317
+ const bridge = new BrowserWASIBridge({ sceneId: "main", columns: 4, rows: 2 });
318
+ const mount = new FakeElement("div");
319
+ const runtime = new WebHostSceneRuntime({
320
+ mount: mount as unknown as HTMLElement,
321
+ descriptor: { id: "main", title: "Main", isDefault: true },
322
+ style: { fontSize: 20, fontFamily: "Test Mono" },
323
+ bridge,
324
+ onInput: () => {},
325
+ });
326
+
327
+ await runtime.mount();
328
+ bridge.stdout.write(encoder.encode(surfaceRecord({
329
+ version: 1,
330
+ width: 4,
331
+ height: 2,
332
+ styles: [null],
333
+ rows: [[[0, "A", 1, 0]], []],
334
+ images: [],
335
+ })));
336
+
337
+ const context = dom.canvases[0]!.context;
338
+ context.operations = [];
339
+ bridge.stdout.write(encoder.encode(surfaceRecord({
340
+ version: 1,
341
+ width: 4,
342
+ height: 2,
343
+ styles: [null],
344
+ rows: [[[0, "A", 1, 0]], []],
345
+ images: [],
346
+ damage: {
347
+ textRows: [],
348
+ requiresFullTextRepaint: false,
349
+ requiresFullGraphicsReplay: false,
350
+ },
351
+ })));
352
+
353
+ expect(context.operations).toEqual([]);
354
+ } finally {
355
+ dom.restore();
356
+ }
357
+ });
358
+
359
+ test("runtime clears dirty rows when an image disappears", async () => {
360
+ const dom = installFakeDOM({
361
+ createImageBitmap: async () => ({ imageId: "decoded-image" }),
362
+ });
363
+ try {
364
+ const bridge = new BrowserWASIBridge({ sceneId: "main", columns: 4, rows: 2 });
365
+ const mount = new FakeElement("div");
366
+ const runtime = new WebHostSceneRuntime({
367
+ mount: mount as unknown as HTMLElement,
368
+ descriptor: { id: "main", title: "Main", isDefault: true },
369
+ style: { fontSize: 20, fontFamily: "Test Mono" },
370
+ bridge,
371
+ onInput: () => {},
372
+ });
373
+
374
+ await runtime.mount();
375
+ bridge.stdout.write(encoder.encode(surfaceRecord({
376
+ version: 1,
377
+ width: 4,
378
+ height: 2,
379
+ styles: [null],
380
+ rows: [
381
+ [[0, "A", 1, 0]],
382
+ [[0, "B", 1, 0]],
383
+ ],
384
+ images: [
385
+ {
386
+ id: "png:test",
387
+ format: "png",
388
+ bounds: [1, 1, 2, 1],
389
+ visibleBounds: [1, 1, 2, 1],
390
+ scalingMode: "stretch",
391
+ dataBase64: "iVBORw==",
392
+ },
393
+ ],
394
+ })));
395
+ await flushPromises();
396
+
397
+ const context = dom.canvases[0]!.context;
398
+ context.operations = [];
399
+ bridge.stdout.write(encoder.encode(surfaceRecord({
400
+ version: 1,
401
+ width: 4,
402
+ height: 2,
403
+ styles: [null],
404
+ rows: [
405
+ [[0, "A", 1, 0]],
406
+ [[0, "B", 1, 0]],
407
+ ],
408
+ images: [],
409
+ damage: {
410
+ textRows: [[1, [[1, 3]]]],
411
+ requiresFullTextRepaint: false,
412
+ requiresFullGraphicsReplay: false,
413
+ },
414
+ })));
415
+
416
+ expect(context.operations).toContainEqual({
417
+ type: "clearRect",
418
+ x: 10,
419
+ y: 27,
420
+ width: 20,
421
+ height: 27,
422
+ });
423
+ expect(drawImageOperations(context)).toEqual([]);
424
+ expect(fillTextOperations(context, "A")).toEqual([]);
425
+ } finally {
426
+ dom.restore();
427
+ }
428
+ });
429
+
430
+ test("WASI runtime forwards bridge control input into the worker queue", async () => {
431
+ const dom = installFakeDOM();
432
+ const previousWorker = globalThis.Worker;
433
+ const postedMessages: Array<{ inputQueue?: ConstructorParameters<typeof SharedInputQueueReader>[0] }> = [];
434
+
435
+ class FakeWorker {
436
+ constructor(
437
+ _url: string | URL,
438
+ _options?: WorkerOptions
439
+ ) {}
440
+
441
+ addEventListener(
442
+ _type: string,
443
+ _listener: EventListener
444
+ ): void {}
445
+
446
+ postMessage(
447
+ message: { inputQueue?: ConstructorParameters<typeof SharedInputQueueReader>[0] }
448
+ ): void {
449
+ postedMessages.push(message);
450
+ }
451
+
452
+ terminate(): void {}
453
+ }
454
+
455
+ globalThis.Worker = FakeWorker as unknown as typeof Worker;
456
+ try {
457
+ const bridge = new BrowserWASIBridge({ sceneId: "main", columns: 4, rows: 2 });
458
+ const mount = new FakeElement("div");
459
+ const runtime = createWasmSceneRuntimeFactory(new URL("https://example.test/app.wasm"), {
460
+ workerModuleURL: "fake-worker.js",
461
+ })({
462
+ mount: mount as unknown as HTMLElement,
463
+ descriptor: { id: "main", title: "Main", isDefault: true },
464
+ style: { fontSize: 20 },
465
+ bridge,
466
+ onInput: () => {},
467
+ });
468
+
469
+ await runtime.mount();
470
+ const inputQueue = postedMessages[0]?.inputQueue;
471
+ if (!inputQueue) {
472
+ throw new Error("worker did not receive an input queue");
473
+ }
474
+ const reader = new SharedInputQueueReader(inputQueue);
475
+ reader.readAvailable(reader.availableBytes());
476
+
477
+ const style = { cursorBlink: true };
478
+ bridge.updateRenderStyle(style);
479
+ const styleBytes = reader.readAvailable(reader.availableBytes());
480
+ expect(Array.from(styleBytes ?? [])).toEqual(
481
+ Array.from(encodeRenderStyleControlMessage(style))
482
+ );
483
+
484
+ bridge.resize(10, 4, 9, 18);
485
+ const resizeBytes = reader.readAvailable(reader.availableBytes());
486
+ expect(Array.from(resizeBytes ?? [])).toEqual(
487
+ Array.from(encodeResizeControlMessage(10, 4, 9, 18))
488
+ );
489
+
490
+ runtime.dispose();
491
+ } finally {
492
+ globalThis.Worker = previousWorker;
493
+ dom.restore();
494
+ }
495
+ });
496
+
497
+ test("runtime mounts accessibility tree and announces live-region changes", async () => {
498
+ const dom = installFakeDOM();
499
+ try {
500
+ const bridge = new BrowserWASIBridge({
501
+ sceneId: "main",
502
+ columns: 4,
503
+ rows: 2,
504
+ });
505
+ const mount = new FakeElement("div");
506
+ const runtime = new WebHostSceneRuntime({
507
+ mount: mount as unknown as HTMLElement,
508
+ descriptor: { id: "main", title: "Main", isDefault: true },
509
+ style: {
510
+ fontSize: 20,
511
+ fontFamily: "Test Mono",
512
+ },
513
+ bridge,
514
+ onInput: () => {},
515
+ });
516
+
517
+ await runtime.mount();
518
+
519
+ const canvas = dom.canvases[0]!;
520
+ expect(canvas.getAttribute("aria-hidden")).toBe("true");
521
+
522
+ bridge.stdout.write(encoder.encode(surfaceRecord({
523
+ version: 2,
524
+ width: 4,
525
+ height: 2,
526
+ styles: [null],
527
+ rows: [[], []],
528
+ accessibilityTree: [
529
+ {
530
+ id: "root",
531
+ rect: [0, 0, 4, 2],
532
+ role: "group",
533
+ label: "Root",
534
+ isFocused: false,
535
+ },
536
+ {
537
+ id: "root/button",
538
+ parentId: "root",
539
+ rect: [0, 0, 2, 1],
540
+ role: "button",
541
+ label: "Save",
542
+ hint: "Writes the file",
543
+ isFocused: true,
544
+ },
545
+ {
546
+ id: "root/status",
547
+ parentId: "root",
548
+ rect: [0, 1, 2, 1],
549
+ role: "status",
550
+ label: "Idle",
551
+ liveRegion: "polite",
552
+ isFocused: false,
553
+ },
554
+ {
555
+ id: "root/error",
556
+ parentId: "root",
557
+ rect: [2, 1, 2, 1],
558
+ role: "alert",
559
+ label: "Ready",
560
+ liveRegion: "assertive",
561
+ isFocused: false,
562
+ },
563
+ ],
564
+ accessibilityAnnouncements: [
565
+ { message: "Ready", politeness: "polite" },
566
+ ],
567
+ })));
568
+
569
+ const tree = childWithClass(runtime.terminalMount, "webhost-scene__accessibility-tree");
570
+ const announcer = childWithClass(
571
+ runtime.terminalMount,
572
+ "webhost-scene__accessibility-announcer"
573
+ );
574
+ const root = childWithData(tree, "accessibilityId", "root");
575
+ const button = childWithData(root, "accessibilityId", "root/button");
576
+ const status = childWithData(root, "accessibilityId", "root/status");
577
+
578
+ expect(button.getAttribute("role")).toBe("button");
579
+ expect(button.getAttribute("aria-label")).toBe("Save");
580
+ expect(button.getAttribute("aria-description")).toBe("Writes the file");
581
+ expect(button.focused).toBe(true);
582
+ expect(button.lastFocusOptions).toEqual({ preventScroll: true });
583
+ expect(status.getAttribute("role")).toBe("status");
584
+ expect(status.getAttribute("aria-live")).toBe("polite");
585
+ expect(status.style.left).toBe("0px");
586
+ expect(status.style.top).toBe("27px");
587
+ expect(announcer.textContent).toBe("Ready");
588
+
589
+ bridge.stdout.write(encoder.encode(surfaceRecord({
590
+ version: 2,
591
+ width: 4,
592
+ height: 2,
593
+ styles: [null],
594
+ rows: [[], []],
595
+ accessibilityTree: [
596
+ {
597
+ id: "root/status",
598
+ rect: [0, 1, 2, 1],
599
+ role: "status",
600
+ label: "Saved",
601
+ liveRegion: "polite",
602
+ isFocused: false,
603
+ },
604
+ {
605
+ id: "root/error",
606
+ rect: [2, 1, 2, 1],
607
+ role: "alert",
608
+ label: "Failed",
609
+ liveRegion: "assertive",
610
+ isFocused: false,
611
+ },
612
+ ],
613
+ })));
614
+
615
+ expect(announcer.getAttribute("aria-live")).toBe("assertive");
616
+ expect(announcer.textContent).toBe("Failed\nSaved");
617
+
618
+ bridge.stdout.write(encoder.encode(surfaceRecord({
619
+ version: 2,
620
+ width: 4,
621
+ height: 2,
622
+ styles: [null],
623
+ rows: [[], []],
624
+ accessibilityAnnouncements: [
625
+ { message: "Published", politeness: "assertive" },
626
+ { message: "Queued", politeness: "polite" },
627
+ ],
628
+ })));
629
+
630
+ expect(announcer.getAttribute("aria-live")).toBe("assertive");
631
+ expect(announcer.textContent).toBe("Published\nQueued");
632
+
633
+ bridge.stdout.write(encoder.encode(surfaceRecord({
634
+ version: 2,
635
+ width: 4,
636
+ height: 2,
637
+ styles: [null],
638
+ rows: [[], []],
639
+ accessibilityTree: [
640
+ {
641
+ id: "root/status",
642
+ rect: [0, 1, 2, 1],
643
+ role: "status",
644
+ label: "Saved",
645
+ liveRegion: "polite",
646
+ isFocused: false,
647
+ },
648
+ ],
649
+ })));
650
+
651
+ expect(announcer.textContent).toBe("Published\nQueued");
652
+ } finally {
653
+ dom.restore();
654
+ }
655
+ });
656
+
657
+ test("runtime decodes surface images once and reuses the cached image", async () => {
658
+ const decodedBlobs: Blob[] = [];
659
+ const dom = installFakeDOM({
660
+ createImageBitmap: async (blob) => {
661
+ decodedBlobs.push(blob);
662
+ return { imageId: `decoded-${decodedBlobs.length}` };
663
+ },
664
+ });
665
+ try {
666
+ const bridge = new BrowserWASIBridge({
667
+ sceneId: "main",
668
+ columns: 4,
669
+ rows: 2,
670
+ });
671
+ const mount = new FakeElement("div");
672
+ const runtime = new WebHostSceneRuntime({
673
+ mount: mount as unknown as HTMLElement,
674
+ descriptor: { id: "main", title: "Main", isDefault: true },
675
+ style: {
676
+ fontSize: 20,
677
+ fontFamily: "Test Mono",
678
+ },
679
+ bridge,
680
+ onInput: () => {},
681
+ });
682
+
683
+ await runtime.mount();
684
+
685
+ const canvas = dom.canvases[0]!;
686
+ const context = canvas.context;
687
+ context.operations = [];
688
+ bridge.stdout.write(encoder.encode(surfaceRecord({
689
+ version: 1,
690
+ width: 4,
691
+ height: 2,
692
+ styles: [null],
693
+ rows: [[], []],
694
+ images: [
695
+ {
696
+ id: "png:test",
697
+ format: "png",
698
+ bounds: [1, 0, 2, 2],
699
+ visibleBounds: [1, 0, 1, 2],
700
+ scalingMode: "stretch",
701
+ pixelSize: [2, 2],
702
+ dataBase64: "iVBORw==",
703
+ },
704
+ {
705
+ id: "png:test",
706
+ format: "png",
707
+ bounds: [3, 0, 1, 1],
708
+ visibleBounds: [3, 0, 1, 1],
709
+ scalingMode: "stretch",
710
+ pixelSize: [2, 2],
711
+ },
712
+ ],
713
+ })));
714
+ await flushPromises();
715
+
716
+ expect(decodedBlobs).toHaveLength(1);
717
+ expect(drawImageOperations(context)).toEqual([
718
+ {
719
+ type: "drawImage",
720
+ imageId: "decoded-1",
721
+ x: 10,
722
+ y: 0,
723
+ width: 20,
724
+ height: 54,
725
+ },
726
+ {
727
+ type: "drawImage",
728
+ imageId: "decoded-1",
729
+ x: 30,
730
+ y: 0,
731
+ width: 10,
732
+ height: 27,
733
+ },
734
+ ]);
735
+ expect(context.operations).toContainEqual({
736
+ type: "rect",
737
+ x: 10,
738
+ y: 0,
739
+ width: 10,
740
+ height: 54,
741
+ });
742
+ expect(context.operations).toContainEqual({
743
+ type: "clip",
744
+ path: [["rect", 10, 0, 10, 54]],
745
+ });
746
+
747
+ context.operations = [];
748
+ bridge.stdout.write(encoder.encode(surfaceRecord({
749
+ version: 1,
750
+ width: 4,
751
+ height: 2,
752
+ styles: [null],
753
+ rows: [[], []],
754
+ images: [
755
+ {
756
+ id: "png:test",
757
+ format: "png",
758
+ bounds: [0, 1, 1, 1],
759
+ visibleBounds: [0, 1, 1, 1],
760
+ scalingMode: "stretch",
761
+ },
762
+ ],
763
+ })));
764
+
765
+ expect(decodedBlobs).toHaveLength(1);
766
+ expect(drawImageOperations(context)).toEqual([
767
+ {
768
+ type: "drawImage",
769
+ imageId: "decoded-1",
770
+ x: 0,
771
+ y: 27,
772
+ width: 10,
773
+ height: 27,
774
+ },
775
+ ]);
776
+ } finally {
777
+ dom.restore();
778
+ }
779
+ });
780
+
781
+ test("runtime draws box and block elements procedurally instead of as font glyphs", async () => {
782
+ const dom = installFakeDOM();
783
+ try {
784
+ const bridge = new BrowserWASIBridge({
785
+ sceneId: "main",
786
+ columns: 4,
787
+ rows: 2,
788
+ });
789
+ const mount = new FakeElement("div");
790
+ const runtime = new WebHostSceneRuntime({
791
+ mount: mount as unknown as HTMLElement,
792
+ descriptor: { id: "main", title: "Main", isDefault: true },
793
+ style: {
794
+ fontSize: 20,
795
+ fontFamily: "Test Mono",
796
+ },
797
+ bridge,
798
+ onInput: () => {},
799
+ });
800
+
801
+ await runtime.mount();
802
+
803
+ const canvas = dom.canvases[0]!;
804
+ const context = canvas.context;
805
+ context.operations = [];
806
+ bridge.stdout.write(encoder.encode(surfaceRecord({
807
+ version: 1,
808
+ width: 4,
809
+ height: 2,
810
+ styles: [
811
+ null,
812
+ {
813
+ fg: "#EBB33CFF",
814
+ },
815
+ ],
816
+ rows: [
817
+ [
818
+ [0, "┌", 1, 1],
819
+ [1, "─", 1, 1],
820
+ [2, "▄", 1, 1],
821
+ [3, "A", 1, 1],
822
+ ],
823
+ ],
824
+ images: [],
825
+ })));
826
+
827
+ expect(fillTextOperations(context, "┌")).toEqual([]);
828
+ expect(fillTextOperations(context, "─")).toEqual([]);
829
+ expect(fillTextOperations(context, "▄")).toEqual([]);
830
+ expect(fillTextOperations(context, "A")).toHaveLength(1);
831
+
832
+ const boxFills = fillRectOperations(context, "#EBB33CFF");
833
+ expect(boxFills).toContainEqual({
834
+ type: "fillRect",
835
+ x: 4.5,
836
+ y: 13,
837
+ width: 5.5,
838
+ height: 1,
839
+ fillStyle: "#EBB33CFF",
840
+ globalAlpha: 1,
841
+ });
842
+ expect(boxFills).toContainEqual({
843
+ type: "fillRect",
844
+ x: 4.5,
845
+ y: 13,
846
+ width: 1,
847
+ height: 14,
848
+ fillStyle: "#EBB33CFF",
849
+ globalAlpha: 1,
850
+ });
851
+ expect(boxFills).toContainEqual({
852
+ type: "fillRect",
853
+ x: 10,
854
+ y: 13,
855
+ width: 5.5,
856
+ height: 1,
857
+ fillStyle: "#EBB33CFF",
858
+ globalAlpha: 1,
859
+ });
860
+ expect(boxFills).toContainEqual({
861
+ type: "fillRect",
862
+ x: 14.5,
863
+ y: 13,
864
+ width: 5.5,
865
+ height: 1,
866
+ fillStyle: "#EBB33CFF",
867
+ globalAlpha: 1,
868
+ });
869
+ expect(boxFills).toContainEqual({
870
+ type: "fillRect",
871
+ x: 20,
872
+ y: 13.5,
873
+ width: 10,
874
+ height: 13.5,
875
+ fillStyle: "#EBB33CFF",
876
+ globalAlpha: 1,
877
+ });
878
+ } finally {
879
+ dom.restore();
880
+ }
881
+ });
882
+
883
+ test("runtime draws rounded box corners with the cell foreground stroke", async () => {
884
+ const dom = installFakeDOM();
885
+ try {
886
+ const bridge = new BrowserWASIBridge({
887
+ sceneId: "main",
888
+ columns: 4,
889
+ rows: 1,
890
+ });
891
+ const mount = new FakeElement("div");
892
+ const runtime = new WebHostSceneRuntime({
893
+ mount: mount as unknown as HTMLElement,
894
+ descriptor: { id: "main", title: "Main", isDefault: true },
895
+ style: {
896
+ fontSize: 20,
897
+ fontFamily: "Test Mono",
898
+ },
899
+ bridge,
900
+ onInput: () => {},
901
+ });
902
+
903
+ await runtime.mount();
904
+
905
+ const canvas = dom.canvases[0]!;
906
+ const context = canvas.context;
907
+ context.strokeStyle = "#000000";
908
+ context.operations = [];
909
+ bridge.stdout.write(encoder.encode(surfaceRecord({
910
+ version: 1,
911
+ width: 4,
912
+ height: 1,
913
+ styles: [
914
+ null,
915
+ {
916
+ fg: "#EBB33CFF",
917
+ },
918
+ ],
919
+ rows: [
920
+ [
921
+ [0, "╭", 1, 1],
922
+ [1, "╮", 1, 1],
923
+ ],
924
+ ],
925
+ images: [],
926
+ })));
927
+
928
+ expect(fillTextOperations(context, "╭")).toEqual([]);
929
+ expect(fillTextOperations(context, "╮")).toEqual([]);
930
+ const strokes = context.operations.filter((operation) => operation.type === "stroke");
931
+ expect(strokes).toHaveLength(2);
932
+ expect(strokes.every((operation) => operation.strokeStyle === "#EBB33CFF")).toBe(true);
933
+ expect(strokes.every((operation) => operation.lineWidth === 1)).toBe(true);
934
+ expect(strokes.every((operation) => operation.lineDash instanceof Array)).toBe(true);
935
+ expect(strokes.every((operation) => (operation.lineDash as unknown[]).length === 0)).toBe(true);
936
+ expect(strokes.every((operation) => {
937
+ const path = operation.path as Array<[string, ...number[]]>;
938
+ return path.some(([command]) => command === "bezierCurveTo");
939
+ })).toBe(true);
940
+ } finally {
941
+ dom.restore();
942
+ }
943
+ });
944
+
945
+ test("runtime keeps diagnostic stdout visible when output is not a surface frame", async () => {
946
+ const dom = installFakeDOM();
947
+ try {
948
+ const bridge = new BrowserWASIBridge({
949
+ sceneId: "main",
950
+ columns: 4,
951
+ rows: 2,
952
+ });
953
+ const mount = new FakeElement("div");
954
+ const runtime = new WebHostSceneRuntime({
955
+ mount: mount as unknown as HTMLElement,
956
+ descriptor: { id: "main", title: "Main", isDefault: true },
957
+ style: {},
958
+ bridge,
959
+ onInput: () => {},
960
+ });
961
+
962
+ await runtime.mount();
963
+ bridge.stdout.write(encoder.encode("legacy output\n"));
964
+
965
+ const diagnostic = runtime.terminalMount.children.find(
966
+ (child) => child.className === "webhost-scene__diagnostic"
967
+ );
968
+ expect(diagnostic?.textContent).toBe("legacy output\n");
969
+ } finally {
970
+ dom.restore();
971
+ }
972
+ });
973
+
974
+ test("runtime reports frame diagnostics without rendering them as terminal text", async () => {
975
+ const dom = installFakeDOM();
976
+ try {
977
+ const bridge = new BrowserWASIBridge({
978
+ sceneId: "main",
979
+ columns: 4,
980
+ rows: 2,
981
+ });
982
+ const diagnostics: unknown[] = [];
983
+ const mount = new FakeElement("div");
984
+ const runtime = new WebHostSceneRuntime({
985
+ mount: mount as unknown as HTMLElement,
986
+ descriptor: { id: "main", title: "Main", isDefault: true },
987
+ style: {},
988
+ bridge,
989
+ onInput: () => {},
990
+ onFrameDiagnostic: (diagnostic) => diagnostics.push(diagnostic),
991
+ });
992
+
993
+ await runtime.mount();
994
+ bridge.stdout.write(encoder.encode(
995
+ '\u001EframeDiagnostic:{"format":"swift-tui-frame-diagnostics-v1",'
996
+ + '"header":["frame","total_ms"],"fields":["7","14.20"]}\n'
997
+ ));
998
+
999
+ expect(diagnostics).toEqual([
1000
+ {
1001
+ format: "swift-tui-frame-diagnostics-v1",
1002
+ header: ["frame", "total_ms"],
1003
+ fields: ["7", "14.20"],
1004
+ },
1005
+ ]);
1006
+ expect(runtime.terminalMount.children.some(
1007
+ (child) => child.className === "webhost-scene__diagnostic"
1008
+ )).toBe(false);
1009
+ } finally {
1010
+ dom.restore();
1011
+ }
1012
+ });
1013
+
1014
+ test("runtime maps browser input events to web-surface messages", async () => {
1015
+ const dom = installFakeDOM();
1016
+ try {
1017
+ const inputs: string[] = [];
1018
+ const mount = new FakeElement("div");
1019
+ const runtime = new WebHostSceneRuntime({
1020
+ mount: mount as unknown as HTMLElement,
1021
+ descriptor: { id: "main", title: "Main", isDefault: true },
1022
+ style: { fontSize: 20 },
1023
+ onInput: (chunk) => {
1024
+ inputs.push(decoder.decode(chunk));
1025
+ },
1026
+ });
1027
+
1028
+ await runtime.mount();
1029
+ runtime.resize(10, 4);
1030
+
1031
+ runtime.terminalMount.dispatch("keydown", {
1032
+ key: "a",
1033
+ shiftKey: true,
1034
+ altKey: false,
1035
+ ctrlKey: true,
1036
+ metaKey: false,
1037
+ isComposing: false,
1038
+ preventDefault() {},
1039
+ });
1040
+ runtime.terminalMount.dispatch("paste", {
1041
+ clipboardData: {
1042
+ getData: () => "hello world",
1043
+ },
1044
+ preventDefault() {},
1045
+ });
1046
+ runtime.terminalMount.dispatch("pointerdown", pointerEvent({
1047
+ button: 0,
1048
+ buttons: 1,
1049
+ clientX: 25,
1050
+ clientY: 10,
1051
+ pointerId: 7,
1052
+ }));
1053
+ runtime.terminalMount.dispatch("pointermove", pointerEvent({
1054
+ buttons: 1,
1055
+ clientX: 35,
1056
+ clientY: 30,
1057
+ pointerId: 7,
1058
+ }));
1059
+ runtime.terminalMount.dispatch("wheel", {
1060
+ clientX: 35,
1061
+ clientY: 30,
1062
+ deltaX: 0,
1063
+ deltaY: 20,
1064
+ shiftKey: false,
1065
+ altKey: true,
1066
+ ctrlKey: false,
1067
+ preventDefault() {},
1068
+ });
1069
+
1070
+ expect(inputs).toEqual([
1071
+ "\u001Ekey:character:a:5\n",
1072
+ "\u001Epaste:hello%20world\n",
1073
+ "\u001Emouse:down:2.5:0.37037037037037035:primary:0:0:0\n",
1074
+ "\u001Emouse:dragged:3.5:1.1111111111111112:primary:0:0:0\n",
1075
+ "\u001Emouse:scrolled:3.5:1.1111111111111112:none:0:1:2\n",
1076
+ ]);
1077
+ } finally {
1078
+ dom.restore();
1079
+ }
1080
+ });
1081
+
1082
+ test("runtime can run as a passive embed without stealing focus or wheel scroll", async () => {
1083
+ const dom = installFakeDOM();
1084
+ try {
1085
+ const inputs: string[] = [];
1086
+ const bridge = new BrowserWASIBridge({
1087
+ sceneId: "main",
1088
+ columns: 4,
1089
+ rows: 2,
1090
+ });
1091
+ const mount = new FakeElement("div");
1092
+ const runtime = new WebHostSceneRuntime({
1093
+ mount: mount as unknown as HTMLElement,
1094
+ descriptor: { id: "main", title: "Main", isDefault: true },
1095
+ style: { fontSize: 20 },
1096
+ bridge,
1097
+ onInput: (chunk) => {
1098
+ inputs.push(decoder.decode(chunk));
1099
+ },
1100
+ synchronizeAccessibilityFocus: false,
1101
+ captureWheelInput: false,
1102
+ });
1103
+
1104
+ await runtime.mount();
1105
+ bridge.stdout.write(encoder.encode(surfaceRecord({
1106
+ version: 2,
1107
+ width: 4,
1108
+ height: 2,
1109
+ styles: [null],
1110
+ rows: [[], []],
1111
+ accessibilityTree: [
1112
+ {
1113
+ id: "root/button",
1114
+ rect: [0, 0, 2, 1],
1115
+ role: "button",
1116
+ label: "Save",
1117
+ isFocused: true,
1118
+ },
1119
+ ],
1120
+ })));
1121
+
1122
+ const tree = childWithClass(runtime.terminalMount, "webhost-scene__accessibility-tree");
1123
+ const button = childWithData(tree, "accessibilityId", "root/button");
1124
+ let wheelPrevented = false;
1125
+
1126
+ runtime.terminalMount.dispatch("wheel", {
1127
+ clientX: 35,
1128
+ clientY: 30,
1129
+ deltaX: 0,
1130
+ deltaY: 20,
1131
+ shiftKey: false,
1132
+ altKey: false,
1133
+ ctrlKey: false,
1134
+ preventDefault() {
1135
+ wheelPrevented = true;
1136
+ },
1137
+ });
1138
+
1139
+ expect(button.focused).toBe(false);
1140
+ expect(button.lastFocusOptions).toBeUndefined();
1141
+ expect(inputs).toEqual([]);
1142
+ expect(wheelPrevented).toBe(false);
1143
+ } finally {
1144
+ dom.restore();
1145
+ }
1146
+ });
1147
+
1148
+ test("runtime preserves pointer movement within one cell", async () => {
1149
+ const dom = installFakeDOM();
1150
+ try {
1151
+ const inputs: string[] = [];
1152
+ const mount = new FakeElement("div");
1153
+ const runtime = new WebHostSceneRuntime({
1154
+ mount: mount as unknown as HTMLElement,
1155
+ descriptor: { id: "main", title: "Main", isDefault: true },
1156
+ style: { fontSize: 20 },
1157
+ onInput: (chunk) => {
1158
+ inputs.push(decoder.decode(chunk));
1159
+ },
1160
+ });
1161
+
1162
+ await runtime.mount();
1163
+ runtime.resize(10, 4);
1164
+
1165
+ runtime.terminalMount.dispatch("pointermove", pointerEvent({
1166
+ buttons: 1,
1167
+ clientX: 21,
1168
+ clientY: 27,
1169
+ pointerId: 7,
1170
+ }));
1171
+ runtime.terminalMount.dispatch("pointermove", pointerEvent({
1172
+ buttons: 1,
1173
+ clientX: 27,
1174
+ clientY: 27,
1175
+ pointerId: 7,
1176
+ }));
1177
+
1178
+ expect(inputs).toEqual([
1179
+ "\u001Emouse:dragged:2.1:1:primary:0:0:0\n",
1180
+ "\u001Emouse:dragged:2.7:1:primary:0:0:0\n",
1181
+ ]);
1182
+ } finally {
1183
+ dom.restore();
1184
+ }
1185
+ });
1186
+
1187
+ test("runtime completes captured drags when pointerup lands outside the grid", async () => {
1188
+ const dom = installFakeDOM();
1189
+ try {
1190
+ const inputs: string[] = [];
1191
+ const mount = new FakeElement("div");
1192
+ const runtime = new WebHostSceneRuntime({
1193
+ mount: mount as unknown as HTMLElement,
1194
+ descriptor: { id: "main", title: "Main", isDefault: true },
1195
+ style: { fontSize: 20 },
1196
+ onInput: (chunk) => {
1197
+ inputs.push(decoder.decode(chunk));
1198
+ },
1199
+ });
1200
+
1201
+ await runtime.mount();
1202
+ runtime.resize(10, 4);
1203
+
1204
+ runtime.terminalMount.dispatch("pointerdown", pointerEvent({
1205
+ button: 0,
1206
+ buttons: 1,
1207
+ clientX: 25,
1208
+ clientY: 10,
1209
+ pointerId: 7,
1210
+ }));
1211
+ runtime.terminalMount.dispatch("pointermove", pointerEvent({
1212
+ buttons: 1,
1213
+ clientX: 35,
1214
+ clientY: 30,
1215
+ pointerId: 7,
1216
+ }));
1217
+ runtime.terminalMount.dispatch("pointerup", pointerEvent({
1218
+ button: 0,
1219
+ buttons: 0,
1220
+ clientX: 125,
1221
+ clientY: 30,
1222
+ pointerId: 7,
1223
+ }));
1224
+
1225
+ expect(inputs).toEqual([
1226
+ "\u001Emouse:down:2.5:0.37037037037037035:primary:0:0:0\n",
1227
+ "\u001Emouse:dragged:3.5:1.1111111111111112:primary:0:0:0\n",
1228
+ "\u001Emouse:up:12.5:1.1111111111111112:primary:0:0:0\n",
1229
+ ]);
1230
+ } finally {
1231
+ dom.restore();
1232
+ }
1233
+ });
1234
+
1235
+ function pointerEvent(
1236
+ overrides: Record<string, unknown>
1237
+ ): Record<string, unknown> {
1238
+ return {
1239
+ button: 0,
1240
+ buttons: 0,
1241
+ clientX: 0,
1242
+ clientY: 0,
1243
+ pointerId: 1,
1244
+ shiftKey: false,
1245
+ altKey: false,
1246
+ ctrlKey: false,
1247
+ preventDefault() {},
1248
+ ...overrides,
1249
+ };
1250
+ }
1251
+
1252
+ function fillTextOperations(
1253
+ context: RecordingCanvasContext,
1254
+ text: string
1255
+ ): RecordingCanvasOperation[] {
1256
+ return context.operations.filter(
1257
+ (operation) => operation.type === "fillText" && operation.text === text
1258
+ );
1259
+ }
1260
+
1261
+ function fillRectOperations(
1262
+ context: RecordingCanvasContext,
1263
+ fillStyle: string
1264
+ ): RecordingCanvasOperation[] {
1265
+ return context.operations.filter(
1266
+ (operation) => operation.type === "fillRect" && operation.fillStyle === fillStyle
1267
+ );
1268
+ }
1269
+
1270
+ function drawImageOperations(
1271
+ context: RecordingCanvasContext
1272
+ ): RecordingCanvasOperation[] {
1273
+ return context.operations.filter((operation) => operation.type === "drawImage");
1274
+ }
1275
+
1276
+ function readCanvasTextLikePixels(
1277
+ canvas: FakeCanvasElement
1278
+ ): string {
1279
+ const textSamples = new Map<string, { x: number; y: number; text: string }>();
1280
+
1281
+ for (const operation of canvas.context.operations) {
1282
+ if (operation.type === "clearRect") {
1283
+ const rect = operationRect(operation);
1284
+ if (!rect) {
1285
+ continue;
1286
+ }
1287
+ for (const [key, sample] of textSamples) {
1288
+ if (textSampleInRect(sample, rect)) {
1289
+ textSamples.delete(key);
1290
+ }
1291
+ }
1292
+ continue;
1293
+ }
1294
+
1295
+ if (operation.type !== "fillText" || typeof operation.text !== "string") {
1296
+ continue;
1297
+ }
1298
+ const x = Number(operation.x);
1299
+ const y = Number(operation.y);
1300
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
1301
+ continue;
1302
+ }
1303
+ textSamples.set(`${x}:${y}`, { x, y, text: operation.text });
1304
+ }
1305
+
1306
+ const rows = new Map<number, Array<{ x: number; text: string }>>();
1307
+ for (const sample of textSamples.values()) {
1308
+ const row = rows.get(sample.y) ?? [];
1309
+ row.push({ x: sample.x, text: sample.text });
1310
+ rows.set(sample.y, row);
1311
+ }
1312
+
1313
+ return Array.from(rows.entries())
1314
+ .sort(([lhs], [rhs]) => lhs - rhs)
1315
+ .map(([, row]) =>
1316
+ row
1317
+ .sort((lhs, rhs) => lhs.x - rhs.x)
1318
+ .map((sample) => sample.text)
1319
+ .join("")
1320
+ )
1321
+ .join("\n");
1322
+ }
1323
+
1324
+ function operationRect(
1325
+ operation: RecordingCanvasOperation
1326
+ ): { x: number; y: number; width: number; height: number } | undefined {
1327
+ const x = Number(operation.x);
1328
+ const y = Number(operation.y);
1329
+ const width = Number(operation.width);
1330
+ const height = Number(operation.height);
1331
+ if (
1332
+ !Number.isFinite(x)
1333
+ || !Number.isFinite(y)
1334
+ || !Number.isFinite(width)
1335
+ || !Number.isFinite(height)
1336
+ ) {
1337
+ return undefined;
1338
+ }
1339
+ return { x, y, width, height };
1340
+ }
1341
+
1342
+ function textSampleInRect(
1343
+ sample: { x: number; y: number },
1344
+ rect: { x: number; y: number; width: number; height: number }
1345
+ ): boolean {
1346
+ return sample.x >= rect.x
1347
+ && sample.x < rect.x + rect.width
1348
+ && sample.y >= rect.y
1349
+ && sample.y < rect.y + rect.height;
1350
+ }
1351
+
1352
+ function childWithClass(
1353
+ element: FakeElement,
1354
+ className: string
1355
+ ): FakeElement {
1356
+ const child = element.children.find((child) => child.className === className);
1357
+ if (!child) {
1358
+ throw new Error(`missing child with class ${className}`);
1359
+ }
1360
+ return child;
1361
+ }
1362
+
1363
+ function childWithData(
1364
+ element: FakeElement,
1365
+ key: string,
1366
+ value: string
1367
+ ): FakeElement {
1368
+ const child = element.children.find((child) => child.dataset[key] === value);
1369
+ if (!child) {
1370
+ throw new Error(`missing child with data-${key} ${value}`);
1371
+ }
1372
+ return child;
1373
+ }
1374
+
1375
+ async function flushPromises(): Promise<void> {
1376
+ await Promise.resolve();
1377
+ await Promise.resolve();
1378
+ }
1379
+
1380
+ function surfaceRecord(
1381
+ frame: Record<string, unknown>
1382
+ ): string {
1383
+ return `\u001Esurface:${JSON.stringify(frame)}\n`;
1384
+ }
1385
+
1386
+ interface FakeDOMOptions {
1387
+ devicePixelRatio?: number;
1388
+ createImageBitmap?: (blob: Blob) => Promise<unknown>;
1389
+ }
1390
+
1391
+ function installFakeDOM(
1392
+ options: FakeDOMOptions = {}
1393
+ ): {
1394
+ canvases: FakeCanvasElement[];
1395
+ restore(): void;
1396
+ } {
1397
+ const previousDocument = globalThis.document;
1398
+ const previousWindow = globalThis.window;
1399
+ const previousResizeObserver = globalThis.ResizeObserver;
1400
+ const previousCreateImageBitmap = globalThis.createImageBitmap;
1401
+ const canvases: FakeCanvasElement[] = [];
1402
+
1403
+ globalThis.document = {
1404
+ createElement: (tagName: string) => {
1405
+ if (tagName === "canvas") {
1406
+ const canvas = new FakeCanvasElement();
1407
+ canvases.push(canvas);
1408
+ return canvas;
1409
+ }
1410
+ return new FakeElement(tagName);
1411
+ },
1412
+ } as unknown as Document;
1413
+ globalThis.window = {
1414
+ devicePixelRatio: options.devicePixelRatio ?? 1,
1415
+ } as unknown as Window & typeof globalThis;
1416
+ globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver;
1417
+ if (options.createImageBitmap) {
1418
+ globalThis.createImageBitmap = options.createImageBitmap as typeof createImageBitmap;
1419
+ }
1420
+
1421
+ return {
1422
+ canvases,
1423
+ restore: () => {
1424
+ globalThis.document = previousDocument;
1425
+ globalThis.window = previousWindow;
1426
+ globalThis.ResizeObserver = previousResizeObserver;
1427
+ globalThis.createImageBitmap = previousCreateImageBitmap;
1428
+ },
1429
+ };
1430
+ }
1431
+
1432
+ class FakeResizeObserver {
1433
+ observe(): void {}
1434
+ disconnect(): void {}
1435
+ }
1436
+
1437
+ class FakeStyle {
1438
+ [key: string]: unknown;
1439
+
1440
+ private readonly values = new Map<string, string>();
1441
+ private readonly priorities = new Map<string, string>();
1442
+
1443
+ setProperty(
1444
+ name: string,
1445
+ value: string,
1446
+ priority?: string
1447
+ ): void {
1448
+ this.values.set(name, value);
1449
+ this.priorities.set(name, priority ?? "");
1450
+ }
1451
+
1452
+ getPropertyValue(
1453
+ name: string
1454
+ ): string {
1455
+ return this.values.get(name) ?? "";
1456
+ }
1457
+
1458
+ getPropertyPriority(
1459
+ name: string
1460
+ ): string {
1461
+ return this.priorities.get(name) ?? "";
1462
+ }
1463
+ }
1464
+
1465
+ class FakeElement {
1466
+ readonly tagName: string;
1467
+ readonly style = new FakeStyle();
1468
+ readonly dataset: Record<string, string> = {};
1469
+ readonly children: FakeElement[] = [];
1470
+ private readonly eventListeners = new Map<string, Set<(event: Record<string, unknown>) => void>>();
1471
+ private readonly attributes = new Map<string, string>();
1472
+
1473
+ className = "";
1474
+ id = "";
1475
+ hidden = false;
1476
+ focused = false;
1477
+ lastFocusOptions: FocusOptions | undefined;
1478
+ tabIndex = 0;
1479
+ textContent = "";
1480
+ rect = {
1481
+ left: 0,
1482
+ top: 0,
1483
+ width: 100,
1484
+ height: 108,
1485
+ right: 100,
1486
+ bottom: 108,
1487
+ };
1488
+
1489
+ constructor(tagName: string) {
1490
+ this.tagName = tagName.toUpperCase();
1491
+ }
1492
+
1493
+ append(
1494
+ ...children: FakeElement[]
1495
+ ): void {
1496
+ this.children.push(...children);
1497
+ }
1498
+
1499
+ appendChild(
1500
+ child: FakeElement
1501
+ ): FakeElement {
1502
+ this.children.push(child);
1503
+ return child;
1504
+ }
1505
+
1506
+ replaceChildren(
1507
+ ...children: FakeElement[]
1508
+ ): void {
1509
+ this.children.splice(0, this.children.length, ...children);
1510
+ }
1511
+
1512
+ remove(): void {}
1513
+ focus(options?: FocusOptions): void {
1514
+ this.focused = true;
1515
+ this.lastFocusOptions = options;
1516
+ }
1517
+ setPointerCapture(): void {}
1518
+ releasePointerCapture(): void {}
1519
+
1520
+ setAttribute(
1521
+ name: string,
1522
+ value: string
1523
+ ): void {
1524
+ this.attributes.set(name, value);
1525
+ }
1526
+
1527
+ getAttribute(
1528
+ name: string
1529
+ ): string | null {
1530
+ return this.attributes.get(name) ?? null;
1531
+ }
1532
+
1533
+ getBoundingClientRect(): typeof this.rect {
1534
+ return this.rect;
1535
+ }
1536
+
1537
+ addEventListener(
1538
+ type: string,
1539
+ listener: (event: Record<string, unknown>) => void
1540
+ ): void {
1541
+ let listeners = this.eventListeners.get(type);
1542
+ if (!listeners) {
1543
+ listeners = new Set();
1544
+ this.eventListeners.set(type, listeners);
1545
+ }
1546
+ listeners.add(listener);
1547
+ }
1548
+
1549
+ removeEventListener(
1550
+ type: string,
1551
+ listener: (event: Record<string, unknown>) => void
1552
+ ): void {
1553
+ this.eventListeners.get(type)?.delete(listener);
1554
+ }
1555
+
1556
+ dispatch(
1557
+ type: string,
1558
+ event: Record<string, unknown>
1559
+ ): void {
1560
+ for (const listener of this.eventListeners.get(type) ?? []) {
1561
+ listener(event);
1562
+ }
1563
+ }
1564
+ }
1565
+
1566
+ class FakeCanvasElement extends FakeElement {
1567
+ readonly context = new RecordingCanvasContext();
1568
+ width = 0;
1569
+ height = 0;
1570
+
1571
+ constructor() {
1572
+ super("canvas");
1573
+ this.rect = {
1574
+ left: 0,
1575
+ top: 0,
1576
+ width: 100,
1577
+ height: 108,
1578
+ right: 100,
1579
+ bottom: 108,
1580
+ };
1581
+ }
1582
+
1583
+ getContext(
1584
+ contextId: string
1585
+ ): RecordingCanvasContext | undefined {
1586
+ return contextId === "2d" ? this.context : undefined;
1587
+ }
1588
+ }
1589
+
1590
+ type RecordingCanvasOperation = Record<string, unknown>;
1591
+
1592
+ class RecordingCanvasContext {
1593
+ operations: RecordingCanvasOperation[] = [];
1594
+ fillStyle = "";
1595
+ strokeStyle = "";
1596
+ font = "";
1597
+ textBaseline = "";
1598
+ globalAlpha = 1;
1599
+ lineWidth = 1;
1600
+ lineCap = "butt";
1601
+
1602
+ private lineDash: number[] = [];
1603
+ private path: Array<[string, ...number[]]> = [];
1604
+
1605
+ measureText(
1606
+ text: string
1607
+ ): { width: number } {
1608
+ return { width: Math.max(1, Array.from(text).length) * 10 };
1609
+ }
1610
+
1611
+ setTransform(
1612
+ a: number,
1613
+ b: number,
1614
+ c: number,
1615
+ d: number,
1616
+ e: number,
1617
+ f: number
1618
+ ): void {
1619
+ this.operations.push({ type: "setTransform", a, b, c, d, e, f });
1620
+ }
1621
+
1622
+ clearRect(
1623
+ x: number,
1624
+ y: number,
1625
+ width: number,
1626
+ height: number
1627
+ ): void {
1628
+ this.operations.push({ type: "clearRect", x, y, width, height });
1629
+ }
1630
+
1631
+ fillRect(
1632
+ x: number,
1633
+ y: number,
1634
+ width: number,
1635
+ height: number
1636
+ ): void {
1637
+ this.operations.push({
1638
+ type: "fillRect",
1639
+ x,
1640
+ y,
1641
+ width,
1642
+ height,
1643
+ fillStyle: this.fillStyle,
1644
+ globalAlpha: this.globalAlpha,
1645
+ });
1646
+ }
1647
+
1648
+ fillText(
1649
+ text: string,
1650
+ x: number,
1651
+ y: number
1652
+ ): void {
1653
+ this.operations.push({
1654
+ type: "fillText",
1655
+ text,
1656
+ x,
1657
+ y,
1658
+ fillStyle: this.fillStyle,
1659
+ font: this.font,
1660
+ globalAlpha: this.globalAlpha,
1661
+ });
1662
+ }
1663
+
1664
+ beginPath(): void {
1665
+ this.path = [];
1666
+ }
1667
+
1668
+ save(): void {
1669
+ this.operations.push({ type: "save" });
1670
+ }
1671
+
1672
+ restore(): void {
1673
+ this.operations.push({ type: "restore" });
1674
+ }
1675
+
1676
+ rect(
1677
+ x: number,
1678
+ y: number,
1679
+ width: number,
1680
+ height: number
1681
+ ): void {
1682
+ this.path.push(["rect", x, y, width, height]);
1683
+ this.operations.push({ type: "rect", x, y, width, height });
1684
+ }
1685
+
1686
+ clip(): void {
1687
+ this.operations.push({
1688
+ type: "clip",
1689
+ path: [...this.path],
1690
+ });
1691
+ }
1692
+
1693
+ drawImage(
1694
+ image: unknown,
1695
+ x: number,
1696
+ y: number,
1697
+ width: number,
1698
+ height: number
1699
+ ): void {
1700
+ this.operations.push({
1701
+ type: "drawImage",
1702
+ imageId: image && typeof image === "object" && "imageId" in image
1703
+ ? (image as { imageId: unknown }).imageId
1704
+ : undefined,
1705
+ x,
1706
+ y,
1707
+ width,
1708
+ height,
1709
+ });
1710
+ }
1711
+
1712
+ moveTo(
1713
+ x: number,
1714
+ y: number
1715
+ ): void {
1716
+ this.path.push(["moveTo", x, y]);
1717
+ }
1718
+
1719
+ lineTo(
1720
+ x: number,
1721
+ y: number
1722
+ ): void {
1723
+ this.path.push(["lineTo", x, y]);
1724
+ }
1725
+
1726
+ bezierCurveTo(
1727
+ control1X: number,
1728
+ control1Y: number,
1729
+ control2X: number,
1730
+ control2Y: number,
1731
+ x: number,
1732
+ y: number
1733
+ ): void {
1734
+ this.path.push(["bezierCurveTo", control1X, control1Y, control2X, control2Y, x, y]);
1735
+ }
1736
+
1737
+ stroke(): void {
1738
+ this.operations.push({
1739
+ type: "stroke",
1740
+ strokeStyle: this.strokeStyle,
1741
+ lineWidth: this.lineWidth,
1742
+ lineDash: [...this.lineDash],
1743
+ path: [...this.path],
1744
+ });
1745
+ }
1746
+
1747
+ setLineDash(
1748
+ lineDash: number[]
1749
+ ): void {
1750
+ this.lineDash = [...lineDash];
1751
+ }
1752
+ }