// Runs the built app (dist/) in Chromium at 1920×1080 from file://, as the // TV loads it, presses remote buttons from a scene file and takes // screenshots. For development and the README pictures. // // node dev/drive.mjs SCENE OUTDIR // // CHROMIUM (default: chromium on PATH). Scene lines: // key Down [Down ...] remote buttons: Left Right Up Down Enter Back Red // Green Yellow Blue PageUp PageDown 1-7 // type TEXT type into the focused field // sleep SECONDS // shot NAME OUTDIR/NAME.png // log print the app's console and the focused element import puppeteer from "puppeteer-core"; import { mkdirSync, readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); const [scenePath, outDir] = process.argv.slice(2); if (!scenePath || !outDir) { console.error("usage: node dev/drive.mjs SCENE OUTDIR"); process.exit(2); } mkdirSync(outDir, { recursive: true }); const codes = { Left: 37, Up: 38, Right: 39, Down: 40, Enter: 13, Back: 461, Red: 403, Green: 404, Yellow: 405, Blue: 406, PageUp: 33, PageDown: 34, }; for (let i = 1; i <= 9; i++) codes[String(i)] = 48 + i; const browser = await puppeteer.launch({ executablePath: process.env.CHROMIUM || "chromium", headless: true, // file:// pages may call the local fake services. args: ["--window-size=1920,1080", "--allow-file-access-from-files", "--no-sandbox"], defaultViewport: { width: 1920, height: 1080 }, }); const page = await browser.newPage(); const logs = []; // Request URLs in browser messages can carry an API key (SABnzbd, posters). const redact = (t) => t.replace(/(apikey=)[^&\s'"]+/gi, "$1…"); page.on("console", (m) => logs.push(redact(`${m.type()}: ${m.text()}`))); page.on("pageerror", (e) => logs.push(redact(`pageerror: ${e.message}`))); await page.goto("file://" + resolve(here, "..", "dist", "index.html")); // press sends a remote button as the TV does: a keydown with its keyCode. async function press(name) { const code = codes[name]; if (!code) throw new Error("unknown key " + name); await page.evaluate((code) => { const target = document.activeElement || document.body; const ev = new KeyboardEvent("keydown", { bubbles: true, cancelable: true }); Object.defineProperty(ev, "keyCode", { get: () => code }); Object.defineProperty(ev, "which", { get: () => code }); target.dispatchEvent(ev); }, code); await new Promise((r) => setTimeout(r, 120)); } for (const raw of readFileSync(scenePath, "utf8").split("\n")) { const line = raw.trim(); if (!line || line.startsWith("#")) continue; const [cmd, ...rest] = line.split(/\s+/); switch (cmd) { case "key": for (const k of rest) await press(k); break; case "type": await page.keyboard.type(line.slice(5)); break; case "sleep": await new Promise((r) => setTimeout(r, parseFloat(rest[0]) * 1000)); break; case "shot": await page.screenshot({ path: join(outDir, rest[0] + ".png") }); break; case "log": { const focused = await page.evaluate(() => { const a = document.activeElement; return a ? `${a.tagName}.${a.className} "${(a.textContent || "").slice(0, 40)}"` : "none"; }); console.log(logs.splice(0).join("\n")); console.log("focused:", focused); break; } default: throw new Error("unknown scene command: " + line); } } if (logs.length) console.log(logs.join("\n")); await browser.close();