Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions libs/ng-mocks/src/lib/common/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { AnyType, DirectiveIo } from './core.types';
import funcDirectiveIoParse from './func.directive-io-parse';
import funcIsMock from './func.is-mock';
import { MockControlValueAccessorProxy } from './mock-control-value-accessor-proxy';
import { resolveMockDeclaration } from './ng-mocks-injected-declarations';
import ngMocksUniverse from './ng-mocks-universe';

const setValueAccessor = (instance: any, ngControl?: any) => {
Expand Down Expand Up @@ -180,6 +181,9 @@ export class Mock {
Object.setPrototypeOf(this, mockOf.prototype);

applyOverrides(this, mockOf, injector ?? undefined);
// Declaration providers are created lazily by Angular during render. Register the fully prepared
// mock instance here so a previous TestBed.inject seed can replay its overrides onto it.
resolveMockDeclaration(mockOf, this);
}
}

Expand Down
68 changes: 68 additions & 0 deletions libs/ng-mocks/src/lib/common/ng-mocks-global-overrides.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { TestBed } from '@angular/core/testing';

import {
patchDebugInjectors,
installInjector,
} from './ng-mocks-global-overrides';

describe('ng-mocks-global-overrides', () => {
afterEach(() => {
delete (TestBed as any).get;
(TestBed as any).ngMocksGetInstalled = undefined;
TestBed.resetTestingModule();
});

it('patches legacy TestBed.get when present', () => {
// The declaration replay feature wraps both TestBed.inject and the legacy TestBed.get entrypoint.
const get = jasmine.createSpy().and.returnValue('value');
(TestBed as any).get = get;
(TestBed as any).ngMocksGetInstalled = undefined;

TestBed.configureTestingModule({});

expect((TestBed as any).ngMocksGetInstalled).toBe(true);
expect((TestBed as any).get('token')).toBe('value');
expect(get).toHaveBeenCalledWith('token');
});

it('patches instance-based injectors and tolerates empty nodes', () => {
// Declaration-local instances are discovered through injector.get, so patched debug injectors
// must tolerate incomplete trees and still wrap detached injector instances.
const get = jasmine.createSpy().and.returnValue(undefined);
const injector: any = {
constructor: {
name: 'LocalInjector',
prototype: {},
},
get,
};

expect(() => patchDebugInjectors(undefined)).not.toThrow();
expect(installInjector(injector)).toBe(injector);
expect(injector.__ngMocksInjector).toBe(true);
expect(() => patchDebugInjectors({ injector })).not.toThrow();

injector.get('token');

expect(get).toHaveBeenCalledWith('token');
});

it('patches instance-based injectors without a prototype getter', () => {
// Some injector implementations expose get directly on the instance, so the wrapper must handle
// both prototype-based and detached forms.
const get = jasmine.createSpy().and.returnValue(undefined);
const injector: any = {
constructor: {
name: 'DetachedInjector',
},
get,
};

expect(installInjector(injector)).toBe(injector);
expect(injector.__ngMocksInjector).toBe(true);

injector.get('token');

expect(get).toHaveBeenCalledWith('token');
});
});
144 changes: 112 additions & 32 deletions libs/ng-mocks/src/lib/common/ng-mocks-global-overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,50 @@ import funcGetType from './func.get-type';
import { isMockNgDef } from './func.is-mock-ng-def';
import { isNgDef } from './func.is-ng-def';
import { isNgModuleDefWithProviders } from './func.is-ng-module-def-with-providers';
import {
rememberInjectedDeclaration,
rememberMockDeclarations,
resetInjectedDeclarations,
resolveInjectedDeclaration,
} from './ng-mocks-injected-declarations';
import ngMocksUniverse from './ng-mocks-universe';
type NgMocksTestBed = TestBedStatic & {
get?: (token: any, ...args: any[]) => any;
inject?: (token: any, ...args: any[]) => any;
ngMocksGetInstalled?: boolean;
ngMocksInjectedDeclarationsLock?: boolean;
ngMocksInjectInstalled?: boolean;
};
const getNgMocksTestBed = (): NgMocksTestBed => TestBed as never;
const createTestBedInjection = (original: (token: any, ...args: any[]) => any, instance: NgMocksTestBed) =>
helperCreateClone(original, undefined, undefined, (token: any, ...args: any[]) => {
// Injector.get can synchronously call back into TestBed.get / inject while Angular is creating
// the declaration-local mock instance, so the lock protects the explicit pre-render seed.
if (getNgMocksTestBed().ngMocksInjectedDeclarationsLock) {
return original.call(instance, token, ...args);
}

let result;
try {
coreDefineProperty(TestBed, 'ngMocksInjectedDeclarationsLock', true);
result = original.call(instance, token, ...args);
} finally {
coreDefineProperty(TestBed, 'ngMocksInjectedDeclarationsLock', undefined);
}
// Tests often mutate the object returned by TestBed.inject before Angular constructs the
// declaration instance used inside the render tree, so remember that seed for later replay.
return rememberInjectedDeclaration(token, result);
});
const installTestBedInjection = (instance: NgMocksTestBed): void => {
if (instance.inject && !instance.ngMocksInjectInstalled) {
coreDefineProperty(instance, 'inject', createTestBedInjection(instance.inject, instance), true);
coreDefineProperty(instance, 'ngMocksInjectInstalled', true);
}
if (instance.get && !instance.ngMocksGetInstalled) {
coreDefineProperty(instance, 'get', createTestBedInjection(instance.get, instance), true);
coreDefineProperty(instance, 'ngMocksGetInstalled', true);
}
};
const applyOverride = (def: any, override: any) => {
if (isNgDef(def, 'c')) {
TestBed.overrideComponent(def, override);
Expand Down Expand Up @@ -62,6 +104,8 @@ const applyNgMocksOverrides = (testBed: TestBedStatic & { ngMocksOverrides?: Map
};

const initTestBed = () => {
installTestBedInjection(TestBed as never);
installTestBedInjection(getTestBed() as never);
if (!(TestBed as any).ngMocksSelectors) {
coreDefineProperty(TestBed, 'ngMocksSelectors', new Map());
}
Expand Down Expand Up @@ -118,6 +162,21 @@ const defineTouches = (testBed: TestBed, moduleDef: TestModuleMetadata, knownTou
return touches;
};

const collectMockDeclarations = (moduleDef: TestModuleMetadata, mocks?: Map<any, any>): Map<any, any> | undefined => {
const result = new Map(mocks);

for (const key of ['imports', 'declarations'] as const) {
for (const declaration of flatten(moduleDef[key] || [])) {
const def = funcGetType(declaration);
if (isMockNgDef(def, 'c') || isMockNgDef(def, 'd') || isMockNgDef(def, 'p')) {
result.set(getSourceOfMock(def), def);
}
}
}

return result.size > 0 ? result : undefined;
};

const applyPlatformOverrideDef = (def: any) => {
const ngModule = funcGetType(def);
if ((TestBed as any).ngMocksOverrides.has(ngModule)) {
Expand Down Expand Up @@ -243,14 +302,17 @@ const configureTestingModule =

const providers = funcExtractTokens(finalModuleDef.providers);
const { mocks, overrides } = providers;
// touches are important,
// therefore we are trying to fetch them from the known providers.
// touches are important, therefore we are trying to fetch them from the known providers.
const touches = defineTouches(testBed, finalModuleDef, providers.touches);

if (mocks) {
ngMocks.flushTestBed();
}

// Track mocked source declarations so TestBed.inject / get only replays overrides for mocked
// components, directives, and pipes in the current TestBed.
rememberMockDeclarations(collectMockDeclarations(finalModuleDef, mocks));

// istanbul ignore else
if (overrides) {
applyOverrides(overrides);
Expand All @@ -270,6 +332,7 @@ const resetTestingModule =
ngMocksUniverse.global.delete('builder:config');
ngMocksUniverse.global.delete('builder:module');
(TestBed as any).ngMocksSelectors = undefined;
resetInjectedDeclarations();
applyNgMocksOverrides(TestBed);

return original.call(instance);
Expand Down Expand Up @@ -303,10 +366,27 @@ const patchVcrInstance = (vcrInstance: ViewContainerRef) => {
}
};

export const patchDebugInjectors = (node: any): void => {
if (!node) {
return;
}

try {
installInjector(node.injector);
} catch {
// nothing to do.
}

for (const child of node.children || []) {
patchDebugInjectors(child);
}
};

const createComponent =
(original: TestBedStatic['createComponent'], instance: TestBedStatic): TestBedStatic['createComponent'] =>
(...args) => {
const fixture = original.call(instance, ...args);
patchDebugInjectors(fixture.debugElement);
try {
const vcr = fixture.debugElement.injector.get(ViewContainerRef);
patchVcrInstance(vcr);
Expand Down Expand Up @@ -345,44 +425,43 @@ const viewContainerInstall = () => {
};

// this function monkey-patches Angular injectors.
const installInjector = (injector: Injector & { __ngMocksInjector?: any }): Injector => {
export const installInjector = (injector: Injector & { __ngMocksInjector?: any }): Injector => {
const target = injector.constructor.prototype?.get ? injector.constructor.prototype : injector;

// skipping the matched injector
if (injector.constructor.prototype.__ngMocksInjector || !injector.constructor.prototype.get) {
if (target.__ngMocksInjector || !target.get) {
return injector;
}

// marking the injector as patched
coreDefineProperty(injector.constructor.prototype, '__ngMocksInjector', true);
const injectorGet = injector.constructor.prototype.get;
coreDefineProperty(target, '__ngMocksInjector', true);
const injectorGet = target.get;

// patch
injector.constructor.prototype.get = helperCreateClone(
injectorGet,
undefined,
undefined,
function (token: any, ...argsGet: any) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const binding: any = this;

// Here we can implement custom logic how to inject token,
// for example, replace with a provider def we need.

const result = injectorGet.call(binding, token, ...argsGet);
// If the result is an injector, we should patch it too.
if (
result &&
typeof result === 'object' &&
typeof result.constructor === 'function' &&
typeof result.constructor.name === 'string' &&
result.constructor.name.slice(-8) === 'Injector'
) {
installInjector(result);
}
target.get = helperCreateClone(injectorGet, undefined, undefined, function (token: any, ...argsGet: any) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const binding: any = this;

// Here we can implement custom logic how to inject token,
// for example, replace with a provider def we need.

// Angular can resolve a declaration token to a local instance that differs from the earlier
// TestBed.inject result, so replay the seeded descriptors onto that local instance.
const result = resolveInjectedDeclaration(token, injectorGet.call(binding, token, ...argsGet));
// If the result is an injector, we should patch it too.
if (
result &&
typeof result === 'object' &&
typeof result.constructor === 'function' &&
typeof result.constructor.name === 'string' &&
result.constructor.name.slice(-8) === 'Injector'
) {
installInjector(result);
}

return result;
},
);
return result;
});

return injector;
};
Expand All @@ -392,6 +471,7 @@ const install = () => {
if (!(TestBed as any).ngMocksOverridesInstalled) {
const hooks = mockHelperFasterInstall();
viewContainerInstall();
initTestBed();

// istanbul ignore else
if (hooks.before.indexOf(configureTestingModule) === -1) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Directive } from '@angular/core';
import { TestBed } from '@angular/core/testing';

import {
rememberInjectedDeclaration,
rememberMockDeclarations,
resetInjectedDeclarations,
resolveInjectedDeclaration,
} from './ng-mocks-injected-declarations';
import ngMocksUniverse from './ng-mocks-universe';

@Directive({
selector: '[target]',
['standalone' as never /* TODO: remove after upgrade to a14 */]: false,
})
class TargetDirective {}

class MockTargetDirective {}
(MockTargetDirective as any).mockOf = TargetDirective;

describe('ng-mocks-injected-declarations', () => {
afterEach(() => {
resetInjectedDeclarations();
ngMocksUniverse.builtDeclarations.delete(TargetDirective);
ngMocksUniverse.cacheDeclarations.delete(TargetDirective);
});

it('reuses a seed before a local declaration has been resolved', () => {
// Explicit TestBed.inject should stay stable until Angular resolves a declaration-local instance.
rememberMockDeclarations(
new Map([[TargetDirective, MockTargetDirective]]),
);
const seed = {
echo: jasmine.createSpy(),
};

expect(rememberInjectedDeclaration(TargetDirective, seed)).toBe(
seed,
);
expect(rememberInjectedDeclaration(TargetDirective, {})).toBe(
seed,
);
});

it('tracks declaration mocks from built declarations', () => {
// Once Angular resolves the local declaration instance, the seeded descriptors should be replayed
// onto it and the local instance becomes the canonical result.
ngMocksUniverse.builtDeclarations.set(
TargetDirective,
MockTargetDirective,
);
const seed = {
echo: jasmine.createSpy(),
};
const local: any = {};

expect(rememberInjectedDeclaration(TargetDirective, seed)).toBe(
seed,
);
expect(resolveInjectedDeclaration(TargetDirective, local)).toBe(
local,
);
expect(local.echo).toBe(seed.echo);
});

it('ignores mocked declarations whose source is not a class', () => {
// Broken metadata should never create tracking state for arbitrary non-declaration tokens.
const token: any = () => undefined;
token.mockOf = 'broken';

expect(rememberInjectedDeclaration(token, {})).toEqual({});
expect(
(TestBed as any).ngMocksInjectedDeclarations,
).toBeUndefined();
});
});
Loading