diff --git a/apps/electron-backend-e2e/src/keyboard-scroll.ts b/apps/electron-backend-e2e/src/keyboard-scroll.ts new file mode 100644 index 000000000..0fbc3086b --- /dev/null +++ b/apps/electron-backend-e2e/src/keyboard-scroll.ts @@ -0,0 +1,41 @@ +import { expect, Locator, Page } from '@playwright/test'; + +type ScrollEndProbe = Element & { e2eScrollEnded?: boolean }; + +/** + * Press a key that scrolls `scroller`, then wait for that scroll's `scrollend`. + * + * Chromium drops a keyboard scroll pressed in the frame or two between the + * compositor finishing the previous smooth scroll and Blink handling that + * finish: the key retargets the finished animation, and the late finish + * notification then resets it. The final `scrollTop` arrives before that + * notification, so the next discrete scroll input waits for `scrollend` + * instead. The key must scroll; a key that does not fails here. + */ +export async function pressScrollKey( + page: Page, + scroller: Locator, + key: string +): Promise { + await scroller.evaluate((element) => { + const probe = element as ScrollEndProbe; + probe.e2eScrollEnded = false; + element.addEventListener( + 'scrollend', + () => { + probe.e2eScrollEnded = true; + }, + { once: true } + ); + }); + await page.keyboard.press(key); + await expect + .poll( + () => + scroller.evaluate( + (element) => (element as ScrollEndProbe).e2eScrollEnded + ), + { message: `${key} scroll ended` } + ) + .toBe(true); +} diff --git a/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts b/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts index 2f0cc03f0..0b97a93f3 100644 --- a/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts @@ -11,6 +11,7 @@ import { waitForXtreamCatalog, xtreamMockServer, } from './electron-test-fixtures'; +import { pressScrollKey } from './keyboard-scroll'; import { applyTheme } from './theme-contrast'; const stressPortalName = 'Stress Xtream Portal'; @@ -377,19 +378,16 @@ test('keeps Live TV scrolling, row actions and resizing independently accessible await expect(viewport).toBeFocused(); await viewport.locator('.channel-name').first().click(); await expect(viewport).toBeFocused(); - await page.keyboard.press('PageDown'); - await expect - .poll(() => viewport.evaluate((el) => el.scrollTop)) - .toBeGreaterThan(100); + // Each key's scroll ends before the next input; see pressScrollKey. + await pressScrollKey(page, viewport, 'PageDown'); const position = await viewport.evaluate((el) => el.scrollTop); - await page.keyboard.press('PageDown'); - await expect - .poll(() => viewport.evaluate((el) => el.scrollTop)) - .toBeGreaterThan(position); - await page.keyboard.press('Home'); - await expect - .poll(() => viewport.evaluate((el) => el.scrollTop)) - .toBe(0); + expect(position).toBeGreaterThan(100); + await pressScrollKey(page, viewport, 'PageDown'); + expect(await viewport.evaluate((el) => el.scrollTop)).toBeGreaterThan( + position + ); + await pressScrollKey(page, viewport, 'Home'); + expect(await viewport.evaluate((el) => el.scrollTop)).toBe(0); const before = (await viewport.boundingBox())!; const handle = page.locator( 'app-live-stream-layout .sidebar > .resize-handle' diff --git a/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts b/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts index 97dd918f8..6548067e8 100644 --- a/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts @@ -10,6 +10,7 @@ import { waitForXtreamWorkspaceReady, xtreamMockServer, } from './electron-test-fixtures'; +import { pressScrollKey } from './keyboard-scroll'; import { fetchXtreamVodFixture, getXtreamTitle, @@ -210,34 +211,12 @@ for (const theme of ['light', 'dark']) { (el) => getComputedStyle(el).scrollbarWidth ) ).not.toBe('none'); - await page.keyboard.press('PageDown'); - await expect - .poll(() => shell.evaluate((el) => el.scrollTop)) - .toBeGreaterThan(0); - // PageDown animates on macOS. Sending Home after the first - // changed frame races that animation rather than testing a - // second discrete keyboard action (same fence as web E2E). - await shell.evaluate( - (element) => - new Promise((resolve) => { - let last = element.scrollTop; - let stableFrames = 0; - const frame = () => { - stableFrames = - element.scrollTop === last - ? stableFrames + 1 - : 0; - last = element.scrollTop; - if (stableFrames === 3) resolve(); - else requestAnimationFrame(frame); - }; - requestAnimationFrame(frame); - }) - ); - await page.keyboard.press('Home'); - await expect - .poll(() => shell.evaluate((el) => el.scrollTop)) - .toBe(0); + await pressScrollKey(page, shell, 'PageDown'); + expect( + await shell.evaluate((el) => el.scrollTop) + ).toBeGreaterThan(0); + await pressScrollKey(page, shell, 'Home'); + expect(await shell.evaluate((el) => el.scrollTop)).toBe(0); const box = (await shell.boundingBox())!; await page.mouse.move( box.x + box.width / 2, diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md index 7562aa512..a4c4de08a 100644 --- a/docs/architecture/validation-map.md +++ b/docs/architecture/validation-map.md @@ -225,6 +225,14 @@ looping over `count()` with per-row `nth(index)` reads. Those reads auto-wait, so a row removed mid-loop hangs the predicate until the poll times out instead of letting it retry. +Chromium drops a scroll key pressed in the frame or two after the previous +smooth scroll lands but before Blink handles its finish, so the final +`scrollTop` is not a safe point for the next scroll input. Electron specs press +scroll keys through `pressScrollKey` in +`apps/electron-backend-e2e/src/keyboard-scroll.ts`, which waits for +`scrollend`; web specs call `waitForScrollIdle` from +`apps/web-e2e/src/e2e-helpers.ts` before the next key. + Playwright coverage is measured semantically by tags and critical journeys, not by a source-line percentage. E2E reports should use tags such as `@critical`, `@electron`, `@web`, `@xtream`, `@stalker`, `@m3u`, `@search`, `@epg`,