From 228b800f934ae0f81f3857251b0a357fd044c4ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Greg=20Berg=C3=A9?= Date: Sat, 31 Aug 2024 16:54:18 +0200 Subject: [PATCH] feat: add finalize command --- packages/api-client/src/schema.ts | 129 +++++++++--------- packages/cli/src/commands/finalize.ts | 30 ++++ packages/cli/src/commands/upload.ts | 108 +++++++++++++++ packages/cli/src/index.ts | 110 +-------------- packages/cli/src/options.ts | 6 + .../src/{api-client.test.ts => auth.test.ts} | 78 +---------- packages/core/src/{api-client.ts => auth.ts} | 0 packages/core/src/config.ts | 6 +- packages/core/src/finalize.ts | 40 ++++++ packages/core/src/index.ts | 1 + packages/core/src/upload.ts | 4 +- 11 files changed, 263 insertions(+), 249 deletions(-) create mode 100644 packages/cli/src/commands/finalize.ts create mode 100644 packages/cli/src/commands/upload.ts create mode 100644 packages/cli/src/options.ts rename packages/core/src/{api-client.test.ts => auth.test.ts} (58%) rename packages/core/src/{api-client.ts => auth.ts} (100%) create mode 100644 packages/core/src/finalize.ts diff --git a/packages/api-client/src/schema.ts b/packages/api-client/src/schema.ts index 4381396..f51681e 100644 --- a/packages/api-client/src/schema.ts +++ b/packages/api-client/src/schema.ts @@ -20,7 +20,7 @@ export interface paths { patch?: never; trace?: never; }; - "/builds/{buildId}": { + "/builds/finalize": { parameters: { query?: never; header?: never; @@ -28,15 +28,15 @@ export interface paths { cookie?: never; }; get?: never; - put: operations["updateBuild"]; - post?: never; + put?: never; + post: operations["finalizeBuilds"]; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - "/builds/{buildId}/finalize": { + "/builds/{buildId}": { parameters: { query?: never; header?: never; @@ -44,8 +44,8 @@ export interface paths { cookie?: never; }; get?: never; - put?: never; - post: operations["finalizeBuild"]; + put: operations["updateBuild"]; + post?: never; delete?: never; options?: never; head?: never; @@ -223,77 +223,29 @@ export interface operations { }; }; }; - updateBuild: { + finalizeBuilds: { parameters: { query?: never; header?: never; - path: { - /** @description A unique identifier for the build */ - buildId: components["schemas"]["buildId"]; - }; + path?: never; cookie?: never; }; requestBody?: { content: { "application/json": { - screenshots: { - key: string; - name: string; - baseName?: string | null; - metadata?: { - url?: string; - viewport?: { - width: number; - height: number; - }; - /** @enum {string} */ - colorScheme?: "light" | "dark"; - /** @enum {string} */ - mediaType?: "screen" | "print"; - test?: { - id?: string; - title: string; - titlePath: string[]; - retries?: number; - retry?: number; - repeat?: number; - location?: { - file: string; - line: number; - column: number; - }; - } | null; - browser?: { - name: string; - version: string; - }; - automationLibrary: { - name: string; - version: string; - }; - sdk: { - name: string; - version: string; - }; - } | null; - pwTraceKey?: string | null; - threshold?: number | null; - }[]; - parallel?: boolean | null; - parallelTotal?: number | null; - parallelIndex?: number | null; + parallelNonce: string; }; }; }; responses: { - /** @description Result of build update */ + /** @description Result of build finalization */ 200: { headers: { [name: string]: unknown; }; content: { "application/json": { - build: components["schemas"]["Build"]; + builds: components["schemas"]["Build"][]; }; }; }; @@ -353,19 +305,70 @@ export interface operations { }; }; }; - finalizeBuild: { + updateBuild: { parameters: { query?: never; header?: never; path: { /** @description A unique identifier for the build */ - buildId: string; + buildId: components["schemas"]["buildId"]; }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": { + screenshots: { + key: string; + name: string; + baseName?: string | null; + metadata?: { + url?: string; + viewport?: { + width: number; + height: number; + }; + /** @enum {string} */ + colorScheme?: "light" | "dark"; + /** @enum {string} */ + mediaType?: "screen" | "print"; + test?: { + id?: string; + title: string; + titlePath: string[]; + retries?: number; + retry?: number; + repeat?: number; + location?: { + file: string; + line: number; + column: number; + }; + } | null; + browser?: { + name: string; + version: string; + }; + automationLibrary: { + name: string; + version: string; + }; + sdk: { + name: string; + version: string; + }; + } | null; + pwTraceKey?: string | null; + threshold?: number | null; + }[]; + parallel?: boolean | null; + parallelTotal?: number | null; + parallelIndex?: number | null; + }; + }; + }; responses: { - /** @description Result of build finalization */ + /** @description Result of build update */ 200: { headers: { [name: string]: unknown; diff --git a/packages/cli/src/commands/finalize.ts b/packages/cli/src/commands/finalize.ts new file mode 100644 index 0000000..bf82d9d --- /dev/null +++ b/packages/cli/src/commands/finalize.ts @@ -0,0 +1,30 @@ +import ora from "ora"; +import { Command } from "commander"; +import { finalize } from "@argos-ci/core"; +import { parallelNonce } from "../options"; + +export function finalizeCommand(program: Command) { + program + .command("finalize") + .description("Finalize pending parallel builds") + .addOption(parallelNonce) + .action(async (options) => { + const spinner = ora("Finalizing builds").start(); + try { + const result = await finalize({ + parallel: { + nonce: options.parallelNonce, + }, + }); + spinner.succeed( + `Builds finalized: ${result.builds.map((b) => b.url).join(", ")}`, + ); + } catch (error) { + if (error instanceof Error) { + spinner.fail(`Failed to finalize: ${error.message}`); + console.error(error.stack); + } + process.exit(1); + } + }); +} diff --git a/packages/cli/src/commands/upload.ts b/packages/cli/src/commands/upload.ts new file mode 100644 index 0000000..b5b04e2 --- /dev/null +++ b/packages/cli/src/commands/upload.ts @@ -0,0 +1,108 @@ +import { Command, Option } from "commander"; +import { upload } from "@argos-ci/core"; +import ora from "ora"; +import { parallelNonce } from "../options"; + +export function uploadCommand(program: Command) { + program + .command("upload") + .argument("", "Directory to upload") + .description("Upload screenshots to Argos") + .option( + "-f, --files ", + "One or more globs matching image file paths to upload", + "**/*.{png,jpg,jpeg}", + ) + .option( + "-i, --ignore ", + 'One or more globs matching image file paths to ignore (ex: "**/*.png **/diff.jpg")', + ) + .addOption( + new Option("--token ", "Repository token").env("ARGOS_TOKEN"), + ) + .addOption( + new Option( + "--build-name ", + "Name of the build, in case you want to run multiple Argos builds in a single CI job", + ).env("ARGOS_BUILD_NAME"), + ) + .addOption( + new Option( + "--mode ", + "Mode of comparison applied. CI for visual regression testing, monitoring for visual monitoring.", + ) + .default("ci") + .choices(["ci", "monitoring"]) + .env("ARGOS_MODE"), + ) + .addOption( + new Option( + "--parallel", + "Enable parallel mode. Run multiple Argos builds and combine them at the end", + ).env("ARGOS_PARALLEL"), + ) + .addOption( + new Option( + "--parallel-total ", + "The number of parallel nodes being ran", + ).env("ARGOS_PARALLEL_TOTAL"), + ) + .addOption(parallelNonce) + .addOption( + new Option( + "--parallel-index ", + "The index of the parallel node being ran", + ).env("ARGOS_PARALLEL_INDEX"), + ) + .addOption( + new Option( + "--reference-branch ", + "Branch used as baseline for screenshot comparison", + ).env("ARGOS_REFERENCE_BRANCH"), + ) + .addOption( + new Option( + "--reference-commit ", + "Commit used as baseline for screenshot comparison", + ).env("ARGOS_REFERENCE_COMMIT"), + ) + .addOption( + new Option( + "--threshold ", + "Sensitivity threshold between 0 and 1. The higher the threshold, the less sensitive the diff will be. Default to 0.5", + ).env("ARGOS_THRESHOLD"), + ) + .action(async (directory, options) => { + const spinner = ora("Uploading screenshots").start(); + try { + const result = await upload({ + token: options.token, + root: directory, + buildName: options.buildName, + files: options.files, + ignore: options.ignore, + prNumber: options.pullRequest + ? Number(options.pullRequest) + : undefined, + parallel: options.parallel + ? { + nonce: options.parallelNonce, + total: options.parallelTotal, + index: options.parallelIndex, + } + : false, + referenceBranch: options.referenceBranch, + referenceCommit: options.referenceCommit, + mode: options.mode, + threshold: options.threshold, + }); + spinner.succeed(`Build created: ${result.build.url}`); + } catch (error) { + if (error instanceof Error) { + spinner.fail(`Build failed: ${error.message}`); + console.error(error.stack); + } + process.exit(1); + } + }); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 5891438..c9d2b04 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,9 +1,9 @@ import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { resolve } from "node:path"; -import { Option, program } from "commander"; -import { upload } from "@argos-ci/core"; -import ora from "ora"; +import { program } from "commander"; +import { uploadCommand } from "./commands/upload"; +import { finalizeCommand } from "./commands/finalize"; const __dirname = fileURLToPath(new URL(".", import.meta.url)); @@ -17,108 +17,8 @@ program ) .version(pkg.version); -program - .command("upload") - .argument("", "Directory to upload") - .description("Upload screenshots to Argos") - .option( - "-f, --files ", - "One or more globs matching image file paths to upload", - "**/*.{png,jpg,jpeg}", - ) - .option( - "-i, --ignore ", - 'One or more globs matching image file paths to ignore (ex: "**/*.png **/diff.jpg")', - ) - .addOption( - new Option("--token ", "Repository token").env("ARGOS_TOKEN"), - ) - .addOption( - new Option( - "--build-name ", - "Name of the build, in case you want to run multiple Argos builds in a single CI job", - ).env("ARGOS_BUILD_NAME"), - ) - .addOption( - new Option( - "--mode ", - "Mode of comparison applied. CI for visual regression testing, monitoring for visual monitoring.", - ) - .default("ci") - .choices(["ci", "monitoring"]) - .env("ARGOS_MODE"), - ) - .addOption( - new Option( - "--parallel", - "Enable parallel mode. Run multiple Argos builds and combine them at the end", - ).env("ARGOS_PARALLEL"), - ) - .addOption( - new Option( - "--parallel-total ", - "The number of parallel nodes being ran", - ).env("ARGOS_PARALLEL_TOTAL"), - ) - .addOption( - new Option( - "--parallel-nonce ", - "A unique ID for this parallel build", - ).env("ARGOS_PARALLEL_NONCE"), - ) - .addOption( - new Option( - "--parallel-index ", - "The index of the parallel node being ran", - ).env("ARGOS_PARALLEL_INDEX"), - ) - .addOption( - new Option( - "--reference-branch ", - "Branch used as baseline for screenshot comparison", - ).env("ARGOS_REFERENCE_BRANCH"), - ) - .addOption( - new Option( - "--reference-commit ", - "Commit used as baseline for screenshot comparison", - ).env("ARGOS_REFERENCE_COMMIT"), - ) - .addOption( - new Option( - "--threshold ", - "Sensitivity threshold between 0 and 1. The higher the threshold, the less sensitive the diff will be. Default to 0.5", - ).env("ARGOS_THRESHOLD"), - ) - .action(async (directory, options) => { - const spinner = ora("Uploading screenshots").start(); - try { - const result = await upload({ - token: options.token, - root: directory, - buildName: options.buildName, - files: options.files, - ignore: options.ignore, - prNumber: options.pullRequest ? Number(options.pullRequest) : undefined, - parallel: options.parallel - ? { - nonce: options.parallelNonce, - total: options.parallelTotal, - index: options.parallelIndex, - } - : false, - referenceBranch: options.referenceBranch, - referenceCommit: options.referenceCommit, - mode: options.mode, - threshold: options.threshold, - }); - spinner.succeed(`Build created: ${result.build.url}`); - } catch (error: any) { - spinner.fail(`Build failed: ${error.message}`); - console.error(error.stack); - process.exit(1); - } - }); +uploadCommand(program); +finalizeCommand(program); if (!process.argv.slice(2).length) { program.outputHelp(); diff --git a/packages/cli/src/options.ts b/packages/cli/src/options.ts new file mode 100644 index 0000000..4b16ef8 --- /dev/null +++ b/packages/cli/src/options.ts @@ -0,0 +1,6 @@ +import { Option } from "commander"; + +export const parallelNonce = new Option( + "--parallel-nonce ", + "A unique ID for this parallel build", +).env("ARGOS_PARALLEL_NONCE"); diff --git a/packages/core/src/api-client.test.ts b/packages/core/src/auth.test.ts similarity index 58% rename from packages/core/src/api-client.test.ts rename to packages/core/src/auth.test.ts index 454c4cf..618d5ef 100644 --- a/packages/core/src/api-client.test.ts +++ b/packages/core/src/auth.test.ts @@ -1,80 +1,6 @@ -import { describe, it, expect, beforeAll } from "vitest"; +import { describe, it, expect } from "vitest"; -import { setupMockServer } from "../mocks/server"; -import { - ArgosApiClient, - createArgosLegacyAPIClient, - getAuthToken, -} from "./api-client"; - -setupMockServer(); - -let apiClient: ArgosApiClient; - -describe("#createArgosLegacyAPIClient", () => { - beforeAll(() => { - apiClient = createArgosLegacyAPIClient({ - baseUrl: "https://api.argos-ci.dev", - bearerToken: "Bearer 92d832e0d22ab113c8979d73a87a11130eaa24a9", - }); - }); - - describe("#createBuild", () => { - it("creates build", async () => { - const result = await apiClient.createBuild({ - commit: "f16f980bd17cccfa93a1ae7766727e67950773d0", - screenshotKeys: ["123", "456"], - pwTraceKeys: [], - }); - expect(result).toEqual({ - build: { - id: "123", - url: "https://app.argos-ci.dev/builds/123", - }, - screenshots: [ - { - key: "123", - putUrl: "https://api.s3.dev/upload/123", - }, - { - key: "456", - putUrl: "https://api.s3.dev/upload/456", - }, - ], - }); - }); - }); - - describe("#updateBuild", () => { - it("updates build", async () => { - const result = await apiClient.updateBuild({ - buildId: "123", - screenshots: [ - { - key: "123", - name: "screenshot 1", - metadata: null, - pwTraceKey: null, - threshold: null, - }, - { - key: "456", - name: "screenshot 2", - metadata: null, - pwTraceKey: null, - threshold: null, - }, - ], - }); - expect(result).toEqual({ - build: { - id: "123", - url: "https://app.argos-ci.dev/builds/123", - }, - }); - }); - }); -}); +import { getAuthToken } from "./auth"; describe("#getAuthToken", () => { describe("without CI", () => { diff --git a/packages/core/src/api-client.ts b/packages/core/src/auth.ts similarity index 100% rename from packages/core/src/api-client.ts rename to packages/core/src/auth.ts diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 21e627f..8879afd 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -104,7 +104,7 @@ const schema = { }, parallelTotal: { env: "ARGOS_PARALLEL_TOTAL", - format: "nat", + format: "int", default: null, nullable: true, }, @@ -217,8 +217,8 @@ export async function readConfig(options: Partial = {}) { config.get("parallelNonce") || ciEnv?.nonce || null, - parallelTotal: options.parallelTotal || config.get("parallelTotal") || null, - parallelIndex: options.parallelIndex || config.get("parallelIndex") || null, + parallelTotal: options.parallelTotal ?? config.get("parallelTotal") ?? null, + parallelIndex: options.parallelIndex ?? config.get("parallelIndex") ?? null, mode: options.mode || config.get("mode") || null, ciProvider: ciEnv?.key || null, }); diff --git a/packages/core/src/finalize.ts b/packages/core/src/finalize.ts new file mode 100644 index 0000000..e069fa4 --- /dev/null +++ b/packages/core/src/finalize.ts @@ -0,0 +1,40 @@ +import { createClient } from "@argos-ci/api-client"; +import { getAuthToken } from "./auth"; +import { readConfig } from "./config"; + +export type FinalizeParameters = { + parallel?: { + nonce: string; + }; +}; + +/** + * Finalize pending builds. + */ +export async function finalize(params: FinalizeParameters) { + const config = await readConfig({ + parallelNonce: params.parallel?.nonce ?? null, + }); + const authToken = getAuthToken(config); + + const apiClient = createClient({ + baseUrl: config.apiBaseUrl, + authToken, + }); + + if (!config.parallelNonce) { + throw new Error("parallel.nonce is required to finalize the build"); + } + + const finalizeBuildsResult = await apiClient.POST("/builds/finalize", { + body: { + parallelNonce: config.parallelNonce, + }, + }); + + if (finalizeBuildsResult.error) { + throw new Error(finalizeBuildsResult.error.error); + } + + return finalizeBuildsResult.data; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8038219..d932d1b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,2 +1,3 @@ export * from "./upload"; +export * from "./finalize"; export * from "./config"; diff --git a/packages/core/src/upload.ts b/packages/core/src/upload.ts index 6ee995a..a710d3a 100644 --- a/packages/core/src/upload.ts +++ b/packages/core/src/upload.ts @@ -3,7 +3,7 @@ import { readConfig } from "./config"; import { discoverScreenshots } from "./discovery"; import { optimizeScreenshot } from "./optimize"; import { hashFile } from "./hashing"; -import { getAuthToken } from "./api-client"; +import { getAuthToken } from "./auth"; import { upload as uploadToS3 } from "./s3"; import { debug, debugTime, debugTimeEnd } from "./debug"; import { chunk } from "./util/chunk"; @@ -69,7 +69,7 @@ export interface UploadParameters { /** Unique build ID for this parallel build */ nonce: string; /** The number of parallel nodes being ran */ - total: number; + total?: number; /** The index of the parallel node */ index?: number; }