From 2cec68e72cf187b29044691d935dfd6e9b8fa75d Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Wed, 2 Sep 2026 10:03:29 -0700 Subject: [PATCH 1/3] chore: track download size per PR in the benchmark comment The benchmark comment reported "Package size" as `du -sk node_modules` after `npm prune --production`. That answered a narrower question than it appeared to: - it measured extracted disk usage, not what npm downloads - `du` counts disk blocks, so thousands of small files inflated it and made it drift with the filesystem - `node_modules` holds only our dependencies, so a change to what we ourselves publish reported as no change at all Replaces it with numbers taken from a real user install: pack the CLI, then install that tarball with `--omit=dev` into a scratch dir with an empty npm cache, and measure what came down. Download size (CLI package) 471 kB Download size (full install) 59 MB Installed size 253 MB Dependency count 1,240 Download totals come from cacache's index, which records an exact byte count per entry, rather than from measuring the cache directory. That lets us count only `.tgz` entries and skip cached registry metadata, which is also downloaded but fluctuates as unrelated packages publish. Published tarballs are immutable, so a given lockfile always yields the same total -- verified byte-identical across runs. Two metric names change, so both start fresh rather than comparing against values with different meaning: `.delta.packageSize` becomes `.delta.installedSize`, and dependency count now comes from the same probe install as everything else (1,093 -> 1,240, since it counts nested copies npm actually wrote rather than `npm ls` entries). `benchmark-post.yml` needs no change; delta-action picks up any `.delta.*`. Adds ~25s to the job. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/benchmark.yml | 14 +- .gitignore | 3 + scripts/measure-size.js | 190 ++++++++++++++++++++++++ tests/unit/scripts/measure-size.test.ts | 120 +++++++++++++++ 4 files changed, 321 insertions(+), 6 deletions(-) create mode 100644 scripts/measure-size.js create mode 100644 tests/unit/scripts/measure-size.test.ts diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 74a60268d6b..517bcdcb537 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -25,14 +25,16 @@ jobs: cache: npm - name: Install dependencies - run: npm ci --no-audit && npm prune --production + run: npm ci --no-audit - - name: Get size - run: du -sk node_modules | cut -f1 > .delta.packageSize && echo "kb (Package size)" >> .delta.packageSize + # `npm pack` only produces a representative tarball once `dist` exists. + - name: Build + run: npm run build - - name: Get dependency count - run: npm ls -a -p | wc -l | tr -d ' \n' > .delta.dependencyCount && echo " (Dependency count)" >> - .delta.dependencyCount + # Packs the CLI and installs it like a user would, then reports download size, installed size + # and dependency count for that install. See scripts/measure-size.js. + - name: Get size and dependency count + run: node scripts/measure-size.js - name: Get TypeScript conversion progress run: grep -r --exclude-dir="node_modules" --include="*.ts" "@ts-expect-error" . | wc -l | xargs > diff --git a/.gitignore b/.gitignore index b46401e388b..705f7d48f35 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ tests/unit/utils/tmp # Used in local dev by tsc: https://www.typescriptlang.org/tsconfig/#tsBuildInfoFile *.tsbuildinfo + +# Size metrics written by scripts/measure-size.js, uploaded as CI artifacts +.delta.* diff --git a/scripts/measure-size.js b/scripts/measure-size.js new file mode 100644 index 00000000000..0c9edec4e33 --- /dev/null +++ b/scripts/measure-size.js @@ -0,0 +1,190 @@ +/* + * Measures the size impact of a change and writes the numbers as `.delta.*` files for + * `netlify/delta-action` to compare against `main` and post on the PR. See + * `.github/workflows/benchmark.yml`. + */ + +import { execFile } from 'node:child_process' +import { readdir, readFile, lstat, mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { promisify } from 'node:util' + +const execFileAsync = promisify(execFile) + +/** cacache stores downloaded tarballs under this key prefix; registry metadata shares the prefix. */ +const TARBALL_KEY_SUFFIX = '.tgz' + +const walkFiles = async (dir) => { + let entries + try { + entries = await readdir(dir, { withFileTypes: true }) + } catch (error) { + if (error.code === 'ENOENT' || error.code === 'ENOTDIR') { + return [] + } + throw error + } + + const nested = await Promise.all( + entries.map(async (entry) => { + const entryPath = path.join(dir, entry.name) + return entry.isDirectory() ? walkFiles(entryPath) : [entryPath] + }), + ) + return nested.flat() +} + +/** + * Total bytes of every package tarball npm downloaded into `cacheDir`. + * + * Read from cacache's index rather than by measuring the cache on disk: the index records an exact + * byte count per entry, and lets us exclude cached registry metadata, which is downloaded too but + * fluctuates as unrelated packages publish. Tarballs for a published version are immutable, so a + * given lockfile always produces the same total. + */ +export const sumCachedTarballBytes = async (cacheDir) => { + const buckets = await walkFiles(path.join(cacheDir, '_cacache', 'index-v5')) + + // Buckets are append-only logs, so one key can appear several times. Keep the last entry per key + // rather than summing every line, which would count a re-fetched tarball more than once. + const sizeByKey = new Map() + + for (const bucket of buckets) { + const contents = await readFile(bucket, 'utf8') + for (const line of contents.split('\n')) { + const separator = line.indexOf('\t') + if (separator === -1) { + continue + } + let entry + try { + entry = JSON.parse(line.slice(separator + 1)) + } catch { + // A partially written line is normal in an append-only log. + continue + } + if (entry?.key?.endsWith(TARBALL_KEY_SUFFIX) && typeof entry.size === 'number') { + sizeByKey.set(entry.key, entry.size) + } + } + } + + return [...sizeByKey.values()].reduce((total, size) => total + size, 0) +} + +/** + * Total bytes of the files under `dir`. + * + * Uses real file sizes rather than `du`, which reports disk blocks and so inflates a tree of many + * small files by an amount that varies with the filesystem. Symlinks are measured as links, not + * followed, so `node_modules/.bin` doesn't count binaries twice. + */ +export const directoryBytes = async (dir) => { + const files = await walkFiles(dir) + const sizes = await Promise.all( + files.map(async (file) => { + try { + return (await lstat(file)).size + } catch { + return 0 + } + }), + ) + return sizes.reduce((total, size) => total + size, 0) +} + +/** Renders one metric in the two-line shape `delta-action` parses: value, then `unit (label)`. */ +export const formatDelta = (value, unit, label) => `${Math.round(value).toString()}\n${unit} (${label})\n` + +/** Counts installed packages by looking for the manifests npm wrote, including nested copies. */ +const countPackages = async (nodeModulesDir) => { + const files = await walkFiles(nodeModulesDir) + return files.filter((file) => path.basename(file) === 'package.json').length +} + +/** + * Builds the tarball a release would publish, installs it the way a user would, and reports what + * that cost. Measuring a real install rather than the repo's own `node_modules` is what lets these + * numbers cover our own package contents as well as our dependencies. + */ +const measure = async (repoDir, scratchDir) => { + const packDir = path.join(scratchDir, 'pack') + const installDir = path.join(scratchDir, 'install') + const cacheDir = path.join(scratchDir, 'npm-cache') + await mkdir(packDir, { recursive: true }) + await mkdir(installDir, { recursive: true }) + + const { stdout: packStdout } = await execFileAsync( + 'npm', + ['pack', '--json', '--pack-destination', packDir, '--silent'], + { cwd: repoDir, maxBuffer: 64 * 1024 * 1024 }, + ) + const [packed] = JSON.parse(packStdout) + const tarballPath = path.join(packDir, packed.filename) + + await writeFile( + path.join(installDir, 'package.json'), + `${JSON.stringify({ name: 'size-probe', version: '1.0.0', private: true }, null, 2)}\n`, + ) + + // `--ignore-scripts` keeps this deterministic and safe; it means the total is what npm downloads, + // not what a dependency's own postinstall might fetch afterwards. + await execFileAsync( + 'npm', + [ + 'install', + tarballPath, + '--omit=dev', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--cache', + cacheDir, + '--loglevel', + 'error', + ], + { cwd: installDir, maxBuffer: 64 * 1024 * 1024 }, + ) + + const nodeModulesDir = path.join(installDir, 'node_modules') + // The CLI tarball is installed from disk, so it never lands in the cache -- add it back to get + // the full download a user would perform. + const dependencyTarballBytes = await sumCachedTarballBytes(cacheDir) + + return { + packageDownloadBytes: packed.size, + totalDownloadBytes: dependencyTarballBytes + packed.size, + installedBytes: await directoryBytes(nodeModulesDir), + dependencyCount: await countPackages(nodeModulesDir), + } +} + +const main = async () => { + const repoDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') + const scratchDir = await mkdtemp(path.join(process.env.RUNNER_TEMP ?? tmpdir(), 'measure-size-')) + + try { + const result = await measure(repoDir, scratchDir) + + const metrics = [ + ['.delta.downloadSizePackage', result.packageDownloadBytes / 1024, 'kb', 'Download size (CLI package)'], + ['.delta.downloadSizeInstall', result.totalDownloadBytes / 1024, 'kb', 'Download size (full install)'], + ['.delta.installedSize', result.installedBytes / 1024, 'kb', 'Installed size'], + ['.delta.dependencyCount', result.dependencyCount, '', 'Dependency count'], + ] + + for (const [filename, value, unit, label] of metrics) { + await writeFile(path.join(repoDir, filename), formatDelta(value, unit, label)) + console.log(`${label}: ${Math.round(value).toLocaleString()} ${unit}`.trim()) + } + } finally { + await rm(scratchDir, { recursive: true, force: true }) + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main() +} diff --git a/tests/unit/scripts/measure-size.test.ts b/tests/unit/scripts/measure-size.test.ts new file mode 100644 index 00000000000..b59c1bd2e44 --- /dev/null +++ b/tests/unit/scripts/measure-size.test.ts @@ -0,0 +1,120 @@ +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { afterEach, beforeEach, describe, expect, test } from 'vitest' + +import { directoryBytes, formatDelta, sumCachedTarballBytes } from '../../../scripts/measure-size.js' + +let workDir: string + +beforeEach(async () => { + workDir = await mkdtemp(path.join(tmpdir(), 'measure-size-')) +}) + +afterEach(async () => { + await rm(workDir, { recursive: true, force: true }) +}) + +/** + * Writes a cacache index bucket. Real buckets are append-only logs of `\t` lines, which + * is the detail most of these tests exist to pin down. + */ +const writeIndexBucket = async (cacheDir: string, bucketName: string, entries: object[]) => { + const bucketDir = path.join(cacheDir, '_cacache', 'index-v5', 'aa', 'bb') + await mkdir(bucketDir, { recursive: true }) + const contents = entries.map((entry) => `0000000000\t${JSON.stringify(entry)}`).join('\n') + await writeFile(path.join(bucketDir, bucketName), `${contents}\n`) +} + +const tarballEntry = (name: string, version: string, size: number) => ({ + key: `make-fetch-happen:request-cache:https://registry.npmjs.org/${name}/-/${name}-${version}.tgz`, + integrity: 'sha512-fake', + time: 1_770_825_313_018, + size, +}) + +describe('sumCachedTarballBytes', () => { + test('sums the byte size of every cached tarball', async () => { + await writeIndexBucket(workDir, 'bucket1', [tarballEntry('chalk', '5.3.0', 13_397)]) + await writeIndexBucket(workDir, 'bucket2', [tarballEntry('diff', '4.0.4', 98_055)]) + + expect(await sumCachedTarballBytes(workDir)).toBe(111_452) + }) + + test('ignores cached registry metadata, counting only tarballs', async () => { + await writeIndexBucket(workDir, 'bucket1', [ + tarballEntry('chalk', '5.3.0', 13_397), + { + key: 'make-fetch-happen:request-cache:https://registry.npmjs.org/chalk', + integrity: 'sha512-fake', + time: 1_770_825_313_018, + size: 5_000_000, + }, + ]) + + expect(await sumCachedTarballBytes(workDir)).toBe(13_397) + }) + + test('counts a re-fetched tarball once rather than once per log entry', async () => { + // cacache appends rather than rewrites, so the same key legitimately appears more than once. + await writeIndexBucket(workDir, 'bucket1', [ + tarballEntry('chalk', '5.3.0', 13_397), + tarballEntry('chalk', '5.3.0', 13_397), + ]) + + expect(await sumCachedTarballBytes(workDir)).toBe(13_397) + }) + + test('skips unparseable lines rather than throwing', async () => { + const bucketDir = path.join(workDir, '_cacache', 'index-v5', 'aa', 'bb') + await mkdir(bucketDir, { recursive: true }) + await writeFile( + path.join(bucketDir, 'bucket1'), + `0000000000\t{"truncated":\n0000000000\t${JSON.stringify(tarballEntry('chalk', '5.3.0', 13_397))}\n`, + ) + + expect(await sumCachedTarballBytes(workDir)).toBe(13_397) + }) + + test('returns zero when nothing was downloaded', async () => { + expect(await sumCachedTarballBytes(path.join(workDir, 'never-created'))).toBe(0) + }) +}) + +describe('directoryBytes', () => { + test('sums real file sizes across nested directories', async () => { + await mkdir(path.join(workDir, 'nested'), { recursive: true }) + await writeFile(path.join(workDir, 'a.js'), 'x'.repeat(100)) + await writeFile(path.join(workDir, 'nested', 'b.js'), 'x'.repeat(250)) + + expect(await directoryBytes(workDir)).toBe(350) + }) + + test('counts a symlink itself rather than following it to its target', async () => { + // `node_modules/.bin` is full of symlinks; following them would count binaries repeatedly. + const { symlink } = await import('node:fs/promises') + await writeFile(path.join(workDir, 'real.js'), 'x'.repeat(100)) + await symlink(path.join(workDir, 'real.js'), path.join(workDir, 'link.js')) + + expect(await directoryBytes(workDir)).toBeLessThan(200) + }) + + test('returns zero for a directory that does not exist', async () => { + expect(await directoryBytes(path.join(workDir, 'never-created'))).toBe(0) + }) +}) + +describe('formatDelta', () => { + test('renders the value and label in the format delta-action parses', () => { + expect(formatDelta(1234, 'kb', 'Download size (full install)')).toBe('1234\nkb (Download size (full install))\n') + }) + + test('omits the unit for unitless metrics, keeping the leading space', () => { + expect(formatDelta(1093, '', 'Dependency count')).toBe('1093\n (Dependency count)\n') + }) + + test('rounds fractional values, since delta-action expects an integer', () => { + expect(formatDelta(1234.6, 'kb', 'Installed size')).toBe('1235\nkb (Installed size)\n') + }) +}) From a938da419551ada43c510eecd985203d60a6c6c7 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Wed, 2 Sep 2026 10:06:42 -0700 Subject: [PATCH 2/3] chore: give the installed package count its own delta key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first run posted "Dependency count: 1,240 ⬆️ 11.85% increase", which is not a real regression -- it compared a count of every package directory npm wrote against the old `npm ls` count under the same key. Same reasoning that renamed `.delta.packageSize` to `.delta.installedSize`; this key was missed. Relabelled to "Installed package count" too, since it counts nested duplicate copies rather than distinct dependencies. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/measure-size.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/measure-size.js b/scripts/measure-size.js index 0c9edec4e33..dff8c54ccff 100644 --- a/scripts/measure-size.js +++ b/scripts/measure-size.js @@ -173,7 +173,10 @@ const main = async () => { ['.delta.downloadSizePackage', result.packageDownloadBytes / 1024, 'kb', 'Download size (CLI package)'], ['.delta.downloadSizeInstall', result.totalDownloadBytes / 1024, 'kb', 'Download size (full install)'], ['.delta.installedSize', result.installedBytes / 1024, 'kb', 'Installed size'], - ['.delta.dependencyCount', result.dependencyCount, '', 'Dependency count'], + // Deliberately not `.delta.dependencyCount`: this counts every package directory npm wrote, + // including nested duplicate copies, so it is a different measurement from the `npm ls` count + // that key used to hold. Reusing the key would compare the two and report a phantom jump. + ['.delta.installedPackageCount', result.dependencyCount, '', 'Installed package count'], ] for (const [filename, value, unit, label] of metrics) { From 4f7fac43558e179896bd7801d5b8d35a57c48dd2 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Thu, 3 Sep 2026 18:42:32 -0700 Subject: [PATCH 3/3] chore: count installed package roots rather than every manifest A dependency can ship `package.json` files outside its own root -- in `dist`, or in test fixtures -- and those were inflating the installed package count. Only direct children of a `node_modules` directory (or of a scope directory inside one) are counted now. Also strengthens two assertions that passed for the wrong reason: the cacache dedupe test used identical sizes, so it could not tell "keep the last entry" from "keep the first", and the symlink test's upper bound also passed if the link was skipped entirely. Records why this is Node rather than a shell script: a bash version produces identical numbers but needs `jq` and GNU `find`, and cannot be reached by the unit tests. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/measure-size.js | 76 ++++++++++++++++++------- tests/unit/scripts/measure-size.test.ts | 60 ++++++++++++++++--- 2 files changed, 106 insertions(+), 30 deletions(-) diff --git a/scripts/measure-size.js b/scripts/measure-size.js index dff8c54ccff..e3e8d424695 100644 --- a/scripts/measure-size.js +++ b/scripts/measure-size.js @@ -1,11 +1,15 @@ /* - * Measures the size impact of a change and writes the numbers as `.delta.*` files for - * `netlify/delta-action` to compare against `main` and post on the PR. See - * `.github/workflows/benchmark.yml`. + * The `.delta.*` files written here are consumed by `netlify/delta-action`, which compares them + * against `main` and posts the result on the PR. See `.github/workflows/benchmark.yml`. + * + * A shell version of this was tried first and produces identical numbers, but it needs `jq` and + * GNU `find` (`-printf` is not in BSD `find`, so it will not run on a maintainer's mac). Node is + * already guaranteed here, and the measurements below are only worth reading if they are right, + * which is what the unit tests in `tests/unit/scripts` are for. */ import { execFile } from 'node:child_process' -import { readdir, readFile, lstat, mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { access, readdir, readFile, lstat, mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import process from 'node:process' @@ -14,7 +18,7 @@ import { promisify } from 'node:util' const execFileAsync = promisify(execFile) -/** cacache stores downloaded tarballs under this key prefix; registry metadata shares the prefix. */ +/** Registry metadata is cached under the same key namespace as tarballs; only tarball keys end here. */ const TARBALL_KEY_SUFFIX = '.tgz' const walkFiles = async (dir) => { @@ -38,8 +42,6 @@ const walkFiles = async (dir) => { } /** - * Total bytes of every package tarball npm downloaded into `cacheDir`. - * * Read from cacache's index rather than by measuring the cache on disk: the index records an exact * byte count per entry, and lets us exclude cached registry metadata, which is downloaded too but * fluctuates as unrelated packages publish. Tarballs for a published version are immutable, so a @@ -76,8 +78,6 @@ export const sumCachedTarballBytes = async (cacheDir) => { } /** - * Total bytes of the files under `dir`. - * * Uses real file sizes rather than `du`, which reports disk blocks and so inflates a tree of many * small files by an amount that varies with the filesystem. Symlinks are measured as links, not * followed, so `node_modules/.bin` doesn't count binaries twice. @@ -96,19 +96,52 @@ export const directoryBytes = async (dir) => { return sizes.reduce((total, size) => total + size, 0) } -/** Renders one metric in the two-line shape `delta-action` parses: value, then `unit (label)`. */ +/** `delta-action` parses each metric file as a value line followed by an `unit (label)` line. */ export const formatDelta = (value, unit, label) => `${Math.round(value).toString()}\n${unit} (${label})\n` -/** Counts installed packages by looking for the manifests npm wrote, including nested copies. */ -const countPackages = async (nodeModulesDir) => { - const files = await walkFiles(nodeModulesDir) - return files.filter((file) => path.basename(file) === 'package.json').length +const hasManifest = async (dir) => { + try { + await access(path.join(dir, 'package.json')) + return true + } catch { + return false + } +} + +/** + * Only the direct children of a `node_modules` directory (or of a scope directory inside one) are + * package roots. A dependency is free to ship `package.json` files of its own -- inside `dist`, or + * in test fixtures -- and those are not installed packages. + */ +export const countPackageRoots = async (nodeModulesDir) => { + let entries + try { + entries = await readdir(nodeModulesDir, { withFileTypes: true }) + } catch (error) { + if (error.code === 'ENOENT' || error.code === 'ENOTDIR') { + return 0 + } + throw error + } + + const counts = await Promise.all( + entries + .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')) + .map(async (entry) => { + const entryPath = path.join(nodeModulesDir, entry.name) + if (entry.name.startsWith('@')) { + return countPackageRoots(entryPath) + } + const nested = await countPackageRoots(path.join(entryPath, 'node_modules')) + return ((await hasManifest(entryPath)) ? 1 : 0) + nested + }), + ) + return counts.reduce((total, count) => total + count, 0) } /** - * Builds the tarball a release would publish, installs it the way a user would, and reports what - * that cost. Measuring a real install rather than the repo's own `node_modules` is what lets these - * numbers cover our own package contents as well as our dependencies. + * Measuring a real install rather than the repo's own `node_modules` is what lets these numbers + * cover our own published package contents as well as our dependencies. */ const measure = async (repoDir, scratchDir) => { const packDir = path.join(scratchDir, 'pack') @@ -158,7 +191,7 @@ const measure = async (repoDir, scratchDir) => { packageDownloadBytes: packed.size, totalDownloadBytes: dependencyTarballBytes + packed.size, installedBytes: await directoryBytes(nodeModulesDir), - dependencyCount: await countPackages(nodeModulesDir), + packageCount: await countPackageRoots(nodeModulesDir), } } @@ -173,10 +206,9 @@ const main = async () => { ['.delta.downloadSizePackage', result.packageDownloadBytes / 1024, 'kb', 'Download size (CLI package)'], ['.delta.downloadSizeInstall', result.totalDownloadBytes / 1024, 'kb', 'Download size (full install)'], ['.delta.installedSize', result.installedBytes / 1024, 'kb', 'Installed size'], - // Deliberately not `.delta.dependencyCount`: this counts every package directory npm wrote, - // including nested duplicate copies, so it is a different measurement from the `npm ls` count - // that key used to hold. Reusing the key would compare the two and report a phantom jump. - ['.delta.installedPackageCount', result.dependencyCount, '', 'Installed package count'], + // Deliberately not `.delta.dependencyCount`: that key held an `npm ls` count of the repo's own + // tree, so reusing it would compare two different measurements and report a phantom jump. + ['.delta.installedPackageCount', result.packageCount, '', 'Installed package count'], ] for (const [filename, value, unit, label] of metrics) { diff --git a/tests/unit/scripts/measure-size.test.ts b/tests/unit/scripts/measure-size.test.ts index b59c1bd2e44..a2a34630574 100644 --- a/tests/unit/scripts/measure-size.test.ts +++ b/tests/unit/scripts/measure-size.test.ts @@ -1,10 +1,10 @@ -import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { mkdtemp, mkdir, writeFile, rm, lstat, symlink } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import { afterEach, beforeEach, describe, expect, test } from 'vitest' -import { directoryBytes, formatDelta, sumCachedTarballBytes } from '../../../scripts/measure-size.js' +import { countPackageRoots, directoryBytes, formatDelta, sumCachedTarballBytes } from '../../../scripts/measure-size.js' let workDir: string @@ -60,10 +60,10 @@ describe('sumCachedTarballBytes', () => { // cacache appends rather than rewrites, so the same key legitimately appears more than once. await writeIndexBucket(workDir, 'bucket1', [ tarballEntry('chalk', '5.3.0', 13_397), - tarballEntry('chalk', '5.3.0', 13_397), + tarballEntry('chalk', '5.3.0', 14_001), ]) - expect(await sumCachedTarballBytes(workDir)).toBe(13_397) + expect(await sumCachedTarballBytes(workDir)).toBe(14_001) }) test('skips unparseable lines rather than throwing', async () => { @@ -93,11 +93,12 @@ describe('directoryBytes', () => { test('counts a symlink itself rather than following it to its target', async () => { // `node_modules/.bin` is full of symlinks; following them would count binaries repeatedly. - const { symlink } = await import('node:fs/promises') - await writeFile(path.join(workDir, 'real.js'), 'x'.repeat(100)) - await symlink(path.join(workDir, 'real.js'), path.join(workDir, 'link.js')) + const target = path.join(workDir, 'real.js') + const link = path.join(workDir, 'link.js') + await writeFile(target, 'x'.repeat(100)) + await symlink(target, link) - expect(await directoryBytes(workDir)).toBeLessThan(200) + expect(await directoryBytes(workDir)).toBe(100 + (await lstat(link)).size) }) test('returns zero for a directory that does not exist', async () => { @@ -105,6 +106,49 @@ describe('directoryBytes', () => { }) }) +describe('countPackageRoots', () => { + const writeManifest = async (...segments: string[]) => { + const dir = path.join(workDir, ...segments) + await mkdir(dir, { recursive: true }) + await writeFile(path.join(dir, 'package.json'), '{}') + } + + test('counts unscoped and scoped packages', async () => { + await writeManifest('node_modules', 'chalk') + await writeManifest('node_modules', '@netlify', 'api') + await writeManifest('node_modules', '@netlify', 'blobs') + + expect(await countPackageRoots(path.join(workDir, 'node_modules'))).toBe(3) + }) + + test('counts nested duplicate copies npm hoisted separately', async () => { + await writeManifest('node_modules', 'chalk') + await writeManifest('node_modules', 'chalk', 'node_modules', 'ansi-styles') + + expect(await countPackageRoots(path.join(workDir, 'node_modules'))).toBe(2) + }) + + test('ignores manifests a package ships outside its root', async () => { + await writeManifest('node_modules', 'chalk') + await writeManifest('node_modules', 'chalk', 'dist') + await writeManifest('node_modules', 'chalk', 'test', 'fixtures', 'project') + + expect(await countPackageRoots(path.join(workDir, 'node_modules'))).toBe(1) + }) + + test('ignores directories npm writes that are not packages', async () => { + await writeManifest('node_modules', 'chalk') + await mkdir(path.join(workDir, 'node_modules', '.bin'), { recursive: true }) + await mkdir(path.join(workDir, 'node_modules', 'empty-dir'), { recursive: true }) + + expect(await countPackageRoots(path.join(workDir, 'node_modules'))).toBe(1) + }) + + test('returns zero when nothing was installed', async () => { + expect(await countPackageRoots(path.join(workDir, 'never-created'))).toBe(0) + }) +}) + describe('formatDelta', () => { test('renders the value and label in the format delta-action parses', () => { expect(formatDelta(1234, 'kb', 'Download size (full install)')).toBe('1234\nkb (Download size (full install))\n')