@argos-ci/core
Version:
Node.js SDK for visual testing with Argos.
1,553 lines (1,548 loc) • 48.9 kB
JavaScript
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 };