Nx 锁文件解析器测试夹具生成指南:为 NPM / Yarn / Pnpm 构建可维护的 Mock 数据
【免费下载链接】nxThe Monorepo Platform that amplifies both developers and AI agents. Nx optimizes your builds, scales your CI, and fixes failed PRs automatically. Ship in half the time.项目地址: https://gitcode.com/GitHub_Trending/nx/nx
Nx 在构建项目依赖图时需要解析package-lock.json、yarn.lock、pnpm-lock.yaml与bun.lock等锁文件,并把其中的外部依赖映射为项目图节点。为了对这套解析逻辑进行稳定、可复现的单元测试,Nx 仓库在 packages/nx/src/plugins/js/lock-file/fixtures目录下维护了一整套"夹具(fixtures)"数据,而fixtures/README.md 正是这套夹具的生成手册:它给出两个可直接运行的 Node.js 脚本,分别用于从真实的node_modules中抽取数据,生成 NPM V1 与 Yarn/Pnpm 两类 mock。读完本文,你将掌握这套夹具的生成原理、脚本的逐行语义,以及它们与 Nx 锁文件解析器(npm-parser、yarn-parser、pnpm-parser、bun-parser)之间的对应关系,从而能够为解析器的回归测试独立生产新的夹具数据。
夹具在 Nx 锁文件解析体系中的位置
在深入脚本之前,先明确这些夹具服务于什么。Nx 的锁文件功能集中在 packages/nx/src/plugins/js/lock-file 目录,其核心入口 lock-file.ts 对外暴露了统一的 API:
getLockFileNodes():解析锁文件内容,生成ProjectGraphExternalNode外部节点集合(Nx 用它把每个 npm 包建模为图中的一个外部节点);getLockFileDependencies():解析锁文件,生成RawProjectGraphDependency依赖边;getLockFileName()/lockFileExists():根据检测到的包管理器(npm / yarn / pnpm / bun)返回对应的锁文件名与存在性判断;createLockFile()/generatePrunedDeployOutput():在generate-package-json流程中生成"剪枝后(pruned)"的锁文件,只保留部署目标真正依赖的包。
不同包管理器的锁文件格式差异极大:npm 是 JSON(v1/v2/v3 三种结构),yarn 是自定义文本格式(v1 与 berry 又有差异),pnpm 是 YAML(v6/v9),bun 则是二进制或文本格式。因此解析逻辑被拆分到四个独立解析器中,各自拥有对应的单元测试:
| 解析器源码 | 对应测试 | 支持的锁文件 |
|---|---|---|
| npm-parser.ts | npm-parser.spec.ts | package-lock.json(v1/v2/v3) |
| yarn-parser.ts | yarn-parser.spec.ts | yarn.lock(v1 与 berry) |
| pnpm-parser.ts | pnpm-parser.spec.ts | pnpm-lock.yaml(v6/v9) |
| bun-parser.ts | bun-parser.spec.ts | bun.lock/bun.lockb |
这些 spec 文件从fixtures导入.fixture与.ts(模板字符串形式)的锁文件内容,构造出各种真实场景(workspaces、peer 依赖、可选依赖、重复包、git/alias 依赖、pnpm 剪枝回归等)。夹具数据是否贴近真实node_modules布局,直接决定了这些测试的价值——这正是 README 中两个生成脚本存在的意义:它们不是手工编写的示例,而是从真实安装目录中"提取"出来的快照。
脚本一:为 NPM V1 mock 提取 peer dependencies
原文档的第一个脚本用于生成NPM V1 mocks的peerDependencies数据,其筛选规则是"只渲染那些声明了 peer 依赖的包"(Renders only those packages that have peer dependencies.)。NPM V1 锁文件(lockfileVersion: 1)的特点是只有嵌套的dependencies树、没有packages索引区,因此夹具需要从package.json的peerDependencies/peerDependenciesMeta字段入手构造数据。完整脚本如下:
const readFileSync = require('fs').readFileSync; const readdirSync = require('fs').readdirSync; const existsSync = require('fs').existsSync; let report = ''; const packageNames = []; function processNodeModules(path = '.') { if (existsSync(`${path}/node_modules`)) { readdirSync(`${path}/node_modules`).forEach((folder) => { if (folder.startsWith('@')) { readdirSync(`${path}/node_modules/${folder}`).forEach((subfolder) => { packageNames.push(`${path}/node_modules/${folder}/${subfolder}`); processNodeModules(`${path}/node_modules/${folder}/${subfolder}`); }); } else { packageNames.push(`${path}/node_modules/${folder}`); processNodeModules(`${path}/node_modules/${folder}`); } }); } } processNodeModules(); packageNames.forEach((path) => { const filePath = `${path}/package.json`; if (existsSync(filePath)) { const content = readFileSync(filePath, 'utf-8'); const peerDependencies = JSON.parse(content).peerDependencies; const peerDependenciesMeta = JSON.parse(content).peerDependenciesMeta; const output = JSON.stringify({ ...(peerDependencies && { peerDependencies }), ...(peerDependenciesMeta && { peerDependenciesMeta }), }); if (output === '{}') return; report += `'${filePath.slice(2)}': '${output}',\n`; } }); console.log(report);脚本行为逐段拆解
- 递归遍历
node_modules:processNodeModules(path)检查当前目录下是否存在node_modules子目录,若存在则遍历其中的每一个包目录。对于以@开头的目录(scoped 包,如@angular/core),还要再深入一层读取 scope 下的子包目录,并把路径加入packageNames数组;之后对每个找到的包目录继续递归调用processNodeModules,从而把嵌套依赖的node_modules也一并扫描进来。这一步与 npm 安装时"嵌套 node_modules"的物理布局完全一致,保证了夹具覆盖到深层传递依赖。 - 过滤出声明了 peer 依赖的包:对每个包的
package.json,读取peerDependencies与peerDependenciesMeta两个字段;只有两者之一存在时才生成输出。peerDependenciesMeta记录了 peer 依赖是否为 optional(例如 pnpm 锁文件中的peerDependenciesMeta: { firebase-tools: { optional: true } }),这在解析 pnpm/npm 的 peer 处理逻辑时至关重要。 - 生成键值对文本:输出形如
'node_modules/<pkg>/package.json': '{"peerDependencies": {...}, "peerDependenciesMeta": {...}}',的行。filePath.slice(2)去掉路径前缀./,得到相对路径,直接可以粘贴进测试夹具的映射表。
与解析器源码的印证
为什么夹具要专门为 NPM V1 保留 peer 信息?看 npm-parser.ts 中对锁文件版本的划分(源码注释直接写明):
/** * NPM * - v1 has only dependencies * - v2 has packages and dependencies for backwards compatibility * - v3 has only packages */v1 结构中没有packages节点,包与包之间的关系只能靠嵌套的dependencies树与各包的requires推断;peer 依赖的建模需要依赖package.json元数据作为补充,这正是该脚本产出数据的用途。仓库中的 package-lock.json.fixture 等文件即包含这类场景,npm-parser 测试通过它们验证 v1 锁文件的节点与依赖边解析结果。
脚本二:为 Yarn 与 Pnpm mock 提取包版本
原文档的第二个脚本面向Yarn 和 Pnpm mocks,筛选规则是"只渲染被提升(hoisted)的依赖"(Renders only hoisted dependencies.)。yarn 与 pnpm 的锁文件在顶层直接列出所有解析后的包及其精确版本(yarn v1 的name@range:块、pnpm 的packages:区),因此夹具只需要"包路径 → 版本号"的映射即可。完整脚本如下:
const readFileSync = require('fs').readFileSync; const readdirSync = require('fs').readdirSync; const existsSync = require('fs').existsSync; let report = ''; const packageNames = []; readdirSync('node_modules').forEach((folder) => { if (folder === '.pnpm') return; if (folder.startsWith('@')) { readdirSync(`node_modules/${folder}`).forEach((subfolder) => { packageNames.push(`${folder}/${subfolder}`); }); } else { packageNames.push(folder); } }); packageNames.forEach((packageName) => { const path = `node_modules/${packageName}/package.json`; if (existsSync(path)) { const content = readFileSync(path, 'utf-8'); const version = JSON.parse(content).version; report += `'${path}': '{"version": "${version}"}',\n`; } }); console.log(report);脚本行为逐段拆解
- 只扫描顶层目录:与脚本一不同,这里直接对
node_modules顶层调用readdirSync,不做递归。这是因为 yarn/pnpm 的锁文件刻画的是"解析结果全集",顶层提升的包已经代表了安装布局的主体,夹具无需模拟嵌套目录。 - 跳过
.pnpm虚拟存储:if (folder === '.pnpm') return;明确排除 pnpm 的虚拟存储目录。.pnpm下是以name@version命名的、包含全部包副本的扁平结构,而 pnpm 锁文件的packages:区恰恰是这种扁平清单的映射,测试夹具通常只保留提升层的引用即可,避免数据冗余。 - scoped 包二级展开:对
@scope开头的目录再读取一层子目录,把@scope/pkg这样的完整包名加入列表,与脚本一中的 scoped 处理保持一致。 - 产出版本映射:读取每个包的
package.json的version字段,输出形如'node_modules/<pkg>/package.json': '{"version": "1.2.3"}',的行。Nx 解析 yarn/pnpm 锁文件时,主要依据锁文件中的版本与resolution字段构造外部节点,此映射为夹具提供了校验基准。
与解析器源码的印证
yarn-parser 与 pnpm-parser 都需要把"锁文件条目"映射为统一的ProjectGraphExternalNode。在 lock-file.ts 中可以看到,yarn/bun 路径还会额外读取根package.json参与解析:
const packageJson = packageManager === 'yarn' || packageManager === 'bun' ? readJsonFile(join(context.workspaceRoot, 'package.json')) : undefined;而 pnpm 路径则直接解析锁文件内容(getPnpmLockfileNodes(contents, lockFileHash))。无论哪条路径,最终都要把字符串版本解析为语义化版本并构造外部节点,本脚本产出的{"version": "..."}数据正是测试断言中节点version字段的对照来源。仓库中的 workspaces/package.json.fixture(声明了"workspaces": ["packages/*"])与对应的yarn.lock.ts、pnpm-lock.yaml.ts即属于此类 hoisted 布局下的典型夹具。
夹具的实际应用场景:从解析到剪枝
__fixtures__目录按场景分子目录组织,README 中的两个脚本只是数据来源,实际测试消费这些数据的方式非常多样,从测试文件中可以观察到几类典型用法:
- 解析器单元测试:如 npm-parser.spec.ts 直接引用
__fixtures__/auxiliary-packages/package-lock.json.fixture、__fixtures__/nextjs/package-lock.json.fixture、__fixtures__/npm-hoisting/package-lock.json.fixture等文件,断言getNpmLockfileNodes产出的外部节点与依赖关系; - 剪枝(pruning)回归测试:pruned-output.spec.ts 与 project-graph-pruning.spec.ts 借助
__fixtures__/pruning/下的typescript/、devkit-yargs/等场景,验证createLockFile()只保留项目图裁剪后仍被引用的包; - 回归与边界场景:
pnpm-regression/、pnpm-semver-range-specifier/、resolutions-and-patches/、bun/等子目录专门覆盖真实项目中出现过的解析问题,防止修复被后续改动重新引入。
如何基于脚本维护自己的夹具
如果需要在本地复现或扩展这套夹具流程,可以按以下步骤操作:
- 准备一个真实安装目录:在临时目录中运行
npm install(或 yarn/pnpm),让node_modules具备与目标项目一致的包布局; - 运行脚本一生成 NPM V1 mock:在安装目录根执行第一个脚本,输出即
'node_modules/...': '{"peerDependencies": ...}'的映射文本,粘贴到测试夹具的 mock 映射中; - 运行脚本二生成 Yarn/Pnpm mock:在安装目录根执行第二个脚本,得到提升包版本映射;
- 写入 fixture 文件并关联测试:将生成的锁文件内容放入fixtures对应场景目录(JSON 用
.fixture后缀,多行文本用.ts模板字符串),再在对应的*-parser.spec.ts中引用并断言解析结果。
需要注意的是,两个脚本依赖fs的同步 API(readFileSync/readdirSync/existsSync),运行环境为 Node.js,且脚本会从当前工作目录出发扫描node_modules,执行前应确认所在目录正确。README 中的说明也隐含了二者的分工前提:NPM V1 mock 关注 peer 元数据(因为 v1 结构缺少packages索引),Yarn/Pnpm mock 关注 hoisted 版本清单(因为这两类锁文件的顶层结构本身就是解析结果的展开)。
小结
__fixtures__/README.md提供的两个脚本,本质上是把"真实安装布局"转化为"解析器测试可断言的夹具数据"的提取器:一个面向 NPM V1 的 peer 依赖元数据,一个面向 Yarn/Pnpm 的 hoisted 版本映射。理解它们,就理解了 Nx 锁文件解析测试的数据来源与设计思路——从getLockFileNodes/getLockFileDependencies的图构建,到createLockFile的剪枝输出,再到 pruned deploy 产物的生成,每一层逻辑都有对应的夹具场景作为可复现的验证锚点。
【免费下载链接】nxThe Monorepo Platform that amplifies both developers and AI agents. Nx optimizes your builds, scales your CI, and fixes failed PRs automatically. Ship in half the time.项目地址: https://gitcode.com/GitHub_Trending/nx/nx
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考