@specific.dev/spectest 0.37.0 → 0.39.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/browser.js CHANGED
@@ -110,11 +110,13 @@ const NAVIGATION_TIMEOUT_MS = 30_000;
110
110
  // comes from the HOME-scoped NSS user DB, so neither cares that playwright
111
111
  // runs a temp --user-data-dir.
112
112
  let PW_BROWSER = null;
113
- // The shared desktop context (default 1280×720 viewport). All desktop views
114
- // live here so they share one cookie jar, mirroring the old one-Chrome-
115
- // profile model. Mobile sessions and custom-viewport views get their own
116
- // contexts (viewport/DPR/UA/touch are context-scoped in playwright).
117
- let DESKTOP_CTX = null;
113
+ // Every view gets its OWN context, desktop included. A context is the cookie
114
+ // jar + storage, so one-context-per-view is what makes `close()` mean what
115
+ // the docs promise: the browsing session is destroyed and the next
116
+ // `ctx.browser()` starts signed out. Desktop views used to share one context
117
+ // (mirroring the old one-Chrome-profile model), which left `close()` closing
118
+ // the page only — cookies survived and the next session was still
119
+ // authenticated.
118
120
  function chromiumPath() {
119
121
  return (Bun.which("chromium") ??
120
122
  Bun.which("chromium-browser") ??
@@ -129,7 +131,6 @@ async function ensurePlaywrightBrowser() {
129
131
  headless: true,
130
132
  args: CHROME_ARGV,
131
133
  });
132
- DESKTOP_CTX = null; // contexts died with the old browser (if any)
133
134
  return PW_BROWSER;
134
135
  }
135
136
  /** Context options for the mobile device preset — playwright's native
@@ -150,18 +151,20 @@ function deviceContextOptions(d) {
150
151
  reducedMotion: "reduce",
151
152
  };
152
153
  }
153
- async function ensureDesktopContext() {
154
+ /** Create the context one view lives in — device emulation when the view is
155
+ * phone-emulated, a plain viewport otherwise. */
156
+ async function newViewContext(width, height, device) {
154
157
  const browser = await ensurePlaywrightBrowser();
155
- if (DESKTOP_CTX)
156
- return DESKTOP_CTX;
157
- DESKTOP_CTX = await browser.newContext({
158
- viewport: { width: 1280, height: 720 },
159
- // Reduced-motion for replay fidelity — see deviceContextOptions.
160
- reducedMotion: "reduce",
161
- });
162
- DESKTOP_CTX.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
163
- DESKTOP_CTX.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
164
- return DESKTOP_CTX;
158
+ const context = await browser.newContext(device
159
+ ? deviceContextOptions(device)
160
+ : {
161
+ viewport: { width, height },
162
+ // Reduced-motion for replay fidelity — see deviceContextOptions.
163
+ reducedMotion: "reduce",
164
+ });
165
+ context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
166
+ context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
167
+ return context;
165
168
  }
166
169
  /**
167
170
  * Make a user script evaluable by the page: Bun's `view.evaluate` accepts a
@@ -791,32 +794,10 @@ async function spawnPage(context) {
791
794
  }
792
795
  return { page, cdp, recordingInstalled };
793
796
  }
794
- /** Acquire the context a view of this shape lives in. */
795
- async function contextFor(width, height, device) {
796
- if (device) {
797
- const browser = await ensurePlaywrightBrowser();
798
- const context = await browser.newContext(deviceContextOptions(device));
799
- context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
800
- context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
801
- return { context, ownsContext: true };
802
- }
803
- if (width === 1280 && height === 720) {
804
- return { context: await ensureDesktopContext(), ownsContext: false };
805
- }
806
- const browser = await ensurePlaywrightBrowser();
807
- const context = await browser.newContext({
808
- viewport: { width, height },
809
- // Reduced-motion for replay fidelity — see deviceContextOptions.
810
- reducedMotion: "reduce",
811
- });
812
- context.setDefaultTimeout(DEFAULT_ACTION_TIMEOUT_MS);
813
- context.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT_MS);
814
- return { context, ownsContext: true };
815
- }
816
- /** Create a default-desktop view for the pool. */
797
+ /** Create a default-desktop view (plus its context) for the pool. */
817
798
  async function createView(width, height) {
818
- const { context } = await contextFor(width, height, null);
819
- return spawnPage(context);
799
+ const context = await newViewContext(width, height, null);
800
+ return { context, ...(await spawnPage(context)) };
820
801
  }
821
802
  /**
822
803
  * Pre-open `n` views into the pool (called by the daemon at the end of
@@ -860,8 +841,7 @@ export async function openMobileBackend(opts = {}) {
860
841
  let holder;
861
842
  if (pooled) {
862
843
  holder = {
863
- context: await ensureDesktopContext(),
864
- ownsContext: false,
844
+ context: pooled.context,
865
845
  page: pooled.page,
866
846
  cdp: pooled.cdp,
867
847
  lastTitle: "",
@@ -903,11 +883,10 @@ export async function openMobileBackend(opts = {}) {
903
883
  let SHARED_BROWSER = null;
904
884
  const SHARED_MOBILE = new Map();
905
885
  async function newHolder(width, height, device) {
906
- const { context, ownsContext } = await contextFor(width, height, device);
886
+ const context = await newViewContext(width, height, device);
907
887
  const spawned = await spawnPage(context);
908
888
  const holder = {
909
889
  context,
910
- ownsContext,
911
890
  page: spawned.page,
912
891
  cdp: spawned.cdp,
913
892
  lastTitle: "",
@@ -1406,13 +1385,13 @@ function buildBackend(holder, recorder, buildOpts) {
1406
1385
  catch {
1407
1386
  /* already closed by the runtime */
