Skip to content

Commit 21e58bd

Browse files
authored
feat(browser): allow injecting scripts (#5656)
1 parent 30f728b commit 21e58bd

File tree

16 files changed

+193
-26
lines changed

16 files changed

+193
-26
lines changed

Diff for: docs/config/index.md

+49
Original file line numberDiff line numberDiff line change
@@ -1612,6 +1612,55 @@ This option has no effect on tests running inside Node.js.
16121612

16131613
If you rely on spying on ES modules with `vi.spyOn`, you can enable this experimental feature to allow spying on module exports.
16141614

1615+
#### browser.indexScripts <Version>1.6.0</Version> {#browser-indexscripts}
1616+
1617+
- **Type:** `BrowserScript[]`
1618+
- **Default:** `[]`
1619+
1620+
Custom scripts that should be injected into the index HTML before test iframes are initiated. This HTML document only sets up iframes and doesn't actually import your code.
1621+
1622+
The script `src` and `content` will be processed by Vite plugins. Script should be provided in the following shape:
1623+
1624+
```ts
1625+
export interface BrowserScript {
1626+
/**
1627+
* If "content" is provided and type is "module", this will be its identifier.
1628+
*
1629+
* If you are using TypeScript, you can add `.ts` extension here for example.
1630+
* @default `injected-${index}.js`
1631+
*/
1632+
id?: string
1633+
/**
1634+
* JavaScript content to be injected. This string is processed by Vite plugins if type is "module".
1635+
*
1636+
* You can use `id` to give Vite a hint about the file extension.
1637+
*/
1638+
content?: string
1639+
/**
1640+
* Path to the script. This value is resolved by Vite so it can be a node module or a file path.
1641+
*/
1642+
src?: string
1643+
/**
1644+
* If the script should be loaded asynchronously.
1645+
*/
1646+
async?: boolean
1647+
/**
1648+
* Script type.
1649+
* @default 'module'
1650+
*/
1651+
type?: string
1652+
}
1653+
```
1654+
1655+
#### browser.testerScripts <Version>1.6.0</Version> {#browser-testerscripts}
1656+
1657+
- **Type:** `BrowserScript[]`
1658+
- **Default:** `[]`
1659+
1660+
Custom scripts that should be injected into the tester HTML before the tests environment is initiated. This is useful to inject polyfills required for Vitest browser implementation. It is recommended to use [`setupFiles`](#setupfiles) in almost all cases instead of this.
1661+
1662+
The script `src` and `content` will be processed by Vite plugins.
1663+
16151664
### clearMocks
16161665

16171666
- **Type:** `boolean`

Diff for: packages/browser/src/client/index.html

+1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
}
2323
</style>
2424
<script>{__VITEST_INJECTOR__}</script>
25+
{__VITEST_SCRIPTS__}
2526
</head>
2627
<body>
2728
<iframe id="vitest-ui" src=""></iframe>

Diff for: packages/browser/src/client/tester.html

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
}
1717
</style>
1818
<script>{__VITEST_INJECTOR__}</script>
19+
{__VITEST_SCRIPTS__}
1920
</head>
2021
<body>
2122
<script type="module" src="/tester.ts"></script>

Diff for: packages/browser/src/node/index.ts

+33-7
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,14 @@
11
import { fileURLToPath } from 'node:url'
22
import { readFile } from 'node:fs/promises'
3-
import { basename, resolve } from 'pathe'
3+
import { basename, join, resolve } from 'pathe'
44
import sirv from 'sirv'
5-
import type { Plugin } from 'vite'
5+
import type { Plugin, ViteDevServer } from 'vite'
66
import type { ResolvedConfig } from 'vitest'
7-
import type { WorkspaceProject } from 'vitest/node'
7+
import type { BrowserScript, WorkspaceProject } from 'vitest/node'
88
import { coverageConfigDefaults } from 'vitest/config'
9+
import { slash } from '@vitest/utils'
910
import { injectVitestModule } from './esmInjector'
1011

11-
function replacer(code: string, values: Record<string, string>) {
12-
return code.replace(/{\s*(\w+)\s*}/g, (_, key) => values[key] ?? '')
13-
}
14-
1512
export default (project: WorkspaceProject, base = '/'): Plugin[] => {
1613
const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..')
1714
const distRoot = resolve(pkgRoot, 'dist')
@@ -41,6 +38,8 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
4138
}
4239
next()
4340
})
41+
let indexScripts: string | undefined
42+
let testerScripts: string | undefined
4443
server.middlewares.use(async (req, res, next) => {
4544
if (!req.url)
4645
return next()
@@ -63,9 +62,13 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
6362
})
6463

