expect-puppeteer
Version:
Assertion toolkit for Puppeteer.
487 lines (470 loc) • 18.6 kB
JavaScript
const checkIsPuppeteerInstance = (instance)=>{
return Boolean(instance?.constructor?.name && typeof instance === "object" && "$" in instance);
};
const checkIsPage = (instance)=>{
return checkIsPuppeteerInstance(instance) && (instance?.constructor?.name === "CDPPage" || instance?.constructor?.name === "CdpPage");
};
const checkIsFrame = (instance)=>{
return checkIsPuppeteerInstance(instance) && (instance?.constructor?.name === "CDPFrame" || instance?.constructor?.name === "CdpFrame" || instance?.constructor?.name === "Frame");
};
const checkIsElementHandle = (instance)=>{
return checkIsPuppeteerInstance(instance) && (instance?.constructor?.name === "CDPElementHandle" || instance?.constructor?.name === "CdpElementHandle");
};
const getContext = async (instance, pageFunction)=>{
if (checkIsFrame(instance) || checkIsPage(instance)) {
return {
page: instance,
handle: await instance.evaluateHandle(pageFunction)
};
}
if (checkIsElementHandle(instance)) {
return {
page: instance.frame,
handle: instance
};
}
throw new Error(`instance is not a valid Puppeteer instance`);
};
const enhanceError = (error, message)=>{
error.message = `${message}\n${error.message}`;
return error;
};
const checkIsRegexp = (input)=>Object.prototype.toString.call(input) === "[object RegExp]";
const serializeRegexp = (regexp)=>{
return regexp.toString();
};
const serializeSearchExpression = (expr)=>{
if (checkIsRegexp(expr)) {
return {
text: null,
regexp: serializeRegexp(expr)
};
}
if (typeof expr === "string") {
return {
text: expr,
regexp: null
};
}
return {
text: null,
regexp: null
};
};
const resolveSelector = (selector)=>{
return typeof selector === "string" ? {
type: "css",
value: selector
} : selector;
};
const getSelectorMessage = (selector, text)=>{
return `Element ${selector.value}${text ? ` (text: "${text}")` : ""}`;
};
const evaluateParseSearchExpression = (page)=>{
return page.evaluateHandle(()=>(expr)=>{
const sanitizeForSearch = (text)=>{
return text.replace(/\s+/g, " ").trim();
};
const parseRegexp = (regexp)=>{
const match = regexp.match(/^\/(.*)\/([gimuy]*)$/);
if (!match) {
throw new Error(`Invalid regexp: ${regexp}`);
}
return new RegExp(match[1], match[2]);
};
const { text, regexp } = expr;
if (text) {
return (value)=>sanitizeForSearch(value).includes(text);
}
if (regexp) {
const regexpInstance = parseRegexp(regexp);
return (value)=>regexpInstance.test(sanitizeForSearch(value));
}
return null;
});
};
let defaultOptionsValue = {
timeout: 500
};
const setDefaultOptions = (options)=>{
defaultOptionsValue = options;
};
const globalWithPuppeteerConfig = global;
const getDefaultOptions = ()=>{
const slowMo = globalWithPuppeteerConfig.puppeteerConfig?.launch?.slowMo || globalWithPuppeteerConfig.puppeteerConfig?.connect?.slowMo || 0;
const defaultTimeout = defaultOptionsValue.timeout || 0;
if (slowMo || defaultOptionsValue.timeout) {
return {
...defaultOptionsValue,
// Multiplying slowMo by 10 is just arbitrary
// slowMo is applied on all Puppeteer internal methods, so it is just a "slow" indicator
// we can't use it as a real value
timeout: defaultTimeout + slowMo * 10
};
}
return defaultOptionsValue;
};
const defaultOptions = (options)=>({
...getDefaultOptions(),
...options
});
async function matchTextContent(instance, matcher, options, type) {
const { traverseShadowRoots = false, ...otherOptions } = options;
const frameOptions = defaultOptions(otherOptions);
const ctx = await getContext(instance, ()=>document.body);
const { text, regexp } = serializeSearchExpression(matcher);
const parseSearchExpressionHandle = await evaluateParseSearchExpression(ctx.page);
await ctx.page.waitForFunction((handle, text, regexp, traverseShadowRoots, parseSearchExpression, type)=>{
const checkNodeIsElement = (node)=>{
return node.nodeType === Node.ELEMENT_NODE;
};
const checkNodeIsText = (node)=>{
return node.nodeType === Node.TEXT_NODE;
};
const checkIsHtmlSlotElement = (node)=>{
return node.nodeType === Node.ELEMENT_NODE && node.nodeName === "SLOT";
};
function getShadowTextContent(node) {
const walker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
let result = "";
let currentNode = walker.nextNode();
while(currentNode){
if (checkNodeIsText(currentNode)) {
result += currentNode.textContent;
} else if (checkNodeIsElement(currentNode)) {
if (currentNode.assignedSlot) {
// Skip everything within this subtree, since it's assigned to a slot in the shadow DOM.
const nodeWithAssignedSlot = currentNode;
while(currentNode === nodeWithAssignedSlot || nodeWithAssignedSlot.contains(currentNode)){
currentNode = walker.nextNode();
}
continue;
} else if (currentNode.shadowRoot) {
result += getShadowTextContent(currentNode.shadowRoot);
} else if (checkIsHtmlSlotElement(currentNode)) {
const assignedNodes = currentNode.assignedNodes();
assignedNodes.forEach((node)=>{
result += getShadowTextContent(node);
});
}
}
currentNode = walker.nextNode();
}
return result;
}
if (!handle) return false;
const textContent = traverseShadowRoots ? getShadowTextContent(handle) : handle.textContent;
const matcher = parseSearchExpression({
text,
regexp
});
if (!matcher) {
throw new Error(`Invalid ${type} matcher: "${text}" or "${regexp}".`);
}
switch(type){
case "positive":
return Boolean(textContent && matcher(textContent));
case "negative":
return Boolean(!textContent || !matcher(textContent));
default:
throw new Error(`Invalid type: "${type}".`);
}
}, frameOptions, ctx.handle, text, regexp, traverseShadowRoots, parseSearchExpressionHandle, type);
}
async function notToMatchTextContent(instance, matcher, options = {}) {
try {
await matchTextContent(instance, matcher, options, "negative");
} catch (error) {
throw enhanceError(error, `Text found "${matcher}"`);
}
}
async function getElementFactory(instance, selector, options) {
const { text: searchExpr, visible = false } = options;
const ctx = await getContext(instance, ()=>document);
const { text, regexp } = serializeSearchExpression(searchExpr);
const parseSearchExpressionHandle = await evaluateParseSearchExpression(ctx.page);
const getElementArgs = [
ctx.handle,
selector,
text,
regexp,
visible,
parseSearchExpressionHandle
];
const getElement = (handle, selector, text, regexp, visible, parseSearchExpression, type)=>{
const hasVisibleBoundingBox = (element)=>{
const rect = element.getBoundingClientRect();
return !!(rect.top || rect.bottom || rect.width || rect.height);
};
const checkNodeIsElement = (node)=>{
return node.nodeType === Node.ELEMENT_NODE;
};
const checkIsElementVisible = (element)=>{
const style = window.getComputedStyle(element);
return style?.visibility !== "hidden" && hasVisibleBoundingBox(element);
};
let elements = [];
switch(selector.type){
case "xpath":
{
const results = document.evaluate(selector.value, handle);
let node = results.iterateNext();
while(node){
if (checkNodeIsElement(node)) {
elements.push(node);
}
node = results.iterateNext();
}
break;
}
case "css":
elements = Array.from(handle.querySelectorAll(selector.value));
break;
default:
throw new Error(`${selector.type} is not implemented`);
}
elements = visible ? elements.filter(checkIsElementVisible) : elements;
const matcher = parseSearchExpression({
text,
regexp
});
const element = matcher ? elements.find(({ textContent })=>textContent && matcher(textContent)) : elements[0];
switch(type){
case "element":
return element;
case "positive":
return !!element;
case "negative":
return !element;
default:
throw new Error(`Unknown type: ${type}`);
}
};
return [
getElement,
getElementArgs,
ctx
];
}
// import {
// getContext,
// enhanceError,
// PuppeteerInstance,
// Selector,
// } from "../utils";
// import { defaultOptions, Options } from "../options";
async function notToMatchElement(instance, selector, options = {}) {
const { text, visible, ...otherOptions } = options;
const frameOptions = defaultOptions(otherOptions);
const rSelector = resolveSelector(selector);
const [getElement, getElementArgs, ctx] = await getElementFactory(instance, rSelector, {
text,
visible
});
try {
await ctx.page.waitForFunction(getElement, frameOptions, ...getElementArgs, "negative");
} catch (error) {
throw enhanceError(error, `${getSelectorMessage(rSelector, text)} found`);
}
}
async function toMatchElement(instance, selector, options = {}) {
const { text, visible, ...otherOptions } = options;
const frameOptions = defaultOptions(otherOptions);
const rSelector = resolveSelector(selector);
const [getElement, getElementArgs, ctx] = await getElementFactory(instance, rSelector, {
text,
visible
});
try {
await ctx.page.waitForFunction(getElement, frameOptions, ...getElementArgs, "positive");
} catch (error) {
throw enhanceError(error, `${getSelectorMessage(rSelector, text)} not found`);
}
const jsHandle = await ctx.page.evaluateHandle(getElement, ...getElementArgs, "element");
return jsHandle.asElement();
}
async function toClick(instance, selector, options = {}) {
const { delay, button, count, offset, ...otherOptions } = options;
const element = await toMatchElement(instance, selector, otherOptions);
await element.click({
delay,
button,
count,
offset
});
}
async function toDisplayDialog(page, block) {
return new Promise((resolve, reject)=>{
const handleDialog = (dialog)=>{
page.off("dialog", handleDialog);
resolve(dialog);
};
page.on("dialog", handleDialog);
block().catch(reject);
});
}
async function selectAll(element) {
// modified from https://github.com/microsoft/playwright/issues/849#issuecomment-587983363
await element.evaluate((element)=>{
if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) {
throw new Error(`Element is not an <input> element.`);
}
if (element.setSelectionRange) {
try {
element.setSelectionRange(0, element.value.length);
} catch {
// setSelectionRange throws an error for inputs: number/date/time/etc
// we can just focus them and the content will be selected
element.focus();
element.select();
}
} else if (window.getSelection && document.createRange) {
const range = document.createRange();
range.selectNodeContents(element);
const selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(range);
}
}
});
}
async function toFill(instance, selector, value, options = {}) {
const { delay, ...toMatchElementOptions } = options;
const element = await toMatchElement(instance, selector, toMatchElementOptions);
await selectAll(element);
await element.press("Delete");
await element.type(value, delay ? {
delay
} : undefined);
}
async function toFillForm(instance, selector, values, options = {}) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { delay, ...otherOptions } = options;
const form = await toMatchElement(instance, selector, otherOptions);
for (const name of Object.keys(values)){
await toFill(form, `[name="${name}"]`, values[name], options);
}
}
async function toMatchTextContent(instance, matcher, options = {}) {
try {
await matchTextContent(instance, matcher, options, "positive");
} catch (error) {
throw enhanceError(error, `Text not found "${matcher}"`);
}
}
const checkIsSelectElement = (element)=>{
return typeof element.select === "function";
};
async function toSelect(instance, selector, valueOrText, options = {}) {
const element = await toMatchElement(instance, selector, options);
if (!checkIsSelectElement(element)) {
throw new Error(`Element is not a <select> element.`);
}
const optionElements = await element.$$("option");
const optionsAttributes = await Promise.all(optionElements.map(async (option)=>{
const textContentProperty = await option.getProperty("textContent");
const valueProperty = await option.getProperty("value");
return {
value: await valueProperty.jsonValue(),
textContent: await textContentProperty.jsonValue()
};
}));
const option = optionsAttributes.find(({ value, textContent })=>value === valueOrText || textContent === valueOrText);
if (!option) {
throw new Error(`Option not found "${resolveSelector(selector).value}" ("${valueOrText}")`);
}
await element.select(option.value);
}
const checkIsInputElement = (element)=>{
return typeof element.uploadFile === "function";
};
async function toUploadFile(instance, selector, filePaths, options = {}) {
const element = await toMatchElement(instance, selector, options);
if (!checkIsInputElement(element)) {
throw new Error(`Element is not an input element`);
}
const resolvedFilePaths = Array.isArray(filePaths) ? filePaths : [
filePaths
];
return element.uploadFile(...resolvedFilePaths);
}
// import modules
// ---------------------------
// @ts-expect-error global node object w/ initial jest expect prop attached
const jestExpect = global.expect;
// ---------------------------
// wrapper executing the matcher and capturing the stack trace on error before rethrowing
const wrapMatcher = (matcher, instance)=>async function throwingMatcher(...args) {
// update the assertions counter
jestExpect.getState().assertionCalls += 1;
try {
// run async matcher
const result = await matcher(instance, ...args);
// resolve
return result;
} catch (err) {
if (err instanceof Error) Error.captureStackTrace(err, throwingMatcher);
// reject
throw err;
}
};
// ---------------------------
// create the generic expect object and bind wrapped matchers to it
const puppeteerExpect = (instance)=>{
// read instance type
const [isPage, isFrame, isHandle] = [
checkIsPage(instance),
checkIsFrame(instance),
checkIsElementHandle(instance)
];
if (!isPage && !isFrame && !isHandle) throw new Error(`${instance.constructor.name} is not supported`);
// retrieve matchers
const expectation = {
// common
toClick: wrapMatcher(toClick, instance),
toFill: wrapMatcher(toFill, instance),
toFillForm: wrapMatcher(toFillForm, instance),
toMatchTextContent: wrapMatcher(toMatchTextContent, instance),
toMatchElement: wrapMatcher(toMatchElement, instance),
toSelect: wrapMatcher(toSelect, instance),
toUploadFile: wrapMatcher(toUploadFile, instance),
// page
toDisplayDialog: isPage ? wrapMatcher(toDisplayDialog, instance) : undefined,
// inverse matchers
not: {
toMatchTextContent: wrapMatcher(notToMatchTextContent, instance),
toMatchElement: wrapMatcher(notToMatchElement, instance)
}
};
return expectation;
};
// ---------------------------
// merge puppeteer matchers w/ jest matchers and return a new object
const expectPuppeteer = (actual)=>{
// puppeteer
if (checkIsPuppeteerInstance(actual)) {
const matchers = puppeteerExpect(actual);
const jestMatchers = jestExpect(actual);
return {
...jestMatchers,
...matchers,
not: {
...jestMatchers.not,
...matchers.not
}
};
}
// not puppeteer (fall back to jest defaults, puppeteer matchers not available)
return jestExpect(actual);
};
Object.keys(jestExpect).forEach((prop)=>{
// @ts-expect-error add jest expect properties to expect-puppeteer implementation
expectPuppeteer[prop] = jestExpect[prop];
});
// replace jest expect by expect-puppeteer ...
if (typeof global.expect !== `undefined`) global.expect = expectPuppeteer;
exports.expect = expectPuppeteer;
exports.getDefaultOptions = getDefaultOptions;
exports.setDefaultOptions = setDefaultOptions;
;