@specific.dev/spectest 0.27.0 → 0.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/browser.d.ts CHANGED
@@ -154,8 +154,8 @@ export interface Browser {
154
154
  /**
155
155
  * Capture a PNG screenshot of the viewport and upload it as a downloadable
156
156
  * **artifact**. Resolves to the artifact's `art_…` id — fetch it locally
157
- * with `spectest artifact download <id>`. Eval-only (`spectest_eval` /
158
- * `spectest env eval`); throws with a clear message during test runs.
157
+ * with `spectest artifact download <id>`. Eval-only (`spectest env eval`);
158
+ * throws with a clear message during test runs.
159
159
  */
160
160
  screenshot(): Promise<string>;
161
161
  /**
package/dist/browser.js CHANGED
@@ -1187,7 +1187,7 @@ function buildBackend(holder, recorder, buildOpts) {
1187
1187
  const register = recorder?.registerArtifact;
1188
1188
  if (!register) {
1189
1189
  throw new Error("screenshot() uploads the capture as a downloadable artifact and is " +
1190
- "currently only available inside an eval (spectest_eval / `spectest env eval`) — " +
1190
+ "currently only available inside an eval (`spectest env eval`) — " +
1191
1191
  "it cannot be used in test runs.");
1192
1192
  }
1193
1193
  // Raw CDP rather than page.screenshot(): captures the viewport
@@ -7,8 +7,37 @@ export interface K3sOptions {
7
7
  /**
8
8
  * Extra arguments appended to `k3s server`. Useful for `--tls-san=...`,
9
9
  * additional `--disable=<addon>`, custom CIDRs, etc.
10
+ *
11
+ * Two values are **rejected** rather than silently ignored, because the
12
+ * component owns those decisions and passing them here has no effect:
13
+ * `--disable=traefik` (use `traefik: false`) and `--disable=servicelb`
14
+ * (use `loadBalancer: false`).
10
15
  */
11
16
  extraArgs?: string[];
17
+ /**
18
+ * Install the component's own Traefik ingress controller (hostNetwork,
19
+ * default IngressClass, wired to the in-VM CA when `ingressDomains` is
20
+ * set). Default `true`.
21
+ *
22
+ * Set `false` to run a cluster with no ingress controller at all — for
23
+ * a project that installs its own (via Helm, an operator, or a raw
24
+ * manifest) and wants the port to itself. The bundled k3s Traefik is
25
+ * disabled either way, so `false` really does mean none.
26
+ */
27
+ traefik?: boolean;
28
+ /**
29
+ * Run k3s's ServiceLB (klipper-lb) so `type: LoadBalancer` Services are
30
+ * assigned an address instead of sitting at `<pending>` forever.
31
+ * Default `true`.
32
+ *
33
+ * The assigned address is the **node IP** — the k3s container's own IP
34
+ * on `spectest-net` — so a LoadBalancer on port 8080 is reachable from
35
+ * a test or a peer service at `<cluster-key>.internal:8080`. Because
36
+ * klipper-lb binds the port on the node, a LoadBalancer that asks for
37
+ * port 80 or 443 collides with the component's hostNetwork Traefik;
38
+ * either pick another port or pass `traefik: false`.
39
+ */
40
+ loadBalancer?: boolean;
12
41
  /**
13
42
  * Readiness probe timeout in seconds. k3s on a warm image is ready in
14
43
  * a few seconds; the first cold start of an env (image pull + cluster
@@ -10,6 +10,31 @@ import { readRaw, 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;
13
+ /**
14
+ * `extraArgs` entries the component overrides anyway. Passing one of
15
+ * these looks like it works — the flag really is appended to the k3s
16
+ * command line — but the component's own behaviour is what decides the
17
+ * outcome, so the cluster comes up contradicting the argument. Failing
18
+ * at load time costs a second; discovering it costs a boot cycle.
19
+ */
20
+ const OVERRIDDEN_EXTRA_ARGS = {
21
+ traefik: "the component installs its own Traefik (hostNetwork) after the cluster is up — " +
22
+ "pass `traefik: false` to have no ingress controller at all",
23
+ servicelb: "ServiceLB is controlled by the `loadBalancer` option — pass `loadBalancer: false` to disable it",
24
+ };
25
+ function assertUsableExtraArgs(extra) {
26
+ for (const arg of extra) {
27
+ const m = /^--disable[=\s]+(.+)$/.exec(arg.trim());
28
+ const addon = m?.[1]?.trim();
29
+ if (!addon)
30
+ continue;
31
+ const why = OVERRIDDEN_EXTRA_ARGS[addon];
32
+ if (why) {
33
+ throw new Error(`k3s(): extraArgs cannot control "${addon}" — ${why}. ` +
34
+ `(Remove ${JSON.stringify(arg)} from extraArgs.)`);
35
+ }
36
+ }
37
+ }
13
38
  function runProcess(cmd, args, timeoutMs = 30_000) {
14
39
  return new Promise((resolve, reject) => {
15
40
  const cp = nodeSpawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
@@ -723,7 +748,7 @@ async function waitForDeployment(clusterName, helpers, deployment, timeoutMs) {
723
748
  * reachable over HTTPS with a cert the test framework already trusts.
724
749
  */
725
750
  async function setupK3sCluster(name, helpers, opts) {
726
- const tlsEnabled = opts.ingressDomains.length > 0 && caPresent();
751
+ const tlsEnabled = opts.traefik && opts.ingressDomains.length > 0 && caPresent();
727
752
  if (tlsEnabled) {
728
753
  const { cert, key } = await issueIngressCert(opts.ingressDomains.map((d) => `*.${d}`));
729
754
  // Apply the cert Secret + dynamic-config ConfigMap before the
@@ -748,12 +773,15 @@ async function setupK3sCluster(name, helpers, opts) {
748
773
  },
749
774
  });
750
775
  }
751
- await helpers.apply(buildTraefikManifest(tlsEnabled));
776
+ if (opts.traefik)
777
+ await helpers.apply(buildTraefikManifest(tlsEnabled));
752
778
  if (opts.registry)
753
779
  await helpers.apply(REGISTRY_MANIFEST);
754
780
  // Both rollouts proceed independently inside the cluster — wait on them
755
781
  // concurrently (they used to serialize, wasting up to a rollout's tail).
756
- const waits = [waitForDeployment(name, helpers, "traefik", 120_000)];
782
+ const waits = opts.traefik
783
+ ? [waitForDeployment(name, helpers, "traefik", 120_000)]
784
+ : [];
757
785
  if (opts.registry) {
758
786
  waits.push(waitForDeployment(name, helpers, "spectest-registry", 120_000));
759
787
  }
@@ -891,7 +919,10 @@ const DEFAULT_K3S_VERSION = "v1.32.1-k3s1";
891
919
  export function k3s(opts = {}) {
892
920
  const version = opts.version ?? DEFAULT_K3S_VERSION;
893
921
  const extra = opts.extraArgs ?? [];
922
+ assertUsableExtraArgs(extra);
894
923
  const registryEnabled = opts.registry !== false;
924
+ const traefikEnabled = opts.traefik !== false;
925
+ const loadBalancerEnabled = opts.loadBalancer !== false;
895
926
  // Wildcard ingress domains. Drives both the `provides(... dnsName)`
896
927
  // wiring below and (when non-empty) the CA-signed TLS default cert that
897
928
  // setupK3sCluster mints so these domains are reachable over HTTPS.
@@ -917,14 +948,26 @@ export function k3s(opts = {}) {
917
948
  "--resolv-conf=/run/spectest-resolv.conf",
918
949
  // metrics-server isn't useful in a test cluster.
919
950
  "--disable=metrics-server",
920
- // traefik + servicelb disabled: their klipper-lb DaemonSet uses
921
- // CNI portmap to bind host port 80, which still needs xt_comment
922
- // (the iptables compat path that the kernel can't satisfy).
923
- // We install Traefik with hostNetwork in setup() — same effect,
924
- // no portmap involved. local-storage stays enabled: it's a
925
- // controller pod that doesn't bind host ports.
951
+ // The bundled traefik is disabled because we install our own with
952
+ // hostNetwork (see setupK3sCluster) — ours is wired to the in-VM CA
953
+ // for `ingressDomains` TLS and to a fixed IngressClass. Opt out of
954
+ // ours entirely with `k3s({ traefik: false })`; passing
955
+ // `--disable=traefik` via `extraArgs` does NOT do that (it only
956
+ // re-disables the bundled one, which is already off) and is rejected
957
+ // below rather than silently ignored.
926
958
  "--disable=traefik",
927
- "--disable=servicelb",
959
+ // ServiceLB (klipper-lb) stays ENABLED, so `type: LoadBalancer`
960
+ // Services get an address and work. It was disabled for years
961
+ // because klipper-lb binds its ports with a CNI portmap hostPort,
962
+ // and portmap's iptables-nft rules need the `xt_comment` netfilter
963
+ // match — absent from the hosted provider's stock kernel, which made
964
+ // every LoadBalancer hang at <pending> with no svclb DaemonSet. We
965
+ // build the guest kernel ourselves now and it carries
966
+ // CONFIG_NETFILTER_XT_MATCH_COMMENT (see
967
+ // scripts/local-vms-kernel-additions.config), so the constraint is
968
+ // gone. local-storage stays enabled too: a controller pod that binds
969
+ // no host ports.
970
+ ...(loadBalancerEnabled ? [] : ["--disable=servicelb"]),
928
971
  // Pod CIDR MUST avoid 10.42.0.0/16: that's the spectest-br0 host
929
972
  // bridge subnet, whose gateway 10.42.0.1 fronts the host image caches
930
973
  // (zot :5000-5007, buildkitd :1234). k3s's *default* pod CIDR is also
@@ -959,6 +1002,16 @@ export function k3s(opts = {}) {
959
1002
  "printf 'nameserver %s\\noptions ndots:0\\n' \"$GW\" > /run/spectest-resolv.conf; " +
960
1003
  "else echo 'spectest: no default gateway found; k3s pod DNS for peer services will not resolve' >&2; " +
961
1004
  ": > /run/spectest-resolv.conf; fi; " +
1005
+ // Make the root mount shared. A CSI node plugin bind-mounts volumes
1006
+ // under /var/lib/kubelet and needs that propagation to reach the
1007
+ // kubelet and the workload pod; docker gives a container a private
1008
+ // root, so without this every CSI node plugin fails to publish and
1009
+ // any snapshot/PVC-backed storage test is impossible. `kind` does the
1010
+ // same in its entrypoint for the same reason. Best-effort: on a
1011
+ // kernel/runtime that refuses it, the cluster still boots — only CSI
1012
+ // is affected.
1013
+ "mount --make-rshared / 2>/dev/null || " +
1014
+ "echo 'spectest: could not make / rshared; CSI node plugins may fail to publish volumes' >&2; " +
962
1015
  `exec ${serverArgs}`;
963
1016
  // Plain /readyz probe. On a warm zot cache the cluster's images are
964
1017
  // already local, so the first boot completes in seconds; the
@@ -972,10 +1025,13 @@ export function k3s(opts = {}) {
972
1025
  tmpfs: ["/run", "/var/run"],
973
1026
  cgroupns: "host",
974
1027
  // 80/443 are advisory — peer services and host code reach them via
975
- // the k3s container's IP. ServiceLB (klipper-lb) binds them inside
976
- // the container's netns and forwards to the traefik pod. 5000 is the
1028
+ // the k3s container's IP. The component's Traefik binds them directly
1029
+ // (hostNetwork, so it shares the container's netns). 5000 is the
977
1030
  // in-cluster registry (hostNetwork pod bound to the container netns),
978
1031
  // reached by peers at the cluster's own `<key>.internal:5000` alias.
1032
+ // A `type: LoadBalancer` Service's ports are bound by klipper-lb in
1033
+ // the same netns and are reachable the same way, without appearing
1034
+ // in this list (it's documentation, not a firewall).
979
1035
  ports: registryEnabled ? [80, 443, 6443, K3S_REGISTRY_PORT] : [80, 443, 6443],
980
1036
  // NOTE: do NOT mount /var/lib/rancher/k3s/agent/containerd as a cache
981
1037
  // volume. It was tried (to spare a recreated cluster re-pulling its
@@ -1002,6 +1058,7 @@ export function k3s(opts = {}) {
1002
1058
  await setupK3sCluster(name, helpers, {
1003
1059
  registry: registryEnabled,
1004
1060
  ingressDomains,
1061
+ traefik: traefikEnabled,
1005
1062
  });
1006
1063
  },
1007
1064
  helpers: async ({ name, exec }) => {
@@ -19,7 +19,7 @@
19
19
  // - RECORD (MITM): the request is forwarded to the REAL host and the
20
20
  // request/response pair is captured (decoded, redacted), and the real
21
21
  // response is returned to the app so a manual session behaves like
22
- // production. This runs under `spectest_eval` / a manual env.
22
+ // production. This runs under `spectest env eval` against a manual env.
23
23
  //
24
24
  // Because the fake's hostname IS the real host, the in-VM resolver points
25
25
  // that name at the daemon — so a naive `fetch("https://api.stripe.com")`
@@ -509,7 +509,7 @@ export function replayFake(opts) {
509
509
  if (missing.length > 0) {
510
510
  const refs = [...new Set(missing)];
511
511
  return new Response(`replayFake(${opts.name}): secret(s) ${JSON.stringify(refs)} were not supplied ` +
512
- `(configure them on the project's Secrets page, and record via spectest_eval).\n`, { status: 599, headers: { "content-type": "text/plain" } });
512
+ `(configure them on the project's Secrets page, and record via \`spectest env eval\`).\n`, { status: 599, headers: { "content-type": "text/plain" } });
513
513
  }
514
514
  // Resolve the REAL host's IP via an external resolver so we don't loop
515
515
  // back into the daemon (the in-VM resolver answers our own gateway for
package/dist/daemon.js CHANGED
@@ -293,6 +293,38 @@ function shxStream(file, args, timeoutMs, env, onLine) {
293
293
  }
294
294
  });
295
295
  }
296
+ // ── Boot log ─────────────────────────────────────────────────────────────
297
+ // Everything the daemon logs while bringing the environment up, including
298
+ // `console.log` from service `setup` hooks and project `setup`. Those used
299
+ // to reach only the VM's journal, so on a *successful* boot they were
300
+ // invisible — the one case where a long bootstrap most needs explaining.
301
+ // Kept in daemon memory (so it rides snapshots like everything else) and
302
+ // served by GET /boot-log; capped so a chatty setup can't grow unbounded.
303
+ const BOOT_LOG_MAX_BYTES = 512 * 1024;
304
+ const BOOT_LOG = {
305
+ lines: [],
306
+ bytes: 0,
307
+ dropped: 0,
308
+ };
309
+ /** A {@link LineSink} that appends to the capped boot log. */
310
+ const BOOT_LOG_SINK = {
311
+ push(line) {
312
+ BOOT_LOG.lines.push(line);
313
+ BOOT_LOG.bytes += line.length;
314
+ // Drop from the front: the tail is what explains where a boot is now.
315
+ while (BOOT_LOG.bytes > BOOT_LOG_MAX_BYTES && BOOT_LOG.lines.length > 1) {
316
+ const gone = BOOT_LOG.lines.shift();
317
+ BOOT_LOG.bytes -= gone.length;
318
+ BOOT_LOG.dropped += 1;
319
+ }
320
+ },
321
+ };
322
+ function bootLogText() {
323
+ const head = BOOT_LOG.dropped > 0
324
+ ? `[spectest: ${BOOT_LOG.dropped} earlier line(s) dropped — boot log capped at ${BOOT_LOG_MAX_BYTES} bytes]\n`
325
+ : "";
326
+ return head + BOOT_LOG.lines.join("");
327
+ }
296
328
  let BOOTSTRAP_PROGRESS = null;
297
329
  function progressInit(services) {
298
330
  BOOTSTRAP_PROGRESS = {
@@ -517,6 +549,63 @@ async function ensureFiles(svc) {
517
549
  }
518
550
  return flags;
519
551
  }
552
+ /**
553
+ * Mint each `svc.certificates` entry from the in-VM root CA and return
554
+ * `--volume` flags bind-mounting the PEMs into the container, using the
555
+ * same pre-entrypoint injection point as {@link ensureFiles}.
556
+ *
557
+ * This is what lets a service terminate TLS *itself* with a certificate
558
+ * the environment already trusts — the `tls` field can't help there,
559
+ * since it terminates in the daemon and proxies plain HTTP to the
560
+ * upstream. Anything that routes by SNI, verifies a client cert, or
561
+ * speaks a protocol with its own TLS handshake needs the key material.
562
+ *
563
+ * Minted fresh on every bootstrap rather than cached: certs are cheap
564
+ * (~50 ms), and a stale one outliving a CA rotation would fail in a way
565
+ * that reads as a code bug.
566
+ */
567
+ async function ensureCertificates(svc) {
568
+ const flags = [];
569
+ const certs = svc.certificates ?? [];
570
+ if (certs.length === 0)
571
+ return flags;
572
+ if (!existsSync(CA_PATH) || !existsSync(CA_KEY_PATH)) {
573
+ throw new Error(`service "${svc.name}": certificates require the in-VM root CA at ${CA_PATH}`);
574
+ }
575
+ const dir = path.join(WORKSPACE, ".spectest", "certs", svc.name);
576
+ await fs.mkdir(dir, { recursive: true });
577
+ for (const [i, c] of certs.entries()) {
578
+ for (const [label, p] of [
579
+ ["certPath", c.certPath],
580
+ ["keyPath", c.keyPath],
581
+ ...(c.caPath ? [["caPath", c.caPath]] : []),
582
+ ]) {
583
+ if (!p.startsWith("/")) {
584
+ throw new Error(`service "${svc.name}": certificate ${label} ${JSON.stringify(p)} must be absolute`);
585
+ }
586
+ }
587
+ const hostnames = c.hostnames.map((h) => h.replaceAll("{{SPECTEST_SERVICE}}", svc.name));
588
+ if (hostnames.length === 0) {
589
+ throw new Error(`service "${svc.name}": certificate entry ${i} lists no hostnames`);
590
+ }
591
+ const { cert, key } = await generateHostCert(`${svc.name}-${i}`, hostnames);
592
+ const certHost = path.join(dir, `${i}.crt`);
593
+ const keyHost = path.join(dir, `${i}.key`);
594
+ await fs.writeFile(certHost, cert);
595
+ await fs.writeFile(keyHost, key);
596
+ // The key's mode has to be set on the staged file: a bind mount
597
+ // carries the host inode's permissions straight through, and a
598
+ // server that checks (postgres, ssh) refuses a lax one.
599
+ if (c.mode)
600
+ await fs.chmod(keyHost, parseInt(c.mode, 8));
601
+ flags.push(`--volume=${certHost}:${c.certPath}:ro`);
602
+ flags.push(`--volume=${keyHost}:${c.keyPath}:ro`);
603
+ if (c.caPath)
604
+ flags.push(`--volume=${CA_PATH}:${c.caPath}:ro`);
605
+ console.log(`[bootstrap] ${svc.name}: minted certificate for ${hostnames.join(", ")}`);
606
+ }
607
+ return flags;
608
+ }
520
609
  function imageTag(name) {
521
610
  return `spectest/${name}:latest`;
522
611
  }
@@ -537,6 +626,54 @@ const DEFAULT_DOCKERIGNORE = [
537
626
  ".turbo",
538
627
  ".DS_Store",
539
628
  ];
629
+ /** First line of the `.dockerignore` we generate ourselves, so a later
630
+ * bootstrap can tell our file apart from one the project ships and never
631
+ * mistakes its own output for user intent. */
632
+ const GENERATED_DOCKERIGNORE_HEADER = "# spectest-generated — do not edit (your own .dockerignore is honoured verbatim)";
633
+ /**
634
+ * The project's own `/workspace/.dockerignore`, read once per bootstrap
635
+ * before we write anything, or `null` when it ships none.
636
+ *
637
+ * This file is the project's statement about what belongs in a build
638
+ * context, and it is frequently the difference between a 30-second and a
639
+ * 30-minute build (the `**` + negations idiom keeps a monorepo's context
640
+ * down to the handful of files a Go build actually reads). We used to
641
+ * ignore it entirely *and* overwrite it — so a carefully minimised
642
+ * context silently became the whole repo, and the checked-out file was
643
+ * clobbered for any in-env tooling that read it too.
644
+ */
645
+ let PROJECT_DOCKERIGNORE = null;
646
+ async function readProjectDockerignore() {
647
+ try {
648
+ const text = await fs.readFile(path.join(WORKSPACE, ".dockerignore"), "utf8");
649
+ // Ours, from a previous bootstrap of this workspace — not the project's.
650
+ if (text.startsWith(GENERATED_DOCKERIGNORE_HEADER))
651
+ return null;
652
+ return text;
653
+ }
654
+ catch {
655
+ return null;
656
+ }
657
+ }
658
+ /**
659
+ * Ignore rules for one dockerfile build, in precedence order: our
660
+ * defaults, then the project's own `.dockerignore` verbatim, then that
661
+ * service's `exclude`.
662
+ *
663
+ * Order is load-bearing for the `**` + negations idiom — the project's
664
+ * `**` subsumes our defaults, its `!` lines re-include exactly what the
665
+ * build needs, and the per-service `exclude` still gets the last word.
666
+ */
667
+ function serviceDockerignore(exclude) {
668
+ const parts = [DEFAULT_DOCKERIGNORE.join("\n")];
669
+ if (PROJECT_DOCKERIGNORE !== null) {
670
+ parts.push(`# --- from the project's .dockerignore ---\n${PROJECT_DOCKERIGNORE.trimEnd()}`);
671
+ }
672
+ if (exclude && exclude.length > 0) {
673
+ parts.push(`# --- from this service's exclude ---\n${exclude.join("\n")}`);
674
+ }
675
+ return parts.join("\n") + "\n";
676
+ }
540
677
  function unionDockerignore(services) {
541
678
  const seen = new Set(DEFAULT_DOCKERIGNORE);
542
679
  const extras = [];
@@ -550,7 +687,7 @@ function unionDockerignore(services) {
550
687
  }
551
688
  }
552
689
  }
553
- return [...DEFAULT_DOCKERIGNORE, ...extras].join("\n") + "\n";
690
+ return ([GENERATED_DOCKERIGNORE_HEADER, ...DEFAULT_DOCKERIGNORE, ...extras].join("\n") + "\n");
554
691
  }
555
692
  /// In-flight/finished dockerfile builds of this bootstrap, keyed by
556
693
  /// sha256(dockerfile content + exclude list). Services that share an
@@ -650,14 +787,15 @@ async function buildServiceImage(name, image, tag) {
650
787
  await fs.writeFile(dfPath, image.content);
651
788
  // Per-service ignore: BuildKit resolves `<Dockerfile>.dockerignore`
652
789
  // (next to the Dockerfile) in preference to the context root's
653
- // `.dockerignore`, so this build sees the defaults plus ITS OWN
654
- // `exclude` only — one service excluding `handhelds/**` no longer
655
- // empties a sibling's build context. Verified on both the remote-buildx
656
- // and DOCKER_BUILDKIT paths (client-side context filtering). The root
657
- // union `.dockerignore` written at bootstrap stays as the fallback for
658
- // the legacy non-BuildKit builder, which predates per-Dockerfile
659
- // ignores.
660
- await fs.writeFile(`${dfPath}.dockerignore`, [...DEFAULT_DOCKERIGNORE, ...(image.exclude ?? [])].join("\n") + "\n");
790
+ // `.dockerignore`, so this build sees the defaults, the project's own
791
+ // `.dockerignore`, and ITS OWN `exclude` — one service excluding
792
+ // `handhelds/**` no longer empties a sibling's build context. Verified
793
+ // on both the remote-buildx and DOCKER_BUILDKIT paths (client-side
794
+ // context filtering). The root `.dockerignore` written at bootstrap
795
+ // stays as the fallback for the legacy non-BuildKit builder, which
796
+ // predates per-Dockerfile ignores — and is written ONLY when the
797
+ // project ships none of its own (see readProjectDockerignore).
798
+ await fs.writeFile(`${dfPath}.dockerignore`, serviceDockerignore(image.exclude));
661
799
  const useRemote = await ensureRemoteBuilder();
662
800
  // Both the remote builder and a local buildx are BuildKit, so both emit
663
801
  // per-step timing on stderr under `--progress=plain` (parsed below). Only
@@ -1966,6 +2104,32 @@ async function registerDnsName(hostname, target) {
1966
2104
  await writeRegistry();
1967
2105
  recordEnv({ op: "dnsName", hostname: decl.hostname, ip, durationMs: 0 }, resv);
1968
2106
  }
2107
+ /**
2108
+ * Mint a leaf certificate from the in-VM root CA and hand back the PEMs
2109
+ * — the implementation behind `ctx.certificate`.
2110
+ *
2111
+ * The value-returning counterpart to the `certificates` service field:
2112
+ * use that to hand a cert to a container at boot, use this when a *test*
2113
+ * needs the material as data — loading it into a Kubernetes Secret,
2114
+ * posting it to a control-plane API, driving a client-cert handshake.
2115
+ * The returned `ca` is the same root every service and `ctx.fetch` /
2116
+ * `ctx.browser()` already trust, so a server configured with these PEMs
2117
+ * verifies cleanly with no `rejectUnauthorized: false`.
2118
+ */
2119
+ async function mintCertificate(hostnames) {
2120
+ const resv = reserveEvent();
2121
+ const t = Date.now();
2122
+ if (!Array.isArray(hostnames) || hostnames.length === 0) {
2123
+ throw new Error("ctx.certificate(hostnames): at least one hostname is required");
2124
+ }
2125
+ if (!existsSync(CA_PATH) || !existsSync(CA_KEY_PATH)) {
2126
+ throw new Error(`ctx.certificate(): the in-VM root CA is missing at ${CA_PATH}`);
2127
+ }
2128
+ const { cert, key } = await generateHostCert("ctx", hostnames);
2129
+ const ca = await fs.readFile(CA_PATH, "utf8");
2130
+ recordEnv({ op: "certificate", hostnames, durationMs: Date.now() - t }, resv);
2131
+ return { cert, key, ca };
2132
+ }
1969
2133
  // ────────────────────────────────────────────────────────────────────────
1970
2134
  // Runtime services — containers started after bootstrap (from a test, an
1971
2135
  // eval, project setup, or a fake handler reacting to the app under test).
@@ -2005,7 +2169,11 @@ async function startRuntimeService(spec) {
2005
2169
  const imageRef = svc.image.type === "registry" ? svc.image.reference : "(dockerfile)";
2006
2170
  try {
2007
2171
  const { tag } = await prepareServiceImage(svc);
2008
- const flags = [...(await ensureVolumes(svc)), ...(await ensureFiles(svc))];
2172
+ const flags = [
2173
+ ...(await ensureVolumes(svc)),
2174
+ ...(await ensureFiles(svc)),
2175
+ ...(await ensureCertificates(svc)),
2176
+ ];
2009
2177
  await runContainer(svc, tag, flags, aliases);
2010
2178
  await waitForReady(svc);
2011
2179
  const ip = (await serviceContainerIp(svc.name)) ?? "";
@@ -2054,6 +2222,7 @@ const FAKE_CTX = {
2054
2222
  startService: startRuntimeService,
2055
2223
  stopService: stopRuntimeService,
2056
2224
  dnsName: registerDnsName,
2225
+ certificate: mintCertificate,
2057
2226
  };
2058
2227
  /** Build (or fetch from cache) the helpers record for a fake — the
2059
2228
  * value that ends up at `ctx.fakes.<name>`. Defaults to `{}` (a fake
@@ -2184,6 +2353,18 @@ function logBootstrapTimings(t) {
2184
2353
  console.log(`[bootstrap] total ${t.totalMs}ms across ${t.services.length} service(s)`);
2185
2354
  }
2186
2355
  async function bootstrap() {
2356
+ // Tee everything this bootstrap logs — ours and every service `setup`
2357
+ // hook's — into the boot log, so GET /boot-log can explain a slow or
2358
+ // finished boot. Restored in the finally at the end.
2359
+ const restoreBootLog = captureConsole(BOOT_LOG_SINK);
2360
+ try {
2361
+ return await bootstrapInner();
2362
+ }
2363
+ finally {
2364
+ restoreBootLog();
2365
+ }
2366
+ }
2367
+ async function bootstrapInner() {
2187
2368
  const bootStart = Date.now();
2188
2369
  const cfg = requireLoaded().project.environment;
2189
2370
  const services = namedServices(cfg);
@@ -2201,6 +2382,18 @@ async function bootstrap() {
2201
2382
  ensureNetwork(),
2202
2383
  (async () => {
2203
2384
  await fs.mkdir(WORKSPACE, { recursive: true });
2385
+ // Read the project's own .dockerignore BEFORE we consider writing
2386
+ // one — every per-service ignore composes on top of it.
2387
+ PROJECT_DOCKERIGNORE = await readProjectDockerignore();
2388
+ if (PROJECT_DOCKERIGNORE !== null) {
2389
+ console.log("[bootstrap] using the project's .dockerignore for every build context");
2390
+ // Never overwrite it. BuildKit reads the per-service
2391
+ // `<Dockerfile>.dockerignore` (which already folds this file in),
2392
+ // and the legacy builder reads the project's file directly —
2393
+ // which is what the project asked for. Clobbering it also broke
2394
+ // any in-env tooling that reads it.
2395
+ return;
2396
+ }
2204
2397
  await fs.writeFile(path.join(WORKSPACE, ".dockerignore"), unionDockerignore(services));
2205
2398
  })(),
2206
2399
  ]);
@@ -2283,7 +2476,11 @@ async function bootstrap() {
2283
2476
  // deps; this adds the image edge. The two compose: we run the moment
2284
2477
  // both are satisfied, with no whole-graph barrier between them.
2285
2478
  await prep.get(svc.name);
2286
- const flags = [...(await ensureVolumes(svc)), ...(await ensureFiles(svc))];
2479
+ const flags = [
2480
+ ...(await ensureVolumes(svc)),
2481
+ ...(await ensureFiles(svc)),
2482
+ ...(await ensureCertificates(svc)),
2483
+ ];
2287
2484
  const tag = tags.get(svc.name);
2288
2485
  if (!tag)
2289
2486
  throw new Error(`internal: no image tag for ${svc.name}`);
@@ -2351,6 +2548,17 @@ async function bootstrap() {
2351
2548
  * env bring-up; the control plane surfaces them as a start failure.
2352
2549
  */
2353
2550
  async function runProjectSetup() {
2551
+ // Same tee as bootstrap: a project `setup` hook's console output is
2552
+ // part of explaining the boot, not something to lose on success.
2553
+ const restoreBootLog = captureConsole(BOOT_LOG_SINK);
2554
+ try {
2555
+ return await runProjectSetupInner();
2556
+ }
2557
+ finally {
2558
+ restoreBootLog();
2559
+ }
2560
+ }
2561
+ async function runProjectSetupInner() {
2354
2562
  const proj = requireLoaded().project;
2355
2563
  if (!proj.setup)
2356
2564
  return { ran: false, durationMs: 0 };
@@ -2370,6 +2578,7 @@ async function runProjectSetup() {
2370
2578
  svc,
2371
2579
  fakes,
2372
2580
  dnsName: registerDnsName,
2581
+ certificate: mintCertificate,
2373
2582
  startService: startRuntimeService,
2374
2583
  stopService: stopRuntimeService,
2375
2584
  };
@@ -2717,6 +2926,7 @@ const EXEC_FRAME_CAP_BYTES = 1024 * 1024;
2717
2926
  * `opts.env`.
2718
2927
  */
2719
2928
  async function openInstrumentedTerminal(service, opts, testStart, sessions, recordEvents, idScope) {
2929
+ assertKnownOpts("ctx.terminal", opts, TERMINAL_OPT_KEYS);
2720
2930
  const cols = opts?.cols ?? 80;
2721
2931
  const rows = opts?.rows ?? 24;
2722
2932
  const session = newTerminalSession(testStart, service, opts?.command ?? "(interactive)", cols, rows, idScope);
@@ -2750,6 +2960,7 @@ const COMPONENT_EXEC_DEFAULT_TIMEOUT_MS = 120_000;
2750
2960
  * `exitCode`, never thrown. Unlike `execInService` this records nothing —
2751
2961
  * setup/helpers-factory time has no test timeline. */
2752
2962
  function componentExec(service, command, opts) {
2963
+ assertKnownOpts("ctx.exec", opts, EXEC_OPT_KEYS);
2753
2964
  const argv = ["exec", "-i"];
2754
2965
  if (opts?.cwd)
2755
2966
  argv.push("-w", opts.cwd);
@@ -2767,21 +2978,30 @@ function componentExec(service, command, opts) {
2767
2978
  const err = [];
2768
2979
  child.stdout.on("data", (c) => out.push(c));
2769
2980
  child.stderr.on("data", (c) => err.push(c));
2770
- const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
2981
+ let timedOut = false;
2982
+ const timer = setTimeout(() => {
2983
+ timedOut = true;
2984
+ child.kill("SIGKILL");
2985
+ }, timeoutMs);
2771
2986
  child.on("error", (e) => {
2772
2987
  clearTimeout(timer);
2773
2988
  reject(e);
2774
2989
  });
2775
2990
  child.on("close", (code) => {
2776
2991
  clearTimeout(timer);
2992
+ // A SIGKILL'd child reports 137, which reads like an OOM kill.
2993
+ // Report the `timeout(1)` convention plus a note instead, so a
2994
+ // wedged command is diagnosable from the result alone.
2995
+ const stderrText = Buffer.concat(err).toString("utf8");
2777
2996
  resolve({
2778
2997
  stdout: Buffer.concat(out).toString("utf8"),
2779
- stderr: Buffer.concat(err).toString("utf8"),
2780
- exitCode: code ?? -1,
2998
+ stderr: timedOut
2999
+ ? `${stderrText}\ntimeout after ${timeoutMs}ms`
3000
+ : stderrText,
3001
+ exitCode: timedOut ? EXEC_TIMEOUT_EXIT_CODE : (code ?? -1),
2781
3002
  });
2782
3003
  });
2783
- if (opts?.stdin !== undefined)
2784
- child.stdin.end(opts.stdin);
3004
+ feedStdin(child, opts?.stdin);
2785
3005
  });
2786
3006
  }
2787
3007
  function componentContext() {
@@ -2933,18 +3153,68 @@ function installFetchWrapper() {
2933
3153
  /** Build the `docker exec` argv for a service command. An optional `cwd`
2934
3154
  * becomes `-w <cwd>` (the working-directory option of `ctx.exec`), so the
2935
3155
  * command runs from that directory without it being baked into the command
2936
- * string. Used by both the buffered and streaming variants so they stay in
2937
- * lockstep. */
2938
- function dockerExecArgs(service, command, cwd) {
3156
+ * string. `-i` is passed whenever stdin will be piped — without it docker
3157
+ * attaches no stdin and the payload is silently discarded. Used by both the
3158
+ * buffered and streaming variants so they stay in lockstep. */
3159
+ function dockerExecArgs(service, command, opts) {
2939
3160
  const args = ["exec"];
2940
- if (cwd)
2941
- args.push("-w", cwd);
3161
+ if (opts?.stdin !== undefined)
3162
+ args.push("-i");
3163
+ if (opts?.cwd)
3164
+ args.push("-w", opts.cwd);
2942
3165
  args.push(service, "sh", "-lc", command);
2943
3166
  return args;
2944
3167
  }
2945
- function execInService(service, command, cwd) {
3168
+ /** Exit code reported when an exec is killed by its own `timeoutMs`
3169
+ * (the `timeout(1)` convention, matching `shx`). */
3170
+ const EXEC_TIMEOUT_EXIT_CODE = 124;
3171
+ /**
3172
+ * Reject option objects carrying properties we don't implement, instead
3173
+ * of ignoring them. TypeScript's excess-property check already flags
3174
+ * these on an object literal, but `spectest test`'s typecheck is
3175
+ * advisory and never gates a run — so a typo'd or unsupported option
3176
+ * (`{ stdin }` before it was supported, `{ timeout }` for `timeoutMs`)
3177
+ * would otherwise vanish without a trace and surface much later as
3178
+ * "the command got no input".
3179
+ */
3180
+ function assertKnownOpts(fnLabel, opts, allowed) {
3181
+ if (!opts || typeof opts !== "object")
3182
+ return;
3183
+ const unknown = Object.keys(opts).filter((k) => !allowed.includes(k));
3184
+ if (unknown.length === 0)
3185
+ return;
3186
+ throw new Error(`${fnLabel}: unsupported option${unknown.length > 1 ? "s" : ""} ` +
3187
+ `${unknown.map((k) => JSON.stringify(k)).join(", ")} — supported: ` +
3188
+ `${allowed.map((k) => JSON.stringify(k)).join(", ")}`);
3189
+ }
3190
+ const EXEC_OPT_KEYS = ["cwd", "stdin", "timeoutMs"];
3191
+ const TERMINAL_OPT_KEYS = ["cols", "rows", "env", "command", "timeoutMs"];
3192
+ const POLL_OPT_KEYS = ["timeoutMs", "intervalMs"];
3193
+ /** Write `stdin` to the child and close the stream. A command that never
3194
+ * reads stdin makes the write fail with EPIPE — harmless, and it must not
3195
+ * take down the daemon as an unhandled 'error' event. */
3196
+ function feedStdin(child, stdin) {
3197
+ if (stdin === undefined)
3198
+ return;
3199
+ const s = child.stdin;
3200
+ if (!s)
3201
+ return;
3202
+ s.on("error", () => { });
3203
+ s.end(stdin);
3204
+ }
3205
+ function execInService(service, command, opts) {
2946
3206
  return new Promise((resolve) => {
2947
- execFile("docker", dockerExecArgs(service, command, cwd), { maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
3207
+ const child = execFile("docker", dockerExecArgs(service, command, opts), { maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
3208
+ if (timer)
3209
+ clearTimeout(timer);
3210
+ if (timedOut) {
3211
+ resolve({
3212
+ stdout: stdout.toString(),
3213
+ stderr: `${stderr.toString()}\ntimeout after ${opts.timeoutMs}ms`,
3214
+ exitCode: EXEC_TIMEOUT_EXIT_CODE,
3215
+ });
3216
+ return;
3217
+ }
2948
3218
  const exitCode = err && typeof err.code === "number"
2949
3219
  ? Number(err.code)
2950
3220
  : err
@@ -2956,6 +3226,14 @@ function execInService(service, command, cwd) {
2956
3226
  exitCode,
2957
3227
  });
2958
3228
  });
3229
+ let timedOut = false;
3230
+ const timer = opts?.timeoutMs && opts.timeoutMs > 0
3231
+ ? setTimeout(() => {
3232
+ timedOut = true;
3233
+ child.kill("SIGKILL");
3234
+ }, opts.timeoutMs)
3235
+ : undefined;
3236
+ feedStdin(child, opts?.stdin);
2959
3237
  });
2960
3238
  }
2961
3239
  /** `execInService` for the no-recorder contexts (`setup`/`eval`): wraps the
@@ -2963,7 +3241,8 @@ function execInService(service, command, cwd) {
2963
3241
  * honest at runtime, but with no provenance (there's no event to link to).
2964
3242
  * The recorded `ctx.exec` used during tests is `recordedExec` below. */
2965
3243
  async function execInServiceWrapped(service, command, opts) {
2966
- const res = await execInService(service, command, opts?.cwd);
3244
+ assertKnownOpts("ctx.exec", opts, EXEC_OPT_KEYS);
3245
+ const res = await execInService(service, command, opts);
2967
3246
  return wrap(res, undefined);
2968
3247
  }
2969
3248
  /** Per-stream cap on the ExecResult strings the streaming variant
@@ -2984,10 +3263,10 @@ const EXEC_RESULT_CAP_BYTES = 16 * 1024 * 1024;
2984
3263
  * analogue of what a terminal would have shown — while the returned
2985
3264
  * `ExecResult` keeps them separate as before.
2986
3265
  */
2987
- function execInServiceStreaming(service, command, onChunk, cwd) {
3266
+ function execInServiceStreaming(service, command, onChunk, opts) {
2988
3267
  return new Promise((resolve) => {
2989
- const child = spawn("docker", dockerExecArgs(service, command, cwd), {
2990
- stdio: ["ignore", "pipe", "pipe"],
3268
+ const child = spawn("docker", dockerExecArgs(service, command, opts), {
3269
+ stdio: [opts?.stdin !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
2991
3270
  });
2992
3271
  const acc = { stdout: "", stderr: "" };
2993
3272
  const decoders = {
@@ -3012,21 +3291,41 @@ function execInServiceStreaming(service, command, onChunk, cwd) {
3012
3291
  child.stdout?.on("data", (c) => drain("stdout", c));
3013
3292
  child.stderr?.on("data", (c) => drain("stderr", c));
3014
3293
  let settled = false;
3294
+ let timedOut = false;
3015
3295
  const finish = (exitCode) => {
3016
3296
  if (settled)
3017
3297
  return;
3018
3298
  settled = true;
3299
+ if (timer)
3300
+ clearTimeout(timer);
3019
3301
  // Flush any multi-byte tail the decoders are still holding.
3020
3302
  acc.stdout += decoders.stdout.decode();
3021
3303
  acc.stderr += decoders.stderr.decode();
3022
- resolve({ stdout: acc.stdout, stderr: acc.stderr, exitCode });
3304
+ if (timedOut) {
3305
+ const note = `\ntimeout after ${opts.timeoutMs}ms`;
3306
+ acc.stderr += note;
3307
+ onChunk("stderr", note);
3308
+ }
3309
+ resolve({
3310
+ stdout: acc.stdout,
3311
+ stderr: acc.stderr,
3312
+ exitCode: timedOut ? EXEC_TIMEOUT_EXIT_CODE : exitCode,
3313
+ });
3023
3314
  };
3315
+ const timer = opts?.timeoutMs && opts.timeoutMs > 0
3316
+ ? setTimeout(() => {
3317
+ timedOut = true;
3318
+ child.kill("SIGKILL");
3319
+ }, opts.timeoutMs)
3320
+ : undefined;
3024
3321
  // `close` (not `exit`) so both pipes are fully drained first.
3025
3322
  child.on("close", (code) => finish(code ?? 1));
3026
3323
  child.on("error", () => finish(1));
3324
+ feedStdin(child, opts?.stdin);
3027
3325
  });
3028
3326
  }
3029
3327
  async function pollCall(description, fn, opts) {
3328
+ assertKnownOpts("ctx.poll", opts, POLL_OPT_KEYS);
3030
3329
  const timeoutMs = opts?.timeoutMs ?? 30_000;
3031
3330
  const intervalMs = opts?.intervalMs ?? 1_000;
3032
3331
  const start = Date.now();
@@ -3096,14 +3395,6 @@ async function pollCall(description, fn, opts) {
3096
3395
  }
3097
3396
  throw new Error(`poll ${JSON.stringify(description)} timed out after ${timeoutMs}ms (${attempts} attempts)`);
3098
3397
  }
3099
- /**
3100
- * Tee `console.*` output into `chunks` for the duration of a test/eval.
3101
- * Bun's console writes through its own native sink, NOT
3102
- * `process.stdout.write`, so patching the streams alone misses every
3103
- * `console.log` the test makes — the captured `log` came back empty.
3104
- * The original method still runs, so the daemon journal keeps the line.
3105
- * Returns a restore function for the caller's `finally`.
3106
- */
3107
3398
  function captureConsole(chunks) {
3108
3399
  const methods = ["log", "info", "warn", "error", "debug"];
3109
3400
  const orig = new Map();
@@ -3161,6 +3452,7 @@ async function runOne(testCase) {
3161
3452
  // frames are presentation-only; the ExecResult (and any assertions on
3162
3453
  // it) still sees the plain separated stdout/stderr.
3163
3454
  const recordedExec = async (service, command, opts) => {
3455
+ assertKnownOpts("ctx.exec", opts, EXEC_OPT_KEYS);
3164
3456
  const t = Date.now();
3165
3457
  const cwd = opts?.cwd;
3166
3458
  const resv = reserveEvent();
@@ -3190,14 +3482,21 @@ async function runOne(testCase) {
3190
3482
  // `\r\n` too keeps a CR|LF split across chunk boundaries
3191
3483
  // harmless (`\r\r\n` renders identically).
3192
3484
  session.pushFrame((Date.now() - t) / 1000, data.replace(/\r?\n/g, "\r\n"));
3193
- }, cwd);
3485
+ }, opts);
3194
3486
  session.markClosed();
3195
3487
  const stdout = truncateUtf8(res.stdout);
3196
3488
  const stderr = truncateUtf8(res.stderr);
3489
+ // The piped payload is never echoed by the process, so the asciicast
3490
+ // can't show it — carry it on the event instead (truncated like the
3491
+ // output streams) so the timeline shows what the command was fed.
3492
+ const stdin = opts?.stdin !== undefined ? truncateUtf8(opts.stdin) : undefined;
3197
3493
  const seq = recordExec({
3198
3494
  service,
3199
3495
  command,
3200
3496
  cwd,
3497
+ ...(stdin
3498
+ ? { stdin: stdin.value, stdinTruncated: stdin.truncated }
3499
+ : {}),
3201
3500
  exitCode: res.exitCode,
3202
3501
  stdout: stdout.value,
3203
3502
  stdoutTruncated: stdout.truncated,
@@ -3380,6 +3679,7 @@ async function runOne(testCase) {
3380
3679
  fakes,
3381
3680
  poll: pollCall,
3382
3681
  dnsName: registerDnsName,
3682
+ certificate: mintCertificate,
3383
3683
  startService: startRuntimeService,
3384
3684
  stopService: stopRuntimeService,
3385
3685
  };
@@ -3549,6 +3849,55 @@ async function ensureDeps(code) {
3549
3849
  });
3550
3850
  return missing;
3551
3851
  }
3852
+ /**
3853
+ * Turn the bare parser error an `export default` misuse produces
3854
+ * ("Unexpected export") into something actionable.
3855
+ *
3856
+ * An eval snippet is imported as a **real ESM module**, so `export
3857
+ * default` is a module-level declaration: there can be exactly one, and
3858
+ * it cannot sit inside an `if`/`try`/loop body. Early-return style —
3859
+ * `if (!x) { out.y = z; export default out; }` — is therefore a syntax
3860
+ * error, and the raw message says nothing about why or what to do.
3861
+ *
3862
+ * We only ever *augment* a message that already failed, and only when
3863
+ * the snippet actually uses `export default`, so a genuine unrelated
3864
+ * syntax error keeps its own text.
3865
+ */
3866
+ function explainEvalExportError(code, message) {
3867
+ if (!/unexpected export|export declarations?|'export'|"export"/i.test(message)) {
3868
+ return message;
3869
+ }
3870
+ // Not line-anchored: the early-return idiom this exists to explain puts
3871
+ // the offending export mid-line (`if (!x) { …; export default out; }`),
3872
+ // which an `^`-anchored match would miss entirely.
3873
+ const matches = [...code.matchAll(/\bexport\s+default\s/g)];
3874
+ if (matches.length === 0)
3875
+ return message;
3876
+ // Anything but whitespace before it on its own line means it's nested in
3877
+ // a block or statement rather than declared at the top level.
3878
+ const nested = matches.some((m) => {
3879
+ const lineStart = code.lastIndexOf("\n", m.index) + 1;
3880
+ return code.slice(lineStart, m.index).trim().length > 0;
3881
+ });
3882
+ const counted = matches.length > 1
3883
+ ? `this snippet has ${matches.length} \`export default\` statements, but a module may only have one`
3884
+ : null;
3885
+ const placed = nested
3886
+ ? "`export default` appears inside a block (`if`/`try`/loop), where a module-level declaration isn't allowed"
3887
+ : null;
3888
+ const reason = counted && placed
3889
+ ? `${counted}, and ${placed}`
3890
+ : (counted ?? placed ?? "`export default` isn't at the module's top level");
3891
+ return (`${message}\n\n` +
3892
+ `spectest: an eval snippet is imported as a real ES module, and ${reason}. ` +
3893
+ "For early-return style, compute the value in a function and export its result once:\n\n" +
3894
+ " export default await (async () => {\n" +
3895
+ " const out = {};\n" +
3896
+ " if (!x) return { ...out, y: z }; // early return, not early export\n" +
3897
+ " out.more = await ctx.exec(\"web\", \"…\");\n" +
3898
+ " return out;\n" +
3899
+ " })();\n");
3900
+ }
3552
3901
  async function evalCode(code, secrets) {
3553
3902
  const start = Date.now();
3554
3903
  // Eval-scoped secret channel for record-mode fakes — set before the
@@ -3688,6 +4037,7 @@ async function evalCode(code, secrets) {
3688
4037
  fakes,
3689
4038
  poll: pollCall,
3690
4039
  dnsName: registerDnsName,
4040
+ certificate: mintCertificate,
3691
4041
  startService: startRuntimeService,
3692
4042
  stopService: stopRuntimeService,
3693
4043
  };
@@ -3715,9 +4065,10 @@ async function evalCode(code, secrets) {
3715
4065
  }
3716
4066
  catch (err) {
3717
4067
  const e = err;
4068
+ const message = e.message ?? String(err);
3718
4069
  outcome = {
3719
4070
  ok: false,
3720
- error: { message: e.message ?? String(err), stack: e.stack },
4071
+ error: { message: explainEvalExportError(code, message), stack: e.stack },
3721
4072
  };
3722
4073
  }
3723
4074
  finally {
@@ -3951,6 +4302,43 @@ async function handle(req, res, state) {
3951
4302
  jsonResponse(res, 200, BOOTSTRAP_PROGRESS ?? {});
3952
4303
  return;
3953
4304
  }
4305
+ if (method === "GET" && url.startsWith("/env-logs")) {
4306
+ // Live logs for a running environment: the daemon's own boot log
4307
+ // (bootstrap + every `setup` hook's console output) plus each
4308
+ // service's container logs. Unlike the per-case deltas this reads
4309
+ // without touching LOG_MARKERS, so calling it never perturbs a run.
4310
+ const q = new URL(url, "http://daemon").searchParams;
4311
+ const only = q.get("service") ?? undefined;
4312
+ const tail = Math.max(1, Math.min(10_000, Number(q.get("tail") ?? "200") || 200));
4313
+ const l = loaded;
4314
+ const byName = new Map();
4315
+ if (l)
4316
+ for (const s of namedServices(l.project.environment))
4317
+ byName.set(s.name, s);
4318
+ for (const [name, s] of RUNTIME_SERVICES)
4319
+ byName.set(name, s);
4320
+ const names = [...byName.keys()].filter((n) => !only || n === only);
4321
+ if (only && names.length === 0) {
4322
+ jsonResponse(res, 404, {
4323
+ error: `unknown service ${JSON.stringify(only)}; known: ${[...byName.keys()].join(", ") || "(none)"}`,
4324
+ });
4325
+ return;
4326
+ }
4327
+ const services = await Promise.all(names.map(async (name) => {
4328
+ const r = await docker(["logs", "--timestamps", `--tail=${tail}`, name], 30_000);
4329
+ const out = capMiddle(r.stdout, LOG_DELTA_MAX_BYTES);
4330
+ const err = capMiddle(r.code === 0 ? r.stderr : r.stderr || r.stdout, LOG_DELTA_MAX_BYTES);
4331
+ return {
4332
+ service: name,
4333
+ stdout: out.value,
4334
+ stdoutTruncated: out.truncated,
4335
+ stderr: err.value,
4336
+ stderrTruncated: err.truncated,
4337
+ };
4338
+ }));
4339
+ jsonResponse(res, 200, { bootLog: bootLogText(), services });
4340
+ return;
4341
+ }
3954
4342
  if (method === "POST" && url === "/load") {
3955
4343
  // Env only — services/fakes/setup. For the legacy single-file layout the
3956
4344
  // entry also defines the tests, so `cases` is populated here; for the
package/dist/index.d.ts CHANGED
@@ -104,6 +104,42 @@ export interface ServiceConfig {
104
104
  * `k3s server` starts.
105
105
  */
106
106
  files?: readonly FileMount[];
107
+ /**
108
+ * Leaf certificates minted from the in-VM root CA and written into the
109
+ * container **before it starts**, so the service can terminate TLS
110
+ * *itself* with a certificate the whole environment already trusts.
111
+ *
112
+ * This is the counterpart to {@link ServiceConfig.tls}. `tls` puts the
113
+ * daemon in front as a terminating reverse proxy and forwards plain
114
+ * HTTP to the service — right for an ordinary web app, wrong for
115
+ * anything that has to see the TLS handshake: a gateway that routes by
116
+ * SNI, a server that verifies client certificates, or a protocol with
117
+ * its own TLS layer. Those need key material, and the only alternative
118
+ * was a self-signed cert plus `rejectUnauthorized: false` — which
119
+ * tests a code path production never runs.
120
+ *
121
+ * ```ts
122
+ * services: {
123
+ * gateway: {
124
+ * image: { type: "registry", reference: "…" },
125
+ * hostnames: ["sql.gateway.test"],
126
+ * certificates: [{
127
+ * hostnames: ["sql.gateway.test", "*.sql.gateway.test"],
128
+ * certPath: "/tls/tls.crt",
129
+ * keyPath: "/tls/tls.key",
130
+ * mode: "0600",
131
+ * }],
132
+ * },
133
+ * }
134
+ * // a test then connects with full verification on:
135
+ * // new Client({ connectionString, ssl: { rejectUnauthorized: true } })
136
+ * ```
137
+ *
138
+ * For a certificate a *test* needs as a value — to load into a
139
+ * Kubernetes Secret, say — use `ctx.certificate(hostnames)` instead,
140
+ * which returns the PEMs rather than mounting them.
141
+ */
142
+ certificates?: readonly CertificateMount[];
107
143
  /** Other services (keys in the services map) that must be ready first. */
108
144
  dependsOn?: readonly string[];
109
145
  readyCheck?: ReadyCheck;
@@ -159,15 +195,19 @@ export interface ComponentContext {
159
195
  */
160
196
  exec(service: string, command: string | string[], opts?: ComponentExecOpts): Promise<ExecResult>;
161
197
  }
162
- /** Options for {@link ComponentContext.exec}. */
163
- export interface ComponentExecOpts {
164
- /** Piped to the command's stdin, then closed. */
165
- stdin?: string;
166
- /** Working directory inside the container (`docker exec -w`). */
167
- cwd?: string;
168
- /** Kill the exec after this long. Default 120_000. */
169
- timeoutMs?: number;
170
- }
198
+ /**
199
+ * Options for {@link ComponentContext.exec} — the same set
200
+ * {@link TestContext.exec} takes, deliberately aliased rather than
201
+ * redeclared: the two surfaces drifted once (the component one grew
202
+ * `stdin`/`timeoutMs`, the test one silently ignored them), and an alias
203
+ * makes that impossible to repeat.
204
+ *
205
+ * The one behavioural difference is the `timeoutMs` default: a component
206
+ * exec runs during boot, where nothing else bounds it, so it defaults to
207
+ * 120 s. A test-context exec has no default — the enclosing test's own
208
+ * timeout is the ceiling there.
209
+ */
210
+ export type ComponentExecOpts = ExecOpts;
171
211
  /** What a service's `setup` hook receives. */
172
212
  export interface ServiceSetupContext<H extends Record<string, any> = Record<string, never>> extends ComponentContext {
173
213
  /** The service's key in the services map (container + DNS name). */
@@ -432,6 +472,38 @@ export interface FileMount {
432
472
  */
433
473
  mode?: string;
434
474
  }
475
+ /** PEM material returned by `ctx.certificate(hostnames)`. */
476
+ export interface CertificateMaterial {
477
+ /** PEM certificate, signed by the in-VM root CA. */
478
+ cert: string;
479
+ /** PEM private key for {@link cert}. */
480
+ key: string;
481
+ /** PEM root CA certificate — the one the environment already trusts. */
482
+ ca: string;
483
+ }
484
+ /** One leaf certificate materialized into a service container — see
485
+ * {@link ServiceConfig.certificates}. */
486
+ export interface CertificateMount {
487
+ /** SANs the leaf covers. Wildcards (`*.example.com`) are allowed. */
488
+ hostnames: readonly string[];
489
+ /** Absolute path inside the container for the PEM certificate. */
490
+ certPath: string;
491
+ /** Absolute path inside the container for the PEM private key. */
492
+ keyPath: string;
493
+ /**
494
+ * Optional absolute path for the root CA certificate. Every container
495
+ * already trusts the CA (`SSL_CERT_FILE` and friends are pre-set), so
496
+ * this is only for software that wants an explicit CA file — client
497
+ * certificate verification, a `sslrootcert=` connection parameter.
498
+ */
499
+ caPath?: string;
500
+ /**
501
+ * Optional octal mode (e.g. `"0600"`) applied to the staged key.
502
+ * Servers that refuse a group/world-readable key (postgres, ssh) need
503
+ * this; the default is the writer's umask.
504
+ */
505
+ mode?: string;
506
+ }
435
507
  export type ReadyCheck = {
436
508
  type: "tcp";
437
509
  port: number;
@@ -524,6 +596,10 @@ export interface FakeContext {
524
596
  /** Map a DNS name onto a service IP (or the daemon ingress). See
525
597
  * {@link TestContext.dnsName}. */
526
598
  dnsName(hostname: string, target: DnsTarget): Promise<void>;
599
+ /** Mint a CA-signed leaf certificate. See
600
+ * {@link TestContext.certificate} — the primitive a fake standing in
601
+ * for a TLS-provisioning provider hands back to the app under test. */
602
+ certificate(hostnames: readonly string[]): Promise<CertificateMaterial>;
527
603
  }
528
604
  /**
529
605
  * A single test step. Created via `test(...)` or `createTest(services)`.
@@ -536,7 +612,7 @@ export interface FakeContext {
536
612
  * is strongly typed.
537
613
  */
538
614
  export interface TestCase<T = unknown, S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
539
- /** Stable id (slug of name). Used in MCP responses and CLI flags. */
615
+ /** Stable id (slug of name). Used in run results and CLI flags. */
540
616
  readonly id: string;
541
617
  readonly name: string;
542
618
  /** Parent test, if any. Single-parent for now. */
@@ -750,6 +826,38 @@ export interface TestContext<P = undefined, S extends ServicesMap = ServicesMap,
750
826
  * ```
751
827
  */
752
828
  dnsName(hostname: string, target: DnsTarget): Promise<void>;
829
+ /**
830
+ * Mint a leaf certificate from the in-VM root CA and return the PEMs.
831
+ *
832
+ * The value-returning counterpart to the `certificates` service field
833
+ * (see {@link ServiceConfig.certificates}): that one hands a
834
+ * certificate to a container before it boots, this one hands it to
835
+ * *you* — for loading into a Kubernetes `kubernetes.io/tls` Secret,
836
+ * posting to a control-plane API that provisions TLS endpoints, or
837
+ * driving a client-certificate handshake.
838
+ *
839
+ * The returned `ca` is the same root the whole environment already
840
+ * trusts (`ctx.fetch`, `ctx.browser()`, every service container), so a
841
+ * server configured with these PEMs verifies cleanly — no
842
+ * `rejectUnauthorized: false`, no `sslmode=require` downgrade.
843
+ * Wildcards (`*.example.com`) are allowed in `hostnames`.
844
+ *
845
+ * ```ts
846
+ * const { cert, key } = await ctx.certificate(["*.apps.test"]);
847
+ * await ctx.svc.k8s.apply(`
848
+ * apiVersion: v1
849
+ * kind: Secret
850
+ * metadata: { name: apps-tls, namespace: default }
851
+ * type: kubernetes.io/tls
852
+ * stringData:
853
+ * tls.crt: |
854
+ * ${cert.replace(/^/gm, " ")}
855
+ * tls.key: |
856
+ * ${key.replace(/^/gm, " ")}
857
+ * `);
858
+ * ```
859
+ */
860
+ certificate(hostnames: readonly string[]): Promise<CertificateMaterial>;
753
861
  /**
754
862
  * Start a real container on `spectest-net` at runtime — a peer machine
755
863
  * with its own IP, reachable like any boot service. Returns once the
@@ -785,7 +893,11 @@ export interface ExecResult {
785
893
  stderr: string;
786
894
  exitCode: number;
787
895
  }
788
- /** Options for {@link TestContext.exec}. */
896
+ /** Options for {@link TestContext.exec}.
897
+ *
898
+ * Unknown properties are rejected at runtime (`ctx.exec` throws), not
899
+ * silently ignored — the type-level excess-property check is advisory
900
+ * here, since `spectest test`'s typecheck never gates a run. */
789
901
  export interface ExecOpts {
790
902
  /**
791
903
  * Working directory inside the container to run the command from.
@@ -797,6 +909,32 @@ export interface ExecOpts {
797
909
  * -w <cwd>`, so a relative path resolves against the image's WORKDIR.
798
910
  */
799
911
  cwd?: string;
912
+ /**
913
+ * Piped to the command's stdin, then closed — the natural way to feed
914
+ * a manifest to `kubectl apply -f -` or a SQL file to `psql -f -`
915
+ * without staging a temp file in the container:
916
+ *
917
+ * ```ts
918
+ * await ctx.exec("k8s", "kubectl apply -f -", { stdin: manifestYaml });
919
+ * ```
920
+ *
921
+ * The payload is written after the process starts and the stream is
922
+ * closed immediately after, so a command that never reads stdin still
923
+ * exits normally (the write is allowed to fail with EPIPE).
924
+ */
925
+ stdin?: string;
926
+ /**
927
+ * Kill the command (SIGKILL) after this long and return `exitCode`
928
+ * 124 with a `timeout after <n>ms` note on stderr, rather than
929
+ * hanging until the enclosing test's own timeout fires.
930
+ *
931
+ * **There is no default** — an `exec` runs as long as it likes. In a
932
+ * test the enclosing `env.test(..., { timeoutMs })` (60 s by default)
933
+ * is the real ceiling; in `setup`/`eval`, where no test timeout
934
+ * applies, an unbounded command hangs the boot, so set this when the
935
+ * command can plausibly wedge.
936
+ */
937
+ timeoutMs?: number;
800
938
  }
801
939
  export interface TestSuite<S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
802
940
  tests: TestCase<unknown, S, F>[];
@@ -983,6 +1121,13 @@ export interface ProjectSetupContext<S extends ServicesMap = ServicesMap, F exte
983
1121
  * test inherits the route without re-registering.
984
1122
  */
985
1123
  dnsName(hostname: string, target: DnsTarget): Promise<void>;
1124
+ /**
1125
+ * Mint a leaf certificate from the in-VM root CA (see
1126
+ * {@link TestContext.certificate}). Minted here it's captured into the
1127
+ * warm-template snapshot, so every test inherits whatever you seeded it
1128
+ * into — the fit for a cluster-wide TLS Secret applied once at setup.
1129
+ */
1130
+ certificate(hostnames: readonly string[]): Promise<CertificateMaterial>;
986
1131
  /**
987
1132
  * Start a runtime service (see {@link TestContext.startService}). Started
988
1133
  * here, it's captured into the warm-template snapshot and inherited by
@@ -26,6 +26,15 @@ export interface ExecEvent extends BaseEvent {
26
26
  * detail view surfaces it. Absent when the call used no `cwd`.
27
27
  */
28
28
  cwd?: string;
29
+ /**
30
+ * The payload piped to the command's stdin (`ctx.exec(svc, cmd, {
31
+ * stdin })`), truncated to OUTPUT_SNIPPET_BYTES. Recorded because the
32
+ * asciicast only captures what the command *wrote* — without this, a
33
+ * `kubectl apply -f -` step shows its error but not the manifest that
34
+ * caused it. Absent when the call piped nothing.
35
+ */
36
+ stdin?: string;
37
+ stdinTruncated?: boolean;
29
38
  exitCode: number;
30
39
  /** stdout truncated to OUTPUT_SNIPPET_BYTES. */
31
40
  stdout: string;
@@ -316,7 +325,9 @@ export interface EmailEvent extends BaseEvent {
316
325
  export interface EnvEvent extends BaseEvent {
317
326
  kind: "env";
318
327
  /** Which environment primitive ran. */
319
- op: "startService" | "stopService" | "dnsName";
328
+ op: "startService" | "stopService" | "dnsName" | "certificate";
329
+ /** SANs of a minted leaf (certificate). */
330
+ hostnames?: string[];
320
331
  /** Service name (startService / stopService). */
321
332
  service?: string;
322
333
  /** Image reference started (startService). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.27.0",
3
+ "version": "0.28.1",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",