mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 02:46:16 -08:00
1217 lines
46 KiB
TypeScript
1217 lines
46 KiB
TypeScript
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
readdirSync,
|
|
readFileSync,
|
|
statSync,
|
|
unlinkSync,
|
|
} from 'fs';
|
|
import { join } from 'path';
|
|
import type { Page } from '@playwright/test';
|
|
import {
|
|
addStalkerPortal,
|
|
addXtreamPortal,
|
|
closeElectronApp,
|
|
createMutableTextServer,
|
|
expect,
|
|
launchElectronApp,
|
|
openSettings,
|
|
resetMockServers,
|
|
saveSettings,
|
|
test,
|
|
waitForStalkerCatalog,
|
|
waitForXtreamWorkspaceReady,
|
|
} from './electron-test-fixtures';
|
|
import { fetchStalkerCategoryFixture } from './portal-mock-fixtures';
|
|
import {
|
|
createThrottledRangeServer,
|
|
createTruncatedDownloadServer,
|
|
getDownloadPlayPaths,
|
|
getStalkerSeriesDownloadTarget,
|
|
installDownloadPlayCapture,
|
|
RANGE_SERVER_ETAG,
|
|
startDownload,
|
|
} from './downloads.e2e-support';
|
|
|
|
async function openDownloadsPage(page: Page): Promise<void> {
|
|
await page.getByRole('button', { name: 'Open downloads' }).click();
|
|
await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/);
|
|
}
|
|
|
|
async function navigateWithinWorkspace(
|
|
page: Page,
|
|
path: string
|
|
): Promise<void> {
|
|
const targetPathname = await page.evaluate((target) => {
|
|
const targetUrl = new URL(window.location.href);
|
|
const workspaceIndex = targetUrl.pathname.lastIndexOf('/workspace');
|
|
const rendererPath =
|
|
workspaceIndex >= 0
|
|
? targetUrl.pathname.slice(0, workspaceIndex)
|
|
: targetUrl.pathname.replace(/\/$/, '');
|
|
|
|
targetUrl.pathname = `${rendererPath}${target}`;
|
|
targetUrl.search = '';
|
|
targetUrl.hash = '';
|
|
window.history.pushState(null, '', targetUrl);
|
|
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
return targetUrl.pathname;
|
|
}, path);
|
|
await page.waitForURL((url) => url.pathname === targetPathname);
|
|
}
|
|
|
|
async function getPlaylistId(page: Page, title: string): Promise<string> {
|
|
const playlists = await page.evaluate(
|
|
async () => (await window.electron?.dbGetAppPlaylistMetas?.()) ?? []
|
|
);
|
|
const playlist = playlists.find((entry) => entry.title === title);
|
|
expect(playlist, `playlist "${title}" should exist`).toBeDefined();
|
|
return playlist?._id ?? '';
|
|
}
|
|
|
|
async function getSeriesTarget(
|
|
page: Page,
|
|
playlistId: string
|
|
): Promise<{ categoryId: number; xtreamId: number }> {
|
|
const target = await page.evaluate(
|
|
async (sourceId) =>
|
|
(await window.electron?.dbGetContent?.(sourceId, 'series'))?.[0] ??
|
|
null,
|
|
playlistId
|
|
);
|
|
expect(target, 'playlist should have an imported series target').not.toBe(
|
|
null
|
|
);
|
|
if (!target) {
|
|
throw new Error('Series target was not imported');
|
|
}
|
|
expect(target.xtream_id).toBeGreaterThan(0);
|
|
expect(target.category_id).toBeGreaterThan(0);
|
|
return {
|
|
categoryId: target.category_id,
|
|
xtreamId: target.xtream_id,
|
|
};
|
|
}
|
|
|
|
async function getVodTarget(
|
|
page: Page,
|
|
playlistId: string
|
|
): Promise<{ categoryId: number; xtreamId: number }> {
|
|
const target = await page.evaluate(
|
|
async (sourceId) =>
|
|
(await window.electron?.dbGetContent?.(sourceId, 'movie'))?.[0] ??
|
|
null,
|
|
playlistId
|
|
);
|
|
expect(target, 'playlist should have an imported VOD target').not.toBe(
|
|
null
|
|
);
|
|
if (!target) {
|
|
throw new Error('VOD target was not imported');
|
|
}
|
|
expect(target.xtream_id).toBeGreaterThan(0);
|
|
expect(target.category_id).toBeGreaterThan(0);
|
|
return {
|
|
categoryId: target.category_id,
|
|
xtreamId: target.xtream_id,
|
|
};
|
|
}
|
|
|
|
async function selectDownloadCoverSize(
|
|
page: Page,
|
|
size: 'small' | 'medium' | 'large'
|
|
): Promise<void> {
|
|
await openSettings(page);
|
|
await page.getByTestId(`cover-size-${size}`).click();
|
|
// Cover size is staged like every other setting now — it reaches the
|
|
// store (and the document dataset) only once the save lands.
|
|
await saveSettings(page);
|
|
await expect
|
|
.poll(() =>
|
|
page.evaluate(
|
|
() => document.documentElement.dataset.coverSize ?? null
|
|
)
|
|
)
|
|
.toBe(size);
|
|
await openDownloadsPage(page);
|
|
}
|
|
|
|
async function measureLibraryCard(
|
|
page: Page
|
|
): Promise<{ gap: number; width: number }> {
|
|
return page
|
|
.locator('article[data-test-id^="download-library-"]')
|
|
.first()
|
|
.evaluate((card) => {
|
|
const grid = card.parentElement;
|
|
if (!grid) {
|
|
throw new Error('Download library card has no grid parent');
|
|
}
|
|
return {
|
|
gap: Number.parseFloat(getComputedStyle(grid).columnGap),
|
|
width: card.getBoundingClientRect().width,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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.getByTestId('downloads-folder')
|
|
).toContainText('e2e-downloads');
|
|
|
|
// Enqueue a download through the same renderer bridge the app uses.
|
|
const playlistId = await getPlaylistId(
|
|
app.mainWindow,
|
|
'Download Portal'
|
|
);
|
|
const startResult = await app.mainWindow.evaluate(
|
|
async ({ url, folder, sourceId }) =>
|
|
window.electron?.downloadsStart?.({
|
|
playlistId: sourceId,
|
|
xtreamId: 4242,
|
|
contentType: 'vod',
|
|
title: 'E2E Movie',
|
|
url,
|
|
downloadFolder: folder,
|
|
}),
|
|
{
|
|
url: fileServer.resourceUrl,
|
|
folder: downloadsDir,
|
|
sourceId: playlistId,
|
|
}
|
|
);
|
|
expect(startResult?.error ?? null).toBeNull();
|
|
expect(startResult?.id).toEqual(expect.any(Number));
|
|
|
|
const card = app.mainWindow.getByTestId(
|
|
`download-library-movie-${startResult?.id}`
|
|
);
|
|
await expect(card).toBeVisible({ timeout: 20000 });
|
|
await expect(
|
|
app.mainWindow.getByRole('heading', {
|
|
name: 'Ready to watch',
|
|
})
|
|
).toBeVisible();
|
|
|
|
// The file landed in the authorized folder.
|
|
const finalPath = join(downloadsDir, 'E2E Movie.mp4');
|
|
expect(readdirSync(downloadsDir)).toEqual(['E2E Movie.mp4']);
|
|
expect(readFileSync(finalPath, 'utf8')).toBe(
|
|
'e2e download payload'
|
|
);
|
|
|
|
// Completed items keep their file actions behind the poster's
|
|
// overflow menu; the card itself stays free of toolbar chrome.
|
|
await expect(
|
|
card.getByRole('button', { name: 'Play: E2E Movie' })
|
|
).toHaveCount(0);
|
|
await expect(
|
|
card.getByText('Offline', { exact: true })
|
|
).toHaveCount(0);
|
|
await expect(
|
|
card.getByText('Download Portal', { exact: true })
|
|
).toHaveCount(0);
|
|
await card
|
|
.getByRole('button', {
|
|
name: 'More actions: E2E Movie',
|
|
})
|
|
.click();
|
|
const sourceHeader = app.mainWindow.locator(
|
|
'.cdk-overlay-pane app-download-source-menu-header'
|
|
);
|
|
await expect(
|
|
sourceHeader.getByText('Source', { exact: true })
|
|
).toBeVisible();
|
|
await expect(sourceHeader).toContainText('Download Portal');
|
|
await expect(
|
|
app.mainWindow.getByRole('menuitem', {
|
|
name: 'Play: E2E Movie',
|
|
})
|
|
).toBeVisible();
|
|
await expect(
|
|
app.mainWindow.getByRole('menuitem', {
|
|
name: 'Show in Folder: E2E Movie',
|
|
})
|
|
).toBeVisible();
|
|
await app.mainWindow.keyboard.press('Escape');
|
|
|
|
// Removing the finalized file must move the persisted completed
|
|
// row out of Ready to watch when offline-detail playback refreshes
|
|
// the authoritative list, then return to Needs attention.
|
|
await card
|
|
.getByRole('button', {
|
|
name: 'Open details: E2E Movie',
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await app.mainWindow.waitForURL((url) =>
|
|
url.pathname.endsWith(`/workspace/downloads/${startResult?.id}`)
|
|
);
|
|
await expect(
|
|
app.mainWindow.getByText('Available offline').first()
|
|
).toBeVisible();
|
|
unlinkSync(finalPath);
|
|
await app.mainWindow
|
|
.getByRole('button', {
|
|
name: 'Play offline',
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await app.mainWindow.waitForURL(/\/workspace\/downloads(?:\?.*)?$/);
|
|
const missingRow = app.mainWindow.getByTestId(
|
|
`download-queue-item-${startResult?.id}`
|
|
);
|
|
await expect(
|
|
app.mainWindow.getByRole('heading', {
|
|
name: 'Needs attention',
|
|
})
|
|
).toBeVisible();
|
|
await expect(missingRow).toBeVisible({ timeout: 20000 });
|
|
await expect(missingRow.getByRole('status')).toContainText(
|
|
'File missing'
|
|
);
|
|
await expect(card).toHaveCount(0);
|
|
await expect(
|
|
missingRow.getByRole('button', { name: 'Play: E2E Movie' })
|
|
).toHaveCount(0);
|
|
await expect(
|
|
missingRow.getByRole('button', {
|
|
name: 'Show in Folder: E2E Movie',
|
|
})
|
|
).toHaveCount(0);
|
|
|
|
await missingRow
|
|
.getByRole('button', {
|
|
name: 'Download E2E Movie again',
|
|
})
|
|
.click();
|
|
await expect(card).toBeVisible({ timeout: 20000 });
|
|
expect(readFileSync(finalPath, 'utf8')).toBe(
|
|
'e2e download payload'
|
|
);
|
|
|
|
await card
|
|
.getByRole('button', {
|
|
name: 'More actions: E2E Movie',
|
|
})
|
|
.click();
|
|
await app.mainWindow
|
|
.getByRole('menuitem', {
|
|
name: 'Remove from manager: E2E Movie',
|
|
})
|
|
.click();
|
|
await expect(
|
|
app.mainWindow.getByRole('heading', {
|
|
name: 'Remove from manager?',
|
|
})
|
|
).toBeVisible();
|
|
await app.mainWindow
|
|
.getByRole('button', {
|
|
name: 'Remove from manager',
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await expect(
|
|
app.mainWindow.getByText('No downloads yet')
|
|
).toBeVisible();
|
|
expect(readFileSync(finalPath, 'utf8')).toBe(
|
|
'e2e download payload'
|
|
);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
await fileServer.close();
|
|
}
|
|
});
|
|
|
|
test('@downloads @electron keeps global and scoped libraries truthful while filters, search, grouping, and scrolling stay functional', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['xtream']);
|
|
const fileServer = await createMutableTextServer('library payload', {
|
|
contentType: 'video/mp4',
|
|
resourcePath: '/media/library-item.mp4',
|
|
});
|
|
// Keep one real transfer alive while route scope changes prove that
|
|
// the shell badge remains global.
|
|
const activeServer = await createThrottledRangeServer(5000);
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await addXtreamPortal(app.mainWindow, {
|
|
name: 'Library Source A',
|
|
username: 'multisrc1',
|
|
password: 'multisrc1',
|
|
});
|
|
await waitForXtreamWorkspaceReady(app.mainWindow);
|
|
await addXtreamPortal(app.mainWindow, {
|
|
name: 'Library Source B',
|
|
username: 'multisrc2',
|
|
password: 'multisrc2',
|
|
});
|
|
await waitForXtreamWorkspaceReady(app.mainWindow);
|
|
|
|
const sourceA = await getPlaylistId(
|
|
app.mainWindow,
|
|
'Library Source A'
|
|
);
|
|
const sourceB = await getPlaylistId(
|
|
app.mainWindow,
|
|
'Library Source B'
|
|
);
|
|
const { xtreamId: seriesXtreamId } = await getSeriesTarget(
|
|
app.mainWindow,
|
|
sourceA
|
|
);
|
|
const { categoryId: vodCategoryId, xtreamId: vodXtreamId } =
|
|
await getVodTarget(app.mainWindow, sourceA);
|
|
await navigateWithinWorkspace(
|
|
app.mainWindow,
|
|
`/workspace/xtreams/${sourceB}/downloads`
|
|
);
|
|
|
|
const downloadsDir = join(dataDir, 'e2e-library-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.getByTestId('downloads-folder')
|
|
).toContainText('e2e-library-downloads');
|
|
|
|
const common = {
|
|
downloadFolder: downloadsDir,
|
|
url: fileServer.resourceUrl,
|
|
};
|
|
const alphaMovieId = await startDownload(app.mainWindow, {
|
|
...common,
|
|
playlistId: sourceA,
|
|
xtreamId: vodXtreamId,
|
|
contentType: 'vod',
|
|
title: 'Alpha Movie',
|
|
});
|
|
await startDownload(app.mainWindow, {
|
|
...common,
|
|
playlistId: sourceA,
|
|
xtreamId: 8201,
|
|
contentType: 'episode',
|
|
seriesXtreamId,
|
|
seasonNumber: 1,
|
|
episodeNumber: 1,
|
|
title: 'Aurora - S01E01 - Arrival',
|
|
});
|
|
const secondSeriesEpisodeId = await startDownload(app.mainWindow, {
|
|
...common,
|
|
playlistId: sourceA,
|
|
xtreamId: 8202,
|
|
contentType: 'episode',
|
|
seriesXtreamId,
|
|
seasonNumber: 1,
|
|
episodeNumber: 2,
|
|
title: 'Aurora - S01E02 - Signal',
|
|
});
|
|
for (let index = 0; index < 6; index += 1) {
|
|
await startDownload(app.mainWindow, {
|
|
...common,
|
|
playlistId: sourceA,
|
|
xtreamId: 8300 + index,
|
|
contentType: 'vod',
|
|
title: `Alpha Archive ${index + 1}`,
|
|
});
|
|
}
|
|
const betaMovieId = await startDownload(app.mainWindow, {
|
|
...common,
|
|
playlistId: sourceB,
|
|
xtreamId: 9101,
|
|
contentType: 'vod',
|
|
title: 'Beta Movie',
|
|
});
|
|
for (let index = 0; index < 4; index += 1) {
|
|
await startDownload(app.mainWindow, {
|
|
...common,
|
|
playlistId: sourceB,
|
|
xtreamId: 9300 + index,
|
|
contentType: 'vod',
|
|
title: `Beta Archive ${index + 1}`,
|
|
});
|
|
}
|
|
|
|
const cards = app.mainWindow.locator(
|
|
'article[data-test-id^="download-library-"]'
|
|
);
|
|
await expect(cards).toHaveCount(5, { timeout: 30000 });
|
|
await expect(
|
|
app.mainWindow.getByTestId(
|
|
`download-library-movie-${betaMovieId}`
|
|
)
|
|
).toBeVisible();
|
|
await expect(
|
|
app.mainWindow.getByTestId(
|
|
`download-library-movie-${alphaMovieId}`
|
|
)
|
|
).toHaveCount(0);
|
|
|
|
const activeMovieId = await startDownload(app.mainWindow, {
|
|
downloadFolder: downloadsDir,
|
|
playlistId: sourceA,
|
|
xtreamId: 8401,
|
|
contentType: 'vod',
|
|
title: 'Alpha Active Movie',
|
|
url: activeServer.url,
|
|
});
|
|
const activeItem = app.mainWindow.getByTestId(
|
|
`download-queue-item-${activeMovieId}`
|
|
);
|
|
const globalBadge = app.mainWindow.getByTestId(
|
|
'global-download-count'
|
|
);
|
|
await expect(globalBadge).toHaveText('1', { timeout: 20000 });
|
|
await expect(activeItem).toHaveCount(0);
|
|
|
|
await navigateWithinWorkspace(
|
|
app.mainWindow,
|
|
`/workspace/xtreams/${sourceA}/downloads`
|
|
);
|
|
await expect(globalBadge).toHaveText('1');
|
|
await expect(cards).toHaveCount(8);
|
|
await expect(activeItem).toBeVisible();
|
|
await expect(
|
|
activeItem.locator('.download-queue__status')
|
|
).toContainText('Downloading', { timeout: 20000 });
|
|
await expect(
|
|
app.mainWindow.getByTestId(
|
|
`download-library-movie-${alphaMovieId}`
|
|
)
|
|
).toBeVisible();
|
|
await expect(
|
|
app.mainWindow.getByTestId(
|
|
`download-library-movie-${betaMovieId}`
|
|
)
|
|
).toHaveCount(0);
|
|
|
|
const alphaMovieCard = app.mainWindow.getByTestId(
|
|
`download-library-movie-${alphaMovieId}`
|
|
);
|
|
await installDownloadPlayCapture(app);
|
|
await alphaMovieCard
|
|
.getByRole('button', {
|
|
name: 'Open details: Alpha Movie',
|
|
exact: true,
|
|
})
|
|
.click();
|
|
const expectedOfflinePath = `/workspace/xtreams/${sourceA}/downloads/${alphaMovieId}`;
|
|
await app.mainWindow.waitForURL((url) =>
|
|
url.pathname.endsWith(expectedOfflinePath)
|
|
);
|
|
await expect(
|
|
app.mainWindow.getByText('Available offline').first()
|
|
).toBeVisible();
|
|
const playOffline = app.mainWindow.getByRole('button', {
|
|
name: 'Play offline',
|
|
exact: true,
|
|
});
|
|
await expect(playOffline).toBeVisible();
|
|
await expect(
|
|
app.mainWindow.getByRole('button', {
|
|
name: /Play from (?:this )?source/i,
|
|
})
|
|
).toHaveCount(0);
|
|
await expect(
|
|
app.mainWindow.locator('app-workspace-shell-context-sidebar')
|
|
).toHaveCount(0);
|
|
|
|
await playOffline.click();
|
|
await expect
|
|
.poll(async () => (await getDownloadPlayPaths(app)).length)
|
|
.toBe(1);
|
|
expect(await getDownloadPlayPaths(app)).toEqual([
|
|
join(downloadsDir, 'Alpha Movie.mp4'),
|
|
]);
|
|
|
|
const viewInPortal = app.mainWindow.getByRole('button', {
|
|
name: 'View in portal',
|
|
exact: true,
|
|
});
|
|
await expect(viewInPortal).toBeEnabled({ timeout: 20000 });
|
|
await viewInPortal.click();
|
|
const expectedVodPath = `/workspace/xtreams/${sourceA}/vod/${vodCategoryId}/${vodXtreamId}`;
|
|
await app.mainWindow.waitForURL((url) =>
|
|
url.pathname.endsWith(expectedVodPath)
|
|
);
|
|
await expect(
|
|
app.mainWindow.locator('app-workspace-shell-context-sidebar')
|
|
).toBeVisible();
|
|
const providerDetail = app.mainWindow.locator(
|
|
'main app-portal-detail-shell'
|
|
);
|
|
await expect(
|
|
providerDetail.getByRole('button', {
|
|
name: 'Play',
|
|
exact: true,
|
|
})
|
|
).toBeVisible();
|
|
await expect(
|
|
providerDetail.getByText('Offline', { exact: true })
|
|
).toHaveCount(0);
|
|
await expect(
|
|
providerDetail.getByRole('button', {
|
|
name: 'Play Local',
|
|
exact: true,
|
|
})
|
|
).toHaveCount(0);
|
|
await expect(
|
|
providerDetail.getByRole('button', {
|
|
name: 'Download',
|
|
exact: true,
|
|
})
|
|
).toHaveCount(0);
|
|
await expect(
|
|
providerDetail.getByRole('button', {
|
|
name: /Play from (?:this )?source/i,
|
|
})
|
|
).toHaveCount(0);
|
|
|
|
await openDownloadsPage(app.mainWindow);
|
|
await expect(globalBadge).toHaveText('1');
|
|
await expect(cards).toHaveCount(13);
|
|
|
|
await selectDownloadCoverSize(app.mainWindow, 'small');
|
|
await expect(cards).toHaveCount(13);
|
|
const smallCover = await measureLibraryCard(app.mainWindow);
|
|
await selectDownloadCoverSize(app.mainWindow, 'medium');
|
|
await expect(cards).toHaveCount(13);
|
|
const mediumCover = await measureLibraryCard(app.mainWindow);
|
|
await selectDownloadCoverSize(app.mainWindow, 'large');
|
|
await expect(cards).toHaveCount(13);
|
|
const largeCover = await measureLibraryCard(app.mainWindow);
|
|
|
|
expect(smallCover.gap).toBe(12);
|
|
expect(mediumCover.gap).toBe(16);
|
|
expect(largeCover.gap).toBe(20);
|
|
expect(mediumCover.width).toBeGreaterThan(smallCover.width);
|
|
expect(largeCover.width).toBeGreaterThan(mediumCover.width);
|
|
|
|
const seriesCard = app.mainWindow.getByTestId(
|
|
`download-library-series-${sourceA}-${seriesXtreamId}`
|
|
);
|
|
await expect(seriesCard).toContainText('2 episodes');
|
|
await expect(
|
|
seriesCard.getByText('Library Source A', { exact: true })
|
|
).toHaveCount(0);
|
|
await seriesCard
|
|
.getByRole('button', {
|
|
name: 'More actions: Aurora',
|
|
})
|
|
.click();
|
|
await expect(
|
|
app.mainWindow.locator(
|
|
'.cdk-overlay-pane app-download-source-menu-header'
|
|
)
|
|
).toContainText('Library Source A');
|
|
await app.mainWindow.keyboard.press('Escape');
|
|
|
|
await app.mainWindow
|
|
.getByTestId('downloads-filter-in-progress')
|
|
.click();
|
|
await expect(cards).toHaveCount(0);
|
|
await expect(activeItem).toBeVisible();
|
|
await expect(
|
|
app.mainWindow.getByTestId('downloads-filter-in-progress')
|
|
).toContainText('1');
|
|
await app.mainWindow.getByTestId('downloads-filter-all').click();
|
|
await expect(cards).toHaveCount(13);
|
|
|
|
await seriesCard
|
|
.getByRole('button', {
|
|
name: 'Open details: Aurora',
|
|
exact: true,
|
|
})
|
|
.click();
|
|
const expectedSeriesPath = `/workspace/downloads/${secondSeriesEpisodeId}`;
|
|
await app.mainWindow.waitForURL((url) =>
|
|
url.pathname.endsWith(expectedSeriesPath)
|
|
);
|
|
await expect(
|
|
app.mainWindow.getByText('Available offline').first()
|
|
).toBeVisible();
|
|
await expect(
|
|
app.mainWindow.locator('[data-testid^="offline-episode-"]')
|
|
).toHaveCount(2);
|
|
await expect(
|
|
app.mainWindow.getByRole('button', {
|
|
name: /Play offline: S01E01/,
|
|
})
|
|
).toBeVisible();
|
|
await expect(
|
|
app.mainWindow.getByRole('button', {
|
|
name: /Play offline: S01E02/,
|
|
})
|
|
).toBeVisible();
|
|
await expect(
|
|
app.mainWindow.locator('app-workspace-shell-context-sidebar')
|
|
).toHaveCount(0);
|
|
|
|
await openDownloadsPage(app.mainWindow);
|
|
await expect(seriesCard).toBeVisible();
|
|
|
|
await activeItem
|
|
.getByRole('button', { name: 'Pause Alpha Active Movie' })
|
|
.click();
|
|
await expect(
|
|
activeItem.locator('.download-queue__status')
|
|
).toContainText('Paused', { timeout: 20000 });
|
|
await expect(globalBadge).toHaveCount(0);
|
|
|
|
await app.mainWindow.getByTestId('downloads-filter-series').click();
|
|
await expect(cards).toHaveCount(1);
|
|
await expect(seriesCard).toBeVisible();
|
|
await app.mainWindow.getByTestId('downloads-filter-movie').click();
|
|
await expect(cards).toHaveCount(12);
|
|
await expect(seriesCard).toHaveCount(0);
|
|
await app.mainWindow.getByTestId('downloads-filter-all').click();
|
|
|
|
const search = app.mainWindow.getByRole('searchbox', {
|
|
name: 'Search',
|
|
});
|
|
await search.fill('Beta Movie');
|
|
await expect
|
|
.poll(
|
|
() => new URL(app.mainWindow.url()).searchParams.get('q'),
|
|
{ timeout: 10000 }
|
|
)
|
|
.toBe('Beta Movie');
|
|
await expect(cards).toHaveCount(1);
|
|
await expect(
|
|
app.mainWindow.getByTestId(
|
|
`download-library-movie-${betaMovieId}`
|
|
)
|
|
).toBeVisible();
|
|
await search.fill('');
|
|
await expect
|
|
.poll(
|
|
() => new URL(app.mainWindow.url()).searchParams.get('q'),
|
|
{ timeout: 10000 }
|
|
)
|
|
.toBeNull();
|
|
await expect(cards).toHaveCount(13);
|
|
|
|
const header = app.mainWindow.getByTestId('downloads-header');
|
|
const filters = app.mainWindow.getByTestId('downloads-filters');
|
|
const content = app.mainWindow.getByTestId('downloads-content');
|
|
const headerBefore = await header.boundingBox();
|
|
const filtersBefore = await filters.boundingBox();
|
|
const scroll = await content.evaluate((element) => {
|
|
const outer =
|
|
element.closest<HTMLElement>('.workspace-content');
|
|
element.scrollTop = element.scrollHeight;
|
|
return {
|
|
clientHeight: element.clientHeight,
|
|
outerScrollTop: outer?.scrollTop ?? -1,
|
|
scrollHeight: element.scrollHeight,
|
|
scrollTop: element.scrollTop,
|
|
};
|
|
});
|
|
const headerAfter = await header.boundingBox();
|
|
const filtersAfter = await filters.boundingBox();
|
|
|
|
expect(scroll.scrollHeight).toBeGreaterThan(scroll.clientHeight);
|
|
expect(scroll.scrollTop).toBeGreaterThan(0);
|
|
expect(scroll.outerScrollTop).toBe(0);
|
|
expect(headerAfter?.y).toBe(headerBefore?.y);
|
|
expect(filtersAfter?.y).toBe(filtersBefore?.y);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
await activeServer.close();
|
|
await fileServer.close();
|
|
}
|
|
});
|
|
|
|
test('@downloads @stalker @electron shows only downloaded non-contiguous series episodes offline', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['stalker']);
|
|
const seriesFixture = await fetchStalkerCategoryFixture(
|
|
request,
|
|
'series'
|
|
);
|
|
const { id: seriesId, title: providerTitle } =
|
|
getStalkerSeriesDownloadTarget(seriesFixture.items[0]);
|
|
|
|
const fileServer = await createMutableTextServer(
|
|
'stalker offline episode',
|
|
{
|
|
contentType: 'video/mp4',
|
|
resourcePath: '/media/stalker-series-episode.mp4',
|
|
}
|
|
);
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await addStalkerPortal(app.mainWindow, {
|
|
name: 'Stalker Download Source',
|
|
});
|
|
await waitForStalkerCatalog(app.mainWindow);
|
|
const playlistId = await getPlaylistId(
|
|
app.mainWindow,
|
|
'Stalker Download Source'
|
|
);
|
|
await openDownloadsPage(app.mainWindow);
|
|
|
|
const downloadsDir = join(dataDir, 'e2e-stalker-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();
|
|
|
|
const common = {
|
|
contentType: 'episode' as const,
|
|
downloadFolder: downloadsDir,
|
|
playlistId,
|
|
seasonNumber: 1,
|
|
seriesXtreamId: seriesId,
|
|
url: fileServer.resourceUrl,
|
|
};
|
|
const firstEpisodeId = await startDownload(app.mainWindow, {
|
|
...common,
|
|
episodeNumber: 1,
|
|
title: `${providerTitle} - S01E01 - First`,
|
|
xtreamId: seriesId * 100 + 1,
|
|
});
|
|
const thirdEpisodeId = await startDownload(app.mainWindow, {
|
|
...common,
|
|
episodeNumber: 3,
|
|
title: `${providerTitle} - S01E03 - Third`,
|
|
xtreamId: seriesId * 100 + 3,
|
|
});
|
|
|
|
const seriesCard = app.mainWindow.getByTestId(
|
|
`download-library-series-${playlistId}-${seriesId}`
|
|
);
|
|
await expect(seriesCard).toBeVisible({ timeout: 30000 });
|
|
await expect(seriesCard).toContainText('2 episodes');
|
|
await seriesCard
|
|
.getByRole('button', {
|
|
name: `Open details: ${providerTitle}`,
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await app.mainWindow.waitForURL((url) =>
|
|
url.pathname.endsWith(`/workspace/downloads/${thirdEpisodeId}`)
|
|
);
|
|
|
|
const offlineEpisodes = app.mainWindow.locator(
|
|
'[data-testid^="offline-episode-"]'
|
|
);
|
|
await expect(offlineEpisodes).toHaveCount(2);
|
|
await expect(
|
|
app.mainWindow.locator(
|
|
`[data-testid="offline-episode-${firstEpisodeId}"]`
|
|
)
|
|
).toContainText('S01E01');
|
|
await expect(
|
|
app.mainWindow.locator(
|
|
`[data-testid="offline-episode-${thirdEpisodeId}"]`
|
|
)
|
|
).toContainText('S01E03');
|
|
await expect(
|
|
app.mainWindow.getByText('S01E02', { exact: true })
|
|
).toHaveCount(0);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
await fileServer.close();
|
|
}
|
|
});
|
|
|
|
test('@downloads @electron deletes retained failed partials when removing one item or clearing finished downloads', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['xtream']);
|
|
const truncatedServer = await createTruncatedDownloadServer();
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await addXtreamPortal(app.mainWindow, {
|
|
name: 'Partial Cleanup Portal',
|
|
username: 'user1',
|
|
password: 'pass1',
|
|
});
|
|
await waitForXtreamWorkspaceReady(app.mainWindow);
|
|
await openDownloadsPage(app.mainWindow);
|
|
|
|
const downloadsDir = join(dataDir, 'e2e-partial-cleanup');
|
|
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.getByTestId('downloads-folder')
|
|
).toContainText('e2e-partial-cleanup');
|
|
|
|
const playlistId = await getPlaylistId(
|
|
app.mainWindow,
|
|
'Partial Cleanup Portal'
|
|
);
|
|
const removeId = await startDownload(app.mainWindow, {
|
|
playlistId,
|
|
xtreamId: 9501,
|
|
contentType: 'vod',
|
|
title: 'Remove Retained Partial',
|
|
url: truncatedServer.url,
|
|
downloadFolder: downloadsDir,
|
|
});
|
|
const clearId = await startDownload(app.mainWindow, {
|
|
playlistId,
|
|
xtreamId: 9502,
|
|
contentType: 'vod',
|
|
title: 'Clear Retained Partial',
|
|
url: truncatedServer.url,
|
|
downloadFolder: downloadsDir,
|
|
});
|
|
|
|
for (const id of [removeId, clearId]) {
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
app.mainWindow.evaluate(
|
|
async (downloadId) =>
|
|
(
|
|
await window.electron?.downloadsGet?.(
|
|
downloadId
|
|
)
|
|
)?.status ?? null,
|
|
id
|
|
),
|
|
{ timeout: 30000 }
|
|
)
|
|
.toBe('failed');
|
|
}
|
|
|
|
const failedRows = await app.mainWindow.evaluate(
|
|
async (ids) =>
|
|
Promise.all(
|
|
ids.map((id) => window.electron?.downloadsGet?.(id))
|
|
),
|
|
[removeId, clearId]
|
|
);
|
|
const partialPaths = failedRows.map((row) => {
|
|
expect(row?.filePath).toEqual(expect.any(String));
|
|
expect(row?.bytesDownloaded).toBe(
|
|
truncatedServer.payload.length
|
|
);
|
|
expect(row?.totalBytes).toBe(truncatedServer.totalBytes);
|
|
return `${row?.filePath}.part`;
|
|
});
|
|
for (const partialPath of partialPaths) {
|
|
expect(existsSync(partialPath)).toBe(true);
|
|
expect(statSync(partialPath).size).toBe(
|
|
truncatedServer.payload.length
|
|
);
|
|
}
|
|
|
|
const removeItem = app.mainWindow.getByTestId(
|
|
`download-queue-item-${removeId}`
|
|
);
|
|
await expect(removeItem).toBeVisible();
|
|
await removeItem
|
|
.getByRole('button', {
|
|
name: 'More actions for Remove Retained Partial',
|
|
})
|
|
.click();
|
|
await app.mainWindow
|
|
.getByRole('menuitem', {
|
|
name: 'Remove Remove Retained Partial from manager',
|
|
})
|
|
.click();
|
|
await expect(
|
|
app.mainWindow.getByRole('heading', {
|
|
name: 'Remove partial download?',
|
|
})
|
|
).toBeVisible();
|
|
await app.mainWindow
|
|
.getByRole('button', {
|
|
name: 'Remove from manager',
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await expect(removeItem).toHaveCount(0);
|
|
await expect
|
|
.poll(() =>
|
|
app.mainWindow.evaluate(
|
|
async (id) =>
|
|
(await window.electron?.downloadsGet?.(id)) ?? null,
|
|
removeId
|
|
)
|
|
)
|
|
.toBeNull();
|
|
expect(existsSync(partialPaths[0])).toBe(false);
|
|
expect(existsSync(partialPaths[1])).toBe(true);
|
|
|
|
await app.mainWindow
|
|
.getByTestId('downloads-clear-finished')
|
|
.click();
|
|
await expect(
|
|
app.mainWindow.getByRole('heading', {
|
|
name: 'Clear finished downloads?',
|
|
})
|
|
).toBeVisible();
|
|
await app.mainWindow
|
|
.getByRole('dialog', {
|
|
name: 'Clear finished downloads?',
|
|
})
|
|
.getByRole('button', {
|
|
name: 'Clear finished',
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await expect(
|
|
app.mainWindow.getByTestId(`download-queue-item-${clearId}`)
|
|
).toHaveCount(0);
|
|
await expect
|
|
.poll(() =>
|
|
app.mainWindow.evaluate(
|
|
async (id) =>
|
|
(await window.electron?.downloadsGet?.(id)) ?? null,
|
|
clearId
|
|
)
|
|
)
|
|
.toBeNull();
|
|
expect(existsSync(partialPaths[1])).toBe(false);
|
|
await expect(
|
|
app.mainWindow.getByText('No downloads yet')
|
|
).toBeVisible();
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
await truncatedServer.close();
|
|
}
|
|
});
|
|
|
|
test('@downloads @electron pauses a download, retains the partial, and resumes it with an HTTP Range request', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['xtream']);
|
|
const rangeServer = await createThrottledRangeServer();
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await addXtreamPortal(app.mainWindow, {
|
|
name: 'Pause Portal',
|
|
username: 'user1',
|
|
password: 'pass1',
|
|
});
|
|
await waitForXtreamWorkspaceReady(app.mainWindow);
|
|
await openDownloadsPage(app.mainWindow);
|
|
|
|
const downloadsDir = join(dataDir, 'e2e-pause-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.getByTestId('downloads-folder')
|
|
).toContainText('e2e-pause-downloads');
|
|
|
|
const playlistId = await getPlaylistId(
|
|
app.mainWindow,
|
|
'Pause Portal'
|
|
);
|
|
const startResult = await app.mainWindow.evaluate(
|
|
async ({ url, folder, sourceId }) =>
|
|
window.electron?.downloadsStart?.({
|
|
playlistId: sourceId,
|
|
xtreamId: 7373,
|
|
contentType: 'vod',
|
|
title: 'E2E Pause Movie',
|
|
url,
|
|
downloadFolder: folder,
|
|
}),
|
|
{
|
|
url: rangeServer.url,
|
|
folder: downloadsDir,
|
|
sourceId: playlistId,
|
|
}
|
|
);
|
|
expect(startResult?.error ?? null).toBeNull();
|
|
expect(startResult?.id).toEqual(expect.any(Number));
|
|
|
|
const item = app.mainWindow.getByTestId(
|
|
`download-queue-item-${startResult?.id}`
|
|
);
|
|
await expect(item).toBeVisible({ timeout: 20000 });
|
|
await expect(item.locator('.download-queue__status')).toContainText(
|
|
'Downloading',
|
|
{ timeout: 20000 }
|
|
);
|
|
|
|
// Pause mid-transfer: the row flips to Paused and only a .part
|
|
// file exists on disk (final file absent, progress retained).
|
|
await item
|
|
.getByRole('button', { name: 'Pause E2E Pause Movie' })
|
|
.click();
|
|
await expect(item.locator('.download-queue__status')).toContainText(
|
|
'Paused',
|
|
{ timeout: 20000 }
|
|
);
|
|
const pausedFiles = readdirSync(downloadsDir);
|
|
expect(pausedFiles).toEqual(['E2E Pause Movie.mp4.part']);
|
|
const pausedBytes = statSync(
|
|
join(downloadsDir, 'E2E Pause Movie.mp4.part')
|
|
).size;
|
|
expect(pausedBytes).toBeGreaterThan(0);
|
|
expect(pausedBytes).toBeLessThan(rangeServer.payload.length);
|
|
|
|
// Resume: the runtime must continue via Range/If-Range instead of
|
|
// restarting, and the assembled file must match the payload.
|
|
await item
|
|
.getByRole('button', { name: 'Resume E2E Pause Movie' })
|
|
.click();
|
|
await expect(
|
|
app.mainWindow.getByTestId(
|
|
`download-library-movie-${startResult?.id}`
|
|
)
|
|
).toBeVisible({ timeout: 30000 });
|
|
await expect(
|
|
app.mainWindow.getByRole('heading', {
|
|
name: 'Ready to watch',
|
|
})
|
|
).toBeVisible();
|
|
|
|
const resumeRequest = rangeServer.requests.find(
|
|
(entry) => entry.range
|
|
);
|
|
expect(resumeRequest?.range).toMatch(/^bytes=\d+-$/);
|
|
expect(resumeRequest?.ifRange).toBe(RANGE_SERVER_ETAG);
|
|
|
|
expect(readdirSync(downloadsDir)).toEqual(['E2E Pause Movie.mp4']);
|
|
const finalFile = readFileSync(
|
|
join(downloadsDir, 'E2E Pause Movie.mp4')
|
|
);
|
|
expect(finalFile.equals(rangeServer.payload)).toBe(true);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
await rangeServer.close();
|
|
}
|
|
});
|
|
});
|