1408
1387
  }
1409
- if (holder.ownsContext) {
1410
- try {
1411
- await holder.context.close();
1412
- }
1413
- catch {
1414
- /* context already gone (browser died) */
1415
- }
1388
+ // The context, not the page, holds the cookies and storage — closing
1389
+ // only the page would leave the next session signed in as this one.
1390
+ try {
1391
+ await holder.context.close();
1392
+ }
1393
+ catch {
1394
+ /* context already gone (browser died) */
1416
1395
  }
1417
1396
  },
1418
1397
  // ── Mobile-only primitives ──────────────────────────────────────────
@@ -6,7 +6,7 @@ import { readFile, unlink } from "node:fs/promises";
6
6
  import { AppsV1Api, BatchV1Api, CoreV1Api, KubeConfig, KubernetesObjectApi, PatchStrategy, ResponseContext, ServerConfiguration, createConfiguration, loadAllYaml, } from "@kubernetes/client-node";
7
7
  import { Observable } from "@kubernetes/client-node/dist/gen/rxjsStub.js";
8
8
  import { dnsName, provides, SELF_SERVICE_TOKEN } from "../index.js";
9
- import { readRaw, readTag, wrap } from "../inspect.js";
9
+ import { deepUnwrap, readTag, wrap } from "../inspect.js";
10
10
  import { recorderAnnotate, recorderRemove } from "../recorder.js";
11
11
  /** Port the in-cluster registry listens on (plain HTTP). */
12
12
  const K3S_REGISTRY_PORT = 5000;
@@ -345,29 +345,6 @@ async function doFetch(request) {
345
345
  binary: async () => buf,
346
346
  });
347
347
  }
