@specific.dev/spectest 0.91.0 → 0.91.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.
@@ -15,7 +15,7 @@ export function androidTargetLabel(target) {
15
15
  case "id":
16
16
  return `id ${quoted}`;
17
17
  case "accessibility":
18
- return `accessibility label ${quoted}`;
18
+ return `label ${quoted}`;
19
19
  case "xpath":
20
20
  return `XPath ${quoted}`;
21
21
  case "custom":
@@ -74,5 +74,5 @@ export function androidStepTitle(step) {
74
74
  label = `run command ${JSON.stringify(step.command)}`;
75
75
  break;
76
76
  }
77
- return `${step.device}: ${label}`;
77
+ return label;
78
78
  }
@@ -1,5 +1,6 @@
1
1
  import { isIP } from "node:net";
2
2
  import { installPodTrust } from "./k3s-trust.js";
3
+ import { waitForAll } from "../harness/concurrency.js";
3
4
  import { loadK3sBuildImages } from "../harness/k3s-image-loader.js";
4
5
  import { AsyncLocalStorage } from "node:async_hooks";
5
6
  import { spawn as nodeSpawn } from "node:child_process";
@@ -1243,48 +1244,51 @@ async function waitForJob(deps, name, opts) {
1243
1244
  * reachable over HTTPS with a cert the test framework already trusts.
1244
1245
  */
1245
1246
  async function setupK3sCluster(name, helpers, opts) {
1246
- const tlsEnabled = opts.traefik && opts.ingressDomains.length > 0 && caPresent();
1247
- if (tlsEnabled) {
1248
- const { cert, key } = await issueIngressCert(opts.ingressDomains.map((d) => `*.${d}`));
1249
- // Apply the cert Secret + dynamic-config ConfigMap before the
1250
- // Deployment that mounts them. `stringData` lets us hand over plain
1251
- // PEM; the API server base64-encodes it.
1252
- await helpers.client.core.createNamespacedSecret({
1253
- namespace: "kube-system",
1254
- body: {
1255
- metadata: { name: TRAEFIK_TLS_SECRET, namespace: "kube-system" },
1256
- type: "kubernetes.io/tls",
1257
- stringData: { "tls.crt": cert, "tls.key": key },
1258
- },
1259
- });
1260
- await helpers.client.core.createNamespacedConfigMap({
1261
- namespace: "kube-system",
1262
- body: {
1263
- metadata: {
1264
- name: TRAEFIK_DYNAMIC_CONFIGMAP,
1265
- namespace: "kube-system",
1266
- },
1267
- data: { "tls.yaml": TRAEFIK_DYNAMIC_TLS },
1268
- },
1269
- });
1270
- }
1271
- if (opts.traefik)
1272
- await helpers.apply(buildTraefikManifest(tlsEnabled));
1273
- if (opts.registry)
1274
- await helpers.apply(REGISTRY_MANIFEST);
1275
- // Both rollouts proceed independently inside the cluster — wait on them
1276
- // concurrently (they used to serialize, wasting up to a rollout's tail).
1277
- // 250ms polling: these two sit on the cold start's critical path, where
1278
- // the default 500ms interval wastes real time.
1247
+ // The registry can start while Traefik's certificates are being prepared.
1279
1248
  const wait = (deployment) => helpers.waitForRollout(deployment, {
1280
1249
  namespace: "kube-system",
1281
1250
  timeoutMs: 120_000,
1282
1251
  intervalMs: 250,
1283
1252
  });
1284
- const waits = opts.traefik ? [wait("traefik")] : [];
1285
- if (opts.registry)
1286
- waits.push(wait("spectest-registry"));
1287
- await Promise.all(waits);
1253
+ await waitForAll([
1254
+ (async () => {
1255
+ if (!opts.traefik)
1256
+ return;
1257
+ const tlsEnabled = opts.ingressDomains.length > 0 && caPresent();
1258
+ if (tlsEnabled) {
1259
+ const { cert, key } = await issueIngressCert(opts.ingressDomains.map((d) => `*.${d}`));
1260
+ // Apply the cert Secret + dynamic-config ConfigMap before the
1261
+ // Deployment that mounts them. `stringData` lets us hand over plain
1262
+ // PEM; the API server base64-encodes it.
1263
+ await helpers.client.core.createNamespacedSecret({
1264
+ namespace: "kube-system",
1265
+ body: {
1266
+ metadata: { name: TRAEFIK_TLS_SECRET, namespace: "kube-system" },
1267
+ type: "kubernetes.io/tls",
1268
+ stringData: { "tls.crt": cert, "tls.key": key },
1269
+ },
1270
+ });
1271
+ await helpers.client.core.createNamespacedConfigMap({
1272
+ namespace: "kube-system",
1273
+ body: {
1274
+ metadata: {
1275
+ name: TRAEFIK_DYNAMIC_CONFIGMAP,
1276
+ namespace: "kube-system",
1277
+ },
1278
+ data: { "tls.yaml": TRAEFIK_DYNAMIC_TLS },
1279
+ },
1280
+ });
1281
+ }
1282
+ await helpers.apply(buildTraefikManifest(tlsEnabled));
1283
+ await wait("traefik");
1284
+ })(),
1285
+ (async () => {
1286
+ if (!opts.registry)
1287
+ return;
1288
+ await helpers.apply(REGISTRY_MANIFEST);
1289
+ await wait("spectest-registry");
1290
+ })(),
1291
+ ]);
1288
1292
  }
1289
1293
  /**
1290
1294
  * Snapshot of a namespace's state, dumped whenever a wait times out: pod
@@ -1571,54 +1575,57 @@ export function k3s(opts = {}) {
1571
1575
  const runtime = componentRuntime(ctx);
1572
1576
  if (images.builds.length && !runtime)
1573
1577
  throw new Error(`k3s(${name}): prepared image inputs are missing`);
1574
- if (runtime?.caBundle && caPresent()) {
1575
- const { cert, key } = await issueIngressCert([runtime.gateway]);
1576
- await installPodTrust({ name, gateway: runtime.gateway, cert, key,
1577
- ca: await readFile(CA_PATH, "utf8"), bundle: await readFile(runtime.caBundle, "utf8"),
1578
- }, {
1579
- apply: async (configuration) => { await helpers.apply(JSON.stringify(configuration)); },
1580
- ensureBundle: async (namespace, configMap, bundle) => {
1581
- try {
1582
- await helpers.client.core.createNamespacedConfigMap({ namespace, body: {
1583
- metadata: { name: configMap, namespace }, immutable: true, data: { "ca-bundle.crt": bundle },
1584
- } });
1585
- }
1586
- catch (err) {
1587
- // A simultaneous Pod may have created the same namespace's bundle.
1588
- const existing = (await helpers.client.core.readNamespacedConfigMap({ namespace, name: configMap })).unwrap();
1589
- if (existing.data?.["ca-bundle.crt"] !== bundle || existing.immutable !== true)
1590
- throw err;
1578
+ await waitForAll([
1579
+ runtime ? loadK3sBuildImages(name, runtime.images, runtime.gateway, exec) : Promise.resolve(),
1580
+ prePullK3sImages(name, images.pulls, exec),
1581
+ (async () => {
1582
+ if (runtime?.caBundle && caPresent()) {
1583
+ const { cert, key } = await issueIngressCert([runtime.gateway]);
1584
+ await installPodTrust({ name, gateway: runtime.gateway, cert, key,
1585
+ ca: await readFile(CA_PATH, "utf8"), bundle: await readFile(runtime.caBundle, "utf8"),
1586
+ }, {
1587
+ apply: async (configuration) => { await helpers.apply(JSON.stringify(configuration)); },
1588
+ ensureBundle: async (namespace, configMap, bundle) => {
1589
+ try {
1590
+ await helpers.client.core.createNamespacedConfigMap({ namespace, body: {
1591
+ metadata: { name: configMap, namespace }, immutable: true, data: { "ca-bundle.crt": bundle },
1592
+ } });
1593
+ }
1594
+ catch (err) {
1595
+ // A simultaneous Pod may have created the same namespace's bundle.
1596
+ const existing = (await helpers.client.core.readNamespacedConfigMap({ namespace, name: configMap })).unwrap();
1597
+ if (existing.data?.["ca-bundle.crt"] !== bundle || existing.immutable !== true)
1598
+ throw err;
1599
+ }
1600
+ },
1601
+ });
1602
+ // The API server observes webhook registration asynchronously. A dry-run
1603
+ // Pod proves TLS reachability and admission before any project workload.
1604
+ const deadline = Date.now() + 30_000;
1605
+ for (;;) {
1606
+ try {
1607
+ const pod = (await helpers.client.core.createNamespacedPod({ namespace: "default", dryRun: "All", body: {
1608
+ metadata: { generateName: "spectest-trust-probe-" }, spec: { containers: [{ name: "probe", image: "registry.k8s.io/pause:3.10" }] },
1609
+ } })).unwrap();
1610
+ if (pod.spec?.containers[0].env?.some(e => e.name === "SSL_CERT_FILE" && e.value === "/spectest/ca-bundle.crt"))
1611
+ break;
1612
+ if (Date.now() >= deadline)
1613
+ throw new Error(`k3s(${name}): Pod CA mounts did not become ready`);
1614
+ }
1615
+ catch (err) {
1616
+ if (Date.now() >= deadline)
1617
+ throw err;
1618
+ }
1619
+ await new Promise(resolve => setTimeout(resolve, 100));
1591
1620
  }
1592
- },
1593
- });
1594
- // The API server observes webhook registration asynchronously. A dry-run
1595
- // Pod proves TLS reachability and admission before any project workload.
1596
- const deadline = Date.now() + 30_000;
1597
- for (;;) {
1598
- try {
1599
- const pod = (await helpers.client.core.createNamespacedPod({ namespace: "default", dryRun: "All", body: {
1600
- metadata: { generateName: "spectest-trust-probe-" }, spec: { containers: [{ name: "probe", image: "registry.k8s.io/pause:3.10" }] },
1601
- } })).unwrap();
1602
- if (pod.spec?.containers[0].env?.some(e => e.name === "SSL_CERT_FILE" && e.value === "/spectest/ca-bundle.crt"))
1603
- break;
1604
- if (Date.now() >= deadline)
1605
- throw new Error(`k3s(${name}): Pod CA mounts did not become ready`);
1606
1621
  }
1607
- catch (err) {
1608
- if (Date.now() >= deadline)
1609
- throw err;
1610
- }
1611
- await new Promise(resolve => setTimeout(resolve, 100));
1612
- }
1613
- }
1614
- if (runtime)
1615
- await loadK3sBuildImages(name, runtime.images, runtime.gateway, exec);
1616
- await setupK3sCluster(name, helpers, {
1617
- registry: registryEnabled,
1618
- ingressDomains,
1619
- traefik: traefikEnabled,
1620
- });
1621
- await prePullK3sImages(name, images.pulls, exec);
1622
+ await setupK3sCluster(name, helpers, {
1623
+ registry: registryEnabled,
1624
+ ingressDomains,
1625
+ traefik: traefikEnabled,
1626
+ });
1627
+ })(),
1628
+ ]);
1622
1629
  if (sticky)
1623
1630
  await registerStickyKubernetesStore(name, version);
1624
1631
  },
package/dist/daemon.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
2
+ import { waitForAll } from "./harness/concurrency.js";
2
3
  import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp } from "./android.js";
3
4
  // Long-running HTTP daemon. Runs as a systemd unit on the VM host (not in
4
5
  // a container). Owns:
@@ -3103,26 +3104,29 @@ async function bootstrapInner(prepared, identity) {
3103
3104
  await waitForReady(svc);
3104
3105
  const tSetup = Date.now();
3105
3106
  const builds = componentTasks.filter(task => task.owner === svc.name);
3106
- await Promise.all(builds.map(task => prep.get(task.name)));
3107
- if (svc.setup) {
3108
- progressService(svc.name, { status: "probing", detail: "running setup" });
3109
- const helpers = await ensureHelpers(svc.name, svc);
3110
- try {
3111
- await svc.setup({
3112
- name: svc.name,
3113
- helpers,
3114
- ...(await spectestContext({ service: svc.name, includeSelf: true })),
3115
- ...{ [COMPONENT_RUNTIME]: {
3116
- images: builds.map(task => ({ source: tags.get(task.name), reference: task.reference })),
3117
- gateway: await bridgeGatewayIp(),
3118
- caBundle: await ensureCaBundle(),
3119
- } },
3120
- });
3121
- }
3122
- catch (err) {
3123
- throw await withContainerPostMortem(svc.name, err);
3124
- }
3125
- }
3107
+ // Setup can overlap pulls/infrastructure with builds, and consume each
3108
+ // image as it becomes ready. Still drain every build if setup fails.
3109
+ await waitForAll([...builds.map(task => prep.get(task.name)), (async () => {
3110
+ if (svc.setup) {
3111
+ progressService(svc.name, { status: "probing", detail: "running setup" });
3112
+ const helpers = await ensureHelpers(svc.name, svc);
3113
+ try {
3114
+ await svc.setup({
3115
+ name: svc.name,
3116
+ helpers,
3117
+ ...(await spectestContext({ service: svc.name, includeSelf: true })),
3118
+ ...{ [COMPONENT_RUNTIME]: {
3119
+ images: builds.map(task => ({ source: imageTag(task.name), reference: task.reference, ready: prep.get(task.name) })),
3120
+ gateway: await bridgeGatewayIp(),
3121
+ caBundle: await ensureCaBundle(),
3122
+ } },
3123
+ });
3124
+ }
3125
+ catch (err) {
3126
+ throw await withContainerPostMortem(svc.name, err);
3127
+ }
3128
+ }
3129
+ })()]);
3126
3130
  for (const task of builds)
3127
3131
  progressService(task.name, { status: "ready", detail: `loaded ${task.reference}` });
3128
3132
  progressService(svc.name, { status: "ready", detail: undefined });
@@ -20,11 +20,14 @@ export declare function componentBuildTasks<T extends ServiceConfig & {
20
20
  /** Private handoff to a component's setup hook. Builds stay generic; the
21
21
  * component decides how its runtime consumes them and establishes trust. */
22
22
  export declare const COMPONENT_RUNTIME: unique symbol;
23
+ export interface PreparedComponentImage {
24
+ source: string;
25
+ reference: string;
26
+ /** A component can load each result as soon as its own build finishes. */
27
+ ready?: Promise<void>;
28
+ }
23
29
  export interface ComponentRuntime {
24
- images: readonly {
25
- source: string;
26
- reference: string;
27
- }[];
30
+ images: readonly PreparedComponentImage[];
28
31
  gateway: string;
29
32
  caBundle: string | null;
30
33
  }
@@ -0,0 +1,2 @@
1
+ /** Wait for every writer to stop before reporting a failure to setup/cleanup. */
2
+ export declare function waitForAll(work: readonly Promise<unknown>[]): Promise<void>;
@@ -0,0 +1,9 @@
1
+ /** Wait for every writer to stop before reporting a failure to setup/cleanup. */
2
+ export async function waitForAll(work) {
3
+ const results = await Promise.allSettled(work);
4
+ const failures = results.flatMap(result => result.status === "rejected" ? [result.reason] : []);
5
+ if (failures.length === 1)
6
+ throw failures[0];
7
+ if (failures.length > 1)
8
+ throw new AggregateError(failures, `Concurrent environment preparation failed: ${failures.map(error => error instanceof Error ? error.message : String(error)).join("; ")}`);
9
+ }
@@ -1,5 +1,3 @@
1
1
  import type { SpectestContext } from "../index.js";
2
- export declare function loadK3sBuildImages(service: string, images: readonly {
3
- source: string;
4
- reference: string;
5
- }[], gateway: string, exec: SpectestContext["exec"]): Promise<void>;
2
+ import { type PreparedComponentImage } from "./component-images.js";
3
+ export declare function loadK3sBuildImages(service: string, images: readonly PreparedComponentImage[], gateway: string, exec: SpectestContext["exec"]): Promise<void>;
@@ -7,6 +7,7 @@ import { createServer } from "node:http";
7
7
  import { promisify } from "node:util";
8
8
  import { canonicalImageReference } from "./component-images.js";
9
9
  import { waitForK3sRuntime } from "./k3s-prepull.js";
10
+ import { waitForAll } from "./concurrency.js";
10
11
  const digest = (bytes) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
11
12
  const descriptor = (bytes, mediaType) => ({ digest: digest(bytes), size: bytes.length, mediaType });
12
13
  const run = promisify(execFile);
@@ -76,7 +77,8 @@ export async function loadK3sBuildImages(service, images, gateway, exec) {
76
77
  const ctr = ["ctr", "--address", "/run/k3s/containerd/containerd.sock", "--namespace", "k8s.io"];
77
78
  const help = await checked([...ctr, "images", "pull", "--help"]);
78
79
  const local = help.includes("--local") ? ["--local"] : [];
79
- for (const image of images) {
80
+ await waitForAll(images.map(async (image) => {
81
+ await image.ready;
80
82
  const reference = canonicalImageReference(image.reference);
81
83
  const { target, metadata: blobs, allowed, configDigest } = await hostImage(image.source);
82
84
  const streams = new Set();
@@ -146,5 +148,5 @@ export async function loadK3sBuildImages(service, images, gateway, exec) {
146
148
  await new Promise(resolve => server.close(() => resolve()));
147
149
  await exec(service, ["rm", "-rf", directory], { timeoutMs: 5_000 }).catch(() => undefined);
148
150
  }
149
- }
151
+ }));
150
152
  }
@@ -1,7 +1,8 @@
1
1
  import type { SpectestContext } from "../index.js";
2
2
  /** CRI PullImage waits for unpacking and uses k3s's registry configuration.
3
3
  * Always resolve tags on a fresh setup. Immutable digests can be used offline.
4
- * Drain both workers on failure so no pulls outlive the setup hook.
4
+ * Submit every image concurrently; containerd manages downloads and unpacking.
5
+ * Drain all pulls on failure so none outlive the setup hook.
5
6
  */
6
7
  export declare function prePullK3sImages(name: string, images: readonly string[], exec: SpectestContext["exec"]): Promise<void>;
7
8
  export declare function waitForK3sRuntime(name: string, exec: SpectestContext["exec"]): Promise<void>;
@@ -1,28 +1,24 @@
1
+ import { waitForAll } from "./concurrency.js";
1
2
  /** CRI PullImage waits for unpacking and uses k3s's registry configuration.
2
3
  * Always resolve tags on a fresh setup. Immutable digests can be used offline.
3
- * Drain both workers on failure so no pulls outlive the setup hook.
4
+ * Submit every image concurrently; containerd manages downloads and unpacking.
5
+ * Drain all pulls on failure so none outlive the setup hook.
4
6
  */
5
7
  export async function prePullK3sImages(name, images, exec) {
6
8
  if (!images.length)
7
9
  return;
8
10
  await waitForK3sRuntime(name, exec);
9
11
  const cri = ["crictl", "--runtime-endpoint", "unix:///run/k3s/containerd/containerd.sock"];
10
- const queue = [...images];
11
- const results = await Promise.allSettled(Array.from({ length: Math.min(2, queue.length) }, async () => {
12
- for (let image = queue.shift(); image !== undefined; image = queue.shift()) {
13
- if (/@sha256:[a-f0-9]{64}$/.test(image)) {
14
- const existing = await exec(name, [...cri, "inspecti", image], { timeoutMs: 30_000 });
15
- if (existing.exitCode === 0)
16
- continue;
17
- }
18
- const result = await exec(name, [...cri, "pull", image], { timeoutMs: 600_000 });
19
- if (result.exitCode !== 0)
20
- throw new Error(`k3s(${name}): pre-pull failed for ${image}: ${result.stderr || result.stdout}`);
12
+ await waitForAll(images.map(async (image) => {
13
+ if (/@sha256:[a-f0-9]{64}$/.test(image)) {
14
+ const existing = await exec(name, [...cri, "inspecti", image], { timeoutMs: 30_000 });
15
+ if (existing.exitCode === 0)
16
+ return;
21
17
  }
18
+ const result = await exec(name, [...cri, "pull", image], { timeoutMs: 600_000 });
19
+ if (result.exitCode !== 0)
20
+ throw new Error(`k3s(${name}): pre-pull failed for ${image}: ${result.stderr || result.stdout}`);
22
21
  }));
23
- const failed = results.find(result => result.status === "rejected");
24
- if (failed?.status === "rejected")
25
- throw failed.reason;
26
22
  }
27
23
  export async function waitForK3sRuntime(name, exec) {
28
24
  const cri = ["crictl", "--runtime-endpoint", "unix:///run/k3s/containerd/containerd.sock"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.91.0",
3
+ "version": "0.91.1",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -41,7 +41,7 @@ const cases: [Omit<AndroidStepDetails, "device">, string][] = [
41
41
  target: { kind: "accessibility", value: "Save" },
42
42
  state: "hidden",
43
43
  },
44
- 'wait for accessibility label "Save" to be hidden',
44
+ 'wait for label "Save" to be hidden',
45
45
  ],
46
46
  [
47
47
  {
@@ -86,7 +86,7 @@ const cases: [Omit<AndroidStepDetails, "device">, string][] = [
86
86
  for (const [action, title] of cases) {
87
87
  test(title, () =>
88
88
  expect(androidStepTitle({ device: "phone", ...action })).toBe(
89
- `phone: ${title}`,
89
+ title,
90
90
  ),
91
91
  );
92
92
  }
@@ -100,7 +100,7 @@ test("custom selector expressions remain opaque structured data", () => {
100
100
  value: expression,
101
101
  });
102
102
  expect(androidStepTitle({ device: "phone", action: "tap", target })).toBe(
103
- "phone: tap custom selector",
103
+ "tap custom selector",
104
104
  );
105
105
  for (const [using, kind] of [
106
106
  ["id", "id"],
@@ -43,7 +43,7 @@ export function androidTargetLabel(target: AndroidTarget): string {
43
43
  case "id":
44
44
  return `id ${quoted}`;
45
45
  case "accessibility":
46
- return `accessibility label ${quoted}`;
46
+ return `label ${quoted}`;
47
47
  case "xpath":
48
48
  return `XPath ${quoted}`;
49
49
  case "custom":
@@ -103,5 +103,5 @@ export function androidStepTitle(step: AndroidStepDetails): string {
103
103
  label = `run command ${JSON.stringify(step.command)}`;
104
104
  break;
105
105
  }
106
- return `${step.device}: ${label}`;
106
+ return label;
107
107
  }
@@ -156,12 +156,12 @@ describe("Android case lifecycle", () => {
156
156
  const steps = events.filter((e) => e.kind === "android");
157
157
  expect(steps.length).toBe(7);
158
158
  expect(steps[0]).toMatchObject({
159
- title: String.raw`phone: fill text "a \"quoted\" label"`,
159
+ title: String.raw`fill text "a \"quoted\" label"`,
160
160
  android: { device: "phone", action: "fill", target: { kind: "text", value: 'a "quoted" label' } },
161
161
  });
162
162
  expect(steps[1]).toMatchObject({ android: { device: "phone", action: "text", target: { kind: "id", value: "com.example.app:id/draft" } } });
163
- expect(steps[2]).toMatchObject({ title: "phone: take screenshot", android: { device: "phone", action: "screenshot" } });
164
- expect(steps[3]).toMatchObject({ title: 'phone: tap text "Save"', android: { device: "phone", action: "tap", target: { kind: "text", value: "Save" } } });
163
+ expect(steps[2]).toMatchObject({ title: "take screenshot", android: { device: "phone", action: "screenshot" } });
164
+ expect(steps[3]).toMatchObject({ title: 'tap text "Save"', android: { device: "phone", action: "tap", target: { kind: "text", value: "Save" } } });
165
165
  expect(steps[4]).toMatchObject({ android: { device: "phone", action: "tap", point: { x: 270, y: 1800 } } });
166
166
  expect(steps[5]).toMatchObject({ status: "failed", android: { device: "phone", action: "tap", target: { kind: "text", value: "Gone" } } });
167
167
  expect(JSON.stringify(steps)).not.toContain("private draft");
@@ -0,0 +1,60 @@
1
+ import { expect, test } from "bun:test";
2
+ import { k3s } from "./k3s";
3
+
4
+ function deferred() {
5
+ let resolve!: () => void;
6
+ const promise = new Promise<void>(r => { resolve = r; });
7
+ return { promise, resolve };
8
+ }
9
+
10
+ test("registry, Traefik, and every declared pull start without waiting for one another", async () => {
11
+ const started = new Set<string>();
12
+ const allStarted = deferred(), release = deferred();
13
+ const enter = async (name: string) => {
14
+ started.add(name);
15
+ if (started.size === 5) allStarted.resolve();
16
+ await release.promise;
17
+ };
18
+ const cluster = k3s({ images: ["app:1", "app:2", "app:3"] });
19
+ let finished = false;
20
+ const work = cluster.setup({ name: "cluster", helpers: {
21
+ apply: async (manifest: string) => { await enter(manifest.includes("spectest-registry") ? "registry" : "traefik"); },
22
+ waitForRollout: async () => {},
23
+ }, exec: async (_name: string, args: string[]) => {
24
+ if (args[3] === "pull") await enter(args.at(-1)!);
25
+ return { exitCode: 0, stdout: "", stderr: "" };
26
+ } } as never).then(() => { finished = true; });
27
+ try {
28
+ await Promise.race([allStarted.promise, Bun.sleep(1000).then(() => { throw new Error(`serialized setup: ${[...started]}`); })]);
29
+ expect(finished).toBe(false);
30
+ expect(started.size).toBe(5);
31
+ } finally { release.resolve(); await work; }
32
+ });
33
+
34
+ test("a failed infrastructure rollout waits for other rollouts and image pulls to stop", async () => {
35
+ const started = deferred(), release = deferred();
36
+ let active = 0, finished = false;
37
+ const hold = async () => {
38
+ if (++active === 2) started.resolve();
39
+ await release.promise;
40
+ --active;
41
+ };
42
+ const failure = new Error("Traefik failed");
43
+ const cluster = k3s({ images: ["app:1"] });
44
+ const work = cluster.setup({ name: "cluster", helpers: {
45
+ apply: async () => {},
46
+ waitForRollout: async (deployment: string) => {
47
+ if (deployment === "traefik") throw failure;
48
+ await hold();
49
+ },
50
+ }, exec: async (_name: string, args: string[]) => {
51
+ if (args[3] === "pull") await hold();
52
+ return { exitCode: 0, stdout: "", stderr: "" };
53
+ } } as never).then(() => { finished = true; }, error => { finished = true; return error; });
54
+ try {
55
+ await started.promise;
56
+ expect(finished).toBe(false);
57
+ } finally { release.resolve(); }
58
+ expect(await work).toBe(failure);
59
+ expect(active).toBe(0);
60
+ });
@@ -1,5 +1,6 @@
1
1
  import { isIP } from "node:net";
2
2
  import { installPodTrust } from "./k3s-trust.js";
3
+ import { waitForAll } from "../harness/concurrency.js";
3
4
  import { loadK3sBuildImages } from "../harness/k3s-image-loader.js";
4
5
  import { AsyncLocalStorage } from "node:async_hooks";
5
6
  import { spawn as nodeSpawn } from "node:child_process";
@@ -1865,48 +1866,52 @@ async function setupK3sCluster(
1865
1866
  helpers: K3sHelpers,
1866
1867
  opts: { registry: boolean; ingressDomains: string[]; traefik: boolean },
1867
1868
  ): Promise<void> {
1868
- const tlsEnabled = opts.traefik && opts.ingressDomains.length > 0 && caPresent();
1869
- if (tlsEnabled) {
1870
- const { cert, key } = await issueIngressCert(
1871
- opts.ingressDomains.map((d) => `*.${d}`),
1872
- );
1873
- // Apply the cert Secret + dynamic-config ConfigMap before the
1874
- // Deployment that mounts them. `stringData` lets us hand over plain
1875
- // PEM; the API server base64-encodes it.
1876
- await helpers.client.core.createNamespacedSecret({
1877
- namespace: "kube-system",
1878
- body: {
1879
- metadata: { name: TRAEFIK_TLS_SECRET, namespace: "kube-system" },
1880
- type: "kubernetes.io/tls",
1881
- stringData: { "tls.crt": cert, "tls.key": key },
1882
- },
1883
- });
1884
- await helpers.client.core.createNamespacedConfigMap({
1885
- namespace: "kube-system",
1886
- body: {
1887
- metadata: {
1888
- name: TRAEFIK_DYNAMIC_CONFIGMAP,
1889
- namespace: "kube-system",
1890
- },
1891
- data: { "tls.yaml": TRAEFIK_DYNAMIC_TLS },
1892
- },
1893
- });
1894
- }
1895
- if (opts.traefik) await helpers.apply(buildTraefikManifest(tlsEnabled));
1896
- if (opts.registry) await helpers.apply(REGISTRY_MANIFEST);
1897
- // Both rollouts proceed independently inside the cluster — wait on them
1898
- // concurrently (they used to serialize, wasting up to a rollout's tail).
1899
- // 250ms polling: these two sit on the cold start's critical path, where
1900
- // the default 500ms interval wastes real time.
1869
+ // The registry can start while Traefik's certificates are being prepared.
1901
1870
  const wait = (deployment: string): Promise<void> =>
1902
1871
  helpers.waitForRollout(deployment, {
1903
1872
  namespace: "kube-system",
1904
1873
  timeoutMs: 120_000,
1905
1874
  intervalMs: 250,
1906
1875
  });
1907
- const waits = opts.traefik ? [wait("traefik")] : [];
1908
- if (opts.registry) waits.push(wait("spectest-registry"));
1909
- await Promise.all(waits);
1876
+ await waitForAll([
1877
+ (async () => {
1878
+ if (!opts.traefik) return;
1879
+ const tlsEnabled = opts.ingressDomains.length > 0 && caPresent();
1880
+ if (tlsEnabled) {
1881
+ const { cert, key } = await issueIngressCert(
1882
+ opts.ingressDomains.map((d) => `*.${d}`),
1883
+ );
1884
+ // Apply the cert Secret + dynamic-config ConfigMap before the
1885
+ // Deployment that mounts them. `stringData` lets us hand over plain
1886
+ // PEM; the API server base64-encodes it.
1887
+ await helpers.client.core.createNamespacedSecret({
1888
+ namespace: "kube-system",
1889
+ body: {
1890
+ metadata: { name: TRAEFIK_TLS_SECRET, namespace: "kube-system" },
1891
+ type: "kubernetes.io/tls",
1892
+ stringData: { "tls.crt": cert, "tls.key": key },
1893
+ },
1894
+ });
1895
+ await helpers.client.core.createNamespacedConfigMap({
1896
+ namespace: "kube-system",
1897
+ body: {
1898
+ metadata: {
1899
+ name: TRAEFIK_DYNAMIC_CONFIGMAP,
1900
+ namespace: "kube-system",
1901
+ },
1902
+ data: { "tls.yaml": TRAEFIK_DYNAMIC_TLS },
1903
+ },
1904
+ });
1905
+ }
1906
+ await helpers.apply(buildTraefikManifest(tlsEnabled));
1907
+ await wait("traefik");
1908
+ })(),
1909
+ (async () => {
1910
+ if (!opts.registry) return;
1911
+ await helpers.apply(REGISTRY_MANIFEST);
1912
+ await wait("spectest-registry");
1913
+ })(),
1914
+ ]);
1910
1915
  }
1911
1916
 
1912
1917
  /**
@@ -2199,47 +2204,51 @@ export function k3s(opts: K3sOptions = {}) {
2199
2204
  const { name, helpers, exec } = ctx;
2200
2205
  const runtime = componentRuntime(ctx);
2201
2206
  if (images.builds.length && !runtime) throw new Error(`k3s(${name}): prepared image inputs are missing`);
2202
- if (runtime?.caBundle && caPresent()) {
2203
- const { cert, key } = await issueIngressCert([runtime.gateway]);
2204
- await installPodTrust({ name, gateway: runtime.gateway, cert, key,
2205
- ca: await readFile(CA_PATH, "utf8"), bundle: await readFile(runtime.caBundle, "utf8"),
2206
- }, {
2207
- apply: async configuration => { await helpers.apply(JSON.stringify(configuration)); },
2208
- ensureBundle: async (namespace, configMap, bundle) => {
2209
- try {
2210
- await helpers.client.core.createNamespacedConfigMap({ namespace, body: {
2211
- metadata: { name: configMap, namespace }, immutable: true, data: { "ca-bundle.crt": bundle },
2212
- } });
2213
- } catch (err) {
2214
- // A simultaneous Pod may have created the same namespace's bundle.
2215
- const existing = (await helpers.client.core.readNamespacedConfigMap({ namespace, name: configMap })).unwrap();
2216
- if (existing.data?.["ca-bundle.crt"] !== bundle || existing.immutable !== true) throw err;
2207
+ await waitForAll([
2208
+ runtime ? loadK3sBuildImages(name, runtime.images, runtime.gateway, exec) : Promise.resolve(),
2209
+ prePullK3sImages(name, images.pulls, exec),
2210
+ (async () => {
2211
+ if (runtime?.caBundle && caPresent()) {
2212
+ const { cert, key } = await issueIngressCert([runtime.gateway]);
2213
+ await installPodTrust({ name, gateway: runtime.gateway, cert, key,
2214
+ ca: await readFile(CA_PATH, "utf8"), bundle: await readFile(runtime.caBundle, "utf8"),
2215
+ }, {
2216
+ apply: async configuration => { await helpers.apply(JSON.stringify(configuration)); },
2217
+ ensureBundle: async (namespace, configMap, bundle) => {
2218
+ try {
2219
+ await helpers.client.core.createNamespacedConfigMap({ namespace, body: {
2220
+ metadata: { name: configMap, namespace }, immutable: true, data: { "ca-bundle.crt": bundle },
2221
+ } });
2222
+ } catch (err) {
2223
+ // A simultaneous Pod may have created the same namespace's bundle.
2224
+ const existing = (await helpers.client.core.readNamespacedConfigMap({ namespace, name: configMap })).unwrap();
2225
+ if (existing.data?.["ca-bundle.crt"] !== bundle || existing.immutable !== true) throw err;
2226
+ }
2227
+ },
2228
+ });
2229
+ // The API server observes webhook registration asynchronously. A dry-run
2230
+ // Pod proves TLS reachability and admission before any project workload.
2231
+ const deadline = Date.now() + 30_000;
2232
+ for (;;) {
2233
+ try {
2234
+ const pod = (await helpers.client.core.createNamespacedPod({ namespace: "default", dryRun: "All", body: {
2235
+ metadata: { generateName: "spectest-trust-probe-" }, spec: { containers: [{ name: "probe", image: "registry.k8s.io/pause:3.10" }] },
2236
+ } })).unwrap();
2237
+ if (pod.spec?.containers[0].env?.some(e => e.name === "SSL_CERT_FILE" && e.value === "/spectest/ca-bundle.crt")) break;
2238
+ if (Date.now() >= deadline) throw new Error(`k3s(${name}): Pod CA mounts did not become ready`);
2239
+ } catch (err) {
2240
+ if (Date.now() >= deadline) throw err;
2241
+ }
2242
+ await new Promise(resolve => setTimeout(resolve, 100));
2217
2243
  }
2218
- },
2219
- });
2220
- // The API server observes webhook registration asynchronously. A dry-run
2221
- // Pod proves TLS reachability and admission before any project workload.
2222
- const deadline = Date.now() + 30_000;
2223
- for (;;) {
2224
- try {
2225
- const pod = (await helpers.client.core.createNamespacedPod({ namespace: "default", dryRun: "All", body: {
2226
- metadata: { generateName: "spectest-trust-probe-" }, spec: { containers: [{ name: "probe", image: "registry.k8s.io/pause:3.10" }] },
2227
- } })).unwrap();
2228
- if (pod.spec?.containers[0].env?.some(e => e.name === "SSL_CERT_FILE" && e.value === "/spectest/ca-bundle.crt")) break;
2229
- if (Date.now() >= deadline) throw new Error(`k3s(${name}): Pod CA mounts did not become ready`);
2230
- } catch (err) {
2231
- if (Date.now() >= deadline) throw err;
2232
2244
  }
2233
- await new Promise(resolve => setTimeout(resolve, 100));
2234
- }
2235
- }
2236
- if (runtime) await loadK3sBuildImages(name, runtime.images, runtime.gateway, exec);
2237
- await setupK3sCluster(name, helpers, {
2238
- registry: registryEnabled,
2239
- ingressDomains,
2240
- traefik: traefikEnabled,
2241
- });
2242
- await prePullK3sImages(name, images.pulls, exec);
2245
+ await setupK3sCluster(name, helpers, {
2246
+ registry: registryEnabled,
2247
+ ingressDomains,
2248
+ traefik: traefikEnabled,
2249
+ });
2250
+ })(),
2251
+ ]);
2243
2252
  if (sticky) await registerStickyKubernetesStore(name, version);
2244
2253
  },
2245
2254
  helpers: async ({ name, exec, poll }: ServiceHelpersContext): Promise<K3sHelpers> => {
package/src/daemon.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
2
+ import { waitForAll } from "./harness/concurrency.js";
2
3
  import { androidScope, androidRecordingChunk, releaseAndroidRecording, isAndroidApp, type AndroidRecording } from "./android.js";
3
4
  // Long-running HTTP daemon. Runs as a systemd unit on the VM host (not in
4
5
  // a container). Owns:
@@ -3687,25 +3688,28 @@ async function bootstrapInner(prepared?: PreparedImages, identity?: string): Pro
3687
3688
  await waitForReady(svc);
3688
3689
  const tSetup = Date.now();
3689
3690
  const builds = componentTasks.filter(task => task.owner === svc.name);
3690
- await Promise.all(builds.map(task => prep.get(task.name)!));
3691
- if (svc.setup) {
3692
- progressService(svc.name, { status: "probing", detail: "running setup" });
3693
- const helpers = await ensureHelpers(svc.name, svc);
3694
- try {
3695
- await svc.setup({
3696
- name: svc.name,
3697
- helpers,
3698
- ...(await spectestContext({ service: svc.name, includeSelf: true })),
3699
- ...{ [COMPONENT_RUNTIME]: {
3700
- images: builds.map(task => ({ source: tags.get(task.name)!, reference: task.reference })),
3701
- gateway: await bridgeGatewayIp(),
3702
- caBundle: await ensureCaBundle(),
3703
- } },
3704
- });
3705
- } catch (err) {
3706
- throw await withContainerPostMortem(svc.name, err);
3691
+ // Setup can overlap pulls/infrastructure with builds, and consume each
3692
+ // image as it becomes ready. Still drain every build if setup fails.
3693
+ await waitForAll([...builds.map(task => prep.get(task.name)!), (async () => {
3694
+ if (svc.setup) {
3695
+ progressService(svc.name, { status: "probing", detail: "running setup" });
3696
+ const helpers = await ensureHelpers(svc.name, svc);
3697
+ try {
3698
+ await svc.setup({
3699
+ name: svc.name,
3700
+ helpers,
3701
+ ...(await spectestContext({ service: svc.name, includeSelf: true })),
3702
+ ...{ [COMPONENT_RUNTIME]: {
3703
+ images: builds.map(task => ({ source: imageTag(task.name), reference: task.reference, ready: prep.get(task.name)! })),
3704
+ gateway: await bridgeGatewayIp(),
3705
+ caBundle: await ensureCaBundle(),
3706
+ } },
3707
+ });
3708
+ } catch (err) {
3709
+ throw await withContainerPostMortem(svc.name, err);
3710
+ }
3707
3711
  }
3708
- }
3712
+ })()]);
3709
3713
  for (const task of builds) progressService(task.name, { status: "ready", detail: `loaded ${task.reference}` });
3710
3714
  progressService(svc.name, { status: "ready", detail: undefined });
3711
3715
  const ti = timings.get(svc.name);
@@ -63,8 +63,14 @@ export function componentBuildTasks<T extends ServiceConfig & { name: string }>(
63
63
  /** Private handoff to a component's setup hook. Builds stay generic; the
64
64
  * component decides how its runtime consumes them and establishes trust. */
65
65
  export const COMPONENT_RUNTIME = Symbol.for("spectest.component-runtime");
66
+ export interface PreparedComponentImage {
67
+ source: string;
68
+ reference: string;
69
+ /** A component can load each result as soon as its own build finishes. */
70
+ ready?: Promise<void>;
71
+ }
66
72
  export interface ComponentRuntime {
67
- images: readonly { source: string; reference: string }[];
73
+ images: readonly PreparedComponentImage[];
68
74
  gateway: string;
69
75
  caBundle: string | null;
70
76
  }
@@ -0,0 +1,8 @@
1
+ /** Wait for every writer to stop before reporting a failure to setup/cleanup. */
2
+ export async function waitForAll(work: readonly Promise<unknown>[]): Promise<void> {
3
+ const results = await Promise.allSettled(work);
4
+ const failures = results.flatMap(result => result.status === "rejected" ? [result.reason] : []);
5
+ if (failures.length === 1) throw failures[0];
6
+ if (failures.length > 1) throw new AggregateError(failures,
7
+ `Concurrent environment preparation failed: ${failures.map(error => error instanceof Error ? error.message : String(error)).join("; ")}`);
8
+ }
@@ -6,8 +6,9 @@ import { createHash } from "node:crypto";
6
6
  import { createServer } from "node:http";
7
7
  import { promisify } from "node:util";
8
8
  import type { SpectestContext } from "../index.js";
9
- import { canonicalImageReference } from "./component-images.js";
9
+ import { canonicalImageReference, type PreparedComponentImage } from "./component-images.js";
10
10
  import { waitForK3sRuntime } from "./k3s-prepull.js";
11
+ import { waitForAll } from "./concurrency.js";
11
12
 
12
13
  interface Descriptor { digest: string; size: number; mediaType: string; platform?: { os: string; architecture: string } }
13
14
  interface Manifest { schemaVersion: number; mediaType: string; config: Descriptor; layers: Descriptor[]; annotations?: Record<string, string> }
@@ -57,7 +58,7 @@ async function hostImage(source: string) {
57
58
  }
58
59
 
59
60
  export async function loadK3sBuildImages(
60
- service: string, images: readonly { source: string; reference: string }[],
61
+ service: string, images: readonly PreparedComponentImage[],
61
62
  gateway: string, exec: SpectestContext["exec"],
62
63
  ): Promise<void> {
63
64
  if (!images.length) return;
@@ -73,7 +74,8 @@ export async function loadK3sBuildImages(
73
74
  const ctr = ["ctr", "--address", "/run/k3s/containerd/containerd.sock", "--namespace", "k8s.io"];
74
75
  const help = await checked([...ctr, "images", "pull", "--help"]);
75
76
  const local = help.includes("--local") ? ["--local"] : [];
76
- for (const image of images) {
77
+ await waitForAll(images.map(async image => {
78
+ await image.ready;
77
79
  const reference = canonicalImageReference(image.reference);
78
80
  const { target, metadata: blobs, allowed, configDigest } = await hostImage(image.source);
79
81
  const streams = new Set<ReturnType<typeof spawn>>();
@@ -125,5 +127,5 @@ export async function loadK3sBuildImages(
125
127
  await new Promise<void>(resolve => server.close(() => resolve()));
126
128
  await exec(service, ["rm", "-rf", directory], { timeoutMs: 5_000 }).catch(() => undefined);
127
129
  }
128
- }
130
+ }));
129
131
  }
@@ -20,7 +20,7 @@ test("pre-pulls reuse immutable cached images, but resolve tags on every fresh s
20
20
  expect(calls.at(-1)).toEqual([...cri, "pull", digest]);
21
21
  });
22
22
 
23
- test("failed pre-pulls fail setup after outstanding pulls finish, with bounded concurrency", async () => {
23
+ test("failed pre-pulls fail setup after all concurrent pulls finish", async () => {
24
24
  let active = 0, peak = 0, finished = 0;
25
25
  const work = prePullK3sImages("cluster", ["a:1", "b:1", "c:1"], mockExec(async args => {
26
26
  if (args.at(-1) === "info") return { exitCode: 0 };
@@ -31,7 +31,7 @@ test("failed pre-pulls fail setup after outstanding pulls finish, with bounded c
31
31
  }));
32
32
  await expect(work).rejects.toThrow("pre-pull failed for a:1: registry unavailable");
33
33
  expect(active).toBe(0);
34
- expect(peak).toBe(2);
34
+ expect(peak).toBe(3);
35
35
  expect(finished).toBe(3);
36
36
  });
37
37
 
@@ -45,3 +45,13 @@ test("pre-pulls wait for the runtime even when the API server is already ready",
45
45
  }));
46
46
  expect(calls).toEqual(["info", "info", "pull"]);
47
47
  });
48
+
49
+ test("simultaneous pull failures retain every failing image in the setup error", async () => {
50
+ const error = await prePullK3sImages("cluster", ["a:1", "b:1"], mockExec(async args => ({
51
+ exitCode: args[3] === "pull" ? 1 : 0, stderr: "unavailable",
52
+ }))).catch(error => error);
53
+ expect(error).toBeInstanceOf(AggregateError);
54
+ expect(error.errors).toHaveLength(2);
55
+ expect(error.message).toContain("pre-pull failed for a:1");
56
+ expect(error.message).toContain("pre-pull failed for b:1");
57
+ });
@@ -1,26 +1,23 @@
1
1
  import type { SpectestContext } from "../index.js";
2
+ import { waitForAll } from "./concurrency.js";
2
3
 
3
4
  /** CRI PullImage waits for unpacking and uses k3s's registry configuration.
4
5
  * Always resolve tags on a fresh setup. Immutable digests can be used offline.
5
- * Drain both workers on failure so no pulls outlive the setup hook.
6
+ * Submit every image concurrently; containerd manages downloads and unpacking.
7
+ * Drain all pulls on failure so none outlive the setup hook.
6
8
  */
7
9
  export async function prePullK3sImages(name: string, images: readonly string[], exec: SpectestContext["exec"]): Promise<void> {
8
10
  if (!images.length) return;
9
11
  await waitForK3sRuntime(name, exec);
10
12
  const cri = ["crictl", "--runtime-endpoint", "unix:///run/k3s/containerd/containerd.sock"];
11
- const queue = [...images];
12
- const results = await Promise.allSettled(Array.from({ length: Math.min(2, queue.length) }, async () => {
13
- for (let image = queue.shift(); image !== undefined; image = queue.shift()) {
14
- if (/@sha256:[a-f0-9]{64}$/.test(image)) {
15
- const existing = await exec(name, [...cri, "inspecti", image], { timeoutMs: 30_000 });
16
- if (existing.exitCode === 0) continue;
17
- }
18
- const result = await exec(name, [...cri, "pull", image], { timeoutMs: 600_000 });
19
- if (result.exitCode !== 0) throw new Error(`k3s(${name}): pre-pull failed for ${image}: ${result.stderr || result.stdout}`);
13
+ await waitForAll(images.map(async image => {
14
+ if (/@sha256:[a-f0-9]{64}$/.test(image)) {
15
+ const existing = await exec(name, [...cri, "inspecti", image], { timeoutMs: 30_000 });
16
+ if (existing.exitCode === 0) return;
20
17
  }
18
+ const result = await exec(name, [...cri, "pull", image], { timeoutMs: 600_000 });
19
+ if (result.exitCode !== 0) throw new Error(`k3s(${name}): pre-pull failed for ${image}: ${result.stderr || result.stdout}`);
21
20
  }));
22
- const failed = results.find(result => result.status === "rejected");
23
- if (failed?.status === "rejected") throw failed.reason;
24
21
  }
25
22
 
26
23
  export async function waitForK3sRuntime(name: string, exec: SpectestContext["exec"]): Promise<void> {