mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
N left the roving focus on its row while the jump scrolled to the playing channel, so the CDK could recycle the focused row and drop DOM focus to the page, and the next arrow key scrolled all the way back. - N moves the roving focus to the playing row; only the keys that move the focus (arrows) reveal it, so N's smooth jump is never cancelled. - The DOM focus is handed to the roving cell once its row is rendered, and never taken from a control outside the grid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
238 lines
8.9 KiB
TypeScript
238 lines
8.9 KiB
TypeScript
import { Page } from '@playwright/test';
|
|
import {
|
|
buildM3uContent,
|
|
channelItemByTitle,
|
|
closeElectronApp,
|
|
createMutableTextServer,
|
|
expect,
|
|
importM3uPlaylistFromUrl,
|
|
launchElectronApp,
|
|
openWorkspaceSection,
|
|
test,
|
|
} from './electron-test-fixtures';
|
|
|
|
function formatXmltvDate(date: Date): string {
|
|
const pad = (value: number) => String(value).padStart(2, '0');
|
|
return [
|
|
date.getUTCFullYear(),
|
|
pad(date.getUTCMonth() + 1),
|
|
pad(date.getUTCDate()),
|
|
pad(date.getUTCHours()),
|
|
pad(date.getUTCMinutes()),
|
|
pad(date.getUTCSeconds()),
|
|
].join('');
|
|
}
|
|
|
|
function xmltvWithCurrentProgramme(
|
|
channelId: string,
|
|
channelName: string,
|
|
title: string
|
|
): string {
|
|
const start = new Date(Date.now() - 15 * 60 * 1000);
|
|
const stop = new Date(Date.now() + 45 * 60 * 1000);
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<tv>
|
|
<channel id="${channelId}"><display-name>${channelName}</display-name></channel>
|
|
<programme start="${formatXmltvDate(start)} +0000" stop="${formatXmltvDate(stop)} +0000" channel="${channelId}">
|
|
<title>${title}</title>
|
|
<desc>Guide E2E programme.</desc>
|
|
</programme>
|
|
</tv>
|
|
`;
|
|
}
|
|
|
|
/** True when the now-line is painted inside the visible programme lane. */
|
|
function nowLineInLane(page: Page): Promise<boolean> {
|
|
return page.evaluate(() => {
|
|
const lane = document
|
|
.querySelector('.epg-guide__now-clip')
|
|
?.getBoundingClientRect();
|
|
const line = document
|
|
.querySelector('.epg-guide__now-line')
|
|
?.getBoundingClientRect();
|
|
return (
|
|
!!lane &&
|
|
!!line &&
|
|
line.left >= lane.left &&
|
|
line.right <= lane.right
|
|
);
|
|
});
|
|
}
|
|
|
|
/** Scroll the lane to whichever end of the day is farther from now. */
|
|
async function scrollAwayFromNow(page: Page): Promise<void> {
|
|
await page.evaluate(() => {
|
|
const viewport = document.querySelector(
|
|
'.epg-guide__viewport'
|
|
) as HTMLElement;
|
|
const badge = document.querySelector(
|
|
'.epg-guide__now-badge'
|
|
) as HTMLElement;
|
|
const nowLeft = parseFloat(badge.style.left);
|
|
const end = viewport.scrollWidth - viewport.clientWidth;
|
|
viewport.scrollTo({ left: nowLeft > end / 2 ? 0 : end });
|
|
});
|
|
}
|
|
|
|
test('@epg @electron opens the programme guide with the playlist channels, switches channels and keeps the player mounted', async ({
|
|
dataDir,
|
|
}) => {
|
|
test.setTimeout(120000);
|
|
const epgServer = await createMutableTextServer(
|
|
xmltvWithCurrentProgramme(
|
|
'guide-news',
|
|
'Guide News',
|
|
'Guide Bulletin Now'
|
|
),
|
|
{
|
|
contentType: 'application/xml; charset=utf-8',
|
|
resourcePath: '/guide.xml',
|
|
}
|
|
);
|
|
const playlistServer = await createMutableTextServer(
|
|
buildM3uContent([
|
|
{
|
|
name: 'Guide News',
|
|
tvgId: 'guide-news',
|
|
url: 'https://example.com/live/guide-news.m3u8',
|
|
},
|
|
{
|
|
name: 'Guide Silent',
|
|
tvgId: 'guide-silent',
|
|
url: 'https://example.com/live/guide-silent.m3u8',
|
|
},
|
|
]).replace('#EXTM3U', `#EXTM3U x-tvg-url="${epgServer.resourceUrl}"`),
|
|
{
|
|
contentType: 'application/x-mpegurl; charset=utf-8',
|
|
resourcePath: '/guide-playlist.m3u',
|
|
}
|
|
);
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await app.electronApp.evaluate(({ BrowserWindow }) => {
|
|
BrowserWindow.getAllWindows()[0].setSize(1600, 1000);
|
|
});
|
|
// The demo stream URLs are unreachable; the player still mounts.
|
|
await app.mainWindow.route('https://example.com/**', (route) =>
|
|
route.abort()
|
|
);
|
|
|
|
await importM3uPlaylistFromUrl(
|
|
app.mainWindow,
|
|
playlistServer.resourceUrl
|
|
);
|
|
await expect(
|
|
app.mainWindow.locator(
|
|
'.epg-progress-panel .import-item.status-complete'
|
|
)
|
|
).toHaveCount(1, { timeout: 30000 });
|
|
|
|
await openWorkspaceSection(app.mainWindow, 'All channels');
|
|
const newsRow = channelItemByTitle(app.mainWindow, 'Guide News');
|
|
await expect(newsRow).toBeVisible();
|
|
await newsRow.click();
|
|
|
|
const timeline = app.mainWindow.locator('app-epg-timeline');
|
|
await expect(timeline).toBeVisible({ timeout: 20000 });
|
|
await app.mainWindow.evaluate(() =>
|
|
document
|
|
.querySelector('app-web-player-view')
|
|
?.setAttribute('data-e2e-guide-marker', 'kept')
|
|
);
|
|
|
|
// Entry point: the Guide button in the timeline toolbar.
|
|
await timeline.locator('.epg-timeline__guide').click();
|
|
const guide = app.mainWindow.locator('app-epg-guide');
|
|
await expect(guide).toBeVisible();
|
|
// The sidebar stays mounted (its channel list resets the active
|
|
// channel on destroy) and is hidden while the guide owns the layout.
|
|
await expect(app.mainWindow.locator('.sidebar')).toBeHidden();
|
|
await expect(timeline).toHaveCount(0);
|
|
|
|
// Rows are the playlist's channels, in order, with the playing row marked.
|
|
const rows = guide.locator('app-epg-guide-row');
|
|
await expect(rows).toHaveCount(2);
|
|
await expect(rows.nth(0).locator('.epg-guide-row__name b')).toHaveText(
|
|
'Guide News'
|
|
);
|
|
await expect(rows.nth(1).locator('.epg-guide-row__name b')).toHaveText(
|
|
'Guide Silent'
|
|
);
|
|
await expect(rows.nth(0)).toHaveClass(/is-active/);
|
|
await expect(
|
|
rows.nth(0).locator('.epg-guide-row__block.is-now')
|
|
).toContainText('Guide Bulletin Now', { timeout: 20000 });
|
|
await expect(rows.nth(1).locator('.epg-guide-row__empty')).toBeVisible({
|
|
timeout: 20000,
|
|
});
|
|
|
|
// The guide opens on "now", and the Now button and N jump back to it
|
|
// on both axes at once (#1733: the lane stayed at midnight).
|
|
await expect.poll(() => nowLineInLane(app.mainWindow)).toBe(true);
|
|
await scrollAwayFromNow(app.mainWindow);
|
|
await expect.poll(() => nowLineInLane(app.mainWindow)).toBe(false);
|
|
await guide.locator('.guide-toolbar__now').click();
|
|
await expect.poll(() => nowLineInLane(app.mainWindow)).toBe(true);
|
|
|
|
await scrollAwayFromNow(app.mainWindow);
|
|
await expect.poll(() => nowLineInLane(app.mainWindow)).toBe(false);
|
|
// Keys are left alone while a toolbar button holds the focus.
|
|
await app.mainWindow.evaluate(() =>
|
|
(document.activeElement as HTMLElement | null)?.blur()
|
|
);
|
|
await app.mainWindow.keyboard.press('n');
|
|
await expect.poll(() => nowLineInLane(app.mainWindow)).toBe(true);
|
|
// The keyboard focus follows the jump to the playing row.
|
|
await expect(
|
|
rows.nth(0).locator('[data-epg-guide-grid][tabindex="0"]')
|
|
).toBeFocused();
|
|
|
|
// "Only with EPG" hides the silent channel once coverage is known.
|
|
const toggle = guide.locator('.guide-toolbar__toggle input');
|
|
await expect(toggle).toBeEnabled({ timeout: 20000 });
|
|
await guide.locator('.guide-toolbar__toggle').click();
|
|
await expect(rows).toHaveCount(1);
|
|
await guide.locator('.guide-toolbar__toggle').click();
|
|
await expect(rows).toHaveCount(2);
|
|
|
|
// Clicking a channel switches playback without closing the guide.
|
|
await rows.nth(1).locator('.epg-guide-row__channel').click();
|
|
await expect(rows.nth(1)).toHaveClass(/is-active/);
|
|
await expect(rows.nth(0)).not.toHaveClass(/is-active/);
|
|
await expect(guide).toBeVisible();
|
|
await expect(
|
|
app.mainWindow.locator('app-epg-guide-now-playing')
|
|
).toContainText('Guide Silent');
|
|
|
|
// The player element survived both the mode switch and the channel switch.
|
|
await expect(
|
|
app.mainWindow.locator(
|
|
'app-web-player-view[data-e2e-guide-marker="kept"]'
|
|
)
|
|
).toHaveCount(1);
|
|
|
|
// Escape closes the guide and restores the sidebar and timeline.
|
|
await app.mainWindow.keyboard.press('Escape');
|
|
await expect(guide).toHaveCount(0);
|
|
await expect(app.mainWindow.locator('.sidebar')).toBeVisible();
|
|
await expect(app.mainWindow.locator('app-epg-timeline')).toBeVisible();
|
|
await expect(
|
|
app.mainWindow.locator(
|
|
'app-web-player-view[data-e2e-guide-marker="kept"]'
|
|
)
|
|
).toHaveCount(1);
|
|
|
|
// G reopens it; the header shortcut is highlighted while open.
|
|
await app.mainWindow.keyboard.press('g');
|
|
await expect(guide).toBeVisible();
|
|
await expect(
|
|
app.mainWindow.locator('.header-shortcut.is-active')
|
|
).toHaveCount(1);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
await playlistServer.close();
|
|
await epgServer.close();
|
|
}
|
|
});
|