diff --git a/tests/frozen-install.test.mjs b/tests/frozen-install.test.mjs new file mode 100644 index 0000000..c80e1be --- /dev/null +++ b/tests/frozen-install.test.mjs @@ -0,0 +1,244 @@ +/** + * Frozen-install test — locks in the [E00-S01-T13] clean-clone frozen + * lockfile install for the workspace. + * + * Acceptance criteria covered (each test fails without the committed config): + * - "a clean clone installs with a frozen lockfile" → a fresh clone of the + * committed tree (no node_modules, no untracked files) runs + * `pnpm install --frozen-lockfile` — through the pinned pnpm 11.23.0 + * (`corepack pnpm`, the same path CI and developers use) — and exits 0 + * with a populated `node_modules/` virtual store. + * - "the frozen install completes without resolving new versions" → pnpm + * reports the lockfile as up to date and skips the resolution step, the + * committed `pnpm-lock.yaml` is byte-identical after the install (a frozen + * install never rewrites the lockfile), and the installed virtual store + * matches the lockfile exactly: every package resolved in the lockfile + * `packages:` section is present in `node_modules/.pnpm/@` + * and no extra package versions are installed (so the install produced + * exactly the locked dependency tree, nothing resolved beyond it). + * + * Run: `node --test tests/frozen-install.test.mjs` + * (node:test — built into Node >= 18; no dependencies, lockfile untouched.) + */ + +import test, { before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync, existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const read = (relPath) => readFileSync(path.join(REPO_ROOT, relPath), 'utf8'); + +/** The exact TypeScript version the workspace is pinned to ([E00-S01-T09]). */ +const PINNED_TYPESCRIPT = '6.0.3'; + +/** Relative path of the pnpm virtual store inside a clone (pnpm 11 default). */ +const VIRTUAL_STORE = 'node_modules/.pnpm'; + +// --------------------------------------------------------------------------- +// Lockfile → virtual-store helpers (no dependencies, lockfile untouched) +// --------------------------------------------------------------------------- + +/** + * Extracts the package entry keys from the `packages:` section of a pnpm 9.x + * lockfile, e.g. `typescript@6.0.3` or `@scope/name@1.2.3(peer@2.0.0)`. + */ +function lockedPackageKeys(lockfileText) { + const packagesSection = lockfileText.slice( + lockfileText.indexOf('packages:'), + lockfileText.indexOf('snapshots:'), + ); + return [...packagesSection.matchAll(/^ (\S+):$/gm)].map((m) => m[1]); +} + +/** + * Maps a lockfile package key to the directory name pnpm gives it inside the + * virtual store: scoped names get their `/` rewritten to `+` (`@scope/name@1.2.3` + * → `@scope+name@1.2.3`), peer-dependency suffixes are dropped (`(peer@2.0.0)` + * → `_peer@2.0.0` appended to the dir name). + */ +function virtualStoreDirName(packageKey) { + const withoutPeers = packageKey.replace(/\([^)]*\)$/, ''); + const at = withoutPeers.lastIndexOf('@'); + assert.ok(at > 0, `lockfile package key "${packageKey}" must be @`); + const name = withoutPeers.slice(0, at); + const version = withoutPeers.slice(at + 1); + const dirName = name.startsWith('@') ? `${name.replace('/', '+')}@${version}` : `${name}@${version}`; + return { dirName, name, version }; +} + +/** + * Returns the locked package keys whose virtual-store directory is missing + * from the given `.pnpm` directory listing. A locked entry matches a directory + * either exactly (`typescript@6.0.3`) or by the `_` peer-suffix prefix pnpm + * appends (`typescript@6.0.3_peer@2.0.0`). + */ +function missingLockedDirs(packageKeys, pnpmDirEntries) { + const missing = []; + for (const key of packageKeys) { + const { dirName } = virtualStoreDirName(key); + const found = pnpmDirEntries.some( + (entry) => entry === dirName || entry.startsWith(`${dirName}_`), + ); + if (!found) missing.push(key); + } + return missing; +} + +// --------------------------------------------------------------------------- +// Clean-clone fixture: one clone + one frozen install, shared by all tests +// --------------------------------------------------------------------------- + +/** Temp dir state shared across the tests in this file. */ +let cloneDir; +let install; + +before(() => { + // A clean clone: only the committed tree, cloned into a temp dir. This is a + // fresh copy — no node_modules, no .pnpm store, no untracked files — exactly + // what `git clone` produces for a new developer. + cloneDir = mkdtempSync(path.join(os.tmpdir(), 'eppp-frozen-install-')); + const cloned = spawnSync('git', ['clone', '--quiet', '--no-hardlinks', REPO_ROOT, cloneDir], { + encoding: 'utf8', + timeout: 60_000, + }); + assert.equal( + cloned.status, + 0, + `git clone of the committed tree failed: ${(cloned.stderr || cloned.stdout || '').trim()}`, + ); + assert.ok( + existsSync(path.join(cloneDir, 'pnpm-lock.yaml')), + 'the clean clone must contain the committed pnpm-lock.yaml', + ); + + // Frozen install through the pinned pnpm (corepack), exactly like CI. + install = spawnSync('corepack', ['pnpm', 'install', '--frozen-lockfile'], { + cwd: cloneDir, + encoding: 'utf8', + timeout: 300_000, + env: { ...process.env, COREPACK_ENABLE_DOWNLOAD_PROMPT: '0', npm_config_update_notifier: 'false' }, + }); + if (install.status !== 0) { + throw new Error( + `pnpm install --frozen-lockfile failed in the clean clone:\n` + + `${(install.stdout || '')}\n${(install.stderr || '')}`.trim(), + ); + } +}); + +after(() => { + // Guarded: the clone may not exist if the before hook failed early. + if (cloneDir) rmSync(cloneDir, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test('a clean clone installs with a frozen lockfile (pnpm install --frozen-lockfile)', () => { + assert.equal( + install.status, + 0, + `pnpm install --frozen-lockfile must exit 0 in a clean clone:\n` + + `${(install.stdout || '')}\n${(install.stderr || '')}`.trim(), + ); + // The install must have actually installed something (node_modules with the + // pnpm virtual store), not silently no-op'd. + assert.ok( + existsSync(path.join(cloneDir, VIRTUAL_STORE)), + 'the frozen install must populate node_modules/.pnpm in the clean clone', + ); +}); + +test('the frozen install completes without resolving new versions', () => { + // pnpm only reports the lockfile as up to date (and skips resolution) when + // the manifests are fully satisfied by the committed lockfile — with + // --frozen-lockfile an out-of-date lockfile fails instead of resolving. + assert.match( + install.stdout, + /Lockfile is up to date, resolution step is skipped/, + 'pnpm must skip the resolution step (the committed lockfile fully satisfies the manifests)', + ); + + // A frozen install never rewrites the lockfile: it must be byte-identical + // to the committed one before and after the install. + const committedLockfile = read('pnpm-lock.yaml'); + const clonedLockfile = readFileSync(path.join(cloneDir, 'pnpm-lock.yaml'), 'utf8'); + assert.equal( + clonedLockfile, + committedLockfile, + 'pnpm-lock.yaml must be byte-identical after the frozen install (no re-resolution, no lockfile drift)', + ); +}); + +test('the installed dependency tree matches the lockfile exactly (no new versions)', () => { + const lockedKeys = lockedPackageKeys(read('pnpm-lock.yaml')); + assert.ok(lockedKeys.length > 0, 'the lockfile packages section must resolve at least one package'); + + const pnpmDir = path.join(cloneDir, VIRTUAL_STORE); + const installedDirs = readdirSync(pnpmDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name !== 'node_modules') + .map((entry) => entry.name); + + // Every package the lockfile resolves must be present in the virtual store + // at its locked version — nothing from the lockfile is missing. + const missing = missingLockedDirs(lockedKeys, installedDirs); + assert.deepEqual( + missing, + [], + `the installed tree is missing locked packages: ${missing.join(', ')}`, + ); + + // And nothing beyond the locked set may be installed: the virtual store + // (minus pnpm's internal node_modules dir) contains exactly the locked + // package directories, so no extra version was resolved. + assert.equal( + installedDirs.length, + lockedKeys.length, + `the virtual store must contain exactly the ${lockedKeys.length} locked packages ` + + `(${installedDirs.join(', ')}), one dir per lockfile packages: entry`, + ); +}); + +test('the clean clone resolves the exact locked TypeScript version (6.0.3)', () => { + const result = spawnSync('corepack', ['pnpm', 'exec', 'tsc', '--version'], { + cwd: cloneDir, + encoding: 'utf8', + timeout: 120_000, + }); + assert.equal( + result.status, + 0, + `"corepack pnpm exec tsc --version" failed in the clean clone: ${(result.stderr || result.stdout || '').trim()}`, + ); + assert.equal( + result.stdout.trim(), + `Version ${PINNED_TYPESCRIPT}`, + `the clean clone must resolve TypeScript ${PINNED_TYPESCRIPT} (got "${result.stdout.trim()}")`, + ); +}); + +test('the lockfile→virtual-store helpers flag missing packages (non-vacuous probe)', () => { + const keys = ['typescript@6.0.3', '@scope/core@1.2.3', 'with-peer@2.0.0(peer@1.0.0)']; + + // Sanity: the dir-name mapping is what pnpm actually lays out. + assert.equal(virtualStoreDirName('typescript@6.0.3').dirName, 'typescript@6.0.3'); + assert.equal(virtualStoreDirName('@scope/core@1.2.3').dirName, '@scope+core@1.2.3'); + assert.equal(virtualStoreDirName('with-peer@2.0.0(peer@1.0.0)').dirName, 'with-peer@2.0.0'); + + // A complete store must not be flagged… + const complete = ['typescript@6.0.3', '@scope+core@1.2.3', 'with-peer@2.0.0_peer@1.0.0']; + assert.deepEqual(missingLockedDirs(keys, complete), []); + + // …while a store missing or drifting on any locked version must be. + const missingOne = ['@scope+core@1.2.3', 'with-peer@2.0.0_peer@1.0.0']; + assert.deepEqual(missingLockedDirs(keys, missingOne), ['typescript@6.0.3']); + + const drifted = ['typescript@6.0.4', '@scope+core@1.2.3', 'with-peer@2.0.0_peer@1.0.0']; + assert.deepEqual(missingLockedDirs(keys, drifted), ['typescript@6.0.3']); +});