@specific.dev/spectest 0.131.12 → 0.131.14

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.
Files changed (48) hide show
  1. package/dist/components/android.js +2 -1
  2. package/dist/components/aws.d.ts +1 -1
  3. package/dist/components/aws.js +2 -1
  4. package/dist/components/electron.js +2 -1
  5. package/dist/components/email.js +2 -1
  6. package/dist/components/emulate/service.js +2 -1
  7. package/dist/components/expo.js +4 -3
  8. package/dist/components/k3s.js +5 -4
  9. package/dist/components/postgres.js +3 -2
  10. package/dist/components/supabase-pooler.js +2 -1
  11. package/dist/components/supabase.js +12 -11
  12. package/dist/components/temporal.js +3 -2
  13. package/dist/daemon.js +50 -17
  14. package/dist/harness/built-image.d.ts +12 -0
  15. package/dist/harness/built-image.js +13 -0
  16. package/dist/harness/builtin-images.d.ts +29 -0
  17. package/dist/harness/builtin-images.js +29 -0
  18. package/dist/harness/component-images.d.ts +7 -0
  19. package/dist/harness/component-images.js +12 -1
  20. package/dist/harness/registry-seed.d.ts +1 -1
  21. package/dist/harness/registry-seed.js +2 -1
  22. package/dist/harness/sticky-consumer.js +6 -0
  23. package/dist/harness/sticky-kubernetes.js +12 -0
  24. package/package.json +1 -1
  25. package/src/components/android.ts +2 -1
  26. package/src/components/aws.ts +2 -1
  27. package/src/components/electron.test.ts +2 -0
  28. package/src/components/electron.ts +2 -1
  29. package/src/components/email.ts +2 -1
  30. package/src/components/emulate/service.ts +2 -1
  31. package/src/components/expo.test.ts +2 -2
  32. package/src/components/expo.ts +5 -4
  33. package/src/components/image-pins.test.ts +44 -0
  34. package/src/components/k3s.ts +5 -4
  35. package/src/components/postgres.ts +3 -2
  36. package/src/components/supabase-pooler.ts +2 -1
  37. package/src/components/supabase.ts +12 -11
  38. package/src/components/temporal.ts +3 -2
  39. package/src/daemon.ts +48 -16
  40. package/src/harness/built-image-docker.test.ts +62 -0
  41. package/src/harness/built-image.ts +14 -0
  42. package/src/harness/builtin-images.ts +29 -0
  43. package/src/harness/component-images.test.ts +19 -1
  44. package/src/harness/component-images.ts +15 -1
  45. package/src/harness/registry-seed.ts +2 -1
  46. package/src/harness/sticky-consumer.ts +6 -0
  47. package/src/harness/sticky-kubernetes.test.ts +23 -0
  48. package/src/harness/sticky-kubernetes.ts +12 -0
@@ -1,3 +1,4 @@
1
+ import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
1
2
  import { readFileSync } from "node:fs";
2
3
  import { posix } from "node:path";
3
4
  import { serviceGroup } from "../index.js";
@@ -21,7 +22,7 @@ a = 'scrcpy_print_version(void) {'
21
22
  assert s.count(a) == 1
