Files
vercel__next.js/packages/devlow-bench/src/browser.ts
Will Binns-Smith 286169a9e7 Remove turbopack/packages and relocate devlow to packages/ (#96860)
## Summary

`@vercel/devlow-bench` hasn't published since December 2024. The job
that published it was removed in #93064; it was gated on the head commit
message starting with `chore: release turbopack npm packages`, which
squash-merge rewrites, so it couldn't fire through normal review —
#93951 bumped the version correctly and still published nothing.

Rather than rebuild that machinery for `turbopack/packages/*`, this
moves the one remaining package to `packages/`, where lerna and the
existing recursive publish already handle everything.
**`publish-release.js` is unchanged.**

The tradeoff is that the package joins the Next.js lockstep version,
going from `0.4.0` to the current canary, and ships nightly instead of
on manual bumps. At ~10k downloads/month, essentially all CI, nobody is
reading its version for compatibility — not worth ~300 lines of bespoke
release code in the script that gates Next.js releases.

`packages/` already holds unscoped, non-`@next/` packages
(`create-next-app`, `eslint-config-next`, `next-rspack`), all lockstep.

The root eslint config covers `packages/` but never applied to
`turbopack/`, so this exempts the three rules the source doesn't satisfy
and prefixes six unused parameters, rather than reformatting the package
as part of a move.

## Deletions

Three nft packages, last published in 2022, none with a build script or
any consumer:

- `@vercel/experimental-nft` — a stub containing only a `package.json`
- `@vercel/webpack-nft` — resolved binaries built from a
`node-file-trace` crate that no longer exists, so its core codepath
couldn't work
- `@vercel/experimental-nft-next-plugin` — depends on the above

`turbopack/xtask/src/publish.rs` goes with them: its only caller was the
workflow removed in #93064, its `npm` subcommand targeted that same
missing crate, and nothing invokes `cargo xtask`.

`turbopack/packages/` no longer exists.

## Verification

- `publish-release.js --dry-run` — publishes `@vercel/devlow-bench` via
the untouched recursive publish
- `lerna list` and `turbo run build` both resolve it at the new path
- `pnpm run --filter=devlow-bench test` — 3/3, and the CI jobs reference
it by name, not path
- Root eslint and the package's own `lint` script both clean
- `cargo check`/`clippy`/`fmt -p xtask`
- Not run: a real publish, which only CI can exercise

`pnpm-lock.yaml` is hand-edited to repoint the workspace entry and drop
the three removed ones. A full `pnpm install` rewrites ~1800 unrelated
lines, and `--frozen-lockfile` fails identically on unmodified `canary`
from pre-existing `apps/docs` drift.

<!-- NEXT_JS_LLM -->
2026-08-06 20:39:38 -07:00

378 lines
11 KiB
TypeScript

import type {
Browser,
BrowserContext,
ConsoleMessage,
Page,
Request,
Response,
} from 'playwright-chromium'
import { chromium } from 'playwright-chromium'
import { measureTime, reportMeasurement } from './index.js'
interface BrowserSession {
close(): Promise<void>
hardNavigation(metricName: string, url: string): Promise<Page>
softNavigationByClick(metricName: string, selector: string): Promise<void>
reload(metricName: string): Promise<void>
}
const browserOutput = Boolean(process.env.BROWSER_OUTPUT)
async function withRequestMetrics(
metricName: string,
page: Page,
fn: () => Promise<void>
): Promise<void> {
const activePromises: Array<Promise<void>> = []
const sizeByExtension = new Map<string, number>()
const requestsByExtension = new Map<string, number>()
const responseHandler = (response: Response) => {
activePromises.push(
(async () => {
const url = response.request().url()
const status = response.status()
const extension =
/^[^?#]+\.([a-z0-9]+)(?:[?#]|$)/i.exec(url)?.[1] ?? 'none'
const currentRequests = requestsByExtension.get(extension) ?? 0
requestsByExtension.set(extension, currentRequests + 1)
if (status >= 200 && status < 300) {
let body
try {
body = await response.body()
} catch {
// empty
}
if (body) {
const size = body.length
const current = sizeByExtension.get(extension) ?? 0
sizeByExtension.set(extension, current + size)
}
}
})()
)
}
let errorCount = 0
let warningCount = 0
let logCount = 0
const consoleHandler = (message: ConsoleMessage) => {
const type = message.type()
if (type === 'error') {
errorCount++
} else if (type === 'warning') {
warningCount++
} else {
logCount++
}
if (browserOutput) {
activePromises.push(
(async () => {
const args = []
try {
const text = message.text()
for (const arg of message.args()) {
args.push(await arg.jsonValue())
}
console.log(`[${type}] ${text}`, ...args)
} catch {
// Ignore
}
})()
)
}
}
let uncaughtCount = 0
const exceptionHandler = (error: Error) => {
uncaughtCount++
if (browserOutput) {
console.error(`[UNCAUGHT]`, error)
}
}
try {
page.on('response', responseHandler)
page.on('console', consoleHandler)
page.on('pageerror', exceptionHandler)
await fn()
await Promise.all(activePromises)
let totalDownload = 0
for (const [extension, size] of sizeByExtension.entries()) {
await reportMeasurement(
`${metricName}/responseSizes/${extension}`,
size,
'bytes'
)
totalDownload += size
}
await reportMeasurement(
`${metricName}/responseSizes`,
totalDownload,
'bytes'
)
let totalRequests = 0
for (const [extension, count] of requestsByExtension.entries()) {
await reportMeasurement(
`${metricName}/requests/${extension}`,
count,
'requests'
)
totalRequests += count
}
await reportMeasurement(`${metricName}/requests`, totalRequests, 'requests')
await reportMeasurement(`${metricName}/console/logs`, logCount, 'messages')
await reportMeasurement(
`${metricName}/console/warnings`,
warningCount,
'messages'
)
await reportMeasurement(
`${metricName}/console/errors`,
errorCount,
'messages'
)
await reportMeasurement(
`${metricName}/console/uncaught`,
uncaughtCount,
'messages'
)
await reportMeasurement(
`${metricName}/console`,
logCount + warningCount + errorCount + uncaughtCount,
'messages'
)
} finally {
page.off('response', responseHandler)
}
}
/**
* Waits until network requests have all been resolved
* @param page - Playwright page object
* @param delayMs - Amount of time in ms to wait after the last request resolves before cleaning up
* @param timeoutMs - Amount of time to wait before continuing. In case of timeout, this function resolves
* @returns
*/
function networkIdle(
page: Page,
delayMs = 300,
timeoutMs = 180000
): Promise<number> {
return new Promise((resolve) => {
const cleanup = () => {
page.off('request', requestHandler)
page.off('requestfailed', requestFinishedHandler)
page.off('requestfinished', requestFinishedHandler)
clearTimeout(fullTimeout)
if (timeout) {
clearTimeout(timeout)
}
}
const requests = new Map<string, number>()
const start = Date.now()
let lastRequest: number
let timeout: NodeJS.Timeout | null = null
const fullTimeout = setTimeout(() => {
cleanup()
console.error(
`Timeout while waiting for network idle. These requests are still pending: ${Array.from(
requests
).join(', ')}} time is ${lastRequest - start}`
)
resolve(Date.now() - lastRequest)
}, timeoutMs)
const requestFilter = (request: Request) => {
return request.headers().accept !== 'text/event-stream'
}
const requestHandler = (request: Request) => {
requests.set(request.url(), (requests.get(request.url()) ?? 0) + 1)
if (timeout) {
clearTimeout(timeout)
timeout = null
}
// Avoid tracking some requests, but we only know this after awaiting
// so we need to do this weird stunt to ensure that
if (!requestFilter(request)) {
requestFinishedInternal(request)
}
}
const requestFinishedHandler = (request: Request) => {
if (requestFilter(request)) {
requestFinishedInternal(request)
}
}
const requestFinishedInternal = (request: Request) => {
lastRequest = Date.now()
const currentCount = requests.get(request.url())
if (currentCount === undefined) {
console.error(
`Unexpected untracked but completed request ${request.url()}`
)
return
}
if (currentCount === 1) {
requests.delete(request.url())
} else {
requests.set(request.url(), currentCount - 1)
}
if (requests.size === 0) {
timeout = setTimeout(() => {
cleanup()
resolve(Date.now() - lastRequest)
}, delayMs)
}
}
page.on('request', requestHandler)
page.on('requestfailed', requestFinishedHandler)
page.on('requestfinished', requestFinishedHandler)
})
}
class BrowserSessionImpl implements BrowserSession {
private browser: Browser
private context: BrowserContext
private page: Page | null
constructor(browser: Browser, context: BrowserContext) {
this.browser = browser
this.context = context
this.page = null
}
async close() {
if (this.page) {
await this.page.close()
}
await this.context.close()
await this.browser.close()
}
async hardNavigation(metricName: string, url: string) {
this.page = this.page ?? (await this.context.newPage())
const page = this.page
await withRequestMetrics(metricName, page, async () => {
await measureTime(`${metricName}/start`)
const idle = networkIdle(page, 3000)
const response = await page.goto(url, {
waitUntil: 'commit',
})
if (!response) {
throw new Error(`Navigation to ${url} produced no response`)
}
if (!response.ok()) {
throw new Error(
`Navigation to ${url} returned HTTP ${response.status()}`
)
}
await measureTime(`${metricName}/html`, {
relativeTo: `${metricName}/start`,
})
await page.waitForLoadState('domcontentloaded')
await measureTime(`${metricName}/dom`, {
relativeTo: `${metricName}/start`,
})
await page.waitForLoadState('load')
await measureTime(`${metricName}/load`, {
relativeTo: `${metricName}/start`,
})
const offset = await idle
await measureTime(`${metricName}`, {
offset,
relativeTo: `${metricName}/start`,
})
})
return page
}
async softNavigationByClick(metricName: string, selector: string) {
const page = this.page
if (!page) {
throw new Error(
'softNavigationByClick() must be called after hardNavigation()'
)
}
await withRequestMetrics(metricName, page, async () => {
await measureTime(`${metricName}/start`)
const firstResponse = new Promise<void>((resolve) => {
page.once('response', () => {
resolve()
})
})
const idle = networkIdle(page, 3000)
await page.click(selector)
await firstResponse
await measureTime(`${metricName}/firstResponse`, {
relativeTo: `${metricName}/start`,
})
await idle
await measureTime(`${metricName}`, {
offset: 3000,
relativeTo: `${metricName}/start`,
})
})
}
async reload(metricName: string) {
const page = this.page
if (!page) {
throw new Error('reload() must be called after hardNavigation()')
}
await withRequestMetrics(metricName, page, async () => {
await measureTime(`${metricName}/start`)
const idle = networkIdle(page, 3000)
const response = await page.reload({
waitUntil: 'commit',
})
if (!response) {
throw new Error('Reload produced no response')
}
if (!response.ok()) {
throw new Error(`Reload returned HTTP ${response.status()}`)
}
await measureTime(`${metricName}/html`, {
relativeTo: `${metricName}/start`,
})
await page.waitForLoadState('domcontentloaded')
await measureTime(`${metricName}/dom`, {
relativeTo: `${metricName}/start`,
})
await page.waitForLoadState('load')
await measureTime(`${metricName}/load`, {
relativeTo: `${metricName}/start`,
})
await idle
await measureTime(`${metricName}`, {
offset: 3000,
relativeTo: `${metricName}/start`,
})
})
}
}
export async function newBrowserSession(options: {
headless?: boolean
devtools?: boolean
baseURL?: string
}): Promise<BrowserSession> {
const browser = await chromium.launch({
headless: options.headless ?? process.env.HEADLESS !== 'false',
args: options.headless ? undefined : ['--auto-open-devtools-for-tabs'],
timeout: 60000,
})
const context = await browser.newContext({
baseURL: options.baseURL ?? 'http://localhost:3000',
viewport: { width: 1280, height: 720 },
})
context.setDefaultTimeout(120000)
context.setDefaultNavigationTimeout(120000)
return new BrowserSessionImpl(browser, context)
}
// see next.js/test/lib/browsers/playwright.ts