348
- /**
349
- * Recursively strip the inspector's carrier/proxy wrappers from a
350
- * value. Needed for arguments flowing into the kubernetes/client-node
351
- * API methods — if a wrapped pod's `metadata.name` (a primitive-carrier
352
- * object) reaches a URL template, the lib stringifies it to
353
- * `"[object Object]"` and the request 404s.
354
- */
355
- function deepUnwrap(value) {
356
- if (value === null || value === undefined)
357
- return value;
358
- const raw = readRaw(value);
359
- if (raw !== value)
360
- return deepUnwrap(raw);
361
- if (typeof value !== "object")
362
- return value;
363
- if (Array.isArray(value))
364
- return value.map(deepUnwrap);
365
- const out = {};
366
- for (const [k, v] of Object.entries(value)) {
367
- out[k] = deepUnwrap(v);
368
- }
369
- return out;
370
- }
371
348
  /**
372
349
  * Wrap a `@kubernetes/client-node` Api instance so each method call
373
350
  * runs in its own AsyncLocalStorage slot — `doFetch` writes the HTTP
package/dist/daemon.js CHANGED
@@ -27,7 +27,7 @@ import { acquirePersistentBrowser } from "./browser.js";
27
27
  import { isMobileApp, openPersistentMobile } from "./mobile.js";
28
28
  import { openTerminal } from "./terminal.js";
29
29
  import { recordEnv, recordExec, recordFake, recordHttp, recordTerminal, recordWait, reserveEvent, recorderEventCount, recorderMarkChildren, recorderTruncate, startRecording, stopRecording, truncateUtf8, } from "./recorder.js";
30
- import { wrap, wrapResponse } from "./inspect.js";
30
+ import { deepUnwrap, wrap, wrapResponse } from "./inspect.js";
31
31
  import { clearRecordSecrets, setRecordSecrets } from "./record-secrets.js";
32
32
  import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
33
33
  function namedServices(cfg) {
@@ -62,6 +62,9 @@ function hostCacheGateway() {
62
62
  return _hostCacheGateway;
63
63
  }
64
64
  const APP_DIR = process.env.SPECTEST_APP_DIR ?? "/opt/spectest/app";
65
+ // The bun the base snapshot installs (base.rs::BASE_SETUP_SH). The daemon
66
+ // runs under it, and eval's dependency install shells out to it.
67
+ const BUN_BIN = "/usr/local/bin/bun";
65
68
  // Root CA baked into the base snapshot at base-snapshot build time
66
69
  // (see base.rs::BASE_SETUP_SH). Bind-mounted into every service
67
70
  // container so apps can verify HTTPS to the daemon's fakes, and
@@ -550,6 +553,130 @@ async function ensureVolumes(svc) {
550
553
  }
551
554
  return flags;
552
555
  }
556
+ /** Keyed by image ID, not tag: `spectest/<svc>:latest` is retagged onto
557
+ * new content every rebuild, and a stale uid is silently wrong. */
558
+ const IMAGE_ID_TABLES = new Map();
559
+ let idProbeSeq = 0;
560
+ /**
561
+ * Read an image's `/etc/passwd` and `/etc/group` so a `user`/`group` can
562
+ * be written as a name rather than the uid the image happens to use.
563
+ *
564
+ * Done with `docker create` + `docker cp` rather than running `id` in the
565
+ * image: nothing is ever started, so it works on an image with no shell
566
+ * (distroless) and costs no entrypoint. An image with no `/etc/passwd`
567
+ * (scratch) yields empty tables — only a name that isn't there fails,
568
+ * and a numeric id never gets this far.
569
+ */
570
+ async function probeImageIdTables(tag) {
571
+ const users = new Map();
572
+ const groups = new Map();
573
+ const name = `spectest-idprobe-${idProbeSeq++}`;
574
+ const dir = path.join(WORKSPACE, ".spectest", "idprobe", name);
575
+ // Idempotent like runContainer: a leftover from a previous run would
576
+ // otherwise surface as a name conflict rather than the real problem.
577
+ await docker(["rm", "-f", name], 30_000);
578
+ // `--entrypoint` supplies the command `docker create` insists on for an
579
+ // image that declares neither ENTRYPOINT nor CMD. It is never executed.
580
+ const created = await docker(["create", "--name", name, "--entrypoint", "/spectest-idprobe", tag], 60_000);
581
+ if (created.code !== 0) {
582
+ throw new Error(`could not read the user/group tables of image ${tag}: ${created.stderr.trim()}`);
583
+ }
584
+ try {
585
+ await fs.mkdir(dir, { recursive: true });
586
+ for (const [file, table] of [
587
+ ["passwd", users],
588
+ ["group", groups],
589
+ ]) {
590
+ const dst = path.join(dir, file);
591
+ const cp = await docker(["cp", `${name}:/etc/${file}`, dst], 60_000);
592
+ if (cp.code !== 0)
593
+ continue;
594
+ const text = await fs.readFile(dst, "utf8").catch(() => "");
595
+ for (const line of text.split("\n")) {
596
+ // name:x:id:… — field 2 is the uid in passwd, the gid in group.
597
+ const fields = line.split(":");
598
+ const id = Number(fields[2]);
599
+ if (fields[0] && fields.length >= 3 && Number.isInteger(id)) {
600
+ table.set(fields[0], id);
601
+ }
602
+ }
603
+ }
604
+ }
605
+ finally {
606
+ await docker(["rm", "-f", name], 30_000);
607
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => { });
608
+ }
609
+ return { users, groups };
610
+ }
611
+ async function imageIdTables(tag) {
612
+ const insp = await docker(["image", "inspect", "--format", "{{.Id}}", tag], 30_000);
613
+ const key = insp.code === 0 && insp.stdout.trim() ? insp.stdout.trim() : tag;
614
+ let tables = IMAGE_ID_TABLES.get(key);
615
+ if (!tables) {
616
+ // Stored before the await so concurrent services on one image probe once.
617
+ tables = probeImageIdTables(tag);
618
+ IMAGE_ID_TABLES.set(key, tables);
619
+ }
620
+ return tables;
621
+ }
622
+ /**
623
+ * Resolve a declared `user`/`group` against `tag`. Numeric ids are taken
624
+ * as-is and never touch the image; names cost one probe per image.
625
+ * Returns `undefined` when neither is declared — the common case, which
626
+ * must stay free.
627
+ */
628
+ async function resolveOwner(svc, tag, what, user, group) {
629
+ if (user === undefined && group === undefined)
630
+ return undefined;
631
+ const numeric = (v) => v !== undefined && /^[0-9]+$/.test(v) ? Number(v) : undefined;
632
+ let uid = numeric(user);
633
+ let gid = numeric(group);
634
+ const needsTables = (user !== undefined && uid === undefined) ||
635
+ (group !== undefined && gid === undefined);
636
+ if (needsTables) {
637
+ const tables = await imageIdTables(tag);
638
+ if (user !== undefined && uid === undefined) {
639
+ uid = tables.users.get(user);
640
+ if (uid === undefined) {
641
+ throw new Error(`service "${svc.name}": ${what} user ${JSON.stringify(user)} is not in the image's /etc/passwd — use a numeric uid`);
642
+ }
643
+ }
644
+ if (group !== undefined && gid === undefined) {
645
+ gid = tables.groups.get(group);
646
+ if (gid === undefined) {
647
+ throw new Error(`service "${svc.name}": ${what} group ${JSON.stringify(group)} is not in the image's /etc/group — use a numeric gid`);
648
+ }
649
+ }
650
+ }
651
+ // chown(2) reads -1 as "unchanged", so `user` alone keeps the group
652
+ // and vice versa — the same thing plain `chown` does.
653
+ return { uid: uid ?? -1, gid: gid ?? -1 };
654
+ }
655
+ /**
656
+ * Apply `mode` and `owner` to a staged file, before it is bind-mounted
657
+ * into a container that does not exist yet.
658
+ *
659
+ * The `-1` halves are resolved against the file's current owner rather
660
+ * than passed through: **Bun's `fs.chown` rejects `-1` with `EPERM`**
661
+ * (measured on Bun 1.3.14; node and chown(2) both read it as "leave this
662
+ * one alone"), so handing it straight to the syscall would break exactly
663
+ * the common cases — a `user` with no `group`, and the reverse.
664
+ */
665
+ async function applyFileOwnership(file, mode, owner) {
666
+ if (mode)
667
+ await fs.chmod(file, parseInt(mode, 8));
668
+ if (!owner)
669
+ return;
670
+ let { uid, gid } = owner;
671
+ if (uid < 0 || gid < 0) {
672
+ const st = await fs.stat(file);
673
+ if (uid < 0)
674
+ uid = st.uid;
675
+ if (gid < 0)
676
+ gid = st.gid;
677
+ }
678
+ await fs.chown(file, uid, gid);
679
+ }
553
680
  // Materialize `svc.files` onto the VM host and return `--volume` flags
