diff --git a/packages/browser-playwright/src/commands/index.ts b/packages/browser-playwright/src/commands/index.ts index 390c1649e..aa9bbe771 100644 --- a/packages/browser-playwright/src/commands/index.ts +++ b/packages/browser-playwright/src/commands/index.ts @@ -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 { @@ -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, } diff --git a/packages/browser-playwright/src/commands/screenshot.ts b/packages/browser-playwright/src/commands/screenshot.ts index f40adacc7..e38686e6a 100644 --- a/packages/browser-playwright/src/commands/screenshot.ts +++ b/packages/browser-playwright/src/commands/screenshot.ts @@ -9,6 +9,16 @@ interface ScreenshotCommandOptions extends Omit 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 @@ -51,6 +66,7 @@ export async function takeScreenshot( ...config, mask, path: savePath, + style, }) return { buffer, path } } @@ -59,6 +75,7 @@ export async function takeScreenshot( ...options, mask, path: savePath, + style, }) return { buffer, path } } diff --git a/packages/browser-playwright/src/commands/viewport.ts b/packages/browser-playwright/src/commands/viewport.ts new file mode 100644 index 000000000..7855bd063 --- /dev/null +++ b/packages/browser-playwright/src/commands/viewport.ts @@ -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) +} diff --git a/packages/browser-webdriverio/src/commands/screenshot.ts b/packages/browser-webdriverio/src/commands/screenshot.ts index a8fa1103f..de74dede0 100644 --- a/packages/browser-webdriverio/src/commands/screenshot.ts +++ b/packages/browser-webdriverio/src/commands/screenshot.ts @@ -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 } } diff --git a/packages/browser/src/client/orchestrator.ts b/packages/browser/src/client/orchestrator.ts index 8b3fbb8cd..4e16be08d 100644 --- a/packages/browser/src/client/orchestrator.ts +++ b/packages/browser/src/client/orchestrator.ts @@ -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) { @@ -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', @@ -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', @@ -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 `` + // - `tan()` converts it back to a unit-less `` + 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 } @@ -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 } @@ -447,16 +469,18 @@ 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', @@ -464,21 +488,6 @@ async function setIframeViewport( [{ 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[]) { diff --git a/packages/browser/src/client/tester/tester-utils.ts b/packages/browser/src/client/tester/tester-utils.ts index a32a52fad..f5def9837 100644 --- a/packages/browser/src/client/tester/tester-utils.ts +++ b/packages/browser/src/client/tester/tester-utils.ts @@ -224,15 +224,16 @@ export function processTimeoutOptions(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.`) } + + // DOMMatrix parses the computed 2D transform matrix [a, b, c, d, e, f] + // `a` and `d` are the x and y scale factors - since we only apply uniform scaling, `a === d` + const scale = new DOMMatrix(getComputedStyle(iframe).transform).a + return scale } diff --git a/packages/ui/client/components/BrowserIframe.vue b/packages/ui/client/components/BrowserIframe.vue index 20823fcbf..8769cece6 100644 --- a/packages/ui/client/components/BrowserIframe.vue +++ b/packages/ui/client/components/BrowserIframe.vue @@ -1,7 +1,6 @@ diff --git a/packages/ui/client/composables/browser.ts b/packages/ui/client/composables/browser.ts index 6767c9352..d15329807 100644 --- a/packages/ui/client/composables/browser.ts +++ b/packages/ui/client/composables/browser.ts @@ -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' }) diff --git a/packages/ui/client/styles/main.css b/packages/ui/client/styles/main.css index a6d48f033..975b61948 100644 --- a/packages/ui/client/styles/main.css +++ b/packages/ui/client/styles/main.css @@ -215,3 +215,31 @@ html.dark { .codemirror-busy > .CodeMirror > .CodeMirror-scroll > .CodeMirror-sizer .CodeMirror-lines { cursor: wait !important; } + +@property --viewport-width { + syntax: ""; + inherits: true; + initial-value: 0px; +} + +@property --viewport-height { + syntax: ""; + inherits: true; + initial-value: 0px; +} + +/* + Firefox can't resolve relative units like `cqw` directly inside `atan2()` + Registering as a typed `` property forces Firefox to resolve the unit to an absolute pixel value at computed-value time, before it reaches the function +*/ +@property --container-width { + syntax: ""; + inherits: false; + initial-value: 0px; +} + +@property --container-height { + syntax: ""; + inherits: false; + initial-value: 0px; +} diff --git a/test/browser/specs/to-match-screenshot.test.ts b/test/browser/specs/to-match-screenshot.test.ts index f5e196222..d652a1cbe 100644 --- a/test/browser/specs/to-match-screenshot.test.ts +++ b/test/browser/specs/to-match-screenshot.test.ts @@ -1,3 +1,4 @@ +import type { Stats } from 'node:fs' import type { ViteUserConfig } from 'vitest/config' import type { TestFsStructure } from '../../test-utils' import { platform } from 'node:os' @@ -5,7 +6,7 @@ import { describe, expect, test } from 'vitest' import { runInlineTests } from '../../test-utils' import { extractToMatchScreenshotPaths } from '../fixtures/expect-dom/utils' import utilsContent from '../fixtures/expect-dom/utils?raw' -import { provider } from '../settings' +import { instances, provider } from '../settings' const testFilename = 'basic.test.ts' const testName = 'screenshot-snapshot' @@ -19,14 +20,12 @@ import { render } from './utils' const dataTestId = 'inline-test' test('${testName}', async ({ expect }) => { - render('
Inline Test
') + render('
Inline Test
') await expect(page.getByTestId(dataTestId)).toMatchScreenshot() }) ` -const browser = 'chromium' - async function runBrowserTests( structure: TestFsStructure, config: ViteUserConfig['test'] = {}, @@ -34,28 +33,34 @@ async function runBrowserTests( return runInlineTests({ ...structure, 'vitest.config.js': ` - import { playwright } from '@vitest/browser-playwright' export default { test: { browser: { - enabled: true, - screenshotFailures: false, - provider: playwright(), - headless: true, - instances: [{ browser: ${JSON.stringify(browser)} }], + ui: false, }, - reporters: ['verbose'], - ...${JSON.stringify(config)}, }, }`, }, { $cliOptions: { watch: true, }, + browser: { + enabled: true, + screenshotFailures: false, + provider, + headless: true, + instances, + viewport: { + width: 400, + height: 200, + }, + }, + reporters: ['verbose'], + ...config, }) } -describe.runIf(provider.name === 'playwright')('--watch', () => { +describe('--watch', () => { test( 'fails when creating a snapshot for the first time and does NOT update it afterwards', async () => { @@ -69,22 +74,31 @@ describe.runIf(provider.name === 'playwright')('--watch', () => { }, ) - const [referencePath] = extractToMatchScreenshotPaths(stderr, testName) + const references = extractToMatchScreenshotPaths(stderr, testName) + const referenceStats: Record> = {} - expect(stderr).toContain(`No existing reference screenshot found; a new one was created. Review it before running tests again.\n\nReference screenshot:\n ${referencePath}`) + for (const referencePath of references) { + expect(stderr).toContain(`No existing reference screenshot found; a new one was created. Review it before running tests again.\n\nReference screenshot:\n ${referencePath}`) - const { atime: _1, atimeMs: _2, ...referenceStat } = fs.statFile(referencePath) + const { atime: _1, atimeMs: _2, ...referenceStat } = fs.statFile(referencePath) + + referenceStats[referencePath] = referenceStat + } fs.editFile(testFilename, content => `${content}\n`) vitest.resetOutput() - await vitest.waitForStdout('Test Files 1 passed') + await vitest.waitForStdout(`Test Files ${instances.length} passed`) - expect(vitest.stdout).toContain('✓ |chromium| basic.test.ts > screenshot-snapshot') + for (const instance of instances) { + expect(vitest.stdout).toContain(`✓ |${instance.browser}| basic.test.ts > screenshot-snapshot`) + } - const { atime: _3, atimeMs: _4, ...newReferenceStat } = fs.statFile(referencePath) + for (const referencePath of references) { + const { atime: _3, atimeMs: _4, ...newReferenceStat } = fs.statFile(referencePath) - expect(referenceStat).toEqual(newReferenceStat) + expect(referenceStats[referencePath]).toEqual(newReferenceStat) + } }, ) @@ -106,9 +120,12 @@ describe.runIf(provider.name === 'playwright')('--watch', () => { fs.editFile(testFilename, content => content.replace(bgColor, '#0ff')) vitest.resetOutput() - await vitest.waitForStdout('Test Files 1 failed') + await vitest.waitForStdout(`Test Files ${instances.length} failed`) + + for (const instance of instances) { + expect(vitest.stdout).toContain(`× |${instance.browser}| basic.test.ts > screenshot-snapshot`) + } - expect(vitest.stdout).toContain('× |chromium| basic.test.ts > screenshot-snapshot') expect(vitest.stdout).toContain('Screenshot does not match the stored reference.') expect(vitest.stdout).toMatch(/\d+ pixels \(ratio 0.\d{2}\) differ\./) }, @@ -131,34 +148,38 @@ describe.runIf(provider.name === 'playwright')('--watch', () => { expect(stderr).toMatchInlineSnapshot(`""`) const osPlatform = platform() - const referencePath = `__screenshots__/${testFilename}/${testName}-1-${browser}-${osPlatform}.png` - const referenceStat = fs.statFile(referencePath) + const references = extractToMatchScreenshotPaths(stderr, testName) + .map(referencePath => [referencePath, fs.statFile(referencePath)] as const) fs.editFile(testFilename, content => `${content}\n`) vitest.resetOutput() - await vitest.waitForStdout('Test Files 1 passed') + await vitest.waitForStdout(`Test Files ${instances.length} passed`) - expect(vitest.stdout).toContain('✓ |chromium| basic.test.ts > screenshot-snapshot') + for (const instance of instances) { + expect(vitest.stdout).toContain(`✓ |${instance.browser}| basic.test.ts > screenshot-snapshot`) + } // only atime should change since reference should NOT be updated - const { - atime, - atimeMs, - ...diffs - } = fs.statFile(referencePath) - - expect(referenceStat).toEqual(expect.objectContaining(diffs)) - - // win32 does not update `atime` by default - if (osPlatform === 'win32') { - expect(atime.getTime()).toEqual(referenceStat.atime.getTime()) - expect(atimeMs).toEqual(referenceStat.atimeMs) - } - else { - expect(atime.getTime()).toBeGreaterThan(referenceStat.atime.getTime()) - expect(atimeMs).toBeGreaterThan(referenceStat.atimeMs) + for (const [referencePath, referenceStat] of references) { + const { + atime, + atimeMs, + ...diffs + } = fs.statFile(referencePath) + + expect(referenceStat).toEqual(expect.objectContaining(diffs)) + + // win32 does not update `atime` by default + if (osPlatform === 'win32') { + expect(atime.getTime()).toEqual(referenceStat.atime.getTime()) + expect(atimeMs).toEqual(referenceStat.atimeMs) + } + else { + expect(atime.getTime()).toBeGreaterThan(referenceStat.atime.getTime()) + expect(atimeMs).toBeGreaterThan(referenceStat.atimeMs) + } } }, ) @@ -178,39 +199,68 @@ describe.runIf(provider.name === 'playwright')('--watch', () => { expect(stderr).toMatchInlineSnapshot(`""`) - const referencePath = `__screenshots__/${testFilename}/${testName}-1-${browser}-${platform()}.png` - const referenceStat = fs.statFile(referencePath) + const references = extractToMatchScreenshotPaths(stderr, testName) + .map(referencePath => [referencePath, fs.statFile(referencePath)] as const) fs.editFile(testFilename, content => content.replace(bgColor, '#000')) vitest.resetOutput() - await vitest.waitForStdout('Test Files 1 passed') + await vitest.waitForStdout(`Test Files ${instances.length} passed`) - expect(vitest.stdout).toContain('✓ |chromium| basic.test.ts > screenshot-snapshot') + for (const instance of instances) { + expect(vitest.stdout).toContain(`✓ |${instance.browser}| basic.test.ts > screenshot-snapshot`) + } // atime, ctime, mtime, and size should change since reference should be updated - const { - atime, - atimeMs, - ctime, - ctimeMs, - mtime, - mtimeMs, - size, - ...diffs - } = fs.statFile(referencePath) - - expect(referenceStat).toEqual(expect.objectContaining(diffs)) - - expect(atime.getTime()).toBeGreaterThan(referenceStat.atime.getTime()) - expect(ctime.getTime()).toBeGreaterThan(referenceStat.ctime.getTime()) - expect(mtime.getTime()).toBeGreaterThan(referenceStat.mtime.getTime()) - - expect(atimeMs).toBeGreaterThan(referenceStat.atimeMs) - expect(ctimeMs).toBeGreaterThan(referenceStat.ctimeMs) - expect(mtimeMs).toBeGreaterThan(referenceStat.mtimeMs) + for (const [referencePath, referenceStat] of references) { + const { + atime, + atimeMs, + ctime, + ctimeMs, + mtime, + mtimeMs, + size, + ...diffs + } = fs.statFile(referencePath) + + expect(referenceStat).toEqual(expect.objectContaining(diffs)) + + expect(atime.getTime()).toBeGreaterThan(referenceStat.atime.getTime()) + expect(ctime.getTime()).toBeGreaterThan(referenceStat.ctime.getTime()) + expect(mtime.getTime()).toBeGreaterThan(referenceStat.mtime.getTime()) + + expect(atimeMs).toBeGreaterThan(referenceStat.atimeMs) + expect(ctimeMs).toBeGreaterThan(referenceStat.ctimeMs) + expect(mtimeMs).toBeGreaterThan(referenceStat.mtimeMs) + } }, ) }) + + // tests whether the screenshots are stable in non-UI and UI mode + test( + 'screenshots match across non-UI and UI mode', + async () => { + const { fs, vitest } = await runBrowserTests( + { + [testFilename]: testContent, + 'utils.ts': utilsContent, + }, + { + update: 'new', + }, + ) + + await vitest.waitForStderr(`Failed Tests ${instances.length}`, 20_000) + + vitest.resetOutput() + + // switch to UI mode + fs.editFile('vitest.config.js', content => content.replace('ui: false,', 'ui: true,')) + + await vitest.waitForStdout(`Test Files ${instances.length} passed`, 20_000) + }, + ) }) diff --git a/test/test-utils/cli.ts b/test/test-utils/cli.ts index 3db845324..aa64df694 100644 --- a/test/test-utils/cli.ts +++ b/test/test-utils/cli.ts @@ -61,15 +61,20 @@ export class Cli { this.stderr = '' } - waitForStdout(expected: string) { - return this.waitForOutput(expected, 'stdout', this.waitForStdout) + waitForStdout(expected: string, timeout?: number) { + return this.waitForOutput(expected, 'stdout', this.waitForStdout, timeout) } - waitForStderr(expected: string) { - return this.waitForOutput(expected, 'stderr', this.waitForStderr) + waitForStderr(expected: string, timeout?: number) { + return this.waitForOutput(expected, 'stderr', this.waitForStderr, timeout) } - private waitForOutput(expected: string, source: Source, caller: Parameters[1]) { + private waitForOutput( + expected: string, + source: Source, + caller: Parameters[1], + timeout?: number, + ) { const error = new Error('Timeout') Error.captureStackTrace(error, caller) @@ -78,15 +83,15 @@ export class Cli { return resolve() } - const timeout = setTimeout(() => { + const timeoutId = setTimeout(() => { error.message = `Timeout when waiting for error "${expected}".\nReceived:\nstdout: ${this.stdout}\nstderr: ${this.stderr}` reject(error) - }, process.env.CI ? 20_000 : 4_000) + }, timeout ?? process.env.CI ? 20_000 : 4_000) const listener = () => { if (this[source].includes(expected)) { - if (timeout) { - clearTimeout(timeout) + if (timeoutId) { + clearTimeout(timeoutId) } resolve()