1import * as HostProcess from "@t3tools/shared/HostProcess";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import * as Schema from "effect/Schema";
8import * as Stream from "effect/Stream";
9import { ChildProcess, ChildProcessSpawner } from "effect/process";
10
11import {
12 buildNpmPlatformPackages,
13 NpmPackagesArchivesMissingError,
14} from "./build-npm-platform-packages.ts";
15
16const VERSION = "1.2.3";
17const decodeManifest = Schema.decodeEffect(
18 Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)),
19);
20const KEYS = ["linux-x64", "darwin-arm64"] as const;
21
22const collect = <E>(stream: Stream.Stream<Uint8Array, E>) =>
23 stream.pipe(
24 Stream.decodeText(),
25 Stream.runFold(
26 () => "",
27 (acc, chunk) => acc + chunk,
28 ),
29 );
30
31const run = Effect.fn("test.run")(function* (
32 command: string,
33 args: ReadonlyArray<string>,
34 options: { readonly cwd: string; readonly env?: Record<string, string> },
35) {
36 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
37 const child = yield* spawner.spawn(
38 ChildProcess.make(command, args, { cwd: options.cwd, env: options.env ?? {} }),
39 );
40 const [stdout, stderr, exitCode] = yield* Effect.all(
41 [collect(child.stdout), collect(child.stderr), child.exitCode.pipe(Effect.map(Number))],
42 { concurrency: "unbounded" },
43 );
44 return { stdout, stderr, exitCode };
45});
46
47/** A tar.gz laid out like build-cli-archive.ts writes, with a stub `t3` that echoes its args. */
48const makeFakeArchives = Effect.fn("test.makeFakeArchives")(function* () {
49 const fs = yield* FileSystem.FileSystem;
50 const path = yield* Path.Path;
51 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-npm-packages-test-" });
52 const archivesDir = path.join(root, "archives");
53 yield* fs.makeDirectory(archivesDir);
54 for (const key of KEYS) {
55 const stem = `t3-${VERSION}-${key}`;
56 const stage = path.join(root, "stage", key);
57 const contentDir = path.join(stage, stem);
58 for (const dir of [
59 "client",
60 "resource-monitor",
61 "node_modules/node-pty",
62 "node_modules/@ff-labs/fff-node",
63 ]) {
64 yield* fs.makeDirectory(path.join(contentDir, dir), { recursive: true });
65 }
66 yield* fs.writeFileString(
67 path.join(contentDir, "node_modules/node-pty/package.json"),
68 '{ "name": "node-pty", "version": "1.1.0" }\n',
69 );
70 yield* fs.writeFileString(
71 path.join(contentDir, "node_modules/@ff-labs/fff-node/package.json"),
72 '{ "name": "@ff-labs/fff-node", "version": "0.9.4" }\n',
73 );
74 yield* fs.writeFileString(path.join(contentDir, "client/index.html"), "<html></html>\n");
75 yield* fs.writeFileString(
76 path.join(contentDir, "t3"),
77 `#!/bin/sh\necho "stub ${key} $*"\nexit 7\n`,
78 );
79 yield* fs.chmod(path.join(contentDir, "t3"), 0o755);
80 const exit = yield* run("tar", ["-czf", path.join(archivesDir, `${stem}.tar.gz`), stem], {
81 cwd: stage,
82 });
83 assert.equal(exit.exitCode, 0, exit.stderr);
84 }
85 yield* fs.writeFileString(path.join(archivesDir, "SHA256SUMS"), "");
86 return { root, archivesDir, outputDir: path.join(root, "out") };
87});
88
89it.layer(NodeServices.layer)("build-npm-platform-packages", (it) => {
90 it.effect("refuses a partial release unless --allow-missing is passed", () =>
91 Effect.gen(function* () {
92 const fixture = yield* makeFakeArchives();
93 const error = yield* buildNpmPlatformPackages({
94 ...fixture,
95 version: VERSION,
96 allowMissing: false,
97 }).pipe(Effect.flip);
98 assert.instanceOf(error, NpmPackagesArchivesMissingError);
99 assert.deepStrictEqual((error as NpmPackagesArchivesMissingError).missing, [
100 "linux-arm64",
101 "win32-arm64",
102 "win32-x64",
103 ]);
104 }),
105 );
106
107 it.effect("builds platform packages and a launcher that execs the installed one", () =>
108 Effect.gen(function* () {
109 const fs = yield* FileSystem.FileSystem;
110 const path = yield* Path.Path;
111 const fixture = yield* makeFakeArchives();
112 const outputs = yield* buildNpmPlatformPackages({
113 ...fixture,
114 version: VERSION,
115 allowMissing: true,
116 });
117 // Platform packages in CLI_ARCHIVE_PLATFORM_KEYS order, launcher last.
118 assert.deepStrictEqual(
119 outputs.map((output) => output.name),
120 ["@t3code/t3-darwin-arm64", "@t3code/t3-linux-x64", "t3"],
121 );
122 for (const output of outputs) {
123 assert.isTrue(yield* fs.exists(output.tarball), output.tarball);
124 }
125
126 const linuxDir = path.join(fixture.outputDir, "@t3code/t3-linux-x64");
127 const linuxManifest = yield* decodeManifest(
128 yield* fs.readFileString(path.join(linuxDir, "package.json")),
129 );
130 assert.equal(linuxManifest.name, "@t3code/t3-linux-x64");
131 assert.equal(linuxManifest.version, VERSION);
132 assert.deepStrictEqual(linuxManifest.os, ["linux"]);
133 assert.deepStrictEqual(linuxManifest.cpu, ["x64"]);
134 assert.deepStrictEqual(linuxManifest.files, [
135 "t3",
136 "t3.exe",
137 "client",
138 "resource-monitor",
139 "node_modules",
140 ]);
141 assert.equal(linuxManifest.preferUnplugged, true);
142 assert.isUndefined(linuxManifest.bin);
143 // The shipped node_modules is declared, or npm prunes it as extraneous
144 // on the next install in the same project and the executable breaks.
145 assert.deepStrictEqual(linuxManifest.dependencies, {
146 "@ff-labs/fff-node": "0.9.4",
147 "node-pty": "1.1.0",
148 });
149 assert.deepStrictEqual(linuxManifest.bundleDependencies, ["@ff-labs/fff-node", "node-pty"]);
150 // Archive contents sit at the package root, not under the archive stem.
151 assert.isTrue(yield* fs.exists(path.join(linuxDir, "client/index.html")));
152 // A root README, or npm would display a bundled dependency's.
153 assert.include(
154 yield* fs.readFileString(path.join(linuxDir, "README.md")),
155 "# @t3code/t3-linux-x64",
156 );
157 assert.isTrue(yield* fs.exists(path.join(linuxDir, "node_modules/node-pty")));
158 assert.equal(Number((yield* fs.stat(path.join(linuxDir, "t3"))).mode) & 0o111, 0o111);
159
160 const darwinManifest = yield* decodeManifest(
161 yield* fs.readFileString(
162 path.join(fixture.outputDir, "@t3code/t3-darwin-arm64/package.json"),
163 ),
164 );
165 assert.deepStrictEqual(darwinManifest.os, ["darwin"]);
166 assert.deepStrictEqual(darwinManifest.cpu, ["arm64"]);
167
168 const launcherDir = path.join(fixture.outputDir, "t3");
169 const launcherManifest = yield* decodeManifest(
170 yield* fs.readFileString(path.join(launcherDir, "package.json")),
171 );
172 assert.equal(launcherManifest.name, "t3");
173 assert.equal(launcherManifest.version, VERSION);
174 assert.deepStrictEqual(launcherManifest.bin, { t3: "./bin/t3.js" });
175 assert.deepStrictEqual(launcherManifest.files, ["bin", "dist"]);
176 assert.deepStrictEqual(launcherManifest.optionalDependencies, {
177 "@t3code/t3-darwin-arm64": VERSION,
178 "@t3code/t3-linux-x64": VERSION,
179 });
180 assert.isUndefined(launcherManifest.engines);
181 assert.isTrue(yield* fs.exists(path.join(launcherDir, "bin/t3.js")));
182
183 // The scratch dirs must not be left behind next to the packages.
184 const outputEntries = yield* fs.readDirectory(fixture.outputDir);
185 assert.deepStrictEqual(outputEntries.sort(), ["@t3code", "t3", "t3.tgz"]);
186
187 // The tarball is what gets published: it must carry node_modules (which
188 // `npm publish <dir>` would strip) under npm's `package/` root, with the
189 // executable bit intact.
190 const listing = yield* run(
191 "tar",
192 ["-tzvf", path.join(fixture.outputDir, "@t3code/t3-linux-x64.tgz")],
193 { cwd: fixture.outputDir },
194 );
195 assert.equal(listing.exitCode, 0, listing.stderr);
196 const lines = listing.stdout.split("\n");
197 assert.isTrue(lines.some((line) => line.endsWith(" package/node_modules/node-pty/")));
198 assert.isTrue(lines.some((line) => line.endsWith(" package/package.json")));
199 assert.isTrue(
200 lines.some((line) => /^-rwxr-xr-x .* package\/t3$/.test(line)),
201 listing.stdout,
202 );
203
204 // NODE_PATH stands in for node_modules: require.resolve finds the
205 // platform package there exactly as it would after `npm install`.
206 const hostPlatform = yield* HostProcess.Platform;
207 const hostArch = yield* HostProcess.Architecture;
208 const env = { ...process.env, NODE_PATH: fixture.outputDir } as Record<string, string>;
209 if (KEYS.some((key) => key === `${hostPlatform}-${hostArch}`)) {
210 const passthrough = yield* run(process.execPath, ["bin/t3.js", "serve", "--port", "1234"], {
211 cwd: launcherDir,
212 env,
213 });
214 assert.equal(
215 passthrough.stdout.trim(),
216 `stub ${hostPlatform}-${hostArch} serve --port 1234`,
217 );
218 assert.equal(passthrough.exitCode, 7);
219
220 // Run the entry point used by already-installed service updaters from
221 // the published tarball, including their preflight arguments.
222 const installedLauncher = path.join(fixture.root, "installed-launcher");
223 yield* fs.makeDirectory(installedLauncher);
224 const unpack = yield* run(
225 "tar",
226 ["-xf", path.join(fixture.outputDir, "t3.tgz"), "-C", installedLauncher],
227 {
228 cwd: fixture.root,
229 },
230 );
231 assert.equal(unpack.exitCode, 0, unpack.stderr);
232 const legacy = yield* run(
233 process.execPath,
234 [
235 "dist/bin.mjs",
236 "__service-preflight",
237 "--database-path",
238 "a database.sqlite",
239 "--launcher-protocol",
240 "2",
241 ],
242 { cwd: path.join(installedLauncher, "package"), env },
243 );
244 assert.equal(
245 legacy.stdout.trim(),
246 `stub ${hostPlatform}-${hostArch} __service-preflight --database-path a database.sqlite --launcher-protocol 2`,
247 );
248 assert.equal(legacy.exitCode, 7);
249 }
250
251 const unsupported = yield* run(process.execPath, ["bin/t3.js", "--version"], {
252 cwd: launcherDir,
253 env: { ...env, NODE_PATH: path.join(fixture.root, "nowhere") },
254 });
255 assert.equal(unsupported.exitCode, 1);
256 assert.include(unsupported.stderr, "linux-x64");
257 assert.include(unsupported.stderr, "win32-arm64");
258 assert.include(unsupported.stderr, "https://github.com/pingdotgg/t3code/releases");
259 }),
260 );
261});