Skip to content

Commit 3796dd7

Browse files
authored
feat: add browser frame to UI (#5808)
1 parent 7900f9f commit 3796dd7

19 files changed

+252
-30
lines changed

packages/browser/src/client/index.html packages/browser/src/client/orchestrator.html

+7-6
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,19 @@
1515
padding: 0;
1616
margin: 0;
1717
}
18-
#vitest-ui {
19-
width: 100vw;
20-
height: 100vh;
21-
border: none;
18+
html,
19+
body,
20+
iframe[data-vitest],
21+
#vitest-tester {
22+
width: 100%;
23+
height: 100%;
2224
}
2325
</style>
2426
<script>{__VITEST_INJECTOR__}</script>
2527
{__VITEST_SCRIPTS__}
2628
</head>
2729
<body>
28-
{__VITEST_UI__}
29-
<script type="module" src="/main.ts"></script>
30+
<script type="module" src="/orchestrator.ts"></script>
3031
<div id="vitest-tester"></div>
3132
</body>
3233
</html>

packages/browser/src/client/main.ts packages/browser/src/client/orchestrator.ts

+54-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
import type { ResolvedConfig } from 'vitest'
2+
import { generateHash } from '@vitest/runner/utils'
3+
import { relative } from 'pathe'
14
import { channel, client } from './client'
25
import { rpcDone } from './rpc'
36
import { getBrowserState, getConfig } from './utils'
7+
import { getUiAPI } from './ui'
48

59
const url = new URL(location.href)
610

