mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
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:
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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user