mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
* fix(portals): preserve playlist ownership during detail handoffs * fix(portals): reload Stalker categories only for a held destination Review follow-ups (Greptile, Codex): resetCategories() reloaded the category resource, and the route session calls it on a portal switch before the destination is resolved and on teardown, so it asked the portal being left, and a failed destination lookup could let that answer repopulate the sidebar. resetCategories() now only clears; the session calls the new reloadCategories() after installing the destination, and only when a handoff had already put that playlist in the store (the owner, and so the resource params, did not change). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
848 lines
33 KiB
TypeScript
848 lines
33 KiB
TypeScript
import { Page } from '@playwright/test';
|
|
import {
|
|
addStalkerPortal,
|
|
addXtreamPortal,
|
|
channelItemByTitle,
|
|
clearCurrentUnifiedCollection,
|
|
clickCategoryById,
|
|
clickGridListCardByTitle,
|
|
clickCategoryByNameExact,
|
|
clickFirstGridListCard,
|
|
closeElectronApp,
|
|
contentCardByTitle,
|
|
defaultXtreamPassword,
|
|
defaultXtreamUsername,
|
|
expectPathname,
|
|
expectVisibleContentCardTitle,
|
|
expect,
|
|
importM3uPlaylistFromNativeDialog,
|
|
launchElectronApp,
|
|
goToDashboard,
|
|
openGlobalRecent,
|
|
openWorkspaceSection,
|
|
openPlaylistFavorites,
|
|
openSources,
|
|
resetMockServers,
|
|
restartElectronApp,
|
|
sourceRowByTitle,
|
|
switchUnifiedCollectionContent,
|
|
switchUnifiedCollectionScope,
|
|
test,
|
|
playlistSwitcherTitle,
|
|
waitForM3uCatalog,
|
|
waitForStalkerCatalog,
|
|
waitForXtreamWorkspaceReady,
|
|
writeTemporaryM3uFile,
|
|
} from './electron-test-fixtures';
|
|
import {
|
|
fetchStalkerCategoryFixture,
|
|
fetchXtreamLiveFixture,
|
|
fetchXtreamSeriesFixture,
|
|
fetchXtreamVodFixture,
|
|
getStalkerTitle,
|
|
getXtreamTitle,
|
|
pickDistinctTitles,
|
|
} from './portal-mock-fixtures';
|
|
import { readVisibleSidebarCategoryNames } from './sidebar-categories.e2e-support';
|
|
import {
|
|
routePlayableStreams,
|
|
startAndConfirmPlayback,
|
|
} from './playable-stream-fixture';
|
|
|
|
test.describe('Electron Favorites', () => {
|
|
for (const origin of ['favorites', 'recent', 'dashboard'] as const) {
|
|
test(`@stalker @electron portal handoff from ${origin} restores the owning categories after opening a different source`, async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['stalker']);
|
|
const vod = await fetchStalkerCategoryFixture(request, 'vod');
|
|
const [title] = pickDistinctTitles(vod.items, getStalkerTitle);
|
|
const app = await launchElectronApp(dataDir);
|
|
const page = app.mainWindow;
|
|
await routePlayableStreams(page);
|
|
try {
|
|
await addStalkerPortal(page, { name: 'Portal A' });
|
|
await waitForStalkerCatalog(page);
|
|
await clickCategoryById(page, vod.categoryId);
|
|
const portalPath = new URL(page.url()).pathname.replace(
|
|
/\/vod(?:\/[^/]+)?$/,
|
|
`/vod/${vod.categoryId}`
|
|
);
|
|
const categoriesA = await readVisibleSidebarCategoryNames(page);
|
|
await clickGridListCardByTitle(page, title);
|
|
await addCurrentDetailToFavorites(page);
|
|
if (origin !== 'favorites') {
|
|
await startAndConfirmPlayback(page, () =>
|
|
page.locator('button.play-btn').first().click()
|
|
);
|
|
}
|
|
await goBackFromDetail(page);
|
|
if (origin !== 'favorites') {
|
|
// Represent an already saved viewing position. The short
|
|
// media fixture ends before the player's 15 s save interval.
|
|
const playlistId = /\/stalker\/([^/]+)/.exec(
|
|
portalPath
|
|
)?.[1];
|
|
const contentId = Number(
|
|
vod.items.find(
|
|
(item) => getStalkerTitle(item) === title
|
|
)?.id
|
|
);
|
|
expect(playlistId).toBeTruthy();
|
|
await page.evaluate(
|
|
async ({ playlistId, contentId }) => {
|
|
await window.electron.dbSavePlaybackPosition(
|
|
playlistId!,
|
|
{
|
|
contentXtreamId: contentId,
|
|
contentType: 'vod',
|
|
playlistType: 'stalker',
|
|
positionSeconds: 2,
|
|
durationSeconds: 6,
|
|
}
|
|
);
|
|
},
|
|
{ playlistId, contentId }
|
|
);
|
|
}
|
|
|
|
await openSources(page);
|
|
await addStalkerPortal(page, {
|
|
name: 'Portal B',
|
|
macAddress: '00:1A:79:00:00:03',
|
|
});
|
|
await waitForStalkerCatalog(page);
|
|
const categoriesB = await readVisibleSidebarCategoryNames(page);
|
|
expect(categoriesB).not.toEqual(categoriesA);
|
|
|
|
if (origin === 'dashboard') {
|
|
await goToDashboard(page);
|
|
await page
|
|
.locator(
|
|
'[data-test-id="dashboard-continue-watching-rail-card"]'
|
|
)
|
|
.filter({ hasText: title })
|
|
.first()
|
|
.click();
|
|
} else {
|
|
if (origin === 'favorites')
|
|
await openWorkspaceSection(page, 'Global favorites');
|
|
else await openGlobalRecent(page);
|
|
await switchUnifiedCollectionScope(page, 'All playlists');
|
|
await contentCardByTitle(page, title).first().click();
|
|
}
|
|
await expect(page.locator('app-content-hero')).toContainText(
|
|
title
|
|
);
|
|
if (origin !== 'favorites') {
|
|
await startAndConfirmPlayback(
|
|
page,
|
|
() =>
|
|
page
|
|
.getByRole('button', { name: /^Continue/ })
|
|
.first()
|
|
.click(),
|
|
0.5
|
|
);
|
|
await page
|
|
.getByRole('button', {
|
|
name: 'Close player',
|
|
exact: true,
|
|
})
|
|
.click();
|
|
}
|
|
await page
|
|
.getByRole('button', {
|
|
name: 'View in portal',
|
|
exact: true,
|
|
})
|
|
.click();
|
|
await expect
|
|
.poll(() => new URL(page.url()).pathname)
|
|
.toBe(portalPath);
|
|
await expect(page.locator('app-content-hero')).toContainText(
|
|
title
|
|
);
|
|
await expect
|
|
.poll(() => readVisibleSidebarCategoryNames(page))
|
|
.toEqual(categoriesA);
|
|
|
|
await goBackFromDetail(page);
|
|
await expectPathname(
|
|
page,
|
|
origin === 'favorites'
|
|
? /\/workspace\/global-favorites$/
|
|
: /\/workspace\/global-recent$/
|
|
);
|
|
await expect(page.locator('app-content-hero')).toContainText(
|
|
title
|
|
);
|
|
for (const [name, expected] of [
|
|
['Portal B', categoriesB],
|
|
['Portal A', categoriesA],
|
|
] as const) {
|
|
await openSources(page);
|
|
await sourceRowByTitle(page, name).first().click();
|
|
await waitForStalkerCatalog(page);
|
|
await expect
|
|
.poll(() => readVisibleSidebarCategoryNames(page))
|
|
.toEqual(expected);
|
|
}
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
}
|
|
|
|
test('@persistence @m3u @electron shows M3U favorites in playlist and all-playlists scope, and preserves them after restart', async ({
|
|
dataDir,
|
|
}) => {
|
|
const playlistTitle = 'm3u-favorites-source.m3u';
|
|
const playlistSourceTitle = 'm3u-favorites-source';
|
|
const filePath = writeTemporaryM3uFile(dataDir, playlistTitle, [
|
|
{
|
|
groupTitle: 'News',
|
|
name: 'M3U Favorite One',
|
|
url: 'https://streams.example.test/m3u-favorite-one.m3u8',
|
|
},
|
|
{
|
|
groupTitle: 'News',
|
|
name: 'M3U Favorite Two',
|
|
url: 'https://streams.example.test/m3u-favorite-two.m3u8',
|
|
},
|
|
]);
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await importM3uPlaylistFromNativeDialog(app, filePath);
|
|
await waitForM3uCatalog(app.mainWindow);
|
|
await toggleFavoriteForChannel(app.mainWindow, 'M3U Favorite One');
|
|
await toggleFavoriteForChannel(app.mainWindow, 'M3U Favorite Two');
|
|
await openPlaylistFavorites(app.mainWindow);
|
|
|
|
await expect(
|
|
channelItemByTitle(app.mainWindow, 'M3U Favorite One')
|
|
).toHaveCount(1);
|
|
await expect(
|
|
channelItemByTitle(app.mainWindow, 'M3U Favorite Two')
|
|
).toHaveCount(1);
|
|
|
|
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
|
|
await expect(
|
|
channelItemByTitle(app.mainWindow, 'M3U Favorite One')
|
|
).toHaveCount(1);
|
|
await expect(
|
|
channelItemByTitle(app.mainWindow, 'M3U Favorite Two')
|
|
).toHaveCount(1);
|
|
|
|
const restarted = await restartElectronApp(app, dataDir);
|
|
app.electronApp = restarted.electronApp;
|
|
app.mainWindow = restarted.mainWindow;
|
|
|
|
await openSources(app.mainWindow);
|
|
await sourceRowByTitle(app.mainWindow, playlistSourceTitle)
|
|
.first()
|
|
.click();
|
|
await waitForM3uCatalog(app.mainWindow);
|
|
await openPlaylistFavorites(app.mainWindow);
|
|
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
|
|
await expect(
|
|
channelItemByTitle(app.mainWindow, 'M3U Favorite One')
|
|
).toHaveCount(1);
|
|
await expect(
|
|
channelItemByTitle(app.mainWindow, 'M3U Favorite Two')
|
|
).toHaveCount(1);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
|
|
test('keeps playlist M3U favorites cleared after navigating away and back', async ({
|
|
dataDir,
|
|
}) => {
|
|
const playlistTitle = 'm3u-clear-favorites-source.m3u';
|
|
const playlistSourceTitle = 'm3u-clear-favorites-source';
|
|
const filePath = writeTemporaryM3uFile(dataDir, playlistTitle, [
|
|
{
|
|
groupTitle: 'News',
|
|
name: 'M3U Clear Favorite One',
|
|
url: 'https://streams.example.test/m3u-clear-favorite-one.m3u8',
|
|
},
|
|
{
|
|
groupTitle: 'News',
|
|
name: 'M3U Clear Favorite Two',
|
|
url: 'https://streams.example.test/m3u-clear-favorite-two.m3u8',
|
|
},
|
|
]);
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await importM3uPlaylistFromNativeDialog(app, filePath);
|
|
await waitForM3uCatalog(app.mainWindow);
|
|
await toggleFavoriteForChannel(
|
|
app.mainWindow,
|
|
'M3U Clear Favorite One'
|
|
);
|
|
await toggleFavoriteForChannel(
|
|
app.mainWindow,
|
|
'M3U Clear Favorite Two'
|
|
);
|
|
await openPlaylistFavorites(app.mainWindow);
|
|
|
|
await expect(
|
|
channelItemByTitle(app.mainWindow, 'M3U Clear Favorite One')
|
|
).toHaveCount(1);
|
|
await expect(
|
|
channelItemByTitle(app.mainWindow, 'M3U Clear Favorite Two')
|
|
).toHaveCount(1);
|
|
|
|
await clearCurrentUnifiedCollection(app.mainWindow);
|
|
|
|
await expect(
|
|
channelItemByTitle(app.mainWindow, 'M3U Clear Favorite One')
|
|
).toHaveCount(0);
|
|
await expect(
|
|
channelItemByTitle(app.mainWindow, 'M3U Clear Favorite Two')
|
|
).toHaveCount(0);
|
|
|
|
await openSources(app.mainWindow);
|
|
await sourceRowByTitle(app.mainWindow, playlistSourceTitle)
|
|
.first()
|
|
.click();
|
|
await waitForM3uCatalog(app.mainWindow);
|
|
await openPlaylistFavorites(app.mainWindow);
|
|
|
|
await expect(
|
|
channelItemByTitle(app.mainWindow, 'M3U Clear Favorite One')
|
|
).toHaveCount(0);
|
|
await expect(
|
|
channelItemByTitle(app.mainWindow, 'M3U Clear Favorite Two')
|
|
).toHaveCount(0);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
|
|
test('@persistence @xtream @electron shows Xtream live, movie, and series favorites in playlist and all-playlists scope, and preserves them after restart', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['xtream']);
|
|
const liveFixture = await fetchXtreamLiveFixture(
|
|
request,
|
|
xtreamCredentials
|
|
);
|
|
const vodFixture = await fetchXtreamVodFixture(
|
|
request,
|
|
xtreamCredentials
|
|
);
|
|
const seriesFixture = await fetchXtreamSeriesFixture(
|
|
request,
|
|
xtreamCredentials
|
|
);
|
|
const [liveTitle] = pickDistinctTitles(
|
|
liveFixture.items,
|
|
getXtreamTitle
|
|
);
|
|
const portalTitle = 'Xtream Favorites Source';
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await addXtreamPortal(app.mainWindow, {
|
|
name: portalTitle,
|
|
});
|
|
await waitForXtreamWorkspaceReady(app.mainWindow);
|
|
|
|
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
|
await clickCategoryByNameExact(
|
|
app.mainWindow,
|
|
liveFixture.categoryName
|
|
);
|
|
await toggleFavoriteForChannel(app.mainWindow, liveTitle);
|
|
|
|
await app.mainWindow
|
|
.getByRole('link', { name: 'Movies', exact: true })
|
|
.click();
|
|
await clickCategoryByNameExact(
|
|
app.mainWindow,
|
|
vodFixture.categoryName
|
|
);
|
|
// Use clickFirstGridListCard: grid sorts by date-desc so fixture order ≠
|
|
// display order; this atomically reads the first card's title and clicks
|
|
// it, avoiding a race where the grid re-renders between the title read
|
|
// and a separate search-by-title click.
|
|
const movieTitle = await clickFirstGridListCard(app.mainWindow);
|
|
await addCurrentDetailToFavorites(app.mainWindow);
|
|
await goBackFromDetail(app.mainWindow);
|
|
|
|
await app.mainWindow
|
|
.getByRole('link', { name: 'Series', exact: true })
|
|
.click();
|
|
await clickCategoryByNameExact(
|
|
app.mainWindow,
|
|
seriesFixture.categoryName
|
|
);
|
|
const seriesTitle = await clickFirstGridListCard(app.mainWindow);
|
|
await addCurrentDetailToFavorites(app.mainWindow);
|
|
await goBackFromDetail(app.mainWindow);
|
|
|
|
await openPlaylistFavorites(app.mainWindow);
|
|
await expect(
|
|
channelItemByTitle(app.mainWindow, liveTitle)
|
|
).toHaveCount(1);
|
|
|
|
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
|
|
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
|
|
|
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
|
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
|
|
|
|
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
|
|
await switchUnifiedCollectionContent(app.mainWindow, 'Live TV');
|
|
await expect(
|
|
channelItemByTitle(app.mainWindow, liveTitle)
|
|
).toHaveCount(1);
|
|
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
|
|
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
|
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
|
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
|
|
|
|
const restarted = await restartElectronApp(app, dataDir);
|
|
app.electronApp = restarted.electronApp;
|
|
app.mainWindow = restarted.mainWindow;
|
|
|
|
await openSources(app.mainWindow);
|
|
await sourceRowByTitle(app.mainWindow, portalTitle).first().click();
|
|
await waitForXtreamWorkspaceReady(app.mainWindow);
|
|
await openPlaylistFavorites(app.mainWindow);
|
|
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
|
|
await switchUnifiedCollectionContent(app.mainWindow, 'Live TV');
|
|
await expect(
|
|
channelItemByTitle(app.mainWindow, liveTitle)
|
|
).toHaveCount(1);
|
|
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
|
|
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
|
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
|
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
|
|
test('opens Xtream favorite movies and series inline from favorites without switching the playlist or showing the sidebar', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['xtream']);
|
|
const vodFixture = await fetchXtreamVodFixture(
|
|
request,
|
|
xtreamCredentials
|
|
);
|
|
const seriesFixture = await fetchXtreamSeriesFixture(
|
|
request,
|
|
xtreamCredentials
|
|
);
|
|
const hostPortalTitle = 'Xtream Favorite Detail Host';
|
|
const sourcePortalTitle = 'Xtream Favorite Detail Source';
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await addXtreamPortal(app.mainWindow, {
|
|
name: hostPortalTitle,
|
|
});
|
|
await waitForXtreamWorkspaceReady(app.mainWindow);
|
|
|
|
await addXtreamPortal(app.mainWindow, {
|
|
name: sourcePortalTitle,
|
|
});
|
|
await waitForXtreamWorkspaceReady(app.mainWindow);
|
|
|
|
await app.mainWindow
|
|
.getByRole('link', { name: 'Movies', exact: true })
|
|
.click();
|
|
await clickCategoryByNameExact(
|
|
app.mainWindow,
|
|
vodFixture.categoryName
|
|
);
|
|
const movieTitle = await clickFirstGridListCard(app.mainWindow);
|
|
await addCurrentDetailToFavorites(app.mainWindow);
|
|
await goBackFromDetail(app.mainWindow);
|
|
|
|
await app.mainWindow
|
|
.getByRole('link', { name: 'Series', exact: true })
|
|
.click();
|
|
await clickCategoryByNameExact(
|
|
app.mainWindow,
|
|
seriesFixture.categoryName
|
|
);
|
|
const seriesTitle = await clickFirstGridListCard(app.mainWindow);
|
|
await addCurrentDetailToFavorites(app.mainWindow);
|
|
await goBackFromDetail(app.mainWindow);
|
|
|
|
await openSources(app.mainWindow);
|
|
await sourceRowByTitle(app.mainWindow, hostPortalTitle)
|
|
.first()
|
|
.click();
|
|
await waitForXtreamWorkspaceReady(app.mainWindow);
|
|
await openPlaylistFavorites(app.mainWindow);
|
|
await expect(playlistSwitcherTitle(app.mainWindow)).toContainText(
|
|
hostPortalTitle
|
|
);
|
|
|
|
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
|
|
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
|
|
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
|
await contentCardByTitle(app.mainWindow, movieTitle)
|
|
.first()
|
|
.click();
|
|
await expectInlineCollectionDetail(app.mainWindow, {
|
|
pathname: /\/workspace\/global-favorites$/,
|
|
title: movieTitle,
|
|
playlistTitle: hostPortalTitle,
|
|
});
|
|
|
|
await goBackFromDetail(app.mainWindow);
|
|
await expectPathname(
|
|
app.mainWindow,
|
|
/\/workspace\/global-favorites$/
|
|
);
|
|
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
|
|
|
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
|
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
|
|
await contentCardByTitle(app.mainWindow, seriesTitle)
|
|
.first()
|
|
.click();
|
|
await expectInlineCollectionDetail(app.mainWindow, {
|
|
pathname: /\/workspace\/global-favorites$/,
|
|
title: seriesTitle,
|
|
playlistTitle: hostPortalTitle,
|
|
});
|
|
|
|
await goBackFromDetail(app.mainWindow);
|
|
await expectPathname(
|
|
app.mainWindow,
|
|
/\/workspace\/global-favorites$/
|
|
);
|
|
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
|
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
|
|
test('@persistence @stalker @electron shows Stalker live, movie, and series favorites in playlist and all-playlists scope, and preserves them after restart', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['stalker']);
|
|
const liveFixture = await fetchStalkerCategoryFixture(request, 'itv');
|
|
const vodFixture = await fetchStalkerCategoryFixture(request, 'vod');
|
|
const seriesFixture = await fetchStalkerCategoryFixture(
|
|
request,
|
|
'series'
|
|
);
|
|
const [liveTitle] = pickDistinctTitles(
|
|
liveFixture.items,
|
|
getStalkerTitle
|
|
);
|
|
const [movieTitle] = pickDistinctTitles(
|
|
vodFixture.items,
|
|
getStalkerTitle
|
|
);
|
|
const [seriesTitle] = pickDistinctTitles(
|
|
seriesFixture.items,
|
|
getStalkerTitle
|
|
);
|
|
const portalTitle = 'Stalker Favorites Source';
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await addStalkerPortal(app.mainWindow, {
|
|
name: portalTitle,
|
|
});
|
|
await waitForStalkerCatalog(app.mainWindow);
|
|
|
|
await app.mainWindow
|
|
.getByRole('link', { name: 'Live TV', exact: true })
|
|
.click();
|
|
await clickCategoryById(app.mainWindow, liveFixture.categoryId);
|
|
await toggleFavoriteForChannel(app.mainWindow, liveTitle);
|
|
|
|
await app.mainWindow
|
|
.getByRole('link', { name: 'Movies', exact: true })
|
|
.click();
|
|
await clickCategoryById(app.mainWindow, vodFixture.categoryId);
|
|
await clickGridListCardByTitle(app.mainWindow, movieTitle);
|
|
await addCurrentDetailToFavorites(app.mainWindow);
|
|
await goBackFromDetail(app.mainWindow);
|
|
|
|
await app.mainWindow
|
|
.getByRole('link', { name: 'Series', exact: true })
|
|
.click();
|
|
await clickCategoryById(app.mainWindow, seriesFixture.categoryId);
|
|
await clickGridListCardByTitle(app.mainWindow, seriesTitle);
|
|
await addCurrentDetailToFavorites(app.mainWindow);
|
|
await goBackFromDetail(app.mainWindow);
|
|
|
|
await openPlaylistFavorites(app.mainWindow);
|
|
await expect(
|
|
channelItemByTitle(app.mainWindow, liveTitle)
|
|
).toHaveCount(1);
|
|
|
|
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
|
|
await expect(
|
|
contentCardByTitle(app.mainWindow, movieTitle)
|
|
).toHaveCount(1);
|
|
|
|
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
|
await expect(
|
|
contentCardByTitle(app.mainWindow, seriesTitle)
|
|
).toHaveCount(1);
|
|
|
|
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
|
|
await switchUnifiedCollectionContent(app.mainWindow, 'Live TV');
|
|
await expect(
|
|
channelItemByTitle(app.mainWindow, liveTitle)
|
|
).toHaveCount(1);
|
|
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
|
|
await expect(
|
|
contentCardByTitle(app.mainWindow, movieTitle)
|
|
).toHaveCount(1);
|
|
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
|
await expect(
|
|
contentCardByTitle(app.mainWindow, seriesTitle)
|
|
).toHaveCount(1);
|
|
|
|
const restarted = await restartElectronApp(app, dataDir);
|
|
app.electronApp = restarted.electronApp;
|
|
app.mainWindow = restarted.mainWindow;
|
|
|
|
await openSources(app.mainWindow);
|
|
await sourceRowByTitle(app.mainWindow, portalTitle).first().click();
|
|
await waitForStalkerCatalog(app.mainWindow);
|
|
await openPlaylistFavorites(app.mainWindow);
|
|
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
|
|
await switchUnifiedCollectionContent(app.mainWindow, 'Live TV');
|
|
await expect(
|
|
channelItemByTitle(app.mainWindow, liveTitle)
|
|
).toHaveCount(1);
|
|
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
|
|
await expect(
|
|
contentCardByTitle(app.mainWindow, movieTitle)
|
|
).toHaveCount(1);
|
|
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
|
await expect(
|
|
contentCardByTitle(app.mainWindow, seriesTitle)
|
|
).toHaveCount(1);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
|
|
test('opens Stalker favorite movies and series inline from favorites without showing the sidebar', async ({
|
|
dataDir,
|
|
request,
|
|
}) => {
|
|
await resetMockServers(request, ['stalker']);
|
|
const vodFixture = await fetchStalkerCategoryFixture(request, 'vod');
|
|
const seriesFixture = await fetchStalkerCategoryFixture(
|
|
request,
|
|
'series'
|
|
);
|
|
const [movieTitle] = pickDistinctTitles(
|
|
vodFixture.items,
|
|
getStalkerTitle
|
|
);
|
|
const [seriesTitle] = pickDistinctTitles(
|
|
seriesFixture.items,
|
|
getStalkerTitle
|
|
);
|
|
const portalTitle = 'Stalker Favorite Detail Source';
|
|
const app = await launchElectronApp(dataDir);
|
|
|
|
try {
|
|
await addStalkerPortal(app.mainWindow, {
|
|
name: portalTitle,
|
|
});
|
|
await waitForStalkerCatalog(app.mainWindow);
|
|
|
|
await app.mainWindow
|
|
.getByRole('link', { name: 'Movies', exact: true })
|
|
.click();
|
|
await clickCategoryById(app.mainWindow, vodFixture.categoryId);
|
|
await clickGridListCardByTitle(app.mainWindow, movieTitle);
|
|
await addCurrentDetailToFavorites(app.mainWindow);
|
|
await goBackFromDetail(app.mainWindow);
|
|
|
|
await app.mainWindow
|
|
.getByRole('link', { name: 'Series', exact: true })
|
|
.click();
|
|
await clickCategoryById(app.mainWindow, seriesFixture.categoryId);
|
|
await clickGridListCardByTitle(app.mainWindow, seriesTitle);
|
|
await addCurrentDetailToFavorites(app.mainWindow);
|
|
await goBackFromDetail(app.mainWindow);
|
|
|
|
await openPlaylistFavorites(app.mainWindow);
|
|
await expect(playlistSwitcherTitle(app.mainWindow)).toContainText(
|
|
portalTitle
|
|
);
|
|
|
|
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
|
|
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
|
await contentCardByTitle(app.mainWindow, movieTitle)
|
|
.first()
|
|
.click();
|
|
await expectInlineCollectionDetail(app.mainWindow, {
|
|
pathname: /\/workspace\/global-favorites$/,
|
|
title: movieTitle,
|
|
playlistTitle: portalTitle,
|
|
});
|
|
await playCurrentDetail(app.mainWindow);
|
|
await expectInlinePlayerWithoutDialog(app.mainWindow);
|
|
|
|
await goBackFromDetail(app.mainWindow);
|
|
await expectPathname(
|
|
app.mainWindow,
|
|
/\/workspace\/global-favorites$/
|
|
);
|
|
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
|
|
|
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
|
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
|
|
await contentCardByTitle(app.mainWindow, seriesTitle)
|
|
.first()
|
|
.click();
|
|
await expectInlineCollectionDetail(app.mainWindow, {
|
|
pathname: /\/workspace\/global-favorites$/,
|
|
title: seriesTitle,
|
|
playlistTitle: portalTitle,
|
|
});
|
|
await playFirstSeriesEpisode(app.mainWindow);
|
|
await expectInlinePlayerWithoutDialog(app.mainWindow);
|
|
|
|
await goBackFromDetail(app.mainWindow);
|
|
await expectPathname(
|
|
app.mainWindow,
|
|
/\/workspace\/global-favorites$/
|
|
);
|
|
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
|
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
|
|
} finally {
|
|
await closeElectronApp(app);
|
|
}
|
|
});
|
|
});
|
|
|
|
const xtreamCredentials = {
|
|
username: defaultXtreamUsername,
|
|
password: defaultXtreamPassword,
|
|
};
|
|
|
|
// By accessible name, not class: the Xtream movie detail's favorite control is
|
|
// an icon-only button that carries its label in aria-label, while series and
|
|
// Stalker details still use the labeled variant. This matches both.
|
|
async function addCurrentDetailToFavorites(page: Page): Promise<void> {
|
|
const addButton = page
|
|
.getByRole('button', { name: /add to favorites/i })
|
|
.first();
|
|
|
|
await expect(addButton).toBeVisible({ timeout: 20000 });
|
|
await addButton.click();
|
|
await expect(
|
|
page.getByRole('button', { name: /remove from favorites/i }).first()
|
|
).toBeVisible({
|
|
timeout: 20000,
|
|
});
|
|
}
|
|
|
|
async function goBackFromDetail(page: Page): Promise<void> {
|
|
// Return to the list: the header's Back is route-level in browse and
|
|
// watch alike (closing the player is the bar's own Close button).
|
|
const backButton = page.getByTestId('workspace-header-back');
|
|
|
|
await expect(backButton).toBeVisible({ timeout: 20000 });
|
|
try {
|
|
await backButton.click({ timeout: 5000 });
|
|
} catch {
|
|
await backButton.evaluate((button: HTMLButtonElement) =>
|
|
button.click()
|
|
);
|
|
}
|
|
}
|
|
|
|
async function expectInlineCollectionDetail(
|
|
page: Page,
|
|
params: {
|
|
pathname: RegExp;
|
|
title: string;
|
|
playlistTitle: string;
|
|
}
|
|
): Promise<void> {
|
|
await expectPathname(page, params.pathname);
|
|
await expect(playlistSwitcherTitle(page)).toContainText(
|
|
params.playlistTitle
|
|
);
|
|
await expect(page.locator('app-workspace-context-panel')).toHaveCount(0);
|
|
await expect(page.locator('app-content-hero')).toContainText(params.title);
|
|
await expect(page.getByTestId('workspace-header-back')).toBeVisible({
|
|
timeout: 20000,
|
|
});
|
|
}
|
|
|
|
async function expectInlinePlayerWithoutDialog(page: Page): Promise<void> {
|
|
await expect(
|
|
page.locator('app-portal-inline-player app-web-player-view').first()
|
|
).toBeVisible({ timeout: 20000 });
|
|
await expect(
|
|
page.locator('mat-dialog-container app-web-player-view')
|
|
).toHaveCount(0);
|
|
}
|
|
|
|
async function playCurrentDetail(page: Page): Promise<void> {
|
|
const playButton = page.locator('button.play-btn').first();
|
|
|
|
await expect(playButton).toBeVisible({ timeout: 20000 });
|
|
await playButton.click();
|
|
}
|
|
|
|
async function playFirstSeriesEpisode(page: Page): Promise<void> {
|
|
const seasonCard = page.locator('.season-card').first();
|
|
const episodeCard = page
|
|
.locator('.episode-card, .episode-list-item')
|
|
.first();
|
|
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(await seasonCard.count()) + (await episodeCard.count()),
|
|
{ timeout: 20000 }
|
|
)
|
|
.toBeGreaterThan(0);
|
|
|
|
if ((await seasonCard.count()) > 0) {
|
|
await seasonCard.scrollIntoViewIfNeeded();
|
|
await expect(seasonCard).toBeVisible({ timeout: 20000 });
|
|
await seasonCard.click();
|
|
}
|
|
|
|
await episodeCard.scrollIntoViewIfNeeded();
|
|
await expect(episodeCard).toBeVisible({ timeout: 20000 });
|
|
await episodeCard.click();
|
|
}
|
|
|
|
async function toggleFavoriteForChannel(
|
|
page: Page,
|
|
title: string
|
|
): Promise<void> {
|
|
const item = channelItemByTitle(page, title).first();
|
|
|
|
await expect(item).toBeVisible({ timeout: 20000 });
|
|
await item.hover();
|
|
await item.locator('.favorite-button').first().click();
|
|
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(
|
|
/star/
|
|
);
|
|
}
|