@specific.dev/spectest 0.37.0 → 0.38.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 ──────────────────────────────────────────
package/dist/daemon.js CHANGED
@@ -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);
@@ -2618,14 +2759,16 @@ async function bootstrapInner() {
2618
2759
  // deps; this adds the image edge. The two compose: we run the moment
2619
2760
  // both are satisfied, with no whole-graph barrier between them.
2620
2761
  await prep.get(svc.name);
2621
- const flags = [
2622
- ...(await ensureVolumes(svc)),
2623
- ...(await ensureFiles(svc)),
2624
- ...(await ensureCertificates(svc)),
2625
- ];
2762
+ // The tag is read before the mounts are staged: a `files`/
2763
+ // `certificates` owner given by name is resolved against the image.
2626
2764
  const tag = tags.get(svc.name);
2627
2765
  if (!tag)
2628
2766
  throw new Error(`internal: no image tag for ${svc.name}`);
2767
+ const flags = [
2768
+ ...(await ensureVolumes(svc)),
2769
+ ...(await ensureFiles(svc, tag)),
2770
+ ...(await ensureCertificates(svc, tag)),
2771
+ ];
2629
2772
  const tRun = Date.now();
2630
2773
  progressService(svc.name, { status: "starting", detail: undefined });
2631
2774
  await runContainer(svc, tag, flags);
@@ -4025,9 +4168,86 @@ function packageName(spec) {
4025
4168
  return spec.split("/")[0];
4026
4169
  }
4027
4170
  /**
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.
4171
+ * Is this package installed? Read from the disk, and deliberately not with
4172
+ * `Bun.resolveSync` — a resolve that misses is cached for the life of the
4173
+ * process (see `ensureDeps`), so asking the resolver whether a package is
4174
+ * missing is what makes it stay missing.
4175
+ *
4176
+ * Walks up from `APP_DIR` like a module resolver does, so a hoisted
4177
+ * install in an ancestor `node_modules` counts.
4178
+ */
4179
+ async function packageInstalled(pkg) {
4180
+ let dir = APP_DIR;
4181
+ for (;;) {
4182
+ if (await fs.exists(path.join(dir, "node_modules", pkg, "package.json")))
4183
+ return true;
4184
+ const parent = path.dirname(dir);
4185
+ if (parent === dir)
4186
+ return false;
4187
+ dir = parent;
4188
+ }
4189
+ }
4190
+ /**
4191
+ * Resolve `spec` in a throwaway `bun` process, which starts with an empty
4192
+ * resolver cache. Returns null if it still does not resolve — the snippet's
4193
+ * own import then reports the real error.
4194
+ */
4195
+ async function resolveInNewProcess(spec) {
4196
+ const probe = `process.stdout.write(Bun.resolveSync(${JSON.stringify(spec)}, ${JSON.stringify(APP_DIR)}))`;
4197
+ const proc = Bun.spawn([BUN_BIN, "-e", probe], {
4198
+ cwd: APP_DIR,
4199
+ stdout: "pipe",
4200
+ stderr: "ignore",
4201
+ });
4202
+ const out = (await new Response(proc.stdout).text()).trim();
4203
+ const code = await proc.exited;
4204
+ if (code !== 0 || out.length === 0)
4205
+ return null;
4206
+ return out;
4207
+ }
4208
+ /**
4209
+ * Replace one import specifier in the snippet — in import positions only
4210
+ * (`from "x"`, `import "x"`, `import("x")`, `require("x")`), so a plain
4211
+ * string that is equal to the specifier stays as it is.
4212
+ */
4213
+ function rewriteSpecifier(code, spec, target) {
4214
+ const escaped = spec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4215
+ const pattern = new RegExp(`((?:\\bfrom|\\bimport|\\brequire)\\s*\\(?\\s*)(["'])${escaped}\\2`, "g");
4216
+ // A callback, not a replacement string: an absolute path can contain `$`.
4217
+ return code.replace(pattern, (_match, lead, quote) => `${lead}${quote}${target}${quote}`);
4218
+ }
4219
+ /** Point each given specifier at the module's absolute path. A specifier
4220
+ * that still does not resolve is left as it is, so the snippet's own
4221
+ * import reports the real error. */
4222
+ async function rewriteUnreachable(code, specs) {
4223
+ let patched = code;
4224
+ for (const spec of specs) {
4225
+ const target = await resolveInNewProcess(spec);
4226
+ if (target)
4227
+ patched = rewriteSpecifier(patched, spec, target);
4228
+ }
4229
+ return patched;
4230
+ }
4231
+ /**
4232
+ * Scan the snippet's imports and `bun add` anything that isn't installed.
4233
+ * Skips relative paths, absolute paths, `node:`/`bun:` built-ins, and
4234
+ * HTTP(S)/file: URLs. Returns the snippet to actually run, plus the packages
4235
+ * it installed.
4236
+ *
4237
+ * A specifier the bare import cannot reach is **rewritten to the absolute
4238
+ * path of the module**, because Bun caches a failed resolution for the life
4239
+ * of the process and the daemon is long-lived: an eval that imports
4240
+ * `otpauth` before it is installed makes every later
4241
+ * `import * as OTPAuth from "otpauth"` keep failing with `Cannot find
4242
+ * package 'otpauth'` — even after the install put it on the disk. Nothing
4243
+ * clears that entry: not a later `Bun.resolveSync`, not an `onResolve`
4244
+ * plugin, not a new directory to import from (all measured on Bun 1.3.14,
4245
+ * where node 24 re-resolves and succeeds). The cache keeps hits the same
4246
+ * way — a package removed mid-process still resolves — so treat resolution
4247
+ * in this process as a snapshot taken at first ask.
4248
+ * An absolute path never consults the cache. The path comes from a new
4249
+ * `bun` process, so the package's own `exports` conditions apply exactly as
4250
+ * they would for the bare specifier.
4031
4251
  */
4032
4252
  async function ensureDeps(code) {
4033
4253
  let scanned;
@@ -4036,9 +4256,13 @@ async function ensureDeps(code) {
4036
4256
  }
4037
4257
  catch {
4038
4258
  // Invalid syntax — let the import call surface the real error.
4039
- return [];
4259
+ return { code, installed: [] };
4040
4260
  }
4041
4261
  const seen = new Set();
4262
+ // Specifiers the bare import cannot reach, and the packages to install for
4263
+ // them. A subpath (`otpauth/dist/…`) resolves to its own file, so these are
4264
+ // deduped per specifier, not per package.
4265
+ const unreachable = [];
4042
4266
  const missing = [];
4043
4267
  for (const imp of scanned) {
4044
4268
  const p = imp.path;
@@ -4051,21 +4275,32 @@ async function ensureDeps(code) {
4051
4275
  p.startsWith("file:")) {
4052
4276
  continue;
4053
4277
  }
4278
+ if (seen.has(p))
4279
+ continue;
4280
+ seen.add(p);
4054
4281
  const pkg = packageName(p);
4055
- if (seen.has(pkg))
4282
+ if (!(await packageInstalled(pkg))) {
4283
+ unreachable.push(p);
4284
+ if (!missing.includes(pkg))
4285
+ missing.push(pkg);
4056
4286
  continue;
4057
- seen.add(pkg);
4287
+ }
4288
+ // Installed — but a poisoned cache entry from an earlier eval can still
4289
+ // fail the import, so ask the resolver. This can only cache a hit.
4058
4290
  try {
4059
4291
  Bun.resolveSync(p, APP_DIR);
4060
4292
  }
4061
4293
  catch {
4062
- missing.push(pkg);
4294
+ unreachable.push(p);
4063
4295
  }
4064
4296
  }
4065
- if (missing.length === 0)
4066
- return [];
4297
+ if (unreachable.length === 0)
4298
+ return { code, installed: [] };
4299
+ if (missing.length === 0) {
4300
+ return { code: await rewriteUnreachable(code, unreachable), installed: [] };
4301
+ }
4067
4302
  await new Promise((resolve, reject) => {
4068
- execFile("/usr/local/bin/bun", ["add", ...missing], { cwd: APP_DIR, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
4303
+ execFile(BUN_BIN, ["add", ...missing], { cwd: APP_DIR, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
4069
4304
  if (err) {
4070
4305
  reject(new Error(`bun add ${missing.join(" ")} failed:\n${String(stderr).trim()}\n${String(stdout).trim()}`));
4071
4306
  }
@@ -4074,7 +4309,7 @@ async function ensureDeps(code) {
4074
4309
  }
4075
4310
  });
4076
4311
  });
4077
- return missing;
4312
+ return { code: await rewriteUnreachable(code, unreachable), installed: missing };
4078
4313
  }
4079
4314
  /**
4080
4315
  * Turn the bare parser error an `export default` misuse produces
@@ -4271,10 +4506,14 @@ async function evalCode(code, secrets) {
4271
4506
  let filePath;
4272
4507
  let outcome;
4273
4508
  try {
4274
- installed = await ensureDeps(code);
4509
+ // The snippet that runs can differ from the one the user sent: a
4510
+ // freshly installed import is rewritten to an absolute path (see
4511
+ // ensureDeps). Error messages keep using the original `code`.
4512
+ const prepared = await ensureDeps(code);
4513
+ installed = prepared.installed;
4275
4514
  await fs.mkdir(EVAL_DIR, { recursive: true });
4276
4515
  filePath = path.join(EVAL_DIR, `${randomUUID()}.ts`);
4277
- await fs.writeFile(filePath, code);
4516
+ await fs.writeFile(filePath, prepared.code);
4278
4517
  const mod = (await import(pathToFileURL(filePath).href));
4279
4518
  outcome = { ok: true, result: safeSerialize(mod.default) };
4280
4519
  }
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/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.38.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",