import { Page } from '@playwright/test'; import { addStalkerPortal, addXtreamPortal, channelItemByTitle, clickCategoryById, clickGridListCardByTitle, clickCategoryByNameExact, clickFirstGridListCard, closeElectronApp, contentCardByTitle, defaultXtreamPassword, defaultXtreamUsername, expectPathname, expectVisibleContentCardTitle, expect, importM3uPlaylistFromNativeDialog, launchElectronApp, openPlaylistFavorites, openPlaylistRecent, openWorkspaceSection, openSources, resetMockServers, restartElectronApp, sourceRowByTitle, switchUnifiedCollectionContent, switchUnifiedCollectionScope, test, playlistSwitcherTitle, waitForM3uCatalog, waitForStalkerCatalog, waitForXtreamWorkspaceReady, writeTemporaryM3uFile, } from './electron-test-fixtures'; import { fetchStalkerCategoryFixture, fetchXtreamLiveFixture, fetchXtreamSeriesFixture, fetchXtreamVodFixture, getStalkerTitle, getXtreamTitle, pickDistinctTitles, } from './portal-mock-fixtures'; test.describe('Electron Recently Viewed', () => { test('keeps unified live detail open when re-clicking the active M3U recent item', async ({ dataDir, }) => { const filePath = writeTemporaryM3uFile( dataDir, 'm3u-live-reclick.m3u', [ { groupTitle: 'Live', name: 'Stable Recent Channel', url: 'https://streams.example.test/stable-recent.m3u8', }, ] ); const app = await launchElectronApp(dataDir); try { await importM3uPlaylistFromNativeDialog(app, filePath); await waitForM3uCatalog(app.mainWindow); await channelItemByTitle(app.mainWindow, 'Stable Recent Channel') .first() .click(); await openPlaylistRecent(app.mainWindow); const item = channelItemByTitle( app.mainWindow, 'Stable Recent Channel' ).first(); const livePlayer = app.mainWindow .locator('app-unified-live-tab .content-container .video-player') .first(); await item.click(); await expect(livePlayer).toBeVisible({ timeout: 20000 }); await expect(item).toHaveClass(/(^|\s)active(\s|$)/); await item.click(); await expect(livePlayer).toBeVisible({ timeout: 20000 }); await expect(item).toHaveClass(/(^|\s)active(\s|$)/); } finally { await closeElectronApp(app); } }); test('@persistence @m3u @electron tracks M3U recent channels in newest-first order, supports all-playlists scope, and persists favorites after restart', async ({ dataDir, }) => { const playlistTitle = 'm3u-recent-source.m3u'; const playlistSourceTitle = 'm3u-recent-source'; const filePath = writeTemporaryM3uFile(dataDir, playlistTitle, [ { groupTitle: 'Live', name: 'Recent Channel One', url: 'https://streams.example.test/recent-one.m3u8', }, { groupTitle: 'Live', name: 'Recent Channel Two', url: 'https://streams.example.test/recent-two.m3u8', }, ]); const app = await launchElectronApp(dataDir); try { await importM3uPlaylistFromNativeDialog(app, filePath); await waitForM3uCatalog(app.mainWindow); await channelItemByTitle(app.mainWindow, 'Recent Channel One') .first() .click(); await channelItemByTitle(app.mainWindow, 'Recent Channel Two') .first() .click(); await openPlaylistRecent(app.mainWindow); await expect .poll(() => visibleLiveTitles(app.mainWindow)) .toEqual(['Recent Channel Two', 'Recent Channel One']); await switchUnifiedCollectionScope(app.mainWindow, 'All playlists'); await expect .poll(() => visibleLiveTitles(app.mainWindow)) .toEqual(['Recent Channel Two', 'Recent Channel One']); await toggleFavoriteForChannel(app.mainWindow, 'Recent Channel Two'); await expect .poll(() => visibleLiveTitles(app.mainWindow)) .toEqual(['Recent Channel Two', 'Recent Channel One']); await openPlaylistFavorites(app.mainWindow); await expect( channelItemByTitle(app.mainWindow, 'Recent Channel Two').first() ).toBeVisible({ timeout: 20000 }); const restarted = await restartElectronApp(app, dataDir); app.electronApp = restarted.electronApp; app.mainWindow = restarted.mainWindow; await openSources(app.mainWindow); await sourceRowByTitle(app.mainWindow, playlistSourceTitle) .first() .click(); await waitForM3uCatalog(app.mainWindow); await openPlaylistRecent(app.mainWindow); await switchUnifiedCollectionScope(app.mainWindow, 'All playlists'); await expect .poll(() => visibleLiveTitles(app.mainWindow)) .toEqual(['Recent Channel Two', 'Recent Channel One']); await openPlaylistFavorites(app.mainWindow); await expect( channelItemByTitle(app.mainWindow, 'Recent Channel Two').first() ).toBeVisible({ timeout: 20000 }); } finally { await closeElectronApp(app); } }); test('removes one M3U recent live channel from the all-playlists context menu', async ({ dataDir, }) => { const filePath = writeTemporaryM3uFile( dataDir, 'm3u-recent-context-menu-source.m3u', [ { groupTitle: 'Live', name: 'Context Recent One', url: 'https://streams.example.test/context-recent-one.m3u8', }, { groupTitle: 'Live', name: 'Context Recent Two', url: 'https://streams.example.test/context-recent-two.m3u8', }, ] ); const app = await launchElectronApp(dataDir); try { await importM3uPlaylistFromNativeDialog(app, filePath); await waitForM3uCatalog(app.mainWindow); await channelItemByTitle(app.mainWindow, 'Context Recent One') .first() .click(); await channelItemByTitle(app.mainWindow, 'Context Recent Two') .first() .click(); await openPlaylistRecent(app.mainWindow); await switchUnifiedCollectionScope(app.mainWindow, 'All playlists'); await expect .poll(() => visibleLiveTitles(app.mainWindow)) .toEqual(['Context Recent Two', 'Context Recent One']); await channelItemByTitle(app.mainWindow, 'Context Recent One') .first() .click({ button: 'right' }); await app.mainWindow .getByRole('menuitem', { name: 'Remove from history' }) .click(); await expect .poll(() => visibleLiveTitles(app.mainWindow)) .toEqual(['Context Recent Two']); await expect( channelItemByTitle(app.mainWindow, 'Context Recent One') ).toHaveCount(0); } finally { await closeElectronApp(app); } }); test('@persistence @xtream @electron tracks Xtream live, movie, and series history across playlist and all-playlists scope, persists after restart, and supports clearing', async ({ dataDir, request, }) => { await resetMockServers(request, ['xtream']); const liveFixture = await fetchXtreamLiveFixture( request, xtreamCredentials ); const vodFixture = await fetchXtreamVodFixture( request, xtreamCredentials ); const seriesFixture = await fetchXtreamSeriesFixture( request, xtreamCredentials ); const [liveTitle] = pickDistinctTitles( liveFixture.items, getXtreamTitle ); const portalTitle = 'Xtream Recent Source'; const app = await launchElectronApp(dataDir); try { await addXtreamPortal(app.mainWindow, { name: portalTitle, }); await waitForXtreamWorkspaceReady(app.mainWindow); await openWorkspaceSection(app.mainWindow, 'Live TV'); await clickCategoryByNameExact( app.mainWindow, liveFixture.categoryName ); await toggleFavoriteForChannel(app.mainWindow, liveTitle); await openPlaylistFavorites(app.mainWindow); await channelItemByTitle(app.mainWindow, liveTitle).first().click(); await expectUnifiedLiveDetailOpen(app.mainWindow, liveTitle); await app.mainWindow .getByRole('link', { name: 'Movies', exact: true }) .click(); await clickCategoryByNameExact( app.mainWindow, vodFixture.categoryName ); // Pick from the displayed grid: fixture order ≠ display order (date-desc // sort + pagination), so the first fixture item may not be on page 1. // Use clickFirstGridListCard to atomically read + click the first visible // card, avoiding a race where the grid re-renders between title read and // a separate search-by-title click. const movieTitle = await clickFirstGridListCard(app.mainWindow); await playCurrentDetail(app.mainWindow); await goBackFromDetail(app.mainWindow); await app.mainWindow .getByRole('link', { name: 'Series', exact: true }) .click(); await clickCategoryByNameExact( app.mainWindow, seriesFixture.categoryName ); const seriesTitle = await clickFirstGridListCard(app.mainWindow); await playFirstSeriesEpisode(app.mainWindow); await openPlaylistRecent(app.mainWindow); await expect( channelItemByTitle(app.mainWindow, liveTitle) ).toHaveCount(1); await switchUnifiedCollectionContent(app.mainWindow, 'Movies'); await expectVisibleContentCardTitle(app.mainWindow, movieTitle); await switchUnifiedCollectionContent(app.mainWindow, 'Series'); await expectVisibleContentCardTitle(app.mainWindow, seriesTitle); await switchUnifiedCollectionScope(app.mainWindow, 'All playlists'); await switchUnifiedCollectionContent(app.mainWindow, 'Live TV'); await expect( channelItemByTitle(app.mainWindow, liveTitle) ).toHaveCount(1); await switchUnifiedCollectionContent(app.mainWindow, 'Movies'); await expectVisibleContentCardTitle(app.mainWindow, movieTitle); await switchUnifiedCollectionContent(app.mainWindow, 'Series'); await expectVisibleContentCardTitle(app.mainWindow, seriesTitle); const restarted = await restartElectronApp(app, dataDir); app.electronApp = restarted.electronApp; app.mainWindow = restarted.mainWindow; await openSources(app.mainWindow); await sourceRowByTitle(app.mainWindow, portalTitle).first().click(); await waitForXtreamWorkspaceReady(app.mainWindow); await openPlaylistRecent(app.mainWindow); await switchUnifiedCollectionScope(app.mainWindow, 'All playlists'); await switchUnifiedCollectionContent(app.mainWindow, 'Live TV'); await expect( channelItemByTitle(app.mainWindow, liveTitle) ).toHaveCount(1); await switchUnifiedCollectionContent(app.mainWindow, 'Movies'); await expectVisibleContentCardTitle(app.mainWindow, movieTitle); await switchUnifiedCollectionContent(app.mainWindow, 'Series'); await expectVisibleContentCardTitle(app.mainWindow, seriesTitle); await switchUnifiedCollectionContent(app.mainWindow, 'Live TV'); await clearRecentItems(app.mainWindow, 'Live TV'); await expect( channelItemByTitle(app.mainWindow, liveTitle) ).toHaveCount(0); await expectVisibleContentCardTitle(app.mainWindow, movieTitle); await clearRecentItems(app.mainWindow, 'Movies'); await expect( contentCardByTitle(app.mainWindow, movieTitle) ).toHaveCount(0); await expectVisibleContentCardTitle(app.mainWindow, seriesTitle); await clearRecentItems(app.mainWindow, 'Series'); await expect( contentCardByTitle(app.mainWindow, seriesTitle) ).toHaveCount(0); } finally { await closeElectronApp(app); } }); test('opens Xtream recent movies and series inline from recent without switching the playlist or showing the sidebar', async ({ dataDir, request, }) => { await resetMockServers(request, ['xtream']); const vodFixture = await fetchXtreamVodFixture( request, xtreamCredentials ); const seriesFixture = await fetchXtreamSeriesFixture( request, xtreamCredentials ); const portalTitle = 'Xtream Recent Detail Source'; const app = await launchElectronApp(dataDir); try { await addXtreamPortal(app.mainWindow, { name: portalTitle, }); await waitForXtreamWorkspaceReady(app.mainWindow); await app.mainWindow .getByRole('link', { name: 'Movies', exact: true }) .click(); await clickCategoryByNameExact( app.mainWindow, vodFixture.categoryName ); const movieTitle = await clickFirstGridListCard(app.mainWindow); await playCurrentDetail(app.mainWindow); await goBackFromDetail(app.mainWindow); await app.mainWindow .getByRole('link', { name: 'Series', exact: true }) .click(); await clickCategoryByNameExact( app.mainWindow, seriesFixture.categoryName ); const seriesTitle = await clickFirstGridListCard(app.mainWindow); await playFirstSeriesEpisode(app.mainWindow); await openPlaylistRecent(app.mainWindow); await expect(playlistSwitcherTitle(app.mainWindow)).toContainText( portalTitle ); await switchUnifiedCollectionContent(app.mainWindow, 'Movies'); await expectVisibleContentCardTitle(app.mainWindow, movieTitle); await contentCardByTitle(app.mainWindow, movieTitle) .first() .click(); await expectInlineCollectionDetail(app.mainWindow, { pathname: /\/workspace\/global-recent$/, title: movieTitle, playlistTitle: portalTitle, }); await goBackFromDetail(app.mainWindow); await expectPathname(app.mainWindow, /\/workspace\/global-recent$/); await expectVisibleContentCardTitle(app.mainWindow, movieTitle); await switchUnifiedCollectionContent(app.mainWindow, 'Series'); await expectVisibleContentCardTitle(app.mainWindow, seriesTitle); await contentCardByTitle(app.mainWindow, seriesTitle) .first() .click(); await expectInlineCollectionDetail(app.mainWindow, { pathname: /\/workspace\/global-recent$/, title: seriesTitle, playlistTitle: portalTitle, }); await goBackFromDetail(app.mainWindow); await expectPathname(app.mainWindow, /\/workspace\/global-recent$/); await switchUnifiedCollectionContent(app.mainWindow, 'Series'); await expectVisibleContentCardTitle(app.mainWindow, seriesTitle); } finally { await closeElectronApp(app); } }); test('@persistence @stalker @electron tracks Stalker live, movie, and series history across playlist and all-playlists scope, and preserves it after restart', async ({ dataDir, request, }) => { await resetMockServers(request, ['stalker']); const liveFixture = await fetchStalkerCategoryFixture(request, 'itv'); const vodFixture = await fetchStalkerCategoryFixture(request, 'vod'); const seriesFixture = await fetchStalkerCategoryFixture( request, 'series' ); const [liveTitle] = pickDistinctTitles( liveFixture.items, getStalkerTitle ); const [movieTitle] = pickDistinctTitles( vodFixture.items, getStalkerTitle ); const [seriesTitle] = pickDistinctTitles( seriesFixture.items, getStalkerTitle ); const portalTitle = 'Stalker Recent Source'; const app = await launchElectronApp(dataDir); try { await addStalkerPortal(app.mainWindow, { name: portalTitle, }); await waitForStalkerCatalog(app.mainWindow); await app.mainWindow .getByRole('link', { name: 'Live TV', exact: true }) .click(); await clickCategoryById(app.mainWindow, liveFixture.categoryId); await toggleFavoriteForChannel(app.mainWindow, liveTitle); await openPlaylistFavorites(app.mainWindow); await channelItemByTitle(app.mainWindow, liveTitle).first().click(); await expectUnifiedLiveDetailOpen(app.mainWindow, liveTitle); await app.mainWindow .getByRole('link', { name: 'Movies', exact: true }) .click(); await clickCategoryById(app.mainWindow, vodFixture.categoryId); await clickGridListCardByTitle(app.mainWindow, movieTitle); await playCurrentDetail(app.mainWindow); await goBackFromDetail(app.mainWindow); await app.mainWindow .getByRole('link', { name: 'Series', exact: true }) .click(); await clickCategoryById(app.mainWindow, seriesFixture.categoryId); await clickGridListCardByTitle(app.mainWindow, seriesTitle); await playFirstSeriesEpisode(app.mainWindow); await openPlaylistRecent(app.mainWindow); await expect( channelItemByTitle(app.mainWindow, liveTitle) ).toHaveCount(1); await switchUnifiedCollectionContent(app.mainWindow, 'Movies'); await expect( contentCardByTitle(app.mainWindow, movieTitle) ).toHaveCount(1); await switchUnifiedCollectionContent(app.mainWindow, 'Series'); await expect( contentCardByTitle(app.mainWindow, seriesTitle) ).toHaveCount(1); await switchUnifiedCollectionScope(app.mainWindow, 'All playlists'); await switchUnifiedCollectionContent(app.mainWindow, 'Live TV'); await expect( channelItemByTitle(app.mainWindow, liveTitle) ).toHaveCount(1); await switchUnifiedCollectionContent(app.mainWindow, 'Movies'); await expect( contentCardByTitle(app.mainWindow, movieTitle) ).toHaveCount(1); await switchUnifiedCollectionContent(app.mainWindow, 'Series'); await expect( contentCardByTitle(app.mainWindow, seriesTitle) ).toHaveCount(1); const restarted = await restartElectronApp(app, dataDir); app.electronApp = restarted.electronApp; app.mainWindow = restarted.mainWindow; await openSources(app.mainWindow); await sourceRowByTitle(app.mainWindow, portalTitle).first().click(); await waitForStalkerCatalog(app.mainWindow); await openPlaylistRecent(app.mainWindow); await switchUnifiedCollectionScope(app.mainWindow, 'All playlists'); await switchUnifiedCollectionContent(app.mainWindow, 'Live TV'); await expect( channelItemByTitle(app.mainWindow, liveTitle) ).toHaveCount(1); await switchUnifiedCollectionContent(app.mainWindow, 'Movies'); await expect( contentCardByTitle(app.mainWindow, movieTitle) ).toHaveCount(1); await switchUnifiedCollectionContent(app.mainWindow, 'Series'); await expect( contentCardByTitle(app.mainWindow, seriesTitle) ).toHaveCount(1); } finally { await closeElectronApp(app); } }); test('opens Stalker recent movies and series inline from recent without showing the sidebar', async ({ dataDir, request, }) => { await resetMockServers(request, ['stalker']); const vodFixture = await fetchStalkerCategoryFixture(request, 'vod'); const seriesFixture = await fetchStalkerCategoryFixture( request, 'series' ); const [movieTitle] = pickDistinctTitles( vodFixture.items, getStalkerTitle ); const [seriesTitle] = pickDistinctTitles( seriesFixture.items, getStalkerTitle ); const portalTitle = 'Stalker Recent Detail Source'; const app = await launchElectronApp(dataDir); try { await addStalkerPortal(app.mainWindow, { name: portalTitle, }); await waitForStalkerCatalog(app.mainWindow); await app.mainWindow .getByRole('link', { name: 'Movies', exact: true }) .click(); await clickCategoryById(app.mainWindow, vodFixture.categoryId); await clickGridListCardByTitle(app.mainWindow, movieTitle); await playCurrentDetail(app.mainWindow); await goBackFromDetail(app.mainWindow); await app.mainWindow .getByRole('link', { name: 'Series', exact: true }) .click(); await clickCategoryById(app.mainWindow, seriesFixture.categoryId); await clickGridListCardByTitle(app.mainWindow, seriesTitle); await playFirstSeriesEpisode(app.mainWindow); await openPlaylistRecent(app.mainWindow); await expect(playlistSwitcherTitle(app.mainWindow)).toContainText( portalTitle ); await switchUnifiedCollectionContent(app.mainWindow, 'Movies'); await expectVisibleContentCardTitle(app.mainWindow, movieTitle); await contentCardByTitle(app.mainWindow, movieTitle) .first() .click(); await expectInlineCollectionDetail(app.mainWindow, { pathname: /\/workspace\/global-recent$/, title: movieTitle, playlistTitle: portalTitle, }); await playCurrentDetail(app.mainWindow); await expectInlinePlayerWithoutDialog(app.mainWindow); await goBackFromDetail(app.mainWindow); await expectPathname(app.mainWindow, /\/workspace\/global-recent$/); await expectVisibleContentCardTitle(app.mainWindow, movieTitle); await switchUnifiedCollectionContent(app.mainWindow, 'Series'); await expectVisibleContentCardTitle(app.mainWindow, seriesTitle); await contentCardByTitle(app.mainWindow, seriesTitle) .first() .click(); await expectInlineCollectionDetail(app.mainWindow, { pathname: /\/workspace\/global-recent$/, title: seriesTitle, playlistTitle: portalTitle, }); await playFirstSeriesEpisode(app.mainWindow); await expectInlinePlayerWithoutDialog(app.mainWindow); await goBackFromDetail(app.mainWindow); await expectPathname(app.mainWindow, /\/workspace\/global-recent$/); await switchUnifiedCollectionContent(app.mainWindow, 'Series'); await expectVisibleContentCardTitle(app.mainWindow, seriesTitle); } finally { await closeElectronApp(app); } }); }); const xtreamCredentials = { username: defaultXtreamUsername, password: defaultXtreamPassword, }; async function clearRecentItems(page: Page, typeLabel: string): Promise { await page .getByRole('button', { name: `Clear recently viewed ${typeLabel}` }) .click(); await page.getByRole('button', { name: 'Yes' }).click(); } async function expectUnifiedLiveDetailOpen( page: Page, title: string ): Promise { await expect( page .locator('app-unified-live-tab .content-container .video-player') .first() ).toBeVisible({ timeout: 20000 }); await expect(channelItemByTitle(page, title).first()).toHaveClass( /(^|\s)active(\s|$)/ ); } async function goBackFromDetail(page: Page): Promise { const backButton = page .locator('app-content-hero .hero__back-button') .first(); await expect(backButton).toBeVisible({ timeout: 20000 }); try { await backButton.click({ timeout: 5000 }); } catch { await backButton.evaluate((button: HTMLButtonElement) => button.click() ); } } async function expectInlineCollectionDetail( page: Page, params: { pathname: RegExp; title: string; playlistTitle: string; } ): Promise { await expectPathname(page, params.pathname); await expect(playlistSwitcherTitle(page)).toContainText( params.playlistTitle ); await expect(page.locator('app-workspace-context-panel')).toHaveCount(0); await expect(page.locator('app-content-hero')).toContainText(params.title); await expect( page.locator('app-content-hero .hero__back-button').first() ).toBeVisible({ timeout: 20000 }); } async function expectInlinePlayerWithoutDialog(page: Page): Promise { await expect( page.locator('app-portal-inline-player app-web-player-view').first() ).toBeVisible({ timeout: 20000 }); await expect( page.locator('mat-dialog-container app-web-player-view') ).toHaveCount(0); } async function playCurrentDetail(page: Page): Promise { const playButton = page.locator('button.play-btn').first(); await expect(playButton).toBeVisible({ timeout: 20000 }); await playButton.click(); } async function playFirstSeriesEpisode(page: Page): Promise { const seasonCard = page.locator('.season-card').first(); const episodeCard = page .locator('.episode-card, .episode-list-item') .first(); await expect .poll( async () => (await seasonCard.count()) + (await episodeCard.count()), { timeout: 20000 } ) .toBeGreaterThan(0); if ((await seasonCard.count()) > 0) { await seasonCard.scrollIntoViewIfNeeded(); await expect(seasonCard).toBeVisible({ timeout: 20000 }); await seasonCard.click(); } await episodeCard.scrollIntoViewIfNeeded(); await expect(episodeCard).toBeVisible({ timeout: 20000 }); await episodeCard.click(); } async function toggleFavoriteForChannel( page: Page, title: string ): Promise { const item = channelItemByTitle(page, title).first(); await expect(item).toBeVisible({ timeout: 20000 }); await item.hover(); await item.locator('.favorite-button').first().click(); await expect(item.locator('.favorite-button mat-icon').first()).toHaveText( 'star' ); } async function visibleLiveTitles(page: Page): Promise { return page .locator('[data-test-id="channel-item"] .channel-name') .allInnerTexts() .then((titles) => titles.map((title) => title.trim())); }