mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
feat(downloads): finish manager MVP
This commit is contained in:
1 parent
eb55c17a05
commit
bcc2e36a05
48 files changed
+4145
-1844
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { mkdirSync, readdirSync, readFileSync, statSync } from 'fs';
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'fs';
|
||||
import { createServer } from 'http';
|
||||
import type { AddressInfo } from 'net';
|
||||
import { join } from 'path';
|
||||
@@ -19,6 +19,75 @@ async function openDownloadsPage(page: Page): Promise<void> {
|
||||
await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/);
|
||||
}
|
||||
|
||||
async function navigateWithinWorkspace(
|
||||
page: Page,
|
||||
path: string
|
||||
): Promise<void> {
|
||||
await page.evaluate((target) => {
|
||||
window.history.pushState(null, '', target);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}, path);
|
||||
await page.waitForURL((url) => url.pathname === path);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
interface DownloadSeed {
|
||||
contentType: 'episode' | 'vod';
|
||||
downloadFolder: string;
|
||||
episodeNumber?: number;
|
||||
playlistId: string;
|
||||
seasonNumber?: number;
|
||||
seriesXtreamId?: number;
|
||||
title: string;
|
||||
url: string;
|
||||
xtreamId: number;
|
||||
}
|
||||
|
||||
async function startDownload(page: Page, seed: DownloadSeed): Promise<number> {
|
||||
const result = await page.evaluate(
|
||||
async (payload) => window.electron?.downloadsStart?.(payload),
|
||||
seed
|
||||
);
|
||||
expect(result?.error ?? null).toBeNull();
|
||||
expect(result?.id).toEqual(expect.any(Number));
|
||||
if (typeof result?.id !== 'number') {
|
||||
throw new Error(`Download did not return an id: ${seed.title}`);
|
||||
}
|
||||
return result.id;
|
||||
}
|
||||
|
||||
interface RangeServerRequest {
|
||||
ifRange?: string;
|
||||
range?: string;
|
||||
@@ -31,6 +100,13 @@ interface ThrottledRangeServer {
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface TruncatedDownloadServer {
|
||||
close: () => Promise<void>;
|
||||
payload: Buffer;
|
||||
totalBytes: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const RANGE_SERVER_ETAG = '"e2e-range-etag"';
|
||||
|
||||
/**
|
||||
@@ -38,7 +114,9 @@ const RANGE_SERVER_ETAG = '"e2e-range-etag"';
|
||||
* window to pause mid-transfer, and answers Range requests with an immediate
|
||||
* 206 so the resumed transfer finishes fast. Records Range/If-Range headers.
|
||||
*/
|
||||
async function createThrottledRangeServer(): Promise<ThrottledRangeServer> {
|
||||
async function createThrottledRangeServer(
|
||||
chunkIntervalMs = 150
|
||||
): Promise<ThrottledRangeServer> {
|
||||
const payload = Buffer.from(
|
||||
Array.from({ length: 96 * 1024 }, (_, index) =>
|
||||
String(index % 10)
|
||||
@@ -85,7 +163,7 @@ async function createThrottledRangeServer(): Promise<ThrottledRangeServer> {
|
||||
}
|
||||
res.write(payload.subarray(sent, sent + 2 * 1024));
|
||||
sent += 2 * 1024;
|
||||
}, 150);
|
||||
}, chunkIntervalMs);
|
||||
res.on('close', () => clearInterval(timer));
|
||||
});
|
||||
|
||||
@@ -103,6 +181,37 @@ async function createThrottledRangeServer(): Promise<ThrottledRangeServer> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends a chunked response cleanly while Content-Range advertises a larger
|
||||
* representation. The runtime therefore retains the valid .part for a Range
|
||||
* retry instead of treating it as an unusable transport failure.
|
||||
*/
|
||||
async function createTruncatedDownloadServer(): Promise<TruncatedDownloadServer> {
|
||||
const payload = Buffer.alloc(16 * 1024, 7);
|
||||
const totalBytes = payload.length * 4;
|
||||
const server = createServer((_req, res) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Range': `bytes 0-${payload.length - 1}/${totalBytes}`,
|
||||
'Content-Type': 'video/mp4',
|
||||
ETag: '"e2e-truncated-etag"',
|
||||
});
|
||||
res.end(payload);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) =>
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
);
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
close: () =>
|
||||
new Promise<void>((resolve) => server.close(() => resolve())),
|
||||
payload,
|
||||
totalBytes,
|
||||
url: `http://127.0.0.1:${port}/media/e2e-truncated-movie.mp4`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -147,9 +256,7 @@ test.describe('Electron 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'
|
||||
)
|
||||
app.mainWindow.getByText('Add a playlist to start downloading')
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
@@ -198,55 +305,549 @@ test.describe('Electron Downloads', () => {
|
||||
.getByRole('button', { name: 'Change Folder' })
|
||||
.click();
|
||||
await expect(
|
||||
app.mainWindow.locator('.downloads__folder-inline-path')
|
||||
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 }) =>
|
||||
async ({ url, folder, sourceId }) =>
|
||||
window.electron?.downloadsStart?.({
|
||||
playlistId: 'e2e-playlist',
|
||||
playlistId: sourceId,
|
||||
xtreamId: 4242,
|
||||
contentType: 'vod',
|
||||
title: 'E2E Movie',
|
||||
url,
|
||||
downloadFolder: folder,
|
||||
}),
|
||||
{ url: fileServer.resourceUrl, folder: downloadsDir }
|
||||
{
|
||||
url: fileServer.resourceUrl,
|
||||
folder: downloadsDir,
|
||||
sourceId: playlistId,
|
||||
}
|
||||
);
|
||||
expect(startResult?.error ?? null).toBeNull();
|
||||
expect(startResult?.id).toEqual(expect.any(Number));
|
||||
|
||||
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 }
|
||||
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.
|
||||
expect(readdirSync(downloadsDir).length).toBeGreaterThan(0);
|
||||
const finalPath = join(downloadsDir, 'E2E Movie.mp4');
|
||||
expect(readdirSync(downloadsDir)).toEqual(['E2E Movie.mp4']);
|
||||
expect(readFileSync(finalPath, 'utf8')).toBe(
|
||||
'e2e download payload'
|
||||
);
|
||||
|
||||
// Completed items expose play / reveal / remove actions.
|
||||
await expect(
|
||||
item.getByRole('button').filter({ hasText: 'play_arrow' })
|
||||
card.getByRole('button', { name: 'Play: E2E Movie' }).last()
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
item.getByRole('button').filter({ hasText: 'folder_open' })
|
||||
card.getByRole('button', {
|
||||
name: 'Show in Folder: E2E Movie',
|
||||
})
|
||||
).toBeVisible();
|
||||
|
||||
await item
|
||||
.getByRole('button')
|
||||
.filter({ hasText: 'delete' })
|
||||
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 { categoryId: seriesCategoryId, xtreamId: seriesXtreamId } =
|
||||
await getSeriesTarget(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: 8101,
|
||||
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',
|
||||
});
|
||||
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);
|
||||
|
||||
await openDownloadsPage(app.mainWindow);
|
||||
await expect(globalBadge).toHaveText('1');
|
||||
await expect(cards).toHaveCount(13);
|
||||
const seriesCard = app.mainWindow.getByTestId(
|
||||
`download-library-series-${sourceA}-${seriesXtreamId}`
|
||||
);
|
||||
await expect(seriesCard).toContainText('2 episodes');
|
||||
await expect(seriesCard).toContainText('Library Source A');
|
||||
|
||||
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/xtreams/${sourceA}/series/${seriesCategoryId}/${seriesXtreamId}`;
|
||||
await app.mainWindow.waitForURL((url) =>
|
||||
url.pathname.endsWith(expectedSeriesPath)
|
||||
);
|
||||
await openDownloadsPage(app.mainWindow);
|
||||
await expect(seriesCard).toBeVisible();
|
||||
|
||||
const episodeButton = seriesCard.getByRole('button', {
|
||||
name: 'Open downloaded episodes: Aurora',
|
||||
});
|
||||
await episodeButton.focus();
|
||||
await episodeButton.press('Enter');
|
||||
await expect(
|
||||
app.mainWindow.getByRole('heading', { name: 'Aurora' })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
app.mainWindow.locator(
|
||||
'[data-test-id^="downloaded-series-episode-"]'
|
||||
)
|
||||
).toHaveCount(2);
|
||||
const closeDialog = app.mainWindow.getByTestId(
|
||||
'downloaded-series-close'
|
||||
);
|
||||
await expect(closeDialog).toHaveAccessibleName('Close');
|
||||
await closeDialog.click();
|
||||
|
||||
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 @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: '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 fileServer.close();
|
||||
await truncatedServer.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -280,26 +881,37 @@ test.describe('Electron Downloads', () => {
|
||||
.getByRole('button', { name: 'Change Folder' })
|
||||
.click();
|
||||
await expect(
|
||||
app.mainWindow.locator('.downloads__folder-inline-path')
|
||||
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 }) =>
|
||||
async ({ url, folder, sourceId }) =>
|
||||
window.electron?.downloadsStart?.({
|
||||
playlistId: 'e2e-playlist',
|
||||
playlistId: sourceId,
|
||||
xtreamId: 7373,
|
||||
contentType: 'vod',
|
||||
title: 'E2E Pause Movie',
|
||||
url,
|
||||
downloadFolder: folder,
|
||||
}),
|
||||
{ url: rangeServer.url, folder: downloadsDir }
|
||||
{
|
||||
url: rangeServer.url,
|
||||
folder: downloadsDir,
|
||||
sourceId: playlistId,
|
||||
}
|
||||
);
|
||||
expect(startResult?.error ?? null).toBeNull();
|
||||
expect(startResult?.id).toEqual(expect.any(Number));
|
||||
|
||||
const item = app.mainWindow.locator('.downloads__item');
|
||||
await expect(item).toHaveCount(1, { timeout: 20000 });
|
||||
await expect(item.locator('.downloads__item-status')).toContainText(
|
||||
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 }
|
||||
);
|
||||
@@ -307,10 +919,9 @@ test.describe('Electron Downloads', () => {
|
||||
// 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')
|
||||
.filter({ hasText: 'pause' })
|
||||
.getByRole('button', { name: 'Pause E2E Pause Movie' })
|
||||
.click();
|
||||
await expect(item.locator('.downloads__item-status')).toContainText(
|
||||
await expect(item.locator('.download-queue__status')).toContainText(
|
||||
'Paused',
|
||||
{ timeout: 20000 }
|
||||
);
|
||||
@@ -325,13 +936,18 @@ test.describe('Electron Downloads', () => {
|
||||
// Resume: the runtime must continue via Range/If-Range instead of
|
||||
// restarting, and the assembled file must match the payload.
|
||||
await item
|
||||
.getByRole('button')
|
||||
.filter({ hasText: 'play_arrow' })
|
||||
.getByRole('button', { name: 'Resume E2E Pause Movie' })
|
||||
.click();
|
||||
await expect(item.locator('.downloads__item-status')).toContainText(
|
||||
'Completed',
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user