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
216 changes: 216 additions & 0 deletions apps/server/src/sourceControl/SourceControlProviderDiscovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import { ChildProcessSpawner } from "effect/unstable/process";
import {
type VcsError,
VcsProcessExitError,
VcsProcessSpawnError,
VcsProcessTimeoutError,
} from "@t3tools/contracts";

import * as VcsProcess from "../vcs/VcsProcess.ts";
import * as AzureDevOps from "./AzureDevOpsSourceControlProvider.ts";
import { probeSourceControlProvider } from "./SourceControlProviderDiscovery.ts";

const processOutput = (
stdout: string,
options?: {
readonly stderr?: string;
readonly exitCode?: ChildProcessSpawner.ExitCode;
},
): VcsProcess.VcsProcessOutput => ({
exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0),
stdout,
stderr: options?.stderr ?? "",
stdoutTruncated: false,
stderrTruncated: false,
});

interface CapturingProcess {
readonly process: VcsProcess.VcsProcess["Service"];
readonly calls: ReadonlyArray<VcsProcess.VcsProcessInput>;
}

const makeProcess = (
run: (input: VcsProcess.VcsProcessInput) => Effect.Effect<VcsProcess.VcsProcessOutput, VcsError>,
): CapturingProcess => {
const calls: VcsProcess.VcsProcessInput[] = [];
return {
calls,
process: {
run: (input) => {
calls.push(input);
return run(input);
},
},
};
};

const isVersionCall = (input: VcsProcess.VcsProcessInput): boolean => input.args[0] === "--version";
const isAuthCall = (input: VcsProcess.VcsProcessInput): boolean => input.args[0] === "account";

it.effect(
"reports Azure CLI as available and still runs the auth probe when `--version` times out",
() =>
Effect.gen(function* () {
const { process, calls } = makeProcess((input) => {
if (isVersionCall(input)) {
return Effect.fail(
new VcsProcessTimeoutError({
operation: input.operation,
command: input.command,
cwd: input.cwd,
timeoutMs: input.timeoutMs ?? 0,
}),
);
}
return Effect.succeed(processOutput("azure-user@example.com\n"));
});

const item = yield* probeSourceControlProvider({
spec: AzureDevOps.discovery,
process,
cwd: "/repo",
});

assert.strictEqual(item.status, "available");
assert.strictEqual(item.auth.status, "authenticated");
assert.deepStrictEqual(item.auth.account, Option.some("azure-user@example.com"));
assert.ok(
calls.some(isAuthCall),
"expected an `account show` auth probe to run after a slow `--version`",
);
}),
);

it.effect(
"reports Azure CLI as missing and skips the auth probe when `--version` cannot spawn",
() =>
Effect.gen(function* () {
const { process, calls } = makeProcess((input) => {
if (isVersionCall(input)) {
return Effect.fail(
new VcsProcessSpawnError({
operation: input.operation,
command: input.command,
cwd: input.cwd,
cause: Object.assign(new Error("spawn az ENOENT"), { code: "ENOENT" }),
}),
);
}
return Effect.succeed(processOutput("azure-user@example.com\n"));
});

const item = yield* probeSourceControlProvider({
spec: AzureDevOps.discovery,
process,
cwd: "/repo",
});

assert.strictEqual(item.status, "missing");
assert.strictEqual(item.auth.status, "unknown");
assert.strictEqual(calls.length, 1);
assert.strictEqual(calls[0]?.args[0], "--version");
}),
);

it.effect(
"raises the `--version` probe timeout above 15s and reports the version on the happy path",
() =>
Effect.gen(function* () {
const { process, calls } = makeProcess((input) => {
if (isVersionCall(input)) {
return Effect.succeed(processOutput("azure-cli 2.87.0\n"));
}
return Effect.succeed(processOutput("azure-user@example.com\n"));
});

const item = yield* probeSourceControlProvider({
spec: AzureDevOps.discovery,
process,
cwd: "/repo",
});

const versionCall = calls.find(isVersionCall);
assert.ok(versionCall, "expected a `--version` probe to run");
assert.ok(
(versionCall.timeoutMs ?? 0) >= 15_000,
`expected the \`--version\` probe timeout to be >= 15000ms, got ${String(versionCall.timeoutMs)}`,
);
assert.deepStrictEqual(item.version, Option.some("azure-cli 2.87.0"));
assert.strictEqual(item.status, "available");
assert.strictEqual(item.auth.status, "authenticated");
}),
);