22
23
  p.write_text(s.replace(a, a + '\n puts("spectest-first-frame-v1");'))`;
23
24
  const shellQuote = (value) => "'" + value.replaceAll("'", "'\\''") + "'";
24
- export const ANDROID_TOOLS_DOCKERFILE = `FROM node:22-bookworm-slim
25
+ export const ANDROID_TOOLS_DOCKERFILE = `FROM ${BUILTIN_IMAGES["node:22-bookworm-slim"]}
25
26
  RUN apt-get update && apt-get install -y --no-install-recommends adb aapt apksigner zipalign ffmpeg default-jdk-headless ca-certificates openssl curl gcc python3 pkg-config meson ninja-build libsdl2-dev libavcodec-dev libavdevice-dev libavformat-dev libavutil-dev libswresample-dev libusb-1.0-0-dev && rm -rf /var/lib/apt/lists/*
26
27
  RUN curl -fL https://github.com/Genymobile/scrcpy/archive/refs/tags/v3.3.1.tar.gz | tar xz -C /tmp && curl -fL https://github.com/Genymobile/scrcpy/releases/download/v3.3.1/scrcpy-server-v3.3.1 -o /tmp/scrcpy-server && python3 -c ${shellQuote(recorderPatch.replaceAll("\n", ";"))} && meson setup /tmp/scrcpy-build /tmp/scrcpy-3.3.1 --buildtype=release -Dprebuilt_server=/tmp/scrcpy-server && ninja -C /tmp/scrcpy-build -j4 install && rm -rf /tmp/scrcpy*
27
28
  ENV ANDROID_HOME=/opt/android JAVA_HOME=/usr/lib/jvm/default-java APPIUM_HOME=/opt/appium
@@ -105,7 +105,7 @@ export declare function aws(opts?: AwsOptions): {
105
105
  inputs?: string[] | undefined;
106
106
  image: {
107
107
  type: "registry";
108
- reference: string;
108
+ reference: "ministackorg/ministack:1.4.8@sha256:eb0a00c897ec91589eb78a77f0afaf13a6d7be99abfeca2d1d719a7ec76d3141";
109
109
  };
110
110
  env: {
111
111
  AWS_DEFAULT_REGION: string;
@@ -1,3 +1,4 @@
1
+ import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
1
2
  // `aws()` — the AWS cloud APIs, emulated inside the environment, answering
2
3
  // at their **real** endpoints.
3
4
  //
@@ -64,7 +65,7 @@ import { deflateRawSync } from "node:zlib";
64
65
  import { certificate, provides, proxy, SELF_SERVICE_TOKEN } from "../index.js";
65
66
  import { pauseRecording, resumeRecording } from "../recorder.js";
66
67
  /** Backing image, pinned internally (not part of the documented surface). */
67
- const AWS_IMAGE = "ministackorg/ministack:1.4.8";
68
+ const AWS_IMAGE = BUILTIN_IMAGES["ministackorg/ministack:1.4.8"];
68
69
  /** Port the emulator serves every AWS API on. Internal: callers reach the
69
70
  * emulator through the AWS hostnames, never through this port. */
70
71
  const AWS_PORT = 4566;
@@ -1,3 +1,4 @@
1
+ import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
1
2
  import { existsSync, readFileSync } from "node:fs";
2
3
  import { posix } from "node:path";
3
4
  import { resolveProjectPath } from "../project-files.js";
@@ -104,7 +105,7 @@ export function electron(options = {}) {
104
105
  type: "dockerfile",
105
106
  context,
106
107
  exclude: ["node_modules", "**/node_modules", "out", "test-results", ".data"],
107
- content: `FROM node:${node}
108
+ content: `FROM ${options.nodeVersion === undefined ? BUILTIN_IMAGES["node:24-bookworm-slim"] : `node:${node}`}
108
109
  RUN apt-get update && apt-get install -y --no-install-recommends xvfb xauth libgtk-3-0 libnss3 libgbm1 libasound2 fonts-liberation ca-certificates && rm -rf /var/lib/apt/lists/*
109
110
  WORKDIR /app
110
111
  COPY . .
@@ -1,7 +1,8 @@
1
+ import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
1
2
  import { rawFetch } from "../harness/raw-fetch.js";
2
3
  import { recordEmail, reserveEvent, truncateUtf8, } from "../recorder.js";
3
4
  import { wrap } from "../inspect.js";
4
- const DEFAULT_IMAGE = "axllent/mailpit:v1.30";
5
+ const DEFAULT_IMAGE = BUILTIN_IMAGES["axllent/mailpit:v1.30"];
5
6
  const SMTP_PORT = 1025;
6
7
  const API_PORT = 8025;
7
8
  /**
@@ -1,3 +1,4 @@
1
+ import { BUILTIN_IMAGES } from "../../harness/builtin-images.js";
1
2
  // Shared runtime behind the third-party provider components — `google()`,
2
3
  // `github()`, `apple()`, `microsoft()`, `okta()`.
3
4
  //
@@ -49,7 +50,7 @@ import path from "node:path";
49
50
  import { fileURLToPath } from "node:url";
50
51
  import { certificate, provides, proxy, SELF_SERVICE_TOKEN } from "../../index.js";
51
52
  /** Base image, pinned. Bun runs the emulators' ESM directly. */
52
- const IMAGE_BASE = "oven/bun:1.3.14-alpine";
53
+ const IMAGE_BASE = BUILTIN_IMAGES["oven/bun:1.3.14-alpine"];
53
54
  /** Emulator package version, pinned rather than floated. */
54
55
  const EMULATE_VERSION = "0.9.0";
55
56
  /** The port the provider is served on. Callers reach it through the
@@ -1,3 +1,4 @@
1
+ import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
1
2
  // `expo()` — a ready-to-use service for an Expo / React Native app rendered
2
3
  // to the web, built for `ctx.mobile(...)`.
3
4
  //
@@ -299,8 +300,8 @@ function publicEnvLines(env) {
299
300
  * BuildKit, which every builder in a VM is.
300
301
  */
301
302
  function generateDockerfile(input) {
302
- const { nodeVersion, plan, publicEnv, outputDir } = input;
303
- const lines = [`FROM node:${nodeVersion}`, "WORKDIR /app", "ENV CI=1", ...plan.env];
303
+ const { nodeImage, plan, publicEnv, outputDir } = input;
304
+ const lines = [`FROM ${nodeImage}`, "WORKDIR /app", "ENV CI=1", ...plan.env];
304
305
  for (const step of plan.bootstrap)
305
306
  lines.push(`RUN ${step}`);
306
307
  lines.push(`COPY ${plan.manifests.join(" ")} ./`);
@@ -389,7 +390,7 @@ export function expo(opts = {}) {
389
390
  image = {
390
391
  type: "dockerfile",
391
392
  content: generateDockerfile({
392
- nodeVersion: node.tag,
393
+ nodeImage: node.source === "the default" ? BUILTIN_IMAGES["node:22-bookworm-slim"] : `node:${node.tag}`,
393
394
  plan,
394
395
  publicEnv: publicEnvLines(opts.env ?? {}),
395
396
  outputDir,
@@ -1,3 +1,4 @@
1
+ import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
1
2
  import { isIP } from "node:net";
2
3
  import { waitForK3sNodes } from "./k3s-nodes.js";
3
4
  import { JOIN_DIRECTORY, prepareK3sJoin } from "./k3s-join.js";
@@ -492,7 +493,7 @@ function withTagging(api) {
492
493
  * `registries.yaml`, then captured into the warm-template snapshot so
493
494
  * warm starts never pull.
494
495
  */
495
- const TRAEFIK_IMAGE = "rancher/mirrored-library-traefik:3.3.2";
496
+ const TRAEFIK_IMAGE = BUILTIN_IMAGES["rancher/mirrored-library-traefik:3.3.2"];
496
497
  /**
497
498
  * Names of the in-cluster resources that carry the CA-signed default
498
499
  * ingress certificate (created in `setupK3sCluster` when TLS is enabled).
@@ -730,7 +731,7 @@ spec:
730
731
  - operator: Exists
731
732
  containers:
732
733
  - name: registry
733
- image: ${seeded ? REGISTRY_IMAGE : "registry:2"}
734
+ image: ${REGISTRY_IMAGE}
734
735
  imagePullPolicy: IfNotPresent
735
736
  env:
736
737
  - name: REGISTRY_HTTP_ADDR
@@ -1476,7 +1477,7 @@ function createK3sCluster(opts, count) {
1476
1477
  // checked all Nodes and finished loading images, not at API readiness.
1477
1478
  const readyCmd = "kubectl get --raw=/readyz >/dev/null 2>&1";
1478
1479
  const def = {
1479
- image: { type: "registry", reference: `rancher/k3s:${version}` },
1480
+ image: { type: "registry", reference: opts.version === undefined ? BUILTIN_IMAGES["rancher/k3s:v1.33.5-k3s1"] : `rancher/k3s:${version}` },
1480
1481
  imageBuilds: server ? images.builds : [],
1481
1482
  tls: server && registryHost ? [{ hostname: registryHost, port: K3S_REGISTRY_PORT }] : [],
1482
1483
  command: cmd,
@@ -1588,7 +1589,7 @@ function createK3sCluster(opts, count) {
1588
1589
  for (;;) {
1589
1590
  try {
1590
1591
  const pod = (await helpers.client.core.createNamespacedPod({ namespace: "default", dryRun: "All", body: {
1591
- metadata: { generateName: "spectest-trust-probe-" }, spec: { containers: [{ name: "probe", image: "registry.k8s.io/pause:3.10" }] },
1592
+ metadata: { generateName: "spectest-trust-probe-" }, spec: { containers: [{ name: "probe", image: BUILTIN_IMAGES["registry.k8s.io/pause:3.10"] }] },
1592
1593
  } })).unwrap();
1593
1594
  if (pod.spec?.containers[0].env?.some(e => e.name === "SSL_CERT_FILE" && e.value === "/spectest/ca-bundle.crt"))
1594
1595
  break;
@@ -1,3 +1,4 @@
1
+ import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
1
2
  import { SQL } from "../sql.js";
2
3
  import { postgresTls } from "./postgres-tls.js";
3
4
  /**
@@ -26,8 +27,8 @@ import { postgresTls } from "./postgres-tls.js";
26
27
  * ```
