import * as NodeServices from "@effect/platform-node/NodeServices";
import { describe, expect, it } from "@effect/vitest";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import { TestClock } from "effect/testing";
import {
VcsProcessExitError,
VcsProcessSpawnError,
VcsProcessTimeoutError,
} from "@t3tools/contracts";
import * as ProcessRunner from "../processRunner.ts";
import * as VcsProcess from "./VcsProcess.ts";
const run = (input: VcsProcess.VcsProcessInput) =>
Effect.gen(function* () {
const process = yield* VcsProcess.VcsProcess;
return yield* process.run(input);
});
const liveLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer));
const provideLive = (effect: Effect.Effect) =>
effect.pipe(Effect.provide(liveLayer));
const baseInput = {
operation: "test.process-boundary",
command: "git",
args: ["status", "--short"],
cwd: "/workspace",
} satisfies VcsProcess.VcsProcessInput;
const captureProcessResult = (
result: Effect.Effect,
) =>
VcsProcess.make.pipe(
Effect.provideService(
ProcessRunner.ProcessRunner,
ProcessRunner.ProcessRunner.of({ run: () => result }),
),
Effect.flatMap((service) => service.run(baseInput)),
Effect.flip,
);
describe("VcsProcess.run", () => {
it.effect("collects stdout", () =>
Effect.gen(function* () {
const result = yield* run({
operation: "test.stdout",
command: "node",
args: ["-e", "process.stdout.write('hello')"],
cwd: process.cwd(),
});
expect(result.stdout).toBe("hello");
expect(result.stderr).toBe("");
expect(result.stdoutTruncated).toBe(false);
expect(result.stderrTruncated).toBe(false);
}).pipe(provideLive),
);
it.effect("writes stdin before waiting for exit", () =>
Effect.gen(function* () {
const result = yield* run({
operation: "test.stdin",
command: "node",
args: [
"-e",
[
"process.stdin.setEncoding('utf8');",
"let data='';",
"process.stdin.on('data', chunk => { data += chunk; });",
"process.stdin.on('end', () => { process.stdout.write(data); });",
].join(""),
],
cwd: process.cwd(),
stdin: "stdin payload",
});
expect(result.stdout).toBe("stdin payload");
}).pipe(provideLive),
);
it.effect("fails with VcsProcessExitError for non-zero exits by default", () =>
Effect.gen(function* () {
const secretArgument = "--token=super-secret-token";
const secretStderr = "remote rejected super-secret-token";
const error = yield* run({
operation: "test.exit",
command: "node",
args: [
"-e",
"process.stderr.write(process.argv[1]); process.exit(2)",
secretStderr,
secretArgument,
],
cwd: process.cwd(),
}).pipe(Effect.flip);
expect(error).toBeInstanceOf(VcsProcessExitError);
expect(error).toMatchObject({
operation: "test.exit",
command: "node",
argumentCount: 4,
exitCode: 2,
detail: "Process exited with a non-zero status.",
failureKind: "command-failed",
stderrLength: secretStderr.length,
stderrTruncated: false,
});
expect(error.message).not.toContain(secretArgument);
expect(error.message).not.toContain(secretStderr);
}).pipe(provideLive),
);
it.effect("classifies authentication failures without retaining stderr", () =>
Effect.gen(function* () {
const secretStderr = "authentication failed for token super-secret-token";
const error = yield* run({
operation: "test.authentication",
command: "node",
args: ["-e", "process.stderr.write(process.argv[1]); process.exit(1)", secretStderr],
cwd: process.cwd(),
}).pipe(Effect.flip);
expect(error).toBeInstanceOf(VcsProcessExitError);
expect(error).toMatchObject({
operation: "test.authentication",
command: "node",
exitCode: 1,
detail: "Authentication failed.",
failureKind: "authentication",
stderrLength: secretStderr.length,
stderrTruncated: false,
});
expect(error.message).not.toContain(secretStderr);
expect(error.message).not.toContain("super-secret-token");
}).pipe(provideLive),
);
it.effect("classifies API rate limits without retaining provider stderr", () =>
Effect.gen(function* () {
const providerStderr =
"GraphQL: API rate limit already exceeded for user ID 51714798 and token secret-value.";
const error = yield* run({
operation: "test.rate-limit",
command: "node",
args: ["-e", "process.stderr.write(process.argv[1]); process.exit(1)", providerStderr],
cwd: process.cwd(),
}).pipe(Effect.flip);
expect(error).toBeInstanceOf(VcsProcessExitError);
expect(error).toMatchObject({
command: "node",
exitCode: 1,
detail: "API rate limit exceeded.",
failureKind: "rate-limited",
stderrLength: providerStderr.length,
stderrTruncated: false,
});
expect(error.message).not.toContain(providerStderr);
expect(error.message).not.toContain("secret-value");
}).pipe(provideLive),
);
it.effect("classifies HTTP 429 responses as rate limits", () =>
Effect.gen(function* () {
const providerStderr = "HTTP 429: Too Many Requests. request-id=secret-value";
const error = yield* run({
operation: "test.rate-limit",
command: "node",
args: ["-e", "process.stderr.write(process.argv[1]); process.exit(1)", providerStderr],
cwd: process.cwd(),
}).pipe(Effect.flip);
expect(error).toMatchObject({
detail: "API rate limit exceeded.",
failureKind: "rate-limited",
});
expect(error.message).not.toContain(providerStderr);
}).pipe(provideLive),
);
it.effect("retains spawn causes without exposing process arguments in the error message", () =>
Effect.gen(function* () {
const secretArgument = "--token=super-secret-token";
const error = yield* run({
operation: "test.spawn",
command: "definitely-not-a-t3code-executable",
args: [secretArgument],
cwd: process.cwd(),
}).pipe(Effect.flip);
expect(error).toBeInstanceOf(VcsProcessSpawnError);
expect(error).toMatchObject({
operation: "test.spawn",
command: "definitely-not-a-t3code-executable",
argumentCount: 1,
});
expect(error).toHaveProperty("cause");
expect(error.message).not.toContain(secretArgument);
}).pipe(provideLive),
);
it.effect("preserves real boundary causes without manufacturing structural ones", () =>
Effect.gen(function* () {
const cause = new Error("secret stdin failure");
const error = yield* captureProcessResult(
Effect.fail(
new ProcessRunner.ProcessStdinError({
command: baseInput.command,
argumentCount: baseInput.args.length,
cwd: baseInput.cwd,
stdinBytes: 47,
cause,
}),
),
);
expect(error).toMatchObject({
_tag: "VcsProcessStdinWriteError",
operation: baseInput.operation,
stdinBytes: 47,
cause,
});
expect(error.message).not.toContain(cause.message);
const missingExitCodeError = yield* captureProcessResult(
Effect.succeed({
stdout: "",
stderr: "",
code: null,
timedOut: false,
stdoutTruncated: false,
stderrTruncated: false,
stdoutInvalidUtf8: false,
stderrInvalidUtf8: false,
}),
);
expect(missingExitCodeError).toMatchObject({
_tag: "VcsProcessMissingExitCodeError",
operation: baseInput.operation,
command: baseInput.command,
cwd: baseInput.cwd,
argumentCount: baseInput.args.length,
});
expect(missingExitCodeError).not.toHaveProperty("cause");
}),
);
it.effect("returns output when non-zero exits are allowed", () =>
Effect.gen(function* () {
const result = yield* run({
operation: "test.allowed-exit",
command: "node",
args: ["-e", "process.stderr.write('boom'); process.exit(2)"],
cwd: process.cwd(),
allowNonZeroExit: true,
});
expect(result.exitCode).toBe(2);
expect(result.stderr).toBe("boom");
}).pipe(provideLive),
);
it.effect("truncates output and appends the marker when requested", () =>
Effect.gen(function* () {
const result = yield* run({
operation: "test.truncate-marker",
command: "node",
args: ["-e", "process.stdout.write('x'.repeat(2048))"],
cwd: process.cwd(),
maxOutputBytes: 128,
appendTruncationMarker: true,
});
expect(result.stdoutTruncated).toBe(true);
expect(result.stdout).toContain("[truncated]");
expect(result.stderrTruncated).toBe(false);
}).pipe(provideLive),
);
it.effect("truncates without the marker when truncation markers are disabled", () =>
Effect.gen(function* () {
const result = yield* run({
operation: "test.truncate-silent",
command: "node",
args: ["-e", "process.stdout.write('x'.repeat(2048))"],
cwd: process.cwd(),
maxOutputBytes: 128,
});
expect(result.stdoutTruncated).toBe(true);
expect(result.stdout).not.toContain("[truncated]");
}).pipe(provideLive),
);
it.effect("fails with VcsProcessTimeoutError on timeout", () =>
Effect.gen(function* () {
const errorFiber = yield* run({
operation: "test.timeout",
command: "node",
args: ["-e", "setTimeout(() => {}, 5000)"],
cwd: process.cwd(),
timeoutMs: 50,
}).pipe(Effect.flip, Effect.forkScoped);
yield* Effect.yieldNow;
yield* TestClock.adjust(Duration.millis(50));
const error = yield* Fiber.join(errorFiber);
expect(error).toBeInstanceOf(VcsProcessTimeoutError);
}).pipe(provideLive),
);
});