experiments in a post-browser web
at main 353 lines 13 kB view raw
1#!/usr/bin/env electron 2/** 3 * Debug Electron pages by capturing console output, errors, and evaluating JS. 4 * 5 * Usage: 6 * npx electron scripts/debug-electron-page.mjs [options] 7 * 8 * Options: 9 * --url <url> URL to load (required unless --extension provides default) 10 * --extension <path> Path to Chrome extension to load 11 * --eval <js> JS expression to evaluate after load (repeatable) 12 * --timeout <ms> Time to wait before exit (default: 5000) 13 * --session <partition> Session partition name 14 * --wait-sw <ms> Extra wait for service worker startup (default: 2000) 15 * --preload <path> Path to preload script 16 * --show Show the BrowserWindow (default: hidden) 17 * 18 * Output format: 19 * [CONSOLE:error] Console errors with source location 20 * [CONSOLE:warning] Console warnings 21 * [CONSOLE:log] Console log messages 22 * [CONSOLE:debug] Console debug messages 23 * [PAGE:url] Final page URL after load 24 * [PAGE:title] Page title 25 * [PAGE:crash] Renderer process crash info 26 * [PAGE:fail] Page load failure 27 * [SW:running] Service worker status 28 * [SW:stopped] No service workers detected 29 * [SW:console] Service worker console output 30 * [EXT:loaded] Extension loaded successfully 31 * [EXT:error] Extension load failure 32 * [EVAL] Result of --eval expression 33 * [EVAL:error] Error from --eval expression 34 * [ERROR] Uncaught exception / unhandled rejection 35 * [EXIT] Script exit info 36 */ 37 38import { app, BrowserWindow, session, ipcMain } from 'electron'; 39import path from 'node:path'; 40import { fileURLToPath } from 'node:url'; 41import { parseArgs } from 'node:util'; 42 43// --------------------------------------------------------------------------- 44// Argument parsing 45// --------------------------------------------------------------------------- 46 47const { values: args } = parseArgs({ 48 options: { 49 url: { type: 'string' }, 50 extension: { type: 'string' }, 51 eval: { type: 'string', multiple: true }, 52 timeout: { type: 'string', default: '5000' }, 53 session: { type: 'string' }, 54 'wait-sw': { type: 'string', default: '2000' }, 55 preload: { type: 'string' }, 56 polyfills: { type: 'boolean', default: false }, 57 show: { type: 'boolean', default: false }, 58 }, 59 strict: false, 60 allowPositionals: true, 61}); 62 63const TIMEOUT = parseInt(args.timeout || '5000', 10); 64const WAIT_SW = parseInt(args['wait-sw'] || '2000', 10); 65const EVALS = args.eval || []; 66const SHOW = args.show || false; 67 68// --------------------------------------------------------------------------- 69// Helpers 70// --------------------------------------------------------------------------- 71 72// Electron console-message numeric levels: 0=verbose/debug, 1=info/log, 2=warning, 3=error 73const LEVEL_NAMES_NUMERIC = ['debug', 'log', 'warning', 'error']; 74 75function levelName(level) { 76 if (typeof level === 'number') { 77 return LEVEL_NAMES_NUMERIC[level] || `level${level}`; 78 } 79 // Newer Electron passes string level names; normalize 'info' -> 'log' 80 const s = String(level); 81 return s === 'info' ? 'log' : s; 82} 83 84function emit(tag, message) { 85 const ts = new Date().toISOString().slice(11, 23); 86 console.log(`[${tag}] ${message} (${ts})`); 87} 88 89function emitConsole(level, message, line, sourceId) { 90 const name = levelName(level); 91 const loc = sourceId ? ` (${path.basename(sourceId)}${line != null ? ':' + line : ''})` : ''; 92 emit(`CONSOLE:${name}`, `${message}${loc}`); 93} 94 95// --------------------------------------------------------------------------- 96// Main 97// --------------------------------------------------------------------------- 98 99app.whenReady().then(async () => { 100 try { 101 await run(); 102 } catch (err) { 103 emit('ERROR', `Fatal: ${err.message}`); 104 app.exit(1); 105 } 106}); 107 108async function run() { 109 // Determine session 110 const ses = args.session 111 ? session.fromPartition(args.session) 112 : session.defaultSession; 113 114 // ------------------------------------------------------------------------- 115 // Load extension if requested 116 // ------------------------------------------------------------------------- 117 let extensionId = null; 118 119 if (args.extension) { 120 const extPath = path.resolve(args.extension); 121 try { 122 // Use newer API if available (Electron 33+), fall back to deprecated one 123 const loader = ses.extensions || ses; 124 const ext = await loader.loadExtension(extPath, { 125 allowFileAccess: true, 126 }); 127 extensionId = ext.id; 128 emit('EXT:loaded', `id=${ext.id} name="${ext.name}" path=${extPath}`); 129 } catch (err) { 130 emit('EXT:error', `Failed to load extension: ${err.message}`); 131 // Continue anyway — user may want to debug why it fails 132 } 133 } 134 135 // ------------------------------------------------------------------------- 136 // Register Chrome API polyfills if --polyfills flag is set 137 // ------------------------------------------------------------------------- 138 let polyfillScript = null; 139 140 if (args.polyfills) { 141 try { 142 const __filename = fileURLToPath(import.meta.url); 143 const __dirname = path.dirname(__filename); 144 const polyfillsPath = path.resolve(__dirname, '..', 'backend', 'electron', 'chrome-api-polyfills', 'index.js'); 145 const polyfills = await import(polyfillsPath); 146 const handle = polyfills.registerAll(ses, { ipcMain, app }); 147 polyfillScript = handle.getPreloadScript(); 148 emit('POLYFILL', `Registered chrome API polyfills`); 149 150 // Inject polyfill into all chrome-extension:// webContents on dom-ready 151 app.on('web-contents-created', (_event, webContents) => { 152 webContents.on('dom-ready', () => { 153 const url = webContents.getURL(); 154 if (url.startsWith('chrome-extension://') && polyfillScript) { 155 webContents.executeJavaScript(polyfillScript).catch(() => {}); 156 } 157 }); 158 }); 159 } catch (err) { 160 emit('POLYFILL:error', `Failed to register polyfills: ${err.message}`); 161 } 162 } 163 164 // ------------------------------------------------------------------------- 165 // Wait for service worker (extensions often need a moment) 166 // ------------------------------------------------------------------------- 167 if (extensionId) { 168 emit('SW:waiting', `Waiting ${WAIT_SW}ms for service worker startup...`); 169 await new Promise(r => setTimeout(r, WAIT_SW)); 170 171 // Capture service worker console output 172 ses.serviceWorkers.on('console-message', (event, details) => { 173 emit('SW:console', `[${levelName(details.level)}] ${details.message}`); 174 }); 175 176 // Report SW status 177 const workers = ses.serviceWorkers.getAllRunning(); 178 const workerEntries = Object.entries(workers); 179 if (workerEntries.length === 0) { 180 emit('SW:stopped', 'No service workers running'); 181 } else { 182 for (const [versionId, info] of workerEntries) { 183 emit('SW:running', `versionId=${versionId} scope=${info.scope}`); 184 } 185 } 186 } 187 188 // ------------------------------------------------------------------------- 189 // Resolve URL 190 // ------------------------------------------------------------------------- 191 let targetUrl = args.url; 192 193 // Replace EXTENSION_ID placeholder 194 if (targetUrl && extensionId) { 195 targetUrl = targetUrl.replace(/EXTENSION_ID/g, extensionId); 196 } 197 198 if (!targetUrl) { 199 if (extensionId) { 200 // Default to extension's popup or settings page 201 targetUrl = `chrome-extension://${extensionId}/popup.html`; 202 emit('PAGE:url', `No --url specified, defaulting to ${targetUrl}`); 203 } else { 204 emit('ERROR', 'No --url specified and no extension loaded'); 205 app.exit(1); 206 return; 207 } 208 } 209 210 // ------------------------------------------------------------------------- 211 // Create BrowserWindow 212 // ------------------------------------------------------------------------- 213 const winOptions = { 214 width: 1280, 215 height: 900, 216 show: SHOW, 217 webPreferences: { 218 session: ses, 219 contextIsolation: true, 220 nodeIntegration: false, 221 }, 222 }; 223 224 if (args.preload) { 225 winOptions.webPreferences.preload = path.resolve(args.preload); 226 } 227 228 const win = new BrowserWindow(winOptions); 229 const wc = win.webContents; 230 231 // ------------------------------------------------------------------------- 232 // Wire up event listeners 233 // ------------------------------------------------------------------------- 234 235 // Console messages (use new Event object API, Electron 33+) 236 wc.on('console-message', (event) => { 237 const { level, message, line, sourceId } = event; 238 emitConsole(level, message, line, sourceId); 239 }); 240 241 // Renderer crash 242 wc.on('render-process-gone', (event, details) => { 243 emit('PAGE:crash', `reason=${details.reason} exitCode=${details.exitCode}`); 244 }); 245 246 // Page load failure 247 wc.on('did-fail-load', (event, errorCode, errorDescription, validatedURL) => { 248 emit('PAGE:fail', `code=${errorCode} desc="${errorDescription}" url=${validatedURL}`); 249 }); 250 251 // Certificate errors (ignore for debugging) 252 wc.on('certificate-error', (event, url, error, certificate, callback) => { 253 event.preventDefault(); 254 callback(true); 255 }); 256 257 // ------------------------------------------------------------------------- 258 // Load the URL 259 // ------------------------------------------------------------------------- 260 emit('PAGE:loading', targetUrl); 261 262 try { 263 await wc.loadURL(targetUrl); 264 } catch (err) { 265 emit('PAGE:fail', `loadURL error: ${err.message}`); 266 } 267 268 // Report final page state 269 emit('PAGE:url', wc.getURL()); 270 emit('PAGE:title', wc.getTitle()); 271 272 // ------------------------------------------------------------------------- 273 // Run --eval expressions 274 // ------------------------------------------------------------------------- 275 for (const expr of EVALS) { 276 try { 277 const result = await wc.executeJavaScript(` 278 (function() { 279 try { 280 var __result = (${expr}); 281 if (__result instanceof Promise) { 282 return __result.then(function(v) { 283 return { ok: true, value: String(v), type: typeof v }; 284 }).catch(function(e) { 285 return { ok: false, error: String(e) }; 286 }); 287 } 288 return { ok: true, value: String(__result), type: typeof __result }; 289 } catch(e) { 290 return { ok: false, error: String(e) }; 291 } 292 })() 293 `); 294 if (result.ok) { 295 emit('EVAL', `${expr} = ${result.value} (${result.type})`); 296 } else { 297 emit('EVAL:error', `${expr} => ${result.error}`); 298 } 299 } catch (err) { 300 emit('EVAL:error', `${expr} => executeJavaScript failed: ${err.message}`); 301 } 302 } 303 304 // ------------------------------------------------------------------------- 305 // Collect DOM snapshot info 306 // ------------------------------------------------------------------------- 307 try { 308 const domInfo = await wc.executeJavaScript(` 309 (function() { 310 var info = {}; 311 info.doctype = document.doctype ? document.doctype.name : 'none'; 312 info.bodyChildCount = document.body ? document.body.childElementCount : 0; 313 info.bodyTextLength = document.body ? document.body.innerText.length : 0; 314 info.title = document.title; 315 var root = document.querySelector('#root') || document.querySelector('#app'); 316 if (root) { 317 info.rootId = root.id; 318 info.rootChildCount = root.childElementCount; 319 info.rootInnerHTMLLength = root.innerHTML.length; 320 } 321 info.scripts = document.querySelectorAll('script').length; 322 info.stylesheets = document.querySelectorAll('link[rel="stylesheet"]').length; 323 return info; 324 })() 325 `); 326 emit('DOM', `doctype=${domInfo.doctype} bodyChildren=${domInfo.bodyChildCount} bodyText=${domInfo.bodyTextLength}chars scripts=${domInfo.scripts} stylesheets=${domInfo.stylesheets}`); 327 if (domInfo.rootId) { 328 emit('DOM', `#${domInfo.rootId} children=${domInfo.rootChildCount} innerHTML=${domInfo.rootInnerHTMLLength}chars`); 329 } 330 } catch (err) { 331 emit('DOM:error', `DOM inspection failed: ${err.message}`); 332 } 333 334 // ------------------------------------------------------------------------- 335 // Wait for remaining console output, then exit 336 // ------------------------------------------------------------------------- 337 const remaining = Math.max(1000, TIMEOUT - WAIT_SW); 338 emit('EXIT', `Listening for ${remaining}ms more before exit...`); 339 340 await new Promise(r => setTimeout(r, remaining)); 341 342 // Final service worker check 343 if (extensionId) { 344 const workers = ses.serviceWorkers.getAllRunning(); 345 const workerEntries = Object.entries(workers); 346 emit('SW:final', workerEntries.length > 0 347 ? `${workerEntries.length} service worker(s) running` 348 : 'No service workers running'); 349 } 350 351 emit('EXIT', `Clean exit after ${TIMEOUT}ms`); 352 app.exit(0); 353}