import { BrowserInterface, Event } from './base' import fs from 'fs-extra' import { chromium, webkit, firefox, Browser, BrowserContext, Page, ElementHandle, devices } from 'playwright-chromium' import path from 'path' import destr from 'destr' let page: Page let browser: Browser let context: BrowserContext let pageLogs: Array<{ source: string; message: string }> = [] let websocketFrames: Array<{ payload: string | Buffer }> = [] const tracePlaywright = process.env.TRACE_PLAYWRIGHT export async function quit() { await context?.close() await browser?.close() } export class Playwright extends BrowserInterface { private activeTrace?: string private eventCallbacks: Record void>> = { request: new Set(), } on(event: Event, cb: (...args: any[]) => void) { if (!this.eventCallbacks[event]) { throw new Error(`Invalid event passed to browser.on, received ${event}. Valid events are ${Object.keys(event)}`) } this.eventCallbacks[event]?.add(cb) } off(event: Event, cb: (...args: any[]) => void) { this.eventCallbacks[event]?.delete(cb) } async setup(browserName: string, locale?: string) { if (browser) return // Headless by default const headless = destr(process.env.HEADLESS) ?? true let device if (process.env.DEVICE_NAME) { device = devices[process.env.DEVICE_NAME] if (!device) { throw new Error(`Invalid playwright device name ${process.env.DEVICE_NAME}`) } } if (browserName === 'safari') { browser = await webkit.launch({ headless }) } else if (browserName === 'firefox') { browser = await firefox.launch({ headless }) } else { browser = await chromium.launch({ headless, devtools: !headless }) } context = await browser.newContext({ locale, ...device }) } async get(url: string): Promise { return page.goto(url) as any } async loadPage(url: string, opts?: { disableCache: boolean; beforePageLoad?: (...args: any[]) => void }) { if (this.activeTrace) { const traceDir = path.join(__dirname, '../../traces') const traceOutputPath = path.join( traceDir, `${path.relative(path.join(__dirname, '../../'), process.env.TEST_FILE_PATH ?? '').replace(/\//g, '-')}`, `playwright-${this.activeTrace}-${Date.now()}.zip`, ) await fs.remove(traceOutputPath) await context.tracing .stop({ path: traceOutputPath, }) .catch((err) => console.error('failed to write playwright trace', err)) } // clean-up existing pages for (const oldPage of context.pages()) { await oldPage.close() } page = await context.newPage() pageLogs = [] websocketFrames = [] page.on('console', (msg) => { console.log('browser log:', msg) pageLogs.push({ source: msg.type(), message: msg.text() }) }) page.on('crash', (page) => { console.error('page crashed') }) page.on('pageerror', (error) => { console.error('page error', error) }) page.on('request', (req) => { this.eventCallbacks.request.forEach((cb) => cb(req)) }) if (opts?.disableCache) { // TODO: this doesn't seem to work (dev tools does not check the box as expected) const session = await context.newCDPSession(page) session.send('Network.setCacheDisabled', { cacheDisabled: true }) } page.on('websocket', (ws) => { if (tracePlaywright) { page.evaluate(`console.log('connected to ws at ${ws.url()}')`).catch(() => {}) ws.on('close', () => page.evaluate(`console.log('closed websocket ${ws.url()}')`).catch(() => {})) } ws.on('framereceived', (frame) => { websocketFrames.push({ payload: frame.payload }) if (tracePlaywright) { if (!frame.payload.includes('pong')) { page.evaluate(`console.log('received ws message ${frame.payload}')`).catch(() => {}) } } }) }) opts?.beforePageLoad?.(page) if (tracePlaywright) { await context.tracing.start({ screenshots: true, snapshots: true, }) this.activeTrace = encodeURIComponent(url) } await page.goto(url, { waitUntil: 'load' }) } back(): BrowserInterface { return this.chain(() => { return page.goBack() }) } forward(): BrowserInterface { return this.chain(() => { return page.goForward() }) } refresh(): BrowserInterface { return this.chain(() => { return page.reload() }) } setDimensions({ width, height }: { height: number; width: number }): BrowserInterface { return this.chain(() => page.setViewportSize({ width, height })) } addCookie(opts: { name: string; value: string }): BrowserInterface { return this.chain(async () => context.addCookies([ { path: '/', domain: await page.evaluate('window.location.hostname'), ...opts, }, ]), ) } deleteCookies(): BrowserInterface { return this.chain(async () => context.clearCookies()) } focusPage() { return this.chain(() => page.bringToFront()) } private wrapElement(el: ElementHandle, selector: string) { ;(el as any).selector = selector ;(el as any).text = () => el.innerText() ;(el as any).getComputedCss = (prop) => page.evaluate( function (args) { return getComputedStyle(document.querySelector(args.selector)!)[args.prop] || null }, { selector, prop }, ) ;(el as any).getCssValue = (el as any).getComputedCss ;(el as any).getValue = () => page.evaluate( function (args) { return (document.querySelector(args.selector) as any).value }, { selector }, ) return el } elementByCss(selector: string) { return this.waitForElementByCss(selector) } elementById(sel) { return this.elementByCss(`#${sel}`) } getValue() { return this.chain((el) => page.evaluate( function (args) { return document.querySelector(args.selector).value }, { selector: el.selector }, ), ) as any } text() { return this.chain((el) => el.text()) as any } type(text) { return this.chain((el) => el.type(text)) } moveTo() { return this.chain((el) => { return page.hover(el.selector).then(() => el) }) } async getComputedCss(prop: string) { return this.chain((el) => { return el.getCssValue(prop) }) as any } async getAttribute(attr) { return this.chain((el) => el.getAttribute(attr)) } async hasElementByCssSelector(selector: string) { return this.eval(`!!document.querySelector('${selector}')`) as any } keydown(key: string): BrowserInterface { return this.chain((el) => { return page.keyboard.down(key).then(() => el) }) } keyup(key: string): BrowserInterface { return this.chain((el) => { return page.keyboard.up(key).then(() => el) }) } click() { return this.chain((el) => { return el.click().then(() => el) }) } touchStart() { return this.chain((el: ElementHandle) => { return el.dispatchEvent('touchstart').then(() => el) }) } elementsByCss(sel) { return this.chain(() => page.$$(sel).then((els) => { return els.map((el) => { const origGetAttribute = el.getAttribute.bind(el) el.getAttribute = (name) => { // ensure getAttribute defaults to empty string to // match selenium return origGetAttribute(name).then((val) => val || '') } return el }) }), ) as any as BrowserInterface[] } waitForElementByCss(selector, timeout?: number) { return this.chain(() => { return page.waitForSelector(selector, { timeout, state: 'attached' }).then(async (el) => { // it seems selenium waits longer and tests rely on this behavior // so we wait for the load event fire before returning await page.waitForLoadState() return this.wrapElement(el, selector) }) }) } waitForCondition(condition, timeout) { return this.chain(() => { return page.waitForFunction(condition, { timeout }) }) } async eval(snippet) { // TODO: should this and evalAsync be chained? Might lead // to bad chains return page .evaluate(snippet) .catch((err) => { console.error('eval error:', err) return null }) .then(async (val) => { await page.waitForLoadState() return val }) } async evalAsync(snippet) { if (typeof snippet === 'function') { snippet = snippet.toString() } if (snippet.includes(`var callback = arguments[arguments.length - 1]`)) { snippet = `(function() { return new Promise((resolve, reject) => { const origFunc = ${snippet} try { origFunc(resolve) } catch (err) { reject(err) } }) })()` } return page.evaluate(snippet).catch(() => null) } async log() { return this.chain(() => pageLogs) as any } async websocketFrames() { return this.chain(() => websocketFrames) as any } async url() { return this.chain(() => page.evaluate('window.location.href')) as any } }