@specific.dev/spectest 0.115.1 → 0.116.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.
Files changed (39) hide show
  1. package/README.md +44 -0
  2. package/dist/components/replay-websocket.d.ts +2 -3
  3. package/dist/components/replay-websocket.js +19 -185
  4. package/dist/components/replayFake.d.ts +2 -53
  5. package/dist/components/replayFake.js +7 -348
  6. package/dist/daemon.js +4 -1
  7. package/dist/fake-upstream-websocket.d.ts +27 -0
  8. package/dist/fake-upstream-websocket.js +184 -0
  9. package/dist/fake-upstream.d.ts +114 -0
  10. package/dist/fake-upstream.js +348 -0
  11. package/dist/harness/fake-websocket.js +6 -3
  12. package/dist/harness/live-fake.d.ts +3 -0
  13. package/dist/harness/live-fake.js +66 -0
  14. package/dist/harness/sticky-consumer.d.ts +11 -3
  15. package/dist/harness/sticky-consumer.js +61 -38
  16. package/dist/harness/sticky-image-retention.d.ts +15 -0
  17. package/dist/harness/sticky-image-retention.js +32 -0
  18. package/dist/index.d.ts +15 -1
  19. package/dist/index.js +3 -0
  20. package/dist/record-secrets.d.ts +1 -1
  21. package/dist/record-secrets.js +3 -3
  22. package/dist/replay-mode.d.ts +2 -0
  23. package/dist/replay-mode.js +6 -2
  24. package/package.json +1 -1
  25. package/src/components/replay-websocket.ts +25 -145
  26. package/src/components/replayFake.ts +8 -459
  27. package/src/daemon.ts +3 -1
  28. package/src/fake-upstream-websocket.ts +157 -0
  29. package/src/fake-upstream.ts +465 -0
  30. package/src/harness/fake-websocket.ts +6 -3
  31. package/src/harness/live-fake.test.ts +227 -0
  32. package/src/harness/live-fake.ts +62 -0
  33. package/src/harness/sticky-consumer.test.ts +93 -23
  34. package/src/harness/sticky-consumer.ts +62 -40
  35. package/src/harness/sticky-image-retention.test.ts +59 -0
  36. package/src/harness/sticky-image-retention.ts +43 -0
  37. package/src/index.ts +19 -1
  38. package/src/record-secrets.ts +3 -3
  39. package/src/replay-mode.ts +7 -2
