mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
test(performance): count startup phases and SQL statements for the J1 launch journey (#1715)
* test(performance): count startup phases and SQL statements for the J1 launch journey
Implements plan item A2. With IPTVNATOR_PERF_CAPTURE=1 the main process
keeps named counters and registers a main-only performance:read-counters
IPC handler; without the flag nothing is counted and the handler does not
exist.
- debug-trace.ts owns the registry; traceStartupPhase replaces the
trace('startup', ...) sites and counts main.startupPhases.
- The database worker counts executed statements through better-sqlite3's
Statement prototype (the verbose callback expands every statement and
made bulk inserts 2-4x slower) and posts the count over its message
port, flushed before every other worker message. The main-thread shared
connection is counted through a new connection observer in the shared
database library.
- The first main window freezes main.modulesRegisteredBeforeWindow at
creation and main.sqlStatementsBeforeReadyToShow at ready-to-show.
- The journey gate drops the ready-to-show that Electron emits for the
about:blank detour, so the app sees the real document's first paint,
and taps the counters handler; the J1 record reads both counters after
the renderer probe completes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(database): require one SQL statement per exec during initialization
The performance capture counts one exec call as one statement, because
SQL cannot be split reliably in the counter (trigger bodies contain
semicolons). The historical-upgrade driver now wraps exec on every
connection initDatabase opens and fails on a batch, so that counting
assumption holds for the fresh profile and all historical schemas.
Documents the definition in the counter and the architecture docs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(performance): count SQL statements only for the launch journey
Codex review: the M3U import, refresh-cancellation and Xtream benchmarks
also run with IPTVNATOR_PERF_CAPTURE=1, so the statement hook wrapped
every row of their bulk inserts and changed what they measure.
SQL counting now also needs IPTVNATOR_PERF_COUNT_SQL=1, which only the
launch journey sets; a harness test fails if another source sets it.
Startup phases, the window snapshot and the read handler stay on the
capture flag. Without SQL counting no ready-to-show listener is attached,
so a zero is never reported for statements nobody counted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
39 files changed
+1991
-73
No files matched your search
@@ -12,6 +12,8 @@ export interface JourneyRendererGateState {
|
||||
readonly gatedEpochMs: number | null;
|
||||
readonly gatedMethod: string | null;
|
||||
readonly passThroughLoads: number;
|
||||
/** `ready-to-show` events dropped while the window was on about:blank. */
|
||||
readonly readyToShowHeldOnBlank: number;
|
||||
readonly releasedEpochMs: number | null;
|
||||
readonly timedOut: boolean;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ import {
|
||||
JOURNEY_RENDERER_API_TRACE_CHANNEL,
|
||||
readJourneyMainIpcCapture,
|
||||
} from '../performance/journey-main-ipc-capture';
|
||||
import {
|
||||
assertJourneyMainCounters,
|
||||
readJourneyMainCounters,
|
||||
} from '../performance/journey-main-counters';
|
||||
import {
|
||||
createLaunchJourneyProbeOptions,
|
||||
installJourneyRendererProbe,
|
||||
@@ -124,9 +128,17 @@ export async function measureLaunchJourney(
|
||||
);
|
||||
try {
|
||||
await cp(templateDirectory, dataDirectory, { recursive: true });
|
||||
// IPTVNATOR_PERF_CAPTURE turns on the main-process counters and
|
||||
// their read handler, IPTVNATOR_PERF_COUNT_SQL the SQL statement
|
||||
// count behind main.sqlStatementsBeforeReadyToShow; only this journey
|
||||
// sets it. See journey-main-counters.ts.
|
||||
const env = buildElectronLaunchEnvironment(
|
||||
dataDirectory,
|
||||
launchOptions({ IPTVNATOR_TRACE_IPC: '1' })
|
||||
launchOptions({
|
||||
IPTVNATOR_PERF_CAPTURE: '1',
|
||||
IPTVNATOR_PERF_COUNT_SQL: '1',
|
||||
IPTVNATOR_TRACE_IPC: '1',
|
||||
})
|
||||
);
|
||||
const args = buildElectronLaunchArgs([
|
||||
'-r',
|
||||
@@ -188,6 +200,14 @@ export async function measureLaunchJourney(
|
||||
JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
10_000
|
||||
);
|
||||
// Read after the probe finished, so both frozen counters exist.
|
||||
const mainCounters = assertJourneyMainCounters(
|
||||
await readJourneyMainCounters(
|
||||
electronApp,
|
||||
JOURNEY_RENDERER_GATE_KEY
|
||||
),
|
||||
gate
|
||||
);
|
||||
if (ipc.installedEpochMs > renderer.installed.epochMs) {
|
||||
throw new Error('journey-main-ipc-capture-installed-late');
|
||||
}
|
||||
@@ -198,6 +218,7 @@ export async function measureLaunchJourney(
|
||||
electronVersion,
|
||||
gate,
|
||||
ipc,
|
||||
mainCounters,
|
||||
pid: electronApp.process().pid ?? -1,
|
||||
renderer,
|
||||
spawnEpochMs,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, relative, resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
assertJourneyMainCounters,
|
||||
JOURNEY_MAIN_COUNTER,
|
||||
JOURNEY_PERFORMANCE_COUNTERS_CHANNEL,
|
||||
} from './journey-main-counters';
|
||||
|
||||
const gate = { gatedEpochMs: 1_020, releasedEpochMs: 1_150 };
|
||||
|
||||
function snapshot(
|
||||
counters: Record<string, number> = {},
|
||||
frozenAtEpochMs: Record<string, number> = {}
|
||||
) {
|
||||
return {
|
||||
counters: {
|
||||
'main.modulesRegisteredBeforeWindow': 2,
|
||||
'main.sqlStatements': 40,
|
||||
'main.sqlStatementsBeforeReadyToShow': 12,
|
||||
'main.startupPhases': 9,
|
||||
...counters,
|
||||
},
|
||||
frozenAtEpochMs: {
|
||||
'main.modulesRegisteredBeforeWindow': 1_010,
|
||||
'main.sqlStatementsBeforeReadyToShow': 1_300,
|
||||
...frozenAtEpochMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('mirrors the channel and counter names of the app', () => {
|
||||
assert.equal(
|
||||
JOURNEY_PERFORMANCE_COUNTERS_CHANNEL,
|
||||
'performance:read-counters'
|
||||
);
|
||||
assert.deepEqual(Object.values(JOURNEY_MAIN_COUNTER).sort(), [
|
||||
'main.modulesRegisteredBeforeWindow',
|
||||
'main.sqlStatements',
|
||||
'main.sqlStatementsBeforeReadyToShow',
|
||||
'main.startupPhases',
|
||||
]);
|
||||
});
|
||||
|
||||
test('accepts a snapshot frozen at window creation and after the release', () => {
|
||||
const value = snapshot();
|
||||
assert.deepEqual(assertJourneyMainCounters(value, gate), value);
|
||||
});
|
||||
|
||||
test('accepts zero statements before ready-to-show with no running total', () => {
|
||||
const value = snapshot({ 'main.sqlStatementsBeforeReadyToShow': 0 });
|
||||
delete (value.counters as Record<string, number>)['main.sqlStatements'];
|
||||
assert.equal(
|
||||
assertJourneyMainCounters(value, gate).counters[
|
||||
'main.sqlStatementsBeforeReadyToShow'
|
||||
],
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects malformed or incomplete snapshots', () => {
|
||||
for (const value of [
|
||||
null,
|
||||
{ counters: {} },
|
||||
{ counters: { 'main.sqlStatements': -1 }, frozenAtEpochMs: {} },
|
||||
{ counters: { 'main.sqlStatements': 1.5 }, frozenAtEpochMs: {} },
|
||||
{ counters: [], frozenAtEpochMs: {} },
|
||||
]) {
|
||||
assert.throws(
|
||||
() => assertJourneyMainCounters(value, gate),
|
||||
/malformed/
|
||||
);
|
||||
}
|
||||
const unfrozen = snapshot();
|
||||
delete (unfrozen.frozenAtEpochMs as Record<string, number>)[
|
||||
'main.sqlStatementsBeforeReadyToShow'
|
||||
];
|
||||
assert.throws(
|
||||
() => assertJourneyMainCounters(unfrozen, gate),
|
||||
/not-frozen: main.sqlStatementsBeforeReadyToShow/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertJourneyMainCounters(snapshot(), {
|
||||
gatedEpochMs: null,
|
||||
releasedEpochMs: 1_150,
|
||||
}),
|
||||
/gate-incomplete/
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects snapshots that were not frozen at the moments they claim', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
assertJourneyMainCounters(
|
||||
snapshot({}, { 'main.modulesRegisteredBeforeWindow': 1_030 }),
|
||||
gate
|
||||
),
|
||||
/window-after-first-load/
|
||||
);
|
||||
// ready-to-show of about:blank, before the real document was released.
|
||||
assert.throws(
|
||||
() =>
|
||||
assertJourneyMainCounters(
|
||||
snapshot({}, { 'main.sqlStatementsBeforeReadyToShow': 1_100 }),
|
||||
gate
|
||||
),
|
||||
/ready-to-show-before-release/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertJourneyMainCounters(
|
||||
snapshot({ 'main.sqlStatements': 11 }),
|
||||
gate
|
||||
),
|
||||
/total-below-frozen: main.sqlStatements/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertJourneyMainCounters(
|
||||
snapshot({ 'main.startupPhases': 1 }),
|
||||
gate
|
||||
),
|
||||
/total-below-frozen: main.startupPhases/
|
||||
);
|
||||
});
|
||||
|
||||
test('only the launch journey opts into SQL statement counting', () => {
|
||||
// The import benchmarks also run with IPTVNATOR_PERF_CAPTURE=1; the SQL
|
||||
// hook wraps every row of a bulk insert, so they must not enable it.
|
||||
const sourceRoot = resolve(__dirname, '..');
|
||||
const files = readdirSync(sourceRoot, { recursive: true })
|
||||
.map(String)
|
||||
.filter(
|
||||
(file) => /\.(ts|cjs)$/.test(file) && !/\.spec\.ts$/.test(file)
|
||||
);
|
||||
const optedIn = files
|
||||
.filter((file) =>
|
||||
readFileSync(join(sourceRoot, file), 'utf8').includes(
|
||||
'IPTVNATOR_PERF_COUNT_SQL'
|
||||
)
|
||||
)
|
||||
.map((file) => relative(sourceRoot, join(sourceRoot, file)));
|
||||
assert.deepEqual(optedIn, [join('journeys', 'launch-journey-app.ts')]);
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { ElectronApplication } from '@playwright/test';
|
||||
|
||||
import type { JourneyRendererGateState } from '../journeys/journey-renderer-gate-client';
|
||||
|
||||
/**
|
||||
* Test-side reader for the main-process performance counters
|
||||
* (`apps/electron-backend/src/app/services/performance-counters.ts`). The app
|
||||
* registers `performance:read-counters` only with IPTVNATOR_PERF_CAPTURE=1
|
||||
* and the preload does not expose it, so the journey calls the registered
|
||||
* handler from the main process through the gate's `ipcMain.handle` tap
|
||||
* (`journey-renderer-gate.cjs`).
|
||||
*/
|
||||
|
||||
/** Literal of `PERFORMANCE_COUNTERS_READ_CHANNEL`. */
|
||||
export const JOURNEY_PERFORMANCE_COUNTERS_CHANNEL = 'performance:read-counters';
|
||||
|
||||
/** Literals of `PERFORMANCE_COUNTER`. */
|
||||
export const JOURNEY_MAIN_COUNTER = {
|
||||
MODULES_REGISTERED_BEFORE_WINDOW: 'main.modulesRegisteredBeforeWindow',
|
||||
SQL_STATEMENTS: 'main.sqlStatements',
|
||||
SQL_STATEMENTS_BEFORE_READY_TO_SHOW: 'main.sqlStatementsBeforeReadyToShow',
|
||||
STARTUP_PHASES: 'main.startupPhases',
|
||||
} as const;
|
||||
|
||||
export interface JourneyMainCountersState {
|
||||
readonly counters: Readonly<Record<string, number>>;
|
||||
readonly frozenAtEpochMs: Readonly<Record<string, number>>;
|
||||
}
|
||||
|
||||
export async function readJourneyMainCounters(
|
||||
electronApp: ElectronApplication,
|
||||
gateKey: string
|
||||
): Promise<unknown> {
|
||||
return electronApp.evaluate(
|
||||
async (_electron, input) => {
|
||||
const gate = (globalThis as unknown as Record<string, unknown>)[
|
||||
input.gateKey
|
||||
] as
|
||||
| { invokeHandler?: (channel: string) => Promise<unknown> }
|
||||
| undefined;
|
||||
if (typeof gate?.invokeHandler !== 'function') {
|
||||
throw new Error('journey-main-counters-gate-missing');
|
||||
}
|
||||
const snapshot = await gate.invokeHandler(input.channel);
|
||||
return JSON.parse(JSON.stringify(snapshot ?? null)) as unknown;
|
||||
},
|
||||
{ channel: JOURNEY_PERFORMANCE_COUNTERS_CHANNEL, gateKey }
|
||||
);
|
||||
}
|
||||
|
||||
function isCountRecord(value: unknown): value is Record<string, number> {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.values(value).every(
|
||||
(entry) => Number.isSafeInteger(entry) && (entry as number) >= 0
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function isEpochRecord(value: unknown): value is Record<string, number> {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.values(value).every(
|
||||
(entry) => typeof entry === 'number' && entry > 0
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts a snapshot only when it proves the ordering its counters claim:
|
||||
* the startup phases were frozen when the window was created (before the
|
||||
* gate saw its first load), and the SQL count at a `ready-to-show` that came
|
||||
* after the gate released the real document.
|
||||
*/
|
||||
export function assertJourneyMainCounters(
|
||||
value: unknown,
|
||||
gate: Pick<JourneyRendererGateState, 'gatedEpochMs' | 'releasedEpochMs'>
|
||||
): JourneyMainCountersState {
|
||||
const snapshot = value as Partial<JourneyMainCountersState> | null;
|
||||
if (
|
||||
!snapshot ||
|
||||
!isCountRecord(snapshot.counters) ||
|
||||
!isEpochRecord(snapshot.frozenAtEpochMs)
|
||||
) {
|
||||
throw new Error('journey-main-counters-malformed');
|
||||
}
|
||||
const { counters, frozenAtEpochMs } = snapshot;
|
||||
const frozen = [
|
||||
JOURNEY_MAIN_COUNTER.MODULES_REGISTERED_BEFORE_WINDOW,
|
||||
JOURNEY_MAIN_COUNTER.SQL_STATEMENTS_BEFORE_READY_TO_SHOW,
|
||||
];
|
||||
for (const name of frozen) {
|
||||
if (
|
||||
counters[name] === undefined ||
|
||||
frozenAtEpochMs[name] === undefined
|
||||
) {
|
||||
throw new Error(`journey-main-counters-not-frozen: ${name}`);
|
||||
}
|
||||
}
|
||||
if (gate.gatedEpochMs === null || gate.releasedEpochMs === null) {
|
||||
throw new Error('journey-main-counters-gate-incomplete');
|
||||
}
|
||||
if (
|
||||
frozenAtEpochMs[JOURNEY_MAIN_COUNTER.MODULES_REGISTERED_BEFORE_WINDOW] >
|
||||
gate.gatedEpochMs
|
||||
) {
|
||||
throw new Error('journey-main-counters-window-after-first-load');
|
||||
}
|
||||
if (
|
||||
frozenAtEpochMs[
|
||||
JOURNEY_MAIN_COUNTER.SQL_STATEMENTS_BEFORE_READY_TO_SHOW
|
||||
] < gate.releasedEpochMs
|
||||
) {
|
||||
throw new Error('journey-main-counters-ready-to-show-before-release');
|
||||
}
|
||||
const running: Array<[string, string]> = [
|
||||
[
|
||||
JOURNEY_MAIN_COUNTER.STARTUP_PHASES,
|
||||
JOURNEY_MAIN_COUNTER.MODULES_REGISTERED_BEFORE_WINDOW,
|
||||
],
|
||||
[
|
||||
JOURNEY_MAIN_COUNTER.SQL_STATEMENTS,
|
||||
JOURNEY_MAIN_COUNTER.SQL_STATEMENTS_BEFORE_READY_TO_SHOW,
|
||||
],
|
||||
];
|
||||
for (const [total, part] of running) {
|
||||
if ((counters[total] ?? 0) < counters[part]) {
|
||||
throw new Error(
|
||||
`journey-main-counters-total-below-frozen: ${total}`
|
||||
);
|
||||
}
|
||||
}
|
||||
return { counters, frozenAtEpochMs };
|
||||
}
|
||||
@@ -16,6 +16,7 @@ function gate(
|
||||
gatedEpochMs: 1_020,
|
||||
gatedMethod: 'loadFile',
|
||||
passThroughLoads: 0,
|
||||
readyToShowHeldOnBlank: 1,
|
||||
releasedEpochMs: 1_150,
|
||||
timedOut: false,
|
||||
...overrides,
|
||||
|
||||
@@ -14,9 +14,61 @@
|
||||
* test calls `globalThis.__iptvnatorJourneyGate.release()`. A safety timeout
|
||||
* releases the gate on its own and records that it did, so a broken test
|
||||
* cannot hang the app; the journey treats a timed-out gate as invalid.
|
||||
*
|
||||
* The detour must not change what the app measures. Electron emits
|
||||
* `ready-to-show` for the first paint of a hidden window, and `about:blank`
|
||||
* paints too: the app would show the window and freeze its
|
||||
* `ready-to-show` counters before its own document exists. The gate
|
||||
* therefore drops `ready-to-show` while the window is on `about:blank`;
|
||||
* Electron emits it again for the real document's first paint, because the
|
||||
* window is still hidden, which is the moment production sees.
|
||||
*
|
||||
* With `ipcMain` passed in, the gate also keeps the listeners registered
|
||||
* with `ipcMain.handle` for `TAPPED_IPC_CHANNELS`, so the test can call a
|
||||
* main-process handler that the preload does not expose (the renderer
|
||||
* bridge stays unchanged). The registration itself is passed through.
|
||||
*/
|
||||
const GATE_KEY = '__iptvnatorJourneyGate';
|
||||
const DEFAULT_TIMEOUT_MS = 15000;
|
||||
const BLANK_URL = 'about:blank';
|
||||
const TAPPED_IPC_CHANNELS = ['performance:read-counters'];
|
||||
|
||||
function isShowingBlank(window) {
|
||||
try {
|
||||
return window.webContents.getURL() === BLANK_URL;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function holdReadyToShowWhileBlank(window, state) {
|
||||
const originalEmit = window.emit;
|
||||
if (typeof originalEmit !== 'function') return;
|
||||
window.emit = function gatedEmit(eventName, ...args) {
|
||||
if (eventName === 'ready-to-show' && isShowingBlank(window)) {
|
||||
state.readyToShowHeldOnBlank += 1;
|
||||
return false;
|
||||
}
|
||||
return originalEmit.call(this, eventName, ...args);
|
||||
};
|
||||
}
|
||||
|
||||
function tapIpcHandlers(ipcMain, channels) {
|
||||
const handlers = new Map();
|
||||
const originalHandle = ipcMain.handle;
|
||||
ipcMain.handle = function tappedHandle(channel, listener) {
|
||||
const result = originalHandle.call(this, channel, listener);
|
||||
if (channels.includes(channel)) handlers.set(channel, listener);
|
||||
return result;
|
||||
};
|
||||
return async function invokeHandler(channel, ...args) {
|
||||
const listener = handlers.get(channel);
|
||||
if (!listener) {
|
||||
throw new Error(`journey-ipc-handler-not-registered: ${channel}`);
|
||||
}
|
||||
return listener({ frameId: -1, sender: null }, ...args);
|
||||
};
|
||||
}
|
||||
|
||||
function installJourneyRendererGate(BrowserWindow, target, options = {}) {
|
||||
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
@@ -27,6 +79,7 @@ function installJourneyRendererGate(BrowserWindow, target, options = {}) {
|
||||
gatedEpochMs: null,
|
||||
gatedMethod: null,
|
||||
passThroughLoads: 0,
|
||||
readyToShowHeldOnBlank: 0,
|
||||
releasedEpochMs: null,
|
||||
timedOut: false,
|
||||
};
|
||||
@@ -41,7 +94,13 @@ function installJourneyRendererGate(BrowserWindow, target, options = {}) {
|
||||
releaseGate();
|
||||
}
|
||||
}, timeoutMs);
|
||||
const invokeHandler = options.ipcMain
|
||||
? tapIpcHandlers(options.ipcMain, TAPPED_IPC_CHANNELS)
|
||||
: async (channel) => {
|
||||
throw new Error(`journey-ipc-handler-tap-missing: ${channel}`);
|
||||
};
|
||||
const api = {
|
||||
invokeHandler,
|
||||
release() {
|
||||
if (state.releasedEpochMs === null) {
|
||||
state.releasedEpochMs = now();
|
||||
@@ -68,8 +127,9 @@ function installJourneyRendererGate(BrowserWindow, target, options = {}) {
|
||||
}
|
||||
state.gatedEpochMs = now();
|
||||
state.gatedMethod = method;
|
||||
holdReadyToShowWhileBlank(this, state);
|
||||
try {
|
||||
await this.webContents.loadURL('about:blank');
|
||||
await this.webContents.loadURL(BLANK_URL);
|
||||
state.blankLoadedEpochMs = now();
|
||||
} catch (error) {
|
||||
state.errors.push(
|
||||
@@ -83,7 +143,7 @@ function installJourneyRendererGate(BrowserWindow, target, options = {}) {
|
||||
return api;
|
||||
}
|
||||
|
||||
module.exports = { GATE_KEY, installJourneyRendererGate };
|
||||
module.exports = { GATE_KEY, installJourneyRendererGate, TAPPED_IPC_CHANNELS };
|
||||
|
||||
if (
|
||||
process.versions &&
|
||||
@@ -91,6 +151,6 @@ if (
|
||||
!process.env['IPTVNATOR_JOURNEY_GATE_MANUAL']
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { BrowserWindow } = require('electron');
|
||||
installJourneyRendererGate(BrowserWindow, globalThis);
|
||||
const { BrowserWindow, ipcMain } = require('electron');
|
||||
installJourneyRendererGate(BrowserWindow, globalThis, { ipcMain });
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import test from 'node:test';
|
||||
|
||||
interface GateState {
|
||||
@@ -7,22 +8,33 @@ interface GateState {
|
||||
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?: { now?: () => number; timeoutMs?: number }
|
||||
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.
|
||||
@@ -139,3 +151,104 @@ test('records a failed about:blank navigation and still loads after release', as
|
||||
api.release();
|
||||
assert.equal(await load, 'loaded:index.html');
|
||||
});
|
||||
|
||||
function createEmittingBrowserWindow(log: string[]) {
|
||||
class EmittingBrowserWindow extends EventEmitter {
|
||||
url = '';
|
||||
webContents = {
|
||||
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('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();
|
||||
});
|
||||
@@ -67,10 +67,23 @@ function measurement(
|
||||
gatedEpochMs: 1_020,
|
||||
gatedMethod: 'loadFile',
|
||||
passThroughLoads: 0,
|
||||
readyToShowHeldOnBlank: 1,
|
||||
releasedEpochMs: 1_150,
|
||||
timedOut: false,
|
||||
},
|
||||
ipc,
|
||||
mainCounters: {
|
||||
counters: {
|
||||
'main.modulesRegisteredBeforeWindow': 2,
|
||||
'main.sqlStatements': 61,
|
||||
'main.sqlStatementsBeforeReadyToShow': 9,
|
||||
'main.startupPhases': 9,
|
||||
},
|
||||
frozenAtEpochMs: {
|
||||
'main.modulesRegisteredBeforeWindow': 1_010,
|
||||
'main.sqlStatementsBeforeReadyToShow': 1_250,
|
||||
},
|
||||
},
|
||||
pid: 4242,
|
||||
renderer,
|
||||
spawnEpochMs: 1_000,
|
||||
@@ -78,12 +91,14 @@ function measurement(
|
||||
};
|
||||
}
|
||||
|
||||
test('maps the probe and IPC capture to exact counters and spawn-relative wall-clock', () => {
|
||||
test('maps the probe, IPC capture and main counters to exact counters and spawn-relative wall-clock', () => {
|
||||
const record = toLaunchIterationRecord(2, false, measurement());
|
||||
assert.equal(record.index, 2);
|
||||
assert.equal(record.warmup, false);
|
||||
assert.equal(record.pid, 4242);
|
||||
assert.deepEqual(record.counters, {
|
||||
'main.modulesRegisteredBeforeWindow': 2,
|
||||
'main.sqlStatementsBeforeReadyToShow': 9,
|
||||
'renderer.domMutationsToFirstCard': 480,
|
||||
'renderer.ipcCallsToFirstCard': 14,
|
||||
'renderer.layoutShiftScore': 0.123,
|
||||
@@ -99,12 +114,21 @@ test('maps the probe and IPC capture to exact counters and spawn-relative wall-c
|
||||
});
|
||||
assert.deepEqual(record.evidence['longTaskDurationsMs'], [71.3, 120]);
|
||||
assert.equal(record.evidence['ipcCallsAfterFirstCard'], 3);
|
||||
assert.deepEqual(record.evidence['mainCountersAtRead'], {
|
||||
'main.modulesRegisteredBeforeWindow': 2,
|
||||
'main.sqlStatements': 61,
|
||||
'main.sqlStatementsBeforeReadyToShow': 9,
|
||||
'main.startupPhases': 9,
|
||||
});
|
||||
assert.equal(record.evidence['rendererGateReadyToShowHeldOnBlank'], 1);
|
||||
assert.deepEqual(record.evidence['epochs'], {
|
||||
firstCard: 2_600.04,
|
||||
firstCardPaint: 2_650,
|
||||
loadEventEnd: 1_400.26,
|
||||
mainIpcCaptureInstalled: 1_100,
|
||||
mainProcessStart: 900,
|
||||
mainReadyToShow: 1_250,
|
||||
mainWindowCreated: 1_010,
|
||||
rendererGateBlankLoaded: 1_050,
|
||||
rendererGateReleased: 1_150,
|
||||
rendererProbeInstalled: 1_200,
|
||||
@@ -144,8 +168,14 @@ test('rejects measurements whose clocks or probes are inconsistent', () => {
|
||||
});
|
||||
|
||||
test('names the counters the harness cannot measure yet', () => {
|
||||
assert.deepEqual(Object.keys(LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS).sort(), [
|
||||
'main.sqlStatementsBeforeReadyToShow',
|
||||
assert.deepEqual(Object.keys(LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS), [
|
||||
'renderer.cdTicksToFirstCard',
|
||||
]);
|
||||
});
|
||||
|
||||
test('never reports a measured counter as unavailable', () => {
|
||||
const record = toLaunchIterationRecord(0, false, measurement());
|
||||
for (const name of Object.keys(LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS)) {
|
||||
assert.equal(name in record.counters, false, name);
|
||||
}
|
||||
});
|
||||
@@ -1,15 +1,24 @@
|
||||
import type { JourneyRendererGateState } from '../journeys/journey-renderer-gate-client';
|
||||
import {
|
||||
JOURNEY_MAIN_COUNTER,
|
||||
type JourneyMainCountersState,
|
||||
} from './journey-main-counters';
|
||||
import type { JourneyMainIpcCaptureState } from './journey-main-ipc-capture';
|
||||
import type { JourneyRendererProbeState } from './journey-renderer-probe';
|
||||
import type { JourneyIterationRecord } from './journey-summary';
|
||||
|
||||
/**
|
||||
* Maps one measured launch (renderer probe + main IPC capture) to the
|
||||
* journey summary's iteration record for J1 "Launch to usable".
|
||||
* Maps one measured launch (renderer probe, main IPC capture and main-process
|
||||
* counters) to the journey summary's iteration record for J1 "Launch to
|
||||
* usable".
|
||||
*/
|
||||
export const LAUNCH_JOURNEY_ID = 'launch';
|
||||
|
||||
export const LAUNCH_JOURNEY_COUNTER = {
|
||||
MODULES_REGISTERED_BEFORE_WINDOW:
|
||||
JOURNEY_MAIN_COUNTER.MODULES_REGISTERED_BEFORE_WINDOW,
|
||||
SQL_STATEMENTS_BEFORE_READY_TO_SHOW:
|
||||
JOURNEY_MAIN_COUNTER.SQL_STATEMENTS_BEFORE_READY_TO_SHOW,
|
||||
DOM_MUTATIONS: 'renderer.domMutationsToFirstCard',
|
||||
IPC_CALLS: 'renderer.ipcCallsToFirstCard',
|
||||
LAYOUT_SHIFT_SCORE: 'renderer.layoutShiftScore',
|
||||
@@ -28,8 +37,6 @@ export const LAUNCH_JOURNEY_WALL_CLOCK = {
|
||||
export const LAUNCH_JOURNEY_UNAVAILABLE_COUNTERS: Readonly<
|
||||
Record<string, string>
|
||||
> = Object.freeze({
|
||||
'main.sqlStatementsBeforeReadyToShow':
|
||||
'SQL statements are only visible as worker stdout trace lines, which are forwarded asynchronously; plan item A2 adds a countable channel.',
|
||||
'renderer.cdTicksToFirstCard':
|
||||
'The electron-performance build optimizes scripts (ngDevMode=false), so Angular does not publish window.ng and ɵsetProfiler is unavailable.',
|
||||
});
|
||||
@@ -38,6 +45,7 @@ export interface LaunchJourneyMeasurement {
|
||||
readonly electronVersion: string;
|
||||
readonly gate: JourneyRendererGateState;
|
||||
readonly ipc: JourneyMainIpcCaptureState;
|
||||
readonly mainCounters: JourneyMainCountersState;
|
||||
readonly pid: number;
|
||||
readonly renderer: JourneyRendererProbeState;
|
||||
readonly spawnEpochMs: number;
|
||||
@@ -52,7 +60,7 @@ export function toLaunchIterationRecord(
|
||||
warmup: boolean,
|
||||
measurement: LaunchJourneyMeasurement
|
||||
): JourneyIterationRecord {
|
||||
const { ipc, renderer, spawnEpochMs } = measurement;
|
||||
const { ipc, mainCounters, renderer, spawnEpochMs } = measurement;
|
||||
if (renderer.terminal === null || renderer.navigation === null) {
|
||||
throw new Error('launch-journey-record-incomplete-probe');
|
||||
}
|
||||
@@ -75,6 +83,14 @@ export function toLaunchIterationRecord(
|
||||
}
|
||||
return Object.freeze({
|
||||
counters: Object.freeze({
|
||||
[LAUNCH_JOURNEY_COUNTER.MODULES_REGISTERED_BEFORE_WINDOW]:
|
||||
mainCounters.counters[
|
||||
LAUNCH_JOURNEY_COUNTER.MODULES_REGISTERED_BEFORE_WINDOW
|
||||
],
|
||||
[LAUNCH_JOURNEY_COUNTER.SQL_STATEMENTS_BEFORE_READY_TO_SHOW]:
|
||||
mainCounters.counters[
|
||||
LAUNCH_JOURNEY_COUNTER.SQL_STATEMENTS_BEFORE_READY_TO_SHOW
|
||||
],
|
||||
[LAUNCH_JOURNEY_COUNTER.DOM_MUTATIONS]:
|
||||
renderer.counters.domMutations,
|
||||
[LAUNCH_JOURNEY_COUNTER.IPC_CALLS]: ipc.callsBeforeSentinel,
|
||||
@@ -90,6 +106,15 @@ export function toLaunchIterationRecord(
|
||||
firstCardPaint: renderer.firstCardPaintEpochMs,
|
||||
loadEventEnd: renderer.navigation.loadEventEndEpochMs,
|
||||
mainIpcCaptureInstalled: ipc.installedEpochMs,
|
||||
mainReadyToShow:
|
||||
mainCounters.frozenAtEpochMs[
|
||||
LAUNCH_JOURNEY_COUNTER
|
||||
.SQL_STATEMENTS_BEFORE_READY_TO_SHOW
|
||||
],
|
||||
mainWindowCreated:
|
||||
mainCounters.frozenAtEpochMs[
|
||||
LAUNCH_JOURNEY_COUNTER.MODULES_REGISTERED_BEFORE_WINDOW
|
||||
],
|
||||
mainProcessStart: ipc.processStartEpochMs,
|
||||
rendererGateBlankLoaded: measurement.gate.blankLoadedEpochMs,
|
||||
rendererGateReleased: measurement.gate.releasedEpochMs,
|
||||
@@ -102,6 +127,10 @@ export function toLaunchIterationRecord(
|
||||
pathname: renderer.terminal.pathname,
|
||||
}),
|
||||
ipcCallsAfterFirstCard: ipc.callsAfterSentinel,
|
||||
// Running totals when the counters were read, after the first card.
|
||||
mainCountersAtRead: mainCounters.counters,
|
||||
rendererGateReadyToShowHeldOnBlank:
|
||||
measurement.gate.readyToShowHeldOnBlank,
|
||||
ipcCallsByMethod: ipc.callsByMethod,
|
||||
longTaskDurationsMs: renderer.longTaskDurationsMs.map(roundTenth),
|
||||
observedTarget: renderer.capabilities.observedTarget,
|
||||
|
||||
Reference in new issue
Block a user