UNPKG

@argos-ci/browser

Version:
648 lines (645 loc) 20.8 kB
(function() { //#region src/global/media.ts /** * Get the current color scheme of the user. */ function getColorScheme() { const { colorScheme } = window.getComputedStyle(document.body); return colorScheme === "dark" || colorScheme === "dark only" || window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } /** * Get the current media type of the user. */ function getMediaType() { return window.matchMedia("print").matches ? "print" : "screen"; } //#endregion //#region src/global/stabilization/plugins/addArgosClass.ts /** * Add a global class to the document element. */ const plugin$14 = { name: "addArgosClass", beforeAll() { const className = "__argos__"; document.documentElement.classList.add(className); return () => { document.documentElement.classList.remove(className); }; } }; //#endregion //#region src/global/stabilization/util.ts /** * Inject global styles in the DOM. */ function injectGlobalStyles(css, id) { const style = document.createElement("style"); style.textContent = css.trim(); style.id = `argos-${id}`; document.head.appendChild(style); return () => { const style = document.getElementById(`argos-${id}`); if (style) style.remove(); }; } //#endregion //#region src/global/stabilization/plugins/addArgosCSS.ts /** * Inject custom CSS into the document. */ const plugin$13 = { name: "addArgosCSS", beforeAll(options) { if (options.argosCSS) return injectGlobalStyles(options.argosCSS, "custom-css"); } }; //#endregion //#region src/global/stabilization/plugins/argosHelpers.ts /** * Inject CSS for Argos helpers. */ const plugin$12 = { name: "argosHelpers", beforeAll() { return injectGlobalStyles(` /* Make the element transparent */ [data-visual-test="transparent"] { color: transparent !important; font-family: monospace !important; opacity: 0 !important; } /* Remove the element */ [data-visual-test="removed"] { display: none !important; } /* Disable radius */ [data-visual-test-no-radius]:not([data-visual-test-no-radius="false"]) { border-radius: 0 !important; } `, "argos-helpers"); } }; //#endregion //#region src/global/stabilization/plugins/disableSpellCheck.ts const UNSET = "--unset"; const BACKUP_ATTRIBUTE$2 = "data-argos-bck-spellcheck"; /** * Disable spellcheck to avoid displaying markers. */ const plugin$11 = { name: "disableSpellcheck", beforeAll() { document.querySelectorAll("[contenteditable]:not([contenteditable=false]), input, textarea").forEach((element) => { const spellcheck = element.getAttribute("spellcheck"); if (spellcheck === "false") return; element.setAttribute(BACKUP_ATTRIBUTE$2, spellcheck ?? UNSET); element.setAttribute("spellcheck", "false"); }); return () => { document.querySelectorAll(`[${BACKUP_ATTRIBUTE$2}]`).forEach((input) => { const bckSpellcheck = input.getAttribute(BACKUP_ATTRIBUTE$2); if (bckSpellcheck === UNSET) input.removeAttribute("spellcheck"); else if (bckSpellcheck) input.setAttribute("spellcheck", bckSpellcheck); input.removeAttribute(BACKUP_ATTRIBUTE$2); }); }; } }; //#endregion //#region src/global/stabilization/plugins/fontAntialiasing.ts /** * Enable antialiasing for fonts. */ const plugin$10 = { name: "fontAntialiasing", beforeAll() { return injectGlobalStyles(`* { -webkit-font-smoothing: antialiased !important; }`, "font-antialiasing"); } }; //#endregion //#region src/global/stabilization/plugins/hideCarets.ts /** * Hide carets. */ const plugin$9 = { name: "hideCarets", beforeAll() { return injectGlobalStyles(`* { caret-color: transparent !important; }`, "hide-carets"); } }; //#endregion //#region src/global/stabilization/plugins/hideScrollbars.ts /** * Hide scrollbars. */ const plugin$8 = { name: "hideScrollbars", beforeAll() { return injectGlobalStyles(`::-webkit-scrollbar { display: none !important; }`, "hide-scrollbars"); } }; //#endregion //#region src/global/stabilization/plugins/loadImageSrcset.ts /** * Force srcset to resolve to the biggest candidate for the current run. * This collapses srcset to a single candidate while staying consistent with * descriptor rules from the spec. */ const plugin$7 = { name: "loadImageSrcset", beforeEach(options) { if (!options.viewports || options.viewports.length === 0) return; function parseSrcset(srcset) { return srcset.split(",").reduce((candidates, rawPart) => { const part = rawPart.trim(); if (!part) return candidates; const tokens = part.split(/\s+/); const maybeDescriptor = tokens.length > 1 ? tokens[tokens.length - 1] : null; if (maybeDescriptor && /^\d+w$/.test(maybeDescriptor)) { const url = tokens.slice(0, -1).join(" "); if (url) candidates.push({ url, kind: "w", value: Number.parseInt(maybeDescriptor.slice(0, -1), 10) }); return candidates; } if (maybeDescriptor && /^\d+\.?\d*x$/.test(maybeDescriptor)) { const url = tokens.slice(0, -1).join(" "); if (url) candidates.push({ url, kind: "x", value: Number.parseFloat(maybeDescriptor.slice(0, -1)) }); return candidates; } const url = tokens[0] ?? ""; if (url) candidates.push({ url, kind: "none", value: 1 }); return candidates; }, []); } function pickLargestCandidate(candidates) { let winner = null; let kind = null; for (const candidate of candidates) { if (kind !== null && candidate.kind !== kind) return null; kind ??= candidate.kind; if (winner === null || candidate.value > winner.value) winner = candidate; } return winner; } function candidateToSingleSrcset(candidate, requireW) { if (candidate.kind === "none") return requireW ? `${candidate.url} 1w` : candidate.url; return `${candidate.url} ${candidate.value}${candidate.kind}`; } function forceSrcsetReload(el) { const srcset = el.getAttribute("srcset"); if (!srcset) return; const candidates = parseSrcset(srcset); const chosen = pickLargestCandidate(candidates); if (!chosen) return; const requireW = el.hasAttribute("sizes") || chosen.kind === "w" || candidates.some((c) => c.kind === "w"); el.setAttribute("srcset", ""); if (el instanceof HTMLImageElement) el.src = chosen.url; el.clientWidth; el.setAttribute("srcset", candidateToSingleSrcset(chosen, requireW)); } Array.from(document.querySelectorAll("img,source")).forEach(forceSrcsetReload); } }; //#endregion //#region src/global/stabilization/plugins/pauseGifs.ts const BACKUP_ATTRIBUTE$1 = "data-argos-gif-src"; /** * Attribute authors can set to flag an image as a GIF explicitly, e.g. * `<img data-image-type="gif">`. */ const IMAGE_TYPE_ATTRIBUTE = "data-image-type"; /** * GIFs currently being frozen for this screenshot cycle. * Freezing is asynchronous (we load a fresh copy to grab its first frame), so * `wait.for` polls this set to keep stabilization blocked until every GIF has * been paused. */ const pendingFreezes = /* @__PURE__ */ new Set(); /** * Detect animated GIFs from their resolved source. There is no cheap way to * inspect the decoded bytes, so we rely on the URL (file extension or the * `data:image/gif` MIME type). Authors can also opt an image in explicitly with * `data-image-type="gif"`, which is the only way to catch GIFs served from URLs * that carry no `.gif` extension (e.g. a CDN endpoint). */ function isGif(img) { if (img.getAttribute(IMAGE_TYPE_ATTRIBUTE) === "gif") return true; const src = img.currentSrc || img.src; return /^data:image\/gif[;,]/i.test(src) || /\.gif(?:$|[?#])/i.test(src); } /** * Pause animated GIFs by replacing them with a static snapshot of their first * frame. A playing GIF renders a non-deterministic frame depending on when the * screenshot is taken, which causes flaky visual diffs. * * The current frame of an already-rendered `<img>` is unpredictable (it has * been animating since it loaded), so we load a fresh copy of the image and * draw it to a canvas the moment it decodes — that always yields the first * frame — then swap the frozen data URL back into the original element. */ const plugin$6 = { name: "pauseGifs", beforeEach() { Array.from(document.images).forEach((img) => { if (img.hasAttribute(BACKUP_ATTRIBUTE$1) || !isGif(img)) return; const originalSrc = img.src; const frame = new Image(); frame.crossOrigin = "anonymous"; pendingFreezes.add(img); const done = () => { pendingFreezes.delete(img); }; frame.onload = () => { const canvas = document.createElement("canvas"); canvas.width = frame.naturalWidth; canvas.height = frame.naturalHeight; const ctx = canvas.getContext("2d"); if (!ctx || canvas.width === 0 || canvas.height === 0) { done(); return; } ctx.drawImage(frame, 0, 0); try { const frozenSrc = canvas.toDataURL("image/png"); img.setAttribute(BACKUP_ATTRIBUTE$1, originalSrc); img.src = frozenSrc; } catch {} done(); }; frame.onerror = done; frame.src = originalSrc; }); return () => { pendingFreezes.clear(); Array.from(document.images).forEach((img) => { const originalSrc = img.getAttribute(BACKUP_ATTRIBUTE$1); if (originalSrc === null) return; img.src = originalSrc; img.removeAttribute(BACKUP_ATTRIBUTE$1); }); }; }, wait: { for: () => pendingFreezes.size === 0, failureExplanation: "Some GIFs are still being paused" } }; //#endregion //#region src/global/stabilization/plugins/roundImageSize.ts const BACKUP_ATTRIBUTE_WIDTH = "data-argos-bck-width"; const BACKUP_ATTRIBUTE_HEIGHT = "data-argos-bck-height"; /** * Round all image sizes to stabilize images rendering. */ const plugin$5 = { name: "roundImageSize", beforeEach() { Array.from(document.images).forEach((img) => { if (!img.complete || img.naturalWidth === 0) return; if (!img.hasAttribute(BACKUP_ATTRIBUTE_WIDTH)) { img.setAttribute(BACKUP_ATTRIBUTE_WIDTH, img.style.width); img.setAttribute(BACKUP_ATTRIBUTE_HEIGHT, img.style.height); } img.style.width = `${Math.round(img.offsetWidth)}px`; img.style.height = `${Math.round(img.offsetHeight)}px`; }); return () => { Array.from(document.images).forEach((img) => { if (!img.hasAttribute(BACKUP_ATTRIBUTE_WIDTH)) return; const bckWidth = img.getAttribute(BACKUP_ATTRIBUTE_WIDTH); const bckHeight = img.getAttribute(BACKUP_ATTRIBUTE_HEIGHT); img.style.width = bckWidth ?? ""; img.style.height = bckHeight ?? ""; img.removeAttribute(BACKUP_ATTRIBUTE_WIDTH); img.removeAttribute(BACKUP_ATTRIBUTE_HEIGHT); }); }; } }; //#endregion //#region src/global/stabilization/plugins/stabilizeSticky.ts const BACKUP_ATTRIBUTE = "data-argos-bck-position"; /** * Set the position of an element and backup the previous one. */ function setAndBackupPosition(element, position) { const previousPosition = element.style.position; const previousRect = element.getBoundingClientRect(); element.style.position = position; const currentRect = element.getBoundingClientRect(); if (previousRect.x !== currentRect.x || previousRect.y !== currentRect.y) { element.style.position = previousPosition; return; } element.setAttribute(BACKUP_ATTRIBUTE, previousPosition ?? "unset"); } /** * Stabilize sticky and fixed elements. */ const plugin$4 = { name: "stabilizeSticky", beforeAll(options) { if (!options.fullPage) return; document.querySelectorAll("*").forEach((element) => { if (!(element instanceof HTMLElement)) return; if (element.tagName === "IFRAME") return; const { position } = window.getComputedStyle(element); if (position === "fixed") setAndBackupPosition(element, "absolute"); else if (position === "sticky") setAndBackupPosition(element, "relative"); }); return () => { document.querySelectorAll(`[${BACKUP_ATTRIBUTE}]`).forEach((element) => { if (!(element instanceof HTMLElement)) return; const position = element.getAttribute(BACKUP_ATTRIBUTE); if (!position) return; if (position === "unset") element.style.removeProperty("position"); else element.style.position = position; element.removeAttribute(BACKUP_ATTRIBUTE); }); }; } }; //#endregion //#region src/global/stabilization/plugins/waitForAriaBusy.ts /** * Check if an element is visible. */ function checkIsElementVisible(element) { if (element instanceof HTMLElement && (element.offsetHeight !== 0 || element.offsetWidth !== 0)) return true; return element.getClientRects().length > 0; } /** * Wait for [aria-busy="true"] elements to be invisible. */ const plugin$3 = { name: "waitForAriaBusy", wait: { for: () => { return Array.from(document.querySelectorAll("[aria-busy=\"true\"]")).every((element) => !checkIsElementVisible(element)); }, failureExplanation: "Some elements still have `aria-busy='true'`" } }; //#endregion //#region src/global/stabilization/plugins/waitForBackgroundImages.ts /** * Images preloaded for the current screenshot cycle. * Populated by a single DOM scan in `beforeEach` and polled cheaply in * `wait.for` (we only read `img.complete`, never re-scan the DOM). */ const preloadedImages = /* @__PURE__ */ new Set(); const URL_REGEX = /url\((['"]?)([^'")]+)\1\)/g; /** * By default, only elements opted in with the `data-visual-test-wait-bg-img` * attribute (and their descendants) are scanned. This keeps the otherwise * expensive `getComputedStyle` sweep cheap while still letting authors flag the * regions whose background images must be loaded before the screenshot. */ const DEFAULT_SELECTOR = "[data-visual-test-wait-bg-img], [data-visual-test-wait-bg-img] *"; /** * Extract non-data background-image URLs from a computed `background-image` * value (which may contain several layered backgrounds and gradients). */ function collectUrls(value, urls) { if (!value || value === "none") return; URL_REGEX.lastIndex = 0; let match; while ((match = URL_REGEX.exec(value)) !== null) { const url = match[2]; if (url && !url.startsWith("data:")) urls.add(url); } } /** * Resolve the selector scoping the scan. Defaults to the opt-in attribute * selector; `true` widens the scan to the whole document, and an explicit * `selector` overrides both. */ function resolveSelector(options) { if (options === true) return "*"; if (options && typeof options === "object") { const { selector } = options; if (typeof selector === "string" && selector.trim() !== "") return selector; } return DEFAULT_SELECTOR; } /** * Wait for CSS background images to be loaded. * * There is no native load event for CSS background images, so URLs are * discovered with `getComputedStyle` and preloaded through `Image` objects. * * A full-document `getComputedStyle` sweep is expensive, so by default the scan * is limited to elements opted in with the `data-visual-test-wait-bg-img` * attribute (and their descendants). Widen it to the whole document with * `stabilize: { waitForBackgroundImages: true }`, or target a custom selector * with `stabilize: { waitForBackgroundImages: { selector: ".hero, [data-bg]" } }`. * * The scan runs only once per viewport in `beforeEach`; `wait.for` just polls * the preloaded images. */ const plugin$2 = { name: "waitForBackgroundImages", beforeEach(_context, options) { const selector = resolveSelector(options); const urls = /* @__PURE__ */ new Set(); document.querySelectorAll(selector).forEach((element) => { collectUrls(window.getComputedStyle(element).backgroundImage, urls); collectUrls(window.getComputedStyle(element, "::before").backgroundImage, urls); collectUrls(window.getComputedStyle(element, "::after").backgroundImage, urls); }); urls.forEach((url) => { const img = new Image(); img.decoding = "sync"; img.src = url; preloadedImages.add(img); }); return () => { preloadedImages.clear(); }; }, wait: { for: () => { return Array.from(preloadedImages).every((img) => img.complete); }, failureExplanation: "Some background images are still loading" } }; //#endregion //#region src/global/stabilization/plugins/waitForFonts.ts /** * Wait for fonts to be loaded. */ const plugin$1 = { name: "waitForFonts", wait: { for: () => { return document.fonts.status === "loaded"; }, failureExplanation: "Some fonts are still loading" } }; //#endregion //#region src/global/stabilization/plugins/waitForImages.ts /** * Wait for images to be loaded. */ const plugin = { name: "waitForImages", beforeEach() { Array.from(document.images).forEach((img) => { if (img.decoding !== "sync") img.decoding = "sync"; if (img.loading !== "eager") img.loading = "eager"; }); }, wait: { for: () => { return Array.from(document.images).every((img) => { if (img.decoding !== "sync") img.decoding = "sync"; if (img.loading !== "eager") img.loading = "eager"; return img.complete; }); }, failureExplanation: "Some images are still loading" } }; //#endregion //#region src/global/stabilization/plugins/index.ts const corePlugins = [ plugin$14, plugin$13, plugin$12 ]; const plugins = [ plugin$11, plugin$10, plugin$9, plugin$8, plugin$7, plugin$6, plugin$5, plugin$4, plugin$3, plugin$2, plugin$1, plugin ]; //#endregion //#region src/global/stabilization/index.ts const beforeAllCleanups = /* @__PURE__ */ new Set(); const beforeEachCleanups = /* @__PURE__ */ new Set(); /** * Get the option value passed for a specific plugin. */ function getPluginOptions(context, name) { if (typeof context.options === "object" && context.options) return context.options[name]; } /** * Get the list of plugins to run based on the options. */ function getPlugins(context) { const enabledPlugins = plugins.filter((plugin) => { if (context.options === false) return false; if (getPluginOptions(context, plugin.name) === false) return false; return true; }); return [...corePlugins, ...enabledPlugins]; } function getPluginByName(name) { const plugin = plugins.find((p) => p.name === name); if (!plugin) throw new Error(`Invariant: plugin ${name} not found`); return plugin; } /** * Run before taking all screenshots. */ function beforeAll(context = {}) { getPlugins(context).forEach((plugin) => { if (plugin.beforeAll) { const cleanup = plugin.beforeAll(context, getPluginOptions(context, plugin.name)); if (cleanup) beforeAllCleanups.add(cleanup); } }); } /** * Run after taking all screenshots. */ function afterAll() { beforeAllCleanups.forEach((cleanup) => { cleanup(); }); beforeAllCleanups.clear(); } /** * Run before taking each screenshot (between viewport changes). */ function beforeEach(context = {}) { getPlugins(context).forEach((plugin) => { if (plugin.beforeEach) { const cleanup = plugin.beforeEach(context, getPluginOptions(context, plugin.name)); if (cleanup) beforeEachCleanups.add(cleanup); } }); } /** * Run after taking each screenshot (between viewport changes). */ function afterEach() { beforeEachCleanups.forEach((cleanup) => { cleanup(); }); beforeEachCleanups.clear(); } /** * Get the stabilization state of the document. */ function getStabilityState(context) { const stabilityState = {}; getPlugins(context).forEach((plugin) => { if (plugin.wait) stabilityState[plugin.name] = plugin.wait.for(context, getPluginOptions(context, plugin.name)); }); return stabilityState; } /** * Wait for a condition to be met before taking a screenshot. */ function waitFor(context) { const stabilityState = getStabilityState(context); return Object.values(stabilityState).every(Boolean); } /** * Get the error message to display if the condition is not met. */ function getWaitFailureExplanations(options) { const stabilityState = getStabilityState(options); return Object.entries(stabilityState).filter(([, value]) => !value).map(([name]) => { const plugin = getPluginByName(name); if (!plugin.wait) throw new Error(`Invariant: plugin ${name} does not have a wait function`); return plugin.wait.failureExplanation; }); } //#endregion //#region src/global/index.ts window.__ARGOS__ = { beforeAll, afterAll, beforeEach, afterEach, waitFor, getWaitFailureExplanations, getColorScheme: () => getColorScheme(), getMediaType: () => getMediaType() }; //#endregion })();