angular/packages/compiler-cli/test/ngtsc/imports_spec.ts
Paul Gschwendtner 9f18c7cc74 fix(compiler-cli): support relative imports to symbols outside rootDir (#60555)
By default, the compiler-cli uses the relative import strategy when
there is no `rootDir` or `rootDirs`. This is expected as everything is
assumed to be somehow reachable through relative imports.

With `rootDirs` that allow for a "virtual file system"-like environment,
the compiler is not necessarily able to always construct proper relative
imports. The compiler includes the `LogicalProjectStrategy` for this
reason. This strategy is able to respect `rootDirs` to construct
relative paths when possible.

This logic currently accidentally triggers when there is a `rootDir`
set. This option is not to be confused with the virtual directory
option called `rootDirs`. The compiler currently confuses this and
accidentally enters this mode when there is just a `rootDir`— breaking
in monorepos that imports can point outside the `rootDir` to e.g. other
compilation unit's `.d.ts` (which is valid; just not `.ts` sources can
live outside the root dir).

This is necessary for our Bazel toolchain migration.

PR Close #60555
2025-03-26 20:45:55 -07:00

180 lines
5.2 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import ts from 'typescript';
import {absoluteFrom} from '../../src/ngtsc/file_system';
import {runInEachFileSystem} from '../../src/ngtsc/file_system/testing';
import {loadStandardTestFiles} from '../../src/ngtsc/testing';
import {NgtscTestEnvironment} from './env';
const testFiles = loadStandardTestFiles();
runInEachFileSystem(() => {
describe('import generation', () => {
let env!: NgtscTestEnvironment;
beforeEach(() => {
env = NgtscTestEnvironment.setup(testFiles);
const tsconfig: {[key: string]: any} = {
extends: '../tsconfig-base.json',
compilerOptions: {
baseUrl: '.',
rootDirs: ['/app'],
},
angularCompilerOptions: {},
};
env.write('tsconfig.json', JSON.stringify(tsconfig, null, 2));
});
it('should report an error when using a directive outside of rootDirs', () => {
env.write(
'/app/module.ts',
`
import {NgModule} from '@angular/core';
import {ExternalDir} from '../lib/dir';
import {MyComponent} from './comp';
@NgModule({
declarations: [ExternalDir, MyComponent],
})
export class MyModule {}
`,
);
env.write(
'/app/comp.ts',
`
import {Component} from '@angular/core';
@Component({
template: '<div external></div>',
})
export class MyComponent {}
`,
);
env.write(
'/lib/dir.ts',
`
import {Directive} from '@angular/core';
@Directive({selector: '[external]'})
export class ExternalDir {}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(ts.flattenDiagnosticMessageText(diags[0].messageText, '\n'))
.toEqual(`Unable to import class ExternalDir.
The file ${absoluteFrom('/lib/dir.ts')} is outside of the configured 'rootDir'.`);
expect(diags[0].file!.fileName).toEqual(absoluteFrom('/app/module.ts'));
expect(getDiagnosticSourceCode(diags[0])).toEqual('ExternalDir');
});
it('should report an error when a library entry-point does not export the symbol', () => {
env.write(
'/app/module.ts',
`
import {NgModule} from '@angular/core';
import {ExternalModule} from 'lib';
import {MyComponent} from './comp';
@NgModule({
imports: [ExternalModule],
declarations: [MyComponent],
})
export class MyModule {}
`,
);
env.write(
'/app/comp.ts',
`
import {Component} from '@angular/core';
@Component({
template: '<div external></div>',
standalone: false,
})
export class MyComponent {}
`,
);
env.write(
'/node_modules/lib/index.d.ts',
`
import {ɵɵNgModuleDeclaration} from '@angular/core';
import {ExternalDir} from './dir';
export class ExternalModule {
static ɵmod: ɵɵNgModuleDeclaration<ExternalModule, [typeof ExternalDir], never, [typeof ExternalDir]>;
}
`,
);
env.write(
'/node_modules/lib/dir.d.ts',
`
import {ɵɵDirectiveDeclaration} from '@angular/core';
export class ExternalDir {
static ɵdir: ɵɵDirectiveDeclaration<ExternalDir, '[external]', never, never, never, never>;
}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(ts.flattenDiagnosticMessageText(diags[0].messageText, '\n'))
.toEqual(`Unable to import directive ExternalDir.
The symbol is not exported from ${absoluteFrom('/node_modules/lib/index.d.ts')} (module 'lib').`);
expect(diags[0].file!.fileName).toEqual(absoluteFrom('/app/comp.ts'));
expect(getDiagnosticSourceCode(diags[0])).toEqual('MyComponent');
});
it('should be possible to use a directive outside of `rootDir` when no `rootDirs` are set.', () => {
env.write(
'tsconfig.json',
JSON.stringify(
{
extends: './tsconfig-base.json',
compilerOptions: {rootDir: './app'},
},
null,
2,
),
);
env.write(
'/app/module.ts',
`
import {NgModule} from '@angular/core';
import {ExternalDir} from '../lib/dir';
@NgModule({
imports: [ExternalDir],
})
export class MyModule {}
`,
);
env.write(
'/lib/dir.d.ts',
`
import {ɵɵDirectiveDeclaration} from '@angular/core';
export class ExternalDir {
static ɵdir: ɵɵDirectiveDeclaration<ExternalDir, '[external]', never, never, never, never, never, true>;
}
`,
);
const diags = env.driveDiagnostics();
expect(diags.length).toBe(0);
});
});
});
function getDiagnosticSourceCode(diag: ts.Diagnostic): string {
return diag.file!.text.substring(diag.start!, diag.start! + diag.length!);
}