From 50345b8c3661d764ff314575142d4e4213865030 Mon Sep 17 00:00:00 2001 From: Victor Berchet Date: Thu, 11 Aug 2016 21:00:35 -0700 Subject: [PATCH] feat(i18n): add an HtmlParser decorator (#10645) * fix(i18n): merge retains attributes w/o value * feat(i18n): allow attributes on ng-container (i.e. i18n) * feat(i18n): add an HtmlParser decorator * style: clang format --- modules/@angular/compiler-cli/src/codegen.ts | 2 +- .../@angular/compiler-cli/src/extract_i18n.ts | 2 +- modules/@angular/compiler/src/compiler.ts | 10 +- .../compiler/src/directive_normalizer.ts | 2 +- .../compiler/src/i18n/extractor_merger.ts | 10 +- .../@angular/compiler/src/i18n/html_parser.ts | 54 +++++ modules/@angular/compiler/src/i18n/index.ts | 7 +- .../compiler/src/i18n/message_bundle.ts | 1 - .../@angular/compiler/src/ml_parser/parser.ts | 6 +- .../src/template_parser/template_parser.ts | 5 +- .../src/view_compiler/view_builder.ts | 17 +- .../test/i18n/extractor_merger_spec.ts | 7 +- .../compiler/test/i18n/integration_spec.ts | 217 ++++++++++++++++++ .../linker/ng_container_integration_spec.ts | 16 ++ 14 files changed, 333 insertions(+), 23 deletions(-) create mode 100644 modules/@angular/compiler/src/i18n/html_parser.ts create mode 100644 modules/@angular/compiler/test/i18n/integration_spec.ts diff --git a/modules/@angular/compiler-cli/src/codegen.ts b/modules/@angular/compiler-cli/src/codegen.ts index 8c468fb830..377c325ac1 100644 --- a/modules/@angular/compiler-cli/src/codegen.ts +++ b/modules/@angular/compiler-cli/src/codegen.ts @@ -132,7 +132,7 @@ export class CodeGenerator { const reflectorHost = new ReflectorHost(program, compilerHost, options, reflectorHostContext); const staticReflector = new StaticReflector(reflectorHost); StaticAndDynamicReflectionCapabilities.install(staticReflector); - const htmlParser = new HtmlParser(); + const htmlParser = new compiler.i18n.HtmlParser(new HtmlParser()); const config = new compiler.CompilerConfig({ genDebugInfo: options.debug === true, defaultEncapsulation: ViewEncapsulation.Emulated, diff --git a/modules/@angular/compiler-cli/src/extract_i18n.ts b/modules/@angular/compiler-cli/src/extract_i18n.ts index 24d2e1cdd8..1372dd9730 100644 --- a/modules/@angular/compiler-cli/src/extract_i18n.ts +++ b/modules/@angular/compiler-cli/src/extract_i18n.ts @@ -142,7 +142,7 @@ export class Extractor { const reflectorHost = new ReflectorHost(program, compilerHost, options, reflectorHostContext); const staticReflector = new StaticReflector(reflectorHost); StaticAndDynamicReflectionCapabilities.install(staticReflector); - const htmlParser = new HtmlParser(); + const htmlParser = new compiler.i18n.HtmlParser(new HtmlParser()); const config = new compiler.CompilerConfig({ genDebugInfo: options.debug === true, diff --git a/modules/@angular/compiler/src/compiler.ts b/modules/@angular/compiler/src/compiler.ts index a36e044122..5d9803dbcf 100644 --- a/modules/@angular/compiler/src/compiler.ts +++ b/modules/@angular/compiler/src/compiler.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {COMPILER_OPTIONS, Compiler, CompilerFactory, CompilerOptions, Component, Inject, Injectable, PLATFORM_DIRECTIVES, PLATFORM_INITIALIZER, PLATFORM_PIPES, PlatformRef, ReflectiveInjector, Type, ViewEncapsulation, createPlatformFactory, isDevMode, platformCore} from '@angular/core'; +import {COMPILER_OPTIONS, Compiler, CompilerFactory, CompilerOptions, Component, Inject, Injectable, OptionalMetadata, PLATFORM_DIRECTIVES, PLATFORM_INITIALIZER, PLATFORM_PIPES, PlatformRef, Provider, ReflectiveInjector, Type, ViewEncapsulation, createPlatformFactory, isDevMode, platformCore} from '@angular/core'; export * from './template_parser/template_ast'; export {TEMPLATE_TRANSFORMS} from './template_parser/template_parser'; @@ -42,6 +42,7 @@ import {PipeResolver} from './pipe_resolver'; import {NgModuleResolver} from './ng_module_resolver'; import {Console, Reflector, reflector, ReflectorReader, ReflectionCapabilities} from '../core_private'; import {XHR} from './xhr'; +import * as i18n from './i18n/index'; const _NO_XHR: XHR = { get(url: string): Promise{ @@ -60,6 +61,12 @@ export const COMPILER_PROVIDERS: Array|{[k: string]: any}|any[]> = Lexer, Parser, HtmlParser, + { + provide: i18n.HtmlParser, + useFactory: (parser: HtmlParser, translations: string) => + new i18n.HtmlParser(parser, translations), + deps: [HtmlParser, [new OptionalMetadata(), new Inject(i18n.TRANSLATIONS)]] + }, TemplateParser, DirectiveNormalizer, CompileMetadataResolver, @@ -78,7 +85,6 @@ export const COMPILER_PROVIDERS: Array|{[k: string]: any}|any[]> = NgModuleResolver ]; - export function analyzeAppProvidersForDeprecatedConfiguration(appProviders: any[] = []): { compilerOptions: CompilerOptions, moduleDeclarations: Type[], diff --git a/modules/@angular/compiler/src/directive_normalizer.ts b/modules/@angular/compiler/src/directive_normalizer.ts index 6fec75d1d8..2488cccd9d 100644 --- a/modules/@angular/compiler/src/directive_normalizer.ts +++ b/modules/@angular/compiler/src/directive_normalizer.ts @@ -225,11 +225,11 @@ class TemplatePreparseVisitor implements html.Visitor { } return null; } + visitComment(ast: html.Comment, context: any): any { return null; } visitAttribute(ast: html.Attribute, context: any): any { return null; } visitText(ast: html.Text, context: any): any { return null; } visitExpansion(ast: html.Expansion, context: any): any { return null; } - visitExpansionCase(ast: html.ExpansionCase, context: any): any { return null; } } diff --git a/modules/@angular/compiler/src/i18n/extractor_merger.ts b/modules/@angular/compiler/src/i18n/extractor_merger.ts index 13e6868f72..1a37f3aaa8 100644 --- a/modules/@angular/compiler/src/i18n/extractor_merger.ts +++ b/modules/@angular/compiler/src/i18n/extractor_merger.ts @@ -111,6 +111,7 @@ class _Visitor implements html.Visitor { const translatedNode = wrapper.visit(this, null); + // TODO(vicb): return MergeResult with errors if (this._inI18nBlock) { this._reportError(nodes[nodes.length - 1], 'Unclosed block'); } @@ -184,7 +185,9 @@ class _Visitor implements html.Visitor { this._closeTranslatableSection(comment, this._blockChildren); this._inI18nBlock = false; const message = this._addMessage(this._blockChildren, this._blockMeaningAndDesc); - return this._translateMessage(comment, message); + // merge attributes in sections + const nodes = this._translateMessage(comment, message); + return html.visitAll(this, nodes); } else { this._reportError(comment, 'I18N blocks should not cross element boundaries'); return; @@ -341,7 +344,8 @@ class _Visitor implements html.Visitor { return message; } - // translate the given message given the `TranslationBundle` + // Translates the given message given the `TranslationBundle` + // no-op when called in extraction mode (returns []) private _translateMessage(el: html.Node, message: i18n.Message): html.Node[] { if (message && this._mode === _VisitorMode.Merge) { const id = digestMessage(message); @@ -377,7 +381,7 @@ class _Visitor implements html.Visitor { return; } - if (i18nAttributeMeanings.hasOwnProperty(attr.name)) { + if (attr.value && attr.value != '' && i18nAttributeMeanings.hasOwnProperty(attr.name)) { const meaning = i18nAttributeMeanings[attr.name]; const message: i18n.Message = this._createI18nMessage([attr], meaning, ''); const id = digestMessage(message); diff --git a/modules/@angular/compiler/src/i18n/html_parser.ts b/modules/@angular/compiler/src/i18n/html_parser.ts new file mode 100644 index 0000000000..2200f838af --- /dev/null +++ b/modules/@angular/compiler/src/i18n/html_parser.ts @@ -0,0 +1,54 @@ +/** + * @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 {HtmlParser as BaseHtmlParser} from '../ml_parser/html_parser'; +import {DEFAULT_INTERPOLATION_CONFIG, InterpolationConfig} from '../ml_parser/interpolation_config'; +import {ParseTreeResult} from '../ml_parser/parser'; + +import {mergeTranslations} from './extractor_merger'; +import {MessageBundle} from './message_bundle'; +import {Xtb} from './serializers/xtb'; +import {TranslationBundle} from './translation_bundle'; + +export class HtmlParser implements BaseHtmlParser { + // @override + public getTagDefinition: any; + + // TODO(vicb): transB.load() should not need a msgB & add transB.resolve(msgB, + // interpolationConfig) + // TODO(vicb): remove the interpolationConfig from the Xtb serializer + constructor(private _htmlParser: BaseHtmlParser, private _translations?: string) {} + + parse( + source: string, url: string, parseExpansionForms: boolean = false, + interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG): ParseTreeResult { + const parseResult = + this._htmlParser.parse(source, url, parseExpansionForms, interpolationConfig); + + if (!this._translations || this._translations === '') { + // Do not enable i18n when no translation bundle is provided + return parseResult; + } + + // TODO(vicb): add support for implicit tags / attributes + const messageBundle = new MessageBundle(this._htmlParser, [], {}); + const errors = messageBundle.updateFromTemplate(source, url, interpolationConfig); + + if (errors && errors.length) { + return new ParseTreeResult(parseResult.rootNodes, parseResult.errors.concat(errors)); + } + + const xtb = new Xtb(this._htmlParser, interpolationConfig); + const translationBundle = TranslationBundle.load(this._translations, url, messageBundle, xtb); + + const translatedNodes = + mergeTranslations(parseResult.rootNodes, translationBundle, interpolationConfig, [], {}); + + return new ParseTreeResult(translatedNodes, []); + } +} \ No newline at end of file diff --git a/modules/@angular/compiler/src/i18n/index.ts b/modules/@angular/compiler/src/i18n/index.ts index e810fa5679..b1f41f0edb 100644 --- a/modules/@angular/compiler/src/i18n/index.ts +++ b/modules/@angular/compiler/src/i18n/index.ts @@ -6,7 +6,12 @@ * found in the LICENSE file at https://angular.io/license */ +import {OpaqueToken} from '@angular/core'; + +export {HtmlParser} from './html_parser'; export {MessageBundle} from './message_bundle'; export {Serializer} from './serializers/serializer'; export {Xmb} from './serializers/xmb'; -export {Xtb} from './serializers/xtb'; \ No newline at end of file +export {Xtb} from './serializers/xtb'; + +export const TRANSLATIONS = new OpaqueToken('Translations'); diff --git a/modules/@angular/compiler/src/i18n/message_bundle.ts b/modules/@angular/compiler/src/i18n/message_bundle.ts index a3918e8100..ff37a19590 100644 --- a/modules/@angular/compiler/src/i18n/message_bundle.ts +++ b/modules/@angular/compiler/src/i18n/message_bundle.ts @@ -15,7 +15,6 @@ import {extractMessages} from './extractor_merger'; import {Message} from './i18n_ast'; import {Serializer} from './serializers/serializer'; - /** * A container for message extracted from the templates. */ diff --git a/modules/@angular/compiler/src/ml_parser/parser.ts b/modules/@angular/compiler/src/ml_parser/parser.ts index f875aa4996..1dcf48a14a 100644 --- a/modules/@angular/compiler/src/ml_parser/parser.ts +++ b/modules/@angular/compiler/src/ml_parser/parser.ts @@ -28,15 +28,15 @@ export class ParseTreeResult { } export class Parser { - constructor(private _getTagDefinition: (tagName: string) => TagDefinition) {} + constructor(public getTagDefinition: (tagName: string) => TagDefinition) {} parse( source: string, url: string, parseExpansionForms: boolean = false, interpolationConfig: InterpolationConfig = DEFAULT_INTERPOLATION_CONFIG): ParseTreeResult { const tokensAndErrors = - lex.tokenize(source, url, this._getTagDefinition, parseExpansionForms, interpolationConfig); + lex.tokenize(source, url, this.getTagDefinition, parseExpansionForms, interpolationConfig); - const treeAndErrors = new _TreeBuilder(tokensAndErrors.tokens, this._getTagDefinition).build(); + const treeAndErrors = new _TreeBuilder(tokensAndErrors.tokens, this.getTagDefinition).build(); return new ParseTreeResult( treeAndErrors.rootNodes, diff --git a/modules/@angular/compiler/src/template_parser/template_parser.ts b/modules/@angular/compiler/src/template_parser/template_parser.ts index 1591182750..9442ca5b1f 100644 --- a/modules/@angular/compiler/src/template_parser/template_parser.ts +++ b/modules/@angular/compiler/src/template_parser/template_parser.ts @@ -14,9 +14,10 @@ import {AST, ASTWithSource, BindingPipe, EmptyExpr, Interpolation, ParserError, import {Parser} from '../expression_parser/parser'; import {ListWrapper, SetWrapper, StringMapWrapper} from '../facade/collection'; import {isBlank, isPresent} from '../facade/lang'; +import {HtmlParser} from '../i18n/html_parser'; import {Identifiers, identifierToken} from '../identifiers'; import * as html from '../ml_parser/ast'; -import {HtmlParser, ParseTreeResult} from '../ml_parser/html_parser'; +import {ParseTreeResult} from '../ml_parser/html_parser'; import {expandNodes} from '../ml_parser/icu_ast_expander'; import {InterpolationConfig} from '../ml_parser/interpolation_config'; import {mergeNsAndName, splitNsName} from '../ml_parser/tags'; @@ -65,7 +66,7 @@ const TEXT_CSS_SELECTOR = CssSelector.parse('*')[0]; * * This is currently an internal-only feature and not meant for general use. */ -export const TEMPLATE_TRANSFORMS: any = new OpaqueToken('TemplateTransforms'); +export const TEMPLATE_TRANSFORMS = new OpaqueToken('TemplateTransforms'); export class TemplateParseError extends ParseError { constructor(message: string, span: ParseSourceSpan, level: ParseErrorLevel) { diff --git a/modules/@angular/compiler/src/view_compiler/view_builder.ts b/modules/@angular/compiler/src/view_compiler/view_builder.ts index 0bf82d14d1..bbe8aee3d5 100644 --- a/modules/@angular/compiler/src/view_compiler/view_builder.ts +++ b/modules/@angular/compiler/src/view_compiler/view_builder.ts @@ -193,13 +193,16 @@ class ViewBuilderVisitor implements TemplateAstVisitor { var htmlAttrs = _readHtmlAttrs(ast.attrs); var attrNameAndValues = _mergeHtmlAndDirectiveAttrs(htmlAttrs, directives); for (var i = 0; i < attrNameAndValues.length; i++) { - var attrName = attrNameAndValues[i][0]; - var attrValue = attrNameAndValues[i][1]; - this.view.createMethod.addStmt( - ViewProperties.renderer - .callMethod( - 'setElementAttribute', [renderNode, o.literal(attrName), o.literal(attrValue)]) - .toStmt()); + const attrName = attrNameAndValues[i][0]; + if (ast.name !== NG_CONTAINER_TAG) { + // are not rendered in the DOM + const attrValue = attrNameAndValues[i][1]; + this.view.createMethod.addStmt( + ViewProperties.renderer + .callMethod( + 'setElementAttribute', [renderNode, o.literal(attrName), o.literal(attrValue)]) + .toStmt()); + } } var compileElement = new CompileElement( parent, this.view, nodeIndex, renderNode, ast, component, directives, ast.providers, diff --git a/modules/@angular/compiler/test/i18n/extractor_merger_spec.ts b/modules/@angular/compiler/test/i18n/extractor_merger_spec.ts index 5972746962..ab2483153e 100644 --- a/modules/@angular/compiler/test/i18n/extractor_merger_spec.ts +++ b/modules/@angular/compiler/test/i18n/extractor_merger_spec.ts @@ -369,11 +369,16 @@ export function main() { expect(fakeTranslate(HTML)).toEqual('

'); }); - it('should merge attributes', () => { + it('should merge nested attributes', () => { const HTML = `
{count, plural, =0 {

}}
`; expect(fakeTranslate(HTML)) .toEqual('
{count, plural, =0 {

}}
'); }); + + it('should merge attributes without values', () => { + const HTML = `

`; + expect(fakeTranslate(HTML)).toEqual('

'); + }); }); }); } diff --git a/modules/@angular/compiler/test/i18n/integration_spec.ts b/modules/@angular/compiler/test/i18n/integration_spec.ts new file mode 100644 index 0000000000..641dec05c6 --- /dev/null +++ b/modules/@angular/compiler/test/i18n/integration_spec.ts @@ -0,0 +1,217 @@ +/** + * @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 {DirectiveResolver, XHR, i18n} from '@angular/compiler'; +import {MockDirectiveResolver} from '@angular/compiler/testing'; +import {Compiler, Component, DebugElement, Injector} from '@angular/core'; +import {TestBed, TestComponentBuilder, fakeAsync} from '@angular/core/testing'; +import {beforeEach, beforeEachProviders, ddescribe, describe, iit, inject, it, xdescribe, xit,} from '@angular/core/testing/testing_internal'; +import {expect} from '@angular/platform-browser/testing/matchers'; +import {By} from '@angular/platform-browser/src/dom/debug/by'; +import {getDOM} from '@angular/platform-browser/src/dom/dom_adapter'; +import {SpyXHR} from '../spies'; +import {NgLocalization} from '@angular/common'; +import {stringifyElement} from '@angular/platform-browser/testing/browser_util'; + +export function main() { + describe('i18n integration spec', () => { + let compiler: Compiler; + let xhr: SpyXHR; + let tcb: TestComponentBuilder; + let dirResolver: MockDirectiveResolver; + let injector: Injector; + + beforeEach(() => { + TestBed.configureCompiler({ + providers: [ + {provide: XHR, useClass: SpyXHR}, + {provide: NgLocalization, useClass: FrLocalization}, + {provide: i18n.TRANSLATIONS, useValue: XTB}, + ] + }); + }); + + beforeEach(fakeAsync(inject( + [Compiler, TestComponentBuilder, XHR, DirectiveResolver, Injector], + (_compiler: Compiler, _tcb: TestComponentBuilder, _xhr: SpyXHR, + _dirResolver: MockDirectiveResolver, _injector: Injector) => { + compiler = _compiler; + tcb = _tcb; + xhr = _xhr; + dirResolver = _dirResolver; + injector = _injector; + }))); + + it('translate templates', () => { + const tb = tcb.createSync(I18nComponent); + const cmp = tb.componentInstance; + const el = tb.debugElement; + + expectHtml(el, 'h1').toBe('

attributs i18n sur les balises

'); + expectHtml(el, '#i18n-1').toBe('

imbriqué

'); + expectHtml(el, '#i18n-2').toBe('

imbriqué

'); + expectHtml(el, '#i18n-3') + .toBe('

avec des espaces réservés

'); + expectHtml(el, '#i18n-4') + .toBe('

'); + expectHtml(el, '#i18n-5').toBe('

'); + expectHtml(el, '#i18n-6').toBe('

'); + + cmp.count = 0; + tb.detectChanges(); + expect(el.query(By.css('#i18n-7')).nativeElement).toHaveText('zero'); + expect(el.query(By.css('#i18n-14')).nativeElement).toHaveText('zero'); + cmp.count = 1; + tb.detectChanges(); + expect(el.query(By.css('#i18n-7')).nativeElement).toHaveText('un'); + expect(el.query(By.css('#i18n-14')).nativeElement).toHaveText('un'); + cmp.count = 2; + tb.detectChanges(); + expect(el.query(By.css('#i18n-7')).nativeElement).toHaveText('deux'); + expect(el.query(By.css('#i18n-14')).nativeElement).toHaveText('deux'); + cmp.count = 3; + tb.detectChanges(); + expect(el.query(By.css('#i18n-7')).nativeElement).toHaveText('beaucoup'); + expect(el.query(By.css('#i18n-14')).nativeElement).toHaveText('beaucoup'); + + cmp.sex = 'm'; + tb.detectChanges(); + expect(el.query(By.css('#i18n-8')).nativeElement).toHaveText('homme'); + cmp.sex = 'f'; + tb.detectChanges(); + expect(el.query(By.css('#i18n-8')).nativeElement).toHaveText('femme'); + + cmp.count = 123; + tb.detectChanges(); + expectHtml(el, '#i18n-9').toEqual('
count = 123
'); + + cmp.sex = 'f'; + tb.detectChanges(); + expectHtml(el, '#i18n-10').toEqual('
sexe = f
'); + + expectHtml(el, '#i18n-11').toEqual('
custom name
'); + expectHtml(el, '#i18n-12') + .toEqual('

Balises dans les commentaires html

'); + expectHtml(el, '#i18n-13') + .toBe('
'); + + expectHtml(el, '#i18n-15').toMatch(/ca devrait<\/b> marcher/); + }); + }); +} + +function expectHtml(el: DebugElement, cssSelector: string): any { + return expect(stringifyElement(el.query(By.css(cssSelector)).nativeElement)); +} + +@Component({ + selector: 'i18n-cmp', + template: ` +
+

i18n attribute on tags

+ +

nested

+ +

nested

+ +

with placeholders

+ +
+

+

+

+
+ + +
{count, plural, =0 {zero} =1 {one} =2 {two} other {many}}
+ +
+ {sex, sex, m {male} f {female}} +
+ +
{{ "count = " + count }}
+
sex = {{ sex }}
+
{{ "custom name" //i18n(ph="CUSTOM_NAME") }}
+
+ + +

Markers in html comments

+
+
{count, plural, =0 {zero} =1 {one} =2 {two} other {many}}
+ + +
it should work
+` +}) +class I18nComponent { + count: number = 0; + sex: string = 'm'; +} + +class FrLocalization extends NgLocalization { + getPluralCategory(value: number): string { + switch (value) { + case 0: + case 1: + return 'one'; + default: + return 'other'; + } + } +} + +const XTB = ` + + attributs i18n sur les balises + imbriqué + imbriqué + avec des espaces réservés + sur des balises non traductibles + sur des balises traductibles + {count, plural, =0 {zero} =1 {un} =2 {deux} other {beaucoup}} + + {sex, sex, m {homme} f {femme}} + + sexe = + + dans une section traductible + + Balises dans les commentaires html + + + + ca devrait marcher +`; + +// unused, for reference only +// can be generated from xmb_spec as follow: +// `iit('extract xmb', () => { console.log(toXmb(HTML)); });` +const XMB = ` + + i18n attribute on tags + nested + nested + <i>with placeholders</i> + on not translatable node + on translatable node + {count, plural, =0 {zero}=1 {one}=2 {two}other {<b>many</b>}} + + + + {sex, sex, m {male}f {female}} + + sex = + + in a translatable section + + <h1>Markers in html comments</h1> + <div></div> + <div></div> + + it <b>should</b> work +`; \ No newline at end of file diff --git a/modules/@angular/core/test/linker/ng_container_integration_spec.ts b/modules/@angular/core/test/linker/ng_container_integration_spec.ts index 45ab12e091..537531ae75 100644 --- a/modules/@angular/core/test/linker/ng_container_integration_spec.ts +++ b/modules/@angular/core/test/linker/ng_container_integration_spec.ts @@ -23,6 +23,22 @@ function declareTests({useJit}: {useJit: boolean}) { beforeEach(() => { TestBed.configureCompiler({useJit: useJit}); }); + it('should support the "i18n" attribute', + inject( + [TestComponentBuilder, AsyncTestCompleter], + (tcb: TestComponentBuilder, async: AsyncTestCompleter) => { + tcb.overrideTemplate(MyComp, 'foo') + .createAsync(MyComp) + .then((fixture) => { + fixture.detectChanges(); + + const el = fixture.debugElement.nativeElement; + expect(el).toHaveText('foo'); + + async.done(); + }); + })); + it('should be rendered as comment with children as siblings', inject( [TestComponentBuilder, AsyncTestCompleter],