Skip to content
Merged
166 changes: 166 additions & 0 deletions apps/electron-backend-e2e/src/downloads.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
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(?:\?.*)?$/);
}

/**
* On a cold profile the renderer can query SQLite while the DB worker is
* still creating tables ("database is locked" / "no such table" on slow CI
* runners), which leaves the downloads page on its skeleton forever. Wait
* until a playlist read succeeds before navigating.
*/
async function waitForDatabaseReady(page: Page): Promise<void> {
await expect
.poll(
() =>
page.evaluate(async () => {
const electron = window.electron;
if (!electron) {
return false;
}
try {
await (electron.dbGetAppPlaylistMetas?.() ??
electron.dbGetAppPlaylists?.());
return true;
} catch {
return false;
}
}),
{ timeout: 30000 }
)
.toBe(true);
}

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 waitForDatabaseReady(app.mainWindow);
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();
}
});
});
101 changes: 101 additions & 0 deletions apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
Loading
Loading