ci: add end-to-end test tier, CI workflow, and coverage gating #1

Merged
alexion merged 3 commits from task-0019-ci-integration-tier into main 2026-07-11 14:03:45 -04:00
3 changed files with 226 additions and 11 deletions
Showing only changes of commit ae0f4c2675 - Show all commits

View File

@@ -0,0 +1,95 @@
import { AxiError } from "axi-sdk-js";
import { describe, expect, it } from "vitest";
import { classifyHttpError } from "../src/errors.js";
/**
* Unit-tier coverage of the HTTP error classifier (a pure function, no I/O).
* The integration tier drives the classifier through the issue-list seam, but
* some branches belong to paths no shipped command reaches yet — pull-request
* 404s, the already-classified passthrough, and non-HTTP transport failures —
* so they are exercised directly here against crafted inputs.
*/
function httpError(status: number, url: string, body?: unknown) {
return { status, url, error: body };
}
const REPO = "http://gitea.example/api/v1/repos/o/r";
describe("classifyHttpError", () => {
it("passes an already-classified AxiError through unchanged", () => {
const original = new AxiError("boom", "FORBIDDEN", ["hint"]);
expect(classifyHttpError(original)).toBe(original);
});
it("classifies a 404 on an issue path as ISSUE_NOT_FOUND", () => {
const result = classifyHttpError(httpError(404, `${REPO}/issues/42`));
expect(result.code).toBe("ISSUE_NOT_FOUND");
expect(result.message).toContain("#42");
});
it("classifies a 404 on a pull path as PR_NOT_FOUND", () => {
const result = classifyHttpError(httpError(404, `${REPO}/pulls/7`));
expect(result.code).toBe("PR_NOT_FOUND");
expect(result.message).toContain("#7");
});
it("classifies a 404 on the repo subtree as REPO_NOT_FOUND", () => {
const result = classifyHttpError(httpError(404, `${REPO}/issues`));
expect(result.code).toBe("REPO_NOT_FOUND");
expect(result.message).toContain("o/r");
});
it("classifies a 404 on a non-repo path as UNKNOWN", () => {
const result = classifyHttpError(httpError(404, "http://gitea.example/api/v1/version"));
expect(result.code).toBe("UNKNOWN");
});
it("falls back to the raw url when the response url does not parse", () => {
const result = classifyHttpError(httpError(404, "::not a url::"));
expect(result.code).toBe("UNKNOWN");
expect(result.message).toContain("::not a url::");
});
it("uses the body message when present, and a default when absent", () => {
expect(classifyHttpError(httpError(401, REPO, { message: "token expired" })).message).toBe(
"token expired",
);
expect(classifyHttpError(httpError(401, REPO, {})).message).toBe("Authentication required");
});
it("maps 403 and every validation status (405/409/422) and 429 to their codes", () => {
expect(classifyHttpError(httpError(403, REPO, {})).code).toBe("FORBIDDEN");
expect(classifyHttpError(httpError(405, REPO, {})).code).toBe("VALIDATION_ERROR");
expect(classifyHttpError(httpError(409, REPO, {})).code).toBe("VALIDATION_ERROR");
expect(classifyHttpError(httpError(422, REPO, {})).code).toBe("VALIDATION_ERROR");
expect(classifyHttpError(httpError(429, REPO, {})).code).toBe("RATE_LIMITED");
});
it("maps an unexpected status to UNKNOWN, with and without a body message", () => {
expect(classifyHttpError(httpError(500, REPO, { message: "kaboom" })).message).toContain(
"kaboom",
);
const bare = classifyHttpError(httpError(503, REPO, {}));
expect(bare.code).toBe("UNKNOWN");
expect(bare.message).toContain("503");
});
it("classifies a plain Error transport failure as UNKNOWN", () => {
const result = classifyHttpError(new Error("network down"));
expect(result.code).toBe("UNKNOWN");
expect(result.message).toContain("network down");
});
it("includes the underlying cause when a transport failure carries one", () => {
const error = new Error("fetch failed", { cause: new Error("ECONNREFUSED") });
const result = classifyHttpError(error);
expect(result.message).toContain("fetch failed");
expect(result.message).toContain("ECONNREFUSED");
});
it("stringifies a non-Error thrown value", () => {
const result = classifyHttpError("just a string");
expect(result.code).toBe("UNKNOWN");
expect(result.message).toContain("just a string");
});
});

View File

