import { Page } from '@playwright/test'; import { channelItemByTitle, addStalkerPortal, addXtreamPortal, closeElectronApp, defaultXtreamPassword, defaultXtreamUsername, expect, importM3uPlaylistFromNativeDialog, launchElectronApp, openPlaylistFavorites, openPlaylistRecent, openSettings, openSourceEditor, openSources, openWorkspaceSection, playlistSwitcherTitle, resetMockServers, restartElectronApp, saveSettings, saveSourceDialog, sourceRowByTitle, test, updateSourceDialog, waitForDbOperationEvent, waitForM3uCatalog, waitForStalkerCatalog, waitForXtreamCatalog, writeTemporaryM3uFile, xtreamMockServer, } from './electron-test-fixtures'; const xtreamStressUsername = 'stress'; const xtreamStressPassword = 'stress'; const dbWorkerBatchDelayMs = '20'; const localAFileName = 'switcher-local-a.m3u'; const localADisplayName = 'switcher-local-a'; const localBFileName = 'switcher-local-b.m3u'; const localBDisplayName = 'switcher-local-b'; const localAChannelName = 'Switcher Favorite A'; const localBChannelName = 'Switcher Favorite B'; const offlineLocalFileName = 'offline-local-source.m3u'; const offlineLocalDisplayName = 'offline-local-source'; const xtreamAName = 'Switcher Xtream A'; const xtreamBName = 'Switcher Xtream B'; const stalkerAName = 'Switcher Stalker A'; const stalkerBName = 'Switcher Stalker B'; const offlineXtreamName = 'Offline Cached Xtream'; const importingXtreamName = 'Importing Xtream Portal'; test.describe('Electron Playlist Switcher', () => { test.describe.configure({ mode: 'serial' }); test.setTimeout(120000); test('preserves same-type sections and cross-type mappings when switching from the workspace header', async ({ dataDir, request, }) => { await resetMockServers(request, ['xtream', 'stalker']); const localAPath = writeTemporaryM3uFile(dataDir, localAFileName, [ { groupTitle: 'News', name: 'Switcher Local A', url: 'https://streams.example.test/switcher-local-a.m3u8', }, ]); const localBPath = writeTemporaryM3uFile(dataDir, localBFileName, [ { groupTitle: 'Sports', name: 'Switcher Local B', url: 'https://streams.example.test/switcher-local-b.m3u8', }, ]); const app = await launchElectronApp(dataDir); try { await importM3uPlaylistFromNativeDialog(app, localAPath); await importM3uPlaylistFromNativeDialog(app, localBPath); await addXtreamPortal(app.mainWindow, { name: xtreamAName, }); await addXtreamPortal(app.mainWindow, { name: xtreamBName, }); await addStalkerPortal(app.mainWindow, { name: stalkerAName, }); await addStalkerPortal(app.mainWindow, { name: stalkerBName, }); await openSources(app.mainWindow); await sourceRowByTitle(app.mainWindow, localADisplayName) .first() .click(); await waitForM3uCatalog(app.mainWindow); await openPlaylistRecent(app.mainWindow); await expectPathname(app.mainWindow, /\/workspace\/global-recent$/); await switchPlaylistFromHeader(app.mainWindow, localBDisplayName); await expectPathname(app.mainWindow, /\/workspace\/global-recent$/); await expect(playlistSwitcherTitle(app.mainWindow)).toContainText( localBDisplayName ); await switchPlaylistFromHeader(app.mainWindow, xtreamAName); await expectPathname(app.mainWindow, /\/workspace\/global-recent$/); await expect(playlistSwitcherTitle(app.mainWindow)).toContainText( xtreamAName ); await openWorkspaceSection(app.mainWindow, 'Live TV'); await waitForXtreamCatalog(app.mainWindow); await expectPathname( app.mainWindow, /\/workspace\/xtreams\/[^/]+\/live$/ ); await switchPlaylistFromHeader(app.mainWindow, xtreamBName); await waitForXtreamCatalog(app.mainWindow); await expectPathname( app.mainWindow, /\/workspace\/xtreams\/[^/]+\/live$/ ); await openWorkspaceSection(app.mainWindow, 'Recently added'); await expectPathname( app.mainWindow, /\/workspace\/xtreams\/[^/]+\/recently-added$/ ); await switchPlaylistFromHeader(app.mainWindow, stalkerAName); await expectPathname( app.mainWindow, /\/workspace\/stalker\/[^/]+\/recent$/ ); await openWorkspaceSection(app.mainWindow, 'Live TV'); await waitForStalkerCatalog(app.mainWindow); await expectPathname( app.mainWindow, /\/workspace\/stalker\/[^/]+\/itv$/ ); await switchPlaylistFromHeader(app.mainWindow, stalkerBName); await waitForStalkerCatalog(app.mainWindow); await expectPathname( app.mainWindow, /\/workspace\/stalker\/[^/]+\/itv$/ ); await switchPlaylistFromHeader(app.mainWindow, xtreamAName); await waitForXtreamCatalog(app.mainWindow); await expectPathname( app.mainWindow, /\/workspace\/xtreams\/[^/]+\/live$/ ); await switchPlaylistFromHeader(app.mainWindow, localADisplayName); await waitForM3uCatalog(app.mainWindow); await expectPathname( app.mainWindow, /\/workspace\/playlists\/[^/]+\/all$/ ); await openPlaylistFavorites(app.mainWindow); await expectPathname( app.mainWindow, /\/workspace\/global-favorites$/ ); await switchPlaylistFromHeader(app.mainWindow, stalkerBName); await expectPathname( app.mainWindow, /\/workspace\/global-favorites$/ ); await expect(playlistSwitcherTitle(app.mainWindow)).toContainText( stalkerBName ); } finally { await closeElectronApp(app); } }); test('refreshes M3U unified collections immediately when switching same-type playlists from the header', async ({ dataDir, }) => { const localAPath = writeTemporaryM3uFile(dataDir, localAFileName, [ { groupTitle: 'News', name: localAChannelName, url: 'https://streams.example.test/switcher-favorite-a.m3u8', }, ]); const localBPath = writeTemporaryM3uFile(dataDir, localBFileName, [ { groupTitle: 'Sports', name: localBChannelName, url: 'https://streams.example.test/switcher-favorite-b.m3u8', }, ]); const app = await launchElectronApp(dataDir); try { await importM3uPlaylistFromNativeDialog(app, localAPath); await importM3uPlaylistFromNativeDialog(app, localBPath); await openSources(app.mainWindow); await sourceRowByTitle(app.mainWindow, localADisplayName) .first() .click(); await waitForM3uCatalog(app.mainWindow); await toggleFavoriteForChannel(app.mainWindow, localAChannelName); await channelItemByTitle(app.mainWindow, localAChannelName) .first() .click(); await openSources(app.mainWindow); await sourceRowByTitle(app.mainWindow, localBDisplayName) .first() .click(); await waitForM3uCatalog(app.mainWindow); await toggleFavoriteForChannel(app.mainWindow, localBChannelName); await channelItemByTitle(app.mainWindow, localBChannelName) .first() .click(); await openSources(app.mainWindow); await sourceRowByTitle(app.mainWindow, localADisplayName) .first() .click(); await waitForM3uCatalog(app.mainWindow); await openPlaylistFavorites(app.mainWindow); await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount( 1 ); await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount( 0 ); await switchPlaylistFromHeader(app.mainWindow, localBDisplayName); await expectPathname( app.mainWindow, /\/workspace\/global-favorites$/ ); await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount( 1 ); await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount( 0 ); await openPlaylistRecent(app.mainWindow); await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount( 1 ); await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount( 0 ); await switchPlaylistFromHeader(app.mainWindow, localADisplayName); await expectPathname( app.mainWindow, /\/workspace\/global-recent$/ ); await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount( 1 ); await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount( 0 ); } finally { await closeElectronApp(app); } }); test('switches away from importing Xtream playlists, returns before completion, and avoids redundant reimport after caching', async ({ dataDir, request, }) => { await resetMockServers(request, ['xtream']); const localAPath = writeTemporaryM3uFile(dataDir, localAFileName, [ { groupTitle: 'News', name: 'Import Switch Local', url: 'https://streams.example.test/import-switch-local.m3u8', }, ]); const app = await launchElectronApp(dataDir, { env: { IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs, }, }); try { await importM3uPlaylistFromNativeDialog(app, localAPath); await waitForM3uCatalog(app.mainWindow); await addXtreamPortal(app.mainWindow, { name: importingXtreamName, password: xtreamStressPassword, username: xtreamStressUsername, }); const overlay = app.mainWindow.locator('.workspace-loading-overlay'); await expect(overlay).toBeVisible({ timeout: 15000 }); await waitForDbOperationEvent(app.mainWindow, { operation: 'save-content', predicate: (event) => event.status === 'progress' && (event.current ?? 0) > 0 && (event.total ?? 0) > 0, timeoutMs: 30000, }); await switchPlaylistFromHeader(app.mainWindow, localADisplayName); await waitForM3uCatalog(app.mainWindow); await expect(overlay).toHaveCount(0); await switchPlaylistFromHeader(app.mainWindow, importingXtreamName); await expectPathname( app.mainWindow, /\/workspace\/xtreams\/[^/]+\/vod$/ ); await Promise.race([ overlay.waitFor({ state: 'visible', timeout: 15000 }), waitForXtreamCatalog(app.mainWindow), ]); await waitForXtreamCatalog(app.mainWindow); await switchPlaylistFromHeader(app.mainWindow, localADisplayName); await waitForM3uCatalog(app.mainWindow); const startedBeforeReturn = await countDbEvents( app.mainWindow, 'save-content', 'started' ); await switchPlaylistFromHeader(app.mainWindow, importingXtreamName); await waitForXtreamCatalog(app.mainWindow); await expect(overlay).toHaveCount(0); await app.mainWindow.waitForTimeout(1200); const startedAfterReturn = await countDbEvents( app.mainWindow, 'save-content', 'started' ); expect(startedAfterReturn).toBe(startedBeforeReturn); } finally { await closeElectronApp(app); } }); test('keeps cached Xtream content browseable with an unavailable warning after the portal goes offline and after restart', async ({ dataDir, request, }) => { await resetMockServers(request, ['xtream']); const offlineLocalPath = writeTemporaryM3uFile( dataDir, offlineLocalFileName, [ { groupTitle: 'News', name: 'Offline Local Channel', url: 'https://streams.example.test/offline-local.m3u8', }, ] ); 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, { name: offlineXtreamName, password: defaultXtreamPassword, username: defaultXtreamUsername, }); await openWorkspaceSection(app.mainWindow, 'Movies'); await waitForXtreamCatalog(app.mainWindow); await importM3uPlaylistFromNativeDialog(app, offlineLocalPath); await waitForM3uCatalog(app.mainWindow); await openSources(app.mainWindow); const dialog = await openSourceEditor( app.mainWindow, offlineXtreamName ); await updateSourceDialog(dialog, { serverUrl: xtreamMockServer.replace(/:\d+$/, ':65530'), }); await saveSourceDialog(app.mainWindow, dialog); await sourceRowByTitle(app.mainWindow, offlineLocalDisplayName) .first() .click(); await waitForM3uCatalog(app.mainWindow); await switchPlaylistFromHeader(app.mainWindow, offlineXtreamName); await waitForXtreamCatalog(app.mainWindow); await expectXtreamUnavailableWarning(app.mainWindow); await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount( 0 ); app = await restartElectronApp(app, dataDir); await waitForXtreamCatalog(app.mainWindow); await expectXtreamUnavailableWarning(app.mainWindow); await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount( 0 ); } finally { await closeElectronApp(app); } }); }); async function openPlaylistSwitcher(page: Page): Promise { await page .locator('app-playlist-switcher .playlist-switcher-trigger') .click(); await expect( page.locator('.cdk-overlay-pane .playlist-item').first() ).toBeVisible(); } async function switchPlaylistFromHeader( page: Page, title: string ): Promise { await openPlaylistSwitcher(page); const item = page .locator('.cdk-overlay-pane .playlist-item') .filter({ hasText: title }) .first(); await expect(item).toBeVisible(); await item.click(); await expect(page.locator('.cdk-overlay-pane .playlist-item')).toHaveCount( 0 ); } async function expectPathname(page: Page, pattern: RegExp): Promise { await expect .poll(() => new URL(page.url()).pathname, { timeout: 20000 }) .toMatch(pattern); } async function countDbEvents( page: Page, operation: string, status: string ): Promise { return page.evaluate( ({ operation, status: desiredStatus }) => (window.__dbOperationEvents ?? []).filter( (event) => event.operation === operation && event.status === desiredStatus ).length, { operation, status } ); } async function selectSettingsOption( page: Page, selectTestId: string, optionTestId: string ): Promise { await page.getByTestId(selectTestId).click(); await page.getByTestId(optionTestId).click(); } async function expectXtreamUnavailableWarning(page: Page): Promise { await expect( page.getByTestId('xtream-offline-warning') ).toBeVisible({ timeout: 20000 }); } 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/); }