Skip to content

Commit c545d0a

Browse files
committed
feat: support autospy in the browser
1 parent 12a00c5 commit c545d0a

File tree

6 files changed

+80
-31
lines changed

6 files changed

+80
-31
lines changed

packages/browser/src/client/channel.ts

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export interface IframeMockEvent {
2626
type: 'mock'
2727
paths: string[]
2828
mock: string | undefined | null
29+
behaviour: 'autospy' | 'automock' | 'manual'
2930
}
3031

3132
export interface IframeUnmockEvent {

packages/browser/src/client/tester/mocker.ts

+49-21
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ interface SpyModule {
1414
export class VitestBrowserClientMocker {
1515
private queue = new Set<Promise<void>>()
1616
private mocks: Record<string, undefined | null | string> = {}
17+
private behaviours: Record<string, 'autospy' | 'automock' | 'manual'> = {}
1718
private mockObjects: Record<string, any> = {}
1819
private factories: Record<string, () => any> = {}
1920
private ids = new Set<string>()
@@ -91,14 +92,20 @@ export class VitestBrowserClientMocker {
9192
return await this.resolve(resolvedId)
9293
}
9394

94-
if (type === 'redirect') {
95-
const url = new URL(`/@id/${mockPath}`, location.href)
96-
return import(/* @vite-ignore */ url.toString())
95+
const behavior = this.behaviours[resolvedId] || 'automock'
96+
97+
if (type === 'automock' || behavior === 'autospy') {
98+
const url = new URL(`/@id/${resolvedId}`, location.href)
99+
const query = url.search ? `${url.search}&t=${now()}` : `?t=${now()}`
100+
const moduleObject = await import(/* @vite-ignore */ `${url.pathname}${query}&mock=${behavior}${url.hash}`)
101+
return this.mockObject(moduleObject, {}, behavior)
102+
}
103+
104+
if (typeof mockPath !== 'string') {
105+
throw new TypeError(`Mock path is not a string: ${mockPath}. This is a bug in Vitest. Please, open a new issue with reproduction.`)
97106
}
98-
const url = new URL(`/@id/${resolvedId}`, location.href)
99-
const query = url.search ? `${url.search}&t=${now()}` : `?t=${now()}`
100-
const moduleObject = await import(/* @vite-ignore */ `${url.pathname}${query}${url.hash}`)
101-
return this.mockObject(moduleObject)
107+
const url = new URL(`/@id/${mockPath}`, location.href)
108+
return import(/* @vite-ignore */ url.toString())
102109
}
103110

104111
public getMockContext() {
@@ -142,32 +149,35 @@ export class VitestBrowserClientMocker {
142149
}
143150
}
144151

145-
public queueMock(id: string, importer: string, factory?: () => any) {
152+
public queueMock(id: string, importer: string, factoryOrOptions?: MockOptions | (() => any)) {
146153
const promise = rpc()
147-
.resolveMock(id, importer, !!factory)
154+
.resolveMock(id, importer, typeof factoryOrOptions === 'function')
148155
.then(async ({ mockPath, resolvedId, needsInterop }) => {
149156
this.ids.add(resolvedId)
150157
const urlPaths = resolveMockPaths(cleanVersion(resolvedId))
151158
const resolvedMock
152159
= typeof mockPath === 'string'
153160
? new URL(resolvedMockedPath(cleanVersion(mockPath)), location.href).toString()
154161
: mockPath
155-
const _factory = factory && needsInterop
162+
const factory = typeof factoryOrOptions === 'function'
156163
? async () => {
157-
const data = await factory()
158-
return { default: data }
164+
const data = await factoryOrOptions()
165+
return needsInterop ? { default: data } : data
159166
}
160-
: factory
167+
: undefined
168+
const behaviour = getMockBehaviour(factoryOrOptions)
161169
urlPaths.forEach((url) => {
162170
this.mocks[url] = resolvedMock
163-
if (_factory) {
164-
this.factories[url] = _factory
171+
if (factory) {
172+
this.factories[url] = factory
165173
}
174+
this.behaviours[url] = behaviour
166175
})
167176
channel.postMessage({
168177
type: 'mock',
169178
paths: urlPaths,
170179
mock: resolvedMock,
180+
behaviour,
171181
})
172182
await waitForChannel('mock:done')
173183
})
@@ -214,6 +224,7 @@ export class VitestBrowserClientMocker {
214224
public mockObject(
215225
object: Record<Key, any>,
216226
mockExports: Record<Key, any> = {},
227+
behaviour: 'autospy' | 'automock' | 'manual' = 'automock',
217228
) {
218229
const finalizers = new Array<() => void>()
219230
const refs = new RefTracker()
@@ -330,13 +341,14 @@ export class VitestBrowserClientMocker {
330341
}
331342
}
332343
}
333-
const mock = spyModule
334-
.spyOn(newContainer, property)
335-
.mockImplementation(mockFunction)
336-
mock.mockRestore = () => {
337-
mock.mockReset()
344+
const mock = spyModule.spyOn(newContainer, property)
345+
if (behaviour === 'automock') {
338346
mock.mockImplementation(mockFunction)
339-
return mock
347+
mock.mockRestore = () => {
348+
mock.mockReset()
349+
mock.mockImplementation(mockFunction)
350+
return mock
351+
}
340352
}
341353
// tinyspy retains length, but jest doesn't.
342354
Object.defineProperty(newContainer[property], 'length', { value: 0 })
@@ -465,3 +477,19 @@ const versionRegexp = /(\?|&)v=\w{8}/
465477
function cleanVersion(url: string) {
466478
return url.replace(versionRegexp, '')
467479
}
480+
481+
export interface MockOptions {
482+
spy?: boolean
483+
}
484+
485+
export type MockBehaviour = 'autospy' | 'automock' | 'manual'
486+
487+
function getMockBehaviour(factoryOrOptions?: (() => void) | MockOptions): MockBehaviour {
488+
if (!factoryOrOptions) {
489+
return 'automock'
490+
}
491+
if (typeof factoryOrOptions === 'function') {
492+
return 'manual'
493+
}
494+
return factoryOrOptions.spy ? 'autospy' : 'automock'
495+
}

packages/browser/src/client/tester/msw.ts

+9-6
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import type {
77
} from '@vitest/browser/client'
88

99
export function createModuleMocker() {
10-
const mocks: Map<string, string | null | undefined> = new Map()
10+
const mocks: Map<string, {
11+
mock: string | null | undefined
12+
behaviour: 'automock' | 'autospy' | 'manual'
13+
}> = new Map()
1114

1215
let started = false
1316
let startPromise: undefined | Promise<unknown>
@@ -34,7 +37,7 @@ export function createModuleMocker() {
3437
return passthrough()
3538
}
3639

37-
const mock = mocks.get(path)
40+
const { mock, behaviour } = mocks.get(path)!
3841

3942
// using a factory
4043
if (mock === undefined) {
@@ -56,11 +59,11 @@ export function createModuleMocker() {
5659
})
5760
}
5861

59-
if (typeof mock === 'string') {
60-
return Response.redirect(mock)
62+
if (behaviour === 'autospy' || mock === null) {
63+
return Response.redirect(injectQuery(path, `mock=${behaviour}`))
6164
}
6265

63-
return Response.redirect(injectQuery(path, 'mock=auto'))
66+
return Response.redirect(mock)
6467
}),
6568
)
6669
return worker.start({
@@ -80,7 +83,7 @@ export function createModuleMocker() {
8083
return {
8184
async mock(event: IframeMockEvent) {
8285
await init()
83-
event.paths.forEach(path => mocks.set(path, event.mock))
86+
event.paths.forEach(path => mocks.set(path, { mock: event.mock, behaviour: event.behaviour }))
8487
channel.postMessage(<IframeMockingDoneEvent>{ type: 'mock:done' })
8588
},
8689
async unmock(event: IframeUnmockEvent) {

packages/vitest/src/node/automock.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type {
1212
import MagicString from 'magic-string'
1313

1414
// TODO: better source map replacement
15-
export function automockModule(code: string, parse: (code: string) => Program) {
15+
export function automockModule(code: string, behavior: 'automock' | 'autospy', parse: (code: string) => Program) {
1616
const ast = parse(code)
1717

1818
const m = new MagicString(code)
@@ -145,7 +145,7 @@ const __vitest_es_current_module__ = {
145145
__esModule: true,
146146
${allSpecifiers.map(({ name }) => `["${name}"]: ${name},`).join('\n ')}
147147
}
148-
const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__)
148+
const __vitest_mocked_module__ = __vitest_mocker__.mockObject(__vitest_es_current_module__, {}, "${behavior}")
149149
`
150150
const assigning = allSpecifiers
151151
.map(({ name }, index) => {

packages/vitest/src/node/plugins/mocks.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ export function MocksPlugins(): Plugin[] {
2020
name: 'vitest:automock',
2121
enforce: 'post',
2222
transform(code, id) {
23-
if (id.includes('mock=auto')) {
24-
const ms = automockModule(code, this.parse)
23+
if (id.includes('mock=automock') || id.includes('mock=autospy')) {
24+
const behavior = id.includes('mock=automock') ? 'automock' : 'autospy'
25+
const ms = automockModule(code, behavior, this.parse)
2526
return {
2627
code: ms.toString(),
2728
map: ms.generateMap({ hires: true, source: cleanUrl(id) }),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { expect, test, vi } from 'vitest'
2+
import { calculator } from './src/calculator'
3+
import * as mocks_calculator from './src/mocks_calculator'
4+
5+
vi.mock('./src/calculator', { spy: true })
6+
vi.mock('./src/mocks_calculator', { spy: true })
7+
8+
test('correctly spies on a regular module', () => {
9+
expect(calculator('plus', 1, 2)).toBe(3)
10+
expect(calculator).toHaveBeenCalled()
11+
})
12+
13+
test('spy options overrides __mocks__ folder', () => {
14+
expect(mocks_calculator.calculator('plus', 1, 2)).toBe(3)
15+
expect(mocks_calculator.calculator).toHaveBeenCalled()
16+
})

0 commit comments

Comments
 (0)