diff --git a/__mocks__/electron.js b/__mocks__/electron.js
index c57fe7a..1285f34 100644
--- a/__mocks__/electron.js
+++ b/__mocks__/electron.js
@@ -1,3 +1,12 @@
+export const contextBridge = {
+ exposeInMainWorld: jest.fn(),
+};
+
+export const ipcRenderer = {
+ send: jest.fn(),
+ on: jest.fn(),
+ invoke: jest.fn(),
+};
import { jest } from '@jest/globals';
export const app = {
diff --git a/app/games/fast-piggie/index.js b/app/games/fast-piggie/index.js
index f4dcbd2..e9a47d8 100644
--- a/app/games/fast-piggie/index.js
+++ b/app/games/fast-piggie/index.js
@@ -337,6 +337,7 @@ function _runRound() {
_roundTimer = setTimeout(() => {
clearImages(_ctx, width, height, wedgeCount);
+ _currentRound._imagesHiddenAt = Date.now();
_clickEnabled = true;
_hoveredWedge = -1;
_selectedWedge = -1;
@@ -486,7 +487,7 @@ function _resolveRound(wedge) {
// Track answer speed (time from images hidden to answer)
// We'll store the time when images are hidden in _currentRound._imagesHiddenAt
let answerSpeedMs = null;
- if (_currentRound._imagesHiddenAt) {
+ if (_currentRound._imagesHiddenAt != null) {
answerSpeedMs = Date.now() - _currentRound._imagesHiddenAt;
}
diff --git a/app/games/fast-piggie/tests/game.test.js b/app/games/fast-piggie/tests/game.test.js
index c5bf563..062ce5a 100644
--- a/app/games/fast-piggie/tests/game.test.js
+++ b/app/games/fast-piggie/tests/game.test.js
@@ -10,6 +10,7 @@ import {
calculateWedgeIndex,
addScore,
addMiss,
+ getBestStats,
getScore,
getRoundsPlayed,
getLevel,
@@ -416,3 +417,51 @@ describe('isRunning()', () => {
expect(isRunning()).toBe(false);
});
});
+
+describe('getBestStats()', () => {
+ it('returns an object with maxScore, mostRounds, mostGuineaPigs, topSpeedMs', () => {
+ const stats = getBestStats();
+ expect(stats).toHaveProperty('maxScore');
+ expect(stats).toHaveProperty('mostRounds');
+ expect(stats).toHaveProperty('mostGuineaPigs');
+ expect(stats).toHaveProperty('topSpeedMs');
+ });
+
+ it('maxScore reflects the highest score achieved since module load', () => {
+ startGame();
+ addScore();
+ addScore();
+ stopGame();
+ const stats = getBestStats();
+ expect(stats.maxScore).toBeGreaterThanOrEqual(2);
+ });
+
+ it('mostGuineaPigs updates when addScore is called with a guineaPigs count', () => {
+ addScore(7);
+ const stats = getBestStats();
+ expect(stats.mostGuineaPigs).toBeGreaterThanOrEqual(7);
+ });
+
+ it('topSpeedMs updates when addScore is called with an answerSpeedMs value', () => {
+ addScore(3, 500);
+ const stats = getBestStats();
+ expect(stats.topSpeedMs).toBeLessThanOrEqual(500);
+ });
+
+ it('topSpeedMs is null when no speed has been recorded', () => {
+ // initGame() resets the session but session-best trackers are module-level;
+ // to get null we rely on a fresh import (module-level state).
+ // Here we just verify the type contract: null or a non-negative number.
+ const stats = getBestStats();
+ expect(stats.topSpeedMs === null || typeof stats.topSpeedMs === 'number').toBe(true);
+ });
+
+ it('mostRounds reflects rounds played across sessions', () => {
+ startGame();
+ addScore();
+ addMiss();
+ stopGame();
+ const stats = getBestStats();
+ expect(stats.mostRounds).toBeGreaterThanOrEqual(2);
+ });
+});
diff --git a/app/games/fast-piggie/tests/index.test.js b/app/games/fast-piggie/tests/index.test.js
index 4ea6b4e..cfb0cb8 100644
--- a/app/games/fast-piggie/tests/index.test.js
+++ b/app/games/fast-piggie/tests/index.test.js
@@ -1094,3 +1094,174 @@ describe('progress saving', () => {
delete globalThis.api;
});
});
+
+// ===========================================================================
+// stop() end-panel flow and return-to-main-menu wiring
+// ===========================================================================
+describe('stop() end-panel flow and _returnToMainMenu', () => {
+ it('stop() shows the end panel with score and high score', async () => {
+ plugin.start();
+ await plugin.stop();
+ const endPanel = container.querySelector('#fp-end-panel');
+ expect(endPanel.hidden).toBe(false);
+ expect(container.querySelector('#fp-final-score').textContent).toBe('3');
+ expect(container.querySelector('#fp-final-high-score').textContent).toBe('3');
+ });
+
+ it('stop() hides the game area and End Game button', async () => {
+ plugin.start();
+ await plugin.stop();
+ expect(container.querySelector('#fp-game-area').hidden).toBe(true);
+ expect(container.querySelector('#fp-stop-btn').hidden).toBe(true);
+ });
+
+ it('clicking #fp-return-btn dispatches bsx:return-to-main-menu', () => {
+ let fired = false;
+ window.addEventListener('bsx:return-to-main-menu', () => { fired = true; }, { once: true });
+ container.querySelector('#fp-return-btn').click();
+ expect(fired).toBe(true);
+ });
+});
+
+// ===========================================================================
+// start button click callback (f[34] in index.js)
+// ===========================================================================
+describe('start button click event', () => {
+ it('clicking #fp-start-btn triggers plugin.start()', () => {
+ const startBtn = container.querySelector('#fp-start-btn');
+ startBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ expect(game.startGame).toHaveBeenCalled();
+ });
+});
+
+// ===========================================================================
+// _triggerFlash setTimeout callback and _resolveRound next-round timer
+// (f[18] and f[30] in index.js)
+// ===========================================================================
+describe('_triggerFlash and next-round timers', () => {
+ function fireCorrectClick() {
+ container.querySelector('#fp-canvas').dispatchEvent(
+ new MouseEvent('click', { clientX: 250, clientY: 100, bubbles: true }),
+ );
+ }
+
+ beforeEach(() => {
+ game.calculateWedgeIndex.mockReturnValue(2);
+ game.checkAnswer.mockReturnValue(true);
+ plugin.start();
+ jest.runAllTimers(); // advance past displayDurationMs → _clickEnabled = true
+ });
+
+ it('flash class is removed after the 600ms _triggerFlash timeout fires', () => {
+ fireCorrectClick();
+ const flash = container.querySelector('#fp-flash');
+ expect(flash.classList.contains('fp-flash--correct')).toBe(true);
+ jest.runAllTimers(); // fires 600ms flash timer + 1000ms next-round + next displayDurationMs
+ expect(flash.classList.contains('fp-flash--correct')).toBe(false);
+ });
+
+ it('next-round 1000ms timer calls _runRound (generateRound is called again)', () => {
+ game.generateRound.mockClear();
+ fireCorrectClick();
+ jest.runAllTimers(); // fires 600ms flash + 1000ms next-round → _runRound → generateRound
+ expect(game.generateRound).toHaveBeenCalled();
+ });
+});
+
+// ===========================================================================
+// loadImages onerror path and init() .catch() fallback
+// (f[4] and f[33] in index.js)
+// ===========================================================================
+describe('loadImages() — onerror path', () => {
+ it('rejects when the image fails to load (onerror fired)', async () => {
+ const OriginalImage = globalThis.Image;
+ globalThis.Image = class {
+ set src(_) {
+ if (this.onerror) this.onerror(new Error('load failed'));
+ }
+ };
+
+ let rejected = false;
+ try {
+ await loadImages('bad.png');
+ } catch {
+ rejected = true;
+ }
+ expect(rejected).toBe(true);
+
+ globalThis.Image = OriginalImage;
+ });
+});
+
+describe('init() — loadImages .catch() fallback', () => {
+ it('does not throw when image loading fails (falls back to null images)', async () => {
+ const OriginalImage = globalThis.Image;
+ globalThis.Image = class {
+ set src(_) {
+ if (this.onerror) this.onerror(new Error('load failed'));
+ }
+ };
+
+ // init() calls loadImages which will reject; .catch() sets _images = [null, null]
+ expect(() => plugin.init(buildContainer())).not.toThrow();
+ // Flush microtasks so the .catch() callback executes
+ await Promise.resolve();
+ await Promise.resolve();
+
+ globalThis.Image = OriginalImage;
+ });
+});
+
+// ===========================================================================
+// _resolveRound — no slot assignment when imageCount equals wedgeCount
+// (covers the `return outlierWedgeIndex` else-path in _getCorrectWedgeIndex)
+// ===========================================================================
+describe('_resolveRound — no slot assignment when imageCount equals wedgeCount', () => {
+ beforeEach(() => {
+ game.generateRound.mockReturnValueOnce({
+ wedgeCount: 6,
+ imageCount: 6,
+ displayDurationMs: 2000,
+ outlierWedgeIndex: 2,
+ });
+ game.calculateWedgeIndex.mockReturnValue(2);
+ game.checkAnswer.mockReturnValue(true);
+ plugin.start();
+ jest.runAllTimers();
+ });
+
+ it('resolves round without slot assignment (imageCount === wedgeCount)', () => {
+ const canvas = container.querySelector('#fp-canvas');
+ canvas.dispatchEvent(new MouseEvent('click', { clientX: 250, clientY: 100, bubbles: true }));
+ expect(game.addScore).toHaveBeenCalled();
+ });
+});
+
+// ===========================================================================
+// _showEndPanel and _returnToMainMenu
+// ===========================================================================
+describe('_showEndPanel and _returnToMainMenu', () => {
+ it('end panel contains Game Over heading after stop()', async () => {
+ plugin.start();
+ await plugin.stop();
+ const endPanel = container.querySelector('#fp-end-panel');
+ expect(endPanel.textContent).toContain('Game Over');
+ });
+
+ it('clicking #fp-play-again-btn resets and restarts game', () => {
+ const resetSpy = jest.spyOn(plugin, 'reset');
+ const startSpy = jest.spyOn(plugin, 'start');
+ container.querySelector('#fp-play-again-btn').click();
+ expect(resetSpy).toHaveBeenCalled();
+ expect(startSpy).toHaveBeenCalled();
+ resetSpy.mockRestore();
+ startSpy.mockRestore();
+ });
+
+ it('clicking #fp-return-btn can be triggered repeatedly without throwing', () => {
+ expect(() => {
+ container.querySelector('#fp-return-btn').click();
+ container.querySelector('#fp-return-btn').click();
+ }).not.toThrow();
+ });
+});
diff --git a/app/games/field-of-view/tests/index.test.js b/app/games/field-of-view/tests/index.test.js
index 4b2d53b..382c0a3 100644
--- a/app/games/field-of-view/tests/index.test.js
+++ b/app/games/field-of-view/tests/index.test.js
@@ -264,4 +264,141 @@ describe('field-of-view index', () => {
expect(document.querySelector('#fov-game-area').hidden).toBe(false);
expect(gameMock.startGame).toHaveBeenCalled();
});
+
+ test('start and stop buttons invoke lifecycle handlers via click', () => {
+ gameMock.startGame.mockClear();
+ gameMock.stopGame.mockClear();
+
+ document.querySelector('#fov-start-btn').click();
+ document.querySelector('#fov-stop-btn').click();
+
+ expect(gameMock.startGame).toHaveBeenCalled();
+ expect(gameMock.stopGame).toHaveBeenCalled();
+ });
+
+ test('center secondary click updates center selection and can submit', () => {
+ plugin.start();
+ jest.runAllTimers();
+
+ document.querySelector('#fov-center-secondary').click();
+ document.querySelector('[data-index="1"]').click();
+
+ expect(gameMock.recordTrial).toHaveBeenCalled();
+ });
+
+ test('center choice is ignored before response phase is enabled', () => {
+ gameMock.recordTrial.mockClear();
+
+ // Before start(), response input is disabled and chooseCenter should return early.
+ document.querySelector('#fov-center-secondary').click();
+
+ expect(gameMock.recordTrial).not.toHaveBeenCalled();
+ });
+
+ test('feedback flash timeout clears stage flash class', () => {
+ gameMock.recordTrial.mockReturnValueOnce({ thresholdMs: 84.2, recentAccuracy: 0.8 });
+ plugin.start();
+ jest.runAllTimers();
+
+ document.querySelector('#fov-center-primary').click();
+ document.querySelector('[data-index="1"]').click();
+
+ const stage = document.querySelector('#fov-stage');
+ expect(stage.classList.contains('fov-stage--flash-correct')).toBe(true);
+
+ jest.runOnlyPendingTimers();
+ expect(stage.classList.contains('fov-stage--flash-correct')).toBe(false);
+ });
+
+ test('next-trial timer callback starts another trial when still running', () => {
+ gameMock.isRunning.mockReturnValue(true);
+ gameMock.createTrialLayout.mockClear();
+
+ plugin.start();
+ jest.runAllTimers();
+
+ document.querySelector('#fov-center-primary').click();
+ document.querySelector('[data-index="1"]').click();
+
+ jest.runOnlyPendingTimers();
+ expect(gameMock.createTrialLayout).toHaveBeenCalledTimes(2);
+ });
+
+ test('stimulus and mask phases take raf else-path before completing', () => {
+ let t = 0;
+ nowSpy.mockRestore();
+ nowSpy = jest.spyOn(performance, 'now').mockImplementation(() => {
+ t += 50;
+ return t;
+ });
+ gameMock.getCurrentSoaMs.mockReturnValueOnce(300);
+
+ plugin.start();
+ jest.runAllTimers();
+
+ expect(document.querySelector('#fov-mask').hidden).toBe(false);
+ expect(document.querySelector('#fov-response').hidden).toBe(false);
+ });
+
+ test('stimulus raf loop iterates when elapsed is below soa (covers loop continuation)', () => {
+ // Use a slow-incrementing clock so elapsed < targetSoa on the first raf tick,
+ // forcing the stimulus raf loop to iterate at least once (line 368).
+ let t = 0;
+ nowSpy.mockRestore();
+ nowSpy = jest.spyOn(performance, 'now').mockImplementation(() => {
+ t += 10;
+ return t;
+ });
+ // targetSoa defaults to 84.2; with t+=10 the first tick gives elapsed<84.2
+ // so the loop continuation branch is taken before the phase completes.
+
+ plugin.start();
+ jest.runAllTimers();
+
+ expect(document.querySelector('#fov-mask').hidden).toBe(false);
+ expect(document.querySelector('#fov-response').hidden).toBe(false);
+ });
+
+ test('stop during mask phase cancels pending mask raf (lines 175-176)', () => {
+ // Complete only the stimulus phase so a mask RAF is scheduled but not yet run.
+ plugin.start();
+ jest.runOnlyPendingTimers(); // fires stimulus RAF → schedules mask RAF
+ // _maskRafId is now set; stopping here exercises the mask-raf cancellation path.
+ plugin.stop();
+
+ expect(document.querySelector('#fov-end-panel').hidden).toBe(false);
+ });
+
+ test('nowMs falls back to Date.now when performance.now is not a function (line 119)', () => {
+ nowSpy.mockRestore();
+ const origNow = performance.now;
+ // Make performance.now not a function so nowMs uses Date.now() instead.
+ let dateT = 0;
+ const dateSpy = jest.spyOn(Date, 'now').mockImplementation(() => {
+ dateT += 200;
+ return dateT;
+ });
+ // @ts-ignore
+ performance.now = null;
+
+ plugin.start(); // runStimulusPhase → nowMs() → Date.now()
+
+ expect(dateSpy).toHaveBeenCalled();
+
+ performance.now = origNow;
+ dateSpy.mockRestore();
+ // Re-create a spy so afterEach's nowSpy.mockRestore() does not throw.
+ nowSpy = jest.spyOn(performance, 'now').mockReturnValue(10200);
+ // Clean up pending timers without running them to avoid infinite loops.
+ jest.clearAllTimers();
+ });
+
+ test('start exits before trial creation when game is not running', () => {
+ gameMock.isRunning.mockReturnValue(false);
+ gameMock.createTrialLayout.mockClear();
+
+ plugin.start();
+
+ expect(gameMock.createTrialLayout).not.toHaveBeenCalled();
+ });
});
diff --git a/app/games/high-speed-memory/tests/index.test.js b/app/games/high-speed-memory/tests/index.test.js
index 85a0bff..ee96d6d 100644
--- a/app/games/high-speed-memory/tests/index.test.js
+++ b/app/games/high-speed-memory/tests/index.test.js
@@ -361,6 +361,37 @@ describe('playWrongSound', () => {
globalThis.AudioContext = OriginalAudioContext;
});
+
+ test('osc.onended swallows ctx.close() rejection', () => {
+ const mockOsc = {
+ connect: jest.fn(),
+ type: '',
+ frequency: { setValueAtTime: jest.fn() },
+ gain: { setValueAtTime: jest.fn(), exponentialRampToValueAtTime: jest.fn() },
+ start: jest.fn(),
+ stop: jest.fn(),
+ onended: null,
+ };
+ const mockGainNode = {
+ connect: jest.fn(),
+ gain: { setValueAtTime: jest.fn(), exponentialRampToValueAtTime: jest.fn() },
+ };
+ const mockCtx = {
+ createOscillator: jest.fn(() => mockOsc),
+ createGain: jest.fn(() => mockGainNode),
+ destination: {},
+ currentTime: 0,
+ close: jest.fn().mockRejectedValue(new Error('close failed')),
+ };
+ const OriginalAudioContext = globalThis.AudioContext;
+ globalThis.AudioContext = jest.fn(() => mockCtx);
+
+ playWrongSound();
+ // Trigger onended — ctx.close() rejects, but the .catch(() => {}) swallows it
+ expect(() => { if (mockOsc.onended) mockOsc.onended(); }).not.toThrow();
+
+ globalThis.AudioContext = OriginalAudioContext;
+ });
});
// ── announce ──────────────────────────────────────────────────────────────────
@@ -497,6 +528,19 @@ describe('renderGrid', () => {
expect(btn.classList.contains('hsm-card--matched')).toBe(false);
jest.useRealTimers();
});
+
+ test('clicking a card button triggers handleCardClick (click event listener)', () => {
+ jest.useFakeTimers();
+ const container = buildContainer();
+ plugin.init(container);
+ startRound();
+ jest.runAllTimers(); // hide cards so flipLock is false
+
+ const btn = container.querySelector('[data-id="0"]'); // Primary card
+ btn.dispatchEvent(new MouseEvent('click', { bubbles: true }));
+ expect(btn.classList.contains('hsm-card--matched')).toBe(true);
+ jest.useRealTimers();
+ });
});
// ── hideCardEl / revealCardEl / markCardMatched / markCardWrong ───────────────
diff --git a/app/games/orbit-sprite-memory/tests/index.test.js b/app/games/orbit-sprite-memory/tests/index.test.js
index 9d7828d..f89da78 100644
--- a/app/games/orbit-sprite-memory/tests/index.test.js
+++ b/app/games/orbit-sprite-memory/tests/index.test.js
@@ -65,6 +65,9 @@ const plugin = pluginModule.default;
const gameMock = await import('../game.js');
const {
+ playPositiveSound,
+ playNegativeSound,
+ flashBoard,
getSpriteBackgroundPosition,
getCircleCoordinates,
announce,
@@ -77,6 +80,7 @@ const {
startPlayback,
startRound,
submitSelection,
+ loadBestStatsFromProgress,
returnToMainMenu,
showEndPanel,
} = pluginModule;
@@ -99,8 +103,12 @@ function buildContainer() {
0
1
0
+ 1
+ 0
0
1
+ 1
+ 0
`;
document.body.appendChild(el);
return el;
@@ -247,6 +255,142 @@ describe('exported helper utilities', () => {
expect(document.querySelector('#osm-final-score').textContent).toBe('9');
expect(document.querySelector('#osm-final-level').textContent).toBe('4');
});
+
+ test('playPositiveSound closes context on onended and handles close rejection', async () => {
+ const onendedRefs = [];
+ const ctx = {
+ currentTime: 0,
+ destination: {},
+ createOscillator: () => {
+ const osc = {
+ connect: jest.fn(),
+ type: '',
+ frequency: { setValueAtTime: jest.fn(), linearRampToValueAtTime: jest.fn() },
+ start: jest.fn(),
+ stop: jest.fn(),
+ onended: null,
+ };
+ onendedRefs.push(osc);
+ return osc;
+ },
+ createGain: () => ({
+ connect: jest.fn(),
+ gain: {
+ setValueAtTime: jest.fn(),
+ linearRampToValueAtTime: jest.fn(),
+ exponentialRampToValueAtTime: jest.fn(),
+ },
+ }),
+ close: jest.fn(() => Promise.reject(new Error('close failed'))),
+ };
+ const OriginalAudioContext = globalThis.AudioContext;
+ globalThis.AudioContext = jest.fn(() => ctx);
+
+ playPositiveSound();
+ onendedRefs[1].onended();
+ await Promise.resolve();
+
+ expect(ctx.close).toHaveBeenCalled();
+ globalThis.AudioContext = OriginalAudioContext;
+ });
+
+ test('playNegativeSound closes context on onended and handles close rejection', async () => {
+ let onended;
+ const ctx = {
+ currentTime: 0,
+ destination: {},
+ createOscillator: () => ({
+ connect: jest.fn(),
+ type: '',
+ frequency: { setValueAtTime: jest.fn(), linearRampToValueAtTime: jest.fn() },
+ start: jest.fn(),
+ stop: jest.fn(),
+ set onended(fn) {
+ onended = fn;
+ },
+ }),
+ createGain: () => ({
+ connect: jest.fn(),
+ gain: {
+ setValueAtTime: jest.fn(),
+ linearRampToValueAtTime: jest.fn(),
+ exponentialRampToValueAtTime: jest.fn(),
+ },
+ }),
+ close: jest.fn(() => Promise.reject(new Error('close failed'))),
+ };
+ const OriginalAudioContext = globalThis.AudioContext;
+ globalThis.AudioContext = jest.fn(() => ctx);
+
+ playNegativeSound();
+ onended();
+ await Promise.resolve();
+
+ expect(ctx.close).toHaveBeenCalled();
+ globalThis.AudioContext = OriginalAudioContext;
+ });
+
+ test('flashBoard timeout callback removes flash classes', () => {
+ const board = document.querySelector('#osm-board');
+ flashBoard('success');
+ expect(board.classList.contains('osm-board--success')).toBe(true);
+
+ jest.runOnlyPendingTimers();
+ expect(board.classList.contains('osm-board--success')).toBe(false);
+ });
+
+ test('submitSelection runs reveal and nested next-round callbacks', () => {
+ plugin.start();
+ jest.advanceTimersByTime(400);
+
+ gameMock.createRound.mockClear();
+ const buttons = document.querySelectorAll('.osm-choice-btn');
+ buttons[0].click();
+ buttons[2].click();
+ buttons[4].click();
+
+ jest.runOnlyPendingTimers();
+ jest.runOnlyPendingTimers();
+
+ expect(gameMock.createRound).toHaveBeenCalled();
+ });
+
+ test('loadBestStatsFromProgress reads saved best stats when API is available', async () => {
+ const oldApi = globalThis.window.api;
+ globalThis.window.api = {
+ invoke: jest.fn().mockResolvedValue({
+ games: {
+ 'orbit-sprite-memory': {
+ highScore: 12,
+ highestLevel: 4,
+ },
+ },
+ }),
+ };
+
+ await loadBestStatsFromProgress();
+
+ expect(document.querySelector('#osm-best-score').textContent).toBe('12');
+ expect(document.querySelector('#osm-best-level').textContent).toBe('5');
+ expect(document.querySelector('#osm-final-best-score').textContent).toBe('12');
+ expect(document.querySelector('#osm-final-best-level').textContent).toBe('5');
+
+ globalThis.window.api = oldApi;
+ });
+
+ test('loadBestStatsFromProgress handles API rejection gracefully (line 268)', async () => {
+ const oldApi = globalThis.window.api;
+ globalThis.window.api = {
+ invoke: jest.fn().mockRejectedValue(new Error('network error')),
+ };
+
+ await loadBestStatsFromProgress();
+
+ // catch block calls updateBestStats(undefined) → defaults to 0
+ expect(document.querySelector('#osm-best-score').textContent).toBe('0');
+
+ globalThis.window.api = oldApi;
+ });
});
describe('plugin contract and lifecycle', () => {
@@ -281,6 +425,17 @@ describe('plugin contract and lifecycle', () => {
expect(container.querySelector('#osm-instructions').hidden).toBe(true);
});
+ test('start button click is wired to start gameplay', () => {
+ const container = buildContainer();
+ plugin.init(container);
+ gameMock.startGame.mockClear();
+
+ container.querySelector('#osm-start-btn').click();
+
+ expect(gameMock.startGame).toHaveBeenCalled();
+ expect(container.querySelector('#osm-game-area').hidden).toBe(false);
+ });
+
test('stop returns result and shows end panel', () => {
const container = buildContainer();
plugin.init(container);
@@ -319,6 +474,41 @@ describe('plugin contract and lifecycle', () => {
globalThis.window.api = oldApi;
});
+ test('stop merges existing orbit progress and keeps higher historical bests', async () => {
+ const container = buildContainer();
+ plugin.init(container);
+ plugin.start();
+
+ const mockApi = {
+ invoke: jest.fn()
+ .mockResolvedValueOnce({
+ playerId: 'default',
+ games: {
+ 'orbit-sprite-memory': {
+ highScore: 10,
+ sessionsPlayed: 2,
+ highestLevel: 3,
+ },
+ },
+ })
+ .mockResolvedValueOnce(undefined),
+ };
+ const oldApi = globalThis.window.api;
+ globalThis.window.api = mockApi;
+
+ plugin.stop();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ const saveCall = mockApi.invoke.mock.calls.find((call) => call[0] === 'progress:save');
+ const saved = saveCall[1].data.games['orbit-sprite-memory'];
+ expect(saved.highScore).toBe(10);
+ expect(saved.highestLevel).toBe(3);
+ expect(saved.sessionsPlayed).toBe(3);
+
+ globalThis.window.api = oldApi;
+ });
+
test('reset returns to pre-game state', () => {
const container = buildContainer();
plugin.init(container);
@@ -362,4 +552,29 @@ describe('plugin contract and lifecycle', () => {
container.querySelector('#osm-stop-btn').click();
expect(container.querySelector('#osm-end-panel').hidden).toBe(false);
});
+
+ test('stop handles progress:load rejection in inner try-catch (line 579)', async () => {
+ const container = buildContainer();
+ plugin.init(container);
+ plugin.start();
+
+ // progress:load rejects → inner catch sets existing to empty defaults
+ // progress:save also rejects → outer catch swallows it
+ const mockApi = {
+ invoke: jest.fn().mockRejectedValue(new Error('IPC error')),
+ };
+ const oldApi = globalThis.window.api;
+ globalThis.window.api = mockApi;
+
+ plugin.stop();
+ // Flush nested promise chains: outer IIFE → inner progress:load → inner catch
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+
+ // End panel should still appear despite progress errors
+ expect(container.querySelector('#osm-end-panel').hidden).toBe(false);
+
+ globalThis.window.api = oldApi;
+ });
});
diff --git a/app/games/registry.integration.test.js b/app/games/registry.integration.test.js
new file mode 100644
index 0000000..2c77009
--- /dev/null
+++ b/app/games/registry.integration.test.js
@@ -0,0 +1,72 @@
+/**
+ * registry.integration.test.js — Integration tests for the game plugin registry.
+ *
+ * Unlike registry.test.js (which mocks fs/promises), these tests read the REAL
+ * games directory on disk. They act as a "game discovery smoke test": if a game's
+ * manifest.json is missing, malformed, or its interface.html doesn't exist, these
+ * tests will fail — catching the class of "application fails to find and load games"
+ * regressions that unit tests (with mocked file I/O) cannot detect.
+ *
+ * @file Integration tests for game plugin registry (real filesystem).
+ */
+/** @jest-environment node */
+import { readFile } from 'fs/promises';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+// Resolve the real games directory relative to this file.
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const REAL_GAMES_PATH = __dirname;
+
+// Import the registry WITHOUT mocking fs so it reads the actual filesystem.
+const { scanGamesDirectory } = await import('./registry.js');
+
+describe('registry integration — real filesystem', () => {
+ /** @type {object[]} */
+ let manifests;
+
+ beforeAll(async () => {
+ manifests = await scanGamesDirectory(REAL_GAMES_PATH);
+ });
+
+ test('scanGamesDirectory finds at least one game', () => {
+ expect(manifests.length).toBeGreaterThan(0);
+ });
+
+ test('every manifest has the required fields: id, name, description, entryPoint', () => {
+ manifests.forEach((m) => {
+ expect(typeof m.id).toBe('string');
+ expect(m.id.length).toBeGreaterThan(0);
+ expect(typeof m.name).toBe('string');
+ expect(m.name.length).toBeGreaterThan(0);
+ expect(typeof m.description).toBe('string');
+ expect(m.description.length).toBeGreaterThan(0);
+ expect(typeof m.entryPoint).toBe('string');
+ expect(m.entryPoint.length).toBeGreaterThan(0);
+ });
+ });
+
+ test('every game has an interface.html that can be read as a string', async () => {
+ for (const manifest of manifests) {
+ const htmlPath = path.join(REAL_GAMES_PATH, manifest.id, 'interface.html');
+ const html = await readFile(htmlPath, 'utf8');
+ expect(typeof html).toBe('string');
+ expect(html.length).toBeGreaterThan(0);
+ }
+ });
+
+ test('every game entry-point file exists and can be read', async () => {
+ for (const manifest of manifests) {
+ const entryPath = path.join(REAL_GAMES_PATH, manifest.id, manifest.entryPoint);
+ const src = await readFile(entryPath, 'utf8');
+ expect(typeof src).toBe('string');
+ expect(src.length).toBeGreaterThan(0);
+ }
+ });
+
+ test('no game IDs are duplicated', () => {
+ const ids = manifests.map((m) => m.id);
+ const unique = new Set(ids);
+ expect(unique.size).toBe(ids.length);
+ });
+});
diff --git a/app/games/registry.test.js b/app/games/registry.test.js
index 71e135b..2591a34 100644
--- a/app/games/registry.test.js
+++ b/app/games/registry.test.js
@@ -173,4 +173,34 @@ describe('loadGame', () => {
'nonexistent-game',
);
});
+
+ test('uses the default import function when importFn is not provided', async () => {
+ mockReaddir.mockResolvedValue([dirent('test-game')]);
+ mockReadFile.mockResolvedValue(JSON.stringify(validManifest));
+
+ // No importFn passed — the default (p) => import(p) is used.
+ // Importing a non-existent mock path will reject; that is expected.
+ await expect(loadGame(GAMES_PATH, 'test-game')).rejects.toThrow();
+ });
+
+ test('finds the correct game when multiple games are registered', async () => {
+ const secondManifest = {
+ id: 'second-game',
+ name: 'Second Game',
+ description: 'Another brain training game.',
+ entryPoint: 'index.js',
+ };
+ mockReaddir.mockResolvedValue([dirent('test-game'), dirent('second-game')]);
+ mockReadFile
+ .mockResolvedValueOnce(JSON.stringify(validManifest))
+ .mockResolvedValueOnce(JSON.stringify(secondManifest));
+ const mockImportFn = jest.fn().mockResolvedValue({ default: mockPlugin });
+
+ const result = await loadGame(GAMES_PATH, 'second-game', mockImportFn);
+
+ expect(result.manifest).toEqual(secondManifest);
+ expect(mockImportFn).toHaveBeenCalledWith(
+ path.join(GAMES_PATH, 'second-game', 'index.js'),
+ );
+ });
});
diff --git a/app/interface.js b/app/interface.js
index 7c05d58..f6a4d76 100644
--- a/app/interface.js
+++ b/app/interface.js
@@ -49,6 +49,23 @@ async function loadAndInitGame(gameId, gameContainer, announcer) {
mod.default.init(gameContainer);
}
+/**
+ * Show an in-container error and restore the main-menu game selector.
+ * Called by game:select listeners when loadAndInitGame rejects.
+ *
+ * @param {string} gameId - ID of the game that failed to load.
+ * @param {HTMLElement} gameContainer - The main game container element.
+ * @param {HTMLElement} announcer - Aria-live announcer element.
+ * @param {Error} [err] - The error that caused the failure.
+ */
+function handleGameLoadError(gameId, gameContainer, announcer, err) {
+ // eslint-disable-next-line no-console
+ console.error(`Failed to load game "${gameId}".`, err);
+ announcer.textContent = 'Failed to load game. Returning to menu.';
+ // Return to the game-selection screen so the player is not left on a blank page.
+ window.dispatchEvent(new Event('bsx:return-to-main-menu'));
+}
+
/**
* DOMContentLoaded event handler. Sets up the game selection UI and plugin loader.
* @returns {Promise}
@@ -76,7 +93,14 @@ document.addEventListener('DOMContentLoaded', async () => {
}
// Fetch the list of available games and render game cards.
- const manifests = await window.api.invoke('games:list');
+ let manifests = [];
+ try {
+ manifests = await window.api.invoke('games:list');
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.error('Failed to load game list:', err);
+ gameSelector.textContent = 'Unable to load games. Please restart the app.';
+ }
manifests.forEach((manifest) => {
let gameProgress = undefined;
if (progress && progress.games && progress.games[manifest.id]) {
@@ -92,7 +116,11 @@ document.addEventListener('DOMContentLoaded', async () => {
gameSelector.addEventListener('game:select', async (event) => {
const { gameId } = event.detail;
gameSelector.remove();
- await loadAndInitGame(gameId, gameContainer, announcer);
+ try {
+ await loadAndInitGame(gameId, gameContainer, announcer);
+ } catch (err) {
+ handleGameLoadError(gameId, gameContainer, announcer, err);
+ }
});
// Listen for custom event to return to main menu from any game
window.addEventListener('bsx:return-to-main-menu', () => {
@@ -122,7 +150,11 @@ document.addEventListener('DOMContentLoaded', async () => {
selector.addEventListener('game:select', async (event) => {
const { gameId } = event.detail;
selector.remove();
- await loadAndInitGame(gameId, gameContainer, announcer);
+ try {
+ await loadAndInitGame(gameId, gameContainer, announcer);
+ } catch (err) {
+ handleGameLoadError(gameId, gameContainer, announcer, err);
+ }
});
}
announcer.textContent = 'Main menu loaded. Select a game.';
diff --git a/app/interface.test.js b/app/interface.test.js
new file mode 100644
index 0000000..185e8ec
--- /dev/null
+++ b/app/interface.test.js
@@ -0,0 +1,352 @@
+/** @jest-environment jsdom */
+import { jest } from '@jest/globals';
+
+// Mock the game module dynamically imported by loadAndInitGame.
+// Must be registered before interface.js is imported.
+const mockGameInit = jest.fn();
+
+// Capture the DOMContentLoaded callback before importing interface.js.
+let domReadyCallback;
+
+// jest.unstable_mockModule MUST be called at module top level (with top-level await) so that
+// the mock is registered before Jest's coverage instrumentation pre-loads interface.js.
+await jest.unstable_mockModule('./games/fast-piggie/index.js', () => ({
+ default: { init: mockGameInit },
+}));
+
+// Spy on document.addEventListener to capture the DOMContentLoaded callback before
+// importing interface.js, so tests can invoke it directly.
+const origDocAddEventListener = document.addEventListener.bind(document);
+jest.spyOn(document, 'addEventListener').mockImplementation((event, cb, opts) => {
+ if (event === 'DOMContentLoaded') domReadyCallback = cb;
+ return origDocAddEventListener(event, cb, opts);
+});
+
+await import('./interface.js');
+
+// Restore so that tests can use document.addEventListener normally.
+document.addEventListener.mockRestore();
+
+// ─── Test fixtures ────────────────────────────────────────────────────────────
+
+const MANIFESTS = [{ id: 'fast-piggie', name: 'Test Game', description: 'A test game.' }];
+
+const GAME_LOAD_RESULT = {
+ html: 'Game UI
',
+ manifest: { name: 'Test Game', entryPoint: 'index.js' },
+};
+
+function setupApi({ progressData = {}, manifests = MANIFESTS, gameLoad = GAME_LOAD_RESULT } = {}) {
+ const invoke = jest.fn().mockImplementation((channel) => {
+ if (channel === 'progress:load') return Promise.resolve(progressData);
+ if (channel === 'games:list') return Promise.resolve(manifests);
+ if (channel === 'games:load') return Promise.resolve(gameLoad);
+ return Promise.resolve(null);
+ });
+ global.window.api = { invoke, on: jest.fn() };
+ return invoke;
+}
+
+async function flush() {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+function dispatchGameSelect(gameId = 'fast-piggie') {
+ document.getElementById('game-selector').dispatchEvent(
+ new CustomEvent('game:select', { bubbles: true, detail: { gameId } }),
+ );
+}
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+describe('interface.js', () => {
+ beforeEach(() => {
+ document.body.innerHTML =
+ '';
+ document.head.innerHTML = '';
+ mockGameInit.mockClear();
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ document.head.innerHTML = '';
+ });
+
+ // ── DOMContentLoaded initialisation ─────────────────────────────────────────
+
+ describe('DOMContentLoaded initialisation', () => {
+ it('requests progress:load and games:list', async () => {
+ const invoke = setupApi();
+ await domReadyCallback();
+ expect(invoke).toHaveBeenCalledWith('progress:load', { playerId: 'default' });
+ expect(invoke).toHaveBeenCalledWith('games:list');
+ });
+
+ it('appends an aria-live polite announcer to the body', async () => {
+ setupApi();
+ await domReadyCallback();
+ const announcer = document.querySelector('[aria-live="polite"]');
+ expect(announcer).not.toBeNull();
+ expect(announcer.getAttribute('aria-atomic')).toBe('true');
+ expect(announcer.classList.contains('sr-only')).toBe(true);
+ });
+
+ it('renders a game card for each manifest returned', async () => {
+ setupApi();
+ await domReadyCallback();
+ expect(document.querySelectorAll('#game-selector article').length).toBe(MANIFESTS.length);
+ });
+
+ it('passes per-game progress data to game cards when available', async () => {
+ setupApi({ progressData: { games: { 'fast-piggie': { highScore: 42 } } } });
+ await domReadyCallback();
+ const scoreElem = document.querySelector('.game-high-score');
+ expect(scoreElem).not.toBeNull();
+ expect(scoreElem.textContent).toContain('42');
+ });
+
+ it('falls back to empty progress and still renders cards when progress:load rejects',
+ async () => {
+ const invoke = jest.fn().mockImplementation((channel) => {
+ if (channel === 'progress:load') return Promise.reject(new Error('disk error'));
+ if (channel === 'games:list') return Promise.resolve(MANIFESTS);
+ return Promise.resolve(null);
+ });
+ global.window.api = { invoke, on: jest.fn() };
+ await domReadyCallback();
+ expect(document.querySelectorAll('#game-selector article').length).toBe(MANIFESTS.length);
+ });
+
+ it('shows an error message when games:list rejects', async () => {
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { });
+ const invoke = jest.fn().mockImplementation((channel) => {
+ if (channel === 'progress:load') return Promise.resolve({});
+ if (channel === 'games:list') return Promise.reject(new Error('IPC error'));
+ return Promise.resolve(null);
+ });
+ global.window.api = { invoke, on: jest.fn() };
+ await domReadyCallback();
+ expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to load game list:', expect.any(Error));
+ expect(document.getElementById('game-selector').textContent)
+ .toContain('Unable to load games');
+ consoleErrorSpy.mockRestore();
+ });
+ });
+
+ describe('game:select handler', () => {
+ it('removes the game selector from the DOM', async () => {
+ setupApi();
+ await domReadyCallback();
+ dispatchGameSelect();
+ await flush();
+ expect(document.getElementById('game-selector')).toBeNull();
+ });
+
+ it('calls games:load and injects HTML into the game container', async () => {
+ const invoke = setupApi();
+ await domReadyCallback();
+ dispatchGameSelect();
+ await flush();
+ expect(invoke).toHaveBeenCalledWith('games:load', 'fast-piggie');
+ expect(document.getElementById('game-container').innerHTML).toContain('game-ui');
+ });
+
+ it('injects a game stylesheet link into the document head', async () => {
+ setupApi();
+ await domReadyCallback();
+ dispatchGameSelect();
+ await flush();
+ const link = document.getElementById('active-game-stylesheet');
+ expect(link).not.toBeNull();
+ expect(link.href).toContain('fast-piggie');
+ });
+
+ it('replaces an existing game stylesheet rather than duplicating it', async () => {
+ setupApi();
+ await domReadyCallback();
+ // Pre-insert a stylesheet from a previous game.
+ const stale = document.createElement('link');
+ stale.id = 'active-game-stylesheet';
+ stale.href = './games/old-game/style.css';
+ document.head.appendChild(stale);
+ dispatchGameSelect();
+ await flush();
+ const links = document.head.querySelectorAll('#active-game-stylesheet');
+ expect(links.length).toBe(1);
+ expect(links[0].href).toContain('fast-piggie');
+ });
+
+ it('calls the game module init function', async () => {
+ setupApi();
+ await domReadyCallback();
+ dispatchGameSelect();
+ await flush();
+ expect(mockGameInit).toHaveBeenCalledWith(document.getElementById('game-container'));
+ });
+
+ it('sets the announcer text after loading the game', async () => {
+ setupApi();
+ await domReadyCallback();
+ dispatchGameSelect();
+ await flush();
+ const announcer = document.querySelector('[aria-live="polite"]');
+ expect(announcer.textContent).toContain('Test Game');
+ });
+ });
+
+ // ── bsx:return-to-main-menu handler (exercises removeGameStylesheet + restore) ─
+
+ describe('bsx:return-to-main-menu handler', () => {
+ it('clears the game container and removes the active stylesheet', async () => {
+ setupApi();
+ await domReadyCallback();
+ dispatchGameSelect();
+ await flush();
+ // Confirm game is loaded.
+ expect(document.getElementById('active-game-stylesheet')).not.toBeNull();
+
+ window.dispatchEvent(new Event('bsx:return-to-main-menu'));
+ expect(document.getElementById('game-container').querySelector('#game-ui')).toBeNull();
+ expect(document.getElementById('game-container').querySelector('#game-selector'))
+ .not.toBeNull();
+ expect(document.getElementById('active-game-stylesheet')).toBeNull();
+ });
+
+ it('handles removeGameStylesheet gracefully when no stylesheet exists', async () => {
+ setupApi();
+ await domReadyCallback();
+ // Dispatch return-to-menu without ever loading a game (no stylesheet present).
+ expect(() => window.dispatchEvent(new Event('bsx:return-to-main-menu'))).not.toThrow();
+ });
+
+ it('recreates the game-selector section inside the container', async () => {
+ setupApi();
+ await domReadyCallback();
+ dispatchGameSelect();
+ await flush();
+ window.dispatchEvent(new Event('bsx:return-to-main-menu'));
+ await flush();
+ expect(document.getElementById('game-selector')).not.toBeNull();
+ });
+
+ it('reloads and renders game cards after returning to the main menu', async () => {
+ setupApi();
+ await domReadyCallback();
+ dispatchGameSelect();
+ await flush();
+ window.dispatchEvent(new Event('bsx:return-to-main-menu'));
+ await flush();
+ expect(document.querySelectorAll('#game-selector article').length).toBe(MANIFESTS.length);
+ });
+
+ it('sets the announcer text to the main menu message', async () => {
+ setupApi();
+ await domReadyCallback();
+ dispatchGameSelect();
+ await flush();
+ window.dispatchEvent(new Event('bsx:return-to-main-menu'));
+ const announcer = document.querySelector('[aria-live="polite"]');
+ expect(announcer.textContent).toBe('Main menu loaded. Select a game.');
+ });
+
+ it('does not recreate the selector if it already exists in the document', async () => {
+ setupApi();
+ await domReadyCallback();
+ // game-selector is still in its original sibling position (no game:select fired).
+ expect(document.getElementById('game-selector')).not.toBeNull();
+ window.dispatchEvent(new Event('bsx:return-to-main-menu'));
+ // Should not create a duplicate.
+ expect(document.querySelectorAll('#game-selector').length).toBe(1);
+ });
+
+ it('reloads progress-aware game cards on return', async () => {
+ const invoke = jest.fn().mockImplementation((channel) => {
+ if (channel === 'progress:load')
+ return Promise.resolve({ games: { 'fast-piggie': { highScore: 77 } } });
+ if (channel === 'games:list') return Promise.resolve(MANIFESTS);
+ if (channel === 'games:load') return Promise.resolve(GAME_LOAD_RESULT);
+ return Promise.resolve(null);
+ });
+ global.window.api = { invoke, on: jest.fn() };
+ await domReadyCallback();
+ dispatchGameSelect();
+ await flush();
+ window.dispatchEvent(new Event('bsx:return-to-main-menu'));
+ await flush();
+ const scoreElem = document.querySelector('#game-selector .game-high-score');
+ expect(scoreElem).not.toBeNull();
+ expect(scoreElem.textContent).toContain('77');
+ });
+
+ it('handles game:select on the recreated selector after returning to menu', async () => {
+ const invoke = setupApi();
+ await domReadyCallback();
+ dispatchGameSelect();
+ await flush();
+
+ window.dispatchEvent(new Event('bsx:return-to-main-menu'));
+ await flush();
+
+ document.getElementById('game-selector').dispatchEvent(
+ new CustomEvent('game:select', { bubbles: true, detail: { gameId: 'fast-piggie' } }),
+ );
+ await flush();
+
+ expect(invoke).toHaveBeenCalledWith('games:load', 'fast-piggie');
+ expect(mockGameInit).toHaveBeenCalledWith(document.getElementById('game-container'));
+ });
+ });
+
+ // ── game:select error handling ────────────────────────────────────────────
+
+ describe('game:select error handling', () => {
+ it('returns to main menu when games:load rejects on initial selector', async () => {
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn());
+ const invoke = jest.fn().mockImplementation((channel) => {
+ if (channel === 'progress:load') return Promise.resolve({});
+ if (channel === 'games:list') return Promise.resolve(MANIFESTS);
+ if (channel === 'games:load') return Promise.reject(new Error('load error'));
+ return Promise.resolve(null);
+ });
+ global.window.api = { invoke, on: jest.fn() };
+ await domReadyCallback();
+
+ dispatchGameSelect();
+ await flush();
+
+ // The error handler fires bsx:return-to-main-menu, which restores the selector.
+ expect(document.getElementById('game-selector')).not.toBeNull();
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('returns to main menu when games:load rejects on recreated selector', async () => {
+ const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn());
+ const invoke = setupApi();
+ await domReadyCallback();
+ // Load a game successfully first to get to a game view.
+ dispatchGameSelect();
+ await flush();
+
+ // Return to main menu (recreates selector).
+ window.dispatchEvent(new Event('bsx:return-to-main-menu'));
+ await flush();
+
+ // Now make games:load fail.
+ invoke.mockImplementation((channel) => {
+ if (channel === 'progress:load') return Promise.resolve({});
+ if (channel === 'games:list') return Promise.resolve(MANIFESTS);
+ if (channel === 'games:load') return Promise.reject(new Error('load error'));
+ return Promise.resolve(null);
+ });
+
+ document.getElementById('game-selector').dispatchEvent(
+ new CustomEvent('game:select', { bubbles: true, detail: { gameId: 'fast-piggie' } }),
+ );
+ await flush();
+
+ // Selector should be restored after error.
+ expect(document.getElementById('game-selector')).not.toBeNull();
+ consoleErrorSpy.mockRestore();
+ });
+ });
+});
diff --git a/app/preload.test.js b/app/preload.test.js
new file mode 100644
index 0000000..6f0e79a
--- /dev/null
+++ b/app/preload.test.js
@@ -0,0 +1,98 @@
+/** @jest-environment node */
+import { jest } from '@jest/globals';
+
+const mockIpcRenderer = {
+ send: jest.fn(),
+ on: jest.fn(),
+ invoke: jest.fn().mockResolvedValue('mocked-result'),
+};
+
+const mockContextBridge = {
+ exposeInMainWorld: jest.fn(),
+};
+
+global.require = jest.fn((moduleName) => {
+ if (moduleName === 'electron') {
+ return {
+ contextBridge: mockContextBridge,
+ ipcRenderer: mockIpcRenderer,
+ };
+ }
+ throw new Error(`Unexpected module requested: ${moduleName}`);
+});
+
+await import('./preload.js');
+
+// Capture the API object registered via contextBridge once at load time.
+const api = mockContextBridge.exposeInMainWorld.mock.calls[0][1];
+
+describe('preload.js', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockIpcRenderer.invoke.mockResolvedValue('mocked-result');
+ });
+
+ afterAll(() => {
+ delete global.require;
+ });
+
+ it('exposes "api" with send, receive, and invoke via contextBridge', () => {
+ expect(api).toEqual(
+ expect.objectContaining({
+ send: expect.any(Function),
+ receive: expect.any(Function),
+ invoke: expect.any(Function),
+ }),
+ );
+ });
+
+ describe('send', () => {
+ it('calls ipcRenderer.send for an allowed channel', () => {
+ api.send('sample_message', { payload: true });
+ expect(mockIpcRenderer.send).toHaveBeenCalledWith('sample_message', { payload: true });
+ });
+
+ it('does not call ipcRenderer.send for a blocked channel', () => {
+ api.send('blocked_channel', { payload: true });
+ expect(mockIpcRenderer.send).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('receive', () => {
+ it('calls ipcRenderer.on for an allowed channel', () => {
+ api.receive('sample_response', jest.fn());
+ expect(mockIpcRenderer.on).toHaveBeenCalledWith('sample_response', expect.any(Function));
+ });
+
+ it('does not call ipcRenderer.on for a blocked channel', () => {
+ api.receive('blocked_channel', jest.fn());
+ expect(mockIpcRenderer.on).not.toHaveBeenCalled();
+ });
+
+ it('strips the IPC event argument before invoking the callback', () => {
+ const callback = jest.fn();
+ api.receive('sample_response', callback);
+ const wrappedHandler = mockIpcRenderer.on.mock.calls[0][1];
+ wrappedHandler({ preventDefault: jest.fn() }, 'arg1', 'arg2');
+ expect(callback).toHaveBeenCalledWith('arg1', 'arg2');
+ expect(callback).not.toHaveBeenCalledWith(
+ expect.objectContaining({ preventDefault: expect.any(Function) }), 'arg1', 'arg2');
+ });
+ });
+
+ describe('invoke', () => {
+ it.each(['games:list', 'games:load', 'progress:save', 'progress:load', 'progress:reset'])(
+ 'calls ipcRenderer.invoke for allowed channel "%s"',
+ async (channel) => {
+ await api.invoke(channel, { data: 1 });
+ expect(mockIpcRenderer.invoke).toHaveBeenCalledWith(channel, { data: 1 });
+ },
+ );
+
+ it('rejects with a descriptive error for a blocked channel', async () => {
+ await expect(api.invoke('blocked_channel', {})).rejects.toThrow(
+ 'Blocked IPC channel: blocked_channel',
+ );
+ });
+ });
+});
diff --git a/package-lock.json b/package-lock.json
index f1041bd..1a30aa6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
- "name": "brainspeedexercises",
- "version": "1.0.0",
+ "name": "brain-speed-exercises",
+ "version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "brainspeedexercises",
- "version": "1.0.0",
+ "name": "brain-speed-exercises",
+ "version": "0.1.0",
"license": "MIT",
"dependencies": {
"electron": "^38"