@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.
- package/dist/browser.d.ts +21 -8
- package/dist/browser.js +78 -36
- package/dist/components/supabase.d.ts +87 -27
- package/dist/components/supabase.js +352 -69
- package/dist/daemon.d.ts +38 -0
- package/dist/daemon.js +464 -987
- package/dist/harness/build-context.d.ts +82 -0
- package/dist/harness/build-context.js +113 -0
- package/dist/harness/buildkit-progress.d.ts +37 -0
- package/dist/harness/buildkit-progress.js +66 -0
- package/dist/harness/container-run.d.ts +89 -0
- package/dist/harness/container-run.js +118 -0
- package/dist/harness/file-mounts.d.ts +91 -0
- package/dist/harness/file-mounts.js +119 -0
- package/dist/harness/hostmatch.d.ts +65 -0
- package/dist/harness/hostmatch.js +108 -0
- package/dist/harness/http-proxy.d.ts +62 -0
- package/dist/harness/http-proxy.js +104 -0
- package/dist/harness/ingress-table.d.ts +148 -0
- package/dist/harness/ingress-table.js +129 -0
- package/dist/harness/log-delta.d.ts +54 -0
- package/dist/harness/log-delta.js +83 -0
- package/dist/harness/main.d.ts +47 -0
- package/dist/harness/main.js +164 -0
- package/dist/harness/methods.d.ts +54 -0
- package/dist/harness/methods.js +65 -0
- package/dist/harness/names-registry.d.ts +63 -0
- package/dist/harness/names-registry.js +90 -0
- package/dist/harness/protocol.d.ts +88 -0
- package/dist/harness/protocol.js +96 -0
- package/dist/harness/ready-poll.d.ts +47 -0
- package/dist/harness/ready-poll.js +67 -0
- package/dist/harness/service-graph.d.ts +29 -0
- package/dist/harness/service-graph.js +92 -0
- package/dist/harness/volume-paths.d.ts +70 -0
- package/dist/harness/volume-paths.js +81 -0
- package/dist/index.d.ts +58 -16
- package/dist/ingress.d.ts +1 -1
- package/dist/mobile.d.ts +9 -5
- package/dist/mobile.js +7 -6
- package/dist/recorder.d.ts +10 -0
- package/dist/resolver.js +5 -8
- package/dist/vendor/rrweb-plugin-console-record.umd.js +521 -0
- package/dist/vendor/rrweb-record.min.js +5061 -0
- package/package.json +7 -1
- package/src/aws-sigv4.ts +218 -0
- package/src/browser.ts +2095 -0
- package/src/components/aws.ts +554 -0
- package/src/components/email.ts +398 -0
- package/src/components/expo.ts +167 -0
- package/src/components/index.ts +81 -0
- package/src/components/k3s.ts +2061 -0
- package/src/components/postgres.ts +132 -0
- package/src/components/replayFake.ts +1015 -0
- package/src/components/s3.ts +132 -0
- package/src/components/supabase.ts +1699 -0
- package/src/daemon.ts +5537 -0
- package/src/harness/build-context.test.ts +0 -0
- package/src/harness/build-context.ts +146 -0
- package/src/harness/buildkit-progress.test.ts +98 -0
- package/src/harness/buildkit-progress.ts +74 -0
- package/src/harness/container-run.test.ts +209 -0
- package/src/harness/container-run.ts +158 -0
- package/src/harness/file-mounts.test.ts +185 -0
- package/src/harness/file-mounts.ts +145 -0
- package/src/harness/hostmatch.test.ts +148 -0
- package/src/harness/hostmatch.ts +109 -0
- package/src/harness/http-proxy.test.ts +156 -0
- package/src/harness/http-proxy.ts +119 -0
- package/src/harness/ingress-rebind.test.ts +125 -0
- package/src/harness/ingress-table.test.ts +172 -0
- package/src/harness/ingress-table.ts +186 -0
- package/src/harness/log-delta.test.ts +125 -0
- package/src/harness/log-delta.ts +100 -0
- package/src/harness/main.test.ts +211 -0
- package/src/harness/main.ts +196 -0
- package/src/harness/methods.test.ts +63 -0
- package/src/harness/methods.ts +92 -0
- package/src/harness/names-registry.test.ts +137 -0
- package/src/harness/names-registry.ts +108 -0
- package/src/harness/protocol.test.ts +148 -0
- package/src/harness/protocol.ts +163 -0
- package/src/harness/ready-poll.test.ts +172 -0
- package/src/harness/ready-poll.ts +93 -0
- package/src/harness/service-graph.test.ts +97 -0
- package/src/harness/service-graph.ts +97 -0
- package/src/harness/volume-paths.test.ts +102 -0
- package/src/harness/volume-paths.ts +112 -0
- package/src/ids.ts +89 -0
- package/src/index.ts +2767 -0
- package/src/ingress.ts +305 -0
- package/src/inspect.ts +739 -0
- package/src/locator.ts +716 -0
- package/src/mobile.ts +138 -0
- package/src/record-secrets.ts +41 -0
- package/src/recorder.ts +856 -0
- package/src/redis.ts +202 -0
- package/src/replay-bundle.ts +108 -0
- package/src/resolver.ts +348 -0
- package/src/s3.ts +333 -0
- package/src/sql.ts +243 -0
- package/src/terminal.ts +740 -0
- package/src/url-match.ts +67 -0
- package/src/vendor/rrweb-plugin-console-record.umd.js +521 -0
- 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
|
+
});
|