diff --git a/.claude/tasks/0019-ci-integration-tier.md b/.claude/tasks/0019-ci-integration-tier.md index 7feea83..8c782a0 100644 --- a/.claude/tasks/0019-ci-integration-tier.md +++ b/.claude/tasks/0019-ci-integration-tier.md @@ -19,8 +19,36 @@ The workflow file stays GitHub-Actions-compatible so the GitHub mirror can adopt ## Acceptance criteria -- [ ] A workflow runs on push/PR on Gitea Actions, executing the unit and integration tiers (the local vitest suite) and the end-to-end tier -- [ ] The disposable Gitea runs as a service container pinned to a specific stable image tag -- [ ] End-to-end tests provision their own repo, token, and seed data on the disposable instance, then assert real CLI output and exit codes for at least the tracer command set -- [ ] The workflow uses only syntax that works verbatim (or near-verbatim) on GitHub Actions -- [ ] A fixture-vs-live divergence in a covered response shape fails the end-to-end tier +- [x] A workflow runs on push/PR on Gitea Actions, executing the unit and integration tiers (the local vitest suite) and the end-to-end tier +- [x] The disposable Gitea runs as a service container pinned to a specific stable image tag +- [x] End-to-end tests provision their own repo, token, and seed data on the disposable instance, then assert real CLI output and exit codes for at least the tracer command set +- [x] The workflow uses only syntax that works verbatim (or near-verbatim) on GitHub Actions +- [x] A fixture-vs-live divergence in a covered response shape fails the end-to-end tier + +## Implementation Notes + +**Tier split.** +The end-to-end tier lives under `test/e2e/` with its own `vitest.e2e.config.ts` and a `test:e2e` npm script; the default `vitest.config.ts` now excludes `test/e2e/**` so `npm test` stays the fast unit+integration tiers with no external dependency. +The e2e suite is gated on `GITEA_AXI_E2E_URL` via `describe.skipIf`, so it skips cleanly (exit 0) when no live instance is configured; `passWithNoTests` guards against a "no tests found" failure in that state. + +**In-Node provisioning, no `docker exec`.** +`test/e2e/provision.ts` brings a fresh Gitea to a usable state entirely over HTTP: it waits on `GET /api/v1/version`, registers the first user through the web `sign_up` form (Gitea makes the first account the site admin), scraping and echoing the double-submit CSRF token, then mints a scoped API token via HTTP Basic auth and creates the repo + seed issues with it. +This keeps provisioning identical on Gitea Actions, GitHub Actions, and a developer's local `docker run gitea/gitea`, with no container-shell access required. + +**Portable networking.** +The workflow job runs inside a `node:20-bookworm` container so the `gitea` service container is reachable by service name (`gitea:3000`) on both Gitea Actions and GitHub Actions, sidestepping the host-`localhost` vs. service-name difference between the two platforms — this is what keeps the file near-verbatim GitHub-compatible (AC4). + +**Fixture-vs-live guard (AC5).** +Rather than hardcode expected keys, the shape guard anchors on one exported contract, `COVERED_ISSUE_PATHS` — the exact dotted paths the issue-list `FieldDef` extractors read — and asserts it holds on *both* the recorded `fixtures/issues-open.json` and the live response. +A drift in either (a fixture edited out of shape, or a live field renamed such as `user`→`author`) fails the tier. + +**Pinned tag.** +`gitea/gitea:1.23.5`, chosen to match the gitea-js client line (`^1.23.0`) so the e2e tier exercises the response shapes the client was generated against; bumped deliberately by the operator. + +**Deviation from the letter of the ACs.** +The workflow adds a `typecheck` step that no AC names; it is cheap CI hygiene and kept deliberately. + +**Verified against a live Gitea 1.23.5.** +The full e2e tier was run against a real disposable `gitea/gitea:1.23.5` container (all seven tests green), which also confirms the pinned tag pulls. +The live run surfaced one thing the earlier mock run could not: creating a repo under a user (`POST /user/repos`) requires the token scope `write:user` on Gitea 1.23, not `write:repository` — the token scopes in `provision.ts` were corrected to `["write:user", "write:repository", "write:issue"]`. +This is exactly the fixture-vs-live class of divergence the tier exists to catch. diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..5752043 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,54 @@ +# CI for gitea-axi. Runs on Gitea Actions on the operator's instance; the syntax +# is kept GitHub-Actions-compatible so the GitHub mirror can adopt this file +# nearly verbatim (copy it to .github/workflows/). +# +# The job runs inside a node container so the disposable Gitea service is +# reachable by its service name (`gitea:3000`) on both Gitea Actions and GitHub +# Actions — avoiding the host-vs-service-name networking difference between the +# two platforms. +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + container: node:20-bookworm + + services: + gitea: + # Pinned to a specific stable tag, bumped deliberately (not floating). + # Kept on the 1.23 line to match the gitea-js client (^1.23.0), so the + # e2e tier exercises response shapes the client was generated against. + image: gitea/gitea:1.23.5 + env: + GITEA__security__INSTALL_LOCK: "true" + GITEA__database__DB_TYPE: sqlite3 + GITEA__database__PATH: /data/gitea/gitea.db + GITEA__server__ROOT_URL: http://gitea:3000/ + GITEA__server__HTTP_PORT: "3000" + GITEA__service__DISABLE_REGISTRATION: "false" + GITEA__service__REQUIRE_SIGNIN_VIEW: "false" + GITEA__log__LEVEL: warn + + env: + # The end-to-end tier provisions and drives the CLI against this instance. + GITEA_AXI_E2E_URL: http://gitea:3000 + + steps: + - uses: actions/checkout@v4 + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Unit and integration tiers + run: npm test + + - name: End-to-end tier + run: npm run test:e2e diff --git a/package.json b/package.json index 17c9532..6d0f3d6 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "prepublishOnly": "npm run build", "typecheck": "tsc --noEmit", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:e2e": "vitest run --config vitest.e2e.config.ts" }, "dependencies": { "@toon-format/toon": "^2.3.0", diff --git a/test/e2e/provision.ts b/test/e2e/provision.ts new file mode 100644 index 0000000..0fa4bb1 --- /dev/null +++ b/test/e2e/provision.ts @@ -0,0 +1,234 @@ +/** + * Provisioning for the end-to-end tier: bring a fresh, disposable Gitea instance + * to a usable state entirely over its HTTP API, with no `docker exec` or shell + * access to the container. Everything here runs identically on Gitea Actions, + * GitHub Actions, and a developer's local `docker run gitea/gitea`. + * + * Bootstrap chain: + * 1. Wait for the instance to answer `GET /api/v1/version`. + * 2. Register the first user through the web sign-up form — Gitea makes the + * first registered account the site administrator. + * 3. Mint a scoped API token for that user via HTTP Basic auth (no CSRF). + * 4. Create a repository and seed issues with that token. + */ + +export interface E2EInstance { + /** Instance base URL, without the /api/v1 suffix (what the CLI expects). */ + baseUrl: string; + owner: string; + repo: string; + token: string; + /** Titles of the seeded open issues, in creation order (newest number last). */ + openTitles: string[]; + /** Title of the single seeded closed issue. */ + closedTitle: string; +} + +const USERNAME = "e2e-admin"; +const PASSWORD = "e2e-admin-password-123"; +const EMAIL = "e2e-admin@example.com"; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForGitea(baseUrl: string): Promise { + const deadline = Date.now() + 120_000; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const res = await fetch(`${baseUrl}/api/v1/version`); + if (res.ok) { + return; + } + lastError = new Error(`GET /api/v1/version returned ${res.status}`); + } catch (error) { + lastError = error; + } + await sleep(1000); + } + throw new Error(`Gitea at ${baseUrl} never became ready: ${String(lastError)}`); +} + +/** A minimal cookie jar: keep the latest value per cookie name. */ +function collectCookies(jar: Map, res: Response): void { + for (const header of res.headers.getSetCookie()) { + const pair = header.split(";", 1)[0]!; + const eq = pair.indexOf("="); + if (eq > 0) { + jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()); + } + } +} + +function cookieHeader(jar: Map): string { + return [...jar.entries()].map(([name, value]) => `${name}=${value}`).join("; "); +} + +/** + * Register the first account through the web sign-up form. Gitea protects the + * form with a double-submit CSRF token that must be scraped from the rendered + * HTML and echoed back alongside the matching cookie. Best-effort: a fresh + * instance succeeds here, and {@link mintToken} is the real gate on success (a + * pre-existing account from a local re-run is tolerated). + */ +async function registerFirstUser(baseUrl: string): Promise { + const jar = new Map(); + const getRes = await fetch(`${baseUrl}/user/sign_up`); + collectCookies(jar, getRes); + const html = await getRes.text(); + const csrf = html.match(/name="_csrf"\s+value="([^"]+)"/)?.[1]; + if (!csrf) { + throw new Error("Could not find a CSRF token on the Gitea sign-up page"); + } + const form = new URLSearchParams({ + _csrf: csrf, + user_name: USERNAME, + email: EMAIL, + password: PASSWORD, + retype: PASSWORD, + }); + await fetch(`${baseUrl}/user/sign_up`, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + cookie: cookieHeader(jar), + }, + body: form.toString(), + redirect: "manual", + }); +} + +function basicAuth(): string { + return `Basic ${Buffer.from(`${USERNAME}:${PASSWORD}`).toString("base64")}`; +} + +async function mintToken(baseUrl: string): Promise { + const res = await fetch(`${baseUrl}/api/v1/users/${USERNAME}/tokens`, { + method: "POST", + headers: { + authorization: basicAuth(), + "content-type": "application/json", + }, + body: JSON.stringify({ + // write:user is what Gitea requires to create a repo under the user + // (POST /user/repos); write:repository/write:issue cover the repo + issue + // reads and writes the provisioning and CLI seam then perform. + name: `e2e-${Date.now()}`, + scopes: ["write:user", "write:repository", "write:issue"], + }), + }); + if (res.status !== 201) { + throw new Error( + `Token creation failed (${res.status}); first-user registration likely did not take. Body: ${await res.text()}`, + ); + } + const body = (await res.json()) as { sha1?: string }; + if (!body.sha1) { + throw new Error("Token response had no sha1 field"); + } + return body.sha1; +} + +/** + * One authenticated Gitea API round-trip: attach the token, send an optional + * JSON body, and fail on any non-2xx. Returns the raw {@link Response} so callers + * can read the body or headers (e.g. the x-total-count the count line uses). + */ +async function apiRequest( + baseUrl: string, + method: string, + path: string, + token: string, + payload?: unknown, +): Promise { + const res = await fetch(`${baseUrl}/api/v1${path}`, { + method, + headers: { + authorization: `token ${token}`, + ...(payload !== undefined ? { "content-type": "application/json" } : {}), + }, + body: payload !== undefined ? JSON.stringify(payload) : undefined, + }); + if (!res.ok) { + throw new Error(`${method} ${path} failed (${res.status}): ${await res.text()}`); + } + return res; +} + +export async function provisionInstance(baseUrl: string): Promise { + const normalized = baseUrl.replace(/\/+$/, ""); + await waitForGitea(normalized); + await registerFirstUser(normalized); + const token = await mintToken(normalized); + + const repo = `e2e-repo-${Date.now()}`; + await apiRequest(normalized, "POST", "/user/repos", token, { + name: repo, + auto_init: true, + default_branch: "main", + private: false, + }); + + const openTitles = ["E2E first issue", "E2E second issue", "E2E third issue"]; + for (const title of openTitles) { + await apiRequest(normalized, "POST", `/repos/${USERNAME}/${repo}/issues`, token, { + title, + body: `Seeded body for ${title}.`, + }); + } + + const closedTitle = "E2E closed issue"; + const closedRes = await apiRequest(normalized, "POST", `/repos/${USERNAME}/${repo}/issues`, token, { + title: closedTitle, + body: "Seeded closed issue.", + }); + const closed = (await closedRes.json()) as { number: number }; + await apiRequest(normalized, "PATCH", `/repos/${USERNAME}/${repo}/issues/${closed.number}`, token, { + state: "closed", + }); + + return { baseUrl: normalized, owner: USERNAME, repo, token, openTitles, closedTitle }; +} + +/** + * The response-shape paths the issue-list command's FieldDef extractors read + * (see ISSUE_LIST_FIELDS in src/commands/issue.ts). This one contract anchors + * three things that must agree: the extractors, the recorded fixtures, and the + * live Gitea response. The end-to-end shape guard asserts both the fixture and + * the live payload satisfy it, so a divergence in either fails the tier. + */ +export const COVERED_ISSUE_PATHS = ["number", "title", "state", "created_at", "user.login"]; + +/** Whether `obj` has a defined value at a dotted `path` (e.g. "user.login"). */ +export function hasPath(obj: unknown, path: string): boolean { + let value: unknown = obj; + for (const key of path.split(".")) { + if (typeof value !== "object" || value === null) { + return false; + } + value = (value as Record)[key]; + } + return value !== undefined && value !== null; +} + +/** + * Fetch the raw issues-list response the CLI's issue-list command consumes, for + * the response-shape guard. Returns both the parsed array and the header the + * count line is built from. + */ +export async function fetchRawIssues( + instance: E2EInstance, + state: "open" | "closed" | "all", +): Promise<{ issues: Record[]; totalCount: string | null }> { + const res = await apiRequest( + instance.baseUrl, + "GET", + `/repos/${instance.owner}/${instance.repo}/issues?type=issues&state=${state}&limit=30&page=1`, + instance.token, + ); + return { + issues: (await res.json()) as Record[], + totalCount: res.headers.get("x-total-count"), + }; +} diff --git a/test/e2e/tracer.test.ts b/test/e2e/tracer.test.ts new file mode 100644 index 0000000..5add944 --- /dev/null +++ b/test/e2e/tracer.test.ts @@ -0,0 +1,128 @@ +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 = {}): Record { + 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 "); + }); + + 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[]; + 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).login).toBe(instance.owner); + } + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 8b5840a..9728888 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,9 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { + // Unit + integration tiers: fast, no external dependencies. The end-to-end + // tier under test/e2e needs a live Gitea instance and runs via `test:e2e`. include: ["test/**/*.test.ts"], + exclude: ["test/e2e/**"], }, }); diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts new file mode 100644 index 0000000..3eb29bc --- /dev/null +++ b/vitest.e2e.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // The end-to-end tier only: the same CLI seam as the integration tier, but + // driven against a live disposable Gitea instance (see test/e2e). Provision + // and network round-trips need a longer timeout than the fast tiers. + include: ["test/e2e/**/*.test.ts"], + testTimeout: 30_000, + hookTimeout: 150_000, + // Without a live instance (GITEA_AXI_E2E_URL unset) every suite skips; + // that must be a pass, not a "no tests found" failure. + passWithNoTests: true, + }, +});