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.
This commit is contained in:
2026-07-11 08:44:09 -04:00
parent d03a079854
commit 1d18472297
7 changed files with 469 additions and 6 deletions

View File

@@ -19,8 +19,36 @@ The workflow file stays GitHub-Actions-compatible so the GitHub mirror can adopt
## Acceptance criteria ## 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 - [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
- [ ] The disposable Gitea runs as a service container pinned to a specific stable image tag - [x] 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 - [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
- [ ] The workflow uses only syntax that works verbatim (or near-verbatim) on GitHub Actions - [x] 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 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.

54
.gitea/workflows/ci.yml Normal file
View File

@@ -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

View File

@@ -18,7 +18,8 @@
"prepublishOnly": "npm run build", "prepublishOnly": "npm run build",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest",
"test:e2e": "vitest run --config vitest.e2e.config.ts"
}, },
"dependencies": { "dependencies": {
"@toon-format/toon": "^2.3.0", "@toon-format/toon": "^2.3.0",

234
test/e2e/provision.ts Normal file
View File

@@ -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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForGitea(baseUrl: string): Promise<void> {
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<string, string>, 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, string>): 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<void> {
const jar = new Map<string, string>();
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<string> {
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<Response> {
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<E2EInstance> {
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<string, unknown>)[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<string, unknown>[]; 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<string, unknown>[],
totalCount: res.headers.get("x-total-count"),
};
}

128
test/e2e/tracer.test.ts Normal file
View File

@@ -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<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);
}
});
});

View File

@@ -2,6 +2,9 @@ import { defineConfig } from "vitest/config";
export default defineConfig({ export default defineConfig({
test: { 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"], include: ["test/**/*.test.ts"],
exclude: ["test/e2e/**"],
}, },
}); });

15
vitest.e2e.config.ts Normal file
View File

@@ -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,
},
});