-
Notifications
You must be signed in to change notification settings - Fork 0
Add electron-log logging service with renderer IPC bridge #62
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
1c912e5
Initial plan
Copilot 787878c
feat: add electron-log based logging service with IPC support
Copilot 19d2e40
fix: move log.initialize() before transport config, improve comments
Copilot 6fee3eb
fix: remove silly log level, fix integration test, clean up eslint co…
Copilot f098222
feat: add info-level logs for startup, progress save, and quit
Copilot aed872e
Apply suggestions from code review
acrosman 65eba7c
fix: safe Error/circular serializer in logService; no-console to error
Copilot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| /** | ||
| * __mocks__/electron-log.js — Jest manual mock for the electron-log package. | ||
| * | ||
| * Provides stub implementations of every log-level method so that unit tests | ||
| * can spy on logging calls without writing to the filesystem or stdout. | ||
| * | ||
| * @file Jest mock for electron-log. | ||
| */ | ||
|
|
||
| import { jest } from '@jest/globals'; | ||
|
|
||
| const electronLog = { | ||
| error: jest.fn(), | ||
| warn: jest.fn(), | ||
| info: jest.fn(), | ||
| verbose: jest.fn(), | ||
| debug: jest.fn(), | ||
| initialize: jest.fn(), | ||
| transports: { | ||
| file: { | ||
| level: 'info', | ||
| resolvePathFn: jest.fn(), | ||
| }, | ||
| console: { | ||
| level: 'warn', | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| export default electronLog; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| /** | ||
| * logService.js — Renderer-side logging service for BrainSpeedExercises. | ||
| * | ||
| * Forwards log messages from the renderer process to the main process via IPC, | ||
| * where they are written through the application's electron-log instance. | ||
| * | ||
| * All renderer code (interface.js, game plugins, components) should import from | ||
| * this module instead of calling console.* directly. | ||
| * | ||
| * Usage: | ||
| * import { logger } from '../../components/logService.js'; | ||
| * logger.error('Something went wrong', err); | ||
| * logger.warn('Unexpected state', { gameId }); | ||
| * logger.info('Game started', gameId); | ||
| * | ||
| * @file Renderer-side IPC logging wrapper. | ||
| */ | ||
|
|
||
| /** | ||
| * Valid log level strings accepted by the main-process log handler. | ||
| * @readonly | ||
| * @enum {string} | ||
| */ | ||
| export const LOG_LEVELS = /** @type {const} */ ( | ||
| ['error', 'warn', 'info', 'verbose', 'debug'] | ||
| ); | ||
|
|
||
| /** | ||
| * Safely serialize a single log argument to a string. | ||
| * | ||
| * - `Error` instances are serialized as `"<name>: <message>\n<stack>"` so | ||
| * that message and stack are never lost (plain `JSON.stringify` returns `{}` | ||
| * for Error objects). | ||
| * - Other objects are serialized via `JSON.stringify`; if that throws (e.g. | ||
| * circular references) the value falls back to `String(a)`. | ||
| * - Primitives are converted with `String()`. | ||
| * | ||
| * @param {*} a - The value to serialize. | ||
| * @returns {string} | ||
| */ | ||
| function serializeArg(a) { | ||
| if (a instanceof Error) { | ||
| return a.stack ? `${a.name}: ${a.message}\n${a.stack}` : `${a.name}: ${a.message}`; | ||
| } | ||
| if (typeof a === 'object' && a !== null) { | ||
| try { | ||
| return JSON.stringify(a); | ||
| } catch { | ||
| return String(a); | ||
| } | ||
| } | ||
| return String(a); | ||
| } | ||
|
|
||
| /** | ||
| * Send a log message to the main process via the `log:send` IPC channel. | ||
| * | ||
| * Safe to call when `window.api` is unavailable (e.g., in a test environment | ||
| * without a real DOM); the call is silently ignored in that case. | ||
| * | ||
| * @param {string} level - One of the {@link LOG_LEVELS} values. | ||
| * @param {...*} args - Message parts; joined with a space before sending. | ||
| * @returns {void} | ||
| */ | ||
| export function log(level, ...args) { | ||
| if (typeof window === 'undefined' || !window.api) return; | ||
| const message = args.map(serializeArg).join(' '); | ||
| // Fire-and-forget: if the IPC channel is unavailable (e.g., main process restarting) | ||
| // the logging call fails silently so it never disrupts the caller. | ||
| window.api.invoke('log:send', { level, message }).catch(() => {}); | ||
|
acrosman marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /** | ||
| * Structured logger object with one method per log level. | ||
| * | ||
| * Each method forwards its arguments to {@link log} with the matching level. | ||
| * | ||
| * @namespace logger | ||
| */ | ||
| export const logger = { | ||
| /** Log an error-level message. @param {...*} args */ | ||
| error: (...args) => log('error', ...args), | ||
| /** Log a warning-level message. @param {...*} args */ | ||
| warn: (...args) => log('warn', ...args), | ||
| /** Log an info-level message. @param {...*} args */ | ||
| info: (...args) => log('info', ...args), | ||
| /** Log a verbose-level message. @param {...*} args */ | ||
| verbose: (...args) => log('verbose', ...args), | ||
| /** Log a debug-level message. @param {...*} args */ | ||
| debug: (...args) => log('debug', ...args), | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,158 @@ | ||
| /** @jest-environment node */ | ||
| /** | ||
| * logService.test.js — Unit tests for the renderer-side logging service. | ||
| * | ||
| * Exercises log(), logger.*, and the LOG_LEVELS export against a mocked | ||
| * window.api IPC bridge. | ||
| * | ||
| * @file Tests for app/components/logService.js | ||
| */ | ||
|
|
||
| import { jest } from '@jest/globals'; | ||
|
|
||
| // ── Module-level mock setup ─────────────────────────────────────────────────── | ||
|
|
||
| const { log, logger, LOG_LEVELS } = await import('../logService.js'); | ||
|
|
||
| // ── Helpers ─────────────────────────────────────────────────────────────────── | ||
|
|
||
| /** | ||
| * Build a mock window.api object that records invoke calls. | ||
| * @returns {{ mock: jest.Mock }} | ||
| */ | ||
| function buildApiMock() { | ||
| const mock = jest.fn(() => Promise.resolve()); | ||
| return { mock }; | ||
| } | ||
|
|
||
| // ── Tests ───────────────────────────────────────────────────────────────────── | ||
|
|
||
| describe('LOG_LEVELS', () => { | ||
| it('exports an array of valid log level strings', () => { | ||
| expect(LOG_LEVELS).toEqual( | ||
| expect.arrayContaining(['error', 'warn', 'info', 'verbose', 'debug']), | ||
| ); | ||
| expect(LOG_LEVELS).not.toContain('silly'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('log()', () => { | ||
| afterEach(() => { | ||
| delete global.window; | ||
| }); | ||
|
|
||
| it('does nothing when window is undefined', () => { | ||
| // No global.window — should not throw. | ||
| expect(() => log('info', 'test message')).not.toThrow(); | ||
| }); | ||
|
|
||
| it('does nothing when window.api is absent', () => { | ||
| global.window = {}; | ||
| expect(() => log('info', 'test message')).not.toThrow(); | ||
| }); | ||
|
|
||
| it('calls window.api.invoke with log:send and the correct payload', () => { | ||
| const { mock } = buildApiMock(); | ||
| global.window = { api: { invoke: mock } }; | ||
|
|
||
| log('error', 'something broke'); | ||
|
|
||
| expect(mock).toHaveBeenCalledWith('log:send', { | ||
| level: 'error', | ||
| message: 'something broke', | ||
| }); | ||
| }); | ||
|
|
||
| it('joins multiple arguments into a single space-separated message string', () => { | ||
| const { mock } = buildApiMock(); | ||
| global.window = { api: { invoke: mock } }; | ||
|
|
||
| log('warn', 'part1', 'part2', 'part3'); | ||
|
|
||
| expect(mock).toHaveBeenCalledWith('log:send', { | ||
| level: 'warn', | ||
| message: 'part1 part2 part3', | ||
| }); | ||
| }); | ||
|
|
||
| it('serializes object arguments to JSON', () => { | ||
| const { mock } = buildApiMock(); | ||
| global.window = { api: { invoke: mock } }; | ||
|
|
||
| log('debug', 'data:', { key: 'value' }); | ||
|
|
||
| expect(mock).toHaveBeenCalledWith('log:send', { | ||
| level: 'debug', | ||
| message: 'data: {"key":"value"}', | ||
| }); | ||
| }); | ||
|
|
||
| it('serializes Error arguments to include name, message, and stack', () => { | ||
| const { mock } = buildApiMock(); | ||
| global.window = { api: { invoke: mock } }; | ||
|
|
||
| const err = new Error('something broke'); | ||
| log('error', err); | ||
|
|
||
| const call = mock.mock.calls[0][1]; | ||
| expect(call.message).toContain('Error: something broke'); | ||
| }); | ||
|
|
||
| it('falls back to String() for circular objects that cannot be JSON-serialized', () => { | ||
| const { mock } = buildApiMock(); | ||
| global.window = { api: { invoke: mock } }; | ||
|
|
||
| const circular = {}; | ||
| circular.self = circular; | ||
| expect(() => log('warn', 'circular:', circular)).not.toThrow(); | ||
| expect(mock).toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('serializes null as the string "null"', () => { | ||
| const { mock } = buildApiMock(); | ||
| global.window = { api: { invoke: mock } }; | ||
|
|
||
| log('info', null); | ||
|
|
||
| expect(mock).toHaveBeenCalledWith('log:send', { | ||
| level: 'info', | ||
| message: 'null', | ||
| }); | ||
| }); | ||
|
|
||
| it('passes the provided level through unchanged', () => { | ||
| const { mock } = buildApiMock(); | ||
| global.window = { api: { invoke: mock } }; | ||
|
|
||
| log('verbose', 'verbose message'); | ||
|
|
||
| expect(mock).toHaveBeenCalledWith('log:send', { | ||
| level: 'verbose', | ||
| message: 'verbose message', | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| describe('logger', () => { | ||
| let mock; | ||
|
|
||
| beforeEach(() => { | ||
| ({ mock } = buildApiMock()); | ||
| global.window = { api: { invoke: mock } }; | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| delete global.window; | ||
| }); | ||
|
|
||
| it.each(['error', 'warn', 'info', 'verbose', 'debug'])( | ||
| 'logger.%s() sends level="%s" to the IPC channel', | ||
| (level) => { | ||
| logger[level]('test'); | ||
| expect(mock).toHaveBeenCalledWith('log:send', { | ||
| level, | ||
| message: 'test', | ||
| }); | ||
| }, | ||
| ); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.