@specific.dev/spectest 0.38.0 → 0.41.0

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 (103) hide show
  1. package/dist/components/k3s.js +1 -24
  2. package/dist/components/supabase.d.ts +87 -27
  3. package/dist/components/supabase.js +352 -69
  4. package/dist/daemon.d.ts +38 -0
  5. package/dist/daemon.js +405 -946
  6. package/dist/harness/build-context.d.ts +82 -0
  7. package/dist/harness/build-context.js +113 -0
  8. package/dist/harness/buildkit-progress.d.ts +37 -0
  9. package/dist/harness/buildkit-progress.js +66 -0
  10. package/dist/harness/container-run.d.ts +89 -0
  11. package/dist/harness/container-run.js +118 -0
  12. package/dist/harness/file-mounts.d.ts +91 -0
  13. package/dist/harness/file-mounts.js +119 -0
  14. package/dist/harness/hostmatch.d.ts +65 -0
  15. package/dist/harness/hostmatch.js +108 -0
  16. package/dist/harness/http-proxy.d.ts +62 -0
  17. package/dist/harness/http-proxy.js +104 -0
  18. package/dist/harness/ingress-table.d.ts +148 -0
  19. package/dist/harness/ingress-table.js +129 -0
  20. package/dist/harness/log-delta.d.ts +54 -0
  21. package/dist/harness/log-delta.js +83 -0
  22. package/dist/harness/main.d.ts +47 -0
  23. package/dist/harness/main.js +164 -0
  24. package/dist/harness/methods.d.ts +54 -0
  25. package/dist/harness/methods.js +65 -0
  26. package/dist/harness/names-registry.d.ts +63 -0
  27. package/dist/harness/names-registry.js +90 -0
  28. package/dist/harness/protocol.d.ts +88 -0
  29. package/dist/harness/protocol.js +96 -0
  30. package/dist/harness/ready-poll.d.ts +47 -0
  31. package/dist/harness/ready-poll.js +67 -0
  32. package/dist/harness/service-graph.d.ts +29 -0
  33. package/dist/harness/service-graph.js +92 -0
  34. package/dist/harness/volume-paths.d.ts +70 -0
  35. package/dist/harness/volume-paths.js +81 -0
  36. package/dist/index.d.ts +3 -3
  37. package/dist/ingress.d.ts +1 -1
  38. package/dist/inspect.d.ts +23 -0
  39. package/dist/inspect.js +65 -0
  40. package/dist/resolver.js +5 -8
  41. package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
  42. package/dist/vendor/rrweb-record.min.js +5061 -0
  43. package/package.json +7 -1
  44. package/src/aws-sigv4.ts +218 -0
  45. package/src/browser.ts +2040 -0
  46. package/src/components/aws.ts +554 -0
  47. package/src/components/email.ts +398 -0
  48. package/src/components/expo.ts +167 -0
  49. package/src/components/index.ts +81 -0
  50. package/src/components/k3s.ts +2061 -0
  51. package/src/components/postgres.ts +132 -0
  52. package/src/components/replayFake.ts +1015 -0
  53. package/src/components/s3.ts +132 -0
  54. package/src/components/supabase.ts +1699 -0
  55. package/src/daemon.ts +5489 -0
  56. package/src/harness/build-context.test.ts +0 -0
  57. package/src/harness/build-context.ts +146 -0
  58. package/src/harness/buildkit-progress.test.ts +98 -0
  59. package/src/harness/buildkit-progress.ts +74 -0
  60. package/src/harness/container-run.test.ts +209 -0
  61. package/src/harness/container-run.ts +158 -0
  62. package/src/harness/file-mounts.test.ts +185 -0
  63. package/src/harness/file-mounts.ts +145 -0
  64. package/src/harness/hostmatch.test.ts +148 -0
  65. package/src/harness/hostmatch.ts +109 -0
  66. package/src/harness/http-proxy.test.ts +156 -0
  67. package/src/harness/http-proxy.ts +119 -0
  68. package/src/harness/ingress-rebind.test.ts +125 -0
  69. package/src/harness/ingress-table.test.ts +172 -0
  70. package/src/harness/ingress-table.ts +186 -0
  71. package/src/harness/log-delta.test.ts +125 -0
  72. package/src/harness/log-delta.ts +100 -0
  73. package/src/harness/main.test.ts +211 -0
  74. package/src/harness/main.ts +196 -0
  75. package/src/harness/methods.test.ts +63 -0
  76. package/src/harness/methods.ts +92 -0
  77. package/src/harness/names-registry.test.ts +137 -0
  78. package/src/harness/names-registry.ts +108 -0
  79. package/src/harness/protocol.test.ts +148 -0
  80. package/src/harness/protocol.ts +163 -0
  81. package/src/harness/ready-poll.test.ts +172 -0
  82. package/src/harness/ready-poll.ts +93 -0
  83. package/src/harness/service-graph.test.ts +97 -0
  84. package/src/harness/service-graph.ts +97 -0
  85. package/src/harness/volume-paths.test.ts +102 -0
  86. package/src/harness/volume-paths.ts +112 -0
  87. package/src/ids.ts +89 -0
  88. package/src/index.ts +2725 -0
  89. package/src/ingress.ts +305 -0
  90. package/src/inspect.ts +739 -0
  91. package/src/locator.ts +716 -0
  92. package/src/mobile.ts +133 -0
  93. package/src/record-secrets.ts +41 -0
  94. package/src/recorder.ts +846 -0
  95. package/src/redis.ts +202 -0
  96. package/src/replay-bundle.ts +108 -0
  97. package/src/resolver.ts +348 -0
  98. package/src/s3.ts +333 -0
  99. package/src/sql.ts +243 -0
  100. package/src/terminal.ts +740 -0
  101. package/src/url-match.ts +67 -0
  102. package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
  103. package/src/vendor/rrweb-record.min.js +5061 -0
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The `dependsOn` graph: validation, and the traversal order bring-up
3
+ * uses.
4
+ *
5
+ * Ported out of `daemon.ts`. Validation runs before anything starts, so
6
+ * the DAG runner can assume a clean graph — an unknown dependency or a
7
+ * cycle must be a clear error at load, not a container that never becomes
8
+ * ready and eventually times out with no explanation.
9
+ */
10
+
11
+ export interface GraphNode {
12
+ name: string;
13
+ dependsOn?: readonly string[];
14
+ }
15
+
16
+ /**
17
+ * Check the graph and return the name→node map used to walk it.
18
+ *
19
+ * Rejects a dependency on a service that doesn't exist, and any cycle.
20
+ */
21
+ export function validateServiceGraph<T extends GraphNode>(services: readonly T[]): Map<string, T> {
22
+ const byName = new Map(services.map((s) => [s.name, s]));
23
+
24
+ // A duplicate name would silently shadow one definition and make the
25
+ // graph lie about what is running. The old code built the map without
26
+ // checking, so the second service simply replaced the first.
27
+ if (byName.size !== services.length) {
28
+ const seen = new Set<string>();
29
+ const dupe = services.find((s) => !seen.add(s.name))?.name;
30
+ throw new Error(`duplicate service name ${JSON.stringify(dupe)}`);
31
+ }
32
+
33
+ for (const s of services) {
34
+ for (const d of s.dependsOn ?? []) {
35
+ if (!byName.has(d)) {
36
+ throw new Error(`service ${s.name} depends on unknown service ${d}`);
37
+ }
38
+ if (d === s.name) {
39
+ throw new Error(`service ${s.name} depends on itself`);
40
+ }
41
+ }
42
+ }
43
+
44
+ // Cycle detection by DFS colouring: white = unseen, gray = on the
45
+ // current stack (so meeting gray again is a cycle), black = finished.
46
+ const WHITE = 0;
47
+ const GRAY = 1;
48
+ const BLACK = 2;
49
+ const color = new Map<string, number>(services.map((s) => [s.name, WHITE]));
50
+ const stack: string[] = [];
51
+ const visit = (name: string): void => {
52
+ color.set(name, GRAY);
53
+ stack.push(name);
54
+ for (const d of byName.get(name)!.dependsOn ?? []) {
55
+ const c = color.get(d);
56
+ if (c === GRAY) {
57
+ // Naming the cycle matters: "service dependency cycle" alone
58
+ // leaves the user to find it by eye in a large services map.
59
+ const from = stack.indexOf(d);
60
+ const loop = [...stack.slice(from), d].join(" → ");
61
+ throw new Error(`service dependency cycle: ${loop}`);
62
+ }
63
+ if (c === WHITE) visit(d);
64
+ }
65
+ stack.pop();
66
+ color.set(name, BLACK);
67
+ };
68
+ for (const s of services) if (color.get(s.name) === WHITE) visit(s.name);
69
+
70
+ return byName;
71
+ }
72
+
73
+ /**
74
+ * Services in an order where every node follows its dependencies.
75
+ *
76
+ * Bring-up itself does **not** walk levels — each service starts the
77
+ * moment its own dependencies are ready, so an unrelated slow probe never
78
+ * holds back a branch that is ready. This ordering is for the places that
79
+ * genuinely need a sequence (teardown, reporting), and for asserting the
80
+ * graph is walkable at all.
81
+ */
82
+ export function topologicalOrder<T extends GraphNode>(services: readonly T[]): T[] {
83
+ const byName = validateServiceGraph(services);
84
+ const out: T[] = [];
85
+ const done = new Set<string>();
86
+ const visit = (name: string): void => {
87
+ if (done.has(name)) return;
88
+ const node = byName.get(name)!;
89
+ for (const d of node.dependsOn ?? []) visit(d);
90
+ done.add(name);
91
+ out.push(node);
92
+ };
93
+ // Declaration order among independent services, so the result is stable
94
+ // rather than dependent on Map iteration incidentals.
95
+ for (const s of services) visit(s.name);
96
+ return out;
97
+ }
@@ -0,0 +1,102 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ CACHE_ROOT,
5
+ resolveHostPath,
6
+ sanitizeSegment,
7
+ survivesTeardown,
8
+ } from "./volume-paths";
9
+
10
+ const WS = "/workspace";
11
+
12
+ describe("sanitizeSegment", () => {
13
+ test("strips leading slashes and replaces unsafe characters", () => {
14
+ expect(sanitizeSegment("/var/lib/postgresql/data")).toBe("var-lib-postgresql-data");
15
+ });
16
+
17
+ test("trims the dashes left at the edges", () => {
18
+ expect(sanitizeSegment("/data/")).toBe("data");
19
+ });
20
+
21
+ /** `.` is not in the allowed set, so `..` cannot survive — a target or a
22
+ * volume name can never escape its root. */
23
+ test("path traversal cannot survive", () => {
24
+ const out = sanitizeSegment("../../etc/shadow");
25
+ expect(out).not.toContain("..");
26
+ expect(out).not.toContain("/");
27
+ });
28
+
29
+ test("keeps letters, digits, underscore and dash", () => {
30
+ expect(sanitizeSegment("pg_data-01")).toBe("pg_data-01");
31
+ });
32
+ });
33
+
34
+ describe("resolveHostPath", () => {
35
+ test("an unnamed volume derives its directory from the target", () => {
36
+ expect(resolveHostPath("db", { target: "/var/lib/postgresql/data" }, WS)).toBe(
37
+ "/workspace/.spectest/volumes/db/var-lib-postgresql-data",
38
+ );
39
+ });
40
+
41
+ test("a relative source resolves under the service's directory", () => {
42
+ expect(resolveHostPath("db", { source: "pgdata", target: "/data" }, WS)).toBe(
43
+ "/workspace/.spectest/volumes/db/pgdata",
44
+ );
45
+ });
46
+
47
+ test("an absolute source is used verbatim", () => {
48
+ expect(resolveHostPath("db", { source: "/mnt/big", target: "/data" }, WS)).toBe("/mnt/big");
49
+ });
50
+
51
+ test("two services mounting the same name share one directory", () => {
52
+ const a = resolveHostPath("storage", { name: "media", target: "/x" }, WS);
53
+ const b = resolveHostPath("imgproxy", { name: "media", target: "/y" }, WS);
54
+ expect(a).toBe(b);
55
+ expect(a).toContain("_shared/media");
56
+ });
57
+
58
+ test("different names do not collide", () => {
59
+ const a = resolveHostPath("s", { name: "media", target: "/x" }, WS);
60
+ const b = resolveHostPath("s", { name: "other", target: "/x" }, WS);
61
+ expect(a).not.toBe(b);
62
+ });
63
+
64
+ test("the same target on two services stays separate", () => {
65
+ const a = resolveHostPath("one", { target: "/data" }, WS);
66
+ const b = resolveHostPath("two", { target: "/data" }, WS);
67
+ expect(a).not.toBe(b);
68
+ });
69
+ });
70
+
71
+ describe("cache volumes and delta teardown", () => {
72
+ /** The whole point of the flag: teardown does `rm -rf /workspace`, so a
73
+ * cache volume must be rooted outside it or it does not survive. */
74
+ test("a cache volume is rooted outside the workspace", () => {
75
+ const host = resolveHostPath("bun", { target: "/root/.bun/install/cache", cache: true }, WS);
76
+ expect(host.startsWith(CACHE_ROOT)).toBe(true);
77
+ expect(host.startsWith(WS)).toBe(false);
78
+ });
79
+
80
+ test("a non-cache volume is inside the workspace, so teardown wipes it", () => {
81
+ const host = resolveHostPath("db", { target: "/data" }, WS);
82
+ expect(host.startsWith(`${WS}/`)).toBe(true);
83
+ });
84
+
85
+ test("a shared volume honours the cache flag too", () => {
86
+ const cached = resolveHostPath("a", { name: "layers", target: "/x", cache: true }, WS);
87
+ const plain = resolveHostPath("a", { name: "layers", target: "/x" }, WS);
88
+ expect(cached.startsWith(CACHE_ROOT)).toBe(true);
89
+ expect(plain.startsWith(`${WS}/`)).toBe(true);
90
+ expect(cached).not.toBe(plain);
91
+ });
92
+
93
+ test("survivesTeardown agrees with where the path landed", () => {
94
+ expect(survivesTeardown({ target: "/data" }, "db", WS)).toBe(false);
95
+ expect(survivesTeardown({ target: "/data", cache: true }, "db", WS)).toBe(true);
96
+ // An absolute source outside /workspace also survives — worth knowing,
97
+ // because it is a way to keep state by accident.
98
+ expect(survivesTeardown({ source: "/mnt/big", target: "/d" }, "db", WS)).toBe(true);
99
+ // …but an absolute source inside /workspace does not.
100
+ expect(survivesTeardown({ source: "/workspace/keep", target: "/d" }, "db", WS)).toBe(false);
101
+ });
102
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Where a service's volume is backed on the VM's filesystem.
3
+ *
4
+ * Ported out of `daemon.ts`. Pure path arithmetic, but it decides
5
+ * something with real consequences: **which volumes survive a
6
+ * delta-restore teardown**.
7
+ *
8
+ * Teardown wipes `/workspace` to give a restored environment fresh-state
9
+ * semantics. Anything that must survive it therefore has to live outside
10
+ * `/workspace` — and that is exactly what `cache: true` selects, by
11
+ * rooting the directory under `/var/cache/spectest/volumes` instead.
12
+ *
13
+ * The flag is only ever correct for **content-addressed accelerator
14
+ * data**: package stores, layer caches — data whose presence can change
15
+ * how *fast* something runs but never *what* it does. It is wrong for any
16
+ * real state, because a restored environment would then start with a
17
+ * previous run's data and stop being reproducible. (Counter-example worth
18
+ * remembering: `k3s()` deliberately does not cache its containerd store —
19
+ * a fresh cluster over an un-cleanly-killed store wedged the apiserver.)
20
+ */
21
+
22
+ import path from "node:path";
23
+
24
+ /** Root of the per-environment state tree. Wiped by delta teardown. */
25
+ export const DEFAULT_WORKSPACE = "/workspace";
26
+
27
+ /** Root of the cache tree. Deliberately outside the workspace. */
28
+ export const CACHE_ROOT = "/var/cache/spectest/volumes";
29
+
30
+ /** Directory holding named shared volumes, under whichever root applies. */
31
+ export const SHARED_DIR = "_shared";
32
+
33
+ export interface VolumeSpec {
34
+ /** Named shared volume — every service mounting this name gets the same
35
+ * directory. Mutually exclusive with `source`. */
36
+ name?: string;
37
+ /** Host path. Absolute paths are used as-is; relative ones resolve under
38
+ * the service's own directory. */
39
+ source?: string;
40
+ /** Path inside the container. Used to derive a directory when neither
41
+ * `name` nor `source` is given. */
42
+ target: string;
43
+ /** Survive the delta-restore teardown. Content-addressed data only. */
44
+ cache?: boolean;
45
+ }
46
+
47
+ /**
48
+ * Make an arbitrary string safe as a single path segment.
49
+ *
50
+ * Strips leading slashes, replaces anything outside `[A-Za-z0-9_-]`, and
51
+ * trims the dashes that leaves at the edges. The character filter also
52
+ * removes `.`, so `..` cannot survive — a volume name or target can't
53
+ * escape its root.
54
+ */
55
+ export function sanitizeSegment(p: string): string {
56
+ return p
57
+ .replace(/^\/+/, "")
58
+ .replace(/[^A-Za-z0-9_-]/g, "-")
59
+ .replace(/^-+|-+$/g, "");
60
+ }
61
+
62
+ /**
63
+ * The host directory backing one volume mount.
64
+ *
65
+ * Resolution order:
66
+ * 1. `name` — a shared directory under `_shared`, so two services
67
+ * mounting the same name genuinely share one directory.
68
+ * 2. An **absolute** `source` — used verbatim. The project asked for a
69
+ * specific path, so it gets it.
70
+ * 3. A relative `source`, or nothing at all — under the service's own
71
+ * directory, derived from `target` when `source` is absent.
72
+ *
73
+ * `workspace` is a parameter rather than a module constant so the rule is
74
+ * testable without touching the filesystem.
75
+ */
76
+ export function resolveHostPath(
77
+ service: string,
78
+ vol: VolumeSpec,
79
+ workspace: string = DEFAULT_WORKSPACE,
80
+ ): string {
81
+ const stateRoot = [workspace, ".spectest", "volumes"];
82
+
83
+ if (vol.name) {
84
+ const root = vol.cache ? [CACHE_ROOT, SHARED_DIR] : [...stateRoot, SHARED_DIR];
85
+ return path.join(...root, sanitizeSegment(vol.name));
86
+ }
87
+
88
+ // An absolute source is the project's own path; `cache` doesn't apply
89
+ // because the location was already chosen explicitly.
90
+ if (vol.source && vol.source.startsWith("/")) return vol.source;
91
+
92
+ const root = vol.cache ? [CACHE_ROOT, service] : [...stateRoot, service];
93
+ if (vol.source) {
94
+ return path.join(...root, vol.source.replace(/^\/+/, ""));
95
+ }
96
+ return path.join(...root, sanitizeSegment(vol.target));
97
+ }
98
+
99
+ /**
100
+ * Does this volume survive a delta-restore teardown?
101
+ *
102
+ * True for cache-flagged volumes and for absolute sources outside the
103
+ * workspace — the two ways a directory ends up beyond `rm -rf /workspace`.
104
+ */
105
+ export function survivesTeardown(
106
+ vol: VolumeSpec,
107
+ service: string,
108
+ workspace: string = DEFAULT_WORKSPACE,
109
+ ): boolean {
110
+ const host = resolveHostPath(service, vol, workspace);
111
+ return !host.startsWith(`${workspace}/`) && host !== workspace;
112
+ }
package/src/ids.ts ADDED
@@ -0,0 +1,89 @@
1
+ // Stripe-style resource ids minted in-VM — format-compatible with the
2
+ // control plane's `crates/control-plane/src/ids.rs` (`generate`):
3
+ // `<prefix>_0<20 chars>` where the chars are Crockford base32 (lowercase,
4
+ // minus the ambiguous i/l/o/u) and the `0` after the underscore is the
5
+ // format-version digit. If the scheme changes there, change it here in
6
+ // lockstep (`env.rs::valid_artifact_id` checks this exact shape at the
7
+ // trust boundary).
8
+ //
9
+ // LANDMINE — why the entropy here is NOT just `crypto.getRandomValues`:
10
+ // Bun serves those from a userspace pool that is FROZEN into snapshots, so
11
+ // sibling forks of one snapshot draw identical bytes (verified 2026-07-14:
12
+ // two forks' first screenshot() minted the same id). We therefore hash
13
+ // together, per mint:
14
+ // - 16 bytes read straight off /dev/urandom (bypasses Bun's pool; the
15
+ // guest kernel CRNG reseeds across restores — see the VM-determinism
16
+ // notes — so forks diverge),
17
+ // - Date.now() (wall clock is re-synced per post-mortem fork start) and
18
+ // performance.now() (sub-ms), which de-collide even if the kernel pool
19
+ // hasn't diverged yet,
20
+ // - a process-local counter (multiple mints in one tick).
21
+ // This makes duplicates practically impossible, but the control plane
22
+ // still treats an id here as untrusted: shape-validated, and a duplicate
23
+ // insert is flagged per-artifact rather than clobbering anything.
24
+
25
+ import { createHash } from "node:crypto";
26
+ import { closeSync, openSync, readSync } from "node:fs";
27
+
28
+ /** Crockford base32, lowercased, minus `i`/`l`/`o`/`u` — mirrors ids.rs. */
29
+ const ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
30
+
31
+ /** Random base32 chars after the version digit (100 bits of entropy). */
32
+ const RANDOM_LEN = 20;
33
+
34
+ let mintCounter = 0;
35
+
36
+ /** Mint a fresh id, e.g. `generateId("art")` → `art_0c3k9mpq…`. */
37
+ export function generateId(prefix: string): string {
38
+ const digest = createHash("sha256")
39
+ .update(urandom(16))
40
+ .update(String(Date.now()))
41
+ .update(String(performance.now()))
42
+ .update(String(mintCounter++))
43
+ .digest();
44
+ return `${prefix}_0${base32Crockford(digest.subarray(0, 16)).slice(0, RANDOM_LEN)}`;
45
+ }
46
+
47
+ /** Read `n` bytes directly from /dev/urandom (NOT Bun's cached pool). */
48
+ function urandom(n: number): Uint8Array {
49
+ const buf = new Uint8Array(n);
50
+ try {
51
+ const fd = openSync("/dev/urandom", "r");
52
+ try {
53
+ let off = 0;
54
+ while (off < n) {
55
+ const read = readSync(fd, buf, off, n - off, null);
56
+ if (read <= 0) break;
57
+ off += read;
58
+ }
59
+ } finally {
60
+ closeSync(fd);
61
+ }
62
+ } catch {
63
+ // Non-Linux host (SDK types compiled locally) or exotic failure — the
64
+ // clock + counter inputs still make the hash unique in practice.
65
+ crypto.getRandomValues(buf);
66
+ }
67
+ return buf;
68
+ }
69
+
70
+ /** Encode bytes as Crockford base32 (no padding). 16 bytes → 26 chars. */
71
+ function base32Crockford(bytes: Uint8Array): string {
72
+ let out = "";
73
+ let acc = 0;
74
+ let bits = 0;
75
+ for (const b of bytes) {
76
+ acc = (acc << 8) | b;
77
+ bits += 8;
78
+ while (bits >= 5) {
79
+ bits -= 5;
80
+ out += ALPHABET[(acc >> bits) & 0x1f];
81
+ }
82
+ // Keep the accumulator within 32-bit safe range for the `<<` operator.
83
+ acc &= (1 << bits) - 1;
84
+ }
85
+ if (bits > 0) {
86
+ out += ALPHABET[(acc << (5 - bits)) & 0x1f];
87
+ }
88
+ return out;
89
+ }