Merge remote-tracking branch 'origin/master' into claude/ui-theme-foundation

# Conflicts:
#	.github/workflows/ci.yml
#	package.json
This commit is contained in:
4gray committed 2026-10-01 11:24:32 +02:00
commit 67b4e41155
57 files changed
+3107 -425

No files matched your search

@@ -0,0 +1,6 @@
---
type: fix
area: settings
---
The parental PIN dialog now names what it will do: Save PIN, Confirm, Unlock or Turn off, next to a Cancel button. When the repeated PIN differs, the dialog says so under the field as you type, and pressing Enter shows the error instead of doing nothing. The error shake is skipped when the system asks for reduced motion.
+8 -2
View File
@@ -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.
@@ -378,6 +378,12 @@ jobs:
- name: Validate Angular Material token overrides
run: pnpm run styles:material-tokens:validate
# Nx rejects a non-parallel task with continuous dependencies, and
# the Playwright plugin infers serve dependencies only when CI is
# unset, so this checks both the local and the CI inference.
- name: Validate Playwright task graphs
run: pnpm run e2e:task-graphs:validate
- name: Typecheck web and Electron entry points
run: pnpm run typecheck:ci
+3 -2
View File
@@ -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,224 @@
import type { ElectronApplication, Page } from '@playwright/test';
import { configureLiveFormat } from '../xtream-live-format.fixture';
import {
JOURNEY_CLICK_QUIET_MS,
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 {
PLAYBACK_JOURNEY_AFTER_PLAYING_WINDOW_MS,
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');
}
// A live stream never leaves the mock quiet, so instead of J2's quiet
// wait the ledger is watched for a fixed window after `playing`.
const afterPlayingUntilEpochMs =
playingEpochMs + PLAYBACK_JOURNEY_AFTER_PLAYING_WINDOW_MS;
const remainingMs =
afterPlayingUntilEpochMs - (performance.timeOrigin + performance.now());
if (remainingMs > 0) {
await new Promise((resolve) => setTimeout(resolve, remainingMs));
}
// The ledger stamps arrivals with this process's clock, the probe with
// the renderer's; both read the same host clock (as in J2). The record
// keeps the distance of the nearest request to each boundary, so a
// count that a small clock difference could flip is visible.
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 &&
entry.epochMs < afterPlayingUntilEpochMs
),
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,267 @@
import { Locator, Page } from '@playwright/test';
import {
closeElectronApp,
expect,
launchElectronApp,
openSettings,
openSettingsSection,
test,
} from './electron-test-fixtures';
/**
* The parental-lock PIN dialog (Settings → Parental lock):
*
* 1. Setting a PIN, a repeat that differs is shown on the repeat field as
* soon as it is as long as the PIN, marked `aria-invalid` and placed
* in the field's live region. Enter is refused visibly — the field
* shakes, except under reduced motion — instead of being swallowed by
* a disabled submit button; fixing the repeat saves.
* 2. Every flow names its own submit verb: Save PIN, Confirm (current PIN
* before a change), Unlock, Turn off; the dismiss button is Cancel.
*/
const PIN = '2468';
const THEMES = [
{ dark: false, color: 'rgb(179, 38, 30)' },
{ dark: true, color: 'rgb(255, 180, 171)' },
] as const;
function pinDialog(page: Page) {
// The newest one: a flow can open the next prompt as one closes.
const dialog = page
.locator('mat-dialog-container', {
has: page.locator('app-parental-lock-pin-dialog'),
})
.last();
return {
dialog,
pin: dialog.getByTestId('parental-lock-pin'),
confirm: dialog.getByTestId('parental-lock-pin-confirm'),
submit: dialog.getByTestId('parental-lock-pin-submit'),
cancel: dialog.getByRole('button', { name: 'Cancel', exact: true }),
mismatch: dialog.getByTestId('parental-lock-pin-mismatch'),
};
}
async function openParentalSettings(page: Page): Promise<Locator> {
await openSettings(page);
await openSettingsSection(page, 'parental');
return page.locator(
'[data-test-id="parental-lock-enabled"] button[role="switch"]'
);
}
/**
* Presses Enter in `input` and reports the field's animation at the moment
* the shake class lands (a MutationObserver sees it synchronously, well
* inside the 400ms the class stays on).
*/
async function pressEnterAndCatchShake(input: Locator): Promise<string | null> {
const field = input.locator('xpath=ancestor::mat-form-field');
await expect(field).not.toHaveClass(/pin-dialog__field--shake/);
await field.evaluate((element) => {
const target = element as HTMLElement & { shakeAnimation?: string };
delete target.shakeAnimation;
const observer = new MutationObserver(() => {
if (target.classList.contains('pin-dialog__field--shake')) {
target.shakeAnimation = getComputedStyle(target).animationName;
observer.disconnect();
}
});
observer.observe(target, { attributeFilter: ['class'] });
});
await input.press('Enter');
await expect
.poll(() =>
field.evaluate(
(element) =>
(element as HTMLElement & { shakeAnimation?: string })
.shakeAnimation ?? null
)
)
.not.toBeNull();
return field.evaluate(
(element) =>
(element as HTMLElement & { shakeAnimation?: string })
.shakeAnimation ?? null
);
}
test.describe('Electron parental-lock PIN dialog', () => {
test('@settings @parental @electron shows, announces and refuses a mismatched repeat, then saves the fixed PIN', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
const page = app.mainWindow;
const toggle = await openParentalSettings(page);
await expect(toggle).toHaveAttribute('aria-checked', 'false');
await toggle.click();
const { dialog, pin, confirm, submit, cancel, mismatch } =
pinDialog(page);
await expect(pin).toBeFocused();
await expect(submit).toHaveText('Save PIN');
await expect(cancel).toBeVisible();
await expect(pin).toHaveAttribute('autocomplete', 'new-password');
await expect(confirm).toHaveAttribute(
'autocomplete',
'new-password'
);
// Enter after the PIN moves on to the empty repeat; Save
// with the repeat still empty is refused.
await pin.fill(PIN);
await pin.press('Enter');
await expect(confirm).toBeFocused();
await expect(mismatch).toBeHidden();
await submit.click();
await expect(mismatch).toBeVisible();
await expect(dialog).toBeVisible();
await confirm.fill(PIN.slice(0, 3));
await expect(mismatch).toBeHidden();
await expect(confirm).toHaveAttribute('aria-invalid', 'false');
// As long as the PIN and different: shown before any submit.
await confirm.fill('2469');
await expect(mismatch).toHaveText('The two PINs do not match.');
await expect(confirm).toHaveAttribute('aria-invalid', 'true');
const errorId = await mismatch.getAttribute('id');
expect(await confirm.getAttribute('aria-describedby')).toContain(
errorId
);
await expect(
mismatch.locator('xpath=ancestor::*[@aria-live="polite"]')
).toHaveCount(1);
// The parental-lock red in both themes, on the text and outline.
const outline = confirm
.locator('xpath=ancestor::mat-form-field')
.locator('.mdc-notched-outline__leading');
for (const theme of THEMES) {
await page.evaluate(
(dark) =>
document.body.classList.toggle('dark-theme', dark),
theme.dark
);
await expect(mismatch).toHaveCSS('color', theme.color);
await expect(outline).toHaveCSS(
'border-top-color',
theme.color
);
}
// Enter is refused with a shake, or without one under reduced
// motion; the dialog stays and nothing is saved.
await page.emulateMedia({ reducedMotion: 'reduce' });
expect(await pressEnterAndCatchShake(confirm)).toBe('none');
await page.emulateMedia({ reducedMotion: 'no-preference' });
expect(await pressEnterAndCatchShake(confirm)).toContain(
'pin-dialog-shake'
);
// Refused again while it still shakes, the field shakes from
// the start: its class never comes off, so the animation is
// rewound. Held at 300ms, the second refusal must reset it.
const replayedAt = await dialog
.locator('form')
.evaluate(async (form: HTMLFormElement) => {
const nextFrame = () =>
new Promise((resolve) =>
requestAnimationFrame(() =>
requestAnimationFrame(resolve)
)
);
const field = form
.querySelector(
'[data-test-id="parental-lock-pin-confirm"]'
)
?.closest('mat-form-field') as HTMLElement;
form.requestSubmit();
await nextFrame();
const [shake] = field.getAnimations();
shake.pause();
shake.currentTime = 300;
form.requestSubmit();
await nextFrame();
return shake.currentTime;
});
expect(replayedAt).toBe(0);
await expect(dialog).toBeVisible();
await expect(mismatch).toBeVisible();
await expect(toggle).toHaveAttribute('aria-checked', 'false');
// Refused from the PIN field, focus moves to the repeat.
await pin.press('Enter');
await expect(confirm).toBeFocused();
await confirm.fill(PIN);
await expect(mismatch).toBeHidden();
await expect(confirm).toHaveAttribute('aria-invalid', 'false');
await confirm.press('Enter');
await expect(dialog).toBeHidden();
await expect(toggle).toHaveAttribute('aria-checked', 'true');
} finally {
await closeElectronApp(app);
}
});
test('@settings @parental @electron names each PIN flow with its own submit verb', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
const page = app.mainWindow;
const toggle = await openParentalSettings(page);
const { dialog, pin, confirm, submit, cancel } = pinDialog(page);
await toggle.click();
await expect(submit).toHaveText('Save PIN');
await pin.fill(PIN);
await confirm.fill(PIN);
await submit.click();
await expect(toggle).toHaveAttribute('aria-checked', 'true');
// Change PIN: the current PIN is confirmed, the new one saved.
await page.getByTestId('parental-lock-change-pin').click();
await expect(submit).toHaveText('Confirm');
await expect(confirm).toHaveCount(0);
await pin.fill(PIN);
await pin.press('Enter');
await expect(confirm).toBeVisible();
await expect(submit).toHaveText('Save PIN');
await cancel.click();
await expect(dialog).toBeHidden();
await page.getByTestId('parental-lock-lock-now').click();
await page.getByTestId('parental-lock-unlock').click();
await expect(submit).toHaveText('Unlock');
await expect(cancel).toBeVisible();
// A wrong PIN keeps the field focused for the next try, although
// it is disabled while the PIN is checked.
await pin.fill('1357');
await pin.press('Enter');
await expect(
dialog.getByTestId('parental-lock-pin-error')
).toHaveText('Wrong PIN. Try again.');
await expect(pin).toHaveAttribute('aria-invalid', 'true');
await expect(pin).toBeFocused();
await pin.fill(PIN);
await pin.press('Enter');
await expect(dialog).toBeHidden();
await expect(
page.getByTestId('parental-lock-lock-now')
).toBeVisible();
await toggle.click();
await expect(submit).toHaveText('Turn off');
await pin.fill(PIN);
await pin.press('Enter');
await expect(dialog).toBeHidden();
await expect(toggle).toHaveAttribute('aria-checked', 'false');
} finally {
await closeElectronApp(app);
}
});
});
@@ -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,344 @@
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,
});
// Click 10,000: last request before at 8,000, first after at 10,020.
// Playing 10,349.84: last before at 10,030, first after at 10,400.
assert.deepEqual(record.evidence['httpBoundaryMarginsMs'], {
click: 20,
playing: 50.2,
});
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,231 @@
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;
/** How long requests after `playing` are observed, for evidence only. */
export const PLAYBACK_JOURNEY_AFTER_PLAYING_WINDOW_MS = 1_000;
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 in the first second after `playing`. */
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$/;
/**
* Distance of the nearest ledger request to a boundary on either side
* (null when there is none). The ledger and the renderer stamp with
* different processes' clocks; a small margin flags a count that a clock
* difference could move across the boundary.
*/
function boundaryMarginMs(
before: readonly JourneyMockRequest[],
after: readonly JourneyMockRequest[],
boundaryEpochMs: number
): number | null {
const distances = [
...before.map((entry) => boundaryEpochMs - entry.epochMs),
...after.map((entry) => entry.epochMs - boundaryEpochMs),
];
return distances.length === 0 ? null : roundTenth(Math.min(...distances));
}
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),
httpBoundaryMarginsMs: Object.freeze({
click: boundaryMarginMs(
http.beforeClick,
http.toPlaying,
start.epochMs
),
playing: boundaryMarginMs(
http.toPlaying,
http.afterPlaying,
terminal.epochMs
),
}),
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,
});
}
+3 -1
View File
@@ -11,7 +11,9 @@
],
"// targets": "to see all targets run: nx show project web-e2e --web",
"targets": {
"e2e": {},
"e2e": {
"dependsOn": []
},
"lint": {
"executor": "@nx/eslint:lint"
}
@@ -24,10 +24,18 @@ describe('AppParentalLockPromptService', () => {
ParentalLockPinDialogComponent: { open },
})) as never;
await expect(service.requestPin({ mode: 'set' })).resolves.toBe('1234');
await expect(
service.requestPin({
mode: 'set',
submitKey: 'PARENTAL_LOCK.PIN_DIALOG.SAVE',
})
).resolves.toBe('1234');
expect(open).toHaveBeenCalledWith(
dialog,
expect.objectContaining({ mode: 'set' })
expect.objectContaining({
mode: 'set',
submitKey: 'PARENTAL_LOCK.PIN_DIALOG.SAVE',
})
);
});
@@ -44,6 +44,7 @@ export class AppParentalLockPromptService implements ParentalLockPrompt {
throttle: request.throttle,
titleKey: request.titleKey,
descriptionKey: request.descriptionKey,
submitKey: request.submitKey,
}
);
const pin = await firstValueFrom(dialogRef.afterClosed());
+2
View File
@@ -1936,6 +1936,8 @@
"NO_RECOVERY": "لا توجد طريقة لاستعادة رمز PIN المنسي — لا يزيله إلا إعادة تعيين بيانات التطبيق.",
"SAVE": "حفظ رمز PIN",
"UNLOCK": "إلغاء القفل",
"CONFIRM": "تأكيد",
"TURN_OFF": "تعطيل",
"CONFIRM_TITLE": "تأكيد رمز PIN للرقابة الأبوية",
"CONFIRM_DESCRIPTION": "أدخل رمز PIN الحالي لتغيير هذا الإعداد."
},
+2
View File
@@ -1936,6 +1936,8 @@
"NO_RECOVERY": "ماكايناش طريقة باش ترجع رمز PIN لي نسيتي — غير إعادة الضبط ديال بيانات التطبيق هي لي كتحيدو.",
"SAVE": "حفظ رمز PIN",
"UNLOCK": "حل القفل",
"CONFIRM": "أكّد",
"TURN_OFF": "طفّي",
"CONFIRM_TITLE": "أكّد رمز PIN ديال الرقابة الأبوية",
"CONFIRM_DESCRIPTION": "دخّل رمز PIN الحالي باش تبدّل هاد الإعداد."
},
+2
View File
@@ -1936,6 +1936,8 @@
"NO_RECOVERY": "Забыты PIN аднавіць немагчыма — яго выдаляе толькі скід дадзеных праграмы.",
"SAVE": "Захаваць PIN",
"UNLOCK": "Разблакіраваць",
"CONFIRM": "Пацвердзіць",
"TURN_OFF": "Выключыць",
"CONFIRM_TITLE": "Пацвердзіце бацькоўскі PIN",
"CONFIRM_DESCRIPTION": "Увядзіце бягучы PIN, каб змяніць гэту наладу."
},
+2
View File
@@ -1936,6 +1936,8 @@
"NO_RECOVERY": "Eine vergessene PIN kann nicht wiederhergestellt werden – nur das Zurücksetzen der App-Daten entfernt sie.",
"SAVE": "PIN speichern",
"UNLOCK": "Entsperren",
"CONFIRM": "Bestätigen",
"TURN_OFF": "Deaktivieren",
"CONFIRM_TITLE": "Kindersicherungs-PIN bestätigen",
"CONFIRM_DESCRIPTION": "Gib die aktuelle PIN ein, um diese Einstellung zu ändern."
},
+2
View File
@@ -1936,6 +1936,8 @@
"NO_RECOVERY": "Δεν υπάρχει τρόπος ανάκτησης ενός ξεχασμένου PIN — μόνο η επαναφορά των δεδομένων της εφαρμογής το αφαιρεί.",
"SAVE": "Αποθήκευση PIN",
"UNLOCK": "Ξεκλείδωμα",
"CONFIRM": "Επιβεβαίωση",
"TURN_OFF": "Απενεργοποίηση",
"CONFIRM_TITLE": "Επιβεβαίωση γονικού PIN",
"CONFIRM_DESCRIPTION": "Εισαγάγετε το τρέχον PIN για να αλλάξετε αυτή τη ρύθμιση."
},
+2
View File
@@ -1936,6 +1936,8 @@
"NO_RECOVERY": "There is no way to recover a forgotten PIN — only resetting the app data removes it.",
"SAVE": "Save PIN",
"UNLOCK": "Unlock",
"CONFIRM": "Confirm",
"TURN_OFF": "Turn off",
"CONFIRM_TITLE": "Confirm parental PIN",
"CONFIRM_DESCRIPTION": "Enter the current PIN to change this setting."
},
+2
View File
@@ -1936,6 +1936,8 @@
"NO_RECOVERY": "No hay forma de recuperar un PIN olvidado: solo restablecer los datos de la aplicación lo elimina.",
"SAVE": "Guardar PIN",
"UNLOCK": "Desbloquear",
"CONFIRM": "Confirmar",
"TURN_OFF": "Desactivar",
"CONFIRM_TITLE": "Confirmar el PIN parental",
"CONFIRM_DESCRIPTION": "Introduce el PIN actual para cambiar este ajuste."
},
+2
View File
@@ -1936,6 +1936,8 @@
"NO_RECOVERY": "Il est impossible de récupérer un code PIN oublié — seule la réinitialisation des données de l'application le supprime.",
"SAVE": "Enregistrer le code PIN",
"UNLOCK": "Déverrouiller",
"CONFIRM": "Confirmer",
"TURN_OFF": "Désactiver",
"CONFIRM_TITLE": "Confirmer le code PIN parental",
"CONFIRM_DESCRIPTION": "Saisissez le code PIN actuel pour modifier ce réglage."
},
+2
View File
@@ -1936,6 +1936,8 @@
"NO_RECOVERY": "Az elfelejtett PIN-kód nem állítható vissza — csak az alkalmazásadatok alaphelyzetbe állítása távolítja el.",
"SAVE": "PIN-kód mentése",
"UNLOCK": "Feloldás",
"CONFIRM": "Megerősítés",
"TURN_OFF": "Kikapcsolás",
"CONFIRM_TITLE": "Szülői PIN-kód megerősítése",
"CONFIRM_DESCRIPTION": "A beállítás módosításához adja meg a jelenlegi PIN-kódot."
},
+2
View File
@@ -1936,6 +1936,8 @@
"NO_RECOVERY": "Non è possibile recuperare un PIN dimenticato: si rimuove solo reimpostando i dati dell'app.",
"SAVE": "Salva PIN",
"UNLOCK": "Sblocca",
"CONFIRM": "Conferma",
"TURN_OFF": "Disattiva",
"CONFIRM_TITLE": "Conferma il PIN parentale",
"CONFIRM_DESCRIPTION": "Inserisci il PIN attuale per modificare questa impostazione."
},
+2
View File
@@ -1936,6 +1936,8 @@
"NO_RECOVERY": "忘れた PIN を復元する方法はありません。PIN を削除するには、アプリのデータをリセットするしかありません。",
"SAVE": "PIN を保存",
"UNLOCK": "ロック解除",
"CONFIRM": "確認",
"TURN_OFF": "無効化",
"CONFIRM_TITLE": "ペアレンタル PIN を確認",
"CONFIRM_DESCRIPTION": "この設定を変更するには、現在の PIN を入力してください。"
},
+2
View File
@@ -1936,6 +1936,8 @@
"NO_RECOVERY": "잊어버린 PIN은 복구할 수 없습니다. 앱 데이터를 초기화해야만 PIN이 삭제됩니다.",
"SAVE": "PIN 저장",
"UNLOCK": "잠금 해제",
"CONFIRM": "확인",
"TURN_OFF": "끄기",
"CONFIRM_TITLE": "보호자 PIN 확인",
"CONFIRM_DESCRIPTION": "이 설정을 변경하려면 현재 PIN을 입력하세요."
},
+2
View File
@@ -1936,6 +1936,8 @@
"NO_RECOVERY": "Een vergeten pincode kan niet worden hersteld — alleen het resetten van de app-gegevens verwijdert hem.",
"SAVE": "Pincode opslaan",
"UNLOCK": "Ontgrendelen",
"CONFIRM": "Bevestigen",
"TURN_OFF": "Uitschakelen",
"CONFIRM_TITLE": "Ouderpincode bevestigen",
"CONFIRM_DESCRIPTION": "Voer de huidige pincode in om deze instelling te wijzigen."
},
+2
View File
@@ -1936,6 +1936,8 @@
"NO_RECOVERY": "Zapomnianego kodu PIN nie da się odzyskać — usuwa go tylko zresetowanie danych aplikacji.",
"SAVE": "Zapisz kod PIN",
"UNLOCK": "Odblokuj",
"CONFIRM": "Potwierdź",
"TURN_OFF": "Wyłącz",
"CONFIRM_TITLE": "Potwierdź rodzicielski kod PIN",
"CONFIRM_DESCRIPTION": "Wpisz obecny kod PIN, aby zmienić to ustawienie."
},
+2
View File
@@ -1936,6 +1936,8 @@
"NO_RECOVERY": "Não é possível recuperar um PIN esquecido — somente redefinir os dados do app o remove.",
"SAVE": "Salvar PIN",
"UNLOCK": "Desbloquear",
"CONFIRM": "Confirmar",
"TURN_OFF": "Desativar",
"CONFIRM_TITLE": "Confirmar PIN parental",
"CONFIRM_DESCRIPTION": "Digite o PIN atual para alterar esta configuração."
},
+2
View File
@@ -1936,6 +1936,8 @@
"NO_RECOVERY": "Забытый PIN восстановить нельзя — его удаляет только сброс данных приложения.",
"SAVE": "Сохранить PIN",
"UNLOCK": "Разблокировать",
"CONFIRM": "Подтвердить",
"TURN_OFF": "Выключить",
"CONFIRM_TITLE": "Подтвердите родительский PIN",
"CONFIRM_DESCRIPTION": "Введите текущий PIN, чтобы изменить эту настройку."
},
+2
View File
@@ -1936,6 +1936,8 @@
"NO_RECOVERY": "Unutulan bir PIN'i kurtarmanın yolu yoktur — yalnızca uygulama verilerini sıfırlamak onu kaldırır.",
"SAVE": "PIN'i kaydet",
"UNLOCK": "Kilidi aç",
"CONFIRM": "Onayla",
"TURN_OFF": "Kapat",
"CONFIRM_TITLE": "Ebeveyn PIN'ini onaylayın",
"CONFIRM_DESCRIPTION": "Bu ayarı değiştirmek için mevcut PIN'i girin."
},
+2
View File
@@ -1936,6 +1936,8 @@
"NO_RECOVERY": "忘记的 PIN 码无法找回——只有重置应用数据才能将其移除。",
"SAVE": "保存 PIN 码",
"UNLOCK": "解锁",
"CONFIRM": "确认",
"TURN_OFF": "关闭",
"CONFIRM_TITLE": "确认家长 PIN 码",
"CONFIRM_DESCRIPTION": "请输入当前 PIN 码以更改此设置。"
},
+2
View File
@@ -1936,6 +1936,8 @@
"NO_RECOVERY": "忘記的 PIN 碼無法復原——只有重設應用程式資料才能將其移除。",
"SAVE": "儲存 PIN 碼",
"UNLOCK": "解鎖",
"CONFIRM": "確認",
"TURN_OFF": "關閉",
"CONFIRM_TITLE": "確認家長 PIN 碼",
"CONFIRM_DESCRIPTION": "請輸入目前的 PIN 碼以變更此設定。"
},
+20 -1
View File
@@ -416,8 +416,27 @@ so a cancelled or refused PIN leaves it showing the real state.
so a child cannot switch the idle relock off for the next unlock.
- Header lock/unlock button and the `parental-lock-now` /
`parental-unlock` palette commands.
- The PIN dialog's submit button names the flow: the service passes a
`submitKey` with every prompt — Unlock (`requestUnlock`), Save PIN (a new
PIN in `setupPin` and `changePin`), Confirm (the current PIN before a
change), Turn off (`disable`). The dismiss button is the shared Cancel.
Errors sit on their field as `mat-error` (an `errorStateMatcher` drives
it, so the input gets `aria-invalid` and the message lands in the field's
live region): a wrong PIN, the cooldown or a too-short PIN on the PIN
field; a mismatch on the repeat field, shown while typing once the repeat
is as long as the PIN. Submit is disabled only while busy or cooling
down, never for incomplete input, because Enter does nothing on a
disabled default button; `submit()` refuses instead, shakes the field
(no shake under `prefers-reduced-motion: reduce`) and focuses it — except
that Enter in the PIN field with the repeat still empty only moves focus
to the repeat (a `keydown.enter` handler: focus cannot tell Enter from a
click on Save, since WebKit does not focus a clicked button). Set
mode marks both inputs `autocomplete="new-password"`. The Electron
`parental-lock-pin-dialog.e2e.ts` covers the labels and the
mismatch → fix → save flow.
- Styling uses app tokens only (the theme declares no `--mat-sys-*`); the
PIN error and the Stalker adult chip use a local red per theme. Keyboard
PIN errors, the fields' error outline and the Stalker adult chip use a
local red per theme. Keyboard
focus on a lock row is a 2px inset `--app-selection-color` ring, and the
lock dialogs open with `maxWidth: 'calc(100vw - 32px)'` and no content
min-width, so they fit a 375px phone. `apps/web-e2e/src/parental-lock-ui.e2e.ts`
+142 -7
View File
@@ -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,132 @@ 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. |
`renderer.httpRequestsToPlaying` compares the ledger's arrival stamps
(test process) with the renderer's click and `playing` stamps. Both are
`performance.timeOrigin + performance.now()` on the same host clock, but the
two processes' time origins can differ by a fraction of a millisecond, so
every iteration records `evidence.httpBoundaryMarginsMs`: the distance of
the nearest request on either side of the click and of `playing`. A margin
of a few milliseconds means a clock difference could move that request
across the boundary. Locally the first request after the click arrives 3-6
ms after its stamp (the click causes it, so it cannot precede the click) and
the nearest request to `playing` is more than 170 ms away; both are well
above a sub-millisecond origin difference. `evidence.httpRequestsAfterPlayingByRoute` covers a fixed
window of 1 s after `playing` (the test waits that long before reading the
ledger), not a quiet mock as in J2: a live stream has no quiet end.
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 +897,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
+4 -2
View File
@@ -87,8 +87,10 @@ launches the backend through Nx or its `env` drifts from the `serve` target.
`self-hosted.e2e.ts` URLs, like `MOCK_PORT` does for the mocks. Use it when
another worktree holds 3333. The `web:serve` entry can stay on Nx:
`@angular/build:dev-server` runs inside the Nx process, so the group kill
stops it. The mock servers follow the same
rule; see [Xtream mock Playwright integration](xtream-mock-server.md#playwright-integration).
stops it. Playwright launches that command itself even outside CI: the
web-e2e targets carry no Nx `serve` dependency, because Nx refuses a
non-parallel task with a continuous dependency. The mock servers follow the
same rule; see [Xtream mock Playwright integration](xtream-mock-server.md#playwright-integration).
The PWA continues to use `PwaService`; only the backend base URL is resolved at
runtime. Electron routes remain owned by the Electron backend and preload
+7
View File
@@ -197,6 +197,13 @@ and JSON summary output. CI uploads the merged Tier A report to Codecov with the
Use atomized E2E targets when available, for example
`pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts`.
After changing a Playwright config's `webServer` list, `nx.json` target
defaults or an E2E project's `dependsOn`, run
`pnpm run e2e:task-graphs:validate`. It builds every Playwright target's task
graph with and without `CI` and fails on the graphs Nx refuses to run; CI runs
it in the `unit-and-typecheck` job. See
[Xtream mock Playwright integration](xtream-mock-server.md#playwright-integration).
Inside `expect.poll`, read a changing list in one DOM snapshot
(`allTextContents()` or `evaluateAll()`, as in
`apps/electron-backend-e2e/src/sidebar-categories.e2e-support.ts`), not by
+21
View File
@@ -586,6 +586,27 @@ continuous `serve` task. The same spec guards the `e2e*` entries of `nx.json`
`targetDefaults` and every target in the `apps/*-e2e` `project.json` files. The web-e2e `web-backend` entry uses the same
launch form; see [PWA web backend](pwa-self-hosted.md#web-backend).
Playwright, not Nx, starts every E2E server, including the web dev server.
`@nx/playwright/plugin` infers a continuous `serve` dependency from a
`pnpm nx run <project>:serve` webServer only while `reuseExistingServer` is
true, which the configs set only when `CI` is unset. It also sets
`parallelism: false` on a target when a webServer has an `env` or a plain
`node` command, because no Nx task covers that server. Nx refuses to run a
non-parallel task that depends on a continuous task, so the inferred
`web-e2e:e2e` failed locally with "do not support parallelism but depend on
continuous tasks" and passed only in CI. Therefore:
- `apps/web-e2e/project.json` sets `e2e.dependsOn` to `[]`, and the filtered
`e2e-ci--src/*.e2e.ts` target default in `nx.json` does the same for the
per-file web targets. The per-file Electron targets depend only on
`electron-backend:build-e2e`.
- The plugin runs with `waitForWebServer: false`, because no target consumes
its `e2e--wait-for-webserver` readiness task. Playwright's own URL probe
covers readiness.
- `pnpm run e2e:task-graphs:validate` (`tools/nx/check-e2e-task-graphs.mjs`)
builds each Playwright target's task graph with Nx's own validation, once
with `CI` unset and once with it set.
### Request Interception
The Angular PWA calls `localhost:3000/xtream?...`. Playwright intercepts these:
@@ -0,0 +1,72 @@
import {
ParentalLockPinThrottle,
verifyParentalLockPin,
} from '@iptvnator/shared/interfaces';
import { ParentalLockPromptRequest } from './parental-lock-prompt.token';
type PromptLabels = Pick<
ParentalLockPromptRequest,
'titleKey' | 'descriptionKey' | 'submitKey'
>;
/**
* The submit verb of each PIN flow, so the button says what it will do
* instead of a generic "Unlock".
*/
export const PARENTAL_LOCK_SUBMIT_KEYS = {
unlock: 'PARENTAL_LOCK.PIN_DIALOG.UNLOCK',
save: 'PARENTAL_LOCK.PIN_DIALOG.SAVE',
confirm: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM',
turnOff: 'PARENTAL_LOCK.PIN_DIALOG.TURN_OFF',
} as const;
/** Setting up or replacing the PIN: typed twice, saved on submit. */
export const NEW_PIN_REQUEST: ParentalLockPromptRequest = {
mode: 'set',
submitKey: PARENTAL_LOCK_SUBMIT_KEYS.save,
};
/** Unlocks the session with the current PIN. */
export function unlockPinRequest(
hash: string,
throttle: ParentalLockPinThrottle,
labels: Pick<PromptLabels, 'titleKey' | 'descriptionKey'> = {}
): ParentalLockPromptRequest {
return currentPinRequest(hash, throttle, {
submitKey: PARENTAL_LOCK_SUBMIT_KEYS.unlock,
...labels,
});
}
/**
* Confirms the current PIN before a protected settings change; `submitKey`
* names that change (Confirm before a new PIN, Turn off).
*/
export function confirmPinRequest(
hash: string,
throttle: ParentalLockPinThrottle,
submitKey: string
): ParentalLockPromptRequest {
return currentPinRequest(hash, throttle, {
titleKey: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_TITLE',
descriptionKey: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_DESCRIPTION',
submitKey,
});
}
/**
* Asks for the current PIN, checked against `hash`. Every such prompt shares
* the service's `throttle`, so the cooldown survives a dismissed dialog.
*/
function currentPinRequest(
hash: string,
throttle: ParentalLockPinThrottle,
labels: PromptLabels
): ParentalLockPromptRequest {
return {
mode: 'unlock',
verify: (candidate) => verifyParentalLockPin(candidate, hash),
throttle,
...labels,
};
}
@@ -20,6 +20,11 @@ export interface ParentalLockPromptRequest {
titleKey?: string;
/** Optional translation key overriding the mode's default description. */
descriptionKey?: string;
/**
* Translation key of the flow's verb on the submit button ("Unlock",
* "Save PIN", "Turn off", …); the mode's default when absent.
*/
submitKey?: string;
}
/**
@@ -559,6 +559,46 @@ describe('ParentalLockService', () => {
expect(prompt.requestPin.mock.calls[0][0].mode).toBe('unlock');
});
it('labels each prompt with the verb of its flow', async () => {
prompt.requestPin.mockImplementation(
async (request: ParentalLockPromptRequest) =>
request.mode === 'set'
? '1234'
: (await request.verify?.('1234'))
? '1234'
: null
);
const service = await createService();
const submitKeys = () =>
prompt.requestPin.mock.calls.map(
([request]: [ParentalLockPromptRequest]) =>
`${request.mode}:${request.submitKey}`
);
await expect(service.setupPin()).resolves.toBe(true);
expect(submitKeys()).toEqual(['set:PARENTAL_LOCK.PIN_DIALOG.SAVE']);
prompt.requestPin.mockClear();
await expect(service.changePin()).resolves.toBe(true);
expect(submitKeys()).toEqual([
'unlock:PARENTAL_LOCK.PIN_DIALOG.CONFIRM',
'set:PARENTAL_LOCK.PIN_DIALOG.SAVE',
]);
prompt.requestPin.mockClear();
service.lock();
await expect(service.requestUnlock()).resolves.toBe(true);
expect(submitKeys()).toEqual([
'unlock:PARENTAL_LOCK.PIN_DIALOG.UNLOCK',
]);
prompt.requestPin.mockClear();
await expect(service.disable()).resolves.toBe(true);
expect(submitKeys()).toEqual([
'unlock:PARENTAL_LOCK.PIN_DIALOG.TURN_OFF',
]);
});
it('persists locks per portal, stamps the Xtream column and bumps the version', async () => {
const service = await createService();
const versionBefore = service.version();
@@ -13,7 +13,6 @@ import {
ParentalLockPlaylistLocks,
ParentalLockStalkerCategoryType,
ParentalLockXtreamCategoryType,
verifyParentalLockPin,
} from '@iptvnator/shared/interfaces';
import { RuntimeCapabilitiesService } from '../runtime-capabilities.service';
import { SettingsStore } from '../settings-store.service';
@@ -35,6 +34,12 @@ import {
persistParentalLockEnabled,
persistParentalLockRelockMinutes,
} from './parental-lock-settings-writer';
import {
confirmPinRequest,
NEW_PIN_REQUEST,
PARENTAL_LOCK_SUBMIT_KEYS,
unlockPinRequest,
} from './parental-lock-prompt-requests';
import { ParentalLockStorageService } from './parental-lock-storage';
/**
@@ -242,12 +247,9 @@ export class ParentalLockService {
if (!this.prompt || !hash) {
return false;
}
const pin = await this.prompt.requestPin({
mode: 'unlock',
verify: (candidate) => verifyParentalLockPin(candidate, hash),
throttle: this.pinThrottle,
...options,
});
const pin = await this.prompt.requestPin(
unlockPinRequest(hash, this.pinThrottle, options)
);
if (pin === null) {
return false;
}
@@ -276,7 +278,7 @@ export class ParentalLockService {
// depends on.
const needsPersist =
this.settingsStore.parentalLockEnabled?.() !== true;
const pin = await this.prompt.requestPin({ mode: 'set' });
const pin = await this.prompt.requestPin(NEW_PIN_REQUEST);
if (pin === null) {
return false;
}
@@ -306,10 +308,10 @@ export class ParentalLockService {
if (!this.prompt || !this.hasPin()) {
return false;
}
if (!(await this.verifyCurrentPin())) {
if (!(await this.verifyCurrentPin(PARENTAL_LOCK_SUBMIT_KEYS.confirm))) {
return false;
}
const pin = await this.prompt.requestPin({ mode: 'set' });
const pin = await this.prompt.requestPin(NEW_PIN_REQUEST);
if (pin === null) {
return false;
}
@@ -321,7 +323,7 @@ export class ParentalLockService {
if (!this.enabled()) {
return true;
}
if (!(await this.verifyCurrentPin())) {
if (!(await this.verifyCurrentPin(PARENTAL_LOCK_SUBMIT_KEYS.turnOff))) {
return false;
}
if (!(await this.persistEnabled(false))) {
@@ -335,22 +337,18 @@ export class ParentalLockService {
* Always asks for the PIN, unlocked session or not: changing the PIN or
* switching the feature off must not be possible just because a parent
* left the app unlocked. Unlike `requestUnlock()` this never short-cuts
* on `active`.
* on `active`. `submitKey` names the step the PIN confirms.
*/
private async verifyCurrentPin(): Promise<boolean> {
private async verifyCurrentPin(submitKey: string): Promise<boolean> {
await this.initialize();
await this.ensurePin();
const hash = this.pinHash();
if (!this.prompt || !hash) {
return false;
}
const pin = await this.prompt.requestPin({
mode: 'unlock',
verify: (candidate) => verifyParentalLockPin(candidate, hash),
throttle: this.pinThrottle,
titleKey: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_TITLE',
descriptionKey: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_DESCRIPTION',
});
const pin = await this.prompt.requestPin(
confirmPinRequest(hash, this.pinThrottle, submitKey)
);
return pin !== null;
}
@@ -11,7 +11,7 @@
appearance="outline"
subscriptSizing="dynamic"
class="pin-dialog__field"
[class.pin-dialog__field--shake]="shake()"
[class.pin-dialog__field--shake]="shake() === 'pin'"
>
<mat-label>{{ 'PARENTAL_LOCK.PIN_DIALOG.PIN_LABEL' | translate }}</mat-label>
<input
@@ -19,12 +19,14 @@
matInput
type="password"
inputmode="numeric"
autocomplete="off"
data-test-id="parental-lock-pin"
[attr.autocomplete]="isSetMode() ? 'new-password' : 'off'"
[attr.maxlength]="maxLength"
[disabled]="busy() || inCooldown()"
[errorStateMatcher]="pinErrorState"
[ngModel]="pin()"
(ngModelChange)="onPinInput($event)"
(keydown.enter)="onPinEnter($event)"
name="pin"
/>
<mat-hint>
@@ -33,6 +35,11 @@
| translate: { min: minLength, max: maxLength }
}}
</mat-hint>
@if (pinErrorKey(); as errorKey) {
<mat-error data-test-id="parental-lock-pin-error">
{{ errorKey | translate: { min: minLength, max: maxLength } }}
</mat-error>
}
</mat-form-field>
@if (isSetMode()) {
@@ -40,30 +47,30 @@
appearance="outline"
subscriptSizing="dynamic"
class="pin-dialog__field"
[class.pin-dialog__field--shake]="shake() === 'confirmation'"
>
<mat-label>{{ 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM_LABEL' | translate }}</mat-label>
<input
#confirmationInput
matInput
type="password"
inputmode="numeric"
autocomplete="off"
autocomplete="new-password"
data-test-id="parental-lock-pin-confirm"
[attr.maxlength]="maxLength"
[disabled]="busy()"
[errorStateMatcher]="confirmationErrorState"
[ngModel]="confirmation()"
(ngModelChange)="onConfirmationInput($event)"
name="confirmation"
/>
@if (mismatch()) {
<mat-error data-test-id="parental-lock-pin-mismatch">
{{ 'PARENTAL_LOCK.PIN_DIALOG.MISMATCH' | translate }}
</mat-error>
}
</mat-form-field>
}
@if (error(); as errorKey) {
<p class="pin-dialog__error" role="alert" data-test-id="parental-lock-pin-error">
{{ errorKey | translate }}
</p>
}
@if (isSetMode()) {
<p class="pin-dialog__note">
{{ 'PARENTAL_LOCK.PIN_DIALOG.NO_RECOVERY' | translate }}
</p>
@@ -72,21 +79,16 @@
<mat-dialog-actions align="end">
<button mat-button type="button" (click)="cancel()" [disabled]="busy()">
{{ 'CLOSE' | translate }}
{{ 'CANCEL' | translate }}
</button>
<button
mat-flat-button
color="primary"
type="submit"
data-test-id="parental-lock-pin-submit"
[disabled]="!canSubmit()"
[disabled]="busy() || inCooldown()"
>
{{
(isSetMode()
? 'PARENTAL_LOCK.PIN_DIALOG.SAVE'
: 'PARENTAL_LOCK.PIN_DIALOG.UNLOCK'
) | translate
}}
{{ submitKey() | translate }}
</button>
</mat-dialog-actions>
</form>
@@ -1,7 +1,28 @@
// The theme declares no Material system colours, so the error colour is a
// local token with a value per theme (>= 6:1 on the dialog surface in both).
// The fields' error state (message, outline, label, caret) takes it too.
:host {
--pin-dialog-error-color: #b3261e;
--mat-form-field-error-text-color: var(--pin-dialog-error-color);
--mat-form-field-outlined-error-outline-color: var(
--pin-dialog-error-color
);
--mat-form-field-outlined-error-hover-outline-color: var(
--pin-dialog-error-color
);
--mat-form-field-outlined-error-focus-outline-color: var(
--pin-dialog-error-color
);
--mat-form-field-outlined-error-label-text-color: var(
--pin-dialog-error-color
);
--mat-form-field-outlined-error-hover-label-text-color: var(
--pin-dialog-error-color
);
--mat-form-field-outlined-error-focus-label-text-color: var(
--pin-dialog-error-color
);
--mat-form-field-outlined-error-caret-color: var(--pin-dialog-error-color);
}
:host-context(.dark-theme) {
@@ -37,12 +58,10 @@
.pin-dialog__field--shake {
animation: pin-dialog-shake 0.4s ease;
}
.pin-dialog__error {
margin: 0;
color: var(--pin-dialog-error-color);
font-size: 0.8125rem;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
.pin-dialog__note {
@@ -1,4 +1,4 @@
import { TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
@@ -6,48 +6,361 @@ import {
createParentalLockPinThrottle,
PARENTAL_LOCK_PIN_MAX_ATTEMPTS,
} from '@iptvnator/shared/interfaces';
import { ParentalLockPinDialogComponent } from './parental-lock-pin-dialog.component';
import {
ParentalLockPinDialogComponent,
ParentalLockPinDialogData,
} from './parental-lock-pin-dialog.component';
describe('ParentalLockPinDialogComponent cooldown', () => {
const throttle = createParentalLockPinThrottle();
const verify = jest.fn(async () => false);
// Without a loader the translate pipe renders the key itself.
const KEYS = {
save: 'PARENTAL_LOCK.PIN_DIALOG.SAVE',
unlock: 'PARENTAL_LOCK.PIN_DIALOG.UNLOCK',
confirm: 'PARENTAL_LOCK.PIN_DIALOG.CONFIRM',
turnOff: 'PARENTAL_LOCK.PIN_DIALOG.TURN_OFF',
mismatch: 'PARENTAL_LOCK.PIN_DIALOG.MISMATCH',
hint: 'PARENTAL_LOCK.PIN_DIALOG.PIN_HINT',
wrongPin: 'PARENTAL_LOCK.PIN_DIALOG.WRONG_PIN',
};
function openDialog(): ParentalLockPinDialogComponent {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [
ParentalLockPinDialogComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{
provide: MAT_DIALOG_DATA,
useValue: { mode: 'unlock', verify, throttle },
},
{ provide: MatDialogRef, useValue: { close: jest.fn() } },
],
interface DialogHarness {
fixture: ComponentFixture<ParentalLockPinDialogComponent>;
component: ParentalLockPinDialogComponent;
close: jest.Mock;
query<T extends Element = HTMLElement>(testId: string): T | null;
type(testId: string, value: string): Promise<void>;
/** Submits the form the way Enter in a field does. */
pressEnter(): Promise<void>;
}
async function openDialog(
data: ParentalLockPinDialogData
): Promise<DialogHarness> {
TestBed.resetTestingModule();
const close = jest.fn();
TestBed.configureTestingModule({
imports: [
ParentalLockPinDialogComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{ provide: MAT_DIALOG_DATA, useValue: data },
{ provide: MatDialogRef, useValue: { close } },
],
});
const fixture = TestBed.createComponent(ParentalLockPinDialogComponent);
await fixture.whenStable();
const root = fixture.nativeElement as HTMLElement;
const query = <T extends Element = HTMLElement>(testId: string) =>
root.querySelector<T>(`[data-test-id="${testId}"]`);
return {
fixture,
component: fixture.componentInstance,
close,
query,
async type(testId, value) {
const input = query<HTMLInputElement>(testId);
if (!input) throw new Error(`No input ${testId}`);
input.value = value;
input.dispatchEvent(new Event('input'));
await fixture.whenStable();
},
async pressEnter() {
root.querySelector('form')?.dispatchEvent(
new Event('submit', { cancelable: true })
);
await fixture.whenStable();
},
};
}
function text(element: Element | null): string {
return element?.textContent?.trim() ?? '';
}
describe('ParentalLockPinDialogComponent', () => {
describe('submit labels', () => {
it.each([
['unlock', undefined, KEYS.unlock],
['set', undefined, KEYS.save],
['unlock', KEYS.confirm, KEYS.confirm],
['unlock', KEYS.turnOff, KEYS.turnOff],
['set', KEYS.save, KEYS.save],
] as const)(
'%s mode with submitKey %s reads %s',
async (mode, submitKey, expected) => {
const dialog = await openDialog({ mode, submitKey });
expect(text(dialog.query('parental-lock-pin-submit'))).toBe(
expected
);
const dismiss = dialog.fixture.nativeElement.querySelector(
'mat-dialog-actions button[type="button"]'
);
expect(text(dismiss)).toBe('CANCEL');
}
);
});
describe('set mode', () => {
it('asks password managers for a new password in both fields', async () => {
const dialog = await openDialog({ mode: 'set' });
expect(
dialog.query('parental-lock-pin')?.getAttribute('autocomplete')
).toBe('new-password');
expect(
dialog
.query('parental-lock-pin-confirm')
?.getAttribute('autocomplete')
).toBe('new-password');
});
const fixture = TestBed.createComponent(ParentalLockPinDialogComponent);
fixture.detectChanges();
return fixture.componentInstance;
}
it('keeps the cooldown when the prompt is dismissed and opened again', async () => {
const first = openDialog();
for (let i = 0; i < PARENTAL_LOCK_PIN_MAX_ATTEMPTS; i++) {
first.onPinInput('0000');
await first.submit();
}
expect(first.inCooldown()).toBe(true);
expect(verify).toHaveBeenCalledTimes(PARENTAL_LOCK_PIN_MAX_ATTEMPTS);
it('shows the mismatch once the repeat is as long as the PIN, then saves the fixed PIN', async () => {
const dialog = await openDialog({ mode: 'set' });
const confirm = () =>
dialog.query<HTMLInputElement>('parental-lock-pin-confirm');
const reopened = openDialog();
expect(reopened.error()).toBe('PARENTAL_LOCK.PIN_DIALOG.COOLDOWN');
reopened.onPinInput('0000');
await reopened.submit();
await dialog.type('parental-lock-pin', '2468');
await dialog.type('parental-lock-pin-confirm', '246');
expect(dialog.query('parental-lock-pin-mismatch')).toBeNull();
expect(confirm()?.getAttribute('aria-invalid')).toBe('false');
expect(reopened.inCooldown()).toBe(true);
expect(verify).toHaveBeenCalledTimes(PARENTAL_LOCK_PIN_MAX_ATTEMPTS);
await dialog.type('parental-lock-pin-confirm', '2469');
const error = dialog.query('parental-lock-pin-mismatch');
expect(text(error)).toBe(KEYS.mismatch);
expect(confirm()?.getAttribute('aria-invalid')).toBe('true');
// Announced: the error is the input's description.
expect(confirm()?.getAttribute('aria-describedby')).toContain(
error?.id
);
// Enter is refused visibly rather than swallowed.
expect(
dialog.query<HTMLButtonElement>('parental-lock-pin-submit')
?.disabled
).toBe(false);
await dialog.pressEnter();
expect(dialog.close).not.toHaveBeenCalled();
expect(dialog.component.shake()).toBe('confirmation');
expect(document.activeElement).toBe(confirm());
await dialog.type('parental-lock-pin-confirm', '2468');
expect(dialog.query('parental-lock-pin-mismatch')).toBeNull();
expect(confirm()?.getAttribute('aria-invalid')).toBe('false');
await dialog.pressEnter();
expect(dialog.close).toHaveBeenCalledWith('2468');
});
it('shows the mismatch for a short repeat when Enter is pressed', async () => {
const dialog = await openDialog({ mode: 'set' });
await dialog.type('parental-lock-pin', '2468');
await dialog.type('parental-lock-pin-confirm', '24');
await dialog.pressEnter();
expect(text(dialog.query('parental-lock-pin-mismatch'))).toBe(
KEYS.mismatch
);
expect(dialog.close).not.toHaveBeenCalled();
// Editing the repeat hands the error back to the length rule.
await dialog.type('parental-lock-pin-confirm', '246');
expect(dialog.query('parental-lock-pin-mismatch')).toBeNull();
});
it('moves on to an empty repeat on Enter in the PIN field', async () => {
const dialog = await openDialog({ mode: 'set' });
const enter = new KeyboardEvent('keydown', {
key: 'Enter',
cancelable: true,
});
await dialog.type('parental-lock-pin', '2468');
dialog.query('parental-lock-pin')?.dispatchEvent(enter);
await dialog.fixture.whenStable();
// Handled before the form sees it: no implicit submission.
expect(enter.defaultPrevented).toBe(true);
expect(document.activeElement).toBe(
dialog.query('parental-lock-pin-confirm')
);
expect(dialog.query('parental-lock-pin-mismatch')).toBeNull();
expect(dialog.component.shake()).toBeNull();
});
it('refuses an empty repeat on Save while the PIN field keeps focus', async () => {
// WebKit does not focus a clicked button, so focus says nothing
// about how the form was submitted.
const dialog = await openDialog({ mode: 'set' });
await dialog.type('parental-lock-pin', '2468');
dialog.query('parental-lock-pin')?.focus();
await dialog.pressEnter();
expect(text(dialog.query('parental-lock-pin-mismatch'))).toBe(
KEYS.mismatch
);
expect(dialog.component.shake()).toBe('confirmation');
expect(dialog.close).not.toHaveBeenCalled();
});
it('refuses an empty repeat when Enter is pressed in it', async () => {
const dialog = await openDialog({ mode: 'set' });
await dialog.type('parental-lock-pin', '2468');
dialog.query('parental-lock-pin-confirm')?.focus();
await dialog.pressEnter();
expect(text(dialog.query('parental-lock-pin-mismatch'))).toBe(
KEYS.mismatch
);
expect(dialog.component.shake()).toBe('confirmation');
expect(dialog.close).not.toHaveBeenCalled();
});
it('lets a second refusal shake for its full time', async () => {
const { component } = await openDialog({ mode: 'set' });
jest.useFakeTimers();
try {
component.onPinInput('24');
await component.submit();
expect(component.shake()).toBe('pin');
jest.advanceTimersByTime(300);
component.onPinInput('2468');
component.onConfirmationInput('2469');
await component.submit();
expect(component.shake()).toBe('confirmation');
// The first refusal's timer must not end the second shake.
jest.advanceTimersByTime(150);
expect(component.shake()).toBe('confirmation');
jest.advanceTimersByTime(250);
expect(component.shake()).toBeNull();
} finally {
jest.useRealTimers();
}
});
it('replays the shake when the same field is refused again', async () => {
const dialog = await openDialog({ mode: 'set' });
const field = dialog
.query('parental-lock-pin')
?.closest('mat-form-field');
// jsdom has no Web Animations: stand in for the running shake.
const shake: Partial<CSSAnimation> = {
animationName: '_ngcontent-x_pin-dialog-shake',
currentTime: 250,
};
Object.defineProperty(field, 'getAnimations', {
value: () => [shake],
});
await dialog.type('parental-lock-pin', '24');
await dialog.pressEnter();
expect(shake.currentTime).toBe(250);
await dialog.pressEnter();
expect(dialog.component.shake()).toBe('pin');
expect(shake.currentTime).toBe(0);
});
it('refuses a too-short PIN on Enter with an error on the PIN field', async () => {
const dialog = await openDialog({ mode: 'set' });
const pin = () =>
dialog.query<HTMLInputElement>('parental-lock-pin');
await dialog.type('parental-lock-pin', '24');
await dialog.type('parental-lock-pin-confirm', '24');
await dialog.pressEnter();
expect(dialog.close).not.toHaveBeenCalled();
expect(text(dialog.query('parental-lock-pin-error'))).toBe(
KEYS.hint
);
expect(pin()?.getAttribute('aria-invalid')).toBe('true');
expect(document.activeElement).toBe(pin());
await dialog.type('parental-lock-pin', '246');
expect(dialog.query('parental-lock-pin-error')).toBeNull();
});
});
describe('unlock mode', () => {
it('has no repeat field and leaves autocomplete off', async () => {
const dialog = await openDialog({ mode: 'unlock' });
expect(dialog.query('parental-lock-pin-confirm')).toBeNull();
expect(
dialog.query('parental-lock-pin')?.getAttribute('autocomplete')
).toBe('off');
});
it('marks the PIN field invalid after a wrong PIN and unlocks with the right one', async () => {
const verify = jest.fn(async (pin: string) => pin === '2468');
const dialog = await openDialog({ mode: 'unlock', verify });
const pin = () =>
dialog.query<HTMLInputElement>('parental-lock-pin');
await dialog.type('parental-lock-pin', '1357');
await dialog.pressEnter();
await dialog.fixture.whenStable();
expect(text(dialog.query('parental-lock-pin-error'))).toBe(
KEYS.wrongPin
);
expect(pin()?.getAttribute('aria-invalid')).toBe('true');
expect(dialog.close).not.toHaveBeenCalled();
await dialog.type('parental-lock-pin', '2468');
expect(dialog.query('parental-lock-pin-error')).toBeNull();
await dialog.pressEnter();
expect(dialog.close).toHaveBeenCalledWith('2468');
});
it('does not verify an incomplete PIN', async () => {
const verify = jest.fn(async () => true);
const dialog = await openDialog({ mode: 'unlock', verify });
await dialog.type('parental-lock-pin', '12');
await dialog.pressEnter();
expect(verify).not.toHaveBeenCalled();
expect(text(dialog.query('parental-lock-pin-error'))).toBe(
KEYS.hint
);
});
});
describe('cooldown', () => {
const throttle = createParentalLockPinThrottle();
const verify = jest.fn(async () => false);
it('keeps the cooldown when the prompt is dismissed and opened again', async () => {
const first = (
await openDialog({ mode: 'unlock', verify, throttle })
).component;
for (let i = 0; i < PARENTAL_LOCK_PIN_MAX_ATTEMPTS; i++) {
first.onPinInput('0000');
await first.submit();
}
expect(first.inCooldown()).toBe(true);
expect(verify).toHaveBeenCalledTimes(
PARENTAL_LOCK_PIN_MAX_ATTEMPTS
);
const reopened = (
await openDialog({ mode: 'unlock', verify, throttle })
).component;
expect(reopened.error()).toBe('PARENTAL_LOCK.PIN_DIALOG.COOLDOWN');
reopened.onPinInput('0000');
await reopened.submit();
expect(reopened.inCooldown()).toBe(true);
expect(verify).toHaveBeenCalledTimes(
PARENTAL_LOCK_PIN_MAX_ATTEMPTS
);
});
});
});
@@ -9,6 +9,7 @@ import {
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { ErrorStateMatcher } from '@angular/material/core';
import {
MAT_DIALOG_DATA,
MatDialog,
@@ -29,6 +30,8 @@ import {
export type ParentalLockPinDialogMode = 'unlock' | 'set';
type PinDialogField = 'pin' | 'confirmation';
export interface ParentalLockPinDialogData {
mode: ParentalLockPinDialogMode;
/** Unlock only: whether the typed PIN is the right one. */
@@ -41,12 +44,18 @@ export interface ParentalLockPinDialogData {
throttle?: ParentalLockPinThrottle;
titleKey?: string;
descriptionKey?: string;
/** The flow's verb on the submit button; defaults to the mode's. */
submitKey?: string;
}
/**
* PIN prompt for the parental lock. Pure UI: the caller supplies `verify`
* for the unlock mode and receives the accepted PIN (or `undefined` when
* dismissed) through the dialog result. `set` mode asks for the PIN twice.
*
* Submit stays enabled while the input is incomplete: implicit submission
* (Enter) does nothing on a disabled default button, so an invalid entry
* is refused by `submit()` with an error on the field instead.
*/
@Component({
selector: 'app-parental-lock-pin-dialog',
@@ -70,18 +79,24 @@ export class ParentalLockPinDialogComponent {
);
private readonly pinInput =
viewChild<ElementRef<HTMLInputElement>>('pinInput');
private readonly confirmationInput =
viewChild<ElementRef<HTMLInputElement>>('confirmationInput');
readonly minLength = PARENTAL_LOCK_PIN_MIN_LENGTH;
readonly maxLength = PARENTAL_LOCK_PIN_MAX_LENGTH;
readonly pin = signal('');
readonly confirmation = signal('');
readonly busy = signal(false);
/** Wrong PIN or cooldown, shown on the PIN field. */
readonly error = signal<string | null>(null);
readonly shake = signal(false);
readonly shake = signal<PinDialogField | null>(null);
readonly cooldownUntil = signal(0);
/** A submit was refused for this field; cleared when it is edited. */
private readonly refused = signal<PinDialogField | null>(null);
private readonly throttle =
this.data.throttle ?? createParentalLockPinThrottle();
private cooldownTimer: number | null = null;
private shakeTimer: number | null = null;
readonly isSetMode = computed(() => this.data.mode === 'set');
readonly titleKey = computed(
@@ -98,14 +113,43 @@ export class ParentalLockPinDialogComponent {
? 'PARENTAL_LOCK.PIN_DIALOG.SET_DESCRIPTION'
: 'PARENTAL_LOCK.PIN_DIALOG.UNLOCK_DESCRIPTION')
);
readonly inCooldown = computed(() => this.cooldownUntil() > Date.now());
readonly canSubmit = computed(
readonly submitKey = computed(
() =>
!this.busy() &&
!this.inCooldown() &&
isValidParentalLockPin(this.pin()) &&
(!this.isSetMode() || this.confirmation() === this.pin())
this.data.submitKey ??
(this.isSetMode()
? 'PARENTAL_LOCK.PIN_DIALOG.SAVE'
: 'PARENTAL_LOCK.PIN_DIALOG.UNLOCK')
);
readonly inCooldown = computed(() => this.cooldownUntil() > Date.now());
/** The message under the PIN field, if any. */
readonly pinErrorKey = computed(
() =>
this.error() ??
(this.refused() === 'pin' && !isValidParentalLockPin(this.pin())
? 'PARENTAL_LOCK.PIN_DIALOG.PIN_HINT'
: null)
);
/**
* Set mode: the repeat differs from the PIN. Shown while typing once the
* repeat is as long as the PIN, and at any length after a refused submit.
*/
readonly mismatch = computed(() => {
const confirmation = this.confirmation();
const pin = this.pin();
if (!this.isSetMode() || confirmation === pin) {
return false;
}
return (
this.refused() === 'confirmation' ||
(confirmation.length > 0 && confirmation.length >= pin.length)
);
});
readonly pinErrorState: ErrorStateMatcher = {
isErrorState: () => this.pinErrorKey() !== null,
};
readonly confirmationErrorState: ErrorStateMatcher = {
isErrorState: () => this.mismatch(),
};
constructor() {
// Reopened during a cooldown: the pause carries on where it was.
@@ -132,24 +176,48 @@ export class ParentalLockPinDialogComponent {
onPinInput(value: string): void {
this.pin.set(value.replace(/\D/g, '').slice(0, this.maxLength));
this.error.set(null);
this.clearRefusal('pin');
}
onConfirmationInput(value: string): void {
this.confirmation.set(
value.replace(/\D/g, '').slice(0, this.maxLength)
);
this.error.set(null);
this.clearRefusal('confirmation');
}
/**
* Set mode: Enter after a complete first PIN moves on to an empty repeat
* instead of submitting. Handled on the key, not in `submit()`: focus
* cannot tell Enter from a click on Save, as WebKit does not focus a
* clicked button.
*/
onPinEnter(event: Event): void {
if (
this.isSetMode() &&
!this.confirmation() &&
isValidParentalLockPin(this.pin())
) {
event.preventDefault();
this.confirmationInput()?.nativeElement.focus();
}
}
async submit(): Promise<void> {
if (!this.canSubmit()) {
if (this.isSetMode() && this.confirmation() !== this.pin()) {
this.fail('PARENTAL_LOCK.PIN_DIALOG.MISMATCH');
}
if (this.busy() || this.inCooldown()) {
return;
}
const pin = this.pin();
if (!isValidParentalLockPin(pin)) {
this.error.set(null);
this.refuse('pin');
return;
}
if (this.isSetMode()) {
if (this.confirmation() !== pin) {
this.refuse('confirmation');
return;
}
this.dialogRef.close(pin);
return;
}
@@ -165,10 +233,11 @@ export class ParentalLockPinDialogComponent {
this.pin.set('');
if (this.throttle.recordFailure()) {
this.startCooldown(this.throttle.cooldownUntil());
this.fail('PARENTAL_LOCK.PIN_DIALOG.COOLDOWN');
this.error.set('PARENTAL_LOCK.PIN_DIALOG.COOLDOWN');
} else {
this.fail('PARENTAL_LOCK.PIN_DIALOG.WRONG_PIN');
this.error.set('PARENTAL_LOCK.PIN_DIALOG.WRONG_PIN');
}
this.shakeField('pin');
} finally {
this.busy.set(false);
queueMicrotask(() => this.pinInput()?.nativeElement.focus());
@@ -179,10 +248,48 @@ export class ParentalLockPinDialogComponent {
this.dialogRef.close(undefined);
}
private fail(messageKey: string): void {
this.error.set(messageKey);
this.shake.set(true);
window.setTimeout(() => this.shake.set(false), 400);
/** Shows the field's error, shakes it and moves focus to it. */
private refuse(field: PinDialogField): void {
this.refused.set(field);
this.shakeField(field);
this.fieldInput(field)?.focus();
}
private fieldInput(field: PinDialogField): HTMLInputElement | undefined {
return (field === 'pin' ? this.pinInput() : this.confirmationInput())
?.nativeElement;
}
private clearRefusal(field: PinDialogField): void {
if (this.refused() === field) {
this.refused.set(null);
}
}
private shakeField(field: PinDialogField): void {
// A refusal inside the previous one's 400ms shakes for its own
// full time; the earlier timer would otherwise end it early.
if (this.shakeTimer !== null) {
window.clearTimeout(this.shakeTimer);
}
if (this.shake() === field) {
// Same field again: its class stays on, so the running shake
// would only finish. Rewind it (none under reduced motion).
this.fieldInput(field)
?.closest('mat-form-field')
?.getAnimations?.()
.filter((animation) =>
(animation as CSSAnimation).animationName?.includes(
'pin-dialog-shake'
)
)
.forEach((animation) => (animation.currentTime = 0));
}
this.shake.set(field);
this.shakeTimer = window.setTimeout(() => {
this.shakeTimer = null;
this.shake.set(null);
}, 400);
}
private startCooldown(until: number): void {
+2 -1
View File
@@ -88,7 +88,8 @@
{
"plugin": "@nx/playwright/plugin",
"options": {
"targetName": "e2e"
"targetName": "e2e",
"waitForWebServer": false
}
},
{
+3
View File
@@ -47,6 +47,9 @@
"styles:material-tokens:test": "node --test tools/nx/check-material-token-overrides.test.mjs",
"styles:material-tokens:check": "node tools/nx/check-material-token-overrides.mjs",
"styles:material-tokens:validate": "pnpm run styles:material-tokens:test && pnpm run styles:material-tokens:check",
"e2e:task-graphs:test": "node --test tools/nx/check-e2e-task-graphs.test.mjs",
"e2e:task-graphs:check": "node tools/nx/check-e2e-task-graphs.mjs",
"e2e:task-graphs:validate": "pnpm run e2e:task-graphs:test && pnpm run e2e:task-graphs:check",
"coverage:tools:test": "node --test tools/coverage/coverage-integrity.test.mjs tools/coverage/e2e-shard-reports.test.mjs tools/coverage/coverage-run-pool.test.mjs tools/coverage/unit-coverage-scope.test.mjs",
"coverage:unit:ci": "node tools/coverage/run-tier-a-coverage.mjs",
"coverage:merge": "node tools/coverage/merge-coverage.mjs",
+172
View File
@@ -0,0 +1,172 @@
import { execFileSync } from 'node:child_process';
import { readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* Nx refuses to run a task with `parallelism: false` that depends on a
* continuous task. `@nx/playwright/plugin` sets `parallelism: false` whenever
* a config has a webServer no inferred Nx task covers (our mocks and the web
* backend launch as plain `node` processes), and it infers continuous serve
* dependencies only from servers with `reuseExistingServer`, which the
* configs enable only when `CI` is unset. A target graph can therefore pass
* in CI and still fail locally, so every Playwright target is validated with
* Nx's own task-graph checks under both environments.
*/
const RESULT_PREFIX = 'e2e-task-graphs-result:';
export const MODES = {
local: { CI: undefined },
ci: { CI: 'true' },
};
/**
* The Playwright projects whose targets must be inferred. The check only sees
* targets the plugin tags as Playwright ones, so a target that drops out of
* the inference would otherwise pass unchecked.
*/
export const E2E_PROJECTS = {
'web-e2e': 'apps/web-e2e',
'electron-backend-e2e': 'apps/electron-backend-e2e',
};
/** `e2e` plus one atomized target per spec, as the plugin names them. */
export function expectedPlaywrightTargets(specFilesByProject) {
return Object.entries(specFilesByProject).flatMap(([project, specs]) => [
`${project}:e2e`,
...specs.map((spec) => `${project}:e2e-ci--${spec}`),
]);
}
export function findMissingTargets(projectGraph, expectedTargets) {
const inferred = new Set(
playwrightTargets(projectGraph).map(
({ project, target }) => `${project}:${target}`
)
);
return expectedTargets.filter((task) => !inferred.has(task));
}
function readSpecFiles(workspaceRoot) {
return Object.fromEntries(
Object.entries(E2E_PROJECTS).map(([project, root]) => [
project,
readdirSync(path.join(workspaceRoot, root, 'src'), {
recursive: true,
})
.filter((file) => file.endsWith('.e2e.ts'))
.map((file) => `src/${file.split(path.sep).join('/')}`)
.sort(),
])
);
}
export function playwrightTargets(projectGraph) {
return Object.values(projectGraph.nodes)
.flatMap((node) =>
Object.entries(node.data.targets ?? {})
.filter(([, target]) =>
target.metadata?.technologies?.includes('playwright')
)
.map(([target]) => ({ project: node.name, target }))
)
.sort((a, b) =>
`${a.project}:${a.target}`.localeCompare(`${b.project}:${b.target}`)
);
}
export async function findInvalidTaskGraphs(projectGraph) {
const { createTaskGraph } =
await import('nx/src/tasks-runner/create-task-graph.js');
const { assertTaskGraphDoesNotContainInvalidTargets } =
await import('nx/src/tasks-runner/task-graph-utils.js');
return playwrightTargets(projectGraph).flatMap(({ project, target }) => {
try {
assertTaskGraphDoesNotContainInvalidTargets(
createTaskGraph(
projectGraph,
{},
[project],
[target],
undefined,
{}
)
);
return [];
} catch (error) {
return [{ task: `${project}:${target}`, message: error.message }];
}
});
}
function childEnv(mode) {
const env = { ...process.env, NX_DAEMON: 'false' };
for (const [name, value] of Object.entries(MODES[mode])) {
if (value === undefined) delete env[name];
else env[name] = value;
}
return env;
}
async function checkCurrentEnvironment() {
const { createProjectGraphAsync, workspaceRoot } =
await import('@nx/devkit');
const projectGraph = await createProjectGraphAsync({ exitOnError: true });
const failures = await findInvalidTaskGraphs(projectGraph);
const checked = playwrightTargets(projectGraph).length;
const missing = findMissingTargets(
projectGraph,
expectedPlaywrightTargets(readSpecFiles(workspaceRoot))
);
console.log(
`${RESULT_PREFIX}${JSON.stringify({ checked, failures, missing })}`
);
}
function main() {
let failed = false;
for (const mode of Object.keys(MODES)) {
const output = execFileSync(
process.execPath,
[fileURLToPath(import.meta.url), '--current-environment'],
{
env: childEnv(mode),
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'inherit'],
}
);
const result = output
.split('\n')
.find((line) => line.startsWith(RESULT_PREFIX));
const { checked, failures, missing } = JSON.parse(
result.slice(RESULT_PREFIX.length)
);
for (const task of missing) {
failed = true;
console.error(
`[${mode}] ${task} was not inferred as a Playwright target`
);
}
for (const { task, message } of failures) {
failed = true;
console.error(
`[${mode}] ${task}\n ${message.replace(/\n/g, '\n ')}`
);
}
if (failures.length === 0 && missing.length === 0) {
console.log(
`[${mode}] ${checked} Playwright task graphs are valid`
);
}
}
process.exitCode = failed ? 1 : 0;
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
if (process.argv.includes('--current-environment')) {
await checkCurrentEnvironment();
} else {
main();
}
}
+99
View File
@@ -0,0 +1,99 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
expectedPlaywrightTargets,
findInvalidTaskGraphs,
findMissingTargets,
MODES,
playwrightTargets,
} from './check-e2e-task-graphs.mjs';
const playwright = { technologies: ['playwright'] };
/**
* Mirrors web-e2e as the Playwright plugin infers it without CI: an uncovered
* mock webServer makes `e2e` non-parallel while `pnpm nx run web:serve` adds a
* continuous dependency on the dev server.
*/
function graphWithE2eTarget(e2eTarget) {
const node = (name, targets) => ({
name,
type: 'app',
data: { root: `apps/${name}`, targets },
});
return {
nodes: {
web: node('web', {
serve: {
executor: '@angular/build:dev-server',
continuous: true,
},
}),
'web-e2e': node('web-e2e', {
e2e: {
executor: 'nx:run-commands',
options: { command: 'playwright test' },
metadata: playwright,
...e2eTarget,
},
lint: {
executor: '@nx/eslint:lint',
parallelism: false,
dependsOn: [{ projects: ['web'], target: 'serve' }],
},
}),
},
dependencies: { web: [], 'web-e2e': [] },
};
}
test('checks only Playwright-inferred targets', () => {
const graph = graphWithE2eTarget({ parallelism: false });
assert.deepEqual(playwrightTargets(graph), [
{ project: 'web-e2e', target: 'e2e' },
]);
});
test('reports a non-parallel e2e target that depends on a continuous serve', async () => {
const graph = graphWithE2eTarget({
parallelism: false,
dependsOn: [{ projects: ['web'], target: 'serve' }],
});
const failures = await findInvalidTaskGraphs(graph);
assert.equal(failures.length, 1);
assert.equal(failures[0].task, 'web-e2e:e2e');
assert.match(failures[0].message, /web-e2e:e2e -> web:serve/);
});
test('accepts a non-parallel e2e target whose servers Playwright starts', async () => {
const graph = graphWithE2eTarget({ parallelism: false, dependsOn: [] });
assert.deepEqual(await findInvalidTaskGraphs(graph), []);
});
test('validates both the local and the CI plugin inference', () => {
assert.deepEqual(MODES, { local: { CI: undefined }, ci: { CI: 'true' } });
});
test('expects the e2e target and one atomized target per spec', () => {
assert.deepEqual(
expectedPlaywrightTargets({ 'web-e2e': ['src/basic.e2e.ts'] }),
['web-e2e:e2e', 'web-e2e:e2e-ci--src/basic.e2e.ts']
);
});
test('reports expected targets the plugin no longer infers', () => {
const graph = graphWithE2eTarget({ parallelism: false, dependsOn: [] });
assert.deepEqual(
findMissingTargets(graph, [
'web-e2e:e2e',
'web-e2e:e2e-ci--src/basic.e2e.ts',
]),
['web-e2e:e2e-ci--src/basic.e2e.ts']
);
});