@@ -0,0 +1,59 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { recordStickyImageUse } from "./sticky-image-retention";
6
+ import { collectStickyCache, STICKY_COMPATIBILITY } from "./sticky-consumer";
7
+
8
+ test("publication records container use before removal and carries it into the next generation", async () => {
9
+ const root = await mkdtemp(join(tmpdir(), "sticky-image-use-"));
10
+ try {
11
+ const now = Date.UTC(2026, 9, 13), GiB = 1024 ** 3;
12
+ const path = `${root}/.spectest-image-use.json`;
13
+ await writeFile(path, JSON.stringify({ schema: 1, images: {
14
+ postgres: now - 10 * 86400_000, unused: now - 9 * 86400_000, gone: now,
15
+ } }));
16
+ let containers = true;
17
+ const calls: string[][] = [];
18
+ const run = async (command: string, args: string[]) => {
19
+ calls.push([command, ...args]);
20
+ if (command === "docker" && args[0] === "image" && args[1] === "ls") return "postgres\nnew\nunused\npostgres";
21
+ if (command === "docker" && args[0] === "ps") return containers ? "db-container" : "";
22
+ if (command === "docker" && args[0] === "container" && args[1] === "inspect") return "postgres";
23
+ return "";
24
+ };
25
+ const first = await recordStickyImageUse(root, run, now);
26
+ expect(first.containers).toEqual(["db-container"]);
27
+ expect(first.protectedImages).toEqual(new Set(["postgres"]));
28
+ expect(JSON.parse(await readFile(path, "utf8"))).toEqual({ schema: 1,
29
+ images: { postgres: now, new: now, unused: now - 9 * 86400_000 } });
30
+ containers = false; // Publication removes the containers after recording use.
31
+ await collectStickyCache({ path: "/var/lib/spectest-sticky", capacityBytes: 256 * GiB,
32
+ headroomBytes: 32 * GiB,
33
+ compatibility: STICKY_COMPATIBILITY }, first, run,
34
+ async () => ({ totalBytes: 256 * GiB, freeBytes: 200 * GiB }));
35
+ expect(calls.filter(c => c[0] === "docker" && c[2] === "rm"))
36
+ .toEqual([["docker", "image", "rm", "-f", "unused"]]);
37
+ expect(calls.some(c => c.includes("until=168h"))).toBe(false);
38
+ const next = await recordStickyImageUse(root, run, now + 86400_000);
39
+ expect(next.images.find(image => image.id === "postgres")?.lastUsed).toBe(now);
40
+ expect(next.protectedImages.size).toBe(0);
41
+ } finally { await rm(root, { recursive: true, force: true }); }
42
+ });
43
+
44
+ test("legacy generations give unknown images a first observation instead of inferring publisher age", async () => {
45
+ const root = await mkdtemp(join(tmpdir(), "sticky-image-use-"));
46
+ try {
47
+ const state = await recordStickyImageUse(root, async (_, args) => args[1] === "ls" ? "old-published-image" : "", 1234);
48
+ expect(state.images).toEqual([{ id: "old-published-image", lastUsed: 1234 }]);
49
+ } finally { await rm(root, { recursive: true, force: true }); }
50
+ });
51
+
52
+ test("invalid image-use history fails publication rather than treating every image as expired", async () => {
53
+ const root = await mkdtemp(join(tmpdir(), "sticky-image-use-"));
54
+ try {
55
+ await writeFile(`${root}/.spectest-image-use.json`, JSON.stringify({ schema: 1, images: { postgres: "yesterday" } }));
56
+ await expect(recordStickyImageUse(root, async () => { throw new Error("must not reach Docker"); }))
57
+ .rejects.toThrow("invalid sticky image-use ledger");
58
+ } finally { await rm(root, { recursive: true, force: true }); }
59
+ });
@@ -0,0 +1,43 @@
1
+ /** Image use belongs to the cache lineage, not the publisher's image clock. */
2
+ import { randomUUID } from "node:crypto";
3
+ import { readFile, rename, writeFile } from "node:fs/promises";
4
+ import type { CacheCommand } from "./sticky-kubernetes.js";
5
+
6
+ export interface StickyImageRetention {
7
+ images: { id: string; lastUsed: number }[];
8
+ protectedImages: Set<string>;
9
+ containers: string[];
10
+ observedAt: number;
11
+ }
12
+
13
+ /** Call before removing containers from the isolated publication clone.
14
+ * New/legacy images get a first observation; inherited unused images retain
15
+ * their previous timestamp. Rewriting from the inventory drops stale IDs.
16
+ */
17
+ export async function recordStickyImageUse(
18
+ root: string, run: CacheCommand, now = Date.now(),
19
+ ): Promise<StickyImageRetention> {
20
+ const path = `${root}/.spectest-image-use.json`;
21
+ const saved = await readFile(path, "utf8").catch((error: NodeJS.ErrnoException) => {
22
+ if (error.code === "ENOENT") return undefined;
23
+ throw error;
24
+ });
25
+ const previous = saved === undefined ? { schema: 1, images: {} } : JSON.parse(saved);
26
+ if (previous.schema !== 1 || !previous.images || typeof previous.images !== "object" ||
27
+ Array.isArray(previous.images) || Object.values(previous.images).some(value =>
28
+ typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)) {
29
+ throw new Error("invalid sticky image-use ledger");
30
+ }
31
+ const ids = [...new Set((await run("docker", ["image", "ls", "-aq", "--no-trunc"]))
32
+ .split(/\s+/).filter(Boolean))];
33
+ const containers = (await run("docker", ["ps", "-aq"])).split(/\s+/).filter(Boolean);
34
+ const protectedImages = new Set(containers.length
35
+ ? (await run("docker", ["container", "inspect", "--format", "{{.Image}}", ...containers]))
36
+ .split(/\s+/).filter(Boolean) : []);
37
+ const images = ids.map(id => ({ id, lastUsed: protectedImages.has(id) ? now : previous.images[id] ?? now }));
38
+ const temporary = `${path}.${randomUUID()}.tmp`;
39
+ await writeFile(temporary, JSON.stringify({ schema: 1,
40
+ images: Object.fromEntries(images.map(({ id, lastUsed }) => [id, lastUsed])) }));
41
+ await rename(temporary, path);
42
+ return { images, protectedImages, containers, observedAt: now };
43
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { InjectRule, SignConfig } from "./fake-upstream.js";
2
+ export type { InjectRule, InjectMatch, StringMatch, SignConfig, AwsSigV4Sign } from "./fake-upstream.js";
1
3
  export type { Phone, PhoneOptions, CallOptions, SpeechOptions } from "./phone.js";
2
4
  import type { Phone, PhoneOptions } from "./phone.js";
3
5
  import type { Android, AndroidApp } from "./android.js";
@@ -2140,6 +2142,15 @@ export interface TestSuite<
2140
2142
  // Fakes — in-daemon HTTP servers that masquerade as external APIs.
2141
2143
  // ──────────────────────────────────────────────────────────────────────────
2142
2144
 
2145
+ /** Forward the entire fake to real HTTPS/WSS upstreams in interactive environments.
2146
+ * Tests and shared baselines always use the scripted handlers. No traffic is recorded. */
2147
+ export interface FakeLiveOptions {
2148
+ /** Same header injection rules as replayFake, applied to HTTP and WebSocket handshakes. */
2149
+ inject?: InjectRule[];
2150
+ /** Re-sign HTTP requests using brokered AWS credentials. Not supported for WebSockets. */
2151
+ sign?: SignConfig;
2152
+ }
2153
+
2143
2154
  /**
2144
2155
  * A fake server hosted in the harness. Use this to stand in for
2145
2156
  * external HTTP APIs (auth providers, payment gateways, …) that you can't
@@ -2177,6 +2188,10 @@ export interface FakeDefinition<
2177
2188
  * other fakes.
2178
2189
  */
2179
2190
  hostnames: readonly string[];
2191
+ /** Opt in to forwarding all HTTP/WebSocket traffic to the declared real hosts
2192
+ * over HTTPS/WSS (port 443) in interactive environments. Errors never fall back
2193
+ * to scripted handlers. Omit to keep the fake scripted in every environment. */
2194
+ live?: FakeLiveOptions;
2180
2195
  /** TCP port the fake listens on. Default `80`. */
2181
2196
  port?: number;
2182
2197
  /** Preserve handler/interceptor CORS policy by default. "permissive"
@@ -2231,7 +2246,7 @@ export interface FakeDefinition<
2231
2246
  */
2232
2247
  helpers?: (args: { name: string; state: S; ctx: FakeContext }) => H | Promise<H>;
2233
2248
  /**
2234
- * Internal. Platform secret references this fake needs at *record* time
2249
+ * Internal. Platform secret references this fake needs for upstream access
2235
2250
  * (set by {@link replayFake}). The daemon reports the union of these to
2236
2251
  * the control plane, which resolves each via its `SecretResolver` and
2237
2252
  * supplies them for the environment lifetime, including snapshots. Values
@@ -2270,6 +2285,9 @@ export function defineFake<
2270
2285
  );
2271
2286
  }
2272
2287
  }
2288
+ if (opts.live !== undefined && (!opts.live || typeof opts.live !== "object" || Array.isArray(opts.live))) {
2289
+ throw new Error(`defineFake(${opts.name}): \`live\` must be an options object`);
2290
+ }
2273
2291
  validateCorsMode(opts.cors);
2274
2292
  if (typeof opts.handler !== "function") {
2275
2293
  throw new Error(`defineFake(${opts.name}): \`handler\` is required`);
@@ -1,9 +1,9 @@
1
- // Environment-lifetime credentials for record-mode fakes. The control plane
1
+ // Environment-lifetime credentials for live and record-mode fakes. The control plane
2
2
  // resolves declared references from the owning project's Secrets store before
3
3
  // service startup and refreshes them on warm starts and interactive forks.
4
4
  // They persist across evals, background requests and memory snapshots, just
5
5
  // like other environment state. Test forks inherit their parent's credentials;
6
- // replay mode still uses cassettes and never forwards upstream.
6
+ // test mode still uses scripted handlers or cassettes and never forwards upstream.
7
7
  // Values stay out of project files, config hashes and recorded cassettes.
8
8
 
9
9
  const SECRETS = new Map<string, string>();
@@ -14,7 +14,7 @@ export function setRecordSecrets(secrets: Record<string, string>): void {
14
14
  for (const [ref, value] of Object.entries(secrets)) SECRETS.set(ref, value);
15
15
  }
16
16
 
17
- /** Resolve a declared project secret for an outbound recording request. */
17
+ /** Resolve a declared project secret for an outbound live or recording request. */
18
18
  export function getRecordSecret(ref: string): string | undefined {
19
19
  return SECRETS.get(ref);
20
20
  }
@@ -1,6 +1,6 @@
1
1
  // Environment state: travels with memory snapshots, independently of the
2
- // per-case event recorder. Shared/warm baselines must never record upstream.
3
- // The control plane enables recording only on an interactive environment,
2
+ // per-case event recorder. Shared/warm baselines must never forward upstream.
3
+ // The control plane enables upstream access only on an interactive environment,
4
4
  // after capturing/restoring the shared baseline and before interactive setup.
5
5
  let mode: "test" | "interactive" = "test";
6
6
 
@@ -12,3 +12,8 @@ export function resolveReplayMode(requested: "auto" | "record" | "replay" = "aut
12
12
  // Even an explicit record option cannot send test traffic to real providers.
13
13
  return mode === "test" || requested === "replay" ? "replay" : "record";
14
14
  }
15
+
16
+ /** Shared gate for live and replay fakes. Credentials never enable upstream access. */
17
+ export function isInteractiveEnvironment(): boolean {
18
+ return mode === "interactive";
19
+ }