UNPKG

cypress-cloud

Version:
1,742 lines (1,703 loc) 312 kB
// index.ts import "source-map-support/register.js"; // lib/require.ts import { createRequire } from "module"; var require2 = createRequire(import.meta.url); // lib/stdout.ts import cp from "child_process"; var orginal = cp.spawn; cp.spawn = function(command, args, options) { if (command.match(/Cypress/)) { const process2 = orginal(command, args, { ...options, // using pipe enables capturing stdout and stderr stdio: ["pipe", "pipe", "pipe"] }); return process2; } return orginal(command, args, options); }; // lib/ws/ws.ts import Debug from "debug"; import http from "http"; import HttpTerminator from "lil-http-terminator"; import { match, P } from "ts-pattern"; import * as WebSocket from "ws"; // lib/pubsub.ts import EventEmitter from "events"; var pubsub = new EventEmitter(); // lib/ws/ws.ts var debug = Debug("currents:ws"); var server = null; var wss = null; var httpTerminator = null; var getWSSPort = () => match(server?.address()).with({ port: P.number }, (address) => address.port).otherwise(() => 0); var stopWSS = async () => { debug("terminating wss server: %d", getWSSPort()); if (!httpTerminator) { debug("no wss server"); return; } const { success, code, message, error: error2 } = await httpTerminator.terminate(); if (!success) { if (code === "TIMED_OUT") error2(message); if (code === "SERVER_ERROR") error2(message, error2); if (code === "INTERNAL_ERROR") error2(message, error2); } debug("terminated wss server: %d", getWSSPort()); }; var startWSS = () => { if (wss) { return; } server = http.createServer().on("listening", () => { if (!server) { throw new Error("Server not initialized"); } wss = new WebSocket.WebSocketServer({ server }); debug("starting wss on port %d", getWSSPort()); wss.on("connection", function connection(ws) { ws.on("message", function incoming(event) { const message = JSON.parse(event.toString()); pubsub.emit(message.type, message.payload); }); }); }).listen(); httpTerminator = HttpTerminator({ server }); }; // lib/capture.ts import Debug2 from "debug"; var debug2 = Debug2("currents:capture"); var _write = process.stdout.write; var _log = process.log; var restore = function() { process.stdout.write = _write; process.log = _log; }; var stdout = function() { debug2("capturing stdout"); let logs = []; const { write } = process.stdout; const { log: log2 } = process; if (log2) { process.log = function(str) { logs.push(str); return log2.apply(this, arguments); }; } process.stdout.write = function(str) { logs.push(str); return write.apply(this, arguments); }; return { toString() { return logs.join(""); }, data: logs, restore, reset: () => { debug2("resetting captured stdout"); logs = []; } }; }; var initialOutput = ""; var capturedOutput = null; var initCapture = () => capturedOutput = stdout(); var cutInitialOutput = () => { if (!capturedOutput) throw new Error("capturedOutput is null"); initialOutput = capturedOutput.toString(); capturedOutput.reset(); }; var resetCapture = () => { if (!capturedOutput) throw new Error("capturedOutput is null"); capturedOutput.reset(); }; var getCapturedOutput = () => { if (!capturedOutput) throw new Error("capturedOutput is null"); return capturedOutput.toString(); }; var getInitialOutput = () => initialOutput; // lib/httpClient/config.ts import { isAxiosError } from "axios"; var isRetriableError = (err) => { if (err.code === "ECONNABORTED") { return true; } if (err.code === "ECONNREFUSED") { return true; } if (err.code === "ETIMEDOUT") { return true; } if (!isAxiosError(err)) { return false; } return !!(err?.response?.status && 500 <= err.response.status && err.response.status < 600); }; var getDelay = (i) => [5 * 1e3, 10 * 1e3, 30 * 1e3][i - 1]; var baseURL = "https://cy.currents.dev"; var getAPIBaseUrl = () => baseURL ?? "https://cy.currents.dev"; var setAPIBaseUrl = (url) => baseURL = url ?? "https://cy.currents.dev"; // lib/httpClient/httpClient.ts import axios from "axios"; import axiosRetry from "axios-retry"; import Debug7 from "debug"; import _5 from "lodash"; import prettyMilliseconds from "pretty-ms"; // lib/config/config.ts import Debug5 from "debug"; import { P as P2, match as match2 } from "ts-pattern"; // lib/bootstrap/bootstrap.ts import { getBinPath } from "cy2"; import Debug4 from "debug"; import execa from "execa"; import fs from "fs"; // lib/errors.ts var ValidationError = class extends Error { constructor(message) { super(message); this.name = ""; } }; // lib/fs.ts import { file } from "tmp-promise"; var createTempFile = async () => { const { path: path4 } = await file(); return path4; }; // lib/log.ts import chalk from "chalk"; import util from "util"; var log = (...args) => console.log(util.format(...args)); var info = log; var format = util.format; var withError = (msg) => chalk.bgRed.white(" ERROR ") + " " + msg; var withWarning = (msg) => chalk.bgYellow.black(" WARNING ") + " " + msg; var warn = (...args) => log(withWarning(util.format(...args))); var error = (...args) => log(withError(util.format(...args)) + "\n"); var title = (color, ...args) => info("\n " + chalk[color].bold(util.format(...args)) + " \n"); var divider = () => console.log("\n" + chalk.gray(Array(100).fill("=").join("")) + "\n"); var spacer = (n = 0) => console.log(Array(n).fill("").join("\n")); var cyan = chalk.cyan; var blue = chalk.blueBright; var red = chalk.red; var green = chalk.greenBright; var gray = chalk.gray; var white = chalk.white; var magenta = chalk.magenta; var bold = chalk.bold; var yellow = chalk.yellow; var dim = chalk.dim; // lib/bootstrap/serializer.ts import Debug3 from "debug"; import _ from "lodash"; import { customAlphabet } from "nanoid"; var debug3 = Debug3("currents:boot"); var getDummySpec = customAlphabet("abcdefghijklmnopqrstuvwxyz", 10); function getBootstrapArgs({ params, tempFilePath }) { return _.chain(getCypressCLIParams(params)).thru((opts) => ({ ...opts, // merge the env with the currents specific env variables env: { ...opts.env ?? {}, currents_temp_file: tempFilePath, currents_debug_enabled: process.env.DEBUG?.includes("currents:") ? true : false } })).tap((opts) => { debug3("cypress bootstrap params: %o", opts); }).thru(serializeOptions).tap((opts) => { debug3("cypress bootstrap serialized params: %o", opts); }).thru((args) => { return [ ...args, "--spec", getDummySpec(), params.testingType === "component" ? "--component" : "--e2e" ]; }).value(); } function getCypressCLIParams(params) { const result = getCypressRunAPIParams(params); const testingType = result.testingType === "component" ? { component: true } : {}; return { ..._.omit(result, "testingType"), ...testingType }; } function serializeOptions(options) { return Object.entries(options).flatMap(([key, value]) => { const _key = dashed(key); if (typeof value === "boolean") { return value === true ? [`--${_key}`] : [`--${_key}`, false]; } if (_.isObject(value)) { return [`--${_key}`, serializeComplexParam(value)]; } return [`--${_key}`, value.toString()]; }); } function serializeComplexParam(param) { return JSON.stringify(param); } var dashed = (v) => v.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase()); // lib/bootstrap/bootstrap.ts var debug4 = Debug4("currents:boot"); var bootCypress = async (params) => { debug4("booting cypress..."); const tempFilePath = await createTempFile(); const cypressBin = await getBinPath(require2.resolve("cypress")); debug4("cypress executable location: %s", cypressBin); const args = getBootstrapArgs({ tempFilePath, params }); debug4("booting cypress with args: %o", args); const { stdout: stdout2, stderr } = await execCypress(cypressBin, args); if (!fs.existsSync(tempFilePath)) { throw new Error( `Cannot resolve cypress configuration from ${tempFilePath}. Please report the issue.` ); } try { const f = fs.readFileSync(tempFilePath, "utf-8"); if (!f) { throw new Error("Is cypress-cloud/plugin installed?"); } debug4("cypress config '%s': '%s'", tempFilePath, f); return JSON.parse(f); } catch (err) { debug4("read config temp file failed: %o", err); info(bold("Cypress stdout:\n"), stdout2); info(bold("Cypress stderr:\n"), stderr); throw new ValidationError(`Unable to resolve cypress configuration - make sure that 'cypress-cloud/plugin' is installed - report the issue together with cypress stdout and stderr `); } }; async function execCypress(cypressBin, args) { let stdout2 = ""; let stderr = ""; try { await execa(cypressBin, ["run", ...args], { stdio: "pipe", env: { ...process.env, // prevent warnings about recording mode CYPRESS_RECORD_KEY: void 0, CYPRESS_PROJECT_ID: void 0 } }); } catch (err) { debug4("exec cypress failed (certain failures are expected): %o", err); stdout2 = err.stdout; stderr = err.stderr; } return { stdout: stdout2, stderr }; } // lib/config/path.ts import isAbsolute from "is-absolute"; import _2 from "lodash"; import path from "path"; var defaultFilenames = [ "currents.config.js", "currents.config.cjs", "currents.config.mjs" ]; function getConfigFilePath(projectRoot = null, explicitConfigFilePath) { const prefix = projectRoot ?? process.cwd(); if (_2.isString(explicitConfigFilePath) && isAbsolute(explicitConfigFilePath)) { return [explicitConfigFilePath]; } if (_2.isString(explicitConfigFilePath)) { return [normalizePath(prefix, explicitConfigFilePath)]; } return defaultFilenames.map((p) => normalizePath(prefix, p)); } function normalizePath(prefix, filename) { return `file://${path.resolve(prefix, filename)}`; } // lib/config/config.ts var debug5 = Debug5("currents:config"); var _config = null; var defaultConfig = { e2e: { batchSize: 3 }, component: { batchSize: 5 }, cloudServiceUrl: "https://cy.currents.dev", networkHeaders: void 0 }; async function getCurrentsConfig(projectRoot, explicitConfigFilePath) { if (_config) { return _config; } const configFilePath = getConfigFilePath(projectRoot, explicitConfigFilePath); for (const filepath of configFilePath) { const config = match2(await loadConfigFile(filepath)).with({ default: P2.not(P2.nullish) }, (c) => c.default).with(P2.not(P2.nullish), (c) => c).otherwise(() => null); if (config) { debug5("loaded currents config from '%s'\n%O", filepath, config); info("Using config file: '%s'", filepath); _config = { ...defaultConfig, ...config }; return _config; } } warn( "Failed to load config file, falling back to the default config. Attempted locations: %s", configFilePath ); _config = defaultConfig; return _config; } async function loadConfigFile(filepath) { try { debug5("loading currents config file from '%s'", filepath); return await import(filepath); } catch (e) { debug5("failed loading config file from: %s", e); return null; } } async function getMergedConfig(params) { debug5("resolving cypress config"); const cypressResolvedConfig = await bootCypress(params); debug5("cypress resolvedConfig: %O", cypressResolvedConfig); const rawE2EPattern = cypressResolvedConfig.rawJson?.e2e?.specPattern; let additionalIgnorePattern = []; if (params.testingType === "component" && rawE2EPattern) { additionalIgnorePattern = rawE2EPattern; } const result = { projectRoot: cypressResolvedConfig?.projectRoot || process.cwd(), projectId: params.projectId, specPattern: cypressResolvedConfig?.specPattern || "**/*.*", excludeSpecPattern: ( // @ts-ignore cypressResolvedConfig?.resolved.excludeSpecPattern.value ?? [] ), additionalIgnorePattern, resolved: cypressResolvedConfig, experimentalCoverageRecording: params.experimentalCoverageRecording }; debug5("merged config: %O", result); return result; } // lib/config/params.ts import Debug6 from "debug"; import _3 from "lodash"; var debug6 = Debug6("currents:validateParams"); async function resolveCurrentsParams(params) { const configFromFile = await getCurrentsConfig( params.project, params.cloudConfigFile ); debug6("resolving currents params: %o", params); debug6("resolving currents config file: %o", configFromFile); const cloudServiceUrl = params.cloudServiceUrl ?? process.env.CURRENTS_API_URL ?? configFromFile.cloudServiceUrl; const recordKey = params.recordKey ?? process.env.CURRENTS_RECORD_KEY ?? configFromFile.recordKey; const projectId = params.projectId ?? process.env.CURRENTS_PROJECT_ID ?? configFromFile.projectId; const testingType = params.testingType ?? "e2e"; const batchSize = testingType === "e2e" ? configFromFile.e2e.batchSize : configFromFile.component.batchSize; return { ...params, cloudServiceUrl, recordKey, projectId, batchSize, testingType }; } var projectIdError = `Cannot resolve projectId. Please use one of the following: - provide it as a "projectId" property for "run" API method - set CURRENTS_PROJECT_ID environment variable - set "projectId" in "currents.config.{c}js" file`; var cloudServiceUrlError = `Cannot resolve cloud service URL. Please use one of the following: - provide it as a "cloudServiceUrl" property for "run" API method - set CURRENTS_API_URL environment variable - set "cloudServiceUrl" in "currents.config.{c}js" file`; var cloudServiceInvalidUrlError = `Invalid cloud service URL provided`; var recordKeyError = `Cannot resolve record key. Please use one of the following: - pass it as a CLI flag '-k, --key <record-key>' - provide it as a "recordKey" property for "run" API method - set CURRENTS_RECORD_KEY environment variable - set "recordKey" in "currents.config.{c}js" file `; async function validateParams(_params) { const params = await resolveCurrentsParams(_params); debug6("validating currents params: %o", params); if (!params.cloudServiceUrl) { throw new ValidationError(cloudServiceUrlError); } if (!params.projectId) { throw new ValidationError(projectIdError); } if (!params.recordKey) { throw new ValidationError(recordKeyError); } validateURL(params.cloudServiceUrl); const requiredParameters = [ "testingType", "batchSize", "projectId" ]; requiredParameters.forEach((key) => { if (typeof params[key] === "undefined") { error('Missing required parameter "%s"', key); throw new Error("Missing required parameter"); } }); params.tag = parseTags(params.tag); params.autoCancelAfterFailures = getAutoCancelValue( params.autoCancelAfterFailures ); debug6("validated currents params: %o", params); return params; } function getAutoCancelValue(value) { if (typeof value === "undefined") { return void 0; } if (typeof value === "boolean") { return value ? 1 : false; } if (typeof value === "number" && value > 0) { return value; } throw new ValidationError( `autoCancelAfterFailures: should be a positive integer or "false". Got: "${value}"` ); } function isOffline(params) { return params.record === false; } function parseTags(tagString) { if (!tagString) { return []; } if (Array.isArray(tagString)) { return tagString.filter(Boolean); } return tagString.split(",").map((tag) => tag.trim()).filter(Boolean); } function validateURL(url) { try { new URL(url); } catch (err) { throw new ValidationError(`${cloudServiceInvalidUrlError}: "${url}"`); } } function getCypressRunAPIParams(params) { return { ..._3.pickBy( _3.omit(params, [ "cloudDebug", "cloudConfigFile", "autoCancelAfterFailures", "cloudServiceUrl", "batchSize", "projectId", "key", "recordKey", "record", "group", "parallel", "tag", "ciBuildId", "spec", "exit", "headless", "experimentalCoverageRecording" ]), Boolean ), record: false }; } function preprocessParams(params) { return { ...params, spec: processSpecParam(params.spec) }; } function processSpecParam(spec) { if (!spec) { return void 0; } if (Array.isArray(spec)) { return _3.flatten(spec.map((i) => i.split(","))); } return spec.split(","); } // lib/httpClient/printErrors.ts import _4 from "lodash"; function maybePrintErrors(err) { if (!err.response?.data || !err.response?.status) { return; } const { message, errors } = err.response.data; switch (err.response.status) { case 401: warn("Received 401 Unauthorized"); break; case 422: spacer(1); warn(...formatGenericError(message, errors)); spacer(1); break; default: break; } } function formatGenericError(message, errors) { if (!_4.isString(message)) { return ["Unexpected error from the cloud service"]; } if (errors?.length === 0) { return [message]; } return [ message, ` ${(errors ?? []).map((e) => ` - ${e}`).join("\n")} ` ]; } // lib/httpClient/httpClient.ts var debug7 = Debug7("currents:api"); var MAX_RETRIES = 3; var TIMEOUT_MS = 30 * 1e3; var _client = null; async function getClient() { if (_client) { return _client; } const currentsConfig = await getCurrentsConfig(); _client = axios.create({ baseURL: getAPIBaseUrl(), timeout: TIMEOUT_MS }); _client.interceptors.request.use((config) => { const ccyVerson = _currentsVersion ?? "0.0.0"; const headers = { ...config.headers, // @ts-ignore "x-cypress-request-attempt": config["axios-retry"]?.retryCount ?? 0, "x-cypress-version": _cypressVersion ?? "0.0.0", "x-ccy-version": ccyVerson, "User-Agent": `cypress-cloud/${ccyVerson}` }; if (_runId) { headers["x-cypress-run-id"] = _runId; } if (!headers["Content-Type"]) { headers["Content-Type"] = "application/json"; } if (currentsConfig.networkHeaders) { const filteredHeaders = _5.omit(currentsConfig.networkHeaders, [ "x-cypress-request-attempt", "x-cypress-version", "x-ccy-version", "x-cypress-run-id", "Content-Type" ]); debug7("using custom network headers: %o", filteredHeaders); Object.assign(headers, filteredHeaders); } const req = { ...config, headers }; debug7("network request: %o", { ..._5.pick(req, "method", "url", "headers"), data: Buffer.isBuffer(req.data) ? "buffer" : req.data }); return req; }); axiosRetry(_client, { retries: MAX_RETRIES, retryCondition: isRetriableError, retryDelay: getDelay, // @ts-ignore onRetry, shouldResetTimeout: true }); return _client; } var _runId = void 0; var setRunId = (runId) => { _runId = runId; }; var _cypressVersion = void 0; var setCypressVersion = (cypressVersion) => { _cypressVersion = cypressVersion; }; var _currentsVersion = void 0; var setCurrentsVersion = (v) => { _currentsVersion = v; }; function onRetry(retryCount, err, config) { warn( "Network request '%s' failed: '%s'. Next attempt is in %s (%d/%d).", `${config.method} ${config.url}`, err.message, prettyMilliseconds(getDelay(retryCount)), retryCount, MAX_RETRIES ); } var makeRequest = async (config) => { return (await getClient())(config).then((res) => { debug7("network response: %o", _5.omit(res, "request", "config")); return res; }).catch((error2) => { maybePrintErrors(error2); throw new ValidationError(error2.message); }); }; // lib/init.ts var cypressPkg = require2("cypress/package.json"); var pkg = require2("cypress-cloud/package.json"); initCapture(); setCypressVersion(cypressPkg.version); setCurrentsVersion(pkg.version); // lib/run.ts import Debug19 from "debug"; import plur from "plur"; // legal.ts function getLegalNotice() { return ` Copyright (C) ${(/* @__PURE__ */ new Date()).getFullYear()} Currents Software Inc https://currents.dev This is free software, and you are welcome to redistribute it under certain conditions. This program comes with no warranty. Parts of this program are MIT licensed. Refer to the license for details https://github.com/currents-dev/cypress-cloud/blob/main/LICENSE.md `; } // lib/api/warnings.ts import _6 from "lodash"; function printWarnings(warnings) { warn("Notice from cloud service:"); warnings.map((w) => { spacer(1); info(magenta.bold(w.message)); Object.entries(_6.omit(w, "message")).map(([key, value]) => { info("- %s: %s", key, value); }); spacer(1); }); } // lib/api/api.ts var createRun = async (payload) => { const response = await makeRequest({ method: "POST", url: "/runs", data: payload }); if ((response.data.warnings?.length ?? 0) > 0) { printWarnings(response.data.warnings); } return response.data; }; var createInstance = async ({ runId, groupId, machineId, platform: platform2 }) => { const response = await makeRequest({ method: "POST", url: `runs/${runId}/instances`, data: { runId, groupId, machineId, platform: platform2 } }); return response.data; }; var createBatchedInstances = async (data) => { const respone = await makeRequest({ method: "POST", url: `runs/${data.runId}/cy/instances`, data }); return respone.data; }; var setInstanceTests = (instanceId, payload) => makeRequest({ method: "POST", url: `instances/${instanceId}/tests`, data: payload }).then((result) => result.data); var updateInstanceResults = (instanceId, payload) => makeRequest({ method: "POST", url: `instances/${instanceId}/results`, data: payload }).then((result) => result.data); var reportInstanceResultsMerged = (instanceId, payload) => makeRequest({ method: "POST", url: `instances/${instanceId}/cy/results`, data: payload }).then((result) => result.data); var updateInstanceStdout = (instanceId, stdout2) => makeRequest({ method: "PUT", url: `instances/${instanceId}/stdout`, data: { stdout: stdout2 } }); // lib/ciProvider/ciProvider.ts import debugFn from "debug"; import _7 from "lodash"; var debug8 = debugFn("currents:ci"); var join = (char, ...pieces) => { return _7.chain(pieces).compact().join(char).value(); }; var toCamelObject = (obj, key) => { return _7.set(obj, _7.camelCase(key), process.env[key]); }; var extract = (envKeys) => { return _7.transform(envKeys, toCamelObject, {}); }; var isTeamFoundation = () => { return process.env.TF_BUILD && process.env.TF_BUILD_BUILDNUMBER; }; var isAzureCi = () => { return process.env.TF_BUILD && process.env.AZURE_HTTP_USER_AGENT; }; var isAWSCodeBuild = () => { return _7.some(process.env, (val, key) => { return /^CODEBUILD_/.test(key); }); }; var isBamboo = () => { return process.env.bamboo_buildNumber; }; var isCodeshipBasic = () => { return process.env.CI_NAME && process.env.CI_NAME === "codeship" && process.env.CODESHIP; }; var isCodeshipPro = () => { return process.env.CI_NAME && process.env.CI_NAME === "codeship" && !process.env.CODESHIP; }; var isConcourse = () => { return _7.some(process.env, (val, key) => { return /^CONCOURSE_/.test(key); }); }; var isGitlab = () => { return process.env.GITLAB_CI || process.env.CI_SERVER_NAME && /^GitLab/.test(process.env.CI_SERVER_NAME); }; var isGoogleCloud = () => { return process.env.GCP_PROJECT || process.env.GCLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT; }; var isJenkins = () => { return process.env.JENKINS_URL || process.env.JENKINS_HOME || process.env.JENKINS_VERSION || process.env.HUDSON_URL || process.env.HUDSON_HOME; }; var isWercker = () => { return process.env.WERCKER || process.env.WERCKER_MAIN_PIPELINE_STARTED; }; var CI_PROVIDERS = { appveyor: "APPVEYOR", azure: isAzureCi, awsCodeBuild: isAWSCodeBuild, bamboo: isBamboo, bitbucket: "BITBUCKET_BUILD_NUMBER", buildkite: "BUILDKITE", circle: "CIRCLECI", codeshipBasic: isCodeshipBasic, codeshipPro: isCodeshipPro, concourse: isConcourse, codeFresh: "CF_BUILD_ID", drone: "DRONE", githubActions: "GITHUB_ACTIONS", gitlab: isGitlab, goCD: "GO_JOB_NAME", googleCloud: isGoogleCloud, jenkins: isJenkins, semaphore: "SEMAPHORE", shippable: "SHIPPABLE", teamcity: "TEAMCITY_VERSION", teamfoundation: isTeamFoundation, travis: "TRAVIS", wercker: isWercker, netlify: "NETLIFY", layerci: "LAYERCI" }; function _detectProviderName() { const { env } = process; return _7.findKey(CI_PROVIDERS, (value) => { if (_7.isString(value)) { return env[value]; } if (_7.isFunction(value)) { return value(); } }); } var _providerCiParams = () => { return { appveyor: extract([ "APPVEYOR_JOB_ID", "APPVEYOR_ACCOUNT_NAME", "APPVEYOR_PROJECT_SLUG", "APPVEYOR_BUILD_NUMBER", "APPVEYOR_BUILD_VERSION", "APPVEYOR_PULL_REQUEST_NUMBER", "APPVEYOR_PULL_REQUEST_HEAD_REPO_BRANCH" ]), azure: extract([ "BUILD_BUILDID", "BUILD_BUILDNUMBER", "BUILD_CONTAINERID", "BUILD_REPOSITORY_URI" ]), awsCodeBuild: extract([ "CODEBUILD_BUILD_ID", "CODEBUILD_BUILD_NUMBER", "CODEBUILD_RESOLVED_SOURCE_VERSION", "CODEBUILD_SOURCE_REPO_URL", "CODEBUILD_SOURCE_VERSION" ]), bamboo: extract([ "bamboo_buildNumber", "bamboo_buildResultsUrl", "bamboo_planRepository_repositoryUrl", "bamboo_buildKey" ]), bitbucket: extract([ "BITBUCKET_REPO_SLUG", "BITBUCKET_REPO_OWNER", "BITBUCKET_BUILD_NUMBER", "BITBUCKET_PARALLEL_STEP", "BITBUCKET_STEP_RUN_NUMBER", // the PR variables are only set on pull request builds "BITBUCKET_PR_ID", "BITBUCKET_PR_DESTINATION_BRANCH", "BITBUCKET_PR_DESTINATION_COMMIT" ]), buildkite: extract([ "BUILDKITE_REPO", "BUILDKITE_SOURCE", "BUILDKITE_JOB_ID", "BUILDKITE_BUILD_ID", "BUILDKITE_BUILD_URL", "BUILDKITE_BUILD_NUMBER", "BUILDKITE_PULL_REQUEST", "BUILDKITE_PULL_REQUEST_REPO", "BUILDKITE_PULL_REQUEST_BASE_BRANCH" ]), circle: extract([ "CIRCLE_JOB", "CIRCLE_BUILD_NUM", "CIRCLE_BUILD_URL", "CIRCLE_PR_NUMBER", "CIRCLE_PR_REPONAME", "CIRCLE_PR_USERNAME", "CIRCLE_COMPARE_URL", "CIRCLE_WORKFLOW_ID", "CIRCLE_PULL_REQUEST", "CIRCLE_REPOSITORY_URL", "CI_PULL_REQUEST" ]), codeshipBasic: extract([ "CI_BUILD_ID", "CI_REPO_NAME", "CI_BUILD_URL", "CI_PROJECT_ID", "CI_BUILD_NUMBER", "CI_PULL_REQUEST" ]), // CodeshipPro provides very few CI variables // https://documentation.codeship.com/pro/builds-and-configuration/environment-variables/ codeshipPro: extract(["CI_BUILD_ID", "CI_REPO_NAME", "CI_PROJECT_ID"]), // https://concourse-ci.org/implementing-resource-types.html#resource-metadata concourse: extract([ "BUILD_ID", "BUILD_NAME", "BUILD_JOB_NAME", "BUILD_PIPELINE_NAME", "BUILD_TEAM_NAME", "ATC_EXTERNAL_URL" ]), // https://codefresh.io/docs/docs/codefresh-yaml/variables/ codeFresh: extract([ "CF_BUILD_ID", "CF_BUILD_URL", "CF_CURRENT_ATTEMPT", "CF_STEP_NAME", "CF_PIPELINE_NAME", "CF_PIPELINE_TRIGGER_ID", // variables added for pull requests "CF_PULL_REQUEST_ID", "CF_PULL_REQUEST_IS_FORK", "CF_PULL_REQUEST_NUMBER", "CF_PULL_REQUEST_TARGET" ]), drone: extract([ "DRONE_JOB_NUMBER", "DRONE_BUILD_LINK", "DRONE_BUILD_NUMBER", "DRONE_PULL_REQUEST" ]), // https://help.github.com/en/actions/automating-your-workflow-with-github-actions/using-environment-variables#default-environment-variables githubActions: extract([ "GITHUB_WORKFLOW", "GITHUB_ACTION", "GITHUB_EVENT_NAME", "GITHUB_RUN_ID", "GITHUB_RUN_ATTEMPT", "GITHUB_REPOSITORY" ]), // see https://docs.gitlab.com/ee/ci/variables/ gitlab: extract([ // pipeline is common among all jobs "CI_PIPELINE_ID", "CI_PIPELINE_URL", // individual jobs "CI_BUILD_ID", // build id and job id are aliases "CI_JOB_ID", "CI_JOB_URL", "CI_JOB_NAME", // other information "GITLAB_HOST", "CI_PROJECT_ID", "CI_PROJECT_URL", "CI_REPOSITORY_URL", "CI_ENVIRONMENT_URL", "CI_DEFAULT_BRANCH" // for PRs: https://gitlab.com/gitlab-org/gitlab-ce/issues/23902 ]), // https://docs.gocd.org/current/faq/dev_use_current_revision_in_build.html#standard-gocd-environment-variables goCD: extract([ "GO_SERVER_URL", "GO_ENVIRONMENT_NAME", "GO_PIPELINE_NAME", "GO_PIPELINE_COUNTER", "GO_PIPELINE_LABEL", "GO_STAGE_NAME", "GO_STAGE_COUNTER", "GO_JOB_NAME", "GO_TRIGGER_USER", "GO_REVISION", "GO_TO_REVISION", "GO_FROM_REVISION", "GO_MATERIAL_HAS_CHANGED" ]), googleCloud: extract([ // individual jobs "BUILD_ID", "PROJECT_ID", // other information "REPO_NAME", "BRANCH_NAME", "TAG_NAME", "COMMIT_SHA", "SHORT_SHA" // https://cloud.google.com/cloud-build/docs/api/reference/rest/Shared.Types/Build ]), jenkins: extract(["BUILD_ID", "BUILD_URL", "BUILD_NUMBER", "ghprbPullId"]), // https://semaphoreci.com/docs/available-environment-variables.html // some come from v1, some from v2 of semaphore semaphore: extract([ "SEMAPHORE_BRANCH_ID", "SEMAPHORE_BUILD_NUMBER", "SEMAPHORE_CURRENT_JOB", "SEMAPHORE_CURRENT_THREAD", "SEMAPHORE_EXECUTABLE_UUID", "SEMAPHORE_GIT_BRANCH", "SEMAPHORE_GIT_DIR", "SEMAPHORE_GIT_REF", "SEMAPHORE_GIT_REF_TYPE", "SEMAPHORE_GIT_REPO_SLUG", "SEMAPHORE_GIT_SHA", "SEMAPHORE_GIT_URL", "SEMAPHORE_JOB_COUNT", "SEMAPHORE_JOB_ID", // v2 "SEMAPHORE_JOB_NAME", "SEMAPHORE_JOB_UUID", // v1 "SEMAPHORE_PIPELINE_ID", "SEMAPHORE_PLATFORM", "SEMAPHORE_PROJECT_DIR", "SEMAPHORE_PROJECT_HASH_ID", "SEMAPHORE_PROJECT_ID", // v2 "SEMAPHORE_PROJECT_NAME", "SEMAPHORE_PROJECT_UUID", // v1 "SEMAPHORE_REPO_SLUG", "SEMAPHORE_TRIGGER_SOURCE", "SEMAPHORE_WORKFLOW_ID", "PULL_REQUEST_NUMBER" // pull requests from forks ONLY ]), // see http://docs.shippable.com/ci/env-vars/ shippable: extract([ // build variables "SHIPPABLE_BUILD_ID", // "5b93354cabfabb07007f01fd" "SHIPPABLE_BUILD_NUMBER", // "4" "SHIPPABLE_COMMIT_RANGE", // "sha1...sha2" "SHIPPABLE_CONTAINER_NAME", // "c.exec.cypress-example-kitchensink.4.1" "SHIPPABLE_JOB_ID", // "1" "SHIPPABLE_JOB_NUMBER", // "1" "SHIPPABLE_REPO_SLUG", // "<username>/<repo>" // additional information that Shippable provides "IS_FORK", // "true" "IS_GIT_TAG", // "false" "IS_PRERELEASE", // "false" "IS_RELEASE", // "false" "REPOSITORY_URL", // "https://github.com/....git" "REPO_FULL_NAME", // "<username>/<repo>" "REPO_NAME", // "cypress-example-kitchensink" "BUILD_URL", // "https://app.shippable.com/github/<username>/<repo>/runs/1" // Pull request information "BASE_BRANCH", // Name of the target branch into which the pull request changes will be merged. "HEAD_BRANCH", // This is only set for pull requests and is the name of the branch the pull request was opened from. "IS_PULL_REQUEST", // "false" or "true" "PULL_REQUEST", // Pull request number if the job is a pull request. If not, this will be set to false. "PULL_REQUEST_BASE_BRANCH", // Name of the branch that the pull request will be merged into. It should be the same as BASE_BRANCH. "PULL_REQUEST_REPO_FULL_NAME" // Full name of the repository from where the pull request originated. ]), teamcity: null, teamfoundation: extract([ "BUILD_BUILDID", "BUILD_BUILDNUMBER", "BUILD_CONTAINERID" ]), travis: extract([ "TRAVIS_JOB_ID", "TRAVIS_BUILD_ID", "TRAVIS_BUILD_WEB_URL", "TRAVIS_REPO_SLUG", "TRAVIS_JOB_NUMBER", "TRAVIS_EVENT_TYPE", "TRAVIS_COMMIT_RANGE", "TRAVIS_BUILD_NUMBER", "TRAVIS_PULL_REQUEST", "TRAVIS_PULL_REQUEST_BRANCH", "TRAVIS_PULL_REQUEST_SHA" ]), wercker: null, // https://docs.netlify.com/configure-builds/environment-variables/#deploy-urls-and-metadata netlify: extract([ "BUILD_ID", "CONTEXT", "URL", "DEPLOY_URL", "DEPLOY_PRIME_URL", "DEPLOY_ID" ]), // https://layerci.com/docs/layerfile-reference/build-env layerci: extract([ "LAYERCI_JOB_ID", "LAYERCI_RUNNER_ID", "RETRY_INDEX", "LAYERCI_PULL_REQUEST", "LAYERCI_REPO_NAME", "LAYERCI_REPO_OWNER", "LAYERCI_BRANCH", "GIT_TAG" // short hex for commits ]) }; }; var _providerCommitParams = () => { const { env } = process; return { appveyor: { sha: env.APPVEYOR_REPO_COMMIT, // since APPVEYOR_REPO_BRANCH will be the target branch on a PR // we need to use PULL_REQUEST_HEAD_REPO_BRANCH if it exists. // e.g. if you have a PR: develop <- my-feature-branch // my-feature-branch is APPVEYOR_PULL_REQUEST_HEAD_REPO_BRANCH // develop is APPVEYOR_REPO_BRANCH branch: env.APPVEYOR_PULL_REQUEST_HEAD_REPO_BRANCH || env.APPVEYOR_REPO_BRANCH, message: join( "\n", env.APPVEYOR_REPO_COMMIT_MESSAGE, env.APPVEYOR_REPO_COMMIT_MESSAGE_EXTENDED ), authorName: env.APPVEYOR_REPO_COMMIT_AUTHOR, authorEmail: env.APPVEYOR_REPO_COMMIT_AUTHOR_EMAIL // remoteOrigin: ??? // defaultBranch: ??? }, awsCodeBuild: { sha: env.CODEBUILD_RESOLVED_SOURCE_VERSION, // branch: ???, // message: ??? // authorName: ??? // authorEmail: ??? remoteOrigin: env.CODEBUILD_SOURCE_REPO_URL // defaultBranch: ??? }, azure: { sha: env.BUILD_SOURCEVERSION, branch: env.BUILD_SOURCEBRANCHNAME, message: env.BUILD_SOURCEVERSIONMESSAGE, authorName: env.BUILD_SOURCEVERSIONAUTHOR, authorEmail: env.BUILD_REQUESTEDFOREMAIL }, bamboo: { sha: env.bamboo_planRepository_revision, branch: env.bamboo_planRepository_branch, // message: ??? authorName: env.bamboo_planRepository_username, // authorEmail: ??? remoteOrigin: env.bamboo_planRepository_repositoryURL // defaultBranch: ??? }, bitbucket: { sha: env.BITBUCKET_COMMIT, branch: env.BITBUCKET_BRANCH // message: ??? // authorName: ??? // authorEmail: ??? // remoteOrigin: ??? // defaultBranch: ??? }, buildkite: { sha: env.BUILDKITE_COMMIT, branch: env.BUILDKITE_BRANCH, message: env.BUILDKITE_MESSAGE, authorName: env.BUILDKITE_BUILD_CREATOR, authorEmail: env.BUILDKITE_BUILD_CREATOR_EMAIL, remoteOrigin: env.BUILDKITE_REPO, defaultBranch: env.BUILDKITE_PIPELINE_DEFAULT_BRANCH }, circle: { sha: env.CIRCLE_SHA1, branch: env.CIRCLE_BRANCH, // message: ??? authorName: env.CIRCLE_USERNAME, // authorEmail: ??? remoteOrigin: env.CIRCLE_REPOSITORY_URL // defaultBranch: ??? }, codeshipBasic: { sha: env.CI_COMMIT_ID, branch: env.CI_BRANCH, message: env.CI_COMMIT_MESSAGE, authorName: env.CI_COMMITTER_NAME, authorEmail: env.CI_COMMITTER_EMAIL // remoteOrigin: ??? // defaultBranch: ??? }, codeshipPro: { sha: env.CI_COMMIT_ID, branch: env.CI_BRANCH, message: env.CI_COMMIT_MESSAGE, authorName: env.CI_COMMITTER_NAME, authorEmail: env.CI_COMMITTER_EMAIL // remoteOrigin: ??? // defaultBranch: ??? }, codeFresh: { sha: env.CF_REVISION, branch: env.CF_BRANCH, message: env.CF_COMMIT_MESSAGE, authorName: env.CF_COMMIT_AUTHOR }, drone: { sha: env.DRONE_COMMIT_SHA, // https://docs.drone.io/pipeline/environment/reference/drone-source-branch/ branch: env.DRONE_SOURCE_BRANCH, message: env.DRONE_COMMIT_MESSAGE, authorName: env.DRONE_COMMIT_AUTHOR, authorEmail: env.DRONE_COMMIT_AUTHOR_EMAIL, remoteOrigin: env.DRONE_GIT_HTTP_URL, defaultBranch: env.DRONE_REPO_BRANCH }, githubActions: { sha: env.GITHUB_SHA, branch: env.GH_BRANCH || env.GITHUB_REF, defaultBranch: env.GITHUB_BASE_REF, remoteBranch: env.GITHUB_HEAD_REF, runAttempt: env.GITHUB_RUN_ATTEMPT }, gitlab: { sha: env.CI_COMMIT_SHA, branch: env.CI_COMMIT_REF_NAME, message: env.CI_COMMIT_MESSAGE, authorName: env.GITLAB_USER_NAME, authorEmail: env.GITLAB_USER_EMAIL, remoteOrigin: env.CI_REPOSITORY_URL, defaultBranch: env.CI_DEFAULT_BRANCH }, googleCloud: { sha: env.COMMIT_SHA, branch: env.BRANCH_NAME // message: ?? // authorName: ?? // authorEmail: ?? // remoteOrigin: ??? // defaultBranch: ?? }, jenkins: { sha: env.GIT_COMMIT, branch: env.GIT_BRANCH // message: ??? // authorName: ??? // authorEmail: ??? // remoteOrigin: ??? // defaultBranch: ??? }, // Only from forks? https://semaphoreci.com/docs/available-environment-variables.html semaphore: { sha: env.SEMAPHORE_GIT_SHA, branch: env.SEMAPHORE_GIT_BRANCH, // message: ??? // authorName: ??? // authorEmail: ??? remoteOrigin: env.SEMAPHORE_GIT_REPO_SLUG // defaultBranch: ??? }, shippable: { sha: env.COMMIT, branch: env.BRANCH, message: env.COMMIT_MESSAGE, authorName: env.COMMITTER // authorEmail: ??? // remoteOrigin: ??? // defaultBranch: ??? }, snap: null, teamcity: null, teamfoundation: { sha: env.BUILD_SOURCEVERSION, branch: env.BUILD_SOURCEBRANCHNAME, message: env.BUILD_SOURCEVERSIONMESSAGE, authorName: env.BUILD_SOURCEVERSIONAUTHOR }, travis: { sha: env.TRAVIS_PULL_REQUEST_SHA || env.TRAVIS_COMMIT, // for PRs, TRAVIS_BRANCH is the base branch being merged into branch: env.TRAVIS_PULL_REQUEST_BRANCH || env.TRAVIS_BRANCH, // authorName: ??? // authorEmail: ??? message: env.TRAVIS_COMMIT_MESSAGE // remoteOrigin: ??? // defaultBranch: ??? }, wercker: null, netlify: { sha: env.COMMIT_REF, branch: env.BRANCH, remoteOrigin: env.REPOSITORY_URL }, layerci: { sha: env.GIT_COMMIT, branch: env.LAYERCI_BRANCH, message: env.GIT_COMMIT_TITLE } }; }; var _get = (fn) => { const providerName = getCiProvider(); if (!providerName) return {}; return _7.chain(fn()).get(providerName).value(); }; function checkForCiBuildFromCi(ciProvider) { if (ciProvider && detectableCiBuildIdProviders().includes(ciProvider)) return true; throw new ValidationError( `Could not determine CI build ID from the environment. Please provide a unique CI build ID using the --ci-build-id CLI flag or 'ciBuildId' parameter for 'run' method.` ); } function detectableCiBuildIdProviders() { return _7.chain(_providerCiParams()).omitBy(_7.isNull).keys().value(); } function getCiProvider() { return _detectProviderName() || null; } function getCiParams() { return _get(_providerCiParams); } function getCommitParams() { return _get(_providerCommitParams); } function getCI(ciBuildId) { const params = getCiParams(); const provider = getCiProvider(); if (!ciBuildId) checkForCiBuildFromCi(provider); debug8("detected CI provider: %s", provider); debug8("detected CI params: %O", params); return { params, provider }; } // lib/ciProvider/merge.ts import debugFn2 from "debug"; import _8 from "lodash"; var debug9 = debugFn2("currents:ci"); function getCommitDefaults(existingInfo) { debug9("git commit existing info"); debug9(existingInfo); const commitParamsObj = getCommitParams(); debug9("commit info from provider environment variables: %O", commitParamsObj); const combined = _8.transform( existingInfo, (memo, value, key) => { return memo[key] = _8.defaultTo( value || (commitParamsObj ? commitParamsObj[key] : null), null ); } ); debug9("combined git and environment variables from provider"); debug9(combined); return combined; } // lib/coverage/index.ts import fs2 from "fs/promises"; import { join as join2 } from "path"; var getCoverageFilePath = async (coverageFile = "./.nyc_output/out.json") => { const path4 = join2(process.cwd(), coverageFile); try { await fs2.access(path4); return { path: path4, error: false }; } catch (error2) { return { path: path4, error: error2 }; } }; // lib/cypress/cypress.ts import cypress from "cypress"; import Debug8 from "debug"; import _9 from "lodash"; // lib/lang.ts import bluebird from "bluebird"; bluebird.Promise.config({ cancellation: true }); var BPromise = bluebird.Promise; var safe = (fn, ifFaled, ifSucceed) => async (...args) => { try { const r = await fn(...args); ifSucceed(); return r; } catch (e) { return ifFaled(e); } }; // lib/cypress/cypress.ts var debug10 = Debug8("currents:cypress"); function runBareCypress(params = {}) { const p = { ...params, ciBuildId: void 0, tag: void 0, parallel: void 0, record: false, group: void 0, spec: _9.flatten(params.spec).join(",") }; debug10("Running bare Cypress with params %o", p); return cypress.run(p); } async function runSpecFile({ spec }, cypressRunOptions) { const runAPIOptions = getCypressRunAPIParams(cypressRunOptions); const options = { ...runAPIOptions, config: { ...runAPIOptions.config, trashAssetsBeforeRuns: false }, env: { ...runAPIOptions.env, currents_ws: getWSSPort() }, spec }; debug10("running cypress with options %o", options); let result = await cypress.run(options); let retries = 0; const currentsConfig = await getCurrentsConfig(); while (currentsConfig.retry && retries < (currentsConfig.retry.hardFailureMaxRetries ?? 0) && result.status === "failed") { warn("Cypress runner failed with message: %s", result.message); warn( "[retry %d/%d] Retrying the following spec files because of retry config: %s", retries + 1, currentsConfig.retry.hardFailureMaxRetries, spec.split(",").map((i) => ` - ${i}`).join("") ); result = await cypress.run(options); retries++; } if (currentsConfig.retry && retries > 0) { warn( "Exhausted max retries: %d/%d", retries, currentsConfig.retry.hardFailureMaxRetries ); } if (result.status === "failed") { warn('Cypress runner failed with message: "%s"', result.message); warn( "The following spec files will be marked as failed: %s", spec.split(",").map((i) => ` - ${i}`).join("") ); } debug10("cypress run result %o", result); return result; } var runSpecFileSafe = (spec, cypressRunOptions) => safe( runSpecFile, (error2) => { const message = `Cypress runnner crashed with an error: ${error2.message} ${error2.stack}}`; debug10("cypress run exception %o", error2); warn('Cypress runner crashed: "%s"', message); warn( "The following spec files will be marked as failed: %s", spec.spec.split(",").map((i) => ` - ${i}`).join("") ); return { status: "failed", failures: 1, message }; }, () => { } )(spec, cypressRunOptions); // lib/debug/index.ts import debug11 from "debug"; import { match as match3, P as P3 } from "ts-pattern"; function activateDebug(mode) { match3(mode).with(P3.instanceOf(Array), (i) => i.forEach(setDebugMode)).with(true, () => setDebugMode("all" /* All */)).with( P3.union( "all" /* All */, "currents" /* Currents */, "cypress" /* Cypress */, "commit-info" /* CommitInfo */ ), (i) => setDebugMode(i) ).otherwise(() => setDebugMode("none" /* None */)); } function setDebugMode(mode) { if (mode === "none" /* None */) { return; } const tokens = new Set(process.env.DEBUG ? process.env.DEBUG.split(",") : []); match3(mode).with("all" /* All */, () => { tokens.add("commit-info" /* CommitInfo */); tokens.add("currents:*" /* Currents */); tokens.add("cypress:*" /* Cypress */); }).with("currents" /* Currents */, () => tokens.add("currents:*" /* Currents */)).with("cypress" /* Cypress */, () => tokens.add("cypress:*" /* Cypress */)).with("commit-info" /* CommitInfo */, () => tokens.add("commit-info" /* CommitInfo */)).otherwise(() => { }); debug11.enable(Array.from(tokens).join(",")); } // lib/env.ts var isCurrents = () => !!process.env.CURRENTS_ENFORCE_IS_CURRENTS || getAPIBaseUrl() === "https://cy.currents.dev"; // lib/git.ts import { commitInfo } from "@currents/commit-info"; var getGitInfo = async (projectRoot) => { const commit = await commitInfo(projectRoot); return getCommitDefaults({ branch: commit.branch, remoteOrigin: commit.remote, authorEmail: commit.email, authorName: commit.author, message: commit.message, sha: commit.sha, ghaEventData: commit.ghaEventData }); }; // lib/platform/browser.ts import Debug9 from "debug"; var debug12 = Debug9("currents:browser"); function guessBrowser(browser, availableBrowsers = []) { debug12( "guessing browser from '%s', available browsers: %o", browser, availableBrowsers ); let result = availableBrowsers.find((b) => b.name === browser); if (result) { debug12("identified browser by name: %o", result); return { browserName: result.displayName, browserVersion: result.version }; } result = availableBrowsers.find((b) => b.path === browser); if (result) { debug12("identified browser by path: %o", result); return { browserName: result.displayName ?? result.name, browserVersion: result.version }; } warn("Unable to identify browser name and version"); return { browserName: "unknown", browserVersion: "unknown" }; } // lib/platform/platform.ts import Debug10 from "debug"; import getos from "getos"; import { cpus, freemem, platform, release, totalmem } from "os"; import { promisify } from "util"; var debug13 = Debug10("currents:platform"); var getOsVersion = async () => { if (platform() === "linux") { try { const linuxOs = await promisify(getos)(); if ("dist" in linuxOs && "release" in linuxOs) { return [linuxOs.dist, linuxOs.release].join(" - "); } else { return release(); } } catch { return release(); } } return release(); }; var getPlatformInfo = async () => { const osVersion = await getOsVersion(); const result = { osName: platform(), osVersion, osCpus: cpus(), osMemory: { free: freemem(), total: totalmem() } }; debug13("platform info: %o", result); return result; }; // lib/platform/index.ts async function getPlatform({ browser, config }) { return { ...await getPlatformInfo(), ...guessBrowser(browser ?? "electron", config.resolved?.browsers) }; } // lib/results/results.ts import Debug11 from "debug"; import _10 from "lodash"; import { nanoid } from "nanoid"; var debug14 = Debug11("currents:results"); var isSuccessResult = (result) => { return result.status === "finished"; }; var getScreenshotsSummary = (tests = []) => { return tests.flatMap( (test, i) => test.attempts.flatMap( (a, ai) => a.screenshots.flatMap((s) => ({ ...s, testId: `r${i}`, testAttemptIndex: ai, screenshotId: nanoid() })) ) ); }; var getStats = (stats) => { return { ...stats, wallClockDuration: stats.duration, wallClockStartedAt: stats.startedAt, wallClockEndedAt: stats.endedAt }; }; var getTestAttempt = (attempt) => { return { ...attempt, state: attempt.state, wallClockDuration: attempt.duration, wallClockStartedAt: attempt.startedAt }; }; var getInstanceResultPayload = (runResult, coverageFilePath) => { return { stats: getStats(runResult.stats), reporterStats: runResult.reporterStats, exception: runResult.error ?? null, video: !!runResult.video, // Did the instance generate a video? screenshots: getScreenshotsSummary(runResult.tests ?? []), hasCoverage: !!coverageFilePath, tests: runResult.tests?.map((test, i) => ({ displayError: test.displayError, state: test.state, hooks: runResult.hooks, attempts: test.attempts?.map(getTestAttempt) ?? [], clientId: `r${i}` })) ?? [] }; }; function getFakeTestFromException(error2, stats) { return { title: ["Unknown"], body: "", displayError: error2.split("\n")[0], state: "failed", hooks: [], attempts: [ getTestAttempt({ state: "failed", duration: 0, error: { name: "Error", message: error2.split("\n