mirror of
https://github.com/hanzoai/next.js.git
synced 2026-09-24 17:00:51 +00:00
The idea here is that we waste a bunch of CPU setting up each test suite by spawning an entirely new Chrome process, but playwright can share the same browser across many tests. Tests are still isolated at the browser level, just not at the OS process level. Hoping to see some performance improvement in e2e tests. Compare to: https://github.com/vercel/next.js/pull/95617 Somewhat unscientific (single sample) results: ``` Comparing the head-commit build-and-test runs — PR #95589 run [28981702219 ](https://github.com/vercel/next.js/actions/runs/28981702219)vs PR #95617 run [28981686692](https://github.com/vercel/next.js/actions/runs/28981686692). Both ran 101 jobs and both succeeded. ┌─────────────────────────┬────────────────┬───────────────────────┬───────────────────┐ │ Metric │ #95617 control │ #95589 shared browser │ Delta │ ├─────────────────────────┼────────────────┼───────────────────────┼───────────────────┤ │ Raw wall-time sum │ 616.2 min │ 571.2 min │ −45.0 min (−7.3%) │ ├─────────────────────────┼────────────────┼───────────────────────┼───────────────────┤ │ Billable (ceil per job) │ 658 min │ 617 min │ −41 min (−6.2%) │ └─────────────────────────┴────────────────┴───────────────────────┴───────────────────┘ The shared-browser experiment is cheaper, and the savings land almost exactly where you'd expect — the browser-driven test suites — while non-browser jobs (rust, lint, unit, windows) are flat within noise: ┌─────────────────────────────────┬─────────┬────────┬───────┐ │ Job category │ control │ shared │ delta │ ├─────────────────────────────────┼─────────┼────────┼───────┤ │ test prod │ 130.7 │ 110.5 │ −20.2 │ ├─────────────────────────────────┼─────────┼────────┼───────┤ │ cache components dev │ 51.5 │ 42.9 │ −8.7 │ ├─────────────────────────────────┼─────────┼────────┼───────┤ │ turbopack dev │ 75.1 │ 69.5 │ −5.5 │ ├─────────────────────────────────┼─────────┼────────┼───────┤ │ turbopack production │ 86.3 │ 81.1 │ −5.2 │ ├─────────────────────────────────┼─────────┼────────┼───────┤ │ test dev │ 110.4 │ 105.5 │ −4.9 │ ├─────────────────────────────────┼─────────┼────────┼───────┤ │ cache components prod │ 48.7 │ 49.0 │ +0.3 │ ├─────────────────────────────────┼─────────┼────────┼───────┤ │ firefox and safari │ 5.3 │ 7.3 │ +2.0 │ ├─────────────────────────────────┼─────────┼────────┼───────┤ │ flake-detection jobs (combined) │ ~15 │ ~21 │ +~6 │ └─────────────────────────────────┴─────────┴────────┴───────┘ Takeaways: - Net savings of ~41 billable minutes per run (~6%), concentrated in the Chromium-driven test prod/dev, turbopack, and cache components suites — consistent with reusing one browser process instead of spawning per-suite. - The small regressions are in firefox and safari and the "new/changed tests for flakes" jobs (+~8 min combined). Worth a glance, though they may just be run-to-run variance. Caveat: this is a single run per PR, so there's real variance run-to-run. That said, the fact that the deltas track the browser-heavy jobs specifically — and not the Rust/lint/unit jobs — is a good signal the effect is genuine rather than noise. If you want a firmer number, re-running each PR 2–3× and averaging would tighten it up. ```
743 lines
23 KiB
TypeScript
743 lines
23 KiB
TypeScript
import fs from 'fs-extra'
|
|
import { debugPrint } from 'next-test-utils'
|
|
import {
|
|
Browser,
|
|
BrowserContext,
|
|
Page,
|
|
ElementHandle,
|
|
devices,
|
|
Locator,
|
|
Request as PlaywrightRequest,
|
|
Response as PlaywrightResponse,
|
|
BrowserContextOptions,
|
|
} from 'playwright'
|
|
import path from 'path'
|
|
import { getBrowserLaunch } from './launch'
|
|
|
|
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,
|
|
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)
|
|
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) {
|
|
// Headless mode is controlled exclusively by the HEADLESS env var. It
|
|
// can't be a per-test option because the browser is shared: either
|
|
// launched once per process, or via the shared browser server below.
|
|
const headless = !!process.env.HEADLESS
|
|
const { browserType, launchOptions } = getBrowserLaunch(browserName, {
|
|
headless,
|
|
})
|
|
|
|
// `run-tests.js` launches a browser server that's shared across all test
|
|
// suites. Connect to it if it's available, otherwise (e.g. when running a
|
|
// test directly via the jest CLI) launch a browser owned by this process.
|
|
// The shared server is always headless (`run-tests.js` sets HEADLESS=true
|
|
// for all test suites), so only connect when we're headless too.
|
|
const wsEndpoint = process.env.NEXT_TEST_BROWSER_WS_ENDPOINT
|
|
if (wsEndpoint && headless) {
|
|
return await browserType.connect(wsEndpoint)
|
|
}
|
|
return await browserType.launch(launchOptions)
|
|
}
|
|
|
|
async get(url: string): Promise<void> {
|
|
await page.goto(url)
|
|
}
|
|
|
|
async loadPage(
|
|
url: string,
|
|
opts?: {
|
|
disableCache?: boolean
|
|
cpuThrottleRate?: number
|
|
pushErrorAsConsoleLog?: boolean
|
|
/**
|
|
* Suppress the harness from echoing the browser's console output to the
|
|
* test's terminal (the `Browser Log:` lines). Browser logs are still
|
|
* collected and available via `browser.log()`. Useful when a test
|
|
* captures and asserts on terminal output, where the echo would be noise.
|
|
*/
|
|
disableBrowserLog?: 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) => {
|
|
if (!opts?.disableBrowserLog) {
|
|
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(opts?: { waitUntil?: PlaywrightNavigationWaitUntil }) {
|
|
// do not preserve the previous chained value, it's likely to be invalid after a reload.
|
|
return this.startChain(async () => {
|
|
await page.reload({ waitUntil: opts?.waitUntil ?? 'load' })
|
|
})
|
|
}
|
|
/**
|
|
* Evict the browser HTTP cache via CDP (`Network.clearBrowserCache`). This is
|
|
* only supported in Chromium; gate the calling test on `global.browserName
|
|
* === 'chrome'` (Playwright's Firefox and WebKit don't expose CDP).
|
|
*/
|
|
clearBrowserCache() {
|
|
return this.startChain(async () => {
|
|
const session = await context!.newCDPSession(page)
|
|
try {
|
|
await session.send('Network.clearBrowserCache')
|
|
} finally {
|
|
await session.detach()
|
|
}
|
|
})
|
|
}
|
|
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,
|
|
})
|
|
}
|
|
}
|