482 lines
21 KiB
JavaScript
482 lines
21 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* qa-firms.mjs — headless proof for the LIVE NASA FIRMS fires layer.
|
|
*
|
|
* Drives the REAL app in headless Chromium against a dev server with a
|
|
* FIRMS key (default :4420). The live section rides the proxy's 30-min disk
|
|
* cache (zero upstream quota risk per run); the degraded states are
|
|
* fabricated with request interception so they're deterministic.
|
|
*
|
|
* (i) LIVE — layer loads >1000 detections, no error, label 'LIVE ·',
|
|
* /api/firms/status reports the key + transaction telemetry.
|
|
* (ii) CARDS — the shared world-overlay canvas reports painted FIRMS
|
|
* entries and is non-blank at a fire cluster (detections LOD) AND at
|
|
* global zoom (cell cards); aggregate cells expose no action target.
|
|
* (iii) KEYLESS — /api/firms intercepted to 503 {error:'no_key'}:
|
|
* stats.error 'KEY REQUIRED', zero fires, no crash.
|
|
* (iv) STALE — /api/firms intercepted to a stale:true payload with
|
|
* fresh-enough detections: stats.stale true, error 'STALE · …',
|
|
* fires still render (old data beats a wiped map).
|
|
* (v) ACTION (synthetic) — one deterministic intercepted detection's
|
|
* accessible card selects exactly that stable fire and transfers the
|
|
* camera through the shared UI navigation policy.
|
|
*
|
|
* Visual proof saved to qa-shots/ (gitignored).
|
|
*
|
|
* Run: node scripts/qa-firms.mjs --url http://localhost:4420
|
|
* Exits non-zero on any FAIL. Does not commit anything.
|
|
*/
|
|
|
|
import puppeteer from 'puppeteer';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
const SHOTS_DIR = path.join(REPO_ROOT, 'qa-shots');
|
|
|
|
const argv = process.argv.slice(2);
|
|
const getOpt = (name, dflt) => {
|
|
const i = argv.indexOf(name);
|
|
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
|
};
|
|
const APP_URL = getOpt('--url', 'http://localhost:4420');
|
|
const HEADFUL = argv.includes('--headful');
|
|
|
|
const CHROME_EXECUTABLE_CANDIDATES = [
|
|
process.env.PUPPETEER_EXECUTABLE_PATH,
|
|
// Prefer puppeteer's version-pinned Chrome-for-Testing over the system
|
|
// Chrome: /Applications auto-updates underneath the harnesses, and its
|
|
// software-GL behavior shifts across majors (system Chrome 150 blew the
|
|
// tile-gated drain budget under SwiftShader on 2026-07-30 — six
|
|
// false-negative qa-cctv-v2 runs against a healthy build). A deterministic
|
|
// pinned browser beats the newest one for regression harnesses.
|
|
(() => { try { return puppeteer.executablePath(); } catch { return null; } })(),
|
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
|
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
].filter(Boolean);
|
|
|
|
function findChromeExecutable() {
|
|
for (const candidate of CHROME_EXECUTABLE_CANDIDATES) {
|
|
try { if (fs.existsSync(candidate)) return candidate; } catch { /* ignore */ }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const results = [];
|
|
function record(name, ok, detail) {
|
|
results.push({ name, ok, detail });
|
|
const tag = ok === null ? '\x1b[33mINCONCLUSIVE\x1b[0m' : ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
|
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
|
}
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
/** Non-transparent pixels plus source-scoped host diagnostics. */
|
|
async function cardCanvasInk(page) {
|
|
return page.evaluate(() => {
|
|
const canvas = document.getElementById('world-overlay-canvas');
|
|
const diagnostics = window.__gevWorldOverlay?.getDiagnostics?.();
|
|
const painted = diagnostics?.paintedBySource?.firms || 0;
|
|
const entries = diagnostics?.entriesBySource?.firms || 0;
|
|
if (!canvas) return {
|
|
present: false, ink: 0, painted, entries,
|
|
candidates: diagnostics?.candidateCount || 0,
|
|
projected: diagnostics?.projectedCount || 0,
|
|
};
|
|
const ctx = canvas.getContext('2d');
|
|
const { width, height } = canvas;
|
|
if (!width || !height) return {
|
|
present: true, ink: 0, painted, entries,
|
|
candidates: diagnostics?.candidateCount || 0,
|
|
projected: diagnostics?.projectedCount || 0,
|
|
};
|
|
const data = ctx.getImageData(0, 0, width, height).data;
|
|
let ink = 0;
|
|
for (let i = 3; i < data.length; i += 4) if (data[i] > 8) ink++;
|
|
return {
|
|
present: true, ink, painted, entries,
|
|
candidates: diagnostics?.candidateCount || 0,
|
|
projected: diagnostics?.projectedCount || 0,
|
|
};
|
|
});
|
|
}
|
|
|
|
/** Wait for two consecutive painted frames instead of sampling a render gap. */
|
|
async function waitForCardCanvasInk(page, { timeoutMs = 12000 } = {}) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
let consecutive = 0;
|
|
let sample = null;
|
|
while (Date.now() < deadline) {
|
|
await page.evaluate(() => window.__godsEyeView?.viewer?.scene?.requestRender?.());
|
|
await sleep(150);
|
|
sample = await cardCanvasInk(page);
|
|
if (sample.present && sample.entries > 0 && sample.painted > 0 && sample.ink > 500) {
|
|
consecutive += 1;
|
|
if (consecutive >= 2) return sample;
|
|
} else {
|
|
consecutive = 0;
|
|
}
|
|
}
|
|
return sample || {
|
|
present: false, ink: 0, painted: 0, entries: 0, candidates: 0, projected: 0,
|
|
};
|
|
}
|
|
|
|
/** Currently painted FIRMS actions mirrored for keyboard/assistive input. */
|
|
async function firmsActionSnapshot(page) {
|
|
return page.evaluate(() => {
|
|
const buttons = [...document.querySelectorAll(
|
|
'#world-overlay-action-list button[data-overlay-action-key]',
|
|
)].filter((button) => String(button.dataset.overlayActionKey || '').startsWith('firms\u0000'));
|
|
return {
|
|
count: buttons.length,
|
|
selectedCount: buttons.filter((button) => button.getAttribute('aria-pressed') === 'true').length,
|
|
labels: buttons.map((button) => button.getAttribute('aria-label') || button.textContent || ''),
|
|
};
|
|
});
|
|
}
|
|
|
|
/** Wait until the accessible mirror reflects the requested FIRMS action count. */
|
|
async function waitForFirmsActionCount(page, expected, { timeoutMs = 12000 } = {}) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
let snapshot = await firmsActionSnapshot(page);
|
|
while (Date.now() < deadline && snapshot.count !== expected) {
|
|
await page.evaluate(() => window.__godsEyeView?.viewer?.scene?.requestRender?.());
|
|
await sleep(175);
|
|
snapshot = await firmsActionSnapshot(page);
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
/** Boot the app and enable the fires layer, polling until settled. */
|
|
async function bootAndEnable(page, { timeoutS = 45 } = {}) {
|
|
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
|
await page.waitForFunction(
|
|
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
|
{ timeout: 60000 },
|
|
);
|
|
await sleep(1500);
|
|
return page.evaluate(async (tS) => {
|
|
const dm = window.__godsEyeView.dataManager;
|
|
await dm.setEnabled('local-firms', true);
|
|
const mod = dm.layers.get('local-firms').module;
|
|
let s = null;
|
|
for (let i = 0; i < tS; i++) {
|
|
await new Promise((r) => setTimeout(r, 1000));
|
|
s = mod.getStats();
|
|
if ((s.count > 0 || s.error) && !s.loading) break;
|
|
}
|
|
return s;
|
|
}, timeoutS);
|
|
}
|
|
|
|
/** Teleport the camera (duck-typed cartographic — no Cesium global). */
|
|
async function setView(page, lon, lat, height) {
|
|
await page.evaluate((lo, la, h) => {
|
|
const gev = window.__godsEyeView;
|
|
const ell = gev.viewer.scene.globe.ellipsoid;
|
|
const d2r = Math.PI / 180;
|
|
// The app's intro flyTo animation clobbers a setView issued mid-flight.
|
|
try { gev.viewer.camera.cancelFlight(); } catch { /* no flight active */ }
|
|
gev.viewer.camera.setView({
|
|
destination: ell.cartographicToCartesian({ longitude: lo * d2r, latitude: la * d2r, height: h }),
|
|
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
|
});
|
|
gev.viewer.scene.requestRender?.();
|
|
}, lon, lat, height);
|
|
}
|
|
|
|
/** Fabricated stale-but-recent proxy payload for section (iv). */
|
|
function stalePayload() {
|
|
const now = Date.now();
|
|
const acq = new Date(now - 2 * 3600000);
|
|
const acqDate = acq.toISOString().slice(0, 10);
|
|
const acqTime = `${String(acq.getUTCHours()).padStart(2, '0')}${String(acq.getUTCMinutes()).padStart(2, '0')}`;
|
|
const mk = (lat, lon, frp, confidence) => ({
|
|
lat, lon, frp, confidence, brightness: 330, brightnessTi5: 290,
|
|
daynight: 'N', acqDate, acqTime, satellite: 'N20', instrument: 'VIIRS',
|
|
});
|
|
return {
|
|
fetchedAt: now - 2 * 3600000,
|
|
stale: true,
|
|
ttlMs: 1800000,
|
|
sources: [{ source: 'VIIRS_NOAA20_NRT', count: 3, ok: false }],
|
|
count: 3,
|
|
fires: [mk(61.9, -122.9, 900, 'h'), mk(61.95, -122.8, 45, 'n'), mk(61.85, -123.0, 4, 'l')],
|
|
};
|
|
}
|
|
|
|
async function main() {
|
|
console.log('\nLive NASA FIRMS Proof (qa-firms)');
|
|
console.log(` App URL : ${APP_URL}\n`);
|
|
|
|
try {
|
|
const res = await fetch(APP_URL);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
} catch (e) {
|
|
console.error(`\x1b[31mDev server not reachable at ${APP_URL} (${e.message}).\x1b[0m`);
|
|
process.exit(2);
|
|
}
|
|
|
|
const status = await fetch(`${APP_URL}/api/firms/status`).then((r) => r.json()).catch(() => null);
|
|
if (!status?.hasKey) {
|
|
console.error('\x1b[31mServer has no FIRMS key — run against the keyed dev server (:4420).\x1b[0m');
|
|
process.exit(2);
|
|
}
|
|
|
|
fs.mkdirSync(SHOTS_DIR, { recursive: true });
|
|
const browser = await puppeteer.launch({
|
|
headless: HEADFUL ? false : 'new',
|
|
...(findChromeExecutable() ? { executablePath: findChromeExecutable() } : {}),
|
|
args: [
|
|
'--no-sandbox', '--disable-setuid-sandbox', '--use-gl=angle', '--use-angle=swiftshader',
|
|
'--disable-dev-shm-usage', '--disable-web-security',
|
|
'--disable-background-timer-throttling', '--disable-renderer-backgrounding',
|
|
'--window-size=1440,900',
|
|
],
|
|
});
|
|
|
|
let exitCode = 0;
|
|
try {
|
|
const page = await browser.newPage();
|
|
await page.setViewport({ width: 1440, height: 900 });
|
|
|
|
// ── (i) LIVE feed ────────────────────────────────────────────────────────
|
|
console.log('(i) LIVE — loading the fires layer through the cached proxy...');
|
|
const live = await bootAndEnable(page);
|
|
{
|
|
const ok = live.count > 1000 && !live.error && String(live.loadingLabel || '').startsWith('LIVE');
|
|
record('LIVE: >1000 detections, no error, LIVE label', ok,
|
|
`count=${live.count} error=${JSON.stringify(live.error)} label=${JSON.stringify(live.loadingLabel)}`);
|
|
record('LIVE: /api/firms/status has key + transaction telemetry',
|
|
status.hasKey === true && (status.transactions === null || Number.isFinite(status.transactions?.used)),
|
|
`transactions=${JSON.stringify(status.transactions)} count=${status.count}`);
|
|
if (!ok) exitCode = 1;
|
|
}
|
|
|
|
// ── (ii) CARDS — canvas overlay draws at both LODs ──────────────────────
|
|
console.log('\n(ii) CARDS — tactical card overlay ink at both LODs...');
|
|
{
|
|
const strongest = await page.evaluate(() => {
|
|
const mod = window.__godsEyeView.dataManager.layers.get('local-firms').module;
|
|
return mod.getStrongestFire();
|
|
});
|
|
if (!strongest) {
|
|
record('CARDS: detections-LOD cards drawn', false, 'no strongest fire available');
|
|
exitCode = 1;
|
|
} else {
|
|
await setView(page, strongest.longitude, strongest.latitude, 60000);
|
|
const det = await waitForCardCanvasInk(page);
|
|
record('CARDS: detections-LOD cards drawn (host source + canvas ink)',
|
|
det.present && det.painted > 0 && det.ink > 500,
|
|
`entries=${det.entries} candidates=${det.candidates} projected=${det.projected} `
|
|
+ `painted=${det.painted} inkPx=${det.ink} @ strongest fire FRP ${strongest.frp}`);
|
|
if (!(det.present && det.painted > 0 && det.ink > 500)) exitCode = 1;
|
|
await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-cards-detections.png') });
|
|
|
|
// Global LOD band starts at 9,000 km; its labelDistance is 12,000 km
|
|
// (cards fade to zero beyond it, matching the legacy labels) — so the
|
|
// cell-card proof must sit inside that window.
|
|
await setView(page, strongest.longitude, strongest.latitude, 10000000);
|
|
// Do not let the previous detections frame satisfy the ink predicate
|
|
// before the 650 ms LOD watcher has swapped in aggregate cell cards.
|
|
const aggregateActions = await waitForFirmsActionCount(page, 0);
|
|
const cells = await waitForCardCanvasInk(page);
|
|
record('CARDS: global-LOD cell cards drawn (host source + canvas ink)',
|
|
cells.present && cells.painted > 0 && cells.ink > 500,
|
|
`entries=${cells.entries} candidates=${cells.candidates} projected=${cells.projected} `
|
|
+ `painted=${cells.painted} inkPx=${cells.ink}`);
|
|
if (!(cells.present && cells.painted > 0 && cells.ink > 500)) exitCode = 1;
|
|
record('CARDS: aggregate cells expose no FIRMS action target',
|
|
aggregateActions.count === 0,
|
|
`actionButtons=${aggregateActions.count}`);
|
|
if (aggregateActions.count !== 0) exitCode = 1;
|
|
await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-global-cells.png') });
|
|
}
|
|
}
|
|
|
|
// ── (iii) KEYLESS (intercepted) ──────────────────────────────────────────
|
|
console.log('\n(iii) KEYLESS — intercepted 503 no_key...');
|
|
await page.setRequestInterception(true);
|
|
let interceptMode = 'keyless';
|
|
page.on('request', (req) => {
|
|
const url = req.url();
|
|
if (url.includes('/api/firms/status')) {
|
|
req.respond({
|
|
status: 200, contentType: 'application/json',
|
|
body: JSON.stringify(interceptMode === 'keyless'
|
|
? { hasKey: false }
|
|
: { hasKey: true, lastFetch: Date.now() - 7200000, count: 3, stale: true, ttlMs: 1800000, transactions: null }),
|
|
});
|
|
return;
|
|
}
|
|
if (url.includes('/api/firms')) {
|
|
if (interceptMode === 'keyless') {
|
|
req.respond({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'no_key' }) });
|
|
} else {
|
|
req.respond({ status: 200, contentType: 'application/json', body: JSON.stringify(stalePayload()) });
|
|
}
|
|
return;
|
|
}
|
|
try { req.continue(); } catch { /* already handled */ }
|
|
});
|
|
|
|
const keyless = await bootAndEnable(page, { timeoutS: 20 });
|
|
{
|
|
const ok = keyless.error === 'KEY REQUIRED' && keyless.count === 0;
|
|
record('KEYLESS: error "KEY REQUIRED", zero fires, no crash', ok,
|
|
`error=${JSON.stringify(keyless.error)} count=${keyless.count} label=${JSON.stringify(keyless.loadingLabel)}`);
|
|
if (!ok) exitCode = 1;
|
|
await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-keyless.png') });
|
|
}
|
|
|
|
// ── (iv) STALE (intercepted) ─────────────────────────────────────────────
|
|
console.log('\n(iv) STALE — intercepted stale:true payload...');
|
|
interceptMode = 'stale';
|
|
const stale = await bootAndEnable(page, { timeoutS: 20 });
|
|
{
|
|
const ok = stale.stale === true
|
|
&& String(stale.error || '').startsWith('STALE')
|
|
&& stale.count === 3;
|
|
record('STALE: stats.stale, "STALE · cached" error, fires still render', ok,
|
|
`stale=${stale.stale} error=${JSON.stringify(stale.error)} count=${stale.count}`);
|
|
if (!ok) exitCode = 1;
|
|
await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-stale.png') });
|
|
}
|
|
|
|
// ── (v) ACTION (deterministic intercepted detection) ────────────────────────
|
|
console.log('\n(v) ACTION — synthetic intercepted fire card through shared camera policy...');
|
|
await setView(page, -122.9, 61.9, 60000);
|
|
const actionCards = await waitForCardCanvasInk(page);
|
|
const beforeActions = await firmsActionSnapshot(page);
|
|
if (!(actionCards.present && actionCards.painted > 0 && beforeActions.count > 0)) {
|
|
record('ACTION (synthetic): individual fire action is available', false,
|
|
`painted=${actionCards.painted} actionButtons=${beforeActions.count}`);
|
|
exitCode = 1;
|
|
} else {
|
|
const prepared = await page.evaluate(() => {
|
|
const gev = window.__godsEyeView;
|
|
const mod = gev.dataManager.layers.get('local-firms').module;
|
|
const target = mod.getDetectableObjects({ maxCount: 1 })[0];
|
|
if (!target?.position) return null;
|
|
|
|
const sentinel = gev.viewer.entities.add({
|
|
id: 'qa-firms-prior-camera-owner',
|
|
position: target.position,
|
|
});
|
|
gev.viewer.trackedEntity = sentinel;
|
|
|
|
const camera = gev.viewer.camera;
|
|
const originalFly = camera.flyToBoundingSphere;
|
|
const proof = {
|
|
sentinel,
|
|
originalFly,
|
|
flightCount: 0,
|
|
trackedAtFlight: 'not-called',
|
|
request: null,
|
|
generationBefore: gev.styleManager._navigationGeneration,
|
|
};
|
|
proof.onRequest = (event) => {
|
|
proof.request = {
|
|
kind: event.detail?.kind || null,
|
|
id: event.detail?.id || null,
|
|
};
|
|
};
|
|
window.addEventListener('gev:world-request-focus', proof.onRequest);
|
|
camera.flyToBoundingSphere = function qaFirmsObservedFlight(...args) {
|
|
proof.flightCount += 1;
|
|
proof.trackedAtFlight = gev.viewer.trackedEntity?.id || null;
|
|
return originalFly.apply(this, args);
|
|
};
|
|
window.__qaFirmsActionProof = proof;
|
|
return { generationBefore: proof.generationBefore };
|
|
});
|
|
|
|
if (!prepared) {
|
|
record('ACTION (synthetic): individual fire action is available', false,
|
|
'no deterministic fire position');
|
|
exitCode = 1;
|
|
} else {
|
|
const clicked = await page.evaluate(() => {
|
|
const button = [...document.querySelectorAll(
|
|
'#world-overlay-action-list button[data-overlay-action-key]',
|
|
)].find((candidate) => String(candidate.dataset.overlayActionKey || '').startsWith('firms\u0000'));
|
|
button?.click();
|
|
return Boolean(button);
|
|
});
|
|
|
|
for (let i = 0; i < 30; i += 1) {
|
|
const settled = await page.evaluate(() => {
|
|
window.__godsEyeView?.viewer?.scene?.requestRender?.();
|
|
return (window.__qaFirmsActionProof?.flightCount || 0) > 0;
|
|
});
|
|
if (settled) break;
|
|
await sleep(100);
|
|
}
|
|
await sleep(250);
|
|
|
|
const proof = await page.evaluate(() => {
|
|
const gev = window.__godsEyeView;
|
|
const state = window.__qaFirmsActionProof;
|
|
const actionButtons = [...document.querySelectorAll(
|
|
'#world-overlay-action-list button[data-overlay-action-key]',
|
|
)].filter((button) => String(button.dataset.overlayActionKey || '').startsWith('firms\u0000'));
|
|
const selectedEntityId = window.__gevContextStore?.selectedEntityId || null;
|
|
const result = {
|
|
flightCount: state?.flightCount || 0,
|
|
trackedAtFlight: state?.trackedAtFlight,
|
|
trackingReleased: gev.viewer.trackedEntity == null,
|
|
generationBefore: state?.generationBefore,
|
|
generationAfter: gev.styleManager._navigationGeneration,
|
|
request: state?.request || null,
|
|
selectedEntityId,
|
|
selectedActionCount: actionButtons.filter(
|
|
(button) => button.getAttribute('aria-pressed') === 'true',
|
|
).length,
|
|
};
|
|
if (state) {
|
|
gev.viewer.camera.flyToBoundingSphere = state.originalFly;
|
|
window.removeEventListener('gev:world-request-focus', state.onRequest);
|
|
if (state.sentinel) gev.viewer.entities.remove(state.sentinel);
|
|
}
|
|
delete window.__qaFirmsActionProof;
|
|
return result;
|
|
});
|
|
|
|
const actionOk = clicked
|
|
&& proof.flightCount === 1
|
|
&& proof.trackedAtFlight == null
|
|
&& proof.trackingReleased
|
|
&& proof.generationAfter === proof.generationBefore + 1
|
|
&& proof.request?.kind === 'fire'
|
|
&& proof.request?.id === proof.selectedEntityId
|
|
&& proof.selectedActionCount === 1;
|
|
record('ACTION (synthetic): stable fire selects once and shared policy owns flight', actionOk,
|
|
`clicked=${clicked} flights=${proof.flightCount} trackedAtFlight=${JSON.stringify(proof.trackedAtFlight)} `
|
|
+ `generation=${proof.generationBefore}->${proof.generationAfter} `
|
|
+ `request=${JSON.stringify(proof.request)} selected=${JSON.stringify(proof.selectedEntityId)} `
|
|
+ `selectedActions=${proof.selectedActionCount}`);
|
|
if (!actionOk) exitCode = 1;
|
|
await page.screenshot({ path: path.join(SHOTS_DIR, 'firms-synthetic-fire-action.png') });
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('\x1b[31mHarness error:\x1b[0m', e);
|
|
exitCode = 3;
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
|
|
const pass = results.filter((r) => r.ok === true).length;
|
|
const fail = results.filter((r) => r.ok === false).length;
|
|
console.log('\n' + '─'.repeat(60));
|
|
console.log(` RESULT: ${pass} passed, ${fail} failed`);
|
|
console.log(` Shots : ${SHOTS_DIR}/firms-*.png`);
|
|
console.log('─'.repeat(60) + '\n');
|
|
process.exit(exitCode || (fail > 0 ? 1 : 0));
|
|
}
|
|
|
|
main().catch((e) => { console.error(e); process.exit(3); });
|