import { expect, test, type Page } from '@playwright/test'; type WebGpuFailureMode = 'adapter-null' | 'adapter-rejects' | 'device-rejects'; const disableWebGpu = async (page: Page) => { await page.addInitScript(() => { Object.defineProperty(navigator, 'gpu', { configurable: true, value: undefined, }); }); }; const emulateWebGpuFailure = async (page: Page, mode: WebGpuFailureMode) => { await page.addInitScript((failureMode) => { const limits = { maxBufferSize: 256 * 1024 * 1024, maxComputeWorkgroupsPerDimension: 65_535, maxStorageBufferBindingSize: 128 * 1024 * 1024, }; const adapter = { features: new Set(), info: { architecture: 'test', description: 'Playwright fake adapter', device: 'test-device', isFallbackAdapter: false, subgroupMaxSize: 0, subgroupMinSize: 0, vendor: 'test-vendor', }, limits, requestDevice: async () => { if (failureMode === 'device-rejects') { throw new Error('Playwright fake device failure'); } return {}; }, }; Object.defineProperty(navigator, 'gpu', { configurable: true, value: { getPreferredCanvasFormat: () => 'rgba8unorm', requestAdapter: async () => { if (failureMode === 'adapter-null') { return null; } if (failureMode === 'adapter-rejects') { throw new Error('Playwright fake adapter failure'); } return adapter; }, }, }); }, mode); }; const getFirstSwatchColor = (page: Page) => page .locator('.color-swatch') .first() .evaluate((element) => getComputedStyle(element).backgroundColor); const getGardenBackground = (page: Page) => page.evaluate(() => document.documentElement.style.getPropertyValue('--garden-background').trim() ); test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) => { await disableWebGpu(page); await page.goto('/'); await expect(page).toHaveTitle('Fleeting Garden'); await expect( page.getByRole('img', { name: 'Interactive generative garden canvas' }) ).toBeVisible(); await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); await expect(page.locator('body')).not.toHaveClass(/is-loading/); await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU'); await page.getByRole('button', { name: 'About' }).click(); await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible(); }); test('keeps fallback controls interactive and accessible', async ({ page }) => { await disableWebGpu(page); await page.goto('/'); await expect(page.locator('body')).not.toHaveClass(/is-loading/); const aboutButton = page.getByRole('button', { name: 'About' }); const aboutPanel = page.locator('#info-panel'); await expect(aboutButton).toHaveAttribute('aria-expanded', 'false'); await aboutButton.click(); await expect(aboutButton).toHaveAttribute('aria-expanded', 'true'); await expect(aboutPanel).toHaveAttribute('aria-hidden', 'false'); await expect(aboutPanel).not.toHaveAttribute('inert', ''); await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible(); await page.keyboard.press('Escape'); await expect(aboutButton).toHaveAttribute('aria-expanded', 'false'); await expect(aboutPanel).toHaveAttribute('aria-hidden', 'true'); await expect(aboutPanel).toHaveAttribute('inert', ''); const settingsButton = page.getByRole('button', { name: 'Show config overlay' }); await settingsButton.click(); await expect(page.getByRole('button', { name: 'Hide config overlay' })).toHaveAttribute( 'aria-expanded', 'true' ); await expect(page.locator('.config-pane')).toBeVisible(); const soundButton = page.locator('button.sound'); await expect(soundButton).toHaveAttribute('aria-pressed', 'false'); await soundButton.click(); await expect(soundButton).toHaveAttribute('aria-pressed', 'true'); await expect(soundButton).toHaveAttribute('aria-label', 'Unmute audio'); await page.reload(); await expect(page.locator('body')).not.toHaveClass(/is-loading/); await expect(page.locator('button.sound')).toHaveAttribute('aria-pressed', 'true'); const initialSwatchColor = await getFirstSwatchColor(page); const initialBackground = await getGardenBackground(page); await page.getByRole('button', { name: 'Next vibe' }).click(); await expect.poll(() => getFirstSwatchColor(page)).not.toBe(initialSwatchColor); await expect.poll(() => getGardenBackground(page)).not.toBe(initialBackground); await page.getByRole('button', { name: 'Draw colour 2' }).click(); await expect(page.locator('.color-swatch').nth(1)).toHaveClass(/active/); await expect(page.locator('.color-swatch').first()).not.toHaveClass(/active/); const mirrorSlider = page.locator('.mirror-segment-slider'); await mirrorSlider.evaluate((input) => { const slider = input as HTMLInputElement; slider.value = '3'; slider.dispatchEvent(new Event('input', { bubbles: true })); }); await expect(page.locator('.mirror-segment-control')).toHaveAttribute( 'title', '3 thirds' ); await expect(page.locator('.mirror-segment-control')).toHaveClass(/active/); }); ( [ { expectedCode: 'webgpu-adapter-unavailable', expectedMessage: 'WebGPU is available, but this browser could not provide a compatible GPU adapter.', mode: 'adapter-null', }, { expectedCode: 'webgpu-adapter-unavailable', expectedMessage: 'Could not request a WebGPU adapter.', mode: 'adapter-rejects', }, { expectedCode: 'webgpu-device-unavailable', expectedMessage: 'Could not create a WebGPU device for this adapter.', mode: 'device-rejects', }, ] satisfies Array<{ expectedCode: string; expectedMessage: string; mode: WebGpuFailureMode; }> ).forEach(({ expectedCode, expectedMessage, mode }) => { test(`reports ${mode} startup failures without leaving the shell loading`, async ({ page, }) => { await emulateWebGpuFailure(page, mode); await page.goto('/'); await expect(page.locator('body')).not.toHaveClass(/is-loading/); await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); await expect(page.getByRole('alert')).toContainText(expectedMessage); await expect(page.getByRole('alert')).toContainText(expectedCode); }); }); test('serves the production bundle without missing browser assets', async ({ page }) => { const browserFailures: Array = []; page.on('requestfailed', (request) => { const failure = request.failure(); browserFailures.push(`${request.method()} ${request.url()} ${failure?.errorText}`); }); page.on('response', (response) => { if (response.status() >= 400) { browserFailures.push(`${response.status()} ${response.url()}`); } }); await disableWebGpu(page); await page.goto('/'); await expect(page.locator('body')).not.toHaveClass(/is-loading/); expect(browserFailures).toEqual([]); }); [ { height: 720, name: 'desktop', width: 1280 }, { height: 844, name: 'mobile', width: 390 }, ].forEach(({ height, name, width }) => { test(`keeps the fallback shell usable on ${name}`, async ({ page }) => { await page.setViewportSize({ height, width }); await disableWebGpu(page); await page.goto('/'); await expect(page.locator('body')).not.toHaveClass(/is-loading/); const canvasBox = await page .getByRole('img', { name: 'Interactive generative garden canvas' }) .boundingBox(); expect(canvasBox?.width).toBeGreaterThan(0); expect(canvasBox?.height).toBeGreaterThan(0); await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); await expect(page.getByRole('button', { name: 'About' })).toBeVisible(); await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU'); const aboutButtonReceivesPointer = await page .getByRole('button', { name: 'About' }) .evaluate((button) => { const rect = button.getBoundingClientRect(); const target = document.elementFromPoint( rect.left + rect.width / 2, rect.top + rect.height / 2 ); return button === target || button.contains(target); }); expect(aboutButtonReceivesPointer).toBe(true); }); }); test('hides the bottom dock after the cursor leaves fullscreen controls', async ({ page, }) => { await disableWebGpu(page); await page.goto('/'); await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); await page.getByRole('button', { name: 'Enter fullscreen' }).click(); await expect .poll(() => page.evaluate(() => Boolean(document.fullscreenElement))) .toBe(true); await page.mouse.move(640, 120); await page.evaluate(() => { if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } }); await expect(page.locator('aside.control-dock')).toHaveClass(/menu-hidden/, { timeout: 6000, }); await expect(page.locator('.garden-controls')).not.toBeVisible(); await expect .poll(() => page .locator('aside.control-dock') .evaluate((dock) => dock.getBoundingClientRect().top >= window.innerHeight) ) .toBe(true); await page.mouse.move(640, 700); await expect(page.locator('aside.control-dock')).not.toHaveClass(/menu-hidden/); await expect(page.locator('.garden-controls')).toBeVisible(); await expect .poll(() => page .locator('aside.control-dock') .evaluate((dock) => dock.getBoundingClientRect().bottom <= window.innerHeight) ) .toBe(true); }); test('keeps the bottom dock visible in mobile fullscreen', async ({ page }) => { await page.setViewportSize({ height: 844, width: 390 }); await disableWebGpu(page); await page.goto('/'); await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); await page.getByRole('button', { name: 'Enter fullscreen' }).click(); await expect .poll(() => page.evaluate(() => Boolean(document.fullscreenElement))) .toBe(true); await page.mouse.move(195, 120); await page.evaluate(() => { if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } }); await page.waitForTimeout(5200); await expect(page.locator('aside.control-dock')).not.toHaveClass(/menu-hidden/); await expect(page.getByRole('button', { name: 'Show config overlay' })).toBeVisible(); });