as-test 1.0.16 → 1.1.1
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 +57 -0
- package/README.md +45 -4
- package/as-test.config.schema.json +5 -0
- package/assembly/__fuzz__/math.fuzz.ts +19 -0
- package/assembly/__fuzz__/string.fuzz.ts +31 -0
- package/assembly/index.ts +5 -5
- package/assembly/src/expectation.ts +93 -42
- package/assembly/util/format.ts +104 -0
- package/assembly/util/helpers.ts +7 -13
- package/assembly/util/json.ts +2 -2
- package/assembly/util/wipc.ts +15 -5
- package/bin/commands/clean-core.js +135 -0
- package/bin/commands/clean.js +51 -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 +391 -78
- package/bin/types.js +1 -0
- package/bin/util.js +16 -1
- package/bin/wipc.js +7 -2
- package/lib/build/index.d.ts +1 -0
- package/lib/build/index.js +1116 -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 +1266 -0
- package/package.json +14 -6
- package/transform/lib/mock.js +50 -27
package/lib/src/index.ts
ADDED
|
@@ -0,0 +1,1266 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "child_process";
|
|
2
|
+
import { createHash } from "crypto";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import http from "http";
|
|
5
|
+
import os from "os";
|
|
6
|
+
import path from "path";
|
|
7
|
+
import type { Duplex } from "stream";
|
|
8
|
+
import { pathToFileURL } from "url";
|
|
9
|
+
import { WASI } from "wasi";
|
|
10
|
+
import { buildWebRunnerClientSource } from "./web-runner/client.js";
|
|
11
|
+
import { buildWebRunnerHtml } from "./web-runner/html.js";
|
|
12
|
+
import { buildWebRunnerWorkerSource } from "./web-runner/worker.js";
|
|
13
|
+
|
|
14
|
+
type BindingsKind = "raw" | "esm" | "none";
|
|
15
|
+
type RuntimeTarget = "bindings" | "wasi" | "web";
|
|
16
|
+
type AnyImports = WebAssembly.Imports & {
|
|
17
|
+
env?: Record<string, unknown>;
|
|
18
|
+
wasi_snapshot_preview1?: WebAssembly.Imports[string];
|
|
19
|
+
};
|
|
20
|
+
type ExportMap = Record<string, unknown>;
|
|
21
|
+
type StartedInstance = WebAssembly.Instance & {
|
|
22
|
+
exports: ExportMap & {
|
|
23
|
+
start?: () => void;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
let patchedNodeIo = false;
|
|
28
|
+
const wasiInstances = new WeakMap<WebAssembly.Instance, WASI>();
|
|
29
|
+
const WEB_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
30
|
+
const WEB_HEADLESS_FLAGS = [
|
|
31
|
+
"--headless=new",
|
|
32
|
+
"--disable-gpu",
|
|
33
|
+
"--no-first-run",
|
|
34
|
+
"--no-default-browser-check",
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
function withNodeIo(imports: WebAssembly.Imports): WebAssembly.Imports {
|
|
38
|
+
validateImports(imports, "withNodeIo");
|
|
39
|
+
patchNodeIo();
|
|
40
|
+
return imports;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function instantiate(
|
|
44
|
+
imports: WebAssembly.Imports,
|
|
45
|
+
): Promise<WebAssembly.Instance> {
|
|
46
|
+
validateImports(imports, "instantiate");
|
|
47
|
+
|
|
48
|
+
const wasmPath = process.env.AS_TEST_WASM_PATH;
|
|
49
|
+
if (!wasmPath || !wasmPath.length) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
"AS_TEST_WASM_PATH is not set; as-test must resolve the wasm artifact before launching the runner",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const target = (process.env.AS_TEST_RUNTIME_TARGET || "bindings") as RuntimeTarget;
|
|
55
|
+
if (target == "wasi") {
|
|
56
|
+
return instantiateWasiInstance(wasmPath, imports);
|
|
57
|
+
}
|
|
58
|
+
if (target == "web") {
|
|
59
|
+
return instantiateWebInstance(wasmPath, imports);
|
|
60
|
+
}
|
|
61
|
+
const kind = (process.env.AS_TEST_BINDINGS_KIND || "none") as BindingsKind;
|
|
62
|
+
|
|
63
|
+
if (kind == "raw") {
|
|
64
|
+
return instantiateRawInstance(wasmPath, imports);
|
|
65
|
+
}
|
|
66
|
+
if (kind == "esm") {
|
|
67
|
+
return instantiateEsmInstance(wasmPath, imports);
|
|
68
|
+
}
|
|
69
|
+
if (kind == "none") {
|
|
70
|
+
return instantiateNoBindingsInstance(wasmPath, imports);
|
|
71
|
+
}
|
|
72
|
+
throw new Error(`unsupported bindings kind "${kind}"`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function validateImports(
|
|
76
|
+
imports: WebAssembly.Imports,
|
|
77
|
+
fnName: string,
|
|
78
|
+
): asserts imports is AnyImports {
|
|
79
|
+
if (arguments.length < 1) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`${fnName}(imports) requires an imports object; pass {} when unused`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (!imports || typeof imports != "object" || Array.isArray(imports)) {
|
|
85
|
+
throw new Error(`${fnName}(imports) requires a non-null imports object`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function patchNodeIo(): void {
|
|
90
|
+
if (patchedNodeIo) return;
|
|
91
|
+
patchedNodeIo = true;
|
|
92
|
+
|
|
93
|
+
const originalWrite = process.stdout.write.bind(process.stdout);
|
|
94
|
+
process.stdout.write = ((chunk: unknown, ...args: unknown[]) => {
|
|
95
|
+
if (chunk instanceof ArrayBuffer) {
|
|
96
|
+
writeRaw(chunk);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
return originalWrite(chunk as never, ...(args as never[]));
|
|
100
|
+
}) as typeof process.stdout.write;
|
|
101
|
+
|
|
102
|
+
process.stdin.read = ((size?: number | null) =>
|
|
103
|
+
readExact(Number(size ?? 0))) as typeof process.stdin.read;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function readExact(length: number): ArrayBuffer {
|
|
107
|
+
const out = Buffer.alloc(length);
|
|
108
|
+
let offset = 0;
|
|
109
|
+
while (offset < length) {
|
|
110
|
+
let read = 0;
|
|
111
|
+
try {
|
|
112
|
+
read = fs.readSync(0, out, offset, length - offset, null);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (
|
|
115
|
+
error &&
|
|
116
|
+
typeof error == "object" &&
|
|
117
|
+
"code" in error &&
|
|
118
|
+
error.code == "EAGAIN"
|
|
119
|
+
) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
if (!read) break;
|
|
125
|
+
offset += read;
|
|
126
|
+
}
|
|
127
|
+
const view = out.subarray(0, offset);
|
|
128
|
+
return view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function writeRaw(data: ArrayBuffer): void {
|
|
132
|
+
const view = Buffer.from(data);
|
|
133
|
+
let offset = 0;
|
|
134
|
+
while (offset < view.byteLength) {
|
|
135
|
+
let written = 0;
|
|
136
|
+
try {
|
|
137
|
+
written = fs.writeSync(1, view, offset, view.byteLength - offset);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (
|
|
140
|
+
error &&
|
|
141
|
+
typeof error == "object" &&
|
|
142
|
+
"code" in error &&
|
|
143
|
+
error.code == "EAGAIN"
|
|
144
|
+
) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
if (!written) continue;
|
|
150
|
+
offset += written;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function mergeImports(...groups: unknown[]): AnyImports {
|
|
155
|
+
const out: Record<string, unknown> = {};
|
|
156
|
+
for (const group of groups) {
|
|
157
|
+
if (!group || typeof group != "object" || Array.isArray(group)) continue;
|
|
158
|
+
for (const [key, value] of Object.entries(group)) {
|
|
159
|
+
if (
|
|
160
|
+
value &&
|
|
161
|
+
typeof value == "object" &&
|
|
162
|
+
!Array.isArray(value) &&
|
|
163
|
+
typeof value != "function"
|
|
164
|
+
) {
|
|
165
|
+
out[key] = mergeImports(out[key], value);
|
|
166
|
+
} else {
|
|
167
|
+
out[key] = value;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return out as AnyImports;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function instantiateRawInstance(
|
|
175
|
+
wasmPath: string,
|
|
176
|
+
imports: WebAssembly.Imports,
|
|
177
|
+
): Promise<WebAssembly.Instance> {
|
|
178
|
+
validateImports(imports, "instantiateRawInstance");
|
|
179
|
+
const helperPath = process.env.AS_TEST_HELPER_PATH || "";
|
|
180
|
+
if (!helperPath.length) {
|
|
181
|
+
throw new Error("bindings kind is raw but AS_TEST_HELPER_PATH is not set");
|
|
182
|
+
}
|
|
183
|
+
const binary = fs.readFileSync(wasmPath);
|
|
184
|
+
const module = new WebAssembly.Module(binary);
|
|
185
|
+
const helper = (await import(`${pathToFileURL(helperPath).href}?t=${Date.now()}`)) as {
|
|
186
|
+
instantiate?: (
|
|
187
|
+
module: WebAssembly.Module,
|
|
188
|
+
imports?: WebAssembly.Imports,
|
|
189
|
+
) => Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
190
|
+
};
|
|
191
|
+
if (typeof helper.instantiate != "function") {
|
|
192
|
+
throw new Error("bindings helper missing instantiate export");
|
|
193
|
+
}
|
|
194
|
+
const mergedImports = mergeImports(withNodeIo({}), imports);
|
|
195
|
+
const instance = await captureHelperInstance(async () => {
|
|
196
|
+
await helper.instantiate!(module, mergedImports);
|
|
197
|
+
});
|
|
198
|
+
return decorateInstance(instance, "bindings");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function instantiateEsmInstance(
|
|
202
|
+
wasmPath: string,
|
|
203
|
+
imports: WebAssembly.Imports,
|
|
204
|
+
): Promise<WebAssembly.Instance> {
|
|
205
|
+
validateImports(imports, "instantiateEsmInstance");
|
|
206
|
+
const helperPath = process.env.AS_TEST_HELPER_PATH || "";
|
|
207
|
+
if (!helperPath.length) {
|
|
208
|
+
throw new Error("bindings kind is esm but AS_TEST_HELPER_PATH is not set");
|
|
209
|
+
}
|
|
210
|
+
if (hasUserImports(imports)) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
"esm bindings do not support custom imports in as-test/lib; pass {} or switch to raw bindings",
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
const instance = await captureHelperInstance(async () => {
|
|
216
|
+
await import(`${pathToFileURL(helperPath).href}?t=${Date.now()}`);
|
|
217
|
+
});
|
|
218
|
+
return decorateInstance(instance, "bindings");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function instantiateNoBindingsInstance(
|
|
222
|
+
wasmPath: string,
|
|
223
|
+
imports: WebAssembly.Imports,
|
|
224
|
+
): Promise<WebAssembly.Instance> {
|
|
225
|
+
validateImports(imports, "instantiateNoBindingsInstance");
|
|
226
|
+
const instance = await instantiateModuleInstance(wasmPath, imports);
|
|
227
|
+
return decorateInstance(instance, "bindings");
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function instantiateWasiInstance(
|
|
231
|
+
wasmPath: string,
|
|
232
|
+
imports: WebAssembly.Imports,
|
|
233
|
+
): Promise<WebAssembly.Instance> {
|
|
234
|
+
validateImports(imports, "instantiateWasiInstance");
|
|
235
|
+
suppressExperimentalWasiWarning();
|
|
236
|
+
const binary = fs.readFileSync(wasmPath);
|
|
237
|
+
const module = new WebAssembly.Module(binary);
|
|
238
|
+
const wasi = new WASI({
|
|
239
|
+
version: "preview1",
|
|
240
|
+
args: [wasmPath],
|
|
241
|
+
env: process.env,
|
|
242
|
+
preopens: {},
|
|
243
|
+
});
|
|
244
|
+
const mergedImports = createWasmImports(module, imports);
|
|
245
|
+
mergedImports.wasi_snapshot_preview1 = wasi.wasiImport;
|
|
246
|
+
const instance = new WebAssembly.Instance(module, mergedImports);
|
|
247
|
+
wasiInstances.set(instance, wasi);
|
|
248
|
+
return decorateInstance(instance, "wasi");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function instantiateWebInstance(
|
|
252
|
+
wasmPath: string,
|
|
253
|
+
imports: WebAssembly.Imports,
|
|
254
|
+
): Promise<WebAssembly.Instance> {
|
|
255
|
+
validateImports(imports, "instantiateWebInstance");
|
|
256
|
+
if (hasUserImports(imports)) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
"web runtime does not support custom imports in the default runner; pass {} or write a custom web runner",
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const bindingsKind = (process.env.AS_TEST_BINDINGS_KIND || "raw") as BindingsKind;
|
|
263
|
+
const helperPath = process.env.AS_TEST_HELPER_PATH
|
|
264
|
+
? path.resolve(process.cwd(), process.env.AS_TEST_HELPER_PATH)
|
|
265
|
+
: wasmPath.replace(/\.wasm$/, ".js");
|
|
266
|
+
const wasmUrlPath = "/" + path.basename(wasmPath);
|
|
267
|
+
const helperUrlPath = "/" + path.basename(helperPath);
|
|
268
|
+
if (!fs.existsSync(wasmPath)) {
|
|
269
|
+
throw new Error(`missing wasm artifact: ${wasmPath}`);
|
|
270
|
+
}
|
|
271
|
+
if (bindingsKind != "none" && !fs.existsSync(helperPath)) {
|
|
272
|
+
throw new Error(`missing bindings helper: ${helperPath}`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const html = buildWebRunnerHtml();
|
|
276
|
+
const client = buildWebRunnerClientSource();
|
|
277
|
+
const worker = buildWebRunnerWorkerSource();
|
|
278
|
+
const headless = process.argv.includes("--headless");
|
|
279
|
+
const webRuntimeEnv = {
|
|
280
|
+
AS_TEST_RUNTIME_TARGET: "web",
|
|
281
|
+
AS_TEST_WASM_PATH: wasmUrlPath,
|
|
282
|
+
AS_TEST_BINDINGS_KIND: bindingsKind,
|
|
283
|
+
...(bindingsKind != "none" ? { AS_TEST_HELPER_PATH: helperUrlPath } : {}),
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
return new Promise<WebAssembly.Instance>((resolve, reject) => {
|
|
287
|
+
let resolved = false;
|
|
288
|
+
let finished = false;
|
|
289
|
+
let ready = false;
|
|
290
|
+
let wsSocket: Duplex | null = null;
|
|
291
|
+
let wsBuffer = Buffer.alloc(0);
|
|
292
|
+
let stdinBuffer = Buffer.alloc(0);
|
|
293
|
+
let browserProcess: ChildProcess | null = null;
|
|
294
|
+
let browserStderr = "";
|
|
295
|
+
let browserRetryTimer: NodeJS.Timeout | null = null;
|
|
296
|
+
let browserStartupTimer: NodeJS.Timeout | null = null;
|
|
297
|
+
let browserTempProfileDir: string | null = null;
|
|
298
|
+
let ownsBrowserProcess = false;
|
|
299
|
+
const pendingFrames: Buffer[] = [];
|
|
300
|
+
const rejectOnce = (error: Error) => {
|
|
301
|
+
if (resolved || finished) return;
|
|
302
|
+
finished = true;
|
|
303
|
+
reject(error);
|
|
304
|
+
cleanup();
|
|
305
|
+
};
|
|
306
|
+
const finish = (code: number) => {
|
|
307
|
+
if (finished) return;
|
|
308
|
+
finished = true;
|
|
309
|
+
cleanup();
|
|
310
|
+
if (!resolved && code != 0) {
|
|
311
|
+
reject(new Error(`web runtime exited with code ${code}`));
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (!resolved) {
|
|
315
|
+
reject(new Error("web runtime exited before instantiation completed"));
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
const cleanup = () => {
|
|
319
|
+
process.stdin.off("data", onStdinData);
|
|
320
|
+
process.stdin.off("end", onStdinEnd);
|
|
321
|
+
try {
|
|
322
|
+
process.stdin.pause();
|
|
323
|
+
} catch {}
|
|
324
|
+
process.off("SIGINT", onSigint);
|
|
325
|
+
process.off("SIGTERM", onSigterm);
|
|
326
|
+
try {
|
|
327
|
+
wsSocket?.end();
|
|
328
|
+
} catch {}
|
|
329
|
+
try {
|
|
330
|
+
wsSocket?.destroy();
|
|
331
|
+
} catch {}
|
|
332
|
+
try {
|
|
333
|
+
server.close();
|
|
334
|
+
} catch {}
|
|
335
|
+
try {
|
|
336
|
+
server.unref();
|
|
337
|
+
} catch {}
|
|
338
|
+
if (browserRetryTimer) {
|
|
339
|
+
clearInterval(browserRetryTimer);
|
|
340
|
+
browserRetryTimer = null;
|
|
341
|
+
}
|
|
342
|
+
if (browserStartupTimer) {
|
|
343
|
+
clearTimeout(browserStartupTimer);
|
|
344
|
+
browserStartupTimer = null;
|
|
345
|
+
}
|
|
346
|
+
if (browserTempProfileDir) {
|
|
347
|
+
try {
|
|
348
|
+
fs.rmSync(browserTempProfileDir, { recursive: true, force: true });
|
|
349
|
+
} catch {}
|
|
350
|
+
browserTempProfileDir = null;
|
|
351
|
+
}
|
|
352
|
+
if (browserProcess && ownsBrowserProcess && !browserProcess.killed) {
|
|
353
|
+
killOwnedBrowserProcess(browserProcess);
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
const sendControl = (message: Record<string, unknown>) => {
|
|
357
|
+
if (!wsSocket) return;
|
|
358
|
+
sendWebSocketFrame(wsSocket, 0x1, Buffer.from(JSON.stringify(message)));
|
|
359
|
+
};
|
|
360
|
+
const flushPendingFrames = () => {
|
|
361
|
+
if (!ready || !wsSocket) return;
|
|
362
|
+
while (pendingFrames.length) {
|
|
363
|
+
sendWebSocketFrame(wsSocket, 0x2, pendingFrames.shift()!);
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
const onControl = (raw: string) => {
|
|
367
|
+
let message: Record<string, unknown> | null = null;
|
|
368
|
+
try {
|
|
369
|
+
message = JSON.parse(raw) as Record<string, unknown>;
|
|
370
|
+
} catch {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (message?.kind == "ready") {
|
|
374
|
+
ready = true;
|
|
375
|
+
flushPendingFrames();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (message?.kind == "instantiated") {
|
|
379
|
+
if (resolved) return;
|
|
380
|
+
resolved = true;
|
|
381
|
+
resolve(createWebInstanceController(() => {
|
|
382
|
+
sendControl({ kind: "start" });
|
|
383
|
+
}));
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (message?.kind == "done") {
|
|
387
|
+
finish(0);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (message?.kind == "error") {
|
|
391
|
+
rejectOnce(
|
|
392
|
+
new Error(String(message.message ?? "browser runtime failed")),
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
const onWebSocketData = (chunk: Buffer) => {
|
|
397
|
+
wsBuffer = Buffer.concat([wsBuffer, chunk]);
|
|
398
|
+
while (wsBuffer.length >= 2) {
|
|
399
|
+
const first = wsBuffer[0]!;
|
|
400
|
+
const second = wsBuffer[1]!;
|
|
401
|
+
const opcode = first & 0x0f;
|
|
402
|
+
const masked = (second & 0x80) !== 0;
|
|
403
|
+
let length = second & 0x7f;
|
|
404
|
+
let offset = 2;
|
|
405
|
+
if (length == 126) {
|
|
406
|
+
if (wsBuffer.length < offset + 2) return;
|
|
407
|
+
length = wsBuffer.readUInt16BE(offset);
|
|
408
|
+
offset += 2;
|
|
409
|
+
} else if (length == 127) {
|
|
410
|
+
if (wsBuffer.length < offset + 8) return;
|
|
411
|
+
length = Number(wsBuffer.readBigUInt64BE(offset));
|
|
412
|
+
offset += 8;
|
|
413
|
+
}
|
|
414
|
+
const maskLength = masked ? 4 : 0;
|
|
415
|
+
if (wsBuffer.length < offset + maskLength + length) return;
|
|
416
|
+
let payload = wsBuffer.subarray(
|
|
417
|
+
offset + maskLength,
|
|
418
|
+
offset + maskLength + length,
|
|
419
|
+
);
|
|
420
|
+
if (masked) {
|
|
421
|
+
const mask = wsBuffer.subarray(offset, offset + 4);
|
|
422
|
+
const unmasked = Buffer.alloc(length);
|
|
423
|
+
for (let i = 0; i < length; i++) {
|
|
424
|
+
unmasked[i] = payload[i]! ^ mask[i % 4]!;
|
|
425
|
+
}
|
|
426
|
+
payload = unmasked;
|
|
427
|
+
} else {
|
|
428
|
+
payload = Buffer.from(payload);
|
|
429
|
+
}
|
|
430
|
+
wsBuffer = wsBuffer.subarray(offset + maskLength + length);
|
|
431
|
+
if (opcode == 0x8) {
|
|
432
|
+
finish(0);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
if (opcode == 0x1) {
|
|
436
|
+
onControl(payload.toString("utf8"));
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
if (opcode == 0x2) {
|
|
440
|
+
process.stdout.write(payload);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
const onStdinData = (chunk: Buffer) => {
|
|
445
|
+
stdinBuffer = Buffer.concat([stdinBuffer, chunk]);
|
|
446
|
+
while (stdinBuffer.length >= 9) {
|
|
447
|
+
const length = stdinBuffer.readUInt32LE(5);
|
|
448
|
+
const frameSize = 9 + length;
|
|
449
|
+
if (stdinBuffer.length < frameSize) return;
|
|
450
|
+
const frame = stdinBuffer.subarray(0, frameSize);
|
|
451
|
+
stdinBuffer = stdinBuffer.subarray(frameSize);
|
|
452
|
+
if (ready && wsSocket) {
|
|
453
|
+
sendWebSocketFrame(wsSocket, 0x2, frame);
|
|
454
|
+
} else {
|
|
455
|
+
pendingFrames.push(Buffer.from(frame));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
const onStdinEnd = () => {
|
|
460
|
+
stdinBuffer = Buffer.alloc(0);
|
|
461
|
+
};
|
|
462
|
+
const onSigint = () => finish(130);
|
|
463
|
+
const onSigterm = () => finish(143);
|
|
464
|
+
|
|
465
|
+
const server = http.createServer((req, res) => {
|
|
466
|
+
const headers = {
|
|
467
|
+
"Cross-Origin-Embedder-Policy": "require-corp",
|
|
468
|
+
"Cross-Origin-Opener-Policy": "same-origin",
|
|
469
|
+
"Cache-Control": "no-store",
|
|
470
|
+
};
|
|
471
|
+
const url = req.url ?? "/";
|
|
472
|
+
if (url == "/" || url.startsWith("/?")) {
|
|
473
|
+
res.writeHead(200, {
|
|
474
|
+
...headers,
|
|
475
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
476
|
+
});
|
|
477
|
+
res.end(
|
|
478
|
+
html.replace(
|
|
479
|
+
"</body>",
|
|
480
|
+
' <script>window.__AS_TEST_ENV__ = ' +
|
|
481
|
+
JSON.stringify(webRuntimeEnv) +
|
|
482
|
+
';</script>\n </body>',
|
|
483
|
+
),
|
|
484
|
+
);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (url == "/client.js") {
|
|
488
|
+
res.writeHead(200, {
|
|
489
|
+
...headers,
|
|
490
|
+
"Content-Type": "text/javascript; charset=utf-8",
|
|
491
|
+
});
|
|
492
|
+
res.end(client);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (url == "/worker.js") {
|
|
496
|
+
res.writeHead(200, {
|
|
497
|
+
...headers,
|
|
498
|
+
"Content-Type": "text/javascript; charset=utf-8",
|
|
499
|
+
});
|
|
500
|
+
res.end(worker);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
if (url == helperUrlPath) {
|
|
504
|
+
if (bindingsKind == "none") {
|
|
505
|
+
res.writeHead(404, headers);
|
|
506
|
+
res.end("not found");
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
res.writeHead(200, {
|
|
510
|
+
...headers,
|
|
511
|
+
"Content-Type": "text/javascript; charset=utf-8",
|
|
512
|
+
});
|
|
513
|
+
res.end(fs.readFileSync(helperPath, "utf8"));
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (url == wasmUrlPath) {
|
|
517
|
+
res.writeHead(200, {
|
|
518
|
+
...headers,
|
|
519
|
+
"Content-Type": "application/wasm",
|
|
520
|
+
});
|
|
521
|
+
res.end(fs.readFileSync(wasmPath));
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
res.writeHead(404, headers);
|
|
525
|
+
res.end("not found");
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
server.on("upgrade", (req, socket) => {
|
|
529
|
+
if ((req.url ?? "") != "/ws") {
|
|
530
|
+
socket.destroy();
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const key = String(req.headers["sec-websocket-key"] ?? "");
|
|
534
|
+
if (!key) {
|
|
535
|
+
socket.destroy();
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
const accept = createHash("sha1")
|
|
539
|
+
.update(key + WEB_MAGIC)
|
|
540
|
+
.digest("base64");
|
|
541
|
+
socket.write(
|
|
542
|
+
[
|
|
543
|
+
"HTTP/1.1 101 Switching Protocols",
|
|
544
|
+
"Upgrade: websocket",
|
|
545
|
+
"Connection: Upgrade",
|
|
546
|
+
"Sec-WebSocket-Accept: " + accept,
|
|
547
|
+
"",
|
|
548
|
+
"",
|
|
549
|
+
].join("\r\n"),
|
|
550
|
+
);
|
|
551
|
+
wsSocket = socket;
|
|
552
|
+
wsBuffer = Buffer.alloc(0);
|
|
553
|
+
if (browserStartupTimer) {
|
|
554
|
+
clearTimeout(browserStartupTimer);
|
|
555
|
+
browserStartupTimer = null;
|
|
556
|
+
}
|
|
557
|
+
socket.on("data", (chunk) => onWebSocketData(chunk));
|
|
558
|
+
socket.on("close", () => {
|
|
559
|
+
wsSocket = null;
|
|
560
|
+
if (!finished) finish(1);
|
|
561
|
+
});
|
|
562
|
+
socket.on("error", (error) => {
|
|
563
|
+
if (!finished) {
|
|
564
|
+
rejectOnce(
|
|
565
|
+
error instanceof Error
|
|
566
|
+
? error
|
|
567
|
+
: new Error(String(error)),
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
flushPendingFrames();
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
process.stdin.on("data", onStdinData);
|
|
575
|
+
process.stdin.on("end", onStdinEnd);
|
|
576
|
+
process.on("SIGINT", onSigint);
|
|
577
|
+
process.on("SIGTERM", onSigterm);
|
|
578
|
+
|
|
579
|
+
server.listen(0, "127.0.0.1", () => {
|
|
580
|
+
const address = server.address();
|
|
581
|
+
if (!address || typeof address == "string") {
|
|
582
|
+
rejectOnce(new Error("failed to determine local web runner address"));
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
const url = "http://127.0.0.1:" + address.port + "/";
|
|
586
|
+
try {
|
|
587
|
+
const launched = launchWebBrowser(url, headless);
|
|
588
|
+
browserProcess = launched.process;
|
|
589
|
+
browserTempProfileDir = launched.tempProfileDir;
|
|
590
|
+
ownsBrowserProcess = launched.ownsProcess;
|
|
591
|
+
if (browserProcess.stderr) {
|
|
592
|
+
browserProcess.stderr.on("data", (chunk: Buffer | string) => {
|
|
593
|
+
browserStderr = appendBrowserOutput(
|
|
594
|
+
browserStderr,
|
|
595
|
+
typeof chunk == "string" ? chunk : chunk.toString("utf8"),
|
|
596
|
+
);
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
if (!headless) {
|
|
600
|
+
browserRetryTimer = setInterval(() => {
|
|
601
|
+
if (finished || resolved || ready || wsSocket) return;
|
|
602
|
+
try {
|
|
603
|
+
openWithReusableBrowserWindow(url);
|
|
604
|
+
} catch {}
|
|
605
|
+
}, 750);
|
|
606
|
+
browserRetryTimer.unref?.();
|
|
607
|
+
}
|
|
608
|
+
if (headless) {
|
|
609
|
+
browserStartupTimer = setTimeout(() => {
|
|
610
|
+
if (finished || resolved || ready || wsSocket) return;
|
|
611
|
+
rejectOnce(
|
|
612
|
+
new Error(
|
|
613
|
+
"headless web browser did not connect to the local runner",
|
|
614
|
+
),
|
|
615
|
+
);
|
|
616
|
+
}, 10000);
|
|
617
|
+
browserStartupTimer.unref?.();
|
|
618
|
+
browserProcess.on("close", (code) => {
|
|
619
|
+
if (finished) return;
|
|
620
|
+
if (resolved) {
|
|
621
|
+
finish(code ?? 0);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
if (code && code != 0) {
|
|
625
|
+
rejectOnce(new Error(formatBrowserExitError(code, browserStderr)));
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
if (ready || wsSocket) {
|
|
629
|
+
finish(code ?? 0);
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
} catch (error) {
|
|
634
|
+
rejectOnce(
|
|
635
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function hasUserImports(imports: AnyImports): boolean {
|
|
643
|
+
return Object.keys(imports).length > 0;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function createWasmImports(
|
|
647
|
+
module: WebAssembly.Module,
|
|
648
|
+
imports: AnyImports,
|
|
649
|
+
): AnyImports {
|
|
650
|
+
const mergedImports = mergeImports(withNodeIo({}), imports);
|
|
651
|
+
if (!mergedImports.env || typeof mergedImports.env != "object") {
|
|
652
|
+
mergedImports.env = {};
|
|
653
|
+
}
|
|
654
|
+
for (const entry of WebAssembly.Module.imports(module)) {
|
|
655
|
+
if (
|
|
656
|
+
entry.module == "env" &&
|
|
657
|
+
entry.kind == "function" &&
|
|
658
|
+
!(entry.name in mergedImports.env)
|
|
659
|
+
) {
|
|
660
|
+
mergedImports.env[entry.name] = () => 0;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return mergedImports;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
let patchedWasiWarning = false;
|
|
667
|
+
|
|
668
|
+
function suppressExperimentalWasiWarning(): void {
|
|
669
|
+
if (patchedWasiWarning) return;
|
|
670
|
+
patchedWasiWarning = true;
|
|
671
|
+
const originalEmitWarning = process.emitWarning.bind(process);
|
|
672
|
+
process.emitWarning = ((warning: unknown, ...args: unknown[]) => {
|
|
673
|
+
const type = typeof args[0] == "string" ? args[0] : "";
|
|
674
|
+
const name =
|
|
675
|
+
warning && typeof warning == "object" && "name" in warning
|
|
676
|
+
? String((warning as { name?: unknown }).name ?? type)
|
|
677
|
+
: type;
|
|
678
|
+
const message =
|
|
679
|
+
typeof warning == "string"
|
|
680
|
+
? warning
|
|
681
|
+
: String(
|
|
682
|
+
warning && typeof warning == "object" && "message" in warning
|
|
683
|
+
? (warning as { message?: unknown }).message ?? ""
|
|
684
|
+
: "",
|
|
685
|
+
);
|
|
686
|
+
if (
|
|
687
|
+
name == "ExperimentalWarning" &&
|
|
688
|
+
message.includes("WASI is an experimental feature")
|
|
689
|
+
) {
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
return originalEmitWarning(warning as never, ...(args as never[]));
|
|
693
|
+
}) as typeof process.emitWarning;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
async function instantiateModuleInstance(
|
|
697
|
+
wasmPath: string,
|
|
698
|
+
imports: WebAssembly.Imports,
|
|
699
|
+
): Promise<WebAssembly.Instance> {
|
|
700
|
+
const binary = fs.readFileSync(wasmPath);
|
|
701
|
+
const module = new WebAssembly.Module(binary);
|
|
702
|
+
return new WebAssembly.Instance(module, createWasmImports(module, imports));
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function decorateInstance(
|
|
706
|
+
instance: WebAssembly.Instance,
|
|
707
|
+
target: RuntimeTarget,
|
|
708
|
+
): WebAssembly.Instance {
|
|
709
|
+
const exports = instance.exports as ExportMap;
|
|
710
|
+
const start = createStartFunction(instance, target, exports);
|
|
711
|
+
if (!start) return instance;
|
|
712
|
+
|
|
713
|
+
const exportsProxy = new Proxy(exports, {
|
|
714
|
+
get(targetExports, prop, receiver) {
|
|
715
|
+
if (prop == "start") return start;
|
|
716
|
+
return Reflect.get(targetExports, prop, receiver);
|
|
717
|
+
},
|
|
718
|
+
has(targetExports, prop) {
|
|
719
|
+
if (prop == "start") return true;
|
|
720
|
+
return Reflect.has(targetExports, prop);
|
|
721
|
+
},
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
return new Proxy(instance, {
|
|
725
|
+
get(targetInstance, prop, receiver) {
|
|
726
|
+
if (prop == "exports") return exportsProxy;
|
|
727
|
+
return Reflect.get(targetInstance, prop, receiver);
|
|
728
|
+
},
|
|
729
|
+
}) as StartedInstance;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function createStartFunction(
|
|
733
|
+
instance: WebAssembly.Instance,
|
|
734
|
+
target: RuntimeTarget,
|
|
735
|
+
exports: ExportMap,
|
|
736
|
+
): (() => void) | null {
|
|
737
|
+
if (target == "wasi") {
|
|
738
|
+
return () => {
|
|
739
|
+
const wasi = wasiInstances.get(instance);
|
|
740
|
+
if (!wasi) {
|
|
741
|
+
throw new Error("WASI runtime state missing for instance");
|
|
742
|
+
}
|
|
743
|
+
wasi.start(instance);
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
const startFn = exports._start;
|
|
747
|
+
if (typeof startFn != "function") {
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
return () => {
|
|
751
|
+
(startFn as () => unknown)();
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function createWebInstanceController(start: () => void): WebAssembly.Instance {
|
|
756
|
+
const exportsProxy = new Proxy(
|
|
757
|
+
{},
|
|
758
|
+
{
|
|
759
|
+
get(_target, prop) {
|
|
760
|
+
if (prop == "start") return start;
|
|
761
|
+
return undefined;
|
|
762
|
+
},
|
|
763
|
+
has(_target, prop) {
|
|
764
|
+
return prop == "start";
|
|
765
|
+
},
|
|
766
|
+
},
|
|
767
|
+
);
|
|
768
|
+
return new Proxy(
|
|
769
|
+
{},
|
|
770
|
+
{
|
|
771
|
+
get(_target, prop) {
|
|
772
|
+
if (prop == "exports") return exportsProxy;
|
|
773
|
+
return undefined;
|
|
774
|
+
},
|
|
775
|
+
has(_target, prop) {
|
|
776
|
+
return prop == "exports";
|
|
777
|
+
},
|
|
778
|
+
},
|
|
779
|
+
) as WebAssembly.Instance;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function sendWebSocketFrame(
|
|
783
|
+
socket: Duplex,
|
|
784
|
+
opcode: number,
|
|
785
|
+
payload: Buffer,
|
|
786
|
+
): void {
|
|
787
|
+
let header: Buffer;
|
|
788
|
+
if (payload.length < 126) {
|
|
789
|
+
header = Buffer.from([0x80 | opcode, payload.length]);
|
|
790
|
+
} else if (payload.length < 65536) {
|
|
791
|
+
header = Buffer.alloc(4);
|
|
792
|
+
header[0] = 0x80 | opcode;
|
|
793
|
+
header[1] = 126;
|
|
794
|
+
header.writeUInt16BE(payload.length, 2);
|
|
795
|
+
} else {
|
|
796
|
+
header = Buffer.alloc(10);
|
|
797
|
+
header[0] = 0x80 | opcode;
|
|
798
|
+
header[1] = 127;
|
|
799
|
+
header.writeBigUInt64BE(BigInt(payload.length), 2);
|
|
800
|
+
}
|
|
801
|
+
socket.write(Buffer.concat([header, payload]));
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function launchWebBrowser(
|
|
805
|
+
url: string,
|
|
806
|
+
headless: boolean,
|
|
807
|
+
): {
|
|
808
|
+
process: ChildProcess;
|
|
809
|
+
tempProfileDir: string | null;
|
|
810
|
+
ownsProcess: boolean;
|
|
811
|
+
} {
|
|
812
|
+
if (!headless) {
|
|
813
|
+
const reused = openWithReusableBrowserWindow(url);
|
|
814
|
+
if (reused) {
|
|
815
|
+
return { process: reused, tempProfileDir: null, ownsProcess: false };
|
|
816
|
+
}
|
|
817
|
+
const opener = openWithSystemBrowser(url);
|
|
818
|
+
if (opener) {
|
|
819
|
+
return { process: opener, tempProfileDir: null, ownsProcess: false };
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
const direct = openWithInstalledBrowser(url, headless);
|
|
823
|
+
if (direct) return direct;
|
|
824
|
+
throw new Error(
|
|
825
|
+
headless
|
|
826
|
+
? "could not find a headless-capable browser; set BROWSER to a Chromium/Firefox executable"
|
|
827
|
+
: "could not open a browser automatically; set BROWSER to a browser executable",
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function openWithReusableBrowserWindow(url: string): ChildProcess | null {
|
|
832
|
+
if (process.platform != "darwin") return null;
|
|
833
|
+
if (!hasExecutable("osascript")) return null;
|
|
834
|
+
const browserApp = resolveMacBrowserAppName(process.env.BROWSER?.trim() ?? "");
|
|
835
|
+
if (!browserApp) return null;
|
|
836
|
+
const script = buildMacBrowserOpenScript(browserApp, url);
|
|
837
|
+
if (!script.length) return null;
|
|
838
|
+
return spawn(
|
|
839
|
+
"osascript",
|
|
840
|
+
script.flatMap((line) => ["-e", line]),
|
|
841
|
+
{ stdio: "ignore", detached: true },
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function openWithSystemBrowser(url: string): ChildProcess | null {
|
|
846
|
+
if (process.env.BROWSER) {
|
|
847
|
+
return spawnBrowserCommand(process.env.BROWSER, url, false)?.process ?? null;
|
|
848
|
+
}
|
|
849
|
+
if (process.platform == "darwin") {
|
|
850
|
+
if (!hasExecutable("open")) return null;
|
|
851
|
+
return spawn("open", [url], { stdio: "ignore", detached: true });
|
|
852
|
+
}
|
|
853
|
+
if (process.platform == "win32") {
|
|
854
|
+
if (!hasExecutable("cmd")) return null;
|
|
855
|
+
return spawn("cmd", ["/c", "start", "", url], {
|
|
856
|
+
stdio: "ignore",
|
|
857
|
+
detached: true,
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
if (!hasExecutable("xdg-open")) return null;
|
|
861
|
+
return spawn("xdg-open", [url], { stdio: "ignore", detached: true });
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function resolveMacBrowserAppName(browser: string): string | null {
|
|
865
|
+
const trimmed = browser.trim();
|
|
866
|
+
if (!trimmed.length) return null;
|
|
867
|
+
const extracted = extractMacAppNameFromExecutable(trimmed);
|
|
868
|
+
if (extracted) return extracted;
|
|
869
|
+
|
|
870
|
+
const command = splitCommand(trimmed)[0]?.toLowerCase() ?? "";
|
|
871
|
+
if (!command.length) return null;
|
|
872
|
+
const aliases: Record<string, string> = {
|
|
873
|
+
chrome: "Google Chrome",
|
|
874
|
+
"google-chrome": "Google Chrome",
|
|
875
|
+
"google-chrome-stable": "Google Chrome",
|
|
876
|
+
chromium: "Chromium",
|
|
877
|
+
"chromium-browser": "Chromium",
|
|
878
|
+
msedge: "Microsoft Edge",
|
|
879
|
+
firefox: "Firefox",
|
|
880
|
+
safari: "Safari",
|
|
881
|
+
};
|
|
882
|
+
return aliases[command] ?? null;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function extractMacAppNameFromExecutable(browser: string): string | null {
|
|
886
|
+
const appMatch = browser.match(/\/([^/]+)\.app\/Contents\/MacOS\//);
|
|
887
|
+
if (!appMatch?.[1]) return null;
|
|
888
|
+
return appMatch[1];
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function buildMacBrowserOpenScript(appName: string, url: string): string[] {
|
|
892
|
+
const escapedApp = escapeAppleScriptString(appName);
|
|
893
|
+
const escapedUrl = escapeAppleScriptString(url);
|
|
894
|
+
const lower = appName.toLowerCase();
|
|
895
|
+
|
|
896
|
+
if (
|
|
897
|
+
lower.includes("chrome") ||
|
|
898
|
+
lower.includes("chromium") ||
|
|
899
|
+
lower.includes("edge")
|
|
900
|
+
) {
|
|
901
|
+
return [
|
|
902
|
+
`tell application "${escapedApp}"`,
|
|
903
|
+
"activate",
|
|
904
|
+
'if (count of windows) = 0 then make new window',
|
|
905
|
+
`set URL of active tab of front window to "${escapedUrl}"`,
|
|
906
|
+
"end tell",
|
|
907
|
+
];
|
|
908
|
+
}
|
|
909
|
+
if (lower.includes("safari")) {
|
|
910
|
+
return [
|
|
911
|
+
`tell application "${escapedApp}"`,
|
|
912
|
+
"activate",
|
|
913
|
+
'if (count of windows) = 0 then make new document',
|
|
914
|
+
`set URL of front document to "${escapedUrl}"`,
|
|
915
|
+
"end tell",
|
|
916
|
+
];
|
|
917
|
+
}
|
|
918
|
+
if (lower.includes("firefox")) {
|
|
919
|
+
return [
|
|
920
|
+
`tell application "${escapedApp}"`,
|
|
921
|
+
"activate",
|
|
922
|
+
`open location "${escapedUrl}"`,
|
|
923
|
+
"end tell",
|
|
924
|
+
];
|
|
925
|
+
}
|
|
926
|
+
return [];
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function escapeAppleScriptString(value: string): string {
|
|
930
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function openWithInstalledBrowser(
|
|
934
|
+
url: string,
|
|
935
|
+
headless: boolean,
|
|
936
|
+
): {
|
|
937
|
+
process: ChildProcess;
|
|
938
|
+
tempProfileDir: string | null;
|
|
939
|
+
ownsProcess: boolean;
|
|
940
|
+
} | null {
|
|
941
|
+
const browserEnv = process.env.BROWSER;
|
|
942
|
+
if (browserEnv) {
|
|
943
|
+
return spawnBrowserCommand(browserEnv, url, headless);
|
|
944
|
+
}
|
|
945
|
+
const candidates = [
|
|
946
|
+
{ command: "chromium", headless: [...WEB_HEADLESS_FLAGS] },
|
|
947
|
+
{ command: "chromium-browser", headless: [...WEB_HEADLESS_FLAGS] },
|
|
948
|
+
{ command: "google-chrome", headless: [...WEB_HEADLESS_FLAGS] },
|
|
949
|
+
{ command: "google-chrome-stable", headless: [...WEB_HEADLESS_FLAGS] },
|
|
950
|
+
{ command: "chrome", headless: [...WEB_HEADLESS_FLAGS] },
|
|
951
|
+
{ command: "msedge", headless: [...WEB_HEADLESS_FLAGS] },
|
|
952
|
+
{ command: "firefox", headless: ["-headless"] },
|
|
953
|
+
];
|
|
954
|
+
for (const candidate of candidates) {
|
|
955
|
+
if (!hasExecutable(candidate.command)) continue;
|
|
956
|
+
return {
|
|
957
|
+
process: spawn(
|
|
958
|
+
candidate.command,
|
|
959
|
+
[...(headless ? candidate.headless : []), url],
|
|
960
|
+
{
|
|
961
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
962
|
+
detached: true,
|
|
963
|
+
},
|
|
964
|
+
),
|
|
965
|
+
tempProfileDir: null,
|
|
966
|
+
ownsProcess: true,
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
const playwrightFallback =
|
|
970
|
+
resolvePlaywrightBrowserExecutable("chromium") ??
|
|
971
|
+
resolvePlaywrightBrowserExecutable("firefox") ??
|
|
972
|
+
resolvePlaywrightBrowserExecutable("webkit");
|
|
973
|
+
if (playwrightFallback) {
|
|
974
|
+
return spawnBrowserCommand(playwrightFallback, url, headless);
|
|
975
|
+
}
|
|
976
|
+
return null;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function spawnBrowserCommand(
|
|
980
|
+
commandValue: string,
|
|
981
|
+
url: string,
|
|
982
|
+
headless: boolean,
|
|
983
|
+
): {
|
|
984
|
+
process: ChildProcess;
|
|
985
|
+
tempProfileDir: string | null;
|
|
986
|
+
ownsProcess: boolean;
|
|
987
|
+
} | null {
|
|
988
|
+
const directCommand = unwrapQuotedPath(String(commandValue).trim());
|
|
989
|
+
if (hasExecutable(directCommand)) {
|
|
990
|
+
const resolvedHeadless = headless
|
|
991
|
+
? resolveHeadlessLaunch(directCommand, directCommand)
|
|
992
|
+
: { flags: [], tempProfileDir: null };
|
|
993
|
+
const args = [...resolvedHeadless.flags];
|
|
994
|
+
args.push(url);
|
|
995
|
+
return {
|
|
996
|
+
process: spawn(directCommand, args, {
|
|
997
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
998
|
+
detached: true,
|
|
999
|
+
}),
|
|
1000
|
+
tempProfileDir: resolvedHeadless.tempProfileDir,
|
|
1001
|
+
ownsProcess: true,
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
const parts = splitCommand(String(commandValue));
|
|
1005
|
+
if (!parts.length) return null;
|
|
1006
|
+
const command = parts[0]!;
|
|
1007
|
+
if (!hasExecutable(command)) return null;
|
|
1008
|
+
const args = parts.slice(1);
|
|
1009
|
+
let tempProfileDir: string | null = null;
|
|
1010
|
+
if (headless) {
|
|
1011
|
+
const resolvedHeadless = resolveHeadlessLaunch(commandValue, command);
|
|
1012
|
+
args.push(...resolvedHeadless.flags);
|
|
1013
|
+
tempProfileDir = resolvedHeadless.tempProfileDir;
|
|
1014
|
+
}
|
|
1015
|
+
args.push(url);
|
|
1016
|
+
return {
|
|
1017
|
+
process: spawn(command, args, {
|
|
1018
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
1019
|
+
detached: true,
|
|
1020
|
+
}),
|
|
1021
|
+
tempProfileDir,
|
|
1022
|
+
ownsProcess: true,
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function splitCommand(commandValue: string): string[] {
|
|
1027
|
+
const parts: string[] = [];
|
|
1028
|
+
let current = "";
|
|
1029
|
+
let quote: "'" | '"' | "" = "";
|
|
1030
|
+
for (let i = 0; i < commandValue.length; i++) {
|
|
1031
|
+
const char = commandValue[i]!;
|
|
1032
|
+
if (quote) {
|
|
1033
|
+
if (char == quote) {
|
|
1034
|
+
quote = "";
|
|
1035
|
+
} else if (char == "\\" && i + 1 < commandValue.length) {
|
|
1036
|
+
current += commandValue[++i]!;
|
|
1037
|
+
} else {
|
|
1038
|
+
current += char;
|
|
1039
|
+
}
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
if (char == "'" || char == '"') {
|
|
1043
|
+
quote = char;
|
|
1044
|
+
continue;
|
|
1045
|
+
}
|
|
1046
|
+
if (/\s/.test(char)) {
|
|
1047
|
+
if (current.length) {
|
|
1048
|
+
parts.push(current);
|
|
1049
|
+
current = "";
|
|
1050
|
+
}
|
|
1051
|
+
continue;
|
|
1052
|
+
}
|
|
1053
|
+
if (char == "\\" && i + 1 < commandValue.length) {
|
|
1054
|
+
current += commandValue[++i]!;
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
current += char;
|
|
1058
|
+
}
|
|
1059
|
+
if (current.length) {
|
|
1060
|
+
parts.push(current);
|
|
1061
|
+
}
|
|
1062
|
+
return parts;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function appendBrowserOutput(current: string, next: string): string {
|
|
1066
|
+
const combined = current + next;
|
|
1067
|
+
if (combined.length <= 16384) return combined;
|
|
1068
|
+
return combined.slice(combined.length - 16384);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function formatBrowserExitError(code: number, stderr: string): string {
|
|
1072
|
+
const trimmed = stderr.trim();
|
|
1073
|
+
if (!trimmed.length) {
|
|
1074
|
+
return `web browser process exited with code ${code}`;
|
|
1075
|
+
}
|
|
1076
|
+
return `web browser process exited with code ${code}\nstderr:\n${trimmed}`;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function killOwnedBrowserProcess(browserProcess: ChildProcess): void {
|
|
1080
|
+
try {
|
|
1081
|
+
if (
|
|
1082
|
+
process.platform != "win32" &&
|
|
1083
|
+
typeof browserProcess.pid == "number" &&
|
|
1084
|
+
browserProcess.pid > 0
|
|
1085
|
+
) {
|
|
1086
|
+
process.kill(-browserProcess.pid, "SIGTERM");
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
} catch {}
|
|
1090
|
+
try {
|
|
1091
|
+
browserProcess.kill("SIGTERM");
|
|
1092
|
+
} catch {}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function unwrapQuotedPath(value: string): string {
|
|
1096
|
+
if (
|
|
1097
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
1098
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
1099
|
+
) {
|
|
1100
|
+
return value.slice(1, -1);
|
|
1101
|
+
}
|
|
1102
|
+
return value;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function resolveHeadlessLaunch(
|
|
1106
|
+
commandValue: string,
|
|
1107
|
+
command: string,
|
|
1108
|
+
): { flags: string[]; tempProfileDir: string | null } {
|
|
1109
|
+
const lower = `${commandValue} ${command}`.toLowerCase();
|
|
1110
|
+
if (lower.includes("firefox")) {
|
|
1111
|
+
const tempProfileDir = fs.mkdtempSync(
|
|
1112
|
+
path.join(os.tmpdir(), "as-test-firefox-profile-"),
|
|
1113
|
+
);
|
|
1114
|
+
return {
|
|
1115
|
+
flags: ["-headless", "-no-remote", "-profile", tempProfileDir],
|
|
1116
|
+
tempProfileDir,
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
if (lower.includes("webkit") || lower.includes("minibrowser")) {
|
|
1120
|
+
return { flags: ["--headless"], tempProfileDir: null };
|
|
1121
|
+
}
|
|
1122
|
+
return { flags: [...WEB_HEADLESS_FLAGS], tempProfileDir: null };
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function hasExecutable(command: string): boolean {
|
|
1126
|
+
if (!command.length) return false;
|
|
1127
|
+
if (command.includes("/") || command.includes("\\")) {
|
|
1128
|
+
return fs.existsSync(command);
|
|
1129
|
+
}
|
|
1130
|
+
const pathValue = process.env.PATH ?? "";
|
|
1131
|
+
const suffixes =
|
|
1132
|
+
process.platform == "win32" ? ["", ".exe", ".cmd", ".bat"] : [""];
|
|
1133
|
+
for (const base of pathValue.split(path.delimiter)) {
|
|
1134
|
+
if (!base) continue;
|
|
1135
|
+
for (const suffix of suffixes) {
|
|
1136
|
+
if (fs.existsSync(path.join(base, command + suffix))) {
|
|
1137
|
+
return true;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
return false;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function resolvePlaywrightBrowserExecutable(browser: string): string | null {
|
|
1145
|
+
const patterns = getPlaywrightBrowserPatterns(browser);
|
|
1146
|
+
if (!patterns.length) return null;
|
|
1147
|
+
for (const cacheRoot of getPlaywrightCacheRoots()) {
|
|
1148
|
+
if (!fs.existsSync(cacheRoot)) continue;
|
|
1149
|
+
for (const pattern of patterns) {
|
|
1150
|
+
const matches = fs.globSync(path.join(cacheRoot, pattern)).sort();
|
|
1151
|
+
if (matches.length) {
|
|
1152
|
+
return matches[matches.length - 1]!;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
return null;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function getPlaywrightCacheRoots(): string[] {
|
|
1160
|
+
const roots = new Set<string>();
|
|
1161
|
+
const configured = process.env.PLAYWRIGHT_BROWSERS_PATH?.trim() ?? "";
|
|
1162
|
+
if (configured.length && configured != "0") {
|
|
1163
|
+
roots.add(path.resolve(configured));
|
|
1164
|
+
}
|
|
1165
|
+
const home = process.env.HOME ?? "";
|
|
1166
|
+
if (process.platform == "darwin" && home.length) {
|
|
1167
|
+
roots.add(path.join(home, "Library", "Caches", "ms-playwright"));
|
|
1168
|
+
} else if (process.platform == "win32") {
|
|
1169
|
+
const localAppData = process.env.LOCALAPPDATA?.trim() ?? "";
|
|
1170
|
+
if (localAppData.length) {
|
|
1171
|
+
roots.add(path.join(localAppData, "ms-playwright"));
|
|
1172
|
+
}
|
|
1173
|
+
const userProfile = process.env.USERPROFILE?.trim() ?? "";
|
|
1174
|
+
if (userProfile.length) {
|
|
1175
|
+
roots.add(path.join(userProfile, "AppData", "Local", "ms-playwright"));
|
|
1176
|
+
}
|
|
1177
|
+
} else if (home.length) {
|
|
1178
|
+
roots.add(path.join(home, ".cache", "ms-playwright"));
|
|
1179
|
+
}
|
|
1180
|
+
return [...roots];
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function getPlaywrightBrowserPatterns(browser: string): string[] {
|
|
1184
|
+
if (process.platform == "darwin") {
|
|
1185
|
+
const macMap: Record<string, string[]> = {
|
|
1186
|
+
chromium: [
|
|
1187
|
+
"chromium-*/chrome-mac*/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
|
|
1188
|
+
"chromium-*/chrome-mac*/Chromium.app/Contents/MacOS/Chromium",
|
|
1189
|
+
"chromium_headless_shell-*/chrome-headless-shell-mac*/chrome-headless-shell",
|
|
1190
|
+
],
|
|
1191
|
+
chrome: [
|
|
1192
|
+
"chromium-*/chrome-mac*/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
|
|
1193
|
+
"chromium-*/chrome-mac*/Chromium.app/Contents/MacOS/Chromium",
|
|
1194
|
+
"chromium_headless_shell-*/chrome-headless-shell-mac*/chrome-headless-shell",
|
|
1195
|
+
],
|
|
1196
|
+
firefox: [
|
|
1197
|
+
"firefox-*/firefox/*.app/Contents/MacOS/firefox",
|
|
1198
|
+
"firefox-*/*.app/Contents/MacOS/firefox",
|
|
1199
|
+
"firefox-*/firefox/firefox",
|
|
1200
|
+
],
|
|
1201
|
+
webkit: ["webkit-*/pw_run.sh"],
|
|
1202
|
+
};
|
|
1203
|
+
return macMap[browser] ?? [];
|
|
1204
|
+
}
|
|
1205
|
+
if (process.platform == "win32") {
|
|
1206
|
+
const winMap: Record<string, string[]> = {
|
|
1207
|
+
chromium: [
|
|
1208
|
+
"chromium-*/chrome-win/chrome.exe",
|
|
1209
|
+
"chromium-*/chrome-win64/chrome.exe",
|
|
1210
|
+
"chromium_headless_shell-*/chrome-headless-shell-win64/chrome-headless-shell.exe",
|
|
1211
|
+
],
|
|
1212
|
+
chrome: [
|
|
1213
|
+
"chromium-*/chrome-win/chrome.exe",
|
|
1214
|
+
"chromium-*/chrome-win64/chrome.exe",
|
|
1215
|
+
"chromium_headless_shell-*/chrome-headless-shell-win64/chrome-headless-shell.exe",
|
|
1216
|
+
],
|
|
1217
|
+
firefox: ["firefox-*/firefox/firefox.exe"],
|
|
1218
|
+
webkit: ["webkit-*/Playwright.exe"],
|
|
1219
|
+
};
|
|
1220
|
+
return winMap[browser] ?? [];
|
|
1221
|
+
}
|
|
1222
|
+
const linuxMap: Record<string, string[]> = {
|
|
1223
|
+
chromium: [
|
|
1224
|
+
"chromium-*/chrome-linux/chrome",
|
|
1225
|
+
"chromium-*/chrome-linux64/chrome",
|
|
1226
|
+
"chromium_headless_shell-*/chrome-headless-shell-linux64/chrome-headless-shell",
|
|
1227
|
+
],
|
|
1228
|
+
chrome: [
|
|
1229
|
+
"chromium-*/chrome-linux/chrome",
|
|
1230
|
+
"chromium-*/chrome-linux64/chrome",
|
|
1231
|
+
"chromium_headless_shell-*/chrome-headless-shell-linux64/chrome-headless-shell",
|
|
1232
|
+
],
|
|
1233
|
+
firefox: ["firefox-*/firefox/firefox"],
|
|
1234
|
+
webkit: ["webkit-*/pw_run.sh"],
|
|
1235
|
+
};
|
|
1236
|
+
return linuxMap[browser] ?? [];
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
async function captureHelperInstance(
|
|
1240
|
+
runHelper: () => Promise<unknown>,
|
|
1241
|
+
): Promise<WebAssembly.Instance> {
|
|
1242
|
+
const originalInstantiate = WebAssembly.instantiate.bind(WebAssembly);
|
|
1243
|
+
let instance: WebAssembly.Instance | null = null;
|
|
1244
|
+
|
|
1245
|
+
WebAssembly.instantiate = (async (
|
|
1246
|
+
source: BufferSource | WebAssembly.Module,
|
|
1247
|
+
importObject?: WebAssembly.Imports,
|
|
1248
|
+
) => {
|
|
1249
|
+
const result = await originalInstantiate(source, importObject);
|
|
1250
|
+
if (result instanceof WebAssembly.Instance) {
|
|
1251
|
+
instance = result;
|
|
1252
|
+
}
|
|
1253
|
+
return result;
|
|
1254
|
+
}) as typeof WebAssembly.instantiate;
|
|
1255
|
+
|
|
1256
|
+
try {
|
|
1257
|
+
await runHelper();
|
|
1258
|
+
} finally {
|
|
1259
|
+
WebAssembly.instantiate = originalInstantiate as typeof WebAssembly.instantiate;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
if (!instance) {
|
|
1263
|
+
throw new Error("bindings helper did not produce a WebAssembly.Instance");
|
|
1264
|
+
}
|
|
1265
|
+
return instance;
|
|
1266
|
+
}
|