@specific.dev/spectest 0.131.11 → 0.131.13

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 (50) 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/replay-websocket.js +6 -1
  11. package/dist/components/supabase-pooler.js +2 -1
  12. package/dist/components/supabase.js +12 -11
  13. package/dist/components/temporal.js +3 -2
  14. package/dist/daemon.js +67 -51
  15. package/dist/harness/built-image.d.ts +3 -0
  16. package/dist/harness/built-image.js +5 -0
  17. package/dist/harness/builtin-images.d.ts +29 -0
  18. package/dist/harness/builtin-images.js +29 -0
  19. package/dist/harness/log-delta.d.ts +9 -0
  20. package/dist/harness/log-delta.js +10 -0
  21. package/dist/harness/registry-seed.d.ts +1 -1
  22. package/dist/harness/registry-seed.js +2 -1
  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/replay-websocket.ts +6 -1
  37. package/src/components/replayFake-websocket.test.ts +23 -0
  38. package/src/components/supabase-pooler.ts +2 -1
  39. package/src/components/supabase.ts +12 -11
  40. package/src/components/temporal.ts +3 -2
  41. package/src/daemon.ts +71 -57
  42. package/src/harness/built-image-docker.test.ts +62 -0
  43. package/src/harness/built-image.ts +6 -0
  44. package/src/harness/builtin-images.ts +29 -0
  45. package/src/harness/log-delta.test.ts +17 -0
  46. package/src/harness/log-delta.ts +15 -0
  47. package/src/harness/registry-seed.ts +2 -1
  48. package/src/harness/sticky-kubernetes.test.ts +23 -0
  49. package/src/harness/sticky-kubernetes.ts +12 -0
  50. package/src/runtime-service-logs.test.ts +74 -0
@@ -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
+ } as const;
@@ -123,3 +123,20 @@ describe("streamDelta", () => {
123
123
  expect(out.delta.length).toBeLessThan(big.length);
124
124
  });
125
125
  });
126
+
127
+ test("repeated service incarnations retain both ends within the log cap", async () => {
128
+ const { mergeServiceLogDeltas, LOG_DELTA_MAX_BYTES } = await import("./log-delta");
129
+ const earlier = { stdout: "first\n" + "x".repeat(LOG_DELTA_MAX_BYTES), stderr: "old error\n",
130
+ stdoutTruncated: false, stderrTruncated: true, stdoutReset: false, stderrReset: false,
131
+ stopped: true, exitCode: 137 };
132
+ const later = { ...earlier, stdout: "last\n", stderr: "new error\n", stderrTruncated: false,
133
+ stopped: false, exitCode: 0 };
134
+ const result = mergeServiceLogDeltas(earlier, later);
135
+ expect(result.stdout.startsWith("first\n")).toBe(true);
136
+ expect(result.stdout.endsWith("last\n")).toBe(true);
137
+ expect(result.stdout.length).toBeLessThan(LOG_DELTA_MAX_BYTES + 100);
138
+ expect(result.stdoutTruncated).toBe(true);
139
+ expect(result.stderr).toBe("old error\nnew error\n");
140
+ expect(result.stderrTruncated).toBe(true);
141
+ expect(result.stopped).toBe(false);
142
+ });
@@ -98,3 +98,18 @@ export function streamDelta(full: string, marker: number): StreamDelta {
98
98
  const capped = capMiddle(delta, LOG_DELTA_MAX_BYTES);
99
99
  return { delta: capped.value, total, reset, truncated: capped.truncated };
100
100
  }
101
+
102
+ /** Join successive container incarnations into one bounded per-service delta. */
103
+ export function mergeServiceLogDeltas<T extends {
104
+ stdout: string; stderr: string;
105
+ stdoutTruncated: boolean; stderrTruncated: boolean;
106
+ stdoutReset: boolean; stderrReset: boolean;
107
+ }>(earlier: T, later: T): T {
108
+ const stdout = capMiddle(earlier.stdout + later.stdout, LOG_DELTA_MAX_BYTES);
109
+ const stderr = capMiddle(earlier.stderr + later.stderr, LOG_DELTA_MAX_BYTES);
110
+ return { ...later, stdout: stdout.value, stderr: stderr.value,
111
+ stdoutTruncated: earlier.stdoutTruncated || later.stdoutTruncated || stdout.truncated,
112
+ stderrTruncated: earlier.stderrTruncated || later.stderrTruncated || stderr.truncated,
113
+ stdoutReset: earlier.stdoutReset || later.stdoutReset,
114
+ stderrReset: earlier.stderrReset || later.stderrReset };
115
+ }
@@ -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. */
@@ -9,7 +10,7 @@ import { canonicalImageReference, type PreparedComponentImage } from "./componen
9
10
  export const REGISTRY_DATA = "/var/lib/spectest-registry";
