CRITICAL BUG. In real SPAs, useEffect fires during document load (microtask after bundle execute + React render), which is BEFORE did-finish-load fires (after window.onload). Without this fix, the ping arrived when bootArmed=false (no-op), then did-finish-load armed a timer nothing would clear → 20s later every successful packaged build falsely entered recovery. Caught by smoke scenario 13 (positive control: page that DOES ping should NOT recover). The smoke proved the page's script ran AND the ping was sent, yet recovery still fired. Fix: module-level pingReceivedThisNav flag, reset on did-navigate, set in handleRendererReady, checked in armBootTimer (early-return if true). Late-ping case (ping after arm) preserved via existing 'if (bootArmed) clearBootTimer()'. Also exports resetBootTimerStateForTest() to ensure full module-state isolation between tests (pingReceivedThisNav is module-level and must not bleed across test cases in the same run). 3 new tests pin the early-ping, late-ping, and per-nav persistence semantics. 48/48 tests pass. Build clean. Spec + docs updated.
345 lines
13 KiB
TypeScript
345 lines
13 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import type { MenuItemConstructorOptions } from 'electron';
|
|
import {
|
|
RecoveryStateStore,
|
|
extractErrorCode,
|
|
buildTrayMenuTemplate,
|
|
buildAppMenuTemplate,
|
|
type RecoveryState,
|
|
armBootTimer,
|
|
clearBootTimer,
|
|
isBootArmed,
|
|
handleRendererReady,
|
|
resetBootTimerStateForTest,
|
|
} from './recovery';
|
|
|
|
// Mock electron with isPackaged=true so the real arm path executes in all tests below.
|
|
vi.mock('electron', () => ({
|
|
app: { isPackaged: true },
|
|
}));
|
|
|
|
describe('RecoveryStateStore', () => {
|
|
it('returns the initial state', () => {
|
|
const store = new RecoveryStateStore();
|
|
const s = store.get();
|
|
expect(s.mode).toBe('normal');
|
|
expect(s.reason).toBeNull();
|
|
expect(s.updateState).toBe('idle');
|
|
expect(s.updateVersion).toBeNull();
|
|
expect(s.lastUpdateError).toBeNull();
|
|
expect(s.lastCheckResult).toBeNull();
|
|
});
|
|
|
|
it('shallow-merges partial updates', () => {
|
|
const store = new RecoveryStateStore();
|
|
store.update({ updateState: 'checking' });
|
|
expect(store.get().updateState).toBe('checking');
|
|
expect(store.get().mode).toBe('normal');
|
|
store.update({ updateVersion: '1.2.3' });
|
|
expect(store.get().updateState).toBe('checking');
|
|
expect(store.get().updateVersion).toBe('1.2.3');
|
|
});
|
|
|
|
it('fires listeners on every update', () => {
|
|
const store = new RecoveryStateStore();
|
|
const cb = vi.fn();
|
|
store.subscribe(cb);
|
|
store.update({ updateState: 'checking' });
|
|
store.update({ updateState: 'idle' });
|
|
expect(cb).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('subscribers receive the latest snapshot', () => {
|
|
const store = new RecoveryStateStore();
|
|
let received: RecoveryState | null = null;
|
|
store.subscribe((s) => { received = s; });
|
|
store.update({ updateState: 'downloaded', updateVersion: '2.0.0' });
|
|
expect(received).not.toBeNull();
|
|
expect(received!.updateState).toBe('downloaded');
|
|
expect(received!.updateVersion).toBe('2.0.0');
|
|
});
|
|
|
|
it('unsubscribe stops callbacks', () => {
|
|
const store = new RecoveryStateStore();
|
|
const cb = vi.fn();
|
|
const unsub = store.subscribe(cb);
|
|
store.update({ updateState: 'checking' });
|
|
unsub();
|
|
store.update({ updateState: 'idle' });
|
|
expect(cb).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('multiple subscribers all receive updates', () => {
|
|
const store = new RecoveryStateStore();
|
|
const a = vi.fn();
|
|
const b = vi.fn();
|
|
store.subscribe(a);
|
|
store.subscribe(b);
|
|
store.update({ updateState: 'checking' });
|
|
expect(a).toHaveBeenCalledTimes(1);
|
|
expect(b).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('isInRecoveryMode is false initially, true after markRecoveryEntered, false after markRecoveryExited', () => {
|
|
const store = new RecoveryStateStore();
|
|
expect(store.isInRecoveryMode()).toBe(false);
|
|
store.markRecoveryEntered();
|
|
expect(store.isInRecoveryMode()).toBe(true);
|
|
store.markRecoveryExited();
|
|
expect(store.isInRecoveryMode()).toBe(false);
|
|
});
|
|
|
|
it('a throwing listener does not stop other listeners', () => {
|
|
const store = new RecoveryStateStore();
|
|
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
const a = vi.fn(() => { throw new Error('boom'); });
|
|
const b = vi.fn();
|
|
store.subscribe(a);
|
|
store.subscribe(b);
|
|
expect(() => store.update({ updateState: 'checking' })).not.toThrow();
|
|
expect(a).toHaveBeenCalledTimes(1);
|
|
expect(b).toHaveBeenCalledTimes(1);
|
|
errSpy.mockRestore();
|
|
});
|
|
|
|
it('a listener can unsubscribe itself during notification without breaking the pass', () => {
|
|
const store = new RecoveryStateStore();
|
|
const b = vi.fn();
|
|
let unsubA: (() => void) | null = null;
|
|
unsubA = store.subscribe(() => { unsubA?.(); });
|
|
store.subscribe(b);
|
|
expect(() => store.update({ updateState: 'checking' })).not.toThrow();
|
|
// Second update: the self-unsubscribed listener should be gone, b still fires
|
|
store.update({ updateState: 'idle' });
|
|
expect(b).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('returned state is frozen — accidental external mutation throws in strict mode', () => {
|
|
const store = new RecoveryStateStore();
|
|
store.update({ updateState: 'checking' });
|
|
const s = store.get();
|
|
expect(Object.isFrozen(s)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('extractErrorCode', () => {
|
|
it('extracts string code from Error-like object', () => {
|
|
const err = new Error('boom') as Error & { code: string };
|
|
err.code = 'ENOSPC';
|
|
expect(extractErrorCode(err)).toBe('ENOSPC');
|
|
});
|
|
|
|
it('returns null for Error without code', () => {
|
|
expect(extractErrorCode(new Error('boom'))).toBeNull();
|
|
});
|
|
|
|
it('returns null when code is non-string', () => {
|
|
const err = { code: 42 };
|
|
expect(extractErrorCode(err)).toBeNull();
|
|
});
|
|
|
|
it('returns null for null/undefined/primitives', () => {
|
|
expect(extractErrorCode(null)).toBeNull();
|
|
expect(extractErrorCode(undefined)).toBeNull();
|
|
expect(extractErrorCode('string')).toBeNull();
|
|
expect(extractErrorCode(42)).toBeNull();
|
|
});
|
|
|
|
it('handles plain objects with code', () => {
|
|
expect(extractErrorCode({ code: 'NET_FAIL' })).toBe('NET_FAIL');
|
|
});
|
|
});
|
|
|
|
function defaultState(overrides?: Partial<RecoveryState>): RecoveryState {
|
|
return {
|
|
mode: 'normal',
|
|
reason: null,
|
|
updateState: 'idle',
|
|
updateVersion: null,
|
|
lastUpdateError: null,
|
|
lastCheckResult: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe('buildTrayMenuTemplate', () => {
|
|
it('includes Show/Hide/Change Instance/Quit base items', () => {
|
|
const items = buildTrayMenuTemplate(defaultState());
|
|
const labels = items.map((i) => i.label);
|
|
expect(labels).toContain('Show Backspace');
|
|
expect(labels).toContain('Hide');
|
|
expect(labels).toContain('Change Instance');
|
|
expect(labels).toContain('Quit');
|
|
});
|
|
|
|
it('includes Check for Updates with idle label when updateState=idle', () => {
|
|
const items = buildTrayMenuTemplate(defaultState({ updateState: 'idle' }));
|
|
const item = items.find((i) => i.id === 'check-for-updates');
|
|
expect(item).toBeDefined();
|
|
expect(item!.label).toBe('Check for Updates…');
|
|
expect(item!.enabled).toBe(true);
|
|
});
|
|
|
|
it('disables Check for Updates while checking', () => {
|
|
const items = buildTrayMenuTemplate(defaultState({ updateState: 'checking' }));
|
|
const item = items.find((i) => i.id === 'check-for-updates');
|
|
expect(item!.label).toBe('Checking for Updates…');
|
|
expect(item!.enabled).toBe(false);
|
|
});
|
|
|
|
it('disables Check for Updates while downloading', () => {
|
|
const items = buildTrayMenuTemplate(defaultState({ updateState: 'downloading' }));
|
|
const item = items.find((i) => i.id === 'check-for-updates');
|
|
expect(item!.label).toBe('Downloading Update…');
|
|
expect(item!.enabled).toBe(false);
|
|
});
|
|
|
|
it('shows Update Ready label when downloaded, with Check disabled (Restart is the action)', () => {
|
|
const items = buildTrayMenuTemplate(defaultState({ updateState: 'downloaded' }));
|
|
const item = items.find((i) => i.id === 'check-for-updates');
|
|
expect(item!.label).toBe('Update Ready');
|
|
expect(item!.enabled).toBe(false);
|
|
});
|
|
|
|
it('shows error suffix on Check for Updates label when updateState=error', () => {
|
|
const items = buildTrayMenuTemplate(defaultState({ updateState: 'error' }));
|
|
const item = items.find((i) => i.id === 'check-for-updates');
|
|
expect(item!.label).toBe('Check for Updates… (last attempt failed)');
|
|
expect(item!.enabled).toBe(true);
|
|
});
|
|
|
|
it('inserts Restart to Install Update only when updateState=downloaded', () => {
|
|
expect(
|
|
buildTrayMenuTemplate(defaultState({ updateState: 'idle' })).find((i) => i.id === 'restart-to-install'),
|
|
).toBeUndefined();
|
|
expect(
|
|
buildTrayMenuTemplate(defaultState({ updateState: 'downloading' })).find((i) => i.id === 'restart-to-install'),
|
|
).toBeUndefined();
|
|
const downloadedItem = buildTrayMenuTemplate(defaultState({ updateState: 'downloaded' }))
|
|
.find((i) => i.id === 'restart-to-install');
|
|
expect(downloadedItem).toBeDefined();
|
|
expect(downloadedItem!.label).toBe('Restart to Install Update');
|
|
expect(downloadedItem!.enabled).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('buildAppMenuTemplate', () => {
|
|
it('returns top-level menu with App, Edit, Window submenus', () => {
|
|
const template = buildAppMenuTemplate('Backspace', defaultState());
|
|
const [appMenu, editMenu, windowMenu] = template;
|
|
expect(template.length).toBeGreaterThanOrEqual(3);
|
|
expect(appMenu!.label).toBe('Backspace');
|
|
expect(editMenu!.label).toBe('Edit');
|
|
expect(windowMenu!.label).toBe('Window');
|
|
});
|
|
|
|
it('App submenu includes About and Change Instance', () => {
|
|
const template = buildAppMenuTemplate('Backspace', defaultState());
|
|
const [appMenu] = template;
|
|
const appSub = appMenu!.submenu as MenuItemConstructorOptions[];
|
|
const labels = appSub.map((i) => i.label).filter(Boolean);
|
|
expect(appSub.find((i) => i.role === 'about')).toBeDefined();
|
|
expect(labels).toContain('Change Instance');
|
|
expect(appSub.find((i) => i.role === 'quit')).toBeDefined();
|
|
});
|
|
|
|
it('App submenu includes Check for Updates with state-correct label', () => {
|
|
const template = buildAppMenuTemplate('Backspace', defaultState({ updateState: 'idle' }));
|
|
const [appMenu] = template;
|
|
const appSub = appMenu!.submenu as MenuItemConstructorOptions[];
|
|
const item = appSub.find((i) => i.id === 'check-for-updates');
|
|
expect(item).toBeDefined();
|
|
expect(item!.label).toBe('Check for Updates…');
|
|
});
|
|
|
|
it('App submenu inserts Restart to Install Update only when downloaded', () => {
|
|
const [idleAppMenu] = buildAppMenuTemplate('Backspace', defaultState({ updateState: 'idle' }));
|
|
const idleSub = idleAppMenu!.submenu as MenuItemConstructorOptions[];
|
|
expect(idleSub.find((i) => i.id === 'restart-to-install')).toBeUndefined();
|
|
|
|
const [dlAppMenu] = buildAppMenuTemplate('Backspace', defaultState({ updateState: 'downloaded' }));
|
|
const dlSub = dlAppMenu!.submenu as MenuItemConstructorOptions[];
|
|
const restartItem = dlSub.find((i) => i.id === 'restart-to-install');
|
|
expect(restartItem).toBeDefined();
|
|
expect(restartItem!.label).toBe('Restart to Install Update');
|
|
});
|
|
});
|
|
|
|
interface FakeWebContents { getURL: () => string }
|
|
interface FakeWindow { webContents: FakeWebContents; isDestroyed: () => boolean }
|
|
|
|
function fakeWin(url: string): FakeWindow {
|
|
return { webContents: { getURL: () => url }, isDestroyed: () => false };
|
|
}
|
|
|
|
describe('armBootTimer / clearBootTimer', () => {
|
|
beforeEach(() => {
|
|
// Reset all module-level boot-timer state including pingReceivedThisNav so
|
|
// tests that call handleRendererReady() don't pollute subsequent tests.
|
|
resetBootTimerStateForTest();
|
|
});
|
|
|
|
it('arms when URL is http://', () => {
|
|
armBootTimer(fakeWin('http://localhost:3005/') as never);
|
|
expect(isBootArmed()).toBe(true);
|
|
clearBootTimer();
|
|
});
|
|
|
|
it('arms when URL is https://', () => {
|
|
armBootTimer(fakeWin('https://example.com/') as never);
|
|
expect(isBootArmed()).toBe(true);
|
|
clearBootTimer();
|
|
});
|
|
|
|
it('skips file:// URLs', () => {
|
|
armBootTimer(fakeWin('file:///path/to/recovery.html') as never);
|
|
expect(isBootArmed()).toBe(false);
|
|
});
|
|
|
|
it('skips empty URLs', () => {
|
|
armBootTimer(fakeWin('') as never);
|
|
expect(isBootArmed()).toBe(false);
|
|
});
|
|
|
|
it('clearBootTimer disarms', () => {
|
|
armBootTimer(fakeWin('http://localhost/') as never);
|
|
expect(isBootArmed()).toBe(true);
|
|
clearBootTimer();
|
|
expect(isBootArmed()).toBe(false);
|
|
});
|
|
|
|
it('does NOT arm if rendererReady arrived before armBootTimer (early-ping case)', () => {
|
|
// Simulate SPA timing: useEffect fires (microtask) before did-finish-load.
|
|
// The ping arrives when bootArmed=false, then armBootTimer is called by
|
|
// did-finish-load. Without the pingReceivedThisNav flag this would arm a
|
|
// 20s timer that nothing clears → false renderer-stalled recovery.
|
|
handleRendererReady();
|
|
armBootTimer(fakeWin('http://localhost:3005/') as never);
|
|
expect(isBootArmed()).toBe(false);
|
|
});
|
|
|
|
it('clears existing timer if rendererReady arrives after armBootTimer (late-ping case)', () => {
|
|
// Simulate the less-common ordering: did-finish-load fires first (arms timer),
|
|
// then the ping arrives. Preserved existing behavior — ping clears the timer.
|
|
armBootTimer(fakeWin('http://localhost:3005/') as never);
|
|
expect(isBootArmed()).toBe(true);
|
|
handleRendererReady();
|
|
expect(isBootArmed()).toBe(false);
|
|
});
|
|
|
|
it('flag is per-navigation: after clearBootTimer reset, a subsequent arm should still be blocked by the set flag', () => {
|
|
// Verify that pingReceivedThisNav persists across clearBootTimer calls until
|
|
// a real did-navigate resets it. The module-level flag is only reset by
|
|
// did-navigate (wired in attachRecoveryHandlers). Here we confirm the flag
|
|
// behaviour in isolation: ping → arm (blocked) → clear → arm again (still blocked).
|
|
// The per-nav reset is integration-tested by smoke scenario 4 + 13 together.
|
|
handleRendererReady();
|
|
armBootTimer(fakeWin('http://localhost:3005/') as never);
|
|
expect(isBootArmed()).toBe(false);
|
|
// clearBootTimer resets bootArmed but NOT pingReceivedThisNav
|
|
clearBootTimer();
|
|
armBootTimer(fakeWin('http://localhost:3005/') as never);
|
|
expect(isBootArmed()).toBe(false);
|
|
});
|
|
});
|