From b2fbf3ec8a44a074fa871de6ada720bad60e37e0 Mon Sep 17 00:00:00 2001 From: Jeremy Sfez Date: Sun, 29 Mar 2026 19:26:04 +0200 Subject: [PATCH] feat(cli): add builds commands --- packages/api-client/src/schema.ts | 75 +++++ packages/cli/e2e/builds.js | 190 +++++++++++++ packages/cli/e2e/skip.js | 25 +- packages/cli/e2e/upload.js | 30 +- packages/cli/e2e/utils.js | 37 +++ packages/cli/package.json | 3 +- packages/cli/src/commands/builds.ts | 392 ++++++++++++++++++++++++++ packages/cli/src/index.ts | 2 + pnpm-lock.yaml | 5 +- skills/argos-cli/SKILL.md | 76 +++++ skills/argos-cli/references/builds.md | 139 +++++++++ skills/argos-cli/references/skip.md | 27 ++ skills/argos-cli/references/upload.md | 51 ++++ turbo.json | 9 +- 14 files changed, 1031 insertions(+), 30 deletions(-) create mode 100644 packages/cli/e2e/builds.js create mode 100644 packages/cli/e2e/utils.js create mode 100644 packages/cli/src/commands/builds.ts create mode 100644 skills/argos-cli/SKILL.md create mode 100644 skills/argos-cli/references/builds.md create mode 100644 skills/argos-cli/references/skip.md create mode 100644 skills/argos-cli/references/upload.md diff --git a/packages/api-client/src/schema.ts b/packages/api-client/src/schema.ts index 7f3da6b6..99b1af5a 100644 --- a/packages/api-client/src/schema.ts +++ b/packages/api-client/src/schema.ts @@ -100,6 +100,22 @@ export interface paths { patch?: never; trace?: never; }; + "/project/builds/{buildNumber}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getAuthBuildByNumber"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -1097,4 +1113,63 @@ export interface operations { }; }; }; + getAuthBuildByNumber: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The build number */ + buildNumber: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Build */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Build"]; + }; + }; + /** @description Invalid parameters */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + } } diff --git a/packages/cli/e2e/builds.js b/packages/cli/e2e/builds.js new file mode 100644 index 00000000..434bbea2 --- /dev/null +++ b/packages/cli/e2e/builds.js @@ -0,0 +1,190 @@ +/** + * E2E tests for `argos builds` commands. + * Requires ARGOS_TOKEN env var. + * Optional: ARGOS_API_BASE_URL env var. + * + * Usage: + * ARGOS_TOKEN=xxx node e2e/builds.js + * ARGOS_TOKEN=xxx ARGOS_API_BASE_URL=https://api.argos-ci.dev:4001/v2 NODE_OPTIONS=--use-system-ca pnpm -C packages/cli exec node e2e/builds.js + */ + +import { assert, run } from "./utils.js"; + +const token = process.env.ARGOS_TOKEN; +const apiBaseURL = process.env.ARGOS_API_BASE_URL; +const buildNumber = process.env.ARGOS_BUILD_NUMBER || "28022"; + +if (!token) { + console.error( + "Usage: ARGOS_TOKEN=xxx [ARGOS_API_BASE_URL=] node e2e/builds.js", + ); + process.exit(1); +} + +function envWith(overrides = {}) { + return { ...process.env, ...overrides }; +} + +const baseEnv = apiBaseURL + ? envWith({ ARGOS_API_BASE_URL: apiBaseURL }) + : process.env; + +console.log("\n`builds get` failing commands:"); + +try { + run(["builds", "get", "1"], { ...baseEnv, ARGOS_TOKEN: "" }); + assert(false, "Missing token with build number should exit with code 1"); +} catch (err) { + assert(err.status !== 0, "Exit code 1 when no token for build number"); + assert( + err.stderr.includes("No Argos token found"), + "Error message includes 'No Argos token found' for build number", + ); +} + +try { + run( + [ + "builds", + "get", + "https://app.argos-ci.com/argos-ci/argos-javascript/builds/1", + ], + { ...baseEnv, ARGOS_TOKEN: "" }, + ); + assert(false, "Missing token with build URL should exit with code 1"); +} catch (err) { + assert(err.status !== 0, "Exit code 1 when no token for build URL"); + assert( + err.stderr.includes("No Argos token found"), + "Error message includes 'No Argos token found' for build URL", + ); +} + +try { + run(["builds", "get", "999999"], { + ...baseEnv, + ARGOS_TOKEN: token, + }); + assert(false, "Unknown build number should exit with code 1"); +} catch (err) { + assert(err.status !== 0, "Unknown build number: exit code 1"); + assert( + err.stderr.includes("Error:"), + "Unknown build number: human-readable error message", + ); +} + +try { + run(["builds", "get", "not-a-number"], { + ...baseEnv, + ARGOS_TOKEN: token, + }); + assert(false, "Invalid build number should exit with code 1"); +} catch (err) { + assert(err.status !== 0, "Invalid build number: exit code 1"); + assert( + err.stderr.includes("valid build number or Argos build URL"), + "Invalid build reference: human-readable error message", + ); +} + +console.log("\n`builds get` successful commands:"); +const buildByNumberJsonOutput = run(["builds", "get", buildNumber, "--json"], { + ...baseEnv, + ARGOS_TOKEN: token, +}); +const buildByNumberJson = JSON.parse(buildByNumberJsonOutput.stdout); +const buildUrl = buildByNumberJson.url; + +const buildByNumberHumanOutput = run(["builds", "get", buildNumber], { + ...baseEnv, + ARGOS_TOKEN: token, +}); +assert( + buildByNumberHumanOutput.stdout.includes(`Build #${buildNumber}`), + "Prints the build number in human-readable mode", +); +assert( + buildByNumberHumanOutput.stdout.includes("Snapshots:"), + "Prints snapshot stats in human-readable mode", +); +assert( + buildByNumberHumanOutput.stdout.includes(`URL: ${buildUrl}`), + "Prints the build URL in human-readable mode", +); +assert(buildByNumberJson.id !== undefined, "Returns build id"); +assert(buildByNumberJson.url !== undefined, "Returns build url"); +assert( + buildByNumberJson.number === Number(buildNumber), + "Returns the requested build number", +); + +const buildByUrlJsonOutput = run(["builds", "get", "--json", buildUrl], { + ...baseEnv, + ARGOS_TOKEN: token, +}); +const buildByUrlJson = JSON.parse(buildByUrlJsonOutput.stdout); +assert( + buildByUrlJson.number === Number(buildNumber), + "accepts an Argos build URL", +); + +console.log("\n`builds snapshots` failing commands:"); + +try { + run(["builds", "snapshots", "1"], { ...baseEnv, ARGOS_TOKEN: "" }); + assert( + false, + "Missing token for snapshots with build number should exit with code 1", + ); +} catch (err) { + assert( + err.status !== 0, + "Exit code 1 when no token for snapshots with build number", + ); + assert( + err.stderr.includes("No Argos token found"), + "Error message includes 'No Argos token found' for snapshots with build number", + ); +} + +console.log("\n`builds snapshots` successful commands:"); +const buildSnapshots = run(["builds", "snapshots", buildNumber], { + ...baseEnv, + ARGOS_TOKEN: token, +}); +assert( + buildSnapshots.stdout.includes("Snapshots for build #"), + "Prints the build id", +); +assert(buildSnapshots.stdout.includes("Summary:"), "Prints the build Summary"); + +const buildSnapshotsJsonOutput = run( + ["builds", "snapshots", buildNumber, "--json"], + { + ...baseEnv, + ARGOS_TOKEN: token, + }, +); +const buildSnapshotsJson = JSON.parse(buildSnapshotsJsonOutput.stdout); +assert(Array.isArray(buildSnapshotsJson), "Returns an array in JSON mode"); +assert( + Boolean(buildSnapshotsJson[0].base.id), + "Returns structured snapshot data", +); + +const snapshotsNeedsReviewJsonOutput = run( + ["builds", "snapshots", buildNumber, "--needs-review", "--json"], + { + ...baseEnv, + ARGOS_TOKEN: token, + }, +); +const snapshotsNeedingReview = JSON.parse( + snapshotsNeedsReviewJsonOutput.stdout, +); +assert(Array.isArray(snapshotsNeedingReview), "Returns an array in JSON mode"); +assert( + snapshotsNeedingReview.length === 0, + "Returns an empty array when there are no snapshots to review", +); diff --git a/packages/cli/e2e/skip.js b/packages/cli/e2e/skip.js index 57e5b986..2ea01ab6 100644 --- a/packages/cli/e2e/skip.js +++ b/packages/cli/e2e/skip.js @@ -1,14 +1,11 @@ -import { exec } from "node:child_process"; - -exec( - `node bin/argos-cli.js skip --build-name "argos-cli-e2e-skipped-node-${process.env.NODE_VERSION}-${process.env.OS}"`, - (err, stdout, stderr) => { - if (err) { - console.error(err); - process.exit(1); - } - - console.log(stdout); - console.error(stderr); - }, -); +import { assert, run } from "./utils.js"; + +const buildName = `argos-cli-e2e-skipped-node-${process.env.NODE_VERSION}-${process.env.OS}`; + +const skipResult = run(["skip", "--build-name", buildName]); + +console.log(skipResult.stdout); +console.error(skipResult.stderr); + +const buildNumberMatch = skipResult.combined.match(/\/builds\/(\d+)/); +assert(buildNumberMatch, "skip returns a build URL"); diff --git a/packages/cli/e2e/upload.js b/packages/cli/e2e/upload.js index dc604b0a..28360f04 100644 --- a/packages/cli/e2e/upload.js +++ b/packages/cli/e2e/upload.js @@ -1,14 +1,18 @@ -import { exec } from "node:child_process"; - -exec( - `node bin/argos-cli.js upload ../../__fixtures__ --build-name "argos-cli-e2e-node-${process.env.NODE_VERSION}-${process.env.OS}"`, - (err, stdout, stderr) => { - if (err) { - console.error(err); - process.exit(1); - } - - console.log(stdout); - console.error(stderr); - }, +import { assert, run } from "./utils.js"; + +const buildName = `argos-cli-e2e-node-${process.env.NODE_VERSION}-${process.env.OS}`; + +const uploadResult = run([ + "upload", + "../../__fixtures__", + "--build-name", + buildName, +]); + +console.log(uploadResult.stdout); +console.error(uploadResult.stderr); + +const buildUrlMatch = uploadResult.combined.match( + /https?:\/\/\S+\/builds\/\d+/, ); +assert(buildUrlMatch, "upload returns a full build URL"); diff --git a/packages/cli/e2e/utils.js b/packages/cli/e2e/utils.js new file mode 100644 index 00000000..23de87f0 --- /dev/null +++ b/packages/cli/e2e/utils.js @@ -0,0 +1,37 @@ +import { spawnSync } from "node:child_process"; + +const cliPath = "bin/argos-cli.js"; + +export function run(args, env = process.env) { + const result = spawnSync("node", [cliPath, ...args], { + encoding: "utf8", + env, + }); + + const stdout = result.stdout ?? ""; + const stderr = result.stderr ?? ""; + + if (result.status !== 0) { + const error = new Error( + `Command failed: node ${cliPath} ${args.join(" ")}`, + ); + error.status = result.status; + error.stdout = stdout; + error.stderr = stderr; + throw error; + } + + return { + stdout, + stderr, + combined: `${stdout}${stderr}`, + }; +} + +export function assert(condition, message) { + if (!condition) { + console.error(`✘ FAIL: ${message}`); + process.exit(1); + } + console.log(`✔ PASS: ${message}`); +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 6d853712..a40ec671 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,6 +34,7 @@ "access": "public" }, "dependencies": { + "@argos-ci/api-client": "workspace:*", "@argos-ci/core": "workspace:*", "commander": "^14.0.3", "ora": "^9.3.0", @@ -41,7 +42,7 @@ }, "scripts": { "build": "tsdown", - "e2e": "node e2e/upload.js && node e2e/skip.js", + "e2e": "node e2e/upload.js && node e2e/skip.js && node e2e/builds.js", "check-types": "tsc", "check-format": "prettier --check --ignore-unknown --ignore-path=../../.gitignore --ignore-path=../../.prettierignore .", "lint": "eslint ." diff --git a/packages/cli/src/commands/builds.ts b/packages/cli/src/commands/builds.ts new file mode 100644 index 00000000..caec7366 --- /dev/null +++ b/packages/cli/src/commands/builds.ts @@ -0,0 +1,392 @@ +import type { Command } from "commander"; +import { Option } from "commander"; +import { createClient, throwAPIError } from "@argos-ci/api-client"; +import type { ArgosAPISchema } from "@argos-ci/api-client"; +import { tokenOption, type TokenOption } from "../options"; + +type Build = ArgosAPISchema.components["schemas"]["Build"]; +type SnapshotDiff = ArgosAPISchema.components["schemas"]["SnapshotDiff"]; +type SnapshotDiffStatus = SnapshotDiff["status"]; + +type SimplifiedBuildStatus = "failure" | "success" | "pending"; + +function getToken(options: TokenOption): string { + const token = options.token ?? process.env["ARGOS_TOKEN"]; + if (!token) { + console.error( + "Error: No Argos token found. Use --token or set ARGOS_TOKEN.", + ); + process.exit(1); + } + return token; +} + +function getAPIBaseURL(): string | undefined { + return process.env["ARGOS_API_BASE_URL"]; +} + +function createBuildsClient(options: TokenOption) { + return createClient({ + authToken: getToken(options), + baseUrl: getAPIBaseURL(), + }); +} + +function getSimplifiedStatus(build: Build): SimplifiedBuildStatus { + switch (build.status) { + case "accepted": + case "no-changes": + return "success"; + + case "rejected": + case "changes-detected": + case "expired": + case "error": + case "aborted": + return "failure"; + + default: + return "pending"; + } +} + +function isBuildPending(build: Build): boolean { + return getSimplifiedStatus(build) === "pending"; +} + +function parseBuildReferenceOrExit(buildReference: string): number { + const parsedBuildNumber = Number(buildReference); + if ( + Number.isFinite(parsedBuildNumber) && + Number.isInteger(parsedBuildNumber) + ) { + return parsedBuildNumber; + } + + const urlMatch = buildReference.match(/\/builds\/(\d+)(?:\/?$|[?#])/); + if (urlMatch) { + return Number(urlMatch[1]); + } + + console.error( + `Error: Build reference must be a valid build number or Argos build URL, got "${buildReference}".`, + ); + process.exit(1); +} + +function getBuildJSON(build: Build) { + return { + id: build.id, + number: build.number, + status: getSimplifiedStatus(build), + rawStatus: build.status, + conclusion: build.conclusion, + branch: build.head?.branch ?? null, + commit: build.head?.sha ?? null, + baseBranch: build.base?.branch ?? null, + baseCommit: build.base?.sha ?? null, + url: build.url, + stats: build.stats, + testReport: build.metadata?.testReport ?? null, + notification: build.notification, + }; +} + +function getSnapshotJSON(diff: SnapshotDiff, build: Build) { + return { + id: diff.id, + name: diff.name, + status: diff.status, + score: diff.score, + buildUrl: build.url, + reviewUrl: `${build.url}/${diff.id}`, + diffImageUrl: diff.url, + group: diff.group, + parentName: diff.parentName, + base: diff.base + ? { + id: diff.base.id, + name: diff.base.name, + imageUrl: diff.base.url, + contentType: diff.base.contentType, + width: diff.base.width, + height: diff.base.height, + pageUrl: diff.base.metadata?.url ?? null, + previewUrl: diff.base.metadata?.previewUrl ?? null, + viewport: diff.base.metadata?.viewport ?? null, + browser: diff.base.metadata?.browser ?? null, + automationLibrary: diff.base.metadata?.automationLibrary ?? null, + sdk: diff.base.metadata?.sdk ?? null, + test: diff.base.metadata?.test ?? null, + story: diff.base.metadata?.story ?? null, + tags: diff.base.metadata?.tags ?? null, + } + : null, + head: diff.head + ? { + id: diff.head.id, + name: diff.head.name, + imageUrl: diff.head.url, + contentType: diff.head.contentType, + width: diff.head.width, + height: diff.head.height, + pageUrl: diff.head.metadata?.url ?? null, + previewUrl: diff.head.metadata?.previewUrl ?? null, + viewport: diff.head.metadata?.viewport ?? null, + browser: diff.head.metadata?.browser ?? null, + automationLibrary: diff.head.metadata?.automationLibrary ?? null, + sdk: diff.head.metadata?.sdk ?? null, + test: diff.head.metadata?.test ?? null, + story: diff.head.metadata?.story ?? null, + tags: diff.head.metadata?.tags ?? null, + } + : null, + }; +} + +function formatValue(value: string | number | null | undefined): string { + if (value === null || value === undefined || value === "") { + return "-"; + } + return String(value); +} + +function formatStats(stats: Build["stats"] | null | undefined): string { + if (!stats) { + return "-"; + } + + return [ + `total ${stats.total}`, + `changed ${stats.changed}`, + `added ${stats.added}`, + `removed ${stats.removed}`, + `unchanged ${stats.unchanged}`, + ].join(", "); +} + +function formatSnapshotSummary(diffs: SnapshotDiff[]): string { + const counts = new Map(); + for (const diff of diffs) { + counts.set(diff.status, (counts.get(diff.status) ?? 0) + 1); + } + + const orderedStatuses: SnapshotDiffStatus[] = [ + "changed", + "added", + "removed", + "unchanged", + "ignored", + "pending", + "failure", + "retryFailure", + ]; + + return orderedStatuses + .map((status) => { + const count = counts.get(status); + return count ? `${status} ${count}` : null; + }) + .filter((part): part is string => Boolean(part)) + .join(", "); +} + +function printBuild(build: Build) { + const buildJSON = getBuildJSON(build); + const lines = [ + `Build #${buildJSON.number}`, + `Status: ${buildJSON.status} (${buildJSON.rawStatus})`, + `Snapshots: ${formatStats(buildJSON.stats)}`, + `Conclusion: ${formatValue(buildJSON.conclusion)}`, + `Branch: ${formatValue(buildJSON.branch)}`, + `Commit: ${formatValue(buildJSON.commit)}`, + `Base branch: ${formatValue(buildJSON.baseBranch)}`, + `Base commit: ${formatValue(buildJSON.baseCommit)}`, + `URL: ${buildJSON.url}`, + ]; + + console.log(lines.join("\n")); +} + +function printSnapshots(diffs: SnapshotDiff[], build: Build) { + if (diffs.length === 0) { + console.log("No snapshots found."); + return; + } + + const lines = [ + `Snapshots for build #${build.number}`, + `Count: ${diffs.length}`, + `Summary: ${formatSnapshotSummary(diffs)}`, + "", + ...diffs.flatMap((diff) => { + const snapshot = getSnapshotJSON(diff, build); + return [ + `${snapshot.name} [${snapshot.status}]`, + ` Review: ${snapshot.reviewUrl}`, + ` Diff image: ${formatValue(snapshot.diffImageUrl)}`, + ` Base image: ${formatValue(snapshot.base?.imageUrl)}`, + ` Head image: ${formatValue(snapshot.head?.imageUrl)}`, + ` Score: ${formatValue(snapshot.score)}`, + ` Group: ${formatValue(snapshot.group)}`, + "", + ]; + }), + ]; + + console.log(lines.slice(0, -1).join("\n")); +} + +async function fetchAllDiffs( + client: ReturnType, + buildId: string, + options?: { needsReview?: boolean }, +): Promise { + const results: SnapshotDiff[] = []; + let page = 1; + const perPage = 100; + + while (true) { + const query = { + page: String(page), + perPage: String(perPage), + ...(options?.needsReview ? ({ needsReview: true } as const) : {}), + }; + const { data, error } = await client.GET("/builds/{buildId}/diffs", { + params: { + path: { buildId }, + query: query as never, + }, + }); + + if (error || !data) { + if (error) { + throwAPIError(error); + } + throw new Error("Unexpected empty response from API."); + } + + results.push(...data.results); + + if (results.length >= data.pageInfo.total) { + break; + } + page++; + } + + return results; +} + +async function fetchBuildByNumber( + client: ReturnType, + buildNumber: number, + errorLabel: string, +): Promise { + const { data, error, response } = await client.GET( + "/project/builds/{buildNumber}", + { params: { path: { buildNumber } } }, + ); + + if (error) { + if (response.status === 404) { + console.error(`Error: Build number ${errorLabel} not found.`); + process.exit(1); + } + throwAPIError(error); + } + + if (!data) { + console.error("Error: Unexpected empty response from API."); + process.exit(1); + } + + return data; +} + +export function buildsCommand(program: Command) { + const builds = program.command("builds").description("Manage Argos builds"); + const createJsonOption = () => + new Option( + "--json", + "Output machine-readable JSON instead of human-readable text", + ); + + builds + .command("get") + .description("Fetch build metadata") + .argument("", "Build number or Argos build URL") + .addOption(tokenOption) + .addOption(createJsonOption()) + .action( + async ( + buildReference: string, + options: TokenOption & { json?: boolean }, + ) => { + const buildNumber = parseBuildReferenceOrExit(buildReference); + const client = createBuildsClient(options); + const build = await fetchBuildByNumber( + client, + buildNumber, + buildReference, + ); + if (options.json) { + console.log(JSON.stringify(getBuildJSON(build), null, 2)); + return; + } + printBuild(build); + }, + ); + + builds + .command("snapshots") + .description("Fetch snapshot diffs for a build") + .argument("", "Build number or Argos build URL") + .option("--needs-review", "Only include snapshot diffs that require review") + .addOption(tokenOption) + .addOption(createJsonOption()) + .action( + async ( + buildReference: string, + options: TokenOption & { needsReview?: boolean; json?: boolean }, + ) => { + const buildNumber = parseBuildReferenceOrExit(buildReference); + const client = createBuildsClient(options); + const build = await fetchBuildByNumber( + client, + buildNumber, + buildReference, + ); + + if (isBuildPending(build)) { + if (options.json) { + console.error( + `Error: Build #${build.number} is still processing (${build.status}). Try again in a moment.`, + ); + process.exit(1); + } + + console.log( + `Build #${build.number} is still processing (${build.status}). Try again in a moment.`, + ); + return; + } + + const diffs = await fetchAllDiffs(client, build.id, { + needsReview: Boolean(options.needsReview), + }); + + if (options.json) { + console.log( + JSON.stringify( + diffs.map((diff) => getSnapshotJSON(diff, build)), + null, + 2, + ), + ); + return; + } + + printSnapshots(diffs, build); + }, + ); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 64877f5f..c615733e 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,6 +5,7 @@ import { program } from "commander"; import { uploadCommand } from "./commands/upload"; import { finalizeCommand } from "./commands/finalize"; import { skipCommand } from "./commands/skip"; +import { buildsCommand } from "./commands/builds"; const __dirname = fileURLToPath(new URL(".", import.meta.url)); @@ -21,6 +22,7 @@ program uploadCommand(program); skipCommand(program); finalizeCommand(program); +buildsCommand(program); if (!process.argv.slice(2).length) { program.outputHelp(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 779e263b..833062e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: packages/cli: dependencies: + '@argos-ci/api-client': + specifier: workspace:* + version: link:../api-client '@argos-ci/core': specifier: workspace:* version: link:../core @@ -10128,7 +10131,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vitest: 4.1.2(@types/node@20.19.37)(@vitest/browser-playwright@4.1.2)(@vitest/ui@4.1.2)(msw@2.12.14(@types/node@20.19.37)(typescript@6.0.2))(vite@7.3.1(@types/node@20.19.37)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.2(@types/node@25.5.0)(@vitest/browser-playwright@4.1.2)(@vitest/ui@4.1.2)(msw@2.12.14(@types/node@25.5.0)(typescript@6.0.2))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/utils@3.2.4': dependencies: diff --git a/skills/argos-cli/SKILL.md b/skills/argos-cli/SKILL.md new file mode 100644 index 00000000..d9c0b5eb --- /dev/null +++ b/skills/argos-cli/SKILL.md @@ -0,0 +1,76 @@ +--- +name: argos-cli +description: > + Operate the Argos visual regression platform from the terminal — fetch build + metadata, review snapshot diffs, upload screenshots, and manage CI builds via + the `argos` CLI. Use when the user wants to run Argos commands in the shell, + scripts, or CI/CD pipelines, or when reviewing visual regression builds. + Always load this skill before running `argos` commands — it contains the flag + contract and output shapes that prevent silent failures. +license: MIT +metadata: + author: argos-ci + homepage: https://argos-ci.com + source: https://github.com/argos-ci/argos-javascript +argument-hint: Requires `ARGOS_TOKEN` or an explicit `--token` when running authenticated `argos` commands. +--- + +# Argos CLI + +## Agent Protocol + +The CLI writes errors to stderr. Some commands support both human-readable text +and JSON output. + +**Rules for agents:** + +- Supply `--token` or set `ARGOS_TOKEN`. The CLI exits with code 1 if no token is found. +- Exit `0` = success, `1` = error. +- All errors go to stderr: `Error: ` +- Use `--json` whenever stdout will be parsed by a script or agent. + +## Authentication + +Auth resolves: `--token` flag > `ARGOS_TOKEN` env var. + +## Available Commands + +| Command | What it does | +| ------------------------ | -------------------------------- | +| `builds get ` | Fetch build metadata | +| `builds snapshots ` | Fetch snapshot diffs for a build | +| `upload ` | Upload screenshots to Argos | +| `finalize` | Finalize a parallel build | +| `skip` | Mark a build as skipped | + +Read the matching reference file for detailed flags and output shapes. + +## Common Patterns + +**Review a build (fetch metadata first, then diffs that need review):** + +```bash +argos builds get 72652 +argos builds snapshots 72652 --needs-review +argos builds get 72652 --json +argos builds snapshots 72652 --needs-review --json +``` + +**Upload screenshots in CI:** + +```bash +argos upload ./screenshots --token $ARGOS_TOKEN +``` + +**Parallel builds:** + +```bash +argos upload ./screenshots --parallel-nonce $CI_PIPELINE_ID --parallel-index $CI_NODE_INDEX --parallel-total $CI_NODE_TOTAL +argos finalize --parallel-nonce $CI_PIPELINE_ID +``` + +## When to Load References + +- **Fetching build data or reviewing snapshots** → [references/builds.md](references/builds.md) +- **Uploading screenshots or finalizing parallel builds** → [references/upload.md](references/upload.md) +- **Skipping a build** → [references/skip.md](references/skip.md) diff --git a/skills/argos-cli/references/builds.md b/skills/argos-cli/references/builds.md new file mode 100644 index 00000000..d5e6b231 --- /dev/null +++ b/skills/argos-cli/references/builds.md @@ -0,0 +1,139 @@ +# builds + +Detailed flag specifications for `argos builds` commands. + +--- + +## builds get + +Fetch build metadata. Use `--json` for machine-readable output. + +**Argument:** `` — Build number (e.g. `72652`) or Argos build URL (e.g. `https://app.argos-ci.com/team/project/builds/72652`) + +| Flag | Type | Required | Description | +| ----------------- | ------- | ----------------------- | --------------------------------------------------------- | +| `--token ` | string | No (uses `ARGOS_TOKEN`) | Argos project token | +| `--json` | boolean | No | Emit machine-readable JSON instead of human-readable text | + +**Default output:** Human-readable text summary. + +Example: + +```text +Build #72652 +Status: failure (changes-detected) +Snapshots: total 42, changed 3, added 1, removed 0, unchanged 38 +Conclusion: rejected +Branch: main +Commit: abc123 +Base branch: main +Base commit: def456 +URL: https://app.argos-ci.com/... +``` + +Use `--json` whenever another tool needs to parse stdout. + +**`--json` output:** + +```json +{ + "id": "", + "number": 72652, + "status": "success" | "failure" | "pending", + "rawStatus": "accepted" | "no-changes" | "rejected" | "changes-detected" | "expired" | "error" | "aborted" | "pending" | "progress", + "conclusion": "accepted" | "rejected" | null, + "branch": "main", + "commit": "", + "baseBranch": "main", + "baseCommit": "", + "url": "https://app.argos-ci.com/...", + "stats": { "total": 42, "changed": 3, "added": 1, "removed": 0, "unchanged": 38 }, + "testReport": null, + "notification": null +} +``` + +**Status mapping:** + +- `success` → `accepted` or `no-changes` +- `failure` → `rejected`, `changes-detected`, `expired`, `error`, or `aborted` +- `pending` → all other statuses + +--- + +## builds snapshots + +Fetch snapshot diffs for a build. Use `--json` for machine-readable output. + +**Argument:** `` — Build number or Argos build URL + +| Flag | Type | Required | Description | +| ----------------- | ------- | ----------------------- | --------------------------------------------------------- | +| `--needs-review` | boolean | No | Only include snapshot diffs that require review | +| `--token ` | string | No (uses `ARGOS_TOKEN`) | Argos project token | +| `--json` | boolean | No | Emit machine-readable JSON instead of human-readable text | + +Use `--needs-review` to delegate review filtering to the API. + +**Default output:** Human-readable snapshot list. + +Example: + +```text +Snapshots for build #72652 +Count: 3 +Summary: changed 2, added 1 + +homepage / desktop [changed] + Review: https://app.argos-ci.com/.../ + Diff image: https://cdn.argos-ci.com/... + Score: 0.042 + Group: homepage +``` + +Use `--json` whenever another tool needs to parse stdout. + +If `--needs-review` is passed, the API returns only diffs that need review. + +**`--json` output:** Array of snapshot diff objects: + +```json +[ + { + "id": "", + "name": "homepage / desktop", + "status": "changed" | "added" | "removed" | "unchanged" | "pending" | "failure" | "ignored" | "retryFailure", + "score": 0.042, + "buildUrl": "https://app.argos-ci.com/...", + "reviewUrl": "https://app.argos-ci.com/.../", + "diffImageUrl": "https://cdn.argos-ci.com/...", + "group": "homepage", + "parentName": null, + "base": { + "id": "", + "name": "homepage / desktop", + "imageUrl": "https://cdn.argos-ci.com/...", + "contentType": "image/png", + "width": 1280, + "height": 800, + "pageUrl": "https://example.com", + "previewUrl": null, + "viewport": { "width": 1280, "height": 800 }, + "browser": { "name": "chromium", "version": "120.0" }, + "automationLibrary": null, + "sdk": null, + "test": null, + "story": null, + "tags": null + }, + "head": { ... } + } +] +``` + +**Notes:** + +- `diffImageUrl` is the visual diff overlay — the most efficient signal for automated review. +- `score` ranges from `0` (identical) to `1` (completely different). `null` for added/removed snapshots. +- `base` is `null` for `added` snapshots; `head` is `null` for `removed` snapshots. +- Paginates automatically — all results are returned in a single call. diff --git a/skills/argos-cli/references/skip.md b/skills/argos-cli/references/skip.md new file mode 100644 index 00000000..e4c3ce5c --- /dev/null +++ b/skills/argos-cli/references/skip.md @@ -0,0 +1,27 @@ +# skip + +Detailed flag specifications for `argos skip`. + +--- + +## skip + +Mark a build as skipped (for example when no visual tests ran). + +| Flag | Type | Required | Description | +| --------------------- | ------ | ---------------------------- | ------------------- | +| `--token ` | string | No (uses `ARGOS_TOKEN`) | Argos project token | +| `--build-name ` | string | No (uses `ARGOS_BUILD_NAME`) | Build name to skip | + +**Output:** + +The CLI prints a success message with the created build URL: + +```text +Build created: https://app.argos-ci.com/... +``` + +**Notes:** + +- Auth resolves in this order: `--token`, then `ARGOS_TOKEN`. +- Use `--build-name` when the skipped build should be labeled explicitly in CI. diff --git a/skills/argos-cli/references/upload.md b/skills/argos-cli/references/upload.md new file mode 100644 index 00000000..776f90f9 --- /dev/null +++ b/skills/argos-cli/references/upload.md @@ -0,0 +1,51 @@ +# upload + +Detailed flag specifications for `argos upload`. + +--- + +## upload + +Upload screenshots from a directory to Argos. + +**Argument:** `` — Directory containing screenshots + +| Flag | Type | Required | Description | +| --------------------------- | -------- | ----------------------- | -------------------------------------- | +| `--token ` | string | No (uses `ARGOS_TOKEN`) | Argos project token | +| `--build-name ` | string | No | Custom build name | +| `--parallel-nonce ` | string | No | Unique identifier for parallel builds | +| `--parallel-index ` | number | No | Index of this parallel shard (1-based) | +| `--parallel-total ` | number | No | Total number of parallel shards | +| `--ignore ` | string[] | No | Glob patterns to exclude | +| `--branch ` | string | No | Override branch name | +| `--commit ` | string | No | Override commit SHA | +| `--mode ` | string | No | `ci` (default) or `monitoring` | +| `--threshold ` | number | No | Diff score threshold (0–1) | +| `--reference-branch ` | string | No | Branch to compare against | + +**Parallel builds:** + +Use `--parallel-nonce`, `--parallel-index`, and `--parallel-total` to split uploads across multiple CI jobs. Run `argos finalize --parallel-nonce ` after all shards complete. + +```bash +# Each shard +argos upload ./screenshots \ + --parallel-nonce $CI_PIPELINE_ID \ + --parallel-index $CI_NODE_INDEX \ + --parallel-total $CI_NODE_TOTAL + +# After all shards +argos finalize --parallel-nonce $CI_PIPELINE_ID +``` + +--- + +## finalize + +Finalize a parallel build after all shards have uploaded. + +| Flag | Type | Required | Description | +| -------------------------- | ------ | -------------------------------- | ------------------------------------ | +| `--token ` | string | No (uses `ARGOS_TOKEN`) | Argos project token | +| `--parallel-nonce ` | string | No (uses `ARGOS_PARALLEL_NONCE`) | Nonce identifying the parallel build | diff --git a/turbo.json b/turbo.json index f1401f89..ff1e2fb4 100644 --- a/turbo.json +++ b/turbo.json @@ -51,7 +51,14 @@ "cache": false }, "e2e": { - "env": ["ARGOS_TOKEN", "NODE_VERSION", "OS", "GITHUB_*"], + "cache": false, + "env": [ + "ARGOS_TOKEN", + "ARGOS_BUILD_NUMBER", + "NODE_VERSION", + "OS", + "GITHUB_*" + ], "dependsOn": ["build-e2e"], "outputs": [] }