Skip to content

Commit

Permalink
feat(MockBuilder): mocks root providers via inject function help-me-m…
Browse files Browse the repository at this point in the history
  • Loading branch information
satanTime committed Nov 20, 2022
1 parent 04dba81 commit 3ba3166
Show file tree
Hide file tree
Showing 6 changed files with 153 additions and 4 deletions.
2 changes: 2 additions & 0 deletions .eslintrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,14 @@ overrides:

'@typescript-eslint/no-explicit-any': off
'@typescript-eslint/no-namespace': off
'@typescript-eslint/no-this-alias': off
'@typescript-eslint/no-unused-vars': error

unicorn/no-array-callback-reference: off
unicorn/no-array-method-this-argument: off
unicorn/no-for-loop: off
unicorn/no-null: off
unicorn/no-this-assignment: off
unicorn/no-useless-undefined: off
unicorn/prefer-array-flat: off
unicorn/prefer-event-target: off
Expand Down
1 change: 1 addition & 0 deletions libs/ng-mocks/src/lib/common/core.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export default {
// https://github.com/help-me-mom/ng-mocks/issues/538
'Sanitizer',
'DomSanitizer',
'DomSanitizerImpl',

// ApplicationModule, A14 made them global at root level
'ApplicationInitStatus',
Expand Down
2 changes: 1 addition & 1 deletion libs/ng-mocks/src/lib/common/func.is-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ export default <T>(
__template?: TemplateRef<any>;
__vcr?: ViewContainerRef;
} => {
return value && typeof value === 'object' && !!(value as any).__ngMocksConfig;
return value && typeof value === 'object' && !!(value as any).__ngMocks;
};
91 changes: 89 additions & 2 deletions libs/ng-mocks/src/lib/common/ng-mocks-global-overrides.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { ViewContainerRef } from '@angular/core';
import { Injector, ViewContainerRef } from '@angular/core';
import { getTestBed, MetadataOverride, TestBed, TestBedStatic, TestModuleMetadata } from '@angular/core/testing';

import funcExtractTokens from '../mock-builder/func.extract-tokens';
import getOverrideDef from '../mock-builder/promise/get-override-def';
import skipDep from '../mock-builder/promise/skip-dep';
import { ngMocks } from '../mock-helper/mock-helper';
import mockHelperFasterInstall from '../mock-helper/mock-helper.faster-install';
import { MockProvider } from '../mock-provider/mock-provider';
Expand All @@ -15,8 +16,9 @@ import coreInjector from './core.injector';
import coreReflectMeta from './core.reflect.meta';
import coreReflectModuleResolve from './core.reflect.module-resolve';
import coreReflectProvidedIn from './core.reflect.provided-in';
import { NG_MOCKS, NG_MOCKS_TOUCHES } from './core.tokens';
import { NG_MOCKS, NG_MOCKS_ROOT_PROVIDERS, NG_MOCKS_TOUCHES } from './core.tokens';
import { AnyType, dependencyKeys } from './core.types';
import funcGetName from './func.get-name';
import funcGetProvider from './func.get-provider';
import { isNgDef } from './func.is-ng-def';
import { isNgModuleDefWithProviders } from './func.is-ng-module-def-with-providers';
Expand Down Expand Up @@ -264,6 +266,87 @@ const viewContainerInstall = () => {
}
};

// this function monkey-patches Angular injectors.
const installInjector = (injector: Injector & { __ngMocksInjector?: any }) => {
// skipping the matched injector
if (injector.constructor.prototype.__ngMocksInjector || !injector.constructor.prototype.get) {
return;
}

// marking the injector as patched
coreDefineProperty(injector.constructor.prototype, '__ngMocksInjector', true);
const injectorGet = injector.constructor.prototype.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;

// Some tokens are declaration of providers and should be normalized.
let ngMocksToken = funcGetProvider(token);
let touched = true;
try {
// First, we check if we should skip the token.
// If the token is a root provider which nobody touched or kept, we can mock it.
if (
!skipDep(ngMocksToken) &&
coreReflectProvidedIn(ngMocksToken) === 'root' &&
ngMocksUniverse.getResolution(ngMocksToken) !== 'keep'
) {
// Now, we check if the token hasn't been used yet.
// NG_MOCKS is present in MockBuilder setup only.
touched =
injectorGet.call(binding, NG_MOCKS).get(NG_MOCKS_ROOT_PROVIDERS) === NG_MOCKS_ROOT_PROVIDERS ||
injectorGet.call(binding, NG_MOCKS).has(ngMocksToken) ||
injectorGet.call(binding, NG_MOCKS_TOUCHES).has(ngMocksToken);
}
} catch {
// nothing to do
}

// This means that the token hasn't been used yet and is suitable for mocking.
if (!touched) {
const def = MockProvider(ngMocksToken);
ngMocksToken = (() => {
const declaration = /* istanbul ignore next */ () => undefined;
coreDefineProperty(declaration, 'name', funcGetName(token), true);
coreDefineProperty(declaration, 'ngInjectableDef', {
...def,
factory: def.useFactory,
providedIn: 'root',
});

return declaration;
})();
} else {
// If we don't mock, we should keep the original token.
ngMocksToken = undefined;
}

const result = injectorGet.call(binding, ngMocksToken ?? 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 injector;
};

const install = () => {
// istanbul ignore else
if (!(TestBed as any).ngMocksOverridesInstalled) {
Expand All @@ -280,6 +363,10 @@ const install = () => {
}

coreDefineProperty(TestBed, 'ngMocksOverridesInstalled', true);
const injectorCreate = Injector.create;
Injector.create = helperCreateClone(injectorCreate, undefined, undefined, (...argsCreate: any) =>
installInjector(injectorCreate.apply(Injector, argsCreate)),
);
}
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ValueProvider } from '@angular/core';

import { mapEntries } from '../../common/core.helpers';
import { mapEntries, mapValues } from '../../common/core.helpers';
import { NG_MOCKS } from '../../common/core.tokens';
import ngMocksUniverse from '../../common/ng-mocks-universe';

Expand All @@ -17,6 +17,12 @@ export default (): ValueProvider => {
}
mocks.set(key, value);
}
for (const key of mapValues(ngMocksUniverse.config.get('ngMocksDepsSkip'))) {
if (mocks.has(key)) {
continue;
}
mocks.set(key, undefined);
}

return {
provide: NG_MOCKS,
Expand Down
53 changes: 53 additions & 0 deletions tests/issue-4282/test.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {
Component,
inject,
Injectable,
VERSION,
} from '@angular/core';

import { isMockOf, MockBuilder, MockRender, ngMocks } from 'ng-mocks';

// @TODO remove with A5 support
const injectableTargetServiceArgs = [
{
providedIn: 'root',
} as never,
];

@Injectable(...injectableTargetServiceArgs)
export class TargetService {
name = 'real';
}

@Component({
selector: 'target',
template: `{{ service.name }}`,
})
export class TargetComponent {
readonly service = inject(TargetService);
}

// @see https://github.com/help-me-mom/ng-mocks/issues/4282
describe('issue-4282', () => {
if (Number.parseInt(VERSION.major, 10) < 14) {
it('a14', () => {
// pending('Need Angular >= 14');
expect(true).toBeTruthy();
});

return;
}

beforeEach(() => MockBuilder(TargetComponent));

it('should create', () => {
const fixture = MockRender(TargetComponent);
expect(ngMocks.formatText(fixture)).toEqual('');
expect(
isMockOf(
fixture.point.componentInstance.service,
TargetService,
),
).toEqual(true);
});
});

0 comments on commit 3ba3166

Please sign in to comment.