test(perf): reject J2 iterations with activity after settling

- The open-source record compares the settle snapshot with what the probe
  and the IPC capture counted up to the click event, and with the mock
  requests between the snapshot and the click stamp. Any change means
  background work began during Playwright's actionability checks and
  could land in J2, so the iteration is rejected.
- The SQL opt-in guard also checks who passes mainCounters: true: only
  measureLaunchJourney may, and J2 must pass false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-29 08:58:37 +02:00
1 parent a28b26b550
commit 994740ee94
5 files changed
+100 -14

No files matched your search

@@ -177,6 +177,9 @@ export async function measureOpenSourceJourney(
ledger,
probeOptions.stateKey
);
// Requests from here on but before the renderer's click stamp arrived
// after the app settled; the record rejects such an iteration.
const settledLedgerMark = ledger.mark();
await card.click({ timeout: timeoutMs });
const renderer: JourneyRendererProbeState =
await waitForJourneyRendererProbe(
@@ -200,11 +203,15 @@ export async function measureOpenSourceJourney(
throw new Error('open-source-journey-click-not-started');
}
const sinceSpawn = ledger.since(spawnLedgerMark);
const beforeClick = sinceSpawn.filter(
(entry) => entry.epochMs < clickEpochMs
);
return {
http: {
beforeClick: sinceSpawn.filter(
(entry) => entry.epochMs < clickEpochMs
),
afterSettleBeforeClick: beforeClick.filter(
(entry) => entry.sequence >= settledLedgerMark
).length,
beforeClick,
requests: sinceSpawn.filter(
(entry) => entry.epochMs >= clickEpochMs
),
@@ -136,17 +136,36 @@ test('only the launch journey opts into SQL statement counting', () => {
.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'
const containing = (needle: RegExp) =>
files
.filter((file) =>
needle.test(readFileSync(join(sourceRoot, file), 'utf8'))
)
)
.map((file) => relative(sourceRoot, join(sourceRoot, file)));
// The journey launch builds its flags in one place, and only the
// launch journey asks for the main counters there (J2 launches with
// `mainCounters: false`; see journey-launch-environment.spec.ts).
assert.deepEqual(optedIn, [
.map((file) => relative(sourceRoot, join(sourceRoot, file)));
// The journey launch builds its flags in one place...
assert.deepEqual(containing(/IPTVNATOR_PERF_COUNT_SQL/), [
join('performance', 'journey-launch-environment.ts'),
]);
// ...and only J1's launch asks for them there. Another journey that
// passed `mainCounters: true` to runLaunchJourney would be measured
// under the statement hook without recording its count.
assert.deepEqual(containing(/mainCounters:\s*true/), [
join('journeys', 'launch-journey-app.ts'),
]);
const launchApp = readFileSync(
join(sourceRoot, 'journeys', 'launch-journey-app.ts'),
'utf8'
);
assert.equal(launchApp.match(/mainCounters:\s*true/g)?.length, 1);
assert.match(
launchApp,
/export async function measureLaunchJourney\([\s\S]*?\{ mainCounters: true \}[\s\S]*?\n\}/
);
assert.match(
readFileSync(
join(sourceRoot, 'journeys', 'open-source.journey.ts'),
'utf8'
),
/\{ mainCounters: false \}/
);
});
@@ -82,6 +82,7 @@ function measurement(
};
return {
http: {
afterSettleBeforeClick: 0,
beforeClick: [
request(0, '/player_api.php?action=get_account_info', 2_000),
],
@@ -249,6 +250,41 @@ test('rejects measurements that did not start at the click or did not open the s
);
});
test('rejects an iteration with activity between the settle snapshot and the click', () => {
const base = measurement();
assert.doesNotThrow(() => toOpenSourceIterationRecord(0, false, base));
// The probe counted DOM mutations after the snapshot (4) but before the
// click, e.g. while Playwright ran its actionability checks.
assert.throws(
() =>
toOpenSourceIterationRecord(0, false, {
...base,
renderer: {
...base.renderer,
preStart: { domMutations: 5, lastMutationEpochMs: 9_990 },
},
}),
/activity-before-click-dom$/
);
assert.throws(
() =>
toOpenSourceIterationRecord(0, false, {
...base,
ipc: { ...base.ipc, callsBeforeStart: 1 },
}),
/activity-before-click-ipc$/
);
assert.throws(
() =>
toOpenSourceIterationRecord(0, false, {
...base,
http: { ...base.http, afterSettleBeforeClick: 1 },
ipc: { ...base.ipc, callsBeforeStart: 2 },
}),
/activity-before-click-ipc-http$/
);
});
test('summarizes under the J2 counters with the unmeasurable ones listed', () => {
const entry = summarizeJourneyIterations(
[0, 1, 2].map((index) =>
@@ -48,6 +48,8 @@ export interface OpenSourceJourneySettle {
export interface OpenSourceJourneyMeasurement {
readonly http: {
/** Mock requests after the app settled but before the click stamp. */
readonly afterSettleBeforeClick: number;
/** Mock requests from the spawn (J1 and settling) until the click. */
readonly beforeClick: readonly JourneyMockRequest[];
/** Mock requests from the click until the mock was quiet again. */
@@ -97,6 +99,23 @@ export function toOpenSourceIterationRecord(
) {
throw new Error('open-source-journey-record-clock-order');
}
// Activity that started after the settle snapshot but before the click
// (while Playwright ran its actionability checks) could complete after
// the click and be counted as J2. The probe and the capture keep
// counting until the click itself, so they must still match the
// snapshot; otherwise the iteration is rejected.
const lateActivity = [
renderer.preStart.domMutations !== settle.preStartDomMutations
? 'dom'
: null,
ipc.callsBeforeStart !== settle.preStartIpcCalls ? 'ipc' : null,
http.afterSettleBeforeClick > 0 ? 'http' : null,
].filter((kind) => kind !== null);
if (lateActivity.length > 0) {
throw new Error(
`open-source-journey-record-activity-before-click-${lateActivity.join('-')}`
);
}
if (
renderer.capabilities.changeDetectionTicks !==
'unavailable-ng-global-not-published'
+6 -1
View File
@@ -264,7 +264,12 @@ 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 wait and what happened during
calls counted as J2. 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
whose DOM mutations, bridge calls or mock requests moved after the snapshot
(`open-source-journey-record-activity-before-click-*`). The settle wait and what happened during
it are kept under `evidence.settle`. The renderer probe is armed in the
loaded document with `page.evaluate` (the same self-contained script as J1,
with `startClick` set). It registers a capture-phase `click` listener on