import { test, expect, type Page } from '@playwright/test'; import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; import { NativeUIWorkflow, observeNative } from '../fixtures/native-ui-workflow'; const root = path.resolve('..'); const fixture = path.join(root, 'tests/files/web/basic_scene.blend'); async function pixels(page: Page, image: Buffer) { return page.evaluate(async encoded => { const bytes = Uint8Array.from(atob(encoded), character => character.charCodeAt(0)); const bitmap = await createImageBitmap(new Blob([bytes], { type: 'image/png' })); try { const surface = new OffscreenCanvas(bitmap.width, bitmap.height); const context = surface.getContext('2d')!; context.drawImage(bitmap, 0, 0); const data = context.getImageData(0, 0, bitmap.width, bitmap.height).data; const colors = new Set(); let minimum = 255, maximum = 0, opaque = 0; for (let index = 0; index < data.length; index += 16) { const red = data[index], green = data[index + 1], blue = data[index + 2]; colors.add((red << 16) | (green << 8) | blue); minimum = Math.min(minimum, red, green, blue); maximum = Math.max(maximum, red, green, blue); if (data[index + 3] > 0) opaque += 1; } return { width: bitmap.width, height: bitmap.height, colors: colors.size, minimum, maximum, opaque }; } finally { bitmap.close(); } }, image.toString('base64')); } for (const viewport of [ { name: 'desktop', width: 1280, height: 800, touch: false }, { name: 'mobile-landscape', width: 844, height: 390, touch: true }, ]) { test(`viewport draws on change and rests when idle: ${viewport.name}`, async ({ page }, testInfo) => { test.setTimeout(40_000); page.setDefaultTimeout(10_000); await page.setViewportSize(viewport); const directory = process.env.WBV2_RECEIPT_DIR ? path.join(process.env.WBV2_RECEIPT_DIR, 'viewport-render-artifacts') : testInfo.outputPath('viewport-render-artifacts'); fs.mkdirSync(directory, { recursive: true }); await observeNative(page); await page.addInitScript(() => { const scope = window as unknown as { __viewportDrawCalls: number }; scope.__viewportDrawCalls = 0; for (const constructor of [window.WebGLRenderingContext, window.WebGL2RenderingContext]) { if (!constructor) continue; const prototype = constructor.prototype as unknown as Record unknown>; for (const name of ['drawArrays', 'drawElements', 'drawArraysInstanced', 'drawElementsInstanced']) { const original = prototype[name]; if (typeof original !== 'function') continue; prototype[name] = function (this: unknown, ...args: unknown[]) { scope.__viewportDrawCalls += 1; return original.apply(this, args); }; } } }); await page.goto('/'); const flow = new NativeUIWorkflow(page, directory); await flow.command(() => page.getByTestId('blend-file-input').setInputFiles(fixture)); const canvas = page.locator('canvas').first(); await expect(canvas).toBeVisible(); const drawCalls = () => page.evaluate(() => (window as unknown as { __viewportDrawCalls: number }).__viewportDrawCalls); await expect.poll(drawCalls).toBeGreaterThan(0); await expect.poll(async () => { const before = await drawCalls(); await page.waitForTimeout(180); return (await drawCalls()) - before; }, { timeout: 6000 }).toBe(0); const idleBefore = await drawCalls(); await page.waitForTimeout(400); const idleDraws = (await drawCalls()) - idleBefore; expect(idleDraws).toBe(0); const beforeImage = await canvas.screenshot(); const beforePixels = await pixels(page, beforeImage); expect(beforePixels.width).toBeGreaterThan(200); expect(beforePixels.height).toBeGreaterThan(100); expect(beforePixels.colors).toBeGreaterThan(20); expect(beforePixels.maximum - beforePixels.minimum).toBeGreaterThan(30); const cameraBefore = await canvas.getAttribute('data-camera-position'); expect(cameraBefore).toBeTruthy(); const bounds = (await canvas.boundingBox())!; const x = bounds.x + bounds.width * 0.65; const y = bounds.y + bounds.height * 0.6; if (viewport.touch) { const session = await page.context().newCDPSession(page); await session.send('Emulation.setTouchEmulationEnabled', { enabled: true }); await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x, y, id: 1 }] }); for (let step = 1; step <= 5; step += 1) { await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: x - step * 12, y: y + step * 3, id: 1 }] }); } await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }); } else { await page.mouse.move(x, y); await page.mouse.wheel(0, -250); } await expect.poll(() => canvas.getAttribute('data-camera-position')).not.toBe(cameraBefore); await expect.poll(drawCalls).toBeGreaterThan(idleBefore); const afterImage = await canvas.screenshot(); expect(crypto.createHash('sha256').update(afterImage).digest('hex')).not.toBe( crypto.createHash('sha256').update(beforeImage).digest('hex')); const afterPixels = await pixels(page, afterImage); expect(afterPixels.colors).toBeGreaterThan(20); fs.writeFileSync(path.join(directory, `${viewport.name}-before.png`), beforeImage); fs.writeFileSync(path.join(directory, `${viewport.name}-after.png`), afterImage); await page.screenshot({ path: path.join(directory, `${viewport.name}-page.png`), fullPage: true }); if (!viewport.touch) { await page.evaluate(() => { const canvas = document.querySelector('canvas')!; const gl = canvas.getContext('webgl2')!; const extension = gl.getExtension('WEBGL_lose_context')!; (window as unknown as { __restoreViewport: () => void }).__restoreViewport = () => extension.restoreContext(); extension.loseContext(); }); await page.waitForTimeout(100); await page.evaluate(() => (window as unknown as { __restoreViewport: () => void }).__restoreViewport()); await expect(canvas).toHaveAttribute('data-device-status', 'ready'); const restored = await pixels(page, await canvas.screenshot()); expect(restored.colors).toBeGreaterThan(20); } fs.writeFileSync(path.join(directory, `${viewport.name}.json`), JSON.stringify({ status: 'PASS', viewport, idleDraws, beforePixels, afterPixels, cameraBefore, cameraAfter: await canvas.getAttribute('data-camera-position'), drawCalls: await drawCalls(), }, null, 2) + '\n'); }); }