test(e2e): add downloads page and EPG timeline interaction coverage

Downloads: empty state without sources, and a full lifecycle - authorize a
folder via a stubbed native dialog, download from a local server, verify
the completed item and file on disk, remove it from the UI. Timeline: zoom
changes block widths and the on-air info affordance opens the programme
dialog with the correct title and watch-live action.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-07-04 11:12:35 +02:00
1 parent be2730edfa
commit d2b95442ed
2 files changed
+238

No files matched your search

@@ -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<void> {
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<ReturnType<typeof dialog.showOpenDialog>>;
}, 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();
}
});
});
@@ -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);
}
});