diff --git a/docs/analyze.md b/docs/analyze.md index 34620713b..4657e9e5c 100644 --- a/docs/analyze.md +++ b/docs/analyze.md @@ -10,12 +10,20 @@ links: ```bash [Terminal] -npx nuxt analyze [ROOTDIR] [--cwd=] [--logLevel=] [--dotenv=...] [-e, --extends=...] [--name=] [--serve] [--prerender] +npx nuxt analyze [ROOTDIR] [--cwd=] [--logLevel=] [--dotenv=...] [-e, --extends=...] [--name=] [--serve] [--prerender] [--json] ``` The `analyze` command builds Nuxt and analyzes the production bundle (experimental). The results are served on a local server when the build finishes, unless you pass `--no-serve` or `CI` is set in the environment. +The server links to each report: the client bundle and the Nitro server bundle. When only one report exists, the server shows it directly. The server uses the port in `PORT`, or port 3000. If that port is in use, it picks a free port between 3000 and 3100. + +Each run is written to its own directory inside [`analyzeDir`](https://nuxt.com/docs/api/nuxt-config#analyzedir), named after `--name` (`default` when omitted), so you can keep several runs side by side, for example `--name=before` and `--name=after`. Running again with the same name replaces that run only. Characters other than letters, digits, `_` and `-` are replaced with `_` in the directory name, so `--name=after/fix` and `--name=after_fix` share the same directory. If you set `build.analyze.filename`, the client report is written to that path instead. With Nuxt 3 and 4, `nitro.analyze.filename` does the same for the Nitro report. + +Pass `--json` to write the reports as JSON instead of HTML: `client.json` and `nitro.json`, in the `raw-data` format of `rollup-plugin-visualizer`. They include each module's size, the chunk it ends up in, and its static and dynamic imports. This makes them easy to process with scripts or AI agents. Nothing is served in this mode. A custom report filename ending in `.html` is written with a `.json` extension instead. With the webpack and rspack builders, `client.json` is the webpack stats file rather than `raw-data`. + +With Nuxt 5, the Nitro report needs `rollup-plugin-visualizer` in your project. If it's missing, the Nitro report is skipped. With the Vite builder, Nuxt also offers to install it as a dev dependency. + Routes are not prerendered while analyzing, since prerendering runs the built app and its output is not what is being measured. Pass `--prerender` if your analysis needs it. ## Arguments @@ -39,6 +47,7 @@ Routes are not prerendered while analyzing, since prerendering runs the built ap | `--serve` | `true` | Serve the analysis results | | `--no-serve` | | Skip serving the analysis results | | `--prerender` | `false` | Prerender routes while analyzing | +| `--json` | `false` | Write the client and server bundle analysis as JSON instead of HTML (implies --no-serve) | ::note diff --git a/packages/nuxt-cli/src/commands/analyze.ts b/packages/nuxt-cli/src/commands/analyze.ts index 489b2f5f5..e62179e7a 100644 --- a/packages/nuxt-cli/src/commands/analyze.ts +++ b/packages/nuxt-cli/src/commands/analyze.ts @@ -2,11 +2,14 @@ import type { NuxtAnalyzeMeta } from '@nuxt/schema' import { promises as fsp } from 'node:fs' import process from 'node:process' +import { pathToFileURL } from 'node:url' import { styleText } from 'node:util' import { note, taskLog } from '@clack/prompts' import { defineCommand } from 'citty' import { defu } from 'defu' +import { resolveModulePath } from 'exsolve' +import { getPort } from 'get-port-please' import { join, relative } from 'pathe' import { serve } from 'srvx' @@ -23,25 +26,57 @@ import { resolveServerBuild } from '../utils/server-build' import { dotEnvArgs, extendsArgs, logLevelArgs, rootDirArgs } from './_shared' const NON_WORD_RE = /[^\w-]/g +const HTML_EXT_RE = /\.html$/ -const indexHtml = ` - - - - - Nuxt Bundle Stats (experimental) - -

Nuxt Bundle Stats (experimental)

- - -`.trim() +const LEGACY_REPORT_FILES = ['client.html', 'client.json', 'nitro.html', 'meta.json', '.vite-inspect'] + +interface ReportOptions { + filename?: string + template?: string +} + +interface WebpackAnalyzeOptions { + enabled?: boolean + openAnalyzer?: boolean + analyzerMode?: string + generateStatsFile?: boolean +} + +function resolveReportFilename(filename: string | undefined, defaultFilename: string, dir: string, json: boolean) { + if (!filename || filename === defaultFilename) { + return join(dir, json ? '{name}.json' : '{name}.html') + } + return json ? filename.replace(HTML_EXT_RE, '.json') : filename +} + +async function readFirstFile(files: (string | undefined)[]) { + for (const file of files) { + if (!file) { + continue + } + const contents = await fsp.readFile(file, 'utf8').catch(() => undefined) + if (contents !== undefined) { + return contents + } + } +} + +function renderIndexHtml(reports: { path: string, label: string }[]) { + const body = reports.length > 0 + ? `` + : '

The analyze build did not generate any bundle reports.

' + return ` + + + +Nuxt Bundle Stats (experimental) + + +

Nuxt Bundle Stats (experimental)

+${body} + +` +} export default defineCommand({ meta: { @@ -70,6 +105,11 @@ export default defineCommand({ description: 'Prerender routes while analyzing', default: false, }, + json: { + type: 'boolean', + description: 'Write the client and server bundle analysis as JSON instead of HTML (implies --no-serve)', + default: false, + }, }, async run(ctx) { overrideEnv('production') @@ -132,19 +172,82 @@ export default defineCommand({ }) } + // Modules and Nitro read `analyzeDir` while initialising, so it has to be set before `ready()`. + const baseAnalyzeDir = nuxt.options.analyzeDir + const analyzeDir = join(baseAnalyzeDir, slug) + nuxt.options.analyzeDir = analyzeDir + + const analyzeOptions = nuxt.options.build.analyze as Exclude & ReportOptions + if (ctx.args.json) { + analyzeOptions.template = 'raw-data' + } + analyzeOptions.filename = resolveReportFilename(analyzeOptions.filename, join(baseAnalyzeDir, '{name}.html'), analyzeDir, ctx.args.json) + + const webpackAnalyze = (nuxt.options as { webpack?: { analyze?: boolean | WebpackAnalyzeOptions } }).webpack?.analyze + if (webpackAnalyze && typeof webpackAnalyze === 'object' && webpackAnalyze.enabled !== false) { + webpackAnalyze.openAnalyzer = false + if (ctx.args.json) { + webpackAnalyze.analyzerMode = 'disabled' + webpackAnalyze.generateStatsFile = true + } + } + + if (ctx.args.json) { + // `loadNuxt()` has already decided whether to install DevTools, so the user's `enabled` choice is kept. + const devtools = nuxt.options.devtools + nuxt.options.devtools = typeof devtools === 'object' ? { ...devtools, viteInspect: false } : { enabled: devtools, viteInspect: false } + } + + let nitroAnalyzeDisabled = false + let nitroReportFile: string | undefined + nuxt.hook('nitro:config', (nitroConfig) => { + if (nitroConfig.analyze === false) { + nitroAnalyzeDisabled = true + return + } + if (!nitroConfig.analyze) { + return + } + if (ctx.args.json) { + nitroConfig.analyze.template = 'raw-data' + nitroConfig.analyze.filename = resolveReportFilename(nitroConfig.analyze.filename, join(analyzeDir, '{name}.html'), analyzeDir, true) + } + nitroReportFile = nitroConfig.analyze.filename?.replace('{name}', 'nitro') + }) + + // Nitro v3 has no `analyze` option, so the server report needs its own visualizer. + nuxt.hook('nitro:init', (nitro) => { + nitro.hooks.hook('rollup:before', async (_nitro, config) => { + const plugins = ([await config.plugins] as unknown[]).flat(Infinity).filter(Boolean) as { name?: string }[] + if (nitroAnalyzeDisabled || plugins.some(plugin => plugin.name === 'visualizer')) { + return + } + const visualizerPath = resolveModulePath('rollup-plugin-visualizer', { + from: [nuxt.options.rootDir, ...nuxt.options.modulesDir].map(dir => join(dir, '/')), + try: true, + }) + if (!visualizerPath) { + return + } + const { visualizer } = await import(pathToFileURL(visualizerPath).href) + nitroReportFile = join(analyzeDir, ctx.args.json ? 'nitro.json' : 'nitro.html') + config.plugins = [...plugins, visualizer({ + template: ctx.args.json ? 'raw-data' : 'treemap', + projectRoot: nuxt.options.rootDir, + filename: nitroReportFile, + title: 'Nitro Server bundle stats', + })] as typeof config.plugins + }) + }) + await nuxt.ready() - const analyzeDir = nuxt.options.analyzeDir const buildDir = nuxt.options.buildDir const serverBuild = resolveServerBuild(kit, nuxt) // The lock has to be taken before the build, so it claims the output // directory as resolved now; `meta.json` records where the build landed. const outDir = serverBuild.dir - nuxt.options.build.analyze = defu(nuxt.options.build.analyze, { - filename: join(analyzeDir, 'client.html'), - }) - const lockInfo = { command: 'analyze' as const, cwd } const lock = acquireLock(buildDir, lockInfo) if (lock.existing) { @@ -165,6 +268,7 @@ export default defineCommand({ }) tasklog.message('Clearing analyze directory...') + await Promise.all(LEGACY_REPORT_FILES.map(file => fsp.rm(join(baseAnalyzeDir, file), { recursive: true, force: true }))) await clearDir(analyzeDir) tasklog.message('Building Nuxt...') await buildNuxt(nuxt) @@ -194,29 +298,37 @@ export default defineCommand({ note(`${relativeToProcess(analyzeDir)}\n\nDo not deploy analyze results! Use ${styleText('cyan', 'nuxt build')} before deploying.`, 'Build location') - if (ctx.args.serve !== false && !process.env.CI) { + if (ctx.args.serve !== false && !ctx.args.json && !process.env.CI) { const headers = { 'content-type': 'text/html' } - const readReport = (name: string) => fsp.readFile(join(analyzeDir, name), 'utf8').catch(() => undefined) - const reports = new Map([ - ['/client', await readReport('client.html')], - ['/nitro', await readReport('nitro.html')], - ]) + const reports = [ + { path: '/client', label: 'Client bundle stats', html: await readFirstFile([analyzeOptions.filename.replace('{name}', 'client'), join(analyzeDir, 'client.html')]) }, + { path: '/nitro', label: 'Nitro server bundle stats', html: await readFirstFile([nitroReportFile, join(analyzeDir, 'nitro.html')]) }, + ] + const contents = new Map(reports.map(report => [report.path, report.html])) + const generated = reports.filter(report => report.html !== undefined) + const indexHtml = generated.length === 1 ? generated[0]!.html! : renderIndexHtml(generated) logger.step('Starting stats server...') const hostname = process.env.HOST || 'localhost' + const requestedPort = Number(process.env.PORT) || 3000 + const port = await getPort({ port: requestedPort, alternativePortRange: [3000, 3100], host: hostname }) + if (port !== requestedPort) { + logger.warn(`Port ${requestedPort} is in use, using port ${port} instead.`) + } const configuredHosts = resolveNuxtAllowedHosts(nuxt.options) const allowedHosts = new Set([hostname, ...Array.isArray(configuredHosts) ? configuredHosts : []].map(host => host.toLowerCase())) await serve({ hostname, + port, fetch(request) { if (configuredHosts !== true && !isAllowedHost(request.headers.get('host') ?? undefined, allowedHosts)) { return new Response('Forbidden: this host is not allowed.', { status: 403, headers: { 'content-type': 'text/plain' } }) } const pathname = new URL(request.url).pathname.replace(/\/$/, '') - if (reports.has(pathname)) { - const report = reports.get(pathname) + if (contents.has(pathname)) { + const report = contents.get(pathname) return report === undefined ? new Response('This report was not generated by the analyze build.', { status: 404, headers }) : new Response(report, { headers }) diff --git a/packages/nuxt-cli/test/unit/commands/analyze.spec.ts b/packages/nuxt-cli/test/unit/commands/analyze.spec.ts index 521debb94..6e0970fc5 100644 --- a/packages/nuxt-cli/test/unit/commands/analyze.spec.ts +++ b/packages/nuxt-cli/test/unit/commands/analyze.spec.ts @@ -1,10 +1,11 @@ import type { Nuxt } from '@nuxt/schema' -import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises' +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { runCommand } from 'citty' -import { join } from 'pathe' +import { resolveModulePath } from 'exsolve' +import { dirname, join } from 'pathe' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import analyze from '../../../src/commands/analyze' @@ -24,11 +25,12 @@ function createHooks() { } } -const { acquireLock, acquireOutputLock, buildNuxt, formatLockError, loadNuxt, releaseBuildLock, releaseOutputLock, serve } = vi.hoisted(() => ({ +const { acquireLock, acquireOutputLock, buildNuxt, formatLockError, getPort, loadNuxt, releaseBuildLock, releaseOutputLock, serve } = vi.hoisted(() => ({ acquireLock: vi.fn(), formatLockError: vi.fn(() => 'locked'), acquireOutputLock: vi.fn(), buildNuxt: vi.fn(), + getPort: vi.fn(), loadNuxt: vi.fn(), releaseBuildLock: vi.fn(), releaseOutputLock: vi.fn(), @@ -37,6 +39,13 @@ const { acquireLock, acquireOutputLock, buildNuxt, formatLockError, loadNuxt, re vi.mock('srvx', () => ({ serve })) +vi.mock('get-port-please', () => ({ getPort })) + +vi.mock('exsolve', async (importOriginal) => { + const exsolve = await importOriginal() + return { ...exsolve, resolveModulePath: vi.fn(exsolve.resolveModulePath) } +}) + vi.mock('../../../src/utils/kit', () => ({ loadKit: () => Promise.resolve({ loadNuxt, buildNuxt }), })) @@ -49,32 +58,81 @@ vi.mock('../../../src/utils/lockfile', () => ({ let cwd: string -async function runAnalyze({ args = ['--no-serve'], routes = [], vite = {}, devServer = {} }: { args?: string[], routes?: string[], vite?: Record, devServer?: Record } = {}) { +function fakeVisualizer(options: Record) { + return { name: 'visualizer', options } +} + +async function installFakeVisualizer() { + const dir = join(cwd, 'node_modules/rollup-plugin-visualizer') + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'package.json'), JSON.stringify({ name: 'rollup-plugin-visualizer', type: 'module', exports: './index.js' })) + await writeFile(join(dir, 'index.js'), `export const visualizer = options => ({ name: 'visualizer', options })`) +} + +interface AnalyzeOptions { + args?: string[] + routes?: string[] + vite?: Record + devServer?: Record + buildAnalyze?: Record + webpackAnalyze?: Record + nitroAnalyze?: false | Record + devtools?: boolean | Record + nitro?: 'v2' | 'v3' +} + +async function runAnalyze({ args = ['--no-serve'], routes = [], vite = {}, devServer = {}, buildAnalyze = {}, webpackAnalyze = {}, nitroAnalyze = {}, devtools = {}, nitro = 'v2' }: AnalyzeOptions = {}) { const nuxtHooks = createHooks() const prerenderRoutes = new Set(routes) let overrides: Record = {} + const nuxtOptions = { + analyzeDir: join(cwd, 'analyze'), + buildDir: join(cwd, '.nuxt'), + rootDir: cwd, + modulesDir: [join(cwd, 'node_modules')], + nitro: {}, + build: { + analyze: { enabled: true, template: 'treemap', filename: join(cwd, 'analyze', '{name}.html'), ...buildAnalyze }, + }, + webpack: { + analyze: { enabled: true, template: 'treemap', filename: join(cwd, 'analyze', '{name}.html'), ...webpackAnalyze } as Record, + }, + devServer, + devtools, + vite, + } + + // Nuxt 4 passes default analyze options to Nitro v2; Nuxt 5 (Nitro v3) has none + const nitroConfig: { analyze?: false | Record } = {} + const nitroHooks = createHooks() loadNuxt.mockImplementation((options: { overrides: Record }) => { overrides = options.overrides return Promise.resolve({ ...nuxtHooks, - ready: () => Promise.resolve(), - options: { - analyzeDir: join(cwd, 'analyze'), - buildDir: join(cwd, '.nuxt'), - rootDir: cwd, - nitro: {}, - build: {}, - devServer, - vite, + ready: async () => { + if (nitro === 'v2') { + nitroConfig.analyze = nitroAnalyze && { template: 'treemap', filename: join(nuxtOptions.analyzeDir, '{name}.html'), ...nitroAnalyze } + } + await nuxtHooks.callHook('nitro:config', nitroConfig) + await nuxtHooks.callHook('nitro:init', { hooks: nitroHooks }) }, + options: nuxtOptions, } as unknown as Nuxt) }) - buildNuxt.mockImplementation(async () => { - await mkdir(join(cwd, 'analyze'), { recursive: true }) - const nitroHooks = createHooks() - await nuxtHooks.callHook('nitro:init', { hooks: nitroHooks }) + buildNuxt.mockImplementation(async (nuxt: Nuxt) => { + const { filename, template } = nuxt.options.build.analyze as { filename: string, template: string } + await mkdir(dirname(filename), { recursive: true }) + await writeFile(filename.replace('{name}', 'client'), template) + const bundlerConfig: { plugins: any[] } = { + plugins: nitroConfig.analyze ? [fakeVisualizer({ ...nitroConfig.analyze, filename: nitroConfig.analyze.filename!.replace('{name}', 'nitro') })] : [], + } + await nitroHooks.callHook('rollup:before', {}, bundlerConfig) + for (const plugin of bundlerConfig.plugins.filter(plugin => plugin.name === 'visualizer')) { + await mkdir(dirname(plugin.options.filename), { recursive: true }) + await writeFile(plugin.options.filename, plugin.options.template) + } await nitroHooks.callHook('prerender:routes', prerenderRoutes) }) @@ -83,6 +141,7 @@ async function runAnalyze({ args = ['--no-serve'], routes = [], vite = {}, devSe const ignore: ((path: string) => unknown)[] = overrides.nitro?.prerender?.ignore || [] return { output: screen(renderer), + nuxtOptions, overrides, prerendered: [...prerenderRoutes].filter(route => !ignore.some(matches => matches(route))), } @@ -91,6 +150,7 @@ async function runAnalyze({ args = ['--no-serve'], routes = [], vite = {}, devSe describe('nuxt analyze command', () => { beforeEach(async () => { vi.clearAllMocks() + getPort.mockImplementation((options: { port: number }) => Promise.resolve(options.port)) acquireLock.mockReturnValue({ release: releaseBuildLock }) acquireOutputLock.mockReturnValue({ release: releaseOutputLock }) cwd = await mkdtemp(join(tmpdir(), 'nuxt-analyze-')) @@ -100,6 +160,28 @@ describe('nuxt analyze command', () => { await rm(cwd, { recursive: true, force: true }) }) + describe('webpack', () => { + it('should not let webpack-bundle-analyzer open a browser', async () => { + const { nuxtOptions } = await runAnalyze() + expect(nuxtOptions.webpack.analyze).toMatchObject({ openAnalyzer: false }) + expect(nuxtOptions.webpack.analyze).not.toHaveProperty('analyzerMode') + }) + + it('should only write the webpack stats file with --json', async () => { + const { nuxtOptions } = await runAnalyze({ args: ['--json'] }) + expect(nuxtOptions.webpack.analyze).toMatchObject({ openAnalyzer: false, analyzerMode: 'disabled', generateStatsFile: true }) + }) + + it('should leave webpack analysis off when it is disabled', async () => { + const { nuxtOptions, overrides } = await runAnalyze({ args: ['--json'], webpackAnalyze: { enabled: false } }) + expect(overrides.webpack).toBeUndefined() + expect(nuxtOptions.webpack.analyze).toMatchObject({ enabled: false }) + expect(nuxtOptions.webpack.analyze).not.toHaveProperty('openAnalyzer') + expect(nuxtOptions.webpack.analyze).not.toHaveProperty('analyzerMode') + expect(nuxtOptions.webpack.analyze).not.toHaveProperty('generateStatsFile') + }) + }) + it('should not prerender any routes by default', async () => { const { prerendered, overrides } = await runAnalyze({ routes: ['/', '/about'] }) expect(prerendered).toEqual([]) @@ -127,17 +209,122 @@ describe('nuxt analyze command', () => { it('should write metadata and fall back to a non-empty slug', async () => { await runAnalyze({ args: ['--no-serve', '--name= '] }) - const meta = JSON.parse(await readFile(join(cwd, 'analyze/meta.json'), 'utf8')) + const meta = JSON.parse(await readFile(join(cwd, 'analyze/default/meta.json'), 'utf8')) expect(meta).toMatchObject({ name: ' ', slug: 'default', - analyzeDir: join(cwd, 'analyze'), + analyzeDir: join(cwd, 'analyze/default'), buildDir: join(cwd, '.nuxt'), outDir: join(cwd, '.output'), }) expect(meta.endTime).toBeGreaterThanOrEqual(meta.startTime) }) + it('should keep the results of each named run', async () => { + await runAnalyze({ args: ['--no-serve', '--name=before'] }) + await runAnalyze({ args: ['--no-serve', '--name=after/fix'] }) + await writeFile(join(cwd, 'analyze/after_fix/stale.html'), 'old') + await runAnalyze({ args: ['--no-serve', '--name=after/fix'] }) + + await expect(access(join(cwd, 'analyze/after_fix/stale.html'))).rejects.toThrow() + + await expect(readFile(join(cwd, 'analyze/before/client.html'), 'utf8')).resolves.toBe('treemap') + await expect(readFile(join(cwd, 'analyze/after_fix/client.html'), 'utf8')).resolves.toBe('treemap') + const meta = JSON.parse(await readFile(join(cwd, 'analyze/before/meta.json'), 'utf8')) + expect(meta).toMatchObject({ name: 'before', slug: 'before' }) + }) + + it('should remove reports left at the root of the analyze directory', async () => { + await mkdir(join(cwd, 'analyze/.vite-inspect'), { recursive: true }) + await mkdir(join(cwd, 'analyze/before'), { recursive: true }) + await writeFile(join(cwd, 'analyze/client.html'), 'old') + await writeFile(join(cwd, 'analyze/meta.json'), '{}') + await writeFile(join(cwd, 'analyze/before/meta.json'), '{}') + + await runAnalyze() + + await expect(readFile(join(cwd, 'analyze/client.html'), 'utf8')).rejects.toThrow() + await expect(readFile(join(cwd, 'analyze/meta.json'), 'utf8')).rejects.toThrow() + await expect(access(join(cwd, 'analyze/.vite-inspect'))).rejects.toThrow() + await expect(readFile(join(cwd, 'analyze/before/meta.json'), 'utf8')).resolves.toBe('{}') + }) + + it('should keep a custom report filename', async () => { + await runAnalyze({ args: ['--no-serve', '--name=custom'], buildAnalyze: { filename: join(cwd, 'reports/{name}.html') } }) + await expect(readFile(join(cwd, 'reports/client.html'), 'utf8')).resolves.toBe('treemap') + await expect(access(join(cwd, 'analyze/custom/client.html'))).rejects.toThrow() + await expect(readFile(join(cwd, 'analyze/custom/meta.json'), 'utf8')).resolves.toContain('"slug": "custom"') + }) + + it('should use a .json extension for a custom report filename with --json', async () => { + await runAnalyze({ args: ['--json'], buildAnalyze: { filename: join(cwd, 'reports/{name}.html') }, nitroAnalyze: { filename: join(cwd, 'reports/{name}.html') } }) + await expect(readFile(join(cwd, 'reports/client.json'), 'utf8')).resolves.toBe('raw-data') + await expect(readFile(join(cwd, 'reports/nitro.json'), 'utf8')).resolves.toBe('raw-data') + }) + + it('should write the client report as JSON with --json and not serve it', async () => { + vi.stubEnv('CI', '') + await runAnalyze({ args: ['--json'] }) + vi.unstubAllEnvs() + + await expect(readFile(join(cwd, 'analyze/default/client.json'), 'utf8')).resolves.toBe('raw-data') + await expect(readFile(join(cwd, 'analyze/default/nitro.json'), 'utf8')).resolves.toBe('raw-data') + expect(serve).not.toHaveBeenCalled() + }) + + it('should write the Nitro report as HTML without --json', async () => { + await runAnalyze() + await expect(readFile(join(cwd, 'analyze/default/nitro.html'), 'utf8')).resolves.toBe('treemap') + }) + + describe('nitro v3', () => { + it('should add the Nitro report itself', async () => { + await installFakeVisualizer() + await runAnalyze({ nitro: 'v3' }) + await expect(readFile(join(cwd, 'analyze/default/nitro.html'), 'utf8')).resolves.toBe('treemap') + }) + + it('should write the Nitro report as JSON with --json', async () => { + await installFakeVisualizer() + await runAnalyze({ args: ['--json'], nitro: 'v3' }) + await expect(readFile(join(cwd, 'analyze/default/nitro.json'), 'utf8')).resolves.toBe('raw-data') + }) + }) + + it('should not add a second Nitro report when Nitro made one', async () => { + await installFakeVisualizer() + await runAnalyze({ nitroAnalyze: { filename: join(cwd, 'reports/{name}.html') } }) + await expect(readFile(join(cwd, 'reports/nitro.html'), 'utf8')).resolves.toBe('treemap') + await expect(readFile(join(cwd, 'analyze/default/nitro.html'), 'utf8')).rejects.toThrow() + }) + + it('should not add a Nitro report when `nitro.analyze` is disabled', async () => { + await installFakeVisualizer() + await runAnalyze({ nitroAnalyze: false }) + await expect(readFile(join(cwd, 'analyze/default/nitro.html'), 'utf8')).rejects.toThrow() + }) + + it('should disable the DevTools Vite inspect output with --json', async () => { + const { nuxtOptions, overrides } = await runAnalyze({ args: ['--json'], devtools: { componentInspector: false } }) + expect(nuxtOptions.devtools).toEqual({ componentInspector: false, viteInspect: false }) + expect(overrides.devtools).toBeUndefined() + }) + + it('should keep the DevTools enabled choice with --json', async () => { + const { nuxtOptions } = await runAnalyze({ args: ['--json'], devtools: false }) + expect(nuxtOptions.devtools).toEqual({ enabled: false, viteInspect: false }) + }) + + it('should leave DevTools options alone without --json', async () => { + const { nuxtOptions } = await runAnalyze({ devtools: true }) + expect(nuxtOptions.devtools).toBe(true) + }) + + it('should keep a custom Nitro report filename with --json', async () => { + await runAnalyze({ args: ['--json'], nitroAnalyze: { filename: join(cwd, 'reports/{name}.data') } }) + await expect(readFile(join(cwd, 'reports/nitro.data'), 'utf8')).resolves.toBe('raw-data') + }) + it('should lock build directories and release both locks', async () => { await runAnalyze() @@ -170,19 +357,62 @@ describe('nuxt analyze command', () => { }) describe('stats server', () => { - async function statusForHost(vite: Record, host: string, devServer: Record = {}): Promise { + async function startStatsServer(options: AnalyzeOptions = {}) { vi.stubEnv('CI', '') vi.stubEnv('HOST', '') serve.mockReturnValue({ ready: () => Promise.resolve() }) - await runAnalyze({ args: [], vite, devServer }) - const { fetch } = serve.mock.calls[0]![0] as { fetch: (request: Request) => Response } - return fetch(new Request('http://localhost/', { headers: { host } })).status + const { output } = await runAnalyze({ ...options, args: [] }) + const { fetch } = serve.mock.lastCall![0] as { fetch: (request: Request) => Response } + const fetchPage = (path: string, headers?: Record) => fetch(new Request(`http://localhost${path}`, { headers })) + return { fetchPage, output } + } + + async function statusForHost(vite: Record, host: string, devServer: Record = {}) { + const { fetchPage } = await startStatsServer({ vite, devServer }) + return fetchPage('/', { host }).status } afterEach(() => { vi.unstubAllEnvs() }) + it('should listen on another port when the requested one is in use', async () => { + vi.stubEnv('PORT', '3000') + getPort.mockResolvedValue(3001) + + const { output } = await startStatsServer() + + expect(getPort).toHaveBeenCalledWith(expect.objectContaining({ port: 3000, host: 'localhost' })) + expect(serve).toHaveBeenCalledWith(expect.objectContaining({ port: 3001 })) + expect(output).toContain('Port 3000 is in use, using port 3001 instead.') + }) + + it('should link to each generated report', async () => { + const { fetchPage } = await startStatsServer() + const html = await fetchPage('/').text() + expect(html).toContain('') + expect(html).toContain('') + }) + + it('should serve the client report at / when it is the only report', async () => { + vi.mocked(resolveModulePath).mockReturnValueOnce(undefined) + const { fetchPage } = await startStatsServer({ nitro: 'v3', buildAnalyze: { template: 'client report' } }) + await expect(fetchPage('/').text()).resolves.toBe('client report') + await expect(fetchPage('/client').text()).resolves.toBe('client report') + expect(fetchPage('/nitro').status).toBe(404) + }) + + it('should serve the client report from a custom report filename', async () => { + const { fetchPage } = await startStatsServer({ buildAnalyze: { template: 'client report', filename: join(cwd, 'reports/{name}.html') } }) + await expect(fetchPage('/client').text()).resolves.toBe('client report') + expect(await fetchPage('/').text()).toContain('') + }) + + it('should serve the Nitro report from a custom report filename', async () => { + const { fetchPage } = await startStatsServer({ nitroAnalyze: { template: 'nitro report', filename: join(cwd, 'reports/{name}.html') } }) + await expect(fetchPage('/nitro').text()).resolves.toBe('nitro report') + }) + it('should reject a host it was not started on', async () => { await expect(statusForHost({}, 'rebinding-attacker.com')).resolves.toBe(403) }) diff --git a/packages/nuxt-cli/test/unit/help.spec.ts b/packages/nuxt-cli/test/unit/help.spec.ts index 9769eff37..c7450e226 100644 --- a/packages/nuxt-cli/test/unit/help.spec.ts +++ b/packages/nuxt-cli/test/unit/help.spec.ts @@ -141,6 +141,7 @@ describe('help', () => { --serve Serve the analysis results (Default: true) --no-serve Skip serving the analysis results --prerender Prerender routes while analyzing (Default: false) + --json Write the client and server bundle analysis as JSON instead of HTML (implies --no-serve) (Default: false) " `) }) diff --git a/playground/package.json b/playground/package.json index ed7ae5a5e..f8e3351c8 100644 --- a/playground/package.json +++ b/playground/package.json @@ -1,5 +1,6 @@ { "name": "nuxt-cli-playground", + "type": "module", "version": "1.0.0", "private": true, "scripts": {