@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/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
- * `json<T>()` / `text()` return {@link Wrapped} body values; `.unwrap()`
226
- * (or {@link unwrap the whole response}) recovers the plain `Response`.
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: 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<ArrayBuffer>;
240
- blob(): Promise<Blob>;
241
- formData(): Promise<FormData>;
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?: RequestInit) => Promise<WrappedResponse>;
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`. The body-reading methods (`json`, `text`) return the
254
- * resolved value wrapped under `path: ["body"]`. Everything else passes
255
- * through bound to the real Response.
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`. The body-reading methods (`json`, `text`) return the
444
- * resolved value wrapped under `path: ["body"]`. Everything else passes
445
- * through bound to the real Response.
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 wrap(value, sourceSeq, ["body"]);
628
+ return wrapChild(value, sourceSeq, ["body"]);
480
629
  };
481
630
  }
482
631
  const value = target[prop];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.88.2",
3
+ "version": "0.89.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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 and Electron. */
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 require = createRequire('/app/package.json');
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: '/app', stdio: 'inherit', env: process.env });
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
- const pkg = JSON.parse(readFileSync(manifest, "utf8"));
79
- if (!pkg.dependencies?.electron && !pkg.devDependencies?.electron) {
80
- throw new Error("electron(): declare electron in the app's dependencies or devDependencies");
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
- const locked = existsSync(resolveProjectPath(`${appDir}/package-lock.json`));
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: appDir,
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 node node_modules/electron/install.js
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: { ...options.env, SPECTEST_ELECTRON_ENTRY: options.entry ?? "." },
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 },