@@ -24,24 +24,49 @@ afterAll(() => {
rmSync(root, { recursive: true, force: true });
});
interface SandboxOptions {
logins?: FakeLogin[];
token?: string;
/** Set false to omit the tea binary entirely (TEA_NOT_INSTALLED path). */
tea?: boolean;
/** Raw stdout for `tea login list`, overriding the logins JSON. */
listOutput?: string;
/** Exit code for `tea login list` (default 0). */
listExitCode?: number;
/** stderr line emitted by `tea login list` when it fails. */
listStderr?: string;
/** Raw stdout for `tea login helper get`, overriding the credential block. */
helperOutput?: string;
/** Exit code for `tea login helper get` (default 0). */
helperExitCode?: number;
/** stderr line emitted by `tea login helper get` when it fails. */
helperStderr?: string;
}
/** A PATH dir with real git and optionally a fake tea baked to fixed replies. */
function makeSandbox(options: { logins?: FakeLogin[]; token?: string; tea?: boolean }): string {
function makeSandbox(options: SandboxOptions): string {
const bin = join(root, `bin-${sandboxCounter++}`);
mkdirSync(bin);
symlinkSync(gitPath, join(bin, "git"));
symlinkSync(catPath, join(bin, "cat"));
if (options.tea !== false) {
const listOutput = options.listOutput ?? JSON.stringify(options.logins ?? []);
const helperOutput =
options.helperOutput ??
`protocol=http\nhost=fixture\nusername=u\npassword=${options.token ?? ""}`;
const script = `#!/bin/sh
if [ "$1" = "login" ] && [ "$2" = "list" ]; then
cat <<'JSON'
${JSON.stringify(options.logins ?? [])}
JSON
exit 0
cat <<'LISTEOF'
${listOutput}
LISTEOF
${options.listStderr ? ` echo '${options.listStderr}' >&2\n` : ""} exit ${options.listExitCode ?? 0}
fi
if [ "$1" = "login" ] && [ "$2" = "helper" ] && [ "$3" = "get" ]; then
cat > /dev/null
printf 'protocol=http\\nhost=fixture\\nusername=u\\npassword=%s\\n' '${options.token ?? ""}'
exit 0
cat <<'HELPEREOF'
${helperOutput}
HELPEREOF
${options.helperStderr ? ` echo '${options.helperStderr}' >&2\n` : ""} exit ${options.helperExitCode ?? 0}
fi
echo "unexpected tea invocation: $*" >&2
exit 1
@@ -254,4 +279,99 @@ describe("repository context detection", () => {
expect(server!.requests[0]!.headers.authorization).toBe("Bearer named-token");
expect(stdout).toContain("--login fixture");
});
it("maps a failing `tea login list` to UNKNOWN, surfacing the stderr detail", async () => {
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
const bin = makeSandbox({ listExitCode: 1, listStderr: "config file is corrupt" });
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
env: { PATH: bin },
cwd,
});
expect(exitCode).toBe(1);
expect(stdout).toContain("code: UNKNOWN");
expect(stdout).toContain("tea login list");
expect(stdout).toContain("config file is corrupt");
});
it("maps invalid JSON from `tea login list` to UNKNOWN", async () => {
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
const bin = makeSandbox({ listOutput: "not json at all {" });
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
env: { PATH: bin },
cwd,
});
expect(exitCode).toBe(1);
expect(stdout).toContain("code: UNKNOWN");
expect(stdout).toContain("invalid JSON");
});
it("maps non-array JSON from `tea login list` to UNKNOWN", async () => {
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
const bin = makeSandbox({ listOutput: '{"not":"an array"}' });
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
env: { PATH: bin },
cwd,
});
expect(exitCode).toBe(1);
expect(stdout).toContain("code: UNKNOWN");
expect(stdout).toContain("unexpected output");
});
it("tolerates login entries with missing fields", async () => {
// A login object with no name/url/ssh_host exercises the field fallbacks;
// it matches no host, so resolution ends in REPO_NOT_FOUND.
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
const bin = makeSandbox({ listOutput: "[{}]" });
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
env: { PATH: bin },
cwd,
});
expect(exitCode).toBe(1);
expect(stdout).toContain("code: REPO_NOT_FOUND");
});
it("maps a failing token helper to AUTH_REQUIRED with a repair hint", async () => {
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
const bin = makeSandbox({
logins: [{ name: "fixture", url: "https://gitea.example.com", default: "true" }],
helperExitCode: 1,
helperStderr: "credential store is locked",
});
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
env: { PATH: bin },
cwd,
});
expect(exitCode).toBe(1);
expect(stdout).toContain("code: AUTH_REQUIRED");
expect(stdout).toContain("tea login edit fixture");
expect(stdout).toContain("credential store is locked");
});
it("maps an empty token from the helper to AUTH_REQUIRED", async () => {
const cwd = makeRepo("https://gitea.example.com/testowner/testrepo.git");
const bin = makeSandbox({
logins: [{ name: "fixture", url: "https://gitea.example.com", default: "true" }],
// A credential block that carries no usable password value.
helperOutput: "protocol=http\nhost=gitea.example.com\nusername=u\npassword=",
});
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
env: { PATH: bin },
cwd,
});
expect(exitCode).toBe(1);
expect(stdout).toContain("code: AUTH_REQUIRED");
expect(stdout).toContain("tea login edit fixture");
});
});

View File

@@ -23,10 +23,10 @@ export default defineConfig({
// coverage so a real drop fails CI while trivial churn does not. Raise
// these as coverage climbs; never lower them to make a red build pass.
thresholds: {
statements: 85,
branches: 78,
functions: 90,
lines: 85,
statements: 92,
branches: 87,
functions: 95,
lines: 92,
},
},
},