diff --git a/apps/electron-backend-e2e/src/downloads.e2e.ts b/apps/electron-backend-e2e/src/downloads.e2e.ts new file mode 100644 index 000000000..498b5e4bf --- /dev/null +++ b/apps/electron-backend-e2e/src/downloads.e2e.ts @@ -0,0 +1,137 @@ +import { mkdirSync, readdirSync } from 'fs'; +import { join } from 'path'; +import type { Page } from '@playwright/test'; +import { + addXtreamPortal, + closeElectronApp, + createMutableTextServer, + expect, + launchElectronApp, + resetMockServers, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; + +async function openDownloadsPage(page: Page): Promise { + await page.getByRole('button', { name: 'Open downloads' }).click(); + await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/); +} + +test.describe('Electron Downloads', () => { + test('@downloads @electron shows the add-a-playlist empty state when no sources exist', async ({ + dataDir, + }) => { + const app = await launchElectronApp(dataDir); + + try { + await openDownloadsPage(app.mainWindow); + + await expect( + app.mainWindow.locator('.downloads__header h1') + ).toHaveText('Downloads'); + // First launch runs the IndexedDB→SQLite playlist migration before + // the playlists query resolves, so allow extra time here. + await expect( + app.mainWindow.getByText( + 'Add a playlist to start downloading' + ) + ).toBeVisible({ timeout: 20000 }); + } finally { + await closeElectronApp(app); + } + }); + + test('@downloads @electron completes a download end-to-end and manages it from the downloads page', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const fileServer = await createMutableTextServer( + 'e2e download payload', + { + contentType: 'video/mp4', + resourcePath: '/media/e2e-movie.mp4', + } + ); + const app = await launchElectronApp(dataDir); + + try { + await addXtreamPortal(app.mainWindow, { + name: 'Download Portal', + username: 'user1', + password: 'pass1', + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + + await openDownloadsPage(app.mainWindow); + await expect( + app.mainWindow.getByText('No downloads yet') + ).toBeVisible(); + + // Authorize a folder inside the isolated data dir by stubbing the + // native folder dialog in the main process, then picking it via UI. + const downloadsDir = join(dataDir, 'e2e-downloads'); + mkdirSync(downloadsDir, { recursive: true }); + await app.electronApp.evaluate(({ dialog }, folder) => { + dialog.showOpenDialog = async () => + ({ + canceled: false, + filePaths: [folder], + }) as Awaited>; + }, downloadsDir); + await app.mainWindow + .getByRole('button', { name: 'Change Folder' }) + .click(); + await expect( + app.mainWindow.locator('.downloads__folder-inline-path') + ).toContainText('e2e-downloads'); + + // Enqueue a download through the same renderer bridge the app uses. + const startResult = await app.mainWindow.evaluate( + async ({ url, folder }) => + window.electron?.downloadsStart?.({ + playlistId: 'e2e-playlist', + xtreamId: 4242, + contentType: 'vod', + title: 'E2E Movie', + url, + downloadFolder: folder, + }), + { url: fileServer.resourceUrl, folder: downloadsDir } + ); + expect(startResult?.error ?? null).toBeNull(); + + const item = app.mainWindow.locator('.downloads__item'); + await expect(item).toHaveCount(1, { timeout: 20000 }); + await expect( + item.locator('.downloads__item-title-text') + ).toHaveText('E2E Movie'); + await expect(item.locator('.downloads__item-status')).toContainText( + 'Completed', + { timeout: 20000 } + ); + + // The file landed in the authorized folder. + expect(readdirSync(downloadsDir).length).toBeGreaterThan(0); + + // Completed items expose play / reveal / remove actions. + await expect( + item.getByRole('button').filter({ hasText: 'play_arrow' }) + ).toBeVisible(); + await expect( + item.getByRole('button').filter({ hasText: 'folder_open' }) + ).toBeVisible(); + + await item + .getByRole('button') + .filter({ hasText: 'delete' }) + .click(); + await expect( + app.mainWindow.getByText('No downloads yet') + ).toBeVisible(); + } finally { + await closeElectronApp(app); + await fileServer.close(); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts b/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts new file mode 100644 index 000000000..7d214dbf2 --- /dev/null +++ b/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts @@ -0,0 +1,101 @@ +import { + addXtreamPortal, + channelItemByTitle, + clickCategoryByNameExact, + closeElectronApp, + expect, + launchElectronApp, + openWorkspaceSection, + resetMockServers, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; +import { fetchXtreamEpgFixture } from './portal-mock-fixtures'; + +const epgCredentials = { + username: 'epg', + password: 'epg', +}; + +test('@epg @xtream @electron opens the programme dialog from a timeline block and reacts to zoom', async ({ + dataDir, + request, +}) => { + await resetMockServers(request, ['xtream']); + const fixture = await fetchXtreamEpgFixture(request, epgCredentials); + const currentProgram = fixture.shortEpg[0]; + if (!currentProgram) { + throw new Error( + 'Expected the Xtream EPG fixture to include a current program.' + ); + } + const app = await launchElectronApp(dataDir, { env: { TZ: 'UTC' } }); + + try { + await addXtreamPortal(app.mainWindow, { + name: 'Xtream Timeline Interaction', + username: epgCredentials.username, + password: epgCredentials.password, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await clickCategoryByNameExact(app.mainWindow, fixture.categoryName); + + const channelRow = channelItemByTitle( + app.mainWindow, + fixture.stream.name ?? '' + ).first(); + await expect(channelRow).toBeVisible({ timeout: 20000 }); + await channelRow.click(); + + const timeline = app.mainWindow.locator('app-epg-timeline'); + await expect(timeline).toBeVisible({ timeout: 20000 }); + + const nowBlock = timeline + .locator('.epg-timeline__block.is-now') + .first(); + await expect(nowBlock).toBeVisible(); + + // Zooming re-renders the ribbon: block widths grow with px/minute. + const zoomInput = timeline.locator( + '.epg-timeline__zoom input[type="range"]' + ); + await expect(zoomInput).toBeVisible(); + + const blockWidthAt = async (zoom: 'min' | 'max') => { + await zoomInput.evaluate((element, target) => { + const input = element as HTMLInputElement; + input.value = target === 'min' ? input.min : input.max; + input.dispatchEvent(new Event('input', { bubbles: true })); + }, zoom); + const box = await nowBlock.boundingBox(); + return box?.width ?? 0; + }; + + const minZoomWidth = await blockWidthAt('min'); + const maxZoomWidth = await blockWidthAt('max'); + expect(maxZoomWidth).toBeGreaterThan(minZoomWidth); + + // At max zoom the on-air block is wide enough to expose the info + // affordance (hidden on narrow/micro tiers), which opens the shared + // programme-details dialog with the programme metadata. + await nowBlock.locator('.epg-timeline__info').click(); + + const dialog = app.mainWindow.locator('.epg-dialog'); + await expect(dialog).toBeVisible(); + await expect(dialog.locator('.epg-dialog__title')).toHaveText( + currentProgram.title + ); + // An on-air programme offers "watch live" as the primary action. + await expect( + dialog.locator('.epg-dialog__btn--primary') + ).toBeVisible(); + + await dialog.locator('.epg-dialog__close').click(); + await app.mainWindow.waitForSelector('.epg-dialog', { + state: 'detached', + }); + } finally { + await closeElectronApp(app); + } +});