Angular ng-modules-importability 集成测试:如何守护框架所有 @NgModule 的可导入性
本文深入讲解 Angular 仓库中 integration/ng-modules-importability 集成测试的工作原理与实现细节:它如何自动扫描框架各包的全部入口点、生成模拟用户代码的 TypeScript 测试文件,并借助 @angular/compiler-cli 的 performCompilation 在 CI 中验证每一个导出的 @NgModule 符号都能被用户代码成功导入。读完本文,你将掌握该安全网测试的完整执行链路、配置方式,以及如何为新增的 @angular 包扩展这一测试覆盖。
为什么需要这个测试:@NgModule 再导出与相对导入陷阱
README 开门见山地说明了该测试的定位:
This test is a safety check, ensuring that all
@NgModule's exported by Angular framework packages can be imported in user code without causing any build errors.
这是一个"安全检查"测试,目标是确保 Angular 框架各包中导出的所有 @NgModule 都能在用户代码中被导入且不引发任何构建错误。README 还点出了它针对的真实问题场景:
- 一个
@NgModule有时会重新导出(re-export)另一个模块。这本身是合法的; - 但在一些情况下——尤其是使用了相对导入(relative imports)时——消费方项目中的编译器无法为这些被再导出的符号找到可用的导入路径,从而在用户构建时报错。
修复方式在 README 中给出了明确结论:被再导出的符号必须从包的入口点(entry-point)重新导出。README 将其归因为 angular/components#30667 中描述的问题类别。换句话说,该测试守护的是框架包的公共 API 契约——凡是 .d.ts 入口点中声明导出的 *Module,用户都必须能直接 import。
作为对比,仓库中 cli-hello-world-* 系列集成测试模仿的是完整的 CLI 应用构建流程(见 integration/README.md),而本测试属于更轻量的"类型级"检查:它不产出产物,只验证编译诊断(diagnostics)是否为零。
测试目录结构与各文件职责
测试目录 integration/ng-modules-importability 结构非常精简:
| 文件 | 职责 |
|---|---|
| README.md | 说明测试目的与再导出问题背景 |
| index.mts | 测试主程序:分片、生成测试文件、调用编译器、收集诊断 |
| find-all-modules.mts | 扫描包 package.json 的 exports 与 .d.ts,提取全部导出的 Module 符号 |
| index.bzl | 定义 Bazel 规则 module_test,封装配置生成与 js_test |
| BUILD.bazel | 声明 test_lib(编译工具库)与具体测试目标,列出被测包 |
| tsconfig.json | 测试工具自身(.mts 文件)的 TS 配置 |
下面沿执行顺序逐一展开。
第一步:扫描包入口点并提取所有导出的 Module 符号
find-all-modules.mts 导出的核心函数是 findAllEntryPointsAndExportedModules(packagePath),它的输入是包目录路径,输出是 {name, packagePath, moduleExports}。其工作流程为:
- 解析
package.json:读取包的package.json,重点是其exports字段(Node 子路径导出映射)。 - 过滤真实入口点:
- 跳过没有
types条件的子路径(无法定位类型声明文件); - 跳过
types值中包含*的通配条件(如common/locales),注释明确说明"通配条件不是入口点"。
- 跳过没有
- 解析
.d.ts声明文件:对每个入口点,用ts.createSourceFile以ESNext目标构建 AST,然后调用scanExportsForModules遍历。 - 提取 Module 符号名:
scanExportsForModules只关注形如export { A, B, C } from '...'的具名导出声明(ExportDeclaration+NamedExports),并过滤出:- 符号名以
Module结尾; - 首字母为大写(
e.name.text[0].toLowerCase() !== e.name.text[0])。
- 符号名以
- 组装结果:每个符号映射为
{importPath, symbolName},其中importPath由path.posix.join(packageJson.name, subpath)得到——即用户视角的完整导入路径,例如@angular/router、@angular/forms的子路径。
这里的关键设计是:以 .d.ts 为准而非源码。这保证了测试视角与真实用户一致——用户拿到的就是编译后的类型声明文件,测试验证的也正是这份声明里"承诺"导出的符号。
第二步:Bazel 规则 module_test 与测试配置
index.bzl 中的 module_test 宏接收两类关键参数:
module_test(
name = "test",
npm_packages = {
"//packages/core:npm_package": "packages/core/npm_package",
# ...其余包
},
shard_count = 4,
skipped_entry_points = [
# Core does not expose any modules and just needs to be made available.
"@angular/core",
],
)
npm_packages:一个从 Bazel 目标到包 runfiles 路径的映射。宏会把路径部分作为packages写入配置文件,把目标部分加入测试的data依赖,使沙箱内可访问这些包的构建产物。skipped_entry_points:需要跳过验证的入口点列表。BUILD.bazel 中只跳过了@angular/core,注释解释了原因:@angular/core本身不暴露任何@NgModule,但它必须"可用"(因为生成的测试文件要import {NgModule, Component} from '@angular/core')。
module_test 宏内部做了两件事:
- 用 bazel_skylib 的
write_file生成<name>_config.json,内容为{"packages": [...], "skipEntryPoints": [...]},并通过fixed_args把配置路径传给测试入口; - 创建
js_test目标(加载自 tools/defaults.bzl),entry_point指向//integration/ng-modules-importability:index.mjs,data依赖配置、test_lib和被测包产物。
当前测试覆盖的包列表(共 10 个)包括 animations、common、core、elements、forms、localize、platform-browser、router、service-worker 与 upgrade。若要为新包补充覆盖,只需在 npm_packages 字典中加入对应的 //packages/<name>:npm_package 条目。
第三步:测试主程序如何模拟"用户代码"
index.mts 的 main() 是测试的真正执行体,可分为五个阶段。
3.1 声明支持 Bazel 分片并切分工作量
程序启动后先 touch TEST_SHARD_STATUS_FILE,以此向 Bazel 宣告该测试支持分片(sharding)。随后按 Bazel 测试百科定义的初始条件环境变量(TEST_SHARD_INDEX / TEST_TOTAL_SHARDS)把全部待验证符号切成连续分片:
const testChunkSize = Math.ceil(allExports.length / testMaxShards);
const testChunkStart = testChunkSize * testShardIndex;
const shardExports = allExports.slice(testChunkStart, testChunkStart + testChunkSize);
配合 BUILD.bazel 中的 shard_count = 4,每个 shard 只负责约 1/4 的模块,从而把总编译时间摊平到 4 个并行进程。
3.2 在临时目录中生成每个符号一个测试文件
对分片内的每个 {importPath, symbolName},程序生成如下测试文件(文件名取符号名小写,如 commonformmodule.ts):
import {NgModule, Component} from '@angular/core';
import {CommonFormModule} from '@angular/common';
@NgModule({
exports: [CommonFormModule]
})
export class TestModule {}
@Component({imports: [TestModule], template: ''})
export class TestComponent {}
注意这段代码刻意构造了双重导入路径:
import {CommonFormModule} from '@angular/common'—— 验证符号可以从用户会写的导入路径解析到;@NgModule({exports: [CommonFormModule]})+@Component({imports: [TestModule]})—— 让 Angular 编译器真正把该 Module 纳入编译图,而不只是一个裸的类型导入。这正是 README 所针对的场景:再导出的模块只有在真正被"使用"时,才能暴露出导入解析问题。
3.3 用符号链接伪造用户项目的 node_modules
测试在 os.tmpdir() 下创建临时目录,并做两处 symlink:
tmp/node_modules→ 仓库integration/node_modules,用于解析三方依赖(如typescript);tmp/test/node_modules/@angular/<name>→ 每个被测包的 runfiles 路径(如packages/common/npm_package),用于解析@angular/common等第一方包。
代码注释特别说明:因为测试运行在 Bazel 沙箱内的 runfiles 目录中,第一方包的相对路径解析才能成立。这个"用户目录 + 本地构建产物"的组合,正是模拟真实用户"从 npm 安装最新版 Angular 并写代码"的环境。
3.4 调用 performCompilation 做全量编译验证
每个测试文件单独调用 packages/compiler-cli 的 performCompilation(定义于 perform_compile.ts),关键选项包括:
options: {
rootDir: tmpDir,
skipLibCheck: true,
noEmit: true,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
preserveSymlinks: true,
_enableHmr: true,
}
各选项的作用:
noEmit+skipLibCheck:只关心诊断,不产出文件,也不检查库文件——聚焦于"用户代码能否编译"这一契约;module: ESNext+moduleResolution: Bundler:贴近现代构建工具链(Bundler 式解析,与 tsconfig.json 中测试工具自身使用的解析模式一致);preserveSymlinks: true:不解析 symlink 到真实路径,保证@angular/*始终按 runfiles 中的相对位置解析;_enableHmr: true:这是最微妙的一项。源码注释解释:HMR 模式会禁用 Angular 编译器对"未使用 directive/component"的树摇(tree-shaking)。对本测试至关重要——只有关闭该优化,编译器才会强制解析每一个被列进@NgModule({exports})的符号,从而自动验证所有符号"可达且可导入"。若开了树摇,未被模板实际使用的模块可能被优化掉,测试就会失去守护力。
3.5 汇总诊断并决定成败
所有测试文件的诊断被收集后,用 ts.formatDiagnosticsWithColorAndContext 打印到 stderr;只要 diagnostics.length > 0,就将 process.exitCode 置为 1,Bazel 据此标记测试失败。临时目录最后会被清理。
也就是说,任何一次框架改动——无论是 package.json 的 exports 结构调整、.d.ts 入口点的导出变化,还是把某个再导出符号的声明路径改错——只要导致任何一个 @NgModule 从任何入口点导入失败,这个测试都会以带上下文的 TS/Angular 诊断精确定位到出问题的包与符号。
如何运行与扩展该测试
本地运行
该测试是 Bazel js_test 目标,仓库根目录下可按 integration/README.md 推荐的方式运行,并建议用 --local_test_jobs 限制并发以避免资源打满:
pnpm bazel test //integration/ng-modules-importability:test --test_output=streamed --cache_test_results=no
由于 shard_count = 4,Bazel 会拆出 4 个 shard 并行执行,每个 shard 处理约四分之一的模块。
扩展覆盖清单
- 新增被测包:在 BUILD.bazel 的
npm_packages中加入//packages/<pkg>:npm_package映射即可,无需改任何脚本——find-all-modules.mts会自动发现新包package.json中所有带types条件的入口点; - 跳过某入口点:把完整导入路径加入
skipped_entry_points列表(如@angular/core的做法); - 调整并行度:修改
shard_count。
小结
ng-modules-importability 是 Angular 框架包公共 API 的一道类型级安全网:以 .d.ts 入口点声明为契约来源(find-all-modules.mts),用"用户视角"的模拟代码与 symlink 化的 node_modules 还原真实消费环境,再借 performCompilation 加 _enableHmr: true 关闭树摇,强制每个导出的 @NgModule 都必须可解析、可导入。它把 README 中指出的"再导出符号需从入口点导出"这一约定,从人工经验固化成了 CI 中可分片、可增量扩展的自动化检查,也是理解 Angular 如何保障自身发布包"用户可导入性"契约的一个完整样本。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00