Skip to content

Commit 718512d

Browse files
authored
feat(browser): support changing the viewport (#5811)
1 parent 4bea1ca commit 718512d

File tree

16 files changed

+178
-51
lines changed

16 files changed

+178
-51
lines changed

docs/config/index.md

+7
Original file line numberDiff line numberDiff line change
@@ -1609,6 +1609,13 @@ To have a better type safety when using built-in providers, you can add one of t
16091609

16101610
Should Vitest UI be injected into the page. By default, injects UI iframe during development.
16111611

1612+
#### browser.viewport {#browser-viewport}
1613+
1614+
- **Type:** `{ width, height }`
1615+
- **Default:** `414x896`
1616+
1617+
Default iframe's viewport.
1618+
16121619
#### browser.indexScripts {#browser-indexscripts}
16131620

16141621
- **Type:** `BrowserScript[]`

docs/guide/browser.md

+12-2
Original file line numberDiff line numberDiff line change
@@ -135,17 +135,23 @@ export const server: {
135135
* The same as calling `process.version` on the server.
136136
*/
137137
version: string
138+
/**
139+
* Name of the browser provider.
140+
*/
141+
provider: string
142+
/**
143+
* Name of the current browser.
144+
*/
145+
browser: string
138146
/**
139147
* Available commands for the browser.
140-
* @see {@link https://vitest.dev/guide/browser#commands}
141148
*/
142149
commands: BrowserCommands
143150
}
144151

145152
/**
146153
* Available commands for the browser.
147154
* A shortcut to `server.commands`.
148-
* @see {@link https://vitest.dev/guide/browser#commands}
149155
*/
150156
export const commands: BrowserCommands
151157

@@ -154,6 +160,10 @@ export const page: {
154160
* Serialized test config.
155161
*/
156162
config: ResolvedConfig
163+
/**
164+
* Change the size of iframe's viewport.
165+
*/
166+
viewport: (width: number | string, height: number | string) => Promise<void>
157167
}
158168
```
159169

packages/browser/context.d.ts

+4
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,8 @@ export const page: {
8484
* Serialized test config.
8585
*/
8686
config: ResolvedConfig
87+
/**
88+
* Change the size of iframe's viewport.
89+
*/
90+
viewport: (width: number | string, height: number | string) => Promise<void>
8791
}

packages/browser/src/client/orchestrator.ts

+50-5
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ function createIframe(container: HTMLDivElement, file: string) {
3838
iframe.setAttribute('src', `${url.pathname}__vitest_test__/__test__/${encodeURIComponent(file)}`)
3939
iframe.setAttribute('data-vitest', 'true')
4040

41+
const config = getConfig().browser
42+
iframe.style.width = `${config.viewport.width}px`
43+
iframe.style.height = `${config.viewport.height}px`
44+
4145
iframe.style.display = 'block'
4246
iframe.style.border = 'none'
4347
iframe.style.pointerEvents = 'none'
@@ -66,7 +70,14 @@ interface IframeErrorEvent {
6670
files: string[]
6771
}
6872

69-
type IframeChannelEvent = IframeDoneEvent | IframeErrorEvent
73+
interface IframeViewportEvent {
74+
type: 'viewport'
75+
width: number | string
76+
height: number | string
77+
id: string
78+
}
79+
80+
type IframeChannelEvent = IframeDoneEvent | IframeErrorEvent | IframeViewportEvent
7081

7182
async function getContainer(config: ResolvedConfig): Promise<HTMLDivElement> {
7283
if (config.browser.ui) {
@@ -99,6 +110,30 @@ client.ws.addEventListener('open', async () => {
99110
channel.addEventListener('message', async (e: MessageEvent<IframeChannelEvent>): Promise<void> => {
100111
debug('channel event', JSON.stringify(e.data))
101112
switch (e.data.type) {
113+
case 'viewport': {
114+
const { width, height, id } = e.data
115+
const widthStr = typeof width === 'number' ? `${width}px` : width
116+
const heightStr = typeof height === 'number' ? `${height}px` : height
117+
const iframe = iframes.get(id)
118+
if (!iframe) {
119+
const error = new Error(`Cannot find iframe with id ${id}`)
120+
channel.postMessage({ type: 'viewport:fail', id, error: error.message })
121+
await client.rpc.onUnhandledError({
122+
name: 'Teardown Error',
123+
message: error.message,
124+
}, 'Teardown Error')
125+
return
126+
}
127+
iframe.style.width = widthStr
128+
iframe.style.height = heightStr
129+
const ui = getUiAPI()
130+
if (ui) {
131+
await new Promise(r => requestAnimationFrame(r))
132+
ui.recalculateDetailPanels()
133+
}
134+
channel.postMessage({ type: 'viewport:done', id })
135+
break
136+
}
102137
case 'done': {
103138
const filenames = e.data.filenames
104139
filenames.forEach(filename => runningFiles.delete(filename))
@@ -161,22 +196,32 @@ async function createTesters(testFiles: string[]) {
161196
container,
162197
ID_ALL,
163198
)
199+
200+
const ui = getUiAPI()
201+
202+
if (ui) {
203+
await new Promise(r => requestAnimationFrame(r))
204+
ui.recalculateDetailPanels()
205+
}
164206
}
165207
else {
166208
// otherwise, we need to wait for each iframe to finish before creating the next one
167209
// this is the most stable way to run tests in the browser
168210
for (const file of testFiles) {
169211
const ui = getUiAPI()
170212

213+
createIframe(
214+
container,
215+
file,
216+
)
217+
171218
if (ui) {
172219
const id = generateFileId(file)
173220
ui.setCurrentById(id)
221+
await new Promise(r => requestAnimationFrame(r))
222+
ui.recalculateDetailPanels()
174223
}
175224

176-
createIframe(
177-
container,
178-
file,
179-
)
180225
await new Promise<void>((resolve) => {
181226
channel.addEventListener('message', function handler(e: MessageEvent<IframeChannelEvent>) {
182227
// done and error can only be triggered by the previous iframe

packages/browser/src/client/ui.ts

+2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import type { File } from '@vitest/runner'
33
interface UiAPI {
44
currentModule: File
55
setCurrentById: (fileId: string) => void
6+
resetDetailSizes: () => void
7+
recalculateDetailPanels: () => void
68
}
79

810
export function getUiAPI(): UiAPI | undefined {

packages/browser/src/client/utils.ts

+1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ interface BrowserRunnerState {
1616
config: ResolvedConfig
1717
type: 'tester' | 'orchestrator'
1818
wrapModule: <T>(module: () => T) => T
19+
iframeId?: string
1920
runTests?: (tests: string[]) => Promise<void>
2021
createTesters?: (files: string[]) => Promise<void>
2122
}

packages/browser/src/node/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
106106
const decodedTestFile = decodeURIComponent(url.pathname.slice(testerPrefix.length))
107107
// if decoded test file is "__vitest_all__" or not in the list of known files, run all tests
108108
const tests = decodedTestFile === '__vitest_all__' || !files.includes(decodedTestFile) ? '__vitest_browser_runner__.files' : JSON.stringify([decodedTestFile])
109+
const iframeId = decodedTestFile === '__vitest_all__' ? '"__vitest_all__"' : JSON.stringify(decodedTestFile)
109110

110111
if (!testerScripts)
111112
testerScripts = await formatScripts(project.config.browser.testerScripts, server)
@@ -119,6 +120,7 @@ export default (project: WorkspaceProject, base = '/'): Plugin[] => {
119120
// TODO: have only a single global variable to not pollute the global scope
120121
`<script type="module">
121122
__vitest_browser_runner__.runningFiles = ${tests}
123+
__vitest_browser_runner__.iframeId = ${iframeId}
122124
__vitest_browser_runner__.runTests(__vitest_browser_runner__.runningFiles)
123125
</script>`,
124126
})

packages/browser/src/node/plugins/pluginContext.ts

+17
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ function generateContextFile(project: WorkspaceProject) {
4040

4141
return `
4242
const rpc = () => __vitest_worker__.rpc
43+
const channel = new BroadcastChannel('vitest')
4344
4445
export const server = {
4546
platform: ${JSON.stringify(process.platform)},
@@ -54,6 +55,22 @@ export const commands = server.commands
5455
export const page = {
5556
get config() {
5657
return __vitest_browser_runner__.config
58+
},
59+
viewport(width, height) {
60+
const id = __vitest_browser_runner__.iframeId
61+
channel.postMessage({ type: 'viewport', width, height, id })
62+
return new Promise((resolve) => {
63+
channel.addEventListener('message', function handler(e) {
64+
if (e.data.type === 'viewport:done' && e.data.id === id) {
65+
channel.removeEventListener('message', handler)
66+
resolve()
67+
}
68+
if (e.data.type === 'viewport:fail' && e.data.id === id) {
69+
channel.removeEventListener('message', handler)
70+
reject(new Error(e.data.error))
71+
}
72+
})
73+
})
5774
}
5875
}
5976
`

packages/ui/client/auto-imports.d.ts

+2
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ declare global {
4040
const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch']
4141
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
4242
const defineComponent: typeof import('vue')['defineComponent']
43+
const detailSizes: typeof import('./composables/navigation')['detailSizes']
4344
const disableCoverage: typeof import('./composables/navigation')['disableCoverage']
4445
const eagerComputed: typeof import('@vueuse/core')['eagerComputed']
4546
const effectScope: typeof import('vue')['effectScope']
@@ -102,6 +103,7 @@ declare global {
102103
const reactiveOmit: typeof import('@vueuse/core')['reactiveOmit']
103104
const reactivePick: typeof import('@vueuse/core')['reactivePick']
104105
const readonly: typeof import('vue')['readonly']
106+
const recalculateDetailPanels: typeof import('./composables/navigation')['recalculateDetailPanels']
105107
const ref: typeof import('vue')['ref']
106108
const refAutoReset: typeof import('@vueuse/core')['refAutoReset']
107109
const refDebounced: typeof import('@vueuse/core')['refDebounced']

packages/ui/client/components/BrowserIframe.vue

+25-24
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,35 @@
11
<script setup lang="ts">
22
const viewport = ref('custom')
3+
import { recalculateDetailPanels } from '~/composables/navigation'
34
4-
function changeViewport(name: string) {
5+
const sizes = {
6+
'small-mobile': ['320px', '568px'],
7+
'large-mobile': ['414px', '896px'],
8+
tablet: ['834px', '1112px'],
9+
custom: ['100%', '100%'],
10+
}
11+
12+
async function changeViewport(name: string) {
513
if (viewport.value === name) {
614
viewport.value = 'custom'
715
} else {
816
viewport.value = name
917
}
18+
19+
const iframe = document.querySelector('#tester-ui iframe[data-vitest]')
20+
if (!iframe) {
21+
console.warn('Iframe not found')
22+
return
23+
}
24+
25+
const [width, height] = sizes[viewport.value]
26+
27+
iframe.style.width = width
28+
iframe.style.height = height
29+
30+
await new Promise(r => requestAnimationFrame(r))
31+
32+
recalculateDetailPanels()
1033
}
1134
</script>
1235

@@ -62,31 +85,9 @@ function changeViewport(name: string) {
6285
/>
6386
</div>
6487
<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">
88+
<div id="tester-ui" class="flex h-full justify-center items-center font-light op70" style="overflow: auto; width: 100%; height: 100%">
6689
Select a test to run
6790
</div>
6891
</div>
6992
</div>
7093
</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/composables/navigation.ts

+17
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,19 @@ export const coverageEnabled = computed(() => {
1212
return coverageConfigured.value
1313
&& coverage.value.reporter.map(([reporterName]) => reporterName).includes('html')
1414
})
15+
export const detailSizes = useLocalStorage<[left: number, right: number]>('vitest-ui_splitpanes-detailSizes', [33, 67], {
16+
initOnMounted: true,
17+
})
18+
19+
export function recalculateDetailPanels() {
20+
const iframe = document.querySelector('#tester-ui iframe[data-vitest]')!
21+
const panel = document.querySelector('#details-splitpanes')!
22+
const panelWidth = panel.clientWidth
23+
const iframeWidth = iframe.clientWidth
24+
const iframePercent = Math.min((iframeWidth / panelWidth) * 100, 95)
25+
const detailsPercent = 100 - iframePercent
26+
detailSizes.value = [iframePercent, detailsPercent]
27+
}
1528

1629
// @ts-expect-error not typed global
1730
window.__vitest_ui_api__ = {
@@ -23,6 +36,10 @@ window.__vitest_ui_api__ = {
2336
currentModule.value = findById(fileId)
2437
showDashboard(false)
2538
},
39+
resetDetailSizes() {
40+
detailSizes.value = [33, 67]
41+
},
42+
recalculateDetailPanels,
2643
}
2744
export const openedTreeItems = useLocalStorage<string[]>('vitest-ui_task-tree-opened', [])
2845
// TODO

packages/ui/client/pages/index.vue

+11-19
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,12 @@
22
// @ts-expect-error missing types
33
import { Pane, Splitpanes } from 'splitpanes'
44
import { browserState } from '~/composables/client';
5-
import { coverageUrl, coverageVisible, initializeNavigation } from '../composables/navigation'
5+
import { coverageUrl, coverageVisible, initializeNavigation, detailSizes } from '../composables/navigation'
66
77
const dashboardVisible = initializeNavigation()
88
const mainSizes = useLocalStorage<[left: number, right: number]>('vitest-ui_splitpanes-mainSizes', [33, 67], {
99
initOnMounted: true,
1010
})
11-
const detailSizes = useLocalStorage<[left: number, right: number]>('vitest-ui_splitpanes-detailSizes', [33, 67], {
12-
initOnMounted: true,
13-
})
1411
1512
const onMainResized = useDebounceFn((event: { size: number }[]) => {
1613
event.forEach((e, i) => {
@@ -28,9 +25,6 @@ function resizeMain() {
2825
const panelWidth = Math.min(width / 3, 300)
2926
mainSizes.value[0] = (100 * panelWidth) / width
3027
mainSizes.value[1] = 100 - mainSizes.value[0]
31-
// initialize suite width with the same navigation panel width in pixels (adjust its % inside detail's split pane)
32-
detailSizes.value[0] = (100 * panelWidth) / (width - panelWidth)
33-
detailSizes.value[1] = 100 - detailSizes.value[0]
3428
}
3529
</script>
3630

@@ -47,18 +41,16 @@ function resizeMain() {
4741
<Coverage v-else-if="coverageVisible" key="coverage" :src="coverageUrl" />
4842
<FileDetails v-else />
4943
</transition>
50-
<transition v-else>
51-
<Splitpanes key="detail" @resized="onModuleResized">
52-
<Pane :size="detailSizes[0]">
53-
<BrowserIframe v-once />
54-
</Pane>
55-
<Pane :size="detailSizes[1]">
56-
<Dashboard v-if="dashboardVisible" key="summary" />
57-
<Coverage v-else-if="coverageVisible" key="coverage" :src="coverageUrl" />
58-
<FileDetails v-else />
59-
</Pane>
60-
</Splitpanes>
61-
</transition>
44+
<Splitpanes v-else key="detail" id="details-splitpanes" @resized="onModuleResized">
45+
<Pane :size="detailSizes[0]">
46+
<BrowserIframe v-once />
47+
</Pane>
48+
<Pane :size="detailSizes[1]" min-size="5">
49+
<Dashboard v-if="dashboardVisible" key="summary" />
50+
<Coverage v-else-if="coverageVisible" key="coverage" :src="coverageUrl" />
51+
<FileDetails v-else />
52+
</Pane>
53+
</Splitpanes>
6254
</Pane>
6355
</Splitpanes>
6456
</div>

packages/vitest/src/node/cli/cli-config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,7 @@ export const cliOptionsConfig: VitestCLIOptions = {
350350
indexScripts: null,
351351
testerScripts: null,
352352
commands: null,
353+
viewport: null,
353354
},
354355
},
355356
pool: {

0 commit comments

Comments
 (0)