makepad/tools/webgl_release_probe.mjs
Admin 12d223737a tools, arch, docs, workspace, examples, sqlite, wasm_bridge
Squash of 55 work commits (Sep 1–12):
  0fd356d  windows: the vendored bindings are generated from a checked-in filter
  79882b5  fabric: a photo or a live camera to a fitted sewing pattern
  1e93309  AGENTS.md: designs stay local; no OS screenshots; focus and hidden-window laws
  aa96dbd  cargo-makepad wasm: package the bin target's wasm and create dirs before minifying
  14d0723  cargo-makepad wasm: production packaging — strip, small profile LTO, optional binaryen, size report
  79e7526  sqlite: a page-store seam — the file backend as before, an in-memory backend, and open_memory / open_with
  60ee978  cargo-makepad: package artifacts carry a content hash so a re-upload is a new URL
  611c9eb  cargo-makepad: production packaging stays off fat LTO; script VM under LTO investigated
  ab8febc  cargo-makepad: fonts packaged from the app's font manifest
  f6bbee9  wasm bridge: shared memory asks for the 4 GiB wasm32 ceiling and steps down where the engine refuses
  fb1416d  cargo-makepad: the threaded wasm module is linked with the 4 GiB wasm32 memory ceiling
  fd5d70c  cargo-makepad: the app's own resources are packaged under its bin name, which is how self:// resolves
  f51ca07  workspace: the wasm interpreter's tests build at opt-level 1 — its own profile setting is ignored inside a workspace, and opt-level 0 overflowed the script eval stack
  942ff86  sqlite: the browser store has one owner — its locks never wait on a clock
  82d0cfa  web path: the trace helper keeps its doc, the journal nonce steps a counter where there is no clock or pid
  6f8c08f  web-server: POST /api/crash stores crash reports in a rotating log on both servers
  106b38c  wasm bridge: the imported memory honours the module's declared limits
  94b8726  dj-pack: tracks in, stems through the hub, a site store snapshot out
  d9f04fc  dj-pack: pack reads caches, never creates them; dry-run writes nothing
  e6a305c  ai-hub + dj-pack: a whole track fits a stems job; long tracks split into spans
  d1910b8  web-server: a store snapshot's extensionless routes are served with the types the exporter recorded
  c1f7b53  asset-client + dj-pack: a long description never rejects a snapshot; the packer writes one bounded line
  453197d  dj-pack: analyse produces the beat grid, overview and loop-splat caches the demo cache ships
  9b4a3ca  network: every completion raises the UI signal
  fad1c49  web server: audio and text files are served, and models/ is immutable like maps/
  569b4a7  dj-pack: every CC BY and CC BY-SA version and the public domain mark are redistributable licences
  dc9cce6  workspace: no std clock on the web in any crate the web apps link — the last start-up worker death is gone
  c7639f0  clippy: timed std waits (sleep, recv_timeout, wait_timeout, park_timeout) are disallowed — they read the std clock and panic on wasm workers
  d748753  wasm bridge: the page environment carries js_worker_wait so the module links — the pool landing added the import for workers only
  ec40fdb  vj + widgets: double-click a knob or fader to reset it to its default — the Slider handles tap_count 2 and emits its normal Slide action; the deck controls carry their neutral defaults (pitch 0, gain/EQ/stems 1, filter centre, crossfader centre)
  fe23e06  AGENTS.md: the execution policy — zero locking on the UI thread as one mechanism for native and wasm, no temporary threads (the pool), the standard operating flow (Codex codes, Fable designs and reviews, Grok tests), and the tweaker on Shift+F10
  e689aea  web_server + geodata + route: live radar, wind and weather on makepad.nl/api — one bounded poller per feed, hourly, disk-backed cache served from an Arc snapshot (a restart never re-polls early), 503 warming until the first result, health reports ok/warming/unavailable with timestamps; KNMI key from --knmi-key-file, the documented anonymous open-data key otherwise; libs/geodata fetches through the platform HTTP client instead of shelling out to curl; the client retries 503 after 30 s and disables a layer only on 404
  cd33943  web_server: radar and weather run on KNMI's documented anonymous key when no --knmi-key-file is given; without --live-cache the pollers keep an in-memory cache and say so once
  2f3e393  web_server: a directory path without its trailing slash (/score) redirects to /score/ instead of 404, query preserved
  9a31d21  flow-ui + widgets: a chosen model shows no node list, and a closed ComboBox shows the start of a long label
  22b2c78  docs: streamline agent runbook and extract reference guides
  6058284  terminal: add hostable session multiplexing via tools/screen
  8a9345b  tools: migrate Cargo.toml lookups to segment-path keys
  8749b91  counter: keep app state across Splash reloads
  3ffd485  tools: add agent launcher and AIHub node update and smoke scripts
  c33a9d3  screen: add bypass and resume menu options
  c40d234  AGENTS.md: current delegation hierarchy (Grok mechanical, Codex hard, Fable manages)
  4184455  Workspace: scope lives at apps/scope (clone of makepad/scope)
  27844c9  makepad arch usb builder
  dd967eb  Workspace: drop nine members that are not in the repository
  1961768  AGENTS.md: hierarchy 2026-09-11 — Fable builds, Codex reviews, Grok proves
  9cd9f94  AGENTS.md: rendering is verified on the real GPU backend, headless is for logic tests only
  5f53254  AGENTS.md: GPU proofs may run hidden; only the headless CPU backend is out
  e950b08  docs: app-remote — hidden GPU runs vs the simulated-GPU backend, measured grab cadence, remote hazards
  9c94a77  arch: the platform plan names the simulated-GPU backend gpusim
  3009257  micro_serde: serde_json-style JsonValue accessors, pretty printer, depth limit and strict parse
  134cd76  gitignore: alternate target directories, root scratch dirs and stray logs are never source
  60ba86e  arch: the render node refs name platform/src/os/gpusim/mod.rs
  1dc571a  tools/arch_usb: Wi-Fi, Intel GPU firmware, the AI hub service and game-hardware udev rules on the Arch image; the WM session script picks the saved compositor GPU
  cc3b05a  tools/arch_usb: a polkit rule lets the arch account start, stop and restart the WM service

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:40:33 +02:00

1131 lines
51 KiB
JavaScript

