228 lines
11 KiB
JavaScript
228 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
// shot.js - headless browser harness for single-file web apps (Puppeteer).
|
|
//
|
|
// node shot.js <app.html> [actions.js] [outdir] run (default outdir: ./shots)
|
|
// node shot.js --check probe the browser: WebGL, flags
|
|
//
|
|
// Opens the HTML via file:// (no web server needed), records every JS
|
|
// exception, console error and failed request, runs the optional actions
|
|
// script and writes numbered screenshots to outdir. Exit code 1 if any error
|
|
// was recorded, 2 on setup problems, 3 on the internal watchdog (default 60 s,
|
|
// SHOT_TIMEOUT=<seconds> to change) - so it never hangs silently.
|
|
//
|
|
// actions.js exports one async function: module.exports = async (h) => { ... }
|
|
// with the helper object h (every call is traced to stdout as "» name args"):
|
|
// h.page Puppeteer page (full API)
|
|
// await h.shot('name') screenshot -> outdir/NN_name.png (waits 2 frames first)
|
|
// await h.click(x, y, {shift}) mouse click at page coordinates (shift = multi-select)
|
|
// await h.clickEl(selector, {shift}) click a DOM element (button, menu entry)
|
|
// await h.set(selector, value) set an <input>/<select> value and dispatch 'input'+'change'
|
|
// await h.key('Delete') press a key (Puppeteer key names)
|
|
// await h.upload('#btn-load-svg', 'x.svg') click the button that opens the file
|
|
// dialog and feed it the file (real import path)
|
|
// const {name,text} = await h.download('#btn-save') click the button that
|
|
// triggers a download, return the file (real export path)
|
|
// await h.eval(fn, ...args) run fn in the page and return its result (keep it JSON-small)
|
|
// await h.wait(ms)
|
|
// h.errors errors collected so far (array of strings)
|
|
//
|
|
// Chrome: Puppeteer downloads its own on 'npm install puppeteer'. To use an
|
|
// existing browser set CHROME_PATH=/path/to/chrome (then 'puppeteer-core' is enough).
|
|
// WebGL: the harness probes WebGL at start and retries with other GPU flags
|
|
// (SwiftShader) if the first launch has none. If every flag set fails, that is an
|
|
// ENVIRONMENT problem - fix it with --check, never by making the app run without WebGL.
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const args = process.argv.slice(2);
|
|
const CHECK = args[0] === '--check';
|
|
const [htmlArg, actionsArg, outArg] = args;
|
|
if (!htmlArg) { console.error('usage: node shot.js <app.html> [actions.js] [outdir] | --check'); process.exit(2); }
|
|
|
|
// Resolve puppeteer from the PROJECT (cwd), not from this skill folder.
|
|
const req = require('module').createRequire(path.join(process.cwd(), 'package.json'));
|
|
let puppeteer;
|
|
try { puppeteer = req('puppeteer'); } catch {
|
|
try { puppeteer = req('puppeteer-core'); } catch {
|
|
console.error('puppeteer not found in ' + process.cwd() + ' - run: npm install puppeteer'); process.exit(2);
|
|
}
|
|
}
|
|
|
|
// GPU flag sets tried in order until a page gets a WebGL context.
|
|
const FLAG_SETS = [
|
|
['--no-sandbox', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist'],
|
|
['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader', '--use-gl=angle'],
|
|
['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=swiftshader', '--disable-gpu'],
|
|
['--no-sandbox', '--enable-unsafe-swiftshader', '--use-angle=swiftshader-webgl'],
|
|
];
|
|
const CACHE = path.join(process.cwd(), '.shot-flags.json');
|
|
|
|
function die(e) { console.error(e.stack || e); process.exit(2); }
|
|
|
|
async function launch(flags) {
|
|
return puppeteer.launch({
|
|
headless: true,
|
|
executablePath: process.env.CHROME_PATH || undefined,
|
|
args: flags,
|
|
protocolTimeout: 30000,
|
|
});
|
|
}
|
|
|
|
async function hasWebGL(browser) {
|
|
const p = await browser.newPage();
|
|
try {
|
|
await p.setContent('<canvas id=c></canvas>');
|
|
return await p.evaluate(() => {
|
|
const c = document.getElementById('c');
|
|
const gl = c.getContext('webgl2') || c.getContext('webgl');
|
|
if (!gl) return null;
|
|
const d = gl.getExtension('WEBGL_debug_renderer_info');
|
|
return d ? gl.getParameter(d.UNMASKED_RENDERER_WEBGL) : 'unknown renderer';
|
|
});
|
|
} finally { await p.close(); }
|
|
}
|
|
|
|
// Launch with the cached flag set, else probe the sets; returns {browser, flags, renderer}.
|
|
async function launchWithWebGL() {
|
|
let order = FLAG_SETS;
|
|
try { const c = JSON.parse(fs.readFileSync(CACHE, 'utf8')); order = [c, ...FLAG_SETS.filter(f => f.join() !== c.join())]; } catch {}
|
|
let last = null;
|
|
for (const flags of order) {
|
|
const browser = await launch(flags);
|
|
const renderer = await hasWebGL(browser).catch(() => null);
|
|
if (renderer) { fs.writeFileSync(CACHE, JSON.stringify(flags)); return { browser, flags, renderer }; }
|
|
last = flags; await browser.close();
|
|
}
|
|
console.error('No WebGL context with any flag set (last tried: ' + last.join(' ') + ').\n' +
|
|
'This is an environment problem (headless Chrome / GPU), NOT an app bug: do not change the app.\n' +
|
|
'Try: CHROME_PATH=<path to a system Chrome>, or update puppeteer (npm i puppeteer@latest), then node shot.js --check');
|
|
process.exit(2);
|
|
}
|
|
|
|
async function check() {
|
|
for (const flags of FLAG_SETS) {
|
|
let browser; try { browser = await launch(flags); } catch (e) { console.log('launch failed:', flags.join(' '), '-', e.message); continue; }
|
|
const r = await hasWebGL(browser).catch(e => 'ERR ' + e.message);
|
|
console.log((r && !String(r).startsWith('ERR') ? 'WebGL OK ' : 'no WebGL ') + flags.join(' ') + (r ? ' -> ' + r : ''));
|
|
await browser.close();
|
|
}
|
|
console.log('chrome:', process.env.CHROME_PATH || (puppeteer.executablePath ? puppeteer.executablePath() : '(bundled)'));
|
|
}
|
|
|
|
if (CHECK) { check().catch(die); } else (async () => {
|
|
const html = path.resolve(htmlArg);
|
|
const outdir = path.resolve(outArg || 'shots');
|
|
fs.mkdirSync(outdir, { recursive: true });
|
|
const errors = [];
|
|
let step = 'launch';
|
|
|
|
// Watchdog: never hang silently. Prints where it was and what it collected.
|
|
const budget = (parseInt(process.env.SHOT_TIMEOUT, 10) || 60) * 1000;
|
|
const watchdog = setTimeout(() => {
|
|
console.log(`\nWATCHDOG: no progress after ${budget / 1000}s - stuck in step: ${step}`);
|
|
if (errors.length) { console.log(`${errors.length} error(s) so far:`); errors.forEach(e => console.log(' ' + e)); }
|
|
process.exit(3);
|
|
}, budget);
|
|
|
|
const { browser, flags, renderer } = await launchWithWebGL();
|
|
console.log(`chrome flags: ${flags.join(' ')} | WebGL: ${renderer}`);
|
|
const page = await browser.newPage();
|
|
await page.setViewport({ width: 1280, height: 800 });
|
|
page.setDefaultTimeout(10000);
|
|
|
|
page.on('pageerror', e => errors.push(`pageerror: ${e.message}`));
|
|
page.on('console', m => { if (m.type() === 'error') errors.push(`console.error: ${m.text()}`); });
|
|
page.on('requestfailed', r => errors.push(`request failed: ${r.url()} (${r.failure()?.errorText})`));
|
|
page.on('response', r => { if (r.status() >= 400) errors.push(`HTTP ${r.status()}: ${r.url()}`); });
|
|
|
|
let n = 0;
|
|
const frames = () => page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));
|
|
const trace = (name, ...a) => { step = `${name} ${a.map(x => typeof x === 'function' ? '[fn]' : JSON.stringify(x)).join(' ')}`.slice(0, 120); console.log('» ' + step); };
|
|
const h = {
|
|
page, errors,
|
|
wait: ms => new Promise(r => setTimeout(r, ms)),
|
|
eval: (fn, ...a) => { trace('eval', fn, ...a); return page.evaluate(fn, ...a); },
|
|
async shot(name) {
|
|
trace('shot', name);
|
|
await frames();
|
|
const file = path.join(outdir, `${String(++n).padStart(2, '0')}_${name}.png`);
|
|
await page.screenshot({ path: file });
|
|
console.log(`shot ${path.relative(process.cwd(), file)}`);
|
|
},
|
|
async click(x, y, { shift = false } = {}) {
|
|
trace('click', x, y, shift ? '+shift' : '');
|
|
if (shift) await page.keyboard.down('Shift');
|
|
await page.mouse.click(x, y);
|
|
if (shift) await page.keyboard.up('Shift');
|
|
await frames();
|
|
},
|
|
async clickEl(selector, { shift = false } = {}) {
|
|
trace('clickEl', selector, shift ? '+shift' : '');
|
|
if (shift) await page.keyboard.down('Shift');
|
|
await page.click(selector);
|
|
if (shift) await page.keyboard.up('Shift');
|
|
await frames();
|
|
},
|
|
async set(selector, value) {
|
|
trace('set', selector, value);
|
|
await page.$eval(selector, (el, v) => {
|
|
el.value = v;
|
|
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}, String(value));
|
|
await frames();
|
|
},
|
|
async key(k) { trace('key', k); await page.keyboard.press(k); await frames(); },
|
|
// Real file import: click the button that opens the file dialog, feed it a file.
|
|
async upload(selector, filePath) {
|
|
trace('upload', selector, filePath);
|
|
const [chooser] = await Promise.all([page.waitForFileChooser({ timeout: 5000 }), page.click(selector)]);
|
|
await chooser.accept([path.resolve(filePath)]);
|
|
await frames(); await h.wait(300);
|
|
},
|
|
// Real file export: click the button that triggers <a download>, return {name, path, text}.
|
|
async download(selector, { timeout = 5000 } = {}) {
|
|
trace('download', selector);
|
|
const dir = path.join(outdir, 'downloads');
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
for (const f of fs.readdirSync(dir)) fs.unlinkSync(path.join(dir, f));
|
|
const cdp = await page.createCDPSession();
|
|
await cdp.send('Browser.setDownloadBehavior', { behavior: 'allow', downloadPath: dir });
|
|
await page.click(selector);
|
|
const t0 = Date.now();
|
|
while (Date.now() - t0 < timeout) {
|
|
const done = fs.readdirSync(dir).filter(f => !f.endsWith('.crdownload'));
|
|
if (done.length) {
|
|
const file = path.join(dir, done[0]);
|
|
return { name: done[0], path: file, text: fs.readFileSync(file, 'utf8') };
|
|
}
|
|
await h.wait(100);
|
|
}
|
|
throw new Error(`download: nothing arrived within ${timeout} ms after clicking ${selector}`);
|
|
},
|
|
};
|
|
|
|
step = 'goto (loading page + CDN modules)';
|
|
await page.goto('file://' + html, { waitUntil: 'load', timeout: 45000 });
|
|
// give module scripts / CDN imports a moment; wait for the test hook if the app has one
|
|
await page.waitForFunction(() => window.__app !== undefined, { timeout: 15000 }).catch(() => {});
|
|
await frames();
|
|
await h.shot('initial');
|
|
|
|
if (actionsArg) {
|
|
try { await require(path.resolve(actionsArg))(h); }
|
|
catch (e) { errors.push(`actions (in step "${step}"): ${e.stack || e}`); }
|
|
}
|
|
step = 'closing browser';
|
|
await browser.close();
|
|
clearTimeout(watchdog);
|
|
|
|
if (errors.length) {
|
|
console.log(`\n${errors.length} error(s):`);
|
|
errors.forEach(e => console.log(' ' + e));
|
|
process.exit(1);
|
|
}
|
|
console.log('\nno errors');
|
|
})().catch(die);
|