UNPKG

expect-puppeteer

Version:
487 lines (470 loc) 18.6 kB
'use strict'; 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;