@argos-ci/browser
Version:
Browser utilities to stabilize visual testing with Argos.
648 lines (645 loc) • 20.8 kB
JavaScript
(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
})();