@specific.dev/spectest 0.39.0 → 0.43.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 (105) hide show
  1. package/dist/browser.d.ts +21 -8
  2. package/dist/browser.js +78 -36
  3. package/dist/components/supabase.d.ts +87 -27
  4. package/dist/components/supabase.js +352 -69
  5. package/dist/daemon.d.ts +38 -0
  6. package/dist/daemon.js +464 -987
  7. package/dist/harness/build-context.d.ts +82 -0
  8. package/dist/harness/build-context.js +113 -0
  9. package/dist/harness/buildkit-progress.d.ts +37 -0
  10. package/dist/harness/buildkit-progress.js +66 -0
  11. package/dist/harness/container-run.d.ts +89 -0
  12. package/dist/harness/container-run.js +118 -0
  13. package/dist/harness/file-mounts.d.ts +91 -0
  14. package/dist/harness/file-mounts.js +119 -0
  15. package/dist/harness/hostmatch.d.ts +65 -0
  16. package/dist/harness/hostmatch.js +108 -0
  17. package/dist/harness/http-proxy.d.ts +62 -0
  18. package/dist/harness/http-proxy.js +104 -0
  19. package/dist/harness/ingress-table.d.ts +148 -0
  20. package/dist/harness/ingress-table.js +129 -0
  21. package/dist/harness/log-delta.d.ts +54 -0
  22. package/dist/harness/log-delta.js +83 -0
  23. package/dist/harness/main.d.ts +47 -0
  24. package/dist/harness/main.js +164 -0
  25. package/dist/harness/methods.d.ts +54 -0
  26. package/dist/harness/methods.js +65 -0
  27. package/dist/harness/names-registry.d.ts +63 -0
  28. package/dist/harness/names-registry.js +90 -0
  29. package/dist/harness/protocol.d.ts +88 -0
  30. package/dist/harness/protocol.js +96 -0
  31. package/dist/harness/ready-poll.d.ts +47 -0
  32. package/dist/harness/ready-poll.js +67 -0
  33. package/dist/harness/service-graph.d.ts +29 -0
  34. package/dist/harness/service-graph.js +92 -0
  35. package/dist/harness/volume-paths.d.ts +70 -0
  36. package/dist/harness/volume-paths.js +81 -0
  37. package/dist/index.d.ts +58 -16
  38. package/dist/ingress.d.ts +1 -1
  39. package/dist/mobile.d.ts +9 -5
  40. package/dist/mobile.js +7 -6
  41. package/dist/recorder.d.ts +10 -0
  42. package/dist/resolver.js +5 -8
  43. package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
  44. package/dist/vendor/rrweb-record.min.js +5061 -0
  45. package/package.json +7 -1
  46. package/src/aws-sigv4.ts +218 -0
  47. package/src/browser.ts +2095 -0
  48. package/src/components/aws.ts +554 -0
  49. package/src/components/email.ts +398 -0
  50. package/src/components/expo.ts +167 -0
  51. package/src/components/index.ts +81 -0
  52. package/src/components/k3s.ts +2061 -0
  53. package/src/components/postgres.ts +132 -0
  54. package/src/components/replayFake.ts +1015 -0
  55. package/src/components/s3.ts +132 -0
  56. package/src/components/supabase.ts +1699 -0
  57. package/src/daemon.ts +5537 -0
  58. package/src/harness/build-context.test.ts +0 -0
  59. package/src/harness/build-context.ts +146 -0
  60. package/src/harness/buildkit-progress.test.ts +98 -0
  61. package/src/harness/buildkit-progress.ts +74 -0
  62. package/src/harness/container-run.test.ts +209 -0
  63. package/src/harness/container-run.ts +158 -0
  64. package/src/harness/file-mounts.test.ts +185 -0
  65. package/src/harness/file-mounts.ts +145 -0
  66. package/src/harness/hostmatch.test.ts +148 -0
  67. package/src/harness/hostmatch.ts +109 -0
  68. package/src/harness/http-proxy.test.ts +156 -0
  69. package/src/harness/http-proxy.ts +119 -0
  70. package/src/harness/ingress-rebind.test.ts +125 -0
  71. package/src/harness/ingress-table.test.ts +172 -0
  72. package/src/harness/ingress-table.ts +186 -0
  73. package/src/harness/log-delta.test.ts +125 -0
  74. package/src/harness/log-delta.ts +100 -0
  75. package/src/harness/main.test.ts +211 -0
  76. package/src/harness/main.ts +196 -0
  77. package/src/harness/methods.test.ts +63 -0
  78. package/src/harness/methods.ts +92 -0
  79. package/src/harness/names-registry.test.ts +137 -0
  80. package/src/harness/names-registry.ts +108 -0
  81. package/src/harness/protocol.test.ts +148 -0
  82. package/src/harness/protocol.ts +163 -0
  83. package/src/harness/ready-poll.test.ts +172 -0
  84. package/src/harness/ready-poll.ts +93 -0
  85. package/src/harness/service-graph.test.ts +97 -0
  86. package/src/harness/service-graph.ts +97 -0
  87. package/src/harness/volume-paths.test.ts +102 -0
  88. package/src/harness/volume-paths.ts +112 -0
  89. package/src/ids.ts +89 -0
  90. package/src/index.ts +2767 -0
  91. package/src/ingress.ts +305 -0
  92. package/src/inspect.ts +739 -0
  93. package/src/locator.ts +716 -0
  94. package/src/mobile.ts +138 -0
  95. package/src/record-secrets.ts +41 -0
  96. package/src/recorder.ts +856 -0
  97. package/src/redis.ts +202 -0
  98. package/src/replay-bundle.ts +108 -0
  99. package/src/resolver.ts +348 -0
  100. package/src/s3.ts +333 -0
  101. package/src/sql.ts +243 -0
  102. package/src/terminal.ts +740 -0
  103. package/src/url-match.ts +67 -0
  104. package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
  105. package/src/vendor/rrweb-record.min.js +5061 -0
