Skip to content

Commit 20471b0

Browse files
committed
test: add integration test to ensure all exported modules can be imported
This commit adds a new integration test which will help ensure that all exported `@NgModule`'s of framework packages can be imported by users without any errors. This test is generally useful, but with our upcoming changes with relative imports, this is a good safety-net. Relative imports could break re-exported NgModules inside NgModule's. For more details, see: angular/components#30667 Notably we don't expect any issues for framework package as re-exporting `@NgModule`'s inside `@NgModule`'s is seemingly a rather rare pattern for APF libraries (confirmed by Material only having like 4-5 instances).
1 parent 164bb3f commit 20471b0

File tree

5 files changed

+221
-0
lines changed

5 files changed

+221
-0
lines changed
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
load("//integration/ng-modules-importability:index.bzl", "module_test")
2+
load("//tools:defaults.bzl", "ts_library")
3+
4+
ts_library(
5+
name = "test_lib",
6+
testonly = True,
7+
srcs = glob(["*.ts"]),
8+
deps = [
9+
"//packages/compiler-cli",
10+
"@npm//typescript",
11+
],
12+
)
13+
14+
module_test(
15+
name = "test",
16+
npm_packages = {
17+
"//packages/animations:npm_package": "packages/animations/npm_package",
18+
"//packages/common:npm_package": "packages/common/npm_package",
19+
"//packages/core:npm_package": "packages/core/npm_package",
20+
"//packages/elements:npm_package": "packages/elements/npm_package",
21+
"//packages/forms:npm_package": "packages/forms/npm_package",
22+
"//packages/localize:npm_package": "packages/localize/npm_package",
23+
"//packages/platform-browser-dynamic:npm_package": "packages/platform-browser-dynamic/npm_package",
24+
"//packages/platform-browser:npm_package": "packages/platform-browser/npm_package",
25+
"//packages/router:npm_package": "packages/router/npm_package",
26+
"//packages/service-worker:npm_package": "packages/service-worker/npm_package",
27+
"//packages/upgrade:npm_package": "packages/upgrade/npm_package",
28+
},
29+
skipped_entry_points = [
30+
# Core does not expose any modules and just needs to be made available.
31+
"@angular/core",
32+
],
33+
)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
This test is a safety check, ensuring that all `@NgModule`'s exported by Angular framework
2+
packages can be imported in user code without causing any build errors.
3+
4+
Occasionally, an `@NgModule` might re-export another module. This is fine, but there are
5+
cases, especially with relative imports being used, where the compiler (in consuming projects)
6+
is not able to find a working import to these re-exported symbols.
7+
8+
The re-exported symbols simply need to be re-exported from the entry-point. For more details
9+
on this, see: https://github.com/angular/components/pull/30667.
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import * as fs from 'node:fs/promises';
2+
import * as path from 'node:path';
3+
import * as ts from 'typescript';
4+
5+
export async function findAllEntryPointsAndExportedModules(packagePath: string) {
6+
const packageJsonRaw = await fs.readFile(path.join(packagePath, 'package.json'), 'utf8');
7+
const packageJson = JSON.parse(packageJsonRaw) as {
8+
name: string;
9+
exports: Record<string, Record<string, string>>;
10+
};
11+
const tasks: Promise<{importPath: string; symbolName: string}[]>[] = [];
12+
13+
for (const [subpath, conditions] of Object.entries(packageJson.exports)) {
14+
if (conditions['types'] === undefined) {
15+
continue;
16+
}
17+
18+
// Skip wild-card conditions. Those are not entry-points. e.g. common/locales.
19+
if (conditions['types'].includes('*')) {
20+
continue;
21+
}
22+
23+
tasks.push(
24+
(async () => {
25+
const dtsFile = path.join(packagePath, conditions['types']);
26+
const dtsBundleFile = ts.createSourceFile(
27+
dtsFile,
28+
await fs.readFile(dtsFile, 'utf8'),
29+
ts.ScriptTarget.ESNext,
30+
false,
31+
);
32+
33+
return scanExportsForModules(dtsBundleFile).map((e) => ({
34+
importPath: path.posix.join(packageJson.name, subpath),
35+
symbolName: e,
36+
}));
37+
})(),
38+
);
39+
}
40+
41+
const moduleExports = (await Promise.all(tasks)).flat();
42+
43+
return {name: packageJson.name, packagePath, moduleExports};
44+
}
45+
46+
function scanExportsForModules(sf: ts.SourceFile): string[] {
47+
const moduleExports: string[] = [];
48+
const visit = (node: ts.Node) => {
49+
if (
50+
ts.isExportDeclaration(node) &&
51+
node.exportClause !== undefined &&
52+
ts.isNamedExports(node.exportClause)
53+
) {
54+
moduleExports.push(
55+
...node.exportClause.elements
56+
.filter(
57+
(e) =>
58+
e.name.text.endsWith('Module') &&
59+
// Check if the first letter is upper-case.
60+
e.name.text[0].toLowerCase() !== e.name.text[0],
61+
)
62+
.map((e) => e.name.text),
63+
);
64+
}
65+
};
66+
67+
ts.forEachChild(sf, visit);
68+
69+
return moduleExports;
70+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
load("@bazel_skylib//rules:write_file.bzl", "write_file")
2+
load("//tools:defaults.bzl", "nodejs_test")
3+
4+
def module_test(name, npm_packages, skipped_entry_points = [], additional_deps = []):
5+
write_file(
6+
name = "%s_config" % name,
7+
out = "%s_config.json" % name,
8+
content = [json.encode({
9+
"packages": [pkg[1] for pkg in npm_packages.items()],
10+
"skipEntryPoints": skipped_entry_points,
11+
})],
12+
)
13+
14+
nodejs_test(
15+
name = "test",
16+
data = [
17+
":%s_config" % name,
18+
"//integration/ng-modules-importability:test_lib",
19+
] + additional_deps + [pkg[0] for pkg in npm_packages.items()],
20+
entry_point = "//integration/ng-modules-importability:index.ts",
21+
enable_linker = True,
22+
templated_args = ["$(rootpath :%s_config)" % name],
23+
)
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import {performCompilation} from '@angular/compiler-cli';
2+
import * as fs from 'fs/promises';
3+
import * as path from 'path';
4+
import * as os from 'os';
5+
import ts from 'typescript';
6+
import {findAllEntryPointsAndExportedModules} from './find-all-modules';
7+
8+
async function main() {
9+
const [configPath] = process.argv.slice(2);
10+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ng-module-test-'));
11+
const config = JSON.parse(await fs.readFile(configPath, 'utf8')) as {
12+
packages: string[];
13+
skipEntryPoints: string[];
14+
};
15+
16+
const packages = await Promise.all(
17+
config.packages.map((pkgPath) => findAllEntryPointsAndExportedModules(pkgPath)),
18+
);
19+
20+
const exports = packages
21+
.map((p) => p.moduleExports)
22+
.flat()
23+
.filter((e) => !config.skipEntryPoints.includes(e.importPath));
24+
25+
const testFile = `
26+
import {NgModule, Component} from '@angular/core';
27+
${exports.map((e) => `import {${e.symbolName}} from '${e.importPath}';`).join('\n')}
28+
29+
@NgModule({
30+
exports: [
31+
${exports.map((e) => e.symbolName).join(', ')}
32+
]
33+
})
34+
export class TestModule {}
35+
36+
@Component({imports: [TestModule], template: ''})
37+
export class TestComponent {}
38+
`;
39+
40+
await fs.writeFile(path.join(tmpDir, 'test.ts'), testFile);
41+
42+
// Prepare node modules to resolve e.g. `@angular/core`
43+
await fs.symlink(path.resolve('./node_modules'), path.join(tmpDir, 'node_modules'));
44+
// Prepare node modules to resolve e.g. `@angular/cdk`. This is possible
45+
// as we are inside the sandbox, inside our test runfiles directory.
46+
for (const {packagePath, name} of packages) {
47+
await fs.symlink(path.resolve(packagePath), `./node_modules/${name}`);
48+
}
49+
50+
const result = performCompilation({
51+
options: {
52+
rootDir: tmpDir,
53+
skipLibCheck: true,
54+
noEmit: true,
55+
module: ts.ModuleKind.ESNext,
56+
moduleResolution: ts.ModuleResolutionKind.Bundler,
57+
strictTemplates: true,
58+
preserveSymlinks: true,
59+
strict: true,
60+
// Note: HMR is needed as it will disable the Angular compiler's tree-shaking of used
61+
// directives/components. This is critical for this test as it allows us to simply all
62+
// modules and automatically validate that all symbols are reachable/importable.
63+
_enableHmr: true,
64+
},
65+
rootNames: [path.join(tmpDir, 'test.ts')],
66+
});
67+
68+
console.error(
69+
ts.formatDiagnosticsWithColorAndContext(result.diagnostics, {
70+
getCanonicalFileName: (f) => f,
71+
getCurrentDirectory: () => '/',
72+
getNewLine: () => '\n',
73+
}),
74+
);
75+
76+
await fs.rm(tmpDir, {recursive: true, force: true, maxRetries: 2});
77+
78+
if (result.diagnostics.length > 0) {
79+
process.exitCode = 1;
80+
}
81+
}
82+
83+
main().catch((e) => {
84+
console.error('Error', e);
85+
process.exitCode = 1;
86+
});

0 commit comments

Comments
 (0)