From f1e90d388c54b421fd7d1ba5dac02fc3c960bee0 Mon Sep 17 00:00:00 2001 From: Nicolas Molina Monroy Date: Wed, 26 Aug 2026 10:27:15 -0400 Subject: [PATCH 1/7] test(spectator): cover props and setInput with aliased input names Adds the failing cases for #15 across the Karma, Jest and Vitest suites, before any source change. Neither `props` nor `setInput()` accepts the class property name of an input that declares an alias, even though that is exactly what both types advertise (`props?: InferInputSignals` and `setInput`). Angular resolves inputs by their public name, so the property name reaches `ComponentRef.setInput()` unmapped and is dropped with NG0303 -- silently for optional inputs, and as a thrown NG0950 for `input.required({ alias })`. 8 of the 15 new tests fail: - props + property name, @Input('userName') / @Input({ alias }) - props + property name, input.required({ alias }) / input(d, { alias }) / model({ alias }) - props mixing property names and alias names - setInput() + property name on an aliased input - createRoutingFactory({ props }) + property name The other 7 pass and stand as regression guards for behaviour that must not change: alias keys keep working, non-aliased inputs are unaffected, an unknown key is still reported rather than redirected, and a key that is both the public name of one input and the property name of another still resolves as the public name. Refs #15 --- .../jest/test/props-alias-names.spec.ts | 192 ++++++++++++++++++ .../spectator/test/props-alias-names.spec.ts | 192 ++++++++++++++++++ .../vitest/test/props-alias-names.spec.ts | 192 ++++++++++++++++++ 3 files changed, 576 insertions(+) create mode 100644 projects/spectator/jest/test/props-alias-names.spec.ts create mode 100644 projects/spectator/test/props-alias-names.spec.ts create mode 100644 projects/spectator/vitest/test/props-alias-names.spec.ts diff --git a/projects/spectator/jest/test/props-alias-names.spec.ts b/projects/spectator/jest/test/props-alias-names.spec.ts new file mode 100644 index 0000000..5c58685 --- /dev/null +++ b/projects/spectator/jest/test/props-alias-names.spec.ts @@ -0,0 +1,192 @@ +import { Component, Input, input, model } from '@angular/core'; +import { createComponentFactory, createRoutingFactory } from '@openng/spectator/jest'; + +describe('PropsAliasNames', () => { + describe('input decorators', () => { + @Component({ + selector: 'app-root', + template: ` +
{{ name }}
+
{{ numOfYears }}
+ `, + standalone: true, + }) + class DummyComponent { + @Input('userName') public name = ''; + @Input({ alias: 'age' }) public numOfYears = 0; + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should accept the property name of an @Input() with a rename alias', () => { + const spectator = createComponent({ props: { name: 'John' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + }); + + it('props should accept the property name of an @Input() with an alias option', () => { + const spectator = createComponent({ props: { numOfYears: 123 } }); + + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + + it('props should still accept the alias names', () => { + const spectator = createComponent({ props: { userName: 'John', age: 123 } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + + it('props should accept property names and alias names in the same object', () => { + const spectator = createComponent({ props: { name: 'John', age: 123 } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + + it('setInput should accept the property name of an aliased input', () => { + const spectator = createComponent(); + + spectator.setInput('name', 'John'); + spectator.setInput('numOfYears', 123); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + }); + + describe('signal inputs', () => { + @Component({ + selector: 'app-root', + template: ` +
{{ name() }}
+
{{ numOfYears() }}
+
{{ nickname() }}
+ `, + standalone: true, + }) + class DummyComponent { + public name = input.required({ alias: 'userName' }); + public numOfYears = input(0, { alias: 'age' }); + public nickname = model('', { alias: 'nick' }); + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should accept the property name of a required signal input with an alias', () => { + const spectator = createComponent({ props: { name: 'John' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + }); + + it('props should accept the property name of an optional signal input with an alias', () => { + const spectator = createComponent({ props: { name: 'John', numOfYears: 123 } }); + + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + + it('props should accept the property name of a model with an alias', () => { + const spectator = createComponent({ props: { name: 'John', nickname: 'Johnny' } }); + + expect(spectator.query('[data-test="props--nickname"]')!.innerHTML).toBe('Johnny'); + }); + + it('props should still accept the alias names', () => { + const spectator = createComponent({ props: { userName: 'John', age: 123, nick: 'Johnny' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + expect(spectator.query('[data-test="props--nickname"]')!.innerHTML).toBe('Johnny'); + }); + }); + + describe('inputs without an alias', () => { + @Component({ + selector: 'app-root', + template: ` +
{{ title }}
+
{{ count() }}
+ `, + standalone: true, + }) + class DummyComponent { + @Input() public title = ''; + public count = input(0); + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should keep working for inputs that declare no alias', () => { + const spectator = createComponent({ props: { title: 'Hello', count: 7 } }); + + expect(spectator.query('[data-test="props--title"]')!.innerHTML).toBe('Hello'); + expect(spectator.query('[data-test="props--count"]')!.innerHTML).toBe('7'); + }); + + it('props should leave a key that matches no input alone', () => { + // Angular reports the unknown key itself (NG0303); the resolver must not + // silently redirect it onto some other input. + const spectator = createComponent({ props: { title: 'Hello', notAnInput: 'ignored' } }); + + expect(spectator.query('[data-test="props--title"]')!.innerHTML).toBe('Hello'); + expect(spectator.query('[data-test="props--count"]')!.innerHTML).toBe('0'); + }); + }); + + describe('a key that is both a public name and a property name', () => { + @Component({ + selector: 'app-root', + template: ` +
{{ numOfYears }}
+
{{ age }}
+ `, + standalone: true, + }) + class DummyComponent { + // 'age' is the public name of this input... + @Input({ alias: 'age' }) public numOfYears = 0; + // ...and the property name of this one. + @Input({ alias: 'collided' }) public age = 'untouched'; + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should resolve the key as a public name, not as a property name', () => { + const spectator = createComponent({ props: { age: 123 } }); + + expect(spectator.query('[data-test="props--years"]')!.innerHTML).toBe('123'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('untouched'); + }); + + it('props should still reach the shadowed input through its own public name', () => { + const spectator = createComponent({ props: { collided: 'reached' } }); + + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('reached'); + }); + }); + + describe('createRoutingFactory', () => { + @Component({ + selector: 'app-root', + template: `
{{ name }}
`, + standalone: true, + }) + class DummyComponent { + @Input('userName') public name = ''; + } + + const createComponent = createRoutingFactory(DummyComponent); + + it('props should accept the property name of an aliased input', () => { + const spectator = createComponent({ props: { name: 'John' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + }); + + it('props should still accept the alias name', () => { + const spectator = createComponent({ props: { userName: 'John' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + }); + }); +}); diff --git a/projects/spectator/test/props-alias-names.spec.ts b/projects/spectator/test/props-alias-names.spec.ts new file mode 100644 index 0000000..01b54b8 --- /dev/null +++ b/projects/spectator/test/props-alias-names.spec.ts @@ -0,0 +1,192 @@ +import { Component, Input, input, model } from '@angular/core'; +import { createComponentFactory, createRoutingFactory } from '@openng/spectator'; + +describe('PropsAliasNames', () => { + describe('input decorators', () => { + @Component({ + selector: 'app-root', + template: ` +
{{ name }}
+
{{ numOfYears }}
+ `, + standalone: true, + }) + class DummyComponent { + @Input('userName') public name = ''; + @Input({ alias: 'age' }) public numOfYears = 0; + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should accept the property name of an @Input() with a rename alias', () => { + const spectator = createComponent({ props: { name: 'John' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + }); + + it('props should accept the property name of an @Input() with an alias option', () => { + const spectator = createComponent({ props: { numOfYears: 123 } }); + + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + + it('props should still accept the alias names', () => { + const spectator = createComponent({ props: { userName: 'John', age: 123 } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + + it('props should accept property names and alias names in the same object', () => { + const spectator = createComponent({ props: { name: 'John', age: 123 } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + + it('setInput should accept the property name of an aliased input', () => { + const spectator = createComponent(); + + spectator.setInput('name', 'John'); + spectator.setInput('numOfYears', 123); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + }); + + describe('signal inputs', () => { + @Component({ + selector: 'app-root', + template: ` +
{{ name() }}
+
{{ numOfYears() }}
+
{{ nickname() }}
+ `, + standalone: true, + }) + class DummyComponent { + public name = input.required({ alias: 'userName' }); + public numOfYears = input(0, { alias: 'age' }); + public nickname = model('', { alias: 'nick' }); + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should accept the property name of a required signal input with an alias', () => { + const spectator = createComponent({ props: { name: 'John' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + }); + + it('props should accept the property name of an optional signal input with an alias', () => { + const spectator = createComponent({ props: { name: 'John', numOfYears: 123 } }); + + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + + it('props should accept the property name of a model with an alias', () => { + const spectator = createComponent({ props: { name: 'John', nickname: 'Johnny' } }); + + expect(spectator.query('[data-test="props--nickname"]')!.innerHTML).toBe('Johnny'); + }); + + it('props should still accept the alias names', () => { + const spectator = createComponent({ props: { userName: 'John', age: 123, nick: 'Johnny' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + expect(spectator.query('[data-test="props--nickname"]')!.innerHTML).toBe('Johnny'); + }); + }); + + describe('inputs without an alias', () => { + @Component({ + selector: 'app-root', + template: ` +
{{ title }}
+
{{ count() }}
+ `, + standalone: true, + }) + class DummyComponent { + @Input() public title = ''; + public count = input(0); + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should keep working for inputs that declare no alias', () => { + const spectator = createComponent({ props: { title: 'Hello', count: 7 } }); + + expect(spectator.query('[data-test="props--title"]')!.innerHTML).toBe('Hello'); + expect(spectator.query('[data-test="props--count"]')!.innerHTML).toBe('7'); + }); + + it('props should leave a key that matches no input alone', () => { + // Angular reports the unknown key itself (NG0303); the resolver must not + // silently redirect it onto some other input. + const spectator = createComponent({ props: { title: 'Hello', notAnInput: 'ignored' } }); + + expect(spectator.query('[data-test="props--title"]')!.innerHTML).toBe('Hello'); + expect(spectator.query('[data-test="props--count"]')!.innerHTML).toBe('0'); + }); + }); + + describe('a key that is both a public name and a property name', () => { + @Component({ + selector: 'app-root', + template: ` +
{{ numOfYears }}
+
{{ age }}
+ `, + standalone: true, + }) + class DummyComponent { + // 'age' is the public name of this input... + @Input({ alias: 'age' }) public numOfYears = 0; + // ...and the property name of this one. + @Input({ alias: 'collided' }) public age = 'untouched'; + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should resolve the key as a public name, not as a property name', () => { + const spectator = createComponent({ props: { age: 123 } }); + + expect(spectator.query('[data-test="props--years"]')!.innerHTML).toBe('123'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('untouched'); + }); + + it('props should still reach the shadowed input through its own public name', () => { + const spectator = createComponent({ props: { collided: 'reached' } }); + + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('reached'); + }); + }); + + describe('createRoutingFactory', () => { + @Component({ + selector: 'app-root', + template: `
{{ name }}
`, + standalone: true, + }) + class DummyComponent { + @Input('userName') public name = ''; + } + + const createComponent = createRoutingFactory(DummyComponent); + + it('props should accept the property name of an aliased input', () => { + const spectator = createComponent({ props: { name: 'John' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + }); + + it('props should still accept the alias name', () => { + const spectator = createComponent({ props: { userName: 'John' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + }); + }); +}); diff --git a/projects/spectator/vitest/test/props-alias-names.spec.ts b/projects/spectator/vitest/test/props-alias-names.spec.ts new file mode 100644 index 0000000..693a26e --- /dev/null +++ b/projects/spectator/vitest/test/props-alias-names.spec.ts @@ -0,0 +1,192 @@ +import { Component, Input, input, model } from '@angular/core'; +import { createComponentFactory, createRoutingFactory } from '@openng/spectator/vitest'; + +describe('PropsAliasNames', () => { + describe('input decorators', () => { + @Component({ + selector: 'app-root', + template: ` +
{{ name }}
+
{{ numOfYears }}
+ `, + standalone: true, + }) + class DummyComponent { + @Input('userName') public name = ''; + @Input({ alias: 'age' }) public numOfYears = 0; + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should accept the property name of an @Input() with a rename alias', () => { + const spectator = createComponent({ props: { name: 'John' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + }); + + it('props should accept the property name of an @Input() with an alias option', () => { + const spectator = createComponent({ props: { numOfYears: 123 } }); + + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + + it('props should still accept the alias names', () => { + const spectator = createComponent({ props: { userName: 'John', age: 123 } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + + it('props should accept property names and alias names in the same object', () => { + const spectator = createComponent({ props: { name: 'John', age: 123 } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + + it('setInput should accept the property name of an aliased input', () => { + const spectator = createComponent(); + + spectator.setInput('name', 'John'); + spectator.setInput('numOfYears', 123); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + }); + + describe('signal inputs', () => { + @Component({ + selector: 'app-root', + template: ` +
{{ name() }}
+
{{ numOfYears() }}
+
{{ nickname() }}
+ `, + standalone: true, + }) + class DummyComponent { + public name = input.required({ alias: 'userName' }); + public numOfYears = input(0, { alias: 'age' }); + public nickname = model('', { alias: 'nick' }); + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should accept the property name of a required signal input with an alias', () => { + const spectator = createComponent({ props: { name: 'John' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + }); + + it('props should accept the property name of an optional signal input with an alias', () => { + const spectator = createComponent({ props: { name: 'John', numOfYears: 123 } }); + + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + }); + + it('props should accept the property name of a model with an alias', () => { + const spectator = createComponent({ props: { name: 'John', nickname: 'Johnny' } }); + + expect(spectator.query('[data-test="props--nickname"]')!.innerHTML).toBe('Johnny'); + }); + + it('props should still accept the alias names', () => { + const spectator = createComponent({ props: { userName: 'John', age: 123, nick: 'Johnny' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('123'); + expect(spectator.query('[data-test="props--nickname"]')!.innerHTML).toBe('Johnny'); + }); + }); + + describe('inputs without an alias', () => { + @Component({ + selector: 'app-root', + template: ` +
{{ title }}
+
{{ count() }}
+ `, + standalone: true, + }) + class DummyComponent { + @Input() public title = ''; + public count = input(0); + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should keep working for inputs that declare no alias', () => { + const spectator = createComponent({ props: { title: 'Hello', count: 7 } }); + + expect(spectator.query('[data-test="props--title"]')!.innerHTML).toBe('Hello'); + expect(spectator.query('[data-test="props--count"]')!.innerHTML).toBe('7'); + }); + + it('props should leave a key that matches no input alone', () => { + // Angular reports the unknown key itself (NG0303); the resolver must not + // silently redirect it onto some other input. + const spectator = createComponent({ props: { title: 'Hello', notAnInput: 'ignored' } }); + + expect(spectator.query('[data-test="props--title"]')!.innerHTML).toBe('Hello'); + expect(spectator.query('[data-test="props--count"]')!.innerHTML).toBe('0'); + }); + }); + + describe('a key that is both a public name and a property name', () => { + @Component({ + selector: 'app-root', + template: ` +
{{ numOfYears }}
+
{{ age }}
+ `, + standalone: true, + }) + class DummyComponent { + // 'age' is the public name of this input... + @Input({ alias: 'age' }) public numOfYears = 0; + // ...and the property name of this one. + @Input({ alias: 'collided' }) public age = 'untouched'; + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should resolve the key as a public name, not as a property name', () => { + const spectator = createComponent({ props: { age: 123 } }); + + expect(spectator.query('[data-test="props--years"]')!.innerHTML).toBe('123'); + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('untouched'); + }); + + it('props should still reach the shadowed input through its own public name', () => { + const spectator = createComponent({ props: { collided: 'reached' } }); + + expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('reached'); + }); + }); + + describe('createRoutingFactory', () => { + @Component({ + selector: 'app-root', + template: `
{{ name }}
`, + standalone: true, + }) + class DummyComponent { + @Input('userName') public name = ''; + } + + const createComponent = createRoutingFactory(DummyComponent); + + it('props should accept the property name of an aliased input', () => { + const spectator = createComponent({ props: { name: 'John' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + }); + + it('props should still accept the alias name', () => { + const spectator = createComponent({ props: { userName: 'John' } }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('John'); + }); + }); +}); From aca0c01069b3eb97fe165e0a903dff9068722c02 Mon Sep 17 00:00:00 2001 From: Nicolas Molina Monroy Date: Wed, 26 Aug 2026 10:29:38 -0400 Subject: [PATCH 2/7] fix(spectator): resolve input aliases in props and setInput `ComponentRef.setInput()` addresses inputs by their public name, so an input declaring an alias was only reachable through that alias. Both `props` and `setInput()` are typed against the class property names, so the keys the types advertise were exactly the keys that failed at runtime -- dropped with NG0303, silently for optional inputs and as a thrown NG0950 for `input.required({ alias })`. `setProps()` now maps every key through a resolver built from `reflectComponentType(type).inputs`, which covers decorator inputs, `input()`, `input.required()` and `model()`: - a public-name match wins, so existing behaviour is preserved exactly; - otherwise the property name is mapped to the input's public name; - an unknown key is passed through untouched, so Angular still reports it rather than it being silently redirected onto another input. Public-name precedence is load-bearing: a key can be the public name of one input and the property name of another, and `props: { age: 1 }` must keep resolving the way `setInput()` has always resolved it. `setHostProps()` is deliberately left alone -- it assigns onto the host component instance, where the class property name is already correct, so `createHostFactory`, `createDirectiveFactory` and `createPipeFactory` are unaffected. `createRoutingFactory` shares `setProps()` and is fixed by the same change. Refs #15 --- projects/spectator/src/lib/internals/query.ts | 9 ++++- .../src/lib/internals/resolve-input-name.ts | 38 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 projects/spectator/src/lib/internals/resolve-input-name.ts diff --git a/projects/spectator/src/lib/internals/query.ts b/projects/spectator/src/lib/internals/query.ts index 8470bde..531800e 100644 --- a/projects/spectator/src/lib/internals/query.ts +++ b/projects/spectator/src/lib/internals/query.ts @@ -5,6 +5,8 @@ import { DOMSelector } from '../dom-selectors'; import { isString, QueryOptions, QueryType } from '../types'; import { type Token } from '../token'; +import { inputNameResolver } from './resolve-input-name'; + export function getChildren(debugElementRoot: DebugElement): (directiveOrSelector: QueryType, options?: QueryOptions) => R[] { return (directiveOrSelector: QueryType, options: QueryOptions = { root: false, read: undefined }): R[] => { if (directiveOrSelector instanceof DOMSelector) { @@ -30,16 +32,19 @@ export function getChildren(debugElementRoot: DebugElement): (directiveOrSele /** * @internal * Set props on a component. This is used in `createComponent` and `createRoutingFactory` since we have direct access to the componentRef. + * Keys may be either the class property name or the input's public name, since `ComponentRef.setInput()` only accepts the latter. */ export function setProps(componentRef: ComponentRef, key: K, value: V): T & { [KEY in K]: V }; export function setProps(componentRef: ComponentRef, keyValues?: KV): T & KV; export function setProps(componentRef: ComponentRef, keyOrKeyValues: any, value?: any): any { + const resolveInputName = inputNameResolver(componentRef.componentType); + if (isString(keyOrKeyValues)) { - componentRef.setInput(keyOrKeyValues, value); + componentRef.setInput(resolveInputName(keyOrKeyValues), value); } else { // eslint-disable-next-line guard-for-in for (const p in keyOrKeyValues) { - componentRef.setInput(p, keyOrKeyValues[p]); + componentRef.setInput(resolveInputName(p), keyOrKeyValues[p]); } } diff --git a/projects/spectator/src/lib/internals/resolve-input-name.ts b/projects/spectator/src/lib/internals/resolve-input-name.ts new file mode 100644 index 0000000..0976bc0 --- /dev/null +++ b/projects/spectator/src/lib/internals/resolve-input-name.ts @@ -0,0 +1,38 @@ +import { reflectComponentType, Type } from '@angular/core'; + +/** + * @internal + * Build a resolver that maps a key used in a test onto the name Angular expects. + * + * `ComponentRef.setInput()` addresses inputs by their public name, so an input + * declaring an alias — `@Input('userName')`, `input({ alias: 'userName' })` — is + * only reachable through that alias. Tests, however, are typed against the class + * property names, so both spellings have to work. + */ +export function inputNameResolver(componentType: Type): (key: string) => string { + const inputs = reflectComponentType(componentType)?.inputs; + + if (!inputs?.length) { + return (key) => key; + } + + const publicNames = new Set(inputs.map((input) => input.templateName)); + const publicNameByPropName = new Map(inputs.map((input) => [input.propName, input.templateName])); + + return (key) => { + /** + * A public name always wins. A key can be the public name of one input and + * the property name of another, and resolving it as the public name is what + * `setInput()` has always done. + */ + if (publicNames.has(key)) { + return key; + } + + /** + * Unknown keys are left untouched so that Angular still reports them + * (NG0303) instead of them being silently redirected onto another input. + */ + return publicNameByPropName.get(key) ?? key; + }; +} From 297b00ab821f2d46761a2137f95f2e7d144c9c6b Mon Sep 17 00:00:00 2001 From: Nicolas Molina Monroy Date: Wed, 26 Aug 2026 10:31:51 -0400 Subject: [PATCH 3/7] feat(spectator): type props to accept input alias names `props` was typed `InferInputSignals`, keyed by the class property names, so addressing an input by its public name was a TS2353 even though it was the only spelling that worked at runtime before the previous commit. Adds `InferInputProps` and applies it to `SpectatorOverrides.props`, which `createRoutingFactory` shares. Aliases cannot be derived from the type system -- neither `@Input('userName')` nor `input({ alias: 'userName' })` carries the literal into the type -- so an index signature is the only way in. The form matters. Both simpler candidates were type-checked and rejected: - `InferInputSignals | Record` silently drops value-type checking, letting `props: { name: 123 }` compile on a string input. - `InferInputSignals & Record` breaks any `props` object typed through an `interface` or a class, since those get no implicit index signature. The union of the mapped type with its own intersection keeps both: value types are still checked for every known input, and `interface`, type-alias, inferred-const and `Record` sources all stay assignable. The one thing it can no longer reject is an unknown key in a `props` object literal, since TypeScript cannot tell an alias from a typo. Angular still reports that at runtime, which is why the resolver added in the previous commit passes unknown keys through untouched instead of swallowing them. `Spectator.setInput()` is left alone: its object overload already accepted arbitrary public names, so only its runtime behaviour needed fixing. Refs #15 --- .../src/lib/spectator-routing/create-factory.ts | 4 ++-- .../src/lib/spectator/create-factory.ts | 6 +++--- projects/spectator/src/lib/types.ts | 17 +++++++++++++++++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/projects/spectator/src/lib/spectator-routing/create-factory.ts b/projects/spectator/src/lib/spectator-routing/create-factory.ts index e993971..9f69c67 100644 --- a/projects/spectator/src/lib/spectator-routing/create-factory.ts +++ b/projects/spectator/src/lib/spectator-routing/create-factory.ts @@ -14,7 +14,7 @@ import { overridePipes, SpectatorOverrides, } from '../spectator/create-factory'; -import { InferInputSignals, isType } from '../types'; +import { InferInputProps, isType } from '../types'; import { SpyObject } from '../mock'; import { ActivatedRouteStub } from './activated-route-stub'; @@ -93,7 +93,7 @@ export function createRoutingFactory(typeOrOptions: Type | SpectatorRoutin }; } -function createSpectatorRouting(options: Required>, props?: InferInputSignals): SpectatorRouting { +function createSpectatorRouting(options: Required>, props?: InferInputProps): SpectatorRouting { const fixture = TestBed.createComponent(options.component, { bindings: options.bindings }); const debugElement = fixture.debugElement; diff --git a/projects/spectator/src/lib/spectator/create-factory.ts b/projects/spectator/src/lib/spectator/create-factory.ts index 2338eca..204510c 100644 --- a/projects/spectator/src/lib/spectator/create-factory.ts +++ b/projects/spectator/src/lib/spectator/create-factory.ts @@ -6,7 +6,7 @@ import { BaseSpectatorOptions, BaseSpectatorOverrides } from '../base/options'; import { addMatchers } from '../core'; import { setProps } from '../internals/query'; import * as customMatchers from '../matchers'; -import { InferInputSignals, isType } from '../types'; +import { InferInputProps, isType } from '../types'; import { initialSpectatorModule } from './initial-module'; import { getSpectatorDefaultOptions, SpectatorOptions } from './options'; @@ -22,7 +22,7 @@ export type SpectatorFactory = (options?: SpectatorOverrides) => Spectator */ export interface SpectatorOverrides extends BaseSpectatorOverrides { detectChanges?: boolean; - props?: InferInputSignals; + props?: InferInputProps; } /** @@ -185,7 +185,7 @@ export function createComponentFactory(typeOrOptions: Type | SpectatorOpti }; } -function createSpectator(options: Required>, props?: InferInputSignals): Spectator { +function createSpectator(options: Required>, props?: InferInputProps): Spectator { const fixture = TestBed.createComponent(options.component, { bindings: options.bindings }); const debugElement = fixture.debugElement; diff --git a/projects/spectator/src/lib/types.ts b/projects/spectator/src/lib/types.ts index 12a07fd..3dc3cdd 100644 --- a/projects/spectator/src/lib/types.ts +++ b/projects/spectator/src/lib/types.ts @@ -13,6 +13,23 @@ export type InferInputSignals = { [P in keyof C]+?: InferInputSignal; }; +/** + * Props accepted by `createComponent()` and `createRoutingFactory()`. + * + * Keys may be either the class property name or the input's public name, since + * an input declaring an alias is only reachable through that alias at runtime. + * Aliases cannot be derived from the type system -- neither `@Input('userName')` + * nor `input({ alias: 'userName' })` carries the literal into the type -- so the + * public names are admitted through an index signature. + * + * The union with the bare mapped type is what keeps that index signature from + * costing more than it has to: value types are still checked for every known + * input, and a `props` object typed through an `interface` or a class stays + * assignable. An unknown key is the one thing it can no longer reject, and + * Angular still reports that at runtime (NG0303). + */ +export type InferInputProps = InferInputSignals | (InferInputSignals & Record); + export type OptionalsRequired = Required> & Partial; export type AtLeastOneRequired = { From 0bee5a1bb9b69db654742403507f5503ded5c634 Mon Sep 17 00:00:00 2001 From: Nicolas Molina Monroy Date: Wed, 26 Aug 2026 10:33:16 -0400 Subject: [PATCH 4/7] test(spectator): assert props accepts alias names in the type tests The type tests compile against the built `d.ts` bundles, so these also prove `InferInputProps` actually ships for every entry point -- the failure mode of #8 and #11. Covers both spellings, and a `props` object typed through an interface, which is the guard against typing `props` as a plain intersection with `Record`. The negative control is the important one: it pins that value types are still checked for known inputs, which is what separates the shipped type from a plain union with `Record`. Signal inputs rather than decorators, since these tsconfigs are standalone and do not enable `experimentalDecorators`. Refs #15 --- type-tests/jasmine/consumer.ts | 30 ++++++++++++++++++++++++++++++ type-tests/jest/consumer.ts | 30 ++++++++++++++++++++++++++++++ type-tests/vitest/consumer.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) diff --git a/type-tests/jasmine/consumer.ts b/type-tests/jasmine/consumer.ts index 405d3ff..e7059ed 100644 --- a/type-tests/jasmine/consumer.ts +++ b/type-tests/jasmine/consumer.ts @@ -1,4 +1,5 @@ import { createComponentFactory, Spectator } from '@openng/spectator'; +import { input } from '@angular/core'; declare const element: HTMLElement; declare const option: HTMLOptionElement; @@ -47,6 +48,35 @@ describe('custom DOM matchers are wired into expect()', () => { }); }); +/** + * `props` accepts either the class property name or the input's public name, + * since an aliased input is only reachable through its alias at runtime (#15). + */ +class AliasedInputsComponent { + public name = input.required({ alias: 'userName' }); + public age = input(0, { alias: 'userAge' }); +} + +const createAliasedComponent = createComponentFactory(AliasedInputsComponent); + +createAliasedComponent({ props: { name: 'John', age: 30 } }); +createAliasedComponent({ props: { userName: 'John', userAge: 30 } }); +createAliasedComponent({ props: { name: 'John', userAge: 30 } }); + +// A `props` object typed through an interface has no index signature, so this is +// the guard against typing `props` as a plain intersection with `Record`. +interface AliasedProps { + name: string; +} +declare const aliasedProps: AliasedProps; +createAliasedComponent({ props: aliasedProps }); + +// Negative control: value types are still checked for known inputs. This is what +// distinguishes the shipped type from a plain union with `Record`, +// which would accept this silently. +// @ts-expect-error a string is not assignable to a number input +createAliasedComponent({ props: { name: 'John', age: 'thirty' } }); + // Reference the entry point exports so the import is used and the bundle resolves. export type Check = Spectator; export const factory = createComponentFactory; diff --git a/type-tests/jest/consumer.ts b/type-tests/jest/consumer.ts index a513ddb..4570595 100644 --- a/type-tests/jest/consumer.ts +++ b/type-tests/jest/consumer.ts @@ -1,4 +1,5 @@ import { createComponentFactory, Spectator } from '@openng/spectator/jest'; +import { input } from '@angular/core'; declare const element: HTMLElement; declare const option: HTMLOptionElement; @@ -47,6 +48,35 @@ describe('custom DOM matchers are wired into expect()', () => { }); }); +/** + * `props` accepts either the class property name or the input's public name, + * since an aliased input is only reachable through its alias at runtime (#15). + */ +class AliasedInputsComponent { + public name = input.required({ alias: 'userName' }); + public age = input(0, { alias: 'userAge' }); +} + +const createAliasedComponent = createComponentFactory(AliasedInputsComponent); + +createAliasedComponent({ props: { name: 'John', age: 30 } }); +createAliasedComponent({ props: { userName: 'John', userAge: 30 } }); +createAliasedComponent({ props: { name: 'John', userAge: 30 } }); + +// A `props` object typed through an interface has no index signature, so this is +// the guard against typing `props` as a plain intersection with `Record`. +interface AliasedProps { + name: string; +} +declare const aliasedProps: AliasedProps; +createAliasedComponent({ props: aliasedProps }); + +// Negative control: value types are still checked for known inputs. This is what +// distinguishes the shipped type from a plain union with `Record`, +// which would accept this silently. +// @ts-expect-error a string is not assignable to a number input +createAliasedComponent({ props: { name: 'John', age: 'thirty' } }); + // Reference the entry point exports so the import is used and the bundle resolves. export type Check = Spectator; export const factory = createComponentFactory; diff --git a/type-tests/vitest/consumer.ts b/type-tests/vitest/consumer.ts index 01b7173..e54d720 100644 --- a/type-tests/vitest/consumer.ts +++ b/type-tests/vitest/consumer.ts @@ -1,4 +1,5 @@ import { createComponentFactory, Spectator } from '@openng/spectator/vitest'; +import { input } from '@angular/core'; import { describe, expect, it } from 'vitest'; declare const element: HTMLElement; @@ -48,6 +49,35 @@ describe('custom DOM matchers are wired into expect()', () => { }); }); +/** + * `props` accepts either the class property name or the input's public name, + * since an aliased input is only reachable through its alias at runtime (#15). + */ +class AliasedInputsComponent { + public name = input.required({ alias: 'userName' }); + public age = input(0, { alias: 'userAge' }); +} + +const createAliasedComponent = createComponentFactory(AliasedInputsComponent); + +createAliasedComponent({ props: { name: 'John', age: 30 } }); +createAliasedComponent({ props: { userName: 'John', userAge: 30 } }); +createAliasedComponent({ props: { name: 'John', userAge: 30 } }); + +// A `props` object typed through an interface has no index signature, so this is +// the guard against typing `props` as a plain intersection with `Record`. +interface AliasedProps { + name: string; +} +declare const aliasedProps: AliasedProps; +createAliasedComponent({ props: aliasedProps }); + +// Negative control: value types are still checked for known inputs. This is what +// distinguishes the shipped type from a plain union with `Record`, +// which would accept this silently. +// @ts-expect-error a string is not assignable to a number input +createAliasedComponent({ props: { name: 'John', age: 'thirty' } }); + // Reference the entry point exports so the import is used and the bundle resolves. export type Check = Spectator; export const factory = createComponentFactory; From 0f32bce421aa87a03d1e98b851202bc6874c2a4b Mon Sep 17 00:00:00 2001 From: Nicolas Molina Monroy Date: Wed, 26 Aug 2026 10:33:48 -0400 Subject: [PATCH 5/7] docs: document alias support in props and setInput Refs #15 --- docs/src/content/docs/testing-components.md | 29 ++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/docs/src/content/docs/testing-components.md b/docs/src/content/docs/testing-components.md index 0a1d6d0..494976d 100644 --- a/docs/src/content/docs/testing-components.md +++ b/docs/src/content/docs/testing-components.md @@ -73,6 +73,29 @@ it('should...', () => { }); ``` +`props` keys may be either the class property name or the input's public name, so +an input that declares an alias can be set either way: + +```ts +@Component({ /* ... */ }) +class UserComponent { + @Input('userName') name = ''; + age = input.required({ alias: 'userAge' }); +} + +it('should...', () => { + // by class property name — type-checked and autocompleted + spectator = createComponent({ props: { name: 'John', age: 30 } }); + + // ...or by public name + spectator = createComponent({ props: { userName: 'John', userAge: 30 } }); +}); +``` + +Because an alias cannot be derived from the type system, `props` accepts unknown +string keys. A key that matches no input is reported by Angular at runtime +(`NG0303`) rather than at compile time. + By providing `overrideComponents` options in scope of our `createComponent()` function we can define the way of overriding standalone component and it's dependencies ```ts @Component({ @@ -196,7 +219,8 @@ spectator.detectChanges(); ``` ### `setInput()` -Changes the value of an `@Input()` of the tested component: +Changes the value of an `@Input()` of the tested component. As with `props`, an +input may be addressed by its class property name or by its public name: ```ts it('should...', () => { @@ -205,6 +229,9 @@ it('should...', () => { spectator.setInput({ className: 'danger' }); + + // an input declared as `@Input('cssClass') className` answers to both + spectator.setInput('cssClass', 'danger'); }); ``` ### `output()` From db110171eee58d89bbe0d59b3bf47a3bf2a75c26 Mon Sep 17 00:00:00 2001 From: Nicolas Molina Monroy Date: Wed, 26 Aug 2026 10:36:21 -0400 Subject: [PATCH 6/7] test(spectator): type-align the colliding-input fixture Karma type-checks the specs, and the collision fixture did not compile: `age` is the public name of the `numOfYears` input and also a property of its own, so the type describes the property while the resolver resolves the public name. That divergence is inherent -- the type cannot express a collided name -- so the fixture now gives both inputs the same value type. The test still pins that the key resolves as a public name, which is the behaviour that has to stay stable. Refs #15 --- projects/spectator/jest/test/props-alias-names.spec.ts | 10 ++++++---- projects/spectator/test/props-alias-names.spec.ts | 10 ++++++---- .../spectator/vitest/test/props-alias-names.spec.ts | 10 ++++++---- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/projects/spectator/jest/test/props-alias-names.spec.ts b/projects/spectator/jest/test/props-alias-names.spec.ts index 5c58685..8be6895 100644 --- a/projects/spectator/jest/test/props-alias-names.spec.ts +++ b/projects/spectator/jest/test/props-alias-names.spec.ts @@ -144,17 +144,19 @@ describe('PropsAliasNames', () => { }) class DummyComponent { // 'age' is the public name of this input... - @Input({ alias: 'age' }) public numOfYears = 0; - // ...and the property name of this one. + @Input({ alias: 'age' }) public numOfYears = 'no-years'; + // ...and the property name of this one. Both are strings on purpose: the + // type describes the property, so a collision is only expressible when the + // two inputs agree on their value type. @Input({ alias: 'collided' }) public age = 'untouched'; } const createComponent = createComponentFactory(DummyComponent); it('props should resolve the key as a public name, not as a property name', () => { - const spectator = createComponent({ props: { age: 123 } }); + const spectator = createComponent({ props: { age: 'set-via-public-name' } }); - expect(spectator.query('[data-test="props--years"]')!.innerHTML).toBe('123'); + expect(spectator.query('[data-test="props--years"]')!.innerHTML).toBe('set-via-public-name'); expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('untouched'); }); diff --git a/projects/spectator/test/props-alias-names.spec.ts b/projects/spectator/test/props-alias-names.spec.ts index 01b54b8..40eed96 100644 --- a/projects/spectator/test/props-alias-names.spec.ts +++ b/projects/spectator/test/props-alias-names.spec.ts @@ -144,17 +144,19 @@ describe('PropsAliasNames', () => { }) class DummyComponent { // 'age' is the public name of this input... - @Input({ alias: 'age' }) public numOfYears = 0; - // ...and the property name of this one. + @Input({ alias: 'age' }) public numOfYears = 'no-years'; + // ...and the property name of this one. Both are strings on purpose: the + // type describes the property, so a collision is only expressible when the + // two inputs agree on their value type. @Input({ alias: 'collided' }) public age = 'untouched'; } const createComponent = createComponentFactory(DummyComponent); it('props should resolve the key as a public name, not as a property name', () => { - const spectator = createComponent({ props: { age: 123 } }); + const spectator = createComponent({ props: { age: 'set-via-public-name' } }); - expect(spectator.query('[data-test="props--years"]')!.innerHTML).toBe('123'); + expect(spectator.query('[data-test="props--years"]')!.innerHTML).toBe('set-via-public-name'); expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('untouched'); }); diff --git a/projects/spectator/vitest/test/props-alias-names.spec.ts b/projects/spectator/vitest/test/props-alias-names.spec.ts index 693a26e..f323d47 100644 --- a/projects/spectator/vitest/test/props-alias-names.spec.ts +++ b/projects/spectator/vitest/test/props-alias-names.spec.ts @@ -144,17 +144,19 @@ describe('PropsAliasNames', () => { }) class DummyComponent { // 'age' is the public name of this input... - @Input({ alias: 'age' }) public numOfYears = 0; - // ...and the property name of this one. + @Input({ alias: 'age' }) public numOfYears = 'no-years'; + // ...and the property name of this one. Both are strings on purpose: the + // type describes the property, so a collision is only expressible when the + // two inputs agree on their value type. @Input({ alias: 'collided' }) public age = 'untouched'; } const createComponent = createComponentFactory(DummyComponent); it('props should resolve the key as a public name, not as a property name', () => { - const spectator = createComponent({ props: { age: 123 } }); + const spectator = createComponent({ props: { age: 'set-via-public-name' } }); - expect(spectator.query('[data-test="props--years"]')!.innerHTML).toBe('123'); + expect(spectator.query('[data-test="props--years"]')!.innerHTML).toBe('set-via-public-name'); expect(spectator.query('[data-test="props--age"]')!.innerHTML).toBe('untouched'); }); From 154c2ae7660b4d8f6677096b5a53fef954b3d5b5 Mon Sep 17 00:00:00 2001 From: Nicolas Molina Monroy Date: Wed, 26 Aug 2026 10:41:35 -0400 Subject: [PATCH 7/7] test(spectator): pin that absent props is a no-op Review raised the concern that `setProps()` would throw on `props: undefined`, since the non-string branch hands `keyOrKeyValues` straight to `for..in`. It does not -- `for..in` over `null` or `undefined` is a spec-mandated no-op, not a throw -- so no guard is added. These tests pin it instead, across the three reachable shapes: explicit `props: undefined`, no overrides at all, and `setInput()` given nothing. Refs #15 --- .../jest/test/props-alias-names.spec.ts | 33 +++++++++++++++++++ .../spectator/test/props-alias-names.spec.ts | 33 +++++++++++++++++++ .../vitest/test/props-alias-names.spec.ts | 33 +++++++++++++++++++ 3 files changed, 99 insertions(+) diff --git a/projects/spectator/jest/test/props-alias-names.spec.ts b/projects/spectator/jest/test/props-alias-names.spec.ts index 8be6895..e7cb1a8 100644 --- a/projects/spectator/jest/test/props-alias-names.spec.ts +++ b/projects/spectator/jest/test/props-alias-names.spec.ts @@ -167,6 +167,39 @@ describe('PropsAliasNames', () => { }); }); + describe('no props', () => { + @Component({ + selector: 'app-root', + template: `
{{ name }}
`, + standalone: true, + }) + class DummyComponent { + @Input('userName') public name = 'default'; + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should be a no-op when it is explicitly undefined', () => { + const spectator = createComponent({ props: undefined }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('default'); + }); + + it('props should be a no-op when no overrides are passed at all', () => { + const spectator = createComponent(); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('default'); + }); + + it('setInput should be a no-op when it is given nothing', () => { + const spectator = createComponent(); + + spectator.setInput(undefined as unknown as Record); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('default'); + }); + }); + describe('createRoutingFactory', () => { @Component({ selector: 'app-root', diff --git a/projects/spectator/test/props-alias-names.spec.ts b/projects/spectator/test/props-alias-names.spec.ts index 40eed96..b1c7b4d 100644 --- a/projects/spectator/test/props-alias-names.spec.ts +++ b/projects/spectator/test/props-alias-names.spec.ts @@ -167,6 +167,39 @@ describe('PropsAliasNames', () => { }); }); + describe('no props', () => { + @Component({ + selector: 'app-root', + template: `
{{ name }}
`, + standalone: true, + }) + class DummyComponent { + @Input('userName') public name = 'default'; + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should be a no-op when it is explicitly undefined', () => { + const spectator = createComponent({ props: undefined }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('default'); + }); + + it('props should be a no-op when no overrides are passed at all', () => { + const spectator = createComponent(); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('default'); + }); + + it('setInput should be a no-op when it is given nothing', () => { + const spectator = createComponent(); + + spectator.setInput(undefined as unknown as Record); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('default'); + }); + }); + describe('createRoutingFactory', () => { @Component({ selector: 'app-root', diff --git a/projects/spectator/vitest/test/props-alias-names.spec.ts b/projects/spectator/vitest/test/props-alias-names.spec.ts index f323d47..e9e9562 100644 --- a/projects/spectator/vitest/test/props-alias-names.spec.ts +++ b/projects/spectator/vitest/test/props-alias-names.spec.ts @@ -167,6 +167,39 @@ describe('PropsAliasNames', () => { }); }); + describe('no props', () => { + @Component({ + selector: 'app-root', + template: `
{{ name }}
`, + standalone: true, + }) + class DummyComponent { + @Input('userName') public name = 'default'; + } + + const createComponent = createComponentFactory(DummyComponent); + + it('props should be a no-op when it is explicitly undefined', () => { + const spectator = createComponent({ props: undefined }); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('default'); + }); + + it('props should be a no-op when no overrides are passed at all', () => { + const spectator = createComponent(); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('default'); + }); + + it('setInput should be a no-op when it is given nothing', () => { + const spectator = createComponent(); + + spectator.setInput(undefined as unknown as Record); + + expect(spectator.query('[data-test="props--name"]')!.innerHTML).toBe('default'); + }); + }); + describe('createRoutingFactory', () => { @Component({ selector: 'app-root',