test(performance): add J4 search journey (#1816)

* test(performance): add J4 search journey

Measures typing a six-character query into the header search box on
/workspace/search until the global search results settle, on a profile
with the M3U fixture and the mock's existing 12,000-item `large` Xtream
catalog. Counters: bridge calls and SQL statements per search (with a
per-keystroke breakdown), serial IPC depth, DOM mutations, change-detection
ticks, layout shift and long tasks; wall-clock last keystroke to settled
and first keystroke to first result.

Runs in the existing journeys target and the warn-only CI job, whose
summary now prints the per-keystroke table. Moves J3's picsum artwork
blocker into a shared helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(performance): J4 settles only on the final term's query

The probe could settle on cards of an earlier term that stay visible while
the final term debounces. The journey now stamps every dbGlobalSearch trace
event in the main process, and the record requires the last query between
the sentinels to be for the final term and to have completed before the
end sentinel. Iterations with a keydown gap over 250 ms (below the 350 ms
debounce) are rejected; gaps and the final query are kept as evidence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(performance): anchor J4's SQL count to the journey sentinels

renderer.sqlStatementsPerSearch was the difference of test-side samples
taken before the first key and after the end sentinel had been read, so
database work in either gap could be counted. The main process now reads
main.sqlStatements when the start and end sentinels arrive, and the counter
is their difference; sqlStatementsAfterSettled starts at the end sentinel.

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:
authored and GitHub committed 2026-10-06 18:46:57 +02:00
1 parent 22a9c1f1e5
commit 5f21e240e9
13 files changed
+2414 -67

No files matched your search

@@ -72,5 +72,11 @@ runs:
(($j.wallClock // {}) | to_entries[] | "| `\(.key)` | \(.value) | |"), (($j.wallClock // {}) | to_entries[] | "| `\(.key)` | \(.value) | |"),
"", "",
([($j.iterations // [])[] | select(.warmup | not) | .evidence.ipcSerialDepth // empty][0] // empty | ([($j.iterations // [])[] | select(.warmup | not) | .evidence.ipcSerialDepth // empty][0] // empty |
"Serial IPC chain (first measured iteration): \(.chain | map("`\(.)`") | join(" → "))", "")) "Serial IPC chain (first measured iteration): \(.chain | map("`\(.)`") | join(" → "))", ""),
([($j.iterations // [])[] | select(.warmup | not) | .evidence.perKeystroke // empty][0] // empty |
"Per keystroke (first measured iteration):", "",
"| Key | Query calls | Bridge calls | SQL statements | DOM mutations | CD ticks |",
"| --- | ---: | ---: | ---: | ---: | ---: |",
(.[] | "| `\(.key)` | \(.queryCalls) | \(.ipcCalls) | \(.sqlStatements) | \(.domMutations) | \(.cdTicks) |"),
""))
' "$SUMMARY" | tee -a "$GITHUB_STEP_SUMMARY" ' "$SUMMARY" | tee -a "$GITHUB_STEP_SUMMARY"
+2 -2
View File
@@ -296,8 +296,8 @@ jobs:
if: needs.performance-journeys-scope.outputs.run == 'true' if: needs.performance-journeys-scope.outputs.run == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
# The electron-performance build is the bulk of the time; each journey # The electron-performance build is the bulk of the time; each journey
# (launch, open-source, playback) is six fresh Electron processes plus # (launch, open-source, playback, search) is six fresh Electron
# one seeding run. # processes plus one seeding run.
timeout-minutes: 30 timeout-minutes: 30
# Warn-only for the first two weeks of plan item B3: a failure is # Warn-only for the first two weeks of plan item B3: a failure is
# visible on the run but does not fail the workflow. # visible on the run but does not fail the workflow.
@@ -1,10 +1,14 @@
import type { ElectronApplication, Page } from '@playwright/test'; import type { Page } from '@playwright/test';
import { configureLiveFormat } from '../xtream-live-format.fixture'; import { configureLiveFormat } from '../xtream-live-format.fixture';
import { import {
JOURNEY_CLICK_QUIET_MS, JOURNEY_CLICK_QUIET_MS,
waitForJourneyClickQuiet, waitForJourneyClickQuiet,
} from '../performance/journey-click-settle'; } from '../performance/journey-click-settle';
import {
blockJourneyExternalArtwork,
readJourneyExternalArtworkCancelled,
} from '../performance/journey-external-artwork';
import { import {
detachJourneyMainIpcCapture, detachJourneyMainIpcCapture,
installJourneyMainIpcCapture, installJourneyMainIpcCapture,
@@ -47,14 +51,6 @@ export const PLAYBACK_JOURNEY_MAIN_IPC_STATE_KEY =
'__iptvnatorJourneyPlaybackMainIpcCapture'; '__iptvnatorJourneyPlaybackMainIpcCapture';
export const PLAYBACK_JOURNEY_PORTAL_NAME = 'Journey live portal'; export const PLAYBACK_JOURNEY_PORTAL_NAME = 'Journey live portal';
const ERROR_PREFIX = 'playback-journey'; const ERROR_PREFIX = 'playback-journey';
const EXTERNAL_ARTWORK_STATE_KEY = '__iptvnatorJourneyExternalArtwork';
/**
* The generated live catalog's channel and category logos point at
* picsum.photos. They are cancelled in the main process, so no request of
* the journey leaves the machine and a logo never loads, or fails, at a
* different moment on a runner with a different network.
*/
const EXTERNAL_ARTWORK_URLS = ['*://picsum.photos/*', '*://*.picsum.photos/*'];
/** Seeds J2's profile with the local-media portal and the HTML5 player. */ /** Seeds J2's profile with the local-media portal and the HTML5 player. */
export const PLAYBACK_JOURNEY_SEED: LaunchJourneySeedOptions = { export const PLAYBACK_JOURNEY_SEED: LaunchJourneySeedOptions = {
@@ -68,45 +64,6 @@ export const PLAYBACK_JOURNEY_SEED: LaunchJourneySeedOptions = {
}, },
}; };
async function blockExternalArtwork(
electronApp: ElectronApplication
): Promise<void> {
await electronApp.evaluate(
({ session }, input) => {
const target = globalThis as unknown as Record<string, unknown>;
if (target[input.key] !== undefined) {
throw new Error('playback-journey-artwork-block-installed');
}
const state = { cancelled: 0 };
target[input.key] = state;
// The app registers no onBeforeRequest listener of its own
// (only onBeforeSendHeaders), so this replaces nothing.
session.defaultSession.webRequest.onBeforeRequest(
{ urls: input.urls },
(_details, callback) => {
state.cancelled += 1;
callback({ cancel: true });
}
);
},
{ key: EXTERNAL_ARTWORK_STATE_KEY, urls: EXTERNAL_ARTWORK_URLS }
);
}
async function readCancelledExternalArtwork(
electronApp: ElectronApplication
): Promise<number> {
return electronApp.evaluate(
(_electron, key) =>
(
(globalThis as unknown as Record<string, unknown>)[key] as {
cancelled: number;
}
).cancelled,
EXTERNAL_ARTWORK_STATE_KEY
);
}
/** Dashboard card → live section → first category, as a user would. */ /** Dashboard card → live section → first category, as a user would. */
async function openLiveCategory(page: Page, timeoutMs: number): Promise<void> { async function openLiveCategory(page: Page, timeoutMs: number): Promise<void> {
await page await page
@@ -143,7 +100,7 @@ export async function measurePlaybackJourney(
if (!startClick) { if (!startClick) {
throw new Error('playback-journey-probe-without-start'); throw new Error('playback-journey-probe-without-start');
} }
await blockExternalArtwork(electronApp); await blockJourneyExternalArtwork(electronApp);
await openLiveCategory(mainWindow, timeoutMs); await openLiveCategory(mainWindow, timeoutMs);
const channel = mainWindow.locator(startClick.selector).first(); const channel = mainWindow.locator(startClick.selector).first();
await channel.waitFor({ state: 'visible', timeout: timeoutMs }); await channel.waitFor({ state: 'visible', timeout: timeoutMs });
@@ -199,7 +156,7 @@ export async function measurePlaybackJourney(
); );
return { return {
externalArtworkCancelled: externalArtworkCancelled:
await readCancelledExternalArtwork(electronApp), await readJourneyExternalArtworkCancelled(electronApp),
http: { http: {
afterPlaying: sinceSpawn.filter( afterPlaying: sinceSpawn.filter(
(entry) => (entry) =>
@@ -0,0 +1,435 @@
import type { ElectronApplication, Page } from '@playwright/test';
import {
blockJourneyExternalArtwork,
readJourneyExternalArtworkCancelled,
} from '../performance/journey-external-artwork';
import {
JOURNEY_MAIN_COUNTER,
JOURNEY_PERFORMANCE_COUNTERS_CHANNEL,
} from '../performance/journey-main-counters';
import {
countJourneyMainIpcInFlight,
detachJourneyMainIpcCapture,
installJourneyMainIpcCapture,
JOURNEY_MAIN_IPC_STATE_KEY,
JOURNEY_RENDERER_API_TRACE_CHANNEL,
peekJourneyMainIpcCaptures,
readJourneyMainIpcCapture,
} from '../performance/journey-main-ipc-capture';
import { waitForJourneyQuiet } from '../performance/journey-quiet-wait';
import {
armSearchJourneyProbe,
createSearchJourneyProbeOptions,
readSearchJourneyPreStartMutations,
type SearchJourneyProbeOptions,
SEARCH_JOURNEY_INPUT_SELECTOR,
SEARCH_JOURNEY_ROUTE_PATH,
waitForSearchJourneyProbe,
} from '../performance/search-journey-probe';
import {
SEARCH_JOURNEY_QUERY_METHOD,
type SearchJourneyActivitySample,
type SearchJourneyMeasurement,
type SearchJourneyQueryTraceEntry,
type SearchJourneySettle,
} from '../performance/search-journey-record';
import { JOURNEY_RENDERER_GATE_KEY } from './journey-renderer-gate-client';
import type {
LaunchJourneySeedOptions,
LaunchJourneySession,
} from './launch-journey-app';
/**
* J4 "Search": runs inside a process that J1 has just launched with the
* main-process counters on (SQL statements are counted). The test opens
* global search from the rail and focuses the header search box (not
* measured), lets the app settle, then types the query one key at a time
* at a fixed interval and measures until the results have settled.
*
* Main-process activity (bridge calls, SQL statements) is sampled before
* every keystroke, so the record can show what each key caused: with the
* shell's debounce, only the last interval should run a query. Contract:
* docs/architecture/performance-journeys.md.
*/
export const SEARCH_JOURNEY_MAIN_IPC_STATE_KEY =
'__iptvnatorJourneySearchMainIpcCapture';
export const SEARCH_JOURNEY_PORTAL_NAME = 'Journey search portal';
/**
* Six characters; on the mock's `large` account (12,000 items) the term
* matches 170 series titles, more than the first page of 100.
*/
export const SEARCH_JOURNEY_QUERY = 'system';
/** Well below the shell's 350 ms input debounce, like steady typing. */
export const SEARCH_JOURNEY_KEY_DELAY_MS = 100;
const QUIET_MS = 1_000;
const QUIET_POLL_MS = 100;
const QUIET_TIMEOUT_MS = 30_000;
const AFTER_SETTLED_WINDOW_MS = 500;
const ERROR_PREFIX = 'search-journey';
/** J1's M3U source plus the mock's existing 12,000-item `large` catalog. */
export const SEARCH_JOURNEY_SEED: LaunchJourneySeedOptions = {
portal: {
name: SEARCH_JOURNEY_PORTAL_NAME,
password: 'large',
username: 'large',
},
};
/**
* One synchronous pass in the main process: the capture's counts and the
* registered counters handler (which reads the registry synchronously), so
* no bridge call or SQL report can land between the two reads.
*/
async function sampleActivity(
electronApp: ElectronApplication
): Promise<SearchJourneyActivitySample> {
return electronApp.evaluate(
async (_electron, input) => {
const target = globalThis as unknown as Record<string, unknown>;
const capture = target[input.captureKey] as
| {
callsBeforeSentinel: number;
callsByMethod: Record<string, number>;
}
| undefined;
const gate = target[input.gateKey] as
| { invokeHandler?: (channel: string) => Promise<unknown> }
| undefined;
if (!capture || typeof gate?.invokeHandler !== 'function') {
throw new Error('search-journey-sample-unavailable');
}
const ipcCalls = capture.callsBeforeSentinel;
const queryCalls = capture.callsByMethod[input.queryMethod] ?? 0;
const pending = gate.invokeHandler(input.channel);
const snapshot = (await pending) as {
counters?: Record<string, number>;
} | null;
const sqlStatements = snapshot?.counters?.[input.sqlCounter];
if (typeof sqlStatements !== 'number') {
throw new Error('search-journey-sql-counter-missing');
}
return { ipcCalls, queryCalls, sqlStatements };
},
{
captureKey: SEARCH_JOURNEY_MAIN_IPC_STATE_KEY,
channel: JOURNEY_PERFORMANCE_COUNTERS_CHANNEL,
gateKey: JOURNEY_RENDERER_GATE_KEY,
queryMethod: SEARCH_JOURNEY_QUERY_METHOD,
sqlCounter: JOURNEY_MAIN_COUNTER.SQL_STATEMENTS,
}
);
}
/**
* Waits until DOM, bridge calls (started and in flight) and SQL statements
* have all been unchanged for `QUIET_MS`, so leftovers of the launch and of
* the navigation to global search are not attributed to the first key.
*/
async function waitForSearchQuiet(
electronApp: ElectronApplication,
page: Page,
probeStateKey: string
): Promise<SearchJourneySettle> {
const { sample, waitedMs } = await waitForJourneyQuiet({
inFlight: (activity) => activity.ipcInFlight,
pollMs: QUIET_POLL_MS,
quietMs: QUIET_MS,
sample: async () => {
const [launchCapture, journeyCapture] =
await peekJourneyMainIpcCaptures(electronApp, [
JOURNEY_MAIN_IPC_STATE_KEY,
SEARCH_JOURNEY_MAIN_IPC_STATE_KEY,
]);
if (launchCapture.unmatchedCompletions > 0) {
throw new Error(`${ERROR_PREFIX}-bridge-completions-unmatched`);
}
return {
domMutations: await readSearchJourneyPreStartMutations(
page,
probeStateKey
),
ipcCalls: journeyCapture.callsBeforeStart,
ipcInFlight: countJourneyMainIpcInFlight(launchCapture),
sqlStatements: (await sampleActivity(electronApp))
.sqlStatements,
};
},
timeoutError: (activity) =>
new Error(`${ERROR_PREFIX}-not-quiet: ${JSON.stringify(activity)}`),
timeoutMs: QUIET_TIMEOUT_MS,
});
return {
preStartDomMutations: sample.domMutations,
preStartIpcCalls: sample.ipcCalls,
quietMs: QUIET_MS,
sqlStatements: sample.sqlStatements,
waitedMs,
};
}
function sleepUntil(epochMs: number): Promise<void> {
const remainingMs = epochMs - Date.now();
return remainingMs > 0
? new Promise((resolve) => setTimeout(resolve, remainingMs))
: Promise.resolve();
}
const QUERY_TRACE_KEY = '__iptvnatorJourneySearchQueryTrace';
/**
* Records every trace event of the query method in the main process, with
* the term and result length the preload's summaries carry and the main
* process's arrival epoch (the clock the IPC capture stamps its sentinels
* with). The record uses it to prove the final term's query completed
* before the settle, so results of an earlier term cannot end the journey.
* The summaries hold the search term and counts only.
*/
async function traceQueryCalls(
electronApp: ElectronApplication
): Promise<void> {
await electronApp.evaluate(
({ ipcMain }, input) => {
const entries: unknown[] = [];
(globalThis as unknown as Record<string, unknown>)[input.key] =
entries;
ipcMain.on(input.channel, (_event, payload: unknown) => {
const record = payload as Record<string, unknown> | null;
if (record?.['method'] !== input.method) return;
const args = record['args'] as { items?: unknown[] } | null;
const result = record['result'] as { length?: unknown } | null;
const term = args?.items?.[0];
entries.push({
epochMs: Date.now(),
phase: String(record['phase']),
resultLength:
typeof result?.length === 'number'
? result.length
: null,
term: typeof term === 'string' ? term : null,
});
});
},
{
channel: JOURNEY_RENDERER_API_TRACE_CHANNEL,
key: QUERY_TRACE_KEY,
method: SEARCH_JOURNEY_QUERY_METHOD,
}
);
}
const SENTINEL_SQL_KEY = '__iptvnatorJourneySearchSentinelSql';
/**
* Reads `main.sqlStatements` in the main process when the start and the end
* sentinel arrive, so the SQL counter covers exactly the IPC capture's
* window: database work just before the first key or after the settle is
* not counted. The gate's `invokeHandler` runs the counters handler
* synchronously, so the value is the total at the sentinel's arrival even
* though it is stored when the promise settles.
*/
async function stampSqlAtSentinels(
electronApp: ElectronApplication,
probeOptions: SearchJourneyProbeOptions
): Promise<void> {
await electronApp.evaluate(
({ ipcMain }, input) => {
const target = globalThis as unknown as Record<string, unknown>;
const gate = target[input.gateKey] as {
invokeHandler: (channel: string) => Promise<unknown>;
};
const state: Record<'end' | 'start', number | null> = {
end: null,
start: null,
};
target[input.key] = state;
ipcMain.on(input.channel, (_event, payload: unknown) => {
const record = payload as Record<string, unknown> | null;
if (
record?.['method'] !== input.sentinelMethod ||
record['phase'] !== 'start'
) {
return;
}
const args = JSON.stringify(record['args'] ?? null);
const which = args.includes(input.startId)
? 'start'
: args.includes(input.endId)
? 'end'
: null;
if (which === null || state[which] !== null) return;
void gate
.invokeHandler(input.countersChannel)
.then((snapshot) => {
const value = (
snapshot as { counters?: Record<string, number> }
)?.counters?.[input.sqlCounter];
state[which] = typeof value === 'number' ? value : null;
});
});
},
{
channel: JOURNEY_RENDERER_API_TRACE_CHANNEL,
countersChannel: JOURNEY_PERFORMANCE_COUNTERS_CHANNEL,
endId: probeOptions.endSentinelId,
gateKey: JOURNEY_RENDERER_GATE_KEY,
key: SENTINEL_SQL_KEY,
sentinelMethod: probeOptions.sentinelMethod,
sqlCounter: JOURNEY_MAIN_COUNTER.SQL_STATEMENTS,
startId: probeOptions.startSentinelId,
}
);
}
async function readQueryTrace(
electronApp: ElectronApplication
): Promise<SearchJourneyQueryTraceEntry[]> {
return electronApp.evaluate(
(_electron, key) =>
JSON.parse(
JSON.stringify(
(globalThis as unknown as Record<string, unknown>)[key]
)
) as SearchJourneyQueryTraceEntry[],
QUERY_TRACE_KEY
);
}
/**
* What a failed settle saw: the bridge calls of the search, renderer errors
* (a search that threw shows the same empty view as one that found
* nothing), SQL statements before each key and now, and the traced query
* calls with their terms and result lengths.
*/
async function describeSettleFailure(
electronApp: ElectronApplication,
samples: readonly SearchJourneyActivitySample[],
consoleErrors: readonly string[]
): Promise<string> {
const [capture] = await peekJourneyMainIpcCaptures(electronApp, [
SEARCH_JOURNEY_MAIN_IPC_STATE_KEY,
]);
const now = await sampleActivity(electronApp);
const queryTrace = await readQueryTrace(electronApp);
return JSON.stringify({
bridgeCalls: capture.callsByMethod,
consoleErrors,
queryTrace,
sqlBeforeKeysAndNow: [...samples, now].map(
(entry) => entry.sqlStatements
),
});
}
/** Rail link → global search, then focus the header box. Not measured. */
async function openGlobalSearch(page: Page, timeoutMs: number): Promise<void> {
await page
.getByRole('link', { name: 'Global search', exact: true })
.click({ timeout: timeoutMs });
// A router navigation, not a document load: wait on the path itself.
await page
.waitForFunction(
(path) => location.pathname.endsWith(path),
SEARCH_JOURNEY_ROUTE_PATH,
{ timeout: timeoutMs }
)
.catch((failure: unknown) => {
throw new Error(
`${ERROR_PREFIX}-route-not-reached: ${page.url()} (${String(failure)})`
);
});
const input = page.locator(SEARCH_JOURNEY_INPUT_SELECTOR);
await input.waitFor({ state: 'visible', timeout: timeoutMs });
await input.focus({ timeout: timeoutMs });
}
export async function measureSearchJourney(
session: LaunchJourneySession,
timeoutMs: number
): Promise<SearchJourneyMeasurement> {
const { electronApp, mainWindow } = session;
const query = SEARCH_JOURNEY_QUERY;
const probeOptions = createSearchJourneyProbeOptions(query);
// Kept for the message of a failed settle.
const consoleErrors: string[] = [];
mainWindow.on('console', (message) => {
if (message.type() === 'error' && consoleErrors.length < 10) {
consoleErrors.push(message.text().slice(0, 300));
}
});
await blockJourneyExternalArtwork(electronApp);
await traceQueryCalls(electronApp);
await stampSqlAtSentinels(electronApp, probeOptions);
await openGlobalSearch(mainWindow, timeoutMs);
await installJourneyMainIpcCapture(electronApp, {
channel: JOURNEY_RENDERER_API_TRACE_CHANNEL,
sentinelId: probeOptions.endSentinelId,
sentinelMethod: probeOptions.sentinelMethod,
startSentinelId: probeOptions.startSentinelId,
stateKey: SEARCH_JOURNEY_MAIN_IPC_STATE_KEY,
});
await armSearchJourneyProbe(mainWindow, probeOptions);
const settle = await waitForSearchQuiet(
electronApp,
mainWindow,
probeOptions.stateKey
);
await detachJourneyMainIpcCapture(electronApp, JOURNEY_MAIN_IPC_STATE_KEY);
const focused = await mainWindow
.locator(SEARCH_JOURNEY_INPUT_SELECTOR)
.evaluate((input) => input === document.activeElement);
if (!focused) {
throw new Error(`${ERROR_PREFIX}-input-not-focused`);
}
// One `keyboard.type` per character on a fixed schedule, so the
// main-process sample before each key sits between two keystrokes.
const samples: SearchJourneyActivitySample[] = [];
const firstKeyAtMs = Date.now();
for (let position = 0; position < query.length; position += 1) {
await sleepUntil(firstKeyAtMs + position * SEARCH_JOURNEY_KEY_DELAY_MS);
samples.push(await sampleActivity(electronApp));
await mainWindow.keyboard.type(query[position]);
}
const renderer = await waitForSearchJourneyProbe(
mainWindow,
probeOptions.stateKey,
timeoutMs
).catch(async (failure: unknown) => {
throw new Error(
`${String(failure)} ${await describeSettleFailure(electronApp, samples, consoleErrors)}`
);
});
const ipc = await readJourneyMainIpcCapture(
electronApp,
SEARCH_JOURNEY_MAIN_IPC_STATE_KEY,
10_000
);
samples.push(await sampleActivity(electronApp));
await new Promise((resolve) =>
setTimeout(resolve, AFTER_SETTLED_WINDOW_MS)
);
return {
afterSettled: await sampleActivity(electronApp),
afterSettledWindowMs: AFTER_SETTLED_WINDOW_MS,
externalArtworkCancelled:
await readJourneyExternalArtworkCancelled(electronApp),
ipc,
keyDelayMs: SEARCH_JOURNEY_KEY_DELAY_MS,
pid: session.launch.pid,
query,
queryTrace: await readQueryTrace(electronApp),
sqlAtSentinels: await electronApp.evaluate(
(_electron, key) =>
JSON.parse(
JSON.stringify(
(globalThis as unknown as Record<string, unknown>)[key]
)
) as SearchJourneyMeasurement['sqlAtSentinels'],
SENTINEL_SQL_KEY
),
renderer,
samples,
settle,
};
}
@@ -0,0 +1,79 @@
import { test } from '@playwright/test';
import type { JourneyIterationRecord } from '../performance/journey-summary';
import {
SEARCH_JOURNEY_ID,
SEARCH_JOURNEY_UNAVAILABLE_COUNTERS,
toSearchIterationRecord,
} from '../performance/search-journey-record';
import {
JOURNEY_ITERATION_TIMEOUT_MS,
JOURNEY_MEASURED_ITERATIONS,
JOURNEY_WARMUP_ITERATIONS,
logJourneyIteration,
writeJourneyRunEntry,
} from './journey-run';
import {
LAUNCH_JOURNEY_MOCK_ORIGIN,
removeLaunchJourneyProfile,
runLaunchJourney,
seedLaunchJourneyProfile,
} from './launch-journey-app';
import {
measureSearchJourney,
SEARCH_JOURNEY_SEED,
} from './search-journey-app';
/**
* J4 "Search": type a six-character query into the header search box on
* /workspace/search until the global search results have settled. Every
* iteration is a fresh J1 launch on a copy of the seeded profile (one M3U
* source and the mock's 12,000-item `large` Xtream catalog), with the
* main-process counters on so SQL statements are counted.
* Contract: docs/architecture/performance-journeys.md.
*/
test.describe.configure({ mode: 'serial' });
test('J4 search', async () => {
const iterations: JourneyIterationRecord[] = [];
let electronVersion = 'unknown';
const templateDirectory = await seedLaunchJourneyProfile(
LAUNCH_JOURNEY_MOCK_ORIGIN,
SEARCH_JOURNEY_SEED
);
try {
const total = JOURNEY_WARMUP_ITERATIONS + JOURNEY_MEASURED_ITERATIONS;
for (let index = 0; index < total; index += 1) {
const warmup = index < JOURNEY_WARMUP_ITERATIONS;
const { continuation, launch } = await runLaunchJourney(
templateDirectory,
JOURNEY_ITERATION_TIMEOUT_MS,
// SQL statements are a J4 counter, so unlike J2 and J3 the
// launch runs with the main-process counters and SQL hook.
{ idleWindowMs: null, mainCounters: true },
(session) =>
measureSearchJourney(
session,
JOURNEY_ITERATION_TIMEOUT_MS
).catch((failure: unknown) => {
throw new Error(
`iteration ${index}: ${String(failure)}`
);
})
);
electronVersion = launch.electronVersion;
const record = toSearchIterationRecord(index, warmup, continuation);
iterations.push(record);
logJourneyIteration(SEARCH_JOURNEY_ID, record);
}
} finally {
await removeLaunchJourneyProfile(templateDirectory);
}
await writeJourneyRunEntry(
SEARCH_JOURNEY_ID,
iterations,
SEARCH_JOURNEY_UNAVAILABLE_COUNTERS,
electronVersion
);
});
@@ -0,0 +1,51 @@
import type { ElectronApplication } from '@playwright/test';
/**
* The mock's generated catalogs point channel, category, poster and cover
* artwork at picsum.photos. Journeys that render that artwork (J3, J4)
* cancel those requests in the main process, so no request of the journey
* leaves the machine and an image never loads, or fails, at a different
* moment on a runner with a different network. Contract:
* docs/architecture/performance-journeys.md.
*/
const EXTERNAL_ARTWORK_STATE_KEY = '__iptvnatorJourneyExternalArtwork';
const EXTERNAL_ARTWORK_URLS = ['*://picsum.photos/*', '*://*.picsum.photos/*'];
export async function blockJourneyExternalArtwork(
electronApp: ElectronApplication
): Promise<void> {
await electronApp.evaluate(
({ session }, input) => {
const target = globalThis as unknown as Record<string, unknown>;
if (target[input.key] !== undefined) {
throw new Error('journey-artwork-block-installed');
}
const state = { cancelled: 0 };
target[input.key] = state;
// The app registers no onBeforeRequest listener of its own
// (only onBeforeSendHeaders), so this replaces nothing.
session.defaultSession.webRequest.onBeforeRequest(
{ urls: input.urls },
(_details, callback) => {
state.cancelled += 1;
callback({ cancel: true });
}
);
},
{ key: EXTERNAL_ARTWORK_STATE_KEY, urls: EXTERNAL_ARTWORK_URLS }
);
}
export async function readJourneyExternalArtworkCancelled(
electronApp: ElectronApplication
): Promise<number> {
return electronApp.evaluate(
(_electron, key) =>
(
(globalThis as unknown as Record<string, unknown>)[key] as {
cancelled: number;
}
).cancelled,
EXTERNAL_ARTWORK_STATE_KEY
);
}
@@ -2,8 +2,8 @@
* Instrumentation flags a journey launch sets on the Electron process. * Instrumentation flags a journey launch sets on the Electron process.
* *
* Every journey needs the renderer-API trace (`IPTVNATOR_TRACE_IPC`) for its * Every journey needs the renderer-API trace (`IPTVNATOR_TRACE_IPC`) for its
* IPC counters. Only J1 records the main-process counters: * IPC counters. Only J1 and J4 (for its SQL count) record the main-process
* `IPTVNATOR_PERF_CAPTURE` turns on the counters and their read handler, and * counters: `IPTVNATOR_PERF_CAPTURE` turns on the counters and their read handler, and
* `IPTVNATOR_PERF_COUNT_SQL` wraps every main-thread and worker SQLite * `IPTVNATOR_PERF_COUNT_SQL` wraps every main-thread and worker SQLite
* statement to count it (see journey-main-counters.ts). A journey that * statement to count it (see journey-main-counters.ts). A journey that
* continues from the launch without reading them (J2) leaves both off, so * continues from the launch without reading them (J2) leaves both off, so
@@ -127,7 +127,7 @@ test('rejects snapshots that were not frozen at the moments they claim', () => {
); );
}); });
test('only the launch journey opts into SQL statement counting', () => { test('only the launch and search journeys opt into SQL statement counting', () => {
// The import benchmarks also run with IPTVNATOR_PERF_CAPTURE=1; the SQL // 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. // hook wraps every row of a bulk insert, so they must not enable it.
const sourceRoot = resolve(__dirname, '..'); const sourceRoot = resolve(__dirname, '..');
@@ -146,12 +146,25 @@ test('only the launch journey opts into SQL statement counting', () => {
assert.deepEqual(containing(/IPTVNATOR_PERF_COUNT_SQL/), [ assert.deepEqual(containing(/IPTVNATOR_PERF_COUNT_SQL/), [
join('performance', 'journey-launch-environment.ts'), join('performance', 'journey-launch-environment.ts'),
]); ]);
// ...and only J1's launch asks for them there. Another journey that // ...and only J1's launch and J4, which records the count as
// renderer.sqlStatementsPerSearch, ask for them. Another journey that
// passed `mainCounters: true` to runLaunchJourney would be measured // passed `mainCounters: true` to runLaunchJourney would be measured
// under the statement hook without recording its count. // under the statement hook without recording its count.
assert.deepEqual(containing(/mainCounters:\s*true/), [ assert.deepEqual(containing(/mainCounters:\s*true/).sort(), [
join('journeys', 'launch-journey-app.ts'), join('journeys', 'launch-journey-app.ts'),
join('journeys', 'search.journey.ts'),
]); ]);
assert.match(
readFileSync(join(sourceRoot, 'journeys', 'search.journey.ts'), 'utf8'),
/\{ idleWindowMs: null, mainCounters: true \}/
);
assert.match(
readFileSync(
join(sourceRoot, 'performance', 'search-journey-record.ts'),
'utf8'
),
/SQL_STATEMENTS: 'renderer\.sqlStatementsPerSearch'/
);
const launchApp = readFileSync( const launchApp = readFileSync(
join(sourceRoot, 'journeys', 'launch-journey-app.ts'), join(sourceRoot, 'journeys', 'launch-journey-app.ts'),
'utf8' 'utf8'
@@ -0,0 +1,348 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import {
JOURNEY_CD_TICK_COUNTER_KEY,
JOURNEY_IPC_SENTINEL_METHOD,
JOURNEY_PLAYBACK_PROBE_STATE_KEY,
JOURNEY_PROBE_STATE_KEY,
} from './journey-renderer-probe';
import {
installFakePerformance,
type FakeObserver,
} from './journey-renderer-probe.test-helpers';
import {
assertSearchJourneyProbeState,
createSearchJourneyProbeOptions,
SEARCH_JOURNEY_END_SENTINEL_ID,
SEARCH_JOURNEY_PROBE_STATE_KEY,
SEARCH_JOURNEY_START_SENTINEL_ID,
searchJourneyProbeScript,
type SearchJourneyProbeOptions,
type SearchJourneyProbeState,
} from './search-journey-probe';
const QUERY = 'system';
interface SearchFixture {
readonly bridgeCalls: unknown[];
readonly input: HTMLInputElement;
readonly observers: FakeObserver[];
readonly results: HTMLElement;
readonly state: () => SearchJourneyProbeState;
readonly ticks: { count: number };
readonly window: JSDOM['window'];
}
function createSearchFixture(
overrides: Partial<SearchJourneyProbeOptions> = {}
): SearchFixture {
const dom = new JSDOM(
`<!doctype html><html><body><app-root>
<app-workspace-shell-header><label class="search-field">
<input type="search" /></label></app-workspace-shell-header>
<button id="elsewhere">x</button>
<app-search-results><div class="results-container"></div></app-search-results>
</app-root></body></html>`,
{
pretendToBeVisual: true,
runScripts: 'outside-only',
url: 'http://localhost/workspace/search',
}
);
const { window } = dom;
const observers: FakeObserver[] = [];
const bridgeCalls: unknown[] = [];
installFakePerformance(window, observers);
// See journey-renderer-probe.test-helpers.ts: tsx keeps names.
Object.defineProperty(window, '__name', {
configurable: true,
value: (target: unknown) => target,
});
Object.defineProperty(window, 'electron', {
configurable: true,
value: Object.freeze({
[JOURNEY_IPC_SENTINEL_METHOD]: (id: unknown) => {
bridgeCalls.push(id);
return Promise.resolve(null);
},
}),
});
const ticks = { count: 40 };
Object.defineProperty(window, JOURNEY_CD_TICK_COUNTER_KEY, {
configurable: true,
value: ticks,
});
const options = {
...createSearchJourneyProbeOptions(QUERY),
quietMs: 30,
...overrides,
};
window.eval(
`(${searchJourneyProbeScript.toString()})(${JSON.stringify(options)})`
);
const { document } = window;
return {
bridgeCalls,
input: document.querySelector('input') as HTMLInputElement,
observers,
results: document.querySelector('.results-container') as HTMLElement,
state: () =>
JSON.parse(
JSON.stringify(
(window as unknown as Record<string, unknown>)[
options.stateKey
]
)
) as SearchJourneyProbeState,
ticks,
window,
};
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** A keydown in the input plus what the shell renders for it. */
async function typeKey(fixture: SearchFixture, key: string): Promise<void> {
fixture.input.dispatchEvent(
new fixture.window.KeyboardEvent('keydown', { bubbles: true, key })
);
fixture.input.setAttribute('data-value', fixture.input.value + key);
fixture.input.value += key;
await wait(0);
}
function setQuery(fixture: SearchFixture, query: string): void {
fixture.window.history.replaceState(
null,
'',
`/workspace/search?q=${encodeURIComponent(query)}`
);
}
function addCards(fixture: SearchFixture, count: number): void {
for (let index = 0; index < count; index += 1) {
fixture.results.append(
fixture.window.document.createElement('app-content-card')
);
}
}
async function waitForFinal(fixture: SearchFixture): Promise<void> {
const deadline = Date.now() + 2_000;
while (!fixture.state().final && Date.now() < deadline) {
await wait(5);
}
}
test('search options use their own state key, sentinels and the query length', () => {
const options = createSearchJourneyProbeOptions(QUERY);
assert.equal(options.keystrokes, 6);
assert.equal(options.query, QUERY);
assert.equal(options.quietMs, 200);
assert.equal(options.stateKey, SEARCH_JOURNEY_PROBE_STATE_KEY);
assert.notEqual(options.stateKey, JOURNEY_PROBE_STATE_KEY);
assert.notEqual(options.stateKey, JOURNEY_PLAYBACK_PROBE_STATE_KEY);
assert.equal(options.startSentinelId, SEARCH_JOURNEY_START_SENTINEL_ID);
assert.equal(options.endSentinelId, SEARCH_JOURNEY_END_SENTINEL_ID);
assert.equal(options.sentinelMethod, JOURNEY_IPC_SENTINEL_METHOD);
assert.equal(options.cdTickCounterKey, JOURNEY_CD_TICK_COUNTER_KEY);
});
test('starts at the first keydown in the search box and buckets work per key', async () => {
const fixture = createSearchFixture();
fixture.results.setAttribute('data-before', '1');
await wait(0);
// A key elsewhere does not start the journey.
fixture.window.document.getElementById('elsewhere')?.dispatchEvent(
new fixture.window.KeyboardEvent('keydown', {
bubbles: true,
key: 'x',
})
);
assert.equal(fixture.state().start, null);
assert.deepEqual(fixture.bridgeCalls, []);
for (const key of QUERY) {
fixture.ticks.count += 1;
await typeKey(fixture, key);
}
let state = fixture.state();
assert.equal(state.preStart.domMutations, 1);
assert.deepEqual(fixture.bridgeCalls, [SEARCH_JOURNEY_START_SENTINEL_ID]);
assert.deepEqual(
state.keystrokes.map((key) => key.key),
[...QUERY]
);
assert.deepEqual(
state.keystrokes.map((key) => key.ticks),
[41, 42, 43, 44, 45, 46]
);
// One attribute record per key, each in its own bucket.
assert.deepEqual(state.domMutationsByKeystroke, [1, 1, 1, 1, 1, 1]);
// The debounced term lands: results for the final query.
setQuery(fixture, QUERY);
fixture.ticks.count += 4;
addCards(fixture, 3);
await waitForFinal(fixture);
state = assertSearchJourneyProbeState(fixture.state());
assert.equal(state.settle.status, 'quiet');
assert.equal(state.settle.query, QUERY);
assert.equal(state.settle.cardCount, 3);
assert.equal(state.settle.ticks, 50);
assert.equal(state.capabilities.changeDetectionTicks, 'counted');
assert.deepEqual(state.domMutationsByKeystroke, [1, 1, 1, 1, 1, 4]);
assert.equal(state.counters.domMutations, 9);
assert.equal(state.firstResult?.query, QUERY);
assert.equal(state.firstResult?.cardCount, 3);
assert.ok(
(state.settle.confirmedEpochMs ?? 0) - (state.settle.epochMs ?? 0) >= 25
);
assert.deepEqual(fixture.bridgeCalls, [
SEARCH_JOURNEY_START_SENTINEL_ID,
SEARCH_JOURNEY_END_SENTINEL_ID,
]);
});
test('does not settle on results for an intermediate term or while loading', async () => {
const fixture = createSearchFixture();
for (const key of QUERY) {
await typeKey(fixture, key);
}
// Results of an earlier term are a first result, not the settle.
setQuery(fixture, 'syst');
addCards(fixture, 2);
await wait(80);
let state = fixture.state();
assert.equal(state.final, false);
assert.equal(state.firstResult?.query, 'syst');
// The final term's spinner keeps the window closed.
setQuery(fixture, QUERY);
const spinner = fixture.window.document.createElement('div');
spinner.className = 'loading-state';
fixture.window.document
.querySelector('app-search-results')
?.append(spinner);
await wait(80);
assert.equal(fixture.state().final, false);
spinner.remove();
await waitForFinal(fixture);
state = assertSearchJourneyProbeState(fixture.state());
assert.equal(state.settle.query, QUERY);
assert.equal(state.settle.cardCount, 2);
});
test('a mutation inside the quiet window restarts it', async () => {
const fixture = createSearchFixture({ quietMs: 60 });
for (const key of QUERY) {
await typeKey(fixture, key);
}
setQuery(fixture, QUERY);
addCards(fixture, 1);
await wait(30);
addCards(fixture, 1);
const lastMutationAt = Date.now();
await waitForFinal(fixture);
const state = assertSearchJourneyProbeState(fixture.state());
assert.equal(state.settle.cardCount, 2);
assert.ok(Date.now() - lastMutationAt >= 55);
});
test('fails the iteration when results never settle', async () => {
const fixture = createSearchFixture({ settleTimeoutMs: 40 });
for (const key of QUERY) {
await typeKey(fixture, key);
}
await waitForFinal(fixture);
const state = fixture.state();
assert.equal(state.settle.status, 'timeout');
assert.throws(
() => assertSearchJourneyProbeState(state),
/search-journey-probe-invalid: settle-timeout/
);
});
test('flags a keystroke beyond the expected count', async () => {
const fixture = createSearchFixture({ keystrokes: 2 });
await typeKey(fixture, 'a');
await typeKey(fixture, 'b');
await typeKey(fixture, 'c');
assert.deepEqual(fixture.state().invalidReasons, ['unexpected-keystroke']);
});
test('counts shifts and long tasks from the first key until the settle only', async () => {
const fixture = createSearchFixture();
const shifts = fixture.observers.find(
(observer) => observer.type === 'layout-shift'
);
const tasks = fixture.observers.find(
(observer) => observer.type === 'longtask'
);
assert.ok(shifts && tasks);
// Buffered launch entries before the first key are dropped.
shifts.emit([{ entryType: 'layout-shift', startTime: 0, value: 0.5 }]);
tasks.emit([{ duration: 60, entryType: 'longtask', startTime: 0 }]);
// jsdom's clock starts with the fixture: let that task end first.
await wait(100);
for (const key of QUERY) {
await typeKey(fixture, key);
}
const now = fixture.window.performance.now();
shifts.emit([
{
entryType: 'layout-shift',
hadRecentInput: true,
startTime: now,
value: 0.02,
},
{ entryType: 'layout-shift', startTime: now, value: 0.01 },
]);
tasks.emit([
{ duration: 30, entryType: 'longtask', startTime: now },
{ duration: 80, entryType: 'longtask', startTime: now },
]);
setQuery(fixture, QUERY);
addCards(fixture, 1);
await waitForFinal(fixture);
const state = assertSearchJourneyProbeState(fixture.state());
assert.equal(state.counters.recentInputLayoutShiftScore, 0.02);
assert.equal(state.counters.layoutShiftScore, 0.01);
assert.equal(state.counters.longTasks, 1);
assert.deepEqual(state.longTaskDurationsMs, [80]);
assert.ok(shifts.disconnected && tasks.disconnected);
});
test('rejects a state without the tick counter or the end sentinel', () => {
assert.throws(
() => assertSearchJourneyProbeState({ schemaVersion: 1 }),
/search-journey-probe-incomplete/
);
const base = {
capabilities: { layoutShift: true, longTask: true },
final: true,
invalidReasons: [],
schemaVersion: 1,
sentinel: { status: 'bridge-missing' },
start: { sentinelStatus: 'sent' },
};
assert.throws(
() => assertSearchJourneyProbeState(base),
/search-journey-probe-sentinel-bridge-missing/
);
assert.throws(
() =>
assertSearchJourneyProbeState({
...base,
capabilities: { layoutShift: false, longTask: true },
sentinel: { status: 'sent' },
}),
/search-journey-probe-observer-unavailable/
);
});
@@ -0,0 +1,507 @@
import type { Page } from '@playwright/test';
import {
JOURNEY_CD_TICK_COUNTER_KEY,
JOURNEY_IPC_SENTINEL_METHOD,
} from './journey-renderer-probe';
/**
* Renderer-side probe for J4 "Search" (docs/architecture/performance-journeys.md).
*
* Armed in the loaded `/workspace/search` document after the header search
* input has focus. A capture-phase `keydown` listener on `window`, which runs
* before every listener of the app, stamps each keystroke in the input; the
* first one starts the journey and sends the start sentinel
* (`cancelSourceProbe`, as in J2 and J3). DOM mutation records and
* change-detection ticks are bucketed by the keystroke they follow, so the
* debounce behaviour is visible per key.
*
* End: after the last expected keystroke, the results for the final term are
* shown (URL `q` equals the query, a result card is visible and no loading
* state is rendered) and then no DOM mutation arrives for `quietMs`. The
* settled moment is the last mutation before that quiet window; the counters
* run until the quiet window is confirmed, when the end sentinel is sent. A
* plain "quiet after the last keystroke" would close inside the shell's
* debounce before any query ran. Without a settle by `settleTimeoutMs`
* after the last keystroke the iteration is invalid.
*
* The script must stay self-contained: Playwright serializes it with
* `toString()`, so it may only use its argument and browser globals.
*/
export const SEARCH_JOURNEY_PROBE_STATE_KEY = '__iptvnatorJourneySearchProbe';
export const SEARCH_JOURNEY_PROBE_SCHEMA_VERSION = 1;
export const SEARCH_JOURNEY_START_SENTINEL_ID =
'__iptvnator-journey-search-start__';
export const SEARCH_JOURNEY_END_SENTINEL_ID =
'__iptvnator-journey-search-end__';
/** The workspace shell header's search box. */
export const SEARCH_JOURNEY_INPUT_SELECTOR =
'app-workspace-shell-header .search-field input[type="search"]';
/** A rendered global search result (grouped or flat). */
export const SEARCH_JOURNEY_RESULT_SELECTOR =
'app-search-results .results-container app-content-card';
/** The search layout's spinner while a query runs. */
export const SEARCH_JOURNEY_LOADING_SELECTOR =
'app-search-results .loading-state';
export const SEARCH_JOURNEY_ROUTE_PATH = '/workspace/search';
export const SEARCH_JOURNEY_QUIET_MS = 200;
export const SEARCH_JOURNEY_SETTLE_TIMEOUT_MS = 15_000;
export interface SearchJourneyProbeOptions {
readonly cdTickCounterKey: string;
readonly endSentinelId: string;
readonly inputSelector: string;
/** Keystrokes the test types; the settle waits for the last one. */
readonly keystrokes: number;
readonly loadingSelector: string;
/** Term the URL `q` must carry when the results count as final. */
readonly query: string;
readonly quietMs: number;
readonly resultSelector: string;
/** The route the results must be on; the renderer path ends with it. */
readonly routePath: string;
readonly sentinelMethod: string;
/** Hard limit from the last keystroke to the settle. */
readonly settleTimeoutMs: number;
readonly startSentinelId: string;
readonly stateKey: string;
}
export interface SearchJourneyProbeKeystroke {
/** `min(event.timeStamp, listener time)` as epoch milliseconds. */
readonly epochMs: number;
readonly key: string;
/** Running tick total at the keydown, before the app handles it. */
readonly ticks: number | null;
}
export type SentinelStatus = 'bridge-missing' | 'failed' | 'not-sent' | 'sent';
export interface SearchJourneyProbeState {
readonly capabilities: {
changeDetectionTicks:
'counted' | 'pending' | 'unavailable-counter-missing';
layoutShift: boolean;
longTask: boolean;
};
readonly counters: {
domMutations: number;
layoutShiftScore: number;
longTasks: number;
/** Shifts with `hadRecentInput === true`; typing is input. */
recentInputLayoutShiftScore: number;
};
/** Mutation records after each keystroke, until the next or the end. */
readonly domMutationsByKeystroke: number[];
final: boolean;
/** First batch with a visible result card, for any term. */
firstResult: {
readonly cardCount: number;
readonly epochMs: number;
readonly query: string | null;
} | null;
readonly invalidReasons: string[];
readonly keystrokes: SearchJourneyProbeKeystroke[];
readonly longTaskDurationsMs: number[];
readonly preStart: {
domMutations: number;
lastMutationEpochMs: number | null;
};
readonly schemaVersion: number;
sentinel: { epochMs: number | null; status: SentinelStatus };
settle: {
cardCount: number;
/** When the quiet window was confirmed; the counters stop here. */
confirmedEpochMs: number | null;
/** Last mutation batch before the quiet window: "settled". */
epochMs: number | null;
query: string | null;
status: 'pending' | 'quiet' | 'timeout';
ticks: number | null;
};
start: {
readonly epochMs: number;
readonly pathname: string;
readonly sentinelStatus: SentinelStatus;
} | null;
}
export function searchJourneyProbeScript(
options: SearchJourneyProbeOptions
): void {
const target = globalThis as unknown as Record<string, unknown>;
if (target[options.stateKey] !== undefined) {
return;
}
const epoch = (): number => performance.timeOrigin + performance.now();
const bridge = target['electron'] as Record<string, unknown> | undefined;
const state: SearchJourneyProbeState = {
capabilities: {
changeDetectionTicks: 'pending',
layoutShift: false,
longTask: false,
},
counters: {
domMutations: 0,
layoutShiftScore: 0,
longTasks: 0,
recentInputLayoutShiftScore: 0,
},
domMutationsByKeystroke: [],
final: false,
firstResult: null,
invalidReasons: [],
keystrokes: [],
longTaskDurationsMs: [],
preStart: { domMutations: 0, lastMutationEpochMs: null },
schemaVersion: 1,
sentinel: { epochMs: null, status: 'not-sent' },
settle: {
cardCount: 0,
confirmedEpochMs: null,
epochMs: null,
query: null,
status: 'pending',
ticks: null,
},
start: null,
};
target[options.stateKey] = state;
const readTicks = (): number | null => {
const counter = target[options.cdTickCounterKey] as
{ count?: unknown } | undefined;
return typeof counter?.count === 'number' ? counter.count : null;
};
const callSentinel = (id: string): SentinelStatus => {
const method = bridge?.[options.sentinelMethod];
if (typeof method !== 'function') return 'bridge-missing';
try {
void Promise.resolve(method.call(bridge, id)).catch(
() => undefined
);
return 'sent';
} catch {
return 'failed';
}
};
const readQuery = (): string | null =>
new URLSearchParams(location.search).get('q');
const visibleCards = (): number => {
let count = 0;
for (const card of Array.from(
document.querySelectorAll(options.resultSelector)
)) {
if (card.getClientRects().length > 0) count += 1;
}
return count;
};
// Entries before the first keystroke belong to the launch (buffered
// entries included) and are dropped.
let fromEpochMs = Number.POSITIVE_INFINITY;
const shifts: PerformanceEntry[] = [];
const tasks: PerformanceEntry[] = [];
const observe = (
type: string,
sink: PerformanceEntry[]
): PerformanceObserver | null => {
try {
const observer = new PerformanceObserver((list) => {
if (!state.final) sink.push(...list.getEntries());
});
observer.observe({ type, buffered: true });
return observer;
} catch {
return null;
}
};
const shiftObserver = observe('layout-shift', shifts);
const taskObserver = observe('longtask', tasks);
state.capabilities.layoutShift = shiftObserver !== null;
state.capabilities.longTask = taskObserver !== null;
const countEntries = (untilEpochMs: number): void => {
if (shiftObserver) shifts.push(...shiftObserver.takeRecords());
if (taskObserver) tasks.push(...taskObserver.takeRecords());
shiftObserver?.disconnect();
taskObserver?.disconnect();
for (const entry of shifts) {
const shift = entry as PerformanceEntry & {
hadRecentInput?: boolean;
value?: number;
};
const at = performance.timeOrigin + entry.startTime;
if (
typeof shift.value !== 'number' ||
at < fromEpochMs ||
at > untilEpochMs
) {
continue;
}
if (shift.hadRecentInput === true) {
state.counters.recentInputLayoutShiftScore += shift.value;
} else {
state.counters.layoutShiftScore += shift.value;
}
}
// A task overlaps the window when it ends after the first keydown:
// the task that dispatched it still counts.
for (const entry of tasks) {
const at = performance.timeOrigin + entry.startTime;
if (
entry.duration <= 50 ||
at + entry.duration < fromEpochMs ||
at > untilEpochMs
) {
continue;
}
state.counters.longTasks += 1;
state.longTaskDurationsMs.push(entry.duration);
}
};
let quietTimer: ReturnType<typeof setTimeout> | undefined;
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
let lastMutationEpochMs: number | null = null;
const accept = (count: number): void => {
if (count === 0) return;
if (state.start === null) {
state.preStart.domMutations += count;
state.preStart.lastMutationEpochMs = epoch();
return;
}
state.counters.domMutations += count;
const bucket = state.keystrokes.length - 1;
state.domMutationsByKeystroke[bucket] =
(state.domMutationsByKeystroke[bucket] ?? 0) + count;
lastMutationEpochMs = epoch();
};
const ready = (): boolean =>
location.pathname.endsWith(options.routePath) &&
readQuery() === options.query &&
document.querySelector(options.loadingSelector) === null &&
visibleCards() > 0;
const end = (status: 'quiet' | 'timeout'): void => {
if (state.settle.status !== 'pending') return;
clearTimeout(quietTimer);
clearTimeout(timeoutTimer);
accept(mutationObserver.takeRecords().length);
mutationObserver.disconnect();
const confirmedEpochMs = epoch();
const ticks = readTicks();
state.settle = {
cardCount: visibleCards(),
confirmedEpochMs,
epochMs: lastMutationEpochMs,
query: readQuery(),
status,
ticks,
};
if (status === 'timeout') {
// What the ready condition saw, so a timeout says which part
// never held.
const input = document.querySelector(options.inputSelector);
state.invalidReasons.push(
`settle-timeout ${JSON.stringify({
cards: visibleCards(),
input:
input instanceof HTMLInputElement ? input.value : null,
loading:
document.querySelector(options.loadingSelector) !==
null,
path: location.pathname.slice(-40),
q: readQuery(),
view:
document.querySelector(
'app-search-results .results-container'
)?.firstElementChild?.className ?? null,
})}`
);
}
state.capabilities.changeDetectionTicks =
ticks === null || state.keystrokes[0]?.ticks === null
? 'unavailable-counter-missing'
: 'counted';
countEntries(confirmedEpochMs);
const sentinelStatus = callSentinel(options.endSentinelId);
state.sentinel = {
epochMs: sentinelStatus === 'sent' ? epoch() : null,
status: sentinelStatus,
};
window.removeEventListener('keydown', onKeydown, true);
state.final = true;
};
const mutationObserver = new MutationObserver((records) => {
if (state.settle.status !== 'pending') return;
accept(records.length);
if (state.start === null) return;
if (state.firstResult === null) {
const cardCount = visibleCards();
if (cardCount > 0) {
state.firstResult = {
cardCount,
epochMs: epoch(),
query: readQuery(),
};
}
}
// Every batch after the last keystroke restarts the quiet window,
// which only opens once the final term's results are shown.
clearTimeout(quietTimer);
if (state.keystrokes.length >= options.keystrokes && ready()) {
quietTimer = setTimeout(() => end('quiet'), options.quietMs);
}
});
mutationObserver.observe(document.documentElement ?? document, {
attributes: true,
characterData: true,
childList: true,
subtree: true,
});
// Capture phase on window runs before every listener of the app, so the
// start sentinel precedes any bridge call the first key causes, and the
// records queued before a key belong to the previous bucket.
const onKeydown = (event: Event): void => {
const origin =
event.target instanceof Element
? event.target.closest(options.inputSelector)
: null;
if (origin === null || state.settle.status !== 'pending') return;
if (state.keystrokes.length >= options.keystrokes) {
state.invalidReasons.push('unexpected-keystroke');
return;
}
accept(mutationObserver.takeRecords().length);
const listenerEpochMs = epoch();
const eventEpochMs = performance.timeOrigin + event.timeStamp;
const epochMs =
Number.isFinite(eventEpochMs) && eventEpochMs <= listenerEpochMs
? eventEpochMs
: listenerEpochMs;
if (state.start === null) {
state.start = {
epochMs,
pathname: location.pathname,
sentinelStatus: callSentinel(options.startSentinelId),
};
fromEpochMs = epochMs;
}
state.keystrokes.push({
epochMs,
key: (event as KeyboardEvent).key ?? '',
ticks: readTicks(),
});
state.domMutationsByKeystroke.push(0);
if (state.keystrokes.length === options.keystrokes) {
timeoutTimer = setTimeout(
() => end('timeout'),
options.settleTimeoutMs
);
}
};
window.addEventListener('keydown', onKeydown, true);
}
export function createSearchJourneyProbeOptions(
query: string
): SearchJourneyProbeOptions {
return {
cdTickCounterKey: JOURNEY_CD_TICK_COUNTER_KEY,
endSentinelId: SEARCH_JOURNEY_END_SENTINEL_ID,
inputSelector: SEARCH_JOURNEY_INPUT_SELECTOR,
keystrokes: query.length,
loadingSelector: SEARCH_JOURNEY_LOADING_SELECTOR,
query,
quietMs: SEARCH_JOURNEY_QUIET_MS,
resultSelector: SEARCH_JOURNEY_RESULT_SELECTOR,
routePath: SEARCH_JOURNEY_ROUTE_PATH,
sentinelMethod: JOURNEY_IPC_SENTINEL_METHOD,
settleTimeoutMs: SEARCH_JOURNEY_SETTLE_TIMEOUT_MS,
startSentinelId: SEARCH_JOURNEY_START_SENTINEL_ID,
stateKey: SEARCH_JOURNEY_PROBE_STATE_KEY,
};
}
export async function armSearchJourneyProbe(
page: Page,
options: SearchJourneyProbeOptions
): Promise<void> {
await page.evaluate(searchJourneyProbeScript, options);
}
export async function readSearchJourneyPreStartMutations(
page: Page,
stateKey: string
): Promise<number> {
return page.evaluate((key) => {
const state = (globalThis as unknown as Record<string, unknown>)[
key
] as { preStart?: { domMutations?: number } } | undefined;
const count = state?.preStart?.domMutations;
if (typeof count !== 'number') {
throw new Error('search-journey-probe-not-armed');
}
return count;
}, stateKey);
}
export async function waitForSearchJourneyProbe(
page: Page,
stateKey: string,
timeoutMs: number
): Promise<SearchJourneyProbeState> {
await page.waitForFunction(
(key) =>
(
(globalThis as unknown as Record<string, unknown>)[key] as
{ final?: boolean } | undefined
)?.final === true,
stateKey,
{ polling: 50, timeout: timeoutMs }
);
const state = await page.evaluate(
(key) =>
JSON.parse(
JSON.stringify(
(globalThis as unknown as Record<string, unknown>)[key]
)
) as unknown,
stateKey
);
return assertSearchJourneyProbeState(state);
}
export function assertSearchJourneyProbeState(
value: unknown
): SearchJourneyProbeState {
const state = value as SearchJourneyProbeState | null;
if (
!state ||
state.schemaVersion !== SEARCH_JOURNEY_PROBE_SCHEMA_VERSION ||
state.final !== true ||
state.start === null
) {
throw new Error('search-journey-probe-incomplete');
}
if (state.invalidReasons.length > 0) {
throw new Error(
`search-journey-probe-invalid: ${state.invalidReasons.join(', ')}`
);
}
if (state.start.sentinelStatus !== 'sent') {
throw new Error(
`search-journey-probe-start-sentinel-${state.start.sentinelStatus}`
);
}
if (state.sentinel.status !== 'sent') {
throw new Error(
`search-journey-probe-sentinel-${state.sentinel.status}`
);
}
// A zero from an observer that never ran is not a measurement.
if (!state.capabilities.layoutShift || !state.capabilities.longTask) {
throw new Error('search-journey-probe-observer-unavailable');
}
return state;
}
@@ -0,0 +1,451 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { JourneyMainIpcCaptureState } from './journey-main-ipc-capture';
import { summarizeJourneyIterations } from './journey-summary';
import type { SearchJourneyProbeState } from './search-journey-probe';
import {
SEARCH_JOURNEY_COUNTER,
SEARCH_JOURNEY_UNAVAILABLE_COUNTERS,
SEARCH_JOURNEY_WALL_CLOCK,
toSearchIterationRecord,
type SearchJourneyActivitySample,
type SearchJourneyMeasurement,
} from './search-journey-record';
const QUERY = 'system';
function sample(
ipcCalls: number,
queryCalls: number,
sqlStatements: number
): SearchJourneyActivitySample {
return { ipcCalls, queryCalls, sqlStatements };
}
/** The shape of a debounced search: only the last key runs a query. */
function measurement(
overrides: Partial<SearchJourneyMeasurement> = {},
rendererOverrides: Partial<SearchJourneyProbeState> = {}
): SearchJourneyMeasurement {
const keystrokes = [...QUERY].map((key, position) => ({
epochMs: 10_000 + position * 100,
key,
ticks: 40 + position,
}));
const renderer: SearchJourneyProbeState = {
capabilities: {
changeDetectionTicks: 'counted',
layoutShift: true,
longTask: true,
},
counters: {
domMutations: 402,
layoutShiftScore: 0.0004,
longTasks: 0,
recentInputLayoutShiftScore: 0.0011,
},
domMutationsByKeystroke: [0, 0, 0, 0, 0, 402],
final: true,
firstResult: { cardCount: 100, epochMs: 11_180, query: QUERY },
invalidReasons: [],
keystrokes,
longTaskDurationsMs: [],
preStart: { domMutations: 5, lastMutationEpochMs: 8_000 },
schemaVersion: 1,
sentinel: { epochMs: 11_400, status: 'sent' },
settle: {
cardCount: 100,
confirmedEpochMs: 11_399.6,
epochMs: 11_192.54,
query: QUERY,
status: 'quiet',
ticks: 54,
},
start: {
epochMs: 10_000,
pathname: '/w/search',
sentinelStatus: 'sent',
},
...rendererOverrides,
};
const ipc: JourneyMainIpcCaptureState = {
ambiguousTimelineCompletions: 0,
callsAfterSentinel: 0,
callsBeforeStart: 0,
callsBeforeSentinel: 1,
callsByMethod: { dbGlobalSearch: 1 },
inFlightByMethod: {},
installedEpochMs: 9_000,
malformedEvents: 0,
processStartEpochMs: 1_000,
senderIds: [1],
sentinel: { occurrences: 1, receivedEpochMs: 11_401 },
start: { occurrences: 1, receivedEpochMs: 10_001 },
timeline: [
{ method: 'dbGlobalSearch', phase: 'start' },
{ method: 'dbGlobalSearch', phase: 'end' },
],
unmatchedCompletions: 0,
};
return {
afterSettled: sample(1, 1, 121),
afterSettledWindowMs: 500,
externalArtworkCancelled: 42,
ipc,
keyDelayMs: 100,
pid: 4242,
query: QUERY,
sqlAtSentinels: { end: 121, start: 119 },
queryTrace: [
{
epochMs: 11_150,
phase: 'start',
resultLength: null,
term: QUERY,
},
{
epochMs: 11_170,
phase: 'success',
resultLength: 101,
term: null,
},
],
renderer,
samples: [
sample(0, 0, 119),
sample(0, 0, 119),
sample(0, 0, 119),
sample(0, 0, 119),
sample(0, 0, 119),
sample(0, 0, 119),
sample(1, 1, 121),
],
settle: {
preStartDomMutations: 5,
preStartIpcCalls: 0,
quietMs: 1_000,
sqlStatements: 119,
waitedMs: 1_226,
},
...overrides,
};
}
test('maps a settled search to exact counters and wall-clock', () => {
const record = toSearchIterationRecord(1, false, measurement());
assert.deepEqual(record.counters, {
[SEARCH_JOURNEY_COUNTER.CD_TICKS]: 14,
[SEARCH_JOURNEY_COUNTER.DOM_MUTATIONS]: 402,
[SEARCH_JOURNEY_COUNTER.IPC_CALLS]: 1,
[SEARCH_JOURNEY_COUNTER.IPC_SERIAL_DEPTH]: 1,
[SEARCH_JOURNEY_COUNTER.LAYOUT_SHIFT_SCORE]: 0.002,
[SEARCH_JOURNEY_COUNTER.LONG_TASKS]: 0,
[SEARCH_JOURNEY_COUNTER.SQL_STATEMENTS]: 2,
});
assert.deepEqual(record.wallClock, {
[SEARCH_JOURNEY_WALL_CLOCK.FIRST_KEYSTROKE_TO_FIRST_RESULT]: 1_180,
[SEARCH_JOURNEY_WALL_CLOCK.LAST_KEYSTROKE_TO_SETTLED]: 692.5,
});
assert.equal(record.pid, 4242);
assert.equal(record.warmup, false);
});
test('breaks every counter down by keystroke', () => {
const record = toSearchIterationRecord(1, false, measurement());
const perKeystroke = record.evidence['perKeystroke'] as {
cdTicks: number;
domMutations: number;
ipcCalls: number;
key: string;
queryCalls: number;
sqlStatements: number;
}[];
assert.deepEqual(
perKeystroke.map((entry) => entry.key),
[...QUERY]
);
assert.deepEqual(
perKeystroke.map((entry) => entry.cdTicks),
[1, 1, 1, 1, 1, 9]
);
assert.deepEqual(
perKeystroke.map((entry) => entry.queryCalls),
[0, 0, 0, 0, 0, 1]
);
assert.deepEqual(
perKeystroke.map((entry) => entry.sqlStatements),
[0, 0, 0, 0, 0, 2]
);
assert.deepEqual(
perKeystroke.map((entry) => entry.domMutations),
[0, 0, 0, 0, 0, 402]
);
assert.deepEqual(record.evidence['sqlStatementsAfterSettled'], {
count: 0,
windowMs: 500,
});
assert.deepEqual(record.evidence['ipcTimeline'], [
'+dbGlobalSearch',
'-dbGlobalSearch',
]);
});
test('a query per keystroke shows up per key, not only in the total', () => {
const record = toSearchIterationRecord(
1,
false,
measurement({
samples: [
sample(0, 0, 100),
sample(0, 0, 100),
sample(1, 1, 102),
sample(2, 2, 104),
sample(3, 3, 106),
sample(4, 4, 108),
sample(5, 5, 110),
],
settle: {
preStartDomMutations: 5,
preStartIpcCalls: 0,
quietMs: 1_000,
sqlStatements: 100,
waitedMs: 1_000,
},
sqlAtSentinels: { end: 110, start: 100 },
ipc: {
...measurement().ipc,
callsBeforeSentinel: 5,
callsByMethod: { dbGlobalSearch: 5 },
},
queryTrace: ['sy', 'sys', 'syst', 'syste', QUERY].flatMap(
(term, position) => [
{
epochMs: 10_150 + position * 100,
phase: 'start',
resultLength: null,
term,
},
{
epochMs: 10_170 + position * 100,
phase: 'success',
resultLength: 101,
term: null,
},
]
),
})
);
assert.equal(record.counters[SEARCH_JOURNEY_COUNTER.IPC_CALLS], 5);
assert.equal(record.counters[SEARCH_JOURNEY_COUNTER.SQL_STATEMENTS], 10);
const perKeystroke = record.evidence['perKeystroke'] as {
queryCalls: number;
}[];
assert.deepEqual(
perKeystroke.map((entry) => entry.queryCalls),
[0, 1, 1, 1, 1, 1]
);
});
test('rejects iterations that did not measure a settled search', () => {
const cases: [Partial<SearchJourneyProbeState>, RegExp][] = [
[{ start: null }, /incomplete-probe/],
[
{
settle: { ...measurement().renderer.settle, status: 'timeout' },
},
/incomplete-probe/,
],
[
{ keystrokes: measurement().renderer.keystrokes.slice(1) },
/keystroke-count/,
],
[
{
keystrokes: measurement().renderer.keystrokes.map((key) => ({
...key,
key: 'x',
})),
},
/typed-text/,
],
[
{ settle: { ...measurement().renderer.settle, query: 'syste' } },
/no-results/,
],
[{ firstResult: null }, /no-results/],
[
{ settle: { ...measurement().renderer.settle, epochMs: 9_000 } },
/clock-order/,
],
[{ preStart: { domMutations: 6, lastMutationEpochMs: 9_990 } }, /dom/],
[
{
capabilities: {
changeDetectionTicks: 'unavailable-counter-missing',
layoutShift: true,
longTask: true,
},
},
/cd-ticks-unavailable-counter-missing/,
],
];
for (const [overrides, error] of cases) {
assert.throws(
() => toSearchIterationRecord(1, false, measurement({}, overrides)),
error
);
}
});
test('rejects work that moved between the quiet snapshot and the first key', () => {
const base = measurement();
assert.throws(
() =>
toSearchIterationRecord(
1,
false,
measurement({ ipc: { ...base.ipc, callsBeforeStart: 1 } })
),
/activity-before-first-key-ipc/
);
assert.throws(
() =>
toSearchIterationRecord(
1,
false,
measurement({ sqlAtSentinels: { end: 122, start: 120 } })
),
/activity-before-first-key-sql/
);
assert.throws(
() =>
toSearchIterationRecord(
1,
false,
measurement({
samples: [...base.samples.slice(0, 6), sample(2, 1, 121)],
})
),
/ipc-sample-mismatch/
);
});
test('summarizes with the shared summary and nothing unavailable', () => {
const iterations = [0, 1, 2].map((index) =>
toSearchIterationRecord(
index,
index === 0,
measurement({ pid: 4_000 + index })
)
);
const entry = summarizeJourneyIterations(
iterations,
SEARCH_JOURNEY_UNAVAILABLE_COUNTERS
);
assert.equal(entry.counters[SEARCH_JOURNEY_COUNTER.IPC_CALLS], 1);
assert.equal(
entry.counterStability[SEARCH_JOURNEY_COUNTER.SQL_STATEMENTS]?.stable,
true
);
assert.equal(
entry.wallClock[
`${SEARCH_JOURNEY_WALL_CLOCK.LAST_KEYSTROKE_TO_SETTLED}.p50`
],
692.5
);
assert.deepEqual(entry.unavailable, {});
});
test('rejects a settle that did not wait for the final query', () => {
const start = (epochMs: number, term: string) => ({
epochMs,
phase: 'start',
resultLength: null,
term,
});
const success = (epochMs: number) => ({
epochMs,
phase: 'success',
resultLength: 101,
term: null,
});
// Results of an earlier term were shown; the final query never ran
// before the end sentinel.
assert.throws(
() =>
toSearchIterationRecord(
1,
false,
measurement({
queryTrace: [
start(10_500, 'syste'),
success(10_520),
start(11_500, QUERY),
success(11_520),
],
})
),
/final-query-not-run/
);
// The final query started but had not completed at the end sentinel.
assert.throws(
() =>
toSearchIterationRecord(
1,
false,
measurement({ queryTrace: [start(11_390, QUERY)] })
),
/final-query-incomplete/
);
const record = toSearchIterationRecord(1, false, measurement());
assert.deepEqual(record.evidence['finalQuery'], {
durationMs: 20,
resultLength: 101,
term: QUERY,
});
});
test('rejects typing slower than the accepted cadence', () => {
const base = measurement();
const keystrokes = base.renderer.keystrokes.map((key, position) => ({
...key,
epochMs: key.epochMs + (position >= 3 ? 200 : 0),
}));
assert.throws(
() =>
toSearchIterationRecord(1, false, measurement({}, { keystrokes })),
/typing-cadence: 100, 100, 300, 100, 100/
);
assert.deepEqual(
toSearchIterationRecord(1, false, base).evidence['keyIntervalsMs'],
[100, 100, 100, 100, 100]
);
});
test('counts SQL between the sentinels only', () => {
// Background statements after the end sentinel reach the final sample
// but not the counter; they show up after the settle instead.
const record = toSearchIterationRecord(
1,
false,
measurement({
afterSettled: sample(1, 1, 126),
samples: [...measurement().samples.slice(0, 6), sample(1, 1, 125)],
})
);
assert.equal(record.counters[SEARCH_JOURNEY_COUNTER.SQL_STATEMENTS], 2);
assert.deepEqual(record.evidence['sqlStatementsAfterSettled'], {
count: 5,
windowMs: 500,
});
assert.throws(
() =>
toSearchIterationRecord(
1,
false,
measurement({ sqlAtSentinels: { end: null, start: 119 } })
),
/sql-at-sentinels-missing/
);
});
@@ -0,0 +1,330 @@
import { computeJourneyIpcSerialDepth } from './journey-ipc-serial-depth';
import type { JourneyMainIpcCaptureState } from './journey-main-ipc-capture';
import type { JourneyIterationRecord } from './journey-summary';
import type { SearchJourneyProbeState } from './search-journey-probe';
/**
* Maps one measured global search (renderer probe from the first keystroke
* until the results settled, main IPC capture between the start and end
* sentinels, main-process activity sampled before every keystroke) to the
* journey summary's iteration record for J4.
*/
export const SEARCH_JOURNEY_ID = 'search';
export const SEARCH_JOURNEY_COUNTER = {
CD_TICKS: 'renderer.cdTicksToResults',
DOM_MUTATIONS: 'renderer.domMutationsToResults',
IPC_CALLS: 'renderer.ipcCallsPerSearch',
IPC_SERIAL_DEPTH: 'renderer.ipcSerialDepthToResults',
LAYOUT_SHIFT_SCORE: 'renderer.layoutShiftScore',
LONG_TASKS: 'renderer.longTasks',
SQL_STATEMENTS: 'renderer.sqlStatementsPerSearch',
} as const;
export const SEARCH_JOURNEY_WALL_CLOCK = {
FIRST_KEYSTROKE_TO_FIRST_RESULT: 'firstKeystrokeToFirstResultMs',
LAST_KEYSTROKE_TO_SETTLED: 'lastKeystrokeToSettledMs',
} as const;
export const SEARCH_JOURNEY_UNAVAILABLE_COUNTERS: Readonly<
Record<string, string>
> = Object.freeze({});
/** Bridge method of a global search query (`DatabaseService`). */
export const SEARCH_JOURNEY_QUERY_METHOD = 'dbGlobalSearch';
/**
* Longest accepted gap between two keydowns. Below the shell's 350 ms input
* debounce, so a late key on a busy machine cannot let an intermediate term
* run a query that steady typing would not.
*/
export const SEARCH_JOURNEY_MAX_KEY_INTERVAL_MS = 250;
/** One traced query event, stamped in the main process on arrival. */
export interface SearchJourneyQueryTraceEntry {
readonly epochMs: number;
readonly phase: string;
/** Length of the returned array, on `success`. */
readonly resultLength: number | null;
readonly term: string | null;
}
/**
* Main-process activity read in one synchronous pass: the journey capture's
* call counts and the running `main.sqlStatements` total.
*/
export interface SearchJourneyActivitySample {
readonly ipcCalls: number;
readonly queryCalls: number;
readonly sqlStatements: number;
}
/** How long the app was left alone before the first keystroke. */
export interface SearchJourneySettle {
readonly preStartDomMutations: number;
readonly preStartIpcCalls: number;
readonly quietMs: number;
readonly sqlStatements: number;
readonly waitedMs: number;
}
export interface SearchJourneyMeasurement {
readonly externalArtworkCancelled: number;
readonly ipc: JourneyMainIpcCaptureState;
readonly keyDelayMs: number;
readonly pid: number;
readonly query: string;
readonly renderer: SearchJourneyProbeState;
/**
* Before each keystroke (index i before key i + 1), then once after the
* probe settled and once more `afterSettledWindowMs` later.
*/
readonly samples: readonly SearchJourneyActivitySample[];
readonly afterSettled: SearchJourneyActivitySample;
readonly afterSettledWindowMs: number;
readonly settle: SearchJourneySettle;
/**
* `main.sqlStatements` read in the main process when the start and the
* end sentinel arrived; null when it was not read.
*/
readonly sqlAtSentinels: {
readonly end: number | null;
readonly start: number | null;
};
/** Every traced query event of the process, in arrival order. */
readonly queryTrace: readonly SearchJourneyQueryTraceEntry[];
}
function roundTenth(value: number): number {
return Math.round(value * 10) / 10;
}
function roundThousandth(value: number): number {
return Math.round(value * 1_000) / 1_000;
}
function difference(
samples: readonly SearchJourneyActivitySample[],
index: number,
key: keyof SearchJourneyActivitySample
): number {
return samples[index + 1][key] - samples[index][key];
}
/**
* The query the settle waited for: the last one started between the start
* and end sentinels. It must be for the final term and must have completed
* before the end sentinel, otherwise the probe settled on results of an
* earlier term (still shown while the final term debounced).
*/
function finalQuery(
trace: readonly SearchJourneyQueryTraceEntry[],
fromEpochMs: number,
untilEpochMs: number,
query: string
) {
const inWindow = trace.filter(
(entry) => entry.epochMs >= fromEpochMs && entry.epochMs <= untilEpochMs
);
const starts = inWindow.filter((entry) => entry.phase === 'start');
const completions = inWindow.filter(
(entry) => entry.phase === 'success' || entry.phase === 'error'
);
const last = starts.at(-1);
if (last === undefined || last.term !== query) {
throw new Error('search-journey-record-final-query-not-run');
}
const completion = completions.find(
(entry) => entry.epochMs >= last.epochMs && entry.phase === 'success'
);
if (completion === undefined || completions.length < starts.length) {
throw new Error('search-journey-record-final-query-incomplete');
}
return Object.freeze({
durationMs: completion.epochMs - last.epochMs,
resultLength: completion.resultLength,
term: last.term,
});
}
export function toSearchIterationRecord(
index: number,
warmup: boolean,
measurement: SearchJourneyMeasurement
): JourneyIterationRecord {
const { ipc, renderer, samples, settle } = measurement;
const { keystrokes, start } = renderer;
const keys = measurement.query.length;
if (start === null || renderer.settle.status !== 'quiet') {
throw new Error('search-journey-record-incomplete-probe');
}
if (ipc.start === null) {
throw new Error('search-journey-record-ipc-without-start');
}
if (keystrokes.length !== keys || samples.length !== keys + 1) {
throw new Error('search-journey-record-keystroke-count');
}
if (keystrokes.map((key) => key.key).join('') !== measurement.query) {
throw new Error('search-journey-record-typed-text');
}
if (
renderer.settle.query !== measurement.query ||
renderer.settle.cardCount === 0 ||
renderer.firstResult === null
) {
throw new Error('search-journey-record-no-results');
}
const settledEpochMs = renderer.settle.epochMs;
const lastKeyEpochMs = keystrokes[keys - 1].epochMs;
if (
settledEpochMs === null ||
settledEpochMs < lastKeyEpochMs ||
renderer.firstResult.epochMs < start.epochMs
) {
throw new Error('search-journey-record-clock-order');
}
const { end: sqlAtEnd, start: sqlAtStart } = measurement.sqlAtSentinels;
if (sqlAtStart === null || sqlAtEnd === null || sqlAtEnd < sqlAtStart) {
throw new Error('search-journey-record-sql-at-sentinels-missing');
}
// Activity between the quiet snapshot and the first key could finish
// after it and be counted as the search's. The probe, the capture and
// the SQL total read at the start sentinel all reflect the first
// keydown, so they must still match the snapshot.
const moved = [
renderer.preStart.domMutations !== settle.preStartDomMutations
? 'dom'
: null,
ipc.callsBeforeStart !== settle.preStartIpcCalls ? 'ipc' : null,
sqlAtStart !== settle.sqlStatements ? 'sql' : null,
].filter((kind): kind is string => kind !== null);
if (moved.length > 0) {
throw new Error(
`search-journey-record-activity-before-first-key-${moved.join('-')}`
);
}
const keyIntervalsMs = keystrokes
.slice(1)
.map((key, position) =>
roundTenth(key.epochMs - keystrokes[position].epochMs)
);
if (
keyIntervalsMs.some(
(interval) => interval > SEARCH_JOURNEY_MAX_KEY_INTERVAL_MS
)
) {
throw new Error(
`search-journey-record-typing-cadence: ${keyIntervalsMs.join(', ')}`
);
}
if (ipc.sentinel.receivedEpochMs === null) {
throw new Error('search-journey-record-ipc-without-end');
}
const lastQuery = finalQuery(
measurement.queryTrace,
ipc.start.receivedEpochMs ?? Number.POSITIVE_INFINITY,
ipc.sentinel.receivedEpochMs,
measurement.query
);
const finalSample = samples[keys];
if (finalSample.ipcCalls !== ipc.callsBeforeSentinel) {
throw new Error('search-journey-record-ipc-sample-mismatch');
}
const settleTicks = renderer.settle.ticks;
const ticks = keystrokes.map((key) => key.ticks);
if (
renderer.capabilities.changeDetectionTicks !== 'counted' ||
settleTicks === null ||
ticks.some((value) => value === null)
) {
throw new Error(
`search-journey-record-cd-ticks-${renderer.capabilities.changeDetectionTicks}`
);
}
const tickAt = (position: number): number =>
position < keys ? (ticks[position] as number) : settleTicks;
const serialDepth = computeJourneyIpcSerialDepth(ipc.timeline);
const perKeystroke = keystrokes.map((key, position) =>
Object.freeze({
atMs: roundTenth(key.epochMs - start.epochMs),
cdTicks: tickAt(position + 1) - tickAt(position),
domMutations: renderer.domMutationsByKeystroke[position] ?? 0,
ipcCalls: difference(samples, position, 'ipcCalls'),
key: key.key,
queryCalls: difference(samples, position, 'queryCalls'),
sqlStatements: difference(samples, position, 'sqlStatements'),
})
);
return Object.freeze({
counters: Object.freeze({
[SEARCH_JOURNEY_COUNTER.CD_TICKS]: settleTicks - tickAt(0),
[SEARCH_JOURNEY_COUNTER.DOM_MUTATIONS]:
renderer.counters.domMutations,
[SEARCH_JOURNEY_COUNTER.IPC_CALLS]: ipc.callsBeforeSentinel,
[SEARCH_JOURNEY_COUNTER.IPC_SERIAL_DEPTH]: serialDepth.depth,
// Typing is input, so shifts flagged hadRecentInput are
// included, as in J2 and J3.
[SEARCH_JOURNEY_COUNTER.LAYOUT_SHIFT_SCORE]: roundThousandth(
renderer.counters.layoutShiftScore +
renderer.counters.recentInputLayoutShiftScore
),
[SEARCH_JOURNEY_COUNTER.LONG_TASKS]: renderer.counters.longTasks,
[SEARCH_JOURNEY_COUNTER.SQL_STATEMENTS]: sqlAtEnd - sqlAtStart,
}),
evidence: Object.freeze({
capabilities: renderer.capabilities,
epochs: Object.freeze({
firstKeystroke: start.epochMs,
firstResult: renderer.firstResult.epochMs,
lastKeystroke: lastKeyEpochMs,
mainIpcSentinel: ipc.sentinel.receivedEpochMs,
mainIpcStart: ipc.start.receivedEpochMs,
settleConfirmed: renderer.settle.confirmedEpochMs,
settled: settledEpochMs,
}),
externalArtworkCancelled: measurement.externalArtworkCancelled,
firstResult: Object.freeze({
cardCount: renderer.firstResult.cardCount,
query: renderer.firstResult.query,
}),
ipcCallsAfterSettled: ipc.callsAfterSentinel,
ipcCallsByMethod: ipc.callsByMethod,
ipcSerialDepth: serialDepth,
ipcTimeline: ipc.timeline.map(
(event) =>
`${event.phase === 'start' ? '+' : '-'}${event.method}`
),
finalQuery: lastQuery,
keyDelayMs: measurement.keyDelayMs,
keyIntervalsMs,
layoutShift: Object.freeze({
recentInput: roundThousandth(
renderer.counters.recentInputLayoutShiftScore
),
withoutRecentInput: roundThousandth(
renderer.counters.layoutShiftScore
),
}),
longTaskDurationsMs: renderer.longTaskDurationsMs.map(roundTenth),
perKeystroke,
query: measurement.query,
results: Object.freeze({ cardCount: renderer.settle.cardCount }),
settle,
sqlAtSentinels: measurement.sqlAtSentinels,
sqlStatementsAfterSettled: Object.freeze({
count: measurement.afterSettled.sqlStatements - sqlAtEnd,
windowMs: measurement.afterSettledWindowMs,
}),
}),
index,
pid: measurement.pid,
wallClock: Object.freeze({
[SEARCH_JOURNEY_WALL_CLOCK.FIRST_KEYSTROKE_TO_FIRST_RESULT]:
roundTenth(renderer.firstResult.epochMs - start.epochMs),
[SEARCH_JOURNEY_WALL_CLOCK.LAST_KEYSTROKE_TO_SETTLED]: roundTenth(
settledEpochMs - lastKeyEpochMs
),
}),
warmup,
});
}
+179 -9
View File
@@ -19,9 +19,9 @@ live in `tools/performance/`.
| J4 `search` | six-character query typed into global search | results list settled | | J4 `search` | six-character query typed into global search | results list settled |
J1 is instrumented: `renderer.initialBytes` from the built output, and the J1 is instrumented: `renderer.initialBytes` from the built output, and the
runtime counters of the launch benchmark below. J2 and J3 are instrumented by runtime counters of the launch benchmark below. J2, J3 and J4 are
their own specs (below). J4 follows the plan in `.plans/` and is added in its instrumented by their own specs (below). Each thread names its journey and
own thread; each thread names its journey and counter in the PR description. counter in the PR description.
## Running the journeys ## Running the journeys
@@ -305,10 +305,12 @@ registers the `performance:read-counters` IPC handler. Without the flag
nothing is counted, no listener is attached and the handler does not exist; nothing is counted, no listener is attached and the handler does not exist;
the preload never exposes the channel. SQL statements are counted only with the preload never exposes the channel. SQL statements are counted only with
`IPTVNATOR_PERF_COUNT_SQL=1` as well, because the hook wraps every statement `IPTVNATOR_PERF_COUNT_SQL=1` as well, because the hook wraps every statement
execution: the launch journey sets both (the flags are built in execution: the launch journey and J4, which reports
`journey-launch-environment.ts`), while J2's launches and the M3U, refresh `renderer.sqlStatementsPerSearch`, set both (the flags are built in
and Xtream benchmarks do not set the SQL flag and keep measuring unwrapped `journey-launch-environment.ts`), while J2's and J3's launches and the M3U,
statements. A harness test fails if any other source sets the SQL flag. After the renderer probe completes, refresh and Xtream benchmarks do not set the SQL flag and keep measuring
unwrapped statements. A harness test fails if any other source sets the SQL
flag. After the renderer probe completes,
`journey-main-counters.ts` calls the handler through `electronApp.evaluate` `journey-main-counters.ts` calls the handler through `electronApp.evaluate`
and the gate's tap. and the gate's tap.
@@ -639,6 +641,13 @@ iterations carry `evidence.media` (the video element at `playing`) and
a `media` field (`null` for J1 and J2), which the probe's a `media` field (`null` for J1 and J2), which the probe's
`schemaVersion` 1 readers ignore. `schemaVersion` 1 readers ignore.
J4 adds the `journeys.search` entry, again with the same shape. Its
iterations carry `evidence.perKeystroke` (one entry per typed key, see
[J4](#j4-search-type-a-query-until-the-results-settle)), which the CI job
summary prints as a table for the first measured iteration. J4 has its own
renderer probe (`search-journey-probe.ts`, `schemaVersion` 1); the shared
probe is unchanged.
## J2 `open-source`: open a source to a browsable list ## J2 `open-source`: open a source to a browsable list
`open-source.journey.ts` reuses the J1 profile and process pattern: the `open-source.journey.ts` reuses the J1 profile and process pattern: the
@@ -868,6 +877,165 @@ mutation and request counts. On the runner `renderer.httpRequestsToPlaying`
and `renderer.layoutShiftScore` are enforced as guards; see and `renderer.layoutShiftScore` are enforced as guards; see
[Enforced journey counters](#enforced-journey-counters). [Enforced journey counters](#enforced-journey-counters).
## J4 `search`: type a query until the results settle
`search.journey.ts` follows J3: the profile is seeded once through the "Add
playlist" dialogs (`seedLaunchJourneyProfile` with `SEARCH_JOURNEY_SEED`),
every iteration copies it, spawns a fresh process through `runLaunchJourney`
and hands the running app to `measureSearchJourney` in
`src/journeys/search-journey-app.ts`. One warm-up and five measured
iterations. Unlike J2 and J3 the launch runs with the main-process counters
(`mainCounters: true`, so `IPTVNATOR_PERF_CAPTURE` and
`IPTVNATOR_PERF_COUNT_SQL`), because SQL statements are a J4 counter; every
statement then runs through the counting hook.
**Profile.** J1's M3U source plus an Xtream portal ("Journey search portal")
on the mock's existing `large:large` scenario: 60 categories of 200 items,
so 4,000 live channels, 4,000 movies and 4,000 series. No fixture was added
for the journey. The query is `system`, which matches 170 series titles of
that deterministic catalog: more than global search's first page of 100, so
the page is full and more results are available. Six characters take the
title FTS path of `globalSearch`
(`apps/electron-backend/src/app/database/operations/content.operations.ts`);
the M3U arm runs as well, because live content is included, but none of the
four fixture channels matches. Poster artwork points at `picsum.photos` and
is cancelled in the main process as in J3
(`src/performance/journey-external-artwork.ts`, shared by both journeys);
the count is kept as `evidence.externalArtworkCancelled`. The mock is not
put behind the request ledger: global search reads only the local database.
**What typing does.** The header search box
(`app-workspace-shell-header .search-field input[type="search"]`) applies
its term after `SEARCH_INPUT_DEBOUNCE_MS` (350 ms,
`WorkspaceShellSearchSyncService`) and writes it to the URL as `q`. On
`/workspace/search` (`app.routes.ts`, Electron only) `SearchResultsComponent`
adopts `q` and runs `executeSearch` after its own 300 ms debounce, which
calls the `dbGlobalSearch` bridge method once with a limit of 101.
**Start.** After J1 has ended, the test clicks the rail's **Global search**
link and focuses the header search box (not measured), installs the IPC
capture with a start sentinel, arms the probe
(`src/performance/search-journey-probe.ts`) and waits until the app has
been quiet for 1 s: no DOM mutation, no new or pending bridge call, and an
unchanged `main.sqlStatements` total (30 s timeout, which fails the
iteration). J1's capture is detached. The test then types the query with one
`keyboard.type` call per character on a fixed schedule, 100 ms apart from
the first key (well below the 350 ms debounce, as steady typing would be),
and samples the main process just before each key. The probe's
capture-phase `keydown` listener on `window` stamps every key in the search
box before the app sees it; the first one starts the journey and sends
`cancelSourceProbe('__iptvnator-journey-search-start__')`. The record
rejects an iteration whose DOM mutations, bridge calls or SQL statements
moved between the quiet snapshot and the first key.
**End.** "No DOM mutation for 200 ms after the last keystroke" alone would
end inside the 650 ms of debounce, before any query ran: nothing in the DOM
changes while the term waits. So after the sixth key the probe waits for
the results of the final term to be shown (the path ends with
`/workspace/search`, the URL `q` is the query, no `.loading-state` is
rendered and an `app-content-card` in the results container is visible). The
mutation batch that first meets that condition opens a 200 ms quiet window,
and every later batch restarts it. When the window elapses the journey has
settled: the settled moment is the last mutation batch, the counters stop at
the confirmation, and the probe sends
`cancelSourceProbe('__iptvnator-journey-search-end__')`. Without a settle
within 15 s of the last key the probe marks the iteration invalid
(`settle-timeout`).
The DOM alone cannot tell the final term's results from an earlier term's
that are still shown while the final term debounces, so the main process
also checks. A listener on the renderer-API trace channel stamps every
`dbGlobalSearch` event on arrival, with the term and result length that the
preload's summaries carry. The record requires that the last query started
between the start and end sentinels is for the final term and completed
before the end sentinel (`final-query-not-run`, `final-query-incomplete`).
The final query's start shows the loading state, which keeps the quiet
window closed until its results replace the old ones.
`evidence.finalQuery` keeps its term, result length and duration. The
record also rejects an iteration in which two keydowns were more than
250 ms apart (`typing-cadence`; the gaps are `evidence.keyIntervalsMs`).
At that point a late key on a busy machine could let the 350 ms debounce
apply an intermediate term, which steady typing does not.
A settle that times out fails the iteration with what the probe and the
main process saw: the URL `q`, the input's value, the results view, the
bridge calls, renderer console errors, the SQL totals before each key, and
the traced `dbGlobalSearch` calls with their terms and result lengths.
### Counters
| Counter | Source |
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `renderer.ipcCallsPerSearch` | Bridge `start` trace events between the start and end sentinels, as `renderer.ipcCallsToFirstPage` in J2. `evidence.ipcCallsByMethod` names them. |
| `renderer.sqlStatementsPerSearch` | `main.sqlStatements` (main thread and database worker, see [Main-process counters](#main-process-counters)) read in the main process when the start sentinel and the end sentinel arrive (a listener on the trace channel calls the counters handler through the gate, which reads the registry synchronously), so the count covers exactly the IPC capture's window and never work just before the first key or after the settle. The worker reports its count before the response it belongs to, so the statements of a query are counted before its results reach the renderer. The two values are `evidence.sqlAtSentinels`; statements from the end sentinel until 500 ms after the test read the summary are kept as `evidence.sqlStatementsAfterSettled`. |
| `renderer.ipcSerialDepthToResults` | `computeJourneyIpcSerialDepth` over the capture's timeline between the sentinels (see [Serial IPC depth](#serial-ipc-depth)); `evidence.ipcSerialDepth.chain` and `evidence.ipcTimeline` show the calls. |
| `renderer.domMutationsToResults` | `MutationRecord`s from the first keydown until the quiet window was confirmed (by definition none arrive inside it). |
| `renderer.cdTicksToResults` | `ApplicationRef` ticks from the first keydown (read in the capture-phase listener, before the app handles the key) until the confirmation (see [Change-detection ticks](#change-detection-ticks)). |
| `renderer.layoutShiftScore` | All `layout-shift` entries from the first keydown until the confirmation, including `hadRecentInput` ones (typing is input, as in J2 and J3), rounded to three decimals; the split is under `evidence.layoutShift`. |
| `renderer.longTasks` | `longtask` entries over 50 ms whose time range overlaps the window from the first keydown to the confirmation. Evidence until shown to be stable on the runner. |
`evidence.perKeystroke` breaks the journey down by key: for each typed
character, what happened from that key until the next one (the last entry:
until the settle). `domMutations` and `cdTicks` are split at the renderer's
keydown stamps. `ipcCalls`, `queryCalls` (`dbGlobalSearch` calls) and
`sqlStatements` are differences of the main-process samples taken just
before each key, so their boundaries sit a few milliseconds before the
renderer's. A search with working debounce shows zeros for the first five
keys and one query after the last; a search that queried on every key from
the second character on would show a `dbGlobalSearch` call and its
statements in each of those entries, even when a later key superseded the
result.
No counter is listed under `unavailable`. HTTP requests are not a J4
counter: global search does not touch the network.
### Wall-clock
| Entry | Derivation |
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `firstKeystrokeToFirstResultMs.p50/.p90` | First mutation batch with a visible result card (for any term) minus the first keydown. |
| `lastKeystrokeToSettledMs.p50/.p90` | Settled moment (the last mutation batch before the 200 ms quiet window) minus the last keydown. The quiet window itself is not included. |
Both are taken in the renderer. With the current debounces both include
the 650 ms the term waits (350 ms in the shell, then 300 ms in the results
component); the first also includes the 500 ms of typing.
### First measurement
Local, macOS, 2026-10-04, three full `perf:journeys` runs plus repeated J4
runs. In the runs where J4 completed (the first and third full runs), every
counter was identical in all ten measured iterations except one:
`renderer.ipcCallsPerSearch` 1 (`dbGlobalSearch`),
`renderer.sqlStatementsPerSearch` 2, `renderer.ipcSerialDepthToResults` 1,
`renderer.domMutationsToResults` 402 (100 cards on the first page),
`renderer.layoutShiftScore` 0 and `renderer.longTasks` 0.
`renderer.cdTicksToResults` read 14 in all five iterations of the third run
and 13, 18, 13, 15, 13 in the first, so check the runner's
`counterStability` before trusting it. P50/P90
`lastKeystrokeToSettledMs` 686/692 ms and `firstKeystrokeToFirstResultMs`
1,178/1,184 ms in the third run (699/720 and 1,193/1,210 in the first).
Per keystroke, the first five keys caused one change-detection tick each
and nothing else: no bridge call, no SQL statement and no DOM mutation.
All of the work followed the sixth key. Search therefore does not run a
query per keystroke. The debounce does dominate the wall clock: about
650 ms of the 686 ms from the last key to settled is the two stacked
debounces (350 ms in the shell, then 300 ms in the results component). The
query itself, from the bridge call to the rendered first page, takes the
remaining 30-40 ms.
About one launch in sixty showed the empty-results view: the single
`dbGlobalSearch` call went out with the same arguments (`system`, all three
types, hidden categories included, limit 101) and the main process answered
with an empty array in about 20 ms, with no renderer error. The database
was unchanged from passing launches (the same 119 statements before the
first key, the usual 2 for the query, none afterwards), and every failure
traced was the first launch after seeding. The journey fails such an
iteration instead of measuring it, so a full run occasionally fails J4.
The cause is in the app, not the harness, and is not fixed here. No J4
baseline exists yet; J4 counters join the ratchet once three runner runs
agree.
## `renderer.initialBytes` ## `renderer.initialBytes`
The bytes a browser fetches before Angular can bootstrap, read from the built The bytes a browser fetches before Angular can bootstrap, read from the built
@@ -1240,8 +1408,10 @@ reports slow imports of non-Latin playlists.
2. Give the journey its own probe options (`cardSelector`, 2. Give the journey its own probe options (`cardSelector`,
`companionSelectors`, `routeFragment`, `startClick` for a click start, `companionSelectors`, `routeFragment`, `startClick` for a click start,
`media` for a media-event end such as J3's `playing`) or extend `media` for a media-event end such as J3's `playing`) or extend
`journey-renderer-probe.ts` when the end condition is neither. Use a state `journey-renderer-probe.ts` when the end condition is neither. A journey
key and sentinel ids of its own. Keep the probe self-contained: Playwright whose start or end does not fit that probe gets a probe of its own, as
J4's typed start and quiet-window end do (`search-journey-probe.ts`). Use
a state key and sentinel ids of its own. Keep the probe self-contained: Playwright
serializes it with `toString()`. A click-started journey settles with serializes it with `toString()`. A click-started journey settles with
`waitForJourneyClickQuiet` from `journey-click-settle.ts`. `waitForJourneyClickQuiet` from `journey-click-settle.ts`.
3. Map the measurement to a `JourneyIterationRecord` in a 3. Map the measurement to a `JourneyIterationRecord` in a