siltrun 0.1.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.
@@ -0,0 +1,330 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { join } from "node:path";
3
+ import { parseDeployArgs, runDeploy, DEFAULT_INTAKE_URL, type DeployLogger } from "./deploy.ts";
4
+ import { bundleContract } from "./bundle.ts";
5
+ import { ArgError } from "./args.ts";
6
+ import type { DeployStatus } from "./deploy-client.ts";
7
+
8
+ // A real example contract — bundled with the REAL bundler so raw-body assertions are against
9
+ // genuine bundled JS, not a synthetic string.
10
+ const HELLO_ROOM = join(import.meta.dir, "..", "..", "..", "examples", "hello-room", "room.ts");
11
+
12
+ // ── capturing logger ────────────────────────────────────────────────────────
13
+ function capLogger(): { logger: DeployLogger; lines: string[]; text: () => string } {
14
+ const lines: string[] = [];
15
+ const push = (m?: string) => {
16
+ lines.push(m ?? "");
17
+ };
18
+ return {
19
+ logger: { info: push, warn: push, error: push, plain: push },
20
+ lines,
21
+ text: () => lines.join("\n"),
22
+ };
23
+ }
24
+
25
+ // ── a mock intake worker that plays the v0 contract ─────────────────────────
26
+ interface MockOpts {
27
+ failWith?: string; // if set, GET reports failed with this error
28
+ stayProvisioning?: boolean; // if set, GET never reaches live (for the timeout path)
29
+ }
30
+ function makeMockIntake(opts: MockOpts = {}) {
31
+ const received = {
32
+ posts: 0,
33
+ gets: 0,
34
+ room: undefined as string | undefined,
35
+ body: undefined as string | undefined,
36
+ contentType: undefined as string | undefined,
37
+ auth: undefined as string | undefined,
38
+ };
39
+ let getCount = 0;
40
+ const server = Bun.serve({
41
+ port: 0,
42
+ async fetch(req) {
43
+ const url = new URL(req.url);
44
+ if (req.method === "POST" && url.pathname === "/v0/deploy") {
45
+ received.posts++;
46
+ received.room = url.searchParams.get("room") ?? undefined;
47
+ received.body = await req.text();
48
+ received.contentType = req.headers.get("content-type") ?? undefined;
49
+ received.auth = req.headers.get("authorization") ?? undefined;
50
+ return Response.json(
51
+ { deployId: "dep_test_1", room: received.room, status: "queued" as DeployStatus },
52
+ { status: 201 },
53
+ );
54
+ }
55
+ if (req.method === "GET" && url.pathname.startsWith("/v0/deploy/")) {
56
+ received.gets++;
57
+ getCount++;
58
+ if (opts.failWith) {
59
+ return Response.json({
60
+ deployId: "dep_test_1",
61
+ room: received.room,
62
+ status: "failed" as DeployStatus,
63
+ error: opts.failWith,
64
+ });
65
+ }
66
+ if (!opts.stayProvisioning && getCount >= 2) {
67
+ return Response.json({
68
+ deployId: "dep_test_1",
69
+ room: received.room,
70
+ status: "live" as DeployStatus,
71
+ url: `https://relay.silt.digitalpine.io:4440/room/${received.room}`,
72
+ doctorVerdict: "SILT-DOCTOR: GREEN (300-tick replay deterministic)",
73
+ });
74
+ }
75
+ return Response.json({
76
+ deployId: "dep_test_1",
77
+ room: received.room,
78
+ status: "provisioning" as DeployStatus,
79
+ });
80
+ }
81
+ return new Response("not found", { status: 404 });
82
+ },
83
+ });
84
+ return { server, url: `http://localhost:${server.port}`, received, stop: () => server.stop(true) };
85
+ }
86
+
87
+ const noSleep = () => Promise.resolve();
88
+
89
+ // ── arg parsing ─────────────────────────────────────────────────────────────
90
+ describe("parseDeployArgs", () => {
91
+ test("requires a contract positional", () => {
92
+ expect(() => parseDeployArgs([], {})).toThrow(ArgError);
93
+ });
94
+ test("derives the room from the contract path (same rule as dev)", () => {
95
+ expect(parseDeployArgs(["examples/hello-room/room.ts"], {}).room).toBe("hello-room");
96
+ });
97
+ test("--room flag wins and is validated", () => {
98
+ expect(parseDeployArgs(["room.ts", "--room", "my-arena"], {}).room).toBe("my-arena");
99
+ expect(() => parseDeployArgs(["room.ts", "--room", "bad name"], {})).toThrow(ArgError);
100
+ });
101
+ test("deployed room names are capped at 12 chars (tap<room> / IFNAMSIZ, DIG-676)", () => {
102
+ // 12 exactly is fine; 13 is rejected with the honest reason, client-side.
103
+ expect(parseDeployArgs(["room.ts", "--room", "abcdefghijkl"], {}).room).toBe("abcdefghijkl");
104
+ expect(() => parseDeployArgs(["room.ts", "--room", "abcdefghijklm"], {})).toThrow(
105
+ /12|interface/,
106
+ );
107
+ // A DERIVED name over the cap is caught too, with the --room way out.
108
+ expect(() => parseDeployArgs(["examples/my-very-long-game-name/room.ts"], {})).toThrow(
109
+ /--room/,
110
+ );
111
+ // Local dev is deliberately uncapped — this rule is deploy-only.
112
+ });
113
+ test("SILT_ROOM env applies when no flag", () => {
114
+ expect(parseDeployArgs(["room.ts"], { SILT_ROOM: "env-room" }).room).toBe("env-room");
115
+ });
116
+ test("baked default intake URL is a concrete https worker URL (filled at integration)", () => {
117
+ expect(DEFAULT_INTAKE_URL).toMatch(/^https:\/\//);
118
+ expect(DEFAULT_INTAKE_URL).not.toContain("PLACEHOLDER");
119
+ });
120
+ });
121
+
122
+ // ── the gate + upload + poll happy path ─────────────────────────────────────
123
+ describe("runDeploy — happy path", () => {
124
+ test("bundle+doctor gate runs, POSTs RAW bundle, polls, prints the live URL", async () => {
125
+ const mock = makeMockIntake();
126
+ const cap = capLogger();
127
+
128
+ // Real bundler (proves reuse), captured so we can byte-compare the uploaded body.
129
+ let capturedBundlePath = "";
130
+ let bundleCalls = 0;
131
+ const bundle = async (contract: string, out: string) => {
132
+ bundleCalls++;
133
+ const p = await bundleContract(contract, out);
134
+ capturedBundlePath = out;
135
+ return p;
136
+ };
137
+ // Doctor injected GREEN + spied so we can assert the gate ran (and ran before any HTTP).
138
+ let doctorCalledAt = -1;
139
+ const doctor = async () => {
140
+ doctorCalledAt = mock.received.posts; // must be 0 — no POST yet when the doctor runs
141
+ return { status: "ok" as const, note: "300-tick replay matched" };
142
+ };
143
+
144
+ const result = await runDeploy(
145
+ { contract: HELLO_ROOM, room: "hello-room" },
146
+ {
147
+ env: { SILT_DEPLOY_TOKEN: "tester-token", SILT_DEPLOY_URL: mock.url },
148
+ fetchImpl: fetch,
149
+ sleep: noSleep,
150
+ bundle,
151
+ doctor,
152
+ logger: cap.logger,
153
+ },
154
+ );
155
+
156
+ // gate ran, and ran BEFORE any upload
157
+ expect(bundleCalls).toBe(1);
158
+ expect(doctorCalledAt).toBe(0);
159
+
160
+ // POSTed exactly once, to the right room, with Bearer + text/javascript
161
+ expect(mock.received.posts).toBe(1);
162
+ expect(mock.received.room).toBe("hello-room");
163
+ expect(mock.received.auth).toBe("Bearer tester-token");
164
+ expect(mock.received.contentType).toBe("text/javascript");
165
+
166
+ // RAW body: byte-identical to the bundle file, NOT base64
167
+ const bundleText = await Bun.file(capturedBundlePath).text();
168
+ expect(mock.received.body).toBe(bundleText);
169
+ expect(mock.received.body).not.toBe(btoa(bundleText));
170
+ expect(mock.received.body).toContain("clamp"); // a real source identifier survives bundling
171
+
172
+ // polled to live and surfaced the URL
173
+ expect(mock.received.gets).toBeGreaterThanOrEqual(2);
174
+ expect(result.ok).toBe(true);
175
+ expect(result.status).toBe("live");
176
+ expect(result.url).toBe("https://relay.silt.digitalpine.io:4440/room/hello-room");
177
+ expect(cap.text()).toContain("https://relay.silt.digitalpine.io:4440/room/hello-room");
178
+
179
+ // the tester never constructs a URL: the check/play URL is printed with the room
180
+ // pre-filled and CORRECTLY URL-ENCODED (CONTROL-PLANE v0.2 §C).
181
+ const roomUrl = "https://relay.silt.digitalpine.io:4440/room/hello-room";
182
+ const expectedCheckUrl = `${mock.url}/check?room=${encodeURIComponent(roomUrl)}`;
183
+ expect(cap.text()).toContain(expectedCheckUrl);
184
+ // the room segment must be percent-encoded (":" -> "%3A", "/" -> "%2F"), never raw in the query
185
+ expect(cap.text()).toContain("/check?room=https%3A%2F%2Frelay.silt.digitalpine.io%3A4440%2Froom%2Fhello-room");
186
+
187
+ // the server-side determinism verdict is surfaced to the tester (CONTROL-PLANE v0.2 §B)
188
+ expect(cap.text()).toContain("SILT-DOCTOR: GREEN (300-tick replay deterministic)");
189
+ expect(cap.text()).toContain("determinism verified server-side");
190
+
191
+ mock.stop();
192
+ });
193
+ });
194
+
195
+ // ── drift aborts before ANY http ────────────────────────────────────────────
196
+ describe("runDeploy — determinism drift", () => {
197
+ test("a drifting contract aborts BEFORE any HTTP call", async () => {
198
+ const cap = capLogger();
199
+ let fetchCalls = 0;
200
+ const spyFetch = ((..._args: Parameters<typeof fetch>) => {
201
+ fetchCalls++;
202
+ return Promise.reject(new Error("network must not be touched on drift"));
203
+ }) as typeof fetch;
204
+
205
+ let bundleCalled = false;
206
+ const bundle = async (_c: string, out: string) => {
207
+ bundleCalled = true;
208
+ await Bun.write(out, "export default { tick(s){return s} }");
209
+ return out;
210
+ };
211
+ const doctor = async () => ({
212
+ status: "drift" as const,
213
+ note: "determinism drift detected",
214
+ output: "✗ FIRST DRIFT at tick 42 — field ships.x",
215
+ });
216
+
217
+ const result = await runDeploy(
218
+ { contract: HELLO_ROOM, room: "hello-room" },
219
+ {
220
+ env: { SILT_DEPLOY_TOKEN: "tester-token", SILT_DEPLOY_URL: "http://127.0.0.1:1/unused" },
221
+ fetchImpl: spyFetch,
222
+ sleep: noSleep,
223
+ bundle,
224
+ doctor,
225
+ logger: cap.logger,
226
+ },
227
+ );
228
+
229
+ expect(bundleCalled).toBe(true); // gate ran
230
+ expect(fetchCalls).toBe(0); // …then aborted before any upload
231
+ expect(result.ok).toBe(false);
232
+ expect(result.status).toBe("aborted");
233
+ expect(cap.text()).toContain("drift");
234
+ });
235
+ });
236
+
237
+ // ── missing token → friendly error, no http ─────────────────────────────────
238
+ describe("runDeploy — missing token", () => {
239
+ test("no SILT_DEPLOY_TOKEN → friendly error, no HTTP, no bundle", async () => {
240
+ const cap = capLogger();
241
+ let fetchCalls = 0;
242
+ const spyFetch = (() => {
243
+ fetchCalls++;
244
+ return Promise.reject(new Error("should not fetch without a token"));
245
+ }) as unknown as typeof fetch;
246
+ let bundleCalled = false;
247
+ const bundle = async (_c: string, out: string) => {
248
+ bundleCalled = true;
249
+ return out;
250
+ };
251
+
252
+ const result = await runDeploy(
253
+ { contract: HELLO_ROOM, room: "hello-room" },
254
+ {
255
+ env: {}, // no token
256
+ fetchImpl: spyFetch,
257
+ sleep: noSleep,
258
+ bundle,
259
+ doctor: async () => ({ status: "ok" as const, note: "n/a" }),
260
+ logger: cap.logger,
261
+ },
262
+ );
263
+
264
+ expect(result.ok).toBe(false);
265
+ expect(result.status).toBe("aborted");
266
+ expect(fetchCalls).toBe(0);
267
+ expect(bundleCalled).toBe(false);
268
+ expect(cap.text()).toContain("SILT_DEPLOY_TOKEN");
269
+ });
270
+ });
271
+
272
+ // ── server-reported failure surfaces honestly ───────────────────────────────
273
+ describe("runDeploy — server failure", () => {
274
+ test("server 'failed' status surfaces the server error, no false success", async () => {
275
+ const mock = makeMockIntake({ failWith: "beta at capacity — no free ports in 4440-4449" });
276
+ const cap = capLogger();
277
+
278
+ const result = await runDeploy(
279
+ { contract: HELLO_ROOM, room: "hello-room" },
280
+ {
281
+ env: { SILT_DEPLOY_TOKEN: "tester-token", SILT_DEPLOY_URL: mock.url },
282
+ fetchImpl: fetch,
283
+ sleep: noSleep,
284
+ bundle: bundleContract,
285
+ doctor: async () => ({ status: "ok" as const, note: "ok" }),
286
+ logger: cap.logger,
287
+ },
288
+ );
289
+
290
+ expect(result.ok).toBe(false);
291
+ expect(result.status).toBe("failed");
292
+ expect(result.error).toContain("beta at capacity");
293
+ expect(cap.text()).toContain("beta at capacity");
294
+ // Vercel bar: operator-only reasons must still leave the tester a route —
295
+ // reference id + support channel, on every server failure.
296
+ expect(cap.text()).toContain("dep_test_1");
297
+ expect(cap.text()).toContain("#support");
298
+ mock.stop();
299
+ });
300
+ });
301
+
302
+ // ── honest timeout ──────────────────────────────────────────────────────────
303
+ describe("runDeploy — timeout", () => {
304
+ test("never-live deploy times out honestly (no false success)", async () => {
305
+ const mock = makeMockIntake({ stayProvisioning: true });
306
+ const cap = capLogger();
307
+
308
+ const result = await runDeploy(
309
+ { contract: HELLO_ROOM, room: "hello-room" },
310
+ {
311
+ env: { SILT_DEPLOY_TOKEN: "tester-token", SILT_DEPLOY_URL: mock.url },
312
+ fetchImpl: fetch,
313
+ sleep: noSleep,
314
+ bundle: bundleContract,
315
+ doctor: async () => ({ status: "ok" as const, note: "ok" }),
316
+ logger: cap.logger,
317
+ pollIntervalMs: 1,
318
+ pollBudgetMs: 8, // tiny budget + noop sleep → a couple polls then honest timeout
319
+ },
320
+ );
321
+
322
+ expect(result.ok).toBe(false);
323
+ expect(result.status).toBe("timeout");
324
+ expect(cap.text().toLowerCase()).toContain("stopped watching");
325
+ // Vercel bar: the tester leaves with a reference + a place to report.
326
+ expect(cap.text()).toContain("dep_test_1");
327
+ expect(cap.text()).toContain("#support");
328
+ mock.stop();
329
+ });
330
+ });