Alex Rickabaugh c37ec8b255 fix(ivy): produce ts.Diagnostics for NgModule scope errors (#29191)
Previously, when the NgModule scope resolver discovered semantic errors
within a users NgModules, it would throw assertion errors. TODOs in the
codebase indicated these should become ts.Diagnostics eventually.

Besides producing better-looking errors, there is another reason to make
this change asap: these assertions were shadowing actual errors, via an
interesting mechanism:

1) a component would produce a ts.Diagnostic during its analyze() step
2) as a result, it wouldn't register component metadata with the scope
   resolver
3) the NgModule for the component references it in exports, which was
   detected as an invalid export (no metadata registering it as a
   component).
4) the resulting assertion error would crash the compiler, hiding the
   real cause of the problem (an invalid component).

This commit should mitigate this problem by converting scoping errors to
proper ts.Diagnostics. Additionally, we should consider registering some
marker indicating a class is a directive/component/pipe without actually
requiring full metadata to be produced for it, which would allow suppression
of errors like "invalid export" for such invalid types.

PR Close #29191
2019-03-08 14:21:48 -08:00

135 lines
4.8 KiB
TypeScript

/**
* @license
* Copyright Google Inc. 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.io/license
*/
import * as ts from 'typescript';
import {ErrorCode, ngErrorCode} from '../../src/ngtsc/diagnostics';
import {NgtscTestEnvironment} from './env';
describe('ngtsc module scopes', () => {
let env !: NgtscTestEnvironment;
beforeEach(() => {
env = NgtscTestEnvironment.setup();
env.tsconfig();
});
describe('diagnostics', () => {
describe('imports', () => {
it('should produce an error when an invalid class is imported', () => {
env.write('test.ts', `
import {NgModule} from '@angular/core';
class NotAModule {}
@NgModule({imports: [NotAModule]})
class IsAModule {}
`);
const [error] = env.driveDiagnostics();
expect(error).not.toBeUndefined();
expect(error.messageText).toContain('IsAModule');
expect(error.messageText).toContain('NgModule.imports');
expect(error.code).toEqual(ngErrorCode(ErrorCode.NGMODULE_INVALID_IMPORT));
expect(diagnosticToNode(error, ts.isIdentifier).text).toEqual('NotAModule');
});
it('should produce an error when a non-class is imported from a .d.ts dependency', () => {
env.write('dep.d.ts', `export declare let NotAClass: Function;`);
env.write('test.ts', `
import {NgModule} from '@angular/core';
import {NotAClass} from './dep';
@NgModule({imports: [NotAClass]})
class IsAModule {}
`);
const [error] = env.driveDiagnostics();
expect(error).not.toBeUndefined();
expect(error.messageText).toContain('IsAModule');
expect(error.messageText).toContain('NgModule.imports');
expect(error.code).toEqual(ngErrorCode(ErrorCode.VALUE_HAS_WRONG_TYPE));
expect(diagnosticToNode(error, ts.isIdentifier).text).toEqual('NotAClass');
});
});
describe('exports', () => {
it('should produce an error when a non-NgModule class is exported', () => {
env.write('test.ts', `
import {NgModule} from '@angular/core';
class NotAModule {}
@NgModule({exports: [NotAModule]})
class IsAModule {}
`);
const [error] = env.driveDiagnostics();
expect(error).not.toBeUndefined();
expect(error.messageText).toContain('IsAModule');
expect(error.messageText).toContain('NgModule.exports');
expect(error.code).toEqual(ngErrorCode(ErrorCode.NGMODULE_INVALID_EXPORT));
expect(diagnosticToNode(error, ts.isIdentifier).text).toEqual('NotAModule');
});
it('should produce a transitive error when an invalid NgModule is exported', () => {
env.write('test.ts', `
import {NgModule} from '@angular/core';
export class NotAModule {}
@NgModule({
imports: [NotAModule],
})
class InvalidModule {}
@NgModule({exports: [InvalidModule]})
class IsAModule {}
`);
// Find the diagnostic referencing InvalidModule, which should have come from IsAModule.
const error = env.driveDiagnostics().find(
error => diagnosticToNode(error, ts.isIdentifier).text === 'InvalidModule');
if (error === undefined) {
return fail('Expected to find a diagnostic referencing InvalidModule');
}
expect(error.messageText).toContain('IsAModule');
expect(error.messageText).toContain('NgModule.exports');
expect(error.code).toEqual(ngErrorCode(ErrorCode.NGMODULE_INVALID_EXPORT));
});
});
describe('re-exports', () => {
it('should produce an error when a non-declared/imported class is re-exported', () => {
env.write('test.ts', `
import {Directive, NgModule} from '@angular/core';
@Directive({selector: 'test'})
class Dir {}
@NgModule({exports: [Dir]})
class IsAModule {}
`);
const [error] = env.driveDiagnostics();
expect(error).not.toBeUndefined();
expect(error.messageText).toContain('IsAModule');
expect(error.messageText).toContain('NgModule.exports');
expect(error.code).toEqual(ngErrorCode(ErrorCode.NGMODULE_INVALID_REEXPORT));
expect(diagnosticToNode(error, ts.isIdentifier).text).toEqual('Dir');
});
});
});
});
function diagnosticToNode<T extends ts.Node>(
diag: ts.Diagnostic, guard: (node: ts.Node) => node is T): T {
if (diag.file === undefined) {
throw new Error(`Expected ts.Diagnostic to have a file source`);
}
const node = (ts as any).getTokenAtPosition(diag.file, diag.start) as ts.Node;
expect(guard(node)).toBe(true);
return node as T;
}