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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-10-11 14:04:48 +02:00
1 parent dd815676b4
commit 633fde3e0e
4 files changed
+66 -40

No files matched your search

@@ -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<void> {
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);
}
@@ -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'
@@ -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<void>((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,
+8
View File
@@ -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`,