-
-
Notifications
You must be signed in to change notification settings - Fork 120
fix: auto-spy standalone inject() services (#9397) #13310
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,14 +3,20 @@ import { getTestBed, ModuleTeardownOptions, TestBed, TestModuleMetadata } from ' | |
|
|
||
| import coreDefineProperty from '../common/core.define-property'; | ||
| import { getInjection } from '../common/core.helpers'; | ||
| import coreReflectParametersResolve from '../common/core.reflect.parameters-resolve'; | ||
| import coreReflectProvidedIn from '../common/core.reflect.provided-in'; | ||
| import { AnyDeclaration, AnyType, Type } from '../common/core.types'; | ||
| import funcGetName from '../common/func.get-name'; | ||
| import funcImportExists from '../common/func.import-exists'; | ||
| import { isNgDef } from '../common/func.is-ng-def'; | ||
| import { isStandalone } from '../common/func.is-standalone'; | ||
| import ngMocksStack from '../common/ng-mocks-stack'; | ||
| import ngMocksUniverse from '../common/ng-mocks-universe'; | ||
| import extractDep from '../mock-builder/promise/extract-dep'; | ||
| import { ngMocks } from '../mock-helper/mock-helper'; | ||
| import helperDefinePropertyDescriptor from '../mock-service/helper.define-property-descriptor'; | ||
| import helperExtractMethodsFromPrototype from '../mock-service/helper.extract-methods-from-prototype'; | ||
| import helperMockService from '../mock-service/helper.mock-service'; | ||
| import { MockService } from '../mock-service/mock-service'; | ||
|
|
||
| import funcCreateWrapper from './func.create-wrapper'; | ||
|
|
@@ -72,6 +78,53 @@ const renderInjection = (fixture: any, template: any, params: any): void => { | |
| funcInstallPropReader(fixture.componentInstance, fixture.point.componentInstance, [], true); | ||
| }; | ||
|
|
||
| const extractCtorTokens = (template: any): Set<any> => { | ||
| const tokens = new Set<any>(); | ||
|
|
||
| for (const decorators of coreReflectParametersResolve(template)) { | ||
| tokens.add(extractDep(decorators)); | ||
| } | ||
| tokens.delete(undefined); | ||
|
|
||
| return tokens; | ||
| }; | ||
|
|
||
| const autoSpyStandaloneInjectProperties = (template: any, instance: any): void => { | ||
| if ( | ||
| !instance || | ||
| !isNgDef(template, 'c') || | ||
| !isStandalone(template) || | ||
| !helperMockService.mockFunction.customMockFunction | ||
| ) { | ||
| return; | ||
| } | ||
|
|
||
| const ctorTokens = extractCtorTokens(template); | ||
|
|
||
| for (const key of Object.keys(instance)) { | ||
| const value = instance[key]; | ||
| const provide = value ? value.constructor : undefined; | ||
|
|
||
| if (!value || typeof value !== 'object' || !provide || ctorTokens.has(provide) || !coreReflectProvidedIn(provide)) { | ||
| continue; | ||
| } | ||
|
|
||
| let injected: any; | ||
| try { | ||
| injected = getInjection(provide); | ||
| } catch { | ||
| continue; | ||
| } | ||
| if (injected !== value) { | ||
| continue; | ||
| } | ||
|
|
||
| for (const method of helperExtractMethodsFromPrototype(provide.prototype)) { | ||
| helperMockService.mock(value, method); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| const tryWhen = (flag: boolean, callback: () => void) => { | ||
| if (!flag) { | ||
| return; | ||
|
|
@@ -179,6 +232,7 @@ const generateFactory = ( | |
| (componentCtor.tpl && isNgDef(template, 'p')) | ||
| ) { | ||
| renderDeclaration(fixture, template, params); | ||
| autoSpyStandaloneInjectProperties(template, fixture.point.componentInstance); | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } else { | ||
| renderInjection(fixture, template, params); | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| import { | ||
| Component, | ||
| forwardRef, | ||
| Inject, | ||
| Injectable, | ||
| VERSION, | ||
| } from '@angular/core'; | ||
| import * as ngCore from '@angular/core'; | ||
| import { TestBed } from '@angular/core/testing'; | ||
|
|
||
| import { MockBuilder, MockRender, ngMocks } from 'ng-mocks'; | ||
|
|
||
| // @see https://github.com/help-me-mom/ng-mocks/issues/9397 | ||
| describe('issue-9397', () => { | ||
| if (Number.parseInt(VERSION.major, 10) < 14) { | ||
| it('needs a14+', () => { | ||
| expect(true).toBeTruthy(); | ||
| }); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| beforeAll(() => | ||
| ngMocks.autoSpy(typeof jest === 'undefined' ? 'jasmine' : 'jest'), | ||
| ); | ||
| afterAll(() => ngMocks.autoSpy('reset')); | ||
|
|
||
| @Injectable({ | ||
| providedIn: 'root', | ||
| }) | ||
| class TodoListService { | ||
| public someMethod(): void {} | ||
| } | ||
|
|
||
| @Component({ | ||
| selector: 'target-component', | ||
| template: '', | ||
| ['standalone' as never /* TODO: remove after upgrade to a14 */]: true, | ||
| }) | ||
| class TargetComponent { | ||
| public readonly todoListService = (ngCore as any).inject( | ||
| TodoListService, | ||
| ); | ||
|
|
||
| public someMethod(): void { | ||
| this.todoListService.someMethod(); | ||
| } | ||
| } | ||
|
|
||
| @Component({ | ||
| selector: 'ctor-target-component', | ||
| template: '', | ||
| ['standalone' as never /* TODO: remove after upgrade to a14 */]: true, | ||
| }) | ||
| class CtorTargetComponent { | ||
| public constructor( | ||
| @Inject(forwardRef(() => TodoListService)) | ||
| public readonly todoListService: TodoListService, | ||
| ) {} | ||
|
|
||
| public someMethod(): void { | ||
| this.todoListService.someMethod(); | ||
| } | ||
| } | ||
|
|
||
| @Component({ | ||
| selector: 'plain-ctor-target-component', | ||
| template: '', | ||
| ['standalone' as never /* TODO: remove after upgrade to a14 */]: true, | ||
| }) | ||
| class PlainCtorTargetComponent { | ||
| public constructor( | ||
| public readonly todoListService: TodoListService, | ||
| ) {} | ||
|
|
||
| public someMethod(): void { | ||
| this.todoListService.someMethod(); | ||
| } | ||
| } | ||
|
|
||
| class SyntheticProvidedShapeService { | ||
| public someMethod(): void {} | ||
| } | ||
|
|
||
| (SyntheticProvidedShapeService as any)['ɵprov'] = { | ||
| providedIn: 'root', | ||
| }; | ||
|
|
||
| @Component({ | ||
| selector: 'detached-target-component', | ||
| template: '', | ||
| ['standalone' as never /* TODO: remove after upgrade to a14 */]: true, | ||
| }) | ||
| class DetachedTargetComponent { | ||
| public readonly detached = new TodoListService(); | ||
|
|
||
| public readonly empty = null; | ||
|
|
||
| public readonly synthetic = new SyntheticProvidedShapeService(); | ||
| } | ||
|
|
||
| beforeEach(() => MockBuilder(TargetComponent)); | ||
|
|
||
| it('auto-spies services injected via inject()', () => { | ||
| const fixture = MockRender(TargetComponent); | ||
| const component = fixture.point.componentInstance; | ||
| const todoListService = TestBed.inject(TodoListService); | ||
|
|
||
| component.someMethod(); | ||
|
|
||
| expect(todoListService.someMethod).toHaveBeenCalledTimes(1); | ||
| expect(component.todoListService.someMethod).toBe( | ||
| todoListService.someMethod, | ||
| ); | ||
| }); | ||
|
|
||
| describe('control', () => { | ||
| beforeEach(() => MockBuilder(CtorTargetComponent)); | ||
|
|
||
| it('auto-spies services injected via constructor', () => { | ||
| const fixture = MockRender(CtorTargetComponent); | ||
| const component = fixture.point.componentInstance; | ||
| const todoListService = TestBed.inject(TodoListService); | ||
|
|
||
| component.someMethod(); | ||
|
|
||
| expect(todoListService.someMethod).toHaveBeenCalledTimes(1); | ||
| expect(component.todoListService.someMethod).toBe( | ||
| todoListService.someMethod, | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe('plain ctor control', () => { | ||
| beforeEach(() => MockBuilder(PlainCtorTargetComponent)); | ||
|
|
||
| it('keeps constructor-based auto-spy for plain parameters', () => { | ||
| const fixture = MockRender(PlainCtorTargetComponent); | ||
| const component = fixture.point.componentInstance; | ||
| const todoListService = TestBed.inject(TodoListService); | ||
|
|
||
| component.someMethod(); | ||
|
|
||
| expect(todoListService.someMethod).toHaveBeenCalledTimes(1); | ||
| expect(component.todoListService.someMethod).toBe( | ||
| todoListService.someMethod, | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe('guards', () => { | ||
| beforeEach(() => MockBuilder(DetachedTargetComponent)); | ||
|
|
||
| it('skips standalone properties which are not the injected singleton', () => { | ||
| const fixture = MockRender(DetachedTargetComponent); | ||
| const component = fixture.point.componentInstance; | ||
|
|
||
| expect(component.detached.someMethod).toBe( | ||
| TodoListService.prototype.someMethod, | ||
| ); | ||
| expect(component.synthetic.someMethod).toBe( | ||
| SyntheticProvidedShapeService.prototype.someMethod, | ||
| ); | ||
| }); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This helper is gated to components only via
isNgDef(template, 'c'), but this render path also serves standalone directives, and the pipe wrapper case reaches the same area too. A standalone directive or pipe that usesinject()still appears to bypass auto-spy support, so either the guard needs widening or the narrower scope should be documented and covered explicitly.