27
28
  */
28
29
  export function postgres(opts) {
29
- const version = opts.version ?? "18-alpine";
30
- const reference = opts.image ?? `postgres:${version}`;
30
+ const reference = opts.image ?? (opts.version === undefined
31
+ ? BUILTIN_IMAGES["postgres:18-alpine"] : `postgres:${opts.version}`);
31
32
  const port = opts.port ?? 5432;
32
33
  const persistent = opts.persistent ?? true;
33
34
  const tls = opts.tls !== false ? postgresTls() : undefined;
@@ -1,3 +1,4 @@
1
+ import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
1
2
  export const DEFAULT_POOLER_CONFIG = {
2
3
  enabled: false,
3
4
  port: 54329,
@@ -64,7 +65,7 @@ export function supabasePoolerService(opts) {
64
65
  const { config, dbHost, tenantId, password, jwtSecret, tls, url } = opts;
65
66
  const quotedUrl = `'${url.replace(/'/g, "'\\''")}'`;
66
67
  return {
67
- image: { type: "registry", reference: "supabase/supavisor:2.9.12" },
68
+ image: { type: "registry", reference: BUILTIN_IMAGES["supabase/supavisor:2.9.12"] },
68
69
  // eval starts the OTP application too. Give that temporary instance
69
70
  // ephemeral SQL ports so readiness only sees the final server process.
70
71
  command: '/app/bin/migrate && PROXY_PORT_SESSION=0 PROXY_PORT_TRANSACTION=0 /app/bin/supavisor eval "$(cat /etc/pooler/pooler.exs)" && exec /app/bin/server',
@@ -1,3 +1,4 @@
1
+ import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
1
2
  import { rawFetch } from "../harness/raw-fetch.js";
2
3
  // A ready-to-use, self-hosted **Supabase** stack, expressed as a set of
3
4
  // spectest services (one container each) wired together on spectest-net.
@@ -68,22 +69,22 @@ import { supabasePoolerService } from "./supabase-pooler.js";
68
69
  import { SUPABASE_COVERAGE_COLLECTOR, supabaseFunctionsCoverage } from "./supabase-coverage.js";
69
70
  import { mapSupabaseConfig, parseSupabaseConfigFile, } from "./supabase-config.js";
70
71
  // ──────────────────────────────────────────────────────────────────────────
71
- // Pinned upstream image tags (github.com/supabase/supabase docker-compose.yml,
72
- // current master). Bump together; each is pulled through the host Hub mirror on
72
+ // Digest-pinned upstream images. Update BUILTIN_IMAGES together for compatible
73
+ // Supabase releases; each is pulled through the host Hub mirror on
73
74
  // first cold boot and captured into the warm-template snapshot thereafter.
74
75
  // ──────────────────────────────────────────────────────────────────────────
75
76
  const IMAGES = {
76
- db: "supabase/postgres:17.6.1.136",
77
- kong: "kong/kong:3.9.1",
78
- auth: "supabase/gotrue:v2.189.0",
79
- rest: "postgrest/postgrest:v14.12",
80
- realtime: "supabase/realtime:v2.102.3",
81
- storage: "supabase/storage-api:v1.60.4",
82
- imgproxy: "darthsim/imgproxy:v3.30.1",
83
- functions: "supabase/edge-runtime:v1.74.0",
77
+ db: BUILTIN_IMAGES["supabase/postgres:17.6.1.136"],
78
+ kong: BUILTIN_IMAGES["kong/kong:3.9.1"],
79
+ auth: BUILTIN_IMAGES["supabase/gotrue:v2.189.0"],
80
+ rest: BUILTIN_IMAGES["postgrest/postgrest:v14.12"],
81
+ realtime: BUILTIN_IMAGES["supabase/realtime:v2.102.3"],
82
+ storage: BUILTIN_IMAGES["supabase/storage-api:v1.60.4"],
83
+ imgproxy: BUILTIN_IMAGES["darthsim/imgproxy:v3.30.1"],
84
+ functions: BUILTIN_IMAGES["supabase/edge-runtime:v1.74.0"],
84
85
  // Serves custom mail templates to GoTrue over HTTP (see the `templates`
85
86
  // part). Tiny, and only pulled by a stack that configures a template.
86
- templates: "busybox:1.37",
87
+ templates: BUILTIN_IMAGES["busybox:1.37"],
87
88
  };
88
89
  /** Override tables without mutating the parsed project config. */
89
90
  function mergeConfig(base, overrides) {
@@ -1,3 +1,4 @@
1
+ import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
1
2
  import { serviceGroup } from "../index.js";
2
3
  import { rawFetch } from "../harness/raw-fetch.js";
3
4
  import { readRaw } from "../inspect.js";
@@ -164,14 +165,14 @@ export function temporal(opts = {}) {
164
165
  primary, expectKey: name,
165
166
  services: () => {
166
167
  const server = {
167
- image: { type: "registry", reference: opts.image ?? "temporalio/temporal:1.9.1" }, args,
168
+ image: { type: "registry", reference: opts.image ?? BUILTIN_IMAGES["temporalio/temporal:1.9.1"] }, args,
168
169
  ports: [port, httpPort, ...(opts.ui ? [8233] : [])],
169
170
  readyCheck: { type: "http", port: httpPort, path: `/api/v1/namespaces/${encodeURIComponent(namespace)}`, timeoutSecs: 30 },
170
171
  };
171
172
  if (!opts.tls)
172
173
  return { server };
173
174
  return { server, tls: {
174
- image: { type: "registry", reference: "nginx:1.29.6-alpine" },
175
+ image: { type: "registry", reference: BUILTIN_IMAGES["nginx:1.29.6-alpine"] },
175
176
  ports: [port], hostnames: [opts.tls.hostname], dependsOn: [serverName],
176
177
  certificates: [{ hostnames: [opts.tls.hostname], certPath: "/tls/server.crt", keyPath: "/tls/server.key" }],
177
178
  files: [{ path: "/etc/nginx/nginx.conf", content: `worker_processes 1;\nevents { worker_connections 1024; }\nstream { server { listen ${port} ssl; ssl_certificate /tls/server.crt; ssl_certificate_key /tls/server.key; ssl_protocols TLSv1.2 TLSv1.3; ssl_alpn h2; proxy_pass ${serverName}:${port}; } }\n` }],
package/dist/daemon.js CHANGED
@@ -4,7 +4,8 @@ import { collectSecretRefs } from "./fake-upstream.js";
4
4
  import { phoneScope, phoneRecordingChunk, releasePhoneRecording } from "./phone.js";
5
5
  import { runWithBackgroundFailures } from "./harness/background-failures.js";
6
6
  import { generateHostCert as mintHostCert } from "./harness/certificate.js";
7
- import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
7
+ import { containerdImageOutput } from "./harness/built-image.js";
8
+ import { componentBuildTasks, COMPONENT_RUNTIME, isComponentImageTask, isDigestReference } from "./harness/component-images.js";
8
9
  import { waitForAll } from "./harness/concurrency.js";
9
10
  import { updateServiceProgress, serviceProgressSnapshot } from "./harness/service-progress.js";
10
11
  import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp } from "./android.js";
@@ -491,21 +492,17 @@ function ensureLocalBuildkitd() {
491
492
  })();
492
493
  return _localBuilder;
493
494
  }
494
- /**
495
- * A reference as containerd names it. buildx's `-t` on a remote builder
496
- * stores an unqualified name (`probe:bx`) that dockerd then cannot
497
- * resolve (measured, CONTAINER_STORE.md), so the in-VM build names its
498
- * output in full.
499
- */
500
- function qualifyImageRef(ref) {
501
- const slash = ref.indexOf("/");
502
- const first = slash < 0 ? "" : ref.slice(0, slash);
503
- const isRegistry = first.includes(".") || first.includes(":") || first === "localhost";
504
- if (slash < 0)
505
- return `docker.io/library/${ref}`;
506
- if (!isRegistry)
507
- return `docker.io/${ref}`;
508
- return ref;
495
+ /** `true` when a digest-pinned reference is already complete in the store. */
496
+ async function imagePresentByDigest(ref) {
497
+ if (!isDigestReference(ref))
498
+ return false;
499
+ // `inspect` succeeds only when every layer of the image for this
500
+ // platform is in the store; a partial or missing image falls through to
501
+ // the ordinary pull, which fetches what is absent. dockerd's containerd
502
+ // store also unpacks on demand at container creation, so a record whose
503
+ // snapshots were reclaimed is repaired there rather than failing.
504
+ const present = await docker(["image", "inspect", "--format", "{{.Id}}", ref], 30_000);
505
+ return present.code === 0;
509
506
  }
510
507
  /** Host directory backing one volume mount. Rules (notably which volumes
511
508
  * survive the delta-restore teardown) live in `harness/volume-paths.ts`. */
@@ -913,6 +910,19 @@ async function prepareServiceImageInner(svc, opts) {
913
910
  // tags (`foo:latest`) at whatever the previous generation pulled, for
914
911
  // as long as the delta chain lives — a silent semantic divergence
915
912
  // from the cold build a delta restore must be equivalent to.
913
+ //
914
+ // The one reference that cannot float is a digest. `name@sha256:…`
915
+ // names exactly one image for as long as it exists, so a copy already
916
+ // in the store is the image the registry would return, and the
917
+ // round-trip buys nothing: skip it. Component images pinned by digest
918
+ // and user references pinned by digest take this path; a tag never does.
919
+ if (await imagePresentByDigest(ref)) {
920
+ progressService(svc.name, { status: "pulling", detail: `present by digest` });
921
+ const tagged = await docker(["tag", ref, tag], 30_000);
922
+ if (tagged.code !== 0)
923
+ throw new Error(`docker tag ${ref} ${tag} failed: ${tagged.stderr.trim()}`);
924
+ return { tag };
925
+ }
916
926
  progressService(svc.name, { status: "pulling", detail: `pulling ${ref}` });
917
927
  let layers = 0;
918
928
  const pull = await shxStream("docker", ["pull", ref], 900_000, undefined, (line) => {
@@ -1053,11 +1063,34 @@ async function runServiceBuild(name, image, tag) {
1053
1063
  // every cached parent before the first uncached step (11–79 s for
1054
1064
  // one `COPY` on Specific's dashboard) and a second full pass to
1055
1065
  // export after every miss.
1066
+ // Keep an immutable image reference as well as the service tag. The
1067
+ // containerd exporter replaces the tag directly, bypassing Docker's
1068
+ // dangling-image retention. Old versions must remain usable until our
1069
+ // image GC decides they are no longer needed (including by nested stores).
1070
+ //
1071
+ // `compression=uncompressed` (`containerdImageOutput`): the image never leaves this store, so
1072
+ // gzip on export only to gunzip on unpack is pure cost — on Listen,
1073
+ // 64 s compressing a 2.7 GB layer and 56 s decompressing it again,
1074
+ // single-threaded, in the same guest that already held the layer as
1075
+ // a build snapshot (CONTAINER_STORE.md, "Image delivery inside the
1076
+ // guest"). With the default `force-compression=false` this applies
1077
+ // to layers this build creates; a pulled base image's gzip blobs are
1078
+ // reused as they are, so no existing layer is re-encoded or
1079
+ // re-unpacked. The uncompressed blob is a second on-disk copy of the
1080
+ // layer; the sticky headroom logic already budgets for blobs.
1081
+ //
1082
+ // `unpack`: dockerd needs the snapshot chain to start a container,
1083
+ // so a service image is unpacked here rather than at `docker run`.
1084
+ // A component image task (a `k3s()` build) is consumed only through
1085
+ // the content store — `k3s-image-loader.ts` serves its blobs and the
1086
+ // cluster's containerd unpacks them into its own store — so unpacking
1087
+ // it in dockerd's store would be a third copy nobody reads.
1088
+ const unpack = !isComponentImageTask(name);
1056
1089
  buildArgs = [
1057
1090
  "buildx", "build",
1058
1091
  "--builder", LOCAL_BUILDER_NAME,
1059
1092
  "--progress=plain",
1060
- "--output", `type=image,name=${qualifyImageRef(tag)},unpack=true`,
1093
+ "--output", containerdImageOutput(tag, { unpack }),
1061
1094
  ...argFlags,
1062
1095
  "-f", dfPath, contextDir,
1063
1096
  ];
@@ -1,3 +1,15 @@
1
+ /** Containerd exports replace mutable tags without Docker's dangling-image
2
+ * retention. Keep each result addressable until the SDK's image GC evicts it.
3
+ *
4
+ * Layers this build creates are stored uncompressed: the image never leaves
5
+ * the VM's store, so compressing on export only to decompress on unpack was
6
+ * pure cost (64 s + 56 s on Listen's 2.7 GB layer). `force-compression` stays
7
+ * off, so a pulled base image's blobs are reused as they are. `unpack` is
8
+ * false for images only a nested store reads (CONTAINER_STORE.md, "Image
9
+ * delivery inside the guest"). */
10
+ export declare function containerdImageOutput(reference: string, options?: {
11
+ unpack?: boolean;
12
+ }): string;
1
13
  export interface Descriptor {
2
14
  digest: string;
3
15
  size: number;
@@ -3,6 +3,19 @@ import { execFile } from "node:child_process";
3
3
  import { createHash } from "node:crypto";
4
4
  import { promisify } from "node:util";
5
5
  import { canonicalImageReference } from "./component-images.js";
6
+ /** Containerd exports replace mutable tags without Docker's dangling-image
7
+ * retention. Keep each result addressable until the SDK's image GC evicts it.
8
+ *
9
+ * Layers this build creates are stored uncompressed: the image never leaves
10
+ * the VM's store, so compressing on export only to decompress on unpack was
11
+ * pure cost (64 s + 56 s on Listen's 2.7 GB layer). `force-compression` stays
12
+ * off, so a pulled base image's blobs are reused as they are. `unpack` is
13
+ * false for images only a nested store reads (CONTAINER_STORE.md, "Image
14
+ * delivery inside the guest"). */
15
+ export function containerdImageOutput(reference, options = {}) {
16
+ const unpack = options.unpack ?? true;
17
+ return `type=image,name=${canonicalImageReference(reference)},name-canonical=true,unpack=${unpack},compression=uncompressed`;
18
+ }
6
19
  const digest = (bytes) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
7
20
  const descriptor = (bytes, mediaType) => ({ digest: digest(bytes), size: bytes.length, mediaType });
8
21
  const run = promisify(execFile);
@@ -0,0 +1,29 @@
1
+ /** SDK-owned upstream inputs, resolved from registry manifests on 2026-11-02.
2
+ * Pin the multi-platform index, never the workstation's platform manifest.
3
+ * Update deliberately with an SDK release; user image/version overrides bypass
4
+ * these defaults. Keep generated Dockerfiles and helper pods here as well.
5
+ */
6
+ export declare const BUILTIN_IMAGES: {
7
+ readonly "postgres:18-alpine": "postgres:18-alpine@sha256:10a5c69a83035ab5239f5620d708520e8b28f5aa1f2b20143e6024532edadfb5";
8
+ readonly "rancher/k3s:v1.33.5-k3s1": "rancher/k3s:v1.33.5-k3s1@sha256:fd4740667b7033055c27d424d0d2d660bf66cedbdb225d68e0eab6dd48aa0fd2";
9
+ readonly "rancher/mirrored-library-traefik:3.3.2": "rancher/mirrored-library-traefik:3.3.2@sha256:a03ee2f858f77975fa943f6797da5446b7ac9a83beef6bbdae2afd8feaf67b76";
10
+ readonly "registry:2.8.3": "registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373";
11
+ readonly "registry.k8s.io/pause:3.10": "registry.k8s.io/pause:3.10@sha256:ee6521f290b2168b6e0935a181d4cff9be1ac3f505666ef0e3c98fae8199917a";
12
+ readonly "node:22-bookworm-slim": "node:22-bookworm-slim@sha256:4b150b48b4116e2a22f2c39cc27025fbf4f153e2078fd3b14d159fdb5c7279da";
13
+ readonly "node:24-bookworm-slim": "node:24-bookworm-slim@sha256:c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c";
14
+ readonly "oven/bun:1.3.14-alpine": "oven/bun:1.3.14-alpine@sha256:5acc90a93e91ff07bf72aa90a7c9f0fa189765aec90b47bdbf2152d2196383c0";
15
+ readonly "axllent/mailpit:v1.30": "axllent/mailpit:v1.30@sha256:d5ecbb067db3705fa953d79e1b7f81ef84038df67aba6c52825d8c02a1ea748a";
16
+ readonly "ministackorg/ministack:1.4.8": "ministackorg/ministack:1.4.8@sha256:eb0a00c897ec91589eb78a77f0afaf13a6d7be99abfeca2d1d719a7ec76d3141";
17
+ readonly "temporalio/temporal:1.9.1": "temporalio/temporal:1.9.1@sha256:ad4c82c97bd12b417d1ea942610dbcd511afb250c4d5ed26c694009533df447e";
18
+ readonly "nginx:1.29.6-alpine": "nginx:1.29.6-alpine@sha256:f46cb72c7df02710e693e863a983ac42f6a9579058a59a35f1ae36c9958e4ce0";
19
+ readonly "supabase/supavisor:2.9.12": "supabase/supavisor:2.9.12@sha256:464b93a60ba8c96bc06dad7f241562d6613b2a994922307717b05f432ae415c2";
20
+ readonly "supabase/postgres:17.6.1.136": "supabase/postgres:17.6.1.136@sha256:f371b5f3f2ac0a05703f33d6e6134515fb2498cab708fb948a0aeb7481467c00";
21
+ readonly "kong/kong:3.9.1": "kong/kong:3.9.1@sha256:6addf50e6bd8d578314cb9ce4f2d2d1e3781d2edecef59f707e00c6e05d384f5";
22
+ readonly "supabase/gotrue:v2.189.0": "supabase/gotrue:v2.189.0@sha256:385184459f57569c54c25209f51f3b2be99ddd7c4ce9e3555b5d3eea8447b7cf";
23
+ readonly "postgrest/postgrest:v14.12": "postgrest/postgrest:v14.12@sha256:54000f24847d01a2c2302e0041cf0618b875c57fb48507d743cfa9aaa50bf43c";
24
+ readonly "supabase/realtime:v2.102.3": "supabase/realtime:v2.102.3@sha256:aa1c92c0cf326007563641730ec9da9c60478caa6853887775365fa2c097a471";
25
+ readonly "supabase/storage-api:v1.60.4": "supabase/storage-api:v1.60.4@sha256:c8eb9858eafec891a97c27125470aaad54703c3f4eb4d55ca7f1bf6c6411febf";
26
+ readonly "darthsim/imgproxy:v3.30.1": "darthsim/imgproxy:v3.30.1@sha256:3b709e4a0e5e8e0e959b556b7031229202b4b8e7e7d955c517ea7abed68ee34d";
27
+ readonly "supabase/edge-runtime:v1.74.0": "supabase/edge-runtime:v1.74.0@sha256:2781daf92394db91f7e94129cc3d04ec474ad16a8fe64b3fbeef6e7d557ab120";
28
+ readonly "busybox:1.37": "busybox:1.37@sha256:a7dbc319b1438115a207e11930dc4bb3e643dcb3ec0bdd694bb36cd8fad289ec";
29
+ };
@@ -0,0 +1,29 @@
1
+ /** SDK-owned upstream inputs, resolved from registry manifests on 2026-11-02.
2
+ * Pin the multi-platform index, never the workstation's platform manifest.
3
+ * Update deliberately with an SDK release; user image/version overrides bypass
4
+ * these defaults. Keep generated Dockerfiles and helper pods here as well.
5
+ */
6
+ export const BUILTIN_IMAGES = {
7
+ "postgres:18-alpine": "postgres:18-alpine@sha256:10a5c69a83035ab5239f5620d708520e8b28f5aa1f2b20143e6024532edadfb5",
8
+ "rancher/k3s:v1.33.5-k3s1": "rancher/k3s:v1.33.5-k3s1@sha256:fd4740667b7033055c27d424d0d2d660bf66cedbdb225d68e0eab6dd48aa0fd2",
9
+ "rancher/mirrored-library-traefik:3.3.2": "rancher/mirrored-library-traefik:3.3.2@sha256:a03ee2f858f77975fa943f6797da5446b7ac9a83beef6bbdae2afd8feaf67b76",
10
+ "registry:2.8.3": "registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373",
11
+ "registry.k8s.io/pause:3.10": "registry.k8s.io/pause:3.10@sha256:ee6521f290b2168b6e0935a181d4cff9be1ac3f505666ef0e3c98fae8199917a",
12
+ "node:22-bookworm-slim": "node:22-bookworm-slim@sha256:4b150b48b4116e2a22f2c39cc27025fbf4f153e2078fd3b14d159fdb5c7279da",
13
+ "node:24-bookworm-slim": "node:24-bookworm-slim@sha256:c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c",
14
+ "oven/bun:1.3.14-alpine": "oven/bun:1.3.14-alpine@sha256:5acc90a93e91ff07bf72aa90a7c9f0fa189765aec90b47bdbf2152d2196383c0",
15
+ "axllent/mailpit:v1.30": "axllent/mailpit:v1.30@sha256:d5ecbb067db3705fa953d79e1b7f81ef84038df67aba6c52825d8c02a1ea748a",
16
+ "ministackorg/ministack:1.4.8": "ministackorg/ministack:1.4.8@sha256:eb0a00c897ec91589eb78a77f0afaf13a6d7be99abfeca2d1d719a7ec76d3141",
17
+ "temporalio/temporal:1.9.1": "temporalio/temporal:1.9.1@sha256:ad4c82c97bd12b417d1ea942610dbcd511afb250c4d5ed26c694009533df447e",
18
+ "nginx:1.29.6-alpine": "nginx:1.29.6-alpine@sha256:f46cb72c7df02710e693e863a983ac42f6a9579058a59a35f1ae36c9958e4ce0",
19
+ "supabase/supavisor:2.9.12": "supabase/supavisor:2.9.12@sha256:464b93a60ba8c96bc06dad7f241562d6613b2a994922307717b05f432ae415c2",
20
+ "supabase/postgres:17.6.1.136": "supabase/postgres:17.6.1.136@sha256:f371b5f3f2ac0a05703f33d6e6134515fb2498cab708fb948a0aeb7481467c00",
21
+ "kong/kong:3.9.1": "kong/kong:3.9.1@sha256:6addf50e6bd8d578314cb9ce4f2d2d1e3781d2edecef59f707e00c6e05d384f5",
22
+ "supabase/gotrue:v2.189.0": "supabase/gotrue:v2.189.0@sha256:385184459f57569c54c25209f51f3b2be99ddd7c4ce9e3555b5d3eea8447b7cf",
23
+ "postgrest/postgrest:v14.12": "postgrest/postgrest:v14.12@sha256:54000f24847d01a2c2302e0041cf0618b875c57fb48507d743cfa9aaa50bf43c",
24
+ "supabase/realtime:v2.102.3": "supabase/realtime:v2.102.3@sha256:aa1c92c0cf326007563641730ec9da9c60478caa6853887775365fa2c097a471",
25
+ "supabase/storage-api:v1.60.4": "supabase/storage-api:v1.60.4@sha256:c8eb9858eafec891a97c27125470aaad54703c3f4eb4d55ca7f1bf6c6411febf",
26
+ "darthsim/imgproxy:v3.30.1": "darthsim/imgproxy:v3.30.1@sha256:3b709e4a0e5e8e0e959b556b7031229202b4b8e7e7d955c517ea7abed68ee34d",
27
+ "supabase/edge-runtime:v1.74.0": "supabase/edge-runtime:v1.74.0@sha256:2781daf92394db91f7e94129cc3d04ec474ad16a8fe64b3fbeef6e7d557ab120",
28
+ "busybox:1.37": "busybox:1.37@sha256:a7dbc319b1438115a207e11930dc4bb3e643dcb3ec0bdd694bb36cd8fad289ec",
29
+ };
@@ -5,10 +5,17 @@ export type KubernetesImage = string | (BuildImage & {
5
5
  });
6
6
  /** containerd/CRI use fully qualified names, including the default tag. */
7
7
  export declare function canonicalImageReference(reference: string): string;
8
+ /** A `name@sha256:…` reference names one immutable image; a tag can move. */
9
+ export declare function isDigestReference(reference: string): boolean;
8
10
  export declare function kubernetesImages(images: readonly KubernetesImage[]): {
9
11
  pulls: string[];
10
12
  builds: ComponentImageBuild[];
11
13
  };
14
+ /** Prefix of every component image task name; `isComponentImageTask` is the test. */
15
+ export declare const COMPONENT_IMAGE_TASK_PREFIX = "spectest-image-";
16
+ /** A component image task builds for a component's own runtime (today: a
17
+ * `k3s()` cluster's containerd), never for a container dockerd starts. */
18
+ export declare function isComponentImageTask(name: string): boolean;
12
19
  /** Stable internal task names, separate from the service graph. */
13
20
  export declare function componentBuildTasks<T extends ServiceConfig & {
14
21
  name: string;
@@ -24,6 +24,10 @@ export function canonicalImageReference(reference) {
24
24
  result += ":latest";
25
25
  return result;
26
26
  }
27
+ /** A `name@sha256:…` reference names one immutable image; a tag can move. */
28
+ export function isDigestReference(reference) {
29
+ return /@sha256:[a-f0-9]{64}$/.test(reference);
30
+ }
27
31
  export function kubernetesImages(images) {
28
32
  if (!Array.isArray(images))
29
33
  throw new Error("k3s(): images must be an array of registry references or Dockerfile builds");
@@ -54,10 +58,17 @@ export function kubernetesImages(images) {
54
58
  }
55
59
  return { pulls, builds };
56
60
  }
61
+ /** Prefix of every component image task name; `isComponentImageTask` is the test. */
62
+ export const COMPONENT_IMAGE_TASK_PREFIX = "spectest-image-";
63
+ /** A component image task builds for a component's own runtime (today: a
64
+ * `k3s()` cluster's containerd), never for a container dockerd starts. */
65
+ export function isComponentImageTask(name) {
66
+ return name.startsWith(COMPONENT_IMAGE_TASK_PREFIX);
67
+ }
57
68
  /** Stable internal task names, separate from the service graph. */
58
69
  export function componentBuildTasks(services) {
59
70
  return services.flatMap(svc => (svc.imageBuilds ?? []).map(build => ({
60
- name: `spectest-image-${createHash("sha256").update(`${svc.name}\0${build.reference}`).digest("hex")}`,
71
+ name: `${COMPONENT_IMAGE_TASK_PREFIX}${createHash("sha256").update(`${svc.name}\0${build.reference}`).digest("hex")}`,
61
72
  owner: svc.name,
62
73
  reference: build.reference,
63
74
  image: build.image,
@@ -3,7 +3,7 @@ import { type PreparedComponentImage } from "./component-images.js";
3
3
  export declare const REGISTRY_DATA = "/var/lib/spectest-registry";
4
4
  export declare const REGISTRY_CONTENT = "/spectest-registry-content";
5
5
  export declare const CONTENT_BLOBS = "io.containerd.content.v1.content/blobs/sha256";
6
- export declare const REGISTRY_IMAGE = "registry:2.8.3";
6
+ export declare const REGISTRY_IMAGE: "registry:2.8.3@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373";
7
7
  export declare const registryDirectory: (service: string) => string;
8
8
  /** Distribution 2.8 registry/storage/paths.go defines this layout. Seed
9
9
  * before starting the registry so it cannot cache partial publication.
@@ -1,3 +1,4 @@
1
+ import { BUILTIN_IMAGES } from "./builtin-images.js";
1
2
  /** Distribution v2 publication. Blob data stays in containerd, mounted
2
3
  * read-only in the registry. Repository metadata and uploads MUST live on
3
4
  * the environment rootfs, outside persistent image/build caches. */
@@ -8,7 +9,7 @@ import { canonicalImageReference } from "./component-images.js";
8
9
  export const REGISTRY_DATA = "/var/lib/spectest-registry";
9
10
  export const REGISTRY_CONTENT = "/spectest-registry-content";
10
11
  export const CONTENT_BLOBS = "io.containerd.content.v1.content/blobs/sha256";
11
- export const REGISTRY_IMAGE = "registry:2.8.3";
12
+ export const REGISTRY_IMAGE = BUILTIN_IMAGES["registry:2.8.3"];
12
13
  export const registryDirectory = (service) => `/var/lib/spectest-registries/${service}`;
13
14
  /** Distribution 2.8 registry/storage/paths.go defines this layout. Seed
14
15
  * before starting the registry so it cannot cache partial publication.
@@ -101,6 +101,12 @@ disabled_plugins = ["io.containerd.grpc.v1.cri"]
101
101
  dockerConfig["data-root"] = `${config.path}/docker`;
102
102
  dockerConfig.features = { "containerd-snapshotter": true };
103
103
  dockerConfig["storage-driver"] = "overlayfs";
104
+ // Pull up to ten layers at once instead of dockerd's default three. The
105
+ // containerd image store honours this since Docker 29.7.0; extraction is
106
+ // still one layer at a time, so this only moves the download side off
107
+ // the critical path of many-layer images. Set here as well as in the
108
+ // golden's daemon.json so an SDK release carries it without a rebuild.
109
+ dockerConfig["max-concurrent-downloads"] = 10;
104
110
  await writeFile(`${STICKY_RUNTIME}/docker.json`, JSON.stringify(dockerConfig));
105
111
  await writeFile(`${STICKY_RUNTIME}/buildkit.toml`, stickyBuildkitConfig(config, (await diskUsage(config.path)).reservedBytes));
106
112
  const start = async (unit, program, args) => {
@@ -111,6 +111,10 @@ export async function openKubernetesCacheMaintenance(run, root = ROOT) {
111
111
  throw error;
112
112
  });
113
113
  try {
114
+ // A successful inventory distinguishes orphaned legacy stores from Docker
115
+ // outages. Only this isolated maintenance copy may discard a store.
116
+ const available = new Set((await run("docker", ["image", "ls", "-aq", "--no-trunc"]))
117
+ .split(/\s+/).filter(Boolean));
114
118
  for (const service of entries) {
115
119
  if (!service.isDirectory() || !SERVICE_NAME.test(service.name))
116
120
  continue;
@@ -122,6 +126,14 @@ export async function openKubernetesCacheMaintenance(run, root = ROOT) {
122
126
  if (descriptor.schema !== 1 || !IMAGE_ID.test(descriptor.image) || !Number.isSafeInteger(descriptor.updatedAt)) {
123
127
  throw new Error(`invalid Kubernetes cache descriptor: ${directory}`);
124
128
  }
129
+ if (!available.has(descriptor.image)) {
130
+ // Older exporters replaced their sole image reference on rebuild.
131
+ // An obsolete version's store can outlive that image. It is disposable
132
+ // cache, and must not prevent publishing the healthy stores.
133
+ console.warn(`Discarding orphaned Kubernetes cache ${directory}: missing runtime image ${descriptor.image}`);
134
+ await run("rm", ["-rf", directory]);
135
+ continue;
136
+ }
125
137
  const container = `spectest-k8s-cache-${randomUUID()}`;
126
138
  await run("docker", ["run", "-d", "--name", container, "--network", "none", "--privileged", "--stop-signal", "SIGTERM", "--tmpfs", "/run", "--tmpfs", "/var/run",
127
139
  "--mount", `type=bind,source=${directory}/containerd,target=${STORE_TARGET}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.131.12",
3
+ "version": "0.131.14",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,3 +1,4 @@
1
+ import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
1
2
  import { readFileSync } from "node:fs";
2
3
  import { posix } from "node:path";
3
4
  import { serviceGroup } from "../index.js";
@@ -44,7 +45,7 @@ assert s.count(a) == 1
44
45
  p.write_text(s.replace(a, a + '\n puts("spectest-first-frame-v1");'))`;
45
46
  const shellQuote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
46
47
 
47
- export const ANDROID_TOOLS_DOCKERFILE = `FROM node:22-bookworm-slim
48
+ export const ANDROID_TOOLS_DOCKERFILE = `FROM ${BUILTIN_IMAGES["node:22-bookworm-slim"]}
48
49
  RUN apt-get update && apt-get install -y --no-install-recommends adb aapt apksigner zipalign ffmpeg default-jdk-headless ca-certificates openssl curl gcc python3 pkg-config meson ninja-build libsdl2-dev libavcodec-dev libavdevice-dev libavformat-dev libavutil-dev libswresample-dev libusb-1.0-0-dev && rm -rf /var/lib/apt/lists/*
49
50
  RUN curl -fL https://github.com/Genymobile/scrcpy/archive/refs/tags/v3.3.1.tar.gz | tar xz -C /tmp && curl -fL https://github.com/Genymobile/scrcpy/releases/download/v3.3.1/scrcpy-server-v3.3.1 -o /tmp/scrcpy-server && python3 -c ${shellQuote(recorderPatch.replaceAll("\n", ";"))} && meson setup /tmp/scrcpy-build /tmp/scrcpy-3.3.1 --buildtype=release -Dprebuilt_server=/tmp/scrcpy-server && ninja -C /tmp/scrcpy-build -j4 install && rm -rf /tmp/scrcpy*
50
51
  ENV ANDROID_HOME=/opt/android JAVA_HOME=/usr/lib/jvm/default-java APPIUM_HOME=/opt/appium
@@ -1,3 +1,4 @@
1
+ import { BUILTIN_IMAGES } from "../harness/builtin-images.js";
1
2
  // `aws()` — the AWS cloud APIs, emulated inside the environment, answering
2
3
  // at their **real** endpoints.
3
4
  //
@@ -68,7 +69,7 @@ import { certificate, provides, proxy, SELF_SERVICE_TOKEN } from "../index.js";
68
69
  import { pauseRecording, resumeRecording } from "../recorder.js";
69
70
 
70
71
  /** Backing image, pinned internally (not part of the documented surface). */
71
- const AWS_IMAGE = "ministackorg/ministack:1.4.8";
72
+ const AWS_IMAGE = BUILTIN_IMAGES["ministackorg/ministack:1.4.8"];
72
73
  /** Port the emulator serves every AWS API on. Internal: callers reach the
73
74
  * emulator through the AWS hostnames, never through this port. */
74
75
  const AWS_PORT = 4566;
@@ -24,6 +24,8 @@ describe('Electron component contract', () => {
24
24
  writeFileSync(join(appDir, 'package-lock.json'), '{}');
25
25
  const service = electron({ appDir, buildCommand: 'npm run compile', env: { API_URL: 'http://backend:4320' } });
26
26
  expect(service.image).toMatchObject({ context: appDir });
27
+ expect(service.image.content.split('\n')[0]).toMatch(/^FROM node:24-bookworm-slim@sha256:[a-f0-9]{64}$/);
28
+ expect(electron({ appDir, nodeVersion: '22-alpine' }).image.content.split('\n')[0]).toBe('FROM node:22-alpine');
27
29
  expect((service.image as { content: string }).content).toContain('RUN npm ci\n');
28
30
  expect((service.image as { content: string }).content).toContain('RUN npm run compile\n');
29
31
  expect(service.env?.API_URL).toBe('http://backend:4320');