mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
Merge branch 'master' into perf/j3-epg-ribbon-window
This commit is contained in:
188 files changed
+6831
-731
No files matched your search
@@ -13,6 +13,10 @@ import {
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
expectRendererReloadedOnRoute,
|
||||
reloadFromMainProcess,
|
||||
} from './renderer-reload.support';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A detail page's Back lives in the workspace header's leading slot, not in
|
||||
@@ -29,6 +33,10 @@ import {
|
||||
// header's history fallback while an in-app previous page exists; on a
|
||||
// phone it yields to the drawer toggle, and with nowhere to go the slot is
|
||||
// empty rather than a disabled arrow.
|
||||
//
|
||||
// A page with a parent route (settings here) that opened the session, as
|
||||
// after a reload, has no in-app entry for history Back: its Back leads to
|
||||
// the parent instead and replaces the page's entry.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const widths = [1280, 780, 375];
|
||||
@@ -346,4 +354,33 @@ test.describe('Portal detail header Back', () => {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@electron @settings settings Back after a reload leads to the dashboard', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
const page = app.mainWindow;
|
||||
await page.waitForURL(/\/workspace\/dashboard$/);
|
||||
await openSettings(page);
|
||||
|
||||
// The reloaded document re-boots on the settings route; the
|
||||
// dashboard entry before it belongs to the old document.
|
||||
await reloadFromMainProcess(app);
|
||||
await expectRendererReloadedOnRoute(
|
||||
page,
|
||||
/\/workspace\/settings\/general$/
|
||||
);
|
||||
await expect(page.getByTestId('settings-container')).toBeVisible();
|
||||
|
||||
await headerBack(page).click();
|
||||
await expect(page).toHaveURL(/\/workspace\/dashboard$/);
|
||||
// The dashboard replaced the settings entry: nothing in this
|
||||
// document precedes it, so the header offers no Back.
|
||||
await expect(headerBack(page)).toHaveCount(0);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
expect,
|
||||
importM3uPlaylistFromNativeDialog,
|
||||
launchElectronApp,
|
||||
goToDashboard,
|
||||
openGlobalRecent,
|
||||
openWorkspaceSection,
|
||||
openPlaylistFavorites,
|
||||
openSources,
|
||||
@@ -41,8 +43,158 @@ import {
|
||||
getXtreamTitle,
|
||||
pickDistinctTitles,
|
||||
} from './portal-mock-fixtures';
|
||||
import { readVisibleSidebarCategoryNames } from './sidebar-categories.e2e-support';
|
||||
import {
|
||||
routePlayableStreams,
|
||||
startAndConfirmPlayback,
|
||||
} from './playable-stream-fixture';
|
||||
|
||||
test.describe('Electron Favorites', () => {
|
||||
for (const origin of ['favorites', 'recent', 'dashboard'] as const) {
|
||||
test(`@stalker @electron portal handoff from ${origin} restores the owning categories after opening a different source`, async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['stalker']);
|
||||
const vod = await fetchStalkerCategoryFixture(request, 'vod');
|
||||
const [title] = pickDistinctTitles(vod.items, getStalkerTitle);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
const page = app.mainWindow;
|
||||
await routePlayableStreams(page);
|
||||
try {
|
||||
await addStalkerPortal(page, { name: 'Portal A' });
|
||||
await waitForStalkerCatalog(page);
|
||||
await clickCategoryById(page, vod.categoryId);
|
||||
const portalPath = new URL(page.url()).pathname.replace(
|
||||
/\/vod(?:\/[^/]+)?$/,
|
||||
`/vod/${vod.categoryId}`
|
||||
);
|
||||
const categoriesA = await readVisibleSidebarCategoryNames(page);
|
||||
await clickGridListCardByTitle(page, title);
|
||||
await addCurrentDetailToFavorites(page);
|
||||
if (origin !== 'favorites') {
|
||||
await startAndConfirmPlayback(page, () =>
|
||||
page.locator('button.play-btn').first().click()
|
||||
);
|
||||
}
|
||||
await goBackFromDetail(page);
|
||||
if (origin !== 'favorites') {
|
||||
// Represent an already saved viewing position. The short
|
||||
// media fixture ends before the player's 15 s save interval.
|
||||
const playlistId = /\/stalker\/([^/]+)/.exec(
|
||||
portalPath
|
||||
)?.[1];
|
||||
const contentId = Number(
|
||||
vod.items.find(
|
||||
(item) => getStalkerTitle(item) === title
|
||||
)?.id
|
||||
);
|
||||
expect(playlistId).toBeTruthy();
|
||||
await page.evaluate(
|
||||
async ({ playlistId, contentId }) => {
|
||||
await window.electron.dbSavePlaybackPosition(
|
||||
playlistId!,
|
||||
{
|
||||
contentXtreamId: contentId,
|
||||
contentType: 'vod',
|
||||
playlistType: 'stalker',
|
||||
positionSeconds: 2,
|
||||
durationSeconds: 6,
|
||||
}
|
||||
);
|
||||
},
|
||||
{ playlistId, contentId }
|
||||
);
|
||||
}
|
||||
|
||||
await openSources(page);
|
||||
await addStalkerPortal(page, {
|
||||
name: 'Portal B',
|
||||
macAddress: '00:1A:79:00:00:03',
|
||||
});
|
||||
await waitForStalkerCatalog(page);
|
||||
const categoriesB = await readVisibleSidebarCategoryNames(page);
|
||||
expect(categoriesB).not.toEqual(categoriesA);
|
||||
|
||||
if (origin === 'dashboard') {
|
||||
await goToDashboard(page);
|
||||
await page
|
||||
.locator(
|
||||
'[data-test-id="dashboard-continue-watching-rail-card"]'
|
||||
)
|
||||
.filter({ hasText: title })
|
||||
.first()
|
||||
.click();
|
||||
} else {
|
||||
if (origin === 'favorites')
|
||||
await openWorkspaceSection(page, 'Global favorites');
|
||||
else await openGlobalRecent(page);
|
||||
await switchUnifiedCollectionScope(page, 'All playlists');
|
||||
await contentCardByTitle(page, title).first().click();
|
||||
}
|
||||
await expect(page.locator('app-content-hero')).toContainText(
|
||||
title
|
||||
);
|
||||
if (origin !== 'favorites') {
|
||||
await startAndConfirmPlayback(
|
||||
page,
|
||||
() =>
|
||||
page
|
||||
.getByRole('button', { name: /^Continue/ })
|
||||
.first()
|
||||
.click(),
|
||||
0.5
|
||||
);
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Close player',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
}
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'View in portal',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).pathname)
|
||||
.toBe(portalPath);
|
||||
await expect(page.locator('app-content-hero')).toContainText(
|
||||
title
|
||||
);
|
||||
await expect
|
||||
.poll(() => readVisibleSidebarCategoryNames(page))
|
||||
.toEqual(categoriesA);
|
||||
|
||||
await goBackFromDetail(page);
|
||||
await expectPathname(
|
||||
page,
|
||||
origin === 'favorites'
|
||||
? /\/workspace\/global-favorites$/
|
||||
: /\/workspace\/global-recent$/
|
||||
);
|
||||
await expect(page.locator('app-content-hero')).toContainText(
|
||||
title
|
||||
);
|
||||
for (const [name, expected] of [
|
||||
['Portal B', categoriesB],
|
||||
['Portal A', categoriesA],
|
||||
] as const) {
|
||||
await openSources(page);
|
||||
await sourceRowByTitle(page, name).first().click();
|
||||
await waitForStalkerCatalog(page);
|
||||
await expect
|
||||
.poll(() => readVisibleSidebarCategoryNames(page))
|
||||
.toEqual(expected);
|
||||
}
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('@persistence @m3u @electron shows M3U favorites in playlist and all-playlists scope, and preserves them after restart', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface JourneyRendererGateState {
|
||||
readonly gatedEpochMs: number | null;
|
||||
readonly gatedMethod: string | null;
|
||||
readonly passThroughLoads: number;
|
||||
/** `did-finish-load` events kept from the app's listeners on about:blank. */
|
||||
readonly didFinishLoadHeldOnBlank: number;
|
||||
/** `ready-to-show` events dropped while the window was on about:blank. */
|
||||
readonly readyToShowHeldOnBlank: number;
|
||||
readonly releasedEpochMs: number | null;
|
||||
|
||||
@@ -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,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.
|
||||
*
|
||||
* 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'
|
||||
|
||||
@@ -14,6 +14,7 @@ function gate(
|
||||
blankLoadedEpochMs: 1_050,
|
||||
errors: [],
|
||||
gatedEpochMs: 1_020,
|
||||
didFinishLoadHeldOnBlank: 1,
|
||||
gatedMethod: 'loadFile',
|
||||
passThroughLoads: 0,
|
||||
readyToShowHeldOnBlank: 1,
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
* therefore drops `ready-to-show` while the window is on `about:blank`;
|
||||
* Electron emits it again for the real document's first paint, because the
|
||||
* window is still hidden, which is the moment production sees.
|
||||
* The app also shows its window at the main frame's `did-finish-load`
|
||||
* when that comes first, so the gate keeps the app's `did-finish-load`
|
||||
* listeners (those registered before the gated load) away from the
|
||||
* about:blank load too. Electron's own listener that resolves
|
||||
* `loadURL(about:blank)` is registered later and still runs.
|
||||
*
|
||||
* With `ipcMain` passed in, the gate also keeps the listeners registered
|
||||
* with `ipcMain.handle` for `TAPPED_IPC_CHANNELS`, so the test can call a
|
||||
@@ -53,6 +58,38 @@ function holdReadyToShowWhileBlank(window, state) {
|
||||
};
|
||||
}
|
||||
|
||||
function holdDidFinishLoadWhileBlank(window, state) {
|
||||
const contents = window.webContents;
|
||||
if (
|
||||
!contents ||
|
||||
typeof contents.emit !== 'function' ||
|
||||
typeof contents.rawListeners !== 'function'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const appListeners = contents.rawListeners('did-finish-load');
|
||||
const originalEmit = contents.emit;
|
||||
contents.emit = function gatedContentsEmit(eventName, ...args) {
|
||||
if (eventName !== 'did-finish-load' || !isShowingBlank(window)) {
|
||||
return originalEmit.call(this, eventName, ...args);
|
||||
}
|
||||
state.didFinishLoadHeldOnBlank += 1;
|
||||
const attached = this.rawListeners(eventName);
|
||||
const held = appListeners.filter((listener) =>
|
||||
attached.includes(listener)
|
||||
);
|
||||
for (const listener of held) this.removeListener(eventName, listener);
|
||||
try {
|
||||
return originalEmit.call(this, eventName, ...args);
|
||||
} finally {
|
||||
// Raw listeners keep their `once` wrappers, so a re-added once
|
||||
// listener still fires once for the real document.
|
||||
for (const listener of held)
|
||||
this.prependListener(eventName, listener);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function tapIpcHandlers(ipcMain, channels) {
|
||||
const handlers = new Map();
|
||||
const originalHandle = ipcMain.handle;
|
||||
@@ -78,6 +115,7 @@ function installJourneyRendererGate(BrowserWindow, target, options = {}) {
|
||||
errors: [],
|
||||
gatedEpochMs: null,
|
||||
gatedMethod: null,
|
||||
didFinishLoadHeldOnBlank: 0,
|
||||
passThroughLoads: 0,
|
||||
readyToShowHeldOnBlank: 0,
|
||||
releasedEpochMs: null,
|
||||
@@ -128,6 +166,7 @@ function installJourneyRendererGate(BrowserWindow, target, options = {}) {
|
||||
state.gatedEpochMs = now();
|
||||
state.gatedMethod = method;
|
||||
holdReadyToShowWhileBlank(this, state);
|
||||
holdDidFinishLoadWhileBlank(this, state);
|
||||
try {
|
||||
await this.webContents.loadURL(BLANK_URL);
|
||||
state.blankLoadedEpochMs = now();
|
||||
|
||||
@@ -4,6 +4,7 @@ import test from 'node:test';
|
||||
|
||||
interface GateState {
|
||||
blankLoadedEpochMs: number | null;
|
||||
didFinishLoadHeldOnBlank: number;
|
||||
errors: string[];
|
||||
gatedEpochMs: number | null;
|
||||
gatedMethod: string | null;
|
||||
@@ -155,13 +156,13 @@ test('records a failed about:blank navigation and still loads after release', as
|
||||
function createEmittingBrowserWindow(log: string[]) {
|
||||
class EmittingBrowserWindow extends EventEmitter {
|
||||
url = '';
|
||||
webContents = {
|
||||
webContents = Object.assign(new EventEmitter(), {
|
||||
getURL: () => this.url,
|
||||
loadURL: async (url: string) => {
|
||||
this.url = url;
|
||||
log.push(`webContents.loadURL:${url}`);
|
||||
},
|
||||
};
|
||||
});
|
||||
async loadFile(file: string): Promise<void> {
|
||||
this.url = `file:///${file}`;
|
||||
log.push(`loadFile:${file}`);
|
||||
@@ -201,6 +202,47 @@ test('holds ready-to-show while the window shows about:blank, then lets the real
|
||||
assert.equal(api.state.readyToShowHeldOnBlank, 1);
|
||||
});
|
||||
|
||||
test('keeps did-finish-load of about:blank from the app listeners, not from later ones', async () => {
|
||||
const log: string[] = [];
|
||||
const EmittingBrowserWindow = createEmittingBrowserWindow(log);
|
||||
const api = gateModule.installJourneyRendererGate(
|
||||
EmittingBrowserWindow as unknown as {
|
||||
prototype: Record<string, unknown>;
|
||||
},
|
||||
{},
|
||||
{ timeoutMs: 60_000 }
|
||||
);
|
||||
const window = new EmittingBrowserWindow();
|
||||
// The app shows its window at the first did-finish-load.
|
||||
window.webContents.once('did-finish-load', () =>
|
||||
log.push('app:did-finish-load')
|
||||
);
|
||||
const load = window.loadFile('index.html');
|
||||
await settle();
|
||||
// Registered after the gated load, like Electron's own listener that
|
||||
// resolves loadURL(about:blank).
|
||||
window.webContents.on('did-finish-load', () =>
|
||||
log.push('electron:did-finish-load')
|
||||
);
|
||||
window.webContents.emit('did-finish-load');
|
||||
assert.equal(api.state.didFinishLoadHeldOnBlank, 1);
|
||||
|
||||
api.release();
|
||||
await load;
|
||||
window.webContents.emit('did-finish-load');
|
||||
window.webContents.emit('did-finish-load');
|
||||
|
||||
assert.deepEqual(log, [
|
||||
'webContents.loadURL:about:blank',
|
||||
'electron:did-finish-load',
|
||||
'loadFile:index.html',
|
||||
'app:did-finish-load',
|
||||
'electron:did-finish-load',
|
||||
'electron:did-finish-load',
|
||||
]);
|
||||
assert.equal(api.state.didFinishLoadHeldOnBlank, 1);
|
||||
});
|
||||
|
||||
test('taps ipcMain.handle for the counters channel and passes registrations through', async () => {
|
||||
const registered: string[] = [];
|
||||
const ipcMain: FakeIpcMain = {
|
||||
|
||||
@@ -341,6 +341,8 @@ test('keeps summing shifts without recent input after the first-card cutoff unti
|
||||
[
|
||||
{
|
||||
deltaHeight: 0,
|
||||
deltaWidth: 0,
|
||||
deltaX: 0,
|
||||
deltaY: -240,
|
||||
node: 'lib-dashboard-rail[data-test-id="dashboard-favorites-rail"]',
|
||||
},
|
||||
@@ -798,6 +800,13 @@ test('drops performance entries from before the click and keeps recent-input shi
|
||||
{
|
||||
entryType: 'layout-shift',
|
||||
hadRecentInput: true,
|
||||
sources: [
|
||||
{
|
||||
currentRect: { height: 40, width: 300, x: 48, y: 152 },
|
||||
node: fixture.card,
|
||||
previousRect: { height: 40, width: 320, x: 0, y: 100 },
|
||||
},
|
||||
],
|
||||
startTime: now(),
|
||||
value: 0.25,
|
||||
},
|
||||
@@ -823,6 +832,25 @@ test('drops performance entries from before the click and keeps recent-input shi
|
||||
assert.equal(state.final, true);
|
||||
assert.equal(state.counters.layoutShiftScore, 0.125);
|
||||
assert.equal(state.counters.recentInputLayoutShiftScore, 0.25);
|
||||
// Each counted shift keeps its nodes; the pre-click ones are not listed.
|
||||
assert.deepEqual(
|
||||
state.shifts.map((shift) => [
|
||||
shift.hadRecentInput,
|
||||
shift.value,
|
||||
shift.sources.map((source) => [
|
||||
source.deltaX,
|
||||
source.deltaY,
|
||||
source.deltaWidth,
|
||||
]),
|
||||
]),
|
||||
[
|
||||
[true, 0.25, [[48, 52, -20]]],
|
||||
[false, 0.125, []],
|
||||
]
|
||||
);
|
||||
assert.equal(state.shiftCount, 2);
|
||||
assert.ok(state.shifts.every((shift) => shift.sinceStartMs >= 0));
|
||||
assert.match(state.shifts[0]?.sources[0]?.node ?? '', /^[a-z-]+/);
|
||||
// J2 has no settle window: the observers close at the cutoff.
|
||||
assert.equal(state.settle.status, 'disabled');
|
||||
assert.equal(state.counters.layoutShiftScoreSettled, 0);
|
||||
@@ -869,6 +897,8 @@ test('rejects a click start whose sentinel could not be sent', async () => {
|
||||
const started = {
|
||||
...state,
|
||||
sentinel: { epochMs: 1, status: 'sent' as const },
|
||||
shiftCount: 0,
|
||||
shifts: [],
|
||||
};
|
||||
assert.throws(
|
||||
() => assertJourneyRendererProbeState(started),
|
||||
|
||||
@@ -17,9 +17,9 @@ export interface FakeEntry {
|
||||
entryType: string;
|
||||
hadRecentInput?: boolean;
|
||||
sources?: {
|
||||
currentRect: { height: number; y: number };
|
||||
currentRect: { height: number; width?: number; x?: number; y: number };
|
||||
node: unknown;
|
||||
previousRect: { height: number; y: number };
|
||||
previousRect: { height: number; width?: number; x?: number; y: number };
|
||||
}[];
|
||||
startTime: number;
|
||||
value?: number;
|
||||
|
||||
@@ -166,12 +166,24 @@ export interface JourneyRendererProbeCounters {
|
||||
recentInputLayoutShiftScore: number;
|
||||
}
|
||||
|
||||
/** A shift counted in `layoutShiftScore` or `recentInputLayoutShiftScore`. */
|
||||
export interface JourneyRendererProbeShift {
|
||||
readonly hadRecentInput: boolean;
|
||||
/** Entry start minus the journey start (navigation start for J1). */
|
||||
readonly sinceStartMs: number;
|
||||
/** `tag.class[data-test-id]` and the move of each source. */
|
||||
readonly sources: JourneyRendererProbeLateShift['sources'];
|
||||
readonly value: number;
|
||||
}
|
||||
|
||||
export interface JourneyRendererProbeLateShift {
|
||||
/** Entry start minus the first-card terminal epoch. */
|
||||
readonly afterFirstCardMs: number;
|
||||
/** `tag.class[data-test-id]` and the vertical move of each source. */
|
||||
/** `tag.class[data-test-id]` and the move of each source. */
|
||||
readonly sources: readonly {
|
||||
readonly deltaHeight: number;
|
||||
readonly deltaWidth: number;
|
||||
readonly deltaX: number;
|
||||
readonly deltaY: number;
|
||||
readonly node: string;
|
||||
}[];
|
||||
@@ -237,6 +249,13 @@ export interface JourneyRendererProbeState {
|
||||
readonly epochMs: number | null;
|
||||
readonly status: 'bridge-missing' | 'failed' | 'not-sent' | 'sent';
|
||||
};
|
||||
/** Every shift counted until the cutoff; `shifts` keeps the first 20. */
|
||||
shiftCount: number;
|
||||
/**
|
||||
* The first 20 shifts counted until the cutoff, with the nodes that
|
||||
* moved, so a layout-shift score can be traced to its components.
|
||||
*/
|
||||
shifts: JourneyRendererProbeShift[];
|
||||
/** `final` freezes the first-card counters; the settle window ends later. */
|
||||
settle: {
|
||||
/** Mutation records under the settle root after the cutoff. */
|
||||
@@ -329,6 +348,8 @@ export function journeyRendererProbeScript(
|
||||
preStart: { domMutations: 0, lastMutationEpochMs: null },
|
||||
schemaVersion: 1,
|
||||
sentinel: { epochMs: null, status: 'not-sent' },
|
||||
shiftCount: 0,
|
||||
shifts: [],
|
||||
settle: {
|
||||
domMutations: 0,
|
||||
epochMs: null,
|
||||
@@ -389,6 +410,7 @@ export function journeyRendererProbeScript(
|
||||
for (const entry of entries) {
|
||||
const shift = entry as PerformanceEntry & {
|
||||
hadRecentInput?: boolean;
|
||||
sources?: readonly LateShiftSource[];
|
||||
value?: number;
|
||||
};
|
||||
if (
|
||||
@@ -397,6 +419,18 @@ export function journeyRendererProbeScript(
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
state.shiftCount += 1;
|
||||
if (state.shifts.length < 20) {
|
||||
state.shifts.push({
|
||||
hadRecentInput: shift.hadRecentInput === true,
|
||||
sinceStartMs:
|
||||
performance.timeOrigin +
|
||||
entry.startTime -
|
||||
(state.start?.epochMs ?? performance.timeOrigin),
|
||||
sources: (shift.sources ?? []).map(describeSource),
|
||||
value: shift.value,
|
||||
});
|
||||
}
|
||||
if (shift.hadRecentInput === true) {
|
||||
state.counters.recentInputLayoutShiftScore += shift.value;
|
||||
continue;
|
||||
@@ -530,10 +564,11 @@ export function journeyRendererProbeScript(
|
||||
state.idle.status = 'done';
|
||||
}, idle.durationMs);
|
||||
};
|
||||
type ShiftRect = { height: number; width?: number; x?: number; y: number };
|
||||
type LateShiftSource = {
|
||||
currentRect?: { height: number; y: number };
|
||||
currentRect?: ShiftRect;
|
||||
node?: Node | null;
|
||||
previousRect?: { height: number; y: number };
|
||||
previousRect?: ShiftRect;
|
||||
};
|
||||
const describeSource = (source: LateShiftSource) => {
|
||||
const node = source.node;
|
||||
@@ -550,9 +585,13 @@ export function journeyRendererProbeScript(
|
||||
}
|
||||
const before = source.previousRect;
|
||||
const after = source.currentRect;
|
||||
const delta = (key: keyof ShiftRect) =>
|
||||
before && after ? (after[key] ?? 0) - (before[key] ?? 0) : 0;
|
||||
return {
|
||||
deltaHeight: before && after ? after.height - before.height : 0,
|
||||
deltaY: before && after ? after.y - before.y : 0,
|
||||
deltaHeight: delta('height'),
|
||||
deltaWidth: delta('width'),
|
||||
deltaX: delta('x'),
|
||||
deltaY: delta('y'),
|
||||
node: label,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -54,6 +54,8 @@ function measurement(
|
||||
preStart: { domMutations: 0, lastMutationEpochMs: null },
|
||||
schemaVersion: 1,
|
||||
sentinel: { epochMs: 2_601, status: 'sent' },
|
||||
shiftCount: 0,
|
||||
shifts: [],
|
||||
settle: {
|
||||
domMutations: 37,
|
||||
epochMs: 3_180.06,
|
||||
@@ -64,6 +66,8 @@ function measurement(
|
||||
sources: [
|
||||
{
|
||||
deltaHeight: 0,
|
||||
deltaWidth: 0,
|
||||
deltaX: 0,
|
||||
deltaY: -240,
|
||||
node: 'section.dashboard-rail',
|
||||
},
|
||||
@@ -112,6 +116,7 @@ function measurement(
|
||||
blankLoadedEpochMs: 1_050,
|
||||
errors: [],
|
||||
gatedEpochMs: 1_020,
|
||||
didFinishLoadHeldOnBlank: 1,
|
||||
gatedMethod: 'loadFile',
|
||||
passThroughLoads: 0,
|
||||
readyToShowHeldOnBlank: 1,
|
||||
@@ -185,6 +190,7 @@ test('maps the probe, IPC capture and main counters to exact counters and spawn-
|
||||
'main.startupPhases': 9,
|
||||
});
|
||||
assert.equal(record.evidence['rendererGateReadyToShowHeldOnBlank'], 1);
|
||||
assert.equal(record.evidence['rendererGateDidFinishLoadHeldOnBlank'], 1);
|
||||
assert.deepEqual(record.evidence['epochs'], {
|
||||
firstCard: 2_600.04,
|
||||
firstCardPaint: 2_650,
|
||||
@@ -208,6 +214,8 @@ test('maps the probe, IPC capture and main counters to exact counters and spawn-
|
||||
sources: [
|
||||
{
|
||||
deltaHeight: 0,
|
||||
deltaWidth: 0,
|
||||
deltaX: 0,
|
||||
deltaY: -240,
|
||||
node: 'section.dashboard-rail',
|
||||
},
|
||||
|
||||
@@ -185,6 +185,8 @@ export function toLaunchIterationRecord(
|
||||
mainCountersAtRead: mainCounters.counters,
|
||||
rendererGateReadyToShowHeldOnBlank:
|
||||
measurement.gate.readyToShowHeldOnBlank,
|
||||
rendererGateDidFinishLoadHeldOnBlank:
|
||||
measurement.gate.didFinishLoadHeldOnBlank,
|
||||
ipcCallsByMethod: ipc.callsByMethod,
|
||||
ipcSerialDepth: serialDepth,
|
||||
ipcTimelineAmbiguousCompletions: ipc.ambiguousTimelineCompletions,
|
||||
|
||||
@@ -59,6 +59,23 @@ function measurement(
|
||||
preStart: { domMutations: 4, lastMutationEpochMs: 9_100 },
|
||||
schemaVersion: 1,
|
||||
sentinel: { epochMs: 10_080.5, status: 'sent' },
|
||||
shiftCount: 23,
|
||||
shifts: [
|
||||
{
|
||||
hadRecentInput: true,
|
||||
sinceStartMs: 41.26,
|
||||
sources: [
|
||||
{
|
||||
deltaHeight: 0,
|
||||
deltaWidth: 0,
|
||||
deltaX: 0,
|
||||
deltaY: 52,
|
||||
node: 'div.content[data-test-id="category-list"]',
|
||||
},
|
||||
],
|
||||
value: 0.22106,
|
||||
},
|
||||
],
|
||||
settle: {
|
||||
domMutations: 0,
|
||||
epochMs: null,
|
||||
@@ -147,6 +164,24 @@ test('maps the click-started probe, IPC window and mock ledger to exact counters
|
||||
});
|
||||
assert.deepEqual(record.evidence['layoutShift'], {
|
||||
recentInput: 0.221,
|
||||
// More shifts were counted than listed.
|
||||
shiftCount: 23,
|
||||
shifts: [
|
||||
{
|
||||
hadRecentInput: true,
|
||||
sinceStartMs: 41.3,
|
||||
sources: [
|
||||
{
|
||||
deltaHeight: 0,
|
||||
deltaWidth: 0,
|
||||
deltaX: 0,
|
||||
deltaY: 52,
|
||||
node: 'div.content[data-test-id="category-list"]',
|
||||
},
|
||||
],
|
||||
value: 0.2211,
|
||||
},
|
||||
],
|
||||
withoutRecentInput: 0,
|
||||
});
|
||||
assert.deepEqual(record.evidence['httpRequestsByRoute'], {
|
||||
|
||||
@@ -184,6 +184,17 @@ export function toOpenSourceIterationRecord(
|
||||
recentInput: roundThousandth(
|
||||
renderer.counters.recentInputLayoutShiftScore
|
||||
),
|
||||
// Every counted shift; `shifts` lists the first 20.
|
||||
shiftCount: renderer.shiftCount,
|
||||
// The first 20 counted shifts and the nodes that moved.
|
||||
shifts: renderer.shifts.map((shift) =>
|
||||
Object.freeze({
|
||||
hadRecentInput: shift.hadRecentInput,
|
||||
sinceStartMs: roundTenth(shift.sinceStartMs),
|
||||
sources: shift.sources,
|
||||
value: Math.round(shift.value * 10_000) / 10_000,
|
||||
})
|
||||
),
|
||||
withoutRecentInput: roundThousandth(
|
||||
renderer.counters.layoutShiftScore
|
||||
),
|
||||
|
||||
@@ -10,6 +10,7 @@ import { JOURNEY_CD_TICK_COUNTER_KEY } from './journey-renderer-probe';
|
||||
|
||||
interface TargetConfiguration {
|
||||
configurations?: Record<string, Record<string, unknown>>;
|
||||
defaultConfiguration?: string;
|
||||
dependsOn?: unknown;
|
||||
executor?: unknown;
|
||||
options?: Record<string, unknown>;
|
||||
@@ -162,7 +163,12 @@ test('only the web performance build installs the tick counter the journeys read
|
||||
for (const [name, configuration] of Object.entries(
|
||||
webProject.targets['build'].configurations ?? {}
|
||||
)) {
|
||||
if (name === 'electron-performance') continue;
|
||||
if (
|
||||
name === 'electron-performance' ||
|
||||
name === 'electron-performance-zoneless'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(configuration['fileReplacements'] ?? []),
|
||||
/environment\.performance/,
|
||||
@@ -171,6 +177,44 @@ test('only the web performance build installs the tick counter the journeys read
|
||||
}
|
||||
});
|
||||
|
||||
// Plan item C6 measures zoneless change detection behind a build-time flag:
|
||||
// each *-zoneless configuration is its base configuration plus one swap of
|
||||
// the change-detection providers, and nothing else selects that swap.
|
||||
test('the zoneless flag is opt-in through the *-zoneless web configurations only', () => {
|
||||
const configurations = webProject.targets['build'].configurations ?? {};
|
||||
const zonelessReplacement = {
|
||||
replace: 'apps/web/src/environments/change-detection.providers.ts',
|
||||
with: 'apps/web/src/environments/change-detection.providers.zoneless.ts',
|
||||
};
|
||||
|
||||
for (const base of ['electron-performance', 'electron-e2e']) {
|
||||
const baseConfiguration = configurations[base];
|
||||
const zoneless = configurations[`${base}-zoneless`];
|
||||
assert.ok(zoneless, `web:build must define ${base}-zoneless`);
|
||||
assert.ok(baseConfiguration, `web:build must define ${base}`);
|
||||
const { fileReplacements: baseReplacements, ...baseRest } =
|
||||
baseConfiguration;
|
||||
const { fileReplacements, ...rest } = zoneless;
|
||||
assert.deepEqual(rest, baseRest, base);
|
||||
assert.deepEqual(fileReplacements, [
|
||||
...((baseReplacements as unknown[] | undefined) ?? []),
|
||||
zonelessReplacement,
|
||||
]);
|
||||
}
|
||||
for (const [name, configuration] of Object.entries(configurations)) {
|
||||
if (name.endsWith('-zoneless')) continue;
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(configuration['fileReplacements'] ?? []),
|
||||
/change-detection\.providers/,
|
||||
name
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
webProject.targets['build'].defaultConfiguration,
|
||||
'production'
|
||||
);
|
||||
});
|
||||
|
||||
test('the resolved web build cache output is the renderer directory', () => {
|
||||
const task = readResolvedWebBuildTask();
|
||||
|
||||
|
||||
@@ -74,6 +74,8 @@ function measurement(
|
||||
preStart: { domMutations: 53, lastMutationEpochMs: 8_900 },
|
||||
schemaVersion: 1,
|
||||
sentinel: { epochMs: 10_350, status: 'sent' },
|
||||
shiftCount: 0,
|
||||
shifts: [],
|
||||
settle: {
|
||||
domMutations: 0,
|
||||
epochMs: null,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -70,9 +70,21 @@ function readEagerChecklist(): { open: string[]; done: string[] } {
|
||||
|
||||
const sources = readSources();
|
||||
|
||||
// Component metadata only: a comment or string that names the strategy is
|
||||
// not an Eager component.
|
||||
const eagerMetadata = /changeDetection\s*:\s*ChangeDetectionStrategy\.Eager\b/;
|
||||
|
||||
function withoutComments(text: string): string {
|
||||
return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '');
|
||||
}
|
||||
|
||||
function isEagerComponent(text: string): boolean {
|
||||
return eagerMetadata.test(withoutComments(text));
|
||||
}
|
||||
|
||||
test('the zoneless checklist lists exactly the components that are still Eager', () => {
|
||||
const eager = [...sources]
|
||||
.filter(([, text]) => text.includes('ChangeDetectionStrategy.Eager'))
|
||||
.filter(([, text]) => isEagerComponent(text))
|
||||
.map(([file]) => file)
|
||||
.sort();
|
||||
const { open } = readEagerChecklist();
|
||||
@@ -106,6 +118,23 @@ test('the guard skips test-only file names and keeps production ones', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('a comment that names the Eager strategy is not an Eager component', () => {
|
||||
assert.equal(
|
||||
isEagerComponent(
|
||||
'// was ChangeDetectionStrategy.Eager before C6\n' +
|
||||
'/* changeDetection: ChangeDetectionStrategy.Eager */\n' +
|
||||
'@Component({ changeDetection: ChangeDetectionStrategy.OnPush })'
|
||||
),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
isEagerComponent(
|
||||
'@Component({\n changeDetection: ChangeDetectionStrategy.Eager,\n})'
|
||||
),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('ticked checklist entries name files that exist', () => {
|
||||
for (const file of readEagerChecklist().done) {
|
||||
assert.ok(sources.has(file), `${file} is ticked but does not exist`);
|
||||
|
||||
@@ -371,7 +371,7 @@ test.describe('Electron Recently Viewed', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('opens Xtream recent movies and series inline from recent without switching the playlist or showing the sidebar', async ({
|
||||
test('opens Xtream recent movies and series inline and preserves history through the movie portal handoff', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
@@ -405,6 +405,7 @@ test.describe('Electron Recently Viewed', () => {
|
||||
await startAndConfirmPlayback(app.mainWindow, () =>
|
||||
playCurrentDetail(app.mainWindow)
|
||||
);
|
||||
const moviePath = new URL(app.mainWindow.url()).pathname;
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
|
||||
await app.mainWindow
|
||||
@@ -435,6 +436,42 @@ test.describe('Electron Recently Viewed', () => {
|
||||
playlistTitle: portalTitle,
|
||||
});
|
||||
|
||||
const viewInPortal = app.mainWindow.getByRole('button', {
|
||||
name: 'View in portal',
|
||||
exact: true,
|
||||
});
|
||||
await viewInPortal.click();
|
||||
await expect
|
||||
.poll(() => new URL(app.mainWindow.url()).pathname)
|
||||
.toBe(moviePath);
|
||||
await expect(
|
||||
app.mainWindow.locator('app-content-hero')
|
||||
).toContainText(movieTitle);
|
||||
await expect(viewInPortal).toHaveCount(0);
|
||||
await startAndConfirmPlayback(app.mainWindow, () =>
|
||||
playCurrentDetail(app.mainWindow)
|
||||
);
|
||||
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
await expectInlineCollectionDetail(app.mainWindow, {
|
||||
pathname: /\/workspace\/global-recent$/,
|
||||
title: movieTitle,
|
||||
playlistTitle: portalTitle,
|
||||
});
|
||||
await app.mainWindow.goForward();
|
||||
await expect
|
||||
.poll(() => new URL(app.mainWindow.url()).pathname)
|
||||
.toBe(moviePath);
|
||||
await expect(
|
||||
app.mainWindow.locator('app-content-hero')
|
||||
).toContainText(movieTitle);
|
||||
await app.mainWindow.goBack();
|
||||
await expectInlineCollectionDetail(app.mainWindow, {
|
||||
pathname: /\/workspace\/global-recent$/,
|
||||
title: movieTitle,
|
||||
playlistTitle: portalTitle,
|
||||
});
|
||||
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
|
||||
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
clickFirstGridListCard,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
LaunchedElectronApp,
|
||||
resetMockServers,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
// Custom window controls are only rendered on Windows/Linux; macOS keeps
|
||||
@@ -188,6 +194,93 @@ test.describe('Custom window controls', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The native buttons are 14pt circles. At 100 % zoom the first header
|
||||
// control starts 60pt right of their origin, where macOS 26 ends them
|
||||
// (earlier releases end them at 52pt).
|
||||
const lightsHeight = 14;
|
||||
const headerControlOffset = 60;
|
||||
|
||||
/** Where macOS drew the native window buttons, in window points. */
|
||||
async function trafficLights(
|
||||
app: LaunchedElectronApp
|
||||
): Promise<{ x: number; y: number }> {
|
||||
const lights = await app.electronApp.evaluate(({ BrowserWindow }) =>
|
||||
BrowserWindow.getAllWindows()[0]?.getWindowButtonPosition()
|
||||
);
|
||||
expect(lights, 'native window button position').toBeTruthy();
|
||||
return lights ?? { x: Number.NaN, y: Number.NaN };
|
||||
}
|
||||
|
||||
/** Window pixels per CSS pixel. */
|
||||
function zoomFactor(page: Page): Promise<number> {
|
||||
return page.evaluate(() => window.outerWidth / window.innerWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Steps the app zoom to its minimum (−4, ≈48 %). App zoom scales CSS pixels
|
||||
* but not the native buttons.
|
||||
*/
|
||||
async function zoomOutFully(page: Page): Promise<void> {
|
||||
for (let step = 0; step < 8; step++) {
|
||||
await page.evaluate(() => window.electron.adjustZoomLevel('out'));
|
||||
}
|
||||
await expect.poll(() => zoomFactor(page)).toBeLessThan(0.6);
|
||||
}
|
||||
|
||||
async function resetZoom(page: Page): Promise<void> {
|
||||
await page.evaluate(() => window.electron.adjustZoomLevel('reset'));
|
||||
await expect.poll(() => zoomFactor(page)).toBeCloseTo(1, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* The header's first rendered control and the top of the content area, in
|
||||
* window pixels (CSS pixels times the zoom factor).
|
||||
*/
|
||||
function headerLayout(
|
||||
page: Page
|
||||
): Promise<{ control?: string; left: number; contentTop: number }> {
|
||||
return page.locator('.workspace-header').evaluate((header) => {
|
||||
const zoom = window.outerWidth / window.innerWidth;
|
||||
const first = [...header.children].find(
|
||||
(child) => child.getBoundingClientRect().width > 0
|
||||
);
|
||||
const body = document.querySelector('.workspace-body');
|
||||
return {
|
||||
control:
|
||||
first?.getAttribute('data-test-id') ??
|
||||
first?.tagName.toLowerCase(),
|
||||
left: (first?.getBoundingClientRect().left ?? 0) * zoom,
|
||||
contentTop: (body?.getBoundingClientRect().top ?? 0) * zoom,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The first header control starts right of the lights and the content area
|
||||
* below them. Polled: the layout follows a zoom change after its resize.
|
||||
*/
|
||||
async function expectHeaderClearOfLights(
|
||||
page: Page,
|
||||
lights: { x: number; y: number },
|
||||
control: string,
|
||||
label: string
|
||||
): Promise<void> {
|
||||
const layout = () => headerLayout(page);
|
||||
await expect
|
||||
.poll(async () => (await layout()).control, { message: label })
|
||||
.toBe(control);
|
||||
await expect
|
||||
.poll(async () => (await layout()).left, {
|
||||
message: `${label}: first control`,
|
||||
})
|
||||
.toBeGreaterThanOrEqual(lights.x + headerControlOffset);
|
||||
await expect
|
||||
.poll(async () => (await layout()).contentTop, {
|
||||
message: `${label}: content top`,
|
||||
})
|
||||
.toBeGreaterThanOrEqual(lights.y + lightsHeight);
|
||||
}
|
||||
|
||||
test.describe('macOS traffic lights', () => {
|
||||
test.skip(
|
||||
process.platform !== 'darwin',
|
||||
@@ -205,49 +298,88 @@ test.describe('macOS traffic lights', () => {
|
||||
const firstLink = page.locator('.app-rail a').first();
|
||||
await expect(firstLink).toBeVisible();
|
||||
|
||||
const [linkBox, contentBox] = await Promise.all([
|
||||
firstLink.boundingBox(),
|
||||
page.locator('.workspace-content').boundingBox(),
|
||||
]);
|
||||
// Aligned with the content area, where the dashboard hero starts.
|
||||
expect(
|
||||
Math.abs((linkBox?.y ?? 0) - (contentBox?.y ?? -100))
|
||||
).toBeLessThanOrEqual(1);
|
||||
const linkOffsetFromContent = async (): Promise<number> => {
|
||||
const [linkBox, contentBox] = await Promise.all([
|
||||
firstLink.boundingBox(),
|
||||
page.locator('.workspace-content').boundingBox(),
|
||||
]);
|
||||
return Math.abs(
|
||||
(linkBox?.y ?? 0) - (contentBox?.y ?? Number.NaN)
|
||||
);
|
||||
};
|
||||
expect(await linkOffsetFromContent()).toBeLessThanOrEqual(1);
|
||||
|
||||
const lights = await app.electronApp.evaluate(({ BrowserWindow }) =>
|
||||
BrowserWindow.getAllWindows()[0]?.getWindowButtonPosition()
|
||||
);
|
||||
expect(lights, 'native window button position').toBeTruthy();
|
||||
const lightsY = lights?.y ?? Number.NaN;
|
||||
// The buttons are about 14pt tall; keep a visible gap below them,
|
||||
// measured in window pixels (CSS pixels times the zoom factor).
|
||||
const lights = await trafficLights(app);
|
||||
// Keep a visible gap below the buttons, measured in window pixels
|
||||
// (CSS pixels times the zoom factor).
|
||||
const linkTopInWindowPixels = async (): Promise<number> => {
|
||||
const [box, zoom] = await Promise.all([
|
||||
firstLink.boundingBox(),
|
||||
page.evaluate(() => window.outerWidth / window.innerWidth),
|
||||
zoomFactor(page),
|
||||
]);
|
||||
return (box?.y ?? Number.NaN) * zoom;
|
||||
};
|
||||
expect(await linkTopInWindowPixels()).toBeGreaterThanOrEqual(
|
||||
lightsY + 14 + 16
|
||||
lights.y + lightsHeight + 16
|
||||
);
|
||||
|
||||
// App zoom scales CSS pixels but not the native buttons: at the
|
||||
// smallest zoom the inset must still clear them.
|
||||
for (let step = 0; step < 8; step++) {
|
||||
await page.evaluate(() =>
|
||||
window.electron.adjustZoomLevel('out')
|
||||
);
|
||||
}
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => window.outerWidth / window.innerWidth)
|
||||
)
|
||||
.toBeLessThan(0.6);
|
||||
// At the smallest zoom the inset must still clear the buttons,
|
||||
// and the link still starts with the content area.
|
||||
await zoomOutFully(page);
|
||||
await expect
|
||||
.poll(linkTopInWindowPixels)
|
||||
.toBeGreaterThanOrEqual(lightsY + 14 + 8);
|
||||
await page.evaluate(() => window.electron.adjustZoomLevel('reset'));
|
||||
.toBeGreaterThanOrEqual(lights.y + lightsHeight + 8);
|
||||
await expect.poll(linkOffsetFromContent).toBeLessThanOrEqual(1);
|
||||
await resetZoom(page);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@xtream @electron the first header control clears the lights at default and minimum zoom', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
const page = app.mainWindow;
|
||||
const lights = await trafficLights(app);
|
||||
const switcher = 'app-playlist-switcher';
|
||||
const back = 'workspace-header-back';
|
||||
|
||||
// The first page has no history to go back to: the switcher leads.
|
||||
await expectHeaderClearOfLights(page, lights, switcher, 'start');
|
||||
await zoomOutFully(page);
|
||||
await expectHeaderClearOfLights(
|
||||
page,
|
||||
lights,
|
||||
switcher,
|
||||
'start at min zoom'
|
||||
);
|
||||
await resetZoom(page);
|
||||
|
||||
// A detail page puts its Back first, pulled 8px toward the edge.
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
await page
|
||||
.getByRole('link', { name: 'Series', exact: true })
|
||||
.click();
|
||||
await clickFirstGridListCard(page);
|
||||
await expect(page.getByTestId(back)).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expectHeaderClearOfLights(page, lights, back, 'detail');
|
||||
await zoomOutFully(page);
|
||||
await expectHeaderClearOfLights(
|
||||
page,
|
||||
lights,
|
||||
back,
|
||||
'detail at min zoom'
|
||||
);
|
||||
await resetZoom(page);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
|
||||
@@ -27,8 +27,9 @@ const setWindowState = (
|
||||
);
|
||||
|
||||
/**
|
||||
* The app creates its window with `show: false` and shows it on
|
||||
* `ready-to-show`; a `hide()` sent earlier would be undone by that `show()`.
|
||||
* The app creates its window with `show: false` and shows it at
|
||||
* `ready-to-show` or `did-finish-load`, whichever comes first; a `hide()`
|
||||
* sent earlier would be undone by that `show()`.
|
||||
*/
|
||||
async function waitUntilShown(app: UnautomatedElectronApp): Promise<void> {
|
||||
await expect
|
||||
|
||||
Reference in new issue
Block a user