as-test 1.0.16 → 1.1.0
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/CHANGELOG.md +48 -0
- package/README.md +45 -4
- package/as-test.config.schema.json +5 -0
- package/assembly/util/wipc.ts +11 -4
- package/bin/commands/clean-core.js +92 -0
- package/bin/commands/clean.js +6 -0
- package/bin/commands/init-core.js +33 -225
- package/bin/commands/run-core.js +433 -289
- package/bin/commands/web-runner-source.js +14 -700
- package/bin/commands/web-session.js +1144 -0
- package/bin/index.js +390 -78
- package/bin/types.js +1 -0
- package/bin/util.js +16 -1
- package/lib/build/index.d.ts +1 -0
- package/lib/build/index.js +1098 -0
- package/lib/build/web-runner/client.d.ts +1 -0
- package/lib/build/web-runner/client.js +167 -0
- package/lib/build/web-runner/html.d.ts +1 -0
- package/lib/build/web-runner/html.js +201 -0
- package/lib/build/web-runner/worker.d.ts +1 -0
- package/lib/build/web-runner/worker.js +271 -0
- package/lib/src/index.ts +1248 -0
- package/package.json +14 -4
- package/transform/lib/mock.js +50 -27
|
@@ -0,0 +1,1144 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { createHash } from "crypto";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import http from "http";
|
|
5
|
+
import path from "path";
|
|
6
|
+
const WEB_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
7
|
+
export class PersistentWebSessionHost {
|
|
8
|
+
constructor(headless) {
|
|
9
|
+
this.headless = headless;
|
|
10
|
+
this.html = buildWebSessionHtml();
|
|
11
|
+
this.client = buildWebSessionClientSource();
|
|
12
|
+
this.worker = buildWebSessionWorkerSource();
|
|
13
|
+
this.server = http.createServer((req, res) => this.onRequest(req, res));
|
|
14
|
+
this.browserProcess = null;
|
|
15
|
+
this.ownsBrowserProcess = false;
|
|
16
|
+
this.serverSockets = new Set();
|
|
17
|
+
this.wsSocket = null;
|
|
18
|
+
this.wsBuffer = Buffer.alloc(0);
|
|
19
|
+
this.ready = false;
|
|
20
|
+
this.closed = false;
|
|
21
|
+
this.currentJob = null;
|
|
22
|
+
this.nextJobId = 1;
|
|
23
|
+
this.closeError = null;
|
|
24
|
+
this.readyResolve = null;
|
|
25
|
+
this.readyReject = null;
|
|
26
|
+
this.terminalHooksEnabled = Boolean(process.stdin.isTTY);
|
|
27
|
+
this.onTerminalClosed = () => {
|
|
28
|
+
void this.exitFromTerminalClosure();
|
|
29
|
+
};
|
|
30
|
+
this.onTerminalHangup = () => {
|
|
31
|
+
void this.exitFromTerminalClosure();
|
|
32
|
+
};
|
|
33
|
+
this.readyPromise = new Promise((resolve, reject) => {
|
|
34
|
+
this.readyResolve = resolve;
|
|
35
|
+
this.readyReject = reject;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
static async start(headless) {
|
|
39
|
+
const host = new PersistentWebSessionHost(headless);
|
|
40
|
+
await host.listen();
|
|
41
|
+
return host;
|
|
42
|
+
}
|
|
43
|
+
async runJob(env, label, onBinary) {
|
|
44
|
+
await this.readyPromise;
|
|
45
|
+
if (this.closed) {
|
|
46
|
+
throw this.closeError ?? new Error("web session host is already closed");
|
|
47
|
+
}
|
|
48
|
+
if (this.currentJob) {
|
|
49
|
+
throw new Error("web session host already has an active job");
|
|
50
|
+
}
|
|
51
|
+
const wasmPath = env.AS_TEST_WASM_PATH || "";
|
|
52
|
+
if (!wasmPath.length) {
|
|
53
|
+
throw new Error("AS_TEST_WASM_PATH is not set for web session job");
|
|
54
|
+
}
|
|
55
|
+
const helperPath = env.AS_TEST_HELPER_PATH?.length
|
|
56
|
+
? env.AS_TEST_HELPER_PATH
|
|
57
|
+
: null;
|
|
58
|
+
const jobId = String(this.nextJobId++);
|
|
59
|
+
const browserEnv = {
|
|
60
|
+
...env,
|
|
61
|
+
AS_TEST_WASM_PATH: `/job/${jobId}/${path.basename(wasmPath)}`,
|
|
62
|
+
};
|
|
63
|
+
if (helperPath) {
|
|
64
|
+
browserEnv.AS_TEST_HELPER_PATH = `/job/${jobId}/${path.basename(helperPath)}`;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
delete browserEnv.AS_TEST_HELPER_PATH;
|
|
68
|
+
}
|
|
69
|
+
await new Promise((resolve, reject) => {
|
|
70
|
+
this.currentJob = {
|
|
71
|
+
id: jobId,
|
|
72
|
+
env: browserEnv,
|
|
73
|
+
label,
|
|
74
|
+
wasmPath,
|
|
75
|
+
helperPath,
|
|
76
|
+
onBinary,
|
|
77
|
+
resolve: () => {
|
|
78
|
+
this.currentJob = null;
|
|
79
|
+
resolve();
|
|
80
|
+
},
|
|
81
|
+
reject: (error) => {
|
|
82
|
+
this.currentJob = null;
|
|
83
|
+
reject(error);
|
|
84
|
+
},
|
|
85
|
+
started: false,
|
|
86
|
+
};
|
|
87
|
+
this.sendControl({ kind: "load", env: browserEnv, label });
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
sendReply(frame) {
|
|
91
|
+
if (!this.wsSocket || !frame.length)
|
|
92
|
+
return;
|
|
93
|
+
sendWebSocketFrame(this.wsSocket, 0x2, frame);
|
|
94
|
+
}
|
|
95
|
+
async close(reason) {
|
|
96
|
+
if (this.closed)
|
|
97
|
+
return;
|
|
98
|
+
this.closed = true;
|
|
99
|
+
this.removeTerminalHooks();
|
|
100
|
+
const closeError = reason ?? new Error("web session host closed");
|
|
101
|
+
this.closeError = closeError;
|
|
102
|
+
if (!this.ready) {
|
|
103
|
+
this.readyReject?.(closeError);
|
|
104
|
+
this.readyResolve = null;
|
|
105
|
+
this.readyReject = null;
|
|
106
|
+
}
|
|
107
|
+
if (this.currentJob) {
|
|
108
|
+
this.currentJob.reject(closeError);
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
this.sendControl({
|
|
112
|
+
kind: "shutdown",
|
|
113
|
+
ok: reason == null,
|
|
114
|
+
message: reason?.message ?? "",
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
catch { }
|
|
118
|
+
try {
|
|
119
|
+
this.wsSocket?.end();
|
|
120
|
+
}
|
|
121
|
+
catch { }
|
|
122
|
+
try {
|
|
123
|
+
this.wsSocket?.destroy();
|
|
124
|
+
}
|
|
125
|
+
catch { }
|
|
126
|
+
try {
|
|
127
|
+
this.server.closeIdleConnections?.();
|
|
128
|
+
this.server.closeAllConnections?.();
|
|
129
|
+
}
|
|
130
|
+
catch { }
|
|
131
|
+
for (const socket of this.serverSockets) {
|
|
132
|
+
try {
|
|
133
|
+
socket.destroy();
|
|
134
|
+
}
|
|
135
|
+
catch { }
|
|
136
|
+
}
|
|
137
|
+
await new Promise((resolve) => {
|
|
138
|
+
try {
|
|
139
|
+
this.server.close(() => resolve());
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
resolve();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
if (this.browserProcess &&
|
|
146
|
+
this.ownsBrowserProcess &&
|
|
147
|
+
!this.browserProcess.killed) {
|
|
148
|
+
killOwnedBrowserProcess(this.browserProcess);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async exitFromTerminalClosure() {
|
|
152
|
+
await this.close(new Error("terminal side closed"));
|
|
153
|
+
process.exit(0);
|
|
154
|
+
}
|
|
155
|
+
async listen() {
|
|
156
|
+
this.installTerminalHooks();
|
|
157
|
+
this.server.on("connection", (socket) => {
|
|
158
|
+
this.serverSockets.add(socket);
|
|
159
|
+
socket.on("close", () => {
|
|
160
|
+
this.serverSockets.delete(socket);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
this.server.on("upgrade", (req, socket) => {
|
|
164
|
+
if ((req.url ?? "") != "/ws") {
|
|
165
|
+
socket.destroy();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const key = String(req.headers["sec-websocket-key"] ?? "");
|
|
169
|
+
if (!key) {
|
|
170
|
+
socket.destroy();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const accept = createHash("sha1")
|
|
174
|
+
.update(key + WEB_MAGIC)
|
|
175
|
+
.digest("base64");
|
|
176
|
+
socket.write([
|
|
177
|
+
"HTTP/1.1 101 Switching Protocols",
|
|
178
|
+
"Upgrade: websocket",
|
|
179
|
+
"Connection: Upgrade",
|
|
180
|
+
"Sec-WebSocket-Accept: " + accept,
|
|
181
|
+
"",
|
|
182
|
+
"",
|
|
183
|
+
].join("\r\n"));
|
|
184
|
+
this.wsSocket = socket;
|
|
185
|
+
this.wsBuffer = Buffer.alloc(0);
|
|
186
|
+
socket.on("data", (chunk) => this.onWebSocketData(chunk));
|
|
187
|
+
socket.on("end", () => {
|
|
188
|
+
this.wsSocket = null;
|
|
189
|
+
if (!this.closed) {
|
|
190
|
+
void this.close(new Error("web browser disconnected"));
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
socket.on("close", () => {
|
|
194
|
+
this.wsSocket = null;
|
|
195
|
+
if (!this.closed) {
|
|
196
|
+
void this.close(new Error("web browser disconnected"));
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
socket.on("error", (error) => {
|
|
200
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
201
|
+
if (!this.closed) {
|
|
202
|
+
void this.close(err);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
await new Promise((resolve, reject) => {
|
|
207
|
+
this.server.once("error", (error) => reject(error instanceof Error ? error : new Error(String(error))));
|
|
208
|
+
this.server.listen(0, "127.0.0.1", () => resolve());
|
|
209
|
+
});
|
|
210
|
+
const address = this.server.address();
|
|
211
|
+
if (!address || typeof address == "string") {
|
|
212
|
+
throw new Error("failed to determine web session host address");
|
|
213
|
+
}
|
|
214
|
+
const url = `http://127.0.0.1:${address.port}/`;
|
|
215
|
+
if (!this.headless && !process.env.BROWSER?.trim().length) {
|
|
216
|
+
process.stdout.write(`Open web session: ${url}\n`);
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
const launched = launchBrowser(url, this.headless);
|
|
220
|
+
this.browserProcess = launched.process;
|
|
221
|
+
this.ownsBrowserProcess = launched.ownsProcess;
|
|
222
|
+
this.browserProcess.on("close", (code) => {
|
|
223
|
+
if (this.closed)
|
|
224
|
+
return;
|
|
225
|
+
const error = new Error(`web browser process exited with code ${code ?? 0}`);
|
|
226
|
+
if (!this.ready && this.readyReject) {
|
|
227
|
+
this.readyReject(error);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
void this.close(error);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
await this.readyPromise;
|
|
234
|
+
}
|
|
235
|
+
installTerminalHooks() {
|
|
236
|
+
if (!this.terminalHooksEnabled)
|
|
237
|
+
return;
|
|
238
|
+
process.stdin.on("close", this.onTerminalClosed);
|
|
239
|
+
process.stdin.on("end", this.onTerminalClosed);
|
|
240
|
+
process.on("SIGHUP", this.onTerminalHangup);
|
|
241
|
+
}
|
|
242
|
+
removeTerminalHooks() {
|
|
243
|
+
if (!this.terminalHooksEnabled)
|
|
244
|
+
return;
|
|
245
|
+
process.stdin.off("close", this.onTerminalClosed);
|
|
246
|
+
process.stdin.off("end", this.onTerminalClosed);
|
|
247
|
+
process.off("SIGHUP", this.onTerminalHangup);
|
|
248
|
+
}
|
|
249
|
+
onRequest(req, res) {
|
|
250
|
+
const headers = {
|
|
251
|
+
"Cross-Origin-Embedder-Policy": "require-corp",
|
|
252
|
+
"Cross-Origin-Opener-Policy": "same-origin",
|
|
253
|
+
"Cache-Control": "no-store",
|
|
254
|
+
};
|
|
255
|
+
const url = req.url ?? "/";
|
|
256
|
+
if (url == "/" || url.startsWith("/?")) {
|
|
257
|
+
res.writeHead(200, {
|
|
258
|
+
...headers,
|
|
259
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
260
|
+
});
|
|
261
|
+
res.end(this.html);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (url == "/client.js") {
|
|
265
|
+
res.writeHead(200, {
|
|
266
|
+
...headers,
|
|
267
|
+
"Content-Type": "text/javascript; charset=utf-8",
|
|
268
|
+
});
|
|
269
|
+
res.end(this.client);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (url == "/worker.js") {
|
|
273
|
+
res.writeHead(200, {
|
|
274
|
+
...headers,
|
|
275
|
+
"Content-Type": "text/javascript; charset=utf-8",
|
|
276
|
+
});
|
|
277
|
+
res.end(this.worker);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (this.currentJob) {
|
|
281
|
+
const wasmUrl = this.currentJob.env.AS_TEST_WASM_PATH;
|
|
282
|
+
const helperUrl = this.currentJob.env.AS_TEST_HELPER_PATH ?? "";
|
|
283
|
+
if (url == wasmUrl) {
|
|
284
|
+
res.writeHead(200, {
|
|
285
|
+
...headers,
|
|
286
|
+
"Content-Type": "application/wasm",
|
|
287
|
+
});
|
|
288
|
+
res.end(fs.readFileSync(this.currentJob.wasmPath));
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (helperUrl.length && url == helperUrl && this.currentJob.helperPath) {
|
|
292
|
+
res.writeHead(200, {
|
|
293
|
+
...headers,
|
|
294
|
+
"Content-Type": "text/javascript; charset=utf-8",
|
|
295
|
+
});
|
|
296
|
+
res.end(fs.readFileSync(this.currentJob.helperPath, "utf8"));
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
res.writeHead(404, headers);
|
|
301
|
+
res.end("not found");
|
|
302
|
+
}
|
|
303
|
+
onWebSocketData(chunk) {
|
|
304
|
+
this.wsBuffer = Buffer.concat([this.wsBuffer, chunk]);
|
|
305
|
+
while (this.wsBuffer.length >= 2) {
|
|
306
|
+
const first = this.wsBuffer[0];
|
|
307
|
+
const second = this.wsBuffer[1];
|
|
308
|
+
const opcode = first & 0x0f;
|
|
309
|
+
const masked = (second & 0x80) !== 0;
|
|
310
|
+
let length = second & 0x7f;
|
|
311
|
+
let offset = 2;
|
|
312
|
+
if (length == 126) {
|
|
313
|
+
if (this.wsBuffer.length < offset + 2)
|
|
314
|
+
return;
|
|
315
|
+
length = this.wsBuffer.readUInt16BE(offset);
|
|
316
|
+
offset += 2;
|
|
317
|
+
}
|
|
318
|
+
else if (length == 127) {
|
|
319
|
+
if (this.wsBuffer.length < offset + 8)
|
|
320
|
+
return;
|
|
321
|
+
length = Number(this.wsBuffer.readBigUInt64BE(offset));
|
|
322
|
+
offset += 8;
|
|
323
|
+
}
|
|
324
|
+
const maskLength = masked ? 4 : 0;
|
|
325
|
+
if (this.wsBuffer.length < offset + maskLength + length)
|
|
326
|
+
return;
|
|
327
|
+
let payload = this.wsBuffer.subarray(offset + maskLength, offset + maskLength + length);
|
|
328
|
+
if (masked) {
|
|
329
|
+
const mask = this.wsBuffer.subarray(offset, offset + 4);
|
|
330
|
+
const unmasked = Buffer.alloc(length);
|
|
331
|
+
for (let i = 0; i < length; i++) {
|
|
332
|
+
unmasked[i] = payload[i] ^ mask[i % 4];
|
|
333
|
+
}
|
|
334
|
+
payload = unmasked;
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
payload = Buffer.from(payload);
|
|
338
|
+
}
|
|
339
|
+
this.wsBuffer = this.wsBuffer.subarray(offset + maskLength + length);
|
|
340
|
+
if (opcode == 0x8) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (opcode == 0x1) {
|
|
344
|
+
this.onControl(payload.toString("utf8"));
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (opcode == 0x2 && this.currentJob) {
|
|
348
|
+
this.currentJob.onBinary(Buffer.from(payload));
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
onControl(raw) {
|
|
353
|
+
let message = null;
|
|
354
|
+
try {
|
|
355
|
+
message = JSON.parse(raw);
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (message?.kind == "ready") {
|
|
361
|
+
this.ready = true;
|
|
362
|
+
this.readyResolve?.();
|
|
363
|
+
this.readyResolve = null;
|
|
364
|
+
this.readyReject = null;
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (message?.kind == "instantiated") {
|
|
368
|
+
if (this.currentJob && !this.currentJob.started) {
|
|
369
|
+
this.currentJob.started = true;
|
|
370
|
+
this.sendControl({ kind: "start" });
|
|
371
|
+
}
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (message?.kind == "done") {
|
|
375
|
+
this.currentJob?.resolve();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (message?.kind == "error") {
|
|
379
|
+
this.currentJob?.reject(new Error(String(message.message ?? "browser runtime failed")));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
sendControl(message) {
|
|
383
|
+
if (!this.wsSocket) {
|
|
384
|
+
throw new Error("web session host is not connected to a browser");
|
|
385
|
+
}
|
|
386
|
+
sendWebSocketFrame(this.wsSocket, 0x1, Buffer.from(JSON.stringify(message), "utf8"));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
function sendWebSocketFrame(socket, opcode, payload) {
|
|
390
|
+
let header;
|
|
391
|
+
if (payload.length < 126) {
|
|
392
|
+
header = Buffer.from([0x80 | opcode, payload.length]);
|
|
393
|
+
}
|
|
394
|
+
else if (payload.length < 65536) {
|
|
395
|
+
header = Buffer.alloc(4);
|
|
396
|
+
header[0] = 0x80 | opcode;
|
|
397
|
+
header[1] = 126;
|
|
398
|
+
header.writeUInt16BE(payload.length, 2);
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
header = Buffer.alloc(10);
|
|
402
|
+
header[0] = 0x80 | opcode;
|
|
403
|
+
header[1] = 127;
|
|
404
|
+
header.writeBigUInt64BE(BigInt(payload.length), 2);
|
|
405
|
+
}
|
|
406
|
+
socket.write(Buffer.concat([header, payload]));
|
|
407
|
+
}
|
|
408
|
+
function launchBrowser(url, headless) {
|
|
409
|
+
if (process.env.BROWSER?.trim()) {
|
|
410
|
+
const child = spawnBrowserCommand(process.env.BROWSER, url, headless);
|
|
411
|
+
if (child)
|
|
412
|
+
return { process: child, ownsProcess: true };
|
|
413
|
+
}
|
|
414
|
+
if (!headless) {
|
|
415
|
+
const opened = openWithSystemBrowser(url);
|
|
416
|
+
if (opened)
|
|
417
|
+
return { process: opened, ownsProcess: false };
|
|
418
|
+
}
|
|
419
|
+
const direct = openWithInstalledBrowser(url, headless);
|
|
420
|
+
if (direct)
|
|
421
|
+
return { process: direct, ownsProcess: true };
|
|
422
|
+
throw new Error(headless
|
|
423
|
+
? "could not find a headless-capable browser"
|
|
424
|
+
: "could not open a browser automatically");
|
|
425
|
+
}
|
|
426
|
+
function openWithSystemBrowser(url) {
|
|
427
|
+
if (process.platform == "darwin") {
|
|
428
|
+
if (!hasExecutable("open"))
|
|
429
|
+
return null;
|
|
430
|
+
return spawn("open", [url], { stdio: "ignore", detached: true });
|
|
431
|
+
}
|
|
432
|
+
if (process.platform == "win32") {
|
|
433
|
+
if (!hasExecutable("cmd"))
|
|
434
|
+
return null;
|
|
435
|
+
return spawn("cmd", ["/c", "start", "", url], {
|
|
436
|
+
stdio: "ignore",
|
|
437
|
+
detached: true,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
if (!hasExecutable("xdg-open"))
|
|
441
|
+
return null;
|
|
442
|
+
return spawn("xdg-open", [url], { stdio: "ignore", detached: true });
|
|
443
|
+
}
|
|
444
|
+
function openWithInstalledBrowser(url, headless) {
|
|
445
|
+
const candidates = [
|
|
446
|
+
{ command: "chromium", headless: ["--headless=new"] },
|
|
447
|
+
{ command: "chromium-browser", headless: ["--headless=new"] },
|
|
448
|
+
{ command: "google-chrome", headless: ["--headless=new"] },
|
|
449
|
+
{ command: "google-chrome-stable", headless: ["--headless=new"] },
|
|
450
|
+
{ command: "chrome", headless: ["--headless=new"] },
|
|
451
|
+
{ command: "msedge", headless: ["--headless=new"] },
|
|
452
|
+
{ command: "firefox", headless: ["-headless"] },
|
|
453
|
+
];
|
|
454
|
+
for (const candidate of candidates) {
|
|
455
|
+
if (!hasExecutable(candidate.command))
|
|
456
|
+
continue;
|
|
457
|
+
return spawn(candidate.command, [...(headless ? candidate.headless : []), url], { stdio: "ignore", detached: true });
|
|
458
|
+
}
|
|
459
|
+
return null;
|
|
460
|
+
}
|
|
461
|
+
function spawnBrowserCommand(commandValue, url, headless) {
|
|
462
|
+
const direct = unwrapQuotedPath(String(commandValue).trim());
|
|
463
|
+
if (hasExecutable(direct)) {
|
|
464
|
+
const args = headless ? resolveHeadlessFlags(direct) : [];
|
|
465
|
+
args.push(url);
|
|
466
|
+
return spawn(direct, args, {
|
|
467
|
+
stdio: "ignore",
|
|
468
|
+
detached: true,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
const parts = splitCommand(String(commandValue));
|
|
472
|
+
if (!parts.length)
|
|
473
|
+
return null;
|
|
474
|
+
const command = parts[0];
|
|
475
|
+
if (!hasExecutable(command))
|
|
476
|
+
return null;
|
|
477
|
+
const args = parts.slice(1);
|
|
478
|
+
if (headless) {
|
|
479
|
+
args.push(...resolveHeadlessFlags(commandValue));
|
|
480
|
+
}
|
|
481
|
+
args.push(url);
|
|
482
|
+
return spawn(command, args, {
|
|
483
|
+
stdio: "ignore",
|
|
484
|
+
detached: true,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
function resolveHeadlessFlags(commandValue) {
|
|
488
|
+
const lower = commandValue.toLowerCase();
|
|
489
|
+
if (lower.includes("firefox"))
|
|
490
|
+
return ["-headless"];
|
|
491
|
+
return ["--headless=new", "--disable-gpu", "--no-first-run", "--no-default-browser-check"];
|
|
492
|
+
}
|
|
493
|
+
function hasExecutable(command) {
|
|
494
|
+
if (!command.length)
|
|
495
|
+
return false;
|
|
496
|
+
if (command.includes("/") || command.includes("\\")) {
|
|
497
|
+
return fs.existsSync(command);
|
|
498
|
+
}
|
|
499
|
+
const pathValue = process.env.PATH ?? "";
|
|
500
|
+
const suffixes = process.platform == "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
|
|
501
|
+
for (const base of pathValue.split(path.delimiter)) {
|
|
502
|
+
if (!base)
|
|
503
|
+
continue;
|
|
504
|
+
for (const suffix of suffixes) {
|
|
505
|
+
if (fs.existsSync(path.join(base, command + suffix))) {
|
|
506
|
+
return true;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
function splitCommand(commandValue) {
|
|
513
|
+
const parts = [];
|
|
514
|
+
let current = "";
|
|
515
|
+
let quote = "";
|
|
516
|
+
for (let i = 0; i < commandValue.length; i++) {
|
|
517
|
+
const char = commandValue[i];
|
|
518
|
+
if (quote) {
|
|
519
|
+
if (char == quote) {
|
|
520
|
+
quote = "";
|
|
521
|
+
}
|
|
522
|
+
else if (char == "\\" && i + 1 < commandValue.length) {
|
|
523
|
+
current += commandValue[++i];
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
current += char;
|
|
527
|
+
}
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
if (char == "'" || char == '"') {
|
|
531
|
+
quote = char;
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
if (/\s/.test(char)) {
|
|
535
|
+
if (current.length) {
|
|
536
|
+
parts.push(current);
|
|
537
|
+
current = "";
|
|
538
|
+
}
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
if (char == "\\" && i + 1 < commandValue.length) {
|
|
542
|
+
current += commandValue[++i];
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
current += char;
|
|
546
|
+
}
|
|
547
|
+
if (current.length) {
|
|
548
|
+
parts.push(current);
|
|
549
|
+
}
|
|
550
|
+
return parts;
|
|
551
|
+
}
|
|
552
|
+
function unwrapQuotedPath(value) {
|
|
553
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
554
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
555
|
+
return value.slice(1, -1);
|
|
556
|
+
}
|
|
557
|
+
return value;
|
|
558
|
+
}
|
|
559
|
+
function killOwnedBrowserProcess(browserProcess) {
|
|
560
|
+
try {
|
|
561
|
+
if (process.platform != "win32" &&
|
|
562
|
+
typeof browserProcess.pid == "number" &&
|
|
563
|
+
browserProcess.pid > 0) {
|
|
564
|
+
process.kill(-browserProcess.pid, "SIGTERM");
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
catch { }
|
|
569
|
+
try {
|
|
570
|
+
browserProcess.kill("SIGTERM");
|
|
571
|
+
}
|
|
572
|
+
catch { }
|
|
573
|
+
}
|
|
574
|
+
function buildWebSessionHtml() {
|
|
575
|
+
return `<!doctype html>
|
|
576
|
+
<html lang="en">
|
|
577
|
+
<head>
|
|
578
|
+
<meta charset="utf-8" />
|
|
579
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
580
|
+
<title>as-test</title>
|
|
581
|
+
<style>
|
|
582
|
+
:root {
|
|
583
|
+
color-scheme: light dark;
|
|
584
|
+
--bg: #edf1f7;
|
|
585
|
+
--panel: rgba(255, 255, 255, 0.72);
|
|
586
|
+
--panel-edge: rgba(255, 255, 255, 0.7);
|
|
587
|
+
--text: #1f2937;
|
|
588
|
+
--muted: #667085;
|
|
589
|
+
--track: rgba(15, 23, 42, 0.09);
|
|
590
|
+
--fill-a: #8ec5ff;
|
|
591
|
+
--fill-b: #5aa8ff;
|
|
592
|
+
--shadow: 0 24px 80px rgba(15, 23, 42, 0.14);
|
|
593
|
+
--button-bg: rgba(255, 255, 255, 0.92);
|
|
594
|
+
--button-text: #0f172a;
|
|
595
|
+
--button-shadow:
|
|
596
|
+
inset 0 1px 0 rgba(255, 255, 255, 0.7),
|
|
597
|
+
0 8px 20px rgba(15, 23, 42, 0.08);
|
|
598
|
+
}
|
|
599
|
+
@media (prefers-color-scheme: dark) {
|
|
600
|
+
:root {
|
|
601
|
+
--bg: #0e1625;
|
|
602
|
+
--panel: rgba(18, 24, 38, 0.76);
|
|
603
|
+
--panel-edge: rgba(148, 163, 184, 0.18);
|
|
604
|
+
--text: #e5edf8;
|
|
605
|
+
--muted: #93a4bc;
|
|
606
|
+
--track: rgba(255, 255, 255, 0.08);
|
|
607
|
+
--fill-a: #79b8ff;
|
|
608
|
+
--fill-b: #4d93ff;
|
|
609
|
+
--shadow: 0 28px 90px rgba(2, 6, 23, 0.42);
|
|
610
|
+
--button-bg: rgba(255, 255, 255, 0.12);
|
|
611
|
+
--button-text: #e5edf8;
|
|
612
|
+
--button-shadow:
|
|
613
|
+
inset 0 1px 0 rgba(255, 255, 255, 0.08),
|
|
614
|
+
0 10px 24px rgba(2, 6, 23, 0.24);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
* { box-sizing: border-box; }
|
|
618
|
+
html, body {
|
|
619
|
+
margin: 0;
|
|
620
|
+
min-height: 100vh;
|
|
621
|
+
font-family:
|
|
622
|
+
"SF Pro Display",
|
|
623
|
+
"SF Pro Text",
|
|
624
|
+
-apple-system,
|
|
625
|
+
BlinkMacSystemFont,
|
|
626
|
+
"Helvetica Neue",
|
|
627
|
+
sans-serif;
|
|
628
|
+
color: var(--text);
|
|
629
|
+
background:
|
|
630
|
+
radial-gradient(circle at top, rgba(255,255,255,.95), rgba(255,255,255,.35) 28%, transparent 50%),
|
|
631
|
+
linear-gradient(180deg, #eef3f9 0%, #e7edf6 48%, #dfe6f0 100%);
|
|
632
|
+
}
|
|
633
|
+
@media (prefers-color-scheme: dark) {
|
|
634
|
+
html, body {
|
|
635
|
+
background:
|
|
636
|
+
radial-gradient(circle at top, rgba(95, 153, 255, 0.16), rgba(14, 22, 37, 0.18) 26%, transparent 48%),
|
|
637
|
+
linear-gradient(180deg, #111827 0%, #0e1625 52%, #09111d 100%);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
body {
|
|
641
|
+
display: grid;
|
|
642
|
+
place-items: center;
|
|
643
|
+
overflow: hidden;
|
|
644
|
+
}
|
|
645
|
+
.panel {
|
|
646
|
+
width: min(520px, calc(100vw - 48px));
|
|
647
|
+
padding: 26px 28px 24px;
|
|
648
|
+
border-radius: 28px;
|
|
649
|
+
background: var(--panel);
|
|
650
|
+
border: 1px solid var(--panel-edge);
|
|
651
|
+
box-shadow: var(--shadow);
|
|
652
|
+
backdrop-filter: blur(24px) saturate(1.2);
|
|
653
|
+
}
|
|
654
|
+
.eyebrow {
|
|
655
|
+
margin: 0 0 8px;
|
|
656
|
+
font-size: 12px;
|
|
657
|
+
font-weight: 600;
|
|
658
|
+
letter-spacing: .12em;
|
|
659
|
+
text-transform: uppercase;
|
|
660
|
+
color: var(--muted);
|
|
661
|
+
}
|
|
662
|
+
h1 {
|
|
663
|
+
margin: 0;
|
|
664
|
+
font-size: 30px;
|
|
665
|
+
font-weight: 650;
|
|
666
|
+
letter-spacing: -0.03em;
|
|
667
|
+
}
|
|
668
|
+
#status {
|
|
669
|
+
margin-top: 10px;
|
|
670
|
+
font-size: 14px;
|
|
671
|
+
color: var(--muted);
|
|
672
|
+
}
|
|
673
|
+
#current {
|
|
674
|
+
margin-top: 22px;
|
|
675
|
+
font-size: 15px;
|
|
676
|
+
font-weight: 560;
|
|
677
|
+
color: var(--text);
|
|
678
|
+
white-space: nowrap;
|
|
679
|
+
overflow: hidden;
|
|
680
|
+
text-overflow: ellipsis;
|
|
681
|
+
}
|
|
682
|
+
.bar {
|
|
683
|
+
position: relative;
|
|
684
|
+
height: 7px;
|
|
685
|
+
margin-top: 16px;
|
|
686
|
+
overflow: hidden;
|
|
687
|
+
border-radius: 999px;
|
|
688
|
+
background: var(--track);
|
|
689
|
+
}
|
|
690
|
+
.bar::before {
|
|
691
|
+
content: "";
|
|
692
|
+
position: absolute;
|
|
693
|
+
inset: 0 auto 0 -30%;
|
|
694
|
+
width: 34%;
|
|
695
|
+
border-radius: inherit;
|
|
696
|
+
background: linear-gradient(90deg, var(--fill-a), var(--fill-b));
|
|
697
|
+
box-shadow: 0 0 14px rgba(90, 168, 255, 0.35);
|
|
698
|
+
animation: glide 1.15s ease-in-out infinite;
|
|
699
|
+
}
|
|
700
|
+
.note {
|
|
701
|
+
margin-top: 14px;
|
|
702
|
+
font-size: 12px;
|
|
703
|
+
color: var(--muted);
|
|
704
|
+
}
|
|
705
|
+
.actions {
|
|
706
|
+
display: flex;
|
|
707
|
+
justify-content: flex-end;
|
|
708
|
+
margin-top: 18px;
|
|
709
|
+
}
|
|
710
|
+
.button {
|
|
711
|
+
appearance: none;
|
|
712
|
+
border: 0;
|
|
713
|
+
border-radius: 999px;
|
|
714
|
+
padding: 10px 16px;
|
|
715
|
+
font: inherit;
|
|
716
|
+
font-size: 13px;
|
|
717
|
+
font-weight: 600;
|
|
718
|
+
color: var(--button-text);
|
|
719
|
+
background: var(--button-bg);
|
|
720
|
+
box-shadow: var(--button-shadow);
|
|
721
|
+
cursor: pointer;
|
|
722
|
+
}
|
|
723
|
+
.button:hover {
|
|
724
|
+
filter: brightness(1.04);
|
|
725
|
+
}
|
|
726
|
+
.button[hidden] {
|
|
727
|
+
display: none;
|
|
728
|
+
}
|
|
729
|
+
body[data-state="done"] .bar::before,
|
|
730
|
+
body[data-state="disconnected"] .bar::before {
|
|
731
|
+
animation: none;
|
|
732
|
+
inset: 0;
|
|
733
|
+
width: 100%;
|
|
734
|
+
}
|
|
735
|
+
@keyframes glide {
|
|
736
|
+
0% { transform: translateX(0); }
|
|
737
|
+
50% { transform: translateX(210%); }
|
|
738
|
+
100% { transform: translateX(0); }
|
|
739
|
+
}
|
|
740
|
+
</style>
|
|
741
|
+
</head>
|
|
742
|
+
<body>
|
|
743
|
+
<section class="panel" aria-live="polite">
|
|
744
|
+
<p class="eyebrow">as-test</p>
|
|
745
|
+
<h1>Running web tests</h1>
|
|
746
|
+
<p id="status">Connecting to browser session…</p>
|
|
747
|
+
<div id="current">Preparing runtime…</div>
|
|
748
|
+
<div class="bar" aria-hidden="true"></div>
|
|
749
|
+
<div class="note" id="note">Results continue streaming to the terminal while this page stays attached to the current run.</div>
|
|
750
|
+
<div class="actions">
|
|
751
|
+
<button class="button" id="exit" hidden type="button">Exit</button>
|
|
752
|
+
</div>
|
|
753
|
+
</section>
|
|
754
|
+
<script type="module" src="/client.js"></script>
|
|
755
|
+
</body>
|
|
756
|
+
</html>`;
|
|
757
|
+
}
|
|
758
|
+
function buildWebSessionClientSource() {
|
|
759
|
+
return String.raw `const runnerOrigin = location.origin;
|
|
760
|
+
const worker = new Worker(new URL("/worker.js", runnerOrigin), { type: "module" });
|
|
761
|
+
const wsUrl = new URL("/ws", runnerOrigin);
|
|
762
|
+
wsUrl.protocol = location.protocol == "https:" ? "wss:" : "ws:";
|
|
763
|
+
const ws = new WebSocket(wsUrl);
|
|
764
|
+
const status = document.getElementById("status");
|
|
765
|
+
const current = document.getElementById("current");
|
|
766
|
+
const note = document.getElementById("note");
|
|
767
|
+
const exitButton = document.getElementById("exit");
|
|
768
|
+
const replyBuffer = new SharedArrayBuffer(8 + 4 * 1024 * 1024);
|
|
769
|
+
const replyState = new Int32Array(replyBuffer, 0, 2);
|
|
770
|
+
const replyBytes = new Uint8Array(replyBuffer, 8);
|
|
771
|
+
ws.binaryType = "arraybuffer";
|
|
772
|
+
|
|
773
|
+
function setState(state) {
|
|
774
|
+
document.body.dataset.state = state;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function setStatus(text) {
|
|
778
|
+
status.textContent = text;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function setCurrent(text) {
|
|
782
|
+
current.textContent = text;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function setNote(text) {
|
|
786
|
+
note.textContent = text;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function showExitButton() {
|
|
790
|
+
exitButton.hidden = false;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function attemptWindowClose(onBlocked) {
|
|
794
|
+
try {
|
|
795
|
+
window.close();
|
|
796
|
+
} catch {}
|
|
797
|
+
setTimeout(() => {
|
|
798
|
+
if (!window.closed) onBlocked();
|
|
799
|
+
}, 80);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function pushReply(frame) {
|
|
803
|
+
if (frame.byteLength > replyBytes.byteLength) {
|
|
804
|
+
throw new Error("WIPC reply exceeded shared browser buffer");
|
|
805
|
+
}
|
|
806
|
+
while (Atomics.load(replyState, 0) != 0) {
|
|
807
|
+
Atomics.wait(replyState, 0, 1, 10);
|
|
808
|
+
}
|
|
809
|
+
replyBytes.set(new Uint8Array(frame), 0);
|
|
810
|
+
Atomics.store(replyState, 1, frame.byteLength);
|
|
811
|
+
Atomics.store(replyState, 0, 1);
|
|
812
|
+
Atomics.notify(replyState, 0, 1);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
worker.onmessage = (event) => {
|
|
816
|
+
const message = event.data ?? {};
|
|
817
|
+
if (message.kind == "wipc" && message.frame instanceof ArrayBuffer) {
|
|
818
|
+
ws.send(message.frame);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
if (message.kind == "instantiated") {
|
|
822
|
+
setState("loaded");
|
|
823
|
+
setStatus("Runtime instantiated. Starting entrypoint…");
|
|
824
|
+
ws.send(JSON.stringify({ kind: "instantiated" }));
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
if (message.kind == "done") {
|
|
828
|
+
setState("done");
|
|
829
|
+
setStatus("Completed.");
|
|
830
|
+
ws.send(JSON.stringify({ kind: "done" }));
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
if (message.kind == "error") {
|
|
834
|
+
setState("error");
|
|
835
|
+
setStatus("Browser runtime error.");
|
|
836
|
+
setCurrent(String(message.message ?? "Unknown browser runtime error"));
|
|
837
|
+
ws.send(JSON.stringify({ kind: "error", message: String(message.message ?? "browser runtime error") }));
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
ws.addEventListener("open", () => {
|
|
843
|
+
setState("connected");
|
|
844
|
+
setStatus("Connected. Waiting for work…");
|
|
845
|
+
setCurrent("Waiting for the next test artifact…");
|
|
846
|
+
ws.send(JSON.stringify({ kind: "ready" }));
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
ws.addEventListener("message", (event) => {
|
|
850
|
+
if (typeof event.data == "string") {
|
|
851
|
+
const message = JSON.parse(event.data);
|
|
852
|
+
if (message.kind == "load") {
|
|
853
|
+
setState("loading");
|
|
854
|
+
setStatus("Instantiating current test file…");
|
|
855
|
+
setCurrent(String(message.label ?? "Preparing test file…"));
|
|
856
|
+
setNote("Results continue streaming to the terminal while this page stays attached to the current run.");
|
|
857
|
+
worker.postMessage({ kind: "load", env: message.env, replyBuffer });
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
if (message.kind == "start") {
|
|
861
|
+
setState("running");
|
|
862
|
+
setStatus("Executing current test file…");
|
|
863
|
+
worker.postMessage({ kind: "start" });
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (message.kind == "shutdown") {
|
|
867
|
+
const ok = message.ok !== false;
|
|
868
|
+
if (!ok) {
|
|
869
|
+
setState("error");
|
|
870
|
+
setStatus("Failed.");
|
|
871
|
+
setCurrent(
|
|
872
|
+
String(message.message ?? "The web test session ended with an error."),
|
|
873
|
+
);
|
|
874
|
+
} else {
|
|
875
|
+
setState("done");
|
|
876
|
+
setStatus("Completed.");
|
|
877
|
+
setCurrent("All selected web binaries have finished.");
|
|
878
|
+
}
|
|
879
|
+
setNote("Closing browser page…");
|
|
880
|
+
attemptWindowClose(() => {
|
|
881
|
+
setNote("The browser kept this page open. You can close it now.");
|
|
882
|
+
showExitButton();
|
|
883
|
+
});
|
|
884
|
+
ws.close();
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
try {
|
|
890
|
+
pushReply(event.data);
|
|
891
|
+
} catch (error) {
|
|
892
|
+
ws.send(JSON.stringify({ kind: "error", message: String(error) }));
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
ws.addEventListener("close", () => {
|
|
897
|
+
const state = document.body.dataset.state;
|
|
898
|
+
if (state != "done" && state != "error") {
|
|
899
|
+
setState("disconnected");
|
|
900
|
+
setStatus("Disconnected.");
|
|
901
|
+
setNote("The browser session closed before the run completed.");
|
|
902
|
+
showExitButton();
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
exitButton.addEventListener("click", () => {
|
|
907
|
+
attemptWindowClose(() => {
|
|
908
|
+
setStatus("Completed.");
|
|
909
|
+
setCurrent("You can close this tab at any time.");
|
|
910
|
+
setNote("This tab was opened manually, so the browser may require you to close it yourself.");
|
|
911
|
+
});
|
|
912
|
+
});
|
|
913
|
+
`;
|
|
914
|
+
}
|
|
915
|
+
function buildWebSessionWorkerSource() {
|
|
916
|
+
return String.raw `let replyState = null;
|
|
917
|
+
let replyBytes = null;
|
|
918
|
+
const WIPC_MAGIC = [0x57, 0x49, 0x50, 0x43];
|
|
919
|
+
let runtimeEnv = {};
|
|
920
|
+
let instance = null;
|
|
921
|
+
|
|
922
|
+
self.onmessage = async (event) => {
|
|
923
|
+
const message = event.data ?? {};
|
|
924
|
+
if (message.kind == "load") {
|
|
925
|
+
const shared = message.replyBuffer;
|
|
926
|
+
replyState = new Int32Array(shared, 0, 2);
|
|
927
|
+
replyBytes = new Uint8Array(shared, 8);
|
|
928
|
+
runtimeEnv = message.env ?? {};
|
|
929
|
+
try {
|
|
930
|
+
applyRuntimeEnvironment(runtimeEnv);
|
|
931
|
+
instance = await instantiate({});
|
|
932
|
+
self.postMessage({ kind: "instantiated" });
|
|
933
|
+
} catch (error) {
|
|
934
|
+
emitError(error);
|
|
935
|
+
}
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
if (message.kind == "start") {
|
|
939
|
+
try {
|
|
940
|
+
if (!instance) throw new Error("web runtime has not been instantiated yet");
|
|
941
|
+
instance.exports.start?.();
|
|
942
|
+
instance = null;
|
|
943
|
+
self.postMessage({ kind: "done" });
|
|
944
|
+
} catch (error) {
|
|
945
|
+
emitError(error);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
};
|
|
949
|
+
|
|
950
|
+
function emitError(error) {
|
|
951
|
+
const message =
|
|
952
|
+
error && typeof error == "object" && "stack" in error
|
|
953
|
+
? String(error.stack)
|
|
954
|
+
: String(error);
|
|
955
|
+
self.postMessage({ kind: "error", message });
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function readReply(max) {
|
|
959
|
+
if (!replyState || !replyBytes || max <= 0) {
|
|
960
|
+
return new ArrayBuffer(0);
|
|
961
|
+
}
|
|
962
|
+
while (Atomics.load(replyState, 0) == 0) {
|
|
963
|
+
Atomics.wait(replyState, 0, 0);
|
|
964
|
+
}
|
|
965
|
+
const total = Atomics.load(replyState, 1);
|
|
966
|
+
const size = Math.min(max, total);
|
|
967
|
+
const out = new Uint8Array(size);
|
|
968
|
+
out.set(replyBytes.subarray(0, size));
|
|
969
|
+
if (size < total) {
|
|
970
|
+
replyBytes.copyWithin(0, size, total);
|
|
971
|
+
Atomics.store(replyState, 1, total - size);
|
|
972
|
+
} else {
|
|
973
|
+
Atomics.store(replyState, 1, 0);
|
|
974
|
+
Atomics.store(replyState, 0, 0);
|
|
975
|
+
Atomics.notify(replyState, 0, 1);
|
|
976
|
+
}
|
|
977
|
+
return out.buffer;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function applyRuntimeEnvironment(env) {
|
|
981
|
+
self.process = {
|
|
982
|
+
env,
|
|
983
|
+
versions: {},
|
|
984
|
+
stdout: {
|
|
985
|
+
write(data) {
|
|
986
|
+
const frame = data instanceof ArrayBuffer ? data : data?.buffer;
|
|
987
|
+
if (frame) {
|
|
988
|
+
mirrorFrame(frame);
|
|
989
|
+
self.postMessage({ kind: "wipc", frame }, [frame]);
|
|
990
|
+
}
|
|
991
|
+
return true;
|
|
992
|
+
},
|
|
993
|
+
},
|
|
994
|
+
stdin: {
|
|
995
|
+
read(size) {
|
|
996
|
+
return readReply(Number(size ?? 0));
|
|
997
|
+
},
|
|
998
|
+
},
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
async function instantiate(imports) {
|
|
1003
|
+
const wasmUrl = String(runtimeEnv.AS_TEST_WASM_PATH ?? "");
|
|
1004
|
+
const helperUrl = String(runtimeEnv.AS_TEST_HELPER_PATH ?? "");
|
|
1005
|
+
const kind = String(runtimeEnv.AS_TEST_BINDINGS_KIND ?? "raw");
|
|
1006
|
+
if (!wasmUrl) throw new Error("web runtime wasm path is missing");
|
|
1007
|
+
if (kind === "raw") {
|
|
1008
|
+
if (!helperUrl) throw new Error("web runtime helper path is missing for raw bindings");
|
|
1009
|
+
const binary = await fetchWasmBinary(wasmUrl);
|
|
1010
|
+
const module = new WebAssembly.Module(binary);
|
|
1011
|
+
const helper = await import(helperUrl);
|
|
1012
|
+
if (typeof helper.instantiate != "function") {
|
|
1013
|
+
throw new Error("bindings helper missing instantiate export");
|
|
1014
|
+
}
|
|
1015
|
+
const instance = await captureInstantiateInstance(async () => {
|
|
1016
|
+
await helper.instantiate(module, imports);
|
|
1017
|
+
});
|
|
1018
|
+
return decorateInstance(instance);
|
|
1019
|
+
}
|
|
1020
|
+
if (kind === "esm") {
|
|
1021
|
+
if (!helperUrl) throw new Error("web runtime helper path is missing for esm bindings");
|
|
1022
|
+
const instance = await captureInstantiateInstance(async () => {
|
|
1023
|
+
await import(helperUrl);
|
|
1024
|
+
});
|
|
1025
|
+
return decorateInstance(instance);
|
|
1026
|
+
}
|
|
1027
|
+
const binary = await fetchWasmBinary(wasmUrl);
|
|
1028
|
+
const module = new WebAssembly.Module(binary);
|
|
1029
|
+
const result = await WebAssembly.instantiate(module, imports);
|
|
1030
|
+
const wasmInstance = result instanceof WebAssembly.Instance ? result : result.instance;
|
|
1031
|
+
return decorateInstance(wasmInstance);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
async function fetchWasmBinary(wasmUrl) {
|
|
1035
|
+
const response = await fetch(wasmUrl);
|
|
1036
|
+
if (!response.ok) throw new Error("failed to fetch wasm artifact: " + response.status);
|
|
1037
|
+
return response.arrayBuffer();
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
async function captureInstantiateInstance(run) {
|
|
1041
|
+
const originalInstantiate = WebAssembly.instantiate.bind(WebAssembly);
|
|
1042
|
+
let captured = null;
|
|
1043
|
+
WebAssembly.instantiate = async (source, importObject) => {
|
|
1044
|
+
const result = await originalInstantiate(source, importObject);
|
|
1045
|
+
captured = result instanceof WebAssembly.Instance ? result : result.instance;
|
|
1046
|
+
return result;
|
|
1047
|
+
};
|
|
1048
|
+
try {
|
|
1049
|
+
await run();
|
|
1050
|
+
} finally {
|
|
1051
|
+
WebAssembly.instantiate = originalInstantiate;
|
|
1052
|
+
}
|
|
1053
|
+
if (!captured) throw new Error("failed to capture WebAssembly.Instance in web worker");
|
|
1054
|
+
return captured;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function decorateInstance(instance) {
|
|
1058
|
+
const exports = instance.exports ?? {};
|
|
1059
|
+
if (typeof exports.start == "function") {
|
|
1060
|
+
return instance;
|
|
1061
|
+
}
|
|
1062
|
+
const startFn = exports._start;
|
|
1063
|
+
if (typeof startFn != "function") {
|
|
1064
|
+
return instance;
|
|
1065
|
+
}
|
|
1066
|
+
const exportsProxy = new Proxy(exports, {
|
|
1067
|
+
get(target, prop, receiver) {
|
|
1068
|
+
if (prop == "start") {
|
|
1069
|
+
return () => startFn.call(target);
|
|
1070
|
+
}
|
|
1071
|
+
return Reflect.get(target, prop, receiver);
|
|
1072
|
+
},
|
|
1073
|
+
has(target, prop) {
|
|
1074
|
+
if (prop == "start") return true;
|
|
1075
|
+
return Reflect.has(target, prop);
|
|
1076
|
+
},
|
|
1077
|
+
});
|
|
1078
|
+
return new Proxy(instance, {
|
|
1079
|
+
get(target, prop, receiver) {
|
|
1080
|
+
if (prop == "exports") return exportsProxy;
|
|
1081
|
+
return Reflect.get(target, prop, receiver);
|
|
1082
|
+
},
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function mirrorFrame(frame) {
|
|
1087
|
+
if (!(frame instanceof ArrayBuffer)) return;
|
|
1088
|
+
const bytes = new Uint8Array(frame);
|
|
1089
|
+
if (
|
|
1090
|
+
bytes.length < 9 ||
|
|
1091
|
+
bytes[0] !== WIPC_MAGIC[0] ||
|
|
1092
|
+
bytes[1] !== WIPC_MAGIC[1] ||
|
|
1093
|
+
bytes[2] !== WIPC_MAGIC[2] ||
|
|
1094
|
+
bytes[3] !== WIPC_MAGIC[3]
|
|
1095
|
+
) {
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
const type = bytes[4];
|
|
1099
|
+
const size =
|
|
1100
|
+
bytes[5] |
|
|
1101
|
+
(bytes[6] << 8) |
|
|
1102
|
+
(bytes[7] << 16) |
|
|
1103
|
+
(bytes[8] << 24);
|
|
1104
|
+
const payload = bytes.subarray(9, 9 + size);
|
|
1105
|
+
if (type !== 0x02) return;
|
|
1106
|
+
let raw = "";
|
|
1107
|
+
try {
|
|
1108
|
+
raw = new TextDecoder().decode(payload);
|
|
1109
|
+
} catch {
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
if (!raw.length) return;
|
|
1113
|
+
let message = null;
|
|
1114
|
+
try {
|
|
1115
|
+
message = JSON.parse(raw);
|
|
1116
|
+
} catch {
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
renderControl(message);
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function renderControl(message) {
|
|
1123
|
+
if (!message || typeof message !== "object") return;
|
|
1124
|
+
const kind = String(message.kind ?? "");
|
|
1125
|
+
if (kind === "event:file-start") {
|
|
1126
|
+
self.postMessage({ kind: "terminal", level: "accent", text: "running " + String(message.file ?? "spec") });
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
if (kind === "event:file-end") {
|
|
1130
|
+
const verdict = String(message.verdict ?? "done").toUpperCase();
|
|
1131
|
+
const time = String(message.time ?? "");
|
|
1132
|
+
self.postMessage({ kind: "terminal", level: verdict === "PASS" ? "success" : "error", text: verdict + " " + String(message.file ?? "") + (time ? " " + time : "") });
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
if (kind === "event:log") {
|
|
1136
|
+
self.postMessage({ kind: "terminal", level: "", text: String(message.text ?? "") });
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
1139
|
+
if (kind === "event:warn") {
|
|
1140
|
+
self.postMessage({ kind: "terminal", level: "error", text: String(message.message ?? "warning") });
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
`;
|
|
1144
|
+
}
|