@@ -0,0 +1,129 @@
1
+ /**
2
+ * The live ingress tables: which hostname reaches which upstream, and
3
+ * which certificate serves the TLS handshake for it.
4
+ *
5
+ * Ported out of `daemon.ts` as part of the harness split. This module owns
6
+ * the *tables and the decisions*; the listeners themselves (`Bun.serve`)
7
+ * stay with the process that binds them, because a socket is not something
8
+ * you can hand across a module boundary usefully.
9
+ *
10
+ * ## Why this state is special
11
+ *
12
+ * These tables are ordinary module-scope objects, and that is the whole
13
+ * design. They live in the harness process, so they are captured by the
14
+ * memory snapshot and **fork with it** — exactly like fake state and the
15
+ * names registry. A `dependsOn` child inherits every route its parent
16
+ * bound; a sibling forked from an earlier snapshot never sees them. That
17
+ * is what lets a fake provision a real service mid-test and hand the app a
18
+ * CA-trusted `https://…` endpoint that only that branch of the test DAG
19
+ * can reach.
20
+ *
21
+ * The failure mode to design against is therefore *silence*: a route that
22
+ * fails to survive doesn't raise anything, it just isn't inherited, and
23
+ * the test that depended on it fails somewhere else entirely.
24
+ *
25
+ * ## The two rules worth stating
26
+ *
27
+ * **A route table is identified by its object, not its contents.** Every
28
+ * listener's request handler closes over the `Map` it was bound with, so
29
+ * adding an entry takes effect with no rebind — that is what makes a
30
+ * runtime route possible at all. Replacing the `Map` (rather than mutating
31
+ * it) silently orphans every listener still holding the old one, and the
32
+ * routes added afterwards go nowhere. {@link routesFor} is the only way to
33
+ * reach a table so that this can't be done by accident.
34
+ *
35
+ * **A rebind is not free, so it must be earned.** Bun fixes a server's TLS
36
+ * config at `Bun.serve` time — `reload` will not add an SNI entry — so a
37
+ * genuinely new certificate means stopping and re-serving :443. It's cheap
38
+ * (~1 ms) but it is a real interruption of live traffic, and a hostname
39
+ * already covered by an existing exact or wildcard cert needs nothing but a
40
+ * route entry. {@link planBind} is that judgement, separated from the
41
+ * mutation so it can be tested without a network stack.
42
+ */
43
+ import { certCovers, isWildcard, wildcardSuffix } from "./hostmatch";
44
+ /** Fixed HTTPS port shared by every TLS route (fakes + service `tls`). */
45
+ export const INGRESS_HTTPS_PORT = 443;
46
+ /** Fixed HTTP port, always bound alongside :443 so both schemes work. */
47
+ export const INGRESS_HTTP_PORT = 80;
48
+ export function emptyTables() {
49
+ return { routesByPort: new Map(), certByHost: new Map() };
50
+ }
51
+ /**
52
+ * The route table for `port`, creating it if absent.
53
+ *
54
+ * Always returns the *same* object for a given port for as long as the
55
+ * tables live. Callers must mutate what they get back and must never swap
56
+ * in a replacement — see the module header for what that breaks.
57
+ */
58
+ export function routesFor(tables, port) {
59
+ let routes = tables.routesByPort.get(port);
60
+ if (!routes) {
61
+ routes = new Map();
62
+ tables.routesByPort.set(port, routes);
63
+ }
64
+ return routes;
65
+ }
66
+ /**
67
+ * Decide what binding `hostname` for TLS costs.
68
+ *
69
+ * A rebind is needed when a new certificate is going into the SNI table,
70
+ * or when :443 isn't listening yet. It is deliberately *not* needed for a
71
+ * hostname an existing wildcard already covers — the common case when a
72
+ * component claims a whole domain up front and services appear under it
73
+ * later, and the reason a fake can mint endpoints in a loop without
74
+ * restarting the listener once per iteration.
75
+ */
76
+ export function planBind(tables, hostname, opts) {
77
+ const needsCert = !certCovers(tables.certByHost.keys(), hostname);
78
+ return { needsCert, needsHttpsRebind: needsCert || !opts.httpsListening };
79
+ }
80
+ /**
81
+ * Point `hostname` at `route` on both :80 and :443.
82
+ *
83
+ * Boot `tls` serves both schemes, and runtime `tls` matches it — a service
84
+ * reachable only over https would differ from its boot-time twin in a way
85
+ * nothing declares.
86
+ */
87
+ export function bindRoute(tables, hostname, route) {
88
+ routesFor(tables, INGRESS_HTTP_PORT).set(hostname, route);
89
+ routesFor(tables, INGRESS_HTTPS_PORT).set(hostname, route);
90
+ }
91
+ /**
92
+ * Drop `hostname`'s routes, so it 404s.
93
+ *
94
+ * The certificate is deliberately left in the SNI table: it is harmless
95
+ * without a route, and removing it would force an otherwise unnecessary
96
+ * :443 rebind at exactly the moment a service is going away.
97
+ */
98
+ export function unbindRoute(tables, hostname) {
99
+ tables.routesByPort.get(INGRESS_HTTP_PORT)?.delete(hostname);
100
+ tables.routesByPort.get(INGRESS_HTTPS_PORT)?.delete(hostname);
101
+ }
102
+ /** The SNI entries for `Bun.serve`'s TLS config. */
103
+ export function certEntries(tables) {
104
+ return [...tables.certByHost].map(([serverName, leaf]) => ({
105
+ cert: leaf.cert,
106
+ key: leaf.key,
107
+ serverName,
108
+ }));
109
+ }
110
+ /** Clear every table — used between `/load` calls so a new project's
111
+ * routes bind against a clean slate rather than the old project's. */
112
+ export function clearTables(tables) {
113
+ tables.routesByPort.clear();
114
+ tables.certByHost.clear();
115
+ }
116
+ /**
117
+ * Point a hostname at an IP in a names registry document, choosing the
118
+ * exact or wildcard table by the hostname's shape.
119
+ *
120
+ * A wildcard has no `--add-host` or `--network-alias` equivalent, so the
121
+ * resolver's suffix table is the only place it can exist; an exact name
122
+ * could live in either, and goes in the exact table so it keeps winning
123
+ * over any wildcard that also matches.
124
+ */
125
+ export function registryTarget(hostname) {
126
+ return isWildcard(hostname)
127
+ ? { wildcard: true, suffix: wildcardSuffix(hostname) }
128
+ : { wildcard: false, host: hostname };
129
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Per-case service-log deltas.
3
+ *
4
+ * A container's log is cumulative, but the dashboard shows each test case
5
+ * only the lines *it* produced — the branch's full log is reconstructed by
6
+ * concatenating the deltas along the `dependsOn` chain. So every capture
7
+ * records how far it read (a line marker), and the next capture returns
8
+ * only what came after.
9
+ *
10
+ * The markers live in harness process memory, which means they fork with
11
+ * the snapshot exactly like fake state: a `dependsOn` child continues from
12
+ * where its parent stopped, and a sibling forked from an earlier snapshot
13
+ * sees the parent's lines again — which is correct, because for that
14
+ * sibling they *are* new.
15
+ *
16
+ * Ported out of `daemon.ts`; pure, so the boundary cases below are
17
+ * testable rather than inferred.
18
+ */
19
+ /** Cap on one delta. Beyond this the middle is elided, not the tail. */
20
+ export declare const LOG_DELTA_MAX_BYTES: number;
21
+ /**
22
+ * Truncate from the **middle**, keeping both ends.
23
+ *
24
+ * Deliberately not a tail cap: the head of a service log carries the
25
+ * startup banner (bind address, version, config errors) and the tail
26
+ * carries whatever just failed. Dropping either loses the half that
27
+ * usually explains the problem.
28
+ */
29
+ export declare function capMiddle(s: string, max: number): {
30
+ value: string;
31
+ truncated: boolean;
32
+ };
33
+ export interface StreamDelta {
34
+ /** The new lines, possibly middle-elided. */
35
+ delta: string;
36
+ /** Line count after this capture — the next call's marker. */
37
+ total: number;
38
+ /**
39
+ * The stream got *shorter* than the marker, so the container was
40
+ * restarted or its log rotated. The delta is then the whole log rather
41
+ * than a suffix, because line N is no longer the line N we saw.
42
+ */
43
+ reset: boolean;
44
+ truncated: boolean;
45
+ }
46
+ /**
47
+ * The portion of `full` after line `marker`.
48
+ *
49
+ * Only **complete** lines are returned. A partial trailing line is held
50
+ * back deliberately: docker's log stream can be read mid-write, and
51
+ * emitting half a line would both corrupt the delta and mean the next
52
+ * capture repeats the other half, since the marker counts lines.
53
+ */
54
+ export declare function streamDelta(full: string, marker: number): StreamDelta;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Per-case service-log deltas.
3
+ *
4
+ * A container's log is cumulative, but the dashboard shows each test case
5
+ * only the lines *it* produced — the branch's full log is reconstructed by
6
+ * concatenating the deltas along the `dependsOn` chain. So every capture
7
+ * records how far it read (a line marker), and the next capture returns
8
+ * only what came after.
9
+ *
10
+ * The markers live in harness process memory, which means they fork with
11
+ * the snapshot exactly like fake state: a `dependsOn` child continues from
12
+ * where its parent stopped, and a sibling forked from an earlier snapshot
13
+ * sees the parent's lines again — which is correct, because for that
14
+ * sibling they *are* new.
15
+ *
16
+ * Ported out of `daemon.ts`; pure, so the boundary cases below are
17
+ * testable rather than inferred.
18
+ */
19
+ /** Cap on one delta. Beyond this the middle is elided, not the tail. */
20
+ export const LOG_DELTA_MAX_BYTES = 2 * 1024 * 1024;
21
+ /**
22
+ * Truncate from the **middle**, keeping both ends.
23
+ *
24
+ * Deliberately not a tail cap: the head of a service log carries the
25
+ * startup banner (bind address, version, config errors) and the tail
26
+ * carries whatever just failed. Dropping either loses the half that
27
+ * usually explains the problem.
28
+ */
29
+ export function capMiddle(s, max) {
30
+ if (s.length <= max)
31
+ return { value: s, truncated: false };
32
+ const half = Math.floor(max / 2);
33
+ const elided = s.length - 2 * half;
34
+ return {
35
+ value: `${s.slice(0, half)}\n… [${elided} bytes elided] …\n${s.slice(s.length - half)}`,
36
+ truncated: true,
37
+ };
38
+ }
39
+ /**
40
+ * The portion of `full` after line `marker`.
41
+ *
42
+ * Only **complete** lines are returned. A partial trailing line is held
43
+ * back deliberately: docker's log stream can be read mid-write, and
44
+ * emitting half a line would both corrupt the delta and mean the next
45
+ * capture repeats the other half, since the marker counts lines.
46
+ */
47
+ export function streamDelta(full, marker) {
48
+ const lastNl = full.lastIndexOf("\n");
49
+ const complete = lastNl < 0 ? "" : full.slice(0, lastNl + 1);
50
+ let total = 0;
51
+ for (let i = 0; i < complete.length; i++) {
52
+ if (complete.charCodeAt(i) === 10)
53
+ total++;
54
+ }
55
+ // Fewer lines than we had already read means this is not the same log.
56
+ let reset = false;
57
+ let startLine = marker;
58
+ if (total < marker) {
59
+ reset = true;
60
+ startLine = 0;
61
+ }
62
+ let delta;
63
+ if (startLine <= 0) {
64
+ delta = complete;
65
+ }
66
+ else if (startLine >= total) {
67
+ delta = "";
68
+ }
69
+ else {
70
+ // Byte offset just past the `startLine`-th newline.
71
+ let seen = 0;
72
+ let off = 0;
73
+ for (let i = 0; i < complete.length; i++) {
74
+ if (complete.charCodeAt(i) === 10 && ++seen === startLine) {
75
+ off = i + 1;
76
+ break;
77
+ }
78
+ }
79
+ delta = complete.slice(off);
80
+ }
81
+ const capped = capMiddle(delta, LOG_DELTA_MAX_BYTES);
82
+ return { delta: capped.value, total, reset, truncated: capped.truncated };
83
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The harness process.
3
+ *
4
+ * Started by the supervisor (`crates/spectest-vm-agent/src/supervisor.rs`)
5
+ * from the app dir's `node_modules`, so it is the *user's* SDK version
6
+ * that runs the user's project. It dials the socket the supervisor
7
+ * already bound, completes the `hello` handshake, and then serves
8
+ * requests until told to shut down.
9
+ *
10
+ * ## This process is long-lived and is never restarted
11
+ *
12
+ * It holds the state that makes forking a product feature — fake `state`,
13
+ * the ingress listeners with their per-fork route and certificate tables,
14
+ * the DNS registry, containers started mid-test, live browser sessions,
15
+ * the recorder. The supervisor deliberately has no respawn path: if this
16
+ * process dies, the environment is dead and says so, rather than silently
17
+ * coming back empty.
18
+ *
19
+ * So the rules here are: never call `process.exit` on a request failure
20
+ * (answer with an error instead), and never let an unhandled rejection
21
+ * take the process down.
22
+ *
23
+ * ## One implementation, two transports
24
+ *
25
+ * Everything this serves comes from the same method table the legacy HTTP
26
+ * daemon serves (`METHODS`, built in `daemon.ts`). That is deliberate and
27
+ * temporary: during the cutover both transports are live, and the failure
28
+ * being designed out is the one where a fix lands on the transport that is
29
+ * being replaced and never reaches the new one.
30
+ *
31
+ * The table is built **once** per process, so the mutual-exclusion slots
32
+ * that stop two tests running at the same time are shared across both
33
+ * transports. Two tables would each enforce the rule and together break it.
34
+ */
35
+ type Handler = (params: Record<string, unknown>) => Promise<unknown>;
36
+ /** Methods this build can serve. Everything else is answered with an
37
+ * error naming the method, which is far easier to diagnose than a
38
+ * request that hangs. */
39
+ export declare function handlers(sdkVersion: string, onShutdown: () => void, methods?: Record<string, Handler>): Record<string, Handler>;
40
+ /**
41
+ * Serve one connection until it closes.
42
+ *
43
+ * Split from `main` so it can be driven over any duplex stream in tests.
44
+ */
45
+ export declare function serve(socket: NodeJS.ReadWriteStream, registry: Record<string, Handler>): void;
46
+ export declare function main(): Promise<void>;
47
+ export {};
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The harness process.
3
+ *
4
+ * Started by the supervisor (`crates/spectest-vm-agent/src/supervisor.rs`)
5
+ * from the app dir's `node_modules`, so it is the *user's* SDK version
6
+ * that runs the user's project. It dials the socket the supervisor
7
+ * already bound, completes the `hello` handshake, and then serves
8
+ * requests until told to shut down.
9
+ *
10
+ * ## This process is long-lived and is never restarted
11
+ *
12
+ * It holds the state that makes forking a product feature — fake `state`,
13
+ * the ingress listeners with their per-fork route and certificate tables,
14
+ * the DNS registry, containers started mid-test, live browser sessions,
15
+ * the recorder. The supervisor deliberately has no respawn path: if this
16
+ * process dies, the environment is dead and says so, rather than silently
17
+ * coming back empty.
18
+ *
19
+ * So the rules here are: never call `process.exit` on a request failure
20
+ * (answer with an error instead), and never let an unhandled rejection
21
+ * take the process down.
22
+ *
23
+ * ## One implementation, two transports
24
+ *
25
+ * Everything this serves comes from the same method table the legacy HTTP
26
+ * daemon serves (`METHODS`, built in `daemon.ts`). That is deliberate and
27
+ * temporary: during the cutover both transports are live, and the failure
28
+ * being designed out is the one where a fix lands on the transport that is
29
+ * being replaced and never reaches the new one.
30
+ *
31
+ * The table is built **once** per process, so the mutual-exclusion slots
32
+ * that stop two tests running at the same time are shared across both
33
+ * transports. Two tables would each enforce the rule and together break it.
34
+ */
35
+ import net from "node:net";
36
+ import { METHODS } from "../daemon";
37
+ import { codeOf } from "./methods";
38
+ import { PROTOCOL_VERSION, decodeFrame, encodeFrame, fail, ok, } from "./protocol";
39
+ /** Capabilities this build announces. Empty is fine — the slot is what
40
+ * matters, so a later release has something to negotiate against. */
41
+ const CAPABILITIES = [];
42
+ /** Methods this build can serve. Everything else is answered with an
43
+ * error naming the method, which is far easier to diagnose than a
44
+ * request that hangs. */
45
+ export function handlers(sdkVersion, onShutdown, methods = METHODS) {
46
+ return {
47
+ // Everything the environment actually does.
48
+ ...methods,
49
+ // Transport-level methods, which the method table has no business
50
+ // knowing about: they concern this connection, not this environment.
51
+ // Declared after the spread so the table can never shadow them — a
52
+ // `shutdown` that didn't shut down would be an unkillable environment.
53
+ hello: async () => ({
54
+ protocolVersion: PROTOCOL_VERSION,
55
+ capabilities: CAPABILITIES,
56
+ sdkVersion,
57
+ }),
58
+ shutdown: async () => {
59
+ // Reply first, exit after — the supervisor is waiting on this
60
+ // response, and dropping the connection instead would surface as a
61
+ // transport error rather than a clean stop.
62
+ queueMicrotask(onShutdown);
63
+ return {};
64
+ },
65
+ };
66
+ }
67
+ /**
68
+ * Serve one connection until it closes.
69
+ *
70
+ * Split from `main` so it can be driven over any duplex stream in tests.
71
+ */
72
+ export function serve(socket, registry) {
73
+ let buffered = "";
74
+ const send = (frame) => socket.write(encodeFrame(frame));
75
+ const dispatch = async (req) => {
76
+ const handler = registry[req.method];
77
+ if (!handler) {
78
+ send(fail(req.id, `harness does not implement ${JSON.stringify(req.method)}`, "unimplemented"));
79
+ return;
80
+ }
81
+ try {
82
+ send(ok(req.id, (await handler(req.params ?? {}))));
83
+ }
84
+ catch (err) {
85
+ // A failing request must not take the process down: the harness holds
86
+ // the environment's live state, so its death is unrecoverable.
87
+ //
88
+ // A refusal the method meant to make (an unknown case, a second test
89
+ // while one is running) reports its own kind and just its message. An
90
+ // unexpected throw is a bug in the method and carries the stack,
91
+ // which is the only place that information exists.
92
+ const e = err;
93
+ const code = codeOf(err);
94
+ const detail = code === "handler_error" ? (e?.stack ?? e?.message ?? String(err)) : e.message;
95
+ send(fail(req.id, detail, code));
96
+ }
97
+ };
98
+ socket.on("data", (chunk) => {
99
+ buffered += chunk.toString();
100
+ // NDJSON: a chunk can hold several frames, or half of one.
101
+ for (;;) {
102
+ const nl = buffered.indexOf("\n");
103
+ if (nl === -1)
104
+ break;
105
+ const line = buffered.slice(0, nl);
106
+ buffered = buffered.slice(nl + 1);
107
+ if (!line.trim())
108
+ continue;
109
+ let frame;
110
+ try {
111
+ frame = decodeFrame(line);
112
+ }
113
+ catch (e) {
114
+ // Unroutable — no id to answer on. Log and keep serving rather
115
+ // than dropping a connection that is otherwise healthy.
116
+ console.error(`harness: ${e.message}`);
117
+ continue;
118
+ }
119
+ if (frame.kind === "request")
120
+ void dispatch(frame);
121
+ // Responses to our own up-calls and events are handled elsewhere;
122
+ // ignoring them here keeps this loop about serving.
123
+ }
124
+ });
125
+ }
126
+ /** Read the SDK's own version, for the handshake. */
127
+ async function sdkVersion() {
128
+ try {
129
+ const pkg = await import("../../package.json", { with: { type: "json" } });
130
+ return pkg.default?.version ?? "unknown";
131
+ }
132
+ catch {
133
+ return "unknown";
134
+ }
135
+ }
136
+ export async function main() {
137
+ const path = process.env.SPECTEST_HARNESS_SOCKET;
138
+ if (!path) {
139
+ console.error("harness: SPECTEST_HARNESS_SOCKET is not set");
140
+ process.exit(2);
141
+ }
142
+ // An unhandled rejection anywhere in user code must not kill the
143
+ // environment — report it and keep serving.
144
+ process.on("unhandledRejection", (reason) => {
145
+ console.error("harness: unhandled rejection:", reason);
146
+ });
147
+ const version = await sdkVersion();
148
+ const socket = net.createConnection(path);
149
+ socket.on("error", (e) => {
150
+ console.error(`harness: socket error: ${e.message}`);
151
+ process.exit(1);
152
+ });
153
+ socket.on("close", () => {
154
+ // The supervisor went away; there is nothing left to serve.
155
+ process.exit(0);
156
+ });
157
+ await new Promise((resolve) => socket.once("connect", () => resolve()));
158
+ serve(socket, handlers(version, () => socket.end(() => process.exit(0))));
159
+ }
160
+ // Only run when executed directly, so importing this module in a test
161
+ // doesn't try to dial a socket.
162
+ if (import.meta.main) {
163
+ void main();
164
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * What the harness can be asked to do, independent of how it was asked.
3
+ *
4
+ * The daemon grew as an HTTP server, so every operation was written against
5
+ * `IncomingMessage`/`ServerResponse`: parsing a body, choosing a status
6
+ * code, and writing a reply were tangled up with deciding what to actually
7
+ * do. That is the coupling this module removes. A method here takes plain
8
+ * params and returns a plain object; a *transport* — the legacy HTTP router,
9
+ * or the supervisor's frame protocol — adapts to it.
10
+ *
11
+ * The point is that both transports run the **same** implementation. During
12
+ * the cutover the two are live at once, and the failure this design refuses
13
+ * to allow is the one where a fix lands on the transport that is being
14
+ * replaced and quietly doesn't reach the new one.
15
+ *
16
+ * ## Errors carry a kind, not a status code
17
+ *
18
+ * HTTP status codes were the daemon's error vocabulary: 400 for a bad body,
19
+ * 404 for an unknown case, 409 for "already running". The frame protocol has
20
+ * no statuses, so the kind is named ({@link HarnessErrorCode}) and each
21
+ * transport renders it — the frame protocol carries it as the error's
22
+ * `code`. Naming them also makes the one that matters legible: `conflict` is
23
+ * not a client mistake, it is the harness refusing to run two tests in one
24
+ * process at once, which is a correctness property rather than a validation
25
+ * failure.
26
+ */
27
+ /** The kinds of failure a method can report. */
28
+ export type HarnessErrorCode =
29
+ /** The request itself was malformed — missing or wrong-typed params. */
30
+ "bad_request"
31
+ /** The thing named does not exist here (an unknown case id). */
32
+ | "not_found"
33
+ /** Refused because something incompatible is already running. */
34
+ | "conflict"
35
+ /** The method ran and threw. */
36
+ | "handler_error";
37
+ /** An error a transport can render without knowing what failed. */
38
+ export declare class HarnessError extends Error {
39
+ readonly code: HarnessErrorCode;
40
+ constructor(code: HarnessErrorCode, message: string);
41
+ }
42
+ export declare const badRequest: (m: string) => HarnessError;
43
+ export declare const notFound: (m: string) => HarnessError;
44
+ export declare const conflict: (m: string) => HarnessError;
45
+ /** One operation: plain params in, plain result out. */
46
+ export type Method = (params: Record<string, unknown>) => Promise<unknown>;
47
+ /** Everything the harness can serve, by method name. */
48
+ export type MethodTable = Record<string, Method>;
49
+ /** The kind to report for a value thrown from a method body. Anything that
50
+ * isn't already a {@link HarnessError} is an unexpected failure inside the
51
+ * method, not a statement about the request. */
52
+ export declare function codeOf(err: unknown): HarnessErrorCode;
53
+ /** Require a non-empty string param, with the message the caller had. */
54
+ export declare function requireString(params: Record<string, unknown>, name: string, label?: string): string;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * What the harness can be asked to do, independent of how it was asked.
3
+ *
4
+ * The daemon grew as an HTTP server, so every operation was written against
5
+ * `IncomingMessage`/`ServerResponse`: parsing a body, choosing a status
6
+ * code, and writing a reply were tangled up with deciding what to actually
7
+ * do. That is the coupling this module removes. A method here takes plain
8
+ * params and returns a plain object; a *transport* — the legacy HTTP router,
9
+ * or the supervisor's frame protocol — adapts to it.
10
+ *
11
+ * The point is that both transports run the **same** implementation. During
12
+ * the cutover the two are live at once, and the failure this design refuses
13
+ * to allow is the one where a fix lands on the transport that is being
14
+ * replaced and quietly doesn't reach the new one.
15
+ *
16
+ * ## Errors carry a kind, not a status code
17
+ *
18
+ * HTTP status codes were the daemon's error vocabulary: 400 for a bad body,
19
+ * 404 for an unknown case, 409 for "already running". The frame protocol has
20
+ * no statuses, so the kind is named ({@link HarnessErrorCode}) and each
21
+ * transport renders it — the frame protocol carries it as the error's
22
+ * `code`. Naming them also makes the one that matters legible: `conflict` is
23
+ * not a client mistake, it is the harness refusing to run two tests in one
24
+ * process at once, which is a correctness property rather than a validation
25
+ * failure.
26
+ */
27
+ /** An error a transport can render without knowing what failed. */
28
+ export class HarnessError extends Error {
29
+ code;
30
+ constructor(code, message) {
31
+ super(message);
32
+ this.name = "HarnessError";
33
+ this.code = code;
34
+ }
35
+ }
36
+ export const badRequest = (m) => new HarnessError("bad_request", m);
37
+ export const notFound = (m) => new HarnessError("not_found", m);
38
+ export const conflict = (m) => new HarnessError("conflict", m);
39
+ /** How a transport that speaks HTTP renders an error kind. */
40
+ function unusedHttpStatusFor(code) {
41
+ switch (code) {
42
+ case "bad_request":
43
+ return 400;
44
+ case "not_found":
45
+ return 404;
46
+ case "conflict":
47
+ return 409;
48
+ case "handler_error":
49
+ return 500;
50
+ }
51
+ }
52
+ /** The kind to report for a value thrown from a method body. Anything that
53
+ * isn't already a {@link HarnessError} is an unexpected failure inside the
54
+ * method, not a statement about the request. */
55
+ export function codeOf(err) {
56
+ return err instanceof HarnessError ? err.code : "handler_error";
57
+ }
58
+ /** Require a non-empty string param, with the message the caller had. */
59
+ export function requireString(params, name, label = `${name} (string)`) {
60
+ const v = params[name];
61
+ if (typeof v !== "string" || v.length === 0) {
62
+ throw badRequest(`${label} is required`);
63
+ }
64
+ return v;
65
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The DNS names registry: the file the harness writes and
3
+ * `spectest-resolver` reads.
4
+ *
5
+ * Ported out of `daemon.ts`. The point of a shared module is that this
6
+ * format had **two independent implementations** — the writer here and
7
+ * the reader in `resolver.ts` — with nothing keeping them in step. That
8
+ * is the same shape as the `config.rs` ↔ SDK drift the harness split
9
+ * exists to remove, just inside one language.
10
+ *
11
+ * The registry answers names that Docker's own DNS cannot:
12
+ * - fake hostnames (`api.stripe.com` → the bridge gateway),
13
+ * - names bound at runtime by `ctx.dnsName`,
14
+ * - **wildcards** (`*.us-east-1.amazonaws.com`), which have no
15
+ * `--add-host` or `--network-alias` equivalent and therefore *only*
16
+ * exist here.
17
+ *
18
+ * Lookup is exact-first, then longest matching suffix — the same
19
+ * precedence the ingress routes and the SNI cert table use.
20
+ */
21
+ export interface RegistryDoc {
22
+ /** Exact name → IP. */
23
+ hosts: Record<string, string>;
24
+ /** Suffix (with leading dot) → IP, longest match wins. */
25
+ wildcards: Array<{
26
+ suffix: string;
27
+ ip: string;
28
+ }>;
29
+ /** Written by the harness; the resolver re-reads on mtime change, so
30
+ * this is diagnostic rather than load-bearing. */
31
+ updatedAt?: number;
32
+ }
33
+ export declare const EMPTY_REGISTRY: RegistryDoc;
34
+ /** Serialise for the resolver. */
35
+ export declare function encodeRegistry(doc: RegistryDoc, now: number): string;
36
+ /**
37
+ * Parse a registry file.
38
+ *
39
+ * Deliberately total: a truncated or malformed file yields an **empty**
40
+ * registry rather than throwing. The resolver reads this on a hot path
41
+ * while the harness may be rewriting it, and a parse error there would
42
+ * take out DNS for the whole environment — every name, not just the one
43
+ * being added. Degrading to "no custom names" is recoverable; the next
44
+ * successful read restores everything.
45
+ */
46
+ export declare function decodeRegistry(text: string): RegistryDoc;
47
+ /** `"*.example.com"` → `".example.com"`, the form stored in `wildcards`. */
48
+ export declare function suffixOf(pattern: string): string;
49
+ /**
50
+ * Resolve a name: exact entry first, then the longest matching wildcard
51
+ * suffix. Returns `null` when nothing matches, which is the resolver's
52
+ * signal to fall through to Docker's DNS and then upstream.
53
+ */
54
+ export declare function lookup(doc: RegistryDoc, name: string): string | null;
55
+ /**
56
+ * Add or replace a name.
57
+ *
58
+ * A wildcard replaces any existing entry for the same suffix rather than
59
+ * appending, so re-registering a name (which `ctx.dnsName` allows, and
60
+ * which a re-run of a `setup` hook does) cannot grow the list without
61
+ * bound or leave two entries racing on equal suffix length.
62
+ */
63
+ export declare function upsert(doc: RegistryDoc, hostname: string, ip: string): RegistryDoc;