@swifttui/web 0.0.13 → 0.0.15

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