diff --git a/apps/desktop/electron/renderer-log.test.ts b/apps/desktop/electron/renderer-log.test.ts index 5140020227..f120e56fbb 100644 --- a/apps/desktop/electron/renderer-log.test.ts +++ b/apps/desktop/electron/renderer-log.test.ts @@ -2,51 +2,85 @@ import { describe, expect, it, vi } from 'vitest' import { attachRendererConsoleCapture, formatRendererBoundaryReport, formatRendererConsoleLine } from './renderer-log' +type ConsoleMessageEvent = { + level?: unknown + message?: unknown + sourceId?: unknown + lineNumber?: unknown +} + +type ConsoleMessageHandler = (event: ConsoleMessageEvent) => void + +function createWindowHarness() { + let handler: ConsoleMessageHandler | undefined + + const win = { + webContents: { + on: (_event: 'console-message', listener: ConsoleMessageHandler) => { + handler = listener + } + } + } + + return { win, getHandler: () => handler } +} + describe('formatRendererConsoleLine', () => { - it('formats the canonical Electron 36+ details shape at error level', () => { + it('formats the canonical Electron console-message event at error level', () => { const line = formatRendererConsoleLine('hud', { - level: 3, + level: 'error', message: 'Minified React error #310', - sourceUrl: 'file:///app/index.js', + sourceId: 'file:///app/index.js', lineNumber: 13 }) expect(line).toBe('[renderer console:hud] Minified React error #310 (file:///app/index.js:13)') }) - it('formats the deprecated positional shape at error level', () => { - const line = formatRendererConsoleLine('main', 3, 'boom', 7, 'file:///app/vendor.js') - - expect(line).toBe('[renderer console:main] boom (file:///app/vendor.js:7)') + it('drops a canonical non-error string level', () => { + expect( + formatRendererConsoleLine('main', { level: 'info', message: 'x', sourceId: 's', lineNumber: 1 }) + ).toBeNull() }) - it('drops non-error levels in both shapes', () => { - expect(formatRendererConsoleLine('main', { level: 1, message: 'x', sourceUrl: 's', lineNumber: 1 })).toBeNull() - expect(formatRendererConsoleLine('main', 2, 'warn', 1, 's')).toBeNull() + it('drops malformed event objects', () => { + expect(formatRendererConsoleLine('main', {})).toBeNull() }) }) describe('attachRendererConsoleCapture', () => { - it('logs error-level messages and skips the rest', () => { + it('registers a single-argument listener, logs errors, and skips the rest', () => { const log = vi.fn() - let handler: ((...args: unknown[]) => void) | undefined + const capture = createWindowHarness() - const win = { - webContents: { - on: (_event: string, listener: (...args: unknown[]) => void) => { - handler = listener - } - } - } + attachRendererConsoleCapture(capture.win, 'quick-entry', log) - attachRendererConsoleCapture(win, 'quick-entry', log) + const handler = capture.getHandler() + expect(handler).toHaveLength(1) - handler?.({}, { level: 3, message: 'crash', sourceUrl: 'src', lineNumber: 2 }) - handler?.({}, { level: 0, message: 'debug', sourceUrl: 'src', lineNumber: 3 }) + handler?.({ level: 'error', message: 'crash', sourceId: 'src', lineNumber: 2 }) + handler?.({ level: 'debug', message: 'debug', sourceId: 'src', lineNumber: 3 }) expect(log).toHaveBeenCalledTimes(1) expect(log).toHaveBeenCalledWith('[renderer console:quick-entry] crash (src:2)') }) + + it('reports signature drift once across renderer windows', () => { + const log = vi.fn() + const firstCapture = createWindowHarness() + const secondCapture = createWindowHarness() + + attachRendererConsoleCapture(firstCapture.win, 'main', log) + attachRendererConsoleCapture(secondCapture.win, 'hud', log) + + firstCapture.getHandler()?.({}) + secondCapture.getHandler()?.({}) + + expect(log).toHaveBeenCalledTimes(1) + expect(log).toHaveBeenCalledWith( + '[renderer console] Electron console-message signature drift detected; renderer errors may not be captured' + ) + }) }) describe('formatRendererBoundaryReport', () => { diff --git a/apps/desktop/electron/renderer-log.ts b/apps/desktop/electron/renderer-log.ts index d3a97c6daa..8ac948e15a 100644 --- a/apps/desktop/electron/renderer-log.ts +++ b/apps/desktop/electron/renderer-log.ts @@ -15,53 +15,75 @@ * tokens or PII we never want on disk. */ +type ConsoleMessageLevel = 'info' | 'warning' | 'error' | 'debug' + +interface ConsoleMessageEventLike { + level?: unknown + message?: unknown + sourceId?: unknown + lineNumber?: unknown +} + interface ConsoleMessageDetails { - level: number + level: ConsoleMessageLevel message: string - sourceUrl: string + sourceId: string lineNumber: number } interface WebContentsLike { - on(event: 'console-message', listener: (...args: unknown[]) => void): unknown + on(event: 'console-message', listener: (event: ConsoleMessageEventLike) => void): unknown } interface WindowLike { webContents: WebContentsLike } -/** Normalize Electron's two `console-message` signatures into one line, or - * null for non-error levels. Canonical (Electron 36+): `(event, details)`; - * deprecated positional: `(event, level, message, line, sourceId)`. - * `level` is numeric 0..3, where 3 === error. */ -export function formatRendererConsoleLine( - label: string, - detailsOrLevel: unknown, - message?: unknown, - line?: unknown, - sourceId?: unknown -): string | null { - const details = - detailsOrLevel && typeof detailsOrLevel === 'object' ? (detailsOrLevel as ConsoleMessageDetails) : null +let didReportConsoleMessageSignatureDrift = false - const level = details ? details.level : detailsOrLevel +function isConsoleMessageDetails(value: unknown): value is ConsoleMessageDetails { + if (typeof value !== 'object' || value === null) { + return false + } - if (level !== 3) { + const event = value as ConsoleMessageEventLike + const isKnownLevel = + event.level === 'info' || event.level === 'warning' || event.level === 'error' || event.level === 'debug' + + return ( + isKnownLevel && + typeof event.message === 'string' && + typeof event.sourceId === 'string' && + typeof event.lineNumber === 'number' + ) +} + +/** Format Electron's canonical console-message event object into one line, or + * null for non-error or malformed events. Hermes's pinned Electron 40.x line + * puts severity and source metadata on the event object itself; accepting one + * listener argument also avoids Electron's deprecated positional + * `(event, level, message, line, sourceId)` path. */ +export function formatRendererConsoleLine(label: string, details: ConsoleMessageEventLike): string | null { + if (!isConsoleMessageDetails(details) || details.level !== 'error') { return null } - const text = details ? details.message : message - const src = details ? details.sourceUrl : sourceId - const lineNo = details ? details.lineNumber : line - - return `[renderer console:${label}] ${String(text)} (${String(src)}:${String(lineNo)})` + return `[renderer console:${label}] ${details.message} (${details.sourceId}:${String(details.lineNumber)})` } /** Attach the error-level console hook to a renderer window. `log` is the * desktop.log sink (rememberLog in main.ts). */ export function attachRendererConsoleCapture(win: WindowLike, label: string, log: (line: string) => void): void { - win.webContents.on('console-message', (_event, detailsOrLevel, message, line, sourceId) => { - const formatted = formatRendererConsoleLine(label, detailsOrLevel, message, line, sourceId) + win.webContents.on('console-message', (event) => { + if (!isConsoleMessageDetails(event)) { + if (!didReportConsoleMessageSignatureDrift) { + didReportConsoleMessageSignatureDrift = true + log('[renderer console] Electron console-message signature drift detected; renderer errors may not be captured') + } + return + } + + const formatted = formatRendererConsoleLine(label, event) if (formatted !== null) { log(formatted)