Skip to content

Commit 169028f

Browse files
authored
feat(browser): allow custom HTML path, respect plugins transformIndexHtml (#6725)
1 parent 5df7414 commit 169028f

22 files changed

+450
-102
lines changed

docs/config/index.md

+8
Original file line numberDiff line numberDiff line change
@@ -1640,6 +1640,14 @@ Run the browser in a `headless` mode. If you are running Vitest in CI, it will b
16401640

16411641
Run every test in a separate iframe.
16421642

1643+
#### browser.testerHtmlPath
1644+
1645+
- **Type:** `string`
1646+
- **Default:** `@vitest/browser/tester.html`
1647+
- **Version:** Since Vitest 2.1.4
1648+
1649+
A path to the HTML entry point. Can be relative to the root of the project. This file will be processed with [`transformIndexHtml`](https://vite.dev/guide/api-plugin#transformindexhtml) hook.
1650+
16431651
#### browser.api
16441652

16451653
- **Type:** `number | { port?, strictPort?, host? }`

packages/browser/rollup.config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export default () =>
107107
input: './src/client/tester/state.ts',
108108
output: {
109109
file: 'dist/state.js',
110-
format: 'esm',
110+
format: 'iife',
111111
},
112112
plugins: [
113113
esbuild({
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,52 @@
1-
const moduleCache = new Map();
2-
3-
function wrapModule(module) {
4-
if (typeof module === "function") {
5-
const promise = new Promise((resolve, reject) => {
6-
if (typeof __vitest_mocker__ === "undefined")
7-
return module().then(resolve, reject);
8-
__vitest_mocker__.prepare().finally(() => {
9-
module().then(resolve, reject);
1+
(() => {
2+
const moduleCache = new Map();
3+
4+
function wrapModule(module) {
5+
if (typeof module === "function") {
6+
const promise = new Promise((resolve, reject) => {
7+
if (typeof __vitest_mocker__ === "undefined")
8+
return module().then(resolve, reject);
9+
__vitest_mocker__.prepare().finally(() => {
10+
module().then(resolve, reject);
11+
});
1012
});
11-
});
12-
moduleCache.set(promise, { promise, evaluated: false });
13-
return promise.finally(() => moduleCache.delete(promise));
13+
moduleCache.set(promise, { promise, evaluated: false });
14+
return promise.finally(() => moduleCache.delete(promise));
15+
}
16+
return module;
1417
}
15-
return module;
16-
}
17-
18-
window.__vitest_browser_runner__ = {
19-
wrapModule,
20-
wrapDynamicImport: wrapModule,
21-
moduleCache,
22-
config: { __VITEST_CONFIG__ },
23-
viteConfig: { __VITEST_VITE_CONFIG__ },
24-
files: { __VITEST_FILES__ },
25-
type: { __VITEST_TYPE__ },
26-
contextId: { __VITEST_CONTEXT_ID__ },
27-
testerId: { __VITEST_TESTER_ID__ },
28-
provider: { __VITEST_PROVIDER__ },
29-
providedContext: { __VITEST_PROVIDED_CONTEXT__ },
30-
};
31-
32-
const config = __vitest_browser_runner__.config;
33-
34-
if (config.testNamePattern)
35-
config.testNamePattern = parseRegexp(config.testNamePattern);
36-
37-
function parseRegexp(input) {
38-
// Parse input
39-
const m = input.match(/(\/?)(.+)\1([a-z]*)/i);
40-
41-
// match nothing
42-
if (!m) return /$^/;
43-
44-
// Invalid flags
45-
if (m[3] && !/^(?!.*?(.).*?\1)[gmixXsuUAJ]+$/.test(m[3]))
46-
return RegExp(input);
47-
48-
// Create the regular expression
49-
return new RegExp(m[2], m[3]);
50-
}
18+
19+
window.__vitest_browser_runner__ = {
20+
wrapModule,
21+
wrapDynamicImport: wrapModule,
22+
moduleCache,
23+
config: { __VITEST_CONFIG__ },
24+
viteConfig: { __VITEST_VITE_CONFIG__ },
25+
files: { __VITEST_FILES__ },
26+
type: { __VITEST_TYPE__ },
27+
contextId: { __VITEST_CONTEXT_ID__ },
28+
testerId: { __VITEST_TESTER_ID__ },
29+
provider: { __VITEST_PROVIDER__ },
30+
providedContext: { __VITEST_PROVIDED_CONTEXT__ },
31+
};
32+
33+
const config = __vitest_browser_runner__.config;
34+
35+
if (config.testNamePattern)
36+
config.testNamePattern = parseRegexp(config.testNamePattern);
37+
38+
function parseRegexp(input) {
39+
// Parse input
40+
const m = input.match(/(\/?)(.+)\1([a-z]*)/i);
41+
42+
// match nothing
43+
if (!m) return /$^/;
44+
45+
// Invalid flags
46+
if (m[3] && !/^(?!.*?(.).*?\1)[gmixXsuUAJ]+$/.test(m[3]))
47+
return RegExp(input);
48+
49+
// Create the regular expression
50+
return new RegExp(m[2], m[3]);
51+
}
52+
})();

packages/browser/src/client/tester/tester.html

+1-6
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<meta charset="UTF-8" />
55
<link rel="icon" href="{__VITEST_FAVICON__}" type="image/svg+xml">
66
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7-
<title>{__VITEST_TITLE__}</title>
7+
<title>Vitest Browser Tester</title>
88
<style>
99
html {
1010
padding: 0;
@@ -16,13 +16,8 @@
1616
min-height: 100vh;
1717
}
1818
</style>
19-
{__VITEST_INJECTOR__}
20-
<script>{__VITEST_STATE__}</script>
21-
{__VITEST_INTERNAL_SCRIPTS__}
22-
{__VITEST_SCRIPTS__}
2319
</head>
2420
<body>
2521
<script type="module" src="./tester.ts"></script>
26-
{__VITEST_APPEND__}
2722
</body>
2823
</html>

packages/browser/src/node/plugin.ts

+102-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Stats } from 'node:fs'
2+
import type { HtmlTagDescriptor } from 'vite'
23
import type { WorkspaceProject } from 'vitest/node'
34
import type { BrowserServer } from './server'
45
import { lstatSync, readFileSync } from 'node:fs'
@@ -72,9 +73,11 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => {
7273
return
7374
}
7475

75-
const html = await resolveTester(browserServer, url, res)
76-
res.write(html, 'utf-8')
77-
res.end()
76+
const html = await resolveTester(browserServer, url, res, next)
77+
if (html) {
78+
res.write(html, 'utf-8')
79+
res.end()
80+
}
7881
})
7982

8083
server.middlewares.use(
@@ -394,6 +397,102 @@ export default (browserServer: BrowserServer, base = '/'): Plugin[] => {
394397
}
395398
},
396399
},
400+
{
401+
name: 'vitest:browser:transform-tester-html',
402+
enforce: 'pre',
403+
async transformIndexHtml(html, ctx) {
404+
if (!ctx.path.startsWith(browserServer.prefixTesterUrl)) {
405+
return
406+
}
407+
408+
if (!browserServer.testerScripts) {
409+
const testerScripts = await browserServer.formatScripts(
410+
project.config.browser.testerScripts,
411+
)
412+
browserServer.testerScripts = testerScripts
413+
}
414+
const stateJs = typeof browserServer.stateJs === 'string'
415+
? browserServer.stateJs
416+
: await browserServer.stateJs
417+
418+
const testerScripts: HtmlTagDescriptor[] = []
419+
if (resolve(distRoot, 'client/tester/tester.html') !== browserServer.testerFilepath) {
420+
const manifestContent = browserServer.manifest instanceof Promise
421+
? await browserServer.manifest
422+
: browserServer.manifest
423+
const testerEntry = manifestContent['tester/tester.html']
424+
425+
testerScripts.push({
426+
tag: 'script',
427+
attrs: {
428+
type: 'module',
429+
crossorigin: '',
430+
src: `${browserServer.base}${testerEntry.file}`,
431+
},
432+
injectTo: 'head',
433+
})
434+
435+
for (const importName of testerEntry.imports || []) {
436+
const entryManifest = manifestContent[importName]
437+
if (entryManifest) {
438+
testerScripts.push(
439+
{
440+
tag: 'link',
441+
attrs: {
442+
href: `${browserServer.base}${entryManifest.file}`,
443+
rel: 'modulepreload',
444+
crossorigin: '',
445+
},
446+
injectTo: 'head',
447+
},
448+
)
449+
}
450+
}
451+
}
452+
453+
return [
454+
{
455+
tag: 'script',
456+
children: '{__VITEST_INJECTOR__}',
457+
injectTo: 'head-prepend' as const,
458+
},
459+
{
460+
tag: 'script',
461+
children: stateJs,
462+
injectTo: 'head-prepend',
463+
} as const,
464+
{
465+
tag: 'script',
466+
attrs: {
467+
type: 'module',
468+
src: browserServer.errorCatcherUrl,
469+
},
470+
injectTo: 'head' as const,
471+
},
472+
browserServer.locatorsUrl
473+
? {
474+
tag: 'script',
475+
attrs: {
476+
type: 'module',
477+
src: browserServer.locatorsUrl,
478+
},
479+
injectTo: 'head',
480+
} as const
481+
: null,
482+
...browserServer.testerScripts,
483+
...testerScripts,
484+
{
485+
tag: 'script',
486+
attrs: {
487+
'type': 'module',
488+
'data-vitest-append': '',
489+
},
490+
children: '{__VITEST_APPEND__}',
491+
injectTo: 'body',
492+
} as const,
493+
].filter(s => s != null)
494+
},
495+
},
397496
{
398497
name: 'vitest:browser:support-testing-library',
399498
config() {

packages/browser/src/node/pool.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,7 @@ export function createBrowserPool(ctx: Vitest): ProcessPool {
101101
url.searchParams.set('contextId', contextId)
102102
const page = provider
103103
.openPage(contextId, url.toString(), () => setBreakpoint(contextId, files[0]))
104-
.then(() => waitPromise)
105-
promises.push(page)
104+
promises.push(page, waitPromise)
106105
}
107106
})
108107

packages/browser/src/node/server.ts

+31-10
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { ErrorWithDiff } from '@vitest/utils'
2-
import type { SerializedConfig } from 'vitest'
1+
import type { HtmlTagDescriptor } from 'vite'
2+
import type { ErrorWithDiff, SerializedConfig } from 'vitest'
33
import type {
44
BrowserProvider,
55
BrowserScript,
@@ -8,6 +8,7 @@ import type {
88
Vite,
99
WorkspaceProject,
1010
} from 'vitest/node'
11+
import { existsSync } from 'node:fs'
1112
import { readFile } from 'node:fs/promises'
1213
import { fileURLToPath } from 'node:url'
1314
import { slash } from '@vitest/utils'
@@ -22,10 +23,11 @@ export class BrowserServer implements IBrowserServer {
2223
public prefixTesterUrl: string
2324

2425
public orchestratorScripts: string | undefined
25-
public testerScripts: string | undefined
26+
public testerScripts: HtmlTagDescriptor[] | undefined
2627

2728
public manifest: Promise<Vite.Manifest> | Vite.Manifest
2829
public testerHtml: Promise<string> | string
30+
public testerFilepath: string
2931
public orchestratorHtml: Promise<string> | string
3032
public injectorJs: Promise<string> | string
3133
public errorCatcherUrl: string
@@ -76,8 +78,16 @@ export class BrowserServer implements IBrowserServer {
7678
)
7779
})().then(manifest => (this.manifest = manifest))
7880

81+
const testerHtmlPath = project.config.browser.testerHtmlPath
82+
? resolve(project.config.root, project.config.browser.testerHtmlPath)
83+
: resolve(distRoot, 'client/tester/tester.html')
84+
if (!existsSync(testerHtmlPath)) {
85+
throw new Error(`Tester HTML file "${testerHtmlPath}" doesn't exist.`)
86+
}
87+
this.testerFilepath = testerHtmlPath
88+
7989
this.testerHtml = readFile(
80-
resolve(distRoot, 'client/tester/tester.html'),
90+
testerHtmlPath,
8191
'utf8',
8292
).then(html => (this.testerHtml = html))
8393
this.orchestratorHtml = (project.config.browser.ui
@@ -124,24 +134,35 @@ export class BrowserServer implements IBrowserServer {
124134
scripts: BrowserScript[] | undefined,
125135
) {
126136
if (!scripts?.length) {
127-
return ''
137+
return []
128138
}
129139
const server = this.vite
130140
const promises = scripts.map(
131-
async ({ content, src, async, id, type = 'module' }, index) => {
141+
async ({ content, src, async, id, type = 'module' }, index): Promise<HtmlTagDescriptor> => {
132142
const srcLink = (src ? (await server.pluginContainer.resolveId(src))?.id : undefined) || src
133143
const transformId = srcLink || join(server.config.root, `virtual__${id || `injected-${index}.js`}`)
134144
await server.moduleGraph.ensureEntryFromUrl(transformId)
135145
const contentProcessed
136146
= content && type === 'module'
137147
? (await server.pluginContainer.transform(content, transformId)).code
138148
: content
139-
return `<script type="${type}"${async ? ' async' : ''}${
140-
srcLink ? ` src="${slash(`/@fs/${srcLink}`)}"` : ''
141-
}>${contentProcessed || ''}</script>`
149+
return {
150+
tag: 'script',
151+
attrs: {
152+
type,
153+
...(async ? { async: '' } : {}),
154+
...(srcLink
155+
? {
156+
src: srcLink.startsWith('http') ? srcLink : slash(`/@fs/${srcLink}`),
157+
}
158+
: {}),
159+
},
160+
injectTo: 'head',
161+
children: contentProcessed || '',
162+
}
142163
},
143164
)
144-
return (await Promise.all(promises)).join('\n')
165+
return (await Promise.all(promises))
145166
}
146167

147168
async initBrowserProvider() {

packages/browser/src/node/serverOrchestrator.ts

+9-2
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,16 @@ export async function resolveOrchestrator(
3838
res.removeHeader('Content-Security-Policy')
3939

4040
if (!server.orchestratorScripts) {
41-
server.orchestratorScripts = await server.formatScripts(
41+
server.orchestratorScripts = (await server.formatScripts(
4242
project.config.browser.orchestratorScripts,
43-
)
43+
)).map((script) => {
44+
let html = '<script '
45+
for (const attr in script.attrs || {}) {
46+
html += `${attr}="${script.attrs![attr]}" `
47+
}
48+
html += `>${script.children}</script>`
49+
return html
50+
}).join('\n')
4451
}
4552

4653
let baseHtml = typeof server.orchestratorHtml === 'string'

0 commit comments

Comments
 (0)