mirror of
https://github.com/hanzoai/next.js.git
synced 2026-10-30 14:46:38 +00:00
After a successful, Turbopack production build, this writes
`.next/diagnostics/route-bundle-stats.json`
containing First Load JS stats for every route.
## Why?
We used to have route bundle size statistics print after every build. It
was not very high-signal and could be misleading, often using heuristics
to give a best-effort estimate of what sizes may be at runtime. **These
kinds of metrics for route bundle sizes can be misleading, as pages can
be slow to visual/interactive completeness even if they have small first
load bytes.**
However, a minimal gauge of how much JavaScript a route ships during
initial page load can be a useful signal so long as users have
additional context and awareness of their app structure. Let's provide
just this number for now for those that could benefit.
Additionally, this does so in the form of writing a json document to the
build directory, rather than writing a table to stdout, where users have
needed to write extra tools to parse.
## Format
An array of route entries sorted descending by
`firstLoadUncompressedJsBytes`:
```json
[
{
"route": "/blog/[slug]",
"firstLoadUncompressedJsBytes": 124928,
"firstLoadChunkPaths": [
".next/static/chunks/framework.js",
".next/static/chunks/main.js",
".next/static/chunks/pages/blog/[slug].js"
]
}
]
```
`firstLoadUncompressedJsBytes`: sum of uncompressed sizes of all JS
chunks loaded on first navigation to the route (shared chunks included),
for both Pages Router and App Router
`firstLoadChunkPaths`: paths to those chunks relative to the project
root
## Test Plan
Added an integration test. Built with a create-next-app and verified the
json document is written and the numbers align with what Chrome reports
during initial load of a page.
384 lines
10 KiB
TypeScript
384 lines
10 KiB
TypeScript
import path from 'path'
|
|
import assert from 'assert'
|
|
import { flushAllTraces, setGlobal, trace } from 'next/dist/trace'
|
|
import { PHASE_DEVELOPMENT_SERVER } from 'next/constants'
|
|
import { NextInstance, NextInstanceOpts } from '../next-modes/base'
|
|
import { NextDevInstance } from '../next-modes/next-dev'
|
|
import { NextStartInstance } from '../next-modes/next-start'
|
|
import { NextDeployInstance } from '../next-modes/next-deploy'
|
|
import { shouldUseTurbopack } from '../next-test-utils'
|
|
|
|
export type { NextInstance }
|
|
|
|
const individualTestTimeout = 60 * 1000
|
|
|
|
// Keep a higher timeout for setup hooks (e.g. initial createNext/startup),
|
|
// but enforce 60s per test case via wrapped `it`/`test` for non-dev modes.
|
|
let setupTimeout = (process.platform === 'win32' ? 240 : 120) * 1000
|
|
|
|
if (process.env.NEXT_E2E_TEST_TIMEOUT) {
|
|
const parsedTimeout = Number.parseInt(process.env.NEXT_E2E_TEST_TIMEOUT, 10)
|
|
if (!Number.isNaN(parsedTimeout)) {
|
|
setupTimeout = parsedTimeout
|
|
}
|
|
}
|
|
|
|
jest.setTimeout(setupTimeout)
|
|
|
|
type E2ETestGlobal = typeof globalThis & {
|
|
__NEXT_E2E_TEST_CONFIG_PATCHED__?: boolean
|
|
__NEXT_E2E_WRAPPED_TEST_FNS__?: WeakMap<Function, Function>
|
|
}
|
|
|
|
const wrapJestTestFn = <T extends Function>(fn: T): T => {
|
|
const e2eGlobal = global as E2ETestGlobal
|
|
const wrappedFns =
|
|
e2eGlobal.__NEXT_E2E_WRAPPED_TEST_FNS__ ??
|
|
(e2eGlobal.__NEXT_E2E_WRAPPED_TEST_FNS__ = new WeakMap())
|
|
const existing = wrappedFns.get(fn)
|
|
if (existing) return existing as T
|
|
|
|
const wrapped = new Proxy(fn, {
|
|
apply(target, thisArg, argArray: unknown[]) {
|
|
const args = [...argArray]
|
|
if (
|
|
args.length >= 2 &&
|
|
typeof args[1] === 'function' &&
|
|
args[2] === undefined
|
|
) {
|
|
args[2] = individualTestTimeout
|
|
}
|
|
|
|
const result = Reflect.apply(target, thisArg, args)
|
|
return typeof result === 'function' ? wrapJestTestFn(result) : result
|
|
},
|
|
get(target, prop, receiver) {
|
|
const value = Reflect.get(target, prop, receiver)
|
|
return typeof value === 'function' ? wrapJestTestFn(value) : value
|
|
},
|
|
})
|
|
|
|
wrappedFns.set(fn, wrapped)
|
|
return wrapped as T
|
|
}
|
|
|
|
const testsFolder = path.join(__dirname, '..', '..')
|
|
|
|
let testFile
|
|
const testFileRegex = /\.test\.(js|tsx?)/
|
|
|
|
const visitedModules = new Set()
|
|
const checkParent = (mod) => {
|
|
if (!mod?.parent || visitedModules.has(mod)) return
|
|
testFile = mod.parent.filename || ''
|
|
visitedModules.add(mod)
|
|
|
|
if (!testFileRegex.test(testFile)) {
|
|
checkParent(mod.parent)
|
|
}
|
|
}
|
|
checkParent(module)
|
|
|
|
process.env.TEST_FILE_PATH = testFile
|
|
|
|
let testMode = process.env.NEXT_TEST_MODE
|
|
|
|
if (!testFileRegex.test(testFile)) {
|
|
throw new Error(
|
|
`e2e-utils imported from non-test file ${testFile} (must end with .test.(js,ts,tsx)`
|
|
)
|
|
}
|
|
|
|
const testFolderModes = ['e2e', 'development', 'production']
|
|
|
|
const testModeFromFile = testFolderModes.find((mode) =>
|
|
testFile.startsWith(path.join(testsFolder, mode))
|
|
)
|
|
|
|
if (testModeFromFile === 'e2e') {
|
|
const validE2EModes = ['dev', 'start', 'deploy']
|
|
|
|
if (!process.env.NEXT_TEST_JOB && !testMode) {
|
|
require('console').warn(
|
|
'Warn: no NEXT_TEST_MODE set, using default of start'
|
|
)
|
|
testMode = 'start'
|
|
}
|
|
assert(
|
|
validE2EModes.includes(testMode!),
|
|
`NEXT_TEST_MODE must be one of ${validE2EModes.join(
|
|
', '
|
|
)} for e2e tests but received ${testMode}`
|
|
)
|
|
} else if (testModeFromFile === 'development') {
|
|
testMode = 'dev'
|
|
} else if (testModeFromFile === 'production') {
|
|
testMode = 'start'
|
|
}
|
|
|
|
const e2eGlobal = global as E2ETestGlobal
|
|
if (!e2eGlobal.__NEXT_E2E_TEST_CONFIG_PATCHED__) {
|
|
if (testMode !== 'dev') {
|
|
if (typeof global.it === 'function') {
|
|
global.it = wrapJestTestFn(global.it) as jest.It
|
|
}
|
|
if (typeof global.test === 'function') {
|
|
global.test = wrapJestTestFn(global.test) as jest.It
|
|
}
|
|
|
|
if (process.env.NEXT_TEST_CI && !process.env.NEXT_FLAKE_DETECTION) {
|
|
jest.retryTimes(1)
|
|
}
|
|
}
|
|
|
|
e2eGlobal.__NEXT_E2E_TEST_CONFIG_PATCHED__ = true
|
|
}
|
|
|
|
if (testMode === 'dev') {
|
|
;(global as any).isNextDev = true
|
|
} else if (testMode === 'deploy') {
|
|
;(global as any).isNextDeploy = true
|
|
} else {
|
|
;(global as any).isNextStart = true
|
|
}
|
|
|
|
/**
|
|
* Whether the test is running in development mode.
|
|
* Based on `process.env.NEXT_TEST_MODE` and the test directory.
|
|
*/
|
|
export const isNextDev = testMode === 'dev'
|
|
/**
|
|
* Whether the test is running in deploy mode.
|
|
* Based on `process.env.NEXT_TEST_MODE`.
|
|
*/
|
|
export const isNextDeploy = testMode === 'deploy'
|
|
/**
|
|
* Whether the test is running in start mode.
|
|
* Default mode. `true` when both `isNextDev` and `isNextDeploy` are false.
|
|
*/
|
|
export const isNextStart = !isNextDev && !isNextDeploy
|
|
|
|
if (!process.env.NEXT_TEST_WASM && process.env.NEXT_TEST_WASM_AFTER_JEST) {
|
|
process.env.NEXT_TEST_WASM = process.env.NEXT_TEST_WASM_AFTER_JEST
|
|
}
|
|
|
|
export const isRspack = !!process.env.NEXT_RSPACK
|
|
const isNextTestWasm = !!process.env.NEXT_TEST_WASM
|
|
export const itTurbopack =
|
|
!isNextTestWasm && shouldUseTurbopack() ? it : it.skip
|
|
|
|
if (!testMode) {
|
|
throw new Error(
|
|
`No 'NEXT_TEST_MODE' set in environment, this is required for e2e-utils`
|
|
)
|
|
}
|
|
require('console').warn(
|
|
`Using test mode: ${testMode} in test folder ${testModeFromFile}`
|
|
)
|
|
|
|
/**
|
|
* FileRef is wrapper around a file path that is meant be copied
|
|
* to the location where the next instance is being created
|
|
*/
|
|
export class FileRef {
|
|
public fsPath: string
|
|
|
|
constructor(path: string) {
|
|
this.fsPath = path
|
|
}
|
|
}
|
|
|
|
/**
|
|
* FileRef is wrapper around a file path that is meant be copied
|
|
* to the location where the next instance is being created
|
|
*/
|
|
export class PatchedFileRef {
|
|
public fsPath: string
|
|
public cb: (content: string) => string
|
|
|
|
constructor(path: string, cb: (content: string) => string) {
|
|
this.fsPath = path
|
|
this.cb = cb
|
|
}
|
|
}
|
|
|
|
let nextInstance: NextInstance | undefined = undefined
|
|
|
|
if (typeof afterAll === 'function') {
|
|
afterAll(async () => {
|
|
if (nextInstance) {
|
|
await nextInstance.destroy()
|
|
throw new Error(
|
|
`next instance not destroyed before exiting, make sure to call .destroy() after the tests after finished`
|
|
)
|
|
}
|
|
})
|
|
}
|
|
|
|
const setupTracing = () => {
|
|
if (!process.env.NEXT_TEST_TRACE) return
|
|
|
|
setGlobal('distDir', './test/.trace')
|
|
// This is a hacky way to use tracing utils even for tracing test utils.
|
|
// We want the same treatment as DEVELOPMENT_SERVER - adds a reasonable treshold for logs size.
|
|
setGlobal('phase', PHASE_DEVELOPMENT_SERVER)
|
|
}
|
|
|
|
/**
|
|
* Sets up and manages a Next.js instance in the configured
|
|
* test mode. The next instance will be isolated from the monorepo
|
|
* to prevent relying on modules that shouldn't be
|
|
*/
|
|
export async function createNext(
|
|
opts: NextInstanceOpts & { skipStart?: boolean; patchFileDelay?: number }
|
|
): Promise<NextInstance> {
|
|
try {
|
|
if (nextInstance) {
|
|
throw new Error(`createNext called without destroying previous instance`)
|
|
}
|
|
|
|
setupTracing()
|
|
return await trace('createNext').traceAsyncFn(async (rootSpan) => {
|
|
const useTurbo = isNextTestWasm
|
|
? false
|
|
: (opts?.turbo ?? shouldUseTurbopack())
|
|
|
|
if (testMode === 'dev') {
|
|
// next dev
|
|
rootSpan.traceChild('init next dev instance').traceFn(() => {
|
|
nextInstance = new NextDevInstance({
|
|
...opts,
|
|
turbo: useTurbo,
|
|
})
|
|
})
|
|
} else if (testMode === 'deploy') {
|
|
// Vercel
|
|
rootSpan.traceChild('init next deploy instance').traceFn(() => {
|
|
nextInstance = new NextDeployInstance({
|
|
...opts,
|
|
})
|
|
})
|
|
} else {
|
|
// next build + next start
|
|
rootSpan.traceChild('init next start instance').traceFn(() => {
|
|
nextInstance = new NextStartInstance({
|
|
...opts,
|
|
})
|
|
})
|
|
}
|
|
|
|
nextInstance = nextInstance!
|
|
|
|
nextInstance.on('destroy', () => {
|
|
nextInstance = undefined
|
|
})
|
|
|
|
await nextInstance.setup(rootSpan)
|
|
|
|
if (!opts.skipStart) {
|
|
await rootSpan
|
|
.traceChild('start next instance')
|
|
.traceAsyncFn(async () => {
|
|
await nextInstance!.start()
|
|
})
|
|
}
|
|
|
|
return nextInstance!
|
|
})
|
|
} catch (err) {
|
|
require('console').error('Failed to create next instance', err)
|
|
try {
|
|
await nextInstance?.destroy()
|
|
} catch (_) {}
|
|
|
|
nextInstance = undefined
|
|
// Throw instead of process exit to ensure that Jest reports the tests as failed.
|
|
throw err
|
|
} finally {
|
|
flushAllTraces()
|
|
}
|
|
}
|
|
|
|
export function nextTestSetup(
|
|
options: Parameters<typeof createNext>[0] & {
|
|
skipDeployment?: boolean
|
|
dir?: string
|
|
}
|
|
): {
|
|
isNextDev: boolean
|
|
isNextDeploy: boolean
|
|
isNextStart: boolean
|
|
isTurbopack: boolean
|
|
isRspack: boolean
|
|
next: NextInstance
|
|
skipped: boolean
|
|
} {
|
|
let skipped = false
|
|
|
|
if (options.skipDeployment) {
|
|
// When the environment is running for deployment tests.
|
|
if (isNextDeploy) {
|
|
// eslint-disable-next-line jest/no-focused-tests
|
|
it.only('should skip next deploy', () => {})
|
|
// No tests are run.
|
|
skipped = true
|
|
}
|
|
}
|
|
|
|
let next: NextInstance | undefined
|
|
if (!skipped) {
|
|
beforeAll(async () => {
|
|
next = await createNext(options)
|
|
})
|
|
afterAll(async () => {
|
|
// Gracefully destroy the instance if `createNext` success.
|
|
// If next instance is not available, it's likely beforeAll hook failed and unnecessarily throws another error
|
|
// by attempting to destroy on undefined.
|
|
await next?.destroy()
|
|
})
|
|
}
|
|
|
|
const nextProxy = new Proxy<NextInstance>({} as NextInstance, {
|
|
get: function (_target, property) {
|
|
if (!next) {
|
|
throw new Error(
|
|
'next instance is not initialized yet, make sure you call methods on next instance in test body.'
|
|
)
|
|
}
|
|
const prop = next[property]
|
|
return typeof prop === 'function' ? prop.bind(next) : prop
|
|
},
|
|
set: function (_target, key, value) {
|
|
if (!next) {
|
|
throw new Error(
|
|
'next instance is not initialized yet, make sure you call methods on next instance in test body.'
|
|
)
|
|
}
|
|
next[key] = value
|
|
return true
|
|
},
|
|
})
|
|
|
|
return {
|
|
get isNextDev() {
|
|
return isNextDev
|
|
},
|
|
get isNextDeploy() {
|
|
return isNextDeploy
|
|
},
|
|
get isNextStart() {
|
|
return isNextStart
|
|
},
|
|
get isTurbopack() {
|
|
return Boolean(!isNextTestWasm && (options.turbo ?? shouldUseTurbopack()))
|
|
},
|
|
get isRspack() {
|
|
return isRspack
|
|
},
|
|
get next() {
|
|
return nextProxy
|
|
},
|
|
skipped,
|
|
}
|
|
}
|