From 3b2d8dd5b942da209c839f21c2dab4ce8bebdb97 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Tue, 4 Aug 2026 11:53:22 -0700 Subject: [PATCH 1/4] Test: assert declared production dependencies resolve in an install A lockfile can be self-consistent and still resolve a published package's runtime dependency to a node that a production install prunes, so the package installs fine for contributors and fails at first import for consumers. Add a dependency-free check that reads every published workspace manifest and probes the installed tree for each declared dependency, plus its black-box test suite. The check is driven off the manifests rather than an allowlist, so it covers new dependencies automatically. --- scripts/check_production_install.mjs | 144 +++++++++++ .../scripts/check_production_install_test.ts | 234 ++++++++++++++++++ 2 files changed, 378 insertions(+) create mode 100644 scripts/check_production_install.mjs create mode 100644 tests/integration/scripts/check_production_install_test.ts diff --git a/scripts/check_production_install.mjs b/scripts/check_production_install.mjs new file mode 100644 index 000000000..cd36109a9 --- /dev/null +++ b/scripts/check_production_install.mjs @@ -0,0 +1,144 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Asserts that every package a published workspace declares under + * `dependencies` is present in the installed tree. + * + * Run it after `npm ci --omit=dev` to prove that a consumer of the published + * packages gets everything the runtime imports: a lockfile can be perfectly + * self-consistent and still resolve a runtime dependency to a node that a + * production install prunes. + * + * This validates the manifest -> installed tree direction only. A package that + * `src` imports but no manifest declares is never in the iteration set, so it + * is not caught here. + * + * Usage: node scripts/check_production_install.mjs [rootDir] + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +/** + * The subset of an npm manifest this check reads. + * + * @typedef {object} Manifest + * @property {string[]} [workspaces] Workspace directories, root manifest only. + * @property {Record} [dependencies] Production dependencies. + */ + +/** + * @param {string} manifestPath + * @returns {Manifest} + */ +function readManifest(manifestPath) { + let contents; + try { + contents = fs.readFileSync(manifestPath, 'utf8'); + } catch (error) { + throw new Error(`Cannot read ${manifestPath}: ${error.message}`); + } + try { + return JSON.parse(contents); + } catch (error) { + throw new Error(`Cannot parse ${manifestPath}: ${error.message}`); + } +} + +/** + * Whether `candidate` is a directory, following symlinks: npm links workspace + * packages into `node_modules`, so an `lstat` would reject a healthy tree. + * + * @param {string} candidate + * @returns {boolean} + */ +function isDirectory(candidate) { + return ( + fs.statSync(candidate, {throwIfNoEntry: false})?.isDirectory() === true + ); +} + +/** + * The locations npm may install `name` for `workspace`: nested under the + * workspace when versions conflict, hoisted to the root otherwise. + * + * @param {string} rootDir + * @param {string} workspace + * @param {string} name + * @returns {string[]} + */ +function candidatePaths(rootDir, workspace, name) { + return [ + path.join(rootDir, workspace, 'node_modules', name), + path.join(rootDir, 'node_modules', name), + ]; +} + +/** + * @param {string[]} argv + * @returns {number} The process exit code. + */ +function main(argv) { + const rootDir = path.resolve(argv[0] ?? process.cwd()); + const rootManifestPath = path.join(rootDir, 'package.json'); + const {workspaces} = readManifest(rootManifestPath); + if (!Array.isArray(workspaces)) { + throw new Error(`${rootManifestPath} declares no "workspaces" array.`); + } + + const unresolved = []; + let checked = 0; + for (const workspace of workspaces) { + if (workspace.includes('*')) { + throw new Error( + `Unsupported glob in workspace entry "${workspace}". This check ` + + `resolves workspace paths literally, so it would silently verify ` + + `nothing for every package the glob matches. Teach it to expand ` + + `globs before declaring one.`, + ); + } + const {dependencies} = readManifest( + path.join(rootDir, workspace, 'package.json'), + ); + for (const name of Object.keys(dependencies ?? {})) { + checked += 1; + const candidates = candidatePaths(rootDir, workspace, name); + if (!candidates.some(isDirectory)) { + unresolved.push( + `${workspace}: "${name}" is declared under dependencies but is ` + + `installed at neither ${candidates[0]} nor ${candidates[1]}.`, + ); + } + } + } + + if (unresolved.length > 0) { + process.stderr.write(`${unresolved.join('\n')}\n`); + process.stderr.write( + `::error::${unresolved.length} of ${checked} declared production ` + + `dependencies are missing after a production install. Move each one ` + + `out of devDependencies into the dependencies block of the workspace ` + + `that imports it at runtime, then re-run npm install so ` + + `package-lock.json records it as a production node.\n`, + ); + return 1; + } + + process.stdout.write( + `Verified ${checked} production dependencies across ` + + `${workspaces.length} workspaces.\n`, + ); + return 0; +} + +try { + process.exitCode = main(process.argv.slice(2)); +} catch (error) { + process.stderr.write(`::error::${error.message}\n`); + process.exitCode = 1; +} diff --git a/tests/integration/scripts/check_production_install_test.ts b/tests/integration/scripts/check_production_install_test.ts new file mode 100644 index 000000000..99c67c229 --- /dev/null +++ b/tests/integration/scripts/check_production_install_test.ts @@ -0,0 +1,234 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {spawnSync, type SpawnSyncReturns} from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; + +const SCRIPT_PATH = fileURLToPath( + new URL('../../../scripts/check_production_install.mjs', import.meta.url), +); + +/** Fixture root for the test currently running. */ +let root: string; + +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'adk-production-install-')); +}); + +afterEach(() => { + fs.rmSync(root, {recursive: true, force: true}); +}); + +function writeJson(filePath: string, value: unknown): void { + fs.mkdirSync(path.dirname(filePath), {recursive: true}); + fs.writeFileSync(filePath, JSON.stringify(value)); +} + +function writeRootManifest(workspaces: string[]): void { + writeJson(path.join(root, 'package.json'), {name: 'fixture', workspaces}); +} + +function writeWorkspaceManifest( + workspace: string, + manifest: Record, +): void { + writeJson(path.join(root, workspace, 'package.json'), { + name: workspace, + ...manifest, + }); +} + +/** Creates `/node_modules/` as an installed package directory. */ +function installPackage(dir: string, name: string): void { + fs.mkdirSync(path.join(root, dir, 'node_modules', name), {recursive: true}); +} + +function runCheck( + args: string[] = [root], + cwd?: string, +): SpawnSyncReturns { + return spawnSync(process.execPath, [SCRIPT_PATH, ...args], { + encoding: 'utf8', + cwd, + }); +} + +describe('check_production_install', () => { + it('passes when a declared dependency is hoisted to the root', () => { + writeRootManifest(['alpha']); + writeWorkspaceManifest('alpha', {dependencies: {'dep-one': '^1.0.0'}}); + installPackage('.', 'dep-one'); + + const result = runCheck(); + + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + expect(result.stdout).toContain('Verified 1 production dependencies'); + expect(result.stdout).toContain('across 1 workspaces'); + }); + + it('defaults the root directory to the working directory', () => { + writeRootManifest(['alpha']); + writeWorkspaceManifest('alpha', {dependencies: {'dep-one': '^1.0.0'}}); + installPackage('.', 'dep-one'); + + const result = runCheck([], root); + + expect(result.status).toBe(0); + expect(result.stdout).toContain('Verified 1 production dependencies'); + }); + + it('fails and names both probed paths when a dependency is absent', () => { + writeRootManifest(['alpha']); + writeWorkspaceManifest('alpha', {dependencies: {'dep-one': '^1.0.0'}}); + + const result = runCheck(); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('alpha'); + expect(result.stderr).toContain('dep-one'); + expect(result.stderr).toContain( + path.join(root, 'alpha', 'node_modules', 'dep-one'), + ); + expect(result.stderr).toContain(path.join(root, 'node_modules', 'dep-one')); + expect(result.stderr).toContain('::error::'); + }); + + it('passes when a dependency is nested under the workspace', () => { + writeRootManifest(['alpha']); + writeWorkspaceManifest('alpha', {dependencies: {'dep-one': '^1.0.0'}}); + installPackage('alpha', 'dep-one'); + + const result = runCheck(); + + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + }); + + it('resolves a scoped dependency name', () => { + writeRootManifest(['alpha']); + writeWorkspaceManifest('alpha', {dependencies: {'@scope/pkg': '^1.0.0'}}); + installPackage('.', '@scope/pkg'); + + const result = runCheck(); + + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + }); + + it('follows a symlinked package directory', () => { + writeRootManifest(['alpha']); + writeWorkspaceManifest('alpha', {dependencies: {'@scope/pkg': '^1.0.0'}}); + const linkTarget = path.join(root, 'vendor', 'pkg'); + fs.mkdirSync(linkTarget, {recursive: true}); + const link = path.join(root, 'node_modules', '@scope', 'pkg'); + fs.mkdirSync(path.dirname(link), {recursive: true}); + fs.symlinkSync(linkTarget, link, 'junction'); + + const result = runCheck(); + + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + }); + + it('fails when the installed path is a file rather than a directory', () => { + writeRootManifest(['alpha']); + writeWorkspaceManifest('alpha', {dependencies: {'dep-one': '^1.0.0'}}); + const installed = path.join(root, 'node_modules', 'dep-one'); + fs.mkdirSync(path.dirname(installed), {recursive: true}); + fs.writeFileSync(installed, ''); + + const result = runCheck(); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('dep-one'); + }); + + it('ignores devDependencies', () => { + writeRootManifest(['alpha']); + writeWorkspaceManifest('alpha', { + dependencies: {'dep-one': '^1.0.0'}, + devDependencies: {'dev-only': '^1.0.0'}, + }); + installPackage('.', 'dep-one'); + + const result = runCheck(); + + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + expect(result.stdout).toContain('Verified 1 production dependencies'); + }); + + it('reports every unresolved dependency in a single run', () => { + writeRootManifest(['alpha', 'beta']); + writeWorkspaceManifest('alpha', {dependencies: {'dep-one': '^1.0.0'}}); + writeWorkspaceManifest('beta', {dependencies: {'dep-two': '^1.0.0'}}); + + const result = runCheck(); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('dep-one'); + expect(result.stderr).toContain('dep-two'); + expect(result.stderr).toContain('2 of 2 declared production dependencies'); + }); + + describe('inputs it cannot verify', () => { + it('rejects a glob workspace entry instead of skipping it', () => { + writeRootManifest(['packages/*']); + + const result = runCheck(); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Unsupported glob'); + expect(result.stderr).toContain('packages/*'); + }); + + it('fails when the root manifest is missing', () => { + const result = runCheck(); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + `Cannot read ${path.join(root, 'package.json')}`, + ); + }); + + it('fails when the root manifest declares no workspaces array', () => { + writeJson(path.join(root, 'package.json'), {name: 'fixture'}); + + const result = runCheck(); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('no "workspaces" array'); + }); + + it('fails when a workspace manifest is missing', () => { + writeRootManifest(['alpha']); + + const result = runCheck(); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + `Cannot read ${path.join(root, 'alpha', 'package.json')}`, + ); + }); + + it('fails when a workspace manifest is not valid JSON', () => { + writeRootManifest(['alpha']); + const manifestPath = path.join(root, 'alpha', 'package.json'); + fs.mkdirSync(path.dirname(manifestPath), {recursive: true}); + fs.writeFileSync(manifestPath, '{'); + + const result = runCheck(); + + expect(result.status).toBe(1); + expect(result.stderr).toContain(`Cannot parse ${manifestPath}`); + }); + }); +}); From 5cb371cd11c6afe52bab127464011ce9dfcaeac6 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Tue, 4 Aug 2026 11:53:30 -0700 Subject: [PATCH 2/4] Ci: run the production dependency check after npm ci --omit=dev `run-tests` installs devDependencies, so a package reachable only through the dev tree still resolves there. Add a separate ubuntu-only job that performs a real production install and runs the check against it, which is the only place the published dependency set is exercised. --- .github/workflows/validation.yaml | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/validation.yaml b/.github/workflows/validation.yaml index 7bb00098e..36fe18d77 100644 --- a/.github/workflows/validation.yaml +++ b/.github/workflows/validation.yaml @@ -7,7 +7,7 @@ on: branches: [main] env: - NODE_OPTIONS: "--max-old-space-size=8192" + NODE_OPTIONS: '--max-old-space-size=8192' jobs: run-tests: @@ -48,3 +48,24 @@ jobs: - name: Run documentation build check run: npm run docs:check + + # A production install is the only place the published dependency set is + # exercised: `run-tests` installs devDependencies too, so a package that is + # only reachable through the dev tree still resolves there. + production-install: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Use Node.js + uses: actions/setup-node@v6 + + # --ignore-scripts skips lifecycle scripts but still extracts every + # package, so the directories the check probes are present. + - name: Install production dependencies only + run: npm ci --omit=dev --ignore-scripts + + - name: Check declared production dependencies are installed + run: node scripts/check_production_install.mjs From 85dc10dd53d83f5575b8443ebb22dc6784a15903 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Tue, 4 Aug 2026 11:59:27 -0700 Subject: [PATCH 3/4] Test: cover a workspace that declares no dependencies A published workspace may legitimately carry no runtime dependencies, and the fallback for a missing dependencies block was the one expression the suite never executed. --- .../scripts/check_production_install_test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/integration/scripts/check_production_install_test.ts b/tests/integration/scripts/check_production_install_test.ts index 99c67c229..103317aa3 100644 --- a/tests/integration/scripts/check_production_install_test.ts +++ b/tests/integration/scripts/check_production_install_test.ts @@ -166,6 +166,20 @@ describe('check_production_install', () => { expect(result.stdout).toContain('Verified 1 production dependencies'); }); + it('accepts a workspace that declares no dependencies', () => { + writeRootManifest(['alpha', 'beta']); + writeWorkspaceManifest('alpha', {dependencies: {'dep-one': '^1.0.0'}}); + writeWorkspaceManifest('beta', {}); + installPackage('.', 'dep-one'); + + const result = runCheck(); + + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + expect(result.stdout).toContain('Verified 1 production dependencies'); + expect(result.stdout).toContain('across 2 workspaces'); + }); + it('reports every unresolved dependency in a single run', () => { writeRootManifest(['alpha', 'beta']); writeWorkspaceManifest('alpha', {dependencies: {'dep-one': '^1.0.0'}}); From 22aeb50d7ad423a50d153486ee5dac7c36516817 Mon Sep 17 00:00:00 2001 From: Amaad Martin Date: Tue, 4 Aug 2026 12:23:26 -0700 Subject: [PATCH 4/4] Ci: assert the production install with npm ls instead of a bespoke script npm already performs this check: `npm ls --omit=dev --workspaces --include-workspace-root` walks every workspace's declared dependencies against the installed tree and exits ELSPROBLEMS when one is absent. It also names the version range and the requiring package, and expands globbed workspace entries, which the hand-rolled script could not. Verified against a production install of this repo: exit 0 on a healthy tree; exit 1 naming the package when a declared dependency is deleted; exit 1 when a lockfile node for a declared production dependency carries "dev": true and npm ci --omit=dev prunes it, which is the incident this job exists to catch. Also revert an unrelated re-quoting of NODE_OPTIONS that a formatter applied to the env block. --- .github/workflows/validation.yaml | 11 +- scripts/check_production_install.mjs | 144 ---------- .../scripts/check_production_install_test.ts | 248 ------------------ 3 files changed, 7 insertions(+), 396 deletions(-) delete mode 100644 scripts/check_production_install.mjs delete mode 100644 tests/integration/scripts/check_production_install_test.ts diff --git a/.github/workflows/validation.yaml b/.github/workflows/validation.yaml index 36fe18d77..b7bfefdfb 100644 --- a/.github/workflows/validation.yaml +++ b/.github/workflows/validation.yaml @@ -7,7 +7,7 @@ on: branches: [main] env: - NODE_OPTIONS: '--max-old-space-size=8192' + NODE_OPTIONS: "--max-old-space-size=8192" jobs: run-tests: @@ -62,10 +62,13 @@ jobs: - name: Use Node.js uses: actions/setup-node@v6 - # --ignore-scripts skips lifecycle scripts but still extracts every - # package, so the directories the check probes are present. + # --ignore-scripts is required here: the root prepare script runs husky, + # which is a devDependency and so is absent from this install. - name: Install production dependencies only run: npm ci --omit=dev --ignore-scripts + # npm ls exits non-zero (ELSPROBLEMS) when a package a manifest declares + # is not present in the tree, which turns it into the assertion: every + # declared production dependency survived the install. - name: Check declared production dependencies are installed - run: node scripts/check_production_install.mjs + run: npm ls --omit=dev --workspaces --include-workspace-root diff --git a/scripts/check_production_install.mjs b/scripts/check_production_install.mjs deleted file mode 100644 index cd36109a9..000000000 --- a/scripts/check_production_install.mjs +++ /dev/null @@ -1,144 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * Asserts that every package a published workspace declares under - * `dependencies` is present in the installed tree. - * - * Run it after `npm ci --omit=dev` to prove that a consumer of the published - * packages gets everything the runtime imports: a lockfile can be perfectly - * self-consistent and still resolve a runtime dependency to a node that a - * production install prunes. - * - * This validates the manifest -> installed tree direction only. A package that - * `src` imports but no manifest declares is never in the iteration set, so it - * is not caught here. - * - * Usage: node scripts/check_production_install.mjs [rootDir] - */ - -import fs from 'node:fs'; -import path from 'node:path'; -import process from 'node:process'; - -/** - * The subset of an npm manifest this check reads. - * - * @typedef {object} Manifest - * @property {string[]} [workspaces] Workspace directories, root manifest only. - * @property {Record} [dependencies] Production dependencies. - */ - -/** - * @param {string} manifestPath - * @returns {Manifest} - */ -function readManifest(manifestPath) { - let contents; - try { - contents = fs.readFileSync(manifestPath, 'utf8'); - } catch (error) { - throw new Error(`Cannot read ${manifestPath}: ${error.message}`); - } - try { - return JSON.parse(contents); - } catch (error) { - throw new Error(`Cannot parse ${manifestPath}: ${error.message}`); - } -} - -/** - * Whether `candidate` is a directory, following symlinks: npm links workspace - * packages into `node_modules`, so an `lstat` would reject a healthy tree. - * - * @param {string} candidate - * @returns {boolean} - */ -function isDirectory(candidate) { - return ( - fs.statSync(candidate, {throwIfNoEntry: false})?.isDirectory() === true - ); -} - -/** - * The locations npm may install `name` for `workspace`: nested under the - * workspace when versions conflict, hoisted to the root otherwise. - * - * @param {string} rootDir - * @param {string} workspace - * @param {string} name - * @returns {string[]} - */ -function candidatePaths(rootDir, workspace, name) { - return [ - path.join(rootDir, workspace, 'node_modules', name), - path.join(rootDir, 'node_modules', name), - ]; -} - -/** - * @param {string[]} argv - * @returns {number} The process exit code. - */ -function main(argv) { - const rootDir = path.resolve(argv[0] ?? process.cwd()); - const rootManifestPath = path.join(rootDir, 'package.json'); - const {workspaces} = readManifest(rootManifestPath); - if (!Array.isArray(workspaces)) { - throw new Error(`${rootManifestPath} declares no "workspaces" array.`); - } - - const unresolved = []; - let checked = 0; - for (const workspace of workspaces) { - if (workspace.includes('*')) { - throw new Error( - `Unsupported glob in workspace entry "${workspace}". This check ` + - `resolves workspace paths literally, so it would silently verify ` + - `nothing for every package the glob matches. Teach it to expand ` + - `globs before declaring one.`, - ); - } - const {dependencies} = readManifest( - path.join(rootDir, workspace, 'package.json'), - ); - for (const name of Object.keys(dependencies ?? {})) { - checked += 1; - const candidates = candidatePaths(rootDir, workspace, name); - if (!candidates.some(isDirectory)) { - unresolved.push( - `${workspace}: "${name}" is declared under dependencies but is ` + - `installed at neither ${candidates[0]} nor ${candidates[1]}.`, - ); - } - } - } - - if (unresolved.length > 0) { - process.stderr.write(`${unresolved.join('\n')}\n`); - process.stderr.write( - `::error::${unresolved.length} of ${checked} declared production ` + - `dependencies are missing after a production install. Move each one ` + - `out of devDependencies into the dependencies block of the workspace ` + - `that imports it at runtime, then re-run npm install so ` + - `package-lock.json records it as a production node.\n`, - ); - return 1; - } - - process.stdout.write( - `Verified ${checked} production dependencies across ` + - `${workspaces.length} workspaces.\n`, - ); - return 0; -} - -try { - process.exitCode = main(process.argv.slice(2)); -} catch (error) { - process.stderr.write(`::error::${error.message}\n`); - process.exitCode = 1; -} diff --git a/tests/integration/scripts/check_production_install_test.ts b/tests/integration/scripts/check_production_install_test.ts deleted file mode 100644 index 103317aa3..000000000 --- a/tests/integration/scripts/check_production_install_test.ts +++ /dev/null @@ -1,248 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import {spawnSync, type SpawnSyncReturns} from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import {fileURLToPath} from 'node:url'; -import {afterEach, beforeEach, describe, expect, it} from 'vitest'; - -const SCRIPT_PATH = fileURLToPath( - new URL('../../../scripts/check_production_install.mjs', import.meta.url), -); - -/** Fixture root for the test currently running. */ -let root: string; - -beforeEach(() => { - root = fs.mkdtempSync(path.join(os.tmpdir(), 'adk-production-install-')); -}); - -afterEach(() => { - fs.rmSync(root, {recursive: true, force: true}); -}); - -function writeJson(filePath: string, value: unknown): void { - fs.mkdirSync(path.dirname(filePath), {recursive: true}); - fs.writeFileSync(filePath, JSON.stringify(value)); -} - -function writeRootManifest(workspaces: string[]): void { - writeJson(path.join(root, 'package.json'), {name: 'fixture', workspaces}); -} - -function writeWorkspaceManifest( - workspace: string, - manifest: Record, -): void { - writeJson(path.join(root, workspace, 'package.json'), { - name: workspace, - ...manifest, - }); -} - -/** Creates `/node_modules/` as an installed package directory. */ -function installPackage(dir: string, name: string): void { - fs.mkdirSync(path.join(root, dir, 'node_modules', name), {recursive: true}); -} - -function runCheck( - args: string[] = [root], - cwd?: string, -): SpawnSyncReturns { - return spawnSync(process.execPath, [SCRIPT_PATH, ...args], { - encoding: 'utf8', - cwd, - }); -} - -describe('check_production_install', () => { - it('passes when a declared dependency is hoisted to the root', () => { - writeRootManifest(['alpha']); - writeWorkspaceManifest('alpha', {dependencies: {'dep-one': '^1.0.0'}}); - installPackage('.', 'dep-one'); - - const result = runCheck(); - - expect(result.stderr).toBe(''); - expect(result.status).toBe(0); - expect(result.stdout).toContain('Verified 1 production dependencies'); - expect(result.stdout).toContain('across 1 workspaces'); - }); - - it('defaults the root directory to the working directory', () => { - writeRootManifest(['alpha']); - writeWorkspaceManifest('alpha', {dependencies: {'dep-one': '^1.0.0'}}); - installPackage('.', 'dep-one'); - - const result = runCheck([], root); - - expect(result.status).toBe(0); - expect(result.stdout).toContain('Verified 1 production dependencies'); - }); - - it('fails and names both probed paths when a dependency is absent', () => { - writeRootManifest(['alpha']); - writeWorkspaceManifest('alpha', {dependencies: {'dep-one': '^1.0.0'}}); - - const result = runCheck(); - - expect(result.status).toBe(1); - expect(result.stderr).toContain('alpha'); - expect(result.stderr).toContain('dep-one'); - expect(result.stderr).toContain( - path.join(root, 'alpha', 'node_modules', 'dep-one'), - ); - expect(result.stderr).toContain(path.join(root, 'node_modules', 'dep-one')); - expect(result.stderr).toContain('::error::'); - }); - - it('passes when a dependency is nested under the workspace', () => { - writeRootManifest(['alpha']); - writeWorkspaceManifest('alpha', {dependencies: {'dep-one': '^1.0.0'}}); - installPackage('alpha', 'dep-one'); - - const result = runCheck(); - - expect(result.stderr).toBe(''); - expect(result.status).toBe(0); - }); - - it('resolves a scoped dependency name', () => { - writeRootManifest(['alpha']); - writeWorkspaceManifest('alpha', {dependencies: {'@scope/pkg': '^1.0.0'}}); - installPackage('.', '@scope/pkg'); - - const result = runCheck(); - - expect(result.stderr).toBe(''); - expect(result.status).toBe(0); - }); - - it('follows a symlinked package directory', () => { - writeRootManifest(['alpha']); - writeWorkspaceManifest('alpha', {dependencies: {'@scope/pkg': '^1.0.0'}}); - const linkTarget = path.join(root, 'vendor', 'pkg'); - fs.mkdirSync(linkTarget, {recursive: true}); - const link = path.join(root, 'node_modules', '@scope', 'pkg'); - fs.mkdirSync(path.dirname(link), {recursive: true}); - fs.symlinkSync(linkTarget, link, 'junction'); - - const result = runCheck(); - - expect(result.stderr).toBe(''); - expect(result.status).toBe(0); - }); - - it('fails when the installed path is a file rather than a directory', () => { - writeRootManifest(['alpha']); - writeWorkspaceManifest('alpha', {dependencies: {'dep-one': '^1.0.0'}}); - const installed = path.join(root, 'node_modules', 'dep-one'); - fs.mkdirSync(path.dirname(installed), {recursive: true}); - fs.writeFileSync(installed, ''); - - const result = runCheck(); - - expect(result.status).toBe(1); - expect(result.stderr).toContain('dep-one'); - }); - - it('ignores devDependencies', () => { - writeRootManifest(['alpha']); - writeWorkspaceManifest('alpha', { - dependencies: {'dep-one': '^1.0.0'}, - devDependencies: {'dev-only': '^1.0.0'}, - }); - installPackage('.', 'dep-one'); - - const result = runCheck(); - - expect(result.stderr).toBe(''); - expect(result.status).toBe(0); - expect(result.stdout).toContain('Verified 1 production dependencies'); - }); - - it('accepts a workspace that declares no dependencies', () => { - writeRootManifest(['alpha', 'beta']); - writeWorkspaceManifest('alpha', {dependencies: {'dep-one': '^1.0.0'}}); - writeWorkspaceManifest('beta', {}); - installPackage('.', 'dep-one'); - - const result = runCheck(); - - expect(result.stderr).toBe(''); - expect(result.status).toBe(0); - expect(result.stdout).toContain('Verified 1 production dependencies'); - expect(result.stdout).toContain('across 2 workspaces'); - }); - - it('reports every unresolved dependency in a single run', () => { - writeRootManifest(['alpha', 'beta']); - writeWorkspaceManifest('alpha', {dependencies: {'dep-one': '^1.0.0'}}); - writeWorkspaceManifest('beta', {dependencies: {'dep-two': '^1.0.0'}}); - - const result = runCheck(); - - expect(result.status).toBe(1); - expect(result.stderr).toContain('dep-one'); - expect(result.stderr).toContain('dep-two'); - expect(result.stderr).toContain('2 of 2 declared production dependencies'); - }); - - describe('inputs it cannot verify', () => { - it('rejects a glob workspace entry instead of skipping it', () => { - writeRootManifest(['packages/*']); - - const result = runCheck(); - - expect(result.status).toBe(1); - expect(result.stderr).toContain('Unsupported glob'); - expect(result.stderr).toContain('packages/*'); - }); - - it('fails when the root manifest is missing', () => { - const result = runCheck(); - - expect(result.status).toBe(1); - expect(result.stderr).toContain( - `Cannot read ${path.join(root, 'package.json')}`, - ); - }); - - it('fails when the root manifest declares no workspaces array', () => { - writeJson(path.join(root, 'package.json'), {name: 'fixture'}); - - const result = runCheck(); - - expect(result.status).toBe(1); - expect(result.stderr).toContain('no "workspaces" array'); - }); - - it('fails when a workspace manifest is missing', () => { - writeRootManifest(['alpha']); - - const result = runCheck(); - - expect(result.status).toBe(1); - expect(result.stderr).toContain( - `Cannot read ${path.join(root, 'alpha', 'package.json')}`, - ); - }); - - it('fails when a workspace manifest is not valid JSON', () => { - writeRootManifest(['alpha']); - const manifestPath = path.join(root, 'alpha', 'package.json'); - fs.mkdirSync(path.dirname(manifestPath), {recursive: true}); - fs.writeFileSync(manifestPath, '{'); - - const result = runCheck(); - - expect(result.status).toBe(1); - expect(result.stderr).toContain(`Cannot parse ${manifestPath}`); - }); - }); -});