Files
iptvnator/apps/electron-backend-e2e/src/downloads.e2e.ts
T
4grayandClaude Opus 5.5 078b91bfdf test(perf): type-check the whole electron-backend-e2e project (#1869)
* test(e2e): fix type errors in four Electron E2E specs

- category-management: the poll's message and 15 s timeout were passed to
  toBe(), which takes one argument, so the poll ran with the default 10 s
  timeout and message. Pass them to expect.poll().
- settings: evaluate<void, HTMLVideoElement> set the argument type, not the
  element type; type the callback parameter instead.
- remote-control: keep the poll callback's writes visible to the return and
  fail with a clear error instead of casting null.
- downloads: read the dataset key through its index signature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(perf): fix type errors in the benchmark harness

None of these changes behaviour for a capture the harness produces today:

- cancellation report: a null playlist id on the request record now means
  unknown, like undefined, instead of matching only null DB records.
- main capture: type the worker transport map so its unavailable-reason
  literals stay narrow.
- worker request performance: a parsed capture carries non-null work
  timestamps; only the unavailable fallback reports null.
- Xtream iteration: hand the teardown closure the narrowed app and data
  directory.
- Xtream exact schema: return early when the identity is not an exact
  record instead of re-checking it three times.
- Xtream assembly: reject an unpainted UI explicitly; the null-to-0
  comparison already rejected it.
- Xtream scenario driver: drop the always-null cancellation observer from
  prepare; trigger() installs it.
- Electron process lifecycle: type the child process by the exit
  subscription it uses, so a test double need not return a ChildProcess.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(perf): fix type errors in the benchmark harness specs

The dynamically imported helpers were checked with
assert.equal(typeof fn, 'function'), which does not narrow, so later calls
used fn?.(). assert.ok(typeof fn === 'function') narrows them. The rest
types fixtures by the module under test, keeps closure-assigned callbacks
visible after their callbacks run, and narrows nullable fixture values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(perf): type-check the whole electron-backend-e2e project

Widen the spec config's include from the journey harness to src/**/*.ts
and the root Playwright configs, with no baseline or ignore list, and
update the validation map and the journey checklist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(perf): build a complete renderer fixture in the request-performance spec

The fixture was cast through unknown, so the type check accepted an
incomplete RendererCaptureMetrics. Fill every required field with neutral
values; the summary reads only the probe arrays and heap fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:33:15 +02:00

1207 lines
45 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';
import { navigateWithinWorkspace } from './workspace-route.e2e-support';
async function openDownloadsPage(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Open downloads' }).click();
await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/);
}
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'
);
// A name missing from the icon font renders as text that
// overflows the icon box instead of one glyph.
const missingIcon = missingRow
.getByRole('status')
.locator('mat-icon');
await expect(missingIcon).toHaveText('error_outline');
expect(
await missingIcon.evaluate(
(icon) => icon.scrollWidth <= icon.clientWidth
)
).toBe(true);
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'
);
// The primary action reads "Play" plus its runtime line.
const primaryAction = providerDetail.locator(
'[data-testid="vod-primary-action"]'
);
await expect(primaryAction).toBeVisible();
await expect(primaryAction).toContainText('Play');
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();
}
});
});