@@ -23,6 +27,14 @@ function createIframe(container: HTMLDivElement, file: string) {
2327
const iframe = document.createElement('iframe')
2428
iframe.setAttribute('loading', 'eager')
2529
iframe.setAttribute('src', `${url.pathname}__vitest_test__/__test__/${encodeURIComponent(file)}`)
30+
iframe.setAttribute('data-vitest', 'true')
31+
32+
iframe.style.display = 'block'
33+
iframe.style.border = 'none'
34+
iframe.style.pointerEvents = 'none'
35+
iframe.setAttribute('allowfullscreen', 'true')
36+
iframe.setAttribute('allow', 'clipboard-write;')
37+
2638
iframes.set(file, iframe)
2739
container.appendChild(iframe)
2840
return iframe
@@ -47,9 +59,23 @@ interface IframeErrorEvent {
4759

4860
type IframeChannelEvent = IframeDoneEvent | IframeErrorEvent
4961

62+
async function getContainer(config: ResolvedConfig): Promise<HTMLDivElement> {
63+
if (config.browser.ui) {
64+
const element = document.querySelector('#tester-ui')
65+
if (!element) {
66+
return new Promise<HTMLDivElement>((resolve) => {
67+
setTimeout(() => {
68+
resolve(getContainer(config))
69+
}, 30)
70+
})
71+
}
72+
return element as HTMLDivElement
73+
}
74+
return document.querySelector('#vitest-tester') as HTMLDivElement
75+
}
76+
5077
client.ws.addEventListener('open', async () => {
5178
const config = getConfig()
52-
const container = document.querySelector('#vitest-tester') as HTMLDivElement
5379
const testFiles = getBrowserState().files
5480

5581
debug('test files', testFiles.join(', '))
@@ -60,6 +86,7 @@ client.ws.addEventListener('open', async () => {
6086
return
6187
}
6288

89+
const container = await getContainer(config)
6390
const runningFiles = new Set<string>(testFiles)
6491

6592
channel.addEventListener('message', async (e: MessageEvent<IframeChannelEvent>): Promise<void> => {
@@ -70,6 +97,13 @@ client.ws.addEventListener('open', async () => {
7097
filenames.forEach(filename => runningFiles.delete(filename))
7198

7299
if (!runningFiles.size) {
100+
const ui = getUiAPI()
101+
// in isolated mode we don't change UI because it will slow down tests,
102+
// so we only select it when the run is done
103+
if (ui && filenames.length > 1) {
104+
const id = generateFileId(filenames[filenames.length - 1])
105+
ui.setCurrentById(id)
106+
}
73107
await done()
74108
}
75109
else {
@@ -103,6 +137,11 @@ client.ws.addEventListener('open', async () => {
103137
}
104138
})
105139

140+
if (config.browser.ui) {
141+
container.className = ''
142+
container.textContent = ''
143+
}
144+
106145
if (config.isolate === false) {
107146
createIframe(
108147
container,
@@ -113,6 +152,13 @@ client.ws.addEventListener('open', async () => {
113152
// otherwise, we need to wait for each iframe to finish before creating the next one
114153
// this is the most stable way to run tests in the browser
115154
for (const file of testFiles) {
155+
const ui = getUiAPI()
156+
157+
if (ui) {
158+
const id = generateFileId(file)
159+
ui.setCurrentById(id)
160+
}
161+
116162
createIframe(
117163
container,
118164
file,
@@ -129,3 +175,10 @@ client.ws.addEventListener('open', async () => {
129175
}
130176
}
131177
})
178+
179+
function generateFileId(file: string) {
180+
const config = getConfig()
181+
const project = config.name || ''
182+
const path = relative(config.root, file)
183+
return generateHash(`${path}${project}`)
184+
}

packages/browser/src/client/tester.html

+2-1
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@
1313
body {
1414
padding: 0;
1515
margin: 0;
16+
min-height: 100vh;
1617
}
1718
</style>
1819
<script>{__VITEST_INJECTOR__}</script>
1920
{__VITEST_SCRIPTS__}
2021
</head>
21-
<body>
22+
<body style="width: 100%; height: 100%; transform: scale(1); transform-origin: left top;">
2223
<script type="module" src="/tester.ts"></script>
2324
{__VITEST_APPEND__}
2425
</body>

packages/browser/src/client/ui.ts

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { File } from '@vitest/runner'
2+
3+
interface UiAPI {
4+
currentModule: File
5+
setCurrentById: (fileId: string) => void
6+
}
7+
8+
export function getUiAPI(): UiAPI | undefined {
9+
// @ts-expect-error not typed global
10+
return window.__vitest_ui_api__
11+
}

packages/browser/src/client/vite.config.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ export default defineConfig({
1313
outDir: '../../dist/client',
1414
emptyOutDir: false,
1515
assetsDir: '__vitest_browser__',
16+
manifest: true,
1617
rollupOptions: {
1718
input: {
18-
main: resolve(__dirname, './index.html'),
19+
orchestrator: resolve(__dirname, './orchestrator.html'),
1920
tester: resolve(__dirname, './tester.html'),
2021
},
2122
},

packages/browser/src/node/index.ts

+23-5
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,13 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
3131
},
3232
async configureServer(server) {
3333
const testerHtml = readFile(resolve(distRoot, 'client/tester.html'), 'utf8')
34-
const runnerHtml = readFile(resolve(distRoot, 'client/index.html'), 'utf8')
34+
const orchestratorHtml = project.config.browser.ui
35+
? readFile(resolve(distRoot, 'client/__vitest__/index.html'), 'utf8')
36+
: readFile(resolve(distRoot, 'client/orchestrator.html'), 'utf8')
3537
const injectorJs = readFile(resolve(distRoot, 'client/esm-client-injector.js'), 'utf8')
38+
const manifest = (async () => {
39+
return JSON.parse(await readFile(`${distRoot}/client/.vite/manifest.json`, 'utf8'))
40+
})()
3641
const favicon = `${base}favicon.svg`
3742
const testerPrefix = `${base}__vitest_test__/__test__/`
3843
server.middlewares.use((_req, res, next) => {
@@ -71,14 +76,27 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
7176
if (!indexScripts)
7277
indexScripts = await formatScripts(project.config.browser.indexScripts, server)
7378

74-
const html = replacer(await runnerHtml, {
79+
let baseHtml = await orchestratorHtml
80+
81+
// if UI is enabled, use UI HTML and inject the orchestrator script
82+
if (project.config.browser.ui) {
83+
const manifestContent = await manifest
84+
const jsEntry = manifestContent['orchestrator.html'].file
85+
baseHtml = baseHtml.replaceAll('./assets/', `${base}__vitest__/assets/`).replace(
86+
'<!-- !LOAD_METADATA! -->',
87+
[
88+
'<script>{__VITEST_INJECTOR__}</script>',
89+
'{__VITEST_SCRIPTS__}',
90+
`<script type="module" crossorigin src="${jsEntry}"></script>`,
91+
].join('\n'),
92+
)
93+
}
94+
95+
const html = replacer(baseHtml, {
7596
__VITEST_FAVICON__: favicon,
7697
__VITEST_TITLE__: 'Vitest Browser Runner',
7798
__VITEST_SCRIPTS__: indexScripts,
7899
__VITEST_INJECTOR__: injector,
79-
__VITEST_UI__: project.config.browser.ui
80-
? '<iframe id="vitest-ui" src="/__vitest__/"></iframe>'
81-
: '',
82100
})
83101
res.write(html, 'utf-8')
84102
res.end()

packages/ui/client/components.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export {}
77
/* prettier-ignore */
88
declare module 'vue' {
99
export interface GlobalComponents {
10+
BrowserIframe: typeof import('./components/BrowserIframe.vue')['default']
1011
CodeMirror: typeof import('./components/CodeMirror.vue')['default']
1112
ConnectionOverlay: typeof import('./components/ConnectionOverlay.vue')['default']
1213
Coverage: typeof import('./components/Coverage.vue')['default']
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<script setup lang="ts">
2+
const viewport = ref('custom')
3+
4+
function changeViewport(name: string) {
5+
if (viewport.value === name) {
6+
viewport.value = 'custom'
7+
} else {
8+
viewport.value = name
9+
}
10+
}
11+
</script>
12+
13+
<template>
14+
<div h="full" flex="~ col">
15+
<div
16+
p="3"
17+
h-10
18+
flex="~ gap-2"
19+
items-center
20+
bg-header
21+
border="b base"
22+
>
23+
<div class="i-carbon-content-delivery-network" />
24+
<span
25+
pl-1
26+
font-bold
27+
text-sm
28+
flex-auto
29+
ws-nowrap
30+
overflow-hidden
31+
truncate
32+
>Browser UI</span>
33+
</div>
34+
<div
35+
p="l3 y2 r2"
36+
flex="~ gap-2"
37+
items-center
38+
bg-header
39+
border="b-2 base"
40+
>
41+
<!-- TODO: these are only for preview (thank you Storybook!), we need to support more different and custom sizes (as a dropdown) -->
42+
<IconButton
43+
v-tooltip.bottom="'Small mobile'"
44+
title="Small mobile"
45+
icon="i-carbon:mobile"
46+
:active="viewport === 'small-mobile'"
47+
@click="changeViewport('small-mobile')"
48+
/>
49+
<IconButton
50+
v-tooltip.bottom="'Large mobile'"
51+
title="Large mobile"
52+
icon="i-carbon:mobile-add"
53+
:active="viewport === 'large-mobile'"
54+
@click="changeViewport('large-mobile')"
55+
/>
56+
<IconButton
57+
v-tooltip.bottom="'Tablet'"
58+
title="Tablet"
59+
icon="i-carbon:tablet"
60+
:active="viewport === 'tablet'"
61+
@click="changeViewport('tablet')"
62+
/>
63+
</div>
64+
<div flex-auto overflow-auto>
65+
<div id="tester-ui" class="flex h-full justify-center items-center font-light op70" style="overflow: auto; width: 100%; height: 100%" :data-viewport="viewport">
66+
Select a test to run
67+
</div>
68+
</div>
69+
</div>
70+
</template>
71+
72+
<style>
73+
[data-viewport="custom"] iframe {
74+
width: 100%;
75+
height: 100%;
76+
}
77+
78+
[data-viewport="small-mobile"] iframe {
79+
width: 320px;
80+
height: 568px;
81+
}
82+
83+
[data-viewport="large-mobile"] iframe {
84+
width: 414px;
85+
height: 896px;
86+
}
87+
88+
[data-viewport="tablet"] iframe {
89+
width: 834px;
90+
height: 1112px;
91+
}
92+
</style>

packages/ui/client/components/ConnectionOverlay.vue

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { client, isConnected, isConnecting } from '~/composables/client'
2+
import { client, isConnected, isConnecting, browserState } from '~/composables/client'
33
</script>
44

55
<template>
@@ -27,7 +27,7 @@ import { client, isConnected, isConnecting } from '~/composables/client'
2727
{{ isConnecting ? 'Connecting...' : 'Disconnected' }}
2828
</div>
2929
<div text-lg op50>
30-
Check your terminal or start a new server with `vitest --ui`
30+
Check your terminal or start a new server with `{{ browserState ? `vitest --browser=${browserState.config.browser.name}` : 'vitest --ui' }}`
3131
</div>
3232
</div>
3333
</div>

packages/ui/client/components/FileDetails.vue

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script setup lang="ts">
22
import type { ModuleGraphData } from 'vitest'
3-
import { client, current, currentLogs, isReport } from '~/composables/client'
3+
import { client, current, currentLogs, isReport, browserState } from '~/composables/client'
44
import type { Params } from '~/composables/params'
55
import { viewMode } from '~/composables/params'
66
import type { ModuleGraph } from '~/composables/module-graph'
@@ -17,7 +17,7 @@ debouncedWatch(
1717
async (c, o) => {
1818
if (c && c.filepath !== o?.filepath) {
1919
const project = c.file.projectName || ''
20-
data.value = await client.rpc.getModuleGraph(project, c.filepath)
20+
data.value = await client.rpc.getModuleGraph(project, c.filepath, !!browserState)
2121
graph.value = getModuleGraph(data.value, c.filepath)
2222
}
2323
},
@@ -50,7 +50,7 @@ function onDraft(value: boolean) {
5050
<div>
5151
<div p="2" h-10 flex="~ gap-2" items-center bg-header border="b base">
5252
<StatusIcon :task="current" />
53-
<div font-light op-50 text-sm :style="{ color: getProjectNameColor(current?.file.projectName) }">
53+
<div v-if="current?.file.projectName" font-light op-50 text-sm :style="{ color: getProjectNameColor(current?.file.projectName) }">
5454
[{{ current?.file.projectName || '' }}]
5555
</div>
5656
<div flex-1 font-light op-50 ws-nowrap truncate text-sm>

packages/ui/client/components/IconButton.vue

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
defineProps<{ icon?: `i-${string}` | `dark:i-${string}`; title?: string; disabled?: boolean }>()
2+
defineProps<{ icon?: `i-${string}` | `dark:i-${string}`; title?: string; disabled?: boolean; active?: boolean }>()
33
</script>
44

55
<template>
@@ -9,8 +9,8 @@ defineProps<{ icon?: `i-${string}` | `dark:i-${string}`; title?: string; disable
99
:opacity="disabled ? 10 : 70"
1010
rounded
1111
:disabled="disabled"
12-
:hover="disabled ? '' : 'bg-active op100'"
13-
class="w-1.4em h-1.4em flex"
12+
:hover="disabled || active ? '' : 'bg-active op100'"
13+
:class="['w-1.4em h-1.4em flex', { 'bg-gray-500:35 op100': active }]"
1414
>
1515
<slot>
1616
<div :class="icon" ma />

0 commit comments

Comments
 (0)