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 10:56:19 +01:00

236 lines
9.2 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}`);
}
const projectionProbe = await page.evaluate(() => {
const node = document.querySelector('[data-agent-node="coordinator"]');
if (!(node instanceof HTMLElement)) return { ok: false, reason: "coordinator DOM node missing" };
const style = node.style;
const x = style.getPropertyValue("--agent-x");
const y = style.getPropertyValue("--agent-y");
const glow = Number.parseFloat(style.getPropertyValue("--agent-glow-pulse"));
return {
ok: x.endsWith("%") && y.endsWith("%") && Number.isFinite(glow),
reason: `x=${x} y=${y} glow=${glow}`,
};
});
if (!projectionProbe.ok) {
throw new Error(`DOM agent projection was not synchronized: ${projectionProbe.reason}`);
}
const hoverBox = await page.locator('[data-agent-node="research"]').boundingBox();
if (!hoverBox) {
throw new Error("research DOM node has no hover bounds");
}
await page.mouse.move(hoverBox.x + hoverBox.width / 2, hoverBox.y + hoverBox.height / 2);
await page.waitForTimeout(180);
const hoverProbe = await page.evaluate(() => {
const node = document.querySelector('[data-agent-node="research"]');
if (!(node instanceof HTMLElement)) return { ok: false, reason: "research DOM node missing" };
const hover = Number.parseFloat(node.style.getPropertyValue("--agent-hover"));
const acknowledgement = Number.parseFloat(node.style.getPropertyValue("--agent-ack"));
return {
ok: hover > 0.7 && acknowledgement >= 0,
reason: `hover=${hover} ack=${acknowledgement}`,
};
});
if (!hoverProbe.ok) {
throw new Error(`DOM hover did not reach aquarium projection: ${hoverProbe.reason}`);
}
const researchBox = await page.locator('[data-agent-node="research"]').boundingBox();
if (!researchBox) {
throw new Error("research DOM node has no clickable bounds");
}
await page.mouse.click(researchBox.x + researchBox.width / 2, researchBox.y + researchBox.height / 2);
try {
await page.waitForFunction(() => {
const audio = window.__epiphanyAquariumAudio;
return audio?.state === "running" &&
audio.vocalAgentCount >= 7 &&
audio.lastBurstChirpDrivers >= 6 &&
audio.spectral?.chirpDrivers === 6 &&
audio.spectral?.lastBurstChoirVoices >= 3 &&
audio.spectral?.reactiveFlushes >= 1 &&
audio.spectral?.transientBins >= 24 &&
audio.spectral?.vocalAgents >= 7 &&
audio.spectral?.queuedFrames >= 2048 &&
audio.lastBurst;
}, null, { timeout: 5000 });
} catch (error) {
const audio = await page.evaluate(() => window.__epiphanyAquariumAudio ?? null);
throw new Error(`aquarium audio did not wake correctly: ${JSON.stringify(audio)}`, { cause: error });
}
const audioProbe = await page.evaluate(() => window.__epiphanyAquariumAudio ?? null);
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, audioProbe, 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.");
}