From a5080d8951e2062efe2519a9e99b2f719e1d1111 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Wed, 21 Oct 2026 10:07:29 +0200 Subject: [PATCH] feat(cli): add `build list`, `build download` and `baseline download` There was no way to bulk export a project's screenshots: a script had to list builds through the API, then call `argos build snapshots --json` on each one to get the image URLs. - `argos build list` lists a project's builds, most recent first, with `--branch`, `--commit`, `--search` and `--limit`. - `argos build download ` writes the screenshots a build captured to a directory (`./argos-build-` by default, `-o` to change it). - `argos baseline download` writes the project's latest baseline: the screenshots of the newest build of the default branch (or `--branch`) that Argos can use as a baseline, for a `--build-name` and `--mode`. The API's `Build` carries no name or type, so the CLI cannot tell a baseline build apart. `baseline download` walks the branch's builds and asks `POST /baseline` for the newest of their commits with an eligible build, which applies Argos's own rules (complete, tests passed, not a subset, approved, not rejected). That endpoint only takes a project token, so a personal access token fails with a hint to use ARGOS_TOKEN. Failure screenshots are left out: Argos never compares them. Files are laid out by snapshot name, so screenshots uploaded from a directory come back under the same paths. A name keeps the image extension it has, since `argos upload` stores JPEGs as PNGs under their `.jpg` name, and gets one only when it has none (Storybook story names). `.` and `..` segments are dropped so every file stays inside the directory, and names that land on the same file get a ` (2)` suffix. The directory must be empty or not exist yet. Downloads run eight at a time, retry server and network errors, and the first file that fails cancels the rest. The build and diff fetch helpers move from `build snapshots` to `lib/build.ts`, unchanged, so the download commands can share them. Co-Authored-By: Claude Opus 5.5 --- packages/cli/e2e/baseline.test.ts | 150 ++++++++++ packages/cli/e2e/build.test.ts | 123 +++++++- .../cli/src/commands/baseline/download.ts | 151 ++++++++++ packages/cli/src/commands/baseline/index.ts | 9 + packages/cli/src/commands/build/download.ts | 61 ++++ packages/cli/src/commands/build/index.ts | 8 +- packages/cli/src/commands/build/list.ts | 67 +++++ packages/cli/src/commands/build/snapshots.ts | 56 +--- packages/cli/src/index.ts | 2 + packages/cli/src/lib/baseline.test.ts | 83 ++++++ packages/cli/src/lib/baseline.ts | 49 ++++ packages/cli/src/lib/build.ts | 57 ++++ packages/cli/src/lib/download.test.ts | 214 ++++++++++++++ packages/cli/src/lib/download.ts | 274 ++++++++++++++++++ packages/cli/src/lib/format.test.ts | 59 ++++ packages/cli/src/lib/format.ts | 41 +++ packages/cli/src/options.ts | 5 + skills/argos-cli/SKILL.md | 31 +- 18 files changed, 1377 insertions(+), 63 deletions(-) create mode 100644 packages/cli/e2e/baseline.test.ts create mode 100644 packages/cli/src/commands/baseline/download.ts create mode 100644 packages/cli/src/commands/baseline/index.ts create mode 100644 packages/cli/src/commands/build/download.ts create mode 100644 packages/cli/src/commands/build/list.ts create mode 100644 packages/cli/src/lib/baseline.test.ts create mode 100644 packages/cli/src/lib/baseline.ts create mode 100644 packages/cli/src/lib/build.ts create mode 100644 packages/cli/src/lib/download.test.ts create mode 100644 packages/cli/src/lib/download.ts diff --git a/packages/cli/e2e/baseline.test.ts b/packages/cli/e2e/baseline.test.ts new file mode 100644 index 00000000..b6828475 --- /dev/null +++ b/packages/cli/e2e/baseline.test.ts @@ -0,0 +1,150 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; + +import { getRequiredEnv, run, type CommandError } from "./utils"; + +const projectToken = getRequiredEnv("ARGOS_TOKEN"); +const userAccessToken = getRequiredEnv("USER_ACCESS_TOKEN"); + +// The `upload` e2e test uploads a build under this name on every run, main +// included, so main always has a baseline for it. +const buildName = `argos-cli-e2e-node-${process.env.NODE_VERSION}-${process.env.OS}`; + +const baseEnv: NodeJS.ProcessEnv = { + ...process.env, + HOME: mkdtempSync(join(tmpdir(), "argos-cli-e2e-")), + ARGOS_API_BASE_URL: process.env.ARGOS_API_BASE_URL, + ARGOS_TOKEN: projectToken, +}; + +function expectRunToFail( + args: string[], + overrideEnv?: NodeJS.ProcessEnv, +): CommandError { + try { + run(args, { ...baseEnv, ...overrideEnv }); + } catch (error) { + return error as CommandError; + } + + throw new Error( + `Expected command to fail: node bin/argos-cli.js ${args.join(" ")}`, + ); +} + +/** A fresh directory path to download into, not created yet. */ +function downloadDirectory(): string { + return join(mkdtempSync(join(tmpdir(), "argos-cli-e2e-baseline-")), "out"); +} + +describe("argos baseline download", () => { + test("downloads the latest baseline of the default branch", () => { + const directory = downloadDirectory(); + const download = JSON.parse( + run( + [ + "baseline", + "download", + "--build-name", + buildName, + "--output", + directory, + "--json", + ], + baseEnv, + ).stdout, + ); + + expect(download.build.head.branch).toBe("main"); + expect(download.files.length).toBeGreaterThan(0); + for (const file of download.files) { + expect(statSync(file.path).size).toBeGreaterThan(0); + } + expect(download.files.map((file: { path: string }) => file.path)).toContain( + join(directory, "screenshots", "penelope.png"), + ); + }); + + test("prints which build the baseline comes from", () => { + const { stdout } = run( + [ + "baseline", + "download", + "--build-name", + buildName, + "--output", + downloadDirectory(), + ], + baseEnv, + ); + + expect(stdout).toMatch(/Downloaded \d+ baseline snapshots of build #\d+/); + expect(stdout).toContain("Branch: main"); + }); + + test("fails when the branch has no baseline", () => { + const error = expectRunToFail([ + "baseline", + "download", + "--branch", + "argos-cli-e2e-no-such-branch", + "--output", + downloadDirectory(), + ]); + + expect(error.status).not.toBe(0); + expect(error.stderr).toContain( + 'No baseline found on branch "argos-cli-e2e-no-such-branch" for build name "default" (ci mode).', + ); + }); + + test("fails when token is missing", () => { + const error = expectRunToFail( + ["baseline", "download", "--output", downloadDirectory()], + { ARGOS_TOKEN: "" }, + ); + + expect(error.status).not.toBe(0); + expect(error.stderr).toContain( + "No Argos token found. Pass the project's token with --token or set ARGOS_TOKEN.", + ); + }); + + test("explains that a personal access token cannot find the baseline", () => { + const error = expectRunToFail( + ["baseline", "download", "--output", downloadDirectory()], + { ARGOS_TOKEN: userAccessToken }, + ); + + expect(error.status).not.toBe(0); + expect(error.stderr).toContain("requires a project token"); + expect(error.stderr).toContain( + "Finding the baseline takes the project's token", + ); + }); + + test("refuses a directory that is not empty", () => { + const directory = downloadDirectory(); + mkdirSync(directory); + writeFileSync(join(directory, "keep.txt"), ""); + + const error = expectRunToFail([ + "baseline", + "download", + "--output", + directory, + ]); + + expect(error.status).not.toBe(0); + expect(error.stderr).toContain(`${directory} is not empty`); + expect(existsSync(join(directory, "keep.txt"))).toBe(true); + }); +}); diff --git a/packages/cli/e2e/build.test.ts b/packages/cli/e2e/build.test.ts index b1db6d0b..29f1a37f 100644 --- a/packages/cli/e2e/build.test.ts +++ b/packages/cli/e2e/build.test.ts @@ -1,4 +1,10 @@ -import { mkdtempSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + statSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { beforeAll, describe, expect, test } from "vitest"; @@ -183,3 +189,118 @@ describe("argos build snapshots", () => { expect(error.stderr).toContain("Allowed choices are 24h, 3d, 7d, 30d, 90d"); }); }); + +/** A fresh directory path to download into, not created yet. */ +function downloadDirectory(): string { + return join(mkdtempSync(join(tmpdir(), "argos-cli-e2e-download-")), "out"); +} + +describe("argos build list", () => { + test("fails when token is missing", () => { + const error = expectRunToFail(["build", "list"], { ARGOS_TOKEN: "" }); + + expect(error.status).not.toBe(0); + expect(error.stderr).toContain("No Argos token found"); + }); + + test("lists the most recent builds first, up to --limit", () => { + const builds = JSON.parse( + run(["build", "list", "--limit", "3", "--json"], baseEnv).stdout, + ); + const numbers = builds.map((build: { number: number }) => build.number); + + expect(numbers.length).toBeGreaterThan(0); + expect(numbers.length).toBeLessThanOrEqual(3); + expect(numbers).toEqual([...numbers].sort((a, b) => b - a)); + }); + + test("filters builds by branch", () => { + const builds = JSON.parse( + run( + ["build", "list", "--branch", "main", "--limit", "5", "--json"], + baseEnv, + ).stdout, + ); + + expect(builds.length).toBeGreaterThan(0); + expect( + builds.every( + (build: { head: { branch: string } }) => build.head.branch === "main", + ), + ).toBe(true); + }); + + test("prints human-readable build data", () => { + const { stdout } = run(["build", "list", "--limit", "1"], baseEnv); + + expect(stdout).toContain("Builds (1)"); + expect(stdout).toContain("URL: "); + }); +}); + +describe("argos build download", () => { + test("downloads the screenshots of a build, laid out by name", () => { + // The `upload` e2e test uploads `__fixtures__` on every run, main included. + const builds = JSON.parse( + run( + [ + "build", + "list", + "--branch", + "main", + "--search", + "argos-cli-e2e-node", + "--limit", + "10", + "--json", + ], + baseEnv, + ).stdout, + ); + const source = builds.find( + (build: { status: string }) => + build.status !== "pending" && build.status !== "progress", + ); + const directory = downloadDirectory(); + const download = JSON.parse( + run( + [ + "build", + "download", + String(source.number), + "--output", + directory, + "--json", + ], + baseEnv, + ).stdout, + ); + + expect(download.build.number).toBe(source.number); + expect(download.files.length).toBeGreaterThan(0); + for (const file of download.files) { + expect(statSync(file.path).size).toBeGreaterThan(0); + } + expect(download.files.map((file: { path: string }) => file.path)).toContain( + join(directory, "screenshots", "penelope.png"), + ); + }); + + test("refuses a directory that is not empty", () => { + const directory = downloadDirectory(); + mkdirSync(directory); + writeFileSync(join(directory, "keep.txt"), ""); + + const error = expectRunToFail([ + "build", + "download", + buildNumber, + "--output", + directory, + ]); + + expect(error.status).not.toBe(0); + expect(error.stderr).toContain(`${directory} is not empty`); + expect(existsSync(join(directory, "keep.txt"))).toBe(true); + }); +}); diff --git a/packages/cli/src/commands/baseline/download.ts b/packages/cli/src/commands/baseline/download.ts new file mode 100644 index 00000000..1d2d61d3 --- /dev/null +++ b/packages/cli/src/commands/baseline/download.ts @@ -0,0 +1,151 @@ +import { resolve } from "node:path"; +import { APIError, type ArgosAPIClient } from "@argos-ci/api-client"; +import { Option, type Command } from "commander"; +import { createApiClient, unwrap } from "../../lib/api"; +import { + BASELINE_PAGE_SIZE, + findLatestBaseline, + MAX_BASELINE_PAGES, +} from "../../lib/baseline"; +import { fetchAllDiffs, type SnapshotDiff } from "../../lib/build"; +import { fail } from "../../lib/cli-error"; +import { + assertEmptyDirectory, + downloadBuildSnapshots, +} from "../../lib/download"; +import { formatSnapshotDownload } from "../../lib/format"; +import { handleCliError, output } from "../../lib/run"; +import { resolveOptionalToken } from "../../lib/target"; +import { + jsonOption, + tokenOption, + type JsonOption, + type TokenOption, +} from "../../options"; + +type DownloadOptions = TokenOption & + JsonOption & { + output?: string | undefined; + branch?: string | undefined; + buildName: string; + mode: "ci" | "monitoring"; + }; + +// The baseline lookup (`POST /baseline`) only accepts a project token, and the +// project it searches is the token's own. +const PROJECT_TOKEN_HINT = + "Finding the baseline takes the project's token: pass it with --token or set ARGOS_TOKEN. The token stored by `argos login` cannot be used."; + +// Failure screenshots are never compared to anything, so they are no one's +// baseline. +const FAILURE_STATUSES = new Set([ + "failure", + "retryFailure", +]); + +async function fetchTokenProject(client: ArgosAPIClient) { + try { + return unwrap(await client.GET("/project")); + } catch (error) { + if ( + error instanceof APIError && + (error.status === 401 || error.status === 403) + ) { + fail(`${error.message}\n${PROJECT_TOKEN_HINT}`); + } + throw error; + } +} + +export function registerBaselineDownload(baseline: Command) { + baseline + .command("download") + .description( + "Download a project's latest baseline: the screenshots of the newest build of its default branch that Argos can use as a baseline", + ) + .option( + "-o, --output ", + "Directory to write the screenshots to, laid out by snapshot name. Must be empty or not exist yet. Defaults to ./argos-baseline", + ) + .option( + "--branch ", + "Branch to take the baseline from. Defaults to the project's default base branch", + ) + .addOption( + new Option( + "--build-name ", + "Name of the build to take the baseline of, as given to `argos upload --build-name`", + ).default("default"), + ) + .addOption( + new Option( + "--mode ", + "Mode of the build to take the baseline of: ci for visual regression testing, monitoring for visual monitoring", + ) + .choices(["ci", "monitoring"]) + .default("ci"), + ) + .addOption(tokenOption) + .addOption(jsonOption) + .action(async (options: DownloadOptions) => { + try { + const directory = resolve(options.output ?? "argos-baseline"); + await assertEmptyDirectory(directory); + + const token = await resolveOptionalToken(options); + if (!token) { + fail( + "No Argos token found. Pass the project's token with --token or set ARGOS_TOKEN.", + ); + } + const client = createApiClient(token); + const project = await fetchTokenProject(client); + const path = { owner: project.account.slug, project: project.name }; + const branch = options.branch ?? project.defaultBaseBranch; + + const build = await findLatestBaseline({ + listBuilds: async (page) => + unwrap( + await client.GET("/projects/{owner}/{project}/builds", { + params: { + path, + query: { + head: branch, + page: String(page), + perPage: String(BASELINE_PAGE_SIZE), + }, + }, + }), + ), + findBaseline: async (commits) => + unwrap( + await client.POST("/baseline", { + body: { commits, name: options.buildName, mode: options.mode }, + }), + ).baseline, + }); + if (!build) { + fail( + `No baseline found on branch "${branch}" for build name "${options.buildName}" (${options.mode} mode). Check --branch, --build-name and --mode; only the latest ${MAX_BASELINE_PAGES * BASELINE_PAGE_SIZE} builds of the branch are searched.`, + ); + } + + const diffs = await fetchAllDiffs({ + client, + ...path, + buildNumber: String(build.number), + }); + const download = await downloadBuildSnapshots({ + build, + diffs: diffs.filter((diff) => !FAILURE_STATUSES.has(diff.status)), + directory, + json: options.json, + }); + output(download, options, (data) => + formatSnapshotDownload(data, "baseline snapshot"), + ); + } catch (error) { + handleCliError(error, "project"); + } + }); +} diff --git a/packages/cli/src/commands/baseline/index.ts b/packages/cli/src/commands/baseline/index.ts new file mode 100644 index 00000000..0de191c1 --- /dev/null +++ b/packages/cli/src/commands/baseline/index.ts @@ -0,0 +1,9 @@ +import type { Command } from "commander"; +import { registerBaselineDownload } from "./download"; + +export function baselineCommand(program: Command) { + const baseline = program + .command("baseline") + .description("Export a project's baseline screenshots"); + registerBaselineDownload(baseline); +} diff --git a/packages/cli/src/commands/build/download.ts b/packages/cli/src/commands/build/download.ts new file mode 100644 index 00000000..59b20dca --- /dev/null +++ b/packages/cli/src/commands/build/download.ts @@ -0,0 +1,61 @@ +import { resolve } from "node:path"; +import type { Command } from "commander"; +import { fetchAllDiffs, fetchBuild, isBuildPending } from "../../lib/build"; +import { fail } from "../../lib/cli-error"; +import { + assertEmptyDirectory, + downloadBuildSnapshots, +} from "../../lib/download"; +import { formatSnapshotDownload } from "../../lib/format"; +import { handleCliError, output, type BaseCommandOptions } from "../../lib/run"; +import { resolveBuildTarget } from "../../lib/target"; +import { buildProjectPathOption, jsonOption, tokenOption } from "../../options"; + +type DownloadOptions = BaseCommandOptions & { + output?: string | undefined; +}; + +export function registerBuildDownload(build: Command) { + build + .command("download") + .description( + "Download the screenshots of a build to a directory, laid out by snapshot name", + ) + .argument("", "Build number or Argos build URL") + .option( + "-o, --output ", + "Directory to write the screenshots to. Must be empty or not exist yet. Defaults to ./argos-build-", + ) + .addOption(tokenOption) + .addOption(buildProjectPathOption) + .addOption(jsonOption) + .action(async (reference: string, options: DownloadOptions) => { + try { + const target = await resolveBuildTarget(reference, options, { + auth: "project", + }); + const directory = resolve( + options.output ?? `argos-build-${target.buildNumber}`, + ); + await assertEmptyDirectory(directory); + + const build = await fetchBuild(target); + if (isBuildPending(build)) { + fail( + `Build #${build.number} is still processing (${build.status}). Try again in a moment.`, + ); + } + + const diffs = await fetchAllDiffs(target); + const download = await downloadBuildSnapshots({ + build, + diffs, + directory, + json: options.json, + }); + output(download, options, (data) => formatSnapshotDownload(data)); + } catch (error) { + handleCliError(error, "project"); + } + }); +} diff --git a/packages/cli/src/commands/build/index.ts b/packages/cli/src/commands/build/index.ts index 86bec7c6..58e256cf 100644 --- a/packages/cli/src/commands/build/index.ts +++ b/packages/cli/src/commands/build/index.ts @@ -1,5 +1,7 @@ import type { Command } from "commander"; +import { registerBuildDownload } from "./download"; import { registerBuildGet } from "./get"; +import { registerBuildList } from "./list"; import { registerBuildSnapshots } from "./snapshots"; import { registerBuildSubscribe, @@ -7,9 +9,13 @@ import { } from "./subscription"; export function buildCommand(program: Command) { - const build = program.command("build").description("Inspect Argos builds"); + const build = program + .command("build") + .description("Inspect and download Argos builds"); + registerBuildList(build); registerBuildGet(build); registerBuildSnapshots(build); + registerBuildDownload(build); registerBuildSubscribe(build); registerBuildUnsubscribe(build); } diff --git a/packages/cli/src/commands/build/list.ts b/packages/cli/src/commands/build/list.ts new file mode 100644 index 00000000..c7d3aee8 --- /dev/null +++ b/packages/cli/src/commands/build/list.ts @@ -0,0 +1,67 @@ +import type { Command } from "commander"; +import { unwrap } from "../../lib/api"; +import { formatBuilds } from "../../lib/format"; +import { fetchPages } from "../../lib/pagination"; +import { handleCliError, output, type BaseCommandOptions } from "../../lib/run"; +import { resolveProjectTarget } from "../../lib/target"; +import { + buildProjectPathOption, + jsonOption, + limitOption, + toLimit, + tokenOption, + type LimitOption, +} from "../../options"; + +type ListOptions = BaseCommandOptions & + LimitOption & { + branch?: string | undefined; + commit?: string | undefined; + search?: string | undefined; + }; + +export function registerBuildList(build: Command) { + build + .command("list") + .description("List a project's builds, most recent first") + .option("--branch ", "Only list the builds of this branch") + .option( + "--commit ", + "Only list the builds of this commit, given as a full 40-character SHA. Use --search to match a prefix", + ) + .option( + "--search ", + "Match builds on their name, branch (substring) or commit (prefix)", + ) + .addOption(limitOption) + .addOption(tokenOption) + .addOption(buildProjectPathOption) + .addOption(jsonOption) + .action(async (options: ListOptions) => { + try { + const { client, owner, project } = await resolveProjectTarget(options, { + auth: "project", + }); + const builds = await fetchPages( + toLimit(options.limit), + async (pagination) => + unwrap( + await client.GET("/projects/{owner}/{project}/builds", { + params: { + path: { owner, project }, + query: { + ...pagination, + ...(options.branch ? { head: options.branch } : {}), + ...(options.commit ? { headSha: options.commit } : {}), + ...(options.search ? { search: options.search } : {}), + }, + }, + }), + ), + ); + output(builds, options, formatBuilds); + } catch (error) { + handleCliError(error, "project"); + } + }); +} diff --git a/packages/cli/src/commands/build/snapshots.ts b/packages/cli/src/commands/build/snapshots.ts index 5910dbb3..c0afd1ca 100644 --- a/packages/cli/src/commands/build/snapshots.ts +++ b/packages/cli/src/commands/build/snapshots.ts @@ -1,69 +1,17 @@ import type { Command } from "commander"; -import type { ArgosAPISchema } from "@argos-ci/api-client"; -import { unwrap } from "../../lib/api"; +import { fetchAllDiffs, fetchBuild, isBuildPending } from "../../lib/build"; import { formatSnapshots } from "../../lib/format"; import { handleCliError, output, type BaseCommandOptions } from "../../lib/run"; -import { resolveBuildTarget, type BuildTarget } from "../../lib/target"; +import { resolveBuildTarget } from "../../lib/target"; import { jsonOption, metricsPeriodOption, - type MetricsPeriod, type MetricsPeriodOption, toMetricsPeriod, tokenOption, } from "../../options"; import { Option } from "commander"; -type Build = ArgosAPISchema.components["schemas"]["Build"]; -type SnapshotDiff = ArgosAPISchema.components["schemas"]["SnapshotDiff"]; - -const PER_PAGE = 100; - -function isBuildPending(build: Build): boolean { - return build.status === "pending" || build.status === "progress"; -} - -async function fetchBuild(target: BuildTarget): Promise { - const { client, owner, project, buildNumber } = target; - return unwrap( - await client.GET("/projects/{owner}/{project}/builds/{buildNumber}", { - params: { path: { owner, project, buildNumber } }, - }), - ); -} - -/** Fetch every diff of a build, following pagination. */ -async function fetchAllDiffs( - target: BuildTarget, - options: { needsReview: boolean; metricsPeriod: MetricsPeriod }, -): Promise { - const { client, owner, project, buildNumber } = target; - const results: SnapshotDiff[] = []; - for (let page = 1; ; page++) { - const data = unwrap( - await client.GET( - "/projects/{owner}/{project}/builds/{buildNumber}/diffs", - { - params: { - path: { owner, project, buildNumber }, - query: { - page: String(page), - perPage: String(PER_PAGE), - metricsPeriod: options.metricsPeriod, - ...(options.needsReview ? { needsReview: "true" } : {}), - }, - }, - }, - ), - ); - results.push(...data.results); - if (results.length >= data.pageInfo.total || data.results.length === 0) { - break; - } - } - return results; -} - export function registerBuildSnapshots(build: Command) { build .command("snapshots") diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 43ed8296..2fa48543 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -6,6 +6,7 @@ import { uploadCommand } from "./commands/upload"; import { finalizeCommand } from "./commands/finalize"; import { skipCommand } from "./commands/skip"; import { buildCommand } from "./commands/build"; +import { baselineCommand } from "./commands/baseline"; import { reviewCommand } from "./commands/review"; import { commentCommand } from "./commands/comment"; import { loginCommand } from "./commands/login"; @@ -43,6 +44,7 @@ uploadCommand(program); skipCommand(program); finalizeCommand(program); buildCommand(program); +baselineCommand(program); reviewCommand(program); commentCommand(program); loginCommand(program); diff --git a/packages/cli/src/lib/baseline.test.ts b/packages/cli/src/lib/baseline.test.ts new file mode 100644 index 00000000..f3a78f14 --- /dev/null +++ b/packages/cli/src/lib/baseline.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from "vitest"; +import { findLatestBaseline } from "./baseline"; + +type FakeBuild = { number: number; head: { sha: string } }; + +/** + * A fake branch: `commits` lists each build's commit, newest first, served + * `perPage` at a time. `eligible` are the commits that have a baseline build. + */ +function branch( + commits: string[], + { + eligible = [], + perPage = 2, + }: { eligible?: string[]; perPage?: number } = {}, +) { + const builds = commits.map((sha, index) => ({ + number: commits.length - index, + head: { sha }, + })); + const listBuilds = vi.fn(async (page: number) => ({ + results: builds.slice((page - 1) * perPage, page * perPage), + pageInfo: { total: builds.length }, + })); + const findBaseline = vi.fn(async (shas: string[]) => { + const sha = shas.find((sha) => eligible.includes(sha)); + return builds.find((build) => build.head.sha === sha) ?? null; + }); + return { listBuilds, findBaseline }; +} + +describe("findLatestBaseline", () => { + it("returns the newest eligible build of the first page", async () => { + const api = branch(["c4", "c3", "c2", "c1"], { eligible: ["c3", "c1"] }); + const baseline = await findLatestBaseline(api); + expect(baseline?.head.sha).toBe("c3"); + expect(api.listBuilds).toHaveBeenCalledTimes(1); + expect(api.findBaseline).toHaveBeenCalledExactlyOnceWith(["c4", "c3"]); + }); + + it("moves on to older pages until a commit has a baseline", async () => { + const api = branch(["c4", "c3", "c2", "c1"], { eligible: ["c1"] }); + const baseline = await findLatestBaseline(api); + expect(baseline?.head.sha).toBe("c1"); + expect(api.findBaseline).toHaveBeenNthCalledWith(2, ["c2", "c1"]); + }); + + it("sends each commit once, however many builds it has", async () => { + const api = branch(["c3", "c3", "c3", "c2"], { eligible: ["c2"] }); + await findLatestBaseline(api); + expect(api.findBaseline).toHaveBeenNthCalledWith(1, ["c3"]); + expect(api.findBaseline).toHaveBeenNthCalledWith(2, ["c2"]); + }); + + it("skips the lookup when a page brings no new commit", async () => { + const api = branch(["c2", "c2", "c2", "c2", "c1"], { eligible: ["c1"] }); + await findLatestBaseline(api); + expect(api.listBuilds).toHaveBeenCalledTimes(3); + expect(api.findBaseline).toHaveBeenCalledTimes(2); + }); + + it("returns null once every build has been looked at", async () => { + const api = branch(["c3", "c2", "c1"]); + await expect(findLatestBaseline(api)).resolves.toBeNull(); + expect(api.listBuilds).toHaveBeenCalledTimes(2); + }); + + it("returns null for a branch without builds", async () => { + const api = branch([]); + await expect(findLatestBaseline(api)).resolves.toBeNull(); + expect(api.findBaseline).not.toHaveBeenCalled(); + }); + + it("gives up after the page limit", async () => { + const api = branch(["c6", "c5", "c4", "c3", "c2", "c1"], { + eligible: ["c1"], + }); + await expect( + findLatestBaseline(api, { maxPages: 2 }), + ).resolves.toBeNull(); + expect(api.listBuilds).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/cli/src/lib/baseline.ts b/packages/cli/src/lib/baseline.ts new file mode 100644 index 00000000..7a8cb4cd --- /dev/null +++ b/packages/cli/src/lib/baseline.ts @@ -0,0 +1,49 @@ +/** Pages of builds looked through for a baseline before giving up. */ +export const MAX_BASELINE_PAGES = 10; + +/** Builds per page: the most the API serves. */ +export const BASELINE_PAGE_SIZE = 100; + +type BuildWithCommit = { head: { sha: string } }; + +/** + * Find the most recent baseline among a branch's builds. + * + * Whether a build can be a baseline (complete, tests passed, not a subset, + * approved and not rejected) is not part of the build the API returns, so this + * leaves the decision to Argos: it walks the branch's builds newest first, a + * page at a time, and asks `findBaseline` for the first of their commits that + * has an eligible build. Gives up after `maxPages` pages. + */ +export async function findLatestBaseline( + api: { + listBuilds: ( + page: number, + ) => Promise<{ results: T[]; pageInfo: { total: number } }>; + findBaseline: (commits: string[]) => Promise; + }, + { maxPages = MAX_BASELINE_PAGES }: { maxPages?: number } = {}, +): Promise { + // A commit already sent covered every build on it, whichever page they + // came from. + const asked = new Set(); + let listed = 0; + for (let page = 1; page <= maxPages; page++) { + const { results, pageInfo } = await api.listBuilds(page); + listed += results.length; + const commits = [...new Set(results.map((build) => build.head.sha))].filter( + (sha) => !asked.has(sha), + ); + if (commits.length > 0) { + commits.forEach((sha) => asked.add(sha)); + const baseline = await api.findBaseline(commits); + if (baseline) { + return baseline; + } + } + if (results.length === 0 || listed >= pageInfo.total) { + break; + } + } + return null; +} diff --git a/packages/cli/src/lib/build.ts b/packages/cli/src/lib/build.ts new file mode 100644 index 00000000..bbb08d60 --- /dev/null +++ b/packages/cli/src/lib/build.ts @@ -0,0 +1,57 @@ +import type { ArgosAPISchema } from "@argos-ci/api-client"; +import type { MetricsPeriod } from "../options"; +import { unwrap } from "./api"; +import type { BuildTarget } from "./target"; + +export type Build = ArgosAPISchema.components["schemas"]["Build"]; +export type SnapshotDiff = ArgosAPISchema.components["schemas"]["SnapshotDiff"]; + +const PER_PAGE = 100; + +/** Whether Argos is still processing the build, so its diffs are not final. */ +export function isBuildPending(build: Build): boolean { + return build.status === "pending" || build.status === "progress"; +} + +export async function fetchBuild(target: BuildTarget): Promise { + const { client, owner, project, buildNumber } = target; + return unwrap( + await client.GET("/projects/{owner}/{project}/builds/{buildNumber}", { + params: { path: { owner, project, buildNumber } }, + }), + ); +} + +/** Fetch every diff of a build, following pagination. */ +export async function fetchAllDiffs( + target: BuildTarget, + options: { needsReview?: boolean; metricsPeriod?: MetricsPeriod } = {}, +): Promise { + const { client, owner, project, buildNumber } = target; + const results: SnapshotDiff[] = []; + for (let page = 1; ; page++) { + const data = unwrap( + await client.GET( + "/projects/{owner}/{project}/builds/{buildNumber}/diffs", + { + params: { + path: { owner, project, buildNumber }, + query: { + page: String(page), + perPage: String(PER_PAGE), + ...(options.metricsPeriod + ? { metricsPeriod: options.metricsPeriod } + : {}), + ...(options.needsReview ? { needsReview: "true" } : {}), + }, + }, + }, + ), + ); + results.push(...data.results); + if (results.length >= data.pageInfo.total || data.results.length === 0) { + break; + } + } + return results; +} diff --git a/packages/cli/src/lib/download.test.ts b/packages/cli/src/lib/download.test.ts new file mode 100644 index 00000000..d91079f7 --- /dev/null +++ b/packages/cli/src/lib/download.test.ts @@ -0,0 +1,214 @@ +import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CliError } from "./cli-error"; +import { + assertEmptyDirectory, + downloadSnapshots, + getSnapshotPath, + getSnapshotPaths, +} from "./download"; + +// Retries wait between attempts; the tests have no reason to. +vi.mock("node:timers/promises", () => ({ setTimeout: async () => {} })); + +const png = (name: string) => ({ name, contentType: "image/png" }); + +describe("getSnapshotPath", () => { + it("mirrors the folders of the snapshot name", () => { + expect(getSnapshotPath(png("chromium/home.png"))).toBe("chromium/home.png"); + expect(getSnapshotPath(png("chromium\\home.png"))).toBe( + "chromium/home.png", + ); + }); + + it("keeps every file inside the download directory", () => { + expect(getSnapshotPath(png("../../.ssh/key.png"))).toBe(".ssh/key.png"); + expect(getSnapshotPath(png("/etc/home.png"))).toBe("etc/home.png"); + expect(getSnapshotPath(png("a/./b/../c.png"))).toBe("a/b/c.png"); + expect(getSnapshotPath(png(".."))).toBe("snapshot.png"); + }); + + it("adds the extension of the file type when the name lacks one", () => { + expect(getSnapshotPath(png("home"))).toBe("home.png"); + expect(getSnapshotPath(png("Button.Primary"))).toBe("Button.Primary.png"); + expect(getSnapshotPath({ name: "photo", contentType: "image/jpeg" })).toBe( + "photo.jpg", + ); + }); + + it("keeps an image extension the name already has", () => { + expect( + getSnapshotPath({ name: "photo.JPEG", contentType: "image/jpeg" }), + ).toBe("photo.JPEG"); + // `argos upload` stores JPEGs as PNGs, under their original name. + expect(getSnapshotPath(png("nested/alicia.jpg"))).toBe("nested/alicia.jpg"); + }); + + it("leaves the name of an unknown file type alone", () => { + expect( + getSnapshotPath({ name: "tree.aria.yml", contentType: "text/yaml" }), + ).toBe("tree.aria.yml"); + }); + + it("replaces the characters Windows refuses, on Windows only", () => { + const file = png("Button: primary?.png"); + expect(getSnapshotPath(file, "win32")).toBe("Button_ primary_.png"); + expect(getSnapshotPath(file, "linux")).toBe("Button: primary?.png"); + }); +}); + +describe("getSnapshotPaths", () => { + it("gives names that land on the same file distinct paths", () => { + expect( + getSnapshotPaths([png("home"), png("home.png"), png("Home.png")]), + ).toEqual(["home.png", "home (2).png", "Home (3).png"]); + }); +}); + +describe("assertEmptyDirectory", () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "argos-download-")); + }); + + it("accepts a directory that does not exist yet", async () => { + await expect(assertEmptyDirectory(join(root, "out"))).resolves.toBe( + undefined, + ); + }); + + it("accepts an empty directory", async () => { + await expect(assertEmptyDirectory(root)).resolves.toBe(undefined); + }); + + it("refuses a directory that holds files", async () => { + await writeFile(join(root, "home.png"), ""); + await expect(assertEmptyDirectory(root)).rejects.toThrow( + new CliError( + `${root} is not empty. Pick a new directory with --output, or empty it first.`, + ), + ); + }); + + it("refuses a file", async () => { + const file = join(root, "home.png"); + await writeFile(file, ""); + await expect(assertEmptyDirectory(file)).rejects.toThrow( + `${file} is not a directory.`, + ); + }); +}); + +describe("downloadSnapshots", () => { + let directory: string; + + /** Serve each URL's last path segment as its content, or `status`. */ + function mockFetch(statuses: (number | Error)[] = []) { + const fetchMock = vi.fn(async (input) => { + const status = statuses.shift() ?? 200; + if (status instanceof Error) { + throw status; + } + return status === 200 + ? new Response(`bytes of ${String(input).split("/").pop()}`) + : new Response(null, { status }); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; + } + + const file = (name: string) => ({ + name, + url: `https://files.argos-ci.com/${name.replaceAll("/", "-")}`, + contentType: "image/png", + }); + + beforeEach(async () => { + directory = join(await mkdtemp(join(tmpdir(), "argos-download-")), "out"); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("writes each snapshot at its path, creating its folders", async () => { + mockFetch(); + const onProgress = vi.fn(); + const files = await downloadSnapshots( + [file("chromium/home.png"), file("about")], + directory, + { onProgress }, + ); + expect(files.map((entry) => entry.path)).toEqual([ + join(directory, "chromium/home.png"), + join(directory, "about.png"), + ]); + await expect( + readFile(join(directory, "chromium/home.png"), "utf8"), + ).resolves.toBe("bytes of chromium-home.png"); + await expect(readFile(join(directory, "about.png"), "utf8")).resolves.toBe( + "bytes of about", + ); + expect(onProgress).toHaveBeenLastCalledWith(2, 2); + }); + + it("keeps the fields of each snapshot in the result", async () => { + mockFetch(); + const [entry] = await downloadSnapshots( + [{ ...file("home.png"), status: "added" as const }], + directory, + ); + expect(entry).toMatchObject({ name: "home.png", status: "added" }); + }); + + it("tries again after a server error or a network failure", async () => { + const fetchMock = mockFetch([503, new TypeError("fetch failed")]); + await downloadSnapshots([file("home.png")], directory); + expect(fetchMock).toHaveBeenCalledTimes(3); + await expect(readFile(join(directory, "home.png"), "utf8")).resolves.toBe( + "bytes of home.png", + ); + }); + + it("gives up after three attempts", async () => { + const fetchMock = mockFetch([500, 500, 500]); + await expect( + downloadSnapshots([file("home.png")], directory), + ).rejects.toThrow( + 'Could not download "home.png" (HTTP 500): https://files.argos-ci.com/home.png', + ); + expect(fetchMock).toHaveBeenCalledTimes(3); + }); + + it("does not retry a missing file", async () => { + const fetchMock = mockFetch([404]); + await expect( + downloadSnapshots([file("home.png")], directory), + ).rejects.toBeInstanceOf(CliError); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("stops taking new files once one fails", async () => { + const fetchMock = mockFetch([404]); + const files = Array.from({ length: 20 }, (_, index) => + file(`${index}.png`), + ); + await expect(downloadSnapshots(files, directory)).rejects.toThrow(); + // The first batch was already under way; nothing after it starts, and + // nothing is left running once the download has failed. + const calls = fetchMock.mock.calls.length; + expect(calls).toBeLessThanOrEqual(8); + await new Promise((resolve) => setImmediate(resolve)); + expect(fetchMock).toHaveBeenCalledTimes(calls); + }); + + it("downloads nothing for an empty list", async () => { + const fetchMock = mockFetch(); + await mkdir(directory); + await expect(downloadSnapshots([], directory)).resolves.toEqual([]); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/lib/download.ts b/packages/cli/src/lib/download.ts new file mode 100644 index 00000000..38d15cfe --- /dev/null +++ b/packages/cli/src/lib/download.ts @@ -0,0 +1,274 @@ +import { createWriteStream } from "node:fs"; +import { mkdir, readdir } from "node:fs/promises"; +import { dirname, extname, join } from "node:path"; +import { Readable } from "node:stream"; +import { pipeline } from "node:stream/promises"; +import type { ReadableStream as NodeReadableStream } from "node:stream/web"; +import { setTimeout as sleep } from "node:timers/promises"; +import ora from "ora"; + +import type { Build, SnapshotDiff } from "./build"; +import { fail } from "./cli-error"; + +/** A snapshot file to download: its name in Argos and where it is served. */ +export type SnapshotFile = { + name: string; + url: string; + contentType: string; +}; + +/** A snapshot of a build, as listed in a download's result. */ +export type DownloadedSnapshot = SnapshotFile & { + /** Status of the diff the snapshot comes from. */ + status: SnapshotDiff["status"]; + /** Absolute path the file was written to. */ + path: string; +}; + +/** What a download command prints: the build, and the files written. */ +export type SnapshotDownload = { + build: Build; + directory: string; + files: DownloadedSnapshot[]; +}; + +/** Files downloaded at the same time. */ +const CONCURRENCY = 8; + +/** Attempts per file before giving up, for failures that may not last. */ +const MAX_ATTEMPTS = 3; +const RETRY_DELAY_MS = 1_000; + +/** Extension given to a file of each content type when its name has none. */ +const EXTENSION_BY_CONTENT_TYPE: Record = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/webp": ".webp", +}; + +const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp"]); + +// Characters Windows refuses in a file name. +// eslint-disable-next-line no-control-regex +const WINDOWS_RESERVED_CHARACTERS = /[<>:"|?*\u0000-\u001f]/g; + +/** + * Get the path, relative to the download directory, a snapshot is written to. + * + * A snapshot is named after the path it was uploaded from + * (`chromium/home.png`), so the directory mirrors the original layout. Names + * are whatever the uploader sent, so the `.` and `..` segments and leading + * slashes are dropped: every file stays inside the directory. + */ +export function getSnapshotPath( + file: Pick, + platform: NodeJS.Platform = process.platform, +): string { + const segments = file.name + .split(/[\\/]/) + .filter((segment) => segment !== "" && segment !== "." && segment !== "..") + .map((segment) => + platform === "win32" + ? segment.replace(WINDOWS_RESERVED_CHARACTERS, "_") + : segment, + ); + const basename = segments.pop() ?? "snapshot"; + // A name that ends with an image extension is kept as is, even when it is + // not the file's: `argos upload` stores a JPEG as a PNG under its `.jpg` + // name, and keeping that name lets the directory be uploaded again as the + // same snapshots. Other names (`Button.Primary`, or the story names the + // Storybook SDK sends) get the extension of the file's type. + const extension = EXTENSION_BY_CONTENT_TYPE[file.contentType]; + const suffix = + extension && !IMAGE_EXTENSIONS.has(extname(basename).toLowerCase()) + ? extension + : ""; + return [...segments, `${basename}${suffix}`].join("/"); +} + +/** + * Get a distinct path for each snapshot. Two names can map to the same file: + * `home` and `home.png` once the extension is added, or `Home.png` and + * `home.png`, which macOS and Windows store as one. The later ones get a + * ` (2)`, ` (3)`… before their extension instead of overwriting the first, on + * every platform, so a directory copied elsewhere keeps all its files. + */ +export function getSnapshotPaths( + files: Pick[], + platform: NodeJS.Platform = process.platform, +): string[] { + const used = new Set(); + return files.map((file) => { + const path = getSnapshotPath(file, platform); + const extension = extname(path); + const stem = path.slice(0, path.length - extension.length); + let candidate = path; + for (let index = 2; used.has(candidate.toLowerCase()); index++) { + candidate = `${stem} (${index})${extension}`; + } + used.add(candidate.toLowerCase()); + return candidate; + }); +} + +function isErrnoException(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + +/** + * Abort unless `directory` is missing or empty. Downloading into a directory + * that already holds files would overwrite some of them and leave others + * around, so the result would no longer match the build. + */ +export async function assertEmptyDirectory(directory: string): Promise { + let entries: string[]; + try { + entries = await readdir(directory); + } catch (error) { + if (isErrnoException(error) && error.code === "ENOENT") { + return; + } + if (isErrnoException(error) && error.code === "ENOTDIR") { + fail(`${directory} is not a directory.`); + } + throw error; + } + if (entries.length > 0) { + fail( + `${directory} is not empty. Pick a new directory with --output, or empty it first.`, + ); + } +} + +/** A failed response, and whether trying again may succeed. */ +class HTTPStatusError extends Error { + retryable: boolean; + + constructor(status: number) { + super(`HTTP ${status}`); + this.retryable = status === 429 || status >= 500; + } +} + +async function downloadFile( + file: SnapshotFile, + path: string, + signal: AbortSignal, +): Promise { + for (let attempt = 1; ; attempt++) { + signal.throwIfAborted(); + try { + const response = await fetch(file.url, { signal }); + if (!response.ok || !response.body) { + await response.body?.cancel(); + throw new HTTPStatusError(response.status); + } + await pipeline( + Readable.fromWeb(response.body as NodeReadableStream), + createWriteStream(path), + { signal }, + ); + return; + } catch (error) { + if (signal.aborted) { + throw error; + } + // Anything but an HTTP status is a network failure, worth another try. + const retryable = !(error instanceof HTTPStatusError) || error.retryable; + if (!retryable || attempt >= MAX_ATTEMPTS) { + const reason = error instanceof Error ? error.message : String(error); + fail(`Could not download "${file.name}" (${reason}): ${file.url}`); + } + await sleep(RETRY_DELAY_MS * attempt, undefined, { signal }); + } + } +} + +/** + * Download snapshot files into `directory`, a few at a time, each to the path + * {@link getSnapshotPaths} gives it. The first file that cannot be downloaded + * cancels the others, and its error is thrown once they have all stopped. + */ +export async function downloadSnapshots( + files: T[], + directory: string, + options: { onProgress?: (done: number, total: number) => void } = {}, +): Promise<(T & { path: string })[]> { + const paths = getSnapshotPaths(files); + const entries = files.map((file, index) => ({ + ...file, + path: join(directory, paths[index]!), + })); + + const controller = new AbortController(); + // Typed by assertion: it is set inside `work`, which narrowing cannot see. + let failure = null as { error: unknown } | null; + let next = 0; + let done = 0; + const work = async () => { + try { + while (!controller.signal.aborted && next < entries.length) { + const entry = entries[next++]!; + await mkdir(dirname(entry.path), { recursive: true }); + await downloadFile(entry, entry.path, controller.signal); + options.onProgress?.(++done, entries.length); + } + } catch (error) { + // Only the first failure counts: the others are the downloads it + // cancelled. + if (!failure) { + failure = { error }; + controller.abort(); + } + } + }; + await Promise.all( + Array.from({ length: Math.min(CONCURRENCY, entries.length) }, work), + ); + if (failure) { + throw failure.error; + } + return entries; +} + +/** + * Download the snapshot each diff captured (its head; a removed diff has none) + * into `directory`, showing progress unless the output is JSON. + */ +export async function downloadBuildSnapshots(args: { + build: Build; + diffs: SnapshotDiff[]; + directory: string; + json: boolean | undefined; +}): Promise { + const snapshots = args.diffs.flatMap((diff) => + diff.head + ? [ + { + name: diff.head.name, + url: diff.head.url, + contentType: diff.head.contentType, + status: diff.status, + }, + ] + : [], + ); + // The spinner would interleave with JSON on stdout and break any parser + // reading it. + const spinner = + args.json || snapshots.length === 0 + ? null + : ora(`Downloading ${snapshots.length} snapshots`).start(); + try { + const files = await downloadSnapshots(snapshots, args.directory, { + onProgress: (done, total) => { + if (spinner) { + spinner.text = `Downloading snapshots (${done}/${total})`; + } + }, + }); + return { build: args.build, directory: args.directory, files }; + } finally { + spinner?.stop(); + } +} diff --git a/packages/cli/src/lib/format.test.ts b/packages/cli/src/lib/format.test.ts index c27cc09f..16345c91 100644 --- a/packages/cli/src/lib/format.test.ts +++ b/packages/cli/src/lib/format.test.ts @@ -2,6 +2,7 @@ import type { ArgosAPISchema } from "@argos-ci/api-client"; import { describe, expect, it } from "vitest"; import { formatBuild, + formatBuilds, formatChange, formatComment, formatComments, @@ -13,6 +14,7 @@ import { formatProject, formatReview, formatReviews, + formatSnapshotDownload, formatSnapshotSummary, formatSnapshots, formatStats, @@ -166,6 +168,63 @@ describe("formatBuild", () => { }); }); +describe("formatBuilds", () => { + it("reports an empty list", () => { + expect(formatBuilds([])).toBe("No builds found."); + }); + + it("lists each build with its status, branch, commit and URL", () => { + const output = formatBuilds([build, { ...build, number: 41 }]); + expect(output).toContain("Builds (2)"); + expect(output).toContain("#42 [changes-detected]"); + expect(output).toContain("#41 [changes-detected]"); + expect(output).toContain(" Branch: feature"); + expect(output).toContain(" Commit: abc123"); + expect(output).toContain(" Snapshots: total 10, changed 2"); + expect(output).toContain(" URL: https://app.argos-ci.com/o/p/builds/42"); + }); +}); + +describe("formatSnapshotDownload", () => { + const file = { + name: "home.png", + url: "https://files.argos-ci.com/home.png", + contentType: "image/png", + status: "unchanged" as const, + path: "/tmp/out/home.png", + }; + + it("says where the files went and which build they come from", () => { + const output = formatSnapshotDownload({ + build, + directory: "/tmp/out", + files: [file, { ...file, name: "about.png" }], + }); + expect(output).toContain("Downloaded 2 snapshots of build #42 to /tmp/out"); + expect(output).toContain("Branch: feature"); + expect(output).toContain("Commit: abc123"); + expect(output).toContain("URL: https://app.argos-ci.com/o/p/builds/42"); + }); + + it("names what was downloaded, singular or plural", () => { + expect( + formatSnapshotDownload( + { build, directory: "/tmp/out", files: [file] }, + "baseline snapshot", + ), + ).toContain("Downloaded 1 baseline snapshot of build #42"); + }); + + it("says when there was nothing to download", () => { + expect( + formatSnapshotDownload( + { build, directory: "/tmp/out", files: [] }, + "baseline snapshot", + ), + ).toBe("Build #42 has no baseline snapshots to download."); + }); +}); + describe("formatSnapshots", () => { it("reports an empty list", () => { expect(formatSnapshots([], build)).toBe("No snapshots found."); diff --git a/packages/cli/src/lib/format.ts b/packages/cli/src/lib/format.ts index bbddb4a9..9caef390 100644 --- a/packages/cli/src/lib/format.ts +++ b/packages/cli/src/lib/format.ts @@ -1,6 +1,7 @@ import type { ArgosAPISchema } from "@argos-ci/api-client"; import type { DocsSearchResult } from "./docs"; +import type { SnapshotDownload } from "./download"; import { sanitizeTerminalText } from "./terminal"; type Build = ArgosAPISchema.components["schemas"]["Build"]; @@ -202,6 +203,46 @@ export function formatSnapshots(diffs: SnapshotDiff[], build: Build): string { .join("\n"); } +export function formatBuilds(builds: Build[]): string { + if (builds.length === 0) { + return "No builds found."; + } + return [ + `Builds (${builds.length})`, + "", + ...builds.flatMap((build) => [ + `#${build.number} [${build.status}]`, + ` Branch: ${formatValue(build.head.branch)}`, + ` Commit: ${formatValue(build.head.sha)}`, + ` Snapshots: ${formatStats(build.stats)}`, + ` URL: ${build.url}`, + "", + ]), + ] + .slice(0, -1) + .join("\n"); +} + +/** + * Format the result of a download. `kind` names what was downloaded, e.g. + * `baseline snapshot`, and is pluralized as needed. + */ +export function formatSnapshotDownload( + download: SnapshotDownload, + kind = "snapshot", +): string { + const { build, directory, files } = download; + if (files.length === 0) { + return `Build #${build.number} has no ${kind}s to download.`; + } + return [ + `Downloaded ${files.length} ${kind}${files.length === 1 ? "" : "s"} of build #${build.number} to ${directory}`, + `Branch: ${formatValue(build.head.branch)}`, + `Commit: ${formatValue(build.head.sha)}`, + `URL: ${build.url}`, + ].join("\n"); +} + /** When and where a change was captured, on one line. */ function formatOccurrence(occurrence: TestChangeOccurrence): string { return `${occurrence.date} in build #${occurrence.buildNumber}`; diff --git a/packages/cli/src/options.ts b/packages/cli/src/options.ts index 9169f279..3cf33a70 100644 --- a/packages/cli/src/options.ts +++ b/packages/cli/src/options.ts @@ -55,6 +55,11 @@ export const testProjectPathOption = new Option( "Project the test belongs to, in owner/project format. Also ARGOS_PROJECT. Optional with a project token, which already identifies its project", ); +export const buildProjectPathOption = new Option( + "--project ", + "Project the builds belong to, in owner/project format. Also ARGOS_PROJECT. Optional with a project token, which already identifies its project", +); + export const mediaProjectPathOption = new Option( "--project ", "Project the media belongs to, in owner/project format. Also ARGOS_PROJECT. Optional with a project token, which already identifies its project", diff --git a/skills/argos-cli/SKILL.md b/skills/argos-cli/SKILL.md index 09aefd0a..f83e3bb9 100644 --- a/skills/argos-cli/SKILL.md +++ b/skills/argos-cli/SKILL.md @@ -1,8 +1,9 @@ --- name: argos-cli description: > - Operate Argos visual testing from the terminal with the `argos` CLI — inspect - builds and snapshot diffs, submit reviews, request reviewers, post comments, + Operate Argos visual testing from the terminal with the `argos` CLI — list + and inspect builds and snapshot diffs, download a build's screenshots or a + project's latest baseline, submit reviews, request reviewers, post comments, inspect a test's flakiness and its recurring changes, ignore flaky test changes, configure a project and its contributors, manage a team's members, invites and email domains, manage automation rules, fetch analytics, upload @@ -50,10 +51,13 @@ Two token types — pick by command: | Commands | Token | Resolution order | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | ------------------------------------------- | -| `build get`, `build snapshots`, `test list`, `test get`, `test changes`, `change list`, `project get`, `project deployments`, `project domain get`, `media get`, `media list`, `media versions`, `media update`, `media delete` | Project token | `--token` › `ARGOS_TOKEN` | +| `build list`, `build get`, `build snapshots`, `build download`, `baseline download`, `test list`, `test get`, `test changes`, `change list`, `project get`, `project deployments`, `project domain get`, `media get`, `media list`, `media versions`, `media update`, `media delete` | Project token | `--token` › `ARGOS_TOKEN` | | `review *`, `comment *`, `test comment *`, `media comment *`, `test subscribe` / `unsubscribe`, `build subscribe` / `unsubscribe`, `change ignore` / `unignore`, `account *`, `project update`, `project transfer`, `project contributor *`, `project domain set`, `automation *`, `analytics`, `create-project` | Personal access token (PAT) | `--token` › `ARGOS_TOKEN` › `argos login` | | `upload`, `finalize`, `skip`, `deploy`, `media upload` | CI / project token | `--token` › `ARGOS_TOKEN` (or tokenless CI) | +`baseline download` takes only a project token: the baseline lookup it relies on +rejects a PAT, including the one from `argos login`. + Project tokens read build data but **cannot** review, comment, ignore changes, or administer a project or team — those need a PAT. If no suitable token is available, ask the user. For these actions, if no PAT exists, report the @@ -62,7 +66,8 @@ humans, not CI. ## Commands -- **Inspect** — `build get ` · `build snapshots [--needs-review] [--metrics-period 24h|3d|7d|30d|90d]` · `build subscribe|unsubscribe ` +- **Inspect** — `build list [--branch ] [--commit ] [--search ]` · `build get ` · `build snapshots [--needs-review] [--metrics-period 24h|3d|7d|30d|90d]` · `build subscribe|unsubscribe ` +- **Export** — `build download [-o ]` · `baseline download [-o ] [--branch ] [--build-name ] [--mode ci|monitoring]` - **Review** — `review list ` · `review create --event [--body ]` · `review dismiss ` · `review reviewer list ` · `review reviewer add|remove ` - **Comment** — `comment list ` · `comment create --body [--reply-to ] [--diff ] [--draft]` · `comment get|edit|delete|resolve|unresolve|subscribe|unsubscribe ` · `comment react|unreact ` - **Flakiness** — `test list [--build-name ] [--search ]` · `test get ` · `test changes [--ignored true|false] [--metrics-period …]` · `test subscribe|unsubscribe ` · `change list` · `change ignore|unignore --project owner/project` @@ -77,9 +82,9 @@ humans, not CI. - **Auth** — `login` · `logout` - **Docs** — `docs search [--limit ]` (no token; up to 10 results, 5 by default) -List commands (`test list`, `change list`, `account member list`, `project -contributor list`, `project deployments`, `automation list`, `account invite -list`) follow pagination up to `--limit` (default 100). +List commands (`build list`, `test list`, `change list`, `account member +list`, `project contributor list`, `project deployments`, `automation list`, +`account invite list`) follow pagination up to `--limit` (default 100). `build snapshots --json` enriches each diff with `test.metrics` (flakiness: `stability`, `consistency`, `flakiness`, all 0–1) and, on a change, `change` @@ -125,6 +130,18 @@ ARGOS_TOKEN= argos test changes --json # occurrence argos change ignore --token --project owner/project ``` +Export screenshots to a directory. Files are laid out by snapshot name +(`chromium/home.png`), so screenshots uploaded from a directory come back under +the same paths; a name without an image extension gets one. The directory must +be empty or not exist yet. `baseline download` takes the newest build of the +default branch (or `--branch`) that Argos can use as a baseline, for one +`--build-name` (`default` unless given); failure screenshots are left out: + +```bash +ARGOS_TOKEN= argos build download -o ./screenshots --json # { build, directory, files: [{ name, status, path, url }] } +ARGOS_TOKEN= argos baseline download -o ./baseline --build-name storybook +``` + Look up how an Argos feature works before guessing at a config option or flag. Append `.md` to a result's URL to read the whole page as Markdown: