/** @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'
}