Files
gitea-axi/test/e2e/tracer.test.ts
alexion 1d18472297 ci: add end-to-end tier and Gitea Actions workflow (task 0019)
Add the end-to-end test tier: a Gitea Actions workflow (GitHub-compatible)
that runs the unit+integration tiers plus e2e tests against a pinned
disposable Gitea service container. The e2e suite provisions its own repo,
token, and seed data over HTTP and drives the real CLI seam, gated on
GITEA_AXI_E2E_URL so the default suite stays dependency-free.

A fixture-vs-live shape guard anchors the issue-list FieldDef paths against
both the recorded fixture and the live response, failing on divergence.
2026-07-11 09:01:12 -04:00

129 lines
4.7 KiB
TypeScript

import { readFileSync } from "node:fs";
import { beforeAll, describe, expect, it } from "vitest";
import { runCliTest } from "../harness.js";
import {
COVERED_ISSUE_PATHS,
fetchRawIssues,
hasPath,
provisionInstance,
type E2EInstance,
} from "./provision.js";
/**
* The end-to-end tier: the real CLI seam (argv in, stdout/exit-code out) against
* a live, disposable Gitea instance seeded over its own API. Gated on
* GITEA_AXI_E2E_URL so the default `npm test` (unit + integration tiers) never
* needs a live instance; CI sets it to the service container's URL.
*/
const E2E_URL = process.env.GITEA_AXI_E2E_URL;
const RELATIVE_TIME = /(just now|\d+(m|h|d|mo|y) ago)/;
describe.skipIf(!E2E_URL)("end-to-end: tracer command set", () => {
let instance: E2EInstance;
function env(overrides: Record<string, string> = {}): Record<string, string> {
return {
GITEA_AXI_API_URL: instance.baseUrl,
GITEA_AXI_TOKEN: instance.token,
GITEA_AXI_REPO: `${instance.owner}/${instance.repo}`,
...overrides,
};
}
beforeAll(async () => {
instance = await provisionInstance(E2E_URL!);
}, 150_000);
it("lists seeded open issues with the default fields and a live count line", async () => {
const { stdout, exitCode } = await runCliTest(["issue", "list"], { env: env() });
expect(exitCode).toBe(0);
const lines = stdout.split("\n");
expect(lines[0]).toBe("count: 3 of 3 total");
expect(lines[1]).toBe("issues[3]{number,title,state,author,created}:");
for (const title of instance.openTitles) {
expect(stdout).toContain(title);
}
expect(stdout).toContain(`,open,${instance.owner},`);
expect(stdout).toMatch(RELATIVE_TIME);
expect(stdout).toMatch(/^help\[\d+\]:/m);
// No `type` field ever leaks into the row header.
expect(lines[1]).not.toContain("type");
});
it("filters to the seeded closed issue with --state closed", async () => {
const { stdout, exitCode } = await runCliTest(["issue", "list", "--state", "closed"], {
env: env(),
});
expect(exitCode).toBe(0);
expect(stdout).toContain("count: 1 of 1 total");
expect(stdout).toContain(instance.closedTitle);
expect(stdout).toContain(",closed,");
});
it("honors --limit and reports the full total from X-Total-Count", async () => {
const { stdout, exitCode } = await runCliTest(["issue", "list", "--limit", "1"], {
env: env(),
});
expect(exitCode).toBe(0);
expect(stdout).toContain("count: 1 of 3 total");
expect(stdout).toContain("issues[1]{number,title,state,author,created}:");
expect(stdout).toContain("issue list --limit <n>");
});
it("classifies a missing repository as REPO_NOT_FOUND with exit code 1", async () => {
const { stdout, exitCode } = await runCliTest(["issue", "list"], {
env: env({ GITEA_AXI_REPO: `${instance.owner}/does-not-exist-e2e` }),
});
expect(exitCode).toBe(1);
expect(stdout).toContain("code: REPO_NOT_FOUND");
});
it("rejects an invalid --state before touching the network, exit code 2", async () => {
const { stdout, exitCode } = await runCliTest(["issue", "list", "--state", "banana"], {
env: env(),
});
expect(exitCode).toBe(2);
expect(stdout).toContain("code: VALIDATION_ERROR");
});
it("renders the home view against the live repo", async () => {
const { stdout, exitCode } = await runCliTest([], { env: env() });
expect(exitCode).toBe(0);
expect(stdout).toContain(`repo: ${instance.owner}/${instance.repo}`);
expect(stdout).toMatch(/^help\[\d+\]:/m);
});
it("guards against fixture-vs-live divergence in the issues response shape", async () => {
// The recorded fixture that the integration tier asserts against. The
// covered-paths contract must hold on both it and the live payload; if
// either drifts from the shape the extractors read, this tier fails.
const fixture = JSON.parse(
readFileSync(new URL("../fixtures/issues-open.json", import.meta.url), "utf8"),
) as Record<string, unknown>[];
for (const recorded of fixture) {
for (const path of COVERED_ISSUE_PATHS) {
expect(hasPath(recorded, path), `fixture missing ${path}`).toBe(true);
}
}
const { issues, totalCount } = await fetchRawIssues(instance, "open");
// The count line is built from this header; its absence would silently
// change output, so it is part of the covered shape.
expect(totalCount).toBe("3");
expect(issues).toHaveLength(3);
for (const issue of issues) {
for (const path of COVERED_ISSUE_PATHS) {
expect(hasPath(issue, path), `live issue missing ${path}`).toBe(true);
}
expect((issue.user as Record<string, unknown>).login).toBe(instance.owner);
}
});
});