test(e2e): keyboard Tab reaches programmes beyond the rendered EPG range

Greptile flagged that windowing the ribbon could strand keyboard users at
the last rendered block. Focusing a block scrolls it into view, which
re-windows before the next key press; the new test walks ten unrendered
programmes past the range with Tab and with Shift+Tab, and fails if focus
ever leaves the ribbon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-04 21:45:20 +02:00
1 parent ca72d75e86
commit 371f8151a5
2 files changed
+102 -1

No files matched your search

@@ -17,6 +17,7 @@ import {
} from './electron-test-fixtures';
import {
fetchXtreamEpgFixture,
fetchXtreamLiveFixture,
fetchStalkerCategoryFixture,
} from './portal-mock-fixtures';
@@ -463,3 +464,100 @@ test('@epg @stalker @theme @electron applies live themes to the shared Stalker g
await closeElectronApp(app);
}
});
/** Title of the programme block holding keyboard focus, if any. */
async function focusedTimelineBlockTitle(page: Page): Promise<string | null> {
return page.evaluate(() => {
const block = document.activeElement?.closest(
'app-epg-timeline .epg-timeline__block'
);
return (
block
?.querySelector('.epg-timeline__block-title')
?.textContent?.trim() ?? null
);
});
}
/** Two frames: the focus scroll's measure, then the re-rendered blocks. */
async function nextFrames(page: Page): Promise<void> {
await page.evaluate(
() =>
new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve))
)
);
}
test('@epg @xtream @electron reaches programmes beyond the rendered range with Tab', async ({
dataDir,
request,
}) => {
// The ribbon renders only the programmes near the visible range; keyboard
// focus scrolls a block into view, which renders its neighbours before
// the next key press. A generated schedule (240 half-hour slots) is far
// wider than that range.
test.setTimeout(180000);
await resetMockServers(request, ['xtream']);
const credentials = { username: 'minimal', password: 'minimal' };
const fixture = await fetchXtreamLiveFixture(request, credentials);
const app = await launchElectronApp(dataDir);
try {
await app.mainWindow.route('https://test-streams.mux.dev/**', () => {
// Keep the external demo request pending; guide data is local.
});
await addXtreamPortal(app.mainWindow, {
name: 'Xtream Timeline Keyboard',
...credentials,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
const channelRow = channelItemByTitle(
app.mainWindow,
fixture.items[0]?.name ?? ''
).first();
await expect(channelRow).toBeVisible({ timeout: 20000 });
await channelRow.click();
const timeline = app.mainWindow.locator('app-epg-timeline');
const nowBlock = timeline.locator('.epg-timeline__block.is-now');
await expect(nowBlock).toBeVisible({ timeout: 20000 });
const titles = timeline.locator('.epg-timeline__block-title');
const initial = new Set(
(await titles.allInnerTexts()).map((title) => title.trim())
);
expect(initial.size).toBeLessThan(120);
// Walk well past the rendered range in both directions: ten
// programmes that were not in the DOM when the walk started.
for (const key of ['Tab', 'Shift+Tab']) {
// The previous walk moved the window away from now.
await timeline.locator('.epg-timeline__jump').click();
await expect(nowBlock).toBeVisible();
await nowBlock.focus();
const reached = new Set<string>();
for (let press = 0; press < 400 && reached.size < 10; press++) {
await app.mainWindow.keyboard.press(key);
await nextFrames(app.mainWindow);
const inRibbon = await app.mainWindow.evaluate(
() =>
!!document.activeElement?.closest(
'app-epg-timeline .epg-timeline__ribbon'
)
);
expect(inRibbon, `${key} press ${press} left the ribbon`).toBe(
true
);
const title = await focusedTimelineBlockTitle(app.mainWindow);
if (title && !initial.has(title)) {
reached.add(title);
}
}
expect(reached.size, `${key} programmes beyond the range`).toBe(10);
}
} finally {
await closeElectronApp(app);
}
});
+4 -1
View File
@@ -378,7 +378,10 @@ remain local when the meaning is explicit.
them all competed with the stream's first frame (J3 in
[performance journeys](performance-journeys.md#j3-playback-start-playback-to-the-first-frame)).
Tests and features must not assume an off-screen programme is in the DOM:
scroll the ribbon to it first.
scroll the ribbon to it first. Keyboard access must keep working: focusing
a block scrolls it into view, which renders its neighbours before the next
Tab (`epg-timeline-interaction.e2e.ts` walks past the rendered range both
ways).
### Collapsible Live Sidebar