/** @vitest-environment node */ import { execSync } from 'node:child_process' import fsp from 'node:fs/promises' import process from 'node:process' import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { glob as globby } from 'tinyglobby' import { join } from 'pathe' /** * Prebuilt native binaries are resolved for whichever platform the build runs * on, so their names and sizes can't be snapshotted. */ const NATIVE_BINARY_RE = /(?:linux|darwin|win32)-(?:arm64|x64)/ const NATIVE_BINARY_GLOB = '!node_modules/**/*{linux,darwin,win32}-{arm64,x64}*/**' describe('project sizes', () => { const rootDir = fileURLToPath(new URL('../..', import.meta.url)) const publicDir = join(rootDir, '.output/public') const serverDir = join(rootDir, '.output/server') const manifestPath = join( rootDir, 'node_modules/.cache/bundle-test-client-manifest.json', ) const stats = { client: { totalBytes: 0, files: [] as string[] }, server: { totalBytes: 0, files: [] as string[] }, } beforeAll(async () => { execSync('pnpm nuxt build', { cwd: rootDir, env: { ...process.env, NODE_ENV: 'production', DISABLE_PRERENDER: 'true', TEST: 'true', VITEST: 'true', NUXT_CLIENT_MANIFEST_PATH: manifestPath, }, }) }, 120 * 1000) afterAll(async () => { await fsp.writeFile( join(rootDir, '.output/test-stats.json'), JSON.stringify(stats, null, 2), ) }) it('public (non-admin) client bundle size', async () => { const adminOnly = await loadAdminOnlyChunks(manifestPath) const allFiles: string[] = await globby( ['**/*.js', '!_scripts/**', '!**/_payload.js', '!_nuxt/builds/**'], { cwd: publicDir }, ) const publicFiles = allFiles.filter(f => !adminOnly.has(basenameOf(f))) stats.client = await measureFiles(publicFiles, publicDir) expect .soft(roundToKilobytes(stats.client.totalBytes)) .toMatchInlineSnapshot(`"274k"`) expect.soft(stats.client.files.map(f => f.replace(/\..*\.js/, '.js')).sort()) .toMatchInlineSnapshot(` [ "_nuxt/BlueskyComments.js", "_nuxt/entry.js", ] `) }) it('default server bundle size', async () => { stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir) expect .soft(roundToKilobytes(stats.server.totalBytes, 10)) .toMatchInlineSnapshot(`"7990k"`) const modules = await analyzeSizes('node_modules/**/*', serverDir) const portableModules = await analyzeSizes( ['node_modules/**/*', NATIVE_BINARY_GLOB], serverDir, ) expect .soft(roundToKilobytes(portableModules.totalBytes, 10)) .toMatchInlineSnapshot(`"390k"`) const packages = modules.files .filter(m => m.endsWith('package.json') && !NATIVE_BINARY_RE.test(m)) .map(m => m.replace('/package.json', '').replace('node_modules/', '')) .sort() expect.soft(packages).toMatchInlineSnapshot(` [ "@img/colour", "@takumi-rs/core", "@takumi-rs/helpers", "detect-libc", "semver", "sharp", ] `) }) }) async function analyzeSizes (pattern: string | string[], rootDir: string) { const files: string[] = await globby(pattern, { cwd: rootDir }) return measureFiles(files, rootDir) } async function measureFiles (files: string[], rootDir: string) { let totalBytes = 0 for (const file of files) { const path = join(rootDir, file) const isSymlink = ( await fsp.lstat(path).catch(() => null) )?.isSymbolicLink() if (!isSymlink) { const bytes = Buffer.byteLength(await fsp.readFile(path)) totalBytes += bytes } } return { files, totalBytes } } /** * Reads the client manifest dumped by the `build:manifest` hook in * `nuxt.config.ts` and returns the set of chunk file names reachable only from * `pages/admin/**` source keys. * * "Reachable" means the transitive static-import closure of each entry, which * is what the SSR renderer emits as ``; anything * reachable from a non-admin key is subtracted so shared chunks (entry, prose * components, etc.) stay in the public set. */ async function loadAdminOnlyChunks (manifestPath: string): Promise> { const manifest = JSON.parse( await fsp.readFile(manifestPath, 'utf8'), ) as Record function collect (key: string, into: Set, seen = new Set()) { if (seen.has(key)) return seen.add(key) const entry = manifest[key] if (!entry) return if (entry.file?.endsWith('.js')) into.add(basenameOf(entry.file)) for (const imported of entry.imports ?? []) collect(imported, into, seen) } const adminReach = new Set() const publicReach = new Set() for (const key of Object.keys(manifest)) { // Skip the synthetic `_.js` entries emitted for shared chunks; they // list themselves and would otherwise leak admin-only chunks into the // public set via the chunk's own metadata entry. if (key.startsWith('_') && key.endsWith('.js')) continue collect(key, key.startsWith('pages/admin/') ? adminReach : publicReach) } return new Set([...adminReach].filter(f => !publicReach.has(f))) } function basenameOf (file: string) { const slash = file.lastIndexOf('/') return slash === -1 ? file : file.slice(slash + 1) } interface ManifestEntry { file?: string imports?: string[] } function roundToKilobytes (bytes: number, granularityK = 1) { if (bytes < 100 * 1024) return (bytes / 1024).toFixed(1) + 'k' return Math.round(bytes / 1024 / granularityK) * granularityK + 'k' }