UNPKG

@argos-ci/playwright

Version:

Playwright SDK for visual testing with Argos.

565 lines (564 loc) 18.1 kB
import { createRequire } from "node:module"; import { mkdir, writeFile } from "node:fs/promises"; import { getGitRepositoryPath, getMetadataPath, getScreenshotName, readVersionFromPackage, validateThreshold, writeMetadata } from "@argos-ci/util"; import { dirname, relative, resolve } from "node:path"; import { AsyncLocalStorage } from "node:async_hooks"; import { getGlobalScript, resolveViewport } from "@argos-ci/browser"; import { createHash } from "node:crypto"; //#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(); /** * Set the metadata config. */ function setMetadataConfig(metadata) { metadataConfigStorage.enterWith(metadata); } const DEFAULT_PLAYWRIGHT_LIBRARIES = [ "@playwright/test", "playwright", "playwright-core" ]; /** * Get the metadata overrides set by the SDK. */ function getMetadataOverrides() { return metadataConfigStorage.getStore(); } /** * 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 }; } /** * Resolve the test file path relative to the repository path. * If the repository path is not set, it returns the absolute path. */ function resolveTestFilePath(filepath, repositoryPath) { if (!repositoryPath) return filepath; return relative(repositoryPath, filepath); } /** * Get the metadata of the test. */ async function getTestMetadata(testInfo) { const repositoryPath = await getGitRepositoryPath(); const metadataConfig = metadataConfigStorage.getStore(); if (metadataConfig?.test) return { ...metadataConfig.test, location: metadataConfig.test?.location ? { file: resolveTestFilePath(metadataConfig.test.location.file, repositoryPath), line: metadataConfig.test.location.line, column: metadataConfig.test.location.column } : void 0 }; if (!testInfo) return null; return { id: testInfo.testId, title: testInfo.title, titlePath: testInfo.titlePath, tags: testInfo.tags.length > 0 ? testInfo.tags : void 0, retry: testInfo.retry, retries: testInfo.project.retries, repeat: testInfo.repeatEachIndex, location: { file: resolveTestFilePath(testInfo.file, repositoryPath), line: testInfo.line, column: testInfo.column }, annotations: testInfo.annotations }; } //#endregion //#region src/util.ts const require = createRequire(import.meta.url); /** * Check if the project is using the Argos reporter. */ function checkIsUsingArgosReporter(testInfo) { if (!testInfo) return false; const reporterPath = require.resolve("@argos-ci/playwright/reporter"); return testInfo.config.reporter.some((reporter) => reporter[0].includes("@argos-ci/playwright/reporter") || reporter[0] === reporterPath); } const PNG_EXTENSION = `.png`; const ARIA_EXTENSION = `.aria.yml`; 255 - PNG_EXTENSION.length - `.argos.json`.length; /** * Get test info from the Playwright test. */ async function getTestInfo() { try { const { test } = await import("@playwright/test"); return test.info(); } catch { return null; } } /** * Check if the value is a Page. */ function checkIsPage(value) { return Boolean(value && typeof value === "object" && "bringToFront" in value && typeof value.bringToFront === "function"); } /** * Check if the value is an element handle. */ function checkIsElementHandle(value) { return Boolean(value && typeof value === "object" && "asElement" in value && typeof value.asElement === "function"); } /** * Check if the handler is a Frame. */ function checkIsFrame(handler) { return "page" in handler && typeof handler.page === "function"; } /** * Get the Playwright `Page` from the handler. * If the handler is a Frame, it returns the parent page. * Otherwise, it returns the handler itself. */ function getPage(handler) { if (checkIsFrame(handler)) return handler.page(); return handler; } /** * Get the viewport size. */ function getViewportSize(page) { const viewportSize = page.viewportSize(); if (!viewportSize) throw new Error("Snapshots can't be taken without a viewport."); return viewportSize; } /** * Sets the viewport size and waits for the visual viewport to match the specified dimensions. * @returns A promise that resolves when the viewport size has been successfully set and matched. */ async function setViewportSize(page, viewportSize) { await page.setViewportSize(viewportSize); await page.waitForFunction(({ width, height }) => window.innerWidth === width && window.innerHeight === height, { width: viewportSize.width, height: viewportSize.height }); } /** * Get the snapshot names based on the test info. */ function getSnapshotNames(name, testInfo) { if (testInfo) { const projectName = `${testInfo.project.name}/${name}`; if (testInfo.repeatEachIndex > 0) return { name: `${projectName} repeat-${testInfo.repeatEachIndex}`, baseName: projectName }; return { name: projectName, baseName: null }; } return { name, baseName: null }; } /** * Inject Argos script into the document. */ async function injectArgos(handler) { if (!await handler.evaluate(() => typeof window.__ARGOS__ !== "undefined")) await handler.addScriptTag({ content: getGlobalScript() }); } /** * Prepare Argos screenshot by injecting the SDK and creating the root directory. */ async function prepare(args) { const { handler, useArgosReporter, root } = args; await Promise.all([useArgosReporter ? null : mkdir(root, { recursive: true }), injectArgos(handler)]); } /** * Get metadata and path. */ async function getPathAndMetadata(args) { const { handler, testInfo, names, extension, root, useArgosReporter } = args; const overrides = getMetadataOverrides(); const path = useArgosReporter && testInfo ? testInfo.outputPath("argos", `${names.name}${extension}`) : resolve(root, `${names.name}${extension}`); const dir = dirname(path); const [colorScheme, mediaType, libMetadata, testMetadata] = await Promise.all([ handler.evaluate(() => window.__ARGOS__.getColorScheme()), handler.evaluate(() => window.__ARGOS__.getMediaType()), getLibraryMetadata(), getTestMetadata(testInfo), dir !== root ? mkdir(dir, { recursive: true }) : null ]); const viewportSize = checkIsFrame(handler) ? null : getViewportSize(handler); const browser = getPage(handler).context().browser(); if (!browser) throw new Error("Can't take screenshots without a browser."); const browserName = browser.browserType().name(); const browserVersion = browser.version(); const metadata = { url: overrides?.url ?? handler.url(), colorScheme, mediaType, test: testMetadata, story: overrides?.story, browser: { name: browserName, version: browserVersion }, ...libMetadata }; const viewport = viewportSize ?? getMetadataOverrides()?.viewport; if (viewport) metadata.viewport = viewport; metadata.transient = {}; if (names.baseName) metadata.transient.baseName = `${names.baseName}${extension}`; return { metadata, path }; } /** * Convert a screenshot to a snapshot path */ function screenshotToSnapshotPath(value) { return value.replace(/\.png$/, ARIA_EXTENSION); } /** * Run before taking all screenshots. */ async function beforeAll(handler, context, options) { await handler.evaluate((context) => window.__ARGOS__.beforeAll(context), context); if (options?.disableHover) await getPage(handler).mouse.move(0, 0); return async () => { await handler.evaluate(() => window.__ARGOS__.afterAll()); }; } /** * Run before taking each screenshot. */ async function beforeEach(handler, context) { await handler.evaluate((context) => window.__ARGOS__.beforeEach(context), context); return async () => { await handler.evaluate(() => window.__ARGOS__.afterEach()); }; } /** * Increase the timeout for the test x3. * Returns a function to reset the timeout. */ async function increaseTimeout() { const testInfo = await getTestInfo(); if (testInfo) { const { timeout } = testInfo; testInfo.setTimeout(timeout * 3); return { value: timeout, reset: () => { testInfo.setTimeout(timeout); } }; } return null; } /** * Wait for the UI to be ready before taking the screenshot. */ async function waitForReadiness(handler, context) { const timeout = await increaseTimeout(); try { await handler.waitForFunction((context) => { return window.__ARGOS__.waitFor(context); }, context, timeout ? { timeout: timeout.value } : void 0); timeout?.reset(); } catch (error) { const reasons = await handler.evaluate((context) => window.__ARGOS__.getWaitFailureExplanations(context), context); throw new Error(` Failed to stabilize screenshot, found the following issues: ${reasons.map((reason) => `- ${reason}`).join("\n")} `.trim(), { cause: error }); } } /** * Attach attachments to test info if necessary. */ async function attachAttachments(args) { const { attachments, useArgosReporter, testInfo } = args; if (useArgosReporter && testInfo) await Promise.all(attachments.map((attachment) => testInfo.attach(attachment.name, { path: attachment.path, contentType: attachment.contentType }))); } //#endregion //#region src/attachment.ts function getAttachmentName(name, type) { return `argos/${type}___${name}`; } //#endregion //#region src/aria-snapshot.ts const DEFAULT_SNAPSHOTS_ROOT = "./screenshots"; /** * Stabilize the UI and takes a snapshot of the application under test. * * @example * argosAriaSnapshot(page, "my-screenshot") * @see https://playwright.dev/docs/aria-snapshots */ async function argosAriaSnapshot(handler, name, options = {}) { const { element, has, hasText, hasNot, hasNotText, timeout, root = DEFAULT_SNAPSHOTS_ROOT } = options; if (!handler) throw new Error("A Playwright `handler` object is required."); if (!name) throw new Error("The `name` argument is required."); const snapshotTarget = typeof element === "string" ? handler.locator(element, { has, hasText, hasNot, hasNotText }) : element ?? handler.locator("body"); const testInfo = await getTestInfo(); const useArgosReporter = checkIsUsingArgosReporter(testInfo); await prepare({ handler, useArgosReporter, root }); const context = getStabilizationContext$1(options); const afterAll = await beforeAll(handler, context); const names = getSnapshotNames(name, testInfo); const { path: snapshotPath, metadata } = await getPathAndMetadata({ handler, extension: ARIA_EXTENSION, names, root, testInfo, useArgosReporter }); await waitForReadiness(handler, context); const afterEach = await beforeEach(handler, context); await waitForReadiness(handler, context); await Promise.all([snapshotTarget.ariaSnapshot({ timeout }).then((snapshot) => { return writeFile(snapshotPath, snapshot, "utf-8"); }), writeMetadata(snapshotPath, metadata)]); const attachments = [{ name: getAttachmentName(names.name, "aria"), contentType: "application/yaml", path: snapshotPath }, { name: getAttachmentName(names.name, "aria/metadata"), contentType: "application/json", path: getMetadataPath(snapshotPath) }]; await attachAttachments({ attachments, testInfo, useArgosReporter }); await afterEach(); await afterAll(); return attachments; } /** * Get the stabilization context from the options. */ function getStabilizationContext$1(options) { const { stabilize } = options; return { fullPage: false, argosCSS: void 0, viewports: void 0, options: stabilize }; } //#endregion //#region src/screenshot.ts const DEFAULT_SCREENSHOT_ROOT = "./screenshots"; /** * Stabilize the UI and takes a screenshot of the application under test. * * @example * argosScreenshot(page, "my-screenshot") * @see https://argos-ci.com/docs/playwright#api-overview */ async function argosScreenshot(handler, name, options = {}) { const { element, has, hasText, hasNot, hasNotText, viewports, argosCSS: _argosCSS, root = DEFAULT_SCREENSHOT_ROOT, ariaSnapshot, disableHover = true, ...playwrightOptions } = options; if (!handler) throw new Error("A Playwright `handler` object is required."); if (!name) throw new Error("The `name` argument is required."); const screenshotTarget = typeof element === "string" ? handler.locator(element, { has, hasText, hasNot, hasNotText }) : element ?? (checkIsFrame(handler) ? handler.locator("body") : handler); const testInfo = await getTestInfo(); const useArgosReporter = checkIsUsingArgosReporter(testInfo); await prepare({ handler, useArgosReporter, root }); const originalViewportSize = checkIsFrame(handler) ? null : getViewportSize(handler); const fullPage = options.fullPage !== void 0 ? options.fullPage : screenshotTarget === handler; const context = getStabilizationContext(options); const afterAll = await beforeAll(handler, context, { disableHover }); const stabilizeAndScreenshot = async (name) => { const names = getSnapshotNames(name, testInfo); const { path: screenshotPath, metadata } = await getPathAndMetadata({ handler, extension: PNG_EXTENSION, root, names, testInfo, useArgosReporter }); if (options.tag) metadata.tags = Array.isArray(options.tag) ? options.tag : [options.tag]; if (options.threshold !== void 0) { validateThreshold(options.threshold); metadata.transient.threshold = options.threshold; } await options.beforeScreenshot?.({ runStabilization: (stabilizationOptions) => waitForReadiness(handler, getStabilizationContext({ ...options, stabilize: stabilizationOptions ?? options.stabilize })) }); await waitForReadiness(handler, context); const afterEach = await beforeEach(handler, context); await waitForReadiness(handler, context); const [snapshotPath] = await Promise.all([ (async () => { if (!ariaSnapshot) return null; const snapshotTarget = checkIsPage(screenshotTarget) ? screenshotTarget.locator("body") : screenshotTarget; if (checkIsElementHandle(snapshotTarget)) throw new Error(`Element handle is not supported with "ariaSnapshot" option. Use a Locator instead.`); const snapshotPath = screenshotToSnapshotPath(screenshotPath); const snapshotMetadata = { ...metadata, transient: { parentName: `${names.name}${PNG_EXTENSION}`, ...metadata.transient.baseName ? { baseName: screenshotToSnapshotPath(metadata.transient.baseName) } : {} } }; await Promise.all([snapshotTarget.ariaSnapshot().then((snapshot) => { return writeFile(snapshotPath, snapshot, "utf-8"); }), writeMetadata(snapshotPath, snapshotMetadata)]); return snapshotPath; })(), screenshotTarget.screenshot({ path: screenshotPath, type: "png", fullPage, mask: [handler.locator("[data-visual-test=\"blackout\"]")], animations: "disabled", ...playwrightOptions }), writeMetadata(screenshotPath, metadata) ]); const attachments = [{ name: getAttachmentName(names.name, "screenshot"), contentType: "image/png", path: screenshotPath }, { name: getAttachmentName(names.name, "screenshot/metadata"), contentType: "application/json", path: getMetadataPath(screenshotPath) }]; if (snapshotPath) attachments.push({ name: getAttachmentName(names.name, "aria"), contentType: "application/yaml", path: snapshotPath }, { name: getAttachmentName(names.name, "aria/metadata"), contentType: "application/json", path: getMetadataPath(snapshotPath) }); await attachAttachments({ attachments, testInfo, useArgosReporter }); await afterEach(); await options.afterScreenshot?.(); return attachments; }; const allAttachments = []; if (viewports) { if (checkIsFrame(handler)) throw new Error(`viewports option is not supported with an iframe`); for (const viewport of viewports) { const viewportSize = resolveViewport(viewport); await setViewportSize(handler, viewportSize); const attachments = await stabilizeAndScreenshot(getScreenshotName(name, { viewportWidth: viewportSize.width })); allAttachments.push(...attachments); } if (!originalViewportSize) throw new Error(`Invariant: viewport size must be saved`); await setViewportSize(handler, originalViewportSize); } else { const attachments = await stabilizeAndScreenshot(name); allAttachments.push(...attachments); } await afterAll(); return allAttachments; } /** * Get the stabilization context from the options. */ function getStabilizationContext(options) { const { fullPage, argosCSS, stabilize, viewports } = options; return { fullPage, argosCSS, viewports, options: stabilize }; } //#endregion //#region src/csp.ts /** * Get the CSP script hash. */ function getCSPScriptHash() { return `'sha256-${createHash("sha256").update(getGlobalScript()).digest("base64")}'`; } //#endregion export { setMetadataConfig as DO_NOT_USE_setMetadataConfig, argosAriaSnapshot, argosScreenshot, getCSPScriptHash };