@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.
- package/AGENTS.md +52 -0
- package/README.md +116 -0
- package/cli.ts +168 -0
- package/index.html +50 -0
- package/index.ts +8 -0
- package/manifest.ts +1 -0
- package/package.json +33 -0
- package/src/AccessibilityTree.ts +262 -0
- package/src/BoxDrawingRenderer.ts +585 -0
- package/src/PublicEntrypointBoundary.test.ts +20 -0
- package/src/WebHostApp.test.ts +222 -0
- package/src/WebHostApp.ts +269 -0
- package/src/WebHostSceneManifest.test.ts +38 -0
- package/src/WebHostSceneManifest.ts +156 -0
- package/src/WebHostSceneRuntime.test.ts +1752 -0
- package/src/WebHostSceneRuntime.ts +955 -0
- package/src/WebHostSurfaceTransport.test.ts +362 -0
- package/src/WebHostSurfaceTransport.ts +648 -0
- package/src/WebHostTerminalStyle.test.ts +123 -0
- package/src/WebHostTerminalStyle.ts +471 -0
- package/src/WebHostTestFixtures.ts +10 -0
- package/src/WebSocketSceneBridge.test.ts +198 -0
- package/src/WebSocketSceneBridge.ts +233 -0
- package/src/browser.ts +59 -0
- package/src/wasi/BrowserWASIBridge.test.ts +168 -0
- package/src/wasi/BrowserWASIBridge.ts +167 -0
- package/src/wasi/SharedInputQueue.test.ts +146 -0
- package/src/wasi/SharedInputQueue.ts +199 -0
- package/src/wasi/StdIOPipe.ts +72 -0
- package/src/wasi/WasiPollScheduler.test.ts +176 -0
- package/src/wasi/WasiPollScheduler.ts +305 -0
- package/src/wasi/WasmSceneRuntime.ts +205 -0
- package/src/wasi/WasmSceneWorker.ts +182 -0
- package/style.css +15 -0
- package/testing.ts +1 -0
- package/tsconfig.json +29 -0
- package/wasi-worker.ts +1 -0
- package/wasi.ts +4 -0
- 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
|
+
}
|