Files
workinf_Blender_Wasm/web/tests/e2e/viewport-render-budget.spec.ts
mes123456 f3e21c6b98
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
chore: checkpoint web Blender migration source, tools and docs
Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer,
web workflow engine and protocol, tooling, docs and task records.
Large generated evidence, user-testing archives, build snapshots and
imported test assets remain untracked.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-10 11:18:01 +08:00

134 lines
6.7 KiB
TypeScript

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<number>();
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<string, (...args: unknown[]) => 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');
});
}