From 43c1ceac1663efdd055d9ed164c87b240812018d Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:44:52 +0200 Subject: [PATCH] test(web-e2e): reach row buttons with Option+Tab on WebKit (#1629) The two "channel scrolling keeps focus after selection" cases (and the Xtream "channel focus and separate scrollbar" cases, which press Tab the same way) failed deterministically on Playwright WebKit while passing on Chromium and Firefox. Playwright's WebKit emulates Safari's default keyboard preference, under which plain Tab visits only text fields and links: from the focused channel pane the key landed on the sidebar search field instead of the first row button. Option+Tab reaches the button and then the favorite action in the same DOM order Chromium's Tab follows, so the app's focus contract (ChannelScrollFocusDirective) is intact and this is Safari's Tab semantics, not an app bug. Add a `pressTab` E2E helper that presses Alt+Tab only on webkit and keeps the literal Tab / Shift+Tab on chromium and firefox, use it at the four Tab presses toward buttons, and note Safari's behaviour in the keyboard scrolling contract. Co-authored-by: Claude Fable 5.1 --- apps/web-e2e/src/e2e-helpers.ts | 26 ++++++++++++++++++++ apps/web-e2e/src/m3u-movie-details.e2e.ts | 7 +++--- apps/web-e2e/src/xtream.e2e.ts | 8 +++--- docs/architecture/iptvnator-ui-guidelines.md | 5 +++- 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/apps/web-e2e/src/e2e-helpers.ts b/apps/web-e2e/src/e2e-helpers.ts index 6f60b8f7c..4331b36e0 100644 --- a/apps/web-e2e/src/e2e-helpers.ts +++ b/apps/web-e2e/src/e2e-helpers.ts @@ -7,6 +7,32 @@ import type { import { expect } from './fixtures'; import sharp from 'sharp'; +/** + * Move keyboard focus to the next (or previous) tabbable element the way a + * user of that browser does. + * + * Safari's default keyboard preference makes plain Tab visit only text fields + * and links; buttons are reached with Option+Tab, or with plain Tab once + * "Press Tab to highlight each item on a webpage" is enabled. Playwright's + * WebKit emulates that default, so a plain `Tab` from a focused channel pane + * lands on the sidebar search field instead of the first row button — the + * app's focus order is intact, Safari simply skips the button. Pressing + * Option+Tab there keeps the assertion about the app's DOM order rather than + * about Safari's preference; Chromium and Firefox treat Alt+Tab as a plain + * Tab, but they get the unmodified key so their run stays a literal user Tab. + */ +export async function pressTab( + page: Page, + browserName: string, + direction: 'forward' | 'backward' = 'forward' +): Promise { + const modifiers = [ + ...(browserName === 'webkit' ? ['Alt'] : []), + ...(direction === 'backward' ? ['Shift'] : []), + ]; + await page.keyboard.press([...modifiers, 'Tab'].join('+')); +} + export async function setInputValue( input: Locator, value: string diff --git a/apps/web-e2e/src/m3u-movie-details.e2e.ts b/apps/web-e2e/src/m3u-movie-details.e2e.ts index 469a220ee..be2a49cd9 100644 --- a/apps/web-e2e/src/m3u-movie-details.e2e.ts +++ b/apps/web-e2e/src/m3u-movie-details.e2e.ts @@ -2,7 +2,7 @@ import type { Page } from '@playwright/test'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { expect, test } from './fixtures'; -import { waitForScrollIdle } from './e2e-helpers'; +import { pressTab, waitForScrollIdle } from './e2e-helpers'; /** * The M3U movie-recognition workflow end to end: a playlist entry whose URL @@ -424,6 +424,7 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({ for (const theme of ['light', 'dark']) { test(`@web @m3u channel scrolling keeps focus after selection (${theme})`, async ({ page, + browserName, }) => { await serveSeekableClip(page); await selectPlayer(page); @@ -452,7 +453,7 @@ for (const theme of ['light', 'dark']) { await expect .poll(() => viewport.evaluate((el) => el.scrollTop)) .toBe(0); - await page.keyboard.press('Tab'); + await pressTab(page, browserName); await expect( viewport.locator('button.channel-content').first() ).toBeFocused(); @@ -461,7 +462,7 @@ for (const theme of ['light', 'dark']) { await expect(viewport.locator('.channel-list-item').nth(1)).toHaveClass( /active/ ); - await page.keyboard.press('Tab'); + await pressTab(page, browserName); const favorite = viewport.locator('.favorite-button').nth(1); await expect(favorite).toBeFocused(); await page.keyboard.press('Space'); diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index 666e025fd..4a227e55f 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -1,6 +1,7 @@ import type { APIRequestContext, Page } from '@playwright/test'; import { expect, test } from './fixtures'; import { + pressTab, rasterizedBorderContrast, setInputValue, surfaceContrast, @@ -978,6 +979,7 @@ for (const theme of ['light', 'dark']) { test(`@xtream navigation: channel focus and separate scrollbar (${theme})`, async ({ page, + browserName, }) => { await addXtreamPortal(page); await page.getByRole('link', { name: 'Live TV', exact: true }).click(); @@ -993,10 +995,10 @@ for (const theme of ['light', 'dark']) { await page.keyboard.press('ArrowRight'); await expect(viewport).toBeFocused(); await expect(page.locator('app-web-player-view')).toHaveCount(0); - await page.keyboard.press('Tab'); + await pressTab(page, browserName); const firstAction = viewport.locator('button.channel-content').first(); await expect(firstAction).toBeFocused(); - await page.keyboard.press('Shift+Tab'); + await pressTab(page, browserName, 'backward'); await expect(viewport).toBeFocused(); const row = viewport.locator('.channel-name').first(); @@ -1021,7 +1023,7 @@ for (const theme of ['light', 'dark']) { await page.keyboard.press('Space'); await expect(page.locator('app-web-player-view')).toBeVisible(); await expect(firstAction).toBeFocused(); - await page.keyboard.press('Tab'); + await pressTab(page, browserName); await expect( viewport.locator('.favorite-button').first() ).toBeFocused(); diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index a07005111..bfb9ce3b8 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -162,7 +162,10 @@ ArrowUp/Down, PageUp/Down, Home/End and Space retain native scrolling there; scroll keys do not bubble into document-level player shortcuts. A row's main button remains separate from favorite/info actions, supports native Enter and Space activation, and retains keyboard focus on activation. Tab/Shift+Tab use -the normal DOM order. Scrolling from a virtual row moves focus to its viewport +the normal DOM order; Safari's default keyboard preference skips buttons on +plain Tab, so there the row button is reached with Option+Tab (WebKit E2E +runs press it through `pressTab` in `apps/web-e2e/src/e2e-helpers.ts`). +Scrolling from a virtual row moves focus to its viewport before CDK can recycle the row; asynchronous data updates never move focus. Xtream aligns a newly selected channel only when it is outside the viewport; updates to the same selected ID never re-align it. A smooth scroll to an