Skip to content

Commit 7b2f64c

Browse files
authored
feat: implement module mocking in browser mode (#5765)
1 parent b84f172 commit 7b2f64c

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

75 files changed

+1743
-1509
lines changed

docs/api/vi.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,6 @@ In order to hoist `vi.mock`, Vitest statically analyzes your files. It indicates
2929
Vitest will not mock modules that were imported inside a [setup file](/config/#setupfiles) because they are cached by the time a test file is running. You can call [`vi.resetModules()`](#vi-resetmodules) inside [`vi.hoisted`](#vi-hoisted) to clear all module caches before running a test file.
3030
:::
3131

32-
::: warning
33-
The [browser mode](/guide/browser) does not presently support mocking modules. You can track this feature in the GitHub [issue](https://github.com/vitest-dev/vitest/issues/3046).
34-
:::
35-
3632
If `factory` is defined, all imports will return its result. Vitest calls factory only once and caches results for all subsequent imports until [`vi.unmock`](#vi-unmock) or [`vi.doUnmock`](#vi-dounmock) is called.
3733

3834
Unlike in `jest`, the factory can be asynchronous. You can use [`vi.importActual`](#vi-importactual) or a helper with the factory passed in as the first argument, and get the original module inside.

docs/config/index.md

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1487,7 +1487,7 @@ Listen to port and serve API. When set to true, the default port is 51204
14871487

14881488
### browser {#browser}
14891489

1490-
- **Type:** `{ enabled?, name?, provider?, headless?, api?, slowHijackESM? }`
1490+
- **Type:** `{ enabled?, name?, provider?, headless?, api? }`
14911491
- **Default:** `{ enabled: false, headless: process.env.CI, api: 63315 }`
14921492
- **CLI:** `--browser`, `--browser=<name>`, `--browser.name=chrome --browser.headless`
14931493

@@ -1601,17 +1601,6 @@ To have a better type safety when using built-in providers, you can add one of t
16011601
```
16021602
:::
16031603

1604-
#### browser.slowHijackESM {#browser-slowhijackesm}
1605-
1606-
- **Type:** `boolean`
1607-
- **Default:** `false`
1608-
1609-
When running tests in Node.js Vitest can use its own module resolution to easily mock modules with `vi.mock` syntax. However it's not so easy to replicate ES module resolution in browser, so we need to transform your source files before browser can consume it.
1610-
1611-
This option has no effect on tests running inside Node.js.
1612-
1613-
If you rely on spying on ES modules with `vi.spyOn`, you can enable this experimental feature to allow spying on module exports.
1614-
16151604
#### browser.ui {#browser-ui}
16161605

16171606
- **Type:** `boolean`

docs/guide/cli-table.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@
5555
| `--browser.api.strictPort` | Set to true to exit if port is already in use, instead of automatically trying the next available port |
5656
| `--browser.provider <name>` | Provider used to run browser tests. Some browsers are only available for specific providers. Can be "webdriverio", "playwright", or the path to a custom provider. Visit [`browser.provider`](https://vitest.dev/config/#browser-provider) for more information (default: `"webdriverio"`) |
5757
| `--browser.providerOptions <options>` | Options that are passed down to a browser provider. Visit [`browser.providerOptions`](https://vitest.dev/config/#browser-provideroptions) for more information |
58-
| `--browser.slowHijackESM` | Let Vitest use its own module resolution on the browser to enable APIs such as vi.mock and vi.spyOn. Visit [`browser.slowHijackESM`](https://vitest.dev/config/#browser-slowhijackesm) for more information (default: `false`) |
5958
| `--browser.isolate` | Run every browser test file in isolation. To disable isolation, use `--browser.isolate=false` (default: `true`) |
6059
| `--pool <pool>` | Specify pool, if not running in the browser (default: `threads`) |
6160
| `--poolOptions.threads.isolate` | Isolate tests in threads pool (default: `true`) |

packages/browser/context.d.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ export const server: {
6161
* Name of the browser provider.
6262
*/
6363
provider: string
64+
/**
65+
* Name of the current browser.
66+
*/
67+
browser: string
6468
/**
6569
* Available commands for the browser.
6670
* @see {@link https://vitest.dev/guide/browser#commands}

packages/browser/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@
7979
"@vitest/ui": "workspace:*",
8080
"@vitest/ws-client": "workspace:*",
8181
"@wdio/protocols": "^8.32.0",
82+
"birpc": "0.2.17",
83+
"flatted": "^3.3.1",
8284
"periscopic": "^4.0.2",
8385
"playwright": "^1.44.0",
8486
"playwright-core": "^1.44.0",
Lines changed: 101 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,116 @@
11
import type { CancelReason } from '@vitest/runner'
2-
import { createClient } from '@vitest/ws-client'
2+
import { type BirpcReturn, createBirpc } from 'birpc'
3+
import type { WebSocketBrowserEvents, WebSocketBrowserHandlers } from 'vitest'
4+
import { parse, stringify } from 'flatted'
5+
import type { VitestBrowserClientMocker } from './mocker'
6+
import { getBrowserState } from './utils'
37

48
export const PORT = import.meta.hot ? '51204' : location.port
59
export const HOST = [location.hostname, PORT].filter(Boolean).join(':')
10+
export const SESSION_ID = crypto.randomUUID()
611
export const ENTRY_URL = `${
712
location.protocol === 'https:' ? 'wss:' : 'ws:'
8-
}//${HOST}/__vitest_api__`
13+
}//${HOST}/__vitest_browser_api__?type=${getBrowserState().type}&sessionId=${SESSION_ID}`
914

1015
let setCancel = (_: CancelReason) => {}
1116
export const onCancel = new Promise<CancelReason>((resolve) => {
1217
setCancel = resolve
1318
})
1419

15-
export const client = createClient(ENTRY_URL, {
16-
handlers: {
20+
export interface VitestBrowserClient {
21+
rpc: BrowserRPC
22+
ws: WebSocket
23+
waitForConnection: () => Promise<void>
24+
}
25+
26+
type BrowserRPC = BirpcReturn<WebSocketBrowserHandlers, WebSocketBrowserEvents>
27+
28+
function createClient() {
29+
const autoReconnect = true
30+
const reconnectInterval = 2000
31+
const reconnectTries = 10
32+
const connectTimeout = 60000
33+
34+
let tries = reconnectTries
35+
36+
const ctx: VitestBrowserClient = {
37+
ws: new WebSocket(ENTRY_URL),
38+
waitForConnection,
39+
} as VitestBrowserClient
40+
41+
let onMessage: Function
42+
43+
ctx.rpc = createBirpc<WebSocketBrowserHandlers, WebSocketBrowserEvents>({
1744
onCancel: setCancel,
18-
},
19-
})
45+
async startMocking(id: string) {
46+
// @ts-expect-error not typed global
47+
if (typeof __vitest_mocker__ === 'undefined')
48+
throw new Error(`Cannot mock modules in the orchestrator process`)
49+
// @ts-expect-error not typed global
50+
const mocker = __vitest_mocker__ as VitestBrowserClientMocker
51+
const exports = await mocker.resolve(id)
52+
return Object.keys(exports)
53+
},
54+
}, {
55+
post: msg => ctx.ws.send(msg),
56+
on: fn => (onMessage = fn),
57+
serialize: e => stringify(e, (_, v) => {
58+
if (v instanceof Error) {
59+
return {
60+
name: v.name,
61+
message: v.message,
62+
stack: v.stack,
63+
}
64+
}
65+
return v
66+
}),
67+
deserialize: parse,
68+
onTimeoutError(functionName) {
69+
throw new Error(`[vitest-browser]: Timeout calling "${functionName}"`)
70+
},
71+
})
72+
73+
let openPromise: Promise<void>
74+
75+
function reconnect(reset = false) {
76+
if (reset)
77+
tries = reconnectTries
78+
ctx.ws = new WebSocket(ENTRY_URL)
79+
registerWS()
80+
}
81+
82+
function registerWS() {
83+
openPromise = new Promise((resolve, reject) => {
84+
const timeout = setTimeout(() => {
85+
reject(new Error(`Cannot connect to the server in ${connectTimeout / 1000} seconds`))
86+
}, connectTimeout)?.unref?.()
87+
if (ctx.ws.OPEN === ctx.ws.readyState)
88+
resolve()
89+
// still have a listener even if it's already open to update tries
90+
ctx.ws.addEventListener('open', () => {
91+
tries = reconnectTries
92+
resolve()
93+
clearTimeout(timeout)
94+
})
95+
})
96+
ctx.ws.addEventListener('message', (v) => {
97+
onMessage(v.data)
98+
})
99+
ctx.ws.addEventListener('close', () => {
100+
tries -= 1
101+
if (autoReconnect && tries > 0)
102+
setTimeout(reconnect, reconnectInterval)
103+
})
104+
}
105+
106+
registerWS()
107+
108+
function waitForConnection() {
109+
return openPromise
110+
}
111+
112+
return ctx
113+
}
20114

115+
export const client = createClient()
21116
export const channel = new BroadcastChannel('vitest')

packages/browser/src/client/logger.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,11 @@ export async function setupConsoleLogSpy() {
5353

5454
console.trace = (...args: unknown[]) => {
5555
const content = processLog(args)
56-
const error = new Error('Trace')
57-
const stack = (error.stack || '').split('\n').slice(2).join('\n')
56+
const error = new Error('$$Trace')
57+
const stack = (error.stack || '')
58+
.split('\n')
59+
.slice(error.stack?.includes('$$Trace') ? 2 : 1)
60+
.join('\n')
5861
sendLog('stdout', `${content}\n${stack}`)
5962
return trace(...args)
6063
}

packages/browser/src/client/main.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,15 @@ client.ws.addEventListener('open', async () => {
6969
const filenames = e.data.filenames
7070
filenames.forEach(filename => runningFiles.delete(filename))
7171

72-
const iframeId = filenames.length > 1 ? ID_ALL : filenames[0]
73-
iframes.get(iframeId)?.remove()
74-
iframes.delete(iframeId)
75-
76-
if (!runningFiles.size)
72+
if (!runningFiles.size) {
7773
await done()
74+
}
75+
else {
76+
// keep the last iframe
77+
const iframeId = filenames.length > 1 ? ID_ALL : filenames[0]
78+
iframes.get(iframeId)?.remove()
79+
iframes.delete(iframeId)
80+
}
7881
break
7982
}
8083
// error happened at the top level, this should never happen in user code, but it can trigger during development

0 commit comments

Comments
 (0)