diff --git a/.releaserc.yml b/.releaserc.yml index 5368ecf633..ec3778ef6b 100644 --- a/.releaserc.yml +++ b/.releaserc.yml @@ -9,11 +9,7 @@ plugins: scope: README release: patch - - '@semantic-release/exec' - - prepareCmd: | - sh compose.sh root - docker compose run --rm ng-mocks npm run lint - docker compose run --rm ng-mocks npm run ts:check - docker compose run --rm ng-mocks npm run build + - prepareCmd: npx npm install --force && npx npm run lint && npx npm run ts:check && npx npm run build - '@semantic-release/release-notes-generator' - - '@semantic-release/changelog' - changelogFile: CHANGELOG.md diff --git a/AGENTS.md b/AGENTS.md index 09cf824cbd..00901cc343 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,6 @@ - `docker compose run --rm ng-mocks npm run prettier:check` - `docker compose run --rm ng-mocks npm run lint` - `docker compose run --rm ng-mocks npm run ts:check` -- If multiple worktrees are active, prefix direct `docker compose` commands with the same `COMPOSE_PROJECT_NAME` you use for wrappers so the checks stay inside that worktree's compose project. - Run Prettier before `git commit`. ## Lockfiles and Dependency Refresh diff --git a/CHANGELOG.md b/CHANGELOG.md index 501b958d27..cf169088ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,3 @@ -## [14.15.2](https://github.com/help-me-mom/ng-mocks/compare/v14.15.1...v14.15.2) (2026-03-15) - - -### Bug Fixes - -* **a20:** adding support of angular 20 ([#13127](https://github.com/help-me-mom/ng-mocks/issues/13127)) ([340585e](https://github.com/help-me-mom/ng-mocks/commit/340585eab40e7d76a3f942c8e91eb9ff2d5887f3)) -* **a21:** official Angular 21 support ([#13295](https://github.com/help-me-mom/ng-mocks/issues/13295)) ([1885657](https://github.com/help-me-mom/ng-mocks/commit/1885657cf00fe0f476af2a4e37c09e7bebc7198d)) -* **a22:** official Angular 22 support ([#13298](https://github.com/help-me-mom/ng-mocks/issues/13298)) ([1d0d039](https://github.com/help-me-mom/ng-mocks/commit/1d0d039b16c19c645161dd46650c53f737b38576)) -* allow MockBuilder.replace on components with injectable bases [#8201](https://github.com/help-me-mom/ng-mocks/issues/8201) ([0c99c20](https://github.com/help-me-mom/ng-mocks/commit/0c99c206ec0b01bcfe668339e96074ad46acbd03)) -* avoid MockInstance false no-deprecated warnings [#10217](https://github.com/help-me-mom/ng-mocks/issues/10217) ([#13306](https://github.com/help-me-mom/ng-mocks/issues/13306)) ([dd917e0](https://github.com/help-me-mom/ng-mocks/commit/dd917e0ee2124290b0cabb19506d9e31f6dcb14b)) -* keep useExisting providers for kept standalone CVAs [#10960](https://github.com/help-me-mom/ng-mocks/issues/10960) ([#13305](https://github.com/help-me-mom/ng-mocks/issues/13305)) ([db1f4c8](https://github.com/help-me-mom/ng-mocks/commit/db1f4c8b3afddaec8d2dee0652fe77e340aa964c)) - ## [14.15.1](https://github.com/help-me-mom/ng-mocks/compare/v14.15.0...v14.15.1) (2026-02-04) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0811cc6ba5..d2435ca88c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,14 +41,6 @@ and click on the "Edit this page" link at the bottom of the page. To develop `ng-mocks` you need to use `bash` and `WSL` in case if you are on Windows. -### Signed commits for pull requests - -Pull requests need signed commits. Unsigned commits can be blocked by the repository settings, -so please configure commit signing before you open or update a PR. - -- GitHub docs: https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits -- Any GitHub-supported signing method is fine as long as GitHub marks the commit as `Verified` - ### How to install dependencies - start `docker` and ensure it's running @@ -65,13 +57,10 @@ so please configure commit signing before you open or update a PR. To avoid collisions when multiple worktrees run docker compose in parallel, set `COMPOSE_PROJECT_NAME`. Use your own unique string for each task/worktree. -Reuse the same value for every `docker compose`, `sh ./compose.sh`, and `sh ./test.sh` command you run in that worktree. -With a unique project name, Compose keeps the worktree resources separate, including the default network and the named `cache`, `gyp`, and `npm` volumes. ```shell COMPOSE_PROJECT_NAME=ngmocks_ sh ./compose.sh e2e COMPOSE_PROJECT_NAME=ngmocks_ sh ./test.sh e2e -COMPOSE_PROJECT_NAME=ngmocks_ docker compose run --rm ng-mocks npm run lint ``` ## How to run unit tests locally diff --git a/libs/ng-mocks/src/lib/mock-render/mock-render-factory.ts b/libs/ng-mocks/src/lib/mock-render/mock-render-factory.ts index 9a5b75a620..77f9c893ea 100644 --- a/libs/ng-mocks/src/lib/mock-render/mock-render-factory.ts +++ b/libs/ng-mocks/src/lib/mock-render/mock-render-factory.ts @@ -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 => { + const tokens = new Set(); + + 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); } else { renderInjection(fixture, template, params); } diff --git a/tests/issue-9397/test.spec.ts b/tests/issue-9397/test.spec.ts new file mode 100644 index 0000000000..9fc5b1e921 --- /dev/null +++ b/tests/issue-9397/test.spec.ts @@ -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, + ); + }); + }); +});