554
681
  // bind-mounting each into the container (read-only). Single-file bind
555
682
  // mounts mean the seeded config lands in place *before the container's
@@ -557,7 +684,7 @@ async function ensureVolumes(svc) {
557
684
  // hook. Staging path mirrors ensureVolumes: a per-service dir derived
558
685
  // from the in-container path, so two files never collide and the
559
686
  // content is captured by snapshots like everything else under WORKSPACE.
560
- async function ensureFiles(svc) {
687
+ async function ensureFiles(svc, tag) {
561
688
  const flags = [];
562
689
  if (!svc.files || svc.files.length === 0)
563
690
  return flags;
@@ -574,8 +701,12 @@ async function ensureFiles(svc) {
574
701
  const content = f.content.replaceAll("{{SPECTEST_SERVICE}}", svc.name);
575
702
  const host = path.join(dir, sanitizeSegment(f.path));
576
703
  await fs.writeFile(host, content);
577
- if (f.mode)
578
- await fs.chmod(host, parseInt(f.mode, 8));
704
+ // A bind mount carries this inode's mode and ownership into the
705
+ // container verbatim, and we write as root — so a `mode` that locks
706
+ // the file down needs `user`/`group` beside it to stay readable to
707
+ // whoever the container actually runs as.
708
+ const owner = await resolveOwner(svc, tag, `file ${f.path}`, f.user, f.group);
709
+ await applyFileOwnership(host, f.mode, owner);
579
710
  flags.push(`--volume=${host}:${f.path}:ro`);
580
711
  }
581
712
  return flags;
@@ -595,7 +726,7 @@ async function ensureFiles(svc) {
595
726
  * (~50 ms), and a stale one outliving a CA rotation would fail in a way
596
727
  * that reads as a code bug.
597
728
  */
598
- async function ensureCertificates(svc) {
729
+ async function ensureCertificates(svc, tag) {
599
730
  const flags = [];
600
731
  const certs = svc.certificates ?? [];
601
732
  if (certs.length === 0)
@@ -624,11 +755,21 @@ async function ensureCertificates(svc) {
624
755
  const keyHost = path.join(dir, `${i}.key`);
625
756
  await fs.writeFile(certHost, cert);
626
757
  await fs.writeFile(keyHost, key);
627
- // The key's mode has to be set on the staged file: a bind mount
628
- // carries the host inode's permissions straight through, and a
629
- // server that checks (postgres, ssh) refuses a lax one.
630
- if (c.mode)
631
- await fs.chmod(keyHost, parseInt(c.mode, 8));
758
+ // Mode AND ownership have to be set on the staged files: a bind
759
+ // mount carries the host inode's straight through, and the daemon
760
+ // writes as root. A server that checks (postgres, ssh) refuses a lax
761
+ // key, but a strict root-owned one it can't open is just as fatal —
762
+ // which is why `mode` alone used to force an entrypoint wrapper that
763
+ // re-installed the key as the right user.
764
+ const owner = await resolveOwner(svc, tag, `certificate ${i}`, c.user, c.group);
765
+ // Declaring who reads the key also says what mode it wants: the
766
+ // strictest one that owner can still open. Only reached when
767
+ // `user`/`group` is set, so no existing environment changes.
768
+ const keyMode = c.mode ?? (owner ? (owner.uid === -1 ? "0640" : "0600") : undefined);
769
+ await applyFileOwnership(keyHost, keyMode, owner);
770
+ // The certificate is public, but it follows the key's owner so a
771
+ // server that insists on owning its whole TLS directory is happy.
772
+ await applyFileOwnership(certHost, undefined, owner);
632
773
  flags.push(`--volume=${certHost}:${c.certPath}:ro`);
633
774
  flags.push(`--volume=${keyHost}:${c.keyPath}:ro`);
634
775
  if (c.caPath)
@@ -2313,8 +2454,8 @@ async function startRuntimeService(spec) {
2313
2454
  const { tag } = await prepareServiceImage(svc);
2314
2455
  const flags = [
2315
2456
  ...(await ensureVolumes(svc)),
2316
- ...(await ensureFiles(svc)),
2317
- ...(await ensureCertificates(svc)),
2457
+ ...(await ensureFiles(svc, tag)),
2458
+ ...(await ensureCertificates(svc, tag)),
2318
2459
  ];
2319
2460
  await runContainer(svc, tag, flags, aliases);
2320
2461
  await waitForReady(svc);
@@ -2387,8 +2528,9 @@ async function ensureFakeHelpers(name) {
2387
2528
  fake.trackedHelpers = trackFakeHelpers(name, fake.helpers);
2388
2529
  return fake.trackedHelpers;
2389
2530
  }
2390
- /** Wrap a fake's helpers so each call becomes a recorded `fake` event
2391
- * and its return value is `wrap()`ped for assertion provenance. Helpers
2531
+ /** Wrap a fake's helpers so each call becomes a recorded `fake` event,
2532
+ * its arguments are `deepUnwrap`ped, and its return value is `wrap()`ped
2533
+ * for assertion provenance. Helpers
2392
2534
  * are functions that read/mutate the fake's private state via closure;
2393
2535
  * tests only ever see what those functions return. The proxy is built
2394
2536
  * once and shared across tests; it consults the recorder at call time,
@@ -2415,10 +2557,21 @@ function trackFakeHelpers(fakeName, helpers) {
2415
2557
  }
2416
2558
  /** Invoke a fake helper function, recording a `fake` event and wrapping
2417
2559
  * the return value. Handles both sync and async helpers, and records an
2418
- * error event (then rethrows) if the helper throws. */
2419
- function invokeFakeHelper(fakeName, member, fn, thisArg, args) {
2560
+ * error event (then rethrows) if the helper throws.
2561
+ *
2562
+ * Arguments are `deepUnwrap`ped on the way in, so a helper body is plain
2563
+ * user code operating on plain values: a test that feeds one helper's
2564
+ * return (or a `ctx.fetch` field) to another passes a provenance wrapper,
2565
+ * and `msg.html.matchAll(...)` inside the fake would otherwise throw a
2566
+ * `TypeError` from a stack the author can't see. Same treatment the k8s
2567
+ * client's arguments get (`components/k3s.ts::withTagging`). The types
2568
+ * still say the value is a handle — passing a wrapped value where the
2569
+ * parameter type is known is a compile error asking for `.unwrap()`; this
2570
+ * catches the case where it isn't known (an `any` off `req.json()`). */
2571
+ function invokeFakeHelper(fakeName, member, fn, thisArg, callArgs) {
2420
2572
  const t = Date.now();
2421
2573
  const resv = reserveEvent();
2574
+ const args = callArgs.map((a) => deepUnwrap(a));
2422
2575
  const safeArgs = args.map((a) => safeSerialize(a));
2423
2576
  const recordResult = (value) => {
2424
2577
  const seq = recordFake({
@@ -2618,14 +2771,16 @@ async function bootstrapInner() {
2618
2771
  // deps; this adds the image edge. The two compose: we run the moment
2619
2772
  // both are satisfied, with no whole-graph barrier between them.
2620
2773
  await prep.get(svc.name);
2621
- const flags = [
2622
- ...(await ensureVolumes(svc)),
2623
- ...(await ensureFiles(svc)),
2624
- ...(await ensureCertificates(svc)),
2625
- ];
2774
+ // The tag is read before the mounts are staged: a `files`/
2775
+ // `certificates` owner given by name is resolved against the image.
2626
2776
  const tag = tags.get(svc.name);
2627
2777
  if (!tag)
2628
2778
  throw new Error(`internal: no image tag for ${svc.name}`);
2779
+ const flags = [
2780
+ ...(await ensureVolumes(svc)),
2781
+ ...(await ensureFiles(svc, tag)),
2782
+ ...(await ensureCertificates(svc, tag)),
2783
+ ];
2629
2784
  const tRun = Date.now();
2630
2785
  progressService(svc.name, { status: "starting", detail: undefined });
2631
2786
  await runContainer(svc, tag, flags);
@@ -4025,9 +4180,86 @@ function packageName(spec) {
4025
4180
  return spec.split("/")[0];
4026
4181
  }
4027
4182
  /**
4028
- * Scan the snippet's imports and `bun add` anything that doesn't already
4029
- * resolve. Skips relative paths, absolute paths, `node:`/`bun:` built-ins,
4030
- * and HTTP(S)/file: URLs.
4183
+ * Is this package installed? Read from the disk, and deliberately not with
4184
+ * `Bun.resolveSync` — a resolve that misses is cached for the life of the
4185
+ * process (see `ensureDeps`), so asking the resolver whether a package is
4186
+ * missing is what makes it stay missing.
4187
+ *
4188
+ * Walks up from `APP_DIR` like a module resolver does, so a hoisted
4189
+ * install in an ancestor `node_modules` counts.
4190
+ */
4191
+ async function packageInstalled(pkg) {
4192
+ let dir = APP_DIR;
4193
+ for (;;) {
4194
+ if (await fs.exists(path.join(dir, "node_modules", pkg, "package.json")))
4195
+ return true;
4196
+ const parent = path.dirname(dir);
4197
+ if (parent === dir)
4198
+ return false;
4199
+ dir = parent;
4200
+ }
4201
+ }
4202
+ /**
4203
+ * Resolve `spec` in a throwaway `bun` process, which starts with an empty
4204
+ * resolver cache. Returns null if it still does not resolve — the snippet's
4205
+ * own import then reports the real error.
4206
+ */
4207
+ async function resolveInNewProcess(spec) {
4208
+ const probe = `process.stdout.write(Bun.resolveSync(${JSON.stringify(spec)}, ${JSON.stringify(APP_DIR)}))`;
4209
+ const proc = Bun.spawn([BUN_BIN, "-e", probe], {
4210
+ cwd: APP_DIR,
4211
+ stdout: "pipe",
4212
+ stderr: "ignore",
4213
+ });
4214
+ const out = (await new Response(proc.stdout).text()).trim();
4215
+ const code = await proc.exited;
4216
+ if (code !== 0 || out.length === 0)
4217
+ return null;
4218
+ return out;
4219
+ }
4220
+ /**
4221
+ * Replace one import specifier in the snippet — in import positions only
4222
+ * (`from "x"`, `import "x"`, `import("x")`, `require("x")`), so a plain
4223
+ * string that is equal to the specifier stays as it is.
4224
+ */
4225
+ function rewriteSpecifier(code, spec, target) {
4226
+ const escaped = spec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4227
+ const pattern = new RegExp(`((?:\\bfrom|\\bimport|\\brequire)\\s*\\(?\\s*)(["'])${escaped}\\2`, "g");
4228
+ // A callback, not a replacement string: an absolute path can contain `$`.
4229
+ return code.replace(pattern, (_match, lead, quote) => `${lead}${quote}${target}${quote}`);
4230
+ }
4231
+ /** Point each given specifier at the module's absolute path. A specifier
4232
+ * that still does not resolve is left as it is, so the snippet's own
4233
+ * import reports the real error. */
4234
+ async function rewriteUnreachable(code, specs) {
4235
+ let patched = code;
4236
+ for (const spec of specs) {
4237
+ const target = await resolveInNewProcess(spec);
4238
+ if (target)
4239
+ patched = rewriteSpecifier(patched, spec, target);
4240
+ }
4241
+ return patched;
4242
+ }
4243
+ /**
4244
+ * Scan the snippet's imports and `bun add` anything that isn't installed.
4245
+ * Skips relative paths, absolute paths, `node:`/`bun:` built-ins, and
4246
+ * HTTP(S)/file: URLs. Returns the snippet to actually run, plus the packages
4247
+ * it installed.
4248
+ *
4249
+ * A specifier the bare import cannot reach is **rewritten to the absolute
4250
+ * path of the module**, because Bun caches a failed resolution for the life
4251
+ * of the process and the daemon is long-lived: an eval that imports
4252
+ * `otpauth` before it is installed makes every later
4253
+ * `import * as OTPAuth from "otpauth"` keep failing with `Cannot find
4254
+ * package 'otpauth'` — even after the install put it on the disk. Nothing
4255
+ * clears that entry: not a later `Bun.resolveSync`, not an `onResolve`
4256
+ * plugin, not a new directory to import from (all measured on Bun 1.3.14,
4257
+ * where node 24 re-resolves and succeeds). The cache keeps hits the same
4258
+ * way — a package removed mid-process still resolves — so treat resolution
4259
+ * in this process as a snapshot taken at first ask.
4260
+ * An absolute path never consults the cache. The path comes from a new
4261
+ * `bun` process, so the package's own `exports` conditions apply exactly as
4262
+ * they would for the bare specifier.
4031
4263
  */
4032
4264
  async function ensureDeps(code) {
4033
4265
  let scanned;
@@ -4036,9 +4268,13 @@ async function ensureDeps(code) {
4036
4268
  }
4037
4269
  catch {
4038
4270
  // Invalid syntax — let the import call surface the real error.
4039
- return [];
4271
+ return { code, installed: [] };
4040
4272
  }
4041
4273
  const seen = new Set();
4274
+ // Specifiers the bare import cannot reach, and the packages to install for
4275
+ // them. A subpath (`otpauth/dist/…`) resolves to its own file, so these are
4276
+ // deduped per specifier, not per package.
4277
+ const unreachable = [];
4042
4278
  const missing = [];
4043
4279
  for (const imp of scanned) {
4044
4280
  const p = imp.path;
@@ -4051,21 +4287,32 @@ async function ensureDeps(code) {
4051
4287
  p.startsWith("file:")) {
4052
4288
  continue;
4053
4289
  }
4290
+ if (seen.has(p))
4291
+ continue;
4292
+ seen.add(p);
4054
4293
  const pkg = packageName(p);
4055
- if (seen.has(pkg))
4294
+ if (!(await packageInstalled(pkg))) {
4295
+ unreachable.push(p);
4296
+ if (!missing.includes(pkg))
4297
+ missing.push(pkg);
4056
4298
  continue;
4057
- seen.add(pkg);
4299
+ }
4300
+ // Installed — but a poisoned cache entry from an earlier eval can still
4301
+ // fail the import, so ask the resolver. This can only cache a hit.
4058
4302
  try {
4059
4303
  Bun.resolveSync(p, APP_DIR);
4060
4304
  }
4061
4305
  catch {
4062
- missing.push(pkg);
4306
+ unreachable.push(p);
4063
4307
  }
4064
4308
  }
4065
- if (missing.length === 0)
4066
- return [];
4309
+ if (unreachable.length === 0)
4310
+ return { code, installed: [] };
4311
+ if (missing.length === 0) {
4312
+ return { code: await rewriteUnreachable(code, unreachable), installed: [] };
4313
+ }
4067
4314
  await new Promise((resolve, reject) => {
4068
- execFile("/usr/local/bin/bun", ["add", ...missing], { cwd: APP_DIR, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
4315
+ execFile(BUN_BIN, ["add", ...missing], { cwd: APP_DIR, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
4069
4316
  if (err) {
4070
4317
  reject(new Error(`bun add ${missing.join(" ")} failed:\n${String(stderr).trim()}\n${String(stdout).trim()}`));
4071
4318
  }
@@ -4074,7 +4321,7 @@ async function ensureDeps(code) {
4074
4321
  }
4075
4322
  });
4076
4323
  });
4077
- return missing;
4324
+ return { code: await rewriteUnreachable(code, unreachable), installed: missing };
4078
4325
  }
4079
4326
  /**
4080
4327
  * Turn the bare parser error an `export default` misuse produces
@@ -4271,10 +4518,14 @@ async function evalCode(code, secrets) {
4271
4518
  let filePath;
4272
4519
  let outcome;
4273
4520
  try {
4274
- installed = await ensureDeps(code);
4521
+ // The snippet that runs can differ from the one the user sent: a
4522
+ // freshly installed import is rewritten to an absolute path (see
4523
+ // ensureDeps). Error messages keep using the original `code`.
4524
+ const prepared = await ensureDeps(code);
4525
+ installed = prepared.installed;
4275
4526
  await fs.mkdir(EVAL_DIR, { recursive: true });
4276
4527
  filePath = path.join(EVAL_DIR, `${randomUUID()}.ts`);
4277
- await fs.writeFile(filePath, code);
4528
+ await fs.writeFile(filePath, prepared.code);
4278
4529
  const mod = (await import(pathToFileURL(filePath).href));
4279
4530
  outcome = { ok: true, result: safeSerialize(mod.default) };
4280
4531
  }
package/dist/index.d.ts CHANGED
@@ -684,6 +684,18 @@ export interface FileMount {
684
684
  * file before it's bind-mounted. Defaults to the writer's umask.
685
685
  */
686
686
  mode?: string;
687
+ /**
688
+ * Owner of the staged file — a user name from the image's
689
+ * `/etc/passwd` (resolved against the image, so `"postgres"` works),
690
+ * or a numeric uid. A bind mount carries the staged file's ownership
691
+ * straight through, so a file written by the daemon lands as `root`:
692
+ * pair a restrictive `mode` with `user` or the container's own
693
+ * process can't read it.
694
+ */
695
+ user?: string;
696
+ /** Group of the staged file — a group name from the image's
697
+ * `/etc/group`, or a numeric gid. */
698
+ group?: string;
687
699
  }
688
700
  /** PEM material returned by `ctx.certificate(hostnames)`. */
689
701
  export interface CertificateMaterial {
@@ -713,9 +725,28 @@ export interface CertificateMount {
713
725
  /**
714
726
  * Optional octal mode (e.g. `"0600"`) applied to the staged key.
715
727
  * Servers that refuse a group/world-readable key (postgres, ssh) need
716
- * this; the default is the writer's umask.
728
+ * this. The default is the writer's umask — or, once `user`/`group`
729
+ * says who reads the key, the strictest mode that owner can still
730
+ * read (`0600`, or `0640` when only `group` is given).
717
731
  */
718
732
  mode?: string;
733
+ /**
734
+ * Owner of the staged key and certificate — a user name from the
735
+ * image's `/etc/passwd` (resolved against the image, so
736
+ * `"postgres"` works), or a numeric uid.
737
+ *
738
+ * A bind mount carries the staged file's ownership through, and the
739
+ * daemon writes as `root`, so a strict `mode` alone gives a
740
+ * non-root server a key it cannot open — postgres reports
741
+ * `could not access private key file`. `user` is what makes the
742
+ * pair work, with no entrypoint wrapper and no custom image.
743
+ */
744
+ user?: string;
745
+ /** Group of the staged key and certificate — a group name from the
746
+ * image's `/etc/group`, or a numeric gid. Enough on its own for a
747
+ * server that accepts a root-owned key readable by its group
748
+ * (postgres does, at `0640`). */
749
+ group?: string;
719
750
  }
720
751
  export type ReadyCheck = {
721
752
  type: "tcp";
package/dist/inspect.d.ts CHANGED
@@ -22,6 +22,29 @@ export declare function clearPendingNullish(): void;
22
22
  export declare function adoptNullishTag<T>(value: T): T;
23
23
  /** If x is wrapped, return the raw value; otherwise return x. */
24
24
  export declare function readRaw<T>(x: T): T;
25
+ /**
26
+ * Strip provenance wrappers from a value AND from anything nested inside a
27
+ * plain object/array it holds. The inbound counterpart of {@link wrap}: for
28
+ * values crossing *back* into code that expects raw data — a fake's `helpers`,
29
+ * a k8s client method's arguments. Without it a helper that takes a value some
30
+ * earlier op produced gets a `Carrier`/proxy, and the first `.matchAll(...)` /
31
+ * typed-client call inside it throws a `TypeError` from deep in the callee,
32
+ * far from the call site that passed the wrapper. Coercion sinks
33
+ * ({@link makeCarrier}) rescue only the interpolation cases; a callee that
34
+ * calls a method on the value, or checks its `typeof`, still sees an object.
35
+ *
36
+ * One {@link readRaw} finishes a wrapped value: a wrapper's target is raw all
37
+ * the way down (the proxy wraps children lazily, on read), so there is nothing
38
+ * left to walk underneath it. The recursion is for the other shape — a
39
+ * container the *caller* built around wrapped leaves: `{ to: msg.to }`,
40
+ * `[row.id, row.email]`.
41
+ *
42
+ * Rebuilds only what changed, and only plain objects/arrays: a `Date`,
43
+ * `Buffer`, `Map`, `Response` or class instance is returned untouched rather
44
+ * than flattened into a plain object. Cyclic containers are left in place at
45
+ * the point the cycle closes.
46
+ */
47
+ export declare function deepUnwrap<T>(value: T): T;
25
48
  /** The raw type behind a wrapper: a `Carrier`/`WrappedObject`/`WrappedArray`/
26
49
  * `WrappedResponse` resolves to its `unwrap()` return type; anything else is
27
50
  * already raw and passes through unchanged. */
package/dist/inspect.js CHANGED
@@ -136,6 +136,71 @@ export function readRaw(x) {
136
136
  }
137
137
  return cur;
138
138
  }
139
+ /**
140
+ * Strip provenance wrappers from a value AND from anything nested inside a
141
+ * plain object/array it holds. The inbound counterpart of {@link wrap}: for
142
+ * values crossing *back* into code that expects raw data — a fake's `helpers`,
143
+ * a k8s client method's arguments. Without it a helper that takes a value some
144
+ * earlier op produced gets a `Carrier`/proxy, and the first `.matchAll(...)` /
145
+ * typed-client call inside it throws a `TypeError` from deep in the callee,
146
+ * far from the call site that passed the wrapper. Coercion sinks
147
+ * ({@link makeCarrier}) rescue only the interpolation cases; a callee that
148
+ * calls a method on the value, or checks its `typeof`, still sees an object.
149
+ *
150
+ * One {@link readRaw} finishes a wrapped value: a wrapper's target is raw all
151
+ * the way down (the proxy wraps children lazily, on read), so there is nothing
152
+ * left to walk underneath it. The recursion is for the other shape — a
153
+ * container the *caller* built around wrapped leaves: `{ to: msg.to }`,
154
+ * `[row.id, row.email]`.
155
+ *
156
+ * Rebuilds only what changed, and only plain objects/arrays: a `Date`,
157
+ * `Buffer`, `Map`, `Response` or class instance is returned untouched rather
158
+ * than flattened into a plain object. Cyclic containers are left in place at
159
+ * the point the cycle closes.
160
+ */
161
+ export function deepUnwrap(value) {
162
+ return deepUnwrapInner(value, new Set());
163
+ }
164
+ function deepUnwrapInner(value, seen) {
165
+ if (value === null || value === undefined)
166
+ return value;
167
+ const raw = readRaw(value);
168
+ // It was wrapped, so it's now fully raw — and recursing into it would be
169
+ // wrong anyway (the raw form may be an exotic object we must not rebuild).
170
+ if (raw !== value)
171
+ return raw;
172
+ if (typeof raw !== "object")
173
+ return raw;
174
+ const obj = raw;
175
+ if (seen.has(obj))
176
+ return obj;
177
+ if (Array.isArray(obj)) {
178
+ seen.add(obj);
179
+ let changed = false;
180
+ const out = obj.map((el) => {
181
+ const next = deepUnwrapInner(el, seen);
182
+ if (next !== el)
183
+ changed = true;
184
+ return next;
185
+ });
186
+ seen.delete(obj);
187
+ return changed ? out : obj;
188
+ }
189
+ const proto = Object.getPrototypeOf(obj);
190
+ if (proto !== Object.prototype && proto !== null)
191
+ return obj;
192
+ seen.add(obj);
193
+ let changed = false;
194
+ const out = {};
195
+ for (const [k, v] of Object.entries(obj)) {
196
+ const next = deepUnwrapInner(v, seen);
197
+ if (next !== v)
198
+ changed = true;
199
+ out[k] = next;
200
+ }
201
+ seen.delete(obj);
202
+ return changed ? out : obj;
203
+ }
139
204
  /**
140
205
  * Wrap a value so reads through it carry an `OpTag`. Recursion is lazy:
141
206
  * a property read on an object Proxy wraps its child on demand.
package/dist/locator.d.ts CHANGED
@@ -5,7 +5,7 @@ import type { Wrapped } from "./inspect.js";
5
5
  * Playwright's own default is 30s — far too slow-failing for tests; 5s
6
6
  * matches the pre-Playwright behavior. A per-call `{ timeout }` overrides it;
7
7
  * `undefined` falls through to the context default (also set to this in
8
- * browser.ts's `contextFor`). Navigations keep a longer deadline. */
8
+ * browser.ts's `newViewContext`). Navigations keep a longer deadline. */
9
9
  export declare const DEFAULT_ACTION_TIMEOUT_MS = 5000;
10
10
  export interface GetByTextOptions {
11
11
  /** Whole-string, case-sensitive match instead of the default
package/dist/locator.js CHANGED
@@ -26,7 +26,7 @@ import { truncateUtf8 } from "./recorder.js";
26
26
  * Playwright's own default is 30s — far too slow-failing for tests; 5s
27
27
  * matches the pre-Playwright behavior. A per-call `{ timeout }` overrides it;
28
28
  * `undefined` falls through to the context default (also set to this in
29
- * browser.ts's `contextFor`). Navigations keep a longer deadline. */
29
+ * browser.ts's `newViewContext`). Navigations keep a longer deadline. */
30
30
  export const DEFAULT_ACTION_TIMEOUT_MS = 5_000;
31
31
  // Brand + chain carrier. Both are `Symbol.for` keys so `JSON.stringify` drops
32
32
  // them (locators are never serialized) while runtime code can still detect a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.37.0",
3
+ "version": "0.39.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",