Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions integration/ng-modules-importability/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
load("//integration/ng-modules-importability:index.bzl", "module_test")
load("//tools:defaults.bzl", "ts_library")

ts_library(
name = "test_lib",
testonly = True,
srcs = glob(["*.ts"]),
deps = [
"//packages/compiler-cli",
"@npm//typescript",
],
)

module_test(
name = "test",
npm_packages = {
"//packages/animations:npm_package": "packages/animations/npm_package",
"//packages/common:npm_package": "packages/common/npm_package",
"//packages/core:npm_package": "packages/core/npm_package",
"//packages/elements:npm_package": "packages/elements/npm_package",
"//packages/forms:npm_package": "packages/forms/npm_package",
"//packages/localize:npm_package": "packages/localize/npm_package",
"//packages/platform-browser-dynamic:npm_package": "packages/platform-browser-dynamic/npm_package",
"//packages/platform-browser:npm_package": "packages/platform-browser/npm_package",
"//packages/router:npm_package": "packages/router/npm_package",
"//packages/service-worker:npm_package": "packages/service-worker/npm_package",
"//packages/upgrade:npm_package": "packages/upgrade/npm_package",
},
skipped_entry_points = [
# Core does not expose any modules and just needs to be made available.
"@angular/core",
],
)
9 changes: 9 additions & 0 deletions integration/ng-modules-importability/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
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.

Occasionally, an `@NgModule` might re-export another module. This is fine, but there are
cases, especially with relative imports being used, where the compiler (in consuming projects)
is not able to find a working import to these re-exported symbols.

The re-exported symbols simply need to be re-exported from the entry-point. For more details
on this, see: https://github.com/angular/components/pull/30667.
70 changes: 70 additions & 0 deletions integration/ng-modules-importability/find-all-modules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as ts from 'typescript';

export async function findAllEntryPointsAndExportedModules(packagePath: string) {
const packageJsonRaw = await fs.readFile(path.join(packagePath, 'package.json'), 'utf8');
const packageJson = JSON.parse(packageJsonRaw) as {
name: string;
exports: Record<string, Record<string, string>>;
};
const tasks: Promise<{importPath: string; symbolName: string}[]>[] = [];

for (const [subpath, conditions] of Object.entries(packageJson.exports)) {
if (conditions['types'] === undefined) {
continue;
}

// Skip wild-card conditions. Those are not entry-points. e.g. common/locales.
if (conditions['types'].includes('*')) {
continue;
}

tasks.push(
(async () => {
const dtsFile = path.join(packagePath, conditions['types']);
const dtsBundleFile = ts.createSourceFile(
dtsFile,
await fs.readFile(dtsFile, 'utf8'),
ts.ScriptTarget.ESNext,
false,
);

return scanExportsForModules(dtsBundleFile).map((e) => ({
importPath: path.posix.join(packageJson.name, subpath),
symbolName: e,
}));
})(),
);
}

const moduleExports = (await Promise.all(tasks)).flat();

return {name: packageJson.name, packagePath, moduleExports};
}

function scanExportsForModules(sf: ts.SourceFile): string[] {
const moduleExports: string[] = [];
const visit = (node: ts.Node) => {
if (
ts.isExportDeclaration(node) &&
node.exportClause !== undefined &&
ts.isNamedExports(node.exportClause)
) {
moduleExports.push(
...node.exportClause.elements
.filter(
(e) =>
e.name.text.endsWith('Module') &&
// Check if the first letter is upper-case.
e.name.text[0].toLowerCase() !== e.name.text[0],
)
.map((e) => e.name.text),
);
}
};

ts.forEachChild(sf, visit);

return moduleExports;
}
23 changes: 23 additions & 0 deletions integration/ng-modules-importability/index.bzl
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
load("@bazel_skylib//rules:write_file.bzl", "write_file")
load("//tools:defaults.bzl", "nodejs_test")

def module_test(name, npm_packages, skipped_entry_points = [], additional_deps = []):
write_file(
name = "%s_config" % name,
out = "%s_config.json" % name,
content = [json.encode({
"packages": [pkg[1] for pkg in npm_packages.items()],
"skipEntryPoints": skipped_entry_points,
})],
)

nodejs_test(
name = "test",
data = [
":%s_config" % name,
"//integration/ng-modules-importability:test_lib",
] + additional_deps + [pkg[0] for pkg in npm_packages.items()],
entry_point = "//integration/ng-modules-importability:index.ts",
enable_linker = True,
templated_args = ["$(rootpath :%s_config)" % name],
)
86 changes: 86 additions & 0 deletions integration/ng-modules-importability/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {performCompilation} from '@angular/compiler-cli';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
import ts from 'typescript';
import {findAllEntryPointsAndExportedModules} from './find-all-modules';

async function main() {
const [configPath] = process.argv.slice(2);
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ng-module-test-'));
const config = JSON.parse(await fs.readFile(configPath, 'utf8')) as {
packages: string[];
skipEntryPoints: string[];
};

const packages = await Promise.all(
config.packages.map((pkgPath) => findAllEntryPointsAndExportedModules(pkgPath)),
);

const exports = packages
.map((p) => p.moduleExports)
.flat()
.filter((e) => !config.skipEntryPoints.includes(e.importPath));

const testFile = `
import {NgModule, Component} from '@angular/core';
${exports.map((e) => `import {${e.symbolName}} from '${e.importPath}';`).join('\n')}

@NgModule({
exports: [
${exports.map((e) => e.symbolName).join(', ')}
]
})
export class TestModule {}

@Component({imports: [TestModule], template: ''})
export class TestComponent {}
`;

await fs.writeFile(path.join(tmpDir, 'test.ts'), testFile);

// Prepare node modules to resolve e.g. `@angular/core`
await fs.symlink(path.resolve('./node_modules'), path.join(tmpDir, 'node_modules'));
// Prepare node modules to resolve e.g. `@angular/cdk`. This is possible
// as we are inside the sandbox, inside our test runfiles directory.
for (const {packagePath, name} of packages) {
await fs.symlink(path.resolve(packagePath), `./node_modules/${name}`);
}

const result = performCompilation({
options: {
rootDir: tmpDir,
skipLibCheck: true,
noEmit: true,
module: ts.ModuleKind.ESNext,
moduleResolution: ts.ModuleResolutionKind.Bundler,
strictTemplates: true,
preserveSymlinks: true,
strict: true,
// Note: HMR is needed as it will disable the Angular compiler's tree-shaking of used
// directives/components. This is critical for this test as it allows us to simply all
// modules and automatically validate that all symbols are reachable/importable.
_enableHmr: true,
},
rootNames: [path.join(tmpDir, 'test.ts')],
});

console.error(
ts.formatDiagnosticsWithColorAndContext(result.diagnostics, {
getCanonicalFileName: (f) => f,
getCurrentDirectory: () => '/',
getNewLine: () => '\n',
}),
);

await fs.rm(tmpDir, {recursive: true, force: true, maxRetries: 2});

if (result.diagnostics.length > 0) {
process.exitCode = 1;
}
}

main().catch((e) => {
console.error('Error', e);
process.exitCode = 1;
});