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>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-04 11:03:26 +02:00
1 parent e8b181fcea
commit 4ecc2d1096
13 files changed
+2087 -67

No files matched your search

@@ -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 {
JOURNEY_CLICK_QUIET_MS,
waitForJourneyClickQuiet,
} from '../performance/journey-click-settle';
import {
blockJourneyExternalArtwork,
readJourneyExternalArtworkCancelled,
} from '../performance/journey-external-artwork';
import {
detachJourneyMainIpcCapture,
installJourneyMainIpcCapture,
@@ -47,14 +51,6 @@ export const PLAYBACK_JOURNEY_MAIN_IPC_STATE_KEY =
'__iptvnatorJourneyPlaybackMainIpcCapture';
export const PLAYBACK_JOURNEY_PORTAL_NAME = 'Journey live portal';
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. */
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. */
async function openLiveCategory(page: Page, timeoutMs: number): Promise<void> {
await page
@@ -143,7 +100,7 @@ export async function measurePlaybackJourney(
if (!startClick) {
throw new Error('playback-journey-probe-without-start');
}
await blockExternalArtwork(electronApp);
await blockJourneyExternalArtwork(electronApp);
await openLiveCategory(mainWindow, timeoutMs);
const channel = mainWindow.locator(startClick.selector).first();
await channel.waitFor({ state: 'visible', timeout: timeoutMs });
@@ -199,7 +156,7 @@ export async function measurePlaybackJourney(
);
return {
externalArtworkCancelled:
await readCancelledExternalArtwork(electronApp),
await readJourneyExternalArtworkCancelled(electronApp),
http: {
afterPlaying: sinceSpawn.filter(
(entry) =>
@@ -0,0 +1,335 @@
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,
SEARCH_JOURNEY_INPUT_SELECTOR,
SEARCH_JOURNEY_ROUTE_PATH,
waitForSearchJourneyProbe,
} from '../performance/search-journey-probe';
import {
SEARCH_JOURNEY_QUERY_METHOD,
type SearchJourneyActivitySample,
type SearchJourneyMeasurement,
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';
/**
* Keeps the trace events of the query method (summarized arguments and
* result, as the preload traces them) for the message of a failed settle.
*/
async function traceQueryCalls(
electronApp: ElectronApplication
): Promise<void> {
await electronApp.evaluate(
({ ipcMain }, input) => {
const entries: string[] = [];
(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 && entries.length < 6) {
entries.push(JSON.stringify(record).slice(0, 600));
}
});
},
{
channel: JOURNEY_RENDERER_API_TRACE_CHANNEL,
key: QUERY_TRACE_KEY,
method: SEARCH_JOURNEY_QUERY_METHOD,
}
);
}
/**
* 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 summarized arguments and results.
*/
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 electronApp.evaluate(
(_electron, key) =>
(globalThis as unknown as Record<string, unknown>)[key],
QUERY_TRACE_KEY
);
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 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,
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.
*
* Every journey needs the renderer-API trace (`IPTVNATOR_TRACE_IPC`) for its
* IPC counters. Only J1 records the main-process counters:
* `IPTVNATOR_PERF_CAPTURE` turns on the counters and their read handler, and
* IPC counters. Only J1 and J4 (for its SQL count) record the main-process
* counters: `IPTVNATOR_PERF_CAPTURE` turns on the counters and their read handler, and
* `IPTVNATOR_PERF_COUNT_SQL` wraps every main-thread and worker SQLite
* statement to count it (see journey-main-counters.ts). A journey that
* 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
// hook wraps every row of a bulk insert, so they must not enable it.
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/), [
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
// 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', '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(
join(sourceRoot, 'journeys', 'launch-journey-app.ts'),
'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,328 @@
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,
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,
},
ipc: {
...measurement().ipc,
callsBeforeSentinel: 5,
callsByMethod: { dbGlobalSearch: 5 },
},
})
);
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({
samples: [sample(0, 0, 120), ...base.samples.slice(1)],
})
),
/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, {});
});
@@ -0,0 +1,240 @@
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';
/**
* 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;
}
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];
}
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');
}
// Activity between the quiet snapshot and the first key could finish
// after it and be counted as the search's. The probe and the capture
// keep counting until the first keydown, so they must still match it.
const moved = [
renderer.preStart.domMutations !== settle.preStartDomMutations
? 'dom'
: null,
ipc.callsBeforeStart !== settle.preStartIpcCalls ? 'ipc' : null,
samples[0].sqlStatements !== 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 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]:
finalSample.sqlStatements - samples[0].sqlStatements,
}),
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}`
),
keyDelayMs: measurement.keyDelayMs,
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,
sqlStatementsAfterSettled: Object.freeze({
count:
measurement.afterSettled.sqlStatements -
finalSample.sqlStatements,
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,
});
}