This repository has been archived on 2026-10-29. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
Epiphany/apps/epiphany-gui/scripts/visual-smoke.mjs
2026-05-05 03:49:37 +01:00

176 lines
6.4 KiB
JavaScript

import { chromium } from "playwright";
import { mkdir } from "node:fs/promises";
import { resolve } from "node:path";
import { createServer } from "vite";
const root = resolve(import.meta.dirname, "..", "..", "..");
const artifactDir = resolve(root, ".epiphany-gui");
const desktopScreenshotPath = resolve(artifactDir, "operator-console-smoke-desktop.png");
const wideScreenshotPath = resolve(artifactDir, "operator-console-smoke-wide.png");
const mobileScreenshotPath = resolve(artifactDir, "operator-console-smoke-mobile.png");
const url = "http://127.0.0.1:1420";
const fluidStorageKey = "epiphany:aquarium-fluid-params:v3";
await mkdir(artifactDir, { recursive: true });
const server = await createServer({
root: resolve(root, "apps", "epiphany-gui"),
logLevel: "silent",
server: {
host: "127.0.0.1",
port: 1420,
strictPort: true,
},
});
try {
await server.listen();
await waitForServer(url);
const browser = await launchSmokeBrowser();
const wide = await smokeViewport(browser, { width: 2048, height: 1024 }, wideScreenshotPath, false);
const desktop = await smokeViewport(browser, { width: 1366, height: 900 }, desktopScreenshotPath, true);
const mobile = await smokeViewport(browser, { width: 390, height: 844 }, mobileScreenshotPath, false);
await browser.close();
console.log(
JSON.stringify(
{
ok: true,
screenshots: [wideScreenshotPath, desktopScreenshotPath, mobileScreenshotPath],
probes: { wide, desktop, mobile },
},
null,
2,
),
);
} finally {
await server.close();
}
async function launchSmokeBrowser() {
const requestedChannel = process.env.EPIPHANY_SMOKE_BROWSER;
const channels = requestedChannel ? [requestedChannel] : ["chrome", "msedge"];
let lastError = null;
for (const channel of channels) {
try {
return await chromium.launch({ channel, headless: true });
} catch (error) {
lastError = error;
}
}
throw lastError;
}
async function smokeViewport(browser, viewport, screenshotPath, exerciseFluidPanel) {
const page = await browser.newPage({ viewport });
await page.goto(url, { waitUntil: "networkidle" });
await page.locator("h1").filter({ hasText: "Operator Console" }).waitFor({ state: "attached" });
await page.locator(".immersiveShell").waitFor();
await page.locator(".agentSmokeCanvas").waitFor();
await page.locator(".agentCrispCanvas").waitFor();
await page.waitForTimeout(700);
const smokeProbe = await probeCanvas(page, ".agentSmokeCanvas");
const crispProbe = await probeCanvas(page, ".agentCrispCanvas");
if (!smokeProbe.nonBlank) {
throw new Error(`agent smoke canvas did not render: ${smokeProbe.reason}`);
}
const operatorSurface = await page.evaluate(() => {
return [".immersiveTopbar", ".deckRail", ".diegeticPanel"].every((selector) => {
const element = document.querySelector(selector);
if (!element) return false;
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return (
style.opacity !== "0" &&
style.visibility !== "hidden" &&
rect.width > 20 &&
rect.height > 20
);
});
});
if (!operatorSurface) {
throw new Error("static operator overlays are not visible");
}
if (!crispProbe.nonBlank && !operatorSurface) {
throw new Error(`no crisp operator surface rendered: ${crispProbe.reason}`);
}
let persistedParams = null;
if (exerciseFluidPanel) {
await page.evaluate((key) => window.localStorage.removeItem(key), fluidStorageKey);
await page.reload({ waitUntil: "networkidle" });
await page.locator(".agentSmokeCanvas").waitFor();
await page.waitForTimeout(700);
const inspectorGuard = viewport.width >= 720 ? Math.min(230, viewport.height * 0.25) : 0;
await page.mouse.click(viewport.width - 48, viewport.height - 48 - inspectorGuard);
await page.waitForTimeout(120);
const railY = Math.round(viewport.height * 0.47);
await page.mouse.move(viewport.width - 226, railY);
await page.mouse.down();
await page.mouse.move(viewport.width - 46, railY, { steps: 8 });
await page.mouse.up();
persistedParams = await page.evaluate((key) => window.localStorage.getItem(key), fluidStorageKey);
if (!persistedParams || !persistedParams.includes("timeScale")) {
throw new Error("fluid parameter panel did not persist changed parameters");
}
}
await page.screenshot({ path: screenshotPath, fullPage: true });
const result = await page.evaluate(() => ({
horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
}));
await page.close();
if (result.horizontalOverflow) {
throw new Error(`visual smoke found horizontal overflow at ${viewport.width}x${viewport.height}`);
}
return { smokeProbe, crispProbe, persistedParams };
}
async function probeCanvas(page, selector) {
return page.locator(selector).evaluate((canvas) => {
if (!(canvas instanceof HTMLCanvasElement) || canvas.width === 0 || canvas.height === 0) {
return { nonBlank: false, reason: "canvas has no drawable dimensions" };
}
const gl = canvas.getContext("webgl2");
if (gl) {
const width = Math.min(12, canvas.width);
const height = Math.min(12, canvas.height);
const pixels = new Uint8Array(width * height * 4);
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
return {
nonBlank: pixels.some((value) => value !== 0),
reason: "webgl2 sample",
};
}
const context = canvas.getContext("2d");
if (!context) return { nonBlank: false, reason: "no readable canvas context" };
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
const stride = Math.max(4, Math.floor(pixels.length / 4096 / 4) * 4);
let nonBlank = false;
for (let index = 0; index < pixels.length - 3; index += stride) {
if (pixels[index] !== 0 || pixels[index + 1] !== 0 || pixels[index + 2] !== 0 || pixels[index + 3] !== 0) {
nonBlank = true;
break;
}
}
return {
nonBlank,
reason: "2d whole-canvas sample",
};
});
}
async function waitForServer(target) {
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
try {
const response = await fetch(target);
if (response.ok) return;
} catch {
// Server is still waking up.
}
await new Promise((resolveWait) => setTimeout(resolveWait, 500));
}
throw new Error("Vite server did not start.");
}