@specific.dev/spectest 0.28.0 → 0.28.2

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
@@ -595,10 +595,9 @@ const ZOT_MIRRORS = [
595
595
  /**
596
596
  * Discover the host-side image cache gateway by reading the same
597
597
  * `registry-mirrors` entry the in-VM dockerd already uses (baked into
598
- * the local provider's golden `/etc/docker/daemon.json`). Returns the
598
+ * the golden rootfs's `/etc/docker/daemon.json`). Returns the
599
599
  * gateway host (`"10.42.0.1"`) when present, or `null` when there's no
600
- * host cache — e.g. on Freestyle, where the cluster then pulls every
601
- * image direct. Runs inside the daemon (VM) at `index.ts` load time, so
600
+ * host cache, in which case the cluster pulls every image direct. Runs inside the daemon (VM) at `index.ts` load time, so
602
601
  * the result is stable per host and never poisons the warm-template
603
602
  * cache.
604
603
  */
@@ -618,7 +617,7 @@ function detectHostMirrorGateway() {
618
617
  * to be seeded via `files` (a pre-start bind mount) rather than a
619
618
  * `setup` hook. Two jobs:
620
619
  * 1. Mirror the cluster's image pulls through the host `zot` cache
621
- * (local provider only; omitted when there's no host cache).
620
+ * (omitted when there's no host cache).
622
621
  * 2. Trust the in-cluster registry, addressed as `<key>.internal:5000`
623
622
  * (the `{{SPECTEST_SERVICE}}` token is expanded to the cluster's
624
623
  * service key when the file is written). Image *references* use
@@ -872,30 +871,18 @@ async function collectTraefikDiagnostics(helpers) {
872
871
  * the k3s container's IP (via systest-resolver), lands on Traefik,
873
872
  * and gets dispatched to the matching Ingress rule's backend pods.
874
873
  *
875
- * **Workarounds for Freestyle's kernel** (Linux 6.1.0-x-freestyle).
876
- * The stock kernel is missing the `xt_comment` netfilter match
877
- * extension. Two consequences, each handled below:
874
+ * **kube-proxy runs in `nftables` mode** (`--kube-proxy-arg=proxy-mode
875
+ * =nftables`), not the iptables default. This started as a workaround
876
+ * for a stock kernel with no `xt_comment` match — kube-proxy's iptables
877
+ * rules carry `-m comment`, and the kernel rejected every one of them,
878
+ * breaking pod→ClusterIP routing and any pod talking to the in-cluster
879
+ * API (helm-install Jobs, CoreDNS, …). We build the guest kernel
880
+ * ourselves now and it has the match, but nftables mode is the better
881
+ * path regardless, so it stays. It is GA in k8s 1.32, which is what
882
+ * pins `DEFAULT_K3S_VERSION`.
878
883
  *
879
- * 1. *kube-proxy* in default iptables mode generates rules with
880
- * `-m comment --comment "..."`, which the kernel rejects —
881
- * breaking pod→ClusterIP routing and every pod that talks to the
882
- * in-cluster API (helm-install Jobs, CoreDNS, …). Fixed by
883
- * `--kube-proxy-arg=proxy-mode=nftables`: kube-proxy emits
884
- * native nftables rules where comments are a first-class
885
- * construct, no xt_comment dependency. nftables proxy mode is
886
- * GA in k8s 1.32, which is why we pin that.
887
- *
888
- * 2. *CNI portmap plugin* (used by klipper-lb's hostPort to expose
889
- * LoadBalancer ports on the host) still uses iptables-nft and
890
- * hits the same xt_comment failure — there's no equivalent
891
- * flag to switch it to native nftables. Workaround: disable the
892
- * bundled traefik + ServiceLB and run Traefik with
893
- * `hostNetwork: true` ourselves. hostNetwork pods don't go
894
- * through portmap at all (they share the node's netns directly),
895
- * so the broken plugin is never invoked.
896
- *
897
- * Flannel uses the `host-gw` backend because Freestyle's stock kernel
898
- * lacks the VXLAN module — fine for single-node clusters.
884
+ * Flannel uses the `host-gw` backend: single-node clusters never route
885
+ * pod traffic off the node, so VXLAN encapsulation is pure overhead.
899
886
  */
900
887
  /**
901
888
  * Default k3s docker image tag.
@@ -912,8 +899,7 @@ async function collectTraefikDiagnostics(helpers) {
912
899
  *
913
900
  * **Why v1.32.x:** kube-proxy's `nftables` proxy mode is GA in k8s 1.32
914
901
  * (beta in 1.31, alpha-gated in 1.30). The component runs kube-proxy in
915
- * this mode to sidestep Freestyle's missing `xt_comment` netfilter
916
- * extension; dropping below 1.31 reintroduces the broken iptables path.
902
+ * that mode; dropping below 1.31 falls back to the iptables path.
917
903
  */
918
904
  const DEFAULT_K3S_VERSION = "v1.32.1-k3s1";
919
905
  export function k3s(opts = {}) {
@@ -960,7 +946,7 @@ export function k3s(opts = {}) {
960
946
  // Services get an address and work. It was disabled for years
961
947
  // because klipper-lb binds its ports with a CNI portmap hostPort,
962
948
  // and portmap's iptables-nft rules need the `xt_comment` netfilter
963
- // match — absent from the hosted provider's stock kernel, which made
949
+ // match — absent from the stock kernel we used to run on, which made
964
950
  // every LoadBalancer hang at <pending> with no svclb DaemonSet. We
965
951
  // build the guest kernel ourselves now and it carries
966
952
  // CONFIG_NETFILTER_XT_MATCH_COMMENT (see
@@ -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
@@ -68,6 +68,17 @@ const APP_DIR = process.env.SPECTEST_APP_DIR ?? "/opt/spectest/app";
68
68
  // referenced when we layer it into each image's system trust store.
69
69
  const CA_PATH = process.env.SPECTEST_CA_PATH ?? "/etc/spectest/ca.crt";
70
70
  const CA_KEY_PATH = process.env.SPECTEST_CA_KEY_PATH ?? "/etc/spectest/ca.key";
71
+ // Public roots ⧺ the spectest CA, materialized by `ensureCaBundle` and
72
+ // bind-mounted into every container. `SSL_CERT_FILE`/`REQUESTS_CA_BUNDLE`/
73
+ // `AWS_CA_BUNDLE` REPLACE the trust store rather than adding to it, so
74
+ // pointing them at the bare CA leaves a container able to verify our fakes
75
+ // and nothing else — public HTTPS then fails to verify from anything using
76
+ // OpenSSL, Python requests, or an AWS SDK. (`NODE_EXTRA_CA_CERTS` appends,
77
+ // which is why Node services never saw this.)
78
+ const CA_BUNDLE_PATH = process.env.SPECTEST_CA_BUNDLE_PATH ?? "/etc/spectest/ca-bundle.crt";
79
+ // The guest's own trust store. BASE_SETUP_SH runs `update-ca-certificates`
80
+ // after generating the CA, so this normally already carries both halves.
81
+ const SYSTEM_CA_BUNDLE = process.env.SPECTEST_SYSTEM_CA_BUNDLE ?? "/etc/ssl/certs/ca-certificates.crt";
71
82
  let loaded = null;
72
83
  function casesMetadata(suite) {
73
84
  if (!suite)
@@ -902,6 +913,46 @@ RUN if command -v update-ca-certificates >/dev/null 2>&1; then \\
902
913
  console.warn(`[ca-trust] could not layer spectest CA into ${serviceName} (${tag}); env-var fallback only:\n${build.stderr.trim() || build.stdout.trim()}`);
903
914
  }
904
915
  }
916
+ /** Memoized across containers — the CA never rotates within a VM's life. */
917
+ let caBundlePromise = null;
918
+ /**
919
+ * Materialize {@link CA_BUNDLE_PATH} (public roots ⧺ the spectest CA) and
920
+ * return its path, or null when there's no CA to trust (daemon running
921
+ * outside a base-snapshot VM).
922
+ *
923
+ * This is what the replace-semantics trust env vars must point at. Falling
924
+ * back to the bare CA when the guest bundle is unreadable keeps the old
925
+ * behaviour — fakes verify, public HTTPS doesn't — which is strictly better
926
+ * than dropping the CA and breaking the fakes everything else depends on.
927
+ */
928
+ async function ensureCaBundle() {
929
+ caBundlePromise ??= (async () => {
930
+ if (!existsSync(CA_PATH))
931
+ return null;
932
+ const ca = await fs.readFile(CA_PATH, "utf8");
933
+ let system = "";
934
+ try {
935
+ system = await fs.readFile(SYSTEM_CA_BUNDLE, "utf8");
936
+ }
937
+ catch (err) {
938
+ // eslint-disable-next-line no-console
939
+ console.warn(`[ca-trust] no system CA bundle at ${SYSTEM_CA_BUNDLE} (${err}); containers will trust the spectest CA only`);
940
+ return CA_PATH;
941
+ }
942
+ // update-ca-certificates already folds our CA into the system bundle;
943
+ // appending again is harmless but pointless.
944
+ const parts = system.includes(ca.trim()) ? [system] : [system, ca];
945
+ const bundle = parts.map((p) => p.trimEnd()).join("\n") + "\n";
946
+ await fs.writeFile(CA_BUNDLE_PATH, bundle);
947
+ await fs.chmod(CA_BUNDLE_PATH, 0o644);
948
+ return CA_BUNDLE_PATH;
949
+ })().catch((err) => {
950
+ // eslint-disable-next-line no-console
951
+ console.warn(`[ca-trust] could not write ${CA_BUNDLE_PATH} (${err}); falling back to ${CA_PATH}`);
952
+ return existsSync(CA_PATH) ? CA_PATH : null;
953
+ });
954
+ return caBundlePromise;
955
+ }
905
956
  async function runContainer(svc, tag, volumeFlags,
906
957
  // Extra `--network-alias`es beyond the lowered `aliasesByService`. Used by
907
958
  // runtime `startService` (whose service isn't in LOWERED) to give the new
@@ -970,10 +1021,18 @@ extraAliases = []) {
970
1021
  // for images where the layer step couldn't run (no
971
1022
  // update-ca-certificates).
972
1023
  args.push(`--volume=${CA_PATH}:${CA_PATH}:ro`);
1024
+ // Appends to the image's roots, so it takes the bare CA.
973
1025
  args.push("-e", `NODE_EXTRA_CA_CERTS=${CA_PATH}`);
974
- args.push("-e", `SSL_CERT_FILE=${CA_PATH}`);
975
- args.push("-e", `REQUESTS_CA_BUNDLE=${CA_PATH}`);
976
- args.push("-e", `AWS_CA_BUNDLE=${CA_PATH}`);
1026
+ // These three REPLACE the roots — they must get the combined bundle or
1027
+ // the container loses every public CA (see CA_BUNDLE_PATH).
1028
+ const caBundle = await ensureCaBundle();
1029
+ if (caBundle) {
1030
+ if (caBundle !== CA_PATH)
1031
+ args.push(`--volume=${caBundle}:${caBundle}:ro`);
1032
+ args.push("-e", `SSL_CERT_FILE=${caBundle}`);
1033
+ args.push("-e", `REQUESTS_CA_BUNDLE=${caBundle}`);
1034
+ args.push("-e", `AWS_CA_BUNDLE=${caBundle}`);
1035
+ }
977
1036
  if (svc.env) {
978
1037
  for (const [k, v] of Object.entries(svc.env)) {
979
1038
  args.push("-e", `${k}=${v}`);
package/dist/index.d.ts CHANGED
@@ -612,7 +612,7 @@ export interface FakeContext {
612
612
  * is strongly typed.
613
613
  */
614
614
  export interface TestCase<T = unknown, S extends ServicesMap = ServicesMap, F extends FakesMap = FakesMap> {
615
- /** 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. */
616
616
  readonly id: string;
617
617
  readonly name: string;
618
618
  /** Parent test, if any. Single-parent for now. */
@@ -693,9 +693,9 @@ export interface TestContext<P = undefined, S extends ServicesMap = ServicesMap,
693
693
  * UI the same way a one-shot `terminal(...)` is.
694
694
  *
695
695
  * Terminals are NOT auto-closed when the test ends. A docker exec
696
- * subprocess is cheap to keep alive and Freestyle captures it
697
- * cleanly in the snapshot along with the rest of the container, so
698
- * leaking the handle past test end is fine. Call `.close()`
696
+ * subprocess is cheap to keep alive and the VM snapshot captures it
697
+ * cleanly along with the rest of the container, so leaking the handle
698
+ * past test end is fine. Call `.close()`
699
699
  * explicitly if you want a `close` step in the timeline; otherwise
700
700
  * `await term.exited` (after the program self-terminates) is the
701
701
  * natural way to assert on the exit code.
@@ -747,7 +747,7 @@ export interface TestContext<P = undefined, S extends ServicesMap = ServicesMap,
747
747
  readonly testName: string;
748
748
  /**
749
749
  * Value returned by the parent test. Carried across the fork in the
750
- * daemon's own memory (Freestyle's snapshot is memory + filesystem), so
750
+ * daemon's own memory (a VM snapshot is memory + filesystem), so
751
751
  * any JS value works — including Maps, Sets, class instances, and live
752
752
  * connections that survive the fork. `undefined` for root tests or when
753
753
  * the parent returned nothing.
package/dist/terminal.js CHANGED
@@ -24,7 +24,7 @@
24
24
  // a PTY for CMD; we set the slave's size with `stty rows R cols C` inside
25
25
  // the wrapped command so the container app sees the cols/rows the caller
26
26
  // asked for. `script(1)` ships in util-linux which is essential on Debian,
27
- // so it's present in the Freestyle base image without extra apt deps.
27
+ // so it's present in the base image without extra apt deps.
28
28
  import { Terminal as XtermHeadless } from "@xterm/headless";
29
29
  import { recordTerminalStep, reserveEvent, truncateUtf8 } from "./recorder.js";
30
30
  import { wrap } from "./inspect.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.28.0",
3
+ "version": "0.28.2",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",