571 lines
24 KiB
JavaScript
571 lines
24 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* qa-failstate-b10.mjs
|
|
*
|
|
* Targeted proof for Batch 10 — the "silent-failure" theme (finding H3 + H9).
|
|
* Drives the REAL app in headless Chromium and asserts that dead/failed feeds
|
|
* are SURFACED to the user instead of a healthy-looking empty state.
|
|
*
|
|
* Node-side request interception (page.setRequestInterception) fabricates the
|
|
* upstream failures so the run is deterministic and never depends on live
|
|
* AISStream / CelesTrak availability:
|
|
*
|
|
* (i) AIS invalid key — /api/ais-live is answered with
|
|
* {rows:[],status:'error',error:'invalid key'}. Assert the layer's
|
|
* getStats().error is set (feed down) — NOT a clean 'just now · 0'.
|
|
*
|
|
* (ii) CelesTrak outage — first serve a good catalog so there's something to
|
|
* preserve, then flip ALL /api/celestrak/* groups to HTTP 503. Toggle
|
|
* satellites off/on. Assert the catalog is NOT wiped to 0 AND
|
|
* getStats().error is set.
|
|
*
|
|
* (iii) DETECT with no data — enable panoptic detection with NO data layers.
|
|
* Assert the shared #world-overlay-canvas is non-empty (mode banner drawn),
|
|
* so DETECT reads "armed, nothing in view" instead of a blank canvas.
|
|
* The banner is developer telemetry, hidden from users by default, so this
|
|
* harness loads the app with ?detectDebug=1 — without it the pixel check
|
|
* would pass on scanlines alone and no longer assert what it names.
|
|
* (Absent-by-default is pinned in src/data/detectionHost.test.mjs.)
|
|
*
|
|
* Run: node scripts/qa-failstate-b10.mjs --url http://localhost:4300
|
|
* Exits non-zero if any assertion fails. 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 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 getFlag = (name) => argv.includes(name);
|
|
|
|
const APP_URL = getOpt('--url', 'http://localhost:4300');
|
|
|
|
/**
|
|
* Check (iii) asserts the detection MODE BANNER is drawn on a zero-object frame.
|
|
* That banner is developer telemetry and is hidden from users by default, so the
|
|
* harness must ask for it explicitly — otherwise the pixel check silently passes
|
|
* on scanlines alone and the assertion name stops meaning anything.
|
|
*/
|
|
function withDetectDebug(url) {
|
|
const parsed = new URL(url);
|
|
parsed.searchParams.set('detectDebug', '1');
|
|
return parsed.toString();
|
|
}
|
|
const HEADFUL = getFlag('--headful');
|
|
const ARTIFACT_DIR = getOpt('--artifact-dir', null);
|
|
|
|
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));
|
|
|
|
async function readLayerControl(page, layerId, expectedLabel) {
|
|
await page.waitForFunction(
|
|
(id, label) => {
|
|
const button = document.querySelector(`[data-layer-id="${id}"] .data-toggle-btn`);
|
|
return button?.textContent?.trim() === label;
|
|
},
|
|
{ timeout: 5000 },
|
|
layerId,
|
|
expectedLabel,
|
|
);
|
|
return page.evaluate((id) => {
|
|
const row = document.querySelector(`[data-layer-id="${id}"]`);
|
|
const button = row?.querySelector('.data-toggle-btn');
|
|
const meta = row?.querySelector('.data-toggle-meta');
|
|
return {
|
|
label: button?.textContent?.trim() || '',
|
|
feedState: button?.dataset?.feedState || '',
|
|
ariaLabel: button?.getAttribute('aria-label') || '',
|
|
meta: meta?.textContent?.trim() || '',
|
|
};
|
|
}, layerId);
|
|
}
|
|
|
|
async function captureLayerControl(page, layerId, filename) {
|
|
if (!ARTIFACT_DIR) return;
|
|
fs.mkdirSync(ARTIFACT_DIR, { recursive: true });
|
|
const dataPanel = await page.$('#data-panel');
|
|
const panelCollapsed = await dataPanel?.evaluate((element) => element.classList.contains('collapsed'));
|
|
if (panelCollapsed) await page.click('#data-panel .panel-collapse-btn');
|
|
const row = await page.$(`[data-layer-id="${layerId}"]`);
|
|
if (!row) throw new Error(`Layer control not found for screenshot: ${layerId}`);
|
|
await row.evaluate((element) => element.scrollIntoView({ block: 'center' }));
|
|
await page.waitForFunction((id) => {
|
|
const element = document.querySelector(`[data-layer-id="${id}"]`);
|
|
const rect = element?.getBoundingClientRect();
|
|
return !!rect && rect.width > 0 && rect.height > 0;
|
|
}, { timeout: 5_000 }, layerId);
|
|
await sleep(100);
|
|
await row.screenshot({ path: path.join(ARTIFACT_DIR, filename) });
|
|
}
|
|
|
|
// A minimal but valid TLE for one satellite (ISS), so the "good catalog" pass
|
|
// builds a non-zero catalog we can later prove is preserved across the outage.
|
|
// parseTLE in satellites.js expects `NAME\n1 ...\n2 ...` triplets.
|
|
const GOOD_TLE = [
|
|
'ISS (ZARYA)',
|
|
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927',
|
|
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537',
|
|
'NOAA 19',
|
|
'1 33591U 09005A 08264.51782528 -.00000045 00000-0 35116-4 0 9999',
|
|
'2 33591 99.1949 123.4567 0013000 200.0000 160.0000 14.12345678123456',
|
|
].join('\n');
|
|
|
|
async function main() {
|
|
console.log('\nSilent-Failure Proof (Batch 10 — H3 + H9)');
|
|
console.log(` App URL : ${APP_URL}`);
|
|
console.log(` Mode : ${HEADFUL ? 'headful' : 'headless'}\n`);
|
|
|
|
try {
|
|
const res = await fetch(APP_URL, { method: 'GET' });
|
|
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 chromeExecutable = findChromeExecutable();
|
|
const browser = await puppeteer.launch({
|
|
headless: HEADFUL ? false : 'new',
|
|
...(chromeExecutable ? { executablePath: chromeExecutable } : {}),
|
|
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=1280,800',
|
|
],
|
|
});
|
|
|
|
let exitCode = 0;
|
|
|
|
// Node-side controllable interception mode, flipped mid-run.
|
|
// celestrak: 'good' → serve GOOD_TLE for every group
|
|
// celestrak: 'partial' → fail one group while preserving a usable catalog
|
|
// celestrak: 'down' → 503 for every group (total outage)
|
|
const mode = { celestrak: 'good' };
|
|
|
|
try {
|
|
const page = await browser.newPage();
|
|
await page.setViewport({ width: 1280, height: 800 });
|
|
|
|
await page.setRequestInterception(true);
|
|
page.on('request', (req) => {
|
|
const url = req.url();
|
|
// (i) AIS invalid-key: the exact payload the spec calls for.
|
|
if (url.includes('/api/ais-live') && !url.includes('/track')) {
|
|
req.respond({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ rows: [], status: 'error', error: 'invalid key' }),
|
|
});
|
|
return;
|
|
}
|
|
// (ii) CelesTrak proxy — /api/celestrak/<group>
|
|
if (url.includes('/api/celestrak/')) {
|
|
if (mode.celestrak === 'down'
|
|
|| (mode.celestrak === 'partial' && url.includes('/api/celestrak/stations'))) {
|
|
req.respond({ status: 503, contentType: 'text/plain', body: 'upstream unavailable' });
|
|
} else {
|
|
req.respond({ status: 200, contentType: 'text/plain', body: GOOD_TLE });
|
|
}
|
|
return;
|
|
}
|
|
req.continue();
|
|
});
|
|
|
|
console.log('Loading app...');
|
|
await page.goto(withDetectDebug(APP_URL), { waitUntil: 'domcontentloaded', timeout: 60000 });
|
|
await page.waitForFunction(
|
|
() => window.__godsEyeView
|
|
&& window.__godsEyeView.viewer
|
|
&& window.__godsEyeView.dataManager
|
|
&& window.__godsEyeView.styleManager,
|
|
{ timeout: 60000 },
|
|
);
|
|
await sleep(1500);
|
|
|
|
// ── (0) Manager-owned periodic refresh → shared work/failure/recovery ──
|
|
console.log('\n(0) Universal periodic refresh feedback...');
|
|
const refreshFeedback = await page.evaluate(async () => {
|
|
const dm = window.__godsEyeView.dataManager;
|
|
const ids = ['flights', 'military', 'satellites'];
|
|
const styleManager = window.__godsEyeView.styleManager;
|
|
const outcomes = [];
|
|
const readBanner = () => ({
|
|
hidden: document.getElementById('global-loading-status')?.hidden,
|
|
state: document.getElementById('global-loading-status')?.dataset?.state || '',
|
|
label: document.getElementById('global-loading-label')?.textContent?.trim() || '',
|
|
detail: document.getElementById('global-loading-detail')?.textContent?.trim() || '',
|
|
});
|
|
|
|
for (const id of ids) {
|
|
const entry = dm.layers.get(id);
|
|
if (!entry) {
|
|
outcomes.push({ id, missing: true });
|
|
continue;
|
|
}
|
|
const original = {
|
|
enabled: entry.enabled,
|
|
initialized: entry.initialized,
|
|
lifecycleState: entry.lifecycleState,
|
|
update: entry.module.update,
|
|
getStats: entry.module.getStats,
|
|
managerRefreshError: entry.managerRefreshError,
|
|
};
|
|
const baseStats = typeof original.getStats === 'function'
|
|
? original.getStats.call(entry.module)
|
|
: { count: 0, lastUpdate: null };
|
|
let rejectRefresh;
|
|
try {
|
|
entry.enabled = true;
|
|
entry.initialized = true;
|
|
entry.lifecycleState = 'enabled';
|
|
entry.managerRefreshError = null;
|
|
entry.module.getStats = () => ({
|
|
...baseStats,
|
|
error: null,
|
|
lastError: null,
|
|
available: true,
|
|
unavailable: false,
|
|
loading: false,
|
|
refreshing: false,
|
|
});
|
|
entry.module.update = () => new Promise((resolve, reject) => {
|
|
rejectRefresh = reject;
|
|
});
|
|
const pendingFailure = dm._runPeriodicUpdate(id, entry);
|
|
styleManager._updateGlobalLoadingFeedback(performance.now());
|
|
await new Promise((resolve) => setTimeout(resolve, 220));
|
|
styleManager._updateGlobalLoadingFeedback(performance.now());
|
|
const working = readBanner();
|
|
rejectRefresh(new Error(`${id} QA refresh failure`));
|
|
await pendingFailure;
|
|
styleManager._updateGlobalLoadingFeedback(performance.now());
|
|
const failed = readBanner();
|
|
const managerError = entry.managerRefreshError;
|
|
|
|
entry.module.update = async () => true;
|
|
const pendingRecovery = dm._runPeriodicUpdate(id, entry);
|
|
styleManager._updateGlobalLoadingFeedback(performance.now());
|
|
await pendingRecovery;
|
|
styleManager._updateGlobalLoadingFeedback(performance.now());
|
|
outcomes.push({
|
|
id,
|
|
name: entry.module.name,
|
|
working,
|
|
failed,
|
|
managerError,
|
|
recoveredError: entry.managerRefreshError,
|
|
lifecycleState: entry.lifecycleState,
|
|
enabled: entry.enabled,
|
|
});
|
|
} finally {
|
|
entry.module.update = original.update;
|
|
entry.module.getStats = original.getStats;
|
|
entry.enabled = original.enabled;
|
|
entry.initialized = original.initialized;
|
|
entry.lifecycleState = original.lifecycleState;
|
|
entry.managerRefreshError = original.managerRefreshError;
|
|
}
|
|
}
|
|
return outcomes;
|
|
});
|
|
for (const outcome of refreshFeedback) {
|
|
const passed = !outcome.missing
|
|
&& outcome.working.hidden === false
|
|
&& outcome.working.label === 'REFRESHING LIVE DATA'
|
|
&& outcome.working.detail.includes(outcome.name)
|
|
&& outcome.failed.hidden === false
|
|
&& outcome.failed.label === 'LOAD FAILED'
|
|
&& outcome.failed.state === 'error'
|
|
&& outcome.managerError?.includes('QA refresh failure')
|
|
&& outcome.recoveredError === null
|
|
&& outcome.lifecycleState === 'enabled'
|
|
&& outcome.enabled === true;
|
|
record(
|
|
`${outcome.name || outcome.id}: periodic refresh reports work, failure, and recovery`,
|
|
passed,
|
|
JSON.stringify(outcome),
|
|
);
|
|
if (!passed) exitCode = 1;
|
|
}
|
|
|
|
// ── (i) AIS invalid key → surfaced error, not a clean empty ──────────────
|
|
console.log('\n(i) AIS invalid-key: enabling ais-live-vessels...');
|
|
const aisStats = await page.evaluate(async () => {
|
|
const dm = window.__godsEyeView.dataManager;
|
|
await dm.setEnabled('ais-live-vessels', true);
|
|
const mod = dm.layers.get('ais-live-vessels').module;
|
|
// DataLayerManager intentionally treats enable() as a synchronous
|
|
// lifecycle hook, while this layer starts its first poll asynchronously
|
|
// from enable(). Wait on the layer's public loading state instead of a
|
|
// machine-speed-dependent fixed delay so the assertion always observes
|
|
// the injected response, not an in-flight request.
|
|
const deadline = Date.now() + 5000;
|
|
while (mod.getStats().loading && Date.now() < deadline) {
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
}
|
|
return mod.getStats();
|
|
});
|
|
{
|
|
const hasError = typeof aisStats.error === 'string' && aisStats.error.length > 0;
|
|
const cleanEmpty = !hasError && (aisStats.count === 0);
|
|
record(
|
|
'AIS: dead feed surfaces an error (not clean empty)',
|
|
hasError && !cleanEmpty,
|
|
`stats=${JSON.stringify({ error: aisStats.error, count: aisStats.count, lastUpdate: aisStats.lastUpdate })}`,
|
|
);
|
|
if (!hasError) exitCode = 1;
|
|
}
|
|
const aisControl = await readLayerControl(page, 'ais-live-vessels', 'UNAVAILABLE');
|
|
const aisChipHonest = aisControl.feedState === 'unavailable'
|
|
&& aisControl.ariaLabel === 'Live AIS Vessels: UNAVAILABLE';
|
|
const aisMetaHonest = /^UNAVAILABLE · AISStream · /i.test(aisControl.meta)
|
|
&& aisStats.error
|
|
&& aisControl.meta.includes(aisStats.error);
|
|
record(
|
|
'AIS: layer control reads UNAVAILABLE',
|
|
aisChipHonest,
|
|
JSON.stringify(aisControl),
|
|
);
|
|
record(
|
|
'AIS: layer metadata names the upstream error',
|
|
aisMetaHonest,
|
|
`meta=${JSON.stringify(aisControl.meta)}`,
|
|
);
|
|
if (!aisChipHonest || !aisMetaHonest) exitCode = 1;
|
|
await captureLayerControl(page, 'ais-live-vessels', 'failstate-ais-unavailable.png');
|
|
|
|
// ── (ii) CelesTrak outage on re-enable → catalog NOT wiped, error set ────
|
|
console.log('\n(ii) Satellites: building a good catalog first...');
|
|
const goodStats = await page.evaluate(async () => {
|
|
const dm = window.__godsEyeView.dataManager;
|
|
await dm.setEnabled('satellites', true);
|
|
await new Promise((r) => setTimeout(r, 800));
|
|
const mod = dm.layers.get('satellites').module;
|
|
return mod.getStats();
|
|
});
|
|
record(
|
|
'Satellites: good catalog loaded before outage (baseline)',
|
|
goodStats.count > 0,
|
|
`count=${goodStats.count} error=${goodStats.error ?? null}`,
|
|
);
|
|
if (!(goodStats.count > 0)) {
|
|
// Without a baseline the preservation assertion is meaningless.
|
|
record('Satellites: catalog preserved across total outage', null, 'no baseline catalog to preserve');
|
|
} else {
|
|
console.log(' Failing one CelesTrak group and verifying the visible degraded state...');
|
|
mode.celestrak = 'partial';
|
|
const partialStats = await page.evaluate(async () => {
|
|
const dm = window.__godsEyeView.dataManager;
|
|
await dm.setEnabled('satellites', false);
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
await dm.setEnabled('satellites', true);
|
|
await new Promise((r) => setTimeout(r, 800));
|
|
return dm.layers.get('satellites').module.getStats();
|
|
});
|
|
const partialControl = await readLayerControl(page, 'satellites', 'DEGRADED');
|
|
const partialChipHonest = partialStats.count > 0
|
|
&& /1 CelesTrak group unavailable/i.test(partialStats.error || '')
|
|
&& partialControl.feedState === 'degraded'
|
|
&& partialControl.ariaLabel === 'Satellites: DEGRADED';
|
|
const partialMetaHonest = /^DEGRADED · CelesTrak · /i.test(partialControl.meta)
|
|
&& /1 CelesTrak group unavailable/i.test(partialControl.meta);
|
|
record(
|
|
'Satellites: partial outage renders a DEGRADED chip',
|
|
partialChipHonest,
|
|
JSON.stringify({ stats: partialStats, control: partialControl }),
|
|
);
|
|
record(
|
|
'Satellites: degraded metadata names partial coverage',
|
|
partialMetaHonest,
|
|
`meta=${JSON.stringify(partialControl.meta)}`,
|
|
);
|
|
if (!partialChipHonest || !partialMetaHonest) exitCode = 1;
|
|
await captureLayerControl(page, 'satellites', 'failstate-satellites-degraded.png');
|
|
|
|
console.log(' Flipping ALL CelesTrak groups to 503 and toggling satellites off→on...');
|
|
mode.celestrak = 'down';
|
|
const outageStats = await page.evaluate(async (baseline) => {
|
|
const dm = window.__godsEyeView.dataManager;
|
|
// Toggle off then on — the re-enable's update() re-fetches (now all 503).
|
|
await dm.setEnabled('satellites', false);
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
await dm.setEnabled('satellites', true);
|
|
await new Promise((r) => setTimeout(r, 800));
|
|
const mod = dm.layers.get('satellites').module;
|
|
return { stats: mod.getStats(), baseline };
|
|
}, goodStats.count);
|
|
|
|
const s = outageStats.stats;
|
|
const notWiped = s.count > 0; // catalog preserved, not blanked to 0
|
|
const errorSet = typeof s.error === 'string' && s.error.length > 0;
|
|
const outageControl = await readLayerControl(page, 'satellites', 'UNAVAILABLE');
|
|
const outageChipHonest = outageControl.feedState === 'unavailable'
|
|
&& outageControl.ariaLabel === 'Satellites: UNAVAILABLE';
|
|
const outageMetaHonest = /^UNAVAILABLE · CelesTrak · /i.test(outageControl.meta)
|
|
&& outageControl.meta.includes(s.error || '');
|
|
record(
|
|
'Satellites: catalog NOT wiped to 0 on total outage',
|
|
notWiped,
|
|
`count=${s.count} (baseline=${outageStats.baseline})`,
|
|
);
|
|
record(
|
|
'Satellites: outage surfaces stats.error',
|
|
errorSet,
|
|
`error=${JSON.stringify(s.error)} lastUpdate=${s.lastUpdate}`,
|
|
);
|
|
record(
|
|
'Satellites: total outage renders an UNAVAILABLE chip',
|
|
outageChipHonest,
|
|
JSON.stringify(outageControl),
|
|
);
|
|
record(
|
|
'Satellites: unavailable metadata names the outage',
|
|
outageMetaHonest,
|
|
`meta=${JSON.stringify(outageControl.meta)}`,
|
|
);
|
|
if (!notWiped || !errorSet || !outageChipHonest || !outageMetaHonest) exitCode = 1;
|
|
await captureLayerControl(page, 'satellites', 'failstate-satellites-unavailable.png');
|
|
}
|
|
|
|
// ── (iii) DETECT with no data layers → mode banner drawn (non-blank) ─────
|
|
console.log('\n(iii) DETECT with no data layers: disabling data layers, enabling panoptic...');
|
|
const detect = await page.evaluate(async () => {
|
|
const gev = window.__godsEyeView;
|
|
const dm = gev.dataManager;
|
|
// Turn OFF every data layer so detection collects zero objects.
|
|
for (const [id, entry] of dm.layers) {
|
|
if (entry.enabled) { try { await dm.setEnabled(id, false); } catch { /* ignore */ } }
|
|
}
|
|
// Enable panoptic detection via the styleManager facade (the UI path).
|
|
gev.styleManager.setDetection({ enabled: true, mode: 'panoptic' });
|
|
const canvas = document.getElementById('world-overlay-canvas');
|
|
if (!canvas) return { present: false };
|
|
const viewer = gev.viewer;
|
|
const tileset = gev.tileset;
|
|
const priorDefaultLoop = viewer.useDefaultRenderLoop;
|
|
const priorTilesetShow = tileset?.show;
|
|
let nonEmpty = 0;
|
|
let solid = 0;
|
|
let sampled = 0;
|
|
viewer.useDefaultRenderLoop = false;
|
|
if (tileset) tileset.show = false;
|
|
try {
|
|
const deadline = performance.now() + 5000;
|
|
while (performance.now() < deadline && solid <= 20) {
|
|
viewer.render();
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
const w = canvas.width, h = canvas.height;
|
|
const off = document.createElement('canvas');
|
|
off.width = w; off.height = h;
|
|
const octx = off.getContext('2d');
|
|
// The detection pass (banner included) paints on the host-owned
|
|
// blend-isolation surface beneath the shared canvas — composite
|
|
// both so the sample sees whichever surface carries the banner.
|
|
const detSurface = document.getElementById('world-overlay-detection-surface');
|
|
if (detSurface) octx.drawImage(detSurface, 0, 0);
|
|
octx.drawImage(canvas, 0, 0);
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const bw = Math.min(w, Math.round(260 * dpr));
|
|
const bh = Math.min(h, Math.round(48 * dpr));
|
|
const data = octx.getImageData(0, 0, bw, bh).data;
|
|
sampled = bw * bh;
|
|
nonEmpty = 0;
|
|
solid = 0;
|
|
for (let i = 3; i < data.length; i += 4) {
|
|
if (data[i] > 4) nonEmpty++;
|
|
// The banner paints labelBg at full opacity (alpha 0.66-0.78 => 168+).
|
|
// Scanlines paint the SAME color but scaled by _theme.scanline, and
|
|
// only on every fourth row, so they never reach this threshold. This
|
|
// is what separates "the banner is drawn" from "something is drawn".
|
|
if (data[i] > 128) solid++;
|
|
}
|
|
}
|
|
} finally {
|
|
if (tileset) tileset.show = priorTilesetShow;
|
|
viewer.useDefaultRenderLoop = priorDefaultLoop;
|
|
viewer.scene.requestRender();
|
|
}
|
|
const detState = gev.styleManager.getDetectionState?.() || null;
|
|
return { present: true, nonEmpty, solid, sampled, mode: detState?.detectionMode ?? null };
|
|
});
|
|
|
|
if (!detect.present) {
|
|
record('DETECT: mode banner drawn on 0 objects', false, 'world-overlay canvas absent');
|
|
exitCode = 1;
|
|
} else {
|
|
// Solid pixels only: a non-empty canvas proves SOMETHING painted, which
|
|
// faint scanlines alone satisfy. The banner's opaque background is what
|
|
// this check is named for, so that is what it asserts.
|
|
const bannerDrawn = detect.solid > 20;
|
|
record(
|
|
'DETECT: mode banner drawn on 0 objects (opaque banner fill present)',
|
|
bannerDrawn,
|
|
`solidPx=${detect.solid} nonEmptyPx=${detect.nonEmpty}/${detect.sampled} mode=${detect.mode}`,
|
|
);
|
|
if (!bannerDrawn) exitCode = 1;
|
|
}
|
|
|
|
} 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;
|
|
const inconclusive = results.filter((r) => r.ok === null).length;
|
|
console.log('\n' + '─'.repeat(60));
|
|
console.log(` RESULT: ${pass} passed, ${fail} failed, ${inconclusive} inconclusive`);
|
|
console.log('─'.repeat(60) + '\n');
|
|
process.exit(exitCode || (fail > 0 ? 1 : 0));
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error(e);
|
|
process.exit(3);
|
|
});
|