Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 131 additions & 9 deletions scripts/sync-reference-repos.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,22 @@ const encoder = new TextEncoder();
const effectSmol = referenceRepos[0]!;
const alchemyEffect = referenceRepos[1]!;

function mockHandle() {
function mockHandle(
options: {
readonly exitCode?: number;
readonly stdout?: string;
readonly stderr?: string;
} = {},
) {
return ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(1),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(options.exitCode ?? 0)),
isRunning: Effect.succeed(false),
kill: () => Effect.void,
unref: Effect.succeed(Effect.void),
stdin: Sink.drain,
stdout: Stream.make(encoder.encode("done\n")),
stderr: Stream.empty,
stdout: Stream.make(encoder.encode(options.stdout ?? "done\n")),
stderr: Stream.make(encoder.encode(options.stderr ?? "")),
all: Stream.empty,
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
Expand All @@ -37,6 +43,7 @@ function mockHandle() {

function mockSpawnerLayer(
commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }>,
handle = mockHandle(),
) {
return Layer.succeed(
ChildProcessSpawner.ChildProcessSpawner,
Expand All @@ -49,7 +56,7 @@ function mockSpawnerLayer(
command: childProcess.command,
args: childProcess.args,
});
return Effect.succeed(mockHandle());
return Effect.succeed(handle);
}),
);
}
Expand Down Expand Up @@ -85,6 +92,75 @@ it.layer(NodeServices.layer)("sync-reference-repos", (it) => {
}),
);

it.effect("preserves version source read context and the filesystem cause", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const rootDir = yield* fs.makeTempDirectoryScoped({
prefix: "sync-reference-repos-read-error-",
});
const sourcePath = path.join(rootDir, effectSmol.versionSourcePath);

const error = yield* resolveReferenceRepoRef(effectSmol, rootDir, false).pipe(Effect.flip);

if (error._tag !== "ReferenceRepoVersionSourceError") {
assert.fail(`Unexpected error: ${error._tag}`);
}
assert.equal(error.operation, "read");
assert.equal(error.repoId, effectSmol.id);
assert.equal(error.sourcePath, sourcePath);
assert.ok(error.cause !== undefined);
assert.ok(!error.message.includes(String((error.cause as Error).message)));
}),
);

it.effect("preserves version source parse context and the schema cause", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const rootDir = yield* fs.makeTempDirectoryScoped({
prefix: "sync-reference-repos-parse-error-",
});
const sourcePath = path.join(rootDir, alchemyEffect.versionSourcePath);
yield* fs.makeDirectory(path.dirname(sourcePath), { recursive: true });
yield* fs.writeFileString(sourcePath, "{");

const error = yield* resolveReferenceRepoRef(alchemyEffect, rootDir, false).pipe(Effect.flip);

if (error._tag !== "ReferenceRepoVersionSourceError") {
assert.fail(`Unexpected error: ${error._tag}`);
}
assert.equal(error.operation, "parse");
assert.equal(error.repoId, alchemyEffect.id);
assert.equal(error.sourcePath, sourcePath);
assert.ok(error.cause !== undefined);
assert.ok(!error.message.includes(String((error.cause as Error).message)));
}),
);

it.effect("reports the unresolved package path without inventing a cause", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const rootDir = yield* fs.makeTempDirectoryScoped({
prefix: "sync-reference-repos-resolution-error-",
});
const sourcePath = path.join(rootDir, alchemyEffect.versionSourcePath);
yield* fs.makeDirectory(path.dirname(sourcePath), { recursive: true });
yield* fs.writeFileString(sourcePath, '{"dependencies":{}}');

const error = yield* resolveReferenceRepoRef(alchemyEffect, rootDir, false).pipe(Effect.flip);

if (error._tag !== "ReferenceRepoVersionResolutionError") {
assert.fail(`Unexpected error: ${error._tag}`);
}
assert.equal(error.repoId, alchemyEffect.id);
assert.equal(error.sourcePath, sourcePath);
assert.deepStrictEqual(error.packageVersionPath, ["dependencies", "alchemy"]);
assert.ok(!("cause" in error));
}),
);

it.effect("resolves the alchemy-effect tag from the relay package", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down Expand Up @@ -171,10 +247,56 @@ it.layer(NodeServices.layer)("sync-reference-repos", (it) => {
dryRun: true,
}).pipe(Effect.flip);

assert.equal(
error.message,
"Unknown reference repo 'missing'. Expected one of: effect-smol, alchemy-effect.",
);
if (error._tag !== "ReferenceRepoSelectionError") {
assert.fail(`Unexpected error: ${error._tag}`);
}
assert.equal(error.repoId, "missing");
assert.deepStrictEqual(error.expectedRepoIds, ["effect-smol", "alchemy-effect"]);
assert.ok(!("cause" in error));
}),
);

