import puppeteer, { type Browser, type ClickOptions, type LaunchOptions, type Page, } from 'puppeteer' export const E2E_TIMEOUT: number = 30 * 1000 const puppeteerOptions: LaunchOptions = { args: process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [], headless: true, } const maxTries = 30 export const timeout = (n: number): Promise => new Promise(r => setTimeout(r, n)) export async function expectByPolling( poll: () => Promise, expected: string, ): Promise { for (let tries = 0; tries < maxTries; tries++) { const actual = (await poll()) || '' if (actual.indexOf(expected) > -1 || tries === maxTries - 1) { expect(actual).toMatch(expected) break } else { await timeout(50) } } } interface PuppeteerUtils { page: () => Page click(selector: string, options?: ClickOptions): Promise domClick(selector: string): Promise count(selector: string): Promise text(selector: string): Promise value(selector: string): Promise html(selector: string): Promise classList(selector: string): Promise style(selector: string, property: keyof CSSStyleDeclaration): Promise children(selector: string): Promise isVisible(selector: string): Promise isChecked(selector: string): Promise isFocused(selector: string): Promise setValue(selector: string, value: string): Promise typeValue(selector: string, value: string): Promise enterValue(selector: string, value: string): Promise clearValue(selector: string): Promise timeout(time: number): Promise nextFrame(): Promise transitionStart( btnSelector: string, containerSelector: string, ): Promise<{ classNames: string[]; innerHTML: string; outerHTML: string }> waitForElement( selector: string, text: string, classNames: string[], timeout?: number, ): Promise waitForInnerHTML( selector: string, expected: string, timeout?: number, ): Promise } export function setupPuppeteer(args?: string[]): PuppeteerUtils { let browser: Browser let page: Page const resolvedOptions = args ? { ...puppeteerOptions, args: [...puppeteerOptions.args!, ...args], } : puppeteerOptions beforeAll(async () => { browser = await puppeteer.launch(resolvedOptions) }, 20000) beforeEach(async () => { page = await browser.newPage() await page.evaluateOnNewDocument(() => { localStorage.clear() }) page.on('console', e => { if (e.type() === 'error') { console.error(`Error from Puppeteer-loaded page:\n`, e.text()) } }) }) afterEach(async () => { await page.close() }) afterAll(async () => { await browser.close() }) async function click( selector: string, options?: ClickOptions, ): Promise { await page.click(selector, options) } async function domClick(selector: string): Promise { await page.$eval(selector, (el: any) => el.click()) } async function count(selector: string): Promise { return (await page.$$(selector)).length } async function text(selector: string): Promise { return page.$eval(selector, node => node.textContent) } async function value(selector: string): Promise { return page.$eval(selector, node => (node as HTMLInputElement).value) } async function html(selector: string): Promise { return page.$eval(selector, node => node.innerHTML) } async function classList(selector: string): Promise { return page.$eval(selector, (node: any) => [...node.classList]) } async function children(selector: string): Promise { return page.$eval(selector, (node: any) => [...node.children]) } async function style( selector: string, property: keyof CSSStyleDeclaration, ): Promise { return await page.$eval( selector, (node, property) => { return window.getComputedStyle(node)[property] }, property, ) } async function isVisible(selector: string): Promise { const display = await page.$eval(selector, node => { return window.getComputedStyle(node).display }) return display !== 'none' } async function isChecked(selector: string) { return await page.$eval( selector, node => (node as HTMLInputElement).checked, ) } async function isFocused(selector: string) { return await page.$eval(selector, node => node === document.activeElement) } async function setValue(selector: string, value: string) { await page.$eval( selector, (node, value) => { ;(node as HTMLInputElement).value = value as string node.dispatchEvent(new Event('input')) }, value, ) } async function typeValue(selector: string, value: string) { const el = (await page.$(selector))! await el.evaluate(node => ((node as HTMLInputElement).value = '')) await el.type(value) } async function enterValue(selector: string, value: string) { const el = (await page.$(selector))! await el.evaluate(node => ((node as HTMLInputElement).value = '')) await el.type(value) await el.press('Enter') } async function clearValue(selector: string) { return await page.$eval( selector, node => ((node as HTMLInputElement).value = ''), ) } function timeout(time: number) { return page.evaluate(time => { return new Promise(r => { setTimeout(r, time) }) }, time) } function nextFrame() { return page.evaluate(() => { return new Promise(resolve => { requestAnimationFrame(() => { requestAnimationFrame(resolve) }) }) }) } const transitionStart = (btnSelector: string, containerSelector: string) => page.evaluate( ([btnSel, containerSel]) => { ;(document.querySelector(btnSel) as HTMLElement)!.click() return Promise.resolve().then(() => { const container = document.querySelector(containerSel)! return { classNames: container.className.split(/\s+/g), innerHTML: container.innerHTML, outerHTML: container.outerHTML, } }) }, [btnSelector, containerSelector], ) const waitForElement = ( selector: string, text: string, classNames: string[], // if empty, check for no classes timeout = 2000, ) => page.waitForFunction( (sel, expectedText, expectedClasses) => { const el = document.querySelector(sel) const hasClasses = expectedClasses.length === 0 ? el?.classList.length === 0 : expectedClasses.every(c => el?.classList.contains(c)) const hasText = el?.textContent?.includes(expectedText) return !!el && hasClasses && hasText }, { timeout }, selector, text, classNames, ) const waitForInnerHTML = ( selector: string, expected: string, timeout = 2000, ) => page.waitForFunction( (sel, exp) => { const el = document.querySelector(sel) return !!el && el.innerHTML === exp }, { timeout }, selector, expected, ) return { page: () => page, click, domClick, count, text, value, html, classList, style, children, isVisible, isChecked, isFocused, setValue, typeValue, enterValue, clearValue, timeout, nextFrame, transitionStart, waitForElement, waitForInnerHTML, } }