From 633fde3e0e927c6daa7ef83db24f700452999250 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:04:48 +0200 Subject: [PATCH] test(e2e): wait for scrollend between scroll keys (#1893) Chromium drops a keyboard scroll pressed in the frame or two after the previous smooth scroll lands but before Blink handles the compositor's finish: ScrollAnimator retargets the finished curve (kRunningOnCompositorButNeedsUpdate), then the late CompositorAnimationFinished treats that state as finished and resets it. The Live TV responsiveness spec pressed the next key as soon as an expect.poll saw scrollTop change, so a poll that sampled the final frame sent PageDown or Home into that window (2 of 5 local runs on master, all three CI attempts on PR #1886). pressScrollKey presses a scroll key and waits for that scroll's scrollend. The Live TV spec uses it for PageDown, PageDown and Home with the same position assertions; the VOD detail spec swaps its stable-frame fence for it. The validation map documents the race. Co-authored-by: Claude Opus 5.5 --- .../src/keyboard-scroll.ts | 41 +++++++++++++++++++ .../src/xtream-responsiveness.e2e.ts | 22 +++++----- .../src/xtream-vod-details.e2e.ts | 35 ++++------------ docs/architecture/validation-map.md | 8 ++++ 4 files changed, 66 insertions(+), 40 deletions(-) create mode 100644 apps/electron-backend-e2e/src/keyboard-scroll.ts 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`,