From da513f47322404adabfb5ddf7b626da744adf768 Mon Sep 17 00:00:00 2001 From: DudaGod Date: Fri, 25 Oct 2024 12:19:42 +0300 Subject: [PATCH] feat(component-testing): implement mocks --- package-lock.json | 106 ++++ package.json | 3 + src/index.ts | 1 + src/mock/index.ts | 10 + src/mock/vitest-spy.ts | 580 ++++++++++++++++++ .../browser-env/vite/browser-modules/mock.ts | 45 ++ .../{mock => stubs}/@wdio-logger.ts | 0 .../{mock => stubs}/default-module.ts | 0 .../{mock => stubs}/import-meta-resolve.ts | 0 .../browser-env/vite/browser-modules/types.ts | 3 + src/runner/browser-env/vite/constants.ts | 5 + src/runner/browser-env/vite/manual-mock.ts | 100 +++ .../vite/plugins/generate-index-html.ts | 63 +- src/runner/browser-env/vite/plugins/mock.ts | 414 +++++++++++++ src/runner/browser-env/vite/server.ts | 10 +- src/runner/browser-env/vite/types.ts | 13 + src/runner/browser-env/vite/utils.ts | 48 ++ .../runner/browser-env/vite/manual-mock.ts | 143 +++++ test/src/runner/browser-env/vite/server.ts | 65 +- 19 files changed, 1556 insertions(+), 53 deletions(-) create mode 100644 src/mock/index.ts create mode 100644 src/mock/vitest-spy.ts create mode 100644 src/runner/browser-env/vite/browser-modules/mock.ts rename src/runner/browser-env/vite/browser-modules/{mock => stubs}/@wdio-logger.ts (100%) rename src/runner/browser-env/vite/browser-modules/{mock => stubs}/default-module.ts (100%) rename src/runner/browser-env/vite/browser-modules/{mock => stubs}/import-meta-resolve.ts (100%) create mode 100644 src/runner/browser-env/vite/manual-mock.ts create mode 100644 src/runner/browser-env/vite/plugins/mock.ts create mode 100644 test/src/runner/browser-env/vite/manual-mock.ts diff --git a/package-lock.json b/package-lock.json index 41495686e..6d4090123 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@jspm/core": "2.0.1", "@types/debug": "4.1.12", "@types/yallist": "4.0.4", + "@vitest/spy": "2.1.4", "@wdio/globals": "8.39.0", "@wdio/protocols": "8.38.0", "@wdio/types": "8.39.0", @@ -39,6 +40,7 @@ "mocha": "10.2.0", "plugins-loader": "1.3.4", "png-validator": "1.1.0", + "recast": "0.23.6", "resolve.exports": "2.0.2", "sharp": "0.32.6", "sizzle": "2.3.6", @@ -48,6 +50,7 @@ "strftime": "0.10.2", "strip-ansi": "6.0.1", "temp": "0.8.3", + "tinyspy": "3.0.2", "urijs": "1.19.11", "url-join": "4.0.1", "vite": "5.1.6", @@ -3351,6 +3354,17 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/@vitest/spy": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.4.tgz", + "integrity": "sha512-4JOxa+UAizJgpZfaCPKK2smq9d8mmjZVPMt2kOsg/R8QkoRzydHH1qHxIYNvr1zlEaFj4SXiaaJWxq/LPLKaLg==", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@wdio/config": { "version": "8.39.0", "resolved": "https://registry.npmjs.org/@wdio/config/-/config-8.39.0.tgz", @@ -13707,6 +13721,40 @@ "node": ">=8.10.0" } }, + "node_modules/recast": { + "version": "0.23.6", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.6.tgz", + "integrity": "sha512-9FHoNjX1yjuesMwuthAmPKabxYQdOgihFYmT5ebXfYGBcnqXZf3WOVz+5foEZ8Y83P4ZY6yQD5GMmtV+pgCCAQ==", + "dependencies": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/recast/node_modules/ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/recast/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -15093,6 +15141,19 @@ "node": ">=0.6.0" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-arraybuffer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz", @@ -19254,6 +19315,14 @@ } } }, + "@vitest/spy": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.4.tgz", + "integrity": "sha512-4JOxa+UAizJgpZfaCPKK2smq9d8mmjZVPMt2kOsg/R8QkoRzydHH1qHxIYNvr1zlEaFj4SXiaaJWxq/LPLKaLg==", + "requires": { + "tinyspy": "^3.0.2" + } + }, "@wdio/config": { "version": "8.39.0", "resolved": "https://registry.npmjs.org/@wdio/config/-/config-8.39.0.tgz", @@ -26858,6 +26927,33 @@ "picomatch": "^2.2.1" } }, + "recast": { + "version": "0.23.6", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.6.tgz", + "integrity": "sha512-9FHoNjX1yjuesMwuthAmPKabxYQdOgihFYmT5ebXfYGBcnqXZf3WOVz+5foEZ8Y83P4ZY6yQD5GMmtV+pgCCAQ==", + "requires": { + "ast-types": "^0.16.1", + "esprima": "~4.0.0", + "source-map": "~0.6.1", + "tiny-invariant": "^1.3.3", + "tslib": "^2.0.1" + }, + "dependencies": { + "ast-types": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", + "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "requires": { + "tslib": "^2.0.1" + } + }, + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + } + } + }, "redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -27901,6 +27997,16 @@ "process": "~0.11.0" } }, + "tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, + "tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==" + }, "to-arraybuffer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz", diff --git a/package.json b/package.json index 9d8967cbd..86eac9fd7 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@jspm/core": "2.0.1", "@types/debug": "4.1.12", "@types/yallist": "4.0.4", + "@vitest/spy": "2.1.4", "@wdio/globals": "8.39.0", "@wdio/protocols": "8.38.0", "@wdio/types": "8.39.0", @@ -81,6 +82,7 @@ "mocha": "10.2.0", "plugins-loader": "1.3.4", "png-validator": "1.1.0", + "recast": "0.23.6", "resolve.exports": "2.0.2", "sharp": "0.32.6", "sizzle": "2.3.6", @@ -90,6 +92,7 @@ "strftime": "0.10.2", "strip-ansi": "6.0.1", "temp": "0.8.3", + "tinyspy": "3.0.2", "urijs": "1.19.11", "url-join": "4.0.1", "vite": "5.1.6", diff --git a/src/index.ts b/src/index.ts index f77383c80..6d257074f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import "expect-webdriverio"; import { GlobalHelper } from "./types"; export { Testplane as default } from "./testplane"; export { Key } from "webdriverio"; +export * from "./mock"; export type { WdioBrowser, diff --git a/src/mock/index.ts b/src/mock/index.ts new file mode 100644 index 000000000..be26171d6 --- /dev/null +++ b/src/mock/index.ts @@ -0,0 +1,10 @@ +export * from "./vitest-spy"; + +// TODO: use from browser code when migrate to esm +type MockFactory = (originalImport?: unknown) => unknown; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function mock(_moduleName: string, _factory?: MockFactory): void {} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function unmock(_moduleName: string): void {} diff --git a/src/mock/vitest-spy.ts b/src/mock/vitest-spy.ts new file mode 100644 index 000000000..901aa3713 --- /dev/null +++ b/src/mock/vitest-spy.ts @@ -0,0 +1,580 @@ +// TODO: use @vitest/spy when migrate to esm + +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-use-before-define */ +import type { SpyInternalImpl } from "tinyspy"; +import * as tinyspy from "tinyspy"; + +interface MockResultReturn { + type: "return"; + /** + * The value that was returned from the function. If function returned a Promise, then this will be a resolved value. + */ + value: T; +} +interface MockResultIncomplete { + type: "incomplete"; + value: undefined; +} +interface MockResultThrow { + type: "throw"; + /** + * An error that was thrown during function execution. + */ + value: any; +} + +interface MockSettledResultFulfilled { + type: "fulfilled"; + value: T; +} + +interface MockSettledResultRejected { + type: "rejected"; + value: any; +} + +export type MockResult = MockResultReturn | MockResultThrow | MockResultIncomplete; +export type MockSettledResult = MockSettledResultFulfilled | MockSettledResultRejected; + +export interface MockContext { + /** + * This is an array containing all arguments for each call. One item of the array is the arguments of that call. + * + * @example + * const fn = vi.fn() + * + * fn('arg1', 'arg2') + * fn('arg3') + * + * fn.mock.calls === [ + * ['arg1', 'arg2'], // first call + * ['arg3'], // second call + * ] + */ + calls: Parameters[]; + /** + * This is an array containing all instances that were instantiated when mock was called with a `new` keyword. Note that this is an actual context (`this`) of the function, not a return value. + */ + instances: ReturnType[]; + /** + * An array of `this` values that were used during each call to the mock function. + */ + contexts: ThisParameterType[]; + /** + * The order of mock's execution. This returns an array of numbers which are shared between all defined mocks. + * + * @example + * const fn1 = vi.fn() + * const fn2 = vi.fn() + * + * fn1() + * fn2() + * fn1() + * + * fn1.mock.invocationCallOrder === [1, 3] + * fn2.mock.invocationCallOrder === [2] + */ + invocationCallOrder: number[]; + /** + * This is an array containing all values that were `returned` from the function. + * + * The `value` property contains the returned value or thrown error. If the function returned a `Promise`, then `result` will always be `'return'` even if the promise was rejected. + * + * @example + * const fn = vi.fn() + * .mockReturnValueOnce('result') + * .mockImplementationOnce(() => { throw new Error('thrown error') }) + * + * const result = fn() + * + * try { + * fn() + * } + * catch {} + * + * fn.mock.results === [ + * { + * type: 'return', + * value: 'result', + * }, + * { + * type: 'throw', + * value: Error, + * }, + * ] + */ + results: MockResult>[]; + /** + * An array containing all values that were `resolved` or `rejected` from the function. + * + * This array will be empty if the function was never resolved or rejected. + * + * @example + * const fn = vi.fn().mockResolvedValueOnce('result') + * + * const result = fn() + * + * fn.mock.settledResults === [] + * fn.mock.results === [ + * { + * type: 'return', + * value: Promise<'result'>, + * }, + * ] + * + * await result + * + * fn.mock.settledResults === [ + * { + * type: 'fulfilled', + * value: 'result', + * }, + * ] + */ + settledResults: MockSettledResult>>[]; + /** + * This contains the arguments of the last call. If spy wasn't called, will return `undefined`. + */ + lastCall: Parameters | undefined; +} + +type Procedure = (...args: any[]) => any; +// pick a single function type from function overloads, unions, etc... +type NormalizedPrecedure = (...args: Parameters) => ReturnType; + +type Methods = keyof { + [K in keyof T as T[K] extends Procedure ? K : never]: T[K]; +}; +type Properties = { + [K in keyof T]: T[K] extends Procedure ? never : K; +}[keyof T] & + (string | symbol); +type Classes = { + [K in keyof T]: T[K] extends new (...args: any[]) => any ? K : never; +}[keyof T] & + (string | symbol); + +/* +cf. https://typescript-eslint.io/rules/method-signature-style/ + +Typescript assignability is different between + { foo: (f: T) => U } (this is "method-signature-style") +and + { foo(f: T): U } + +Jest uses the latter for `MockInstance.mockImplementation` etc... and it allows assignment such as: + const boolFn: Jest.Mock<() => boolean> = jest.fn<() => true>(() => true) +*/ +export interface MockInstance { + /** + * Use it to return the name given to mock with method `.mockName(name)`. + */ + getMockName(): string; + /** + * Sets internal mock name. Useful to see the name of the mock if an assertion fails. + */ + mockName(n: string): this; + /** + * Current context of the mock. It stores information about all invocation calls, instances, and results. + */ + mock: MockContext; + /** + * Clears all information about every call. After calling it, all properties on `.mock` will return an empty state. This method does not reset implementations. + * + * It is useful if you need to clean up mock between different assertions. + */ + mockClear(): this; + /** + * Does what `mockClear` does and makes inner implementation an empty function (returning `undefined` when invoked). This also resets all "once" implementations. + * + * This is useful when you want to completely reset a mock to the default state. + */ + mockReset(): this; + /** + * Does what `mockReset` does and restores inner implementation to the original function. + * + * Note that restoring mock from `vi.fn()` will set implementation to an empty function that returns `undefined`. Restoring a `vi.fn(impl)` will restore implementation to `impl`. + */ + mockRestore(): void; + /** + * Returns current mock implementation if there is one. + * + * If mock was created with `vi.fn`, it will consider passed down method as a mock implementation. + * + * If mock was created with `vi.spyOn`, it will return `undefined` unless a custom implementation was provided. + */ + getMockImplementation(): NormalizedPrecedure | undefined; + /** + * Accepts a function that will be used as an implementation of the mock. + * @example + * const increment = vi.fn().mockImplementation(count => count + 1); + * expect(increment(3)).toBe(4); + */ + mockImplementation(fn: NormalizedPrecedure): this; + /** + * Accepts a function that will be used as a mock implementation during the next call. Can be chained so that multiple function calls produce different results. + * @example + * const fn = vi.fn(count => count).mockImplementationOnce(count => count + 1); + * expect(fn(3)).toBe(4); + * expect(fn(3)).toBe(3); + */ + mockImplementationOnce(fn: NormalizedPrecedure): this; + /** + * Overrides the original mock implementation temporarily while the callback is being executed. + * @example + * const myMockFn = vi.fn(() => 'original') + * + * myMockFn.withImplementation(() => 'temp', () => { + * myMockFn() // 'temp' + * }) + * + * myMockFn() // 'original' + */ + withImplementation( + fn: NormalizedPrecedure, + cb: () => T2, + ): T2 extends Promise ? Promise : this; + + /** + * Use this if you need to return `this` context from the method without invoking actual implementation. + */ + mockReturnThis(): this; + /** + * Accepts a value that will be returned whenever the mock function is called. + */ + mockReturnValue(obj: ReturnType): this; + /** + * Accepts a value that will be returned during the next function call. If chained, every consecutive call will return the specified value. + * + * When there are no more `mockReturnValueOnce` values to use, mock will fallback to the previously defined implementation if there is one. + * @example + * const myMockFn = vi + * .fn() + * .mockReturnValue('default') + * .mockReturnValueOnce('first call') + * .mockReturnValueOnce('second call') + * + * // 'first call', 'second call', 'default' + * console.log(myMockFn(), myMockFn(), myMockFn()) + */ + mockReturnValueOnce(obj: ReturnType): this; + /** + * Accepts a value that will be resolved when async function is called. + * @example + * const asyncMock = vi.fn().mockResolvedValue(42) + * asyncMock() // Promise<42> + */ + mockResolvedValue(obj: Awaited>): this; + /** + * Accepts a value that will be resolved during the next function call. If chained, every consecutive call will resolve specified value. + * @example + * const myMockFn = vi + * .fn() + * .mockResolvedValue('default') + * .mockResolvedValueOnce('first call') + * .mockResolvedValueOnce('second call') + * + * // Promise<'first call'>, Promise<'second call'>, Promise<'default'> + * console.log(myMockFn(), myMockFn(), myMockFn()) + */ + mockResolvedValueOnce(obj: Awaited>): this; + /** + * Accepts an error that will be rejected when async function is called. + * @example + * const asyncMock = vi.fn().mockRejectedValue(new Error('Async error')) + * await asyncMock() // throws 'Async error' + */ + mockRejectedValue(obj: any): this; + /** + * Accepts a value that will be rejected during the next function call. If chained, every consecutive call will reject specified value. + * @example + * const asyncMock = vi + * .fn() + * .mockResolvedValueOnce('first call') + * .mockRejectedValueOnce(new Error('Async error')) + * + * await asyncMock() // first call + * await asyncMock() // throws "Async error" + */ + mockRejectedValueOnce(obj: any): this; +} + +export interface Mock extends MockInstance { + new (...args: Parameters): ReturnType; + (...args: Parameters): ReturnType; +} + +type PartialMaybePromise = T extends Promise> ? Promise>> : Partial; + +export interface PartialMock + extends MockInstance<(...args: Parameters) => PartialMaybePromise>> { + new (...args: Parameters): ReturnType; + (...args: Parameters): ReturnType; +} + +export type MaybeMockedConstructor = T extends new (...args: Array) => infer R + ? Mock<(...args: ConstructorParameters) => R> + : T; +export type MockedFunction = Mock & { + [K in keyof T]: T[K]; +}; +export type PartiallyMockedFunction = PartialMock & { + [K in keyof T]: T[K]; +}; +export type MockedFunctionDeep = Mock & MockedObjectDeep; +export type PartiallyMockedFunctionDeep = PartialMock & MockedObjectDeep; +export type MockedObject = MaybeMockedConstructor & { + [K in Methods]: T[K] extends Procedure ? MockedFunction : T[K]; +} & { [K in Properties]: T[K] }; +export type MockedObjectDeep = MaybeMockedConstructor & { + [K in Methods]: T[K] extends Procedure ? MockedFunctionDeep : T[K]; + // eslint-disable-next-line no-use-before-define +} & { [K in Properties]: MaybeMockedDeep }; + +export type MaybeMockedDeep = T extends Procedure + ? MockedFunctionDeep + : T extends object + ? MockedObjectDeep + : T; + +export type MaybePartiallyMockedDeep = T extends Procedure + ? PartiallyMockedFunctionDeep + : T extends object + ? MockedObjectDeep + : T; + +export type MaybeMocked = T extends Procedure ? MockedFunction : T extends object ? MockedObject : T; + +export type MaybePartiallyMocked = T extends Procedure + ? PartiallyMockedFunction + : T extends object + ? MockedObject + : T; + +interface Constructable { + new (...args: any[]): any; +} + +export type MockedClass = MockInstance< + (...args: ConstructorParameters) => InstanceType +> & { + prototype: T extends { prototype: any } ? Mocked : never; +} & T; + +export type Mocked = { + [P in keyof T]: T[P] extends Procedure ? MockInstance : T[P] extends Constructable ? MockedClass : T[P]; +} & T; + +export const mocks: Set = new Set(); + +export function isMockFunction(fn: any): fn is MockInstance { + return typeof fn === "function" && "_isMockFunction" in fn && fn._isMockFunction; +} + +export function spyOn>>( + obj: T, + methodName: S, + accessType: "get", +): MockInstance<() => T[S]>; +export function spyOn>>( + obj: T, + methodName: G, + accessType: "set", +): MockInstance<(arg: T[G]) => void>; +export function spyOn> | Methods>>( + obj: T, + methodName: M, +): Required[M] extends { new (...args: infer A): infer R } + ? MockInstance<(this: R, ...args: A) => R> + : T[M] extends Procedure + ? MockInstance + : never; +export function spyOn(obj: T, method: K, accessType?: "get" | "set"): MockInstance { + const dictionary = { + get: "getter", + set: "setter", + } as const; + const objMethod = accessType ? { [dictionary[accessType]]: method } : method; + + const stub = tinyspy.internalSpyOn(obj, objMethod as any); + + return enhanceSpy(stub) as MockInstance; +} + +let callOrder = 0; + +function enhanceSpy(spy: SpyInternalImpl, ReturnType>): MockInstance { + type TArgs = Parameters; + type TReturns = ReturnType; + + const stub = spy as unknown as MockInstance; + + let implementation: T | undefined; + + let instances: any[] = []; + let contexts: any[] = []; + let invocations: number[] = []; + + const state = tinyspy.getInternalState(spy); + + const mockContext: MockContext = { + get calls() { + return state.calls; + }, + get contexts() { + return contexts; + }, + get instances() { + return instances; + }, + get invocationCallOrder() { + return invocations; + }, + get results() { + return state.results.map(([callType, value]) => { + const type = callType === "error" ? ("throw" as const) : ("return" as const); + return { type, value }; + }); + }, + get settledResults() { + return state.resolves.map(([callType, value]) => { + const type = callType === "error" ? ("rejected" as const) : ("fulfilled" as const); + return { type, value }; + }); + }, + get lastCall() { + return state.calls[state.calls.length - 1]; + }, + }; + + let onceImplementations: ((...args: TArgs) => TReturns)[] = []; + let implementationChangedTemporarily = false; + + function mockCall(this: unknown, ...args: any) { + instances.push(this); + contexts.push(this); + invocations.push(++callOrder); + const impl = implementationChangedTemporarily + ? implementation! + : onceImplementations.shift() || implementation || state.getOriginal() || (() => {}); + return impl.apply(this, args); + } + + let name: string = (stub as any).name; + + stub.getMockName = () => name || "vi.fn()"; + stub.mockName = n => { + name = n; + return stub; + }; + + stub.mockClear = () => { + state.reset(); + instances = []; + contexts = []; + invocations = []; + return stub; + }; + + stub.mockReset = () => { + stub.mockClear(); + implementation = (() => undefined) as T; + onceImplementations = []; + return stub; + }; + + stub.mockRestore = () => { + stub.mockReset(); + state.restore(); + implementation = undefined; + return stub; + }; + + stub.getMockImplementation = () => implementation; + stub.mockImplementation = (fn: T) => { + implementation = fn; + state.willCall(mockCall); + return stub; + }; + + stub.mockImplementationOnce = (fn: T) => { + onceImplementations.push(fn); + return stub; + }; + + function withImplementation(fn: T, cb: () => void): MockInstance; + function withImplementation(fn: T, cb: () => Promise): Promise>; + function withImplementation(fn: T, cb: () => void | Promise): MockInstance | Promise> { + const originalImplementation = implementation; + + implementation = fn; + state.willCall(mockCall); + implementationChangedTemporarily = true; + + const reset = () => { + implementation = originalImplementation; + implementationChangedTemporarily = false; + }; + + const result = cb(); + + if (result instanceof Promise) { + return result.then(() => { + reset(); + return stub; + }); + } + + reset(); + + return stub; + } + + stub.withImplementation = withImplementation; + + stub.mockReturnThis = () => + stub.mockImplementation(function (this: TReturns) { + return this; + } as any); + + stub.mockReturnValue = (val: TReturns) => stub.mockImplementation((() => val) as any); + stub.mockReturnValueOnce = (val: TReturns) => stub.mockImplementationOnce((() => val) as any); + + stub.mockResolvedValue = (val: Awaited) => + stub.mockImplementation((() => Promise.resolve(val as TReturns)) as any); + + stub.mockResolvedValueOnce = (val: Awaited) => + stub.mockImplementationOnce((() => Promise.resolve(val as TReturns)) as any); + + stub.mockRejectedValue = (val: unknown) => stub.mockImplementation((() => Promise.reject(val)) as any); + + stub.mockRejectedValueOnce = (val: unknown) => stub.mockImplementationOnce((() => Promise.reject(val)) as any); + + Object.defineProperty(stub, "mock", { + get: () => mockContext, + }); + + state.willCall(mockCall); + + mocks.add(stub); + + return stub as any; +} + +export function fn(implementation?: T): Mock { + const enhancedSpy = enhanceSpy( + tinyspy.internalSpyOn( + { + spy: implementation || (function () {} as T), + }, + "spy", + ), + ); + if (implementation) { + enhancedSpy.mockImplementation(implementation); + } + + return enhancedSpy as any; +} diff --git a/src/runner/browser-env/vite/browser-modules/mock.ts b/src/runner/browser-env/vite/browser-modules/mock.ts new file mode 100644 index 000000000..02d9afcfa --- /dev/null +++ b/src/runner/browser-env/vite/browser-modules/mock.ts @@ -0,0 +1,45 @@ +// This module replaces testplane import and works only in browser environment + +export * from "@vitest/spy"; +export { Key } from "webdriverio"; +import type { MockFactory } from "./types.js"; + +// solution found here - https://stackoverflow.com/questions/48674303/resolve-relative-path-to-absolute-url-from-es6-module-in-browser +const a = document.createElement("a"); +function resolveUrl(path: string): string { + a.href = path; + return a.href; +} + +export async function mock(moduleName: string, factory?: MockFactory, originalImport?: unknown): Promise { + // Mock call without factory parameter is handled by manual-mock module and is being removed from the source code by mock vite plugin + if (!factory || typeof factory !== "function") { + return; + } + + const { file, mockCache } = window.__testplane__; + const isModuleLocal = moduleName.startsWith("./") || moduleName.startsWith("../"); + + let mockPath: string; + + if (isModuleLocal) { + const absModuleUrl = resolveUrl(file.split("/").slice(0, -1).join("/") + `/${moduleName}`); + + mockPath = new URL(absModuleUrl).pathname; + } else { + mockPath = moduleName; + } + + try { + const resolvedMock = await factory(originalImport); + mockCache.set(mockPath, resolvedMock); + } catch (err: unknown) { + const error = err as Error; + throw new Error(`There was an error in mock factory of module "${moduleName}":\n${error.stack}`); + } +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function unmock(_moduleName: string): void { + // is implemented in manual-mock module and is being removed from the source code by mock vite plugin +} diff --git a/src/runner/browser-env/vite/browser-modules/mock/@wdio-logger.ts b/src/runner/browser-env/vite/browser-modules/stubs/@wdio-logger.ts similarity index 100% rename from src/runner/browser-env/vite/browser-modules/mock/@wdio-logger.ts rename to src/runner/browser-env/vite/browser-modules/stubs/@wdio-logger.ts diff --git a/src/runner/browser-env/vite/browser-modules/mock/default-module.ts b/src/runner/browser-env/vite/browser-modules/stubs/default-module.ts similarity index 100% rename from src/runner/browser-env/vite/browser-modules/mock/default-module.ts rename to src/runner/browser-env/vite/browser-modules/stubs/default-module.ts diff --git a/src/runner/browser-env/vite/browser-modules/mock/import-meta-resolve.ts b/src/runner/browser-env/vite/browser-modules/stubs/import-meta-resolve.ts similarity index 100% rename from src/runner/browser-env/vite/browser-modules/mock/import-meta-resolve.ts rename to src/runner/browser-env/vite/browser-modules/stubs/import-meta-resolve.ts diff --git a/src/runner/browser-env/vite/browser-modules/types.ts b/src/runner/browser-env/vite/browser-modules/types.ts index b4a7740e3..43682c3fe 100644 --- a/src/runner/browser-env/vite/browser-modules/types.ts +++ b/src/runner/browser-env/vite/browser-modules/types.ts @@ -107,6 +107,7 @@ declare global { errors: BrowserError[]; socket: BrowserViteSocket; browser: WebdriverIO.Browser; + mockCache: Map; } & WorkerInitializePayload; testplane: typeof Proxy; hermione: typeof Proxy; @@ -114,3 +115,5 @@ declare global { expect: Expect; } } + +export type MockFactory = (originalImport?: unknown) => unknown; diff --git a/src/runner/browser-env/vite/constants.ts b/src/runner/browser-env/vite/constants.ts index 999498454..9991dc098 100644 --- a/src/runner/browser-env/vite/constants.ts +++ b/src/runner/browser-env/vite/constants.ts @@ -20,3 +20,8 @@ export const SOCKET_MAX_TIMEOUT = 2147483647; export const SOCKET_TIMED_OUT_ERROR = "operation has timed out"; export const WORKER_ENV_BY_RUN_UUID = new Map(); + +export const MOCK_MODULE_NAME = "testplane"; + +export const DEFAULT_AUTOMOCK = false; +export const DEFAULT_AUTOMOCK_DIRECTORY = "__mocks__"; diff --git a/src/runner/browser-env/vite/manual-mock.ts b/src/runner/browser-env/vite/manual-mock.ts new file mode 100644 index 000000000..0d7a7e81c --- /dev/null +++ b/src/runner/browser-env/vite/manual-mock.ts @@ -0,0 +1,100 @@ +import path from "node:path"; +import fs from "node:fs/promises"; +import _ from "lodash"; +import { DEFAULT_AUTOMOCK, DEFAULT_AUTOMOCK_DIRECTORY } from "./constants"; + +import type { InlineConfig } from "vite"; +import type { BrowserTestRunEnvOptions } from "./types"; + +type MockOnFs = { + fullPath: string; + moduleName: string; +}; + +type ManualMockOptions = { + automock: boolean; + mocksOnFs: MockOnFs[]; +}; + +export class ManualMock { + private _automock: boolean; + private _mocksOnFs: MockOnFs[]; + private _mocks: string[]; + private _unmocks: string[]; + + static async create( + this: new (opts: ManualMockOptions) => T, + config: Partial, + options?: BrowserTestRunEnvOptions, + ): Promise { + const automock = typeof options?.automock === "boolean" ? options?.automock : DEFAULT_AUTOMOCK; + const automockDir = path.resolve(config?.root || "", options?.automockDir || DEFAULT_AUTOMOCK_DIRECTORY); + const mocksOnFs = await getMocksOnFs(automockDir); + + return new this({ automock, mocksOnFs }); + } + + constructor(options: ManualMockOptions) { + this._automock = options.automock; + this._mocksOnFs = options.mocksOnFs; + this._mocks = []; + this._unmocks = []; + } + + async resolveId(id: string): Promise { + const foundMockOnFs = this._mocksOnFs.find(mock => id === mock.moduleName); + + if ((this._mocks.includes(id) || this._automock) && foundMockOnFs && !this._unmocks.includes(id)) { + return foundMockOnFs.fullPath; + } + } + + mock(moduleName: string): void { + this._mocks.push(moduleName); + } + + unmock(moduleName: string): void { + this._unmocks.push(moduleName); + } + + resetMocks(): void { + this._mocks = []; + this._unmocks = []; + } +} + +async function getMocksOnFs(automockDir: string): Promise<{ fullPath: string; moduleName: string }[]> { + const mockedModules = await getFilesFromDirectory(automockDir); + + return mockedModules.map(filePath => { + const extName = path.extname(filePath); + + return { + fullPath: filePath, + moduleName: filePath.slice(automockDir.length + 1, -extName.length), + }; + }); +} + +async function getFilesFromDirectory(dir: string): Promise { + const isDirExists = await fs.access(dir).then( + () => true, + () => false, + ); + + if (!isDirExists) { + return []; + } + + const files = await fs.readdir(dir); + const allFiles = await Promise.all( + files.map(async (file: string): Promise => { + const filePath = path.join(dir, file); + const stats = await fs.stat(filePath); + + return stats.isDirectory() ? getFilesFromDirectory(filePath) : filePath; + }), + ); + + return _.flatten(allFiles).filter(Boolean) as string[]; +} diff --git a/src/runner/browser-env/vite/plugins/generate-index-html.ts b/src/runner/browser-env/vite/plugins/generate-index-html.ts index 13e5a9130..b9fec6285 100644 --- a/src/runner/browser-env/vite/plugins/generate-index-html.ts +++ b/src/runner/browser-env/vite/plugins/generate-index-html.ts @@ -3,8 +3,8 @@ import url from "node:url"; import { builtinModules } from "node:module"; import _ from "lodash"; import createDebug from "debug"; -import { MODULE_NAMES, VITE_RUN_UUID_ROUTE, WORKER_ENV_BY_RUN_UUID } from "../constants"; -import { getNodeModulePath, getImportMetaUrl } from "../utils"; +import { MODULE_NAMES, MOCK_MODULE_NAME } from "../constants"; +import { getNodeModulePath, getImportMetaUrl, getTestInfoFromViteRequest } from "../utils"; import { polyfillPath } from "../polyfill"; import logger from "../../../../utils/logger"; @@ -14,7 +14,7 @@ import type { Plugin, Rollup } from "vite"; const debug = createDebug("vite:plugin:generateIndexHtml"); // modules that used only in NodeJS environment and don't need to be compiled -const DEFAULT_MODULES_TO_MOCK = ["puppeteer-core", "archiver", "@wdio/repl", "jszip"]; +const DEFAULT_MODULES_TO_STUB = ["puppeteer-core", "archiver", "@wdio/repl", "jszip"]; const POLYFILLS = [...builtinModules, ...builtinModules.map(m => `node:${m}`)]; const virtualDriverModuleId = "virtual:@testplane/driver"; @@ -38,16 +38,17 @@ export const plugin = async (): Promise => { const browserRunnerModulePath = path.resolve(browserModulesPath, "index.js"); const globalsModulePath = path.resolve(browserModulesPath, "globals.js"); const driverModulePath = path.resolve(browserModulesPath, "driver.js"); + const mockModulePath = path.resolve(browserModulesPath, "mock.js"); const automationProtocolPath = `/@fs${driverModulePath}`; - const mockDefaultModulePath = path.resolve(browserModulesPath, "mock/default-module.js"); - const mockImportMetaResolvePath = path.resolve(browserModulesPath, "mock/import-meta-resolve.js"); - const mockWdioLoggerPath = path.resolve(browserModulesPath, "mock/@wdio-logger.js"); + const stubDefaultModulePath = path.resolve(browserModulesPath, "stubs/default-module.js"); + const stubImportMetaResolvePath = path.resolve(browserModulesPath, "stubs/import-meta-resolve.js"); + const stubWdioLoggerPath = path.resolve(browserModulesPath, "stubs/@wdio-logger.js"); - const modulesToMock = DEFAULT_MODULES_TO_MOCK.reduce((acc, val) => _.set(acc, val, mockDefaultModulePath), { - "@wdio/logger": mockWdioLoggerPath, - "import-meta-resolve": mockImportMetaResolvePath, + const modulesToStub = DEFAULT_MODULES_TO_STUB.reduce((acc, val) => _.set(acc, val, stubDefaultModulePath), { + "@wdio/logger": stubWdioLoggerPath, + "import-meta-resolve": stubImportMetaResolvePath, }) as Record; return [ @@ -59,32 +60,13 @@ export const plugin = async (): Promise => { server.middlewares.use(async (req, res, next) => { debug(`Received request for: ${req.originalUrl}`); - if (!req.url?.endsWith("index.html") || !req.originalUrl) { - return next(); - } - - const parsedUrl = url.parse(req.originalUrl); - const [routeName, runUuid] = _.compact(parsedUrl.pathname?.split("/")); - try { - if (routeName !== VITE_RUN_UUID_ROUTE || !runUuid) { - throw new Error( - `Pathname must be in "/${VITE_RUN_UUID_ROUTE}/:uuid" format, but got: ${req.originalUrl}`, - ); - } - - const env = WORKER_ENV_BY_RUN_UUID.get(runUuid); - if (!env) { - throw new Error( - `Worker environment is not found by "${runUuid}". ` + - "This is possible if:\n" + - ' - "runUuid" is not generated by Testplane\n' + - " - the test has already been completed\n" + - " - worker was disconnected", - ); + const testInfo = getTestInfoFromViteRequest(req); + if (!testInfo) { + return next(); } - const template = generateTemplate(env, runUuid); + const template = generateTemplate(testInfo.env, testInfo.runUuid); res.end(await server.transformIndexHtml(`${req.originalUrl}`, template)); } catch (err) { const template = generateErrorTemplate(err as Error); @@ -102,6 +84,11 @@ export const plugin = async (): Promise => { return virtualModules.driver.resolvedId; } + // fake module and load the implementation of the browser mock + if (id === MOCK_MODULE_NAME) { + return mockModulePath; + } + if (id.endsWith(MODULE_NAMES.browserRunner)) { return browserRunnerModulePath; } @@ -118,8 +105,8 @@ export const plugin = async (): Promise => { return polyfillPath(id.replace("/promises", "")); } - if (Object.keys(modulesToMock).includes(id)) { - return modulesToMock[id]; + if (Object.keys(modulesToStub).includes(id)) { + return modulesToStub[id]; } }, @@ -151,6 +138,14 @@ function generateTemplate(env: WorkerInitializePayload, runUuid: string): string diff --git a/src/runner/browser-env/vite/plugins/mock.ts b/src/runner/browser-env/vite/plugins/mock.ts new file mode 100644 index 000000000..c39ff1a19 --- /dev/null +++ b/src/runner/browser-env/vite/plugins/mock.ts @@ -0,0 +1,414 @@ +import url from "node:url"; +import path from "node:path"; +import createDebug from "debug"; +import { parse, print, visit, types } from "recast"; + +import { ManualMock } from "../manual-mock"; +import logger from "../../../../utils/logger"; +import { MOCK_MODULE_NAME } from "../constants"; +import { getTestInfoFromViteRequest, getPathWithoutExtName } from "../utils"; + +import type { Plugin, Rollup } from "vite"; + +const debug = createDebug("vite:plugin:mock"); +const b = types.builders; + +type MockState = { + importIndex: number; + mockFnName: string; + unmockFnName: string; + mockCalls: (types.namedTypes.ExpressionStatement | types.namedTypes.ImportDeclaration)[]; +}; + +type RewriteImportDeclarationOpts = { + state: MockState; + id: string; + testFilePath: string | null; + isTestModule: boolean; + registeredMocks: Set; + testFileMocks: Map; +}; + +type HandleMockCallsOpts = { + state: MockState; + registeredMocks: Set; + manualMock: ManualMock; +}; + +export const plugin = (manualMock: ManualMock): Plugin[] => { + const registeredMocks = new Set(); + const testFileMocks = new Map(); + + let testFilePath: string | null = null; + + return [ + { + name: "testplane:manual-mock", + enforce: "pre", + resolveId: manualMock.resolveId.bind(manualMock), + }, + { + name: "testplane:mock", + enforce: "post", + configureServer(server) { + return () => { + server.middlewares.use("/", async (req, _res, next) => { + const testInfo = getTestInfoFromViteRequest(req); + if (!testInfo) { + return next(); + } + + testFilePath = testInfo.env?.file; + manualMock.resetMocks(); + + return next(); + }); + }; + }, + + transform(code, id): Rollup.TransformResult { + const isTestModule = testFilePath === id; + + if (!isTestModule) { + const isModuleFromNodeModules = id.includes("/node_modules/"); + const isVirtualModule = id.startsWith("virtual:"); + const hasRegisteredMocks = registeredMocks.size > 0; + + if (isModuleFromNodeModules || isVirtualModule || !hasRegisteredMocks) { + return { code }; + } + } + + let ast: types.namedTypes.File; + const start = Date.now(); + + try { + ast = parse(code, { + parser: require("recast/parsers/typescript"), + sourceFileName: id, + sourceRoot: path.dirname(id), + }); + + debug(`Parsed file for mocking: ${id} in ${Date.now() - start}ms`); + } catch (err) { + logger.error(`Failed to parse file ${id}: ${(err as Error).stack}`); + + return { code }; + } + + const state: MockState = { + importIndex: 0, + mockFnName: "", + unmockFnName: "", + mockCalls: [], + }; + + const testModuleVisitorMethods = isTestModule + ? { visitExpressionStatement: handleMockCalls({ state, registeredMocks, manualMock }) } + : {}; + + visit(ast, { + visitImportDeclaration: handleModuleImportWithMocks(state), + ...testModuleVisitorMethods, + }); + + visit(ast, { + visitImportDeclaration: rewriteImportDeclaration({ + state, + id, + testFilePath, + isTestModule, + registeredMocks, + testFileMocks, + }), + }); + + const preparedMockCalls = state.mockCalls.map(mockCall => { + const exp = mockCall as types.namedTypes.ExpressionStatement; + + if (!isCallExpression(exp)) { + return mockCall; + } + + const mockCallExpression = exp.expression as types.namedTypes.CallExpression; + const mockModuleName = (mockCallExpression.arguments[0] as types.namedTypes.Literal) + .value as string; + + if (testFileMocks.has(mockModuleName)) { + mockCallExpression.arguments.push(b.identifier(testFileMocks.get(mockModuleName)!)); + } else { + throw new Error(`Cannot find mocked module "${mockModuleName}"`); + } + + return b.expressionStatement(b.awaitExpression(mockCallExpression)); + }); + + ast.program.body.unshift(...preparedMockCalls); + + try { + const newCode = print(ast, { sourceMapName: id }); + debug(`Transformed file for mocking: ${id} in ${Date.now() - start}ms`); + + return newCode; + } catch (err) { + logger.error(`Failed to transform file ${id} for mocking: ${(err as Error).stack}`); + return { code }; + } + }, + }, + ]; +}; + +/** + * Find import module with mocks and save name for mock and unmock calls + */ +function handleModuleImportWithMocks(state: MockState): types.Visitor["visitImportDeclaration"] { + return function (nodePath) { + const declaration = nodePath.value as types.namedTypes.ImportDeclaration; + const source = declaration.source.value!; + const specifiers = declaration.specifiers as types.namedTypes.ImportSpecifier[]; + + if (!specifiers || specifiers.length === 0 || source !== MOCK_MODULE_NAME) { + return this.traverse(nodePath); + } + + const mockSpecifier = specifiers + .filter(s => s.type === types.namedTypes.ImportSpecifier.toString()) + .find(s => s.imported.name === "mock"); + + if (mockSpecifier && mockSpecifier.local) { + state.mockFnName = mockSpecifier.local.name as string; + } + + const unmockSpecifier = (declaration.specifiers as types.namedTypes.ImportSpecifier[]) + .filter(s => s.type === types.namedTypes.ImportSpecifier.toString()) + .find(s => s.imported.name === "unmock"); + + if (unmockSpecifier && unmockSpecifier.local) { + state.unmockFnName = unmockSpecifier.local.name as string; + } + + // Move import module with mocks to the top of the file + state.mockCalls.push(declaration); + nodePath.prune(); + + return this.traverse(nodePath); + }; +} + +/** + * Detect which modules are supposed to be mocked + */ +function handleMockCalls({ + state, + registeredMocks, + manualMock, +}: HandleMockCallsOpts): types.Visitor["visitExpressionStatement"] { + return function (nodePath) { + const exp = nodePath.value as types.namedTypes.ExpressionStatement; + + if (exp.expression.type !== types.namedTypes.CallExpression.toString()) { + return this.traverse(nodePath); + } + + const callExp = exp.expression as types.namedTypes.CallExpression; + const isMockCall = + Boolean(state.mockFnName) && (callExp.callee as types.namedTypes.Identifier).name === state.mockFnName; + const isUnmockCall = + Boolean(state.unmockFnName) && (callExp.callee as types.namedTypes.Identifier).name === state.unmockFnName; + + if (!isMockCall && !isUnmockCall) { + return this.traverse(nodePath); + } + + if ( + isUnmockCall && + callExp.arguments[0] && + typeof (callExp.arguments[0] as types.namedTypes.Literal).value === "string" + ) { + manualMock.unmock((callExp.arguments[0] as types.namedTypes.Literal).value as string); + } + + if (isMockCall) { + const mockCall = exp.expression as types.namedTypes.CallExpression; + + if (mockCall.arguments.length === 1) { + manualMock.mock((mockCall.arguments[0] as types.namedTypes.StringLiteral).value); + } else { + if ((exp.expression as types.namedTypes.CallExpression).arguments.length) { + registeredMocks.add( + ((exp.expression as types.namedTypes.CallExpression).arguments[0] as types.namedTypes.Literal) + .value as string, + ); + } + + state.mockCalls.push(exp); + } + } + + // Remove original node from ast + nodePath.prune(); + + return this.traverse(nodePath); + }; +} +/** + * Rewrite import declarations in test file and its dependencies in order to use user mocks instead original module. + * Exmample in test file: + * + * From: + * import {fn, mock} from "testplane/mock"; + * import {foo} from "bar"; + * import {handleClick} from "./utils"; + * mock("./utils", () => ({handleClick: fn()}); + * + * To: + * import * as __testplane_import_0__ from "./utils"; // move import of mocked module to the top + * await mock("utils", () => ({handleClick: fn()}, __testplane_import_0__); // move right after import original module (will call `mock` from `vite/browser-modules/mock.ts`) + * const {foo} = await import("bar"); // transform to import expression in order to import it only after mock module + * const {handleClick} = importWithMock("/Users/../utils", __testplane_import_0__); // use importWithMock helper in order to get mocked module +})); + */ +function rewriteImportDeclaration({ + state, + id, + testFilePath, + isTestModule, + registeredMocks, + testFileMocks, +}: RewriteImportDeclarationOpts): types.Visitor["visitImportDeclaration"] { + return function (nodePath) { + const declaration = nodePath.value as types.namedTypes.ImportDeclaration; + const source = declaration.source.value as string; + + if (!declaration.specifiers || declaration.specifiers.length === 0) { + return this.traverse(nodePath); + } + + const absImportPath = path.resolve(path.dirname(id), source); + const absImportPathWithoutExtName = getPathWithoutExtName(absImportPath); + + const isModuleMockedRelatively = Boolean( + source.startsWith(".") && + [...registeredMocks.values()].find(m => { + const absMockPath = path.resolve(path.dirname(testFilePath || "/"), m); + const absMockPathWithoutExtName = getPathWithoutExtName(absMockPath); + + return absImportPathWithoutExtName === absMockPathWithoutExtName; + }), + ); + + const isModuleMocked = isModuleMockedRelatively || registeredMocks.has(source); + const newImportIdentifier = genImportIdentifier(state); + + if (isTestModule && isModuleMocked) { + testFileMocks.set(source, newImportIdentifier); + } + + /** + * Use import with custom namespace specifier for mocked module + * + * From: + * import {handleClick} from "./utils"; + * + * To: + * import * as __testplane_import_0__ from "./utils"; + */ + if (isModuleMocked) { + const newNode = b.importDeclaration( + [b.importNamespaceSpecifier(b.identifier(newImportIdentifier))], + b.literal(source), + ); + + // should be specified first in order to correctly mock module + state.mockCalls.unshift(newNode); + } + + let mockImport: types.namedTypes.VariableDeclaration | undefined; + + /** + * Transform import mocked module to use helper `importWithMock` in order to get mocked implementation + * + * From: + * import {handleClick} from "./utils"; + * + * To: + * import * as __testplane_import_0__ from "./utils"; // from code above + * const { handleClick } = await importWithMock("./utils", __testplane_import_0__); + */ + if (isModuleMocked) { + const mockModuleIdentifier = + source.startsWith(".") || source.startsWith("/") + ? url.pathToFileURL(absImportPathWithoutExtName).pathname + : source; + + const variableValue = b.callExpression(b.identifier("importWithMock"), [ + b.literal(mockModuleIdentifier), + b.identifier(newImportIdentifier), + ]); + + mockImport = b.variableDeclaration("const", [ + b.variableDeclarator(genVarDeclKey(declaration), variableValue), + ]); + } + + /** + * Transform not mocked import declarations to import expressions only in test file. + * In order to hoist `mock(...)` calls and run them before another dependencies. + * + * From: + * import {foo} from "bar"; + * + * To: + * const {foo} = await import("bar"); + */ + if (isTestModule && !isModuleMocked) { + mockImport = b.variableDeclaration("const", [ + b.variableDeclarator( + genVarDeclKey(declaration), + b.awaitExpression(b.importExpression(b.literal(source))), + ), + ]); + } + + if (mockImport) { + nodePath.replace(mockImport); + } + + return this.traverse(nodePath); + }; +} + +function genVarDeclKey( + declaration: types.namedTypes.ImportDeclaration, +): types.namedTypes.Identifier | types.namedTypes.ObjectPattern { + const isNamespaceImport = + declaration.specifiers?.length === 1 && + declaration.specifiers[0].type === types.namedTypes.ImportNamespaceSpecifier.toString(); + + if (isNamespaceImport) { + return declaration.specifiers![0].local as types.namedTypes.Identifier; + } + + return b.objectPattern( + declaration.specifiers!.map(s => { + if (s.type === types.namedTypes.ImportDefaultSpecifier.toString()) { + return b.property("init", b.identifier("default"), b.identifier(s.local!.name as string)); + } + + return b.property( + "init", + b.identifier((s as types.namedTypes.ImportSpecifier).imported.name as string), + b.identifier(s.local!.name as string), + ); + }), + ); +} + +function genImportIdentifier(state: MockState): string { + return `__testplane_import_${state.importIndex++}__`; +} + +function isCallExpression(exp: types.namedTypes.ExpressionStatement): boolean { + return exp.expression && exp.expression.type === types.namedTypes.CallExpression.toString(); +} diff --git a/src/runner/browser-env/vite/server.ts b/src/runner/browser-env/vite/server.ts index ccccd8217..9b738d5f3 100644 --- a/src/runner/browser-env/vite/server.ts +++ b/src/runner/browser-env/vite/server.ts @@ -10,6 +10,8 @@ import chalk from "chalk"; import logger from "../../../utils/logger"; import { createSocketServer } from "./socket"; import { plugin as generateIndexHtml } from "./plugins/generate-index-html"; +import { plugin as mockPlugin } from "./plugins/mock"; +import { ManualMock } from "./manual-mock"; import { Config } from "../../../config"; import { VITE_DEFAULT_CONFIG_ENV } from "./constants"; @@ -101,7 +103,13 @@ export class ViteServer { } private async _addRequiredVitePlugins(): Promise { - this._viteConfig.plugins = [...(this._viteConfig.plugins || []), await generateIndexHtml()]; + const manualMock = await ManualMock.create(this._viteConfig, this._options); + + this._viteConfig.plugins = [ + ...(this._viteConfig.plugins || []), + await generateIndexHtml(), + mockPlugin(manualMock), + ]; } get baseUrl(): string | undefined { diff --git a/src/runner/browser-env/vite/types.ts b/src/runner/browser-env/vite/types.ts index 948d1440b..386b9e961 100644 --- a/src/runner/browser-env/vite/types.ts +++ b/src/runner/browser-env/vite/types.ts @@ -5,7 +5,20 @@ import type { BrowserViteEvents, WorkerViteEvents, ViteBrowserEvents } from "./b export type { BrowserViteEvents, WorkerViteEvents } from "./browser-modules/types"; export interface BrowserTestRunEnvOptions { + /** + * Vite configuration + */ viteConfig?: string | UserConfig | ((env: ConfigEnv) => UserConfig | Promise); + /** + * If set to `true` Testplane will automatically mock dependencies within the `automockDir` directory + * @default false + */ + automock?: boolean; + /** + * Path to automock directory in which Testplane will look for dependencies to mock + * @default ./__mocks__ + */ + automockDir?: string; } export interface ClientViteEvents extends BrowserViteEvents, WorkerViteEvents {} diff --git a/src/runner/browser-env/vite/utils.ts b/src/runner/browser-env/vite/utils.ts index 91daf0f84..c8d261e8d 100644 --- a/src/runner/browser-env/vite/utils.ts +++ b/src/runner/browser-env/vite/utils.ts @@ -1,5 +1,17 @@ import url from "node:url"; import path from "node:path"; +import _ from "lodash"; + +import { VITE_RUN_UUID_ROUTE, WORKER_ENV_BY_RUN_UUID } from "./constants"; + +import type { Connect } from "vite"; +import type { WorkerInitializePayload } from "./browser-modules/types"; + +type TestInfoFromViteRequest = { + routeName: string; + runUuid: string; + env: WorkerInitializePayload; +}; // TODO: use import.meta.url after migrate to esm export const getImportMetaUrl = (path: string): string => { @@ -27,3 +39,39 @@ export const getNodeModulePath = async ({ export const prepareError = (error: Error): Error => { return JSON.parse(JSON.stringify(error, Object.getOwnPropertyNames(error))); }; + +export const getTestInfoFromViteRequest = (req: Connect.IncomingMessage): TestInfoFromViteRequest | null => { + if (!req.url?.endsWith("index.html") || !req.originalUrl) { + return null; + } + + const parsedUrl = url.parse(req.originalUrl); + const [routeName, runUuid] = _.compact(parsedUrl.pathname?.split("/")); + + if (routeName !== VITE_RUN_UUID_ROUTE || !runUuid) { + throw new Error(`Pathname must be in "/${VITE_RUN_UUID_ROUTE}/:uuid" format, but got: ${req.originalUrl}`); + } + + const env = WORKER_ENV_BY_RUN_UUID.get(runUuid); + if (!env) { + throw new Error( + `Worker environment is not found by "${runUuid}". ` + + "This is possible if:\n" + + ' - "runUuid" is not generated by Testplane\n' + + " - the test has already been completed\n" + + " - worker was disconnected", + ); + } + + return { routeName, runUuid, env }; +}; + +export const getPathWithoutExtName = (fsPath: string): string => { + const extname = path.extname(fsPath); + + if (!extname) { + return fsPath; + } + + return fsPath.slice(0, -extname.length); +}; diff --git a/test/src/runner/browser-env/vite/manual-mock.ts b/test/src/runner/browser-env/vite/manual-mock.ts new file mode 100644 index 000000000..8f1b98559 --- /dev/null +++ b/test/src/runner/browser-env/vite/manual-mock.ts @@ -0,0 +1,143 @@ +import path from "node:path"; +import fs from "node:fs/promises"; +import sinon, { SinonStub } from "sinon"; + +import type { Stats } from "node:fs"; +import { ManualMock } from "../../../../../src/runner/browser-env/vite/manual-mock"; +import { DEFAULT_AUTOMOCK_DIRECTORY } from "../../../../../src/runner/browser-env/vite/constants"; + +type ManualMockTestData = { + name: string; + opts?: { automock: boolean }; + cb: (manualMock: ManualMock, moduleName?: string) => void; +}; + +describe("runner/browser-env/vite/manual-mock", () => { + const sandbox = sinon.createSandbox(); + + beforeEach(() => { + sandbox.stub(fs, "access").resolves(); + sandbox.stub(fs, "readdir").resolves([]); + sandbox.stub(fs, "stat").resolves({ isDirectory: () => false } as Stats); + }); + + afterEach(() => sandbox.restore()); + + describe("'resolveId' method", () => { + const mockModuleName = "mock-module"; + + ( + [ + { + name: "automock enabled", + opts: { automock: true }, + cb: (): void => {}, + }, + { + name: "mock by user", + cb: (manualMock: ManualMock, moduleName: string = mockModuleName): void => { + manualMock.mock(moduleName); + }, + }, + ] as ManualMockTestData[] + ).forEach(({ name, opts, cb }) => { + describe(name, () => { + describe("should not resolve if", () => { + it("automock directory is not exists", async () => { + const automockDir = path.resolve(DEFAULT_AUTOMOCK_DIRECTORY); + (fs.access as SinonStub).withArgs(automockDir).rejects(new Error("ENOENT")); + + const manualMock = await ManualMock.create({}, opts); + cb(manualMock); + const resolvedId = await manualMock.resolveId(mockModuleName); + + assert.isUndefined(resolvedId); + }); + + it("files are not exist in automock directory", async () => { + const automockDir = path.resolve(DEFAULT_AUTOMOCK_DIRECTORY); + (fs.access as SinonStub).withArgs(automockDir).resolves(); + (fs.readdir as SinonStub).withArgs(automockDir).resolves([]); + + const manualMock = await ManualMock.create({}, opts); + cb(manualMock); + const resolvedId = await manualMock.resolveId(mockModuleName); + + assert.isUndefined(resolvedId); + }); + + it("only directories exist in automock directory", async () => { + const automockDir = path.resolve(DEFAULT_AUTOMOCK_DIRECTORY); + const subDir = path.join(automockDir, "some-dir"); + + (fs.access as SinonStub).withArgs(automockDir).resolves(); + (fs.readdir as SinonStub).withArgs(automockDir).resolves(["some-dir"]); + + (fs.stat as SinonStub).withArgs(subDir).resolves({ isDirectory: () => true }); + (fs.access as SinonStub).withArgs(subDir).resolves(); + (fs.readdir as SinonStub).withArgs(subDir).resolves([]); + + const manualMock = await ManualMock.create({}, opts); + cb(manualMock); + const resolvedId = await manualMock.resolveId(mockModuleName); + + assert.isUndefined(resolvedId); + }); + + it("module umocked by user", async () => { + const automockDir = path.resolve(DEFAULT_AUTOMOCK_DIRECTORY); + const filePath = path.join(automockDir, `${mockModuleName}.js`); + + (fs.access as SinonStub).withArgs(automockDir).resolves(); + (fs.readdir as SinonStub).withArgs(automockDir).resolves([`${mockModuleName}.js`]); + (fs.stat as SinonStub).withArgs(filePath).resolves({ isDirectory: () => false }); + + const manualMock = await ManualMock.create({}, opts); + cb(manualMock); + manualMock.unmock(mockModuleName); + const resolvedId = await manualMock.resolveId(mockModuleName); + + assert.isUndefined(resolvedId); + }); + }); + + describe("should resolve if", () => { + it("mocked module exists in automock directory", async () => { + const automockDir = path.resolve(DEFAULT_AUTOMOCK_DIRECTORY); + const filePath = path.join(automockDir, `${mockModuleName}.js`); + + (fs.access as SinonStub).withArgs(automockDir).resolves(); + (fs.readdir as SinonStub).withArgs(automockDir).resolves([`${mockModuleName}.js`]); + (fs.stat as SinonStub).withArgs(filePath).resolves({ isDirectory: () => false }); + + const manualMock = await ManualMock.create({}, opts); + cb(manualMock); + const resolvedId = await manualMock.resolveId(mockModuleName); + + assert.equal(resolvedId, filePath); + }); + + it("mocked module exists in subdirectory of automock directory", async () => { + const automockDir = path.resolve(DEFAULT_AUTOMOCK_DIRECTORY); + const subDir = path.join(automockDir, "@scope"); + const filePath = path.join(subDir, `${mockModuleName}.js`); + + (fs.access as SinonStub).withArgs(automockDir).resolves(); + (fs.readdir as SinonStub).withArgs(automockDir).resolves(["@scope"]); + (fs.stat as SinonStub).withArgs(subDir).resolves({ isDirectory: () => true }); + + (fs.access as SinonStub).withArgs(subDir).resolves(); + (fs.readdir as SinonStub).withArgs(subDir).resolves([`${mockModuleName}.js`]); + (fs.stat as SinonStub).withArgs(filePath).resolves({ isDirectory: () => false }); + + const manualMock = await ManualMock.create({}, opts); + cb(manualMock, `@scope/${mockModuleName}`); + const resolvedId = await manualMock.resolveId(`@scope/${mockModuleName}`); + + assert.equal(resolvedId, filePath); + }); + }); + }); + }); + }); +}); diff --git a/test/src/runner/browser-env/vite/server.ts b/test/src/runner/browser-env/vite/server.ts index 752247198..e35faba8b 100644 --- a/test/src/runner/browser-env/vite/server.ts +++ b/test/src/runner/browser-env/vite/server.ts @@ -5,6 +5,7 @@ import P from "bluebird"; import chalk from "chalk"; import { ViteServer } from "../../../../../src/runner/browser-env/vite/server"; +import { ManualMock } from "../../../../../src/runner/browser-env/vite/manual-mock"; import logger from "../../../../../src/utils/logger"; import { makeConfigStub } from "../../../../utils"; import { BROWSER_TEST_RUN_ENV } from "../../../../../src/constants/config"; @@ -12,14 +13,14 @@ import { BROWSER_TEST_RUN_ENV } from "../../../../../src/constants/config"; import type { Config } from "../../../../../src/config"; import type { BrowserTestRunEnvOptions } from "../../../../../src/runner/browser-env/vite/types"; -describe("runner/browser-env/vite", () => { +describe("runner/browser-env/vite/server", () => { const sandbox = sinon.createSandbox(); let ViteServerStub: typeof ViteServer; let getPortStub: SinonStub; let createSocketServer: SinonStub; let getNodeModulePathStub: SinonStub; let generateIndexHtmlPlugin: () => Vite.Plugin[]; - let resolveModulePathsPlugin: () => Vite.Plugin[]; + let mockPlugin: () => Vite.Plugin[]; const mkViteServer_ = (opts: Partial = {}): Vite.ViteDevServer => ({ @@ -34,15 +35,10 @@ describe("runner/browser-env/vite", () => { } as Vite.ViteDevServer); const mkConfig_ = (opts?: Partial): Config => makeConfigStub(opts) as Config; - const mkConfigWithVite_ = (viteConfig: BrowserTestRunEnvOptions["viteConfig"]): Config => { + const mkConfigWithVite_ = (options: BrowserTestRunEnvOptions = {}): Config => { return mkConfig_({ system: { - testRunEnv: [ - BROWSER_TEST_RUN_ENV, - { - viteConfig, - }, - ], + testRunEnv: [BROWSER_TEST_RUN_ENV, options], }, } as Partial); }; @@ -55,13 +51,14 @@ describe("runner/browser-env/vite", () => { getPortStub = sandbox.stub().resolves(12345); getNodeModulePathStub = sandbox.stub().resolves("file:///default-cwd"); generateIndexHtmlPlugin = sandbox.stub().returns([{ name: "default-plugin-1" }]); - resolveModulePathsPlugin = sandbox.stub().returns([{ name: "default-plugin-2" }]); + mockPlugin = sandbox.stub().returns([{ name: "default-plugin-2" }]); + sandbox.stub(ManualMock, "create").resolves(sinon.stub() as unknown as ManualMock); ({ ViteServer: ViteServerStub } = proxyquire("../../../../../src/runner/browser-env/vite/server", { "get-port": getPortStub, "./socket": { createSocketServer }, "./plugins/generate-index-html": { plugin: generateIndexHtmlPlugin }, - "./plugins/resolve-module-paths": { plugin: resolveModulePathsPlugin }, + "./plugins/mock": { plugin: mockPlugin }, "./utils": { getNodeModulePath: getNodeModulePathStub }, })); }); @@ -116,7 +113,7 @@ describe("runner/browser-env/vite", () => { describe("with user config from file", () => { it("on specified host and port", async () => { - const config = mkConfigWithVite_("./test/fixtures/vite.conf.ts"); + const config = mkConfigWithVite_({ viteConfig: "./test/fixtures/vite.conf.ts" }); await ViteServerStub.create(config).start(); @@ -135,7 +132,7 @@ describe("runner/browser-env/vite", () => { }, }; }; - const config = mkConfigWithVite_(userConfigFn); + const config = mkConfigWithVite_({ viteConfig: userConfigFn }); await ViteServerStub.create(config).start(); @@ -145,9 +142,10 @@ describe("runner/browser-env/vite", () => { describe("with user config as object", () => { it("on specified host and port", async () => { - const config = mkConfigWithVite_({ + const viteConfig = { server: { host: "2.2.2.2", port: 6000 }, - }); + }; + const config = mkConfigWithVite_({ viteConfig }); await ViteServerStub.create(config).start(); @@ -156,22 +154,53 @@ describe("runner/browser-env/vite", () => { }); it("with plugins", async () => { - const config = mkConfigWithVite_({ + const viteConfig = { plugins: [{ name: "user-plugin-1" }, { name: "user-plugin-2" }], - }); + }; + const config = mkConfigWithVite_({ viteConfig }); (generateIndexHtmlPlugin as SinonStub).resolves([{ name: "gen-index-html" }]); + (mockPlugin as SinonStub).returns([{ name: "mock" }]); await ViteServerStub.create(config).start(); assert.calledOnceWith( Vite.createServer, sinon.match({ - plugins: [{ name: "user-plugin-1" }, { name: "user-plugin-2" }, [{ name: "gen-index-html" }]], + plugins: [ + { name: "user-plugin-1" }, + { name: "user-plugin-2" }, + [{ name: "gen-index-html" }], + [{ name: "mock" }], + ], }), ); }); }); + describe("mock plugin", () => { + it("should create manual mock instance with config and options", async () => { + const options = { + viteConfig: { + server: { host: "2.2.2.2", port: 6000 }, + }, + }; + const config = mkConfigWithVite_(options); + + await ViteServerStub.create(config).start(); + + assert.calledOnceWith(ManualMock.create as SinonStub, sinon.match(options.viteConfig), options); + }); + + it("should init mock plugin with manual mock module", async () => { + const manualMockInstance = sinon.stub(); + (ManualMock.create as SinonStub).resolves(manualMockInstance); + + await ViteServerStub.create(mkConfigWithVite_({})).start(); + + assert.calledOnceWith(mockPlugin, manualMockInstance); + }); + }); + it("should create socket server", async () => { const viteServer = mkViteServer_(); (Vite.createServer as SinonStub).resolves(viteServer);