1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import * as Config from "effect/Config";
4import * as ConfigProvider from "effect/ConfigProvider";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Path from "effect/Path";
9import * as PlatformError from "effect/PlatformError";
10import * as Schema from "effect/Schema";
11import { Command, CliError } from "effect/cli";
12import * as TestConsole from "effect/testing/TestConsole";
13import { fromJsonStringPretty } from "@t3tools/shared/schemaJson";
14
15import {
16 ReleaseGitHubOutputConfigurationError,
17 ReleaseGitHubOutputWriteError,
18 ReleasePackageManifestError,
19 releasePackageFiles,
20 updateReleasePackageVersions,
21 updateReleasePackageVersionsCommand,
22} from "./update-release-package-versions.ts";
23
24const ScriptTestLayer = Layer.mergeAll(NodeServices.layer, TestConsole.layer);
25const runCli = Command.runWith(updateReleasePackageVersionsCommand, { version: "0.0.0" });
26const PackageJsonSchema = Schema.Record(Schema.String, Schema.Unknown);
27const PackageJsonPrettyJson = fromJsonStringPretty(PackageJsonSchema);
28const decodePackageJson = Schema.decodeEffect(PackageJsonPrettyJson);
29const encodePackageJson = Schema.encodeEffect(PackageJsonPrettyJson);
30
31const writePackageJsonFixtures = Effect.fn("writePackageJsonFixtures")(function* (
32 rootDir: string,
33 version: string,
34) {
35 const fs = yield* FileSystem.FileSystem;
36 const path = yield* Path.Path;
37
38 for (const relativePath of releasePackageFiles) {
39 const filePath = path.join(rootDir, relativePath);
40 yield* fs.makeDirectory(path.dirname(filePath), { recursive: true });
41 yield* fs.writeFileString(
42 filePath,
43 `${yield* encodePackageJson({
44 name: relativePath,
45 version,
46 private: true,
47 })}\n`,
48 );
49 }
50});
51
52const readReleaseVersions = Effect.fn("readReleaseVersions")(function* (rootDir: string) {
53 const fs = yield* FileSystem.FileSystem;
54 const path = yield* Path.Path;
55 const versions = new Map<string, string>();
56
57 for (const relativePath of releasePackageFiles) {
58 const filePath = path.join(rootDir, relativePath);
59 const packageJson = yield* fs.readFileString(filePath).pipe(Effect.flatMap(decodePackageJson));
60 versions.set(relativePath, String(packageJson.version));
61 }
62
63 return versions;
64});
65
66const captureLogs = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
67 Effect.gen(function* () {
68 const result = yield* effect;
69 const logs = (yield* TestConsole.logLines).filter(
70 (line): line is string => typeof line === "string",
71 );
72 return { result, logs };
73 });
74
75it.layer(ScriptTestLayer)("update-release-package-versions", (it) => {
76 it.effect("updates all release package versions under the provided root", () =>
77 Effect.gen(function* () {
78 const fs = yield* FileSystem.FileSystem;
79 const baseDir = yield* fs.makeTempDirectoryScoped({
80 prefix: "update-release-package-versions-",
81 });
82
83 yield* writePackageJsonFixtures(baseDir, "0.0.1");
84
85 const result = yield* updateReleasePackageVersions("1.2.3", { rootDir: baseDir });
86 const versions = yield* readReleaseVersions(baseDir);
87
88 assert.deepStrictEqual(result, { changed: true });
89 assert.deepStrictEqual(
90 Array.from(versions.entries()),
91 releasePackageFiles.map((relativePath) => [relativePath, "1.2.3"]),
92 );
93 }),
94 );
95
96 it.effect("returns changed=false when all versions already match", () =>
97 Effect.gen(function* () {
98 const fs = yield* FileSystem.FileSystem;
99 const baseDir = yield* fs.makeTempDirectoryScoped({
100 prefix: "update-release-package-versions-unchanged-",
101 });
102
103 yield* writePackageJsonFixtures(baseDir, "1.2.3");
104
105 const result = yield* updateReleasePackageVersions("1.2.3", { rootDir: baseDir });
106
107 assert.deepStrictEqual(result, { changed: false });
108 }),
109 );
110
111 it.effect("preserves manifest read context and the filesystem cause", () =>
112 Effect.gen(function* () {
113 const fs = yield* FileSystem.FileSystem;
114 const path = yield* Path.Path;
115 const baseDir = yield* fs.makeTempDirectoryScoped({
116 prefix: "update-release-package-versions-read-error-",
117 });
118 const filePath = path.join(baseDir, releasePackageFiles[0]);
119
120 const error = yield* updateReleasePackageVersions("1.2.3", {
121 rootDir: baseDir,
122 }).pipe(Effect.flip);
123
124 assert.instanceOf(error, ReleasePackageManifestError);
125 assert.equal(error.operation, "read");
126 assert.equal(error.filePath, filePath);
127 assert.instanceOf(error.cause, PlatformError.PlatformError);
128 assert.equal(error.message, `Failed to read release package manifest '${filePath}'.`);
129 }),
130 );
131
132 it.effect("preserves manifest decode context and the schema cause", () =>
133 Effect.gen(function* () {
134 const fs = yield* FileSystem.FileSystem;
135 const path = yield* Path.Path;
136 const baseDir = yield* fs.makeTempDirectoryScoped({
137 prefix: "update-release-package-versions-decode-error-",
138 });
139 const filePath = path.join(baseDir, releasePackageFiles[0]);
140
141 yield* writePackageJsonFixtures(baseDir, "0.0.1");
142 yield* fs.writeFileString(filePath, "not json");
143
144 const error = yield* updateReleasePackageVersions("1.2.3", {
145 rootDir: baseDir,
146 }).pipe(Effect.flip);
147
148 assert.equal(error.operation, "decode");
149 assert.equal(error.filePath, filePath);
150 assert.isTrue(Schema.isSchemaError(error.cause));
151 assert.equal(error.message, `Failed to decode release package manifest '${filePath}'.`);
152 }),
153 );
154
155 it.effect("preserves manifest write context and the filesystem cause", () =>
156 Effect.gen(function* () {
157 const fs = yield* FileSystem.FileSystem;
158 const path = yield* Path.Path;
159 const baseDir = yield* fs.makeTempDirectoryScoped({
160 prefix: "update-release-package-versions-write-error-",
161 });
162 const filePath = path.join(baseDir, releasePackageFiles[0]);
163
164 yield* writePackageJsonFixtures(baseDir, "0.0.1");
165 yield* fs.chmod(filePath, 0o400);
166
167 const error = yield* updateReleasePackageVersions("1.2.3", {
168 rootDir: baseDir,
169 }).pipe(Effect.flip, Effect.ensuring(fs.chmod(filePath, 0o600).pipe(Effect.orDie)));
170
171 assert.equal(error.operation, "write");
172 assert.equal(error.filePath, filePath);
173 assert.instanceOf(error.cause, PlatformError.PlatformError);
174 assert.equal(error.message, `Failed to write release package manifest '${filePath}'.`);
175 }),
176 );
177
178 it.effect("accepts flags before the version positional and appends changed output", () =>
179 Effect.gen(function* () {
180 const fs = yield* FileSystem.FileSystem;
181 const path = yield* Path.Path;
182 const baseDir = yield* fs.makeTempDirectoryScoped({
183 prefix: "update-release-package-versions-cli-",
184 });
185 const githubOutputPath = path.join(baseDir, "github-output.txt");
186
187 yield* writePackageJsonFixtures(baseDir, "0.0.1");
188
189 yield* runCli(["--github-output", "--root", baseDir, "2.0.0"]).pipe(
190 Effect.provide(
191 ConfigProvider.layer(
192 ConfigProvider.fromEnv({
193 env: {
194 GITHUB_OUTPUT: githubOutputPath,
195 },
196 }),
197 ),
198 ),
199 );
200
201 const githubOutput = yield* fs.readFileString(githubOutputPath);
202 assert.equal(githubOutput, "changed=true\n");
203 }),
204 );
205
206 it.effect("logs when nothing changed", () =>
207 captureLogs(
208 Effect.gen(function* () {
209 const fs = yield* FileSystem.FileSystem;
210 const baseDir = yield* fs.makeTempDirectoryScoped({
211 prefix: "update-release-package-versions-cli-log-",
212 });
213
214 yield* writePackageJsonFixtures(baseDir, "3.0.0");
215 yield* runCli(["3.0.0", "--root", baseDir]);
216 }),
217 ).pipe(
218 Effect.tap(({ logs }) => {
219 assert.deepStrictEqual(logs, ["All package.json versions already match release version."]);
220 return Effect.void;
221 }),
222 ),
223 );
224
225 it.effect("requires GITHUB_OUTPUT when --github-output is set", () =>
226 Effect.gen(function* () {
227 const fs = yield* FileSystem.FileSystem;
228 const baseDir = yield* fs.makeTempDirectoryScoped({
229 prefix: "update-release-package-versions-cli-missing-output-",
230 });
231
232 yield* writePackageJsonFixtures(baseDir, "0.0.1");
233
234 const error = yield* runCli(["4.0.0", "--root", baseDir, "--github-output"]).pipe(
235 Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} }))),
236 Effect.flip,
237 );
238
239 assert.instanceOf(error, ReleaseGitHubOutputConfigurationError);
240 assert.instanceOf(error.cause, Config.ConfigError);
241 assert.equal(
242 error.message,
243 "Failed to resolve GITHUB_OUTPUT for release package version output.",
244 );
245 }),
246 );
247
248 it.effect("preserves GITHUB_OUTPUT write context and the filesystem cause", () =>
249 Effect.gen(function* () {
250 const fs = yield* FileSystem.FileSystem;
251 const baseDir = yield* fs.makeTempDirectoryScoped({
252 prefix: "update-release-package-versions-cli-output-error-",
253 });
254
255 yield* writePackageJsonFixtures(baseDir, "0.0.1");
256
257 const error = yield* runCli(["4.0.0", "--root", baseDir, "--github-output"]).pipe(
258 Effect.provide(
259 ConfigProvider.layer(
260 ConfigProvider.fromEnv({
261 env: {
262 GITHUB_OUTPUT: baseDir,
263 },
264 }),
265 ),
266 ),
267 Effect.flip,
268 );
269
270 assert.instanceOf(error, ReleaseGitHubOutputWriteError);
271 assert.equal(error.filePath, baseDir);
272 assert.instanceOf(error.cause, PlatformError.PlatformError);
273 assert.equal(
274 error.message,
275 `Failed to append release package version output to '${baseDir}'.`,
276 );
277 }),
278 );
279
280 it.effect("rejects unknown flags during cli parsing", () =>
281 Effect.gen(function* () {
282 const error = yield* runCli(["1.2.3", "--unknown"]).pipe(Effect.flip);
283
284 if (!CliError.isCliError(error)) {
285 assert.fail(`Expected CliError, got ${String(error)}`);
286 }
287
288 const optionError =
289 error._tag === "ShowHelp" ? (error.errors[0] as CliError.CliError | undefined) : error;
290
291 if (!optionError || optionError._tag !== "UnrecognizedOption") {
292 assert.fail(`Expected UnrecognizedOption, got ${String(optionError?._tag)}`);
293 }
294
295 assert.equal(optionError.option, "--unknown");
296 }),
297 );
298
299 it.effect("rejects a missing version positional during cli parsing", () =>
300 Effect.gen(function* () {
301 const error = yield* runCli(["--github-output"]).pipe(Effect.flip);
302
303 if (!CliError.isCliError(error)) {
304 assert.fail(`Expected CliError, got ${String(error)}`);
305 }
306
307 const versionError =
308 error._tag === "ShowHelp" ? (error.errors[0] as CliError.CliError | undefined) : error;
309
310 if (!versionError || versionError._tag !== "MissingArgument") {
311 assert.fail(`Expected MissingArgument, got ${String(versionError?._tag)}`);
312 }
313
314 assert.equal(versionError.argument, "version");
315 }),
316 );
317});