10
11
  export const REGISTRY_CONTENT = "/spectest-registry-content";
11
12
  export const CONTENT_BLOBS = "io.containerd.content.v1.content/blobs/sha256";
12
- export const REGISTRY_IMAGE = "registry:2.8.3";
13
+ export const REGISTRY_IMAGE = BUILTIN_IMAGES["registry:2.8.3"];
13
14
  export const registryDirectory = (service: string) => `/var/lib/spectest-registries/${service}`;
14
15
 
15
16
  /** Distribution 2.8 registry/storage/paths.go defines this layout. Seed
@@ -18,6 +18,7 @@ async function store(root: string, service: string, age = 0) {
18
18
  function runner(calls: string[][]): CacheCommand {
19
19
  return async (program, args) => {
20
20
  calls.push([program, ...args]);
21
+ if (program === "docker" && args.join(" ") === "image ls -aq --no-trunc") return image;
21
22
  if (program === "docker" && args[0] === "inspect") return JSON.stringify({ ExitCode: 0, OOMKilled: false });
22
23
  return "";
23
24
  };
@@ -86,3 +87,25 @@ test("unclean nested containerd shutdown rejects sealing instead of publishing i
86
87
  await expect(cache.close()).rejects.toThrow("Kubernetes cache shutdown failed");
87
88
  expect(calls.some(c => c[0] === "rm")).toBe(false);
88
89
  });
90
+
91
+ test("orphaned legacy stores are discarded while healthy stores are maintained", async () => {
92
+ const root = await temporary();
93
+ const orphan = await store(root, "old");
94
+ await writeFile(`${orphan}/store.json`, JSON.stringify({ schema: 1, image: `sha256:${"b".repeat(64)}`, updatedAt: Date.now() }));
95
+ await store(root, "healthy");
96
+ const calls: string[][] = [];
97
+ const cache = await openKubernetesCacheMaintenance(runner(calls), root);
98
+ expect(calls).toContainEqual(["rm", "-rf", orphan]);
99
+ expect(calls.filter(c => c[1] === "run")).toHaveLength(1);
100
+ await cache.close();
101
+ });
102
+
103
+ test("inventory failure never discards stores", async () => {
104
+ const root = await temporary(); await store(root, "cluster");
105
+ const calls: string[][] = [];
106
+ await expect(openKubernetesCacheMaintenance(async (program, args) => {
107
+ calls.push([program, ...args]);
108
+ throw new Error("Docker unavailable");
109
+ }, root)).rejects.toThrow("Docker unavailable");
110
+ expect(calls.some(c => c[0] === "rm")).toBe(false);
111
+ });
@@ -119,6 +119,10 @@ export async function openKubernetesCacheMaintenance(run: CacheCommand, root = R
119
119
  throw error;
120
120
  });
121
121
  try {
122
+ // A successful inventory distinguishes orphaned legacy stores from Docker
123
+ // outages. Only this isolated maintenance copy may discard a store.
124
+ const available = new Set((await run("docker", ["image", "ls", "-aq", "--no-trunc"]))
125
+ .split(/\s+/).filter(Boolean));
122
126
  for (const service of entries) {
123
127
  if (!service.isDirectory() || !SERVICE_NAME.test(service.name)) continue;
124
128
  for (const layout of await readdir(`${root}/${service.name}`, { withFileTypes: true })) {
@@ -128,6 +132,14 @@ export async function openKubernetesCacheMaintenance(run: CacheCommand, root = R
128
132
  if (descriptor.schema !== 1 || !IMAGE_ID.test(descriptor.image) || !Number.isSafeInteger(descriptor.updatedAt)) {
129
133
  throw new Error(`invalid Kubernetes cache descriptor: ${directory}`);
130
134
  }
135
+ if (!available.has(descriptor.image)) {
136
+ // Older exporters replaced their sole image reference on rebuild.
137
+ // An obsolete version's store can outlive that image. It is disposable
138
+ // cache, and must not prevent publishing the healthy stores.
139
+ console.warn(`Discarding orphaned Kubernetes cache ${directory}: missing runtime image ${descriptor.image}`);
140
+ await run("rm", ["-rf", directory]);
141
+ continue;
142
+ }
131
143
  const container = `spectest-k8s-cache-${randomUUID()}`;
132
144
  await run("docker", ["run", "-d", "--name", container, "--network", "none", "--privileged", "--stop-signal", "SIGTERM", "--tmpfs", "/run", "--tmpfs", "/var/run",
133
145
  "--mount", `type=bind,source=${directory}/containerd,target=${STORE_TARGET}`,
@@ -0,0 +1,74 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ test("stopped service logs survive removal and are emitted only in their owning case", async () => {
7
+ const root = await mkdtemp(join(tmpdir(), "spectest-stopped-logs-"));
8
+ try {
9
+ await mkdir(join(root, "spectest"));
10
+ await mkdir(join(root, "bin"));
11
+ await writeFile(join(root, "bin/docker"), `#!/bin/sh
12
+ case "$1" in
13
+ kill) touch "$SPECTEST_APP_DIR/stopped" ;;
14
+ rm) touch "$SPECTEST_APP_DIR/removed" ;;
15
+ run) rm -f "$SPECTEST_APP_DIR/stopped" "$SPECTEST_APP_DIR/removed" ;;
16
+ inspect) echo 'exited 137' ;;
17
+ logs)
18
+ if [ -f "$SPECTEST_APP_DIR/removed" ]; then echo 'container removed' >&2; exit 1; fi
19
+ if [ -f "$SPECTEST_APP_DIR/stopped" ]; then
20
+ printf 'startup\nlast diagnostic\n'
21
+ printf 'failure detail' >&2
22
+ else printf 'startup\n'; fi ;;
23
+ esac
24
+ `, { mode: 0o755 });
25
+ await writeFile(join(root, "spectest/index.ts"), `
26
+ import { defineEnvironment } from ${JSON.stringify(new URL("./index.ts", import.meta.url).href)};
27
+ const env = defineEnvironment({ name: "logs", services: {
28
+ agent: { image: { type: "registry", reference: "busybox" } }
29
+ }});
30
+ export default env.project([
31
+ env.test("parent", async () => {}),
32
+ env.test("stop", async ctx => { await ctx.stopService("agent"); await ctx.stopService("agent"); }),
33
+ env.test("child", async () => {}),
34
+ env.test("reuse", async ctx => {
35
+ const spec = { name: "runtime", image: { type: "registry", reference: "busybox" } };
36
+ await ctx.startService(spec);
37
+ await ctx.stopService("runtime");
38
+ await ctx.startService(spec);
39
+ await ctx.stopService("runtime");
40
+ }),
41
+ ]);
42
+ `);
43
+ const script = `
44
+ import { strict as assert } from "node:assert";
45
+ import { METHODS } from ${JSON.stringify(new URL("./daemon.ts", import.meta.url).href)};
46
+ const loaded = await METHODS.load({});
47
+ const parent = await METHODS.run({caseId: loaded.cases[0].id});
48
+ assert.equal(parent.serviceLogDeltas[0].stdout, "startup\\n");
49
+ const stopped = await METHODS.run({caseId: loaded.cases[1].id});
50
+ assert.equal(stopped.status, "passed", JSON.stringify(stopped));
51
+ assert.equal(stopped.serviceLogDeltas.length, 1);
52
+ assert.equal(stopped.serviceLogDeltas[0].stdout, "last diagnostic\\n");
53
+ assert.equal(stopped.serviceLogDeltas[0].stderr, "failure detail\\n");
54
+ assert.equal(stopped.serviceLogDeltas[0].stopped, true);
55
+ assert.equal(stopped.serviceLogDeltas[0].exitCode, 137);
56
+ const child = await METHODS.run({caseId: loaded.cases[2].id});
57
+ assert.deepEqual(child.serviceLogDeltas, []);
58
+ const reuse = await METHODS.run({caseId: loaded.cases[3].id});
59
+ assert.equal(reuse.status, "passed", JSON.stringify(reuse));
60
+ const logs = reuse.serviceLogDeltas.find(d => d.service === "runtime");
61
+ assert.equal(logs.stdout, "startup\\nlast diagnostic\\nstartup\\nlast diagnostic\\n");
62
+ assert.equal(logs.stderr, "failure detail\\nfailure detail\\n");
63
+ process.exit(0);
64
+ `;
65
+ const child = Bun.spawn([process.execPath, "--eval", script], {
66
+ env: { ...process.env, SPECTEST_APP_DIR: root, PATH: `${root}/bin:${process.env.PATH}` },
67
+ stdout: "pipe", stderr: "pipe",
68
+ });
69
+ const [code, stdout, stderr] = await Promise.all([
70
+ child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(),
71
+ ]);
72
+ expect(code, stdout + stderr).toBe(0);
73
+ } finally { await rm(root, { recursive: true, force: true }); }
74
+ }, 20_000);