mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
test(perf): add the J3 playback journey
J3 clicks a live channel of an Xtream portal and ends at the built-in HTML5 player's first `playing` event, with `loadedmetadata` as a secondary phase. It follows J2: every iteration is a fresh J1 launch on a copy of a profile seeded through the app's dialogs, and the click happens after the app has settled in the portal's first live category. The portal is the mock's `live-fallback` account, whose `.ts` live URLs serve the local H.264/AAC MPEG-TS fixture that mpegts.js plays through MSE on every platform. The marketing accounts' local live bytes are zero-filled and never reach `playing`. Seeding selects the HTML5 player and the `ts` stream format; the catalog's picsum.photos logos are cancelled from the test side so no request leaves the machine. Counters: renderer.ipcCallsToPlaying, renderer.httpRequestsToPlaying, renderer.domMutationsToPlaying, renderer.layoutShiftScore and renderer.longTasks; wall-clock click->loadedmetadata and click->playing. renderer.ipcSerialDepthToPlaying is listed as unavailable until the serial-depth helper lands. No baseline yet. The probe gains a media-event terminal; J2's pre-click settle moves to journey-click-settle.ts so both journeys share it unchanged, and the probe spec's jsdom fixtures move to a shared test helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
525ca7bc44
commit
4d7ad9c120
17 files changed
+1720
-318
No files matched your search
@@ -296,8 +296,8 @@ jobs:
|
||||
if: needs.performance-journeys-scope.outputs.run == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# The electron-performance build is the bulk of the time; each journey
|
||||
# (launch, open-source) is six fresh Electron processes plus one
|
||||
# seeding run.
|
||||
# (launch, open-source, playback) is six fresh Electron processes plus
|
||||
# one seeding run.
|
||||
timeout-minutes: 30
|
||||
# Warn-only for the first two weeks of plan item B3: a failure is
|
||||
# visible on the run but does not fail the workflow.
|
||||
|
||||
@@ -388,8 +388,9 @@ $ pnpm run perf:initial-bytes
|
||||
The contract behind that number is in
|
||||
[docs/architecture/performance-journeys.md](docs/architecture/performance-journeys.md).
|
||||
|
||||
To benchmark the "launch to usable" journey (fresh Electron process on a
|
||||
seeded profile, exact renderer counters plus wall-clock), run:
|
||||
To benchmark the "launch to usable", "open a source" and "start playback"
|
||||
journeys (fresh Electron processes on a seeded profile against the local
|
||||
Xtream mock, exact renderer counters plus wall-clock), run:
|
||||
|
||||
```
|
||||
$ pnpm run perf:journeys
|
||||
|
||||
@@ -85,12 +85,21 @@ function removeDirectory(directory: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
/** What a journey changes in the seeded profile; J1 and J2 use neither. */
|
||||
export interface LaunchJourneySeedOptions {
|
||||
/** Portal credentials; default: the mock's default account. */
|
||||
readonly portal?: Parameters<typeof addXtreamPortal>[1];
|
||||
/** Runs after both sources are imported, e.g. to change settings. */
|
||||
readonly configure?: (page: Page) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds one M3U source and one Xtream portal through the app's own dialogs
|
||||
* and returns the data directory to copy for every measured launch.
|
||||
*/
|
||||
export async function seedLaunchJourneyProfile(
|
||||
mockOrigin: string
|
||||
mockOrigin: string,
|
||||
options: LaunchJourneySeedOptions = {}
|
||||
): Promise<string> {
|
||||
const templateDirectory = await mkdtemp(
|
||||
join(tmpdir(), 'iptvnator-journey-launch-seed-')
|
||||
@@ -103,8 +112,12 @@ export async function seedLaunchJourneyProfile(
|
||||
`${mockOrigin}/playlist.m3u`
|
||||
);
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
await addXtreamPortal(app.mainWindow, { serverUrl: mockOrigin });
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
...options.portal,
|
||||
serverUrl: mockOrigin,
|
||||
});
|
||||
await waitForXtreamCatalog(app.mainWindow);
|
||||
await options.configure?.(app.mainWindow);
|
||||
} finally {
|
||||
await closeElectronAppAndConfirmExit(app);
|
||||
}
|
||||
|
||||
@@ -1,161 +1,44 @@
|
||||
import type { ElectronApplication, Page } from '@playwright/test';
|
||||
|
||||
import { defaultXtreamPortalName } from '../electron-test-fixtures';
|
||||
import {
|
||||
countJourneyMainIpcInFlight,
|
||||
JOURNEY_CLICK_QUIET_MS,
|
||||
waitForJourneyClickQuiet,
|
||||
waitForJourneyMockQuiet,
|
||||
} from '../performance/journey-click-settle';
|
||||
import {
|
||||
detachJourneyMainIpcCapture,
|
||||
installJourneyMainIpcCapture,
|
||||
JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
JOURNEY_RENDERER_API_TRACE_CHANNEL,
|
||||
peekJourneyMainIpcCaptures,
|
||||
readJourneyMainIpcCapture,
|
||||
} from '../performance/journey-main-ipc-capture';
|
||||
import type { JourneyMockRequestLedger } from '../performance/journey-mock-request-ledger';
|
||||
import { waitForJourneyQuiet } from '../performance/journey-quiet-wait';
|
||||
import {
|
||||
armJourneyRendererProbe,
|
||||
createOpenSourceJourneyProbeOptions,
|
||||
waitForJourneyRendererProbe,
|
||||
type JourneyRendererProbeState,
|
||||
} from '../performance/journey-renderer-probe';
|
||||
import type {
|
||||
OpenSourceJourneyMeasurement,
|
||||
OpenSourceJourneySettle,
|
||||
} from '../performance/open-source-journey-record';
|
||||
import type { OpenSourceJourneyMeasurement } from '../performance/open-source-journey-record';
|
||||
import type { LaunchJourneySession } from './launch-journey-app';
|
||||
|
||||
/**
|
||||
* J2 "Open a source": runs inside a process that J1 has just launched, after
|
||||
* J1's counters are final. The app is first allowed to settle (no DOM
|
||||
* mutation, bridge call or mock request for `QUIET_MS`), so leftovers of the
|
||||
* startup are not attributed to the click. Then the Xtream portal card on
|
||||
* mutation, bridge call or mock request for `JOURNEY_CLICK_QUIET_MS`), so
|
||||
* leftovers of the startup are not attributed to the click. Then the Xtream portal card on
|
||||
* the dashboard is clicked and the probe, the IPC capture and the mock
|
||||
* request ledger measure until the category list and the first page of
|
||||
* items are painted.
|
||||
*/
|
||||
export const OPEN_SOURCE_JOURNEY_MAIN_IPC_STATE_KEY =
|
||||
'__iptvnatorJourneyOpenSourceMainIpcCapture';
|
||||
const QUIET_MS = 1_000;
|
||||
const POLL_MS = 100;
|
||||
const SETTLE_TIMEOUT_MS = 30_000;
|
||||
const ERROR_PREFIX = 'open-source-journey';
|
||||
/**
|
||||
* After the mock settled, the ledger is watched this much longer before it
|
||||
* is read, so requests that arrive after the accepted quiet sample show up
|
||||
* in `httpRequestsAfterSettledByRoute` instead of vanishing unseen.
|
||||
*/
|
||||
const LATE_REQUEST_OBSERVATION_MS = QUIET_MS;
|
||||
|
||||
interface ActivitySample {
|
||||
readonly domMutations: number;
|
||||
readonly httpInFlight: number;
|
||||
readonly httpRequests: number;
|
||||
readonly ipcCalls: number;
|
||||
readonly ipcInFlight: number;
|
||||
}
|
||||
|
||||
async function readPreStartMutations(
|
||||
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('journey-renderer-probe-not-armed');
|
||||
}
|
||||
return count;
|
||||
}, stateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until DOM, bridge and mock traffic have all been unchanged for
|
||||
* `QUIET_MS` with no mock request and no bridge call in flight: a slow
|
||||
* response or a pending bridge call can still change the DOM or trigger
|
||||
* follow-up work after the click. Pending bridge calls come from J1's
|
||||
* capture, which was installed before the document loaded and so has seen
|
||||
* every call start. An app that never settles fails the iteration instead
|
||||
* of producing a count that includes its background work.
|
||||
*/
|
||||
async function waitForQuiet(
|
||||
electronApp: ElectronApplication,
|
||||
page: Page,
|
||||
ledger: JourneyMockRequestLedger,
|
||||
probeStateKey: string
|
||||
): Promise<{
|
||||
/** Ledger position read by the accepted quiet sample itself. */
|
||||
readonly ledgerMark: number;
|
||||
readonly settle: OpenSourceJourneySettle;
|
||||
}> {
|
||||
const armMark = ledger.mark();
|
||||
const sample = async (): Promise<ActivitySample> => {
|
||||
// Both captures in one snapshot: a call counted by J2's capture is
|
||||
// then also pending in J1's, never counted with a stale in-flight 0.
|
||||
const [launchCapture, openSourceCapture] =
|
||||
await peekJourneyMainIpcCaptures(electronApp, [
|
||||
JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
OPEN_SOURCE_JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
]);
|
||||
if (launchCapture.unmatchedCompletions > 0) {
|
||||
throw new Error('open-source-journey-bridge-completions-unmatched');
|
||||
}
|
||||
return {
|
||||
domMutations: await readPreStartMutations(page, probeStateKey),
|
||||
httpInFlight: ledger.inFlight(),
|
||||
httpRequests: ledger.mark(),
|
||||
ipcCalls: openSourceCapture.callsBeforeStart,
|
||||
ipcInFlight: countJourneyMainIpcInFlight(launchCapture),
|
||||
};
|
||||
};
|
||||
const { sample: quiet, waitedMs } = await waitForJourneyQuiet({
|
||||
inFlight: (activity) => activity.httpInFlight + activity.ipcInFlight,
|
||||
pollMs: POLL_MS,
|
||||
quietMs: QUIET_MS,
|
||||
sample,
|
||||
timeoutError: (activity) =>
|
||||
new Error(
|
||||
`open-source-journey-not-quiet: ${JSON.stringify(activity)}`
|
||||
),
|
||||
timeoutMs: SETTLE_TIMEOUT_MS,
|
||||
});
|
||||
return {
|
||||
ledgerMark: quiet.httpRequests,
|
||||
settle: {
|
||||
preStartDomMutations: quiet.domMutations,
|
||||
preStartHttpRequests: quiet.httpRequests - armMark,
|
||||
preStartIpcCalls: quiet.ipcCalls,
|
||||
quietMs: QUIET_MS,
|
||||
waitedMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until the mock has seen no new request for `QUIET_MS` and none is
|
||||
* in flight, so responses slower than the quiet interval and the requests
|
||||
* they trigger stay inside the measured window.
|
||||
*/
|
||||
async function waitForMockQuiet(
|
||||
ledger: JourneyMockRequestLedger
|
||||
): Promise<number> {
|
||||
const { sample: quiet } = await waitForJourneyQuiet({
|
||||
inFlight: (activity) => activity.inFlight,
|
||||
pollMs: POLL_MS,
|
||||
quietMs: QUIET_MS,
|
||||
sample: async () => ({
|
||||
inFlight: ledger.inFlight(),
|
||||
requests: ledger.mark(),
|
||||
}),
|
||||
timeoutError: () => new Error('open-source-journey-mock-not-quiet'),
|
||||
timeoutMs: SETTLE_TIMEOUT_MS,
|
||||
});
|
||||
// The window ends at the ledger position this accepted sample read. A
|
||||
// request that arrives after it was never seen in flight, so its
|
||||
// response and follow-ups are not waited for; counting it would make
|
||||
// the counter depend on when the ledger is read.
|
||||
return quiet.requests;
|
||||
}
|
||||
const LATE_REQUEST_OBSERVATION_MS = JOURNEY_CLICK_QUIET_MS;
|
||||
|
||||
/**
|
||||
* `spawnLedgerMark` is the ledger position taken before the process was
|
||||
@@ -194,12 +77,12 @@ export async function measureOpenSourceJourney(
|
||||
// reading the IPC capture. Requests from that position on but before
|
||||
// the renderer's click stamp arrived after the app settled, and the
|
||||
// record rejects such an iteration.
|
||||
const { ledgerMark: settledLedgerMark, settle } = await waitForQuiet(
|
||||
electronApp,
|
||||
mainWindow,
|
||||
ledger,
|
||||
probeOptions.stateKey
|
||||
);
|
||||
const { ledgerMark: settledLedgerMark, settle } =
|
||||
await waitForJourneyClickQuiet(electronApp, mainWindow, ledger, {
|
||||
errorPrefix: ERROR_PREFIX,
|
||||
ipcCaptureStateKey: OPEN_SOURCE_JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
probeStateKey: probeOptions.stateKey,
|
||||
});
|
||||
// J1's capture was only needed to see pending launch calls while
|
||||
// settling; detached, it no longer runs for every J2 bridge call.
|
||||
await detachJourneyMainIpcCapture(electronApp, JOURNEY_MAIN_IPC_STATE_KEY);
|
||||
@@ -215,7 +98,10 @@ export async function measureOpenSourceJourney(
|
||||
OPEN_SOURCE_JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
10_000
|
||||
);
|
||||
const settledAfterLedgerMark = await waitForMockQuiet(ledger);
|
||||
const settledAfterLedgerMark = await waitForJourneyMockQuiet(
|
||||
ledger,
|
||||
ERROR_PREFIX
|
||||
);
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, LATE_REQUEST_OBSERVATION_MS)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import type { ElectronApplication, Page } from '@playwright/test';
|
||||
|
||||
import { configureLiveFormat } from '../xtream-live-format.fixture';
|
||||
import { waitForJourneyClickQuiet } from '../performance/journey-click-settle';
|
||||
import {
|
||||
detachJourneyMainIpcCapture,
|
||||
installJourneyMainIpcCapture,
|
||||
JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
JOURNEY_RENDERER_API_TRACE_CHANNEL,
|
||||
readJourneyMainIpcCapture,
|
||||
} from '../performance/journey-main-ipc-capture';
|
||||
import type { JourneyMockRequestLedger } from '../performance/journey-mock-request-ledger';
|
||||
import {
|
||||
armJourneyRendererProbe,
|
||||
createPlaybackJourneyProbeOptions,
|
||||
JOURNEY_OPEN_SOURCE_START_SELECTOR,
|
||||
waitForJourneyRendererProbe,
|
||||
} from '../performance/journey-renderer-probe';
|
||||
import type { PlaybackJourneyMeasurement } from '../performance/playback-journey-record';
|
||||
import type {
|
||||
LaunchJourneySeedOptions,
|
||||
LaunchJourneySession,
|
||||
} from './launch-journey-app';
|
||||
|
||||
/**
|
||||
* J3 "Playback": runs inside a process that J1 has just launched. The test
|
||||
* opens the Xtream portal's live section and its first category (not
|
||||
* measured), lets the app settle like J2, then clicks the first live channel
|
||||
* and measures until the HTML5 player's video element fires `playing`.
|
||||
*
|
||||
* The portal is the mock's `live-fallback` account, whose `.ts` live URLs
|
||||
* serve a local six-second H.264 baseline + AAC MPEG-TS fixture
|
||||
* (`apps/xtream-mock-server/src/fixtures/live.mpegts`). The HTML5 player
|
||||
* plays it through mpegts.js and Media Source Extensions, which Electron's
|
||||
* Chromium supports on every platform. The marketing accounts' live URLs
|
||||
* serve zero-filled bytes that no player can decode, and the other accounts
|
||||
* redirect to a public HLS stream. Contract:
|
||||
* docs/architecture/performance-journeys.md.
|
||||
*/
|
||||
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 = {
|
||||
// The built-in HTML5 player with the `ts` stream format: live URLs end
|
||||
// in `.ts`, which the mock serves from the local fixture.
|
||||
configure: (page) => configureLiveFormat(page, 'html5', 'ts'),
|
||||
portal: {
|
||||
name: PLAYBACK_JOURNEY_PORTAL_NAME,
|
||||
password: 'live-fallback',
|
||||
username: 'live-fallback',
|
||||
},
|
||||
};
|
||||
|
||||
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
|
||||
.locator(JOURNEY_OPEN_SOURCE_START_SELECTOR)
|
||||
.filter({ hasText: PLAYBACK_JOURNEY_PORTAL_NAME })
|
||||
.first()
|
||||
.click({ timeout: timeoutMs });
|
||||
await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod/, {
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
await page
|
||||
.getByRole('link', { name: 'Live TV', exact: true })
|
||||
.click({ timeout: timeoutMs });
|
||||
await page
|
||||
.locator('app-workspace-context-panel .category-item')
|
||||
.first()
|
||||
.click({ timeout: timeoutMs });
|
||||
}
|
||||
|
||||
/**
|
||||
* `spawnLedgerMark` is the ledger position taken before the process was
|
||||
* spawned, so the launch's and the navigation's mock traffic is kept as
|
||||
* evidence.
|
||||
*/
|
||||
export async function measurePlaybackJourney(
|
||||
session: LaunchJourneySession,
|
||||
ledger: JourneyMockRequestLedger,
|
||||
spawnLedgerMark: number,
|
||||
timeoutMs: number
|
||||
): Promise<PlaybackJourneyMeasurement> {
|
||||
const { electronApp, mainWindow } = session;
|
||||
const probeOptions = createPlaybackJourneyProbeOptions();
|
||||
const startClick = probeOptions.startClick;
|
||||
if (!startClick) {
|
||||
throw new Error('playback-journey-probe-without-start');
|
||||
}
|
||||
await blockExternalArtwork(electronApp);
|
||||
await openLiveCategory(mainWindow, timeoutMs);
|
||||
const channel = mainWindow.locator(startClick.selector).first();
|
||||
await channel.waitFor({ state: 'visible', timeout: timeoutMs });
|
||||
await installJourneyMainIpcCapture(electronApp, {
|
||||
channel: JOURNEY_RENDERER_API_TRACE_CHANNEL,
|
||||
sentinelId: probeOptions.sentinelId,
|
||||
sentinelMethod: probeOptions.sentinelMethod,
|
||||
startSentinelId: startClick.sentinelId,
|
||||
stateKey: PLAYBACK_JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
});
|
||||
await armJourneyRendererProbe(mainWindow, probeOptions);
|
||||
// Hover first so hover effects happen before the app settles.
|
||||
await channel.hover({ timeout: timeoutMs });
|
||||
const { ledgerMark: settledLedgerMark, settle } =
|
||||
await waitForJourneyClickQuiet(electronApp, mainWindow, ledger, {
|
||||
errorPrefix: ERROR_PREFIX,
|
||||
ipcCaptureStateKey: PLAYBACK_JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
probeStateKey: probeOptions.stateKey,
|
||||
});
|
||||
await detachJourneyMainIpcCapture(electronApp, JOURNEY_MAIN_IPC_STATE_KEY);
|
||||
await channel.click({ timeout: timeoutMs });
|
||||
const renderer = await waitForJourneyRendererProbe(
|
||||
mainWindow,
|
||||
probeOptions.stateKey,
|
||||
timeoutMs
|
||||
);
|
||||
const ipc = await readJourneyMainIpcCapture(
|
||||
electronApp,
|
||||
PLAYBACK_JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
10_000
|
||||
);
|
||||
const clickEpochMs = renderer.start?.epochMs;
|
||||
const playingEpochMs = renderer.terminal?.epochMs;
|
||||
if (clickEpochMs === undefined || playingEpochMs === undefined) {
|
||||
throw new Error('playback-journey-probe-incomplete');
|
||||
}
|
||||
// The ledger stamps arrivals with this process's clock, the probe with
|
||||
// the renderer's; both read the same host clock (as in J2).
|
||||
const sinceSpawn = ledger.since(spawnLedgerMark);
|
||||
const beforeClick = sinceSpawn.filter(
|
||||
(entry) => entry.epochMs < clickEpochMs
|
||||
);
|
||||
return {
|
||||
externalArtworkCancelled:
|
||||
await readCancelledExternalArtwork(electronApp),
|
||||
http: {
|
||||
afterPlaying: sinceSpawn.filter(
|
||||
(entry) => entry.epochMs >= playingEpochMs
|
||||
),
|
||||
afterSettleBeforeClick: beforeClick.filter(
|
||||
(entry) => entry.sequence >= settledLedgerMark
|
||||
).length,
|
||||
beforeClick,
|
||||
toPlaying: sinceSpawn.filter(
|
||||
(entry) =>
|
||||
entry.epochMs >= clickEpochMs &&
|
||||
entry.epochMs < playingEpochMs
|
||||
),
|
||||
},
|
||||
ipc,
|
||||
pid: session.launch.pid,
|
||||
renderer,
|
||||
settle,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { test } from '@playwright/test';
|
||||
|
||||
import { startJourneyMockRequestLedger } from '../performance/journey-mock-request-ledger';
|
||||
import type { JourneyIterationRecord } from '../performance/journey-summary';
|
||||
import {
|
||||
PLAYBACK_JOURNEY_ID,
|
||||
PLAYBACK_JOURNEY_UNAVAILABLE_COUNTERS,
|
||||
toPlaybackIterationRecord,
|
||||
} from '../performance/playback-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 {
|
||||
measurePlaybackJourney,
|
||||
PLAYBACK_JOURNEY_SEED,
|
||||
} from './playback-journey-app';
|
||||
|
||||
/**
|
||||
* J3 "Playback": click on a live channel of an Xtream portal until the
|
||||
* built-in HTML5 player's video element fires `playing`. Every iteration is
|
||||
* a fresh J1 launch on a copy of the seeded profile (J2's profile with the
|
||||
* portal on the mock's local-media `live-fallback` account and the HTML5
|
||||
* player selected); the click happens after the app has settled in the
|
||||
* portal's first live category.
|
||||
* Contract: docs/architecture/performance-journeys.md.
|
||||
*/
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test('J3 start playback', async () => {
|
||||
const ledger = await startJourneyMockRequestLedger(
|
||||
LAUNCH_JOURNEY_MOCK_ORIGIN
|
||||
);
|
||||
const iterations: JourneyIterationRecord[] = [];
|
||||
let electronVersion = 'unknown';
|
||||
try {
|
||||
const templateDirectory = await seedLaunchJourneyProfile(
|
||||
ledger.origin,
|
||||
PLAYBACK_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 spawnLedgerMark = ledger.mark();
|
||||
const { continuation, launch } = await runLaunchJourney(
|
||||
templateDirectory,
|
||||
JOURNEY_ITERATION_TIMEOUT_MS,
|
||||
// Like J2: no main-process counters or SQL hook.
|
||||
{ mainCounters: false },
|
||||
(session) =>
|
||||
measurePlaybackJourney(
|
||||
session,
|
||||
ledger,
|
||||
spawnLedgerMark,
|
||||
JOURNEY_ITERATION_TIMEOUT_MS
|
||||
)
|
||||
);
|
||||
electronVersion = launch.electronVersion;
|
||||
const record = toPlaybackIterationRecord(
|
||||
index,
|
||||
warmup,
|
||||
continuation
|
||||
);
|
||||
iterations.push(record);
|
||||
logJourneyIteration(PLAYBACK_JOURNEY_ID, record);
|
||||
}
|
||||
} finally {
|
||||
await removeLaunchJourneyProfile(templateDirectory);
|
||||
}
|
||||
} finally {
|
||||
await ledger.close();
|
||||
}
|
||||
|
||||
await writeJourneyRunEntry(
|
||||
PLAYBACK_JOURNEY_ID,
|
||||
iterations,
|
||||
PLAYBACK_JOURNEY_UNAVAILABLE_COUNTERS,
|
||||
electronVersion
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { ElectronApplication, Page } from '@playwright/test';
|
||||
|
||||
import {
|
||||
countJourneyMainIpcInFlight,
|
||||
JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
peekJourneyMainIpcCaptures,
|
||||
} from './journey-main-ipc-capture';
|
||||
import type { JourneyMockRequestLedger } from './journey-mock-request-ledger';
|
||||
import { waitForJourneyQuiet } from './journey-quiet-wait';
|
||||
|
||||
/**
|
||||
* Settling for the click-started journeys (J2 "Open a source", J3
|
||||
* "Playback"). Before the click the app must be quiet, so leftovers of the
|
||||
* startup or of the navigation to the start screen are not attributed to
|
||||
* the click. Contract: docs/architecture/performance-journeys.md.
|
||||
*/
|
||||
export const JOURNEY_CLICK_QUIET_MS = 1_000;
|
||||
const POLL_MS = 100;
|
||||
const SETTLE_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** How long the app was left alone before the click, and what it did. */
|
||||
export interface JourneyClickSettle {
|
||||
readonly preStartDomMutations: number;
|
||||
readonly preStartHttpRequests: number;
|
||||
readonly preStartIpcCalls: number;
|
||||
readonly quietMs: number;
|
||||
readonly waitedMs: number;
|
||||
}
|
||||
|
||||
export interface JourneyClickSettleOptions {
|
||||
/** Prefix of the timeout errors, e.g. `open-source-journey`. */
|
||||
readonly errorPrefix: string;
|
||||
/** State key of the journey's own IPC capture (with a start sentinel). */
|
||||
readonly ipcCaptureStateKey: string;
|
||||
readonly probeStateKey: string;
|
||||
}
|
||||
|
||||
interface ActivitySample {
|
||||
readonly domMutations: number;
|
||||
readonly httpInFlight: number;
|
||||
readonly httpRequests: number;
|
||||
readonly ipcCalls: number;
|
||||
readonly ipcInFlight: number;
|
||||
}
|
||||
|
||||
async function readPreStartMutations(
|
||||
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('journey-renderer-probe-not-armed');
|
||||
}
|
||||
return count;
|
||||
}, stateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until DOM, bridge and mock traffic have all been unchanged for
|
||||
* `JOURNEY_CLICK_QUIET_MS` with no mock request and no bridge call in
|
||||
* flight: a slow response or a pending bridge call can still change the DOM
|
||||
* or trigger follow-up work after the click. Pending bridge calls come from
|
||||
* J1's capture, which was installed before the document loaded and so has
|
||||
* seen every call start. An app that never settles fails the iteration
|
||||
* instead of producing a count that includes its background work.
|
||||
*/
|
||||
export async function waitForJourneyClickQuiet(
|
||||
electronApp: ElectronApplication,
|
||||
page: Page,
|
||||
ledger: JourneyMockRequestLedger,
|
||||
options: JourneyClickSettleOptions
|
||||
): Promise<{
|
||||
/** Ledger position read by the accepted quiet sample itself. */
|
||||
readonly ledgerMark: number;
|
||||
readonly settle: JourneyClickSettle;
|
||||
}> {
|
||||
const armMark = ledger.mark();
|
||||
const sample = async (): Promise<ActivitySample> => {
|
||||
// Both captures in one snapshot: a call counted by the journey's
|
||||
// capture is then also pending in J1's, never counted with a stale
|
||||
// in-flight 0.
|
||||
const [launchCapture, journeyCapture] =
|
||||
await peekJourneyMainIpcCaptures(electronApp, [
|
||||
JOURNEY_MAIN_IPC_STATE_KEY,
|
||||
options.ipcCaptureStateKey,
|
||||
]);
|
||||
if (launchCapture.unmatchedCompletions > 0) {
|
||||
throw new Error(
|
||||
`${options.errorPrefix}-bridge-completions-unmatched`
|
||||
);
|
||||
}
|
||||
return {
|
||||
domMutations: await readPreStartMutations(
|
||||
page,
|
||||
options.probeStateKey
|
||||
),
|
||||
httpInFlight: ledger.inFlight(),
|
||||
httpRequests: ledger.mark(),
|
||||
ipcCalls: journeyCapture.callsBeforeStart,
|
||||
ipcInFlight: countJourneyMainIpcInFlight(launchCapture),
|
||||
};
|
||||
};
|
||||
const { sample: quiet, waitedMs } = await waitForJourneyQuiet({
|
||||
inFlight: (activity) => activity.httpInFlight + activity.ipcInFlight,
|
||||
pollMs: POLL_MS,
|
||||
quietMs: JOURNEY_CLICK_QUIET_MS,
|
||||
sample,
|
||||
timeoutError: (activity) =>
|
||||
new Error(
|
||||
`${options.errorPrefix}-not-quiet: ${JSON.stringify(activity)}`
|
||||
),
|
||||
timeoutMs: SETTLE_TIMEOUT_MS,
|
||||
});
|
||||
return {
|
||||
ledgerMark: quiet.httpRequests,
|
||||
settle: {
|
||||
preStartDomMutations: quiet.domMutations,
|
||||
preStartHttpRequests: quiet.httpRequests - armMark,
|
||||
preStartIpcCalls: quiet.ipcCalls,
|
||||
quietMs: JOURNEY_CLICK_QUIET_MS,
|
||||
waitedMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until the mock has seen no new request for `JOURNEY_CLICK_QUIET_MS`
|
||||
* and none is in flight, so responses slower than the quiet interval and
|
||||
* the requests they trigger stay inside the measured window.
|
||||
*/
|
||||
export async function waitForJourneyMockQuiet(
|
||||
ledger: JourneyMockRequestLedger,
|
||||
errorPrefix: string
|
||||
): Promise<number> {
|
||||
const { sample: quiet } = await waitForJourneyQuiet({
|
||||
inFlight: (activity) => activity.inFlight,
|
||||
pollMs: POLL_MS,
|
||||
quietMs: JOURNEY_CLICK_QUIET_MS,
|
||||
sample: async () => ({
|
||||
inFlight: ledger.inFlight(),
|
||||
requests: ledger.mark(),
|
||||
}),
|
||||
timeoutError: () => new Error(`${errorPrefix}-mock-not-quiet`),
|
||||
timeoutMs: SETTLE_TIMEOUT_MS,
|
||||
});
|
||||
// The window ends at the ledger position this accepted sample read. A
|
||||
// request that arrives after it was never seen in flight, so its
|
||||
// response and follow-ups are not waited for; counting it would make
|
||||
// the counter depend on when the ledger is read.
|
||||
return quiet.requests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects an iteration whose activity moved after the settle snapshot but
|
||||
* before the click (while Playwright ran its actionability checks): it
|
||||
* could complete after the click and be counted as the journey's. The probe
|
||||
* and the capture keep counting until the click itself, so they must still
|
||||
* match the snapshot. Returns the kinds that moved.
|
||||
*/
|
||||
export function journeyActivityBeforeClick(
|
||||
settle: JourneyClickSettle,
|
||||
observed: {
|
||||
readonly httpAfterSettleBeforeClick: number;
|
||||
readonly ipcCallsBeforeStart: number;
|
||||
readonly preStartDomMutations: number;
|
||||
}
|
||||
): string[] {
|
||||
return [
|
||||
observed.preStartDomMutations !== settle.preStartDomMutations
|
||||
? 'dom'
|
||||
: null,
|
||||
observed.ipcCallsBeforeStart !== settle.preStartIpcCalls ? 'ipc' : null,
|
||||
observed.httpAfterSettleBeforeClick > 0 ? 'http' : null,
|
||||
].filter((kind): kind is string => kind !== null);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
import {
|
||||
assertJourneyRendererProbeState,
|
||||
createPlaybackJourneyProbeOptions,
|
||||
JOURNEY_IPC_SENTINEL_ID,
|
||||
JOURNEY_IPC_SENTINEL_METHOD,
|
||||
JOURNEY_OPEN_SOURCE_PROBE_STATE_KEY,
|
||||
JOURNEY_PLAYBACK_END_SENTINEL_ID,
|
||||
JOURNEY_PLAYBACK_PROBE_STATE_KEY,
|
||||
JOURNEY_PLAYBACK_START_SENTINEL_ID,
|
||||
JOURNEY_PROBE_STATE_KEY,
|
||||
type JourneyRendererProbeOptions,
|
||||
} from './journey-renderer-probe';
|
||||
import {
|
||||
createFixtureFromDom,
|
||||
settle,
|
||||
type FakeObserver,
|
||||
type Fixture,
|
||||
} from './journey-renderer-probe.test-helpers';
|
||||
|
||||
const LIVE_URL = 'http://localhost/workspace/xtreams/playlist-1/live';
|
||||
|
||||
interface PlaybackFixture extends Fixture {
|
||||
readonly channel: HTMLElement;
|
||||
readonly player: HTMLElement;
|
||||
}
|
||||
|
||||
function createPlaybackFixture(
|
||||
overrides: Partial<JourneyRendererProbeOptions> = {}
|
||||
): PlaybackFixture {
|
||||
const dom = new JSDOM(
|
||||
`<!doctype html><html><body><app-root>
|
||||
<app-live-stream-layout>
|
||||
<div data-test-id="channel-item"><span class="name">Channel 1</span></div>
|
||||
<app-web-player-view></app-web-player-view>
|
||||
</app-live-stream-layout></app-root></body></html>`,
|
||||
{ pretendToBeVisual: true, runScripts: 'outside-only', url: LIVE_URL }
|
||||
);
|
||||
const fixture = createFixtureFromDom(
|
||||
dom,
|
||||
{ ...createPlaybackJourneyProbeOptions(), ...overrides },
|
||||
true
|
||||
);
|
||||
const { document } = fixture.window;
|
||||
return {
|
||||
...fixture,
|
||||
channel: document.querySelector(
|
||||
'[data-test-id="channel-item"]'
|
||||
) as HTMLElement,
|
||||
player: document.querySelector('app-web-player-view') as HTMLElement,
|
||||
};
|
||||
}
|
||||
|
||||
/** What the player does on the click: mount a video element. */
|
||||
function mountVideo(fixture: PlaybackFixture): HTMLVideoElement {
|
||||
const video = fixture.window.document.createElement('video');
|
||||
fixture.player.append(video);
|
||||
return video;
|
||||
}
|
||||
|
||||
function fire(fixture: Fixture, target: EventTarget, type: string): void {
|
||||
// Media events do not bubble; the probe must see them in capture.
|
||||
target.dispatchEvent(new fixture.window.Event(type, { bubbles: false }));
|
||||
}
|
||||
|
||||
test('playback options start at a live channel row and end on the player video', () => {
|
||||
const options = createPlaybackJourneyProbeOptions();
|
||||
assert.equal(options.journey, 'playback');
|
||||
assert.equal(options.stateKey, JOURNEY_PLAYBACK_PROBE_STATE_KEY);
|
||||
assert.notEqual(options.stateKey, JOURNEY_PROBE_STATE_KEY);
|
||||
assert.notEqual(options.stateKey, JOURNEY_OPEN_SOURCE_PROBE_STATE_KEY);
|
||||
assert.equal(options.sentinelId, JOURNEY_PLAYBACK_END_SENTINEL_ID);
|
||||
assert.notEqual(options.sentinelId, JOURNEY_IPC_SENTINEL_ID);
|
||||
assert.equal(options.sentinelMethod, JOURNEY_IPC_SENTINEL_METHOD);
|
||||
assert.equal(
|
||||
options.startClick?.sentinelId,
|
||||
JOURNEY_PLAYBACK_START_SENTINEL_ID
|
||||
);
|
||||
assert.match(options.startClick?.selector ?? '', /channel-item/);
|
||||
assert.equal(options.cardSelector, 'app-web-player-view video');
|
||||
assert.deepEqual(options.media, {
|
||||
endEvent: 'playing',
|
||||
phaseEvents: ['loadedmetadata'],
|
||||
});
|
||||
});
|
||||
|
||||
test('ends at the playing event, not when the video becomes visible, and records loadedmetadata on the way', async () => {
|
||||
const fixture = createPlaybackFixture();
|
||||
let video: HTMLVideoElement | null = null;
|
||||
fixture.channel.addEventListener('click', () => {
|
||||
video = mountVideo(fixture);
|
||||
});
|
||||
(fixture.channel.querySelector('.name') as HTMLElement).click();
|
||||
await settle();
|
||||
assert.ok(video, 'the app mounted a video');
|
||||
const mounted = video as HTMLVideoElement;
|
||||
assert.equal(
|
||||
fixture.state().terminal,
|
||||
null,
|
||||
'a visible video is not enough'
|
||||
);
|
||||
assert.deepEqual(fixture.bridgeCalls, [JOURNEY_PLAYBACK_START_SENTINEL_ID]);
|
||||
|
||||
fire(fixture, mounted, 'loadedmetadata');
|
||||
await settle();
|
||||
const afterMetadata = fixture.state();
|
||||
assert.equal(afterMetadata.terminal, null);
|
||||
assert.equal(
|
||||
typeof afterMetadata.media?.phases['loadedmetadata'],
|
||||
'number'
|
||||
);
|
||||
|
||||
// Mutations queued in the same task as the event still count: the
|
||||
// probe takes them synchronously at the event.
|
||||
mounted.setAttribute('data-state', 'playing');
|
||||
fire(fixture, mounted, 'playing');
|
||||
mounted.setAttribute('data-state', 'after');
|
||||
await settle();
|
||||
const state = fixture.state();
|
||||
assert.deepEqual(fixture.bridgeCalls, [
|
||||
JOURNEY_PLAYBACK_START_SENTINEL_ID,
|
||||
JOURNEY_PLAYBACK_END_SENTINEL_ID,
|
||||
]);
|
||||
assert.ok(state.start && state.terminal && state.media?.element);
|
||||
assert.equal(state.start.targetTestId, 'channel-item');
|
||||
assert.equal(state.terminal.cardTag, 'video');
|
||||
assert.equal(state.terminal.cardCount, 1);
|
||||
assert.deepEqual(state.terminal.companionCounts, []);
|
||||
assert.equal(state.terminal.pathname, '/workspace/xtreams/playlist-1/live');
|
||||
// video mounted (1) + data-state=playing (1); the later change is not.
|
||||
assert.equal(state.counters.domMutations, 2);
|
||||
const metadataEpochMs = state.media.phases['loadedmetadata'] as number;
|
||||
assert.ok(state.start.epochMs <= metadataEpochMs);
|
||||
assert.ok(metadataEpochMs <= state.terminal.epochMs);
|
||||
assert.equal(state.media.phases['playing'], state.terminal.epochMs);
|
||||
assert.equal(state.media.element.paused, true);
|
||||
assert.equal(state.final, true);
|
||||
assert.equal(state.settle.status, 'disabled');
|
||||
assert.doesNotThrow(() => assertJourneyRendererProbeState(state));
|
||||
});
|
||||
|
||||
test('ignores media events before the click and on other media elements', async () => {
|
||||
const fixture = createPlaybackFixture();
|
||||
const early = mountVideo(fixture);
|
||||
fire(fixture, early, 'loadedmetadata');
|
||||
fire(fixture, early, 'playing');
|
||||
await settle();
|
||||
assert.equal(fixture.state().start, null);
|
||||
assert.equal(fixture.state().terminal, null);
|
||||
|
||||
fixture.channel.click();
|
||||
const preview = fixture.window.document.createElement('video');
|
||||
fixture.window.document.body.append(preview);
|
||||
fire(fixture, preview, 'playing');
|
||||
await settle();
|
||||
const state = fixture.state();
|
||||
assert.ok(state.start);
|
||||
assert.equal(state.terminal, null);
|
||||
assert.deepEqual(state.media?.phases, {});
|
||||
assert.deepEqual(fixture.bridgeCalls, [JOURNEY_PLAYBACK_START_SENTINEL_ID]);
|
||||
});
|
||||
|
||||
test('counts shifts and long tasks up to the playing event, not to the post-paint cutoff', async () => {
|
||||
const fixture = createPlaybackFixture();
|
||||
const [layoutShift, longTask] = fixture.observers as [
|
||||
FakeObserver,
|
||||
FakeObserver,
|
||||
];
|
||||
const now = () => fixture.window.performance.now();
|
||||
fixture.channel.click();
|
||||
const video = mountVideo(fixture);
|
||||
layoutShift.emit([
|
||||
{
|
||||
entryType: 'layout-shift',
|
||||
hadRecentInput: true,
|
||||
startTime: now(),
|
||||
value: 0.004,
|
||||
},
|
||||
]);
|
||||
longTask.emit([{ duration: 80, entryType: 'longtask', startTime: now() }]);
|
||||
// Leave room between the click and the event for the entries below.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
fire(fixture, video, 'playing');
|
||||
const playingMs =
|
||||
(fixture.rawState().terminal?.epochMs ?? 0) -
|
||||
fixture.window.performance.timeOrigin;
|
||||
// Delivered after the event: one started before it, one after.
|
||||
layoutShift.emit([
|
||||
{
|
||||
entryType: 'layout-shift',
|
||||
hadRecentInput: false,
|
||||
startTime: playingMs - 1,
|
||||
value: 0.002,
|
||||
},
|
||||
{
|
||||
entryType: 'layout-shift',
|
||||
hadRecentInput: false,
|
||||
startTime: playingMs + 5,
|
||||
value: 0.5,
|
||||
},
|
||||
]);
|
||||
longTask.emit([
|
||||
// The task that dispatched `playing` began before it and counts.
|
||||
{ duration: 60, entryType: 'longtask', startTime: playingMs - 10 },
|
||||
{ duration: 300, entryType: 'longtask', startTime: playingMs + 5 },
|
||||
]);
|
||||
await settle();
|
||||
const state = fixture.state();
|
||||
assert.equal(state.final, true);
|
||||
assert.equal(state.counters.recentInputLayoutShiftScore, 0.004);
|
||||
assert.equal(state.counters.layoutShiftScore, 0.002);
|
||||
assert.equal(state.counters.longTasks, 2);
|
||||
assert.deepEqual(state.longTaskDurationsMs, [80, 60]);
|
||||
assert.ok((state.firstCardPaintEpochMs ?? 0) >= state.terminal!.epochMs);
|
||||
});
|
||||
|
||||
test('a media terminal without a click start is invalid', () => {
|
||||
const fixture = createPlaybackFixture({ startClick: undefined });
|
||||
assert.ok(
|
||||
fixture
|
||||
.state()
|
||||
.invalidReasons.includes('media-terminal-needs-start-click')
|
||||
);
|
||||
});
|
||||
|
||||
test('other journeys carry no media block', () => {
|
||||
const fixture = createPlaybackFixture({ media: undefined });
|
||||
assert.equal(fixture.state().media, null);
|
||||
});
|
||||
@@ -18,38 +18,14 @@ import {
|
||||
JOURNEY_SETTLE_ROOT_SELECTOR,
|
||||
journeyRendererProbeScript,
|
||||
type JourneyRendererProbeOptions,
|
||||
type JourneyRendererProbeState,
|
||||
} from './journey-renderer-probe';
|
||||
|
||||
interface FakeEntry {
|
||||
duration?: number;
|
||||
entryType: string;
|
||||
hadRecentInput?: boolean;
|
||||
sources?: {
|
||||
currentRect: { height: number; y: number };
|
||||
node: unknown;
|
||||
previousRect: { height: number; y: number };
|
||||
}[];
|
||||
startTime: number;
|
||||
value?: number;
|
||||
}
|
||||
|
||||
interface FakeObserver {
|
||||
disconnected: boolean;
|
||||
emit(entries: FakeEntry[]): void;
|
||||
queue: FakeEntry[];
|
||||
type: string | null;
|
||||
}
|
||||
|
||||
interface Fixture {
|
||||
readonly bridgeCalls: unknown[];
|
||||
readonly observers: FakeObserver[];
|
||||
/** The live state object inside the jsdom realm. */
|
||||
readonly rawState: () => JourneyRendererProbeState;
|
||||
/** A JSON clone, so assertions compare values across realms. */
|
||||
readonly state: () => JourneyRendererProbeState;
|
||||
readonly window: JSDOM['window'];
|
||||
}
|
||||
import {
|
||||
createFixtureFromDom,
|
||||
settle,
|
||||
type FakeEntry,
|
||||
type FakeObserver,
|
||||
type Fixture,
|
||||
} from './journey-renderer-probe.test-helpers';
|
||||
|
||||
const PAGE = `<!doctype html><html><head></head><body class="mat-app-background">
|
||||
<div id="initial-splash" role="status"><span>IPTVnator</span></div>
|
||||
@@ -62,55 +38,6 @@ const FAST_SETTLE = {
|
||||
rootSelector: JOURNEY_SETTLE_ROOT_SELECTOR,
|
||||
} as const;
|
||||
|
||||
function installFakePerformance(
|
||||
window: JSDOM['window'],
|
||||
observers: FakeObserver[]
|
||||
): void {
|
||||
class FakePerformanceObserver implements FakeObserver {
|
||||
disconnected = false;
|
||||
queue: FakeEntry[] = [];
|
||||
type: string | null = null;
|
||||
constructor(
|
||||
private readonly callback: (list: {
|
||||
getEntries(): FakeEntry[];
|
||||
}) => void
|
||||
) {
|
||||
observers.push(this);
|
||||
}
|
||||
observe(options: { type: string }): void {
|
||||
this.type = options.type;
|
||||
}
|
||||
takeRecords(): FakeEntry[] {
|
||||
const queued = this.queue;
|
||||
this.queue = [];
|
||||
return queued;
|
||||
}
|
||||
disconnect(): void {
|
||||
this.disconnected = true;
|
||||
}
|
||||
emit(entries: FakeEntry[]): void {
|
||||
this.callback({ getEntries: () => entries });
|
||||
}
|
||||
}
|
||||
Object.defineProperty(window, 'PerformanceObserver', {
|
||||
configurable: true,
|
||||
value: FakePerformanceObserver,
|
||||
});
|
||||
Object.defineProperty(window.performance, 'getEntriesByType', {
|
||||
configurable: true,
|
||||
value: (type: string) =>
|
||||
type === 'navigation'
|
||||
? [{ domContentLoadedEventEnd: 100, loadEventEnd: 120 }]
|
||||
: [],
|
||||
});
|
||||
// jsdom never lays out, so visibility is "connected to the document".
|
||||
window.HTMLElement.prototype.getClientRects = function getClientRects(
|
||||
this: HTMLElement
|
||||
) {
|
||||
return (this.isConnected ? [{}] : []) as unknown as DOMRectList;
|
||||
};
|
||||
}
|
||||
|
||||
function createFixture(
|
||||
overrides: Partial<JourneyRendererProbeOptions> & {
|
||||
bridge?: boolean;
|
||||
@@ -138,79 +65,6 @@ function createFixture(
|
||||
);
|
||||
}
|
||||
|
||||
function createFixtureFromDom(
|
||||
dom: JSDOM,
|
||||
options: JourneyRendererProbeOptions,
|
||||
bridge: boolean
|
||||
): Fixture {
|
||||
const { window } = dom;
|
||||
const observers: FakeObserver[] = [];
|
||||
const bridgeCalls: unknown[] = [];
|
||||
installFakePerformance(window, observers);
|
||||
// tsx (esbuild keepNames) rewrites named inner functions as
|
||||
// `__name(fn, 'name')` when it transpiles the probe for this test runner.
|
||||
// Playwright's Babel transform, which serializes the probe for the real
|
||||
// browser, does not, so the shim is a test-runner concern only.
|
||||
Object.defineProperty(window, '__name', {
|
||||
configurable: true,
|
||||
value: (target: unknown) => target,
|
||||
});
|
||||
if (bridge) {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: Object.freeze({
|
||||
[JOURNEY_IPC_SENTINEL_METHOD]: (id: unknown) => {
|
||||
bridgeCalls.push(id);
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
onSomething: () => undefined,
|
||||
}),
|
||||
});
|
||||
}
|
||||
window.eval(
|
||||
`(${journeyRendererProbeScript.toString()})(${JSON.stringify(options)})`
|
||||
);
|
||||
const rawState = (): JourneyRendererProbeState =>
|
||||
(window as unknown as Record<string, JourneyRendererProbeState>)[
|
||||
options.stateKey
|
||||
] as JourneyRendererProbeState;
|
||||
liveStates.push(rawState);
|
||||
return {
|
||||
bridgeCalls,
|
||||
observers,
|
||||
rawState,
|
||||
state: () =>
|
||||
JSON.parse(JSON.stringify(rawState())) as JourneyRendererProbeState,
|
||||
window,
|
||||
};
|
||||
}
|
||||
|
||||
/** Probes created by this file, so `settle` can wait for their cutoff. */
|
||||
const liveStates: (() => JourneyRendererProbeState)[] = [];
|
||||
|
||||
/**
|
||||
* Waits `ms`, then until every probe that reached its terminal batch has also
|
||||
* passed the post-paint cutoff (a rAF plus a timer) and closed its settle
|
||||
* window. A fixed delay alone flakes when the harness runs all spec files in
|
||||
* parallel.
|
||||
*/
|
||||
async function settle(ms = 40): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const deadline = Date.now() + 3_000;
|
||||
while (
|
||||
liveStates.some((read) => {
|
||||
const state = read();
|
||||
return (
|
||||
state.terminal !== null &&
|
||||
(!state.final || state.settle.status === 'pending')
|
||||
);
|
||||
}) &&
|
||||
Date.now() < deadline
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
|
||||
function renderFirstCard(fixture: Fixture): void {
|
||||
const { document } = fixture.window;
|
||||
document.getElementById('initial-splash')?.remove();
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { JSDOM } from 'jsdom';
|
||||
|
||||
import {
|
||||
JOURNEY_IPC_SENTINEL_METHOD,
|
||||
journeyRendererProbeScript,
|
||||
type JourneyRendererProbeOptions,
|
||||
type JourneyRendererProbeState,
|
||||
} from './journey-renderer-probe';
|
||||
|
||||
/**
|
||||
* jsdom fixtures shared by the renderer probe specs: fake performance
|
||||
* observers, a frozen bridge that records sentinel calls, and a wait for
|
||||
* every probe's post-paint cutoff.
|
||||
*/
|
||||
export interface FakeEntry {
|
||||
duration?: number;
|
||||
entryType: string;
|
||||
hadRecentInput?: boolean;
|
||||
sources?: {
|
||||
currentRect: { height: number; y: number };
|
||||
node: unknown;
|
||||
previousRect: { height: number; y: number };
|
||||
}[];
|
||||
startTime: number;
|
||||
value?: number;
|
||||
}
|
||||
|
||||
export interface FakeObserver {
|
||||
disconnected: boolean;
|
||||
emit(entries: FakeEntry[]): void;
|
||||
queue: FakeEntry[];
|
||||
type: string | null;
|
||||
}
|
||||
|
||||
export interface Fixture {
|
||||
readonly bridgeCalls: unknown[];
|
||||
readonly observers: FakeObserver[];
|
||||
/** The live state object inside the jsdom realm. */
|
||||
readonly rawState: () => JourneyRendererProbeState;
|
||||
/** A JSON clone, so assertions compare values across realms. */
|
||||
readonly state: () => JourneyRendererProbeState;
|
||||
readonly window: JSDOM['window'];
|
||||
}
|
||||
|
||||
export function installFakePerformance(
|
||||
window: JSDOM['window'],
|
||||
observers: FakeObserver[]
|
||||
): void {
|
||||
class FakePerformanceObserver implements FakeObserver {
|
||||
disconnected = false;
|
||||
queue: FakeEntry[] = [];
|
||||
type: string | null = null;
|
||||
constructor(
|
||||
private readonly callback: (list: {
|
||||
getEntries(): FakeEntry[];
|
||||
}) => void
|
||||
) {
|
||||
observers.push(this);
|
||||
}
|
||||
observe(options: { type: string }): void {
|
||||
this.type = options.type;
|
||||
}
|
||||
takeRecords(): FakeEntry[] {
|
||||
const queued = this.queue;
|
||||
this.queue = [];
|
||||
return queued;
|
||||
}
|
||||
disconnect(): void {
|
||||
this.disconnected = true;
|
||||
}
|
||||
emit(entries: FakeEntry[]): void {
|
||||
this.callback({ getEntries: () => entries });
|
||||
}
|
||||
}
|
||||
Object.defineProperty(window, 'PerformanceObserver', {
|
||||
configurable: true,
|
||||
value: FakePerformanceObserver,
|
||||
});
|
||||
Object.defineProperty(window.performance, 'getEntriesByType', {
|
||||
configurable: true,
|
||||
value: (type: string) =>
|
||||
type === 'navigation'
|
||||
? [{ domContentLoadedEventEnd: 100, loadEventEnd: 120 }]
|
||||
: [],
|
||||
});
|
||||
// jsdom never lays out, so visibility is "connected to the document".
|
||||
window.HTMLElement.prototype.getClientRects = function getClientRects(
|
||||
this: HTMLElement
|
||||
) {
|
||||
return (this.isConnected ? [{}] : []) as unknown as DOMRectList;
|
||||
};
|
||||
}
|
||||
|
||||
export function createFixtureFromDom(
|
||||
dom: JSDOM,
|
||||
options: JourneyRendererProbeOptions,
|
||||
bridge: boolean
|
||||
): Fixture {
|
||||
const { window } = dom;
|
||||
const observers: FakeObserver[] = [];
|
||||
const bridgeCalls: unknown[] = [];
|
||||
installFakePerformance(window, observers);
|
||||
// tsx (esbuild keepNames) rewrites named inner functions as
|
||||
// `__name(fn, 'name')` when it transpiles the probe for this test runner.
|
||||
// Playwright's Babel transform, which serializes the probe for the real
|
||||
// browser, does not, so the shim is a test-runner concern only.
|
||||
Object.defineProperty(window, '__name', {
|
||||
configurable: true,
|
||||
value: (target: unknown) => target,
|
||||
});
|
||||
if (bridge) {
|
||||
Object.defineProperty(window, 'electron', {
|
||||
configurable: true,
|
||||
value: Object.freeze({
|
||||
[JOURNEY_IPC_SENTINEL_METHOD]: (id: unknown) => {
|
||||
bridgeCalls.push(id);
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
onSomething: () => undefined,
|
||||
}),
|
||||
});
|
||||
}
|
||||
window.eval(
|
||||
`(${journeyRendererProbeScript.toString()})(${JSON.stringify(options)})`
|
||||
);
|
||||
const rawState = (): JourneyRendererProbeState =>
|
||||
(window as unknown as Record<string, JourneyRendererProbeState>)[
|
||||
options.stateKey
|
||||
] as JourneyRendererProbeState;
|
||||
liveStates.push(rawState);
|
||||
return {
|
||||
bridgeCalls,
|
||||
observers,
|
||||
rawState,
|
||||
state: () =>
|
||||
JSON.parse(JSON.stringify(rawState())) as JourneyRendererProbeState,
|
||||
window,
|
||||
};
|
||||
}
|
||||
|
||||
/** Probes created by this file, so `settle` can wait for their cutoff. */
|
||||
const liveStates: (() => JourneyRendererProbeState)[] = [];
|
||||
|
||||
/**
|
||||
* Waits `ms`, then until every probe that reached its terminal batch has also
|
||||
* passed the post-paint cutoff (a rAF plus a timer) and closed its settle
|
||||
* window. A fixed delay alone flakes when the harness runs all spec files in
|
||||
* parallel.
|
||||
*/
|
||||
export async function settle(ms = 40): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const deadline = Date.now() + 3_000;
|
||||
while (
|
||||
liveStates.some((read) => {
|
||||
const state = read();
|
||||
return (
|
||||
state.terminal !== null &&
|
||||
(!state.final || state.settle.status === 'pending')
|
||||
);
|
||||
}) &&
|
||||
Date.now() < deadline
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ import type { Page } from '@playwright/test';
|
||||
* then emits one JSON blob under `options.stateKey`. With `options.settle`
|
||||
* (J1) it keeps summing layout shifts after the first card until the page
|
||||
* has settled, for shifts such as collapsing skeletons that land later.
|
||||
* With `options.media` (J3 "Playback") the terminal condition is a media
|
||||
* event (`playing`) on an element matching `cardSelector` instead of that
|
||||
* element becoming visible.
|
||||
*
|
||||
* IPC invocations are not counted here: the bridge object exposed by
|
||||
* `contextBridge` is frozen, so the probe cannot wrap it. Instead the probe
|
||||
@@ -33,6 +36,17 @@ export const JOURNEY_OPEN_SOURCE_START_SENTINEL_ID =
|
||||
'__iptvnator-journey-open-source-start__';
|
||||
export const JOURNEY_OPEN_SOURCE_END_SENTINEL_ID =
|
||||
'__iptvnator-journey-open-source-end__';
|
||||
export const JOURNEY_PLAYBACK_PROBE_STATE_KEY =
|
||||
'__iptvnatorJourneyPlaybackProbe';
|
||||
export const JOURNEY_PLAYBACK_START_SENTINEL_ID =
|
||||
'__iptvnator-journey-playback-start__';
|
||||
export const JOURNEY_PLAYBACK_END_SENTINEL_ID =
|
||||
'__iptvnator-journey-playback-end__';
|
||||
/** A live channel row in the Xtream live layout. */
|
||||
export const JOURNEY_PLAYBACK_START_SELECTOR =
|
||||
'app-live-stream-layout [data-test-id="channel-item"]';
|
||||
/** The HTML5 player's video element inside the web player view. */
|
||||
export const JOURNEY_PLAYBACK_VIDEO_SELECTOR = 'app-web-player-view video';
|
||||
/** The Xtream portal card on the dashboard or its row on /workspace/sources. */
|
||||
export const JOURNEY_OPEN_SOURCE_START_SELECTOR =
|
||||
'[data-test-id="dashboard-recent-sources-rail-card"], app-playlist-item';
|
||||
@@ -64,6 +78,17 @@ export interface JourneyRendererProbeSettleOptions {
|
||||
readonly rootSelector: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A journey that ends on a media event rather than on visibility (J3). The
|
||||
* first `endEvent` after the start on an element matching `cardSelector`
|
||||
* is the terminal moment; the first of each `phaseEvents` after the start
|
||||
* is recorded under `media.phases`.
|
||||
*/
|
||||
export interface JourneyRendererProbeMediaOptions {
|
||||
readonly endEvent: string;
|
||||
readonly phaseEvents: readonly string[];
|
||||
}
|
||||
|
||||
export const JOURNEY_SETTLE_QUIET_MS = 500;
|
||||
export const JOURNEY_SETTLE_CAP_MS = 3_000;
|
||||
/** The workspace shell's content pane; the rail and header stay outside. */
|
||||
@@ -75,6 +100,8 @@ export interface JourneyRendererProbeOptions {
|
||||
/** Further selectors that must each match a visible element as well. */
|
||||
readonly companionSelectors?: readonly string[];
|
||||
readonly journey: string;
|
||||
/** Absent: the journey ends when `cardSelector` becomes visible. */
|
||||
readonly media?: JourneyRendererProbeMediaOptions;
|
||||
/** Pathname fragment the terminal route must contain. */
|
||||
readonly routeFragment: string;
|
||||
readonly sentinelId: string;
|
||||
@@ -137,6 +164,20 @@ export interface JourneyRendererProbeState {
|
||||
readonly invalidReasons: string[];
|
||||
readonly journey: string;
|
||||
readonly longTaskDurationsMs: number[];
|
||||
/** Media-terminated journeys only; null otherwise. */
|
||||
readonly media: {
|
||||
/** At the terminal event, the element it fired on. */
|
||||
element: {
|
||||
readonly currentSrcScheme: string;
|
||||
readonly currentTime: number;
|
||||
readonly paused: boolean;
|
||||
readonly readyState: number;
|
||||
readonly videoHeight: number;
|
||||
readonly videoWidth: number;
|
||||
} | null;
|
||||
/** Event type → epoch of its first occurrence after the start. */
|
||||
readonly phases: Record<string, number>;
|
||||
} | null;
|
||||
navigation: {
|
||||
readonly domContentLoadedEpochMs: number;
|
||||
readonly loadEventEndEpochMs: number;
|
||||
@@ -197,6 +238,7 @@ export function journeyRendererProbeScript(
|
||||
}
|
||||
const epoch = (): number => performance.timeOrigin + performance.now();
|
||||
const startClick = options.startClick ?? null;
|
||||
const mediaOptions = options.media ?? null;
|
||||
const settleOptions = options.settle ?? null;
|
||||
const companionSelectors = options.companionSelectors ?? [];
|
||||
const bridge = target['electron'] as Record<string, unknown> | undefined;
|
||||
@@ -228,6 +270,7 @@ export function journeyRendererProbeScript(
|
||||
invalidReasons: [],
|
||||
journey: options.journey,
|
||||
longTaskDurationsMs: [],
|
||||
media: mediaOptions === null ? null : { element: null, phases: {} },
|
||||
navigation: null,
|
||||
preStart: { domMutations: 0, lastMutationEpochMs: null },
|
||||
schemaVersion: 1,
|
||||
@@ -251,6 +294,9 @@ export function journeyRendererProbeScript(
|
||||
) {
|
||||
state.invalidReasons.push('probe-installed-after-document-start');
|
||||
}
|
||||
if (mediaOptions !== null && startClick === null) {
|
||||
state.invalidReasons.push('media-terminal-needs-start-click');
|
||||
}
|
||||
// Performance entries before the journey's start belong to an earlier
|
||||
// journey (buffered entries included) and are dropped.
|
||||
let fromEpochMs =
|
||||
@@ -463,20 +509,27 @@ export function journeyRendererProbeScript(
|
||||
armQuiet();
|
||||
};
|
||||
|
||||
// A media journey's counters stop at the terminal event itself (the
|
||||
// task that dispatched it still overlaps and counts); the others count
|
||||
// until the post-paint cutoff.
|
||||
const finalize = (untilEpochMs: number): void => {
|
||||
const countUntilEpochMs =
|
||||
mediaOptions !== null && state.terminal !== null
|
||||
? state.terminal.epochMs
|
||||
: untilEpochMs;
|
||||
if (layoutShiftObserver) {
|
||||
const records = layoutShiftObserver.takeRecords();
|
||||
collectSettleShifts(records);
|
||||
acceptLayoutShift(
|
||||
[...pendingLayoutShifts, ...records],
|
||||
untilEpochMs
|
||||
countUntilEpochMs
|
||||
);
|
||||
if (settleOptions === null) layoutShiftObserver.disconnect();
|
||||
}
|
||||
if (longTaskObserver) {
|
||||
acceptLongTasks(
|
||||
[...pendingLongTasks, ...longTaskObserver.takeRecords()],
|
||||
untilEpochMs
|
||||
countUntilEpochMs
|
||||
);
|
||||
longTaskObserver.disconnect();
|
||||
}
|
||||
@@ -535,6 +588,7 @@ export function journeyRendererProbeScript(
|
||||
}
|
||||
state.counters.domMutations += records.length;
|
||||
if (
|
||||
mediaOptions !== null ||
|
||||
!location.pathname.includes(options.routeFragment) ||
|
||||
document.getElementById(options.splashId) !== null
|
||||
) {
|
||||
@@ -547,12 +601,19 @@ export function journeyRendererProbeScript(
|
||||
if (!isVisible(document.querySelector(selector))) return;
|
||||
companionCounts.push(document.querySelectorAll(selector).length);
|
||||
}
|
||||
end(card, companionCounts, epoch());
|
||||
});
|
||||
const end = (
|
||||
card: Element,
|
||||
companionCounts: number[],
|
||||
epochMs: number
|
||||
): void => {
|
||||
state.terminal = {
|
||||
cardCount: document.querySelectorAll(options.cardSelector).length,
|
||||
cardTag: card.tagName.toLowerCase(),
|
||||
cardTestId: card.getAttribute('data-test-id'),
|
||||
companionCounts,
|
||||
epochMs: epoch(),
|
||||
epochMs,
|
||||
pathname: location.pathname,
|
||||
};
|
||||
mutationObserver.disconnect();
|
||||
@@ -577,7 +638,7 @@ export function journeyRendererProbeScript(
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(() => finalize(epoch()), 0);
|
||||
});
|
||||
});
|
||||
};
|
||||
mutationObserver.observe(document.documentElement ?? document, {
|
||||
attributes: true,
|
||||
characterData: true,
|
||||
@@ -615,6 +676,43 @@ export function journeyRendererProbeScript(
|
||||
window.removeEventListener('click', onClick, true);
|
||||
};
|
||||
window.addEventListener('click', onClick, true);
|
||||
if (mediaOptions === null) return;
|
||||
|
||||
// Media events do not bubble, but a capture listener on window still
|
||||
// sees them before any listener of the app. Mutations up to the event
|
||||
// are taken synchronously, so the count ends exactly at the event.
|
||||
const onMediaEvent = (event: Event): void => {
|
||||
const element = event.target;
|
||||
if (
|
||||
state.start === null ||
|
||||
state.terminal !== null ||
|
||||
state.media === null ||
|
||||
!(element instanceof HTMLMediaElement) ||
|
||||
!element.matches(options.cardSelector)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const at = epoch();
|
||||
state.media.phases[event.type] ??= at;
|
||||
if (event.type !== mediaOptions.endEvent) return;
|
||||
state.counters.domMutations += mutationObserver.takeRecords().length;
|
||||
const video = element as HTMLMediaElement & {
|
||||
videoHeight?: number;
|
||||
videoWidth?: number;
|
||||
};
|
||||
state.media.element = {
|
||||
currentSrcScheme: element.currentSrc.split(':')[0] ?? '',
|
||||
currentTime: element.currentTime,
|
||||
paused: element.paused,
|
||||
readyState: element.readyState,
|
||||
videoHeight: video.videoHeight ?? 0,
|
||||
videoWidth: video.videoWidth ?? 0,
|
||||
};
|
||||
end(element, [], at);
|
||||
};
|
||||
for (const type of [mediaOptions.endEvent, ...mediaOptions.phaseEvents]) {
|
||||
window.addEventListener(type, onMediaEvent, true);
|
||||
}
|
||||
}
|
||||
|
||||
export function createLaunchJourneyProbeOptions(): JourneyRendererProbeOptions {
|
||||
@@ -660,6 +758,28 @@ export function createOpenSourceJourneyProbeOptions(): JourneyRendererProbeOptio
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for J3 "Playback": the click on a live channel row starts the
|
||||
* journey; it ends at the first `playing` event of the HTML5 player's video
|
||||
* element, with `loadedmetadata` recorded as an intermediate phase.
|
||||
*/
|
||||
export function createPlaybackJourneyProbeOptions(): JourneyRendererProbeOptions {
|
||||
return {
|
||||
cardSelector: JOURNEY_PLAYBACK_VIDEO_SELECTOR,
|
||||
journey: 'playback',
|
||||
media: { endEvent: 'playing', phaseEvents: ['loadedmetadata'] },
|
||||
routeFragment: '/workspace/xtreams/',
|
||||
sentinelId: JOURNEY_PLAYBACK_END_SENTINEL_ID,
|
||||
sentinelMethod: JOURNEY_IPC_SENTINEL_METHOD,
|
||||
splashId: 'initial-splash',
|
||||
startClick: {
|
||||
selector: JOURNEY_PLAYBACK_START_SELECTOR,
|
||||
sentinelId: JOURNEY_PLAYBACK_START_SENTINEL_ID,
|
||||
},
|
||||
stateKey: JOURNEY_PLAYBACK_PROBE_STATE_KEY,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the probe on a page that is still parked on `about:blank` by the
|
||||
* journey gate, so it is guaranteed to run at the start of the next document.
|
||||
|
||||
@@ -38,6 +38,7 @@ function measurement(
|
||||
invalidReasons: [],
|
||||
journey: 'launch',
|
||||
longTaskDurationsMs: [71.26, 120.04],
|
||||
media: null,
|
||||
navigation: {
|
||||
domContentLoadedEpochMs: 1_300,
|
||||
loadEventEndEpochMs: 1_400.26,
|
||||
|
||||
@@ -46,6 +46,7 @@ function measurement(
|
||||
invalidReasons: [],
|
||||
journey: 'open-source',
|
||||
longTaskDurationsMs: [61.26],
|
||||
media: null,
|
||||
navigation: null,
|
||||
preStart: { domMutations: 4, lastMutationEpochMs: 9_100 },
|
||||
schemaVersion: 1,
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
journeyActivityBeforeClick,
|
||||
type JourneyClickSettle,
|
||||
} from './journey-click-settle';
|
||||
import type { JourneyMainIpcCaptureState } from './journey-main-ipc-capture';
|
||||
import {
|
||||
countJourneyMockRoutes,
|
||||
@@ -38,13 +42,7 @@ export const OPEN_SOURCE_JOURNEY_UNAVAILABLE_COUNTERS: Readonly<
|
||||
});
|
||||
|
||||
/** How long the app was left alone before the click, and what it did. */
|
||||
export interface OpenSourceJourneySettle {
|
||||
readonly preStartDomMutations: number;
|
||||
readonly preStartHttpRequests: number;
|
||||
readonly preStartIpcCalls: number;
|
||||
readonly quietMs: number;
|
||||
readonly waitedMs: number;
|
||||
}
|
||||
export type OpenSourceJourneySettle = JourneyClickSettle;
|
||||
|
||||
export interface OpenSourceJourneyMeasurement {
|
||||
readonly http: {
|
||||
@@ -109,13 +107,11 @@ export function toOpenSourceIterationRecord(
|
||||
// the click and be counted as J2. The probe and the capture keep
|
||||
// counting until the click itself, so they must still match the
|
||||
// snapshot; otherwise the iteration is rejected.
|
||||
const lateActivity = [
|
||||
renderer.preStart.domMutations !== settle.preStartDomMutations
|
||||
? 'dom'
|
||||
: null,
|
||||
ipc.callsBeforeStart !== settle.preStartIpcCalls ? 'ipc' : null,
|
||||
http.afterSettleBeforeClick > 0 ? 'http' : null,
|
||||
].filter((kind) => kind !== null);
|
||||
const lateActivity = journeyActivityBeforeClick(settle, {
|
||||
httpAfterSettleBeforeClick: http.afterSettleBeforeClick,
|
||||
ipcCallsBeforeStart: ipc.callsBeforeStart,
|
||||
preStartDomMutations: renderer.preStart.domMutations,
|
||||
});
|
||||
if (lateActivity.length > 0) {
|
||||
throw new Error(
|
||||
`open-source-journey-record-activity-before-click-${lateActivity.join('-')}`
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { JourneyMainIpcCaptureState } from './journey-main-ipc-capture';
|
||||
import type { JourneyMockRequest } from './journey-mock-request-ledger';
|
||||
import type { JourneyRendererProbeState } from './journey-renderer-probe';
|
||||
import { summarizeJourneyIterations } from './journey-summary';
|
||||
import {
|
||||
PLAYBACK_JOURNEY_UNAVAILABLE_COUNTERS,
|
||||
toPlaybackIterationRecord,
|
||||
type PlaybackJourneyMeasurement,
|
||||
} from './playback-journey-record';
|
||||
|
||||
const LIVE_PATH = '/dist/apps/web/workspace/xtreams/playlist-1/live';
|
||||
const STREAM_ROUTE = '/live/:username/:password/10000.ts';
|
||||
|
||||
function request(sequence: number, route: string, epochMs: number) {
|
||||
return { epochMs, method: 'GET', route, sequence } as JourneyMockRequest;
|
||||
}
|
||||
|
||||
type Start = NonNullable<JourneyRendererProbeState['start']>;
|
||||
type Terminal = NonNullable<JourneyRendererProbeState['terminal']>;
|
||||
type Media = NonNullable<JourneyRendererProbeState['media']>;
|
||||
|
||||
function measurement(
|
||||
overrides: Partial<PlaybackJourneyMeasurement> = {}
|
||||
): PlaybackJourneyMeasurement {
|
||||
const renderer: JourneyRendererProbeState = {
|
||||
capabilities: {
|
||||
changeDetectionTicks: 'unavailable-ng-global-not-published',
|
||||
layoutShift: true,
|
||||
longTask: true,
|
||||
observedTarget: 'documentElement',
|
||||
},
|
||||
counters: {
|
||||
domMutations: 6_188,
|
||||
layoutShiftScore: 0,
|
||||
layoutShiftScoreSettled: 0,
|
||||
longTasks: 0,
|
||||
recentInputLayoutShiftScore: 0.00061,
|
||||
},
|
||||
final: true,
|
||||
firstCardPaintEpochMs: 10_360,
|
||||
installed: {
|
||||
bridgePresent: true,
|
||||
documentElementPresent: true,
|
||||
epochMs: 9_000,
|
||||
readyState: 'complete',
|
||||
scriptCount: 12,
|
||||
},
|
||||
invalidReasons: [],
|
||||
journey: 'playback',
|
||||
longTaskDurationsMs: [],
|
||||
media: {
|
||||
element: {
|
||||
currentSrcScheme: 'blob',
|
||||
currentTime: 0.02133,
|
||||
paused: false,
|
||||
readyState: 4,
|
||||
videoHeight: 90,
|
||||
videoWidth: 160,
|
||||
},
|
||||
phases: { loadedmetadata: 10_095.04, playing: 10_349.84 },
|
||||
},
|
||||
navigation: null,
|
||||
preStart: { domMutations: 53, lastMutationEpochMs: 8_900 },
|
||||
schemaVersion: 1,
|
||||
sentinel: { epochMs: 10_350, status: 'sent' },
|
||||
settle: {
|
||||
domMutations: 0,
|
||||
epochMs: null,
|
||||
lastMutationEpochMs: null,
|
||||
lateShifts: [],
|
||||
observedTarget: null,
|
||||
status: 'disabled',
|
||||
},
|
||||
start: {
|
||||
epochMs: 10_000,
|
||||
listenerEpochMs: 10_000.5,
|
||||
pathname: LIVE_PATH,
|
||||
sentinelStatus: 'sent',
|
||||
targetTag: 'div',
|
||||
targetTestId: 'channel-item',
|
||||
},
|
||||
terminal: {
|
||||
cardCount: 1,
|
||||
cardTag: 'video',
|
||||
cardTestId: null,
|
||||
companionCounts: [],
|
||||
epochMs: 10_349.84,
|
||||
pathname: LIVE_PATH,
|
||||
},
|
||||
};
|
||||
const ipc: JourneyMainIpcCaptureState = {
|
||||
callsAfterSentinel: 3,
|
||||
callsBeforeStart: 6,
|
||||
callsBeforeSentinel: 4,
|
||||
callsByMethod: {
|
||||
getEpgMapping: 1,
|
||||
setUserAgent: 1,
|
||||
updateRemoteControlStatus: 1,
|
||||
xtreamRequest: 1,
|
||||
},
|
||||
inFlightByMethod: {},
|
||||
installedEpochMs: 9_500,
|
||||
malformedEvents: 0,
|
||||
processStartEpochMs: 1_000,
|
||||
senderIds: [1],
|
||||
sentinel: { occurrences: 1, receivedEpochMs: 10_351 },
|
||||
start: { occurrences: 1, receivedEpochMs: 10_001 },
|
||||
unmatchedCompletions: 0,
|
||||
};
|
||||
return {
|
||||
externalArtworkCancelled: 8,
|
||||
http: {
|
||||
afterPlaying: [
|
||||
request(9, '/player_api.php?action=get_short_epg', 10_400),
|
||||
],
|
||||
afterSettleBeforeClick: 0,
|
||||
beforeClick: [
|
||||
request(0, '/player_api.php?action=get_live_streams', 8_000),
|
||||
],
|
||||
toPlaying: [
|
||||
request(7, STREAM_ROUTE, 10_020),
|
||||
request(
|
||||
8,
|
||||
'/player_api.php?action=get_simple_data_table',
|
||||
10_030
|
||||
),
|
||||
],
|
||||
},
|
||||
ipc,
|
||||
pid: 5151,
|
||||
renderer,
|
||||
settle: {
|
||||
preStartDomMutations: 53,
|
||||
preStartHttpRequests: 4,
|
||||
preStartIpcCalls: 6,
|
||||
quietMs: 1_000,
|
||||
waitedMs: 1_809,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function withRenderer(
|
||||
patch: Partial<JourneyRendererProbeState>
|
||||
): PlaybackJourneyMeasurement {
|
||||
const base = measurement();
|
||||
return { ...base, renderer: { ...base.renderer, ...patch } };
|
||||
}
|
||||
|
||||
test('maps the media-terminated probe, IPC window and mock ledger to exact counters', () => {
|
||||
const record = toPlaybackIterationRecord(2, false, measurement());
|
||||
assert.equal(record.index, 2);
|
||||
assert.equal(record.warmup, false);
|
||||
assert.equal(record.pid, 5151);
|
||||
assert.deepEqual(record.counters, {
|
||||
'renderer.domMutationsToPlaying': 6_188,
|
||||
'renderer.httpRequestsToPlaying': 2,
|
||||
'renderer.ipcCallsToPlaying': 4,
|
||||
'renderer.layoutShiftScore': 0.001,
|
||||
'renderer.longTasks': 0,
|
||||
});
|
||||
assert.deepEqual(record.wallClock, {
|
||||
clickToLoadedMetadataMs: 95,
|
||||
clickToPlayingMs: 349.8,
|
||||
});
|
||||
assert.deepEqual(record.evidence['httpRequestsByRoute'], {
|
||||
'/live/:username/:password/10000.ts': 1,
|
||||
'/player_api.php?action=get_simple_data_table': 1,
|
||||
});
|
||||
assert.deepEqual(record.evidence['httpRequestsAfterPlayingByRoute'], {
|
||||
'/player_api.php?action=get_short_epg': 1,
|
||||
});
|
||||
assert.deepEqual(record.evidence['httpRequestsBeforeClickByRoute'], {
|
||||
'/player_api.php?action=get_live_streams': 1,
|
||||
});
|
||||
assert.deepEqual(record.evidence['media'], {
|
||||
currentSrcScheme: 'blob',
|
||||
currentTime: 0.021,
|
||||
paused: false,
|
||||
readyState: 4,
|
||||
videoElements: 1,
|
||||
videoHeight: 90,
|
||||
videoWidth: 160,
|
||||
});
|
||||
assert.deepEqual(record.evidence['epochs'], {
|
||||
click: 10_000,
|
||||
clickListener: 10_000.5,
|
||||
loadedMetadata: 10_095.04,
|
||||
mainIpcSentinel: 10_351,
|
||||
mainIpcStart: 10_001,
|
||||
playing: 10_349.84,
|
||||
});
|
||||
assert.equal(record.evidence['externalArtworkCancelled'], 8);
|
||||
assert.equal(record.evidence['ipcCallsAfterPlaying'], 3);
|
||||
});
|
||||
|
||||
test('rejects measurements that did not start at a live channel or did not play a video', () => {
|
||||
const base = measurement();
|
||||
const start = base.renderer.start as Start;
|
||||
const terminal = base.renderer.terminal as Terminal;
|
||||
const media = base.renderer.media as Media;
|
||||
const cases: [PlaybackJourneyMeasurement, RegExp][] = [
|
||||
[withRenderer({ start: null }), /incomplete-probe/],
|
||||
[withRenderer({ terminal: null }), /incomplete-probe/],
|
||||
[withRenderer({ media: null }), /incomplete-probe/],
|
||||
[{ ...base, ipc: { ...base.ipc, start: null } }, /ipc-without-start/],
|
||||
[
|
||||
withRenderer({
|
||||
start: { ...start, pathname: '/workspace/dashboard' },
|
||||
}),
|
||||
/start-route/,
|
||||
],
|
||||
[
|
||||
withRenderer({
|
||||
start: {
|
||||
...start,
|
||||
pathname: '/workspace/xtreams/playlist-1/vod',
|
||||
},
|
||||
}),
|
||||
/start-route/,
|
||||
],
|
||||
[
|
||||
withRenderer({ terminal: { ...terminal, cardTag: 'div' } }),
|
||||
/not-a-video/,
|
||||
],
|
||||
[withRenderer({ media: { ...media, element: null } }), /not-a-video/],
|
||||
[withRenderer({ media: { ...media, phases: {} } }), /clock-order/],
|
||||
[
|
||||
withRenderer({
|
||||
media: {
|
||||
...media,
|
||||
phases: { loadedmetadata: terminal.epochMs + 1 },
|
||||
},
|
||||
}),
|
||||
/clock-order/,
|
||||
],
|
||||
[
|
||||
withRenderer({
|
||||
media: {
|
||||
...media,
|
||||
phases: { loadedmetadata: start.epochMs - 1 },
|
||||
},
|
||||
}),
|
||||
/clock-order/,
|
||||
],
|
||||
[
|
||||
withRenderer({
|
||||
terminal: { ...terminal, epochMs: start.epochMs },
|
||||
media: { ...media, phases: { loadedmetadata: start.epochMs } },
|
||||
}),
|
||||
/clock-order/,
|
||||
],
|
||||
[
|
||||
withRenderer({
|
||||
capabilities: {
|
||||
...base.renderer.capabilities,
|
||||
changeDetectionTicks: 'hook-present-not-counted',
|
||||
},
|
||||
}),
|
||||
/cd-hook-hook-present-not-counted/,
|
||||
],
|
||||
];
|
||||
for (const [input, error] of cases) {
|
||||
assert.throws(() => toPlaybackIterationRecord(0, false, input), error);
|
||||
}
|
||||
});
|
||||
|
||||
test('requires the stream from the local fixture inside the window', () => {
|
||||
const base = measurement();
|
||||
const withoutStream = {
|
||||
...base,
|
||||
http: {
|
||||
...base.http,
|
||||
toPlaying: base.http.toPlaying.filter(
|
||||
(entry) => entry.route !== STREAM_ROUTE
|
||||
),
|
||||
},
|
||||
};
|
||||
assert.throws(
|
||||
() => toPlaybackIterationRecord(0, false, withoutStream),
|
||||
/no-local-stream/
|
||||
);
|
||||
const hls = {
|
||||
...base,
|
||||
http: {
|
||||
...base.http,
|
||||
toPlaying: [request(7, '/live/:username/:password/10000.m3u8', 1)],
|
||||
},
|
||||
};
|
||||
assert.throws(
|
||||
() => toPlaybackIterationRecord(0, false, hls),
|
||||
/no-local-stream/
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects activity that moved between the settle snapshot and the click', () => {
|
||||
const base = measurement();
|
||||
assert.throws(
|
||||
() =>
|
||||
toPlaybackIterationRecord(0, false, {
|
||||
...base,
|
||||
http: { ...base.http, afterSettleBeforeClick: 1 },
|
||||
ipc: { ...base.ipc, callsBeforeStart: 7 },
|
||||
renderer: {
|
||||
...base.renderer,
|
||||
preStart: { domMutations: 54, lastMutationEpochMs: 1 },
|
||||
},
|
||||
}),
|
||||
/activity-before-click-dom-ipc-http/
|
||||
);
|
||||
});
|
||||
|
||||
test('summarizes playback iterations with J3 counters and unavailable reasons', () => {
|
||||
const iterations = [0, 1, 2].map((index) =>
|
||||
toPlaybackIterationRecord(index, index === 0, {
|
||||
...measurement(),
|
||||
pid: 100 + index,
|
||||
})
|
||||
);
|
||||
const entry = summarizeJourneyIterations(
|
||||
iterations,
|
||||
PLAYBACK_JOURNEY_UNAVAILABLE_COUNTERS
|
||||
);
|
||||
assert.equal(entry.counters['renderer.ipcCallsToPlaying'], 4);
|
||||
assert.deepEqual(entry.counterStability['renderer.ipcCallsToPlaying'], {
|
||||
stable: true,
|
||||
values: [4, 4],
|
||||
});
|
||||
assert.equal(entry.wallClock['clickToPlayingMs.p50'], 349.8);
|
||||
assert.equal(entry.wallClock['clickToLoadedMetadataMs.p90'], 95);
|
||||
assert.deepEqual(Object.keys(entry.unavailable).sort(), [
|
||||
'renderer.cdTicksToPlaying',
|
||||
'renderer.ipcSerialDepthToPlaying',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
journeyActivityBeforeClick,
|
||||
type JourneyClickSettle,
|
||||
} from './journey-click-settle';
|
||||
import type { JourneyMainIpcCaptureState } from './journey-main-ipc-capture';
|
||||
import {
|
||||
countJourneyMockRoutes,
|
||||
type JourneyMockRequest,
|
||||
} from './journey-mock-request-ledger';
|
||||
import type { JourneyRendererProbeState } from './journey-renderer-probe';
|
||||
import type { JourneyIterationRecord } from './journey-summary';
|
||||
|
||||
/**
|
||||
* Maps one measured "start playback" (renderer probe armed at the click and
|
||||
* ended by the video element's `playing` event, main IPC capture between the
|
||||
* start and end sentinels, mock request ledger) to the journey summary's
|
||||
* iteration record for J3.
|
||||
*/
|
||||
export const PLAYBACK_JOURNEY_ID = 'playback';
|
||||
|
||||
export const PLAYBACK_JOURNEY_COUNTER = {
|
||||
DOM_MUTATIONS: 'renderer.domMutationsToPlaying',
|
||||
HTTP_REQUESTS: 'renderer.httpRequestsToPlaying',
|
||||
IPC_CALLS: 'renderer.ipcCallsToPlaying',
|
||||
LAYOUT_SHIFT_SCORE: 'renderer.layoutShiftScore',
|
||||
LONG_TASKS: 'renderer.longTasks',
|
||||
} as const;
|
||||
|
||||
export const PLAYBACK_JOURNEY_WALL_CLOCK = {
|
||||
/** Click until the video element's first `loadedmetadata`. */
|
||||
CLICK_TO_LOADED_METADATA: 'clickToLoadedMetadataMs',
|
||||
/** Click until its first `playing`. */
|
||||
CLICK_TO_PLAYING: 'clickToPlayingMs',
|
||||
} as const;
|
||||
|
||||
export const PLAYBACK_JOURNEY_UNAVAILABLE_COUNTERS: Readonly<
|
||||
Record<string, string>
|
||||
> = Object.freeze({
|
||||
'renderer.cdTicksToPlaying':
|
||||
'The electron-performance build optimizes scripts (ngDevMode=false), so Angular does not publish window.ng and ɵsetProfiler is unavailable.',
|
||||
'renderer.ipcSerialDepthToPlaying':
|
||||
'The serial-depth helper is being added for J1 in a separate thread and is not on master yet; J3 adopts it once it lands.',
|
||||
});
|
||||
|
||||
export interface PlaybackJourneyMeasurement {
|
||||
/** Logo requests to picsum.photos cancelled in the main process. */
|
||||
readonly externalArtworkCancelled: number;
|
||||
readonly http: {
|
||||
/** Requests at or after `playing` seen when the ledger was read. */
|
||||
readonly afterPlaying: readonly JourneyMockRequest[];
|
||||
/** Mock requests after the app settled but before the click stamp. */
|
||||
readonly afterSettleBeforeClick: number;
|
||||
/** Mock requests from the spawn (J1, navigation, settling). */
|
||||
readonly beforeClick: readonly JourneyMockRequest[];
|
||||
/** Mock requests from the click stamp until `playing`. */
|
||||
readonly toPlaying: readonly JourneyMockRequest[];
|
||||
};
|
||||
readonly ipc: JourneyMainIpcCaptureState;
|
||||
readonly pid: number;
|
||||
readonly renderer: JourneyRendererProbeState;
|
||||
readonly settle: JourneyClickSettle;
|
||||
}
|
||||
|
||||
const ROUTE_FRAGMENT = '/workspace/xtreams/';
|
||||
const LIVE_STREAM_ROUTE = /^\/live\/:username\/:password\/[^/]+\.ts$/;
|
||||
|
||||
function roundTenth(value: number): number {
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
function roundThousandth(value: number): number {
|
||||
return Math.round(value * 1_000) / 1_000;
|
||||
}
|
||||
|
||||
export function toPlaybackIterationRecord(
|
||||
index: number,
|
||||
warmup: boolean,
|
||||
measurement: PlaybackJourneyMeasurement
|
||||
): JourneyIterationRecord {
|
||||
const { http, ipc, renderer, settle } = measurement;
|
||||
const { media, start, terminal } = renderer;
|
||||
if (start === null || terminal === null || media === null) {
|
||||
throw new Error('playback-journey-record-incomplete-probe');
|
||||
}
|
||||
if (ipc.start === null) {
|
||||
throw new Error('playback-journey-record-ipc-without-start');
|
||||
}
|
||||
if (
|
||||
!start.pathname.includes(ROUTE_FRAGMENT) ||
|
||||
!start.pathname.includes('/live')
|
||||
) {
|
||||
throw new Error('playback-journey-record-start-route');
|
||||
}
|
||||
if (terminal.cardTag !== 'video' || media.element === null) {
|
||||
throw new Error('playback-journey-record-not-a-video');
|
||||
}
|
||||
const loadedMetadataEpochMs = media.phases['loadedmetadata'];
|
||||
const clickToPlayingMs = terminal.epochMs - start.epochMs;
|
||||
if (
|
||||
loadedMetadataEpochMs === undefined ||
|
||||
loadedMetadataEpochMs < start.epochMs ||
|
||||
loadedMetadataEpochMs > terminal.epochMs ||
|
||||
clickToPlayingMs <= 0
|
||||
) {
|
||||
throw new Error('playback-journey-record-clock-order');
|
||||
}
|
||||
// The stream itself must have come from the mock's local fixture; a
|
||||
// player that played something else did not measure this journey.
|
||||
if (!http.toPlaying.some((entry) => LIVE_STREAM_ROUTE.test(entry.route))) {
|
||||
throw new Error('playback-journey-record-no-local-stream');
|
||||
}
|
||||
const lateActivity = journeyActivityBeforeClick(settle, {
|
||||
httpAfterSettleBeforeClick: http.afterSettleBeforeClick,
|
||||
ipcCallsBeforeStart: ipc.callsBeforeStart,
|
||||
preStartDomMutations: renderer.preStart.domMutations,
|
||||
});
|
||||
if (lateActivity.length > 0) {
|
||||
throw new Error(
|
||||
`playback-journey-record-activity-before-click-${lateActivity.join('-')}`
|
||||
);
|
||||
}
|
||||
if (
|
||||
renderer.capabilities.changeDetectionTicks !==
|
||||
'unavailable-ng-global-not-published'
|
||||
) {
|
||||
throw new Error(
|
||||
`playback-journey-record-cd-hook-${renderer.capabilities.changeDetectionTicks}`
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
counters: Object.freeze({
|
||||
[PLAYBACK_JOURNEY_COUNTER.DOM_MUTATIONS]:
|
||||
renderer.counters.domMutations,
|
||||
[PLAYBACK_JOURNEY_COUNTER.HTTP_REQUESTS]: http.toPlaying.length,
|
||||
[PLAYBACK_JOURNEY_COUNTER.IPC_CALLS]: ipc.callsBeforeSentinel,
|
||||
// The click's 500 ms input window covers the start of the
|
||||
// journey, so shifts flagged hadRecentInput are included, as
|
||||
// in J2.
|
||||
[PLAYBACK_JOURNEY_COUNTER.LAYOUT_SHIFT_SCORE]: roundThousandth(
|
||||
renderer.counters.layoutShiftScore +
|
||||
renderer.counters.recentInputLayoutShiftScore
|
||||
),
|
||||
[PLAYBACK_JOURNEY_COUNTER.LONG_TASKS]: renderer.counters.longTasks,
|
||||
}),
|
||||
evidence: Object.freeze({
|
||||
capabilities: renderer.capabilities,
|
||||
epochs: Object.freeze({
|
||||
click: start.epochMs,
|
||||
clickListener: start.listenerEpochMs,
|
||||
loadedMetadata: loadedMetadataEpochMs,
|
||||
mainIpcSentinel: ipc.sentinel.receivedEpochMs,
|
||||
mainIpcStart: ipc.start.receivedEpochMs,
|
||||
playing: terminal.epochMs,
|
||||
}),
|
||||
externalArtworkCancelled: measurement.externalArtworkCancelled,
|
||||
httpRequestsAfterPlayingByRoute: countJourneyMockRoutes(
|
||||
http.afterPlaying
|
||||
),
|
||||
httpRequestsBeforeClickByRoute: countJourneyMockRoutes(
|
||||
http.beforeClick
|
||||
),
|
||||
httpRequestsByRoute: countJourneyMockRoutes(http.toPlaying),
|
||||
ipcCallsAfterPlaying: ipc.callsAfterSentinel,
|
||||
ipcCallsByMethod: ipc.callsByMethod,
|
||||
layoutShift: Object.freeze({
|
||||
recentInput: roundThousandth(
|
||||
renderer.counters.recentInputLayoutShiftScore
|
||||
),
|
||||
withoutRecentInput: roundThousandth(
|
||||
renderer.counters.layoutShiftScore
|
||||
),
|
||||
}),
|
||||
longTaskDurationsMs: renderer.longTaskDurationsMs.map(roundTenth),
|
||||
media: Object.freeze({
|
||||
...media.element,
|
||||
currentTime: roundThousandth(media.element.currentTime),
|
||||
videoElements: terminal.cardCount,
|
||||
}),
|
||||
settle,
|
||||
start: Object.freeze({
|
||||
pathname: start.pathname,
|
||||
targetTag: start.targetTag,
|
||||
targetTestId: start.targetTestId,
|
||||
}),
|
||||
terminalPathname: terminal.pathname,
|
||||
}),
|
||||
index,
|
||||
pid: measurement.pid,
|
||||
wallClock: Object.freeze({
|
||||
[PLAYBACK_JOURNEY_WALL_CLOCK.CLICK_TO_LOADED_METADATA]: roundTenth(
|
||||
loadedMetadataEpochMs - start.epochMs
|
||||
),
|
||||
[PLAYBACK_JOURNEY_WALL_CLOCK.CLICK_TO_PLAYING]:
|
||||
roundTenth(clickToPlayingMs),
|
||||
}),
|
||||
warmup,
|
||||
});
|
||||
}
|
||||
@@ -19,9 +19,9 @@ live in `tools/performance/`.
|
||||
| J4 `search` | six-character query typed into global search | results list settled |
|
||||
|
||||
J1 is instrumented: `renderer.initialBytes` from the built output, and the
|
||||
runtime counters of the launch benchmark below. J2 is instrumented by its own
|
||||
spec (below). J3 and J4 follow the plan in `.plans/` and are added one thread
|
||||
at a time; each thread names its journey and counter in the PR description.
|
||||
runtime counters of the launch benchmark below. J2 and J3 are instrumented by
|
||||
their own specs (below). J4 follows the plan in `.plans/` and is added in its
|
||||
own thread; each thread names its journey and counter in the PR description.
|
||||
|
||||
## Running the journeys
|
||||
|
||||
@@ -367,6 +367,13 @@ values, so a new counter needs no schema change. A J1 runtime baseline is added
|
||||
once its counter is deterministic on the CI runner; the launch counters are
|
||||
not yet (see [Ratchet](#ratchet)), so the summary is evidence only.
|
||||
|
||||
J3 adds the `journeys.playback` entry with the same shape and no schema
|
||||
version change: `counters` and `wallClock` hold only plain numbers, and its
|
||||
iterations carry `evidence.media` (the video element at `playing`) and
|
||||
`evidence.epochs.loadedMetadata` / `.playing`. The renderer probe blob gained
|
||||
a `media` field (`null` for J1 and J2), which the probe's
|
||||
`schemaVersion` 1 readers ignore.
|
||||
|
||||
## J2 `open-source`: open a source to a browsable list
|
||||
|
||||
`open-source.journey.ts` reuses the J1 profile and process pattern: the
|
||||
@@ -469,6 +476,118 @@ give a click-to-settled count; that is left to a follow-up.
|
||||
|
||||
All epochs are taken in the renderer, so neither entry crosses a process clock.
|
||||
|
||||
## J3 `playback`: start playback to the first frame
|
||||
|
||||
`playback.journey.ts` follows J2: the profile is seeded once through the
|
||||
"Add playlist" dialogs (`seedLaunchJourneyProfile` with
|
||||
`PLAYBACK_JOURNEY_SEED`), every iteration copies it, spawns a fresh process
|
||||
through `runLaunchJourney` without main-process counters, and hands the
|
||||
running app to `measurePlaybackJourney` in
|
||||
`src/journeys/playback-journey-app.ts`. One warm-up and five measured
|
||||
iterations.
|
||||
|
||||
**Profile.** J2's M3U source plus an Xtream portal ("Journey live portal") on
|
||||
the mock's `live-fallback:live-fallback` account, behind the same request
|
||||
ledger proxy. Seeding also selects **Settings > Playback > Video player >
|
||||
HTML5 video player** and **Stream format > ts** through the settings page
|
||||
(`configureLiveFormat`), so live URLs end in `.ts`. Embedded MPV and external
|
||||
players are out of scope.
|
||||
|
||||
**Stream.** `/live/live-fallback/live-fallback/10000.ts` returns
|
||||
`apps/xtream-mock-server/src/fixtures/live.mpegts` from disk: six seconds of
|
||||
160x90 H.264 baseline video and AAC audio in MPEG-TS, about 300 KB. The HTML5
|
||||
player plays `.ts` through mpegts.js, which transmuxes to fragmented MP4 for
|
||||
Media Source Extensions; H.264 and AAC are among the codecs Electron's
|
||||
Chromium decodes on every platform, including the Linux runner, and the
|
||||
Electron E2E for the live-format fallback already plays this fixture there.
|
||||
Two other choices were rejected: the `marketing` and `marketing2` accounts
|
||||
serve live URLs from local bytes, but those bytes are zero-filled (a fixture
|
||||
for download screenshots, not media), so no player ever fires `playing`; every
|
||||
other account redirects streams to a public HLS test stream. The record
|
||||
fails an iteration whose click-to-`playing` window has no `.ts` request for
|
||||
a live stream, so a player that played something else is never measured.
|
||||
|
||||
**No request leaves the machine.** The generated live catalog's channel and
|
||||
category logos point at `picsum.photos`. Before navigating, the journey
|
||||
registers `session.defaultSession.webRequest.onBeforeRequest` for
|
||||
`*://picsum.photos/*` from the test side and cancels those requests (the app
|
||||
registers no `onBeforeRequest` listener of its own, so none is replaced). A
|
||||
logo therefore never loads, or fails, at a moment that depends on the
|
||||
runner's network; the number cancelled is kept as
|
||||
`evidence.externalArtworkCancelled`. Every other request goes to the mock
|
||||
through the ledger.
|
||||
|
||||
**Start.** After J1 has ended, the test clicks the portal's dashboard card,
|
||||
the **Live TV** link and the first category (not measured), then installs the
|
||||
IPC capture with a start sentinel, arms the probe, hovers the first
|
||||
`app-live-stream-layout [data-test-id="channel-item"]` and waits for the same
|
||||
1 s quiet as J2 (`src/performance/journey-click-settle.ts`, shared with J2).
|
||||
J1's capture is detached and the channel is clicked. The probe's capture-phase
|
||||
`click` listener stamps the start and sends
|
||||
`cancelSourceProbe('__iptvnator-journey-playback-start__')`. The record
|
||||
rejects activity between the settle snapshot and the click exactly as J2
|
||||
does.
|
||||
|
||||
**End.** The probe runs with `media: { endEvent: 'playing', phaseEvents:
|
||||
['loadedmetadata'] }`. Media events do not bubble, but a capture-phase
|
||||
listener on `window` sees them before any listener of the app. The first
|
||||
`playing` event after the start on an element matching
|
||||
`app-web-player-view video` ends the journey: pending mutation records are
|
||||
taken synchronously, the end sentinel
|
||||
`cancelSourceProbe('__iptvnator-journey-playback-end__')` is sent, and the
|
||||
element's state is recorded (`evidence.media`: `readyState`, `paused`,
|
||||
`currentTime`, intrinsic size, and `currentSrcScheme`, which is `blob` for
|
||||
Media Source playback). The first `loadedmetadata` on such an element after
|
||||
the start is recorded as a phase. A visible video element does not end the
|
||||
journey, and media events before the click or on other elements are
|
||||
ignored.
|
||||
|
||||
### Counters
|
||||
|
||||
| Counter | Source |
|
||||
| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `renderer.ipcCallsToPlaying` | Bridge `start` trace events between the start and end sentinels, as `renderer.ipcCallsToFirstPage` in J2. |
|
||||
| `renderer.httpRequestsToPlaying` | Requests the ledger proxy received from the click stamp until the `playing` stamp, from either process (the stream request comes from the renderer, Xtream API calls from the main process). Both stamps are `performance.timeOrigin + performance.now()` of processes on the same host clock, as in J2. Unlike J2's counter the window ends at the terminal, not at a quiet mock: a live stream has no quiet end. Later requests are kept as `evidence.httpRequestsAfterPlayingByRoute`, the ones in the window as `evidence.httpRequestsByRoute`. |
|
||||
| `renderer.domMutationsToPlaying` | `MutationRecord`s from the click until the `playing` event, including records still queued when it fires. |
|
||||
| `renderer.layoutShiftScore` | All `layout-shift` entries from the click until the `playing` event, including `hadRecentInput` ones (as J2), rounded to three decimals. Entries delivered up to the post-paint cutoff are read, but only those that started by the event count. |
|
||||
| `renderer.longTasks` | `longtask` entries over 50 ms whose time range overlaps the window from the click to the `playing` event, so the task that dispatched the event counts. Evidence until shown to be stable on the runner. |
|
||||
|
||||
Two counters are listed under `unavailable`. `renderer.cdTicksToPlaying` is
|
||||
missing for the same reason as in J1 and J2.
|
||||
`renderer.ipcSerialDepthToPlaying` is missing because the serial-depth
|
||||
helper (see [Startup work before the first card](#startup-work-before-the-first-card))
|
||||
was not on `master` when J3 landed; J3 adopts it once the J1 thread adds it.
|
||||
`main.sqlStatementsToPlaying` is not measured for the same reason as J2's
|
||||
SQL counter.
|
||||
|
||||
### Wall-clock
|
||||
|
||||
| Entry | Derivation |
|
||||
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| `clickToPlayingMs.p50/.p90` | `playing` epoch minus start epoch. |
|
||||
| `clickToLoadedMetadataMs.p50/.p90` | First `loadedmetadata` epoch minus start epoch: player setup, the stream request and the first transmuxed init segment. |
|
||||
|
||||
The difference of the two is stream start: buffering until the element can
|
||||
play. All epochs are taken in the renderer.
|
||||
|
||||
### First measurement
|
||||
|
||||
Local, macOS, 2026-09-30 (two `perf:journeys` runs, five measured iterations
|
||||
each): every counter identical in all ten, `renderer.ipcCallsToPlaying` 4
|
||||
(`getEpgMapping`, `xtreamRequest`, `updateRemoteControlStatus`,
|
||||
`setUserAgent`), `renderer.httpRequestsToPlaying` 2 (the `.ts` stream and
|
||||
`get_simple_data_table`), `renderer.domMutationsToPlaying` 6,188,
|
||||
`renderer.layoutShiftScore` 0.001, `renderer.longTasks` 0; P50
|
||||
click→`loadedmetadata` 92-94 ms and click→`playing` 239-257 ms. The warm-up
|
||||
iteration of the first run took the cold path (888 ms to `playing`, one more
|
||||
`updateRemoteControlStatus` call); warm-ups are excluded. About 6,000 of the
|
||||
mutations come from the EPG timeline rendering about 240 programme blocks
|
||||
from the `get_simple_data_table` response before the first frame. Whether
|
||||
that response and its render land before `playing` is a race on a slower
|
||||
machine, so check the runner's `counterStability` before trusting the
|
||||
mutation and request counts. No J3 baseline exists yet; J3 counters join the
|
||||
ratchet once three runner runs agree.
|
||||
|
||||
## `renderer.initialBytes`
|
||||
|
||||
The bytes a browser fetches before Angular can bootstrap, read from the built
|
||||
@@ -764,10 +883,12 @@ reports slow imports of non-Latin playlists.
|
||||
continues from J1 with `runLaunchJourney` and lets the app settle first,
|
||||
as `open-source-journey-app.ts` does.
|
||||
2. Give the journey its own probe options (`cardSelector`,
|
||||
`companionSelectors`, `routeFragment`, `startClick` for a click start) or
|
||||
extend `journey-renderer-probe.ts` when the end condition is not "elements
|
||||
became visible". Use a state key and sentinel ids of its own. Keep the
|
||||
probe self-contained: Playwright serializes it with `toString()`.
|
||||
`companionSelectors`, `routeFragment`, `startClick` for a click start,
|
||||
`media` for a media-event end such as J3's `playing`) or extend
|
||||
`journey-renderer-probe.ts` when the end condition is neither. Use a state
|
||||
key and sentinel ids of its own. Keep the probe self-contained: Playwright
|
||||
serializes it with `toString()`. A click-started journey settles with
|
||||
`waitForJourneyClickQuiet` from `journey-click-settle.ts`.
|
||||
3. Map the measurement to a `JourneyIterationRecord` in a
|
||||
`<journey>-journey-record.ts` under `src/performance/`; name counters
|
||||
`renderer.*` or `main.*`, and list counters you cannot measure under
|
||||
|
||||
Reference in new issue
Block a user