import { Page } from '@playwright/test'; import { addXtreamPortal, channelItemByTitle, clickCategoryByNameExact, clickFirstGridListCard, closeElectronApp, expectVisibleContentCardTitle, defaultXtreamPassword, defaultXtreamUsername, expectPathname, expect, goToDashboard, launchElectronApp, openWorkspaceSection, resetMockServers, test, waitForXtreamWorkspaceReady, } from './electron-test-fixtures'; import { fetchXtreamEpgFixture, fetchXtreamLiveFixture, fetchXtreamSeriesFixture, fetchXtreamVodFixture, getXtreamTitle, pickDistinctTitles, } from './portal-mock-fixtures'; test.describe('Dashboard Activation', () => { test('opens live favorites in the collection route and movies/series in global collection detail views from the dashboard', 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 ); // The mock's guide for the channel favourited below (its first live // stream). The programme on air is read off the full guide at // assertion time: the mock cuts its slots from the second the guide // was generated, so the "current" listing of a fixture fetched in // that same second is the slot that ended just then. const epgFixture = await fetchXtreamEpgFixture( request, xtreamCredentials ); expect(getXtreamTitle(epgFixture.stream)).toBe(liveTitle); const liveNowTitle = () => { const nowSeconds = Math.floor(Date.now() / 1000); const index = epgFixture.fullEpg.findIndex( (listing) => listing.startTimestamp <= nowSeconds && nowSeconds < listing.stopTimestamp ); expect(index).toBeGreaterThanOrEqual(0); // Tolerate a slot boundary passing between the app's answer and // this assertion. const titles = epgFixture.fullEpg .slice(index, index + 2) .map((listing) => listing.title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') ); return new RegExp(titles.join('|')); }; const app = await launchElectronApp(dataDir); try { await addXtreamPortal(app.mainWindow, { name: 'Xtream Dashboard Activation Source', }); await waitForXtreamWorkspaceReady(app.mainWindow); await openWorkspaceSection(app.mainWindow, 'Live TV'); await clickCategoryByNameExact( app.mainWindow, liveFixture.categoryName ); await toggleFavoriteForChannel(app.mainWindow, liveTitle); await app.mainWindow .getByRole('link', { name: 'Movies', exact: true }) .click(); await clickCategoryByNameExact( app.mainWindow, vodFixture.categoryName ); const movieTitle = await clickFirstGridListCard(app.mainWindow); await addCurrentDetailToFavorites(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 addCurrentDetailToFavorites(app.mainWindow); await playFirstSeriesEpisode(app.mainWindow); await goToDashboard(app.mainWindow); // Favorited movies/series are shown as cover cards in their own // rail. The rail's "See all" opens Global Favorites directly on // Movies instead of defaulting back to Live TV. await expectDashboardRail( app.mainWindow, 'dashboard-favorite-vod-rail' ); await app.mainWindow .getByTestId('dashboard-favorite-vod-rail-manage-all') .click(); await expectPathname( app.mainWindow, /\/workspace\/global-favorites$/ ); await expectVisibleContentCardTitle(app.mainWindow, movieTitle); await app.mainWindow.goBack(); await expectPathname(app.mainWindow, /\/workspace\/dashboard$/); await dashboardRailCardByTitle( app.mainWindow, 'dashboard-favorite-vod-rail', movieTitle ).click(); await expectInlineCollectionDetail(app.mainWindow, { pathname: /\/workspace\/global-favorites$/, title: movieTitle, }); await app.mainWindow.goBack(); await expectPathname(app.mainWindow, /\/workspace\/dashboard$/); await dashboardRailCardByTitle( app.mainWindow, 'dashboard-favorite-vod-rail', seriesTitle ).click(); await expectInlineCollectionDetail(app.mainWindow, { pathname: /\/workspace\/global-favorites$/, title: seriesTitle, }); await app.mainWindow.goBack(); await expectPathname(app.mainWindow, /\/workspace\/dashboard$/); // Live favorites no longer fall back to recent live history. // Clicking a favorited live card still opens the Xtream favorites // collection with the channel playing. await expectDashboardRail( app.mainWindow, 'dashboard-live-favorites-rail' ); // An Xtream card has no XMLTV key: its "now on air" line comes // from the portal, asked for lazily once the card is on screen. await expect( dashboardRailCardByTitle( app.mainWindow, 'dashboard-live-favorites-rail', liveTitle ).locator('.rail__channel-now') ).toContainText(liveNowTitle(), { timeout: 30000 }); await dashboardRailCardByTitle( app.mainWindow, 'dashboard-live-favorites-rail', liveTitle ).click(); await app.mainWindow.waitForURL( /\/workspace\/xtreams\/[^/]+\/favorites$/ ); await expect( app.mainWindow .locator( 'app-unified-live-tab .content-container .video-player' ) .first() ).toBeVisible({ timeout: 20000 }); await expect( channelItemByTitle(app.mainWindow, liveTitle).first() ).toHaveClass(/(^|\s)active(\s|$)/); await app.mainWindow.goBack(); await app.mainWindow.waitForURL(/\/workspace\/dashboard$/); // Once the live card has been played, the separate recently // watched live rail appears with the same channel layout. await expectDashboardRail( app.mainWindow, 'dashboard-recent-live-rail' ); // Same channel, same key: the recent card shares the answer. await expect( dashboardRailCardByTitle( app.mainWindow, 'dashboard-recent-live-rail', liveTitle ).locator('.rail__channel-now') ).toContainText(liveNowTitle(), { timeout: 30000 }); await dashboardRailCardByTitle( app.mainWindow, 'dashboard-recent-live-rail', liveTitle ).click(); await expectPathname( app.mainWindow, /\/workspace\/xtreams\/[^/]+\/recent$/ ); await expect( channelItemByTitle(app.mainWindow, liveTitle).first() ).toBeVisible({ timeout: 20000 }); await app.mainWindow.goBack(); await expectPathname(app.mainWindow, /\/workspace\/dashboard$/); // Movies and series that were played land in the Continue // Watching rail (formerly "recently-watched"); clicking a card // opens it inline in the global-recent collection detail view. await expectDashboardRail( app.mainWindow, 'dashboard-continue-watching-rail' ); await dashboardRailCardByTitle( app.mainWindow, 'dashboard-continue-watching-rail', movieTitle ).click(); await expectInlineCollectionDetail(app.mainWindow, { pathname: /\/workspace\/global-recent$/, title: movieTitle, }); await app.mainWindow.goBack(); await expectPathname(app.mainWindow, /\/workspace\/dashboard$/); await dashboardRailCardByTitle( app.mainWindow, 'dashboard-continue-watching-rail', seriesTitle ).click(); await expectInlineCollectionDetail(app.mainWindow, { pathname: /\/workspace\/global-recent$/, title: seriesTitle, }); await goBackFromDetail(app.mainWindow); await expectPathname(app.mainWindow, /\/workspace\/dashboard$/); } finally { await closeElectronApp(app); } }); }); const xtreamCredentials = { username: defaultXtreamUsername, password: defaultXtreamPassword, }; async function expectDashboardRail( page: Page, railTestId: string ): Promise { await expect(page.locator(`[data-test-id="${railTestId}"]`)).toBeVisible({ timeout: 20000, }); } function dashboardRailCardByTitle( page: Page, railTestId: string, title: string ) { return page .locator(`[data-test-id="${railTestId}-card"]`) .filter({ hasText: title, }) .first(); } async function goBackFromDetail(page: Page): Promise { // Return to the list: the shell's sticky Back is route-level in browse // and watch alike (closing the player is the bar's own Close button). const backButton = page .locator('app-portal-detail-shell') .first() .getByRole('button', { name: 'Back', exact: true }); await expect(backButton).toBeVisible({ timeout: 20000 }); try { await backButton.click({ timeout: 5000 }); } catch { await backButton.evaluate((button: HTMLButtonElement) => button.click() ); } } // By accessible name, not class: the Xtream movie detail's favorite control is // an icon-only button that carries its label in aria-label, while series and // Stalker details still use the labeled variant. This matches both. async function addCurrentDetailToFavorites(page: Page): Promise { const addButton = page .getByRole('button', { name: /add to favorites/i }) .first(); await expect(addButton).toBeVisible({ timeout: 20000 }); await addButton.click(); await expect( page.getByRole('button', { name: /remove from favorites/i }).first() ).toBeVisible({ timeout: 20000, }); } async function expectInlineCollectionDetail( page: Page, params: { pathname: RegExp; title: string; } ): Promise { await expectPathname(page, params.pathname); 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-portal-detail-shell .shell__back-button').first() ).toBeVisible({ timeout: 20000 }); } 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 = page .locator('[data-test-id="channel-item"]') .filter({ hasText: 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/ ); }