From 03937a8f6e86fde8aa08631d87bb1bf0af2b64e2 Mon Sep 17 00:00:00 2001 From: alexion Date: Tue, 14 Jul 2026 07:35:45 -0400 Subject: [PATCH] test: add label e2e coverage and fixture-tier backfill (task 0015) --- .claude/tasks/0015-label-commands.md | 12 ++ test/e2e/mutations.test.ts | 86 +++++++++ test/e2e/provision.ts | 12 ++ test/label.test.ts | 261 +++++++++++++++++++++++++++ 4 files changed, 371 insertions(+) diff --git a/.claude/tasks/0015-label-commands.md b/.claude/tasks/0015-label-commands.md index d83d65b..79b8693 100644 --- a/.claude/tasks/0015-label-commands.md +++ b/.claude/tasks/0015-label-commands.md @@ -20,6 +20,7 @@ The label command group: `label list`, `label create`, `label edit`, `label dele - [x] `label edit`/`label delete` on an unknown name yield `VALIDATION_ERROR` (exit 2) - [x] `label delete ` outputs `delete: ok` + `label: ` - [x] Fixture-server tests cover create, idempotent re-create, edit, delete, and the unknown-name refusals +- [x] End-to-end tests exercise the live-Gitea semantics the fixture server cannot attest to: the `#`-prefixed color round-trip on create, idempotency against the live listing, and name→id resolution behind edit/delete (plus the not-idempotent delete refusal) ## Implementation Notes @@ -38,3 +39,14 @@ Added `renderObject(item, help)` to `src/render.ts` because the label create/edi - **Shared lookup + positional helpers (cleanups from review).** Extracted `findLabel`/`resolveLabel` and a shared `labelNotFound` message into `src/lookup.ts`, reused by the existing `resolveLabelIds`; and extracted `parseSinglePositional` in `src/flags.ts`, now shared by `parsePositionalNumber` and the label name positionals, removing the duplicated count-check/error scaffolding. - **`label list` uses a single-page fetch with `--limit` (default 500)**, reading `X-Total-Count` for the count line, rather than the exhaustive pagination `resolveLabel`/`resolveLabelIds` use; the spec asks only for `--limit`, and a repo with >500 labels is signalled by the `count: N of T total` line. + +### Follow-ups added after review + +- **End-to-end tier extended.** +The task originally scoped its tests to the fixture-server tier only, matching the precedent of the preceding PR-command tasks (0011–0014), none of which added e2e cases. +On reflection the label commands sit squarely inside the e2e tier's charter — "behavior the fixture server cannot attest to" — because a fixture server never enforces that Gitea's `CreateLabelOption.color` requires the leading `#`, nor that edit/delete really key on the numeric label id. +Added a `test/e2e/mutations.test.ts` label-lifecycle case (create → idempotent re-create → edit → delete, verified against live state via a new `fetchLabels` provisioner helper) plus the not-idempotent delete refusal. +These run only in CI (gated on `GITEA_AXI_E2E_URL`). +- **Unit-tier coverage backfill.** +The initial fixture tests covered only the happy paths and unknown-name refusals, leaving the help output, validation errors, and API-error propagation untested — enough to drop the repo below its global branch-coverage gate. +Added confirming fixture tests for those behaviors, bringing `src/commands/label.ts` to ~96% line / ~86% branch and the suite back over its thresholds. diff --git a/test/e2e/mutations.test.ts b/test/e2e/mutations.test.ts index d839b5a..3181795 100644 --- a/test/e2e/mutations.test.ts +++ b/test/e2e/mutations.test.ts @@ -3,6 +3,7 @@ import { runCliTest } from "../harness.js"; import { fetchComments, fetchIssue, + fetchLabels, fetchOpenPulls, provisionInstance, seedBranch, @@ -216,3 +217,88 @@ describe.skipIf(!E2E_URL)("end-to-end: pull request mutations", () => { expect(comments[0]!.body).toBe("A PR comment from the e2e tier."); }); }); + +describe.skipIf(!E2E_URL)("end-to-end: label mutations", () => { + let instance: E2EInstance; + // Unique to this block so it never collides with the seeded instance.labelName. + const NAME = "E2E-Lifecycle"; + + function env(): Record { + return envFor(instance); + } + + beforeAll(async () => { + instance = await instanceOnce(); + }, 150_000); + + /** The repo's labels named `name`, matched case-insensitively. */ + async function labelsNamed(name: string): Promise[]> { + const labels = await fetchLabels(instance); + return labels.filter( + (label) => String(label.name).toLowerCase() === name.toLowerCase(), + ); + } + + /** A color as Gitea returned it, with any leading `#` stripped for comparison. */ + function normalizedColor(label: Record): string { + return String(label.color).replace(/^#/, ""); + } + + it("creates, re-creates idempotently, edits, and deletes a label against live Gitea", async () => { + // 1. Create: the CLI prepends `#` to the color, which live Gitea requires. + const created = await runCliTest( + ["label", "create", "--name", NAME, "--color", "ff0000"], + { env: env() }, + ); + expect(created.exitCode).toBe(0); + expect(created.stdout).toContain("created: ok"); + expect(created.stdout).toContain(`label: ${NAME}`); + + const afterCreate = await labelsNamed(NAME); + expect(afterCreate).toHaveLength(1); + expect(normalizedColor(afterCreate[0]!)).toBe("ff0000"); + + // 2. Re-create with different casing: the live listing is checked + // case-insensitively, so no second label is made and nothing changes. + const recreated = await runCliTest( + ["label", "create", "--name", NAME.toUpperCase(), "--color", "00ff00"], + { env: env() }, + ); + expect(recreated.exitCode).toBe(0); + expect(recreated.stdout).toContain("create: already_exists"); + expect(recreated.stdout).toContain(`label: ${NAME}`); + + const afterRecreate = await labelsNamed(NAME); + expect(afterRecreate).toHaveLength(1); + expect(normalizedColor(afterRecreate[0]!)).toBe("ff0000"); + + // 3. Edit: name→id resolution plus PATCH-by-id, verified live. + const edited = await runCliTest( + ["label", "edit", NAME, "--color", "00ff00"], + { env: env() }, + ); + expect(edited.exitCode).toBe(0); + expect(edited.stdout).toContain("edit: ok"); + + const afterEdit = await labelsNamed(NAME); + expect(afterEdit).toHaveLength(1); + expect(normalizedColor(afterEdit[0]!)).toBe("00ff00"); + + // 4. Delete: name→id resolution plus DELETE-by-id, verified live. + const deleted = await runCliTest(["label", "delete", NAME], { env: env() }); + expect(deleted.exitCode).toBe(0); + expect(deleted.stdout).toContain("delete: ok"); + + expect(await labelsNamed(NAME)).toHaveLength(0); + }); + + it("refuses to delete a label that does not exist", async () => { + const { stdout, exitCode } = await runCliTest( + ["label", "delete", "no-such-label-xyz"], + { env: env() }, + ); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + }); +}); diff --git a/test/e2e/provision.ts b/test/e2e/provision.ts index 80fe70f..f5db011 100644 --- a/test/e2e/provision.ts +++ b/test/e2e/provision.ts @@ -250,6 +250,18 @@ export async function fetchOpenPulls( return (await res.json()) as Record[]; } +/** Fetch the repository's labels as Gitea returns them, for verifying what a + * label mutation wrote against live state rather than the CLI's own echo. */ +export async function fetchLabels(instance: E2EInstance): Promise[]> { + const res = await apiRequest( + instance.baseUrl, + "GET", + `/repos/${instance.owner}/${instance.repo}/labels`, + instance.token, + ); + return (await res.json()) as Record[]; +} + /** Fetch a single issue as Gitea returns it, for verifying what a mutation wrote. */ export async function fetchIssue( instance: E2EInstance, diff --git a/test/label.test.ts b/test/label.test.ts index 002542a..13832d6 100644 --- a/test/label.test.ts +++ b/test/label.test.ts @@ -52,6 +52,41 @@ describe("label list", () => { expect(stdout).toContain("count: 0 of 0 total"); expect(stdout).toContain("labels[0]: (none)"); }); + + it("accepts a numeric --limit value", async () => { + server = await startFixtureServer([ + { + method: "GET", + path: LABELS_PATH, + headers: { "X-Total-Count": "1" }, + body: [{ id: 11, name: "bug" }], + }, + ]); + const { stdout, exitCode } = await runCliTest( + ["label", "list", "--limit", "5"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(stdout).toContain("count: 1 of 1 total"); + }); + + it("propagates a 403 from the labels API as a FORBIDDEN error", async () => { + server = await startFixtureServer([ + { + method: "GET", + path: LABELS_PATH, + status: 403, + body: { message: "forbidden" }, + }, + ]); + const { stdout, exitCode } = await runCliTest(["label", "list"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(1); + expect(stdout).toContain("code: FORBIDDEN"); + }); }); describe("label create", () => { @@ -90,6 +125,48 @@ describe("label create", () => { expect(stdout).toContain("label: bug"); expect(server.requests.some((r) => r.method === "POST")).toBe(false); }); + + it("passes --description through in the POST body", async () => { + server = await startFixtureServer([ + { method: "GET", path: LABELS_PATH, body: [] }, + { + method: "POST", + path: LABELS_PATH, + status: 201, + body: { id: 5, name: "bug" }, + }, + ]); + const { exitCode } = await runCliTest( + ["label", "create", "--name", "bug", "--color", "ff0000", "--description", "A bug"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + expect(postedBody(server, LABELS_PATH)).toEqual({ + name: "bug", + color: "#ff0000", + description: "A bug", + }); + }); + + it("propagates a 403 on the create call as a FORBIDDEN error", async () => { + server = await startFixtureServer([ + { method: "GET", path: LABELS_PATH, body: [] }, + { + method: "POST", + path: LABELS_PATH, + status: 403, + body: { message: "forbidden" }, + }, + ]); + const { stdout, exitCode } = await runCliTest( + ["label", "create", "--name", "bug", "--color", "ff0000"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(1); + expect(stdout).toContain("code: FORBIDDEN"); + }); }); describe("label edit", () => { @@ -145,6 +222,30 @@ describe("label edit", () => { server.requests.some((r) => r.method === "PATCH" || r.method === "DELETE"), ).toBe(false); }); + + it("PATCHes only the provided field when just --color is given", async () => { + server = await startFixtureServer([ + { method: "GET", path: LABELS_PATH, body: [{ id: 11, name: "bug" }] }, + { + method: "PATCH", + path: `${LABELS_PATH}/11`, + status: 200, + body: { id: 11, name: "bug", color: "#00ff00" }, + }, + ]); + const { stdout, exitCode } = await runCliTest( + ["label", "edit", "bug", "--color", "00ff00"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(0); + const patch = server.requests.find( + (r) => r.method === "PATCH" && r.path === `${LABELS_PATH}/11`, + ); + expect(patch?.body).toEqual({ color: "#00ff00" }); + expect(stdout).toContain("edit: ok"); + expect(stdout).toContain("label: bug"); + }); }); describe("label delete", () => { @@ -181,4 +282,164 @@ describe("label delete", () => { server.requests.some((r) => r.method === "PATCH" || r.method === "DELETE"), ).toBe(false); }); + + it("propagates a 403 on the delete call as a FORBIDDEN error", async () => { + server = await startFixtureServer([ + { method: "GET", path: LABELS_PATH, body: [{ id: 11, name: "bug" }] }, + { + method: "DELETE", + path: `${LABELS_PATH}/11`, + status: 403, + body: { message: "forbidden" }, + }, + ]); + const { stdout, exitCode } = await runCliTest(["label", "delete", "bug"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(1); + expect(stdout).toContain("code: FORBIDDEN"); + }); +}); + +describe("label help and dispatch", () => { + it("prints group usage when no subcommand is given", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest(["label"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("usage: gitea-axi label "); + expect(server.requests).toHaveLength(0); + }); + + it("prints group usage for label --help", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest(["label", "--help"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("usage: gitea-axi label "); + expect(server.requests).toHaveLength(0); + }); + + it("prints subcommand usage for label list --help", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest(["label", "list", "--help"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("usage: gitea-axi label list"); + expect(server.requests).toHaveLength(0); + }); + + it("prints subcommand usage for label create --help", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest(["label", "create", "--help"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("usage: gitea-axi label create"); + expect(server.requests).toHaveLength(0); + }); + + it("prints subcommand usage for label edit --help", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest(["label", "edit", "--help"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("usage: gitea-axi label edit"); + expect(server.requests).toHaveLength(0); + }); + + it("prints subcommand usage for label delete --help", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest(["label", "delete", "--help"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("usage: gitea-axi label delete"); + expect(server.requests).toHaveLength(0); + }); + + it("rejects an unknown label subcommand", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest(["label", "bogus"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(stdout).toContain("Unknown label command: bogus"); + expect(server.requests).toHaveLength(0); + }); + + it("rejects an unexpected positional on label list", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest(["label", "list", "extra"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(stdout).toContain("Unexpected argument: extra"); + expect(server.requests).toHaveLength(0); + }); + + it("rejects a non-numeric --limit on label list", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest( + ["label", "list", "--limit", "abc"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(stdout).toContain("Invalid --limit value"); + expect(server.requests).toHaveLength(0); + }); + + it("requires --name on label create", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest(["label", "create"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(stdout).toContain("label create requires --name"); + expect(server.requests).toHaveLength(0); + }); + + it("requires --color on label create", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest( + ["label", "create", "--name", "bug"], + { env: testModeEnv(server.url) }, + ); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(stdout).toContain("label create requires --color"); + expect(server.requests).toHaveLength(0); + }); + + it("requires at least one change flag on label edit", async () => { + server = await startFixtureServer([]); + const { stdout, exitCode } = await runCliTest(["label", "edit", "bug"], { + env: testModeEnv(server.url), + }); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(stdout).toContain("label edit requires at least one change"); + expect(server.requests).toHaveLength(0); + }); });