1131 lines
51 KiB
JavaScript
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.
|