feat(ivy): ngcc - support ngcc "migrations" (#31544)

This commit implements support for the ngcc migrations
as designed in https://hackmd.io/KhyrFV1VQHmeQsgfJq6AyQ

PR Close #31544
This commit is contained in:
Pete Bacon Darwin
2019-07-18 21:05:32 +01:00
committed by Misko Hevery
parent d39a2beae1
commit 4d93d2406f
10 changed files with 504 additions and 73 deletions
@@ -7,15 +7,17 @@
*/
import * as ts from 'typescript';
import {FatalDiagnosticError, makeDiagnostic} from '../../../src/ngtsc/diagnostics';
import {absoluteFrom, getFileSystem, getSourceFileOrError} from '../../../src/ngtsc/file_system';
import {TestFile, runInEachFileSystem} from '../../../src/ngtsc/file_system/testing';
import {Decorator} from '../../../src/ngtsc/reflection';
import {ClassDeclaration, Decorator} from '../../../src/ngtsc/reflection';
import {DecoratorHandler, DetectResult} from '../../../src/ngtsc/transform';
import {loadFakeCore, loadTestFiles} from '../../../test/helpers';
import {DecorationAnalyzer} from '../../src/analysis/decoration_analyzer';
import {NgccReferencesRegistry} from '../../src/analysis/ngcc_references_registry';
import {CompiledClass, DecorationAnalyses} from '../../src/analysis/types';
import {Esm2015ReflectionHost} from '../../src/host/esm2015_host';
import {Migration, MigrationHost} from '../../src/migrations/migration';
import {MockLogger} from '../helpers/mock_logger';
import {getRootFiles, makeTestEntryPointBundle} from '../helpers/utils';
@@ -31,6 +33,8 @@ runInEachFileSystem(() => {
describe('analyzeProgram()', () => {
let logs: string[];
let migrationLogs: string[];
let diagnosticLogs: ts.Diagnostic[];
let program: ts.Program;
let testHandler: jasmine.SpyObj<DecoratorHandlerWithResolve>;
let result: DecorationAnalyses;
@@ -87,7 +91,7 @@ runInEachFileSystem(() => {
return handler;
};
function setUpAndAnalyzeProgram(testFiles: TestFile[]) {
function setUpAnalyzer(testFiles: TestFile[]) {
logs = [];
loadTestFiles(testFiles);
loadFakeCore(getFileSystem());
@@ -99,11 +103,17 @@ runInEachFileSystem(() => {
const reflectionHost =
new Esm2015ReflectionHost(new MockLogger(), false, program.getTypeChecker());
const referencesRegistry = new NgccReferencesRegistry(reflectionHost);
const analyzer =
new DecorationAnalyzer(getFileSystem(), bundle, reflectionHost, referencesRegistry);
diagnosticLogs = [];
const analyzer = new DecorationAnalyzer(
getFileSystem(), bundle, reflectionHost, referencesRegistry,
(error) => diagnosticLogs.push(error));
testHandler = createTestHandler();
analyzer.handlers = [testHandler];
result = analyzer.analyzeProgram();
migrationLogs = [];
const migration1 = new MockMigration('migration1', migrationLogs);
const migration2 = new MockMigration('migration2', migrationLogs);
analyzer.migrations = [migration1, migration2];
return analyzer;
}
describe('basic usage', () => {
@@ -144,7 +154,8 @@ runInEachFileSystem(() => {
`,
},
];
setUpAndAnalyzeProgram(TEST_PROGRAM);
const analyzer = setUpAnalyzer(TEST_PROGRAM);
result = analyzer.analyzeProgram();
});
it('should return an object containing a reference to the original source file', () => {
@@ -185,6 +196,18 @@ runInEachFileSystem(() => {
} as unknown as CompiledClass));
});
it('should call `apply()` on each migration for each class', () => {
expect(migrationLogs).toEqual([
'migration1:MyComponent',
'migration2:MyComponent',
'migration1:MyDirective',
'migration2:MyDirective',
'migration1:MyOtherComponent',
'migration2:MyOtherComponent',
]);
});
it('should analyze, resolve and compile the classes that are detected', () => {
expect(logs).toEqual([
// Classes without decorators should also be detected.
@@ -238,7 +261,8 @@ runInEachFileSystem(() => {
isRoot: false,
}
];
setUpAndAnalyzeProgram(INTERNAL_COMPONENT_PROGRAM);
const analyzer = setUpAnalyzer(INTERNAL_COMPONENT_PROGRAM);
result = analyzer.analyzeProgram();
});
// The problem of exposing the type of these internal components in the .d.ts typing
@@ -299,7 +323,8 @@ runInEachFileSystem(() => {
},
];
setUpAndAnalyzeProgram(EXTERNAL_COMPONENT_PROGRAM);
const analyzer = setUpAnalyzer(EXTERNAL_COMPONENT_PROGRAM);
result = analyzer.analyzeProgram();
});
it('should ignore classes from an externally imported file', () => {
@@ -307,6 +332,45 @@ runInEachFileSystem(() => {
expect(result.has(file)).toBe(false);
});
});
describe('diagnostic handling', () => {
it('should report migration diagnostics to the `diagnosticHandler` callback', () => {
const analyzer = setUpAnalyzer([
{
name: _('/node_modules/test-package/index.js'),
contents: `
import {Component, Directive, Injectable} from '@angular/core';
export class MyComponent {}
MyComponent.decorators = [{type: Component}];
`,
},
]);
analyzer.migrations = [
{
apply(clazz: ClassDeclaration) {
return makeDiagnostic(9999, clazz, 'normal diagnostic');
}
},
{
apply(clazz: ClassDeclaration) {
throw new FatalDiagnosticError(6666, clazz, 'fatal diagnostic');
}
}
];
analyzer.analyzeProgram();
expect(diagnosticLogs.length).toEqual(2);
expect(diagnosticLogs[0]).toEqual(jasmine.objectContaining({code: -999999}));
expect(diagnosticLogs[1]).toEqual(jasmine.objectContaining({code: -996666}));
});
});
});
});
});
});
class MockMigration implements Migration {
constructor(private name: string, private log: string[]) {}
apply(clazz: ClassDeclaration, host: MigrationHost): ts.Diagnostic|null {
this.log.push(`${this.name}:${clazz.name.text}`);
return null;
}
}
@@ -0,0 +1,179 @@
/**
* @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 {ErrorCode} from '../../../src/ngtsc/diagnostics';
import {ClassDeclaration, ClassSymbol, Decorator} from '../../../src/ngtsc/reflection';
import {AnalysisOutput, CompileResult, DecoratorHandler, DetectResult, HandlerPrecedence} from '../../../src/ngtsc/transform';
import {DefaultMigrationHost} from '../../src/analysis/migration_host';
import {AnalyzedClass, AnalyzedFile} from '../../src/analysis/types';
describe('DefaultMigrationHost', () => {
describe('injectSyntheticDecorator()', () => {
const mockHost: any = {
getClassSymbol: (node: any): ClassSymbol | undefined =>
({ valueDeclaration: node, name: node.name.text } as any),
};
const mockMetadata: any = {};
const mockEvaluator: any = {};
const mockClazz: any = {
name: {text: 'MockClazz'},
getSourceFile: () => { fileName: 'test-file.js'; },
};
const mockDecorator: any = {name: 'MockDecorator'};
it('should call `detect()` on each of the provided handlers', () => {
const log: string[] = [];
const handler1 = new TestHandler('handler1', log);
const handler2 = new TestHandler('handler2', log);
const host =
new DefaultMigrationHost(mockHost, mockMetadata, mockEvaluator, [handler1, handler2], []);
host.injectSyntheticDecorator(mockClazz, mockDecorator);
expect(log).toEqual([
`handler1:detect:MockClazz:MockDecorator`,
`handler2:detect:MockClazz:MockDecorator`,
]);
});
it('should call `analyze()` on each of the provided handlers whose `detect()` call returns a result',
() => {
const log: string[] = [];
const handler1 = new TestHandler('handler1', log);
const handler2 = new AlwaysDetectHandler('handler2', log);
const handler3 = new TestHandler('handler3', log);
const host = new DefaultMigrationHost(
mockHost, mockMetadata, mockEvaluator, [handler1, handler2, handler3], []);
host.injectSyntheticDecorator(mockClazz, mockDecorator);
expect(log).toEqual([
`handler1:detect:MockClazz:MockDecorator`,
`handler2:detect:MockClazz:MockDecorator`,
`handler3:detect:MockClazz:MockDecorator`,
'handler2:analyze:MockClazz',
]);
});
it('should add a newly `AnalyzedFile` to the `analyzedFiles` object', () => {
const log: string[] = [];
const handler = new AlwaysDetectHandler('handler', log);
const analyzedFiles: AnalyzedFile[] = [];
const host =
new DefaultMigrationHost(mockHost, mockMetadata, mockEvaluator, [handler], analyzedFiles);
host.injectSyntheticDecorator(mockClazz, mockDecorator);
expect(analyzedFiles.length).toEqual(1);
expect(analyzedFiles[0].analyzedClasses.length).toEqual(1);
expect(analyzedFiles[0].analyzedClasses[0].name).toEqual('MockClazz');
});
it('should add a newly `AnalyzedClass` to an existing `AnalyzedFile` object', () => {
const DUMMY_CLASS_1: any = {};
const DUMMY_CLASS_2: any = {};
const log: string[] = [];
const handler = new AlwaysDetectHandler('handler', log);
const analyzedFiles: AnalyzedFile[] = [{
sourceFile: mockClazz.getSourceFile(),
analyzedClasses: [DUMMY_CLASS_1, DUMMY_CLASS_2],
}];
const host =
new DefaultMigrationHost(mockHost, mockMetadata, mockEvaluator, [handler], analyzedFiles);
host.injectSyntheticDecorator(mockClazz, mockDecorator);
expect(analyzedFiles.length).toEqual(1);
expect(analyzedFiles[0].analyzedClasses.length).toEqual(3);
expect(analyzedFiles[0].analyzedClasses[2].name).toEqual('MockClazz');
});
it('should add a new decorator into an already existing `AnalyzedClass`', () => {
const analyzedClass: AnalyzedClass = {
name: 'MockClazz',
declaration: mockClazz,
matches: [],
decorators: null,
};
const log: string[] = [];
const handler = new AlwaysDetectHandler('handler', log);
const analyzedFiles: AnalyzedFile[] = [{
sourceFile: mockClazz.getSourceFile(),
analyzedClasses: [analyzedClass],
}];
const host =
new DefaultMigrationHost(mockHost, mockMetadata, mockEvaluator, [handler], analyzedFiles);
host.injectSyntheticDecorator(mockClazz, mockDecorator);
expect(analyzedFiles.length).toEqual(1);
expect(analyzedFiles[0].analyzedClasses.length).toEqual(1);
expect(analyzedFiles[0].analyzedClasses[0]).toBe(analyzedClass);
expect(analyzedClass.decorators !.length).toEqual(1);
expect(analyzedClass.decorators ![0].name).toEqual('MockDecorator');
});
it('should merge a new decorator into pre-existing decorators an already existing `AnalyzedClass`',
() => {
const analyzedClass: AnalyzedClass = {
name: 'MockClazz',
declaration: mockClazz,
matches: [],
decorators: [{name: 'OtherDecorator'} as Decorator],
};
const log: string[] = [];
const handler = new AlwaysDetectHandler('handler', log);
const analyzedFiles: AnalyzedFile[] = [{
sourceFile: mockClazz.getSourceFile(),
analyzedClasses: [analyzedClass],
}];
const host = new DefaultMigrationHost(
mockHost, mockMetadata, mockEvaluator, [handler], analyzedFiles);
host.injectSyntheticDecorator(mockClazz, mockDecorator);
expect(analyzedFiles.length).toEqual(1);
expect(analyzedFiles[0].analyzedClasses.length).toEqual(1);
expect(analyzedFiles[0].analyzedClasses[0]).toBe(analyzedClass);
expect(analyzedClass.decorators !.length).toEqual(2);
expect(analyzedClass.decorators ![1].name).toEqual('MockDecorator');
});
it('should throw an error if the injected decorator already exists', () => {
const analyzedClass: AnalyzedClass = {
name: 'MockClazz',
declaration: mockClazz,
matches: [],
decorators: [{name: 'MockDecorator'} as Decorator],
};
const log: string[] = [];
const handler = new AlwaysDetectHandler('handler', log);
const analyzedFiles: AnalyzedFile[] = [{
sourceFile: mockClazz.getSourceFile(),
analyzedClasses: [analyzedClass],
}];
const host =
new DefaultMigrationHost(mockHost, mockMetadata, mockEvaluator, [handler], analyzedFiles);
expect(() => host.injectSyntheticDecorator(mockClazz, mockDecorator))
.toThrow(
jasmine.objectContaining({code: ErrorCode.NGCC_MIGRATION_DECORATOR_INJECTION_ERROR}));
});
});
});
class TestHandler implements DecoratorHandler<any, any> {
constructor(protected name: string, protected log: string[]) {}
precedence = HandlerPrecedence.PRIMARY;
detect(node: ClassDeclaration, decorators: Decorator[]|null): DetectResult<any>|undefined {
this.log.push(`${this.name}:detect:${node.name.text}:${decorators !.map(d => d.name)}`);
return undefined;
}
analyze(node: ClassDeclaration): AnalysisOutput<any> {
this.log.push(this.name + ':analyze:' + node.name.text);
return {};
}
compile(node: ClassDeclaration): CompileResult|CompileResult[] {
this.log.push(this.name + ':compile:' + node.name.text);
return [];
}
}
class AlwaysDetectHandler extends TestHandler {
detect(node: ClassDeclaration, decorators: Decorator[]|null): DetectResult<any>|undefined {
super.detect(node, decorators);
return {trigger: node, metadata: {}};
}
}