6564
if (url.pathname === base) {
65+
if (!indexScripts)
66+
indexScripts = await formatScripts(project.config.browser.indexScripts, server)
67+
6668
const html = replacer(await runnerHtml, {
6769
__VITEST_FAVICON__: favicon,
6870
__VITEST_TITLE__: 'Vitest Browser Runner',
71+
__VITEST_SCRIPTS__: indexScripts,
6972
__VITEST_INJECTOR__: injector,
7073
})
7174
res.write(html, 'utf-8')
@@ -77,9 +80,13 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
7780
// if decoded test file is "__vitest_all__" or not in the list of known files, run all tests
7881
const tests = decodedTestFile === '__vitest_all__' || !files.includes(decodedTestFile) ? '__vitest_browser_runner__.files' : JSON.stringify([decodedTestFile])
7982

83+
if (!testerScripts)
84+
testerScripts = await formatScripts(project.config.browser.testerScripts, server)
85+
8086
const html = replacer(await testerHtml, {
8187
__VITEST_FAVICON__: favicon,
8288
__VITEST_TITLE__: 'Vitest Browser Tester',
89+
__VITEST_SCRIPTS__: testerScripts,
8390
__VITEST_INJECTOR__: injector,
8491
__VITEST_APPEND__:
8592
// TODO: have only a single global variable to not pollute the global scope
@@ -233,3 +240,22 @@ function wrapConfig(config: ResolvedConfig): ResolvedConfig {
233240
: undefined,
234241
}
235242
}
243+
244+
function replacer(code: string, values: Record<string, string>) {
245+
return code.replace(/{\s*(\w+)\s*}/g, (_, key) => values[key] ?? '')
246+
}
247+
248+
async function formatScripts(scripts: BrowserScript[] | undefined, server: ViteDevServer) {
249+
if (!scripts?.length)
250+
return ''
251+
const promises = scripts.map(async ({ content, src, async, id, type = 'module' }, index) => {
252+
const srcLink = (src ? (await server.pluginContainer.resolveId(src))?.id : undefined) || src
253+
const transformId = srcLink || join(server.config.root, `virtual__${id || `injected-${index}.js`}`)
254+
await server.moduleGraph.ensureEntryFromUrl(transformId)
255+
const contentProcessed = content && type === 'module'
256+
? (await server.pluginContainer.transform(content, transformId)).code
257+
: content
258+
return `<script type="${type}"${async ? ' async' : ''}${srcLink ? ` src="${slash(`/@fs/${srcLink}`)}"` : ''}>${contentProcessed || ''}</script>`
259+
})
260+
return (await Promise.all(promises)).join('\n')
261+
}

Diff for: packages/vitest/src/node/cli/cli-config.ts

+2
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,8 @@ export const cliOptionsConfig: VitestCLIOptions = {
350350
fileParallelism: {
351351
description: 'Should all test files run in parallel. Use `--browser.file-parallelism=false` to disable (default: same as `--file-parallelism`)',
352352
},
353+
indexScripts: null,
354+
testerScripts: null,
353355
},
354356
},
355357
pool: {

Diff for: packages/vitest/src/node/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@ export { VitestPackageInstaller } from './packageInstaller'
1313
export type { TestSequencer, TestSequencerConstructor } from './sequencers/types'
1414
export { BaseSequencer } from './sequencers/BaseSequencer'
1515

16-
export type { BrowserProviderInitializationOptions, BrowserProvider, BrowserProviderOptions } from '../types/browser'
16+
export type { BrowserProviderInitializationOptions, BrowserProvider, BrowserProviderOptions, BrowserScript } from '../types/browser'

Diff for: packages/vitest/src/types/browser.ts

+39
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,45 @@ export interface BrowserConfigOptions {
9393
* @default test.fileParallelism
9494
*/
9595
fileParallelism?: boolean
96+
97+
/**
98+
* Scripts injected into the tester iframe.
99+
*/
100+
testerScripts?: BrowserScript[]
101+
102+
/**
103+
* Scripts injected into the main window.
104+
*/
105+
indexScripts?: BrowserScript[]
106+
}
107+
108+
export interface BrowserScript {
109+
/**
110+
* If "content" is provided and type is "module", this will be its identifier.
111+
*
112+
* If you are using TypeScript, you can add `.ts` extension here for example.
113+
* @default `injected-${index}.js`
114+
*/
115+
id?: string
116+
/**
117+
* JavaScript content to be injected. This string is processed by Vite plugins if type is "module".
118+
*
119+
* You can use `id` to give Vite a hint about the file extension.
120+
*/
121+
content?: string
122+
/**
123+
* Path to the script. This value is resolved by Vite so it can be a node module or a file path.
124+
*/
125+
src?: string
126+
/**
127+
* If the script should be loaded asynchronously.
128+
*/
129+
async?: boolean
130+
/**
131+
* Script type.
132+
* @default 'module'
133+
*/
134+
type?: string
96135
}
97136

98137
export interface ResolvedBrowserOptions extends BrowserConfigOptions {

Diff for: packages/vitest/src/types/config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type { BenchmarkUserOptions } from './benchmark'
1616
import type { BrowserConfigOptions, ResolvedBrowserOptions } from './browser'
1717
import type { Pool, PoolOptions } from './pool-options'
1818

19+
export type { BrowserScript, BrowserConfigOptions } from './browser'
1920
export type { SequenceHooks, SequenceSetupFiles } from '@vitest/runner'
2021

2122
export type BuiltinEnvironment = 'node' | 'jsdom' | 'happy-dom' | 'edge-runtime'

Diff for: pnpm-lock.yaml

+7-15
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: test/browser/injected-lib/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__injected.push(4)

Diff for: test/browser/injected-lib/package.json

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"name": "@vitest/injected-lib",
3+
"type": "module",
4+
"exports": {
5+
"default": "./index.js"
6+
}
7+
}

Diff for: test/browser/injected.ts

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// @ts-expect-error not typed global
2+
;(__injected as string[]).push(3)

Diff for: test/browser/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@vitejs/plugin-basic-ssl": "^1.0.2",
1616
"@vitest/browser": "workspace:*",
1717
"@vitest/cjs-lib": "link:./cjs-lib",
18+
"@vitest/injected-lib": "link:./injected-lib",
1819
"execa": "^7.1.1",
1920
"playwright": "^1.41.0",
2021
"url": "^0.11.3",

Diff for: test/browser/specs/runner.test.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { beforeAll, describe, expect, test } from 'vitest'
1+
import { beforeAll, describe, expect, onTestFailed, test } from 'vitest'
22
import { runBrowserTests } from './utils'
33

44
describe.each([
@@ -26,8 +26,12 @@ describe.each([
2626
})
2727

2828
test(`[${description}] tests are actually running`, () => {
29-
expect(browserResultJson.testResults).toHaveLength(14)
30-
expect(passedTests).toHaveLength(12)
29+
onTestFailed(() => {
30+
console.error(stderr)
31+
})
32+
33+
expect(browserResultJson.testResults).toHaveLength(15)
34+
expect(passedTests).toHaveLength(13)
3135
expect(failedTests).toHaveLength(2)
3236

3337
expect(stderr).not.toContain('has been externalized for browser compatibility')

Diff for: test/browser/test/injected.test.ts

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { expect, test } from 'vitest'
2+
3+
test('injected values are correct', () => {
4+
expect((globalThis as any).__injected).toEqual([
5+
1,
6+
2,
7+
3,
8+
4,
9+
])
10+
})

Diff for: test/browser/vitest.config.mts

+31
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,37 @@ export default defineConfig({
2929
provider,
3030
isolate: false,
3131
slowHijackESM: true,
32+
testerScripts: [
33+
{
34+
content: 'globalThis.__injected = []',
35+
type: 'text/javascript',
36+
},
37+
{
38+
content: '__injected.push(1)',
39+
},
40+
{
41+
id: 'ts.ts',
42+
content: '(__injected as string[]).push(2)',
43+
},
44+
{
45+
src: './injected.ts',
46+
},
47+
{
48+
src: '@vitest/injected-lib',
49+
},
50+
],
51+
indexScripts: [
52+
{
53+
content: 'console.log("Hello, World");globalThis.__injected = []',
54+
type: 'text/javascript',
55+
},
56+
{
57+
content: 'import "./injected.ts"',
58+
},
59+
{
60+
content: 'if(__injected[0] !== 3) throw new Error("injected not working")',
61+
},
62+
],
3263
},
3364
alias: {
3465
'#src': resolve(dir, './src'),

0 commit comments

Comments
 (0)