1// @effect-diagnostics nodeBuiltinImport:off globalFetch:off - Exercises real HTTP bodies and tar archives, including deterministic connection interruption.
2import * as NodeAssert from "node:assert/strict";
3import * as NodeCrypto from "node:crypto";
4import * as NodeChildProcess from "node:child_process";
5import * as NodeFSP from "node:fs/promises";
6import * as NodeHttp from "node:http";
7import * as NodeOS from "node:os";
8import * as NodePath from "node:path";
9import * as NodeUtil from "node:util";
10import * as NodeURL from "node:url";
11import { afterEach, it, vi } from "vite-plus/test";
12
13import { prepareRuntime } from "./container-runtime.ts";
14
15const run = NodeUtil.promisify(NodeChildProcess.execFile);
16const cleanups: Array<() => Promise<void>> = [];
17
18afterEach(async () => {
19 vi.restoreAllMocks();
20 for (const cleanup of cleanups.splice(0).toReversed()) await cleanup();
21});
22
23async function fixture() {
24 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "knife-container-"));
25 cleanups.push(() => NodeFSP.rm(root, { recursive: true, force: true }));
26 const baseDirectory = NodePath.join(root, "base");
27 const cacheDirectory = NodePath.join(root, "cache");
28 await NodeFSP.mkdir(NodePath.join(baseDirectory, "apps/server/node_modules/container-native"), {
29 recursive: true,
30 });
31 await NodeFSP.writeFile(NodePath.join(baseDirectory, "knife-server-runtime"), "a".repeat(64));
32 await NodeFSP.writeFile(
33 NodePath.join(baseDirectory, "apps/server/node_modules/container-native/package.json"),
34 '{"main":"index.js"}',
35 );
36 await NodeFSP.writeFile(
37 NodePath.join(baseDirectory, "apps/server/node_modules/container-native/index.js"),
38 'module.exports = "native dependency resolved";',
39 );
40 const serverRoot = NodePath.join(root, "server");
41 const uiRoot = NodePath.join(root, "ui");
42 const assetsRoot = NodePath.join(root, "assets");
43 await NodeFSP.mkdir(NodePath.join(serverRoot, "apps/server/dist"), { recursive: true });
44 await NodeFSP.mkdir(uiRoot);
45 await NodeFSP.mkdir(NodePath.join(assetsRoot, "fonts"), { recursive: true });
46 await NodeFSP.writeFile(NodePath.join(assetsRoot, "fonts/ui.woff2"), "static font bytes");
47 await NodeFSP.writeFile(
48 NodePath.join(serverRoot, "apps/server/dist/bin.mjs"),
49 'import native from "container-native"; console.log(native);',
50 );
51 await NodeFSP.writeFile(NodePath.join(uiRoot, "index.html"), "<html>latest client</html>");
52
53 const archive = async (name: string, directory: string, members: string[]) => {
54 const file = `${name}.tar.gz`;
55 const target = NodePath.join(root, file);
56 await run("tar", ["-czf", target, "-C", directory, ...members]);
57 const bytes = await NodeFSP.readFile(target);
58 return {
59 artifact: {
60 file,
61 size: bytes.length,
62 sha256: NodeCrypto.createHash("sha256").update(bytes).digest("hex"),
63 },
64 bytes,
65 };
66 };
67 const server = await archive("server", serverRoot, ["."]);
68 const ui = await archive("ui", uiRoot, ["."]);
69 const assets = await archive("assets", assetsRoot, ["."]);
70 const release = {
71 version: "0.0.123",
72 server: { ...server.artifact, version: "0.0.123", runtime: "a".repeat(64) },
73 ui: ui.artifact,
74 assets: assets.artifact,
75 desktop: {},
76 };
77 const requests: string[] = [];
78 const statuses = new Map<string, number>();
79 const interrupted = new Map<string, NodeHttp.ServerResponse | undefined>();
80 const responses = new Map<string, Buffer>([
81 [
82 "/?ls=",
83 Buffer.from(
84 JSON.stringify({
85 files: [
86 { href: "0.0.9.json" },
87 { href: "0.0.123.json" },
88 { href: "0.0.100-mac.yml" },
89 { href: "notes.json" },
90 ],
91 }),
92 ),
93 ],
94 ["/0.0.123.json", Buffer.from(JSON.stringify(release))],
95 ["/server.tar.gz", server.bytes],
96 ["/ui.tar.gz", ui.bytes],
97 ["/assets.tar.gz", assets.bytes],
98 ]);
99 const listener = NodeHttp.createServer((request, response) => {
100 requests.push(request.url ?? "");
101 const bytes = responses.get(request.url ?? "");
102 if (bytes && interrupted.has(request.url ?? "")) {
103 interrupted.set(request.url ?? "", response);
104 response.writeHead(200, { "Content-Length": bytes.length });
105 response.write(bytes.subarray(0, Math.max(1, Math.floor(bytes.length / 2))));
106 return;
107 }
108 response.writeHead(statuses.get(request.url ?? "") ?? (bytes ? 200 : 404));
109 response.end(bytes);
110 });
111 await new Promise<void>((resolve) => listener.listen(0, "127.0.0.1", resolve));
112 cleanups.push(
113 () =>
114 new Promise<void>((resolve, reject) =>
115 listener.close((error) => (error ? reject(error) : resolve())),
116 ),
117 );
118 const address = listener.address();
119 if (!address || typeof address === "string") throw new Error("HTTP fixture has no port.");
120 return {
121 root,
122 input: { baseDirectory, cacheDirectory, feedUrl: `http://127.0.0.1:${address.port}` },
123 release,
124 responses,
125 requests,
126 statuses,
127 interrupted,
128 archive,
129 };
130}
131
132it("installs the published server and client, resolving native modules from the base image", async () => {
133 const { input, requests } = await fixture();
134 const entrypoint = await prepareRuntime(input);
135 const { stdout } = await run(process.execPath, [entrypoint]);
136 NodeAssert.equal(stdout.trim(), "native dependency resolved");
137 NodeAssert.equal(
138 await NodeFSP.readFile(
139 NodePath.join(input.cacheDirectory, "current/apps/server/dist/client/index.html"),
140 "utf8",
141 ),
142 "<html>latest client</html>",
143 );
144 NodeAssert.equal(
145 await NodeFSP.readFile(NodePath.join(input.cacheDirectory, "current/version"), "utf8"),
146 "0.0.123",
147 );
148 await prepareRuntime(input);
149 NodeAssert.deepEqual(requests, [
150 "/?ls=",
151 "/0.0.123.json",
152 "/server.tar.gz",
153 "/assets.tar.gz",
154 "/ui.tar.gz",
155 "/?ls=",
156 "/0.0.123.json",
157 ]);
158});
159
160it("keeps the compiled server version when a newer feed manifest includes an older native build", async () => {
161 const { input, root, responses, release, archive } = await fixture();
162 await NodeFSP.writeFile(
163 NodePath.join(root, "server/apps/server/dist/bin.mjs"),
164 'if (process.env.T3CODE_VERSION_OVERRIDE !== "0.0.123") process.exit(23);',
165 );
166 const server = await archive("versioned-server", NodePath.join(root, "server"), ["."]);
167 const newer = {
168 ...release,
169 version: "0.0.124",
170 server: { ...release.server, ...server.artifact },
171 };
172 responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.124.json" }] })));
173 responses.set("/0.0.124.json", Buffer.from(JSON.stringify(newer)));
174 responses.set("/versioned-server.tar.gz", server.bytes);
175
176 await prepareRuntime(input);
177
178 NodeAssert.equal(
179 await NodeFSP.readFile(NodePath.join(input.cacheDirectory, "current/version"), "utf8"),
180 "0.0.123",
181 );
182});
183
184it("keeps the installed runtime when a later archive fails its checksum", async () => {
185 const { input, responses, release } = await fixture();
186 const original = await prepareRuntime(input);
187 const changed = {
188 ...release,
189 version: "0.0.124",
190 server: { ...release.server, sha256: "f".repeat(64) },
191 };
192 responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.124.json" }] })));
193 responses.set("/0.0.124.json", Buffer.from(JSON.stringify(changed)));
194 await NodeAssert.rejects(prepareRuntime(input), /checksum verification/);
195 NodeAssert.equal(
196 await NodeFSP.realpath(NodePath.join(input.cacheDirectory, "current/apps/server/dist/bin.mjs")),
197 await NodeFSP.realpath(original),
198 );
199 NodeAssert.equal(
200 (await NodeFSP.readdir(input.cacheDirectory)).some((name) => name.startsWith(".download-")),
201 false,
202 );
203});
204
205it("rejects native dependency changes before downloading either archive", async () => {
206 const { input, responses, release, requests } = await fixture();
207 responses.set(
208 "/0.0.123.json",
209 Buffer.from(
210 JSON.stringify({ ...release, server: { ...release.server, runtime: "b".repeat(64) } }),
211 ),
212 );
213 await NodeAssert.rejects(prepareRuntime(input), /newer container image/);
214 NodeAssert.deepEqual(requests, ["/?ls=", "/0.0.123.json"]);
215});
216
217it("rejects artifact paths that leave the public release directory", async () => {
218 const { input, responses, release } = await fixture();
219 responses.set(
220 "/0.0.123.json",
221 Buffer.from(JSON.stringify({ ...release, ui: { ...release.ui, file: "../ui.tar.gz" } })),
222 );
223 await NodeAssert.rejects(prepareRuntime(input), /ForkReleaseManifest|Expected|file|Invalid/);
224});
225
226it("lets the launcher use its installed runtime while the release share is unavailable", async () => {
227 const { input, statuses } = await fixture();
228 statuses.set("/?ls=", 503);
229 const result = await run(
230 process.execPath,
231 [NodeURL.fileURLToPath(new URL("./container-runtime.ts", import.meta.url))],
232 {
233 env: { ...process.env, KNIFE_UPDATE_URL: input.feedUrl },
234 },
235 );
236 NodeAssert.match(result.stderr, /Starting the installed runtime/);
237});
238
239it("keeps the installed runtime when downloaded server code cannot start", async () => {
240 const { input, root, responses, release } = await fixture();
241 const original = await prepareRuntime(input);
242 await NodeFSP.writeFile(
243 NodePath.join(root, "server/apps/server/dist/bin.mjs"),
244 'throw new Error("broken release");',
245 );
246 const archive = NodePath.join(root, "broken.tar.gz");
247 await run("tar", ["-czf", archive, "-C", NodePath.join(root, "server"), "."]);
248 const bytes = await NodeFSP.readFile(archive);
249 const changed = {
250 ...release,
251 version: "0.0.124",
252 server: {
253 ...release.server,
254 size: bytes.length,
255 sha256: NodeCrypto.createHash("sha256").update(bytes).digest("hex"),
256 },
257 };
258 responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.124.json" }] })));
259 responses.set("/0.0.124.json", Buffer.from(JSON.stringify(changed)));
260 responses.set("/server.tar.gz", bytes);
261 await NodeAssert.rejects(prepareRuntime(input), /broken release/);
262 NodeAssert.equal(
263 await NodeFSP.realpath(NodePath.join(input.cacheDirectory, "current/apps/server/dist/bin.mjs")),
264 await NodeFSP.realpath(original),
265 );
266});
267
268it("downloads only changed UI or static assets and bounds the verified artifact cache", async () => {
269 const { input, root, responses, requests, release, archive } = await fixture();
270 const original = await prepareRuntime(input);
271 await NodeFSP.writeFile(NodePath.join(root, "ui/index.html"), "<html>updated client</html>");
272 const nextUi = await archive("next-ui", NodePath.join(root, "ui"), ["."]);
273 const next = {
274 ...release,
275 version: "0.0.124",
276 server: { ...release.server, version: "0.0.124" },
277 ui: nextUi.artifact,
278 };
279 responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.124.json" }] })));
280 responses.set("/0.0.124.json", Buffer.from(JSON.stringify(next)));
281 responses.set("/next-ui.tar.gz", nextUi.bytes);
282 const nextEntrypoint = await prepareRuntime(input);
283 NodeAssert.notEqual(nextEntrypoint, original);
284 NodeAssert.equal(requests.filter((url) => url === "/server.tar.gz").length, 1);
285 NodeAssert.equal(requests.filter((url) => url === "/assets.tar.gz").length, 1);
286 NodeAssert.equal(requests.filter((url) => url === "/next-ui.tar.gz").length, 1);
287 NodeAssert.equal(
288 await NodeFSP.readFile(
289 NodePath.join(input.cacheDirectory, "current/apps/server/dist/client/index.html"),
290 "utf8",
291 ),
292 "<html>updated client</html>",
293 );
294 NodeAssert.equal(
295 await NodeFSP.readFile(
296 NodePath.join(input.cacheDirectory, "current/apps/server/dist/client/fonts/ui.woff2"),
297 "utf8",
298 ),
299 "static font bytes",
300 );
301
302 await NodeFSP.writeFile(NodePath.join(root, "assets/fonts/ui.woff2"), "updated font bytes");
303 const nextAssets = await archive("next-assets", NodePath.join(root, "assets"), ["."]);
304 const final = {
305 ...next,
306 version: "0.0.125",
307 server: { ...next.server, version: "0.0.125" },
308 assets: nextAssets.artifact,
309 };
310 responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.125.json" }] })));
311 responses.set("/0.0.125.json", Buffer.from(JSON.stringify(final)));
312 responses.set("/next-assets.tar.gz", nextAssets.bytes);
313 const finalEntrypoint = await prepareRuntime(input);
314 NodeAssert.notEqual(finalEntrypoint, nextEntrypoint);
315 NodeAssert.equal(requests.filter((url) => url === "/server.tar.gz").length, 1);
316 NodeAssert.equal(requests.filter((url) => url === "/next-ui.tar.gz").length, 1);
317 NodeAssert.equal(requests.filter((url) => url === "/next-assets.tar.gz").length, 1);
318 NodeAssert.equal(
319 await NodeFSP.readFile(
320 NodePath.join(input.cacheDirectory, "current/apps/server/dist/client/fonts/ui.woff2"),
321 "utf8",
322 ),
323 "updated font bytes",
324 );
325 const cached = await NodeFSP.readdir(NodePath.join(input.cacheDirectory, "artifacts"));
326 NodeAssert.deepEqual(
327 cached.sort(),
328 [final.server, final.assets, final.ui].map(({ sha256 }) => `${sha256}.tar.gz`).sort(),
329 );
330 NodeAssert.equal(
331 (await NodeFSP.readdir(input.cacheDirectory)).filter((name) => name.startsWith("release-"))
332 .length,
333 1,
334 );
335});
336
337it("prefetches updates without changing the running release, then activates on restart", async () => {
338 const { input, root, responses, requests, release, archive } = await fixture();
339 const original = await prepareRuntime(input);
340 const current = NodePath.join(input.cacheDirectory, "current");
341 const pointer = await NodeFSP.readlink(current);
342 let pending = original;
343 for (const version of ["0.0.124", "0.0.125"]) {
344 await NodeFSP.writeFile(NodePath.join(root, "ui/index.html"), `<html>${version}</html>`);
345 const nextUi = await archive(`ui-${version}`, NodePath.join(root, "ui"), ["."]);
346 const next = {
347 ...release,
348 version,
349 server: { ...release.server, version },
350 ui: nextUi.artifact,
351 };
352 responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: `${version}.json` }] })));
353 responses.set(`/${version}.json`, Buffer.from(JSON.stringify(next)));
354 responses.set(`/${nextUi.artifact.file}`, nextUi.bytes);
355 const staged = await prepareRuntime({ ...input, activate: false });
356 NodeAssert.notEqual(staged, original);
357 if (pending !== original) await NodeAssert.rejects(NodeFSP.access(pending));
358 pending = staged;
359 NodeAssert.equal(await NodeFSP.readlink(current), pointer);
360 NodeAssert.equal(await NodeFSP.readFile(NodePath.join(current, "version"), "utf8"), "0.0.123");
361 NodeAssert.equal(
362 await NodeFSP.readFile(NodePath.join(current, "apps/server/dist/client/index.html"), "utf8"),
363 "<html>latest client</html>",
364 );
365 NodeAssert.equal(
366 (await run(process.execPath, [original])).stdout.trim(),
367 "native dependency resolved",
368 );
369 NodeAssert.equal(
370 (await NodeFSP.readdir(input.cacheDirectory)).filter((name) => name.startsWith("release-"))
371 .length,
372 2,
373 );
374 }
375 const downloads = requests.length;
376 NodeAssert.equal(await prepareRuntime(input), pending);
377 NodeAssert.deepEqual(requests.slice(downloads), ["/?ls=", "/0.0.125.json"]);
378 NodeAssert.equal(await NodeFSP.readFile(NodePath.join(current, "version"), "utf8"), "0.0.125");
379 await NodeAssert.rejects(NodeFSP.access(original));
380});
381
382it("preserves the running version when prefetching identical artifacts under a newer release", async () => {
383 const { input, responses, release } = await fixture();
384 await prepareRuntime(input);
385 responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.124.json" }] })));
386 responses.set("/0.0.124.json", Buffer.from(JSON.stringify({ ...release, version: "0.0.124" })));
387 await prepareRuntime({ ...input, activate: false });
388 NodeAssert.equal(
389 await NodeFSP.readFile(NodePath.join(input.cacheDirectory, "current/version"), "utf8"),
390 "0.0.123",
391 );
392});
393
394it("lets the actual serve --version command exit with the background downloader loaded", async () => {
395 const { input, root, requests } = await fixture();
396 const result = await run(
397 process.execPath,
398 [
399 "--import",
400 NodeURL.fileURLToPath(new URL("./container-runtime.ts", import.meta.url)),
401 NodeURL.fileURLToPath(new URL("../apps/server/src/bin.ts", import.meta.url)),
402 "serve",
403 "--version",
404 ],
405 {
406 env: {
407 ...process.env,
408 KNIFE_UPDATE_URL: input.feedUrl,
409 T3CODE_HOME: NodePath.join(root, "home"),
410 },
411 timeout: 10_000,
412 },
413 );
414 NodeAssert.match(result.stdout, /^t3 v/);
415 NodeAssert.deepEqual(requests, []);
416});
417
418it.each(["/?ls=", "/0.0.124.json", "/server.tar.gz"])(
419 "classifies a mid-body disconnect at %s as a transient outage while preserving the installed release",
420 async (interruptedPath) => {
421 const { input, release, responses, interrupted } = await fixture();
422 const original = await prepareRuntime(input);
423 responses.set("/?ls=", Buffer.from(JSON.stringify({ files: [{ href: "0.0.124.json" }] })));
424 responses.set(
425 "/0.0.124.json",
426 Buffer.from(
427 JSON.stringify({
428 ...release,
429 version: "0.0.124",
430 server: { ...release.server, sha256: "f".repeat(64) },
431 }),
432 ),
433 );
434 interrupted.set(interruptedPath, undefined);
435 const fetch = globalThis.fetch;
436 vi.spyOn(globalThis, "fetch").mockImplementation(async (url, options) => {
437 const response = await fetch(url, options);
438 const requested = new URL(response.url);
439 interrupted.get(`${requested.pathname}${requested.search}`)?.destroy();
440 return response;
441 });
442 await NodeAssert.rejects(prepareRuntime(input), /release share is unavailable/);
443 NodeAssert.equal(
444 await NodeFSP.realpath(
445 NodePath.join(input.cacheDirectory, "current/apps/server/dist/bin.mjs"),
446 ),
447 await NodeFSP.realpath(original),
448 );
449 NodeAssert.equal(
450 (await NodeFSP.readdir(input.cacheDirectory)).some((name) => name.startsWith(".download-")),
451 false,
452 );
453 },
454);
455
456it("keeps malformed JSON fatal instead of treating it as a connection outage", async () => {
457 const { input, responses } = await fixture();
458 responses.set("/0.0.123.json", Buffer.from("{"));
459 await NodeAssert.rejects(prepareRuntime(input), SyntaxError);
460});