From 26fa697afeb34db6b179a6ea14034e9ec0a48bb1 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Apr 2026 10:09:27 +0200 Subject: [PATCH] test(e2e): add comprehensive tests for dashboard activation, favorites, recent items, and settings behavior Entire-Checkpoint: 02062a64b591 --- .../src/dashboard-activation.e2e.ts | 179 ++++++++++++++++++ .../electron-backend-e2e/src/favorites.e2e.ts | 64 +++++++ apps/electron-backend-e2e/src/recent.e2e.ts | 111 +++++++++++ apps/electron-backend-e2e/src/settings.e2e.ts | 118 ++++++++++++ apps/electron-backend-e2e/src/sources.e2e.ts | 8 + .../src/xtream-responsiveness.e2e.ts | 138 ++++++++++++++ 6 files changed, 618 insertions(+) create mode 100644 apps/electron-backend-e2e/src/dashboard-activation.e2e.ts diff --git a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts new file mode 100644 index 000000000..1497d016a --- /dev/null +++ b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts @@ -0,0 +1,179 @@ +import { Page } from '@playwright/test'; +import { + addXtreamPortal, + clickCategoryByNameExact, + clickFirstGridListCard, + closeElectronApp, + defaultXtreamPassword, + defaultXtreamUsername, + expect, + goToDashboard, + launchElectronApp, + openWorkspaceSection, + resetMockServers, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; +import { + 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 source detail routes 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); + 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 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 goToDashboard(app.mainWindow); + + await dashboardActivityItemByTitle( + app.mainWindow, + 'Global Favorites', + liveTitle + ).click(); + await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/favorites$/); + await expect( + app.mainWindow.locator('.player-toolbar .close-btn').first() + ).toBeVisible({ timeout: 20000 }); + + await app.mainWindow.goBack(); + await app.mainWindow.waitForURL(/\/workspace\/dashboard$/); + + await dashboardActivityItemByTitle( + app.mainWindow, + 'Recently Watched', + movieTitle + ).click(); + await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/); + await expect(app.mainWindow.locator('app-content-hero')).toContainText( + movieTitle + ); + + await app.mainWindow.goBack(); + await app.mainWindow.waitForURL(/\/workspace\/dashboard$/); + + await dashboardActivityItemByTitle( + app.mainWindow, + 'Recently Watched', + seriesTitle + ).click(); + await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/); + await expect(app.mainWindow.locator('app-content-hero')).toContainText( + seriesTitle + ); + } finally { + await closeElectronApp(app); + } + }); +}); + +const xtreamCredentials = { + username: defaultXtreamUsername, + password: defaultXtreamPassword, +}; + +function dashboardActivityItemByTitle( + page: Page, + widgetTitle: string, + title: string +) { + return page + .locator('.widget-slot') + .filter({ hasText: widgetTitle }) + .first() + .locator('.activity-item') + .filter({ hasText: title }) + .first(); +} + +async function goBackFromDetail(page: Page): Promise { + const backButton = page.locator('app-content-hero .hero__back-button').first(); + + await expect(backButton).toBeVisible({ timeout: 20000 }); + await backButton.click(); +} + +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/); +} diff --git a/apps/electron-backend-e2e/src/favorites.e2e.ts b/apps/electron-backend-e2e/src/favorites.e2e.ts index aacbdceff..ff46bb0d8 100644 --- a/apps/electron-backend-e2e/src/favorites.e2e.ts +++ b/apps/electron-backend-e2e/src/favorites.e2e.ts @@ -279,6 +279,70 @@ test.describe('Electron Favorites', () => { await closeElectronApp(app); } }); + + test('opens Stalker favorite movies and series in source detail routes and returns back to favorites', 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 app = await launchElectronApp(dataDir); + + try { + await addStalkerPortal(app.mainWindow, { + name: 'Stalker Favorite Detail Source', + }); + 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 addCurrentDetailToFavorites(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 addCurrentDetailToFavorites(app.mainWindow); + await goBackFromDetail(app.mainWindow); + + await openPlaylistFavorites(app.mainWindow); + + await switchUnifiedCollectionContent(app.mainWindow, 'Movies'); + await expectVisibleContentCardTitle(app.mainWindow, movieTitle); + await contentCardByTitle(app.mainWindow, movieTitle).first().click(); + await app.mainWindow.waitForURL(/\/workspace\/stalker\/[^/]+\/vod\/[^/]+$/); + await expect(app.mainWindow.locator('app-content-hero')).toContainText( + movieTitle + ); + + await goBackFromDetail(app.mainWindow); + await expect + .poll(() => new URL(app.mainWindow.url()).pathname) + .toMatch(/\/favorites$/); + await expectVisibleContentCardTitle(app.mainWindow, movieTitle); + + await switchUnifiedCollectionContent(app.mainWindow, 'Series'); + await expectVisibleContentCardTitle(app.mainWindow, seriesTitle); + await contentCardByTitle(app.mainWindow, seriesTitle).first().click(); + await app.mainWindow.waitForURL(/\/workspace\/stalker\/[^/]+\/series\/[^/]+$/); + await expect(app.mainWindow.locator('app-content-hero')).toContainText( + seriesTitle + ); + + await goBackFromDetail(app.mainWindow); + await expect + .poll(() => new URL(app.mainWindow.url()).pathname) + .toMatch(/\/favorites$/); + await switchUnifiedCollectionContent(app.mainWindow, 'Series'); + await expectVisibleContentCardTitle(app.mainWindow, seriesTitle); + } finally { + await closeElectronApp(app); + } + }); }); const xtreamCredentials = { diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts index 2bae352ed..8b40a5fa0 100644 --- a/apps/electron-backend-e2e/src/recent.e2e.ts +++ b/apps/electron-backend-e2e/src/recent.e2e.ts @@ -41,6 +41,46 @@ import { } 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 closeButton = app.mainWindow + .locator('.player-toolbar .close-btn') + .first(); + + await item.click(); + await expect(closeButton).toBeVisible({ timeout: 20000 }); + + await item.click(); + await expect(closeButton).toBeVisible({ timeout: 20000 }); + } finally { + await closeElectronApp(app); + } + }); + test('tracks M3U recent channels in newest-first order, supports all-playlists scope, and persists removals after restart', async ({ dataDir, }) => { @@ -201,6 +241,77 @@ test.describe('Electron Recently Viewed', () => { } }); + test('opens Xtream recent movies and series in source detail routes and returns back to recent', 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 switchUnifiedCollectionContent(app.mainWindow, 'Movies'); + await expectVisibleContentCardTitle(app.mainWindow, movieTitle); + await contentCardByTitle(app.mainWindow, movieTitle).first().click(); + await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/); + await expect(app.mainWindow.locator('app-content-hero')).toContainText( + movieTitle + ); + + await goBackFromDetail(app.mainWindow); + await expect + .poll(() => new URL(app.mainWindow.url()).pathname) + .toMatch(/\/recent$/); + await expectVisibleContentCardTitle(app.mainWindow, movieTitle); + + await switchUnifiedCollectionContent(app.mainWindow, 'Series'); + await expectVisibleContentCardTitle(app.mainWindow, seriesTitle); + await contentCardByTitle(app.mainWindow, seriesTitle).first().click(); + await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/); + await expect(app.mainWindow.locator('app-content-hero')).toContainText( + seriesTitle + ); + + await goBackFromDetail(app.mainWindow); + await expect + .poll(() => new URL(app.mainWindow.url()).pathname) + .toMatch(/\/recent$/); + await switchUnifiedCollectionContent(app.mainWindow, 'Series'); + await expectVisibleContentCardTitle(app.mainWindow, seriesTitle); + } finally { + await closeElectronApp(app); + } + }); + test('tracks Stalker live, movie, and series history across playlist and all-playlists scope, and preserves it after restart', async ({ dataDir, request, diff --git a/apps/electron-backend-e2e/src/settings.e2e.ts b/apps/electron-backend-e2e/src/settings.e2e.ts index 47233cff5..2b4fbda65 100644 --- a/apps/electron-backend-e2e/src/settings.e2e.ts +++ b/apps/electron-backend-e2e/src/settings.e2e.ts @@ -1,14 +1,27 @@ import { Page } from '@playwright/test'; import { + addXtreamPortal, + clickCategoryByNameExact, + clickFirstGridListCard, closeElectronApp, createMutableTextServer, enableRemoteControl, expect, launchElectronApp, + openGlobalRecent, openSettings, + openWorkspaceSection, + resetMockServers, + restartElectronApp, saveSettings, test, + waitForXtreamWorkspaceReady, } from './electron-test-fixtures'; +import { + defaultXtreamPassword, + defaultXtreamUsername, +} from './electron-test-fixtures'; +import { fetchXtreamVodFixture } from './portal-mock-fixtures'; const epgFixtureXml = ` @@ -108,6 +121,111 @@ test.describe('Electron Settings', () => { await epgServer.close(); } }); + + test('starts on sources when dashboard is disabled', async ({ dataDir }) => { + const firstLaunch = await launchElectronApp(dataDir); + + try { + await openSettings(firstLaunch.mainWindow); + await firstLaunch.mainWindow + .locator( + 'mat-checkbox[formcontrolname="showDashboard"] input[type="checkbox"]' + ) + .uncheck(); + await saveSettings(firstLaunch.mainWindow); + } finally { + await closeElectronApp(firstLaunch); + } + + const secondLaunch = await launchElectronApp(dataDir); + + try { + await secondLaunch.mainWindow.waitForURL(/\/workspace\/sources$/); + await expect( + secondLaunch.mainWindow.getByRole('link', { + name: 'Dashboard', + exact: true, + }) + ).toHaveCount(0); + await expect(secondLaunch.mainWindow.locator('a.brand')).toHaveAttribute( + 'href', + /\/workspace\/sources$/ + ); + } finally { + await closeElectronApp(secondLaunch); + } + }); + + test('restores the last section-level view across restart when configured', async ({ + dataDir, + }) => { + let app = await launchElectronApp(dataDir); + + try { + await openSettings(app.mainWindow); + await selectSettingsOption( + app.mainWindow, + 'select-startup-behavior', + 'restore-last-view' + ); + await saveSettings(app.mainWindow); + + await openGlobalRecent(app.mainWindow); + } finally { + app = await restartElectronApp(app, dataDir); + } + + try { + await app.mainWindow.waitForURL(/\/workspace\/global-recent$/); + } finally { + await closeElectronApp(app); + } + }); + + test('ignores settings and restores only the section root after a detail route', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const vodFixture = await fetchXtreamVodFixture(request, { + password: defaultXtreamPassword, + username: defaultXtreamUsername, + }); + let app = await launchElectronApp(dataDir); + + try { + await openSettings(app.mainWindow); + await selectSettingsOption( + app.mainWindow, + 'select-startup-behavior', + 'restore-last-view' + ); + await saveSettings(app.mainWindow); + + await addXtreamPortal(app.mainWindow); + await waitForXtreamWorkspaceReady(app.mainWindow); + + await openWorkspaceSection(app.mainWindow, 'Movies'); + await clickCategoryByNameExact( + app.mainWindow, + vodFixture.categoryName + ); + await clickFirstGridListCard(app.mainWindow); + await app.mainWindow.waitForURL( + /\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/ + ); + + await openSettings(app.mainWindow); + } finally { + app = await restartElectronApp(app, dataDir); + } + + try { + await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod$/); + } finally { + await closeElectronApp(app); + } + }); }); async function selectSettingsOption( diff --git a/apps/electron-backend-e2e/src/sources.e2e.ts b/apps/electron-backend-e2e/src/sources.e2e.ts index c95276a0c..7d91c0907 100644 --- a/apps/electron-backend-e2e/src/sources.e2e.ts +++ b/apps/electron-backend-e2e/src/sources.e2e.ts @@ -26,6 +26,7 @@ import { waitForM3uCatalog, waitForPortalDebugEvent, waitForXtreamCatalog, + waitForSourceRowIdle, writeTemporaryM3uFile, } from './electron-test-fixtures'; @@ -447,6 +448,13 @@ https://streams.example.test/refreshed-url.m3u8 provider: 'xtream', timeoutMs: 30000, }); + await openSources(app.mainWindow); + await waitForSourceRowIdle(app.mainWindow, 'Refresh Xtream Source'); + await expect( + sourceRowByTitle(app.mainWindow, 'Refresh Xtream Source') + .first() + .locator('.meta') + ).toContainText('Updated:'); } finally { await closeElectronApp(app); await urlServer.close(); diff --git a/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts b/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts index 66f452690..fd621e65c 100644 --- a/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts @@ -9,6 +9,7 @@ import { test, waitForDbOperationEvent, waitForXtreamCatalog, + xtreamMockServer, } from './electron-test-fixtures'; const stressPortalName = 'Stress Xtream Portal'; @@ -25,6 +26,10 @@ async function openSources(page: Parameters[0]) { await page.waitForURL(/\/workspace\/sources$/); } +function xtreamBlockedStateTitle(page: Parameters[0]) { + return page.locator('.xtream-content-gate .error-view__title'); +} + test.describe('Electron Xtream Responsiveness', () => { test.describe.configure({ mode: 'serial' }); test.setTimeout(120000); @@ -101,6 +106,139 @@ test.describe('Electron Xtream Responsiveness', () => { } }); + test('stops importing after cancel until the user explicitly retries', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + + const app = await launchElectronApp(dataDir, { + env: { + IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs, + }, + }); + + try { + await addXtreamPortal(app.mainWindow, { + name: stressPortalName, + password: stressXtreamPassword, + username: stressXtreamUsername, + }); + + const overlay = app.mainWindow.locator('.workspace-loading-overlay'); + const cancelButton = overlay.getByRole('button', { + name: /cancel import/i, + }); + + await expect(overlay).toBeVisible({ timeout: 15000 }); + await expect(cancelButton).toBeVisible({ timeout: 20000 }); + + await waitForDbOperationEvent(app.mainWindow, { + operation: 'save-content', + predicate: (event) => + event.status === 'progress' && + (event.current ?? 0) > 0 && + (event.total ?? 0) > 0, + timeoutMs: 30000, + }); + + await cancelButton.click(); + + await expect(overlay).toHaveCount(0, { timeout: 20000 }); + await expect( + xtreamBlockedStateTitle(app.mainWindow) + ).toHaveText('Import cancelled', { timeout: 20000 }); + + await app.mainWindow.waitForTimeout(1200); + await expect(overlay).toHaveCount(0); + + const retryButton = app.mainWindow.getByRole('button', { + name: 'Retry', + exact: true, + }); + await retryButton.click(); + + await waitForXtreamCatalog(app.mainWindow); + } finally { + await closeElectronApp(app); + } + }); + + test('blocks expired and inactive Xtream accounts before import starts', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + name: 'Expired Xtream Portal', + password: 'expired', + username: 'expired', + }); + + await expect(xtreamBlockedStateTitle(app.mainWindow)).toHaveText( + 'Account expired', + { timeout: 20000 } + ); + await expect( + app.mainWindow.locator('.workspace-loading-overlay') + ).toHaveCount(0); + + await openSources(app.mainWindow); + + await addXtreamPortal(app.mainWindow, { + name: 'Inactive Xtream Portal', + password: 'inactive', + username: 'inactive', + }); + + await expect(xtreamBlockedStateTitle(app.mainWindow)).toHaveText( + 'Account inactive', + { timeout: 20000 } + ); + await expect( + app.mainWindow.locator('.workspace-loading-overlay') + ).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); + + test('blocks unreachable Xtream portals immediately without an import loop', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + name: 'Unavailable Xtream Portal', + password: stressXtreamPassword, + serverUrl: xtreamMockServer.replace(/:\d+$/, ':65530'), + username: stressXtreamUsername, + }); + + await expect(xtreamBlockedStateTitle(app.mainWindow)).toHaveText( + 'Portal unavailable', + { timeout: 20000 } + ); + await expect( + app.mainWindow.locator('.workspace-loading-overlay') + ).toHaveCount(0); + await app.mainWindow.waitForTimeout(1200); + await expect( + app.mainWindow.locator('.workspace-loading-overlay') + ).toHaveCount(0); + } finally { + await closeElectronApp(app); + } + }); + test('keeps the UI responsive during large Xtream playlist deletion', async ({ dataDir, request,