Files
aego/tools/websmoke/demo.mjs
T
sanspie 5eb1832907
ci-platforms / darwin amd64 smoke (push) Skipped
ci / build (amd64, linux, ubuntu-24.04) (push) Failing after 15m28s
ci / release (push) Skipped
ci-platforms / windows amd64 smoke (push) Skipped
ci-platforms / darwin arm64 smoke (push) Skipped
ci / checks (push) Successful in 7m47s
Complete Stage 12 for v0.12.0
2026-11-05 11:46:53 +03:00

236 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from "node:fs";
import http from "node:http";
import path from "node:path";
import { chromium } from "playwright-core";
import pixelmatch from "pixelmatch";
import pngjs from "pngjs";
import { frameLimitOutcome } from "./ci-warning-policy.mjs";
const { PNG } = pngjs;
const opts = Object.fromEntries(
process.argv.slice(2).map((a) => {
const [k, v] = a.replace(/^--/, "").split("=");
return [k, v ?? "true"];
}),
);
const site = path.resolve(opts.site ?? "../../site");
const goldenDir = path.resolve(opts.golden ?? "golden");
const update = opts.update === "true";
const executablePath = process.env.CHROMIUM || "/usr/bin/chromium";
const mime = {
".html": "text/html",
".js": "text/javascript",
".wasm": "application/wasm",
".json": "application/json",
".png": "image/png",
".aetext": "text/plain",
};
const failures = [];
const warnings = [];
function fail(msg) {
failures.push(msg);
console.error("FAIL " + msg);
}
function warn(msg) {
warnings.push(msg);
console.warn("WARN " + msg);
if (process.env.GITHUB_ACTIONS) console.warn("::warning title=SwiftShader frame limit::" + msg);
}
function serve(root) {
return new Promise((resolve) => {
const srv = http.createServer((req, res) => {
let p = decodeURIComponent(new URL(req.url, "http://local").pathname);
if (p.endsWith("/")) p += "index.html";
const file = path.join(root, p);
if (!file.startsWith(root) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) {
res.writeHead(404);
res.end();
return;
}
res.writeHead(200, { "Content-Type": mime[path.extname(file)] || "application/octet-stream" });
fs.createReadStream(file).pipe(res);
});
srv.listen(0, "127.0.0.1", () => resolve(srv));
});
}
async function frames(page, n, timeout) {
await page.waitForFunction(
(want) => {
const err = document.getElementById("error");
if (err && !err.hidden) throw new Error(err.textContent);
return globalThis.aegoGame && globalThis.aegoGame.frames() >= want;
},
n,
{ timeout, polling: 100 },
);
}
async function compare(page, name) {
const shot = PNG.sync.read(await page.locator("#game").screenshot());
const file = path.join(goldenDir, name + ".png");
if (update) {
fs.mkdirSync(goldenDir, { recursive: true });
fs.writeFileSync(file, PNG.sync.write(shot));
console.log("wrote " + file);
return;
}
if (!fs.existsSync(file)) {
fail(`${name}: no golden, run make web-smoke-update`);
return;
}
const want = PNG.sync.read(fs.readFileSync(file));
if (want.width !== shot.width || want.height !== shot.height) {
fail(`${name}: ${shot.width}x${shot.height}, golden ${want.width}x${want.height}`);
return;
}
const n = pixelmatch(want.data, shot.data, null, want.width, want.height, { threshold: 0.1 });
const pct = (n * 100) / (want.width * want.height);
if (pct > 2) {
fs.writeFileSync(path.join(goldenDir, `actual-${name}.png`), PNG.sync.write(shot));
fail(`${name}: ${pct.toFixed(2)}% of pixels differ`);
return;
}
console.log(`${name}: ${pct.toFixed(3)}% of pixels differ`);
}
// touchPad drives the demo on a phone through the touch gamepad and checks the game reads its stick.
async function touchPad() {
const ctx = await browser.newContext({ viewport: { width: 844, height: 390 }, deviceScaleFactor: 1, hasTouch: true, isMobile: true });
const phone = await ctx.newPage();
phone.on("pageerror", (e) => fail("phone page error: " + e.message));
await phone.goto(base + "/demo/");
await frames(phone, 120, 120000);
if (!(await phone.locator("#vpad .stick").isVisible())) {
fail("the touch gamepad is hidden on a phone");
return;
}
const box = await phone.locator("#vpad .stick").boundingBox();
const cdp = await ctx.newCDPSession(phone);
const at = (x) => [{ x: box.x + box.width / 2 + x, y: box.y + box.height / 2, id: 1 }];
const leftX = async () => {
const pads = await phone.evaluate(() => globalThis.aegoGame.pads());
const touch = pads.find((p) => p.name === "Touch Gamepad");
return touch ? touch.leftX : null;
};
await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: at(0) });
await cdp.send("Input.dispatchTouchEvent", { type: "touchMove", touchPoints: at(box.width / 2) });
await phone.waitForTimeout(200);
const held = await leftX();
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
// The virtual pad resets on pointerup, but the game reads it on its next frame.
const releaseFrame = await phone.evaluate(() => globalThis.aegoGame.frames());
await frames(phone, releaseFrame + 1, 120000);
const released = await leftX();
if (held === null || held < 0.9 || released !== 0) fail(`the game reads the touch stick as ${held}, then ${released} after release`);
else console.log("demo: the game reads the touch gamepad");
await menuAndFullscreen(phone, cdp);
await ctx.close();
}
// menuAndFullscreen checks that the gamepad gives way to the settings screen and stays on screen in fullscreen.
async function menuAndFullscreen(phone, cdp) {
const tap = async (sel) => {
const b = await phone.locator(sel).boundingBox();
const pt = [{ x: b.x + b.width / 2, y: b.y + b.height / 2, id: 2 }];
await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: pt });
await phone.waitForTimeout(150);
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
};
const padShown = () => phone.locator("#vpad .stick").isVisible();
await tap("#vpad .start");
await phone.waitForTimeout(600);
const open = await phone.evaluate(() => globalThis.aegoGame.menuOpen());
if (!open) fail("Start on the touch gamepad does not open the settings screen");
else if (await padShown()) fail("the touch gamepad covers the open settings screen");
else console.log("demo: the touch gamepad hides under the settings screen");
if (opts.shots) await phone.screenshot({ path: path.join(opts.shots, "phone-settings.png") });
await phone.keyboard.press("Escape");
await phone.waitForTimeout(600);
if (await phone.evaluate(() => globalThis.aegoGame.menuOpen())) fail("Escape does not close the settings screen");
else if (!(await padShown())) fail("the touch gamepad does not come back after the settings screen closes");
await phone.locator("#fullscreen").click();
await phone.waitForTimeout(500);
const full = await phone.evaluate(() => document.fullscreenElement === document.documentElement);
if (!full) fail("the fullscreen button does not make the page fullscreen");
else if (!(await padShown())) fail("the touch gamepad is hidden in fullscreen");
else console.log("demo: the touch gamepad stays in fullscreen");
if (opts.shots) await phone.screenshot({ path: path.join(opts.shots, "phone-fullscreen.png") });
}
const srv = await serve(site);
async function frameLimit() {
await page.evaluate(() => globalThis.aegoGame.frameLimit(30));
await page.waitForTimeout(500);
const f0 = await page.evaluate(() => globalThis.aegoGame.frames());
await page.waitForTimeout(3000);
const f1 = await page.evaluate(() => globalThis.aegoGame.frames());
await page.evaluate(() => globalThis.aegoGame.frameLimit(0));
const fps = (f1 - f0) / 3;
let renderer = "";
if (fps < 26) {
try { renderer = await page.evaluate(() => JSON.parse(globalThis.aegoGame.stats()).renderer || ""); }
catch { /* An unverified renderer must not qualify for a warning. */ }
}
const outcome = frameLimitOutcome(fps, renderer, process.env.AEGO_CI_WEB_WARN === "1");
if (outcome === "warning") warn(`a 30 fps limit gives ${fps.toFixed(1)} fps on SwiftShader (required 26–32; Stage 13.5)`);
else if (outcome === "fail") fail(`a 30 fps limit gives ${fps.toFixed(1)} fps`);
else console.log(`demo: frame limit 30 gives ${fps.toFixed(1)} fps`);
}
async function pixelRatio() {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 2 });
const hi = await ctx.newPage();
hi.on("pageerror", (e) => fail("high-density page error: " + e.message));
await hi.goto(base + "/demo/");
await frames(hi, 30, 120000);
const r = await hi.evaluate(() => {
const c = document.getElementById("game");
return c.width / c.getBoundingClientRect().width;
});
if (Math.abs(r - 1.5) > 0.01) fail(`at device pixel ratio 2 the canvas renders at ${r.toFixed(3)}, want the 1.5 limit`);
else console.log("demo: the pixel ratio is capped at 1.5");
await ctx.close();
}
const base = `http://127.0.0.1:${srv.address().port}`;
const browser = await chromium.launch({
executablePath,
headless: true,
args: ["--mute-audio", "--use-angle=swiftshader", "--enable-unsafe-swiftshader", "--ignore-gpu-blocklist"],
});
const page = await browser.newPage({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1 });
page.on("pageerror", (e) => fail("page error: " + e.message));
try {
await page.goto(base + "/demo/");
await frames(page, 120, 120000);
console.log(`demo: cold start ${await page.evaluate(() => window.aegoBoot)} ms`);
await compare(page, "demo");
const err = await page.evaluate(() => globalThis.aegoGame.userWrite("smoke/check.txt", "kept"));
if (err) throw new Error("userWrite: " + err);
await page.reload();
await frames(page, 10, 120000);
const kept = await page.evaluate(() => globalThis.aegoGame.userRead("smoke/check.txt"));
if (kept !== "kept") fail(`the profile did not survive a reload: ${kept}`);
else console.log("demo: profile survives a reload");
await touchPad();
await frameLimit();
await pixelRatio();
} catch (e) {
fail(e.message);
} finally {
await browser.close();
srv.close();
}
if (failures.length) {
console.error(`${failures.length} failure(s)`);
process.exit(1);
}
if (warnings.length) console.log(`web demo: completed with ${warnings.length} warning(s); FPS check did not pass`);
else console.log("web demo: ok");