@argos-ci/playwright
Version:
Playwright SDK for visual testing with Argos.
565 lines (564 loc) • 18.1 kB
JavaScript
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 };