diff --git a/projects/core/.visual/dialog.center-alignment.png b/projects/core/.visual/dialog.center-alignment.png index 60ef9c9d2..15770f9c3 100644 --- a/projects/core/.visual/dialog.center-alignment.png +++ b/projects/core/.visual/dialog.center-alignment.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0a6ce7a929ef0bf32d7cde0ce9ee09a6989d9a2f1f193bbedb1807ed3b4fe49b -size 16529 +oid sha256:c5edddcafef562847a7097ed0bde37cf6e78a21653fd96f9b289176c71d0029f +size 26823 diff --git a/projects/core/.visual/dialog.dark.png b/projects/core/.visual/dialog.dark.png index 4548bc3fc..47de49116 100644 --- a/projects/core/.visual/dialog.dark.png +++ b/projects/core/.visual/dialog.dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f715a40935e94fe3cb183985aa1faeb90d675dc711f3e8fd89038c9b56624e53 -size 11376 +oid sha256:2f88011b34c109c45c466b235d997dbdfab794b0e0736f36a8354491152efdd1 +size 11295 diff --git a/projects/core/.visual/dialog.png b/projects/core/.visual/dialog.png index 8f7589916..e5f55f38b 100644 --- a/projects/core/.visual/dialog.png +++ b/projects/core/.visual/dialog.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b4f6aa9f3a62ae15406a0dfbd92703bcff2645b45aa9db27acf8de1a995c577d -size 10303 +oid sha256:694ecdb8073dd03cb19929357c58cb9ebb00b0163b99feec3b7fd70c144da4e6 +size 14824 diff --git a/projects/core/.visual/dialog.scroll-height.png b/projects/core/.visual/dialog.scroll-height.png index 34e783b0f..f573c5c0b 100644 --- a/projects/core/.visual/dialog.scroll-height.png +++ b/projects/core/.visual/dialog.scroll-height.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6b027c5f3d7d15280f359180b658f455e46ce8e12bf7e7d29ee0f67729a43c71 -size 11166 +oid sha256:a1f0c463d0bea699c779495362fb5d50beea08a0b02e8b64f7f211381944c90b +size 11068 diff --git a/projects/core/.visual/toast.body-anchor.png b/projects/core/.visual/toast.body-anchor.png index 5c15d76e4..f269d2cd6 100644 --- a/projects/core/.visual/toast.body-anchor.png +++ b/projects/core/.visual/toast.body-anchor.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fd971b20059ac27c7cb35b48b66a4e057187725fef6b6f8f545f8e9b88161425 -size 20071 +oid sha256:faf40020f09c96517e2e5313072c9a49474284415eea9906ff5fc3db3f519754 +size 19096 diff --git a/projects/internals/vite/src/index.d.ts b/projects/internals/vite/src/index.d.ts index 9a8267f1f..2daf401b5 100644 --- a/projects/internals/vite/src/index.d.ts +++ b/projects/internals/vite/src/index.d.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import type { UserConfig } from 'vite'; +import type { Page } from 'playwright'; export declare const libraryBuildConfig: UserConfig; export declare const libraryNodeBuildConfig: UserConfig; @@ -40,8 +41,35 @@ export declare const visualRunner: { waitFor?: (waitForFunction: (...args: unknown[]) => Promise) => Promise; } ): Promise<{ maxDiffPercentage: number }>; + inspect( + name: string, + content: string, + inspectPage: (page: Page) => Result | Promise, + options?: { deviceScaleFactor?: number } + ): Promise; + runWebGPUSmoke(name: string): Promise; }; +export interface WebGPUVisualDiagnostics { + browserVersion: string; + chromiumArgs: string[]; + secureContext: boolean; + adapterInfo: { + vendor: string; + architecture: string; + device: string; + description: string; + isFallbackAdapter?: boolean; + } | null; + software: boolean; + workCompleted: boolean; + clearFrame: boolean; + clearPixel?: number[]; + expectedPixel?: number[]; + format?: string; + error?: string; +} + export declare const ssrRunner: { render(content: unknown): Promise; }; diff --git a/projects/internals/vite/src/runners/lighthouse.js b/projects/internals/vite/src/runners/lighthouse.js index af887a75b..707ce0035 100644 --- a/projects/internals/vite/src/runners/lighthouse.js +++ b/projects/internals/vite/src/runners/lighthouse.js @@ -2,10 +2,25 @@ import lighthouse from 'lighthouse'; import fs from 'fs'; import { resolve } from 'path'; import { VitePlaywrightRunner, buildPage } from './playwright.js'; +import { withSoftwareWebGPUChromiumArgs } from './webgpu.js'; const output = process.env.CI ? ['json'] : ['json', 'html']; const RUNNER_ID = 'lighthouse'; const LIGHTHOUSE_FLAGS = { logLevel: 'error', output }; +const CHROMIUM_ARGS = withSoftwareWebGPUChromiumArgs([ + '--headless', + '--remote-debugging-port=9222', + '--disable-dev-shm-usage', + '--disable-extensions', + '--disable-background-networking', + '--disable-default-apps', + '--disable-sync', + '--disable-translate', + '--metrics-recording-only', + '--mute-audio', + '--no-first-run', + '--safebrowsing-disable-auto-update' +]); const LIGHTHOUSE_CONFIG = { // https://github.com/GoogleChrome/lighthouse/blob/main/core/config/default-config.js extends: 'lighthouse:default', @@ -22,24 +37,11 @@ const LIGHTHOUSE_CONFIG = { }; export class LighthouseRunner { - #runner = new VitePlaywrightRunner({ - runnerID: RUNNER_ID, - chromiumArgs: [ - '--headless', - '--remote-debugging-port=9222', - '--disable-dev-shm-usage', - '--disable-gpu', - '--disable-extensions', - '--disable-background-networking', - '--disable-default-apps', - '--disable-sync', - '--disable-translate', - '--metrics-recording-only', - '--mute-audio', - '--no-first-run', - '--safebrowsing-disable-auto-update' - ] - }); + #runner; + + constructor({ chromiumArgs = CHROMIUM_ARGS } = {}) { + this.#runner = new VitePlaywrightRunner({ runnerID: RUNNER_ID, chromiumArgs }); + } async open() { await this.#runner.open(); diff --git a/projects/internals/vite/src/runners/playwright.js b/projects/internals/vite/src/runners/playwright.js index 1379df878..86d7c12d9 100644 --- a/projects/internals/vite/src/runners/playwright.js +++ b/projects/internals/vite/src/runners/playwright.js @@ -6,6 +6,8 @@ import { preview, build } from 'vite'; const resolve = rel => path.resolve(process.cwd(), rel); const DEFAULT_PORT = 4176; +export const VIEWPORT_WIDTH = 1180; +export const VIEWPORT_HEIGHT = 820; process.env.NODE_ENV = 'production'; @@ -26,6 +28,10 @@ export class VitePlaywrightRunner { return this.#page; } + get browserVersion() { + return this.#browser?.version(); + } + get port() { return this.#serverPort ?? this.#storedPort ?? DEFAULT_PORT; } @@ -96,7 +102,9 @@ export class VitePlaywrightRunner { throw error; }); console.log('playwright-runner: creating context'); - this.#page = await (await this.#browser.newContext({ viewport: { width: 1180, height: 820 } })).newPage(); + this.#page = await ( + await this.#browser.newContext({ viewport: { width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT } }) + ).newPage(); this.#page.on('crash', data => console.error('playwright-runner: browser crashed', data)); console.log('playwright-runner: creating server'); this.#server = await preview({ @@ -137,8 +145,7 @@ export async function buildPage(testName, runnerID, render) { if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true }); let scriptIdx = 0; - const scriptRe = /([\s\S]*?)<\/script>/g; - html = html.replace(scriptRe, (_match, content) => { + html = replaceInlineModuleScripts(html, content => { const name = `_entry${scriptIdx++}.js`; fs.writeFileSync(path.join(tmpDir, name), content.trim()); return ``; @@ -177,3 +184,56 @@ export async function buildPage(testName, runnerID, render) { fs.rmSync(tmpDir, { recursive: true, force: true }); } } + +/** Replaces inline module scripts with a forward-only scan of the HTML. */ +export function replaceInlineModuleScripts(html, replaceScript) { + const parts = []; + let copiedUntil = 0; + let searchFrom = 0; + + while (searchFrom < html.length) { + const scriptStart = html.indexOf('', contentStart); + if (scriptEnd === -1) break; + + parts.push(html.slice(copiedUntil, scriptStart)); + parts.push(replaceScript(html.slice(contentStart, scriptEnd))); + copiedUntil = scriptEnd + ''.length; + searchFrom = copiedUntil; + } + + parts.push(html.slice(copiedUntil)); + return parts.join(''); +} + +function getInlineModuleScriptContentStart(html, scriptStart) { + let cursor = scriptStart + '') { + return undefined; + } + + return moduleEnd + 2; +} + +function isWhitespace(character) { + return character !== undefined && character.trim() === ''; +} diff --git a/projects/internals/vite/src/runners/visual.js b/projects/internals/vite/src/runners/visual.js index b667586fa..86085541e 100644 --- a/projects/internals/vite/src/runners/visual.js +++ b/projects/internals/vite/src/runners/visual.js @@ -1,20 +1,26 @@ import fs from 'fs'; import { PNG } from 'pngjs'; import pixelmatch from 'pixelmatch'; -import { VitePlaywrightRunner, buildPage } from './playwright.js'; +import { VitePlaywrightRunner, buildPage, VIEWPORT_WIDTH, VIEWPORT_HEIGHT } from './playwright.js'; +import { withSoftwareWebGPUChromiumArgs } from './webgpu.js'; const RUNNER_ID = 'visual'; -const CHROMIUM_ARGS = [ +const CHROMIUM_ARGS = withSoftwareWebGPUChromiumArgs([ '--headless', '--font-render-hinting=none', '--disable-skia-runtime-opts', '--disable-font-subpixel-positioning', - '--disable-lcd-text', - '--disable-gpu' -]; + '--disable-lcd-text' +]); export class VisualRunner { - #runner = new VitePlaywrightRunner({ runnerID: RUNNER_ID, chromiumArgs: CHROMIUM_ARGS }); + #chromiumArgs; + #runner; + + constructor({ chromiumArgs = CHROMIUM_ARGS } = {}) { + this.#chromiumArgs = chromiumArgs; + this.#runner = new VitePlaywrightRunner({ runnerID: RUNNER_ID, chromiumArgs }); + } async open() { await this.#runner.open(); @@ -48,26 +54,315 @@ export class VisualRunner { await options.waitFor(this.#runner.page.waitForFunction.bind(this.#runner.page)); } - const baselinePath = `./.${RUNNER_ID}/${name}.png`; + return this.#compareSnapshot(name); + } + + async inspect(name, content, inspectPage, options = {}) { + await buildPage(name, RUNNER_ID, template => template.replace('', `${content}`)); + const session = await setDeviceScaleFactor(this.#runner.page, options.deviceScaleFactor); + try { + await this.#runner.page.goto(`http://localhost:${this.#runner.port}/${name}/index.html`); + await this.#runner.page.evaluate(async () => { + await document.fonts.ready; + await new Promise(resolve => requestAnimationFrame(resolve)); + }); + const result = await inspectPage(this.#runner.page); + const report = await this.#compareSnapshot(name); + if (report.maxDiffPercentage >= 1) { + throw new Error(`Visual snapshot "${name}" differs from its baseline by ${report.maxDiffPercentage}%.`); + } + return result; + } finally { + try { + await session?.send('Emulation.clearDeviceMetricsOverride'); + } catch { + // Cleanup failures must not replace the inspection result. + } + try { + await session?.detach(); + } catch { + // Cleanup failures must not replace the inspection result. + } + } + } + + async runWebGPUSmoke(name) { + await buildPage(name, RUNNER_ID, template => + template.replace('', '') + ); + + await this.#runner.page.goto(`http://localhost:${this.#runner.port}/${name}/index.html`); - if (fs.existsSync(baselinePath)) { - const img1 = PNG.sync.read(fs.readFileSync(baselinePath)); - const img2 = PNG.sync.read(await this.#runner.page.locator('body').screenshot()); - const imgDiff = new PNG({ width: img1.width, height: img1.height }); - const maxDiffPercentage = getImagePercentageDifference(img1, img2, imgDiff); + const diagnostics = { + browserVersion: this.#runner.browserVersion ?? 'unknown', + chromiumArgs: [...this.#chromiumArgs], + secureContext: false, + adapterInfo: null, + software: false, + workCompleted: false, + clearFrame: false + }; - if (maxDiffPercentage >= 1) { - fs.writeFileSync(`./.${RUNNER_ID}/${name}.diff.png`, PNG.sync.write(imgDiff)); - if (!process.env.CI) { - fs.writeFileSync(baselinePath, PNG.sync.write(img2)); + try { + diagnostics.secureContext = await this.#runner.page.evaluate(() => window.isSecureContext); + const result = await this.#runner.page.evaluate(async () => { + if (!window.isSecureContext) { + throw new Error('WebGPU requires a secure context'); } - } - return { maxDiffPercentage }; - } else { - await this.#runner.page.locator('body').screenshot({ path: baselinePath }); + if (!navigator.gpu) { + throw new Error('navigator.gpu is unavailable'); + } + + const adapter = await navigator.gpu.requestAdapter(); + if (!adapter) { + throw new Error('requestAdapter returned null'); + } + + const adapterInfo = { + vendor: adapter.info.vendor, + architecture: adapter.info.architecture, + device: adapter.info.device, + description: adapter.info.description, + isFallbackAdapter: adapter.info.isFallbackAdapter + }; + const device = await adapter.requestDevice(); + let readbackTexture; + let readback; + let hasReadbackMapping = false; + let hasValidationErrorScope = false; + let validationError; + let clearPixel; + let expectedPixel; + let clearFrame; + let format; + + try { + device.pushErrorScope('validation'); + hasValidationErrorScope = true; + const module = device.createShaderModule({ + code: '@compute @workgroup_size(1) fn main() {}' + }); + const pipeline = await device.createComputePipelineAsync({ + layout: 'auto', + compute: { module, entryPoint: 'main' } + }); + const canvas = document.querySelector('#webgpu-smoke'); + const context = canvas?.getContext('webgpu'); + + if (!context) { + throw new Error('WebGPU canvas context is unavailable'); + } + + format = navigator.gpu.getPreferredCanvasFormat(); + context.configure({ + device, + format, + alphaMode: 'opaque', + usage: GPUTextureUsage.RENDER_ATTACHMENT + }); + + const clearColor = { r: 0.25, g: 0.5, b: 0.75, a: 1 }; + // Use a copyable texture for deterministic readback after verifying that the canvas can be configured. + readbackTexture = device.createTexture({ + size: { width: 1, height: 1, depthOrArrayLayers: 1 }, + format, + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC + }); + readback = device.createBuffer({ + size: 256, + usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ + }); + const encoder = device.createCommandEncoder(); + const computePass = encoder.beginComputePass(); + computePass.setPipeline(pipeline); + computePass.dispatchWorkgroups(1); + computePass.end(); + const pass = encoder.beginRenderPass({ + colorAttachments: [ + { + view: readbackTexture.createView(), + clearValue: clearColor, + loadOp: 'clear', + storeOp: 'store' + } + ] + }); + pass.end(); + encoder.copyTextureToBuffer( + { texture: readbackTexture }, + { buffer: readback, bytesPerRow: 256 }, + { width: 1, height: 1, depthOrArrayLayers: 1 } + ); + device.queue.submit([encoder.finish()]); + await device.queue.onSubmittedWorkDone(); + await readback.mapAsync(GPUMapMode.READ); + hasReadbackMapping = true; + + clearPixel = [...new Uint8Array(readback.getMappedRange()).slice(0, 4)]; + const rgba = [64, 128, 191, 255]; + expectedPixel = format.startsWith('bgra') ? [rgba[2], rgba[1], rgba[0], rgba[3]] : rgba; + clearFrame = clearPixel.every((value, index) => Math.abs(value - expectedPixel[index]) <= 1); + } finally { + if (hasValidationErrorScope) { + validationError = await device.popErrorScope(); + } + if (hasReadbackMapping) { + readback?.unmap(); + } + readback?.destroy(); + readbackTexture?.destroy(); + device.destroy(); + } + + return { + secureContext: window.isSecureContext, + adapterInfo, + software: + adapterInfo.isFallbackAdapter === true || adapterInfo.architecture.toLowerCase().includes('swiftshader'), + workCompleted: true, + clearFrame, + clearPixel, + expectedPixel, + format, + error: validationError?.message + }; + }); + + return { ...diagnostics, ...result }; + } catch (error) { + return { + ...diagnostics, + error: error instanceof Error ? error.message : String(error) + }; + } + } + + async #compareSnapshot(name) { + const baselinePath = `./.${RUNNER_ID}/${name}.png`; + + if (!fs.existsSync(baselinePath)) { + fs.writeFileSync(baselinePath, await captureBodyScreenshot(this.#runner.page)); return { maxDiffPercentage: 0 }; } + + const img1 = PNG.sync.read(fs.readFileSync(baselinePath)); + const img2 = PNG.sync.read(await captureBodyScreenshot(this.#runner.page)); + const imgDiff = new PNG({ width: img1.width, height: img1.height }); + const maxDiffPercentage = getImagePercentageDifference(img1, img2, imgDiff); + + if (maxDiffPercentage >= 1) { + fs.writeFileSync(`./.${RUNNER_ID}/${name}.diff.png`, PNG.sync.write(imgDiff)); + if (!process.env.CI) { + fs.writeFileSync(baselinePath, PNG.sync.write(img2)); + } + } + + return { maxDiffPercentage }; + } +} + +async function captureBodyScreenshot(page) { + const viewport = page.viewportSize(); + if (!viewport) { + throw new Error('Visual screenshots require a fixed viewport'); + } + + const bounds = await page.evaluate(() => { + const { left, top, right, bottom } = document.body.getBoundingClientRect(); + return { + left: left + window.scrollX, + top: top + window.scrollY, + right: right + window.scrollX, + bottom: bottom + window.scrollY + }; + }); + if (bounds.right - bounds.left <= viewport.width && bounds.bottom - bounds.top <= viewport.height) { + return page.locator('body').screenshot(); + } + + // SwiftShader can stall when Chromium captures a surface larger than the viewport. + const initialViewportImage = PNG.sync.read(await page.screenshot()); + const scaleX = initialViewportImage.width / viewport.width; + const scaleY = initialViewportImage.height / viewport.height; + const bodyImage = new PNG({ + width: Math.ceil((bounds.right - bounds.left) * scaleX), + height: Math.ceil((bounds.bottom - bounds.top) * scaleY) + }); + const xTargets = createScreenshotTargets(bounds.left, bounds.right, viewport.width); + const yTargets = createScreenshotTargets(bounds.top, bounds.bottom, viewport.height); + const originalScroll = await page.evaluate(() => ({ x: window.scrollX, y: window.scrollY })); + + try { + for (const y of yTargets) { + for (const x of xTargets) { + const scroll = await page.evaluate( + async ({ x, y }) => { + window.scrollTo(x, y); + await new Promise(resolve => requestAnimationFrame(resolve)); + return { x: window.scrollX, y: window.scrollY }; + }, + { x, y } + ); + const viewportImage = PNG.sync.read(await page.screenshot()); + const left = Math.max(bounds.left, scroll.x); + const top = Math.max(bounds.top, scroll.y); + const right = Math.min(bounds.right, scroll.x + viewport.width); + const bottom = Math.min(bounds.bottom, scroll.y + viewport.height); + const sourceX = Math.floor((left - scroll.x) * scaleX); + const sourceY = Math.floor((top - scroll.y) * scaleY); + const destinationX = Math.floor((left - bounds.left) * scaleX); + const destinationY = Math.floor((top - bounds.top) * scaleY); + const width = Math.min( + Math.ceil((right - left) * scaleX), + viewportImage.width - sourceX, + bodyImage.width - destinationX + ); + const height = Math.min( + Math.ceil((bottom - top) * scaleY), + viewportImage.height - sourceY, + bodyImage.height - destinationY + ); + + PNG.bitblt(viewportImage, bodyImage, sourceX, sourceY, width, height, destinationX, destinationY); + } + } + } finally { + await page.evaluate(({ x, y }) => window.scrollTo(x, y), originalScroll); + } + + return PNG.sync.write(bodyImage); +} + +function createScreenshotTargets(start, end, viewportSize) { + const targets = []; + for (let position = start; position < end; position += viewportSize) { + targets.push(position); + } + return targets; +} + +async function setDeviceScaleFactor(page, deviceScaleFactor) { + if (deviceScaleFactor === undefined) { + return undefined; + } + const session = await page.context().newCDPSession(page); + const deviceMetrics = { + width: VIEWPORT_WIDTH, + height: VIEWPORT_HEIGHT, + deviceScaleFactor, + mobile: false + }; + let setupComplete = false; + try { + await session.send('Emulation.setDeviceMetricsOverride', deviceMetrics); + console.log('visual-runner: applied device metrics override', deviceMetrics); + setupComplete = true; + return session; + } finally { + if (!setupComplete) { + await session.detach(); + } } } diff --git a/projects/internals/vite/src/runners/webgpu.js b/projects/internals/vite/src/runners/webgpu.js new file mode 100644 index 000000000..2d218b195 --- /dev/null +++ b/projects/internals/vite/src/runners/webgpu.js @@ -0,0 +1,19 @@ +const INCOMPATIBLE_SOFTWARE_ARGUMENTS = new Set(['--disable-gpu', '--disable-software-rasterizer']); +const SOFTWARE_WEBGPU_ARGUMENTS = [ + '--use-angle=swiftshader', + '--use-vulkan=swiftshader', + '--use-webgpu-adapter=swiftshader', + '--enable-features=Vulkan', + '--enable-unsafe-webgpu', + '--use-gpu-in-tests' +]; + +/** Creates deterministic software-WebGPU Chromium arguments without mutating the base profile. */ +export function withSoftwareWebGPUChromiumArgs(chromiumArgs) { + return [ + ...chromiumArgs.filter( + argument => !INCOMPATIBLE_SOFTWARE_ARGUMENTS.has(argument) && !SOFTWARE_WEBGPU_ARGUMENTS.includes(argument) + ), + ...SOFTWARE_WEBGPU_ARGUMENTS + ]; +}