UNPKG

@argos-ci/playwright

Version:

Playwright SDK for visual testing with Argos.

324 lines (323 loc) 11.5 kB
import { createRequire } from "node:module"; import chalk from "chalk"; import { readConfig, upload } from "@argos-ci/core"; import { copyFile, readdir, writeFile } from "node:fs/promises"; import { dirname, join, relative } from "node:path"; import { createDirectory, createTemporaryDirectory, getGitRepositoryPath, readVersionFromPackage } from "@argos-ci/util"; import { AsyncLocalStorage } from "node:async_hooks"; import "@argos-ci/browser"; import createDebug from "debug"; //#region src/metadata.ts const require$1 = createRequire(import.meta.url); /** * Try to resolve a package. */ function tryResolve(pkg) { try { return require$1.resolve(pkg); } catch { return null; } } /** * Private metadata config storage. * Used to inject the metadata from other SDKs like @argos-ci/storybook. */ const metadataConfigStorage = new AsyncLocalStorage(); const DEFAULT_PLAYWRIGHT_LIBRARIES = [ "@playwright/test", "playwright", "playwright-core" ]; /** * Get the name and version of the automation library. */ async function getAutomationLibraryMetadata() { const libraries = metadataConfigStorage.getStore()?.playwrightLibraries ?? DEFAULT_PLAYWRIGHT_LIBRARIES; for (const name of libraries) { const pkgPath = tryResolve(`${name}/package.json`); if (pkgPath) return { version: await readVersionFromPackage(pkgPath), name }; } throw new Error(`Unable to find any of the following packages: ${libraries.join(", ")}`); } /** * Get the version of the Argos Playwright SDK. */ async function getArgosPlaywrightVersion() { return readVersionFromPackage(require$1.resolve("@argos-ci/playwright/package.json")); } /** * Get the name and version of the SDK. */ async function getSdkMetadata() { const metadataConfig = metadataConfigStorage.getStore(); if (metadataConfig) return metadataConfig.sdk; return { name: "@argos-ci/playwright", version: await getArgosPlaywrightVersion() }; } /** * Get the metadata of the automation library and the SDK. */ async function getLibraryMetadata() { const [automationLibrary, sdk] = await Promise.all([getAutomationLibraryMetadata(), getSdkMetadata()]); return { automationLibrary, sdk }; } async function getTestMetadataFromTestCase(testCase, testResult) { const repositoryPath = await getGitRepositoryPath(); return { title: testCase.title, titlePath: testCase.titlePath(), tags: testCase.tags.length > 0 ? testCase.tags : void 0, retry: testResult.retry, retries: testCase.retries, repeat: testCase.repeatEachIndex, location: { file: repositoryPath ? relative(repositoryPath, testCase.location.file) : testCase.location.file, line: testCase.location.line, column: testCase.location.column }, annotations: testCase.annotations }; } async function getMetadataFromTestCase(testCase, testResult) { const [libMetadata, testMetadata] = await Promise.all([getLibraryMetadata(), getTestMetadataFromTestCase(testCase, testResult)]); return { test: testMetadata, ...libMetadata }; } createRequire(import.meta.url); const PNG_EXTENSION = `.png`; const ARIA_EXTENSION = `.aria.yml`; const METADATA_EXTENSION = `.argos.json`; /** * Maximum length for a screenshot name. */ const MAX_NAME_LENGTH = 255 - PNG_EXTENSION.length - METADATA_EXTENSION.length; /** * Truncate a text to a length and add `...` */ function truncate(text, length) { if (text.length <= length) return text; return text.slice(0, length - 1) + "…"; } /** * Get the automatic screenshot name. */ function getAutomaticScreenshotName(test, result) { const name = test.titlePath().join(" "); let suffix = ""; suffix += result.retry > 0 ? ` #${result.retry + 1}` : ""; suffix += result.status === "failed" || result.status === "timedOut" ? " (failed)" : ""; const maxNameLength = MAX_NAME_LENGTH - suffix.length; if (name.length > maxNameLength) return `${truncate(`${test.id} - ${test.title}`, maxNameLength)}${suffix}`; return `${name}${suffix}`; } //#endregion //#region src/attachment.ts function parseAttachmentName(name) { const match = name.match(/^argos\/(screenshot|aria)(\/metadata)?___(.*)$/); if (!match) return null; const [, mainType, metadataPart, originalName] = match; if (!originalName) throw new Error(`Invalid attachment name: ${name}`); return { type: metadataPart ? `${mainType}/metadata` : mainType, originalName }; } function getAttachmentFilename(attachment) { const parsed = parseAttachmentName(attachment.name); if (!parsed) throw new Error(`Invalid attachment name: ${attachment.name}`); const { type, originalName } = parsed; return `${originalName}${{ screenshot: PNG_EXTENSION, aria: ARIA_EXTENSION, "screenshot/metadata": `${PNG_EXTENSION}${METADATA_EXTENSION}`, "aria/metadata": `${ARIA_EXTENSION}${METADATA_EXTENSION}` }[type]}`; } function checkIsTrace(attachment) { return attachment.name === "trace" && attachment.contentType === "application/zip" && Boolean(attachment.path); } function checkIsArgosSnapshot(attachment) { const parsed = parseAttachmentName(attachment.name); if (!parsed) return false; return parsed.type === "aria" || parsed.type === "screenshot"; } function checkIsArgosMetadata(attachment) { const parsed = parseAttachmentName(attachment.name); if (!parsed) return false; return parsed.type === "aria/metadata" || parsed.type === "screenshot/metadata"; } function checkIsAutomaticScreenshot(attachment) { return attachment.name === "screenshot" && attachment.contentType === "image/png" && Boolean(attachment.path); } const debug = createDebug("@argos-ci/playwright"); //#endregion //#region src/reporter.ts /** * Check if the build name is dynamic. */ function checkIsDynamicBuildName(buildName) { return Boolean(typeof buildName === "object" && buildName); } function createArgosReporterOptions(options) { return options; } async function getParallelFromConfig(config) { if (!config.shard) return null; if (config.shard.total === 1) return null; const argosConfig = await readConfig(); if (!argosConfig.parallelNonce) throw new Error("Playwright shard mode detected. Please specify ARGOS_PARALLEL_NONCE env variable. Read /parallel-testing"); return { total: argosConfig.parallelTotal ?? config.shard.total, nonce: argosConfig.parallelNonce, index: argosConfig.parallelIndex ?? config.shard.current }; } var ArgosReporter = class { rootUploadDirectoryPromise; uploadDirectoryPromises; config; playwrightConfig; uploadToArgos; constructor(config) { this.config = config; this.uploadToArgos = config.uploadToArgos ?? true; this.rootUploadDirectoryPromise = null; this.uploadDirectoryPromises = /* @__PURE__ */ new Map(); } /** * Write a file to the temporary directory. */ async writeFile(path, body) { await createDirectory(dirname(path)); debug(`Writing file to ${path}`); await writeFile(path, body); debug(`File written to ${path}`); } /** * Copy a file to the temporary directory. */ async copyFile(from, to) { await createDirectory(dirname(to)); debug(`Copying file from ${from} to ${to}`); await copyFile(from, to); debug(`File copied from ${from} to ${to}`); } /** * Copy the trace file if found in the result. */ async copyTraceIfFound(result, path) { const trace = result.attachments.find(checkIsTrace) ?? null; if (trace) await this.copyFile(trace.path, path + ".pw-trace.zip"); } /** * Get the root upload directory (cached). */ getRootUploadDirectory() { if (!this.rootUploadDirectoryPromise) this.rootUploadDirectoryPromise = createTemporaryDirectory(); return this.rootUploadDirectoryPromise; } onBegin(config) { debug("ArgosReporter:onBegin"); this.playwrightConfig = config; } async onTestEnd(test, result) { const buildName = checkIsDynamicBuildName(this.config.buildName) ? this.config.buildName.get(test) : this.config.buildName; if (buildName === "") throw new Error("Argos \"buildName\" cannot be an empty string."); const rootUploadDir = await this.getRootUploadDirectory(); const uploadDir = buildName ? join(rootUploadDir, buildName) : rootUploadDir; debug("ArgosReporter:onTestEnd"); await Promise.all(result.attachments.map(async (attachment) => { if (checkIsArgosSnapshot(attachment) || checkIsArgosMetadata(attachment)) { const path = join(uploadDir, getAttachmentFilename(attachment)); await Promise.all([this.copyFile(attachment.path, path), this.copyTraceIfFound(result, path)]); return; } if (checkIsAutomaticScreenshot(attachment)) { const metadata = await getMetadataFromTestCase(test, result); const path = join(uploadDir, `${getAutomaticScreenshotName(test, result)}${PNG_EXTENSION}`); await Promise.all([ this.writeFile(path + METADATA_EXTENSION, JSON.stringify(metadata)), this.copyFile(attachment.path, path), this.copyTraceIfFound(result, path) ]); return; } })); } async onEnd(result) { debug("ArgosReporter:onEnd"); const rootUploadDir = await this.getRootUploadDirectory(); if (!this.uploadToArgos) { debug("Not uploading to Argos because uploadToArgos is false."); debug(`Upload directory: ${rootUploadDir}`); return; } debug("Getting parallel from config"); const parallel = await getParallelFromConfig(this.playwrightConfig); if (parallel) debug(`Using parallel config — total: ${parallel.total}, nonce: "${parallel.nonce}"`); else debug("Non-parallel mode"); const buildNameConfig = this.config.buildName; const uploadOptions = { files: ["**/*.png", "**/*.aria.yml"], parallel: parallel ?? void 0, ...this.config, buildName: void 0, metadata: { testReport: { status: result.status, stats: { startTime: result.startTime.toISOString(), duration: result.duration } } } }; try { if (checkIsDynamicBuildName(buildNameConfig)) { debug(`Dynamic build names, uploading to Argos for each build name: ${buildNameConfig.values.join()}`); const directories = await readdir(rootUploadDir); if (directories.some((dir) => !buildNameConfig.values.includes(dir))) throw new Error(`The \`buildName.values\` (${buildNameConfig.values.join(", ")}) are inconsistent with the \`buildName.get\` returns values (${directories.join(", ")}). Please fix the configuration.`); const iteratesOnBuildNames = parallel ? buildNameConfig.values : directories; for (const buildName of iteratesOnBuildNames) { const uploadDir = join(rootUploadDir, buildName); await createDirectory(uploadDir); debug(`Uploading to Argos for build: ${buildName}`); const res = await upload({ ...uploadOptions, root: uploadDir, buildName }); console.log(chalk.green(`✅ Argos "${buildName}" build created: ${res.build.url}`)); } } else { debug("Uploading to Argos"); const uploadDir = buildNameConfig ? join(rootUploadDir, buildNameConfig) : rootUploadDir; const res = await upload({ ...uploadOptions, root: uploadDir, buildName: buildNameConfig ?? void 0 }); console.log(chalk.green(`✅ Argos build created: ${res.build.url}`)); } } catch (error) { console.error(chalk.red(`❌ Error while creating the Argos build`)); console.error(error); if (!this.config.ignoreUploadFailures) return { status: "failed" }; else console.warn(chalk.yellow("⚠️ Upload failure ignored due to ignoreUploadFailures option")); } } printsToStdio() { return false; } }; //#endregion export { createArgosReporterOptions, ArgosReporter as default };