it.effect(
"reports available but unauthenticated when `--version` times out and `account show` exits non-zero",
() =>
Effect.gen(function* () {
const { process } = makeProcess((input) => {
if (isVersionCall(input)) {
return Effect.fail(
new VcsProcessTimeoutError({
operation: input.operation,
command: input.command,
cwd: input.cwd,
timeoutMs: input.timeoutMs ?? 0,
}),
);
}
return Effect.succeed(
processOutput("", {
stderr: "ERROR: Please run 'az login' to setup account.",
exitCode: ChildProcessSpawner.ExitCode(1),
}),
);
});

const item = yield* probeSourceControlProvider({
spec: AzureDevOps.discovery,
process,
cwd: "/repo",
});

assert.strictEqual(item.status, "available");
assert.strictEqual(item.auth.status, "unauthenticated");
}),
);

it.effect(
"treats a non-spawn `--version` failure (e.g. a non-zero exit) as present and still runs the auth probe",
() =>
Effect.gen(function* () {
const { process, calls } = makeProcess((input) => {
if (isVersionCall(input)) {
return Effect.fail(
new VcsProcessExitError({
operation: input.operation,
command: input.command,
cwd: input.cwd,
exitCode: 1,
detail: "Process exited with a non-zero status.",
}),
);
}
return Effect.succeed(processOutput("azure-user@example.com\n"));
});

const item = yield* probeSourceControlProvider({
spec: AzureDevOps.discovery,
process,
cwd: "/repo",
});

// Only a genuine spawn failure means "missing"; any other runtime failure of
// `--version` (here a non-zero exit) means the CLI is present, so it stays
// available and the auth probe still runs. (issue #4)
assert.strictEqual(item.status, "available");
assert.strictEqual(item.auth.status, "authenticated");
assert.ok(
calls.some(isAuthCall),
"expected the auth probe to run after a non-spawn `--version` failure",
);
}),
);
28 changes: 20 additions & 8 deletions apps/server/src/sourceControl/SourceControlProviderDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import * as Option from "effect/Option";
import type * as SourceControlProvider from "./SourceControlProvider.ts";
import type * as VcsProcess from "../vcs/VcsProcess.ts";

// `az --version` on Windows routinely takes 6-8s (Python startup + Azure CLI's
// "updates available" check), so the old 5s cap misclassified a present-but-slow
// CLI as missing. Raise the discovery probe budget well above that. (issue #4)
const CLI_PROBE_TIMEOUT_MS = 15_000;

export interface SourceControlAuthProbeInput {
readonly stdout: string;
readonly stderr: string;
Expand Down Expand Up @@ -167,7 +172,7 @@ function probeCli(input: {
command: input.spec.executable,
args: input.spec.versionArgs,
cwd: input.cwd,
timeoutMs: 5_000,
timeoutMs: CLI_PROBE_TIMEOUT_MS,
maxOutputBytes: 8_000,
appendTruncationMarker: true,
})
Expand All @@ -186,17 +191,24 @@ function probeCli(input: {
detail: Option.none<string>(),
}) satisfies DiscoveryProbeResult,
),
Effect.catch((cause) =>
Effect.succeed({
Effect.catch((cause) => {
// Only a genuine spawn failure (e.g. ENOENT) means the CLI is absent, so
// that is the sole case that reports "missing" and shows the install hint.
// Any other failure (timeout, non-zero exit, etc.) means the CLI is present
// but the version probe was slow/noisy; keep it "available" so
// `probeSourceControlProvider` still runs the fast `account show` auth
// probe instead of short-circuiting to the install hint. (issue #4)
const status = cause._tag === "VcsProcessSpawnError" ? "missing" : "available";
return Effect.succeed({
kind: input.spec.kind,
label: input.spec.label,
executable: input.spec.executable,
status: "missing" as const,
status,
version: Option.none<string>(),
installHint: input.spec.installHint,
detail: detailFromCause(cause),
} satisfies DiscoveryProbeResult),
),
} satisfies DiscoveryProbeResult);
}),
);
}

Expand Down Expand Up @@ -244,7 +256,7 @@ export function probeSourceControlProvider(input: {
args: spec.authArgs,
cwd: input.cwd,
allowNonZeroExit: true,
timeoutMs: 5_000,
timeoutMs: CLI_PROBE_TIMEOUT_MS,
maxOutputBytes: 8_000,
appendTruncationMarker: true,
})
Expand Down Expand Up @@ -287,7 +299,7 @@ export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvide
args: spec.authArgs,
cwd: input.cwd,
allowNonZeroExit: true,
timeoutMs: 5_000,
timeoutMs: CLI_PROBE_TIMEOUT_MS,
maxOutputBytes: 8_000,
appendTruncationMarker: true,
})
Expand Down