test(perf): detach J1's IPC capture before the J2 click

J2 used J1's capture to see pending launch bridge calls while settling,
but its ipcMain listener stayed attached and ran for every bridge call of
the measured click. The capture can now be detached; J2 detaches J1's
right after settling, before the click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-29 17:51:10 +02:00
1 parent 6fbb79e66c
commit 5ca9c0b4c7
4 files changed
+69 -6

No files matched your search

@@ -3,6 +3,7 @@ import type { ElectronApplication, Page } from '@playwright/test';
import { defaultXtreamPortalName } from '../electron-test-fixtures';
import {
countJourneyMainIpcInFlight,
detachJourneyMainIpcCapture,
installJourneyMainIpcCapture,
JOURNEY_MAIN_IPC_STATE_KEY,
JOURNEY_RENDERER_API_TRACE_CHANNEL,
@@ -201,6 +202,9 @@ export async function measureOpenSourceJourney(
ledger,
probeOptions.stateKey
);
// J1's capture was only needed to see pending launch calls while
// settling; detached, it no longer runs for every J2 bridge call.
await detachJourneyMainIpcCapture(electronApp, JOURNEY_MAIN_IPC_STATE_KEY);
await card.click({ timeout: timeoutMs });
const renderer: JourneyRendererProbeState =
await waitForJourneyRendererProbe(
@@ -9,6 +9,7 @@ import type { ElectronApplication } from '@playwright/test';
import {
assertJourneyMainIpcCapture,
countJourneyMainIpcInFlight,
detachJourneyMainIpcCapture,
installJourneyMainIpcCapture,
JOURNEY_RENDERER_API_TRACE_CHANNEL,
peekJourneyMainIpcCapture,
@@ -139,6 +140,7 @@ test('rejects captures that cannot bound the counter exactly', () => {
*/
function createFakeElectronApp(): {
readonly app: ElectronApplication;
listeners(): number;
send(
senderId: number,
method: string,
@@ -161,6 +163,7 @@ function createFakeElectronApp(): {
} as unknown as ElectronApplication;
return {
app,
listeners: () => ipcMain.listenerCount(channel),
send: (senderId, method, args, phase = 'start') => {
ipcMain.emit(
channel,
@@ -175,7 +178,8 @@ async function withCapture(
options: Partial<JourneyMainIpcCaptureOptions>,
run: (
fake: ReturnType<typeof createFakeElectronApp>,
read: () => Promise<JourneyMainIpcCaptureState>
read: () => Promise<JourneyMainIpcCaptureState>,
stateKey: string
) => Promise<void>
): Promise<void> {
const stateKey = `__journeyIpcCaptureTest${Math.random()}`;
@@ -188,9 +192,15 @@ async function withCapture(
stateKey,
...options,
});
await run(fake, () => peekJourneyMainIpcCapture(fake.app, stateKey));
await run(
fake,
() => peekJourneyMainIpcCapture(fake.app, stateKey),
stateKey
);
} finally {
delete (globalThis as unknown as Record<string, unknown>)[stateKey];
const target = globalThis as unknown as Record<string, unknown>;
delete target[stateKey];
delete target[`${stateKey}:detach`];
}
}
@@ -344,3 +354,21 @@ test('tracks bridge calls in flight from start to success or error', async () =>
assert.equal(countJourneyMainIpcInFlight(state), 0);
});
});
test('a detached capture stops listening and keeps its last state', async () => {
await withCapture({}, async (fake, read, stateKey) => {
fake.send(1, 'getSettings', []);
assert.equal(fake.listeners(), 1);
await detachJourneyMainIpcCapture(fake.app, stateKey);
assert.equal(fake.listeners(), 0);
fake.send(1, 'getSettings', []);
fake.send(1, 'dbGetAppState', []);
const state = await read();
assert.equal(state.callsBeforeSentinel, 1);
assert.deepEqual(state.inFlightByMethod, { getSettings: 1 });
await assert.rejects(
detachJourneyMainIpcCapture(fake.app, stateKey),
/not-attached/
);
});
});
@@ -102,7 +102,10 @@ export async function installJourneyMainIpcCapture(
}
};
target[input.stateKey] = state;
ipcMain.on(input.channel, (event, payload: unknown) => {
const listener = (
event: { sender: { id: number } },
payload: unknown
): void => {
const record =
typeof payload === 'object' && payload !== null
? (payload as Record<string, unknown>)
@@ -165,10 +168,36 @@ export async function installJourneyMainIpcCapture(
state.callsBeforeSentinel += 1;
state.callsByMethod[method] =
(state.callsByMethod[method] ?? 0) + 1;
});
};
ipcMain.on(input.channel, listener);
// Kept next to the state (which is read as JSON) so the capture can
// be detached from the same main process later.
target[`${input.stateKey}:detach`] = () => {
ipcMain.removeListener(input.channel, listener);
};
}, options);
}
/**
* Removes a capture's listener. J2 detaches J1's capture once it has used it
* to settle, so the launch listener does not run for every bridge call of
* the measured click.
*/
export async function detachJourneyMainIpcCapture(
electronApp: ElectronApplication,
stateKey: string
): Promise<void> {
await electronApp.evaluate((_electron, key) => {
const target = globalThis as unknown as Record<string, unknown>;
const detach = target[`${key}:detach`];
if (typeof detach !== 'function') {
throw new Error('journey-main-ipc-capture-not-attached');
}
(detach as () => void)();
delete target[`${key}:detach`];
}, stateKey);
}
/** Total of `inFlightByMethod`. */
export function countJourneyMainIpcInFlight(
state: JourneyMainIpcCaptureState
+3 -1
View File
@@ -264,7 +264,9 @@ fails the iteration). Bridge calls in flight come from J1's IPC capture: it
was installed before the document loaded, and the preload follows every
traced `start` with exactly one `success` or `error`, so a call that is still
pending cannot resolve after the click and have its DOM changes or follow-up
calls counted as J2. The settle is a snapshot, and Playwright's
calls counted as J2. After settling, J1's capture is detached
(`detachJourneyMainIpcCapture`), so its listener does not run for every
bridge call of the measured click. The settle is a snapshot, and Playwright's
actionability checks run between it and the click. The probe and the IPC
capture keep counting pre-click activity until the click event itself, and
the ledger splits at the click stamp. So the record rejects an iteration