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>
134 lines
6.7 KiB
TypeScript
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');
|
|
});
|
|
}
|