1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { ServerNpmReleaseError } from "@t3tools/contracts";
4import * as HostProcess from "@t3tools/shared/HostProcess";
5import * as Deferred from "effect/Deferred";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Path from "effect/Path";
9import * as Queue from "effect/Queue";
10import * as Fiber from "effect/Fiber";
11import { HttpClient, HttpClientResponse } from "effect/http";
12import * as Result from "effect/Result";
13import * as TestClock from "effect/testing/TestClock";
14import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
15
16import * as ProcessRunner from "../processRunner.ts";
17import * as NpmRelease from "./npmRelease.ts";
18
19const version = "0.1.5-nightly.20261030.1";
20const output = (stdout: string, code = 0): ProcessRunner.ProcessRunOutput => ({
21 stdout,
22 stderr: "",
23 code: ChildProcessSpawner.ExitCode(code),
24 timedOut: false,
25 stdoutTruncated: false,
26 stderrTruncated: false,
27 stdoutInvalidUtf8: false,
28 stderrInvalidUtf8: false,
29});
30
31const noPublicRegistry = HttpClient.make(() =>
32 Effect.die("a configured npm lookup must not fall back to public npm"),
33);
34// A fake npm on PATH decides whether npm counts as installed.
35const makeService = Effect.fnUntraced(function* (
36 run: ProcessRunner.ProcessRunner["Service"]["run"],
37 publicRegistry: HttpClient.HttpClient = noPublicRegistry,
38 npmInstalled = true,
39) {
40 const fs = yield* FileSystem.FileSystem;
41 const path = yield* Path.Path;
42 const npmBin = yield* fs.makeTempDirectoryScoped({ prefix: "t3-npm-release-" });
43 for (const name of ["npm", "npm.cmd"]) {
44 yield* fs.writeFileString(path.join(npmBin, name), "", { mode: 0o755 });
45 }
46 return yield* NpmRelease.make().pipe(
47 Effect.provideService(ProcessRunner.ProcessRunner, { run }),
48 Effect.provideService(HttpClient.HttpClient, publicRegistry),
49 Effect.provideService(HostProcess.Environment, {
50 PATH: npmInstalled ? npmBin : "",
51 PATHEXT: ".CMD",
52 }),
53 );
54}, Effect.provide(NodeServices.layer));
55
56it.effect.each([
57 {
58 spec: version,
59 stdout: JSON.stringify({ version, t3code: { databaseMigration: 42 } }),
60 migration: 42,
61 },
62 {
63 spec: "nightly",
64 stdout: JSON.stringify([{ version, t3code: { databaseMigration: 42 } }]),
65 migration: 42,
66 },
67 { spec: "latest", stdout: JSON.stringify(version), migration: undefined },
68])("reads $spec through npm and retains migration compatibility metadata", (fixture) =>
69 Effect.gen(function* () {
70 const calls: ProcessRunner.ProcessRunInput[] = [];
71 const service = yield* makeService((input) => {
72 calls.push(input);
73 return Effect.succeed(output(fixture.stdout));
74 });
75 const expected = {
76 version,
77 ...(fixture.migration === undefined ? {} : { databaseMigration: fixture.migration }),
78 };
79 expect(yield* service.get({ spec: fixture.spec })).toEqual(expected);
80 expect(yield* service.get({ spec: fixture.spec })).toEqual(expected);
81 expect(calls).toHaveLength(1);
82 expect(calls[0]).toMatchObject({
83 command: expect.stringMatching(/[/\\]npm(?:\.cmd)?$/i),
84 args: ["view", "--global", "--json", `t3@${fixture.spec}`, "version", "t3code"],
85 maxOutputBytes: 256 * 1024,
86 });
87 yield* TestClock.adjust("1 minute");
88 expect(yield* service.get({ spec: fixture.spec })).toEqual(expected);
89 expect(calls).toHaveLength(2);
90 }),
91);
92
93it.effect("treats a registry 404 as unpublished without trying another registry", () =>
94 Effect.gen(function* () {
95 const service = yield* makeService(() =>
96 Effect.succeed(output('{"error":{"code":"E404"}}', 1)),
97 );
98 expect(yield* service.get({ spec: version })).toBeNull();
99 }),
100);
101
102const spawnFailure = () =>
103 Effect.fail(
104 new ProcessRunner.ProcessSpawnError({
105 command: "npm",
106 argumentCount: 6,
107 cause: new Error("EACCES"),
108 }),
109 );
110
111it.effect.each([
112 output("not json"),
113 output('{"error":{"code":"E401","detail":"sensitive"}}', 1),
114 output('{"version":"9.9.9"}'),
115 output("[]"),
116 output("", 9009),
117 { ...output(JSON.stringify({ version })), stdoutTruncated: true },
118 { ...output(JSON.stringify({ version })), timedOut: true },
119 "spawn failure",
120] as const)("reports an unavailable lookup rather than an unpublished release: %j", (result) =>
121 Effect.gen(function* () {
122 const service = yield* makeService(() =>
123 result === "spawn failure" ? spawnFailure() : Effect.succeed(result),
124 );
125 const found = yield* service.get({ spec: version }).pipe(Effect.result);
126 expect(Result.isFailure(found)).toBe(true);
127 if (Result.isFailure(found)) {
128 expect(found.failure).toBeInstanceOf(ServerNpmReleaseError);
129 expect(found.failure.message).not.toContain("sensitive");
130 }
131 }),
132);
133
134it.effect("falls back to public npm only when npm is not installed", () =>
135 Effect.gen(function* () {
136 const urls: string[] = [];
137 const service = yield* makeService(
138 () => Effect.die("npm is not installed"),
139 HttpClient.make((request) => {
140 urls.push(request.url);
141 const response = request.url.endsWith("/nightly")
142 ? new Response(JSON.stringify({ version, t3code: { databaseMigration: 42 } }))
143 : request.url.endsWith("/latest")
144 ? new Response(new ReadableStream())
145 : new Response("{}", { status: 404 });
146 return Effect.succeed(HttpClientResponse.fromWeb(request, response));
147 }),
148 false,
149 );
150 expect(yield* service.get({ spec: "nightly" })).toEqual({ version, databaseMigration: 42 });
151 expect(yield* service.get({ spec: "9.9.9" })).toBeNull();
152 const stalled = yield* service.get({ spec: "latest" }).pipe(Effect.result, Effect.forkChild);
153 yield* TestClock.adjust("15 seconds");
154 expect(Result.isFailure(yield* Fiber.join(stalled))).toBe(true);
155 expect(urls).toEqual([
156 "https://registry.npmjs.org/t3/nightly",
157 "https://registry.npmjs.org/t3/9.9.9",
158 "https://registry.npmjs.org/t3/latest",
159 ]);
160 }),
161);
162
163it.effect("runs at most two npm lookups at once", () =>
164 Effect.gen(function* () {
165 const started = yield* Queue.unbounded<void>();
166 const release = yield* Deferred.make<void>();
167 let running = 0;
168 let peak = 0;
169 const service = yield* makeService(() =>
170 Effect.gen(function* () {
171 peak = Math.max(peak, ++running);
172 yield* Queue.offer(started, undefined);
173 yield* Deferred.await(release);
174 running--;
175 return output('{"error":{"code":"E404"}}', 1);
176 }),
177 );
178 const lookups = yield* Effect.forEach(
179 ["1.0.0", "1.0.1", "1.0.2"],
180 (spec) => service.get({ spec }),
181 { concurrency: "unbounded" },
182 ).pipe(Effect.forkChild);
183 yield* Queue.take(started);
184 yield* Queue.take(started);
185 yield* Deferred.succeed(release, undefined);
186 expect(yield* Fiber.join(lookups)).toEqual([null, null, null]);
187 expect(peak).toBe(2);
188 }),
189);
190
191it.effect.each(["--registry=https://example.com", "../other", "other-package@latest", "*", ""])(
192 "rejects invalid release specs before spawning npm: %s",
193 (spec) =>
194 Effect.gen(function* () {
195 const service = yield* makeService(() => Effect.die("invalid specs must not spawn npm"));
196 expect(Result.isFailure(yield* service.get({ spec }).pipe(Effect.result))).toBe(true);
197 }),
198);