mirror of
https://github.com/vercel/next.js.git
synced 2026-09-20 02:25:18 +08:00
286169a9e7
## 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 -->
378 lines
11 KiB
TypeScript
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
|