diff --git a/apps/electron-backend-e2e/src/epg-guide.e2e.ts b/apps/electron-backend-e2e/src/epg-guide.e2e.ts new file mode 100644 index 000000000..6e8b84692 --- /dev/null +++ b/apps/electron-backend-e2e/src/epg-guide.e2e.ts @@ -0,0 +1,182 @@ +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 ` + + ${channelName} + + ${title} + Guide E2E programme. + + +`; +} + +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, + }); + + // "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(); + } +});