@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,362 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
WebHostOutputDecoder,
|
|
5
|
+
encodeMouseInputMessage,
|
|
6
|
+
type WebHostOutputRecord,
|
|
7
|
+
type WebHostSurfaceFrame,
|
|
8
|
+
} from "./WebHostSurfaceTransport.ts";
|
|
9
|
+
import { transportFixture } from "./WebHostTestFixtures.ts";
|
|
10
|
+
|
|
11
|
+
const encoder = new TextEncoder();
|
|
12
|
+
const decoder = new TextDecoder();
|
|
13
|
+
|
|
14
|
+
test("decoder reads shared web-surface fixtures across chunk boundaries", () => {
|
|
15
|
+
const decoder = new WebHostOutputDecoder();
|
|
16
|
+
const fixture = transportFixture("web-surface-basic");
|
|
17
|
+
const split = Math.floor(fixture.length / 2);
|
|
18
|
+
|
|
19
|
+
expect(decoder.feed(encoder.encode(fixture.slice(0, split)))).toEqual([]);
|
|
20
|
+
|
|
21
|
+
const records = decoder.feed(encoder.encode(fixture.slice(split)));
|
|
22
|
+
expect(records).toHaveLength(1);
|
|
23
|
+
expect(records[0]?.type).toBe("surface");
|
|
24
|
+
|
|
25
|
+
const frame = surfaceFrame(records[0]);
|
|
26
|
+
expect(frame.width).toBe(2);
|
|
27
|
+
expect(frame.height).toBe(2);
|
|
28
|
+
expect(frame.styles).toEqual([null]);
|
|
29
|
+
expect(frame.rows[0]).toEqual([[0, "O", 1, 0], [1, "K", 1, 0]]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("decoder returns multiple records from one stdout chunk", () => {
|
|
33
|
+
const decoder = new WebHostOutputDecoder();
|
|
34
|
+
const records = decoder.feed(
|
|
35
|
+
encoder.encode(
|
|
36
|
+
transportFixture("web-surface-basic")
|
|
37
|
+
+ "legacy text\n"
|
|
38
|
+
+ transportFixture("web-surface-styled")
|
|
39
|
+
)
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
expect(records.map((record) => record.type)).toEqual(["surface", "text", "surface"]);
|
|
43
|
+
expect(records[1]).toEqual({ type: "text", text: "legacy text\n" });
|
|
44
|
+
expect(surfaceFrame(records[2]).styles).toHaveLength(4);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("decoder preserves typed image records", () => {
|
|
48
|
+
const decoder = new WebHostOutputDecoder();
|
|
49
|
+
const records = decoder.feed(encoder.encode(
|
|
50
|
+
'\u001Esurface:{"version":1,"width":2,"height":1,"styles":[null],"rows":[[]],'
|
|
51
|
+
+ '"images":[{"id":"png:test","format":"png","bounds":[0,0,1,1],'
|
|
52
|
+
+ '"visibleBounds":[0,0,1,1],"scalingMode":"stretch","pixelSize":[1,1],'
|
|
53
|
+
+ '"dataBase64":"iVBORw=="}]}\n'
|
|
54
|
+
));
|
|
55
|
+
|
|
56
|
+
const frame = surfaceFrame(records[0]);
|
|
57
|
+
expect(frame.images).toEqual([
|
|
58
|
+
{
|
|
59
|
+
id: "png:test",
|
|
60
|
+
format: "png",
|
|
61
|
+
bounds: [0, 0, 1, 1],
|
|
62
|
+
visibleBounds: [0, 0, 1, 1],
|
|
63
|
+
scalingMode: "stretch",
|
|
64
|
+
pixelSize: [1, 1],
|
|
65
|
+
dataBase64: "iVBORw==",
|
|
66
|
+
},
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("decoder preserves presentation damage records", () => {
|
|
71
|
+
const decoder = new WebHostOutputDecoder();
|
|
72
|
+
const records = decoder.feed(encoder.encode(
|
|
73
|
+
'\u001Esurface:{"version":1,"width":2,"height":2,"styles":[null],"rows":[[],[]],'
|
|
74
|
+
+ '"images":[],"damage":{"textRows":[[1,[[0,2]]]],'
|
|
75
|
+
+ '"requiresFullTextRepaint":false,"requiresFullGraphicsReplay":false}}\n'
|
|
76
|
+
));
|
|
77
|
+
|
|
78
|
+
const frame = surfaceFrame(records[0]);
|
|
79
|
+
expect(frame.damage).toEqual({
|
|
80
|
+
textRows: [[1, [[0, 2]]]],
|
|
81
|
+
requiresFullTextRepaint: false,
|
|
82
|
+
requiresFullGraphicsReplay: false,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("decoder accepts v2 accessibility trees", () => {
|
|
87
|
+
const decoder = new WebHostOutputDecoder();
|
|
88
|
+
const records = decoder.feed(encoder.encode(
|
|
89
|
+
'\u001Esurface:{"version":2,"sequence":9,"width":2,"height":1,'
|
|
90
|
+
+ '"styles":[null],"rows":[[]],'
|
|
91
|
+
+ '"accessibilityTree":[{"id":"root/button","parentId":"root","rect":[0,0,2,1],'
|
|
92
|
+
+ '"role":"button","label":"Save","hint":"Writes the file",'
|
|
93
|
+
+ '"cursorAnchor":[1,0],"isFocused":true},'
|
|
94
|
+
+ '{"id":"root/status","rect":[0,1,2,1],"role":"status","label":"Saved",'
|
|
95
|
+
+ '"liveRegion":"polite","isFocused":false}]}\n'
|
|
96
|
+
));
|
|
97
|
+
|
|
98
|
+
const frame = surfaceFrame(records[0]);
|
|
99
|
+
expect(frame.version).toBe(2);
|
|
100
|
+
expect(frame.sequence).toBe(9);
|
|
101
|
+
expect(frame.accessibilityTree).toEqual([
|
|
102
|
+
{
|
|
103
|
+
id: "root/button",
|
|
104
|
+
parentId: "root",
|
|
105
|
+
rect: [0, 0, 2, 1],
|
|
106
|
+
role: "button",
|
|
107
|
+
label: "Save",
|
|
108
|
+
hint: "Writes the file",
|
|
109
|
+
cursorAnchor: [1, 0],
|
|
110
|
+
isFocused: true,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: "root/status",
|
|
114
|
+
rect: [0, 1, 2, 1],
|
|
115
|
+
role: "status",
|
|
116
|
+
label: "Saved",
|
|
117
|
+
liveRegion: "polite",
|
|
118
|
+
isFocused: false,
|
|
119
|
+
},
|
|
120
|
+
]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("decoder accepts imperative accessibility announcements", () => {
|
|
124
|
+
const decoder = new WebHostOutputDecoder();
|
|
125
|
+
const records = decoder.feed(encoder.encode(
|
|
126
|
+
'\u001Esurface:{"version":2,"width":2,"height":1,"styles":[null],"rows":[[]],'
|
|
127
|
+
+ '"accessibilityAnnouncements":[{"message":"Saved","politeness":"assertive"},'
|
|
128
|
+
+ '{"message":"Queued","politeness":"polite"}]}\n'
|
|
129
|
+
));
|
|
130
|
+
|
|
131
|
+
const frame = surfaceFrame(records[0]);
|
|
132
|
+
expect(frame.accessibilityAnnouncements).toEqual([
|
|
133
|
+
{ message: "Saved", politeness: "assertive" },
|
|
134
|
+
{ message: "Queued", politeness: "polite" },
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("decoder rejects malformed accessibility trees as diagnostic text", () => {
|
|
139
|
+
const decoder = new WebHostOutputDecoder();
|
|
140
|
+
const line = '\u001Esurface:{"version":2,"width":2,"height":1,"styles":[null],"rows":[[]],'
|
|
141
|
+
+ '"accessibilityTree":[{"id":"missing-rect","role":"button"}]}\n';
|
|
142
|
+
|
|
143
|
+
expect(decoder.feed(encoder.encode(line))).toEqual([
|
|
144
|
+
{
|
|
145
|
+
type: "text",
|
|
146
|
+
text: line,
|
|
147
|
+
},
|
|
148
|
+
]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("decoder keeps malformed surface output visible as text", () => {
|
|
152
|
+
const decoder = new WebHostOutputDecoder();
|
|
153
|
+
const records = decoder.feed(encoder.encode('\u001Esurface:{"version":1,"width":2}\n'));
|
|
154
|
+
|
|
155
|
+
expect(records).toEqual([
|
|
156
|
+
{
|
|
157
|
+
type: "text",
|
|
158
|
+
text: '\u001Esurface:{"version":1,"width":2}\n',
|
|
159
|
+
},
|
|
160
|
+
]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("decoder reads typed clipboard records", () => {
|
|
164
|
+
const decoder = new WebHostOutputDecoder();
|
|
165
|
+
const records = decoder.feed(encoder.encode('\u001Eclipboard:{"text":"copy \\"this\\""}\n'));
|
|
166
|
+
|
|
167
|
+
expect(records).toEqual([
|
|
168
|
+
{
|
|
169
|
+
type: "clipboard",
|
|
170
|
+
text: 'copy "this"',
|
|
171
|
+
},
|
|
172
|
+
]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("decoder reads typed runtime issue records", () => {
|
|
176
|
+
const decoder = new WebHostOutputDecoder();
|
|
177
|
+
const records = decoder.feed(encoder.encode(
|
|
178
|
+
'\u001EruntimeIssue:{"severity":"warning","code":"toolbar.unhostedItems",'
|
|
179
|
+
+ '"message":"Toolbar item was not rendered",'
|
|
180
|
+
+ '"description":"SwiftTUI runtime warning [toolbar.unhostedItems] Toolbar item was not rendered",'
|
|
181
|
+
+ '"identity":"root/body","source":".toolbarItem(...)"}\n'
|
|
182
|
+
));
|
|
183
|
+
|
|
184
|
+
expect(records).toEqual([
|
|
185
|
+
{
|
|
186
|
+
type: "runtimeIssue",
|
|
187
|
+
issue: {
|
|
188
|
+
severity: "warning",
|
|
189
|
+
code: "toolbar.unhostedItems",
|
|
190
|
+
message: "Toolbar item was not rendered",
|
|
191
|
+
description: "SwiftTUI runtime warning [toolbar.unhostedItems] Toolbar item was not rendered",
|
|
192
|
+
identity: "root/body",
|
|
193
|
+
source: ".toolbarItem(...)",
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
]);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("decoder keeps malformed runtime issue records visible as text", () => {
|
|
200
|
+
const decoder = new WebHostOutputDecoder();
|
|
201
|
+
const line = '\u001EruntimeIssue:{"severity":"warning","code":"toolbar.unhostedItems",'
|
|
202
|
+
+ '"message":7,'
|
|
203
|
+
+ '"description":"SwiftTUI runtime warning [toolbar.unhostedItems] Toolbar item was not rendered"}\n';
|
|
204
|
+
|
|
205
|
+
expect(decoder.feed(encoder.encode(line))).toEqual([
|
|
206
|
+
{
|
|
207
|
+
type: "text",
|
|
208
|
+
text: line,
|
|
209
|
+
},
|
|
210
|
+
]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("decoder reads typed frame diagnostic records", () => {
|
|
214
|
+
const decoder = new WebHostOutputDecoder();
|
|
215
|
+
const records = decoder.feed(encoder.encode(
|
|
216
|
+
'\u001EframeDiagnostic:{"format":"swift-tui-frame-diagnostics-v1",'
|
|
217
|
+
+ '"header":["frame","total_ms"],"fields":["7","14.20"]}\n'
|
|
218
|
+
));
|
|
219
|
+
|
|
220
|
+
expect(records).toEqual([
|
|
221
|
+
{
|
|
222
|
+
type: "frameDiagnostic",
|
|
223
|
+
diagnostic: {
|
|
224
|
+
format: "swift-tui-frame-diagnostics-v1",
|
|
225
|
+
header: ["frame", "total_ms"],
|
|
226
|
+
fields: ["7", "14.20"],
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("decoder keeps malformed frame diagnostic records visible as text", () => {
|
|
233
|
+
const decoder = new WebHostOutputDecoder();
|
|
234
|
+
const line = '\u001EframeDiagnostic:{"format":"swift-tui-frame-diagnostics-v1",'
|
|
235
|
+
+ '"header":["frame"],"fields":[7]}\n';
|
|
236
|
+
|
|
237
|
+
expect(decoder.feed(encoder.encode(line))).toEqual([
|
|
238
|
+
{
|
|
239
|
+
type: "text",
|
|
240
|
+
text: line,
|
|
241
|
+
},
|
|
242
|
+
]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("decoder materializes delta surface frames from a full baseline", () => {
|
|
246
|
+
const decoder = new WebHostOutputDecoder();
|
|
247
|
+
const records = decoder.feed(encoder.encode(
|
|
248
|
+
'\u001Esurface:{"version":2,"width":2,"height":2,"styles":[null],'
|
|
249
|
+
+ '"rows":[[[0,"A",1,0]],[[0,"B",1,0]]],"images":[]}\n'
|
|
250
|
+
+ '\u001Esurface:{"version":3,"encoding":"delta","width":2,"height":2,'
|
|
251
|
+
+ '"sequence":7,"styles":[null],"deltaRows":[[1,[[0,"C",1,0]]]],"images":[],'
|
|
252
|
+
+ '"damage":{"textRows":[[1,[[0,1]]]],'
|
|
253
|
+
+ '"requiresFullTextRepaint":false,"requiresFullGraphicsReplay":false}}\n'
|
|
254
|
+
));
|
|
255
|
+
|
|
256
|
+
expect(records.map((record) => record.type)).toEqual(["surface", "surface"]);
|
|
257
|
+
expect(surfaceFrame(records[1])).toEqual({
|
|
258
|
+
version: 2,
|
|
259
|
+
sequence: 7,
|
|
260
|
+
width: 2,
|
|
261
|
+
height: 2,
|
|
262
|
+
styles: [null],
|
|
263
|
+
rows: [
|
|
264
|
+
[[0, "A", 1, 0]],
|
|
265
|
+
[[0, "C", 1, 0]],
|
|
266
|
+
],
|
|
267
|
+
images: [],
|
|
268
|
+
damage: {
|
|
269
|
+
textRows: [[1, [[0, 1]]]],
|
|
270
|
+
requiresFullTextRepaint: false,
|
|
271
|
+
requiresFullGraphicsReplay: false,
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("decoder keeps delta surface output before any full baseline visible as text", () => {
|
|
277
|
+
const decoder = new WebHostOutputDecoder();
|
|
278
|
+
const line = '\u001Esurface:{"version":3,"encoding":"delta","width":2,"height":2,'
|
|
279
|
+
+ '"styles":[null],"deltaRows":[[1,[[0,"C",1,0]]]],"images":[]}\n';
|
|
280
|
+
|
|
281
|
+
expect(decoder.feed(encoder.encode(line))).toEqual([
|
|
282
|
+
{
|
|
283
|
+
type: "text",
|
|
284
|
+
text: line,
|
|
285
|
+
},
|
|
286
|
+
]);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("decoder keeps delta surface output with changed dimensions visible as text", () => {
|
|
290
|
+
const decoder = new WebHostOutputDecoder();
|
|
291
|
+
const baseline = '\u001Esurface:{"version":2,"width":2,"height":2,"styles":[null],'
|
|
292
|
+
+ '"rows":[[[0,"A",1,0]],[[0,"B",1,0]]]}\n';
|
|
293
|
+
const delta = '\u001Esurface:{"version":3,"encoding":"delta","width":3,"height":2,'
|
|
294
|
+
+ '"styles":[null],"deltaRows":[[1,[[0,"C",1,0]]]],"images":[]}\n';
|
|
295
|
+
|
|
296
|
+
const records = decoder.feed(encoder.encode(baseline + delta));
|
|
297
|
+
|
|
298
|
+
expect(records.map((record) => record.type)).toEqual(["surface", "text"]);
|
|
299
|
+
expect(records[1]).toEqual({ type: "text", text: delta });
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("decoder keeps delta surface output with out-of-range row indexes visible as text", () => {
|
|
303
|
+
const decoder = new WebHostOutputDecoder();
|
|
304
|
+
const baseline = '\u001Esurface:{"version":2,"width":2,"height":2,"styles":[null],'
|
|
305
|
+
+ '"rows":[[[0,"A",1,0]],[[0,"B",1,0]]]}\n';
|
|
306
|
+
const delta = '\u001Esurface:{"version":3,"encoding":"delta","width":2,"height":2,'
|
|
307
|
+
+ '"styles":[null],"deltaRows":[[2,[[0,"C",1,0]]]],"images":[]}\n';
|
|
308
|
+
|
|
309
|
+
const records = decoder.feed(encoder.encode(baseline + delta));
|
|
310
|
+
|
|
311
|
+
expect(records.map((record) => record.type)).toEqual(["surface", "text"]);
|
|
312
|
+
expect(records[1]).toEqual({ type: "text", text: delta });
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("decoder keeps delta surface output with malformed cells visible as text", () => {
|
|
316
|
+
const decoder = new WebHostOutputDecoder();
|
|
317
|
+
const baseline = '\u001Esurface:{"version":2,"width":2,"height":2,"styles":[null],'
|
|
318
|
+
+ '"rows":[[[0,"A",1,0]],[[0,"B",1,0]]]}\n';
|
|
319
|
+
const delta = '\u001Esurface:{"version":3,"encoding":"delta","width":2,"height":2,'
|
|
320
|
+
+ '"styles":[null],"deltaRows":[[1,[["not-a-cell"],[1,"C",1,"bad-style"]]]],"images":[]}\n';
|
|
321
|
+
|
|
322
|
+
const records = decoder.feed(encoder.encode(baseline + delta));
|
|
323
|
+
|
|
324
|
+
expect(records.map((record) => record.type)).toEqual(["surface", "text"]);
|
|
325
|
+
expect(records[1]).toEqual({ type: "text", text: delta });
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("decoder flushes partial buffered text as diagnostic output", () => {
|
|
329
|
+
const decoder = new WebHostOutputDecoder();
|
|
330
|
+
|
|
331
|
+
expect(decoder.feed(encoder.encode("partial diagnostic"))).toEqual([]);
|
|
332
|
+
expect(decoder.flush()).toEqual([
|
|
333
|
+
{
|
|
334
|
+
type: "text",
|
|
335
|
+
text: "partial diagnostic\n",
|
|
336
|
+
},
|
|
337
|
+
]);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("mouse input encoder preserves fractional cell coordinates", () => {
|
|
341
|
+
expect(decoder.decode(encodeMouseInputMessage({
|
|
342
|
+
kind: "dragged",
|
|
343
|
+
x: 2.125,
|
|
344
|
+
y: 1.75,
|
|
345
|
+
button: "primary",
|
|
346
|
+
}))).toBe("\u001Emouse:dragged:2.125:1.75:primary:0:0:0\n");
|
|
347
|
+
|
|
348
|
+
expect(decoder.decode(encodeMouseInputMessage({
|
|
349
|
+
kind: "moved",
|
|
350
|
+
x: -0.25,
|
|
351
|
+
y: 99.5,
|
|
352
|
+
}))).toBe("\u001Emouse:moved:-0.25:99.5:none:0:0:0\n");
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
function surfaceFrame(
|
|
356
|
+
record: WebHostOutputRecord | undefined
|
|
357
|
+
): WebHostSurfaceFrame {
|
|
358
|
+
if (record?.type !== "surface") {
|
|
359
|
+
throw new Error(`expected surface record, got ${record?.type ?? "undefined"}`);
|
|
360
|
+
}
|
|
361
|
+
return record.frame;
|
|
362
|
+
}
|