Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/browser-playwright/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from './trace'
import { type } from './type'
import { upload } from './upload'
import { viewport } from './viewport'
import { wheel } from './wheel'

export default {
Expand All @@ -45,4 +46,5 @@ export default {
__vitest_markTrace: markTrace as typeof markTrace,
__vitest_groupTraceStart: groupTraceStart as typeof groupTraceStart,
__vitest_groupTraceEnd: groupTraceEnd as typeof groupTraceEnd,
__vitest_viewport: viewport as typeof viewport,
}
17 changes: 17 additions & 0 deletions packages/browser-playwright/src/commands/screenshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ interface ScreenshotCommandOptions extends Omit<ScreenshotOptions, 'element' | '
element?: string
mask?: readonly string[]
}

const SCREENSHOT_STYLES = /* css */`
iframe[data-vitest="true"] {
position: absolute !important;
inset: 0 !important;
z-index: ${Number.MAX_SAFE_INTEGER} !important;
transform: none !important;
}
`

/**
* Takes a screenshot using the provided browser context and returns a buffer and the expected screenshot path.
*
Expand Down Expand Up @@ -43,6 +53,11 @@ export async function takeScreenshot(
}

const mask = options.mask?.map(selector => getDescribedLocator(context, selector))
const style = context.project.config.browser.ui
? options.style === undefined
? SCREENSHOT_STYLES
: SCREENSHOT_STYLES + options.style
: options.style

if (options.element) {
const { element: selector, ...config } = options
Expand All @@ -51,6 +66,7 @@ export async function takeScreenshot(
...config,
mask,
path: savePath,
style,
})
return { buffer, path }
}
Expand All @@ -59,6 +75,7 @@ export async function takeScreenshot(
...options,
mask,
path: savePath,
style,
})
return { buffer, path }
}
8 changes: 8 additions & 0 deletions packages/browser-playwright/src/commands/viewport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { UserEventCommand } from './utils'

export const viewport: UserEventCommand<(options: {
width: number
height: number
}) => void> = async (context, options) => {
await context.page.setViewportSize(options)
}
2 changes: 2 additions & 0 deletions packages/browser-webdriverio/src/commands/screenshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,10 @@ export async function takeScreenshot(
const buffer = await element.saveScreenshot(
platformNormalize(savePathWithExtension),
)

if (!options.save) {
await rm(savePathWithExtension, { force: true })
}

return { buffer, path }
}
67 changes: 38 additions & 29 deletions packages/browser/src/client/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,7 @@ export class IframeOrchestrator {
const container = await getContainer(config)

if (config.browser.ui) {
container.className = 'absolute origin-top mt-[8px]'
container.parentElement!.setAttribute('data-ready', 'true')
container.setAttribute('data-ready', 'true')
// in non-isolated mode this will also remove the iframe,
// so we only do this once
if (container.textContent) {
Expand Down Expand Up @@ -154,9 +153,8 @@ export class IframeOrchestrator {

const config = getConfig()
const { width, height } = config.browser.viewport
const iframe = this.iframes.get(ID_ALL)!

await setIframeViewport(iframe, width, height)
await setIframeViewport(width, height)
debug('run non-isolated tests', options.files.join(', '))
await this.sendEventToIframe({
event: 'execute',
Expand Down Expand Up @@ -187,13 +185,13 @@ export class IframeOrchestrator {
this.iframes.delete(file)
}

const iframe = await this.prepareIframe(
await this.prepareIframe(
container,
file,
startTime,
otelContext,
)
await setIframeViewport(iframe, width, height)
await setIframeViewport(width, height)
// running tests after the "prepare" event
await this.sendEventToIframe({
event: 'execute',
Expand Down Expand Up @@ -312,16 +310,40 @@ export class IframeOrchestrator {
private createTestIframe(iframeId: string) {
const iframe = document.createElement('iframe')
const src = `/?sessionId=${getBrowserState().sessionId}&iframeId=${iframeId}`
const config = getConfig()

iframe.setAttribute('loading', 'eager')
iframe.setAttribute('src', src)
iframe.setAttribute('data-vitest', 'true')

iframe.style.border = 'none'
iframe.style.width = '100%'
iframe.style.height = '100%'
iframe.setAttribute('allowfullscreen', 'true')
iframe.setAttribute('allow', 'clipboard-write;')
iframe.setAttribute('name', 'vitest-iframe')

iframe.style.setProperty('border', 'none')
iframe.style.setProperty('background-color', '#fff')
iframe.style.setProperty('width', 'var(--viewport-width)')
iframe.style.setProperty('height', 'var(--viewport-height)')

// enable scaling only when using the UI, without UI the iframe fills the page
if (config.browser.ui) {
if (config.browser.name !== 'firefox') {
iframe.style.setProperty('transform', 'scale(min(1, calc(100cqw / var(--viewport-width)), calc(100cqh / var(--viewport-height))))')
}
else {
// Firefox cannot resolve relative units like `cqw` directly inside `atan2()`
// Storing it in a CSS variable first forces Firefox to resolve `100cqw` to an absolute pixel value
iframe.style.setProperty('--container-width', '100cqw')
iframe.style.setProperty('--container-height', '100cqh')
// Firefox does not support typed arithmetic (divisions between typed values): https://bugzilla.mozilla.org/show_bug.cgi?id=1264520
// `tan(atan2(a, b))` produces a unit-less `a / b` ratio:
// - `atan2()` accepts two lengths and returns an `<angle>`
// - `tan()` converts it back to a unit-less `<number>`
iframe.style.setProperty('transform', 'scale(min(1, tan(atan2(var(--container-width), var(--viewport-width))), tan(atan2(var(--container-height), var(--viewport-height)))))')
}

iframe.style.setProperty('transform-origin', 'top left')
}

return iframe
}

Expand Down Expand Up @@ -357,7 +379,7 @@ export class IframeOrchestrator {
)
break
}
await setIframeViewport(iframe, width, height)
await setIframeViewport(width, height)
channel.postMessage({ event: 'viewport:done', iframeId: id } satisfies IframeViewportDoneEvent)
break
}
Expand Down Expand Up @@ -447,38 +469,25 @@ function generateFileId(file: string) {
}

async function setIframeViewport(
iframe: HTMLIFrameElement,
width: number,
height: number,
) {
const ui = getUiAPI()

if (ui) {
await ui.setIframeViewport(width, height)
}
else if (getBrowserState().provider === 'webdriverio') {
iframe.parentElement?.setAttribute('data-scale', '1')
else {
document.body.style.setProperty('--viewport-width', `${width}px`)
document.body.style.setProperty('--viewport-height', `${height}px`)

await client.rpc.triggerCommand(
getBrowserState().sessionId,
'__vitest_viewport',
undefined,
[{ width, height }],
)
}
else {
const scale = Math.min(
1,
iframe.parentElement!.parentElement!.clientWidth / width,
iframe.parentElement!.parentElement!.clientHeight / height,
)
iframe.parentElement!.style.cssText = `
width: ${width}px;
height: ${height}px;
transform: scale(${scale});
transform-origin: left top;
`
iframe.parentElement?.setAttribute('data-scale', String(scale))
await new Promise(r => requestAnimationFrame(r))
}
}

function debug(...args: unknown[]) {
Expand Down
16 changes: 8 additions & 8 deletions packages/browser/src/client/tester/tester-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,15 +224,15 @@ export function processTimeoutOptions<T extends { timeout?: number }>(options_:
}

export function getIframeScale(): number {
const testerUi = window.parent.document.querySelector(`iframe[data-vitest]`)?.parentElement
if (!testerUi) {
throw new Error(`Cannot find Tester element. This is a bug in Vitest. Please, open a new issue with reproduction.`)
}
const scaleAttribute = testerUi.getAttribute('data-scale')
const scale = Number(scaleAttribute)
if (Number.isNaN(scale)) {
throw new TypeError(`Cannot parse scale value from Tester element (${scaleAttribute}). This is a bug in Vitest. Please, open a new issue with reproduction.`)
const iframe = window.frameElement

if (!iframe) {
throw new Error(`Cannot find iframe element. This is a bug in Vitest. Please, open a new issue with reproduction.`)
}

// we can safely use `a` as both vertical and horizontal scale are the same
const scale = new DOMMatrix(getComputedStyle(iframe).transform).a
Copy link
Copy Markdown
Member

@sheremet-va sheremet-va Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add an explanation (as a comment) of what is going on here? I've never seen this usage before

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've updated the comment, does this version explain it better? 🤔


return scale
}

Expand Down
95 changes: 35 additions & 60 deletions packages/ui/client/components/BrowserIframe.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<script setup lang="ts">
import type { ViewportSize } from '~/composables/browser'
import { useWindowSize } from '@vueuse/core'
import { computed } from 'vue'
import { computed, onMounted, onUnmounted, ref, useTemplateRef } from 'vue'
import { viewport } from '~/composables/browser'
import { browserState } from '~/composables/client'
import {
Expand All @@ -24,56 +23,42 @@ function isViewport(name: ViewportSize) {
return viewport.value[0] === preset[0] && viewport.value[1] === preset[1]
}

const { width: windowWidth, height: windowHeight } = useWindowSize()

async function changeViewport(name: ViewportSize) {
viewport.value = sizes[name]
if (browserState?.provider === 'webdriverio') {
updateBrowserPanel()
}
}

const PADDING_SIDES = 20
const PADDING_TOP = 100

const containerSize = computed(() => {
if (browserState?.provider === 'webdriverio') {
const [width, height] = viewport.value
return { width, height }
}
const testContainer = useTemplateRef('tester-ui')
const testContainerRect = ref<DOMRectReadOnly | null>(null)

const parentContainerWidth = windowWidth.value * (panels.details.size / 100)
const parentOffsetWidth = parentContainerWidth * (panels.details.browser / 100)
const containerWidth = parentOffsetWidth - PADDING_SIDES
const containerHeight = windowHeight.value - PADDING_TOP
return {
width: containerWidth,
height: containerHeight,
}
const observer = new ResizeObserver(([entry]) => {
testContainerRect.value = entry.contentRect
})

const scale = computed(() => {
if (browserState?.provider === 'webdriverio') {
return 1
onMounted(() => {
if (testContainer.value) {
observer.observe(testContainer.value)
}

const [iframeWidth, iframeHeight] = viewport.value
const { width: containerWidth, height: containerHeight } = containerSize.value
const widthScale = containerWidth > iframeWidth ? 1 : containerWidth / iframeWidth
const heightScale = containerHeight > iframeHeight ? 1 : containerHeight / iframeHeight
return Math.min(1, widthScale, heightScale)
})

const marginLeft = computed(() => {
const containerWidth = containerSize.value.width
const iframeWidth = viewport.value[0]
const offset = Math.trunc((containerWidth + PADDING_SIDES - iframeWidth) / 2)
return `${offset}px`
onUnmounted(() => {
observer.disconnect()
})

const scale = computed(() =>
testContainerRect.value
? Math.floor(
Math.min(
testContainerRect.value.width / viewport.value[0],
testContainerRect.value.height / viewport.value[1],
) * 100,
)
: 100,
)
</script>

<template>
<div h="full" flex="~ col">
<div id="browser-frame" h="full" flex="~ col">
<div p="3" h-10 flex="~ gap-2" items-center bg-header border="b base">
<IconButton
v-show="panels.navigation <= 15"
Expand Down Expand Up @@ -118,40 +103,30 @@ const marginLeft = computed(() => {
/>
<span class="pointer-events-none" text-sm>
{{ viewport[0] }}x{{ viewport[1] }}px
<span v-if="scale < 1">({{ (scale * 100).toFixed(0) }}%)</span>
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I can add this back now if necessary.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why would we remove it? The window is still scaled

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed it initially when I wasn't sure about what approach to go with. I'll add it back 👍🏼

<span v-if="scale < 100">({{ scale }}%)</span>
</span>
</div>
<div id="tester-container" relative>
<div
id="tester-ui"
class="flex h-full justify-center items-center font-light op70"
:data-scale="scale"
:style="{
'--viewport-width': `${viewport[0]}px`,
'--viewport-height': `${viewport[1]}px`,
'--tester-transform': `scale(${scale})`,
'--tester-margin-left': marginLeft,
}"
>
Select a test to run
</div>
<div id="tester-ui" ref="tester-ui">
Select a test to run
</div>
</div>
</template>

<style scoped>
#tester-container:not([data-ready]) {
width: 100%;
#tester-ui {
height: 100%;
container-type: size;

margin-top: 0.5rem;
}

#tester-ui:not([data-ready]) {
display: flex;
align-items: center;
justify-content: center;
}

[data-ready] #tester-ui {
width: var(--viewport-width);
height: var(--viewport-height);
transform: var(--tester-transform);
margin-left: var(--tester-margin-left);
opacity: 0.7;

font-weight: 300;
}
</style>
7 changes: 6 additions & 1 deletion packages/ui/client/composables/browser.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { ref } from 'vue'
import { ref, watch } from 'vue'

export type ViewportSize
= | 'small-mobile'
| 'large-mobile'
| 'tablet'
export const viewport = ref<[number, number]>([414, 896])

watch([viewport], () => {
document.body.style.setProperty('--viewport-width', `${viewport.value[0]}px`)
document.body.style.setProperty('--viewport-height', `${viewport.value[1]}px`)
}, { immediate: true, flush: 'sync' })
Loading
Loading