Skip to content
Source
Sign inSign up

VcsProcess.test.ts

597 lines22.1 KB

1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, expect, it } from "@effect/vitest";
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import * as Deferred from "effect/Deferred";
5import * as Effect from "effect/Effect";
6import * as Exit from "effect/Exit";
7import * as Cause from "effect/Cause";
8import * as Fiber from "effect/Fiber";
9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
12import * as Path from "effect/Path";
13import * as Queue from "effect/Queue";
14import * as Ref from "effect/Ref";
15import * as Schema from "effect/Schema";
16import * as TestClock from "effect/testing/TestClock";
17import { ChildProcessSpawner } from "effect/process";
18​
19import { VcsProcessExitError, VcsProcessSpawnError } from "@t3tools/contracts";
20import * as ProcessRunner from "../processRunner.ts";
21import * as VcsProcess from "./VcsProcess.ts";
22​
23const encodeExitError = Schema.encodeEffect(Schema.fromJsonString(VcsProcessExitError));
24​
25const run = (input: VcsProcess.VcsProcessInput) =>
26 Effect.gen(function* () {
27 const process = yield* VcsProcess.VcsProcess;
28 return yield* process.run(input);
29 });
30​
31const layerLive = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer));
32​
33const provideLive = <A, E, R>(effect: Effect.Effect<A, E, R | VcsProcess.VcsProcess>) =>
34 effect.pipe(Effect.provide(layerLive));
35​
36const baseInput = {
37 operation: "test.process-boundary",
38 command: "git",
39 args: ["status", "--short"],
40 cwd: "/workspace",
41} satisfies VcsProcess.VcsProcessInput;
42​
43const captureProcessResult = (
44 result: Effect.Effect<ProcessRunner.ProcessRunOutput, ProcessRunner.ProcessRunError>,
45) =>
46 VcsProcess.make.pipe(
47 Effect.provideService(
48 ProcessRunner.ProcessRunner,
49 ProcessRunner.ProcessRunner.of({ run: () => result }),
50 ),
51 Effect.flatMap((service) => service.run(baseInput)),
52 Effect.flip,
53 );
54​
55describe("VcsProcess.run", () => {
56 it.effect("explains a locked Git index and allows restore after the lock is released", () =>
57 Effect.gen(function* () {
58 const fs = yield* FileSystem.FileSystem;
59 const path = yield* Path.Path;
60 const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-index-lock-" });
61 const git = (args: readonly string[]) =>
62 run({ operation: "test.checkpoint.restore", command: "git", cwd, args });
63 yield* git(["init"]);
64 const file = path.join(cwd, "README.md");
65 yield* fs.writeFileString(file, "before\n");
66 yield* git(["add", "README.md"]);
67 yield* git([
68 "-c",
69 "user.name=Test",
70 "-c",
71 "user.email=test@example.com",
72 "commit",
73 "-m",
74 "initial",
75 ]);
76 yield* fs.writeFileString(file, "after\n");
77 const lock = path.join(cwd, ".git", "index.lock");
78 yield* fs.writeFileString(lock, "held by another process\n");
79 const restore = ["restore", "--source=HEAD", "--worktree", "--staged", "--", "README.md"];
80​
81 const error = yield* Effect.flip(git(restore));
82 expect(error).toBeInstanceOf(VcsProcessExitError);
83 expect(error).toMatchObject({
84 exitCode: 128,
85 failureKind: "command-failed",
86 detail: "Git's index is locked. Wait for other Git operations to finish, then try again.",
87 });
88 expect(error.message).not.toContain("index.lock");
89 expect(yield* fs.readFileString(file)).toBe("after\n");
90 expect(yield* fs.readFileString(lock)).toBe("held by another process\n");
91​
92 yield* fs.remove(lock);
93 yield* git(restore);
94 expect(yield* fs.readFileString(file)).toBe("before\n");
95 }).pipe(provideLive, Effect.provide(NodeServices.layer)),
96 );
97​
98 it.effect.each([
99 {
100 stderr: "fatal: Unable to create '/private/repo/index.lock': File exists",
101 retryable: true,
102 detail: "Git's index is locked. Wait for other Git operations to finish, then try again.",
103 },
104 {
105 stderr: "fatal: Unable to create 'C:\\private\\repo\\.git\\index.lock': File exists.",
106 retryable: true,
107 detail: "Git's index is locked. Wait for other Git operations to finish, then try again.",
108 },
109 {
110 stderr: "fatal: Unable to create '/private/repo/.git/config.lock': File exists.",
111 retryable: true,
112 },
113 {
114 stderr: "fatal: Unable to create '/private/repo/index.lock': Permission denied",
115 retryable: false,
116 },
117 { stderr: 'error: open("/private/repo/file"): No such file or directory', retryable: true },
118 {
119 stderr: "fatal: unable to stat '/private/repo/file': No such file or directory",
120 retryable: true,
121 },
122 { stderr: 'error: open("/private/repo/file"): Permission denied', retryable: false },
123 {
124 stderr: "error: '/private/repo/child/' does not have a commit checked out",
125 retryable: false,
126 },
127 { stderr: "fatal: index file corrupt", retryable: false },
128 ])(
129 "classifies Git exit retryability and safe detail without retaining stderr: $stderr",
130 ({ stderr, retryable, detail = "Process exited with a non-zero status." }) =>
131 Effect.gen(function* () {
132 const error = yield* captureProcessResult(
133 Effect.succeed({
134 stdout: "",
135 stderr,
136 code: ChildProcessSpawner.ExitCode(128),
137 timedOut: false,
138 stdoutTruncated: false,
139 stderrTruncated: false,
140 stdoutInvalidUtf8: false,
141 stderrInvalidUtf8: false,
142 }),
143 );
144 assert.instanceOf(error, VcsProcessExitError);
145 expect(error.retryable === true).toBe(retryable);
146 expect(error.detail).toBe(detail);
147 const encoded = yield* encodeExitError(error);
148 expect(encoded).not.toContain("private");
149 }),
150 );
151​
152 // Without LANG, macOS Git follows the system language, as in an app launched from Finder.
153 it.effect("reads Git failures in English when Git would follow the system language", () =>
154 Effect.gen(function* () {
155 let attempts = 0;
156 const service = yield* VcsProcess.make.pipe(
157 Effect.provideService(
158 ProcessRunner.ProcessRunner,
159 ProcessRunner.ProcessRunner.of({
160 run: (input) =>
161 Effect.sync(() => {
162 attempts += 1;
163 return {
164 stdout: "",
165 stderr:
166 attempts % 2 === 0
167 ? ""
168 : input.env?.LANGUAGE === "C"
169 ? "fatal: Unable to create '/private/repo/.git/index.lock': File exists.\n"
170 : "致命錯誤: 無法建立「/private/repo/.git/index.lock」:File exists。\n",
171 code: ChildProcessSpawner.ExitCode(attempts % 2 === 0 ? 0 : 128),
172 timedOut: false,
173 stdoutTruncated: false,
174 stderrTruncated: false,
175 stdoutInvalidUtf8: false,
176 stderrInvalidUtf8: false,
177 };
178 }),
179 }),
180 ),
181 );
182​
183 const capture = yield* service
184 .run({
185 ...baseInput,
186 operation: VcsProcess.CHECKPOINT_CAPTURE_OPERATION,
187 args: ["add", "--all"],
188 })
189 .pipe(Effect.exit, Effect.forkChild({ startImmediately: true }));
190 yield* TestClock.adjust("1 second");
191 expect(Exit.isSuccess(yield* Fiber.join(capture))).toBe(true);
192 expect(attempts).toBe(2);
193​
194 attempts = 0;
195 const error = yield* Effect.flip(service.run(baseInput));
196 assert.instanceOf(error, VcsProcessExitError);
197 expect(error.detail).toBe(VcsProcess.GIT_INDEX_LOCKED_DETAIL);
198 }),
199 );
200​
201 it.effect.each([
202 {
203 stderr: "fatal: fsync error on '.git/objects/a5/tmp_obj_Pseg66': Operation not supported",
204 fsyncMethods: ["fsync", "writeout-only"],
205 },
206 {
207 stderr:
208 "fatal: update_ref failed for ref 'refs/t3/checkpoints/x': cannot update ref 'refs/t3/checkpoints/x': couldn't write '/Volumes/share/.git/refs/t3/checkpoints/x.lock'",
209 fsyncMethods: ["fsync", "writeout-only"],
210 },
211 { stderr: "fatal: Operation not supported", fsyncMethods: ["fsync"] },
212 {
213 stderr:
214 "fatal: update_ref failed for ref 'refs/t3/checkpoints/x': cannot update ref 'refs/t3/checkpoints/x': couldn't write '/Volumes/share/.git/refs/t3/checkpoints/x.lock': Input/output error",
215 fsyncMethods: ["fsync"],
216 },
217 {
218 stderr: "fatal: fsync error on '.git/objects/a5/tmp_obj_Pseg66': Operation not supported",
219 fsyncMethods: ["fsync", "writeout-only"],
220 fallbackFails: true,
221 },
222 {
223 platform: "linux",
224 stderr: "fatal: fsync error on '.git/objects/a5/tmp_obj_Pseg66': Operation not supported",
225 fsyncMethods: ["fsync"],
226 },
227 {
228 operation: baseInput.operation,
229 stderr: "fatal: fsync error on '.git/objects/a5/tmp_obj_Pseg66': Operation not supported",
230 fsyncMethods: ["fsync"],
231 },
232 {
233 stderr: "fatal: fsync error on '.git/objects/bulk_fsync_Pseg66': Operation not supported",
234 fsyncMethods: ["batch", "writeout-only"],
235 },
236 {
237 stderr:
238 "fatal: update_ref failed for ref 'refs/t3/checkpoints/x': cannot update ref 'refs/t3/checkpoints/x': couldn't write '/Volumes/share/.git/refs/t3/checkpoints/x.lock'",
239 fsyncMethods: ["batch", "writeout-only"],
240 },
241 ] as const)(
242 "falls back to writeout-only only for unsupported flushes on macOS ($fsyncMethods): $stderr",
243 ({ stderr, fsyncMethods, ...scenario }) =>
244 Effect.gen(function* () {
245 const seen: Array<ReadonlyArray<string>> = [];
246 const service = yield* VcsProcess.make.pipe(
247 Effect.provideService(
248 HostProcess.Platform,
249 "platform" in scenario ? scenario.platform : "darwin",
250 ),
251 Effect.provideService(ProcessRunner.ProcessRunner, {
252 run: (input) =>
253 Effect.sync(() => {
254 seen.push(input.args);
255 const failed =
256 input.args.includes(`core.fsyncMethod=${fsyncMethods[0]}`) ||
257 ("fallbackFails" in scenario && scenario.fallbackFails);
258 return {
259 stdout: "",
260 stderr: failed ? stderr : "",
261 code: ChildProcessSpawner.ExitCode(failed ? 128 : 0),
262 timedOut: false,
263 stdoutTruncated: false,
264 stderrTruncated: false,
265 stdoutInvalidUtf8: false,
266 stderrInvalidUtf8: false,
267 };
268 }),
269 }),
270 );
271 const exit = yield* service
272 .run({
273 ...baseInput,
274 operation:
275 "operation" in scenario
276 ? scenario.operation
277 : VcsProcess.CHECKPOINT_CAPTURE_OPERATION,
278 args: [
279 "-c",
280 "core.fsync=objects,reference",
281 "-c",
282 ...(fsyncMethods[0] === "batch" ? ["core.fsyncMethod=fsync", "-c"] : []),
283 `core.fsyncMethod=${fsyncMethods[0]}`,
284 "add",
285 ],
286 })
287 .pipe(Effect.exit);
288 expect(seen).toEqual(
289 fsyncMethods.map((method) => [
290 "-c",
291 "core.fsync=objects,reference",
292 "-c",
293 ...(fsyncMethods[0] === "batch"
294 ? [`core.fsyncMethod=${method === "batch" ? "fsync" : method}`, "-c"]
295 : []),
296 `core.fsyncMethod=${method}`,
297 "add",
298 ]),
299 );
300 expect(Exit.isSuccess(exit)).toBe(
301 fsyncMethods.length === 2 && !("fallbackFails" in scenario && scenario.fallbackFails),
302 );
303 if (Exit.isFailure(exit)) {
304 expect(Cause.findErrorOption(exit.cause)).toMatchObject({
305 _tag: "Some",
306 value: { _tag: "VcsProcessExitError", exitCode: 128 },
307 });
308 }
309 }),
310 );
311​
312 it.effect("bounds a burst of processes that share a concurrency key", () =>
313 Effect.gen(function* () {
314 const gate = yield* Deferred.make<void>();
315 const starts = yield* Queue.unbounded<number>();
316 const active = yield* Ref.make(0);
317 const peak = yield* Ref.make(0);
318 const total = yield* Ref.make(0);
319 const service = yield* VcsProcess.make.pipe(
320 Effect.provideService(
321 ProcessRunner.ProcessRunner,
322 ProcessRunner.ProcessRunner.of({
323 run: () =>
324 Effect.gen(function* () {
325 const count = yield* Ref.updateAndGet(active, (held) => held + 1);
326 yield* Ref.update(peak, (held) => Math.max(held, count));
327 yield* Ref.update(total, (held) => held + 1);
328 yield* Queue.offer(starts, count);
329 yield* Deferred.await(gate);
330 return {
331 stdout: "",
332 stderr: "",
333 code: ChildProcessSpawner.ExitCode(0),
334 timedOut: false,
335 stdoutTruncated: false,
336 stderrTruncated: false,
337 stdoutInvalidUtf8: false,
338 stderrInvalidUtf8: false,
339 };
340 }).pipe(Effect.ensuring(Ref.update(active, (count) => count - 1))),
341 }),
342 ),
343 );
344​
345 const burst = yield* Effect.all(
346 Array.from({ length: 32 }, (_, index) =>
347 service.run({
348 operation: `synthetic.cli.${index}`,
349 command: "host-cli",
350 args: ["api", "user"],
351 cwd: "/workspace",
352 concurrency: { key: "host-cli", limit: 4 },
353 }),
354 ),
355 { concurrency: "unbounded" },
356 ).pipe(Effect.forkChild);
357​
358 yield* Effect.all(Array.from({ length: 4 }, () => Queue.take(starts)));
359 yield* Effect.yieldNow;
360 expect(yield* Queue.size(starts)).toBe(0);
361 expect(yield* Ref.get(peak)).toBe(4);
362​
363 yield* Deferred.succeed(gate, undefined);
364 yield* Fiber.join(burst);
365 expect(yield* Ref.get(total)).toBe(32);
366 expect(yield* Ref.get(peak)).toBe(4);
367 }),
368 );
369​
370 it.effect("leaves git a slot while slow forge CLI processes are running", () =>
371 Effect.gen(function* () {
372 const gate = yield* Deferred.make<void>();
373 const service = yield* VcsProcess.make.pipe(
374 Effect.provideService(
375 ProcessRunner.ProcessRunner,
376 ProcessRunner.ProcessRunner.of({
377 run: (input) =>
378 Effect.gen(function* () {
379 if (input.command !== "git") yield* Deferred.await(gate);
380 return {
381 stdout: "",
382 stderr: "",
383 code: ChildProcessSpawner.ExitCode(0),
384 timedOut: false,
385 stdoutTruncated: false,
386 stderrTruncated: false,
387 stdoutInvalidUtf8: false,
388 stderrInvalidUtf8: false,
389 };
390 }),
391 }),
392 ),
393 );
394​
395 const forgeRuns = yield* Effect.forEach(Array.from({ length: 12 }), () =>
396 service
397 .run({
398 operation: "synthetic.forge.listChangeRequests",
399 command: "tea",
400 args: ["pulls", "list"],
401 cwd: "/workspace",
402 })
403 .pipe(Effect.forkChild({ startImmediately: true })),
404 );
405 const checkpoint = yield* service
406 .run({
407 ...baseInput,
408 operation: VcsProcess.CHECKPOINT_CAPTURE_OPERATION,
409 args: ["rev-parse", "HEAD"],
410 })
411 .pipe(Effect.timeoutOption("1 second"), Effect.forkChild({ startImmediately: true }));
412 yield* TestClock.adjust("1 second");
413​
414 const gitResult = yield* Fiber.join(checkpoint);
415 yield* Deferred.succeed(gate, undefined);
416 yield* Fiber.joinAll(forgeRuns);
417 expect(
418 Option.isSome(gitResult),
419 "git waited behind forge CLI runs that held every VCS process slot",
420 ).toBe(true);
421 }),
422 );
423​
424 it.effect("keeps git from opening a folder laid out as a bare repository", () =>
425 Effect.gen(function* () {
426 const fileSystem = yield* FileSystem.FileSystem;
427 const path = yield* Path.Path;
428 const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-vcs-bare-" });
429 yield* fileSystem.makeDirectory(path.join(cwd, "objects", "info"), { recursive: true });
430 yield* fileSystem.makeDirectory(path.join(cwd, "refs", "heads"), { recursive: true });
431 yield* fileSystem.writeFileString(path.join(cwd, "HEAD"), "ref: refs/heads/main\n");
432 yield* fileSystem.writeFileString(
433 path.join(cwd, "config"),
434 '[core]\n\trepositoryformatversion = 0\n\tbare = false\n\tworktree = .\n\tfsmonitor = "echo ran >> fsmonitor-runs; false"\n',
435 );
436​
437 const result = yield* run({
438 operation: "test.bare-repository",
439 command: "git",
440 args: ["status"],
441 cwd,
442 allowNonZeroExit: true,
443 });
444​
445 expect(result.exitCode).not.toBe(0);
446 expect(yield* fileSystem.exists(path.join(cwd, "fsmonitor-runs"))).toBe(false);
447 }).pipe(Effect.scoped, provideLive, Effect.provide(NodeServices.layer)),
448 );
449​
450 it.effect("classifies authentication failures without retaining stderr", () =>
451 Effect.gen(function* () {
452 const secretStderr = "authentication failed for token super-secret-token";
453 const error = yield* run({
454 operation: "test.authentication",
455 command: "node",
456 args: ["-e", "process.stderr.write(process.argv[1]); process.exit(1)", secretStderr],
457 cwd: process.cwd(),
458 }).pipe(Effect.flip);
459​
460 expect(error).toBeInstanceOf(VcsProcessExitError);
461 expect(error).toMatchObject({
462 operation: "test.authentication",
463 command: "node",
464 exitCode: 1,
465 detail: "Authentication failed.",
466 failureKind: "authentication",
467 stderrLength: secretStderr.length,
468 stderrTruncated: false,
469 });
470 expect(error.message).not.toContain(secretStderr);
471 expect(error.message).not.toContain("super-secret-token");
472 }).pipe(provideLive),
473 );
474​
475 it.effect("classifies API rate limits without retaining provider stderr", () =>
476 Effect.gen(function* () {
477 const providerStderr =
478 "GraphQL: API rate limit already exceeded for user ID 51714798 and token secret-value.";
479 const error = yield* run({
480 operation: "test.rate-limit",
481 command: "node",
482 args: ["-e", "process.stderr.write(process.argv[1]); process.exit(1)", providerStderr],
483 cwd: process.cwd(),
484 }).pipe(Effect.flip);
485​
486 expect(error).toBeInstanceOf(VcsProcessExitError);
487 expect(error).toMatchObject({
488 command: "node",
489 exitCode: 1,
490 detail: "API rate limit exceeded.",
491 failureKind: "rate-limited",
492 stderrLength: providerStderr.length,
493 stderrTruncated: false,
494 });
495 expect(error.message).not.toContain(providerStderr);
496 expect(error.message).not.toContain("secret-value");
497 }).pipe(provideLive),
498 );
499​
500 it.effect("reads a failure with the command's own classifier and names it in its terms", () =>
501 Effect.gen(function* () {
502 const fail = (input: Partial<VcsProcess.VcsProcessInput>) =>
503 run({
504 operation: "test.classify",
505 command: "node",
506 args: ["-e", "process.stderr.write('HTTP 404: 404 Not Found'); process.exit(1)"],
507 cwd: process.cwd(),
508 ...input,
509 }).pipe(Effect.flip);
510​
511 expect(yield* fail({})).toMatchObject({
512 failureKind: "command-failed",
513 detail: "Process exited with a non-zero status.",
514 });
515 expect(
516 yield* fail({
517 classifyFailure: (stderr) => (stderr.includes("404") ? "not-found" : null),
518 notFoundDetail: "Merge request not found.",
519 }),
520 ).toMatchObject({ failureKind: "not-found", detail: "Merge request not found." });
521 }).pipe(provideLive),
522 );
523​
524 it.effect("retains spawn causes without exposing process arguments in the error message", () =>
525 Effect.gen(function* () {
526 const secretArgument = "--token=super-secret-token";
527 const error = yield* run({
528 operation: "test.spawn",
529 command: "definitely-not-a-t3code-executable",
530 args: [secretArgument],
531 cwd: process.cwd(),
532 }).pipe(Effect.flip);
533​
534 expect(error).toBeInstanceOf(VcsProcessSpawnError);
535 expect(error).toMatchObject({
536 operation: "test.spawn",
537 command: "definitely-not-a-t3code-executable",
538 argumentCount: 1,
539 });
540 expect(error).toHaveProperty("cause");
541 expect(error.message).not.toContain(secretArgument);
542 }).pipe(provideLive),
543 );
544​
545 it.effect("truncates output and appends the marker when requested", () =>
546 Effect.gen(function* () {
547 const result = yield* run({
548 operation: "test.truncate-marker",
549 command: "node",
550 args: ["-e", "process.stdout.write('x'.repeat(2048))"],
551 cwd: process.cwd(),
552 maxOutputBytes: 128,
553 appendTruncationMarker: true,
554 });
555​
556 expect(result.stdoutTruncated).toBe(true);
557 expect(result.stdout).toContain("[truncated]");
558 expect(result.stderrTruncated).toBe(false);
559 }).pipe(provideLive),
560 );
561​
562 it.effect("fails with measured byte counts when output must not be truncated", () =>
563 Effect.gen(function* () {
564 const error = yield* run({
565 operation: "test.output-limit",
566 command: "node",
567 args: ["-e", "process.stdout.write('x'.repeat(2048))"],
568 cwd: yield* HostProcess.WorkingDirectory,
569 maxOutputBytes: 128,
570 outputMode: "error",
571 }).pipe(Effect.flip);
572​
573 assert(error._tag === "VcsProcessOutputLimitError");
574 expect(error.stream).toBe("stdout");
575 expect(error.maxBytes).toBe(128);
576 expect(error.observedBytes).toBeGreaterThan(error.maxBytes);
577 }).pipe(provideLive),
578 );
579​
580 it.effect("streams all stdout bytes beyond the buffered output cap", () =>
581 Effect.gen(function* () {
582 const chunks: Uint8Array[] = [];
583 const result = yield* run({
584 operation: "test.stream-output",
585 command: "node",
586 args: ["-e", "process.stdout.write('x'.repeat(131072) + '\\0S final\\0')"],
587 cwd: process.cwd(),
588 maxOutputBytes: 8,
589 onStdoutChunk: (chunk) => chunks.push(chunk),
590 });
591​
592 expect(result.stdout).toBe("xxxxxxxx");
593 expect(result.stdoutTruncated).toBe(true);
594 expect(Buffer.concat(chunks).toString()).toBe("x".repeat(131072) + "\0S final\0");
595 }).pipe(provideLive),
596 );
597});