experiments in a post-browser web
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}