mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* fix(electron): show the main window once its document has loaded; enforce J1 IPC and mutation counters Re-lands #1788, which merged into #1782's branch after #1782 had already reached master, so none of it is on master. The hidden main window was shown on ready-to-show only. On Linux under X11, when the startup scripts run before the window's first frame, the next frame comes about a second later: nothing is on screen and the splash's requestAnimationFrame waits, so J1's first card came ~940 ms after load instead of ~480 ms in most runner launches (18 bridge calls / 1,031-1,033 DOM mutations instead of 15 / 558). The window is now shown at ready-to-show or the main frame's did-finish-load, whichever comes first, with the splash colour as its background so showing before the first paint does not flash. The journey gate keeps the app's did-finish-load listeners away from its about:blank detour, as it already does for ready-to-show. Three dispatched runs on this branch (37192092882, 37192097790, 37192103151) read 15 calls and 558 mutations in all 18 iterations, stable: true. Both become baselines (slack 0), and the Performance journeys job checks them with check-journey-ratchet.mjs --only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(perf): record the evidence PR of the J1 runtime baselines Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: 4gray <fourgray@proton.me>
297 lines
9.6 KiB
TypeScript
297 lines
9.6 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { EventEmitter } from 'node:events';
|
|
import test from 'node:test';
|
|
|
|
interface GateState {
|
|
blankLoadedEpochMs: number | null;
|
|
didFinishLoadHeldOnBlank: number;
|
|
errors: string[];
|
|
gatedEpochMs: number | null;
|
|
gatedMethod: string | null;
|
|
passThroughLoads: number;
|
|
readyToShowHeldOnBlank: number;
|
|
releasedEpochMs: number | null;
|
|
timedOut: boolean;
|
|
}
|
|
|
|
interface GateApi {
|
|
invokeHandler(channel: string, ...args: unknown[]): Promise<unknown>;
|
|
release(): GateState;
|
|
state: GateState;
|
|
}
|
|
|
|
interface FakeIpcMain {
|
|
handle(channel: string, listener: (...args: unknown[]) => unknown): void;
|
|
}
|
|
|
|
interface GateModule {
|
|
GATE_KEY: string;
|
|
installJourneyRendererGate(
|
|
browserWindow: { prototype: Record<string, unknown> },
|
|
target: Record<string, unknown>,
|
|
options?: {
|
|
ipcMain?: FakeIpcMain;
|
|
now?: () => number;
|
|
timeoutMs?: number;
|
|
}
|
|
): GateApi;
|
|
TAPPED_IPC_CHANNELS: string[];
|
|
}
|
|
|
|
// The e2e project compiles to CommonJS, so the hook is loaded with require.
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
const gateModule = require('./journey-renderer-gate.cjs') as GateModule;
|
|
|
|
function createFakeBrowserWindow(log: string[]) {
|
|
class FakeBrowserWindow {
|
|
webContents = {
|
|
loadURL: async (url: string) => {
|
|
log.push(`webContents.loadURL:${url}`);
|
|
},
|
|
};
|
|
async loadFile(file: string): Promise<string> {
|
|
log.push(`loadFile:${file}`);
|
|
return `loaded:${file}`;
|
|
}
|
|
async loadURL(url: string): Promise<string> {
|
|
log.push(`loadURL:${url}`);
|
|
return `loaded:${url}`;
|
|
}
|
|
}
|
|
return FakeBrowserWindow;
|
|
}
|
|
|
|
function settle(): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, 5));
|
|
}
|
|
|
|
test('the module does not touch Electron when loaded outside it', () => {
|
|
assert.equal(typeof gateModule.installJourneyRendererGate, 'function');
|
|
assert.equal(gateModule.GATE_KEY, '__iptvnatorJourneyGate');
|
|
assert.equal(
|
|
(globalThis as Record<string, unknown>)[gateModule.GATE_KEY],
|
|
undefined
|
|
);
|
|
});
|
|
|
|
test('holds the first load behind about:blank until released, then passes later loads through', async () => {
|
|
const log: string[] = [];
|
|
const FakeBrowserWindow = createFakeBrowserWindow(log);
|
|
const target: Record<string, unknown> = {};
|
|
let clock = 100;
|
|
const api = gateModule.installJourneyRendererGate(
|
|
FakeBrowserWindow as unknown as { prototype: Record<string, unknown> },
|
|
target,
|
|
{ now: () => clock++, timeoutMs: 60_000 }
|
|
);
|
|
assert.equal(target[gateModule.GATE_KEY], api);
|
|
const window = new FakeBrowserWindow();
|
|
const load = window.loadFile('index.html');
|
|
await settle();
|
|
assert.deepEqual(log, ['webContents.loadURL:about:blank']);
|
|
assert.equal(api.state.gatedMethod, 'loadFile');
|
|
assert.equal(api.state.gatedEpochMs, 100);
|
|
assert.equal(api.state.blankLoadedEpochMs, 101);
|
|
assert.equal(api.state.releasedEpochMs, null);
|
|
|
|
api.release();
|
|
assert.equal(await load, 'loaded:index.html');
|
|
assert.deepEqual(log, [
|
|
'webContents.loadURL:about:blank',
|
|
'loadFile:index.html',
|
|
]);
|
|
assert.equal(api.state.releasedEpochMs, 102);
|
|
assert.equal(api.state.timedOut, false);
|
|
|
|
assert.equal(
|
|
await window.loadURL('http://localhost/'),
|
|
'loaded:http://localhost/'
|
|
);
|
|
assert.equal(api.state.passThroughLoads, 1);
|
|
assert.equal(api.release().releasedEpochMs, 102);
|
|
});
|
|
|
|
test('releases itself after the timeout and records it', async () => {
|
|
const log: string[] = [];
|
|
const FakeBrowserWindow = createFakeBrowserWindow(log);
|
|
const api = gateModule.installJourneyRendererGate(
|
|
FakeBrowserWindow as unknown as { prototype: Record<string, unknown> },
|
|
{},
|
|
{ timeoutMs: 10 }
|
|
);
|
|
const window = new FakeBrowserWindow();
|
|
assert.equal(
|
|
await window.loadURL('http://localhost/'),
|
|
'loaded:http://localhost/'
|
|
);
|
|
assert.equal(api.state.timedOut, true);
|
|
assert.equal(typeof api.state.releasedEpochMs, 'number');
|
|
});
|
|
|
|
test('records a failed about:blank navigation and still loads after release', async () => {
|
|
class BrokenBrowserWindow {
|
|
webContents = {
|
|
loadURL: async () => {
|
|
throw new Error('blank-failed');
|
|
},
|
|
};
|
|
async loadFile(file: string): Promise<string> {
|
|
return `loaded:${file}`;
|
|
}
|
|
}
|
|
const api = gateModule.installJourneyRendererGate(
|
|
BrokenBrowserWindow as unknown as {
|
|
prototype: Record<string, unknown>;
|
|
},
|
|
{},
|
|
{ timeoutMs: 60_000 }
|
|
);
|
|
const load = new BrokenBrowserWindow().loadFile('index.html');
|
|
await settle();
|
|
assert.deepEqual(api.state.errors, ['blank-failed']);
|
|
api.release();
|
|
assert.equal(await load, 'loaded:index.html');
|
|
});
|
|
|
|
function createEmittingBrowserWindow(log: string[]) {
|
|
class EmittingBrowserWindow extends EventEmitter {
|
|
url = '';
|
|
webContents = Object.assign(new EventEmitter(), {
|
|
getURL: () => this.url,
|
|
loadURL: async (url: string) => {
|
|
this.url = url;
|
|
log.push(`webContents.loadURL:${url}`);
|
|
},
|
|
});
|
|
async loadFile(file: string): Promise<void> {
|
|
this.url = `file:///${file}`;
|
|
log.push(`loadFile:${file}`);
|
|
}
|
|
}
|
|
return EmittingBrowserWindow;
|
|
}
|
|
|
|
test('holds ready-to-show while the window shows about:blank, then lets the real one through', async () => {
|
|
const log: string[] = [];
|
|
const EmittingBrowserWindow = createEmittingBrowserWindow(log);
|
|
const api = gateModule.installJourneyRendererGate(
|
|
EmittingBrowserWindow as unknown as {
|
|
prototype: Record<string, unknown>;
|
|
},
|
|
{},
|
|
{ timeoutMs: 60_000 }
|
|
);
|
|
const window = new EmittingBrowserWindow();
|
|
window.once('ready-to-show', () => log.push('app:ready-to-show'));
|
|
const load = window.loadFile('index.html');
|
|
await settle();
|
|
// Electron's first paint of about:blank.
|
|
assert.equal(window.emit('ready-to-show'), false);
|
|
window.emit('did-finish-load');
|
|
assert.equal(api.state.readyToShowHeldOnBlank, 1);
|
|
|
|
api.release();
|
|
await load;
|
|
window.emit('ready-to-show');
|
|
|
|
assert.deepEqual(log, [
|
|
'webContents.loadURL:about:blank',
|
|
'loadFile:index.html',
|
|
'app:ready-to-show',
|
|
]);
|
|
assert.equal(api.state.readyToShowHeldOnBlank, 1);
|
|
});
|
|
|
|
test('keeps did-finish-load of about:blank from the app listeners, not from later ones', async () => {
|
|
const log: string[] = [];
|
|
const EmittingBrowserWindow = createEmittingBrowserWindow(log);
|
|
const api = gateModule.installJourneyRendererGate(
|
|
EmittingBrowserWindow as unknown as {
|
|
prototype: Record<string, unknown>;
|
|
},
|
|
{},
|
|
{ timeoutMs: 60_000 }
|
|
);
|
|
const window = new EmittingBrowserWindow();
|
|
// The app shows its window at the first did-finish-load.
|
|
window.webContents.once('did-finish-load', () =>
|
|
log.push('app:did-finish-load')
|
|
);
|
|
const load = window.loadFile('index.html');
|
|
await settle();
|
|
// Registered after the gated load, like Electron's own listener that
|
|
// resolves loadURL(about:blank).
|
|
window.webContents.on('did-finish-load', () =>
|
|
log.push('electron:did-finish-load')
|
|
);
|
|
window.webContents.emit('did-finish-load');
|
|
assert.equal(api.state.didFinishLoadHeldOnBlank, 1);
|
|
|
|
api.release();
|
|
await load;
|
|
window.webContents.emit('did-finish-load');
|
|
window.webContents.emit('did-finish-load');
|
|
|
|
assert.deepEqual(log, [
|
|
'webContents.loadURL:about:blank',
|
|
'electron:did-finish-load',
|
|
'loadFile:index.html',
|
|
'app:did-finish-load',
|
|
'electron:did-finish-load',
|
|
'electron:did-finish-load',
|
|
]);
|
|
assert.equal(api.state.didFinishLoadHeldOnBlank, 1);
|
|
});
|
|
|
|
test('taps ipcMain.handle for the counters channel and passes registrations through', async () => {
|
|
const registered: string[] = [];
|
|
const ipcMain: FakeIpcMain = {
|
|
handle(channel) {
|
|
registered.push(channel);
|
|
},
|
|
};
|
|
const api = gateModule.installJourneyRendererGate(
|
|
createFakeBrowserWindow([]) as unknown as {
|
|
prototype: Record<string, unknown>;
|
|
},
|
|
{},
|
|
{ ipcMain, timeoutMs: 60_000 }
|
|
);
|
|
assert.deepEqual(gateModule.TAPPED_IPC_CHANNELS, [
|
|
'performance:read-counters',
|
|
]);
|
|
await assert.rejects(
|
|
api.invokeHandler('performance:read-counters'),
|
|
/journey-ipc-handler-not-registered: performance:read-counters/
|
|
);
|
|
|
|
ipcMain.handle('performance:read-counters', (event, ...args) => ({
|
|
args,
|
|
sender: (event as { sender: unknown }).sender,
|
|
}));
|
|
ipcMain.handle('db:other', () => 'other');
|
|
|
|
assert.deepEqual(registered, ['performance:read-counters', 'db:other']);
|
|
assert.deepEqual(await api.invokeHandler('performance:read-counters', 1), {
|
|
args: [1],
|
|
sender: null,
|
|
});
|
|
await assert.rejects(api.invokeHandler('db:other'), /not-registered/);
|
|
api.release();
|
|
});
|
|
|
|
test('refuses handler calls when no ipcMain was tapped', async () => {
|
|
const api = gateModule.installJourneyRendererGate(
|
|
createFakeBrowserWindow([]) as unknown as {
|
|
prototype: Record<string, unknown>;
|
|
},
|
|
{},
|
|
{ timeoutMs: 60_000 }
|
|
);
|
|
await assert.rejects(
|
|
api.invokeHandler('performance:read-counters'),
|
|
/journey-ipc-handler-tap-missing/
|
|
);
|
|
api.release();
|
|
});
|