diff --git a/packages/desktop/__tests__/laser-view.test.ts b/packages/desktop/__tests__/laser-view.test.ts new file mode 100644 index 0000000..7d13d22 --- /dev/null +++ b/packages/desktop/__tests__/laser-view.test.ts @@ -0,0 +1,120 @@ +/** + * The embedded laser view is the one surface that can silently go black: a load + * that fails while the brain restarts leaves a native view with nothing painted + * in it, and the renderer has no reason to ask again. These tests pin the + * recovery, not the Electron plumbing. + */ +const webContents = { + isDestroyed: () => false, + setWindowOpenHandler: jest.fn(), + on: jest.fn(), + loadURL: jest.fn, [string]>() +}; +const viewInstance = { + webContents, + setVisible: jest.fn(), + setBounds: jest.fn() +}; + +jest.mock('electron', () => ({ + shell: { openExternal: jest.fn() }, + WebContentsView: jest.fn(() => viewInstance) +})); +jest.mock('@/main/brain', () => ({ status: () => ({ project: 'grace' }) })); +jest.mock('@/main/operator-session', () => ({ embeddedUrl: (url: string) => `${url}#wg_token=t` })); +jest.mock('@/main/runtime', () => ({ + runtime: { mainWindow: { isDestroyed: () => false, contentView: { addChildView: jest.fn() } } } +})); + +import { invalidateLaserView, resetLaserView, syncLaser } from '@/main/laser-view'; + +const URL = 'http://127.0.0.1:3000/'; +const show = (url: string | null = URL) => + syncLaser({ url, bounds: { x: 0, y: 0, width: 100, height: 100 }, visible: url != null }); + +const flush = () => new Promise((r) => setImmediate(r)); + +beforeEach(() => { + // setImmediate stays real so `flush()` can drain the promise queue between + // the retry timers this exercises. + jest.useFakeTimers({ doNotFake: ['setImmediate'] }); + resetLaserView(); + webContents.loadURL.mockReset().mockResolvedValue(undefined); + viewInstance.setVisible.mockClear(); +}); + +afterEach(() => jest.useRealTimers()); + +const visibility = () => viewInstance.setVisible.mock.calls.map(([v]) => v); + +it('shows the view only once the page has painted', async () => { + let resolveLoad: () => void = () => undefined; + webContents.loadURL.mockReturnValueOnce(new Promise((r) => (resolveLoad = r))); + + show(); + expect(visibility().at(-1)).toBe(false); + + resolveLoad(); + await flush(); + expect(visibility().at(-1)).toBe(true); +}); + +it('retries a load that failed while the brain was restarting', async () => { + webContents.loadURL.mockRejectedValueOnce(new Error('ERR_CONNECTION_REFUSED')); + + show(); + await flush(); + expect(visibility().at(-1)).toBe(false); + + jest.advanceTimersByTime(250); + await flush(); + expect(webContents.loadURL).toHaveBeenCalledTimes(2); + expect(visibility().at(-1)).toBe(true); +}); + +it('backs off rather than hammering a brain that is still down', async () => { + webContents.loadURL.mockRejectedValue(new Error('down')); + + show(); + await flush(); + for (const delay of [250, 500, 1000]) { + jest.advanceTimersByTime(delay); + await flush(); + } + expect(webContents.loadURL).toHaveBeenCalledTimes(4); +}); + +it('stops retrying once the operator leaves the show', async () => { + webContents.loadURL.mockRejectedValue(new Error('down')); + + show(); + await flush(); + show(null); + jest.advanceTimersByTime(5000); + await flush(); + expect(webContents.loadURL).toHaveBeenCalledTimes(1); + expect(visibility().at(-1)).toBe(false); +}); + +it('reloads on a project switch, hiding the stale page until the new one paints', async () => { + show(); + await flush(); + viewInstance.setVisible.mockClear(); + + let resolveLoad: () => void = () => undefined; + webContents.loadURL.mockReturnValueOnce(new Promise((r) => (resolveLoad = r))); + invalidateLaserView(); + expect(webContents.loadURL).toHaveBeenCalledTimes(2); + + resolveLoad(); + await flush(); + expect(visibility().at(-1)).toBe(true); +}); + +it('does not reload an unchanged url on every sync', async () => { + show(); + await flush(); + show(); + show(); + expect(webContents.loadURL).toHaveBeenCalledTimes(1); +}); diff --git a/packages/desktop/src/main/laser-view.ts b/packages/desktop/src/main/laser-view.ts index d1a81af..b4ce643 100644 --- a/packages/desktop/src/main/laser-view.ts +++ b/packages/desktop/src/main/laser-view.ts @@ -16,28 +16,91 @@ import type { LaserSyncState } from '@/types/ipc'; export type { LaserSyncState } from '@/types/ipc'; let view: WebContentsView | null = null; +/** What the renderer wants shown, independent of what has actually loaded. */ +let desiredUrl: string | null = null; +/** The URL currently loaded (or loading). Null means "nothing usable is up". */ let loadedUrl: string | null = null; +/** True once the current load painted; a loading view is a black rectangle. */ +let painted = false; +let retryTimer: NodeJS.Timeout | null = null; +let attempts = 0; +/** Bumped per load so a superseded one (which rejects with ERR_ABORTED) cannot + * report failure for the load that replaced it. */ +let loadSeq = 0; + +const MAX_RETRY_MS = 2000; export function resetLaserView(): void { + if (retryTimer) clearTimeout(retryTimer); + retryTimer = null; + attempts = 0; + loadSeq += 1; view = null; + desiredUrl = null; loadedUrl = null; + painted = false; } /** - * Drop the loaded-URL memo so the next sync reloads the page. The brain serves - * a different project on the same origin after a project switch, so without - * this the embedded UI keeps rendering the previous project's layout. + * A load can fail for a perfectly ordinary reason — the brain is mid-restart, + * or a second load aborted this one — and the renderer has no reason to send + * another sync afterwards, so nothing would ever ask again and the panel would + * stay black until the operator reloaded the app. Retry it here instead. */ -export function invalidateLaserView(): void { - const previous = loadedUrl; +function retryLater(url: string): void { loadedUrl = null; - if (!view || view.webContents.isDestroyed() || !previous) return; + painted = false; + if (retryTimer || desiredUrl !== url) return; + const delay = Math.min(MAX_RETRY_MS, 250 * 2 ** attempts); + attempts += 1; + retryTimer = setTimeout(() => { + retryTimer = null; + if (desiredUrl === url) load(url); + }, delay); +} + +function load(url: string): void { + const v = ensureView(); + if (!v) return; + loadedUrl = url; + painted = false; + const seq = ++loadSeq; + // Carries a session for the active project, so a project switch doesn't + // strand the operator on a login screen inside their own app. + v.webContents + .loadURL(embeddedUrl(url, status().project)) + .then(() => { + if (seq !== loadSeq) return; + attempts = 0; + painted = true; + applyVisibility(); + }) + .catch(() => { + if (seq === loadSeq) retryLater(url); + }); +} + +/** Keep the black rectangle of an unpainted view off-screen: until the page has + * loaded the renderer's own empty state is the better thing to look at. */ +function applyVisibility(): void { + if (!view || view.webContents.isDestroyed()) return; + view.setVisible(Boolean(desiredUrl) && painted); +} + +/** + * Reload the embedded UI. The brain serves a different project on the same + * origin after a project switch, so without this the embedded UI keeps + * rendering the previous project's layout. + */ +export function invalidateLaserView(): void { + attempts = 0; + if (!view || view.webContents.isDestroyed() || !desiredUrl) { + loadedUrl = null; + return; + } // Not `reload()`: the UI strips the session token out of the address once it // has consumed it, so reloading would land on the new project's login screen. - void view.webContents.loadURL(embeddedUrl(previous, status().project)).catch(() => { - // Brain restarting — the next sync loads it. - }); - loadedUrl = previous; + load(desiredUrl); } function ensureView(): WebContentsView | null { @@ -53,10 +116,8 @@ function ensureView(): WebContentsView | null { void shell.openExternal(url); return { action: 'deny' }; }); - // A load that failed (brain not listening yet) must not count as loaded, or - // the URL gate below would never retry and the panel would stay blank. - created.webContents.on('did-fail-load', () => { - loadedUrl = null; + created.webContents.on('did-fail-load', (_e, _code, _desc, _url, isMainFrame) => { + if (isMainFrame && desiredUrl && loadedUrl === desiredUrl) retryLater(desiredUrl); }); win.contentView.addChildView(created); view = created; @@ -67,24 +128,21 @@ function ensureView(): WebContentsView | null { export function syncLaser(state: LaserSyncState): void { const { url, bounds, visible } = state; if (!url || !visible) { - if (view && !view.webContents.isDestroyed()) view.setVisible(false); + desiredUrl = null; + if (retryTimer) clearTimeout(retryTimer); + retryTimer = null; + applyVisibility(); return; } + desiredUrl = url; const v = ensureView(); if (!v) return; - if (loadedUrl !== url) { - loadedUrl = url; - // Carries a session for the active project, so a project switch doesn't - // strand the operator on a login screen inside their own app. - void v.webContents.loadURL(embeddedUrl(url, status().project)).catch(() => { - // Brain not up yet / refused — the renderer shows its own empty state. - }); - } + if (loadedUrl !== url) load(url); v.setBounds({ x: Math.round(bounds.x), y: Math.round(bounds.y), width: Math.round(bounds.width), height: Math.round(bounds.height) }); - v.setVisible(true); + applyVisibility(); }