mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 21:56:18 -08:00
* feat(settings): split settings into per-section pages with an unsaved-changes bar Replace the single scrolling settings page with routed section pages (/workspace/settings/:section): the context-panel rail links each section, only the active section renders, and unknown or capability-gated sections redirect to General. The shared form lives on the parent component, so staged edits survive section switches; a floating unsaved-changes bar (Save/Discard) replaces the always-visible footer Save button. Rail links navigate with replaceUrl so Back still leaves settings in one step. Along the way: - delete the unreachable settings dialog mode and the dead AppPortalNavigationActionsService with both of its never-injected DI tokens (PORTAL_NAVIGATION_ACTIONS, PLAYLIST_PLAYER_ACTIONS) - delete the scroll-spy directive and pendingScrollTarget plumbing - revive the EPG panel's "Open EPG settings" empty-state button as a deep link to /workspace/settings/epg; the M3U player now reports m3u-needs-setup only when the channel has no programmes and no EPG source exists in settings or on the playlist itself - load TMDB cache stats when the Metadata page opens (the section component now only exists while its page is open) - add SETTINGS.UNSAVED_CHANGES / SETTINGS.DISCARD_CHANGES to all 19 locales Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(settings): confirm before leaving with unsaved changes Add settingsUnsavedChangesGuard (canDeactivate on the :section route) with a three-action dialog: save and leave, leave without saving, keep editing. The guard only intercepts leaving the settings AREA — section switches share the one settings form and pass unconditionally, so the dialog can never nag while moving between pages. A failed save cancels the navigation instead of silently dropping the edits it promised to keep; leaving without saving also reverts the live theme preview. Save-and-leave is disabled while the form is invalid, with a hint explaining why. New SETTINGS.UNSAVED_DIALOG_* keys in all 19 locales. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(settings): stage cover size and EPG view mode; adapt e2e to section pages Cover size and EPG view mode were the only two controls that persisted eagerly on click, which made Discard (and leave-without-saving) unable to revert them: hydrateFromStore() faithfully reloaded the just-persisted edit. They now stage in the form like every other setting and reach the store on Save. Review finding by Greptile (P1) and Codex. E2E suites that walk through settings are updated for one-section-page rendering (epg, backup-roundtrip, xtream-epg, remote-control) and for the staged cover size (downloads asserts the dataset after Save); the EPG icon fallback test saves before leaving settings so the new unsaved-changes dialog does not block its navigation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1206 lines
45 KiB
TypeScript
1206 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';
|
|
|
|
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 expose play / reveal / remove actions.
|
|
await expect(
|
|
card.getByRole('button', { name: 'Play: E2E Movie' }).last()
|
|
).toBeVisible();
|
|
await expect(
|
|
card.getByRole('button', {
|
|
name: 'Show in Folder: E2E Movie',
|
|
})
|
|
).toBeVisible();
|
|
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 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: '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();
|
|
}
|
|
});
|
|
});
|