// Usage: node tools/webgl_release_probe.mjs http://127.0.0.1:PORT/path [--map] [--buildings] [--text] [--lose-context] [--location] [--diagnostics]
// Bounded software-WebGL smoke test. It never uses a user profile, hardware GPU, or screenshots.
import assert from 'node:assert/strict';
import {spawn} from 'node:child_process';
import {mkdtempSync, rmSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
const PIXEL_BUDGET = 2_097_152;
const STARTUP_TIMEOUT_MS = 45_000;
const CDP_TIMEOUT_MS = 15_000;
const LOSS_WARNING_TIMEOUT_MS = 5_000;
const LOSS_QUIET_MS = 3_000;
const LOCATION_ACTION_TIMEOUT_MS = 5_000;
const BUILDING_SETTLE_MS = 8_000;
const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const RENDERER_REJECTION = /WebGL .*rejected|WebGL2 error|webgl\.compile_fail|webgl shaders:.*\bfailed\b|Missing shader|R32F render target.*unavailable|EXT_color_buffer_float unavailable/i;
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
function parseArguments(argv) {
const knownFlags = new Set(['--map', '--buildings', '--text', '--lose-context', '--location', '--diagnostics']);
const flags = argv.filter(value => value.startsWith('--'));
const urls = argv.filter(value => !value.startsWith('--'));
for (const flag of flags) {
assert(knownFlags.has(flag), `Unknown option: ${flag}`);
}
assert.equal(new Set(flags).size, flags.length, 'Options may only be supplied once');
assert.equal(urls.length, 1, 'Supply exactly one explicit http://127.0.0.1:PORT URL');
let parsed;
try {
parsed = new URL(urls[0]);
} catch {
assert.fail('Supply a valid explicit http://127.0.0.1:PORT URL');
}
assert.equal(parsed.protocol, 'http:', 'Only HTTP localhost test servers are allowed');
assert.equal(parsed.hostname, '127.0.0.1', 'Only an explicit 127.0.0.1 URL is allowed');
assert(parsed.port, 'The localhost URL must include an explicit port');
assert.equal(parsed.username, '', 'URL credentials are not allowed');
assert.equal(parsed.password, '', 'URL credentials are not allowed');
return {
url: parsed.href,
map: flags.includes('--map'),
buildings: flags.includes('--buildings'),
text: flags.includes('--text'),
loseContext: flags.includes('--lose-context'),
location: flags.includes('--location'),
diagnostics: flags.includes('--diagnostics'),
};
}
function childHasExited(child, closed) {
return closed.value || child.exitCode !== null || child.signalCode !== null;
}
async function waitForChildExit(child, closed, milliseconds) {
if (childHasExited(child, closed)) return true;
await Promise.race([
new Promise(resolve => child.once('close', resolve)),
sleep(milliseconds),
]);
return childHasExited(child, closed);
}
function pageInstrumentation(diagnostics) {
return String.raw`
(() => {
const diagnostics = ${diagnostics ? 'true' : 'false'};
const stats = window.__renderProbe = {
draws: 0,
mapDraws: 0,
wallDraws: 0,
wallInstances: 0,
textDraws: 0,
glyphInstances: 0,
textShaderAttaches: 0,
textProgramLinks: 0,
textProgramUses: 0,
drawByMethod: {},
liveBuffers: 0,
liveTextures: 0,
bufferHighWater: 0,
textureHighWater: 0,
deletedBuffers: 0,
deletedTextures: 0,
maxBufferBytes: 0,
maxTexturePixels: 0,
invalidDraws: 0,
contextLosses: 0,
contextRestores: 0,
contextLossAt: null,
rafRequests: 0,
rafCallbacks: 0,
glErrors: [],
uncaught: [],
};
const documentToken = Math.random().toString(36).slice(2) + '-' + Date.now();
const contexts = [];
const knownContexts = new WeakSet();
const shaderIsMap = new WeakMap();
const programIsMap = new WeakMap();
const shaderIsWall = new WeakMap();
const programIsWall = new WeakMap();
const shaderTextSignatures = new WeakMap();
const programShaders = new WeakMap();
const linkedTextPrograms = new WeakSet();
const shaderDiagnostics = new WeakMap();
const textShaderInventory = [];
const currentProgram = new WeakMap();
const liveBuffers = new WeakSet();
const liveTextures = new WeakSet();
const patched = new WeakMap();
const text = value => {
try {
if (value instanceof Error) return value.stack || value.message;
return String(value);
} catch (_) {
return '[unprintable]';
}
};
const keep = (array, value) => {
if (array.length < 32) array.push(value);
};
const noteGlError = (gl, where, error) => {
if (error !== gl.NO_ERROR) keep(stats.glErrors, {where, error});
};
const drainGlErrors = (gl, where) => {
for (let i = 0; i < 16; i++) {
const error = gl.getError();
if (error === gl.NO_ERROR) return;
}
};
const noteContext = gl => {
if (!gl || knownContexts.has(gl)) return;
knownContexts.add(gl);
contexts.push(gl);
};
const samplerInventory = source => {
const samplers = [];
const pattern = /\b(?:uniform\s+)?(?:(?:lowp|mediump|highp)\s+)?sampler(?:2D|2DArray|3D|Cube)\s+([A-Za-z_]\w*)/g;
for (const match of source.matchAll(pattern)) {
if (!samplers.includes(match[1]) && samplers.length < 16) samplers.push(match[1]);
}
return samplers;
};
const classifyTextShader = source => {
const signatures = [];
const hasFontWord = /font|glyph|draw_text/i.test(source);
const hasSlugWord = /slug/i.test(source);
const hasCurve = /curve/i.test(source);
const hasBand = /band/i.test(source);
const hasRaster = /raster/i.test(source);
const hasGlyph = /glyph/i.test(source);
const hasTextShaderName = /DrawText|draw_text/i.test(source);
const hasSlugAtlasFields = /font_t[12]/i.test(source)
&& /tex_coord[12]/i.test(source)
&& /curve|brightness/i.test(source);
// DrawText's Slug path contains curve/band coverage logic; its older
// atlas form exposes the paired font texture/coordinate fields.
if ((hasCurve && hasBand && (hasSlugWord || hasFontWord)) || hasSlugAtlasFields) {
signatures.push('slug-curve-band');
}
// Raster glyph shaders have explicit raster + glyph/font vocabulary.
// Requiring both avoids treating an unrelated sampled image as text.
if (hasRaster && (hasGlyph || hasFontWord)) signatures.push('raster-glyph');
if (hasTextShaderName && (hasGlyph || hasRaster || samplerInventory(source).length > 0)) {
signatures.push('draw-text');
}
return [...new Set(signatures)];
};
const patch = (object, name, hooks) => {
if (!object || typeof object[name] !== 'function') return;
let names = patched.get(object);
if (!names) patched.set(object, names = new Set());
if (names.has(name)) return;
names.add(name);
const original = object[name];
object[name] = function(...args) {
if (hooks.before) hooks.before.call(this, args);
const result = original.apply(this, args);
if (hooks.after) hooks.after.call(this, args, result);
return result;
};
};
const prototypes = [];
if (typeof WebGLRenderingContext !== 'undefined') prototypes.push(WebGLRenderingContext.prototype);
if (typeof WebGL2RenderingContext !== 'undefined') prototypes.push(WebGL2RenderingContext.prototype);
const canvasGetContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function(...args) {
const result = canvasGetContext.apply(this, args);
if (String(args[0]).toLowerCase() === 'webgl' || String(args[0]).toLowerCase() === 'experimental-webgl' || String(args[0]).toLowerCase() === 'webgl2') {
noteContext(result);
}
return result;
};
const originalRaf = window.requestAnimationFrame.bind(window);
window.requestAnimationFrame = callback => {
stats.rafRequests++;
return originalRaf(time => {
stats.rafCallbacks++;
return callback(time);
});
};
window.addEventListener('error', event => {
keep(stats.uncaught, text(event.error || event.message || 'window error'));
}, true);
window.addEventListener('unhandledrejection', event => {
keep(stats.uncaught, text(event.reason || 'unhandled rejection'));
}, true);
document.addEventListener('webglcontextlost', () => {
stats.contextLosses++;
if (!stats.contextLossAt) {
stats.contextLossAt = {
draws: stats.draws,
rafRequests: stats.rafRequests,
rafCallbacks: stats.rafCallbacks,
};
}
}, true);
document.addEventListener('webglcontextrestored', () => stats.contextRestores++, true);
for (const proto of prototypes) {
patch(proto, 'shaderSource', {before(args) {
const shader = args[0];
const source = String(args[1] || '');
shaderIsMap.set(shader, /shadow_mask|terrain_lift|tile_origin/.test(source));
// Generated attributes are commonly prefixed (for example,
// rustinst_inst_heights), so deliberately use substrings here.
shaderIsWall.set(shader, source.includes('inst_heights') && source.includes('inst_ao'));
const signatures = classifyTextShader(source);
shaderTextSignatures.set(shader, signatures);
if (diagnostics && signatures.length > 0 && textShaderInventory.length < 16) {
let type = 'unknown';
try {
const shaderType = this.getShaderParameter(shader, this.SHADER_TYPE);
if (shaderType === this.VERTEX_SHADER) type = 'vertex';
if (shaderType === this.FRAGMENT_SHADER) type = 'fragment';
} catch (_) {
// Shader type is diagnostic-only.
}
const entry = {
id: textShaderInventory.length + 1,
type,
signatures,
samplers: samplerInventory(source),
attaches: 0,
links: 0,
};
shaderDiagnostics.set(shader, entry);
textShaderInventory.push(entry);
}
}});
patch(proto, 'attachShader', {before(args) {
programIsMap.set(args[0], Boolean(programIsMap.get(args[0]) || shaderIsMap.get(args[1])));
programIsWall.set(args[0], Boolean(programIsWall.get(args[0]) || shaderIsWall.get(args[1])));
let shaders = programShaders.get(args[0]);
if (!shaders) programShaders.set(args[0], shaders = new Set());
shaders.add(args[1]);
const signatures = shaderTextSignatures.get(args[1]);
if (signatures?.length) {
stats.textShaderAttaches++;
const entry = shaderDiagnostics.get(args[1]);
if (entry) entry.attaches++;
}
}});
patch(proto, 'detachShader', {before(args) {
programShaders.get(args[0])?.delete(args[1]);
}});
patch(proto, 'linkProgram', {after(args) {
const program = args[0];
const signatures = new Set();
const diagnosticEntries = new Set();
for (const shader of programShaders.get(program) || []) {
for (const signature of shaderTextSignatures.get(shader) || []) signatures.add(signature);
const entry = shaderDiagnostics.get(shader);
if (entry) diagnosticEntries.add(entry);
}
let linked = false;
try {
linked = true; // Actual draws plus GL-error checks prove usability without blocking asynchronous shader compilation.
} catch (_) {
// A failed link remains ineligible for text draw accounting.
}
if (linked && signatures?.size) {
linkedTextPrograms.add(program);
stats.textProgramLinks++;
if (diagnostics) {
for (const entry of diagnosticEntries) entry.links++;
}
} else {
linkedTextPrograms.delete(program);
}
}});
patch(proto, 'useProgram', {after(args) {
currentProgram.set(this, args[0]);
if (linkedTextPrograms.has(args[0])) stats.textProgramUses++;
}});
patch(proto, 'createBuffer', {after(_args, result) {
if (result && !liveBuffers.has(result)) {
liveBuffers.add(result);
stats.liveBuffers++;
stats.bufferHighWater = Math.max(stats.bufferHighWater, stats.liveBuffers);
}
}});
patch(proto, 'deleteBuffer', {after(args) {
if (args[0] && liveBuffers.has(args[0])) {
liveBuffers.delete(args[0]);
stats.liveBuffers--;
stats.deletedBuffers++;
}
}});
patch(proto, 'createTexture', {after(_args, result) {
if (result && !liveTextures.has(result)) {
liveTextures.add(result);
stats.liveTextures++;
stats.textureHighWater = Math.max(stats.textureHighWater, stats.liveTextures);
}
}});
patch(proto, 'deleteTexture', {after(args) {
if (args[0] && liveTextures.has(args[0])) {
liveTextures.delete(args[0]);
stats.liveTextures--;
stats.deletedTextures++;
}
}});
patch(proto, 'bufferData', {before(args) {
const data = args[1];
let bytes = typeof data === 'number' ? data : Number(data && data.byteLength) || 0;
if (ArrayBuffer.isView(data) && Number.isFinite(args[4])) {
bytes = Math.max(0, args[4]) * (data.BYTES_PER_ELEMENT || 1);
}
stats.maxBufferBytes = Math.max(stats.maxBufferBytes, bytes);
}});
const textureSizeArguments = {
texImage2D: [3, 4, 9],
texSubImage2D: [4, 5, 9],
texStorage2D: [3, 4, 5],
texImage3D: [3, 4, 10],
texSubImage3D: [5, 6, 11],
texStorage3D: [3, 4, 6],
};
for (const [name, [widthIndex, heightIndex, explicitLength]] of Object.entries(textureSizeArguments)) {
patch(proto, name, {before(args) {
let width;
let height;
if (args.length >= explicitLength) {
width = Number(args[widthIndex]);
height = Number(args[heightIndex]);
} else {
const source = args[args.length - 1];
width = Number(source && (source.videoWidth || source.naturalWidth || source.width));
height = Number(source && (source.videoHeight || source.naturalHeight || source.height));
}
if (Number.isFinite(width) && Number.isFinite(height) && width >= 0 && height >= 0) {
stats.maxTexturePixels = Math.max(stats.maxTexturePixels, width * height);
}
}});
}
patch(proto, 'getError', {after(_args, result) {
noteGlError(this, 'getError', result);
}});
}
const drawSpecs = {
drawArrays: [1, 2],
drawElements: [1, 3],
drawRangeElements: [1, 2, 3, 5],
drawArraysInstanced: [1, 2, 3],
drawElementsInstanced: [1, 3, 4],
};
const positive = value => Number.isFinite(Number(value)) && Number(value) > 0;
const instancedDraw = (name, args) => {
if (name === 'drawArraysInstanced' || name === 'drawArraysInstancedANGLE') {
return positive(args[2]) && positive(args[3]) ? {draws: 1, instances: Number(args[3])} : null;
}
if (name === 'drawElementsInstanced' || name === 'drawElementsInstancedANGLE') {
return positive(args[1]) && positive(args[4]) ? {draws: 1, instances: Number(args[4])} : null;
}
const arrays = name === 'multiDrawArraysInstancedWEBGL';
const elements = name === 'multiDrawElementsInstancedWEBGL';
if (!arrays && !elements) return null;
const counts = args[1];
const countsOffset = Number(args[2]);
const instanceCounts = args[arrays ? 5 : 6];
const instanceCountsOffset = Number(args[arrays ? 6 : 7]);
const drawCount = Number(args[arrays ? 7 : 8]);
if (!counts || !instanceCounts || !Number.isInteger(countsOffset)
|| !Number.isInteger(instanceCountsOffset) || !Number.isInteger(drawCount) || drawCount <= 0) return null;
let draws = 0;
let instances = 0;
for (let index = 0; index < drawCount; index++) {
const count = counts[countsOffset + index];
const instanceCount = instanceCounts[instanceCountsOffset + index];
if (positive(count) && positive(instanceCount)) {
draws++;
instances += Number(instanceCount);
}
}
return draws > 0 ? {draws, instances} : null;
};
const recordDraw = (gl, name, args, numericArguments) => {
stats.draws++;
stats.drawByMethod[name] = (stats.drawByMethod[name] || 0) + 1;
const program = currentProgram.get(gl);
if (programIsMap.get(program)) stats.mapDraws++;
const wallDraw = programIsWall.get(program) && instancedDraw(name, args);
if (wallDraw) {
stats.wallDraws += wallDraw.draws;
stats.wallInstances += wallDraw.instances;
}
if (linkedTextPrograms.has(program)) {
const textDraw = instancedDraw(name, args);
if (textDraw) {
stats.textDraws += textDraw.draws;
stats.glyphInstances += textDraw.instances;
}
}
if (numericArguments.some(index => !Number.isFinite(args[index]))) stats.invalidDraws++;
drainGlErrors(gl, name);
};
for (const proto of prototypes) {
for (const [name, numericArguments] of Object.entries(drawSpecs)) {
patch(proto, name, {after(args) {
recordDraw(this, name, args, numericArguments);
}});
}
patch(proto, 'getExtension', {after(_args, extension) {
if (!extension) return;
const owningGl = this;
for (const [name, numericArguments] of Object.entries({
drawArraysInstancedANGLE: [1, 2, 3],
drawElementsInstancedANGLE: [1, 3, 4],
multiDrawArraysWEBGL: [],
multiDrawElementsWEBGL: [],
multiDrawArraysInstancedWEBGL: [],
multiDrawElementsInstancedWEBGL: [],
})) {
patch(extension, name, {after(args) {
recordDraw(owningGl, name, args, numericArguments);
}});
}
}});
}
const visibleText = () => document.body ? document.body.innerText : '';
const rendererName = gl => {
try {
const extension = gl.getExtension('WEBGL_debug_renderer_info');
if (extension) return String(gl.getParameter(extension.UNMASKED_RENDERER_WEBGL));
return String(gl.getParameter(gl.RENDERER));
} catch (error) {
return '[renderer query failed: ' + text(error) + ']';
}
};
window.__renderProbeSnapshot = () => {
for (const gl of contexts) {
if (!gl.isContextLost()) drainGlErrors(gl, 'snapshot');
}
return {
documentToken,
title: document.title,
stats,
...(diagnostics ? {textShaderInventory} : {}),
visibleReloads: (visibleText().match(/\breload\b/gi) || []).length,
canvases: Array.from(document.querySelectorAll('canvas')).map(canvas => {
const gl = contexts.find(context => context.canvas === canvas);
return {
width: canvas.width,
height: canvas.height,
cssWidth: canvas.clientWidth,
cssHeight: canvas.clientHeight,
live: canvas.isConnected && canvas.width > 0 && canvas.height > 0 && canvas.clientWidth > 0 && canvas.clientHeight > 0,
hasWebgl: Boolean(gl),
contextLost: gl ? gl.isContextLost() : false,
renderer: gl ? rendererName(gl) : null,
};
}),
};
};
window.__renderProbeLoseContext = () => {
const candidates = contexts.filter(gl => gl.canvas && gl.canvas.isConnected && gl.canvas.width > 0 && gl.canvas.height > 0);
candidates.sort((a, b) => b.canvas.width * b.canvas.height - a.canvas.width * a.canvas.height);
const gl = candidates[0];
if (!gl) return {ok: false, error: 'No live app WebGL canvas'};
const extension = gl.getExtension('WEBGL_lose_context');
if (!extension) return {ok: false, error: 'WEBGL_lose_context is unavailable'};
extension.loseContext();
return {ok: true};
};
})();
`;
}
function locationInstrumentation() {
return String.raw`
(() => {
const watchers = new Map();
const pendingGets = [];
let nextWatchId = 1;
const calls = {
watchPosition: 0,
getCurrentPosition: 0,
clearWatch: 0,
clearedIds: [],
};
const position = fix => ({
coords: {
longitude: fix.longitude,
latitude: fix.latitude,
accuracy: fix.accuracy,
altitude: null,
altitudeAccuracy: null,
heading: null,
speed: null,
},
timestamp: fix.timestamp,
});
const geolocation = {
watchPosition(success, error, options) {
const id = nextWatchId++;
calls.watchPosition++;
watchers.set(id, {success, error, options});
return id;
},
getCurrentPosition(success, error, options) {
calls.getCurrentPosition++;
pendingGets.push({success, error, options});
},
clearWatch(id) {
calls.clearWatch++;
calls.clearedIds.push(id);
watchers.delete(id);
},
};
Object.defineProperty(navigator, 'geolocation', {
configurable: true,
enumerable: true,
value: geolocation,
});
window.__locationProbe = {
snapshot() {
return {
watchPosition: calls.watchPosition,
getCurrentPosition: calls.getCurrentPosition,
clearWatch: calls.clearWatch,
clearedIds: calls.clearedIds.slice(),
activeWatches: watchers.size,
pendingGets: pendingGets.length,
};
},
deny() {
const error = {code: 1, message: 'Permission denied by location consent probe'};
for (const watcher of Array.from(watchers.values())) {
if (typeof watcher.error === 'function') watcher.error(error);
}
for (const request of pendingGets.splice(0)) {
if (typeof request.error === 'function') request.error(error);
}
},
fix(fix) {
const value = position(fix);
for (const watcher of Array.from(watchers.values())) watcher.success(value);
for (const request of pendingGets.splice(0)) request.success(value);
},
};
})();
`;
}
function isLiveWebGlCanvas(canvas) {
return canvas.live && canvas.hasWebgl;
}
function assertPixelBudget(snapshot, phase) {
assert(snapshot.canvases.length > 0, `No canvas found during ${phase}`);
assert(
snapshot.canvases.every(canvas => canvas.width * canvas.height <= PIXEL_BUDGET),
`Canvas exceeded ${PIXEL_BUDGET} pixels during ${phase}`,
);
}
function assertNoRenderWorkAfterContextLoss(snapshot, phase) {
const baseline = snapshot.stats.contextLossAt;
assert(baseline, `Missing event-time WebGL context-loss counters during ${phase}`);
assert.equal(snapshot.stats.draws, baseline.draws, `Renderer submitted a draw after WebGL context loss during ${phase}`);
assert.equal(snapshot.stats.rafRequests, baseline.rafRequests, `Renderer requested an animation frame after WebGL context loss during ${phase}`);
assert.equal(snapshot.stats.rafCallbacks, baseline.rafCallbacks, `Renderer ran requestAnimationFrame after WebGL context loss during ${phase}`);
}
function resultSummary(snapshot, mode, fonts, diagnostics, extra = {}) {
const stats = snapshot.stats;
return {
status: 'PASS',
mode,
title: snapshot.title,
draws: stats.draws,
mapDraws: stats.mapDraws,
wallDraws: stats.wallDraws,
wallInstances: stats.wallInstances,
textDraws: stats.textDraws,
glyphInstances: stats.glyphInstances,
drawByMethod: stats.drawByMethod,
fonts,
renderers: [...new Set(snapshot.canvases.filter(canvas => canvas.hasWebgl).map(canvas => canvas.renderer))],
buffers: {
live: stats.liveBuffers,
highWater: stats.bufferHighWater,
deleted: stats.deletedBuffers,
maxBytes: stats.maxBufferBytes,
},
textures: {
live: stats.liveTextures,
highWater: stats.textureHighWater,
deleted: stats.deletedTextures,
maxPixels: stats.maxTexturePixels,
},
...(diagnostics ? {
diagnostics: {
textShaderAttaches: stats.textShaderAttaches,
textProgramLinks: stats.textProgramLinks,
textProgramUses: stats.textProgramUses,
textShaders: snapshot.textShaderInventory || [],
consoleWarnings: diagnostics.consoleWarnings,
memoryPressureMessages: diagnostics.memoryPressureMessages,
},
} : {}),
...extra,
};
}
async function run() {
const options = parseArguments(process.argv.slice(2));
const profile = mkdtempSync(join(tmpdir(), 'makepad-webgl-release-probe-'));
let child;
let socket;
let childStderr = '';
let pageSessionId;
let sequence = 0;
let mainFrameNavigations = 0;
const pending = new Map();
const runtimeErrors = [];
const rendererRejections = [];
const fetchedErrorReports = [];
const fontRequests = new Map();
const fonts = {successes: [], failures: []};
const consoleWarnings = [];
const memoryPressureMessages = [];
const closed = {value: false};
let locationCounts;
const rejectPending = error => {
for (const request of pending.values()) {
clearTimeout(request.timeout);
request.reject(error);
}
pending.clear();
};
const send = (method, params = {}, sessionId, timeoutMs = CDP_TIMEOUT_MS) => new Promise((resolve, reject) => {
if (!socket || socket.readyState !== WebSocket.OPEN) {
reject(new Error(`CDP socket is not open for ${method}`));
return;
}
const id = ++sequence;
const timeout = setTimeout(() => {
pending.delete(id);
reject(new Error(`CDP timeout: ${method}`));
}, timeoutMs);
pending.set(id, {resolve, reject, timeout});
socket.send(JSON.stringify({id, method, params, ...(sessionId ? {sessionId} : {})}));
});
const command = (method, params = {}) => send(method, params, pageSessionId);
const consoleText = args => args.map(value => value.value ?? value.unserializableValue ?? value.description ?? '').join(' ');
const captureMemoryPressure = value => {
if (!options.diagnostics || !/MapView: .*memory pressure|map memory \(/i.test(value)
|| memoryPressureMessages.length >= 32 || memoryPressureMessages.includes(value)) return;
memoryPressureMessages.push(value.slice(0, 1_000));
};
const captureFetchedReport = request => {
try {
const requestUrl = new URL(request.url);
let report;
if (requestUrl.pathname === '/api/crash') {
report = request.postData || '[crash report without captured body]';
} else if (requestUrl.pathname === '/$report_error' && requestUrl.searchParams.has('data')) {
report = requestUrl.searchParams.get('data');
}
if (report !== undefined && fetchedErrorReports.length < 32) {
fetchedErrorReports.push(String(report).slice(0, 4000));
}
} catch {
// A malformed request URL will be surfaced by the app if it matters.
}
};
const isHttpUrl = value => /^https?:\/\//i.test(String(value || ''));
const isFontResource = (type, url, mimeType = '') => type === 'Font'
|| /^font\//i.test(mimeType)
|| /\.(?:woff2?|ttf|otf)(?:$|[?#])/i.test(String(url || ''));
const boundedFontUrl = value => String(value || '').slice(0, 1_000);
const keepUniqueFont = (array, value) => {
if (array.length >= 32) return;
if (!array.some(existing => JSON.stringify(existing) === JSON.stringify(value))) array.push(value);
};
const captureFontResponse = params => {
const response = params.response || {};
if (!isHttpUrl(response.url) || !isFontResource(params.type, response.url, response.mimeType)) return;
fontRequests.set(params.requestId, response.url);
const value = {
url: boundedFontUrl(response.url),
status: response.status,
mimeType: String(response.mimeType || '').slice(0, 200),
};
if (response.status >= 200 && response.status < 400) keepUniqueFont(fonts.successes, value);
else keepUniqueFont(fonts.failures, value);
};
const captureFontFailure = params => {
const url = fontRequests.get(params.requestId);
if (!isHttpUrl(url) && params.type !== 'Font') return;
keepUniqueFont(fonts.failures, {
url: boundedFontUrl(url || '[font URL unavailable]'),
error: String(params.errorText || 'loading failed').slice(0, 500),
canceled: Boolean(params.canceled),
});
};
const evaluate = async expression => {
const response = await command('Runtime.evaluate', {expression, returnByValue: true, awaitPromise: true});
if (response.exceptionDetails) {
throw new Error(response.exceptionDetails.exception?.description || response.exceptionDetails.text);
}
return response.result.value;
};
const snapshot = async () => {
const value = await evaluate('window.__renderProbeSnapshot ? window.__renderProbeSnapshot() : null');
assert(value, 'Probe instrumentation is unavailable in the app document');
return value;
};
const locationSnapshot = async () => {
const value = await evaluate('window.__locationProbe ? window.__locationProbe.snapshot() : null');
assert(value, 'Location probe instrumentation is unavailable in the app document');
return value;
};
const assertLocationCounts = (current, phase, watches, clears, activeWatches) => {
assert.deepEqual({
watches: current.watchPosition,
currentRequests: current.getCurrentPosition,
clears: current.clearWatch,
activeWatches: current.activeWatches,
pendingCurrentRequests: current.pendingGets,
}, {
watches,
currentRequests: 0,
clears,
activeWatches,
pendingCurrentRequests: 0,
}, `Unexpected geolocation calls during ${phase}`);
};
const waitForLocation = async (predicate, phase) => {
const deadline = Date.now() + LOCATION_ACTION_TIMEOUT_MS;
let current;
while (Date.now() < deadline) {
current = await locationSnapshot();
if (predicate(current)) return current;
await sleep(100);
}
assert.fail(`Timed out waiting for geolocation state during ${phase}: ${JSON.stringify(current)}`);
};
const expectLocation = async (phase, watches, clears, activeWatches, {wait = false, settle = 0} = {}) => {
if (settle) await sleep(settle);
const current = wait
? await waitForLocation(value => value.watchPosition >= watches && value.clearWatch >= clears, phase)
: await locationSnapshot();
assertLocationCounts(current, phase, watches, clears, activeWatches);
return current;
};
const clickLocationControl = async () => {
const point = await evaluate(`(() => {
const canvases = Array.from(document.querySelectorAll('canvas'))
.filter(canvas => canvas.isConnected && canvas.clientWidth > 0 && canvas.clientHeight > 0)
.sort((a, b) => b.clientWidth * b.clientHeight - a.clientWidth * a.clientHeight);
const canvas = canvases[0];
if (!canvas) return null;
const localX = 165;
const localY = canvas.clientHeight - 36;
if (localX < 0 || localX >= canvas.clientWidth || localY < 0 || localY >= canvas.clientHeight) return null;
const rect = canvas.getBoundingClientRect();
return {
x: rect.left + localX * rect.width / canvas.clientWidth,
y: rect.top + localY * rect.height / canvas.clientHeight,
};
})()`);
assert(point, 'Known location-control coordinate is outside the live canvas');
await command('Input.dispatchMouseEvent', {type: 'mouseMoved', x: point.x, y: point.y, button: 'none', buttons: 0});
await command('Input.dispatchMouseEvent', {type: 'mousePressed', x: point.x, y: point.y, button: 'left', buttons: 1, clickCount: 1});
await command('Input.dispatchMouseEvent', {type: 'mouseReleased', x: point.x, y: point.y, button: 'left', buttons: 0, clickCount: 1});
};
const allErrors = current => [
...runtimeErrors,
...rendererRejections,
...fetchedErrorReports.map(report => `fetch error report: ${report}`),
...(current?.stats?.uncaught || []),
];
const assertClean = (current, expectedContextLosses = 0) => {
const errors = allErrors(current);
assert.equal(errors.length, 0, errors.join('\n').slice(0, 12_000));
assert.equal(current.stats.invalidDraws, 0, 'Invalid WebGL draw arguments observed');
assert.deepEqual(current.stats.glErrors, [], 'WebGL errors observed');
assert.equal(current.stats.contextLosses, expectedContextLosses, 'Unexpected WebGL context loss count');
assert.equal(current.stats.contextRestores, 0, 'Unexpected WebGL context restoration');
};
try {
child = spawn(CHROME, [
'--headless=new',
'--use-gl=angle',
'--use-angle=swiftshader',
'--enable-unsafe-swiftshader',
'--disable-background-networking',
'--disable-component-update',
'--disable-sync',
'--disable-extensions',
'--disable-default-apps',
'--no-first-run',
'--no-default-browser-check',
'--remote-debugging-port=0',
`--user-data-dir=${profile}`,
'about:blank',
], {stdio: ['ignore', 'ignore', 'pipe']});
child.once('close', () => { closed.value = true; });
child.stderr.on('data', data => {
childStderr = (childStderr + data).slice(-12_000);
});
const endpoint = await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error(`Chrome launch timeout\n${childStderr}`)), 20_000);
const finish = callback => value => {
clearTimeout(timeout);
callback(value);
};
child.once('error', finish(reject));
child.once('exit', finish(code => reject(new Error(`Chrome exited before CDP was ready: ${code}\n${childStderr}`))));
child.stderr.on('data', () => {
const match = childStderr.match(/DevTools listening on (ws:\/\/[^\s]+)/);
if (match) finish(resolve)(match[1]);
});
});
socket = new WebSocket(endpoint);
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('CDP WebSocket connection timeout')), 10_000);
socket.onopen = () => { clearTimeout(timeout); resolve(); };
socket.onerror = () => { clearTimeout(timeout); reject(new Error('CDP WebSocket connection failed')); };
});
socket.onclose = () => rejectPending(new Error('CDP WebSocket closed'));
socket.onmessage = event => {
let message;
try {
message = JSON.parse(event.data);
} catch (error) {
runtimeErrors.push(`Invalid CDP message: ${error}`);
return;
}
if (message.id) {
const request = pending.get(message.id);
if (!request) return;
pending.delete(message.id);
clearTimeout(request.timeout);
if (message.error) request.reject(new Error(JSON.stringify(message.error)));
else request.resolve(message.result);
return;
}
if (message.sessionId !== pageSessionId) return;
if (message.method === 'Runtime.exceptionThrown') {
runtimeErrors.push(message.params.exceptionDetails.exception?.description || message.params.exceptionDetails.text);
} else if (message.method === 'Runtime.consoleAPICalled') {
const value = consoleText(message.params.args);
if (RENDERER_REJECTION.test(value)) rendererRejections.push(value);
captureMemoryPressure(value);
if (options.diagnostics && /^(?:warning|error)$/.test(message.params.type) && consoleWarnings.length < 16) {
consoleWarnings.push(value.slice(0, 1_000));
}
} else if (message.method === 'Log.entryAdded') {
const value = message.params.entry.text || '';
if (RENDERER_REJECTION.test(value)) rendererRejections.push(value);
captureMemoryPressure(value);
if (options.diagnostics && /^(?:warning|error)$/.test(message.params.entry.level) && consoleWarnings.length < 16) {
consoleWarnings.push(value.slice(0, 1_000));
}
} else if (message.method === 'Network.requestWillBeSent') {
captureFetchedReport(message.params.request);
if (isHttpUrl(message.params.request.url)
&& isFontResource(message.params.type, message.params.request.url)) {
fontRequests.set(message.params.requestId, message.params.request.url);
}
} else if (message.method === 'Network.responseReceived') {
captureFontResponse(message.params);
} else if (message.method === 'Network.loadingFailed') {
captureFontFailure(message.params);
} else if (message.method === 'Page.frameNavigated' && !message.params.frame.parentId) {
mainFrameNavigations++;
}
};
const targets = await send('Target.getTargets');
const target = targets.targetInfos.find(info => info.type === 'page' && info.url === 'about:blank')
|| targets.targetInfos.find(info => info.type === 'page');
assert(target, 'Chrome did not expose an owned page target');
({sessionId: pageSessionId} = await send('Target.attachToTarget', {targetId: target.targetId, flatten: true}));
await Promise.all([
command('Runtime.enable'),
command('Page.enable'),
command('Network.enable'),
command('Log.enable'),
]);
await command('Page.addScriptToEvaluateOnNewDocument', {source: pageInstrumentation(options.diagnostics)});
if (options.location) {
await command('Page.addScriptToEvaluateOnNewDocument', {source: locationInstrumentation()});
}
await command('Emulation.setDeviceMetricsOverride', {width: 900, height: 640, deviceScaleFactor: 3, mobile: false});
await command('Page.navigate', {url: options.url});
const startupDeadline = Date.now() + STARTUP_TIMEOUT_MS;
let initial;
while (Date.now() < startupDeadline) {
await sleep(500);
try {
initial = await snapshot();
} catch (error) {
if (Date.now() >= startupDeadline) throw error;
continue;
}
// Cached idle maps legitimately render nine setup draws and then stop.
const drewEnough = initial.stats.draws > 0
&& (!options.map || initial.stats.mapDraws > 0)
&& (!options.buildings || (initial.stats.wallDraws > 0 && initial.stats.wallInstances > 0))
&& (!options.text || (initial.stats.textDraws > 0 && initial.stats.glyphInstances > 0));
if (drewEnough && initial.canvases.some(isLiveWebGlCanvas)) break;
if (allErrors(initial).length > 0) break;
}
if (options.diagnostics && initial
&& (allErrors(initial).length > 0
|| !initial.canvases.some(isLiveWebGlCanvas)
|| (options.buildings && !(initial.stats.wallDraws > 0 && initial.stats.wallInstances > 0))
|| (options.text && !(initial.stats.textDraws > 0 && initial.stats.glyphInstances > 0)))) {
console.error(JSON.stringify({
status: 'DIAGNOSTICS',
textDraws: initial.stats.textDraws,
glyphInstances: initial.stats.glyphInstances,
textShaderAttaches: initial.stats.textShaderAttaches,
textProgramLinks: initial.stats.textProgramLinks,
textProgramUses: initial.stats.textProgramUses,
textShaders: initial.textShaderInventory || [],
wallDraws: initial.stats.wallDraws,
wallInstances: initial.stats.wallInstances,
consoleWarnings,
memoryPressureMessages,
fonts,
}));
}
assert(initial, 'No instrumented app document became available within 45 seconds');
assertClean(initial);
assert(initial.canvases.some(isLiveWebGlCanvas), 'No live WebGL canvas became available within 45 seconds');
if (options.map) {
assert(initial.stats.mapDraws > 0, 'No map shader draws observed within 45 seconds');
}
if (options.buildings) {
assert(initial.stats.wallDraws > 0, 'No building wall draws observed within 45 seconds');
assert(initial.stats.wallInstances > 0, 'No positive-count building wall instances observed within 45 seconds');
}
if (options.text) {
assert(initial.stats.textDraws > 0, 'No Makepad text shader draws observed within 45 seconds');
assert(initial.stats.glyphInstances > 0, 'No positive-count glyph instances observed within 45 seconds');
}
assert(initial.stats.draws > 0, 'No WebGL draw calls observed within 45 seconds');
assertPixelBudget(initial, 'retina startup');
if (options.location) {
locationCounts = await expectLocation('startup', 0, 0, 0);
}
let wallInstancesBeforeResize = 0;
if (options.buildings) {
await sleep(BUILDING_SETTLE_MS);
const settled = await snapshot();
assertClean(settled);
wallInstancesBeforeResize = settled.stats.wallInstances;
}
await command('Emulation.setDeviceMetricsOverride', {width: 1700, height: 1000, deviceScaleFactor: 3, mobile: false});
await sleep(2_500);
const resized = await snapshot();
assertClean(resized);
assert(resized.canvases.some(isLiveWebGlCanvas), 'WebGL canvas was no longer live after resize');
assertPixelBudget(resized, 'large retina resize');
if (options.buildings) {
assert(resized.stats.wallInstances > wallInstancesBeforeResize,
`No new positive-count building wall instances after settlement/resize: ${JSON.stringify({before: wallInstancesBeforeResize, after: resized.stats.wallInstances, memoryPressureMessages})}`);
}
if (options.location) {
locationCounts = await expectLocation('resize before consent', 0, 0, 0);
await clickLocationControl();
locationCounts = await expectLocation('first explicit click', 1, 0, 1, {wait: true});
await clickLocationControl();
locationCounts = await expectLocation('pending second click', 1, 0, 1, {settle: 500});
await evaluate('window.__locationProbe.deny()');
locationCounts = await expectLocation('permission denial cleanup', 1, 1, 0, {wait: true});
assert.deepEqual(locationCounts.clearedIds, [1], 'Permission denial cleared an unexpected watch');
await clickLocationControl();
locationCounts = await expectLocation('explicit retry', 2, 1, 1, {wait: true});
await evaluate(`window.__locationProbe.fix({
longitude: 4.8952,
latitude: 52.3702,
accuracy: 25,
timestamp: Date.now(),
})`);
locationCounts = await expectLocation('synthetic fix', 2, 1, 1, {settle: 500});
await clickLocationControl();
locationCounts = await expectLocation('explicit recenter', 2, 1, 1, {settle: 500});
assertClean(await snapshot());
}
const baseMode = `${options.map ? 'map' : 'generic'}${options.buildings ? '+buildings' : ''}${options.text ? '+text' : ''}`;
const diagnostics = options.diagnostics ? {consoleWarnings, memoryPressureMessages} : null;
console.log(JSON.stringify(resultSummary(resized, baseMode, fonts, diagnostics, options.buildings ? {
redrawWallInstances: resized.stats.wallInstances - wallInstancesBeforeResize,
buildingSettleMs: BUILDING_SETTLE_MS,
} : {})));
if (options.loseContext) {
const errorsBeforeLoss = {
runtime: runtimeErrors.length,
renderer: rendererRejections.length,
fetched: fetchedErrorReports.length,
uncaught: resized.stats.uncaught.length,
};
const navigationAtLoss = mainFrameNavigations;
const tokenAtLoss = resized.documentToken;
const loss = await evaluate('window.__renderProbeLoseContext()');
assert(loss?.ok, loss?.error || 'Failed to request WebGL context loss');
const warningDeadline = Date.now() + LOSS_WARNING_TIMEOUT_MS;
let terminal;
while (Date.now() < warningDeadline) {
await sleep(100);
try {
terminal = await snapshot();
} catch {
continue;
}
if (terminal.stats.contextLosses === 1 && terminal.visibleReloads === 1) break;
}
assert(terminal, 'App document disappeared after WebGL context loss');
assert.equal(terminal.documentToken, tokenAtLoss, 'App automatically reloaded after WebGL context loss');
assert.equal(mainFrameNavigations, navigationAtLoss, 'App navigated after WebGL context loss');
assert.equal(terminal.stats.contextLosses, 1, 'Expected exactly one WebGL context loss event');
assert.equal(terminal.stats.contextRestores, 0, 'WebGL context restored after terminal loss');
assert.equal(terminal.visibleReloads, 1, 'Expected exactly one visible terminal Reload warning');
assertNoRenderWorkAfterContextLoss(terminal, 'terminal warning');
await sleep(LOSS_QUIET_MS);
const quiet = await snapshot();
assert.equal(quiet.documentToken, tokenAtLoss, 'App automatically reloaded during the context-loss quiet interval');
assert.equal(mainFrameNavigations, navigationAtLoss, 'App navigated during the context-loss quiet interval');
assert.equal(quiet.stats.contextLosses, 1, 'Context-loss event repeated during the quiet interval');
assert.equal(quiet.stats.contextRestores, 0, 'WebGL context restored during the quiet interval');
assert.equal(quiet.visibleReloads, 1, 'Terminal Reload warning changed during the quiet interval');
assertNoRenderWorkAfterContextLoss(quiet, 'quiet interval');
assert.equal(runtimeErrors.length, errorsBeforeLoss.runtime, 'Uncaught exception followed intentional context loss');
assert.equal(rendererRejections.length, errorsBeforeLoss.renderer, 'Renderer rejection followed intentional context loss: ' + rendererRejections.slice(errorsBeforeLoss.renderer).join('\n'));
assert.equal(fetchedErrorReports.length, errorsBeforeLoss.fetched, 'Error report followed intentional context loss');
assert.equal(quiet.stats.uncaught.length, errorsBeforeLoss.uncaught, 'Uncaught page error followed intentional context loss');
if (options.location) {
locationCounts = await expectLocation('terminal context-loss cleanup', 2, 2, 0, {wait: true});
assert.deepEqual(locationCounts.clearedIds, [1, 2], 'Terminal cleanup cleared unexpected watches');
}
console.log(JSON.stringify(resultSummary(quiet, `${baseMode}+lose-context`, fonts, diagnostics, {
contextLosses: quiet.stats.contextLosses,
visibleReloads: quiet.visibleReloads,
quietMs: LOSS_QUIET_MS,
})));
}
if (options.location) {
console.log(JSON.stringify({
status: 'PASS',
mode: 'consent',
watches: locationCounts.watchPosition,
currentRequests: locationCounts.getCurrentPosition,
clears: locationCounts.clearWatch,
activeWatches: locationCounts.activeWatches,
}));
}
} finally {
if (socket?.readyState === WebSocket.OPEN) {
await send('Browser.close', {}, undefined, 3_000).catch(() => {});
}
if (socket && socket.readyState < WebSocket.CLOSING) socket.close();
rejectPending(new Error('Probe cleanup'));
let exited = !child || await waitForChildExit(child, closed, 3_000);
if (!exited && child.pid) {
child.kill('SIGTERM');
exited = await waitForChildExit(child, closed, 3_000);
}
if (!exited && child.pid) {
child.kill('SIGKILL');
exited = await waitForChildExit(child, closed, 3_000);
}
if (exited) {
rmSync(profile, {recursive: true, force: true});
} else {
throw new Error(`Owned Chrome process ${child.pid} did not exit; temporary profile retained at ${profile}`);
}
}
}
let exitCode = 0;
try {
await run();
} catch (error) {
exitCode = 1;
console.error(error?.stack || String(error));
}
process.exit(exitCode); // Node's CDP WebSocket can otherwise retain an idle event-loop handle.