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
120 changes: 120 additions & 0 deletions packages/desktop/__tests__/laser-view.test.ts
Original file line number Diff line number Diff line change
@@ -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<Promise<void>, [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<void>((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<void>((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);
});
106 changes: 82 additions & 24 deletions packages/desktop/src/main/laser-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -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();
}
Loading