Files
aego/tools/websmoke/try-contract.mjs

305 lines
17 KiB
JavaScript

// A line-oriented adapter for democheck. It drives the published docs page and
// the actual WASM Try runtime, rather than implementing a second scene runner.
// Stdout is reserved for protocol replies; diagnostics go to stderr.
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import readline from "node:readline";
import zlib from "node:zlib";
import {createRequire} from "node:module";
import { chromium, firefox } from "playwright-core";
import pngjs from "pngjs";
const require = createRequire(import.meta.url);
const options = Object.fromEntries(process.argv.slice(2).map(arg => {
const [key, ...value] = arg.replace(/^--/, "").split("=");
return [key, value.join("=")];
}));
if (!options.site) throw new Error("--site is required");
const root = path.resolve(options.site);
const mime = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".wasm": "application/wasm", ".json": "application/json" };
const server = http.createServer((req, res) => {
try {
const url = new URL(req.url, "http://127.0.0.1");
let name = decodeURIComponent(url.pathname);
if (name.endsWith("/")) name += "index.html";
const file = path.resolve(root, "." + name);
if (!file.startsWith(root + path.sep) || !fs.statSync(file).isFile()) throw new Error("not found");
res.writeHead(200, { "Content-Type": mime[path.extname(file)] || "application/octet-stream" });
fs.createReadStream(file).pipe(res);
} catch {
res.writeHead(404);
res.end();
}
});
await new Promise((resolve, reject) => server.listen(0, "127.0.0.1", resolve).on("error", reject));
const base = `http://127.0.0.1:${server.address().port}`;
const browsers = new Map();
const renderers = new Map();
async function browserFor(environment) {
if (!["chromium", "firefox"].includes(environment)) throw new Error("unsupported browser environment");
if (browsers.has(environment)) return browsers.get(environment);
const type = environment === "chromium" ? chromium : firefox;
const settings = {headless: environment !== "firefox" || process.env.AEGO_TRY_FIREFOX_HEADED !== "1"};
const executable = environment === "chromium" ? (process.env.CHROMIUM || "/usr/bin/chromium") : process.env.FIREFOX;
if (executable) settings.executablePath = executable;
if (environment === "chromium") {
const angle = process.env.AEGO_TRY_CHROMIUM_ANGLE || "swiftshader";
if (!["swiftshader", "gl"].includes(angle)) throw new Error("unsupported Chromium ANGLE backend");
settings.args = ["--mute-audio", "--use-gl=angle", `--use-angle=${angle}`, "--ignore-gpu-blocklist"];
if (angle === "swiftshader") settings.args.push("--enable-unsafe-swiftshader");
} else {
settings.firefoxUserPrefs = {"webgl.force-enabled": true, "webgl.disabled": false, "media.volume_scale": "0.0"};
if (process.env.MOZ_DISABLE_CONTENT_SANDBOX === "1") settings.firefoxUserPrefs["security.sandbox.content.level"] = 0;
}
const browser = await type.launch(settings);
browsers.set(environment, browser);
return browser;
}
let pages = [];
let contract;
let fields;
let environment;
let captureID = 0;
let observationID = 0;
function evidenceDirectory() {
const root = process.env.AEGO_DEMOCHECK_EVIDENCE;
if (!root) return null;
const runRoot = contract.demoID.endsWith(".negative-observable") ? root + "-negative" : root;
return path.join(runRoot, environment, contract.demoID);
}
async function closePages() {
for (const { page } of pages) await page.close();
pages = [];
}
async function invoke(page, method, params = {}) {
const reply = await page.evaluate(([m, p]) => JSON.parse(globalThis.aegoDocs.call(m, JSON.stringify(p))), [method, params]);
if (reply.error) throw new Error(`${method}: ${reply.error}`);
return reply;
}
async function running(page) {
// Browsers may suspend requestAnimationFrame in the background page. Each
// language must really render its own runtime before its state is checked.
await page.bringToFront();
await page.waitForFunction(() => {
const status = document.querySelector(".try .try-status");
if (status?.classList.contains("error")) throw new Error(status.textContent);
if (!globalThis.aegoDocs?.call) return false;
const r = JSON.parse(globalThis.aegoDocs.call("state", "{}"));
return r.state?.running && !r.state.paused && r.state.frames > 0 && r.control?.enabled && r.control.remaining === 0;
}, null, { timeout: 120000, polling: 50 });
}
async function advance(page, frames, keys = []) {
await invoke(page, "advance", {frames, keys});
await running(page);
}
function normalizedPayload(p) {
return {
scene: p.scene || "", select: p.select || "", code: p.code || "",
prefabs: Object.fromEntries(Object.entries(p.prefabs || {}).sort(([a], [b]) => a.localeCompare(b))),
artifacts: p.artifacts || "",
};
}
async function checkCameraSearch(page, lang) {
if (contract.componentID !== "camera.CameraBrain2D") return;
for (const query of ["camera.CameraBrain2D", "Camera Output Selector 2D", "Выбор выхода камеры 2D", "Camera Brain 2D", "Мозг камеры 2D"]) {
await page.locator("#docs-search").fill(query);
await page.waitForFunction(() => [...document.querySelectorAll("#results a")].some(a => new URL(a.href).pathname.replace(/\.html$/, "").endsWith("/ref/camera.CameraBrain2D")) && !document.getElementById("results").hidden, null, {timeout: 10000});
// Results are grouped by category, so a related guide may precede the
// reference. Check the heading attached to this exact component result.
const section = await page.locator("#results").evaluate(root => {
const target = [...root.querySelectorAll("a")].find(a => new URL(a.href).pathname.replace(/\.html$/, "").endsWith("/ref/camera.CameraBrain2D"));
let row = target?.closest("li");
while (row && !row.classList.contains("cat")) row = row.previousElementSibling;
return row?.textContent || "";
});
if (section !== (lang === "ru" ? "Справочник" : "Reference")) throw new Error(`${lang}: component search category is missing or not localized: ${section}`);
}
await page.locator("#docs-search").fill("");
}
async function open(req) {
await closePages();
contract = req.contract;
environment = req.environment || "chromium";
captureID = 0;
observationID = 0;
if (!contract || !req.payload || !/^[A-Za-z0-9_.-]+$/.test(contract.componentID) || !/^[A-Za-z0-9_.-]+$/.test(contract.demoID) || [".", ".."].includes(contract.demoID)) throw new Error("invalid contract or component ID");
fields = [...new Set(contract.actions.flatMap(a => a.expected.map(e => e.observable)))];
const browser = await browserFor(environment);
for (const lang of ["en", "ru"]) {
const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, deviceScaleFactor: 1, colorScheme: "dark" });
const state = { page, lang, errors: [] };
pages.push(state);
page.on("pageerror", e => state.errors.push(e.message));
const prefix = lang === "ru" ? "/ru" : "";
const response = await page.goto(`${base}${prefix}/ref/${encodeURIComponent(contract.componentID)}.html`);
if (!response?.ok()) throw new Error(`${lang}: component page is not available`);
const published = await page.evaluate(() => {
const b = document.querySelector(".try[data-scene]");
if (!b) throw new Error("Try block missing");
return {
lang: document.documentElement.lang,
payload: { scene: b.dataset.scene, select: b.dataset.select, code: b.dataset.code, prefabs: b.dataset.prefabs ? JSON.parse(b.dataset.prefabs) : {}, artifacts: b.dataset.artifacts },
title: b.querySelector(".try-demo-title")?.textContent,
description: b.querySelector(".try-demo-description")?.textContent,
input: b.querySelector(".try-demo-input")?.textContent,
};
});
if (published.lang !== lang) throw new Error(`${lang}: wrong page language ${published.lang}`);
if (JSON.stringify(normalizedPayload(published.payload)) !== JSON.stringify(normalizedPayload(req.payload)))
throw new Error(`${lang}: published Try payload does not match the registry`);
for (const key of ["title", "description", "input"]) {
if (published[key] !== contract.languages[lang]?.[key]) throw new Error(`${lang}: missing or stale ${key}`);
}
await checkCameraSearch(page, lang);
// Lazy auto-start may remove the button before the click completes.
const start = await page.$(".try .try-start");
if (start) {
try {
await start.click({timeout: 3000});
} catch (error) {
const booting = await page.evaluate(() => !!document.querySelector(".try .try-progress, .try canvas") || !!globalThis.aegoDocs?.call);
if (!booting) throw error;
} finally {
await start.dispose();
}
}
await page.waitForFunction(() => {
const status = document.querySelector(".try .try-status");
if (status?.classList.contains("error")) throw new Error(status.textContent);
if (!globalThis.aegoDocs?.call) return false;
return JSON.parse(globalThis.aegoDocs.call("state", "{}")).state?.running;
}, null, {timeout: 120000});
await page.locator(".try canvas").click({position: {x: 80, y: 80}});
await invoke(page, "control", {enabled: true});
await invoke(page, "reset");
// Bootstrap only; the gate's Reset performs the contract warmup.
await advance(page, 4);
if (!renderers.has(environment)) {
renderers.set(environment, await page.evaluate(() => {
const gl = document.querySelector(".try canvas").getContext("webgl2");
if (!gl) throw new Error("actual Try WebGL2 context is unavailable");
const debug = gl.getExtension("WEBGL_debug_renderer_info");
return {version: gl.getParameter(gl.VERSION), renderer: gl.getParameter(debug ? debug.UNMASKED_RENDERER_WEBGL : gl.RENDERER)};
}));
}
}
}
async function frame(page, lang) {
await page.bringToFront();
// Do not accept stale pixels when a page is not rendering. A timeout is a
// failed probe, never a substitute screenshot or a successful environment.
let timer;
try {
await Promise.race([
page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))),
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Game View did not render two animation frames within 30s")), 30000); }),
]);
} finally {
clearTimeout(timer);
}
const viewport = (await invoke(page, "viewport")).viewport;
if (!viewport || ![viewport.x, viewport.y, viewport.width, viewport.height, viewport.canvasWidth, viewport.canvasHeight].every(Number.isFinite) || viewport.width <= 0 || viewport.height <= 0 || viewport.canvasWidth <= 0 || viewport.canvasHeight <= 0) throw new Error("runtime did not report a valid Game View rectangle");
const screenshot = await page.locator(".try canvas").screenshot();
const dir = evidenceDirectory();
if (dir) {
fs.mkdirSync(dir, {recursive: true});
fs.writeFileSync(path.join(dir, `${lang}-${String(captureID++).padStart(3, "0")}.png`), screenshot);
}
const shot = pngjs.PNG.sync.read(screenshot);
// The Go layout owns this rectangle; a guessed crop could include the
// changing Inspector and produce a false visual PASS.
const sx = shot.width / viewport.canvasWidth, sy = shot.height / viewport.canvasHeight;
const left = Math.ceil(viewport.x * sx), top = Math.ceil(viewport.y * sy);
const right = Math.floor((viewport.x + viewport.width) * sx), bottom = Math.floor((viewport.y + viewport.height) * sy);
const width = right - left, height = bottom - top;
if (width <= 0 || height <= 0 || left < 0 || top < 0 || right > shot.width || bottom > shot.height) throw new Error("Game View capture is outside the actual canvas");
const data = Buffer.alloc(width * height * 4);
for (let y = 0; y < height; y++)
shot.data.copy(data, y * width * 4, ((y + top) * shot.width + left) * 4, ((y + top) * shot.width + right) * 4);
return { width, height, pixels: data.toString("base64") };
}
async function observe() {
if (pages.length !== 2) throw new Error("no open Try instance");
let observed;
const frames = [];
for (const { page, lang, errors } of pages) {
await page.bringToFront();
if (errors.length) throw new Error(`${lang}: browser error: ${errors.join("; ")}`);
const log = await invoke(page, "log");
const faults = (log.log || []).filter(line => line.error).map(line => line.text);
if (faults.length) throw new Error(`${lang}: Try runtime error: ${faults.join("; ")}`);
const reply = await invoke(page, "inspect", { component: contract.componentID, fields, dependencies: contract.dependencies });
if (!reply.inspection) throw new Error(`${lang}: runtime returned no inspection`);
if (observed && JSON.stringify(observed) !== JSON.stringify(reply.inspection))
throw new Error(`${lang}: runtime observation differs across localized pages`);
observed = reply.inspection;
if (contract.actions.some(a => a.visual)) frames.push(await frame(page, lang));
}
const result = {identity: contract.identity, ...observed, frames};
const dir = evidenceDirectory();
if (dir) {
fs.mkdirSync(dir, {recursive: true});
const runID = (process.env.AEGO_DEMOCHECK_RUN_ID || "") + (contract.demoID.endsWith(".negative-observable") ? "-negative" : "");
const record = {schema: 1, runID, environment, demoID: contract.demoID, observation: result};
fs.writeFileSync(path.join(dir, `observation-${String(observationID++).padStart(3, "0")}.json.gz`), zlib.gzipSync(Buffer.from(JSON.stringify(record)), {level: 1}));
}
return result;
}
async function action(req) {
const toggle = req.action.scriptToggle;
const field = req.action.field;
const prefix = contract.componentID + ".";
const value = req.action.value;
const stringList = Array.isArray(value) && value.length <= 64 && value.every(item => typeof item === "string");
if (toggle && (typeof toggle.script !== "string" || typeof toggle.enabled !== "boolean" || field || value !== undefined)) throw new Error("unsupported script attachment action");
if (field && (!field.startsWith(prefix) || (!stringList && !["number", "string"].includes(typeof value)))) throw new Error("unsupported action or value");
if (!toggle && !field && !req.action.keys?.length && !req.action.inputs?.length) throw new Error("action has no field, script toggle or gameplay input");
for (const { page } of pages) {
await page.bringToFront();
if (toggle) await invoke(page, "scriptEnabled", toggle);
if (field) await invoke(page, "set", {component: contract.componentID, field: field.slice(prefix.length), value});
if ((field || toggle) && (req.action.keys?.length || req.action.inputs?.length)) await advance(page, contract.warmupFrames || 4);
for (const step of req.action.inputs || []) await advance(page, step.frames, step.keys || []);
if (!req.action.inputs?.length || req.action.frames || req.action.keys?.length) {
await advance(page, req.action.frames || contract.warmupFrames || 4, req.action.keys || []);
}
}
}
async function reset(req) {
if (req.action !== "reset") throw new Error("unsupported reset");
for (const { page } of pages) {
await page.bringToFront();
await invoke(page, "reset");
await advance(page, contract.warmupFrames || 4);
}
}
try {
const lines = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
for await (const line of lines) {
let req;
try {
req = JSON.parse(line);
let result;
switch (req.method) {
case "open": result = await open(req); break;
case "observe": result = await observe(); break;
case "action": result = await action(req); break;
case "versions": result = {node: process.version, playwright: require("playwright-core/package.json").version, browsers: Object.fromEntries([...browsers].map(([name, browser]) => [name, browser.version()])), renderers: Object.fromEntries(renderers)}; break;
case "reset": result = await reset(req); break;
case "close": result = await closePages(); break;
default: throw new Error(`unknown method ${req.method}`);
}
process.stdout.write(JSON.stringify({ id: req.id, result: result ?? null }) + "\n");
} catch (error) {
process.stdout.write(JSON.stringify({ id: req?.id, error: error.stack || String(error) }) + "\n");
}
}
} finally {
await closePages();
for (const browser of browsers.values()) await browser.close();
await new Promise(resolve => server.close(resolve));
}