@specific.dev/spectest 0.88.2 → 0.89.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/README.md +108 -0
- package/dist/components/electron-chrome.d.ts +1 -0
- package/dist/components/electron-chrome.js +47 -0
- package/dist/components/electron.d.ts +11 -1
- package/dist/components/electron.js +42 -10
- package/dist/components/emulate/entry.mjs +242 -0
- package/dist/harness/fetch.js +7 -3
- package/dist/index.d.ts +6 -2
- package/dist/inspect.d.ts +85 -12
- package/dist/inspect.js +154 -5
- package/package.json +1 -1
- package/src/components/electron-chrome.ts +48 -0
- package/src/components/electron.test.ts +84 -1
- package/src/components/electron.ts +54 -11
- package/src/harness/fetch.test.ts +108 -2
- package/src/harness/fetch.ts +8 -5
- package/src/index.ts +17 -1
- package/src/inspect-fetch.test.ts +202 -0
- package/src/inspect.ts +245 -19
package/dist/inspect.d.ts
CHANGED
|
@@ -163,7 +163,7 @@ export type Provenanced = {
|
|
|
163
163
|
* recovered at runtime by {@link adoptNullishTag}; `Provenanced` admits it because
|
|
164
164
|
* it includes `null | undefined`.
|
|
165
165
|
*/
|
|
166
|
-
export type Wrapped<T> = T extends null | undefined ? T : T extends readonly (infer U)[] ? WrappedArray<U> : T extends (...args: never[]) => unknown ? T : T extends object ? WrappedObject<T> : Carrier<T>;
|
|
166
|
+
export type Wrapped<T> = T extends null | undefined ? T : T extends readonly (infer U)[] ? WrappedArray<U> : T extends ArrayBuffer | SharedArrayBuffer ? WrappedBuffer<T> : T extends ArrayBufferView ? WrappedBinaryView<T> : T extends File ? WrappedFile : T extends Blob ? WrappedBlob : T extends FormData ? WrappedFormData : T extends ReadableStream<infer C> ? WrappedStream<C> : T extends (...args: never[]) => unknown ? T : T extends object ? WrappedObject<T> : Carrier<T>;
|
|
167
167
|
/** A wrapped object: every own property is itself {@link Wrapped}, plus an
|
|
168
168
|
* `.unwrap()` that recovers the fully-raw value (all nested leaves raw). */
|
|
169
169
|
export type WrappedObject<T> = {
|
|
@@ -214,6 +214,69 @@ export interface WrappedArray<U> {
|
|
|
214
214
|
/** Iteration yields *raw* elements (the runtime forwards the raw iterator). */
|
|
215
215
|
[Symbol.iterator](): IterableIterator<U>;
|
|
216
216
|
}
|
|
217
|
+
/** Native objects need their own views: getters and methods must receive the
|
|
218
|
+
* real native receiver, and method results must be wrapped after they resolve.
|
|
219
|
+
* Iteration, callbacks, and control-flow flags on stream readers remain raw.
|
|
220
|
+
*/
|
|
221
|
+
type NativeResult<T> = T extends Promise<infer R> ? Promise<Wrapped<R>> : Wrapped<T>;
|
|
222
|
+
type NativeView<T, K extends keyof T> = Omit<T, K> & {
|
|
223
|
+
readonly [P in K]: T[P] extends (...args: infer A) => infer R ? (...args: A) => NativeResult<R> : Wrapped<T[P]>;
|
|
224
|
+
} & {
|
|
225
|
+
unwrap(): T;
|
|
226
|
+
transform<R>(label: string, fn: (raw: T) => R): Wrapped<R>;
|
|
227
|
+
};
|
|
228
|
+
export type WrappedBuffer<T extends ArrayBuffer | SharedArrayBuffer = ArrayBuffer> = NativeView<T, Extract<keyof T, "byteLength" | "maxByteLength" | "resizable" | "growable" | "detached" | "slice" | "transfer" | "transferToFixedLength">>;
|
|
229
|
+
declare const BINARY_METHOD_NAMES: readonly ["at", "slice", "subarray", "includes", "indexOf", "lastIndexOf", "find", "findLast", "findIndex", "findLastIndex", "some", "every", "filter", "map", "join", "fill", "copyWithin", "reverse", "sort", "toReversed", "toSorted", "with", "toHex", "toBase64", "getInt8", "getUint8", "getInt16", "getUint16", "getInt32", "getUint32", "getFloat16", "getFloat32", "getFloat64", "getBigInt64", "getBigUint64"];
|
|
230
|
+
/** Numeric indexes carry provenance. Like WrappedArray, length and iteration
|
|
231
|
+
* stay raw; callbacks receive native values. Unwrap for native API inputs. */
|
|
232
|
+
export type WrappedBinaryView<T extends ArrayBufferView> = NativeView<T, Extract<keyof T, number | "buffer" | "byteLength" | "byteOffset" | typeof BINARY_METHOD_NAMES[number]>>;
|
|
233
|
+
export type WrappedBytes = WrappedBinaryView<Uint8Array>;
|
|
234
|
+
export type WrappedBlob = NativeView<Blob, "size" | "type" | "slice" | "text" | "bytes" | "arrayBuffer" | "stream">;
|
|
235
|
+
export type WrappedFile = NativeView<File, "name" | "lastModified" | "webkitRelativePath" | "size" | "type" | "slice" | "text" | "bytes" | "arrayBuffer" | "stream">;
|
|
236
|
+
export type WrappedFormData = NativeView<FormData, "get" | "getAll" | "has">;
|
|
237
|
+
export type WrappedReadResult<T> = {
|
|
238
|
+
done: false;
|
|
239
|
+
value: Wrapped<T>;
|
|
240
|
+
} | {
|
|
241
|
+
done: true;
|
|
242
|
+
value: Wrapped<T> | undefined;
|
|
243
|
+
};
|
|
244
|
+
export interface WrappedReader<T> extends Omit<ReadableStreamDefaultReader<T>, "read"> {
|
|
245
|
+
read(): Promise<WrappedReadResult<T>>;
|
|
246
|
+
unwrap(): ReadableStreamDefaultReader<T>;
|
|
247
|
+
}
|
|
248
|
+
export interface WrappedBYOBReader extends Omit<ReadableStreamBYOBReader, "read"> {
|
|
249
|
+
read<T extends ArrayBufferView>(view: T, options?: {
|
|
250
|
+
min?: number;
|
|
251
|
+
}): Promise<WrappedReadResult<T>>;
|
|
252
|
+
unwrap(): ReadableStreamBYOBReader;
|
|
253
|
+
}
|
|
254
|
+
export interface WrappedStream<T> extends Omit<ReadableStream<T>, "getReader" | "tee" | "pipeThrough"> {
|
|
255
|
+
getReader(): WrappedReader<T>;
|
|
256
|
+
getReader(options: {
|
|
257
|
+
mode: "byob";
|
|
258
|
+
}): WrappedBYOBReader;
|
|
259
|
+
getReader(options: ReadableStreamGetReaderOptions): WrappedReader<T> | WrappedBYOBReader;
|
|
260
|
+
tee(): [WrappedStream<T>, WrappedStream<T>];
|
|
261
|
+
pipeThrough<R>(transform: ReadableWritablePair<R, any>, options?: StreamPipeOptions): WrappedStream<R>;
|
|
262
|
+
unwrap(): ReadableStream<T>;
|
|
263
|
+
transform<R>(label: string, fn: (raw: ReadableStream<T>) => R): Wrapped<R>;
|
|
264
|
+
}
|
|
265
|
+
/** Response headers whose lookups retain the originating HTTP call's provenance.
|
|
266
|
+
* Missing headers remain `null`; `expect(headers.get(name)).toBe(null)` still
|
|
267
|
+
* links to the call. Iterators and other native methods return raw values.
|
|
268
|
+
*/
|
|
269
|
+
export interface WrappedHeaders extends Omit<Headers, "get" | "has" | "getSetCookie" | "getAll" | "toJSON" | "count"> {
|
|
270
|
+
get(name: string): Carrier<string> | null;
|
|
271
|
+
/** Unwrap before using the result as a condition. */
|
|
272
|
+
has(name: string): Carrier<boolean>;
|
|
273
|
+
getSetCookie(): WrappedArray<string>;
|
|
274
|
+
getAll(name: "set-cookie" | "Set-Cookie"): WrappedArray<string>;
|
|
275
|
+
toJSON(): Wrapped<Record<string, string | string[]>>;
|
|
276
|
+
readonly count: Carrier<number>;
|
|
277
|
+
unwrap(): Headers;
|
|
278
|
+
transform<R = unknown>(label: string, fn: (raw: Headers) => R): Wrapped<R>;
|
|
279
|
+
}
|
|
217
280
|
/**
|
|
218
281
|
* The view `ctx.fetch` resolves to in every context (a spectest op wraps
|
|
219
282
|
* unconditionally): a {@link Response} whose status-line accessors are
|
|
@@ -222,8 +285,9 @@ export interface WrappedArray<U> {
|
|
|
222
285
|
* used to silently always be false. Compare `res.status.unwrap() === 200`
|
|
223
286
|
* or `res.unwrap().status === 200`, or assert with `expect(res.status)`.
|
|
224
287
|
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
288
|
+
* All body readers return provenance-carrying values, including binary bodies,
|
|
289
|
+
* blobs and form data. `.unwrap()` recovers the native response or body value.
|
|
290
|
+
* Stream reader `done` and stream `locked` remain raw control-flow booleans.
|
|
227
291
|
*/
|
|
228
292
|
export interface WrappedResponse {
|
|
229
293
|
readonly status: Carrier<number>;
|
|
@@ -232,13 +296,15 @@ export interface WrappedResponse {
|
|
|
232
296
|
readonly url: Carrier<string>;
|
|
233
297
|
readonly redirected: Carrier<boolean>;
|
|
234
298
|
readonly type: Carrier<string>;
|
|
235
|
-
readonly headers:
|
|
236
|
-
readonly bodyUsed: boolean
|
|
299
|
+
readonly headers: WrappedHeaders;
|
|
300
|
+
readonly bodyUsed: Carrier<boolean>;
|
|
301
|
+
readonly body: WrappedStream<Uint8Array> | null;
|
|
237
302
|
json<T = unknown>(): Promise<Wrapped<T>>;
|
|
238
303
|
text(): Promise<Carrier<string>>;
|
|
239
|
-
arrayBuffer(): Promise<
|
|
240
|
-
|
|
241
|
-
|
|
304
|
+
arrayBuffer(): Promise<WrappedBuffer>;
|
|
305
|
+
bytes(): Promise<WrappedBytes>;
|
|
306
|
+
blob(): Promise<WrappedBlob>;
|
|
307
|
+
formData(): Promise<WrappedFormData>;
|
|
242
308
|
clone(): WrappedResponse;
|
|
243
309
|
/** Recover the underlying raw {@link Response} (a real `number` status,
|
|
244
310
|
* an unwrapped body, etc.). */
|
|
@@ -246,12 +312,19 @@ export interface WrappedResponse {
|
|
|
246
312
|
}
|
|
247
313
|
/** The signature of `ctx.fetch`: a `fetch` that resolves to a
|
|
248
314
|
* {@link WrappedResponse} so reads carry provenance into assertions. */
|
|
249
|
-
export type SpectestFetch = (input: RequestInfo | URL, init?:
|
|
315
|
+
export type SpectestFetch = (input: RequestInfo | URL, init?: SpectestRequestInit) => Promise<WrappedResponse>;
|
|
316
|
+
export interface SpectestRequestInit extends RequestInit {
|
|
317
|
+
/** Skip the recorder's body capture for open-ended streams. SSE skips it
|
|
318
|
+
* automatically. The response and assertions remain recorded. */
|
|
319
|
+
captureBody?: boolean;
|
|
320
|
+
}
|
|
250
321
|
/**
|
|
251
322
|
* Bespoke wrapper for `fetch` responses. Reads on `status` / `ok` /
|
|
252
323
|
* `statusText` / `url` / `redirected` / `type` return carriers tagged
|
|
253
|
-
* to `sourceSeq`.
|
|
254
|
-
*
|
|
255
|
-
*
|
|
324
|
+
* to `sourceSeq`. Every body-reading method returns its resolved value
|
|
325
|
+
* wrapped under `path: ["body"]`. Header lookups carry paths
|
|
326
|
+
* under `headers`, and clones retain the same provenance. Everything else
|
|
327
|
+
* passes through bound to the real Response.
|
|
256
328
|
*/
|
|
257
329
|
export declare function wrapResponse(res: Response, sourceSeq: number | undefined): WrappedResponse;
|
|
330
|
+
export {};
|
package/dist/inspect.js
CHANGED
|
@@ -210,6 +210,20 @@ export function wrap(raw, sourceSeq, path = []) {
|
|
|
210
210
|
return raw;
|
|
211
211
|
const t = typeof raw;
|
|
212
212
|
if (t === "object") {
|
|
213
|
+
const native = readRaw(raw);
|
|
214
|
+
if (native instanceof ArrayBuffer || (typeof SharedArrayBuffer !== "undefined" && native instanceof SharedArrayBuffer)) {
|
|
215
|
+
return wrapNative(native, sourceSeq, path, new Set(["byteLength", "maxByteLength", "resizable", "growable", "detached"]), new Set(["slice", "transfer", "transferToFixedLength"]));
|
|
216
|
+
}
|
|
217
|
+
if (ArrayBuffer.isView(native)) {
|
|
218
|
+
return wrapNative(native, sourceSeq, path, new Set(["buffer", "byteLength", "byteOffset"]), BINARY_METHODS, true);
|
|
219
|
+
}
|
|
220
|
+
if (native instanceof Blob) {
|
|
221
|
+
return wrapNative(native, sourceSeq, path, new Set(["size", "type", "name", "lastModified", "webkitRelativePath"]), new Set(["slice", "text", "bytes", "arrayBuffer", "stream"]));
|
|
222
|
+
}
|
|
223
|
+
if (native instanceof FormData)
|
|
224
|
+
return wrapFormData(native, sourceSeq, path);
|
|
225
|
+
if (native instanceof ReadableStream)
|
|
226
|
+
return wrapStream(native, sourceSeq, path);
|
|
213
227
|
return wrapObject(raw, sourceSeq, path);
|
|
214
228
|
}
|
|
215
229
|
if (t === "function")
|
|
@@ -437,15 +451,140 @@ export function field(value, ...path) {
|
|
|
437
451
|
return cur;
|
|
438
452
|
return retag(cur, tag.sourceSeq, [...tag.path, ...path.map(String)]);
|
|
439
453
|
}
|
|
454
|
+
const BINARY_METHOD_NAMES = [
|
|
455
|
+
"at", "slice", "subarray", "includes", "indexOf", "lastIndexOf", "find", "findLast",
|
|
456
|
+
"findIndex", "findLastIndex", "some", "every", "filter", "map", "join",
|
|
457
|
+
"fill", "copyWithin", "reverse", "sort", "toReversed", "toSorted", "with", "toHex", "toBase64",
|
|
458
|
+
"getInt8", "getUint8", "getInt16", "getUint16", "getInt32", "getUint32",
|
|
459
|
+
"getFloat16", "getFloat32", "getFloat64", "getBigInt64", "getBigUint64",
|
|
460
|
+
];
|
|
461
|
+
const BINARY_METHODS = new Set(BINARY_METHOD_NAMES);
|
|
462
|
+
function wrapNative(raw, sourceSeq, path, properties, methods, indexes = false) {
|
|
463
|
+
const tag = { sourceSeq, path };
|
|
464
|
+
return new Proxy(raw, {
|
|
465
|
+
get(target, prop) {
|
|
466
|
+
if (prop === OP_TAG)
|
|
467
|
+
return tag;
|
|
468
|
+
if (prop === UNWRAP)
|
|
469
|
+
return target;
|
|
470
|
+
if (prop === "unwrap")
|
|
471
|
+
return () => target;
|
|
472
|
+
if (prop === "transform")
|
|
473
|
+
return makeTransform(target, sourceSeq, path);
|
|
474
|
+
const value = Reflect.get(target, prop, target);
|
|
475
|
+
if (typeof prop === "string") {
|
|
476
|
+
if (properties.has(prop) || (indexes && /^(0|[1-9]\d*)$/.test(prop))) {
|
|
477
|
+
return wrapChild(value, sourceSeq, [...path, prop]);
|
|
478
|
+
}
|
|
479
|
+
if (methods.has(prop) && typeof value === "function") {
|
|
480
|
+
return (...args) => {
|
|
481
|
+
const result = value.apply(target, args);
|
|
482
|
+
const resultPath = [...path, `<${prop}>`];
|
|
483
|
+
return result instanceof Promise
|
|
484
|
+
? result.then((v) => wrapChild(v, sourceSeq, resultPath))
|
|
485
|
+
: wrapChild(result, sourceSeq, resultPath);
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
490
|
+
},
|
|
491
|
+
has(target, prop) {
|
|
492
|
+
return prop === OP_TAG || prop === UNWRAP || Reflect.has(target, prop);
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
function wrapFormData(raw, sourceSeq, path) {
|
|
497
|
+
const base = wrapNative(raw, sourceSeq, path, new Set(), new Set());
|
|
498
|
+
return new Proxy(base, {
|
|
499
|
+
get(target, prop) {
|
|
500
|
+
if (prop === "get" || prop === "getAll" || prop === "has") {
|
|
501
|
+
return (name) => wrapChild(raw[prop](name), sourceSeq, prop === "get" ? [...path, name] : [...path, name, `<${prop}>`]);
|
|
502
|
+
}
|
|
503
|
+
return Reflect.get(target, prop);
|
|
504
|
+
},
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
function wrapStream(raw, sourceSeq, path) {
|
|
508
|
+
const base = wrapNative(raw, sourceSeq, path, new Set(), new Set(["pipeThrough"]));
|
|
509
|
+
return new Proxy(base, {
|
|
510
|
+
get(target, prop) {
|
|
511
|
+
if (prop === "tee")
|
|
512
|
+
return () => raw.tee().map((s, i) => wrapStream(s, sourceSeq, [...path, `<tee>`, String(i)]));
|
|
513
|
+
if (prop === "getReader")
|
|
514
|
+
return (options) => {
|
|
515
|
+
const reader = raw.getReader(options);
|
|
516
|
+
let index = 0;
|
|
517
|
+
return new Proxy(reader, {
|
|
518
|
+
get(r, key) {
|
|
519
|
+
if (key === "unwrap" || key === UNWRAP)
|
|
520
|
+
return key === "unwrap" ? () => r : r;
|
|
521
|
+
if (key === OP_TAG)
|
|
522
|
+
return { sourceSeq, path };
|
|
523
|
+
if (key === "read")
|
|
524
|
+
return async (...args) => {
|
|
525
|
+
const read = r.read;
|
|
526
|
+
const result = await read.apply(r, args);
|
|
527
|
+
const valuePath = [...path, "<read>", String(index++)];
|
|
528
|
+
// done stays a real boolean so normal reader loops terminate.
|
|
529
|
+
return { done: result.done, get value() { return wrapChild(result.value, sourceSeq, valuePath); } };
|
|
530
|
+
};
|
|
531
|
+
const value = Reflect.get(r, key, r);
|
|
532
|
+
return typeof value === "function" ? value.bind(r) : value;
|
|
533
|
+
},
|
|
534
|
+
has(r, key) { return key === UNWRAP || key === OP_TAG || Reflect.has(r, key); },
|
|
535
|
+
});
|
|
536
|
+
};
|
|
537
|
+
return Reflect.get(target, prop);
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
function wrapHeaders(headers, sourceSeq) {
|
|
542
|
+
const path = ["headers"];
|
|
543
|
+
const tag = { sourceSeq, path };
|
|
544
|
+
return new Proxy(headers, {
|
|
545
|
+
get(target, prop) {
|
|
546
|
+
if (prop === OP_TAG)
|
|
547
|
+
return tag;
|
|
548
|
+
if (prop === UNWRAP)
|
|
549
|
+
return target;
|
|
550
|
+
if (prop === "unwrap")
|
|
551
|
+
return () => target;
|
|
552
|
+
if (prop === "transform")
|
|
553
|
+
return makeTransform(target, sourceSeq, path);
|
|
554
|
+
if (prop === "get" || prop === "has") {
|
|
555
|
+
return (name) => wrapChild(target[prop](name), sourceSeq, prop === "get" ? [...path, name.toLowerCase()] : [...path, name.toLowerCase(), "<has>"]);
|
|
556
|
+
}
|
|
557
|
+
if (prop === "count")
|
|
558
|
+
return wrap(target.count, sourceSeq, [...path, "count"]);
|
|
559
|
+
if (prop === "getAll")
|
|
560
|
+
return (name) => wrap(target.getAll(name), sourceSeq, [...path, name.toLowerCase()]);
|
|
561
|
+
if (prop === "toJSON")
|
|
562
|
+
return () => wrap(target.toJSON(), sourceSeq, path);
|
|
563
|
+
if (prop === "getSetCookie") {
|
|
564
|
+
return () => wrap(target.getSetCookie(), sourceSeq, [...path, "set-cookie"]);
|
|
565
|
+
}
|
|
566
|
+
const value = Reflect.get(target, prop, target);
|
|
567
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
568
|
+
},
|
|
569
|
+
has(target, prop) {
|
|
570
|
+
if (prop === OP_TAG || prop === UNWRAP)
|
|
571
|
+
return true;
|
|
572
|
+
return Reflect.has(target, prop);
|
|
573
|
+
},
|
|
574
|
+
});
|
|
575
|
+
}
|
|
440
576
|
/**
|
|
441
577
|
* Bespoke wrapper for `fetch` responses. Reads on `status` / `ok` /
|
|
442
578
|
* `statusText` / `url` / `redirected` / `type` return carriers tagged
|
|
443
|
-
* to `sourceSeq`.
|
|
444
|
-
*
|
|
445
|
-
*
|
|
579
|
+
* to `sourceSeq`. Every body-reading method returns its resolved value
|
|
580
|
+
* wrapped under `path: ["body"]`. Header lookups carry paths
|
|
581
|
+
* under `headers`, and clones retain the same provenance. Everything else
|
|
582
|
+
* passes through bound to the real Response.
|
|
446
583
|
*/
|
|
447
584
|
export function wrapResponse(res, sourceSeq) {
|
|
448
585
|
const tag = { sourceSeq, path: [] };
|
|
586
|
+
let headers;
|
|
587
|
+
let body;
|
|
449
588
|
const carrierProps = new Set([
|
|
450
589
|
"status",
|
|
451
590
|
"ok",
|
|
@@ -453,8 +592,9 @@ export function wrapResponse(res, sourceSeq) {
|
|
|
453
592
|
"url",
|
|
454
593
|
"redirected",
|
|
455
594
|
"type",
|
|
595
|
+
"bodyUsed",
|
|
456
596
|
]);
|
|
457
|
-
const bodyMethods = new Set(["json", "text"]);
|
|
597
|
+
const bodyMethods = new Set(["json", "text", "bytes", "arrayBuffer", "blob", "formData"]);
|
|
458
598
|
const handler = {
|
|
459
599
|
get(target, prop) {
|
|
460
600
|
if (prop === OP_TAG)
|
|
@@ -466,6 +606,15 @@ export function wrapResponse(res, sourceSeq) {
|
|
|
466
606
|
return () => readRaw(target);
|
|
467
607
|
if (prop === "then")
|
|
468
608
|
return undefined;
|
|
609
|
+
if (prop === "headers")
|
|
610
|
+
return headers ??= wrapHeaders(target.headers, sourceSeq);
|
|
611
|
+
if (prop === "body") {
|
|
612
|
+
if (target.body === null)
|
|
613
|
+
return wrapChild(null, sourceSeq, ["body"]);
|
|
614
|
+
return body ??= wrapStream(target.body, sourceSeq, ["body"]);
|
|
615
|
+
}
|
|
616
|
+
if (prop === "clone")
|
|
617
|
+
return () => wrapResponse(target.clone(), sourceSeq);
|
|
469
618
|
if (typeof prop === "string" && carrierProps.has(prop)) {
|
|
470
619
|
const value = target[prop];
|
|
471
620
|
return wrap(value, sourceSeq, [prop]);
|
|
@@ -476,7 +625,7 @@ export function wrapResponse(res, sourceSeq) {
|
|
|
476
625
|
return fn;
|
|
477
626
|
return async (...args) => {
|
|
478
627
|
const value = await fn.apply(target, args);
|
|
479
|
-
return
|
|
628
|
+
return wrapChild(value, sourceSeq, ["body"]);
|
|
480
629
|
};
|
|
481
630
|
}
|
|
482
631
|
const value = target[prop];
|
package/package.json
CHANGED
|
@@ -1,7 +1,55 @@
|
|
|
1
|
+
// Trust for the environment CA, applied per session. Chromium on Linux
|
|
2
|
+
// reads its own root store (NSS), never the container's mounted bundle, so
|
|
3
|
+
// the renderer rejects every certificate the environment mints while Node
|
|
4
|
+
// in the main process accepts them through NODE_EXTRA_CA_CERTS. This verify
|
|
5
|
+
// procedure accepts exactly the chains that CA issued for the requested
|
|
6
|
+
// host, and defers to Chromium's own verdict for everything else. An app
|
|
7
|
+
// that installs its own procedure later replaces it, as in production.
|
|
8
|
+
// Kept free of Electron so the unit test can run it under Bun.
|
|
9
|
+
export const ELECTRON_CA_TRUST = String.raw`
|
|
10
|
+
const { X509Certificate } = require('node:crypto');
|
|
11
|
+
function loadEnvironmentCa(path) {
|
|
12
|
+
try { return path ? new X509Certificate(require('node:fs').readFileSync(path)) : null; }
|
|
13
|
+
catch { return null; }
|
|
14
|
+
}
|
|
15
|
+
// request: { hostname, errorCode, certificate: { data, issuerCert? } }.
|
|
16
|
+
// Returns true only for a chain that ends at the CA and names the host.
|
|
17
|
+
function issuedByEnvironmentCa(ca, request) {
|
|
18
|
+
try {
|
|
19
|
+
const leaf = new X509Certificate(request.certificate.data);
|
|
20
|
+
const host = request.hostname;
|
|
21
|
+
const named = leaf.checkHost(host) !== undefined || leaf.checkIP(host) !== undefined;
|
|
22
|
+
if (!named) return false;
|
|
23
|
+
const chain = [leaf];
|
|
24
|
+
for (let c = request.certificate; c.issuerCert && c.issuerCert !== c && chain.length < 8; c = c.issuerCert) {
|
|
25
|
+
chain.push(new X509Certificate(c.issuerCert.data));
|
|
26
|
+
}
|
|
27
|
+
for (let i = 0; i < chain.length; i++) {
|
|
28
|
+
const cert = chain[i];
|
|
29
|
+
if (cert.fingerprint256 === ca.fingerprint256) return i > 0;
|
|
30
|
+
const issuer = chain[i + 1] ?? ca;
|
|
31
|
+
if (!cert.checkIssued(issuer) || !cert.verify(issuer.publicKey)) return false;
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
} catch { return false; }
|
|
35
|
+
}
|
|
36
|
+
`;
|
|
37
|
+
|
|
1
38
|
// Runs before the application's main entry, like Playwright's Electron loader.
|
|
2
39
|
// Observe constructor options; do not replace the app entry, preload, IPC, or OS.
|
|
3
40
|
export const ELECTRON_CHROME_HOOK = String.raw`
|
|
4
41
|
const electron = require('electron');
|
|
42
|
+
${ELECTRON_CA_TRUST}
|
|
43
|
+
const environmentCa = loadEnvironmentCa(process.env.NODE_EXTRA_CA_CERTS);
|
|
44
|
+
if (environmentCa) {
|
|
45
|
+
// Every session, including the default one and app-created partitions.
|
|
46
|
+
electron.app.on('session-created', session => {
|
|
47
|
+
session.setCertificateVerifyProc((request, callback) => {
|
|
48
|
+
if (request.errorCode === 0) return callback(0);
|
|
49
|
+
callback(issuedByEnvironmentCa(environmentCa, request) ? 0 : -3);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
5
53
|
const NativeBrowserWindow = electron.BrowserWindow;
|
|
6
54
|
const WrappedBrowserWindow = new Proxy(NativeBrowserWindow, {
|
|
7
55
|
construct(Target, args, NewTarget) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { electron } from './electron.js';
|
|
@@ -36,3 +36,86 @@ describe('Electron component contract', () => {
|
|
|
36
36
|
expect(isDesktopApp(null)).toBe(false);
|
|
37
37
|
});
|
|
38
38
|
});
|
|
39
|
+
|
|
40
|
+
describe('Electron component in a monorepo', () => {
|
|
41
|
+
function monorepo(run: (root: string) => void) {
|
|
42
|
+
const root = mkdtempSync(join(tmpdir(), 'spectest-electron-mono-'));
|
|
43
|
+
try {
|
|
44
|
+
mkdirSync(join(root, 'apps', 'desktop'), { recursive: true });
|
|
45
|
+
writeFileSync(join(root, 'package.json'), JSON.stringify({ workspaces: ['apps/*'], devDependencies: { electron: '44.0.0' } }));
|
|
46
|
+
writeFileSync(join(root, 'package-lock.json'), '{}');
|
|
47
|
+
writeFileSync(join(root, 'apps', 'desktop', 'package.json'), JSON.stringify({ name: '@acme/desktop' }));
|
|
48
|
+
run(root);
|
|
49
|
+
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
50
|
+
}
|
|
51
|
+
test('builds from the context root and runs the app from its workspace', () => monorepo(root => {
|
|
52
|
+
const service = electron({ appDir: join(root, 'apps/desktop'), context: root });
|
|
53
|
+
expect(service.image).toMatchObject({ context: root });
|
|
54
|
+
const content = (service.image as { content: string }).content;
|
|
55
|
+
expect(content).toContain('COPY . .\nRUN npm ci\n');
|
|
56
|
+
expect(content).toContain("RUN cd apps/desktop && node \"$(node -p \"require.resolve('electron/install.js')\")\"\n");
|
|
57
|
+
expect(content).toContain('RUN cd apps/desktop && npm run build\n');
|
|
58
|
+
expect(service.env?.SPECTEST_ELECTRON_APP_DIR).toBe('/app/apps/desktop');
|
|
59
|
+
}));
|
|
60
|
+
test('accepts electron declared at the context root only', () => monorepo(root => {
|
|
61
|
+
expect(() => electron({ appDir: join(root, 'apps/desktop'), context: root })).not.toThrow();
|
|
62
|
+
writeFileSync(join(root, 'package.json'), JSON.stringify({ workspaces: ['apps/*'] }));
|
|
63
|
+
expect(() => electron({ appDir: join(root, 'apps/desktop'), context: root })).toThrow('declare electron');
|
|
64
|
+
}));
|
|
65
|
+
test('rejects an app directory outside the context', () => monorepo(root => {
|
|
66
|
+
expect(() => electron({ appDir: root, context: join(root, 'apps/desktop') })).toThrow('must be inside context');
|
|
67
|
+
}));
|
|
68
|
+
test('the default context keeps the single-app image shape', () => fixture(appDir => {
|
|
69
|
+
const content = (electron({ appDir }).image as { content: string }).content;
|
|
70
|
+
expect(content).toContain('RUN node "$(node -p "require.resolve(\'electron/install.js\')")"\nRUN npm run build\n');
|
|
71
|
+
expect(electron({ appDir }).env?.SPECTEST_ELECTRON_APP_DIR).toBe('/app');
|
|
72
|
+
}));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('Electron environment-CA trust', () => {
|
|
76
|
+
const { ELECTRON_CA_TRUST } = require('./electron-chrome.js');
|
|
77
|
+
const { execFileSync } = require('node:child_process');
|
|
78
|
+
const { readFileSync: read } = require('node:fs');
|
|
79
|
+
function pki(run: (dir: string) => void) {
|
|
80
|
+
const dir = mkdtempSync(join(tmpdir(), 'spectest-electron-ca-'));
|
|
81
|
+
const sh = (cmd: string) => execFileSync('sh', ['-c', cmd], { cwd: dir, stdio: 'pipe' });
|
|
82
|
+
try {
|
|
83
|
+
sh('openssl req -x509 -newkey rsa:2048 -nodes -keyout ca.key -out ca.pem -days 2 -subj /CN=env-ca 2>/dev/null');
|
|
84
|
+
sh('openssl req -x509 -newkey rsa:2048 -nodes -keyout other.key -out other.pem -days 2 -subj /CN=other-ca 2>/dev/null');
|
|
85
|
+
sh('printf "subjectAltName=DNS:api.example.test\\nbasicConstraints=CA:FALSE\\n" > ext.cnf');
|
|
86
|
+
sh('openssl req -newkey rsa:2048 -nodes -keyout leaf.key -out leaf.csr -subj /CN=api.example.test 2>/dev/null');
|
|
87
|
+
sh('openssl x509 -req -in leaf.csr -CA ca.pem -CAkey ca.key -CAcreateserial -out leaf.pem -days 2 -extfile ext.cnf 2>/dev/null');
|
|
88
|
+
sh('openssl x509 -req -in leaf.csr -CA other.pem -CAkey other.key -CAcreateserial -out stranger.pem -days 2 -extfile ext.cnf 2>/dev/null');
|
|
89
|
+
sh('printf "basicConstraints=CA:TRUE\\nkeyUsage=keyCertSign\\n" > ica.cnf');
|
|
90
|
+
sh('openssl req -newkey rsa:2048 -nodes -keyout ica.key -out ica.csr -subj /CN=env-intermediate 2>/dev/null');
|
|
91
|
+
sh('openssl x509 -req -in ica.csr -CA ca.pem -CAkey ca.key -CAcreateserial -out ica.pem -days 2 -extfile ica.cnf 2>/dev/null');
|
|
92
|
+
sh('openssl x509 -req -in leaf.csr -CA ica.pem -CAkey ica.key -CAcreateserial -out leaf2.pem -days 2 -extfile ext.cnf 2>/dev/null');
|
|
93
|
+
run(dir);
|
|
94
|
+
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
95
|
+
}
|
|
96
|
+
// The hook's source, evaluated the way Electron evaluates it: plain CommonJS.
|
|
97
|
+
const trust = new Function('require', `${ELECTRON_CA_TRUST}; return { loadEnvironmentCa, issuedByEnvironmentCa };`)(require);
|
|
98
|
+
const request = (leafPath: string, hostname: string, issuers: string[] = []) => {
|
|
99
|
+
let cert: any = undefined;
|
|
100
|
+
for (const p of [...issuers].reverse()) cert = { data: read(p, 'utf8'), issuerCert: cert };
|
|
101
|
+
return { hostname, errorCode: -202, certificate: { data: read(leafPath, 'utf8'), issuerCert: cert } };
|
|
102
|
+
};
|
|
103
|
+
test('accepts a leaf the environment CA issued for the requested host', () => pki(dir => {
|
|
104
|
+
const ca = trust.loadEnvironmentCa(join(dir, 'ca.pem'));
|
|
105
|
+
expect(ca).not.toBeNull();
|
|
106
|
+
expect(trust.issuedByEnvironmentCa(ca, request(join(dir, 'leaf.pem'), 'api.example.test'))).toBe(true);
|
|
107
|
+
expect(trust.issuedByEnvironmentCa(ca, request(join(dir, 'leaf2.pem'), 'api.example.test', [join(dir, 'ica.pem')]))).toBe(true);
|
|
108
|
+
expect(trust.issuedByEnvironmentCa(ca, request(join(dir, 'leaf2.pem'), 'api.example.test', [join(dir, 'ica.pem'), join(dir, 'ca.pem')]))).toBe(true);
|
|
109
|
+
}));
|
|
110
|
+
test('rejects other issuers, other hosts, a bare intermediate, and the CA itself', () => pki(dir => {
|
|
111
|
+
const ca = trust.loadEnvironmentCa(join(dir, 'ca.pem'));
|
|
112
|
+
expect(trust.issuedByEnvironmentCa(ca, request(join(dir, 'stranger.pem'), 'api.example.test'))).toBe(false);
|
|
113
|
+
expect(trust.issuedByEnvironmentCa(ca, request(join(dir, 'leaf.pem'), 'evil.example.test'))).toBe(false);
|
|
114
|
+
expect(trust.issuedByEnvironmentCa(ca, request(join(dir, 'leaf2.pem'), 'api.example.test'))).toBe(false);
|
|
115
|
+
expect(trust.issuedByEnvironmentCa(ca, request(join(dir, 'ca.pem'), 'api.example.test'))).toBe(false);
|
|
116
|
+
}));
|
|
117
|
+
test('no CA file means no trust hook', () => {
|
|
118
|
+
expect(trust.loadEnvironmentCa(undefined)).toBeNull();
|
|
119
|
+
expect(trust.loadEnvironmentCa('/nonexistent/ca.pem')).toBeNull();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { posix } from "node:path";
|
|
2
3
|
import type { ServiceDefinition } from "../index.js";
|
|
3
4
|
import { resolveProjectPath } from "../project-files.js";
|
|
4
5
|
import { desktopApp, type DesktopApp } from "../desktop.js";
|
|
5
6
|
import { ELECTRON_CHROME_HOOK } from "./electron-chrome.js";
|
|
6
7
|
|
|
7
8
|
export interface ElectronOptions {
|
|
8
|
-
/** Project-relative app directory; must contain package.json
|
|
9
|
+
/** Project-relative app directory; must contain package.json. Electron must
|
|
10
|
+
* be declared here or in the context's package.json. */
|
|
9
11
|
appDir?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Image build context, relative to the project root. Default `appDir`.
|
|
14
|
+
* Set it to the repository root when the app is one workspace of a
|
|
15
|
+
* monorepo and resolves packages from above its own directory. The
|
|
16
|
+
* context is copied to `/app`; `installCommand` runs at the context root,
|
|
17
|
+
* `buildCommand` and Electron run in the app directory below it.
|
|
18
|
+
*/
|
|
19
|
+
context?: string;
|
|
10
20
|
/** Debian Node image tag, default 24-bookworm-slim. */
|
|
11
21
|
nodeVersion?: string;
|
|
12
22
|
/** Build the main/preload/renderer output; default npm run build. */
|
|
@@ -26,13 +36,14 @@ export const ELECTRON_LAUNCHER = String.raw`
|
|
|
26
36
|
import http from 'node:http';
|
|
27
37
|
import { spawn } from 'node:child_process';
|
|
28
38
|
import { createRequire } from 'node:module';
|
|
29
|
-
const
|
|
39
|
+
const appDir = process.env.SPECTEST_ELECTRON_APP_DIR || '/app';
|
|
40
|
+
const require = createRequire(appDir + '/package.json');
|
|
30
41
|
const child = spawn(require('electron'), [
|
|
31
42
|
...(process.getuid?.() === 0 ? ['--no-sandbox'] : []),
|
|
32
43
|
'--disable-dev-shm-usage', '--remote-debugging-port=9222',
|
|
33
44
|
'-r', '/spectest-electron-chrome.cjs',
|
|
34
45
|
process.env.SPECTEST_ELECTRON_ENTRY || '.',
|
|
35
|
-
], { cwd:
|
|
46
|
+
], { cwd: appDir, stdio: 'inherit', env: process.env });
|
|
36
47
|
const upstream = req => ({
|
|
37
48
|
host: '127.0.0.1', port: 9222, path: req.url, method: req.method,
|
|
38
49
|
headers: { ...req.headers, host: '127.0.0.1:9222' },
|
|
@@ -68,18 +79,42 @@ child.on('exit', code => process.exit(code ?? 1));
|
|
|
68
79
|
for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => child.kill(signal));
|
|
69
80
|
`;
|
|
70
81
|
|
|
82
|
+
/** Read a package.json and tell whether it declares electron. */
|
|
83
|
+
function declaresElectron(manifest: string): boolean {
|
|
84
|
+
if (!existsSync(manifest)) return false;
|
|
85
|
+
const pkg = JSON.parse(readFileSync(manifest, "utf8"));
|
|
86
|
+
return Boolean(pkg.dependencies?.electron || pkg.devDependencies?.electron);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The app directory as a path inside the build context: "." or "a/b". */
|
|
90
|
+
function appPathInContext(appDir: string, context: string): string {
|
|
91
|
+
const rel = posix.relative(posix.normalize(context), posix.normalize(appDir));
|
|
92
|
+
if (rel === "") return ".";
|
|
93
|
+
if (rel.startsWith("..") || posix.isAbsolute(rel)) {
|
|
94
|
+
throw new Error(`electron(): appDir ${JSON.stringify(appDir)} must be inside context ${JSON.stringify(context)}`);
|
|
95
|
+
}
|
|
96
|
+
return rel;
|
|
97
|
+
}
|
|
98
|
+
|
|
71
99
|
/** Real Linux Electron under Xvfb, with a generated image and graphical replay.
|
|
72
100
|
* Like expo(), returns an app handle for the matching context method.
|
|
73
101
|
* Requires Linux-compatible application dependencies and binaries. */
|
|
74
102
|
export function electron(options: ElectronOptions = {}) {
|
|
75
103
|
const appDir = options.appDir ?? ".";
|
|
104
|
+
const context = options.context ?? appDir;
|
|
105
|
+
const appPath = appPathInContext(appDir, context);
|
|
76
106
|
const manifest = resolveProjectPath(`${appDir}/package.json`);
|
|
77
107
|
if (!existsSync(manifest)) throw new Error(`electron(): no package.json in ${appDir}`);
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
108
|
+
// A monorepo declares electron in the workspace or at the root; both work
|
|
109
|
+
// because the app resolves `electron` upward from its own directory.
|
|
110
|
+
if (!declaresElectron(manifest) && !declaresElectron(resolveProjectPath(`${context}/package.json`))) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
"electron(): declare electron in the app's dependencies or devDependencies" +
|
|
113
|
+
(context === appDir ? "" : ` (in ${appDir} or in the context ${context})`),
|
|
114
|
+
);
|
|
81
115
|
}
|
|
82
|
-
|
|
116
|
+
// The install runs at the context root, so that is where the lockfile counts.
|
|
117
|
+
const locked = existsSync(resolveProjectPath(`${context}/package-lock.json`));
|
|
83
118
|
const node = options.nodeVersion ?? "24-bookworm-slim";
|
|
84
119
|
if (!/^[\w.-]+$/.test(node)) throw new Error("electron(): invalid Node image tag");
|
|
85
120
|
const install = options.installCommand ?? (locked ? "npm ci" : "npm install");
|
|
@@ -87,18 +122,22 @@ export function electron(options: ElectronOptions = {}) {
|
|
|
87
122
|
if ([install, build].some(command => /[\r\n]/.test(command))) {
|
|
88
123
|
throw new Error("electron(): build/install commands must be single-line shell commands");
|
|
89
124
|
}
|
|
125
|
+
// Electron can be hoisted to the context root, so find its install script
|
|
126
|
+
// through Node's resolution from the app directory instead of a fixed path.
|
|
127
|
+
const inApp = appPath === "." ? "" : `cd ${appPath} && `;
|
|
128
|
+
const fetchElectron = `${inApp}node "$(node -p "require.resolve('electron/install.js')")"`;
|
|
90
129
|
return {
|
|
91
130
|
image: {
|
|
92
131
|
type: "dockerfile",
|
|
93
|
-
context
|
|
132
|
+
context,
|
|
94
133
|
exclude: ["node_modules", "**/node_modules", "out", "test-results", ".data"],
|
|
95
134
|
content: `FROM node:${node}
|
|
96
135
|
RUN apt-get update && apt-get install -y --no-install-recommends xvfb xauth libgtk-3-0 libnss3 libgbm1 libasound2 fonts-liberation ca-certificates && rm -rf /var/lib/apt/lists/*
|
|
97
136
|
WORKDIR /app
|
|
98
137
|
COPY . .
|
|
99
138
|
RUN ${install}
|
|
100
|
-
RUN
|
|
101
|
-
RUN ${build}
|
|
139
|
+
RUN ${fetchElectron}
|
|
140
|
+
RUN ${inApp}${build}
|
|
102
141
|
`,
|
|
103
142
|
},
|
|
104
143
|
files: [
|
|
@@ -106,7 +145,11 @@ RUN ${build}
|
|
|
106
145
|
{ path: "/spectest-electron-chrome.cjs", content: ELECTRON_CHROME_HOOK },
|
|
107
146
|
],
|
|
108
147
|
command: "xvfb-run -a node /spectest-electron.mjs",
|
|
109
|
-
env: {
|
|
148
|
+
env: {
|
|
149
|
+
...options.env,
|
|
150
|
+
SPECTEST_ELECTRON_ENTRY: options.entry ?? ".",
|
|
151
|
+
SPECTEST_ELECTRON_APP_DIR: appPath === "." ? "/app" : `/app/${appPath}`,
|
|
152
|
+
},
|
|
110
153
|
dependsOn: options.dependsOn,
|
|
111
154
|
ports: [9223],
|
|
112
155
|
readyCheck: { type: "http", port: 9223, path: "/json/version", timeoutSecs: 60 },
|