UNPKG

@argos-ci/core

Version:

Node.js SDK for visual testing with Argos.

1,553 lines (1,548 loc) 48.9 kB
import { createRequire } from "node:module"; import convict from "convict"; import { execSync } from "node:child_process"; import createDebug from "debug"; import { createReadStream, existsSync, readFileSync } from "node:fs"; import { readFile, stat } from "node:fs/promises"; import { basename, extname, join, resolve } from "node:path"; import glob from "fast-glob"; import mime from "mime-types"; import { createClient, throwAPIError } from "@argos-ci/api-client"; import { createHash } from "node:crypto"; import { promisify } from "node:util"; import sharp from "sharp"; import tmp from "tmp"; import { getPlaywrightTracePath, readMetadata, readVersionFromPackage } from "@argos-ci/util"; //#region src/debug.ts const KEY = "@argos-ci/core"; const debug = createDebug(KEY); const isDebugEnabled = createDebug.enabled(KEY); const debugTime = (arg) => { if (isDebugEnabled) console.time(arg); }; const debugTimeEnd = (arg) => { if (isDebugEnabled) console.timeEnd(arg); }; //#endregion //#region src/ci-environment/git.ts /** * Check if the current directory is a git repository. */ function checkIsGitRepository() { try { return execSync("git rev-parse --is-inside-work-tree").toString().trim() === "true"; } catch { return false; } } /** * Returns the head commit. */ function head() { try { return execSync("git rev-parse HEAD").toString().trim(); } catch { return null; } } /** * Returns the current branch. */ function branch() { try { const headRef = execSync("git rev-parse --abbrev-ref HEAD").toString().trim(); if (headRef === "HEAD") return null; return headRef; } catch { return null; } } /** * Returns the repository URL. */ function getRepositoryURL() { try { return execSync("git config --get remote.origin.url").toString().trim(); } catch { return null; } } /** * Run git merge-base command. */ function gitMergeBase(input) { try { return execSync(`git merge-base ${input.head} ${input.base}`).toString().trim(); } catch (error) { if (checkIsExecError(error) && error.status === 1 && error.stderr.toString() === "") return null; throw error; } } /** * Run git fetch with a specific ref and depth. */ function gitFetch(input) { execSync(`git fetch --force --update-head-ok --depth ${input.depth} origin ${input.ref}:${input.target}`); } /** * Check if an error is an exec error that includes stderr. */ function checkIsExecError(error) { return error instanceof Error && "status" in error && typeof error.status === "number" && "stderr" in error && Buffer.isBuffer(error.stderr); } /** * Get the merge base commit SHA. * Fetch both base and head with depth and then run merge base. * Try to find a merge base with a depth of 1000 max. */ function getMergeBaseCommitSha$1(input) { let depth = 200; const argosBaseRef = `argos/${input.base}`; const argosHeadRef = `argos/${input.head}`; while (depth < 1e3) { gitFetch({ ref: input.head, depth, target: argosHeadRef }); gitFetch({ ref: input.base, depth, target: argosBaseRef }); const mergeBase = gitMergeBase({ base: argosBaseRef, head: argosHeadRef }); if (mergeBase) return mergeBase; depth += 200; } if (isDebugEnabled) { const headShas = listShas(argosHeadRef); const baseShas = listShas(argosBaseRef); debug(`No merge base found for ${input.head} and ${input.base} with depth ${depth}`); debug(`Found ${headShas.length} commits in ${input.head}: ${headShas.join(", ")}`); debug(`Found ${baseShas.length} commits in ${input.base}: ${baseShas.join(", ")}`); } return null; } function listShas(path, maxCount) { return execSync(`git log --format="%H" ${maxCount ? `--max-count=${maxCount}` : ""} ${path}`.trim()).toString().trim().split("\n"); } function listParentCommits$1(input) { const limit = 200; try { execSync(`git fetch --depth=${limit} origin ${input.sha}`); } catch (error) { if (error instanceof Error && error.message.includes("not our ref")) return []; } return listShas(input.sha, limit); } //#endregion //#region src/ci-environment/services/bitrise.ts function getPrNumber$2(context) { const { env } = context; return env.BITRISE_PULL_REQUEST ? Number(env.BITRISE_PULL_REQUEST) : null; } function getRepository$6(context) { const { env } = context; if (env.BITRISEIO_GIT_REPOSITORY_OWNER && env.BITRISEIO_GIT_REPOSITORY_SLUG) return `${env.BITRISEIO_GIT_REPOSITORY_OWNER}/${env.BITRISEIO_GIT_REPOSITORY_SLUG}`; return null; } const service$7 = { name: "Bitrise", key: "bitrise", detect: ({ env }) => Boolean(env.BITRISE_IO), config: (context) => { const { env } = context; const repository = getRepository$6(context); return { commit: env.BITRISE_GIT_COMMIT || null, branch: env.BITRISE_GIT_BRANCH || null, repository, originalRepository: repository, jobId: null, runId: null, runAttempt: null, prNumber: getPrNumber$2({ env }), prHeadCommit: null, prBaseBranch: null, nonce: env.BITRISEIO_PIPELINE_ID || null }; }, getMergeBaseCommitSha: getMergeBaseCommitSha$1, listParentCommits: listParentCommits$1 }; //#endregion //#region src/util/url.ts /** * Utility functions for parsing Git remote URLs. * Supports SSH, HTTPS, and git protocols. */ function getRepositoryNameFromURL(url) { const sshMatch = url.match(/^git@[^:]+:([^/]+)\/(.+?)(?:\.git)?$/); if (sshMatch && sshMatch[1] && sshMatch[2]) return `${sshMatch[1]}/${sshMatch[2]}`; const httpsMatch = url.match(/^(?:https?|git):\/\/[^/]+\/([^/]+)\/(.+?)(?:\.git)?$/); if (httpsMatch && httpsMatch[1] && httpsMatch[2]) return `${httpsMatch[1]}/${httpsMatch[2]}`; return null; } //#endregion //#region src/ci-environment/services/buildkite.ts function getRepository$5(context) { const { env } = context; if (env.BUILDKITE_REPO) return getRepositoryNameFromURL(env.BUILDKITE_REPO); return null; } const service$6 = { name: "Buildkite", key: "buildkite", detect: ({ env }) => Boolean(env.BUILDKITE), config: (context) => { const { env } = context; const repository = getRepository$5(context); return { commit: env.BUILDKITE_COMMIT || head() || null, branch: env.BUILDKITE_BRANCH || branch() || null, repository, originalRepository: repository, jobId: null, runId: null, runAttempt: null, prNumber: env.BUILDKITE_PULL_REQUEST ? Number(env.BUILDKITE_PULL_REQUEST) : null, prHeadCommit: null, prBaseBranch: null, nonce: env.BUILDKITE_BUILD_ID || null }; }, getMergeBaseCommitSha: getMergeBaseCommitSha$1, listParentCommits: listParentCommits$1 }; //#endregion //#region src/ci-environment/services/heroku.ts const service$5 = { name: "Heroku", key: "heroku", detect: ({ env }) => Boolean(env.HEROKU_TEST_RUN_ID), config: ({ env }) => ({ commit: env.HEROKU_TEST_RUN_COMMIT_VERSION || null, branch: env.HEROKU_TEST_RUN_BRANCH || null, owner: null, repository: null, originalRepository: null, jobId: null, runId: null, runAttempt: null, prNumber: null, prHeadCommit: null, prBaseBranch: null, nonce: env.HEROKU_TEST_RUN_ID || null }), getMergeBaseCommitSha: getMergeBaseCommitSha$1, listParentCommits: listParentCommits$1 }; //#endregion //#region src/ci-environment/github.ts /** * Get the full repository name (account/repo) from environment variable. */ function getGitHubRepository(ctx) { return ctx.env.GITHUB_REPOSITORY || null; } /** * Get the full repository name (account/repo) from environment variable or throws. */ function assertGitHubRepository(ctx) { const repo = getGitHubRepository(ctx); if (!repo) throw new Error("GITHUB_REPOSITORY is missing"); return repo; } /** * Get a GitHub token from environment variables. */ function getGitHubToken({ env }) { if (!env.GITHUB_TOKEN) { if (!env.DISABLE_GITHUB_TOKEN_WARNING) console.log(` Argos couldn’t find a relevant pull request in the current environment. To resolve this, Argos requires a GITHUB_TOKEN to fetch the pull request associated with the head SHA. Please ensure the following environment variable is added: GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }} For more details, check out the documentation: Read more at https://argos-ci.com/docs/run-on-preview-deployment If you want to disable this warning, you can set the following environment variable: DISABLE_GITHUB_TOKEN_WARNING: true `.trim()); return null; } return env.GITHUB_TOKEN; } /** * Fetch GitHub API. */ async function fetchGitHubAPI(ctx, url) { const githubToken = getGitHubToken(ctx); if (!githubToken) return null; return await fetch(url, { headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${githubToken}`, "X-GitHub-Api-Version": "2022-11-28" }, signal: AbortSignal.timeout(1e4) }); } const GITHUB_API_BASE_URL = "https://api.github.com"; /** * Get a pull request from a head sha. * Fetch the last 30 pull requests sorted by updated date * then try to find the one that matches the head sha. * If no pull request is found, return null. */ async function getPullRequestFromHeadSha(ctx, sha) { debug(`Fetching pull request details from head sha: ${sha}`); const githubRepository = assertGitHubRepository(ctx); const url = new URL(`/repos/${githubRepository}/pulls`, GITHUB_API_BASE_URL); url.search = new URLSearchParams({ state: "open", sort: "updated", per_page: "30", page: "1" }).toString(); const response = await fetchGitHubAPI(ctx, url); if (!response) return null; if (!response.ok) throw new Error(`Non-OK response (status: ${response.status}) while fetching pull request details from head sha (${sha})`); const result = await response.json(); if (result.length === 0) { debug("No results, no pull request found"); return null; } const matchingPr = result.find((pr) => pr.head.sha === sha); if (matchingPr) { debug("Pull request found", matchingPr); return matchingPr; } debug("No matching pull request found"); return null; } /** * Get a pull request from a PR number. */ async function getPullRequestFromPrNumber(ctx, prNumber) { debug(`Fetching pull request #${prNumber}`); const githubRepository = assertGitHubRepository(ctx); const response = await fetchGitHubAPI(ctx, new URL(`/repos/${githubRepository}/pulls/${prNumber}`, GITHUB_API_BASE_URL)); if (!response) return null; if (response.status === 404) { debug("No pull request found, pr detection from branch was probably a mistake"); return null; } if (!response.ok) throw new Error(`Non-OK response (status: ${response.status}) while fetching pull request #${prNumber}`); return await response.json(); } /** * Get the PR number from a merge group branch. * Example: gh-readonly-queue/master/pr-1529-c1c25caabaade7a8ddc1178c449b872b5d3e51a4 */ function getPRNumberFromMergeGroupBranch(branch) { const prMatch = /queue\/[^/]*\/pr-(\d+)-/.exec(branch); if (prMatch) return Number(prMatch[1]); return null; } //#endregion //#region src/ci-environment/services/github-actions.ts /** * Read the event payload. */ function readEventPayload({ env }) { if (!env.GITHUB_EVENT_PATH) return null; if (!existsSync(env.GITHUB_EVENT_PATH)) return null; return JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, "utf-8")); } /** * Get a payload from a Vercel deployment "repository_dispatch" * @see https://vercel.com/docs/git/vercel-for-github#repository-dispatch-events */ function getVercelDeploymentPayload(payload) { if (process.env.GITHUB_EVENT_NAME === "repository_dispatch" && payload && "action" in payload && payload.action === "vercel.deployment.success") return payload; return null; } /** * Get a merge group payload from a "merge_group" event. */ function getMergeGroupPayload(payload) { if (payload && process.env.GITHUB_EVENT_NAME === "merge_group" && "action" in payload && payload.action === "checks_requested") return payload; return null; } function getMergeQueuePrNumbers(args) { const { mergeGroupPayload, pullRequest } = args; if (!mergeGroupPayload) return null; if (pullRequest) return [pullRequest.number]; const headRef = mergeGroupPayload.merge_group.head_ref; const prNumberFromBranch = getPRNumberFromMergeGroupBranch(headRef); if (prNumberFromBranch != null) return [prNumberFromBranch]; return []; } /** * Get the branch from the local context. */ function getBranchFromContext(context) { const { env } = context; if (env.GITHUB_HEAD_REF) return env.GITHUB_HEAD_REF; if (!env.GITHUB_REF) return null; return /refs\/heads\/(.*)/.exec(env.GITHUB_REF)?.[1] ?? null; } /** * Get the branch from the payload. */ function getBranchFromPayload(payload) { if ("workflow_run" in payload && payload.workflow_run) return payload.workflow_run.head_branch; if ("deployment" in payload && payload.deployment) return payload.deployment.environment; return null; } /** * Get the branch. */ function getBranch(args) { const { payload, mergeGroupPayload, vercelPayload, pullRequest, context } = args; if (mergeGroupPayload && pullRequest?.head.ref) return pullRequest.head.ref; if (vercelPayload) return vercelPayload.client_payload.git.ref; if (payload) { const fromPayload = getBranchFromPayload(payload); if (fromPayload) return fromPayload; } const fromContext = getBranchFromContext(context); if (fromContext) return fromContext; if (pullRequest) return pullRequest.head.ref; return null; } /** * Get the repository either from payload or from environment variables. */ function getRepository$4(context, payload) { if (payload && "pull_request" in payload && payload.pull_request) { const pr = payload.pull_request; if (pr.head && pr.head.repo && pr.head.repo.full_name) return pr.head.repo.full_name; } return getGitHubRepository(context); } /** * Get the head sha. */ function getSha(context, vercelPayload, payload) { if (context.env.GITHUB_EVENT_NAME === "pull_request_target") { if (!payload) throw new Error("Payload is missing in \"pull_request_target\" event"); const pullRequest = getPullRequestFromPayload(payload); if (!pullRequest) throw new Error("Pull request missing in \"pull_request_target\" event"); return pullRequest.head.sha; } if (vercelPayload) return vercelPayload.client_payload.git.sha; if (!context.env.GITHUB_SHA) throw new Error("GITHUB_SHA is missing"); return context.env.GITHUB_SHA; } /** * Get the pull request from an event payload. */ function getPullRequestFromPayload(payload) { if ("pull_request" in payload && payload.pull_request && payload.pull_request) return payload.pull_request; if ("workflow_run" in payload && payload.workflow_run && payload.workflow_run.pull_requests[0]) return payload.workflow_run.pull_requests[0]; if ("check_run" in payload && payload.check_run && "pull_requests" in payload.check_run && payload.check_run.pull_requests[0]) return payload.check_run.pull_requests[0]; return null; } /** * Get the pull request either from payload or local fetching. */ async function getPullRequest(args) { const { payload, vercelPayload, mergeGroupPayload, context, sha } = args; if (vercelPayload || !payload) return getPullRequestFromHeadSha(context, sha); if (mergeGroupPayload) { const prNumber = getPRNumberFromMergeGroupBranch(mergeGroupPayload.merge_group.head_ref); if (!prNumber) { debug(`No PR found from merge group head ref: ${mergeGroupPayload.merge_group.head_ref}`); return null; } debug(`PR #${prNumber} found from merge group head ref (${mergeGroupPayload.merge_group.head_ref})`); return getPullRequestFromPrNumber(context, prNumber); } return getPullRequestFromPayload(payload); } const service$4 = { name: "GitHub Actions", key: "github-actions", detect: (context) => Boolean(context.env.GITHUB_ACTIONS), config: async (context) => { const { env } = context; const payload = readEventPayload(context); const vercelPayload = getVercelDeploymentPayload(payload); const mergeGroupPayload = getMergeGroupPayload(payload); const sha = getSha(context, vercelPayload, payload); const pullRequest = await getPullRequest({ payload, vercelPayload, mergeGroupPayload, sha, context }); const branch = getBranch({ payload, vercelPayload, mergeGroupPayload, context, pullRequest }); return { commit: sha, repository: getRepository$4(context, payload), originalRepository: getGitHubRepository(context), jobId: env.GITHUB_JOB || null, runId: env.GITHUB_RUN_ID || null, runAttempt: env.GITHUB_RUN_ATTEMPT ? Number(env.GITHUB_RUN_ATTEMPT) : null, nonce: `${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}`, branch, prNumber: pullRequest?.number || null, prHeadCommit: pullRequest?.head.sha ?? null, prBaseBranch: pullRequest?.base.ref ?? null, mergeQueuePrNumbers: getMergeQueuePrNumbers({ mergeGroupPayload, pullRequest }) }; }, getMergeBaseCommitSha: getMergeBaseCommitSha$1, listParentCommits: listParentCommits$1 }; //#endregion //#region src/ci-environment/services/circleci.ts function getPrNumber$1(context) { const { env } = context; const matches = /pull\/(\d+)/.exec(env.CIRCLE_PULL_REQUEST || ""); if (matches) return Number(matches[1]); return null; } function getRepository$3(context) { const { env } = context; if (env.CIRCLE_PR_REPONAME && env.CIRCLE_PR_USERNAME) return `${env.CIRCLE_PR_USERNAME}/${env.CIRCLE_PR_REPONAME}`; return getOriginalRepository$2(context); } function getOriginalRepository$2(context) { const { env } = context; if (env.CIRCLE_PROJECT_USERNAME && env.CIRCLE_PROJECT_REPONAME) return `${env.CIRCLE_PROJECT_USERNAME}/${env.CIRCLE_PROJECT_REPONAME}`; return null; } const service$3 = { name: "CircleCI", key: "circleci", detect: ({ env }) => Boolean(env.CIRCLECI), config: (context) => { const { env } = context; return { commit: env.CIRCLE_SHA1 || null, branch: env.CIRCLE_BRANCH || null, repository: getRepository$3(context), originalRepository: getOriginalRepository$2(context), jobId: null, runId: null, runAttempt: null, prNumber: getPrNumber$1({ env }), prHeadCommit: null, prBaseBranch: null, nonce: env.CIRCLE_WORKFLOW_ID || env.CIRCLE_BUILD_NUM || null }; }, getMergeBaseCommitSha: getMergeBaseCommitSha$1, listParentCommits: listParentCommits$1 }; //#endregion //#region src/ci-environment/services/travis.ts function getRepository$2(context) { const { env } = context; if (env.TRAVIS_PULL_REQUEST_SLUG) return env.TRAVIS_PULL_REQUEST_SLUG; return getOriginalRepository$1(context); } function getOriginalRepository$1(context) { const { env } = context; return env.TRAVIS_REPO_SLUG || null; } function getPrNumber(context) { const { env } = context; if (env.TRAVIS_PULL_REQUEST) return Number(env.TRAVIS_PULL_REQUEST); return null; } const service$2 = { name: "Travis CI", key: "travis", detect: ({ env }) => Boolean(env.TRAVIS), config: (ctx) => { const { env } = ctx; return { commit: env.TRAVIS_COMMIT || null, branch: env.TRAVIS_BRANCH || null, repository: getRepository$2(ctx), originalRepository: getOriginalRepository$1(ctx), jobId: null, runId: null, runAttempt: null, prNumber: getPrNumber(ctx), prHeadCommit: null, prBaseBranch: null, nonce: env.TRAVIS_BUILD_ID || null }; }, getMergeBaseCommitSha: getMergeBaseCommitSha$1, listParentCommits: listParentCommits$1 }; //#endregion //#region src/ci-environment/services/gitlab.ts function getRepository$1(context) { const { env } = context; if (env.CI_MERGE_REQUEST_PROJECT_PATH) return env.CI_MERGE_REQUEST_PROJECT_PATH; return getOriginalRepository(context); } function getOriginalRepository(context) { const { env } = context; return env.CI_PROJECT_PATH || null; } const service$1 = { name: "GitLab", key: "gitlab", detect: ({ env }) => env.GITLAB_CI === "true", config: (context) => { const { env } = context; return { commit: env.CI_COMMIT_SHA || null, branch: env.CI_COMMIT_REF_NAME || null, repository: getRepository$1(context), originalRepository: getOriginalRepository(context), jobId: null, runId: null, runAttempt: null, prNumber: null, prHeadCommit: null, prBaseBranch: null, nonce: env.CI_PIPELINE_ID || null }; }, getMergeBaseCommitSha: getMergeBaseCommitSha$1, listParentCommits: listParentCommits$1 }; //#endregion //#region src/ci-environment/services/git.ts function getRepository() { const repositoryURL = getRepositoryURL(); if (!repositoryURL) return null; return getRepositoryNameFromURL(repositoryURL); } //#endregion //#region src/ci-environment/index.ts const services = [ service$5, service$4, service$3, service$2, service$6, service$1, service$7, { name: "Git", key: "git", detect: () => checkIsGitRepository(), config: () => { const repository = getRepository(); return { commit: head() || null, branch: branch() || null, repository, originalRepository: repository, jobId: null, runId: null, runAttempt: null, prNumber: null, prHeadCommit: null, prBaseBranch: null, nonce: null }; }, getMergeBaseCommitSha: getMergeBaseCommitSha$1, listParentCommits: listParentCommits$1 } ]; /** * Create the context for the CI service detection. */ function createContext() { return { env: process.env }; } /** * Get the CI service that is currently running. */ function getCiService(context) { return services.find((service) => service.detect(context)); } /** * Get the merge base commit. */ function getMergeBaseCommitSha(input) { const context = createContext(); const service = getCiService(context); if (!service) return null; return service.getMergeBaseCommitSha(input, context); } /** * Get the merge base commit. */ function listParentCommits(input) { const context = createContext(); const service = getCiService(context); if (!service) return null; return service.listParentCommits(input, context); } /** * Get the CI environment. */ async function getCiEnvironment() { const context = createContext(); debug("Detecting CI environment", context); const service = getCiService(context); if (service) { debug("Internal service matched", service.name); const variables = await service.config(context); const ciEnvironment = { name: service.name, key: service.key, ...variables }; debug("CI environment", ciEnvironment); return ciEnvironment; } return null; } //#endregion //#region src/config.ts const mustBeApiBaseUrl = (value) => { if (!/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/.test(value)) throw new Error("Invalid Argos API base URL"); }; const mustBeCommit = (value) => { if (!/^[0-9a-f]{40}$/.test(value)) { if (/^[0-9a-f]{7}$/.test(value)) throw new Error("Short SHA1 is not allowed"); throw new Error("Invalid commit"); } }; const mustBeArgosToken = (value) => { if (value && value.length !== 40) throw new Error("Invalid Argos repository token (must be 40 characters)"); }; const minInteger = (min) => (value) => { if (!Number.isInteger(value)) throw new Error("must be an integer"); if (value < min) throw new Error(`must be at least ${min}`); }; const toInt = (value) => { if (value === "") return null; const num = Number(value); if (!Number.isInteger(num) || Number.isNaN(num)) return num; return num; }; const toFloat = (value) => parseFloat(value); const toIntArray = (value) => { if (Array.isArray(value)) return value; if (value === "") return null; return value.split(",").map(toInt); }; convict.addFormat({ name: "parallel-total", validate: minInteger(-1), coerce: toInt }); convict.addFormat({ name: "parallel-index", validate: minInteger(1), coerce: toInt }); convict.addFormat({ name: "float-percent", validate: (val) => { if (val !== 0 && (!val || val > 1 || val < 0)) throw new Error("Must be a float between 0 and 1, inclusive."); }, coerce: toFloat }); convict.addFormat({ name: "int-array", validate: (value) => { if (value === null) return; if (!Array.isArray(value)) throw new Error("must be an array"); for (const item of value) if (!Number.isInteger(item)) throw new Error("must be an array of integers"); }, coerce: toIntArray }); const schema = { apiBaseUrl: { env: "ARGOS_API_BASE_URL", default: "https://api.argos-ci.com/v2/", format: mustBeApiBaseUrl }, commit: { env: "ARGOS_COMMIT", default: null, format: mustBeCommit }, branch: { env: "ARGOS_BRANCH", default: null, format: String }, token: { env: "ARGOS_TOKEN", default: null, format: mustBeArgosToken }, buildName: { env: "ARGOS_BUILD_NAME", default: null, format: String, nullable: true }, mode: { env: "ARGOS_MODE", format: ["ci", "monitoring"], default: null, nullable: true }, prNumber: { env: "ARGOS_PR_NUMBER", format: Number, default: null, nullable: true }, prHeadCommit: { env: "ARGOS_PR_HEAD_COMMIT", format: String, default: null, nullable: true }, prBaseBranch: { env: "ARGOS_PR_BASE_BRANCH", format: String, default: null, nullable: true }, parallel: { env: "ARGOS_PARALLEL", default: false, format: Boolean }, parallelNonce: { env: "ARGOS_PARALLEL_NONCE", format: String, default: null, nullable: true }, parallelIndex: { env: "ARGOS_PARALLEL_INDEX", format: "parallel-index", default: null, nullable: true }, parallelTotal: { env: "ARGOS_PARALLEL_TOTAL", format: "parallel-total", default: null, nullable: true }, referenceBranch: { env: "ARGOS_REFERENCE_BRANCH", format: String, default: null, nullable: true }, referenceCommit: { env: "ARGOS_REFERENCE_COMMIT", format: String, default: null, nullable: true }, jobId: { format: String, default: null, nullable: true }, runId: { format: String, default: null, nullable: true }, runAttempt: { format: "nat", default: null, nullable: true }, repository: { format: String, default: null, nullable: true }, originalRepository: { format: String, default: null, nullable: true }, ciProvider: { format: String, default: null, nullable: true }, threshold: { env: "ARGOS_THRESHOLD", format: "float-percent", default: null, nullable: true }, previewBaseUrl: { env: "ARGOS_PREVIEW_BASE_URL", format: String, default: null, nullable: true }, skipped: { env: "ARGOS_SKIPPED", format: Boolean, default: false }, mergeQueuePrNumbers: { env: "ARGOS_MERGE_QUEUE_PRS", format: "int-array", default: null, nullable: true }, mergeQueue: { format: Boolean, default: false }, subset: { env: "ARGOS_SUBSET", format: Boolean, default: false } }; function createConfig() { return convict(schema, { args: [], env: {} }); } function getDefaultConfig() { return Object.entries(schema).reduce((cfg, [key, entry]) => { cfg[key] = "env" in entry && entry.env && process.env[entry.env] ? process.env[entry.env] : entry.default; return cfg; }, {}); } async function readConfig(options = {}) { const config = createConfig(); const ciEnv = await getCiEnvironment(); const defaultConfig = getDefaultConfig(); config.load({ apiBaseUrl: options.apiBaseUrl || defaultConfig.apiBaseUrl, commit: options.commit || defaultConfig.commit || ciEnv?.commit || null, branch: options.branch || defaultConfig.branch || ciEnv?.branch || null, token: options.token || defaultConfig.token || null, buildName: options.buildName || defaultConfig.buildName || null, prNumber: options.prNumber || defaultConfig.prNumber || ciEnv?.prNumber || null, prHeadCommit: defaultConfig.prHeadCommit || ciEnv?.prHeadCommit || null, prBaseBranch: defaultConfig.prBaseBranch || ciEnv?.prBaseBranch || null, referenceBranch: options.referenceBranch || defaultConfig.referenceBranch || null, referenceCommit: options.referenceCommit || defaultConfig.referenceCommit || null, repository: ciEnv?.repository || null, originalRepository: ciEnv?.originalRepository || null, jobId: ciEnv?.jobId || null, runId: ciEnv?.runId || null, runAttempt: ciEnv?.runAttempt || null, parallel: options.parallel ?? defaultConfig.parallel ?? false, parallelNonce: options.parallelNonce || defaultConfig.parallelNonce || ciEnv?.nonce || null, parallelTotal: options.parallelTotal ?? defaultConfig.parallelTotal ?? null, parallelIndex: options.parallelIndex ?? defaultConfig.parallelIndex ?? null, mode: options.mode || defaultConfig.mode || null, ciProvider: ciEnv?.key || null, previewBaseUrl: defaultConfig.previewBaseUrl || null, skipped: options.skipped ?? defaultConfig.skipped ?? false, subset: options.subset ?? defaultConfig.subset ?? false, mergeQueuePrNumbers: options.mergeQueuePrNumbers ?? defaultConfig.mergeQueuePrNumbers ?? ciEnv?.mergeQueuePrNumbers ?? null }); if (!config.get("branch") || !config.get("commit")) throw new Error("Argos requires a branch and a commit to be set. If you are running in a non-git environment consider setting ARGOS_BRANCH and ARGOS_COMMIT environment variables."); config.validate(); return config.get(); } async function getConfigFromOptions({ parallel, ...options }) { return readConfig({ ...options, parallel: parallel !== void 0 ? Boolean(parallel) : void 0, parallelNonce: parallel ? parallel.nonce : void 0, parallelTotal: parallel ? parallel.total : void 0, parallelIndex: parallel ? parallel.index : void 0 }); } //#endregion //#region src/hashing.ts const hashFile = async (filepath) => { const fileStream = createReadStream(filepath); const hash = createHash("sha256"); await new Promise((resolve, reject) => { fileStream.on("error", reject); hash.on("error", reject); hash.on("finish", resolve); fileStream.pipe(hash); }); return hash.digest("hex"); }; //#endregion //#region src/s3.ts async function uploadFile(input) { const file = await readFile(input.path); const response = await fetch(input.url, { method: "PUT", headers: { "Content-Type": input.contentType }, signal: AbortSignal.timeout(3e4), body: new Uint8Array(file) }); if (!response.ok) throw new Error(`Failed to upload file to ${input.url}: ${response.status} ${response.statusText}`); } //#endregion //#region src/util/chunk.ts /** * Split an array into chunks of a given size. */ const chunk = (collection, size) => { const result = []; for (let x = 0; x < Math.ceil(collection.length / size); x++) { const start = x * size; const end = start + size; result.push(collection.slice(start, end)); } return result; }; //#endregion //#region src/github-actions-oidc.ts /** * Check if GitHub Actions OIDC is available for auto-detection. */ function isGitHubActionsOidcAvailable() { return process.env.GITHUB_ACTIONS === "true" && Boolean(process.env.ACTIONS_ID_TOKEN_REQUEST_URL) && Boolean(process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN) && !process.env.ARGOS_TOKEN; } async function fetchOidcToken(args) { if (!process.env.ACTIONS_ID_TOKEN_REQUEST_URL) throw new Error(`ACTIONS_ID_TOKEN_REQUEST_URL not found`); if (!process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN) throw new Error(`ACTIONS_ID_TOKEN_REQUEST_TOKEN not found`); const url = new URL(process.env.ACTIONS_ID_TOKEN_REQUEST_URL); url.searchParams.set("audience", args.audience); const response = await fetch(url.toString(), { headers: { Authorization: `Bearer ${process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN}`, Accept: "application/json; api-version=2.0", "Content-Type": "application/json" } }); if (!response.ok) throw new Error(`Failed to fetch GitHub Actions OIDC token: ${response.status} ${response.statusText}`); const data = await response.json(); if (!data.value) throw new Error("Invalid GitHub Actions OIDC token response: missing 'value' field"); return data.value; } /** * Exchange a GitHub Actions OIDC token for a short-lived Argos token. */ async function exchangeGitHubActionsOidcToken(args) { const { apiBaseUrl, config } = args; const audience = new URL(apiBaseUrl).origin; const oidcToken = await fetchOidcToken({ audience }); const result = await createClient({ baseUrl: apiBaseUrl }).POST("/auth/github-actions/oidc/exchange", { body: { oidcToken, repository: config.originalRepository ?? void 0, commit: config.commit, branch: config.branch, pullRequestNumber: config.prNumber ?? void 0 } }); if (result.error) throwAPIError(result.error); return result.data.token; } //#endregion //#region src/github-actions-tokenless.ts const base64Encode = (obj) => Buffer.from(JSON.stringify(obj), "utf8").toString("base64"); /** * Check if GitHub Actions tokenless authentication is available for auto-detection. */ function isGitHubActionsTokenlessAvailable(config) { return config.ciProvider === "github-actions"; } /** * Build a tokenless GitHub Actions bearer token from the CI environment. */ function getTokenlessBearerToken(config) { const { originalRepository: repository, jobId, runId, prNumber } = config; if (!repository || !jobId || !runId) throw new Error(`Automatic GitHub Actions variables detection failed. Please set ARGOS_TOKEN.`); const [owner, repo] = repository.split("/"); return `tokenless-github-${base64Encode({ owner, repository: repo, jobId, runId, prNumber: prNumber ?? void 0 })}`; } /** * Exchange a tokenless GitHub Actions bearer token for a short-lived Argos token. */ async function exchangeGitHubActionsTokenlessToken(args) { const { apiBaseUrl, config } = args; const commit = config.prHeadCommit ?? config.commit; const tokenlessToken = getTokenlessBearerToken(config); const result = await createClient({ baseUrl: apiBaseUrl }).POST("/auth/github-actions/tokenless/exchange", { body: { tokenlessToken, commit, branch: config.branch } }); if (result.error) throwAPIError(result.error); return result.data.token; } //#endregion //#region src/auth.ts /** * Resolve the Argos authentication token. * Priority: ARGOS_TOKEN > GitHub Actions OIDC > GitHub Actions tokenless exchange. */ async function resolveArgosToken(config) { if (config.token) { debug("Authenticated with ARGOS_TOKEN."); return config.token; } if (isGitHubActionsOidcAvailable()) { const token = await exchangeGitHubActionsOidcToken({ apiBaseUrl: config.apiBaseUrl, config }); debug("Authenticated with GitHub Actions OIDC."); debug(`Repository: ${config.originalRepository}`); debug(`Run: ${config.runId}`); return token; } if (isGitHubActionsTokenlessAvailable(config)) { const token = await exchangeGitHubActionsTokenlessToken({ apiBaseUrl: config.apiBaseUrl, config }); debug("Authenticated with GitHub Actions tokenless exchange."); debug(`Repository: ${config.originalRepository}`); debug(`Run: ${config.runId}`); return token; } throw new Error("Missing Argos repository token 'ARGOS_TOKEN'"); } //#endregion //#region src/deploy.ts const CHUNK_SIZE$1 = 10; function getContentType(filePath) { return mime.lookup(filePath) || "application/octet-stream"; } /** * Deploy a static site (e.g. Storybook) to Argos. */ async function deploy(params) { const { token: _token, ...debugParams } = params; debug("Starting deploy with params", debugParams); const config = await getConfigFromOptions(params); const authToken = await resolveArgosToken(config); const apiClient = createClient({ baseUrl: config.apiBaseUrl, authToken }); debug("Listing files in", params.root); const relativePaths = await glob("**/*", { cwd: params.root, onlyFiles: true, dot: true }); if (relativePaths.length === 0) throw new Error(`No files found in directory: ${params.root}`); debug(`Found ${relativePaths.length} files`); const files = await Promise.all(relativePaths.map(async (relativePath) => { const absolutePath = join(params.root, relativePath); const [hash, stats] = await Promise.all([hashFile(absolutePath), stat(absolutePath)]); return { absolutePath, path: relativePath, hash, size: stats.size, contentType: getContentType(absolutePath) }; })); const filesByPath = new Map(files.map((file) => [file.path, file])); debug("Creating deployment"); const createResponse = await apiClient.POST("/deployments", { body: { commit: config.commit ?? null, branch: config.branch ?? null, prNumber: config.prNumber ?? null, environment: params.environment, files: files.map(({ path, hash, size, contentType }) => ({ path, hash, size, contentType })) } }); if (createResponse.error) throwAPIError(createResponse.error); const { deploymentId, uploadFiles: filesToUpload } = createResponse.data; debug(`Deployment created: ${deploymentId}, files to upload: ${filesToUpload.length}`); const uploadChunks = chunk(filesToUpload.map(({ path, uploadUrl }) => { const file = filesByPath.get(path); if (!file) throw new Error(`Invariant: file not found for path: ${path}`); return { url: uploadUrl, path: file.absolutePath, contentType: file.contentType }; }), CHUNK_SIZE$1); for (let i = 0; i < uploadChunks.length; i++) { const uploadChunk = uploadChunks[i]; if (!uploadChunk) continue; debug(`Uploading chunk ${i + 1}/${uploadChunks.length}`); await Promise.all(uploadChunk.map(({ url, path, contentType }) => uploadFile({ url, path, contentType }))); } debug("Finalizing deployment"); const finalizeResponse = await apiClient.POST("/deployments/{deploymentId}/finalize", { params: { path: { deploymentId } } }); if (finalizeResponse.error) throwAPIError(finalizeResponse.error); return finalizeResponse.data; } //#endregion //#region src/finalize.ts /** * Finalize pending builds. */ async function finalize(params) { const config = await readConfig({ parallelNonce: params.parallel?.nonce }); const authToken = await resolveArgosToken(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) throwAPIError(finalizeBuildsResult.error); return finalizeBuildsResult.data; } //#endregion //#region src/discovery.ts /** * Discover snapshots in the given root directory matching the provided patterns. */ async function discoverSnapshots(patterns, { root = process.cwd(), ignore } = {}) { debug(`Discovering snapshots with patterns: ${Array.isArray(patterns) ? patterns.join(", ") : patterns} in ${root}`); return (await glob(patterns, { onlyFiles: true, ignore, cwd: root })).map((match) => { debug(`Found screenshot: ${match}`); return { name: match, path: resolve(root, match) }; }); } /** * Check if the given filename corresponds to an Argos image. */ function checkIsValidImageFile(filename) { const lowerFilename = extname(filename).toLowerCase(); return lowerFilename === ".png" || lowerFilename === ".jpg" || lowerFilename === ".jpeg"; } //#endregion //#region src/optimize.ts const tmpFile = promisify(tmp.file); /** * Maximum number of pixels allowed in a screenshot. */ const MAX_PIXELS = 8e7; /** * Default maximum width of a screenshot. * Used when the width or height of the image is not available. */ const DEFAULT_MAX_WIDTH = 2048; async function optimizeScreenshot(filepath) { if (!checkIsValidImageFile(filepath)) return filepath; try { const [resultFilePath, metadata] = await Promise.all([tmpFile(), sharp(filepath).metadata()]); const { width, height } = metadata; const maxDimensions = (() => { if (!width || !height) return { width: DEFAULT_MAX_WIDTH, height: Math.floor(MAX_PIXELS / DEFAULT_MAX_WIDTH) }; const nbPixels = width * height; if (nbPixels <= MAX_PIXELS) return null; if (width < height) return { width: DEFAULT_MAX_WIDTH, height: Math.floor(MAX_PIXELS / DEFAULT_MAX_WIDTH) }; const scaleFactor = Math.sqrt(MAX_PIXELS / nbPixels); return { width: Math.floor(width * scaleFactor), height: Math.floor(height * scaleFactor) }; })(); let operation = sharp(filepath); if (maxDimensions) operation = operation.resize(maxDimensions.width, maxDimensions.height, { fit: "inside", withoutEnlargement: true }); await operation.png({ force: true }).toFile(resultFilePath); if (width && height && maxDimensions) { const { width: maxWidth, height: maxHeight } = maxDimensions; const widthRatio = maxWidth / width; const heightRatio = maxHeight / height; const scaleFactor = Math.min(widthRatio, heightRatio); const newWidth = Math.floor(width * scaleFactor); const newHeight = Math.floor(height * scaleFactor); console.warn(`Image ${basename(filepath)} resized from ${width}x${height} to ${newWidth}x${newHeight}.`); } return resultFilePath; } catch (error) { const message = error instanceof Error ? error.message : "Unknown Error"; throw new Error(`Error while processing image (${filepath}): ${message}`, { cause: error }); } } //#endregion //#region src/version.ts const require = createRequire(import.meta.url); /** * Get the version of the @argos-ci/core package. */ async function getArgosCoreSDKIdentifier() { return `@argos-ci/core@${await readVersionFromPackage(require.resolve("@argos-ci/core/package.json"))}`; } //#endregion //#region src/mime-type.ts /** * Get the mime type of a snapshot file based on its extension. */ function getSnapshotMimeType(filepath) { const type = mime.lookup(filepath); if (!type) throw new Error(`Unable to determine snapshot file type for: ${filepath}`); return type; } //#endregion //#region src/skip.ts /** * Mark a build as skipped. */ async function skip(params) { const [config, argosSdk] = await Promise.all([getConfigFromOptions(params), getArgosCoreSDKIdentifier()]); const authToken = await resolveArgosToken(config); const createBuildResponse = await createClient({ baseUrl: config.apiBaseUrl, authToken }).POST("/builds", { body: { commit: config.commit, branch: config.branch, name: config.buildName, mode: config.mode, prNumber: config.prNumber, prHeadCommit: config.prHeadCommit, referenceBranch: config.referenceBranch, referenceCommit: config.referenceCommit, argosSdk, ciProvider: config.ciProvider, runId: config.runId, runAttempt: config.runAttempt, skipped: true, screenshotKeys: [], pwTraceKeys: [], parentCommits: [] } }); if (createBuildResponse.error) throwAPIError(createBuildResponse.error); return { build: createBuildResponse.data.build }; } //#endregion //#region src/upload.ts /** * Size of the chunks used to upload screenshots to Argos. */ const CHUNK_SIZE = 10; /** * Upload screenshots to Argos. */ async function upload(params) { debug("Starting upload with params", params); const [config, argosSdk] = await Promise.all([getConfigFromOptions(params), getArgosCoreSDKIdentifier()]); const authToken = await resolveArgosToken(config); const apiClient = createClient({ baseUrl: config.apiBaseUrl, authToken }); if (config.skipped) { const { build } = await skip(params); return { build, screenshots: [] }; } const previewUrlFormatter = params.previewUrl ?? (config.previewBaseUrl ? { baseUrl: config.previewBaseUrl } : void 0); const globs = params.files ?? ["**/*.{png,jpg,jpeg}"]; debug("Using config and files", config, globs); const files = await discoverSnapshots(globs, { root: params.root, ignore: params.ignore }); debug("Found snapshots", files); const snapshots = await Promise.all(files.map(async (snapshot) => { const contentType = getSnapshotMimeType(snapshot.path); const [metadata, pwTracePath, optimizedPath] = await Promise.all([ readMetadata(snapshot.path), getPlaywrightTracePath(snapshot.path), contentType.startsWith("image/") ? optimizeScreenshot(snapshot.path) : snapshot.path ]); const [hash, pwTraceHash] = await Promise.all([hashFile(optimizedPath), pwTracePath ? hashFile(pwTracePath) : null]); const threshold = metadata?.transient?.threshold ?? null; const baseName = metadata?.transient?.baseName ?? null; const parentName = metadata?.transient?.parentName ?? null; if (metadata) { delete metadata.transient; if (metadata.url && previewUrlFormatter) metadata.previewUrl = formatPreviewUrl(metadata.url, previewUrlFormatter); } return { ...snapshot, hash, optimizedPath, metadata, threshold, baseName, parentName, pwTrace: pwTracePath && pwTraceHash ? { path: pwTracePath, hash: pwTraceHash } : null, contentType }; })); debug("Fetch project"); const projectResponse = await apiClient.GET("/project"); if (projectResponse.error) throwAPIError(projectResponse.error); debug("Project fetched", projectResponse.data); const { defaultBaseBranch, hasRemoteContentAccess } = projectResponse.data; const referenceCommit = (() => { if (config.referenceCommit) { debug("Found reference commit in config", config.referenceCommit); return config.referenceCommit; } if (hasRemoteContentAccess) return null; const sha = getMergeBaseCommitSha({ base: config.referenceBranch || config.prBaseBranch || defaultBaseBranch, head: config.branch }); if (sha) debug("Found merge base", sha); else debug("No merge base found"); return sha; })(); const parentCommits = (() => { if (hasRemoteContentAccess) return null; if (referenceCommit) { const commits = listParentCommits({ sha: referenceCommit }); if (commits) debug("Found parent commits", commits); else debug("No parent commits found"); return commits; } return null; })(); debug("Creating build"); const [pwTraceKeys, snapshotKeys] = snapshots.reduce(([pwTraceKeys, snapshotKeys], snapshot) => { if (snapshot.pwTrace && !pwTraceKeys.includes(snapshot.pwTrace.hash)) pwTraceKeys.push(snapshot.pwTrace.hash); if (!snapshotKeys.includes(snapshot.hash)) snapshotKeys.push(snapshot.hash); return [pwTraceKeys, snapshotKeys]; }, [[], []]); const createBuildResponse = await apiClient.POST("/builds", { body: { commit: config.commit, branch: config.branch, name: config.buildName, mode: config.mode, parallel: config.parallel, parallelNonce: config.parallelNonce, screenshotKeys: snapshotKeys, pwTraceKeys, prNumber: config.prNumber, prHeadCommit: config.prHeadCommit, referenceBranch: config.referenceBranch, referenceCommit, parentCommits, argosSdk, ciProvider: config.ciProvider, runId: config.runId, runAttempt: config.runAttempt, mergeQueue: Boolean(config.mergeQueuePrNumbers), mergeQueuePrNumbers: config.mergeQueuePrNumbers, subset: config.subset } }); if (createBuildResponse.error) throwAPIError(createBuildResponse.error); const result = createBuildResponse.data; debug("Got uploads url", result); await uploadFilesToS3([...result.screenshots.map(({ key, putUrl }) => { const snapshot = snapshots.find((s) => s.hash === key); if (!snapshot) throw new Error(`Invariant: snapshot with hash ${key} not found`); return { url: putUrl, path: snapshot.optimizedPath, contentType: snapshot.contentType }; }), ...result.pwTraces?.map(({ key, putUrl }) => { const snapshot = snapshots.find((s) => s.pwTrace && s.pwTrace.hash === key); if (!snapshot || !snapshot.pwTrace) throw new Error(`Invariant: trace with ${key} not found`); return { url: putUrl, path: snapshot.pwTrace.path, contentType: "application/json" }; }) ?? []]); debug("Updating build"); const uploadBuildResponse = await apiClient.PUT("/builds/{buildId}", { params: { path: { buildId: result.build.id } }, body: { screenshots: snapshots.map((snapshot) => ({ key: snapshot.hash, name: snapshot.name, metadata: snapshot.metadata, pwTraceKey: snapshot.pwTrace?.hash ?? null, threshold: snapshot.threshold ?? config?.threshold ?? null, baseName: snapshot.baseName, parentName: snapshot.parentName, contentType: snapshot.contentType })), parallel: config.parallel, parallelTotal: config.parallelTotal, parallelIndex: config.parallelIndex, metadata: params.metadata } }); if (uploadBuildResponse.error) throwAPIError(uploadBuildResponse.error); return { build: uploadBuildResponse.data.build, screenshots: snapshots }; } async function uploadFilesToS3(files) { debug(`Split files in chunks of ${CHUNK_SIZE}`); const chunks = chunk(files, CHUNK_SIZE); debug(`Starting upload of ${chunks.length} chunks`); for (let i = 0; i < chunks.length; i++) { debug(`Uploading chunk ${i + 1}/${chunks.length}`); const timeLabel = `Chunk ${i + 1}/${chunks.length}`; debugTime(timeLabel); const chunk = chunks[i]; if (!chunk) throw new Error(`Invariant: chunk ${i} is empty`); await Promise.all(chunk.map(async ({ url, path, contentType }) => { await uploadFile({ url, path, contentType }); })); debugTimeEnd(timeLabel); } } /** * Format the preview URL. */ function formatPreviewUrl(url, formatter) { if (typeof formatter === "function") return formatter(url); const urlObj = new URL(url); return new URL(urlObj.pathname + urlObj.search + urlObj.hash, formatter.baseUrl).href; } //#endregion export { deploy, finalize, getConfigFromOptions, readConfig, skip, upload };