import fs from 'fs-extra'
import { debugPrint } from 'next-test-utils'
import {
chromium,
webkit,
firefox,
Browser,
BrowserContext,
Page,
ElementHandle,
devices,
Locator,
Request as PlaywrightRequest,
Response as PlaywrightResponse,
BrowserContextOptions,
} from 'playwright'
import path from 'path'
type EventType = 'request' | 'response'
type PageLog = { source: string; message: string; args: unknown[] }
export type Permissions = BrowserContextOptions['permissions']
let page: Page
let browser: Browser | undefined
let context: BrowserContext | undefined
let contextHasJSEnabled: boolean = true
let contextPermissions: Permissions = undefined
let pageLogs: Array<Promise<PageLog> | PageLog> = []
let websocketFrames: Array<{ payload: string | Buffer }> = []
const tracePlaywright = process.env.TRACE_PLAYWRIGHT
const defaultTimeout = process.env.NEXT_E2E_TEST_TIMEOUT
? parseInt(process.env.NEXT_E2E_TEST_TIMEOUT, 10)
: // In development mode, compilation can take longer due to lower CPU
// availability in GitHub Actions.
60 * 1000
// loose global to register teardown functions before quitting the browser instance.
// This is due to `quit` can be called anytime outside of Playwright's lifecycle,
// which can create corrupted state by terminating the context.
// [TODO] global `quit` might need to be removed, instead should introduce per-instance teardown
const pendingTeardown: Array<() => Promise<void>> = []
export async function quit() {
await Promise.all(pendingTeardown.map((fn) => fn()))
await context?.close()
await browser?.close()
context = undefined
browser = undefined
}
async function teardown(tearDownFn: () => Promise<void>) {
pendingTeardown.push(tearDownFn)
await tearDownFn()
pendingTeardown.splice(pendingTeardown.indexOf(tearDownFn), 1)
}
interface ElementHandleExt extends ElementHandle {
getComputedCss(prop: string): Promise<string>
text(): Promise<string>
}
export type ElementByCssOpts = {
timeout?: number
/**
* The state of the DOM element.
* @default 'visible'
*/
state?: 'attached' | 'visible' | 'hidden'
/**
* The state of the page.
* @default 'load'
*/
waitUntil?: false | 'load' | 'domcontentloaded' | 'networkidle'
}
export type PlaywrightNavigationWaitUntil =
| 'load'
| 'domcontentloaded'
| 'networkidle'
| 'commit'
export class Playwright<TCurrent = undefined> {
private activeTrace?: string
private eventCallbacks: Record<EventType, Set<(...args: any[]) => void>> = {
request: new Set(),
response: new Set(),
}
private async initContextTracing(url: string, context: BrowserContext) {
if (!tracePlaywright) {
return
}
try {
// Clean up if any previous traces are still active
await teardown(this.teardownTracing.bind(this))
await context.tracing.start({
screenshots: true,
snapshots: true,
sources: true,
})
this.activeTrace = encodeURIComponent(url)
} catch (e) {
this.activeTrace = undefined
}
}
private async teardownTracing() {
if (!this.activeTrace) {
return
}
try {
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 (e) {
require('console').warn('Failed to teardown playwright tracing', e)
} finally {
this.activeTrace = undefined
}
}
on(
event: 'request',
cb: (request: PlaywrightRequest) => void | Promise<void>
): void
on(
event: 'response',
cb: (request: PlaywrightResponse) => void | Promise<void>
): void
on(event: EventType, cb: (...args: any[]) => void) {
if (!this.eventCallbacks[event]) {
throw new Error(
`Invalid event passed to browser.on, received ${event}. Valid events are ${Object.keys(
this.eventCallbacks
)}`
)
}
this.eventCallbacks[event]?.add(cb)
}
off(
event: 'request',
cb: (request: PlaywrightRequest) => void | Promise<void>
): void
off(
event: 'response',
cb: (request: PlaywrightResponse) => void | Promise<void>
): void
off(event: EventType, cb: (...args: any[]) => void) {
this.eventCallbacks[event]?.delete(cb)
}
async setup(
browserName: string,
locale: string,
javaScriptEnabled: boolean,
ignoreHTTPSErrors: boolean,
headless: boolean,
userAgent: string | undefined,
permissions: Permissions
) {
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 (browser) {
if (
contextHasJSEnabled !== javaScriptEnabled ||
// Even triggers on same set of permissions, but we don't want to deal
// with the complexity of diffing them, so we just always recreate the
// context when permissions are set.
contextPermissions !== permissions
) {
// If we have switched from having JS enable/disabled we need to recreate the context.
await teardown(this.teardownTracing.bind(this))
await context?.close()
context = await browser.newContext({
locale,
javaScriptEnabled,
ignoreHTTPSErrors,
...(userAgent ? { userAgent } : {}),
...device,
permissions,
})
contextHasJSEnabled = javaScriptEnabled
contextPermissions = permissions
}
return
}
browser = await this.launchBrowser(browserName, { headless })
context = await browser.newContext({
locale,
javaScriptEnabled,
ignoreHTTPSErrors,
...(userAgent ? { userAgent } : {}),
...device,
permissions,
})
contextHasJSEnabled = javaScriptEnabled
}
async close(): Promise<void> {
await teardown(this.teardownTracing.bind(this))
await page?.close()
}
async launchBrowser(
browserName: string,
launchOptions: { headless: boolean }
) {
if (browserName === 'safari') {
return await webkit.launch(launchOptions)
} else if (browserName === 'firefox') {
return await firefox.launch({
...launchOptions,
firefoxUserPrefs: {
// The "fission.webContentIsolationStrategy" pref must be
// set to 1 on Firefox due to the bug where a new history
// state is pushed on a page reload.
// See https://github.com/microsoft/playwright/issues/22640
// See https://bugzilla.mozilla.org/show_bug.cgi?id=1832341
'fission.webContentIsolationStrategy': 1,
},
})
} else {
let launchArgs: string[] = []
if (!launchOptions.headless) {
launchArgs.push('--auto-open-devtools-for-tabs')
}
return await chromium.launch({
...launchOptions,
args: launchArgs,
ignoreDefaultArgs: ['--disable-back-forward-cache'],
})
}
}
async get(url: string): Promise<void> {
await page.goto(url)
}
async loadPage(
url: string,
opts?: {
disableCache?: boolean
cpuThrottleRate?: number
pushErrorAsConsoleLog?: boolean
beforePageLoad?: (page: Page) => void | Promise<void>
/**
* @see {@link https://playwright.dev/docs/api/class-page#page-set-extra-http-headers Playwright.Page.setExtraHTTPHeaders}
*/
extraHTTPHeaders?: Record<string, string>
waitUntil?: PlaywrightNavigationWaitUntil
}
) {
await this.close()
// clean-up existing pages
for (const oldPage of context!.pages()) {
await oldPage.close()
}
await this.initContextTracing(url, context!)
page = await context!.newPage()
page.setDefaultTimeout(defaultTimeout)
page.setDefaultNavigationTimeout(defaultTimeout)
const extraHTTPHeaders = opts?.extraHTTPHeaders
if (extraHTTPHeaders !== undefined) {
page.setExtraHTTPHeaders(extraHTTPHeaders)
}
pageLogs = []
websocketFrames = []
page.on('console', (msg) => {
debugPrint('Browser Log:', msg)
pageLogs.push(
Promise.all(
msg.args().map((handle) => handle.jsonValue().catch(() => {}))
).then((args) => ({ source: msg.type(), message: msg.text(), args }))
)
})
page.on('crash', () => {
console.error('page crashed')
})
page.on('pageerror', (error) => {
console.error('page error', error)
if (opts?.pushErrorAsConsoleLog) {
pageLogs.push({ source: 'error', message: error.message, args: [] })
}
})
page.on('request', (req) => {
this.eventCallbacks.request.forEach((cb) => cb(req))
})
page.on('response', (res) => {
this.eventCallbacks.response.forEach((cb) => cb(res))
})
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 })
}
if (opts?.cpuThrottleRate) {
const session = await context!.newCDPSession(page)
// https://chromedevtools.github.io/devtools-protocol/tot/Emulation/#method-setCPUThrottlingRate
session.send('Emulation.setCPUThrottlingRate', {
rate: opts.cpuThrottleRate,
})
}
page.on('websocket', (ws) => {
const decoder = tracePlaywright ? new TextDecoder() : null
if (tracePlaywright) {
// We're just evaluating a string here so that it appears in Playwright
// traces.
// console.log spams CI logs. If you already have a browser open, you can
// see WebSocket messages in the network tab of dev tools.
// TODO: Revisit once https://github.com/microsoft/playwright/issues/10996 is resolved.
page.evaluate(`'connected to ws at ${ws.url()}'`).catch(() => {})
ws.on('close', () =>
page.evaluate(`'closed websocket ${ws.url()}'`).catch(() => {})
)
}
ws.on('framereceived', (frame) => {
websocketFrames.push({ payload: frame.payload })
if (tracePlaywright) {
const { payload } = frame
page
// Note that passing the payload as a an argument is 2 orders of magnitude more expensive in Playwright.
.evaluate(
`'received ws message ${JSON.stringify(typeof payload === 'string' ? payload : decoder!.decode(payload))}'`
)
.catch(() => {})
}
})
})
await opts?.beforePageLoad?.(page)
await page.goto(url, { waitUntil: opts?.waitUntil ?? 'load' })
}
back(options?: Parameters<Page['goBack']>[0]) {
// do not preserve the previous chained value, it might be invalid after a navigation.
return this.startChain(async () => {
await page.goBack(options)
})
}
forward(options?: Parameters<Page['goForward']>[0]) {
// do not preserve the previous chained value, it might be invalid after a navigation.
return this.startChain(async () => {
await page.goForward(options)
})
}
refresh() {
// do not preserve the previous chained value, it's likely to be invalid after a reload.
return this.startChain(async () => {
await page.reload()
})
}
setDimensions({ width, height }: { height: number; width: number }) {
return this.startOrPreserveChain(() =>
page.setViewportSize({ width, height })
)
}
addCookie(opts: { name: string; value: string }) {
return this.startOrPreserveChain(async () =>
context!.addCookies([
{
path: '/',
domain: await page.evaluate('window.location.hostname'),
...opts,
},
])
)
}
deleteCookies() {
return this.startOrPreserveChain(async () => context!.clearCookies())
}
private wrapElement(el: ElementHandle, selector: string): ElementHandleExt {
function getComputedCss(prop: string) {
return page.evaluate(
function (args) {
const style = getComputedStyle(document.querySelector(args.selector)!)
return style[args.prop] || null
},
{ selector, prop }
)
}
return Object.assign(el, {
selector,
getComputedCss,
text: () => el.innerText(),
})
}
elementByCss(selector: string, opts?: ElementByCssOpts) {
return this.waitForElementByCss(selector, {
timeout: 5_000,
...opts,
})
}
/** A replacement for the default `browser.elementByCss` that doesn't wait for the page to fire "load". */
elementByCssInstant(selector: string, opts?: ElementByCssOpts) {
return this.waitForElementByCss(selector, {
timeout: 10,
waitUntil: false,
...opts,
})
}
hasElementByCss(selector: string) {
return this.startChain(() => page.locator(selector).isVisible())
}
elementById(id: string) {
return this.elementByCss(`#${id}`)
}
getValue(this: Playwright<ElementHandleExt>) {
return this.continueChain((el) => el.inputValue())
}
text(this: Playwright<ElementHandleExt>) {
return this.continueChain((el) => el.innerText())
}
type(this: Playwright<ElementHandleExt>, text: string) {
return this.continueChain(async (el) => {
await el.type(text)
return el
})
}
moveTo(this: Playwright<ElementHandleExt>) {
return this.continueChain(async (el) => {
await el.hover()
return el
})
}
async getComputedCss(this: Playwright<ElementHandleExt>, prop: string) {
return this.continueChain((el) => el.getComputedCss(prop))
}
async getAttribute(this: Playwright<ElementHandleExt>, attr: string) {
return this.continueChain((el) => el.getAttribute(attr))
}
hasElementByCssSelector(selector: string) {
return this.eval<boolean>(`!!document.querySelector('${selector}')`)
}
keydown(key: string) {
return this.startOrPreserveChain(() => page.keyboard.down(key))
}
keyup(key: string) {
return this.startOrPreserveChain(() => page.keyboard.up(key))
}
click(this: Playwright<ElementHandleExt>) {
return this.continueChain(async (el) => {
await el.click()
return el
})
}
touchStart(this: Playwright<ElementHandleExt>) {
return this.continueChain(async (el) => {
await el.dispatchEvent('touchstart')
return el
})
}
elementsByCss(selector: string) {
return this.startChain(() =>
page.$$(selector).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
})
})
)
}
waitForElementByCss(selector: string, opts: number | ElementByCssOpts = {}) {
const {
timeout = 10_000,
waitUntil = 'load', // TODO: we should get rid of this and fix the tests that implicitly rely on it
// Selected elements may be in a completed boundary that React hasn't revealed yet.
// We almost always want to wait for the reveal.
// This matches Playwright's default behavior.
// We don't care about visibility of metadata tags.
// Can hopefully be dropped if https://github.com/microsoft/playwright/pull/37265 is accepted
state = selector.startsWith('base') ||
selector.startsWith('link') ||
selector.startsWith('meta') ||
selector.startsWith('script') ||
selector.startsWith('source') ||
selector.startsWith('style') ||
selector.startsWith('title')
? 'attached'
: 'visible',
} = typeof opts === 'number' ? { timeout: opts } : opts
return this.startChain(async () => {
const el = await page.waitForSelector(selector, {
timeout,
state,
})
if (waitUntil !== false) {
// it seems selenium waits longer and tests rely on this behavior
// so we wait for the load event fire before returning
await page.waitForLoadState(waitUntil)
}
return this.wrapElement(
// Playwright has `null` as a possible return type in case `state` is `detached`,
// but we don't allow passing that here, so we can assume it's non-null
el!,
selector
)
})
}
waitForCondition(snippet: string, timeout?: number) {
return this.startOrPreserveChain(async () => {
await page.waitForFunction(snippet, { timeout })
})
}
// TODO: this should default to unknown, but a lot of tests use and rely on the result being `any`
eval<TFn extends (...args: any[]) => any>(
fn: TFn,
...args: Parameters<TFn>
): Playwright<ReturnType<TFn>> & Promise<ReturnType<TFn>>
// TODO: this is ugly, the type parameter is basically a hidden cast
eval<T = any>(fn: string, ...args: any[]): Playwright<T> & Promise<T>
eval<T = any>(
fn: string | ((...args: any[]) => any),
...args: any[]
): Playwright<T> & Promise<T>
eval(
fn: string | ((...args: any[]) => any),
...args: any[]
): Playwright<any> & Promise<any> {
return this.startChain(async () =>
page
.evaluate(fn, ...args)
.catch((err) => {
// TODO: gross, why are we doing this
console.error('eval error:', err)
return null!
})
.finally(async () => {
await page.waitForLoadState()
})
)
}
async log<T extends boolean = false>(options?: { includeArgs?: T }) {
return this.startChain(
() =>
options?.includeArgs
? Promise.all(pageLogs)
: Promise.all(pageLogs).then((logs) =>
logs.map(({ source, message }) => ({ source, message }))
)
// TODO: Starting with TypeScript 5.8 we might not need this type cast.
) as Promise<
T extends true
? { source: string; message: string; args: unknown[] }[]
: { source: string; message: string }[]
>
}
async websocketFrames() {
return this.startChain(() => websocketFrames)
}
async url() {
return this.startChain(() => page.url())
}
async waitForIdleNetwork() {
return this.startOrPreserveChain(() => {
return page.waitForLoadState('networkidle')
})
}
getByRole(
role: Parameters<(typeof page)['getByRole']>[0],
options?: Parameters<(typeof page)['getByRole']>[1]
) {
return page.getByRole(role, options)
}
locateRedbox(): Locator {
return page.locator(
'nextjs-portal [aria-labelledby="nextjs__container_errors_label"]'
)
}
locateDevToolsIndicator(): Locator {
return page.locator('nextjs-portal [data-nextjs-dev-tools-button]:visible')
}
locator(selector: string, options?: Parameters<(typeof page)['locator']>[1]) {
return page.locator(selector, options)
}
/** A call that expects to be chained after a previous call, because it needs its value. */
private continueChain<TNext>(nextCall: (value: TCurrent) => Promise<TNext>) {
return this._chain(true, nextCall)
}
/** Start a chain. If continuing, it overwrites the current chained value. */
private startChain<TNext>(nextCall: () => TNext | Promise<TNext>) {
return this._chain(false, nextCall)
}
/** Either start or continue a chain. If continuing, it preserves the current chained value. */
private startOrPreserveChain(nextCall: () => Promise<void>) {
return this._chain(false, async (value) => {
await nextCall()
return value
})
}
// necessary for the type of the function below
readonly [Symbol.toStringTag]: string = 'Playwright'
private _chain<TNext>(
this: Playwright<TCurrent>,
mustBeChained: boolean,
nextCall: (current: TCurrent) => TNext | Promise<TNext>
): Playwright<TNext> & Promise<TNext> {
const syncError = new Error('next-browser-base-chain-error')
// If `this` is actually a proxy created by a previous chained call, it'll act like it has a `promise` property.
// (see proxy code below)
type MaybeChained<T> = Playwright<T> & {
promise?: Promise<T>
}
const self = this as MaybeChained<TCurrent>
let currentPromise = self.promise
if (!currentPromise) {
if (mustBeChained) {
// Note that this should also be enforced by the type system
// by adding appropriate `(this: Playwright<PreviousValue>)` type annotations
// to methods that expect to be chained, but tests can bypass this (or not be checked because they use JS)
throw new Error(
'Expected this call to be chained after a previous call'
)
} else {
// We're handling a call that does not expect to be chained after a previous one,
// so it's safe to default the current value to undefined -- we don't need a value to invoke `nextCall`
currentPromise = Promise.resolve(undefined as TCurrent)
}
}
const promise = currentPromise.then(nextCall).catch((reason: unknown) => {
// TODO: only patch the stacktrace if the sync callstack is missing from it
if (reason && typeof reason === 'object' && 'stack' in reason) {
const syncCallStack = syncError.stack!.split(syncError.message)[1]
reason.stack += `\n${syncCallStack}`
}
throw reason
})
function get(target: Playwright<TCurrent>, p: string | symbol): any {
switch (p) {
case 'promise':
return promise
case 'then':
return promise.then.bind(promise)
case 'catch':
return promise.catch.bind(promise)
case 'finally':
return promise.finally.bind(promise)
default:
return target[p]
}
}
// @ts-expect-error: we're changing `TCurrent` into TNext via proxy hacks
return new Proxy(this, {
get,
})
}
}