import { Page } from '@playwright/test'; import { addXtreamPortal, channelItemByTitle, clickCategoryByNameExact, clickFirstGridListCard, closeElectronApp, createMutableTextServer, enableRemoteControl, expect, goToDashboard, importM3uPlaylistFromNativeDialog, launchCompetingElectronInstance, launchElectronApp, LaunchedElectronApp, m3uFixturePath, 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 = ` News One Morning Bulletin Daily morning news. `; test.describe('Electron Settings', () => { test('@settings @electron shows manual app update fallback when self-update is unavailable', async ({ dataDir, }) => { const app = await launchElectronApp(dataDir); try { await openSettings(app.mainWindow); await expect(app.mainWindow.getByTestId('app-update-status')).toBeVisible(); await expect(app.mainWindow.getByTestId('app-update-check')).toBeVisible(); await expect( app.mainWindow.getByTestId('app-update-open-release') ).toBeVisible(); await expect( app.mainWindow.getByTestId('app-update-download') ).toHaveCount(0); await expect( app.mainWindow.getByTestId('app-update-install') ).toHaveCount(0); } finally { await closeElectronApp(app); } }); test('@settings @electron gates external MPV playback behind double-click when enabled', async ({ dataDir, }) => { const app = await launchElectronApp(dataDir); try { await installExternalPlayerLaunchCapture(app); await openSettings(app.mainWindow); await expect( app.mainWindow.getByTestId( 'external-player-double-click-setting' ) ).toHaveCount(0); await selectSettingsOption( app.mainWindow, 'select-video-player', 'mpv' ); const doubleClickSetting = app.mainWindow.getByTestId( 'external-player-double-click-setting' ); const doubleClickCheckbox = doubleClickSetting.locator( 'input[type="checkbox"]' ); await expect(doubleClickSetting).toBeVisible(); await expect(doubleClickCheckbox).not.toBeChecked(); await saveSettings(app.mainWindow); await goToDashboard(app.mainWindow); await importM3uPlaylistFromNativeDialog(app, m3uFixturePath); await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/); const firstChannel = channelItemByTitle( app.mainWindow, 'Channel 1' ).first(); await expect(firstChannel).toBeVisible({ timeout: 20000 }); await firstChannel.click(); await expectExternalPlayerLaunchCount(app, 1); await expectExternalPlayerLaunch(app, 0, { player: 'mpv', title: 'Channel 1', url: 'https://example.channels/path-to-file/1.m3u8', }); await openSettings(app.mainWindow); await expect(doubleClickSetting).toBeVisible(); await doubleClickCheckbox.check(); await saveSettings(app.mainWindow); await app.mainWindow.goBack(); await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/); await resetExternalPlayerLaunches(app); const secondChannel = channelItemByTitle( app.mainWindow, 'Positive News TV' ).first(); await expect(secondChannel).toBeVisible({ timeout: 20000 }); await secondChannel.click(); await expect(secondChannel).toHaveClass(/active/); await expectNoExternalPlayerLaunchesAfterSettled(app); await secondChannel.dblclick(); await expectExternalPlayerLaunchCount(app, 1); await expectExternalPlayerLaunch(app, 0, { player: 'mpv', title: 'Positive News TV', url: 'https://example.channels/path-to-file/2.m3u8', }); } finally { await closeElectronApp(app); } }); test('@settings @persistence @electron refuses a second instance so it cannot break settings storage', async ({ dataDir, }) => { const runningApp = await launchElectronApp(dataDir); try { const competing = await launchCompetingElectronInstance(dataDir); expect( { exitCode: competing.exitCode, signal: competing.signal, timedOut: competing.timedOut, }, competing.stderr ).toEqual({ exitCode: 0, signal: null, timedOut: false }); // The running instance keeps its window and its storage lock. expect(runningApp.electronApp.windows().length).toBeGreaterThan(0); await expect(runningApp.mainWindow.locator('body')).toBeVisible(); } finally { await closeElectronApp(runningApp); } // The lock is released on exit, so a later launch starts normally. const relaunch = await launchElectronApp(dataDir); await closeElectronApp(relaunch); }); test('@settings @electron re-opens a window when a second launch arrives with none open', async ({ dataDir, }) => { // Only macOS keeps the process alive after its last window closes; // elsewhere `window-all-closed` quits and the next launch is a plain // cold start. test.skip( process.platform !== 'darwin', 'macOS-only windowless-process behaviour' ); const runningApp = await launchElectronApp(dataDir); try { await runningApp.electronApp.evaluate(({ BrowserWindow }) => { for (const window of BrowserWindow.getAllWindows()) { window.close(); } }); await expect .poll(() => runningApp.electronApp.windows().length) .toBe(0); const recreatedWindow = runningApp.electronApp.waitForEvent('window'); const competing = await launchCompetingElectronInstance(dataDir); expect(competing.timedOut).toBe(false); expect(competing.exitCode).toBe(0); // Without this the guard would quit the launch into nothing and // leave the user staring at no window at all. await expect((await recreatedWindow).locator('body')).toBeVisible(); } finally { await closeElectronApp(runningApp); } }); test('@settings @persistence @electron persists changed desktop settings across app restart', async ({ dataDir, }) => { const epgServer = await createMutableTextServer(epgFixtureXml, { contentType: 'application/xml; charset=utf-8', resourcePath: '/settings-guide.xml', }); const firstLaunch = await launchElectronApp(dataDir); try { await openSettings(firstLaunch.mainWindow); await selectSettingsOption( firstLaunch.mainWindow, 'select-language', 'de' ); await firstLaunch.mainWindow .locator('[data-test-id="DARK_THEME"]') .click(); await selectSettingsOption( firstLaunch.mainWindow, 'select-video-player', 'html5' ); const sharedControlsCheckbox = firstLaunch.mainWindow .getByTestId('web-player-shared-controls-setting') .locator('input[type="checkbox"]'); await expect(sharedControlsCheckbox).toBeVisible(); await sharedControlsCheckbox.check(); await selectSettingsOption( firstLaunch.mainWindow, 'select-stream-format', 'ts' ); await firstLaunch.mainWindow .locator( 'mat-checkbox[formcontrolname="showExternalPlaybackBar"] input[type="checkbox"]' ) .uncheck(); await enableRemoteControl(firstLaunch.mainWindow, 8877); await firstLaunch.mainWindow .getByRole('button', { name: 'Add EPG source' }) .click(); await firstLaunch.mainWindow .locator('.epg-source-row input') .first() .fill(epgServer.resourceUrl); await saveSettings(firstLaunch.mainWindow); } finally { await closeElectronApp(firstLaunch); } const secondLaunch = await launchElectronApp(dataDir); try { await openSettings(secondLaunch.mainWindow); await expect( secondLaunch.mainWindow.getByTestId('select-language') ).toContainText('Deutsch'); await expect( secondLaunch.mainWindow.locator('[data-test-id="DARK_THEME"]') ).toHaveAttribute('aria-checked', 'true'); await expect( secondLaunch.mainWindow.getByTestId('select-video-player') ).toContainText(/HTML5/i); await expect( secondLaunch.mainWindow .getByTestId('web-player-shared-controls-setting') .locator('input[type="checkbox"]') ).toBeChecked(); await expect( secondLaunch.mainWindow.getByTestId('select-stream-format') ).toContainText('ts'); await expect( secondLaunch.mainWindow.locator( 'mat-checkbox[formcontrolname="showExternalPlaybackBar"] input[type="checkbox"]' ) ).not.toBeChecked(); await expect( secondLaunch.mainWindow.locator( 'mat-checkbox[formcontrolname="remoteControl"] input[type="checkbox"]' ) ).toBeChecked(); await expect( secondLaunch.mainWindow.locator('#remoteControlPort') ).toHaveValue('8877'); await expect( secondLaunch.mainWindow.locator('.epg-source-row input').first() ).toHaveValue(epgServer.resourceUrl); } finally { await closeElectronApp(secondLaunch); await epgServer.close(); } }); test('@settings @playback @electron applies shared controls to the next HTML5 session', async ({ dataDir, }) => { const app = await launchElectronApp(dataDir); try { await openSettings(app.mainWindow); await selectSettingsOption( app.mainWindow, 'select-video-player', 'html5' ); const sharedControlsCheckbox = app.mainWindow .getByTestId('web-player-shared-controls-setting') .locator('input[type="checkbox"]'); await expect(sharedControlsCheckbox).toBeVisible(); await sharedControlsCheckbox.check(); await saveSettings(app.mainWindow); await goToDashboard(app.mainWindow); await importM3uPlaylistFromNativeDialog(app, m3uFixturePath); await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/); await app.mainWindow.route( 'https://example.channels/path-to-file/1.m3u8', () => { // Keep the synthetic stream pending so its network failure // cannot replace the shared controls with a diagnostic. } ); const firstChannel = channelItemByTitle( app.mainWindow, 'Channel 1' ).first(); await expect(firstChannel).toBeVisible({ timeout: 20000 }); await firstChannel.click(); const video = app.mainWindow.locator( 'app-html-video-player video' ); await expect(video).toBeAttached(); await video.evaluate((video) => { const ownerDocument = video.ownerDocument; let activePictureInPictureElement: Element | null = null; video.dataset['pictureInPictureRequestCount'] = '0'; video.dataset['pictureInPictureExitCount'] = '0'; Object.defineProperty( ownerDocument, 'pictureInPictureEnabled', { configurable: true, value: true, } ); Object.defineProperty( ownerDocument, 'pictureInPictureElement', { configurable: true, get: () => activePictureInPictureElement, } ); Object.defineProperty(ownerDocument, 'exitPictureInPicture', { configurable: true, value: async (): Promise => { video.dataset['pictureInPictureExitCount'] = String( Number( video.dataset['pictureInPictureExitCount'] ?? '0' ) + 1 ); const previousOwner = activePictureInPictureElement; activePictureInPictureElement = null; previousOwner?.dispatchEvent( new Event('leavepictureinpicture') ); }, }); Object.defineProperty(video, 'requestPictureInPicture', { configurable: true, value: async (): Promise => { video.dataset['pictureInPictureRequestCount'] = String( Number( video.dataset['pictureInPictureRequestCount'] ?? '0' ) + 1 ); activePictureInPictureElement = video; video.dispatchEvent(new Event('enterpictureinpicture')); const pictureInPictureWindow: PictureInPictureWindow = Object.assign(new EventTarget(), { height: video.videoHeight, onresize: null, width: video.videoWidth, }); return pictureInPictureWindow; }, }); Object.defineProperty(video, 'disablePictureInPicture', { configurable: true, value: false, }); Object.defineProperty(video, 'readyState', { configurable: true, value: HTMLMediaElement.HAVE_METADATA, }); video.dispatchEvent(new Event('loadedmetadata')); }); const playerControls = app.mainWindow.locator( 'app-html-video-player app-player-controls' ); await expect(playerControls).toBeVisible(); await expect( app.mainWindow.locator('app-html-video-player video[controls]') ).toHaveCount(0); const enterPictureInPicture = playerControls.getByRole('button', { name: 'Enter picture-in-picture', }); await expect(enterPictureInPicture).toBeVisible(); await expect(enterPictureInPicture).toBeEnabled(); await expect(enterPictureInPicture).toHaveAttribute( 'aria-pressed', 'false' ); await expect(video).toHaveAttribute( 'data-picture-in-picture-request-count', '0' ); await enterPictureInPicture.click(); await expect(video).toHaveAttribute( 'data-picture-in-picture-request-count', '1' ); const exitPictureInPicture = playerControls.getByRole('button', { name: 'Exit picture-in-picture', }); await expect(exitPictureInPicture).toBeVisible(); await expect(exitPictureInPicture).toHaveAttribute( 'aria-pressed', 'true' ); await expect(video).toHaveAttribute( 'data-picture-in-picture-exit-count', '0' ); await exitPictureInPicture.click(); await expect(video).toHaveAttribute( 'data-picture-in-picture-exit-count', '1' ); await expect(enterPictureInPicture).toBeVisible(); await expect(enterPictureInPicture).toHaveAttribute( 'aria-pressed', 'false' ); } finally { await closeElectronApp(app); } }); test('@settings @persistence @electron persists the EPG view mode across app restart', async ({ dataDir, }) => { const firstLaunch = await launchElectronApp(dataDir); try { await openSettings(firstLaunch.mainWindow); const listToggle = firstLaunch.mainWindow.locator( '[data-test-id="epg-view-mode-list"]' ); await expect(listToggle).toBeVisible(); await listToggle.click(); await expect(listToggle).toHaveAttribute('aria-checked', 'true'); await saveSettings(firstLaunch.mainWindow); } finally { await closeElectronApp(firstLaunch); } const secondLaunch = await launchElectronApp(dataDir); try { await openSettings(secondLaunch.mainWindow); await expect( secondLaunch.mainWindow.locator( '[data-test-id="epg-view-mode-list"]' ) ).toHaveAttribute('aria-checked', 'true'); } finally { await closeElectronApp(secondLaunch); } }); test('@settings @electron 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(); for (const toggleId of [ 'toggle-dashboard-hero', 'toggle-dashboard-rail-continue-watching', 'toggle-dashboard-rail-live-favorites', 'toggle-dashboard-rail-recently-watched-live', 'toggle-dashboard-rail-favorite-movies-and-series', 'toggle-dashboard-rail-recent-sources', 'toggle-dashboard-rail-xtream-recently-added', ]) { await expect( firstLaunch.mainWindow .getByTestId(toggleId) .locator('input[type="checkbox"]') ).toBeDisabled(); } 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('@settings @dashboard @electron hides an individually disabled dashboard rail while dashboard remains enabled', async ({ dataDir, }) => { const app = await launchElectronApp(dataDir); try { await importM3uPlaylistFromNativeDialog(app, m3uFixturePath); await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/); await goToDashboard(app.mainWindow); await expect( app.mainWindow.getByTestId('dashboard-recent-sources-rail') ).toBeVisible({ timeout: 20000 }); await openSettings(app.mainWindow); await app.mainWindow .locator('.settings-section-item') .filter({ hasText: 'Dashboard' }) .first() .click(); await expect( app.mainWindow .getByTestId('toggle-show-dashboard') .locator('input[type="checkbox"]') ).toBeChecked(); await app.mainWindow .getByTestId('toggle-dashboard-rail-recent-sources') .locator('input[type="checkbox"]') .uncheck(); await saveSettings(app.mainWindow); await goToDashboard(app.mainWindow); await app.mainWindow.waitForURL(/\/workspace\/dashboard$/); await expect( app.mainWindow.getByTestId('dashboard-recent-sources-rail') ).toHaveCount(0); await expect( app.mainWindow.getByRole('link', { name: 'Dashboard', exact: true, }) ).toBeVisible(); } finally { await closeElectronApp(app); } }); 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); } }); test('@settings @electron @persistence sizes and clears the TMDB metadata cache', async ({ dataDir, }) => { const app = await launchElectronApp(dataDir); try { // Seeded through the same preload bridge the enrichment service // uses, so this exercises real IPC -> DB worker -> SQLite. // Enrichment itself cannot run here: it needs a TMDB API key, // which builds outside the release pipeline do not carry. await app.mainWindow.evaluate(async () => { await window.electron.dbSetTmdbMetadata({ mediaType: 'movie', lookupKey: 'id:603|v2', language: 'en-US', tmdbId: 603, payload: JSON.stringify({ id: 603, title: 'The Matrix' }), fetchedAt: new Date().toISOString(), }); }); await openSettings(app.mainWindow); await app.mainWindow.getByTestId('settings-section-tmdb').click(); // Sizing is deferred until this section is the active one await expect( app.mainWindow.getByTestId('tmdb-cache-size') ).toHaveText(/\b1 entries/); const clearButton = app.mainWindow.getByTestId('tmdb-clear-cache'); await expect(clearButton).toBeEnabled(); await clearButton.click(); // Re-read reports an empty cache, so there is nothing to clear await expect(clearButton).toBeDisabled(); await expect( app.mainWindow.getByTestId('tmdb-cache-size') ).toHaveText(/\b0 entries/); // ...and the row is gone from the database, not just the panel const remaining = await app.mainWindow.evaluate(() => window.electron.dbGetTmdbMetadata('movie', 'id:603|v2', 'en-US') ); expect(remaining).toBeNull(); } finally { await closeElectronApp(app); } }); }); async function selectSettingsOption( page: Page, selectTestId: string, optionTestId: string ): Promise { await page.getByTestId(selectTestId).click(); await page.getByTestId(optionTestId).click(); } type CapturedExternalPlayerLaunch = { player: 'mpv' | 'vlc'; title: string; url: string; }; const externalPlayerLaunchCaptureKey = '__iptvnatorE2eExternalPlayerLaunches'; async function installExternalPlayerLaunchCapture( app: LaunchedElectronApp ): Promise { await app.electronApp.evaluate(({ ipcMain }, captureKey) => { const globalRef = globalThis as typeof globalThis & Record; globalRef[captureKey] = []; const captureLaunch = (player: 'mpv' | 'vlc') => async ( _event: unknown, url: string, title: string, thumbnail?: string | null ) => { const launches = globalRef[captureKey] ?? []; const now = new Date().toISOString(); launches.push({ player, title, url, }); globalRef[captureKey] = launches; return { canClose: false, id: `e2e-${player}-${launches.length}`, player, startedAt: now, status: 'opened', streamUrl: url, thumbnail: thumbnail ?? null, title, updatedAt: now, }; }; ipcMain.removeHandler('OPEN_MPV_PLAYER'); ipcMain.removeHandler('OPEN_VLC_PLAYER'); ipcMain.handle('OPEN_MPV_PLAYER', captureLaunch('mpv')); ipcMain.handle('OPEN_VLC_PLAYER', captureLaunch('vlc')); }, externalPlayerLaunchCaptureKey); } async function getExternalPlayerLaunches( app: LaunchedElectronApp ): Promise { return app.electronApp.evaluate((_, captureKey) => { const globalRef = globalThis as typeof globalThis & Record; return globalRef[captureKey] ?? []; }, externalPlayerLaunchCaptureKey); } async function resetExternalPlayerLaunches( app: LaunchedElectronApp ): Promise { await app.electronApp.evaluate((_, captureKey) => { const globalRef = globalThis as typeof globalThis & Record; globalRef[captureKey] = []; }, externalPlayerLaunchCaptureKey); } async function expectExternalPlayerLaunchCount( app: LaunchedElectronApp, count: number ): Promise { await expect .poll(async () => (await getExternalPlayerLaunches(app)).length, { timeout: 10000, }) .toBe(count); } async function expectExternalPlayerLaunch( app: LaunchedElectronApp, index: number, expected: CapturedExternalPlayerLaunch ): Promise { const launches = await getExternalPlayerLaunches(app); expect(launches[index]).toMatchObject({ player: expected.player, url: expected.url, }); expect(launches[index]?.title.trim()).toBe(expected.title); } async function expectNoExternalPlayerLaunchesAfterSettled( app: LaunchedElectronApp ): Promise { await new Promise((resolve) => setTimeout(resolve, 750)); expect(await getExternalPlayerLaunches(app)).toHaveLength(0); }