makepad/tools/webgl_release_probe.mjs

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.