@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.
- package/README.md +44 -0
- package/dist/components/replay-websocket.d.ts +2 -3
- package/dist/components/replay-websocket.js +19 -185
- package/dist/components/replayFake.d.ts +2 -53
- package/dist/components/replayFake.js +7 -348
- package/dist/daemon.js +4 -1
- package/dist/fake-upstream-websocket.d.ts +27 -0
- package/dist/fake-upstream-websocket.js +184 -0
- package/dist/fake-upstream.d.ts +114 -0
- package/dist/fake-upstream.js +348 -0
- package/dist/harness/fake-websocket.js +6 -3
- package/dist/harness/live-fake.d.ts +3 -0
- package/dist/harness/live-fake.js +66 -0
- package/dist/harness/sticky-consumer.d.ts +11 -3
- package/dist/harness/sticky-consumer.js +61 -38
- package/dist/harness/sticky-image-retention.d.ts +15 -0
- package/dist/harness/sticky-image-retention.js +32 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.js +3 -0
- package/dist/record-secrets.d.ts +1 -1
- package/dist/record-secrets.js +3 -3
- package/dist/replay-mode.d.ts +2 -0
- package/dist/replay-mode.js +6 -2
- package/package.json +1 -1
- package/src/components/replay-websocket.ts +25 -145
- package/src/components/replayFake.ts +8 -459
- package/src/daemon.ts +3 -1
- package/src/fake-upstream-websocket.ts +157 -0
- package/src/fake-upstream.ts +465 -0
- package/src/harness/fake-websocket.ts +6 -3
- package/src/harness/live-fake.test.ts +227 -0
- package/src/harness/live-fake.ts +62 -0
- package/src/harness/sticky-consumer.test.ts +93 -23
- package/src/harness/sticky-consumer.ts +62 -40
- package/src/harness/sticky-image-retention.test.ts +59 -0
- package/src/harness/sticky-image-retention.ts +43 -0
- package/src/index.ts +19 -1
- package/src/record-secrets.ts +3 -3
- 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
|
|
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`);
|
package/src/record-secrets.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
}
|
package/src/replay-mode.ts
CHANGED
|
@@ -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
|
|
3
|
-
// The control plane enables
|
|
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
|
+
}
|