it.effect("reports non-zero git exits without retaining process output", () => {
const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> = [];

return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const rootDir = yield* fs.makeTempDirectoryScoped({
prefix: "sync-reference-repos-exit-error-",
});
yield* fs.writeFileString(
path.join(rootDir, "pnpm-workspace.yaml"),
"catalog:\n effect: 4.0.0-beta.73\n",
);

const error = yield* syncReferenceRepos({ rootDir, repoId: "effect-smol" }).pipe(
Effect.provide(
mockSpawnerLayer(
commands,
mockHandle({ exitCode: 23, stderr: "subtree failed secret-token-value\n" }),
),
),
Effect.flip,
);

if (error._tag !== "ReferenceRepoGitSubtreeError") {
assert.fail(`Unexpected error: ${error._tag}`);
}
assert.equal(error.operation, "exit");
assert.equal(error.repoId, effectSmol.id);
assert.equal(error.action, "add");
assert.equal(error.repository, effectSmol.repository);
assert.equal(error.ref, "effect@4.0.0-beta.73");
assert.equal(error.rootDir, rootDir);
assert.equal(error.argumentCount, commands[0]?.args.length);
assert.equal(error.exitCode, 23);
assert.equal(error.stdoutLength, 5);
assert.equal(error.stderrLength, 34);
assert.notProperty(error, "args");
assert.notProperty(error, "stderr");
assert.notInclude(error.message, "secret-token-value");
assert.ok(!("cause" in error));
});
});
});
147 changes: 110 additions & 37 deletions scripts/sync-reference-repos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
import * as NodeServices from "@effect/platform-node/NodeServices";
import * as Console from "effect/Console";
import * as Data from "effect/Data";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Option from "effect/Option";
Expand Down Expand Up @@ -32,10 +31,74 @@ export interface ReferenceRepoSyncPlan {
readonly args: ReadonlyArray<string>;
}

export class ReferenceRepoSyncError extends Data.TaggedError("ReferenceRepoSyncError")<{
readonly message: string;
readonly cause?: unknown;
}> {}
export class ReferenceRepoSelectionError extends Schema.TaggedErrorClass<ReferenceRepoSelectionError>()(
"ReferenceRepoSelectionError",
{
repoId: Schema.String,
expectedRepoIds: Schema.Array(Schema.String),
},
) {
override get message(): string {
return `Unknown reference repo "${this.repoId}". Expected one of: ${this.expectedRepoIds.join(", ")}.`;
}
}

export class ReferenceRepoVersionSourceError extends Schema.TaggedErrorClass<ReferenceRepoVersionSourceError>()(
"ReferenceRepoVersionSourceError",
{
operation: Schema.Literals(["read", "parse"]),
repoId: Schema.String,
sourcePath: Schema.String,
cause: Schema.Defect(),
},
) {
override get message(): string {
return `Reference repo "${this.repoId}" version source operation "${this.operation}" failed for ${this.sourcePath}.`;
}
}

export class ReferenceRepoVersionResolutionError extends Schema.TaggedErrorClass<ReferenceRepoVersionResolutionError>()(
"ReferenceRepoVersionResolutionError",
{
repoId: Schema.String,
sourcePath: Schema.String,
packageVersionPath: Schema.Array(Schema.String),
},
) {
override get message(): string {
return `No version was found for reference repo "${this.repoId}" at ${this.sourcePath}:${this.packageVersionPath.join(".")}.`;
}
}

export class ReferenceRepoGitSubtreeError extends Schema.TaggedErrorClass<ReferenceRepoGitSubtreeError>()(
"ReferenceRepoGitSubtreeError",
{
operation: Schema.Literals(["spawn", "communicate", "exit"]),
repoId: Schema.String,
action: Schema.Literals(["add", "pull"]),
repository: Schema.String,
ref: Schema.String,
rootDir: Schema.String,
argumentCount: Schema.Number,
exitCode: Schema.optional(Schema.Number),
stdoutLength: Schema.optional(Schema.Number),
stderrLength: Schema.optional(Schema.Number),
cause: Schema.optional(Schema.Defect()),
},
) {
override get message(): string {
return `Git subtree ${this.action} for reference repo "${this.repoId}" failed during "${this.operation}".`;
}
}

export const ReferenceRepoSyncError = Schema.Union([
ReferenceRepoSelectionError,
ReferenceRepoVersionSourceError,
ReferenceRepoVersionResolutionError,
ReferenceRepoGitSubtreeError,
]);
export type ReferenceRepoSyncError = typeof ReferenceRepoSyncError.Type;
export const isReferenceRepoSyncError = Schema.is(ReferenceRepoSyncError);

