Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ BrainSpeedExercises is an Electron desktop application that delivers a series of
| Linting | [ESLint](https://eslint.org/) (flat config) | Airbnb-style rules; see `.eslint.config.js` |
| Accessibility standard | WCAG 2.2 Level AA | Required for all UI |
| Progress persistence | Node `fs` (JSON flat-file) via IPC | No external database required |
| Logging | [`electron-log`](https://github.com/megahertz/electron-log) | Main process + IPC bridge for renderer |

> **Dependency policy:** All NPM packages are updated regularly. Pin to a major-version range (e.g. `"^38"`) rather than exact versions, and run `npm audit` on every PR.

Expand Down Expand Up @@ -98,6 +99,7 @@ Valid channels (extend as needed):
| `games:load` | renderer → main | Load a specific game by ID |
| `progress:save` | renderer → main | Persist player progress |
| `progress:load` | renderer → main | Retrieve player progress |
| `log:send` | renderer → main | Forward a log message to `electron-log` |

### 3 — Renderer Process (`app/interface.js`)

Expand Down Expand Up @@ -210,6 +212,50 @@ game-specific fields that need custom merge logic.

---

### 5c — Logging Service

All application code **must** use the centralized logging service instead of `console.*` calls.
The `no-console` ESLint rule is enforced; scripts under `scripts/` are exempt.

#### Main process (`main.js`, `registry.js`, and any other Node-context file)

Import `electron-log` directly:

```js
import log from 'electron-log';
log.warn('Skipping game: manifest missing fields');
log.error('IPC handler failed', err);
```

#### Renderer process (game plugins, `interface.js`, `components/*.js`)

Import `logger` from the shared Log Service:

```js
import { logger } from '../../components/logService.js';
// Adjust the relative path to logService.js as needed.
logger.error('Failed to load game', err);
logger.warn('Unexpected state', { gameId });
logger.info('Game started', gameId);
```

The `logger` object exposes one method per level: `error`, `warn`, `info`, `verbose`, `debug`.
Each call fires a `log:send` IPC message to the main process, where it is written through
`electron-log`. The call is fire-and-forget — logging failures never interrupt gameplay.

**Never** call `window.api.invoke('log:send', ...)` directly. Always use `logger.*`.

#### Testing

Mock `electron-log` with `jest.unstable_mockModule('electron-log', ...)` in Node-environment tests
(`/** @jest-environment node */`). A shared manual mock is also available at
`__mocks__/electron-log.js` for automatic mocking.

For renderer-side tests that exercise code which calls `logger.*`, set `global.window.api.invoke`
to a `jest.fn()` and assert on `'log:send'` calls, or simply verify the call count is correct.

---

### 5b — Shared Game Screen Components

All games **must** use the shared CSS classes defined in `app/style.css` for their welcome and end
Expand Down Expand Up @@ -270,7 +316,8 @@ Rules summary:
- Airbnb-style base: `eslint-config-airbnb-base`
- Enforce `const`/`let` (no `var`)
- Single quotes, trailing commas, semicolons required
- `no-console` warnings in renderer code; allowed in main process
- `no-console` error in all app code; use the logging service instead (see §5c)
- `no-console` is **off** only for scripts under `scripts/`
- Max line length: 100 characters
- All files under `app/` and root JS files are linted; `node_modules/` is excluded

Expand Down Expand Up @@ -308,6 +355,8 @@ Use the vscode built-in test runner whenever possible.

The Jest config maps Electron's Node modules to lightweight mocks in `__mocks__/electron.js`. Never import Electron APIs directly in renderer-side code — always use the `window.api` bridge.

A matching mock for `electron-log` lives in `__mocks__/electron-log.js`. Tests for main-process modules that import `electron-log` should use `jest.unstable_mockModule('electron-log', ...)` (see `registry.test.js` for an example).

---

## Accessibility (WCAG 2.2 AA)
Expand Down
30 changes: 30 additions & 0 deletions __mocks__/electron-log.js
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;
91 changes: 91 additions & 0 deletions app/components/logService.js
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(() => {});
Comment thread
acrosman marked this conversation as resolved.
Comment thread
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),
};
158 changes: 158 additions & 0 deletions app/components/tests/logService.test.js
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',
});
},
);
});
5 changes: 3 additions & 2 deletions app/games/registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import fs from 'fs/promises';
import path from 'path';
import log from 'electron-log';
Comment thread
acrosman marked this conversation as resolved.

const REQUIRED_FIELDS = ['id', 'name', 'description', 'entryPoint'];

Expand All @@ -36,7 +37,7 @@ export async function scanGamesDirectory(gamesPath) {
const manifest = JSON.parse(raw);
const missingFields = REQUIRED_FIELDS.filter((f) => !manifest[f]);
if (missingFields.length > 0) {
console.warn(
log.warn(
`Skipping game "${dir.name}": manifest missing required fields: ${missingFields.join(', ')}`,
);
return null;
Expand All @@ -46,7 +47,7 @@ export async function scanGamesDirectory(gamesPath) {
}
return manifest;
} catch (err) {
console.warn(`Skipping game "${dir.name}": ${err.message}`);
log.warn(`Skipping game "${dir.name}": ${err.message}`);
return null;
}
}),
Expand Down
Loading
Loading