@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,186 @@
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
+
44
+ import { certCovers, isWildcard, wildcardSuffix } from "./hostmatch";
45
+
46
+ /**
47
+ * One hostname's upstream: a fake handled in-process, or a container.
48
+ *
49
+ * Generic in the fake's runtime record so the harness keeps its own type
50
+ * through the tables — the alternative, `unknown` plus a cast at each
51
+ * dispatch site, would trade a real type for a comment.
52
+ */
53
+ export type Route<F = unknown> =
54
+ | { kind: "fake"; fake: F }
55
+ | { kind: "proxy"; service: string; port: number };
56
+
57
+ /** A PEM leaf + key, as handed to `Bun.serve`'s TLS config. */
58
+ export interface Leaf {
59
+ cert: string;
60
+ key: string;
61
+ }
62
+
63
+ /**
64
+ * The mutable ingress state of one harness process.
65
+ *
66
+ * Held by the caller rather than this module so there is no hidden
67
+ * singleton: tests build one per case, and the harness holds exactly one.
68
+ */
69
+ export interface IngressTables<F = unknown> {
70
+ /** Listen port → (hostname → route). Values are mutated in place. */
71
+ routesByPort: Map<number, Map<string, Route<F>>>;
72
+ /** SNI server name → leaf. Adding to this is what forces a rebind. */
73
+ certByHost: Map<string, Leaf>;
74
+ }
75
+
76
+ /** Fixed HTTPS port shared by every TLS route (fakes + service `tls`). */
77
+ export const INGRESS_HTTPS_PORT = 443;
78
+ /** Fixed HTTP port, always bound alongside :443 so both schemes work. */
79
+ export const INGRESS_HTTP_PORT = 80;
80
+
81
+ export function emptyTables<F = unknown>(): IngressTables<F> {
82
+ return { routesByPort: new Map(), certByHost: new Map() };
83
+ }
84
+
85
+ /**
86
+ * The route table for `port`, creating it if absent.
87
+ *
88
+ * Always returns the *same* object for a given port for as long as the
89
+ * tables live. Callers must mutate what they get back and must never swap
90
+ * in a replacement — see the module header for what that breaks.
91
+ */
92
+ export function routesFor<F>(tables: IngressTables<F>, port: number): Map<string, Route<F>> {
93
+ let routes = tables.routesByPort.get(port);
94
+ if (!routes) {
95
+ routes = new Map<string, Route<F>>();
96
+ tables.routesByPort.set(port, routes);
97
+ }
98
+ return routes;
99
+ }
100
+
101
+ /** What binding `hostname` will require, before anything is mutated. */
102
+ export interface BindPlan {
103
+ /** No existing cert covers the hostname, so a leaf must be minted. */
104
+ needsCert: boolean;
105
+ /** The :443 listener must be stopped and re-served. */
106
+ needsHttpsRebind: boolean;
107
+ }
108
+
109
+ /**
110
+ * Decide what binding `hostname` for TLS costs.
111
+ *
112
+ * A rebind is needed when a new certificate is going into the SNI table,
113
+ * or when :443 isn't listening yet. It is deliberately *not* needed for a
114
+ * hostname an existing wildcard already covers — the common case when a
115
+ * component claims a whole domain up front and services appear under it
116
+ * later, and the reason a fake can mint endpoints in a loop without
117
+ * restarting the listener once per iteration.
118
+ */
119
+ export function planBind<F>(
120
+ tables: IngressTables<F>,
121
+ hostname: string,
122
+ opts: { httpsListening: boolean },
123
+ ): BindPlan {
124
+ const needsCert = !certCovers(tables.certByHost.keys(), hostname);
125
+ return { needsCert, needsHttpsRebind: needsCert || !opts.httpsListening };
126
+ }
127
+
128
+ /**
129
+ * Point `hostname` at `route` on both :80 and :443.
130
+ *
131
+ * Boot `tls` serves both schemes, and runtime `tls` matches it — a service
132
+ * reachable only over https would differ from its boot-time twin in a way
133
+ * nothing declares.
134
+ */
135
+ export function bindRoute<F>(tables: IngressTables<F>, hostname: string, route: Route<F>): void {
136
+ routesFor(tables, INGRESS_HTTP_PORT).set(hostname, route);
137
+ routesFor(tables, INGRESS_HTTPS_PORT).set(hostname, route);
138
+ }
139
+
140
+ /**
141
+ * Drop `hostname`'s routes, so it 404s.
142
+ *
143
+ * The certificate is deliberately left in the SNI table: it is harmless
144
+ * without a route, and removing it would force an otherwise unnecessary
145
+ * :443 rebind at exactly the moment a service is going away.
146
+ */
147
+ export function unbindRoute<F>(tables: IngressTables<F>, hostname: string): void {
148
+ tables.routesByPort.get(INGRESS_HTTP_PORT)?.delete(hostname);
149
+ tables.routesByPort.get(INGRESS_HTTPS_PORT)?.delete(hostname);
150
+ }
151
+
152
+ /** The SNI entries for `Bun.serve`'s TLS config. */
153
+ export function certEntries<F>(
154
+ tables: IngressTables<F>,
155
+ ): Array<{ cert: string; key: string; serverName: string }> {
156
+ return [...tables.certByHost].map(([serverName, leaf]) => ({
157
+ cert: leaf.cert,
158
+ key: leaf.key,
159
+ serverName,
160
+ }));
161
+ }
162
+
163
+ /** Clear every table — used between `/load` calls so a new project's
164
+ * routes bind against a clean slate rather than the old project's. */
165
+ export function clearTables<F>(tables: IngressTables<F>): void {
166
+ tables.routesByPort.clear();
167
+ tables.certByHost.clear();
168
+ }
169
+
170
+ /**
171
+ * Point a hostname at an IP in a names registry document, choosing the
172
+ * exact or wildcard table by the hostname's shape.
173
+ *
174
+ * A wildcard has no `--add-host` or `--network-alias` equivalent, so the
175
+ * resolver's suffix table is the only place it can exist; an exact name
176
+ * could live in either, and goes in the exact table so it keeps winning
177
+ * over any wildcard that also matches.
178
+ */
179
+ export function registryTarget(hostname: string): { wildcard: true; suffix: string } | {
180
+ wildcard: false;
181
+ host: string;
182
+ } {
183
+ return isWildcard(hostname)
184
+ ? { wildcard: true, suffix: wildcardSuffix(hostname) }
185
+ : { wildcard: false, host: hostname };
186
+ }
@@ -0,0 +1,125 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { capMiddle, streamDelta } from "./log-delta";
4
+
5
+ describe("capMiddle", () => {
6
+ test("leaves short text alone", () => {
7
+ expect(capMiddle("hello", 100)).toEqual({ value: "hello", truncated: false });
8
+ });
9
+
10
+ /** Both ends are kept on purpose: the head has the startup banner, the
11
+ * tail has whatever just failed. A tail-only cap loses the half that
12
+ * usually explains the problem. */
13
+ test("keeps both ends and elides the middle", () => {
14
+ const s = "A".repeat(50) + "MIDDLE" + "Z".repeat(50);
15
+ const out = capMiddle(s, 20);
16
+ expect(out.truncated).toBe(true);
17
+ expect(out.value.startsWith("AAAA")).toBe(true);
18
+ expect(out.value.endsWith("ZZZZ")).toBe(true);
19
+ expect(out.value).not.toContain("MIDDLE");
20
+ expect(out.value).toContain("bytes elided");
21
+ });
22
+
23
+ test("reports how much was dropped", () => {
24
+ const out = capMiddle("x".repeat(1000), 100);
25
+ expect(out.value).toContain(`${1000 - 100} bytes elided`);
26
+ });
27
+
28
+ test("text exactly at the limit is not truncated", () => {
29
+ const out = capMiddle("x".repeat(100), 100);
30
+ expect(out.truncated).toBe(false);
31
+ expect(out.value).toHaveLength(100);
32
+ });
33
+ });
34
+
35
+ describe("streamDelta", () => {
36
+ test("a first read returns everything", () => {
37
+ const out = streamDelta("a\nb\nc\n", 0);
38
+ expect(out.delta).toBe("a\nb\nc\n");
39
+ expect(out.total).toBe(3);
40
+ expect(out.reset).toBe(false);
41
+ });
42
+
43
+ test("a later read returns only the new lines", () => {
44
+ const out = streamDelta("a\nb\nc\n", 2);
45
+ expect(out.delta).toBe("c\n");
46
+ expect(out.total).toBe(3);
47
+ });
48
+
49
+ test("no new lines yields an empty delta, not a repeat", () => {
50
+ const out = streamDelta("a\nb\n", 2);
51
+ expect(out.delta).toBe("");
52
+ expect(out.total).toBe(2);
53
+ expect(out.reset).toBe(false);
54
+ });
55
+
56
+ /** Docker's log can be read mid-write. Emitting a partial line would
57
+ * corrupt the delta AND make the next capture repeat the other half,
58
+ * because the marker counts whole lines. */
59
+ test("a partial trailing line is held back until it completes", () => {
60
+ const first = streamDelta("a\nb\npartial", 0);
61
+ expect(first.delta).toBe("a\nb\n");
62
+ expect(first.total).toBe(2);
63
+
64
+ const second = streamDelta("a\nb\npartial line now done\n", first.total);
65
+ expect(second.delta).toBe("partial line now done\n");
66
+ expect(second.total).toBe(3);
67
+ });
68
+
69
+ test("a log with no newline at all yields nothing", () => {
70
+ const out = streamDelta("no newline yet", 0);
71
+ expect(out.delta).toBe("");
72
+ expect(out.total).toBe(0);
73
+ });
74
+
75
+ /** A shorter log than the marker means the container restarted or the
76
+ * log rotated: line N is no longer the line N we saw, so the whole
77
+ * thing is new. */
78
+ test("a shrunken log is reported as a reset and replayed in full", () => {
79
+ const out = streamDelta("fresh\n", 10);
80
+ expect(out.reset).toBe(true);
81
+ expect(out.delta).toBe("fresh\n");
82
+ expect(out.total).toBe(1);
83
+ });
84
+
85
+ test("a marker beyond the end yields nothing rather than throwing", () => {
86
+ // Equal to total: nothing new, and not a reset.
87
+ const out = streamDelta("a\nb\n", 2);
88
+ expect(out.delta).toBe("");
89
+ expect(out.reset).toBe(false);
90
+ });
91
+
92
+ test("an empty log is handled", () => {
93
+ const out = streamDelta("", 0);
94
+ expect(out.delta).toBe("");
95
+ expect(out.total).toBe(0);
96
+ expect(out.reset).toBe(false);
97
+ });
98
+
99
+ test("blank lines are counted like any other", () => {
100
+ const out = streamDelta("a\n\n\nb\n", 1);
101
+ expect(out.delta).toBe("\n\nb\n");
102
+ expect(out.total).toBe(4);
103
+ });
104
+
105
+ test("successive captures never overlap or drop a line", () => {
106
+ let marker = 0;
107
+ let log = "";
108
+ const seen: string[] = [];
109
+ for (const chunk of [["l1", "l2"], ["l3"], [], ["l4", "l5", "l6"]]) {
110
+ log += chunk.map((l) => `${l}\n`).join("");
111
+ const out = streamDelta(log, marker);
112
+ marker = out.total;
113
+ if (out.delta) seen.push(out.delta);
114
+ }
115
+ expect(seen.join("")).toBe("l1\nl2\nl3\nl4\nl5\nl6\n");
116
+ });
117
+
118
+ test("a very large delta is truncated but still reports the true total", () => {
119
+ const big = "x".repeat(5 * 1024 * 1024) + "\n";
120
+ const out = streamDelta(big, 0);
121
+ expect(out.truncated).toBe(true);
122
+ expect(out.total).toBe(1);
123
+ expect(out.delta.length).toBeLessThan(big.length);
124
+ });
125
+ });
@@ -0,0 +1,100 @@
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
+
20
+ /** Cap on one delta. Beyond this the middle is elided, not the tail. */
21
+ export const LOG_DELTA_MAX_BYTES = 2 * 1024 * 1024;
22
+
23
+ /**
24
+ * Truncate from the **middle**, keeping both ends.
25
+ *
26
+ * Deliberately not a tail cap: the head of a service log carries the
27
+ * startup banner (bind address, version, config errors) and the tail
28
+ * carries whatever just failed. Dropping either loses the half that
29
+ * usually explains the problem.
30
+ */
31
+ export function capMiddle(s: string, max: number): { value: string; truncated: boolean } {
32
+ if (s.length <= max) return { value: s, truncated: false };
33
+ const half = Math.floor(max / 2);
34
+ const elided = s.length - 2 * half;
35
+ return {
36
+ value: `${s.slice(0, half)}\n… [${elided} bytes elided] …\n${s.slice(s.length - half)}`,
37
+ truncated: true,
38
+ };
39
+ }
40
+
41
+ export interface StreamDelta {
42
+ /** The new lines, possibly middle-elided. */
43
+ delta: string;
44
+ /** Line count after this capture — the next call's marker. */
45
+ total: number;
46
+ /**
47
+ * The stream got *shorter* than the marker, so the container was
48
+ * restarted or its log rotated. The delta is then the whole log rather
49
+ * than a suffix, because line N is no longer the line N we saw.
50
+ */
51
+ reset: boolean;
52
+ truncated: boolean;
53
+ }
54
+
55
+ /**
56
+ * The portion of `full` after line `marker`.
57
+ *
58
+ * Only **complete** lines are returned. A partial trailing line is held
59
+ * back deliberately: docker's log stream can be read mid-write, and
60
+ * emitting half a line would both corrupt the delta and mean the next
61
+ * capture repeats the other half, since the marker counts lines.
62
+ */
63
+ export function streamDelta(full: string, marker: number): StreamDelta {
64
+ const lastNl = full.lastIndexOf("\n");
65
+ const complete = lastNl < 0 ? "" : full.slice(0, lastNl + 1);
66
+
67
+ let total = 0;
68
+ for (let i = 0; i < complete.length; i++) {
69
+ if (complete.charCodeAt(i) === 10) total++;
70
+ }
71
+
72
+ // Fewer lines than we had already read means this is not the same log.
73
+ let reset = false;
74
+ let startLine = marker;
75
+ if (total < marker) {
76
+ reset = true;
77
+ startLine = 0;
78
+ }
79
+
80
+ let delta: string;
81
+ if (startLine <= 0) {
82
+ delta = complete;
83
+ } else if (startLine >= total) {
84
+ delta = "";
85
+ } else {
86
+ // Byte offset just past the `startLine`-th newline.
87
+ let seen = 0;
88
+ let off = 0;
89
+ for (let i = 0; i < complete.length; i++) {
90
+ if (complete.charCodeAt(i) === 10 && ++seen === startLine) {
91
+ off = i + 1;
92
+ break;
93
+ }
94
+ }
95
+ delta = complete.slice(off);
96
+ }
97
+
98
+ const capped = capMiddle(delta, LOG_DELTA_MAX_BYTES);
99
+ return { delta: capped.value, total, reset, truncated: capped.truncated };
100
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * The frame transport, driven over a real duplex stream.
3
+ *
4
+ * `handlers()` takes its method table as a parameter so these can run
5
+ * without importing `daemon.ts`, which would pull in dockerd clients, a
6
+ * browser and an HTTP listener to test a dispatch loop.
7
+ */
8
+ import { describe, expect, test } from "bun:test";
9
+
10
+ import { handlers, serve } from "./main";
11
+ import { conflict, notFound, type MethodTable } from "./methods";
12
+ import { PROTOCOL_VERSION, encodeFrame, type Frame } from "./protocol";
13
+
14
+ /**
15
+ * Drive `serve` over a stand-in socket and collect what it writes back.
16
+ *
17
+ * The two directions have to stay separate: a duplex whose write side feeds
18
+ * its own read side would echo every request straight back as if it were a
19
+ * reply, and every assertion below would be reading its own input.
20
+ */
21
+ function harness(methods: MethodTable, onShutdown = () => {}) {
22
+ const readers: Array<(chunk: Buffer | string) => void> = [];
23
+ const frames: Frame[] = [];
24
+ const socket = {
25
+ on(event: string, cb: (chunk: Buffer | string) => void) {
26
+ if (event === "data") readers.push(cb);
27
+ return socket;
28
+ },
29
+ write(chunk: string) {
30
+ for (const line of chunk.split("\n")) {
31
+ if (line.trim()) frames.push(JSON.parse(line) as Frame);
32
+ }
33
+ return true;
34
+ },
35
+ } as unknown as NodeJS.ReadWriteStream;
36
+
37
+ serve(socket, handlers("9.9.9", onShutdown, methods as never));
38
+
39
+ return {
40
+ send: (frame: Frame | string) => {
41
+ const line = typeof frame === "string" ? `${frame}\n` : encodeFrame(frame);
42
+ for (const r of readers) r(line);
43
+ },
44
+ frames,
45
+ /** Wait for the reply to `id`, so tests never race the event loop. */
46
+ async reply(id: string): Promise<any> {
47
+ for (let i = 0; i < 200; i++) {
48
+ const f = frames.find((x) => (x as { id?: string }).id === id);
49
+ if (f) return f;
50
+ await Bun.sleep(1);
51
+ }
52
+ throw new Error(`no reply for ${id}; got ${JSON.stringify(frames)}`);
53
+ },
54
+ };
55
+ }
56
+
57
+ const request = (id: string, method: string, params: Record<string, unknown> = {}) =>
58
+ ({ kind: "request", id, method, params }) as unknown as Frame;
59
+
60
+ describe("dispatch", () => {
61
+ test("serves a method from the table and returns its result", async () => {
62
+ const h = harness({ cases: async () => ({ cases: [{ id: "a" }] }) });
63
+ h.send(request("1", "cases"));
64
+ expect(await h.reply("1")).toMatchObject({
65
+ kind: "response",
66
+ ok: true,
67
+ result: { cases: [{ id: "a" }] },
68
+ });
69
+ });
70
+
71
+ test("passes params through", async () => {
72
+ const h = harness({ run: async (p) => ({ ran: p.caseId }) });
73
+ h.send(request("1", "run", { caseId: "case-7" }));
74
+ expect((await h.reply("1")).result).toEqual({ ran: "case-7" });
75
+ });
76
+
77
+ test("answers the handshake without touching the table", async () => {
78
+ const h = harness({});
79
+ h.send(request("1", "hello"));
80
+ expect((await h.reply("1")).result).toMatchObject({
81
+ protocolVersion: PROTOCOL_VERSION,
82
+ sdkVersion: "9.9.9",
83
+ });
84
+ });
85
+
86
+ /** An unknown method must answer, not hang. A request that never gets a
87
+ * reply is indistinguishable from a wedged environment. */
88
+ test("an unknown method is refused by name", async () => {
89
+ const h = harness({});
90
+ h.send(request("1", "nonesuch"));
91
+ const r = await h.reply("1");
92
+ expect(r).toMatchObject({ ok: false, error: { code: "unimplemented" } });
93
+ expect(r.error.message).toContain("nonesuch");
94
+ });
95
+
96
+ test("replies are correlated, so concurrent requests can't be confused", async () => {
97
+ const h = harness({
98
+ slow: async () => {
99
+ await Bun.sleep(20);
100
+ return { which: "slow" };
101
+ },
102
+ fast: async () => ({ which: "fast" }),
103
+ });
104
+ h.send(request("slow-1", "slow"));
105
+ h.send(request("fast-1", "fast"));
106
+ expect((await h.reply("fast-1")).result).toEqual({ which: "fast" });
107
+ expect((await h.reply("slow-1")).result).toEqual({ which: "slow" });
108
+ });
109
+
110
+ test("several frames arriving in one chunk are all served", async () => {
111
+ const h = harness({ ping: async () => ({ pong: true }) });
112
+ h.send(request("1", "ping"));
113
+ h.send(request("2", "ping"));
114
+ expect((await h.reply("1")).ok).toBe(true);
115
+ expect((await h.reply("2")).ok).toBe(true);
116
+ });
117
+ });
118
+
119
+ describe("failures", () => {
120
+ /** A refusal the method meant to make carries its kind, so the control
121
+ * plane can tell "you asked for something that isn't here" from "the
122
+ * harness broke". */
123
+ test("a deliberate refusal keeps its kind and message", async () => {
124
+ const h = harness({
125
+ run: async () => {
126
+ throw notFound("unknown caseId: nope");
127
+ },
128
+ });
129
+ h.send(request("1", "run"));
130
+ expect(await h.reply("1")).toMatchObject({
131
+ ok: false,
132
+ error: { code: "not_found", message: "unknown caseId: nope" },
133
+ });
134
+ });
135
+
136
+ test("a conflict is reported as such, not as a crash", async () => {
137
+ const h = harness({
138
+ run: async () => {
139
+ throw conflict("another test is already running");
140
+ },
141
+ });
142
+ h.send(request("1", "run"));
143
+ expect(await h.reply("1")).toMatchObject({ ok: false, error: { code: "conflict" } });
144
+ });
145
+
146
+ /** An unexpected throw is a bug in the method, and the stack is the only
147
+ * place that information exists — the control plane cannot get it any
148
+ * other way once the fork is gone. */
149
+ test("an unexpected error carries its stack", async () => {
150
+ const h = harness({
151
+ run: async () => {
152
+ throw new Error("boom");
153
+ },
154
+ });
155
+ h.send(request("1", "run"));
156
+ const r = await h.reply("1");
157
+ expect(r.error.code).toBe("handler_error");
158
+ expect(r.error.message).toContain("boom");
159
+ expect(r.error.message).toContain("at ");
160
+ });
161
+
162
+ /** The harness holds the environment's only copy of its live state, so a
163
+ * failing request must never be allowed to end the process. */
164
+ test("a failing method leaves the harness serving", async () => {
165
+ const h = harness({
166
+ bad: async () => {
167
+ throw new Error("boom");
168
+ },
169
+ good: async () => ({ fine: true }),
170
+ });
171
+ h.send(request("1", "bad"));
172
+ await h.reply("1");
173
+ h.send(request("2", "good"));
174
+ expect((await h.reply("2")).result).toEqual({ fine: true });
175
+ });
176
+
177
+ /** Garbage on the wire has no id to answer on, so the only safe thing is
178
+ * to keep serving the connection rather than tear it down. */
179
+ test("an undecodable frame does not break the connection", async () => {
180
+ const h = harness({ good: async () => ({ fine: true }) });
181
+ h.send("{not json");
182
+ h.send(request("2", "good"));
183
+ expect((await h.reply("2")).result).toEqual({ fine: true });
184
+ });
185
+ });
186
+
187
+ describe("transport methods cannot be shadowed", () => {
188
+ /** A method table that could override `shutdown` would produce an
189
+ * environment the supervisor cannot stop. */
190
+ test("a table's shutdown does not replace the real one", async () => {
191
+ let stopped = false;
192
+ const h = harness(
193
+ { shutdown: async () => ({ hijacked: true }) },
194
+ () => {
195
+ stopped = true;
196
+ },
197
+ );
198
+ h.send(request("1", "shutdown"));
199
+ expect((await h.reply("1")).result).toEqual({});
200
+ await Bun.sleep(5);
201
+ expect(stopped).toBe(true);
202
+ });
203
+
204
+ test("a table's hello does not replace the handshake", async () => {
205
+ const h = harness({ hello: async () => ({ protocolVersion: 999 }) });
206
+ h.send(request("1", "hello"));
207
+ expect((await h.reply("1")).result).toMatchObject({
208
+ protocolVersion: PROTOCOL_VERSION,
209
+ });
210
+ });
211
+ });