1// @effect-diagnostics nodeBuiltinImport:off - Fixtures create real archives to exercise extraction and atomic activation.
2import * as NodeCrypto from "node:crypto";
3import * as NodeFSP from "node:fs/promises";
4import * as NodePath from "node:path";
5import * as NodeChildProcess from "node:child_process";
6import * as NodeHttp from "node:http";
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import { assert, it } from "@effect/vitest";
9import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";
12import { FetchHttpClient } from "effect/http";
13import { ForkReleaseManifest } from "@t3tools/shared/forkUpdateDirectory";
14
15import * as DesktopConfig from "../app/DesktopConfig.ts";
16import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
17import * as DesktopLocalUi from "./DesktopLocalUi.ts";
18import * as DesktopForkUpdates from "./DesktopForkUpdates.ts";
19import { readActiveForkRuntime } from "./forkRuntime.ts";
20
21const shell = "a".repeat(64);
22const runtime = "b".repeat(64);
23const nextRuntime = "c".repeat(64);
24const version = "0.0.2000000000";
25
26const withUpdater = <A, E>(
27 test: (input: {
28 updater: DesktopForkUpdates.DesktopForkUpdates["Service"];
29 release: typeof ForkReleaseManifest.Type;
30 nextUi: (typeof ForkReleaseManifest.Type)["ui"];
31 base: URL;
32 requested: string[];
33 applied: string[];
34 files: Map<string, Uint8Array>;
35 environment: DesktopEnvironment.DesktopEnvironment["Service"];
36 }) => Effect.Effect<A, E>,
37 platform: NodeJS.Platform = "darwin",
38) =>
39 Effect.gen(function* () {
40 const fs = yield* FileSystem.FileSystem;
41 const home = yield* fs.makeTempDirectoryScoped();
42 const resources = NodePath.join(home, "resources");
43 const bundled = NodePath.join(resources, "app.asar");
44 yield* fs.makeDirectory(bundled, { recursive: true });
45 yield* fs.writeFileString(
46 NodePath.join(bundled, "knife-runtime.json"),
47 JSON.stringify({ version: "0.0.1", sha256: runtime, shell }),
48 );
49 const files = new Map<string, Uint8Array>();
50 const archive = (kind: string, contents: Record<string, string>) =>
51 Effect.promise(async () => {
52 const directory = NodePath.join(home, kind);
53 await NodeFSP.mkdir(directory);
54 for (const [name, content] of Object.entries(contents)) {
55 const file = NodePath.join(directory, name);
56 await NodeFSP.mkdir(NodePath.dirname(file), { recursive: true });
57 await NodeFSP.writeFile(file, content);
58 }
59 const file = `${kind}.tar.gz`;
60 NodeChildProcess.execFileSync("tar", [
61 "-czf",
62 NodePath.join(home, file),
63 "-C",
64 directory,
65 ".",
66 ]);
67 const bytes = await NodeFSP.readFile(NodePath.join(home, file));
68 files.set(file, bytes);
69 return {
70 file,
71 size: bytes.length,
72 sha256: NodeCrypto.createHash("sha256").update(bytes).digest("hex"),
73 };
74 });
75 const ui = yield* archive("ui", {
76 "index.html": '<script src="/app.js"></script>',
77 "app.js": "new UI",
78 ".vite/manifest.json": '{"app":{"file":"app.js"}}',
79 });
80 const assets = yield* archive("assets", { "images/logo.svg": "static asset" });
81 const nextUi = yield* archive("ui-next", {
82 "index.html": '<script src="/app-next.js"></script>',
83 "app-next.js": "next UI",
84 ".vite/manifest.json": '{"app":{"file":"app-next.js"}}',
85 });
86 const code = yield* archive("code", {
87 "apps/desktop/dist-electron/main.cjs": "new main",
88 "apps/desktop/dist-electron/preload.cjs": "new preload",
89 "apps/desktop/dist-electron/compileCache.cjs": "cache",
90 [`${platform === "win32" ? "server/" : ""}apps/server/dist/bin.mjs`]:
91 'console.log("fixture");',
92 });
93 const platformKey = `${platform}-${platform === "darwin" ? "arm64" : "x64"}`;
94 const release = {
95 version,
96 ui,
97 assets,
98 server: { ...code, version, runtime: nextRuntime },
99 desktop: {
100 [platformKey]: {
101 version,
102 ui,
103 assets,
104 shell,
105 runtime: nextRuntime,
106 code,
107 app: { ...code, version },
108 },
109 },
110 };
111 files.set(
112 "?ls",
113 new TextEncoder().encode(JSON.stringify({ files: [{ href: `${version}.json` }] })),
114 );
115 const requested: string[] = [];
116 const applied: string[] = [];
117 const server = NodeHttp.createServer((request, response) => {
118 const url = new URL(request.url ?? "/", "http://127.0.0.1");
119 const file = url.searchParams.has("ls") ? "?ls" : url.pathname.split("/").at(-1)!;
120 requested.push(file);
121 const body = file === `${version}.json` ? JSON.stringify(release) : files.get(file);
122 response.writeHead(body === undefined ? 404 : 200).end(body);
123 });
124 yield* Effect.acquireRelease(
125 Effect.promise(() => new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve))),
126 () =>
127 Effect.promise(
128 () =>
129 new Promise<void>((resolve, reject) =>
130 server.close((error) => (error ? reject(error) : resolve())),
131 ),
132 ),
133 );
134 const address = server.address();
135 if (address === null || typeof address === "string")
136 return yield* Effect.die("Missing fixture server address");
137 const base = new URL(`http://127.0.0.1:${address.port}/`);
138 const environmentLayer = DesktopEnvironment.layer({
139 dirname: NodePath.join(bundled, "apps/desktop/dist-electron"),
140 homeDirectory: home,
141 platform,
142 processArch: platform === "darwin" ? "arm64" : "x64",
143 appVersion: "0.0.1",
144 appPath: bundled,
145 resourcesPath: resources,
146 isPackaged: true,
147 runningUnderArm64Translation: false,
148 }).pipe(Layer.provideMerge(DesktopConfig.layerTest({ T3CODE_HOME: home })));
149 return yield* Effect.gen(function* () {
150 const updater = yield* DesktopForkUpdates.make;
151 const environment = yield* DesktopEnvironment.DesktopEnvironment;
152 return yield* test({
153 updater,
154 release,
155 nextUi,
156 base,
157 requested,
158 applied,
159 files,
160 environment,
161 });
162 }).pipe(
163 Effect.provide(FetchHttpClient.layer),
164 Effect.provideService(DesktopLocalUi.DesktopLocalUi, {
165 assetDirectories: Effect.succeed(["/client"] as const),
166 currentVersion: Effect.succeed("0.0.1"),
167 reset: Effect.void,
168 apply: (source, version) =>
169 Effect.promise(async () => {
170 assert.equal(
171 await NodeFSP.readFile(NodePath.join(source, "images/logo.svg"), "utf8"),
172 "static asset",
173 );
174 applied.push(`${source}:${version}`);
175 }),
176 }),
177 Effect.provide(environmentLayer),
178 );
179 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped);
180
181it.effect("downloads verified code and UI before atomically activating an exact shell match", () =>
182 withUpdater(({ updater, base, release, requested, environment }) =>
183 Effect.gen(function* () {
184 assert.deepEqual(yield* updater.check(base), release);
185 assert.equal(yield* updater.prepare(base, release), "code");
186 assert.deepEqual(requested, [
187 "?ls",
188 `${version}.json`,
189 "code.tar.gz",
190 "assets.tar.gz",
191 "ui.tar.gz",
192 ]);
193 assert.equal(readActiveForkRuntime(environment.stateDir, environment.appRoot), undefined);
194 assert.equal(yield* updater.activateCode("0.0.1999999999"), false);
195 assert.equal(yield* updater.activateCode(version), true);
196 const active = readActiveForkRuntime(environment.stateDir, environment.appRoot);
197 assert.equal(active?.version, version);
198 assert.equal(active?.runtime, nextRuntime);
199 assert.equal(active?.shell, shell);
200 }),
201 ),
202);
203
204it.effect("downloads only new UI on a second release with unchanged code and static assets", () =>
205 withUpdater(({ updater, base, release, nextUi, requested, applied }) =>
206 Effect.gen(function* () {
207 const target = release.desktop["darwin-arm64"]!;
208 const matching = { ...release, desktop: { "darwin-arm64": { ...target, runtime } } };
209 assert.equal(yield* updater.prepare(base, matching), "ui");
210 assert.equal(yield* updater.installUi(version), true);
211 assert.equal(applied.length, 1);
212 const next = {
213 ...matching,
214 version: "0.0.2000000001",
215 ui: nextUi,
216 desktop: {
217 "darwin-arm64": {
218 ...matching.desktop["darwin-arm64"]!,
219 version: "0.0.2000000001",
220 ui: nextUi,
221 },
222 },
223 };
224 assert.equal(yield* updater.prepare(base, next), "ui");
225 assert.equal(yield* updater.activateCode(next.version), false);
226 assert.deepEqual(requested, ["assets.tar.gz", "ui.tar.gz", "ui-next.tar.gz"]);
227 }),
228 ),
229);
230
231it.effect("leaves the active pointer unchanged after corrupt or incomplete downloads", () =>
232 withUpdater(({ updater, base, release, files, environment }) =>
233 Effect.gen(function* () {
234 files.set("code.tar.gz", new TextEncoder().encode("corrupt"));
235 const error = yield* updater.prepare(base, release).pipe(Effect.flip);
236 assert.equal(error._tag, "DesktopForkUpdateError");
237 assert.equal(yield* updater.activateCode(version), false);
238 assert.equal(readActiveForkRuntime(environment.stateDir, environment.appRoot), undefined);
239 }),
240 ),
241);
242
243it.effect("requires a signed shell update without downloading code when the shell differs", () =>
244 withUpdater(({ updater, base, release, requested }) =>
245 Effect.gen(function* () {
246 const mismatch = {
247 ...release,
248 desktop: { "darwin-arm64": { ...release.desktop["darwin-arm64"]!, shell: "d".repeat(64) } },
249 };
250 assert.equal(yield* updater.prepare(base, mismatch), "shell");
251 assert.deepEqual(requested, []);
252 }),
253 ),
254);
255
256it.effect("reports an empty public release directory without fetching an unfinished manifest", () =>
257 withUpdater(({ updater, base, files, requested }) =>
258 Effect.gen(function* () {
259 files.set(
260 "?ls",
261 new TextEncoder().encode(JSON.stringify({ files: [{ href: "unfinished.json" }] })),
262 );
263 const error = yield* updater.check(base).pipe(Effect.flip);
264 assert.equal(error.message, "No updates have been published. Try again later.");
265 assert.deepEqual(requested, ["?ls"]);
266 }),
267 ),
268);
269
270it.effect(
271 "preserves the platform's compatible renderer and version when the aggregate is newer",
272 () =>
273 withUpdater(({ updater, base, release, nextUi, requested }) =>
274 Effect.gen(function* () {
275 const aggregate = { ...release, version: "0.0.2000000001", ui: nextUi };
276 assert.equal(yield* updater.prepare(base, aggregate), "code");
277 assert.equal(yield* updater.activateCode(aggregate.version), false);
278 assert.equal(yield* updater.activateCode(version), true);
279 assert.deepEqual(requested, ["code.tar.gz", "assets.tar.gz", "ui.tar.gz"]);
280 }),
281 ),
282);
283
284it.effect.each(["linux", "win32"] as const)(
285 "activates a verified %s code tree and preserves its shell",
286 (platform) =>
287 withUpdater(
288 ({ updater, release, base, environment, requested }) =>
289 Effect.gen(function* () {
290 assert.equal(yield* updater.prepare(base, release), "code");
291 assert.equal(yield* updater.activateCode(version), true);
292 assert.equal(
293 readActiveForkRuntime(environment.stateDir, environment.appRoot, platform)?.version,
294 version,
295 );
296 assert.deepEqual(requested, ["code.tar.gz", "assets.tar.gz", "ui.tar.gz"]);
297 }),
298 platform,
299 ),
300);
301
302it.effect.each(["linux", "win32"] as const)(
303 "downloads only renderer assets for an unchanged %s runtime",
304 (platform) =>
305 withUpdater(
306 ({ updater, release, base, requested, applied }) =>
307 Effect.gen(function* () {
308 const key = `${platform}-x64`;
309 const matching = {
310 ...release,
311 desktop: { [key]: { ...release.desktop[key]!, runtime } },
312 };
313 assert.equal(yield* updater.prepare(base, matching), "ui");
314 assert.equal(yield* updater.installUi(version), true);
315 assert.equal(applied.length, 1);
316 assert.deepEqual(requested, ["assets.tar.gz", "ui.tar.gz"]);
317 }),
318 platform,
319 ),
320);