const decodeJsonSource = Schema.decodeUnknownEffect(Schema.UnknownFromJsonString);
const decodeYamlSource = Schema.decodeEffect(fromYaml(Schema.Unknown));
Expand All @@ -61,26 +124,21 @@ function readNestedString(input: unknown, keys: ReadonlyArray<string>): string |
}

function decodeVersionSource(
repo: ReferenceRepo,
sourcePath: string,
content: string,
): Effect.Effect<unknown, ReferenceRepoSyncError> {
if (sourcePath.endsWith(".yaml") || sourcePath.endsWith(".yml")) {
return decodeYamlSource(content).pipe(
Effect.mapError(
(cause) =>
new ReferenceRepoSyncError({
message: `Unable to parse version source ${sourcePath}.`,
cause,
}),
),
);
}

return decodeJsonSource(content).pipe(
const decode =
repo.versionSourcePath.endsWith(".yaml") || repo.versionSourcePath.endsWith(".yml")
? decodeYamlSource
: decodeJsonSource;
return decode(content).pipe(
Effect.mapError(
(cause) =>
new ReferenceRepoSyncError({
message: `Unable to parse version source ${sourcePath}.`,
new ReferenceRepoVersionSourceError({
operation: "parse",
repoId: repo.id,
sourcePath,
cause,
}),
),
Expand All @@ -98,10 +156,9 @@ function getSelectedRepos(
return repo
? Effect.succeed([repo])
: Effect.fail(
new ReferenceRepoSyncError({
message: `Unknown reference repo '${repoId}'. Expected one of: ${referenceRepos
.map((candidate) => candidate.id)
.join(", ")}.`,
new ReferenceRepoSelectionError({
repoId,
expectedRepoIds: referenceRepos.map((candidate) => candidate.id),
}),
);
}
Expand All @@ -121,20 +178,22 @@ export const resolveReferenceRepoRef = Effect.fn("resolveReferenceRepoRef")(func
const versionSourceContent = yield* fs.readFileString(versionSourcePath).pipe(
Effect.mapError(
(cause) =>
new ReferenceRepoSyncError({
message: `Unable to read package version for '${repo.id}' from ${versionSourcePath}.`,
new ReferenceRepoVersionSourceError({
operation: "read",
repoId: repo.id,
sourcePath: versionSourcePath,
cause,
}),
),
);
const versionSource = yield* decodeVersionSource(repo.versionSourcePath, versionSourceContent);
const versionSource = yield* decodeVersionSource(repo, versionSourcePath, versionSourceContent);
const version = readNestedString(versionSource, repo.packageVersionPath);

if (!version) {
return yield* new ReferenceRepoSyncError({
message: `Unable to resolve package version for '${repo.id}' at ${repo.versionSourcePath}:${repo.packageVersionPath.join(
".",
)}.`,
return yield* new ReferenceRepoVersionResolutionError({
repoId: repo.id,
sourcePath: versionSourcePath,
packageVersionPath: repo.packageVersionPath,
});
}

Expand Down Expand Up @@ -163,11 +222,20 @@ export const planReferenceRepoSync = Effect.fn("planReferenceRepoSync")(function

const runGit = Effect.fn("runGit")(function* (rootDir: string, plan: ReferenceRepoSyncPlan) {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const errorContext = {
repoId: plan.repo.id,
action: plan.action,
repository: plan.repo.repository,
ref: plan.ref,
rootDir,
argumentCount: plan.args.length,
} as const;
const child = yield* spawner.spawn(ChildProcess.make("git", plan.args, { cwd: rootDir })).pipe(
Effect.mapError(
(cause) =>
new ReferenceRepoSyncError({
message: `Unable to start git subtree ${plan.action} for '${plan.repo.id}'.`,
new ReferenceRepoGitSubtreeError({
...errorContext,
operation: "spawn",
cause,
}),
),
Expand All @@ -182,16 +250,21 @@ const runGit = Effect.fn("runGit")(function* (rootDir: string, plan: ReferenceRe
).pipe(
Effect.mapError(
(cause) =>
new ReferenceRepoSyncError({
message: `Unable to run git subtree ${plan.action} for '${plan.repo.id}'.`,
new ReferenceRepoGitSubtreeError({
...errorContext,
operation: "communicate",
cause,
}),
),
);

if (exitCode !== 0) {
return yield* new ReferenceRepoSyncError({
message: `git subtree ${plan.action} failed for '${plan.repo.id}' with exit code ${exitCode}.\n${stderr.trim()}`,
return yield* new ReferenceRepoGitSubtreeError({
...errorContext,
operation: "exit",
exitCode,
stdoutLength: stdout.length,
stderrLength: stderr.length,
});
}

Expand Down
Loading