import { writeFileSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import { createServer } from 'node:http'; import { join } from 'node:path'; import { gzipSync } from 'node:zlib'; import { Page } from '@playwright/test'; import { buildM3uContent, channelItemByTitle, closeElectronApp, createMutableTextServer, expect, goToDashboard, importM3uPlaylistFromUrl, launchElectronApp, openWorkspaceSection, openSettings, openSettingsSection, saveSettings, test, } from './electron-test-fixtures'; const fallbackLogoSvg = Buffer.from( `GN` ).toString('base64'); const fallbackLogoDataUrl = `data:image/svg+xml;base64,${fallbackLogoSvg}`; const epgFixtureXml = ` Guide News Guide Bulletin EPG refresh smoke test. `; function createCurrentXmltvFixture( channelId: string, channelName: string, programTitle: string, iconUrl?: string ): string { const start = new Date(Date.now() - 15 * 60 * 1000); const stop = new Date(Date.now() + 45 * 60 * 1000); return ` ${channelName} ${iconUrl ? `` : ''} ${programTitle} M3U-declared EPG source smoke test. `; } function formatXmltvDate(date: Date): string { const pad = (value: number) => String(value).padStart(2, '0'); return [ date.getUTCFullYear(), pad(date.getUTCMonth() + 1), pad(date.getUTCDate()), pad(date.getUTCHours()), pad(date.getUTCMinutes()), pad(date.getUTCSeconds()), ].join(''); } async function createGzipEpgServer(xml: string, doubleGzip: boolean) { const file = gzipSync(xml); const body = doubleGzip ? gzipSync(file) : file; const server = createServer((_request, response) => { response.writeHead(200, { 'Content-Type': 'application/gzip', 'Content-Encoding': 'gzip', }); response.end(body); }); await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); const address = server.address(); if (!address || typeof address === 'string') { throw new Error('Expected a TCP address for the XMLTV fixture server'); } return { url: `http://127.0.0.1:${address.port}/guide.xml.gz`, close: () => new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }), }; } test.describe('Electron EPG', () => { for (const [name, doubleGzip] of [ ['double', true], ['single', false], ] as const) { test(`@epg @electron imports ${name} gzip with a .gz URL and HTTP gzip`, async ({ dataDir, }) => { const server = await createGzipEpgServer( createCurrentXmltvFixture( 'gzip-news', 'Gzip News', 'Gzip Bulletin' ), doubleGzip ); try { const app = await launchElectronApp(dataDir); try { const result = await app.mainWindow.evaluate( (url) => window.electron.forceFetchEpg(url), server.url ); expect(result.success).toBe(true); expect(await getEpgChannelCount(app.mainWindow)).toBe(1); const metadata = await app.mainWindow.evaluate(() => window.electron.getEpgChannelMetadata(['gzip-news']) ); expect(metadata['gzip-news']).toMatchObject({ displayName: 'Gzip News', }); const programs = await app.mainWindow.evaluate(() => window.electron.getChannelPrograms('gzip-news') ); expect(programs.map((program) => program.title)).toEqual([ 'Gzip Bulletin', ]); } finally { await closeElectronApp(app); } } finally { await server.close(); } }); } test('@epg @electron saves unrelated settings when EPG reconciliation is unavailable', async ({ dataDir, }) => { const app = await launchElectronApp(dataDir); try { await openSettings(app.mainWindow); await openSettingsSection(app.mainWindow, 'general'); // After startup, make any unexpected reconciliation fail. await app.electronApp.evaluate(({ ipcMain }) => { ipcMain.removeHandler('EPG_RECONCILE_SOURCES'); ipcMain.handle('EPG_RECONCILE_SOURCES', () => { throw new Error('Synthetic migration failure'); }); }); const guard = app.mainWindow .getByTestId('portal-connectivity-toggle') .locator('input'); await guard.uncheck(); await expect( app.mainWindow.getByTestId('settings-unsaved-bar') ).toBeVisible(); await saveSettings(app.mainWindow); await expect( app.mainWindow.getByTestId('settings-unsaved-bar') ).not.toBeVisible(); await goToDashboard(app.mainWindow); await expect( app.mainWindow.getByTestId('unsaved-dialog-save') ).not.toBeVisible(); } finally { await closeElectronApp(app); } }); test('@epg @electron mirrors saved desktop settings when source cleanup fails', async ({ dataDir, }) => { const app = await launchElectronApp(dataDir); try { await openSettings(app.mainWindow); await app.electronApp.evaluate(({ ipcMain }) => { ipcMain.removeHandler('EPG_RECONCILE_SOURCES'); ipcMain.handle('EPG_RECONCILE_SOURCES', () => { throw new Error('Synthetic cleanup failure'); }); }); await openSettingsSection(app.mainWindow, 'general'); await app.mainWindow .getByTestId('portal-connectivity-toggle') .locator('input') .uncheck(); await openSettingsSection(app.mainWindow, 'epg'); await app.mainWindow .getByRole('button', { name: 'Add EPG source' }) .click(); await app.mainWindow .locator('.epg-source-row input') .fill('https://example.invalid/guide.xml'); await app.mainWindow .getByTestId('save-settings') .click({ noWaitAfter: true }); await expect( app.mainWindow.getByText( 'Failed to clear EPG data. Please try again.', { exact: true } ) ).toBeVisible(); await expect( app.mainWindow.getByTestId('settings-unsaved-bar') ).toBeVisible(); await expect .poll(async () => { const config = JSON.parse( await readFile( join(dataDir, 'config', 'config.json'), 'utf8' ) ); return config.PORTAL_CONNECTIVITY_GUARD; }) .toBe(false); // Restore cleanup so the explicit retry can finish and close normally. await app.electronApp.evaluate(({ ipcMain }) => { ipcMain.removeHandler('EPG_RECONCILE_SOURCES'); ipcMain.handle('EPG_RECONCILE_SOURCES', () => ({ success: true, removedUrls: [], })); }); await app.mainWindow.getByTestId('epg-source-remove').click(); await saveSettings(app.mainWindow); await expect( app.mainWindow.getByTestId('settings-unsaved-bar') ).not.toBeVisible(); } finally { await closeElectronApp(app); } }); test('@epg @electron omits source secrets from main and parser-worker diagnostics', async ({ dataDir, }) => { const server = await createMutableTextServer(epgFixtureXml, { contentType: 'application/xml; charset=utf-8', resourcePath: '/epg-path-secret/guide.xml', }); const app = await launchElectronApp(dataDir); const sourceUrl = `${server.resourceUrl}?custom=epg-query-secret`; let diagnostics = ''; const capture = (chunk: Buffer) => { diagnostics += chunk.toString(); }; const process = app.electronApp.process(); process.stdout?.on('data', capture); process.stderr?.on('data', capture); try { const imported = await app.mainWindow.evaluate(async (url) => { const result = await window.electron.forceFetchEpg(url); await window.electron.clearEpgDataForSource(url); return result; }, sourceUrl); expect(imported.success).toBe(true); await expect .poll(() => diagnostics) .toContain('EPG data cleared for source via worker'); const failed = await app.mainWindow.evaluate(async (url) => { const missing = new URL(url); missing.pathname += '/missing'; return window.electron.forceFetchEpg(missing.toString()); }, sourceUrl); expect(failed.success).toBe(false); await expect .poll(() => diagnostics) .toContain('Request failed with status code 404'); // Worker stdout can lose its final buffered line on termination; // the awaited import result and main-process completion are authoritative. expect(diagnostics).toContain('[EPG Worker] Fetching EPG source'); expect(diagnostics).toContain('EPG parsing complete:'); expect(diagnostics).not.toContain('epg-path-secret'); expect(diagnostics).not.toContain('epg-query-secret'); expect(await getEpgChannelCount(app.mainWindow)).toBe(0); } finally { process.stdout?.off('data', capture); process.stderr?.off('data', capture); await closeElectronApp(app); await server.close(); } }); test('@epg @electron imports a local gzipped XMLTV file given as an absolute path', async ({ dataDir, }) => { const localGuide = join(dataDir, 'local guide.xml.gz'); writeFileSync(localGuide, gzipSync(epgFixtureXml)); const app = await launchElectronApp(dataDir); try { await openSettings(app.mainWindow); await openSettingsSection(app.mainWindow, 'epg'); await expect( app.mainWindow.getByTestId('epg-source-formats') ).toContainText('file://'); await app.mainWindow .getByRole('button', { name: 'Add EPG source' }) .click(); const field = app.mainWindow .locator('.epg-source-row input') .first(); await field.fill('guide.xml'); // Material reveals errors once the control is touched. await field.blur(); await expect( app.mainWindow.locator('.epg-source-row mat-error') ).toBeVisible(); await field.fill(localGuide); await expect( app.mainWindow.locator('.epg-source-row mat-error') ).toHaveCount(0); // Native dialogs cannot be driven from Playwright: stub them in // the main process. A hand-typed path is confirmed once through // the message box; a refused path is reported, never read. await app.electronApp.evaluate(({ dialog }) => { dialog.showMessageBox = (async () => ({ response: 1, checkboxChecked: false, })) as typeof dialog.showMessageBox; }); await app.mainWindow.getByTestId('epg-source-refresh').click(); await expect( app.mainWindow.locator( '.epg-progress-panel .import-item.status-error' ) ).toContainText('was not allowed'); expect(await getEpgChannelCount(app.mainWindow)).toBe(0); await app.electronApp.evaluate(({ dialog }) => { dialog.showMessageBox = (async () => ({ response: 0, checkboxChecked: false, })) as typeof dialog.showMessageBox; }); await app.mainWindow .locator('.epg-progress-panel .retry-btn') .click(); await expect .poll(() => getEpgChannelCount(app.mainWindow), { timeout: 30000, }) .toBeGreaterThan(0); await expect( app.mainWindow.locator( '.epg-progress-panel .import-item.status-complete' ) ).toHaveCount(1); // A file chosen in the native picker is trusted without a prompt. const browsedGuide = join(dataDir, 'browsed guide.xml.gz'); writeFileSync(browsedGuide, gzipSync(epgFixtureXml)); await app.electronApp.evaluate(({ dialog }, filePath) => { dialog.showOpenDialog = (async () => ({ canceled: false, filePaths: [filePath], })) as typeof dialog.showOpenDialog; dialog.showMessageBox = (async () => { throw new Error('picked files must not prompt'); }) as typeof dialog.showMessageBox; }, browsedGuide); await app.mainWindow.getByTestId('epg-source-browse').click(); await expect(field).toHaveValue(browsedGuide); await app.mainWindow.getByTestId('epg-source-refresh').click(); await expect( app.mainWindow.locator( '.epg-progress-panel .import-item.status-complete' ) ).toHaveCount(2); // Leave with a pristine form: a dirty settings form arms the // main-process close guard, and the app would then wait for a // confirmation dialog instead of closing. await app.mainWindow.getByTestId('discard-settings').click(); await expect( app.mainWindow.getByTestId('settings-unsaved-bar') ).toHaveCount(0); } finally { await closeElectronApp(app); } }); test('@epg @electron adds an EPG source, fetches guide data, removes its stored EPG data on save', async ({ dataDir, }) => { const epgServer = await createMutableTextServer(epgFixtureXml, { contentType: 'application/xml; charset=utf-8', resourcePath: '/guide.xml', }); const app = await launchElectronApp(dataDir); try { await openSettings(app.mainWindow); await openSettingsSection(app.mainWindow, 'epg'); await app.mainWindow .getByRole('button', { name: 'Add EPG source' }) .click(); await app.mainWindow .locator('.epg-source-row input') .first() .fill(epgServer.resourceUrl); await app.mainWindow.getByTestId('epg-source-refresh').click(); await expect( app.mainWindow.locator('.epg-progress-panel') ).toBeVisible(); await expect .poll(() => getEpgChannelCount(app.mainWindow), { timeout: 30000, }) .toBeGreaterThan(0); await expect( app.mainWindow .locator('.epg-progress-panel .stat-badge') .first() ).toBeVisible(); // The row must fit the 300px panel: Material icon buttons carry a // 48px touch target that used to poke past the row padding and // turn into a horizontal scrollbar. const importsList = app.mainWindow.locator( '.epg-progress-panel .imports-list' ); await expect .poll(() => importsList.evaluate( (element) => element.scrollWidth - element.clientWidth ) ) .toBe(0); await expect( app.mainWindow.locator('.epg-progress-panel .item-url') ).toHaveText( `${new URL(epgServer.resourceUrl).hostname}/guide.xml` ); await saveSettings(app.mainWindow); await app.mainWindow.getByTestId('epg-source-remove').click(); await expect(app.mainWindow.locator('.epg-source-row')).toHaveCount( 0 ); // Adding and removing the source row leaves the settings form // dirty; save so the unsaved-changes close guard has nothing to // ask about when the app closes below — an unanswered dialog // would block the close and leak the Electron process. await saveSettings(app.mainWindow); await expect .poll(() => getEpgChannelCount(app.mainWindow), { timeout: 20000, }) .toBe(0); } finally { await closeElectronApp(app); await epgServer.close(); } }); test('@epg @electron removes a retained error row after its import worker has finished', async ({ dataDir, }) => { const server = await createMutableTextServer(epgFixtureXml, { contentType: 'application/xml', }); const app = await launchElectronApp(dataDir); try { await openSettings(app.mainWindow); await openSettingsSection(app.mainWindow, 'epg'); await app.mainWindow .getByRole('button', { name: 'Add EPG source' }) .click(); await app.mainWindow .locator('.epg-source-row input') .fill(server.resourceUrl); await saveSettings(app.mainWindow); await expect( app.mainWindow.locator( '.epg-progress-panel .import-item.status-complete' ) ).toHaveCount(1, { timeout: 30000 }); // Capture the actual generation; a force-fetch also awaits termination. const generation = await app.mainWindow.evaluate(async (url) => { await window.electron.clearEpgDataForSource(url); const completed = new Promise((resolve) => { window.electron.onEpgProgress?.((progress) => { if ( progress.url === url && progress.status === 'complete' ) resolve(progress.generation ?? 0); }); }); await window.electron.forceFetchEpg(url); return completed; }, server.resourceUrl); await app.electronApp.evaluate( ({ BrowserWindow }, { url, generation }) => { BrowserWindow.getAllWindows()[0].webContents.send( 'EPG_PROGRESS_UPDATE', { url, status: 'error', generation, errorCode: 'invalid-tls-certificate', error: 'Synthetic retained TLS error', } ); }, { url: server.resourceUrl, generation } ); await expect( app.mainWindow.locator( '.epg-progress-panel .import-item.status-error' ) ).toHaveCount(1); await app.mainWindow.getByTestId('epg-source-remove').click(); await saveSettings(app.mainWindow); await expect( app.mainWindow.locator('.epg-progress-panel .import-item') ).toHaveCount(0); await expect.poll(() => getEpgChannelCount(app.mainWindow)).toBe(0); } finally { await closeElectronApp(app); await server.close(); } }); test('@epg @electron removes only the saved source, preserves shared IDs and mappings, and repairs old orphan data on restart', async ({ dataDir, }) => { test.setTimeout(120000); const first = await createMutableTextServer( createCurrentXmltvFixture( 'shared-news', 'Shared News', 'Removed Bulletin' ), { contentType: 'application/xml', resourcePath: '/first.xml' } ); const second = await createMutableTextServer( createCurrentXmltvFixture( 'shared-news', 'Shared News', 'Retained Bulletin' ), { contentType: 'application/xml', resourcePath: '/second.xml' } ); let app = await launchElectronApp(dataDir); try { await openSettings(app.mainWindow); await openSettingsSection(app.mainWindow, 'epg'); for (const [index, url] of [ first.resourceUrl, second.resourceUrl, ].entries()) { await app.mainWindow .getByRole('button', { name: 'Add EPG source' }) .click(); const inputs = app.mainWindow.locator('.epg-source-row input'); await expect(inputs).toHaveCount(index + 1); await inputs.nth(index).fill(url); } await saveSettings(app.mainWindow); const programs = () => app.mainWindow.evaluate(async () => ( await window.electron.getChannelPrograms('mapped-news') ).map((p) => p.title) ); await app.mainWindow.evaluate(() => window.electron.setEpgMapping('mapped-news', 'shared-news') ); await expect .poll(programs, { timeout: 30000 }) .toEqual(['Removed Bulletin', 'Retained Bulletin']); await app.mainWindow .locator('.epg-source-row') .first() .getByTestId('epg-source-remove') .click(); // A staged removal must not delete data before Save. expect(await programs()).toContain('Removed Bulletin'); await saveSettings(app.mainWindow); await expect.poll(programs).toEqual(['Retained Bulletin']); await closeElectronApp(app); app = await launchElectronApp(dataDir); await expect.poll(programs).toEqual(['Retained Bulletin']); // Simulate a cache left by 0.23: import a source absent from settings. await app.mainWindow.evaluate( (url) => window.electron.forceFetchEpg(url), first.resourceUrl ); await expect.poll(programs).toContain('Removed Bulletin'); await closeElectronApp(app); app = await launchElectronApp(dataDir); await expect.poll(programs).toEqual(['Retained Bulletin']); await openSettings(app.mainWindow); await openSettingsSection(app.mainWindow, 'epg'); await app.mainWindow.getByTestId('epg-source-remove').click(); await saveSettings(app.mainWindow); await expect.poll(programs).toEqual([]); await expect.poll(() => getEpgChannelCount(app.mainWindow)).toBe(0); expect( await app.mainWindow.evaluate(() => window.electron.getEpgMapping('mapped-news') ) ).toMatchObject({ epgChannelId: 'shared-news' }); } finally { await closeElectronApp(app); await first.close(); await second.close(); } }); test('@epg @electron restores the first source name and logo after removing the last metadata writer', async ({ dataDir, }) => { const first = await createMutableTextServer( createCurrentXmltvFixture( 'shared-logo', 'First News', 'First Bulletin', 'https://example.com/first.png' ), { contentType: 'application/xml', resourcePath: '/first.xml' } ); const second = await createMutableTextServer( createCurrentXmltvFixture( 'shared-logo', 'Second News', 'Second Bulletin', 'https://example.com/second.png' ), { contentType: 'application/xml', resourcePath: '/second.xml' } ); let app = await launchElectronApp(dataDir); const metadata = () => app.mainWindow.evaluate( async () => ( await window.electron.getEpgChannelMetadata([ 'shared-logo', ]) )['shared-logo'] ); try { await openSettings(app.mainWindow); await openSettingsSection(app.mainWindow, 'epg'); for (const [index, source] of [first, second].entries()) { await app.mainWindow .getByRole('button', { name: 'Add EPG source' }) .click(); const inputs = app.mainWindow.locator('.epg-source-row input'); await expect(inputs).toHaveCount(index + 1); await inputs.nth(index).fill(source.resourceUrl); await saveSettings(app.mainWindow); await expect.poll(metadata, { timeout: 30000 }).toMatchObject({ displayName: index === 0 ? 'First News' : 'Second News', iconUrl: `https://example.com/${index === 0 ? 'first' : 'second'}.png`, }); } await app.mainWindow .locator('.epg-source-row') .nth(1) .getByTestId('epg-source-remove') .click(); await saveSettings(app.mainWindow); const firstMetadata = { displayName: 'First News', iconUrl: 'https://example.com/first.png', }; await expect.poll(metadata).toMatchObject(firstMetadata); await closeElectronApp(app); app = await launchElectronApp(dataDir); await expect.poll(metadata).toMatchObject(firstMetadata); expect( await app.mainWindow.evaluate(async () => ( await window.electron.getChannelPrograms('shared-logo') ).map((p) => p.title) ) ).toEqual(['First Bulletin']); } finally { await closeElectronApp(app); await first.close(); await second.close(); } }); test('@epg @electron imports and renders an EPG source declared by an M3U playlist header', async ({ dataDir, }) => { test.setTimeout(90000); const epgServer = await createMutableTextServer( createCurrentXmltvFixture( 'playlist-guide-news', 'Playlist Guide News', 'Playlist Scoped Bulletin' ), { contentType: 'application/xml; charset=utf-8', resourcePath: '/guides/ua/playlist-guide.xml', } ); const detectedEpgUrls = [ `${epgServer.origin}/guides/us/ignored.xml`, `${epgServer.origin}/guides/de/ignored.xml`, epgServer.resourceUrl, `${epgServer.origin}/guides/fr/ignored.xml`, `${epgServer.origin}/guides/uk/ignored.xml`, `${epgServer.origin}/guides/es/ignored.xml`, ]; const playlistContent = buildM3uContent([ { name: 'Playlist Guide News', tvgCountry: 'UA', tvgId: 'playlist-guide-news', url: 'https://example.com/live/playlist-guide-news.m3u8', }, ]).replace( '#EXTM3U', `#EXTM3U x-tvg-url="${detectedEpgUrls.join(',')}"` ); const playlistServer = await createMutableTextServer(playlistContent, { contentType: 'application/x-mpegurl; charset=utf-8', resourcePath: '/playlist-with-epg.m3u', }); const app = await launchElectronApp(dataDir); try { await importM3uPlaylistFromUrl( app.mainWindow, playlistServer.resourceUrl ); await expect( app.mainWindow.locator('.epg-progress-panel') ).toBeVisible(); await expect( app.mainWindow.locator('.epg-progress-panel .import-item') ).toHaveCount(1); await expect( app.mainWindow.locator( '.epg-progress-panel .import-item.status-complete' ) ).toHaveCount(1, { timeout: 30000 }); await expect .poll(() => getEpgChannelCount(app.mainWindow), { timeout: 30000, }) .toBeGreaterThan(0); await openWorkspaceSection(app.mainWindow, 'All channels'); const channelItem = channelItemByTitle( app.mainWindow, 'Playlist Guide News' ); await expect(channelItem).toBeVisible(); await expect(channelItem.locator('.epg-title')).toContainText( 'Playlist Scoped Bulletin', { timeout: 30000 } ); await openSettings(app.mainWindow); await openSettingsSection(app.mainWindow, 'epg'); await app.mainWindow .getByRole('button', { name: 'Add EPG source' }) .click(); await app.mainWindow .locator('.epg-source-row input') .fill(epgServer.resourceUrl); await saveSettings(app.mainWindow); await app.mainWindow.getByTestId('epg-source-remove').click(); await saveSettings(app.mainWindow); // The same URL still belongs to the saved M3U playlist. const retained = await app.mainWindow.evaluate(async () => window.electron.getChannelPrograms('playlist-guide-news') ); expect(retained.map((program) => program.title)).toContain( 'Playlist Scoped Bulletin' ); } finally { await closeElectronApp(app); await playlistServer.close(); await epgServer.close(); } }); test("@epg @electron dashboard live rails find a programme that only another playlist's XMLTV carries", async ({ dataDir, }) => { test.setTimeout(120000); // The reported case: the favourited channel's own playlist declares // no guide, Settings hold a global XMLTV that does not know it, and // the programme exists only in the guide a DIFFERENT playlist // imported. The rails searched the global scope alone and showed // nothing, while the "See all" row — resolved unscoped — had it. const otherPlaylistEpgServer = await createMutableTextServer( createCurrentXmltvFixture( 'playlist-guide-news', 'Playlist Guide News', 'Other Playlist Bulletin' ), { contentType: 'application/xml; charset=utf-8', resourcePath: '/guides/other-playlist.xml', } ); const globalEpgServer = await createMutableTextServer( createCurrentXmltvFixture( 'global-other', 'Global Other', 'Global Other Bulletin' ), { contentType: 'application/xml; charset=utf-8', resourcePath: '/guides/global-guide.xml', } ); // Declares the guide, so importing it is what puts those programmes // in the database under that source. const guideOwnerServer = await createMutableTextServer( buildM3uContent([ { name: 'Playlist Guide News', tvgId: 'playlist-guide-news', url: 'https://example.com/live/guide-owner.m3u8', }, ]).replace( '#EXTM3U', `#EXTM3U x-tvg-url="${otherPlaylistEpgServer.resourceUrl}"` ), { contentType: 'application/x-mpegurl; charset=utf-8', resourcePath: '/guide-owner.m3u', } ); // Carries the same XMLTV id under its own display name and declares // no guide at all — the playlist the dashboard card comes from. const guidelessServer = await createMutableTextServer( buildM3uContent([ { name: 'Mirror News', tvgId: 'playlist-guide-news', url: 'https://example.com/live/mirror-news.m3u8', }, ]), { contentType: 'application/x-mpegurl; charset=utf-8', resourcePath: '/guideless.m3u', } ); const app = await launchElectronApp(dataDir); try { await importM3uPlaylistFromUrl( app.mainWindow, guideOwnerServer.resourceUrl ); await expect( app.mainWindow.locator( '.epg-progress-panel .import-item.status-complete' ) ).toHaveCount(1, { timeout: 30000 }); await openSettings(app.mainWindow); await openSettingsSection(app.mainWindow, 'epg'); await app.mainWindow .getByRole('button', { name: 'Add EPG source' }) .click(); await app.mainWindow .locator('.epg-source-row input') .first() .fill(globalEpgServer.resourceUrl); await saveSettings(app.mainWindow); await expect .poll(() => getEpgChannelCount(app.mainWindow), { timeout: 30000, }) .toBe(2); await importM3uPlaylistFromUrl( app.mainWindow, guidelessServer.resourceUrl ); await openWorkspaceSection(app.mainWindow, 'All channels'); const channelItem = channelItemByTitle( app.mainWindow, 'Mirror News' ); await expect(channelItem).toBeVisible({ timeout: 20000 }); await channelItem.hover(); await channelItem.locator('.favorite-button').first().click(); await expect( channelItem.locator('.favorite-button mat-icon').first() ).toHaveText(/star/); await goToDashboard(app.mainWindow); const card = app.mainWindow .locator('[data-test-id="dashboard-live-favorites-rail-card"]') .filter({ hasText: 'Mirror News' }) .first(); await expect(card).toBeVisible({ timeout: 20000 }); await expect(card.locator('.rail__channel-now')).toContainText( 'Other Playlist Bulletin', { timeout: 30000 } ); } finally { await closeElectronApp(app); await guidelessServer.close(); await guideOwnerServer.close(); await otherPlaylistEpgServer.close(); await globalEpgServer.close(); } }); test('@epg @electron uses the XMLTV channel icon as a fallback when the playlist has no tvg-logo', async ({ dataDir, }) => { const playlistServer = await createMutableTextServer( buildM3uContent([ { name: 'Guide News Live', tvgId: 'guide-news', url: 'https://example.com/live/guide-news.m3u8', }, ]), { contentType: 'application/x-mpegurl; charset=utf-8', resourcePath: '/playlist.m3u', } ); const epgServer = await createMutableTextServer( ` Guide News Live Guide Bulletin EPG logo fallback test. `, { contentType: 'application/xml; charset=utf-8', resourcePath: '/guide.xml', } ); const app = await launchElectronApp(dataDir); try { await importM3uPlaylistFromUrl( app.mainWindow, playlistServer.resourceUrl ); await openSettings(app.mainWindow); await openSettingsSection(app.mainWindow, 'epg'); await app.mainWindow .getByRole('button', { name: 'Add EPG source' }) .click(); await app.mainWindow .locator('.epg-source-row input') .first() .fill(epgServer.resourceUrl); await app.mainWindow.getByTestId('epg-source-refresh').click(); await expect .poll(() => getEpgChannelCount(app.mainWindow), { timeout: 30000, }) .toBeGreaterThan(0); // The added source is still only staged in the form — leaving // settings without saving would surface the unsaved-changes // dialog and block the navigation below. await saveSettings(app.mainWindow); await openWorkspaceSection(app.mainWindow, 'All channels'); const channelItem = channelItemByTitle( app.mainWindow, 'Guide News Live' ); await expect(channelItem).toBeVisible(); await expect(channelItem.locator('.channel-logo')).toHaveAttribute( 'src', fallbackLogoDataUrl ); await expect( channelItem.locator('.channel-logo-fallback') ).toHaveCount(0); } finally { await closeElectronApp(app); await playlistServer.close(); await epgServer.close(); } }); }); async function getEpgChannelCount(page: Page): Promise { return page.evaluate(async () => { const result = await window.electron?.getEpgChannels?.(); return Array.isArray(result?.channels) ? result.channels.length : 0; }); }