test: lock in clean-clone frozen lockfile install (E00-S01-T13)
CI / Frozen lockfile install (pull_request) Successful in 45s
CI / Frozen lockfile install (pull_request) Successful in 45s
This commit is contained in:
@@ -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/<name>@<version>`
|
||||
* 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 <name>@<version>`);
|
||||
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']);
|
||||
});
|
||||
Reference in New Issue
Block a user