Merge pull request #844 from 4gray/claude/fix-e2e-playlist-tests-ZB1XE

fix(e2e): use mat-button-toggle value selectors for playlist type selection
This commit is contained in:
4gray authored and GitHub committed 2026-04-04 13:49:56 +02:00
commit 2e29523078
17 files changed
+847 -104

No files matched your search

@@ -9,6 +9,7 @@ import {
openWorkspaceSection,
refreshSource,
resetMockServers,
restartElectronApp,
sourceRowByTitle,
test,
waitForSourceRowIdle,
@@ -30,6 +31,12 @@ test.describe('Electron Xtream Category Management', () => {
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
// Wait for the route to settle on the live TV section so that
// the sidebar shows live categories (not VOD/series from a
// previous section) before we read from it.
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/live/
);
const targetCategory = await pickSidebarCategory(app.mainWindow);
@@ -56,7 +63,7 @@ test.describe('Electron Xtream Category Management', () => {
).toBeVisible();
await dialog.locator('.search-field input').fill(targetCategory.name);
await toggleManagedCategory(dialog, targetCategory);
await toggleManagedCategory(dialog, targetCategory, false);
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await app.mainWindow.waitForSelector('mat-dialog-container', {
state: 'detached',
@@ -68,26 +75,43 @@ test.describe('Electron Xtream Category Management', () => {
await openSources(app.mainWindow);
await refreshSource(app.mainWindow, portalName, { confirm: true });
await openSources(app.mainWindow);
await waitForSourceRowIdle(app.mainWindow, portalName);
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
app.mainWindow = restarted.mainWindow;
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, portalName).first().click();
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/live/
);
await expect(sidebarCategoryById(app.mainWindow, targetCategory.id)).toHaveCount(
0
);
dialog = await openManageCategoriesDialog(app.mainWindow);
await dialog.locator('.search-field input').fill(targetCategory.name);
await toggleManagedCategory(dialog, targetCategory);
await toggleManagedCategory(dialog, targetCategory, true);
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await app.mainWindow.waitForSelector('mat-dialog-container', {
state: 'detached',
});
await expect(sidebarCategoryById(app.mainWindow, targetCategory.id)).toHaveCount(
1
);
await clickCategoryById(app.mainWindow, targetCategory.id);
dialog = await openManageCategoriesDialog(app.mainWindow);
await dialog.locator('.search-field input').fill(targetCategory.name);
const restoredRows = dialog.locator('.category-item');
await expect(restoredRows).toHaveCount(1, { timeout: 15000 });
await expect(
restoredRows.first().locator('mat-checkbox input')
).toBeChecked();
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
await app.mainWindow.waitForSelector('mat-dialog-container', {
state: 'detached',
});
} finally {
await closeElectronApp(app);
}
@@ -102,6 +126,11 @@ async function openManageCategoriesDialog(page: Page) {
await expect(
dialog.getByRole('button', { name: 'Select All', exact: true })
).toBeVisible();
// Wait for the category list to render — Angular needs a CD cycle after
// isLoading() flips to false before the @for items are painted.
await expect(dialog.locator('.category-item').first()).toBeVisible({
timeout: 15000,
});
return dialog;
}
@@ -153,32 +182,25 @@ async function pickSidebarCategory(
async function toggleManagedCategory(
dialog: Locator,
targetCategory: {
id: string;
itemCount: number;
name: string;
}
},
shouldBeSelected: boolean
): Promise<void> {
const categories = dialog.locator('.category-item');
const count = await categories.count();
void targetCategory.id;
const categoryRows = dialog.locator('.category-item');
await expect(categoryRows).toHaveCount(1, { timeout: 15000 });
const categoryRow = categoryRows.first();
const checkbox = categoryRow.locator('mat-checkbox input');
for (let index = 0; index < count; index += 1) {
const categoryRow = categories.nth(index);
const name =
(await categoryRow.locator('.category-name').textContent())?.trim() ??
'';
const countText =
(await categoryRow.locator('.item-count').textContent())?.trim() ?? '';
const itemCount = Number.parseInt(countText.replace(/[()]/g, ''), 10) || 0;
if (name !== targetCategory.name || itemCount !== targetCategory.itemCount) {
continue;
}
await expect(categoryRow).toBeVisible();
await categoryRow.click();
await expect(categoryRow).toBeVisible({ timeout: 15000 });
if (shouldBeSelected) {
await checkbox.check();
await expect(checkbox).toBeChecked();
return;
}
throw new Error(
`Could not find category "${targetCategory.name}" with count ${targetCategory.itemCount} in the management dialog.`
);
await checkbox.uncheck();
await expect(checkbox).not.toBeChecked();
}
@@ -0,0 +1,179 @@
import { Page } from '@playwright/test';
import {
addXtreamPortal,
clickCategoryByNameExact,
clickFirstGridListCard,
closeElectronApp,
defaultXtreamPassword,
defaultXtreamUsername,
expect,
goToDashboard,
launchElectronApp,
openWorkspaceSection,
resetMockServers,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import {
fetchXtreamLiveFixture,
fetchXtreamSeriesFixture,
fetchXtreamVodFixture,
getXtreamTitle,
pickDistinctTitles,
} from './portal-mock-fixtures';
test.describe('Dashboard Activation', () => {
test('opens live favorites in the collection route and movies/series in source detail routes from the dashboard', 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 app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
name: 'Xtream Dashboard Activation Source',
});
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
);
const movieTitle = await clickFirstGridListCard(app.mainWindow);
await playCurrentDetail(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 playFirstSeriesEpisode(app.mainWindow);
await goToDashboard(app.mainWindow);
await dashboardActivityItemByTitle(
app.mainWindow,
'Global Favorites',
liveTitle
).click();
await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/favorites$/);
await expect(
app.mainWindow.locator('.player-toolbar .close-btn').first()
).toBeVisible({ timeout: 20000 });
await app.mainWindow.goBack();
await app.mainWindow.waitForURL(/\/workspace\/dashboard$/);
await dashboardActivityItemByTitle(
app.mainWindow,
'Recently Watched',
movieTitle
).click();
await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/);
await expect(app.mainWindow.locator('app-content-hero')).toContainText(
movieTitle
);
await app.mainWindow.goBack();
await app.mainWindow.waitForURL(/\/workspace\/dashboard$/);
await dashboardActivityItemByTitle(
app.mainWindow,
'Recently Watched',
seriesTitle
).click();
await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/);
await expect(app.mainWindow.locator('app-content-hero')).toContainText(
seriesTitle
);
} finally {
await closeElectronApp(app);
}
});
});
const xtreamCredentials = {
username: defaultXtreamUsername,
password: defaultXtreamPassword,
};
function dashboardActivityItemByTitle(
page: Page,
widgetTitle: string,
title: string
) {
return page
.locator('.widget-slot')
.filter({ hasText: widgetTitle })
.first()
.locator('.activity-item')
.filter({ hasText: title })
.first();
}
async function goBackFromDetail(page: Page): Promise<void> {
const backButton = page.locator('app-content-hero .hero__back-button').first();
await expect(backButton).toBeVisible({ timeout: 20000 });
await backButton.click();
}
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 = page
.locator('[data-test-id="channel-item"]')
.filter({ hasText: 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/);
}
@@ -193,7 +193,7 @@ async function waitForAppReady(page: Page): Promise<void> {
);
}
export async function openAddPlaylistMenu(page: Page): Promise<void> {
export async function openAddPlaylistDialog(page: Page): Promise<void> {
await page.getByRole('button', { name: 'Add playlist' }).click();
}
@@ -215,9 +215,9 @@ export async function importM3uPlaylistFromNativeDialog(
filePath: string
): Promise<void> {
await stubNativePlaylistFileDialog(app.electronApp, filePath);
await openAddPlaylistMenu(app.mainWindow);
await openAddPlaylistDialog(app.mainWindow);
await app.mainWindow
.getByRole('menuitem', { name: 'Add via file upload' })
.locator('mat-dialog-container mat-button-toggle[value="file"]')
.click();
await app.mainWindow.locator('mat-dialog-container .file-upload').click();
await app.mainWindow.waitForSelector('mat-dialog-container', {
@@ -241,9 +241,9 @@ export async function addXtreamPortal(
username = defaultXtreamUsername,
} = options;
await openAddPlaylistMenu(page);
await page.getByRole('menuitem', { name: 'Add Xtreme Code' }).click();
await openAddPlaylistDialog(page);
const dialog = page.locator('mat-dialog-container');
await dialog.locator('mat-button-toggle[value="xtream"]').click();
await setInputValue(dialog.locator('#title'), name);
await setInputValue(dialog.locator('#serverUrl'), serverUrl);
@@ -298,9 +298,9 @@ export async function addStalkerPortal(
portalUrl = `${stalkerMockServer}/portal.php`,
} = options;
await openAddPlaylistMenu(page);
await page.getByRole('menuitem', { name: 'Add Stalker Portal' }).click();
await openAddPlaylistDialog(page);
const dialog = page.locator('mat-dialog-container');
await dialog.locator('mat-button-toggle[value="stalker"]').click();
await setInputValue(dialog.locator('input#title'), name);
await setInputValue(dialog.locator('input#portalUrl'), portalUrl);
@@ -366,9 +366,9 @@ export async function importM3uPlaylistFromUrl(
page: Page,
playlistUrl: string
): Promise<void> {
await openAddPlaylistMenu(page);
await page.getByRole('menuitem', { name: 'Add via URL' }).click();
await openAddPlaylistDialog(page);
const dialog = page.locator('mat-dialog-container');
await dialog.locator('mat-button-toggle[value="url"]').click();
await setInputValue(
dialog.locator('input[formcontrolname="playlistUrl"]'),
@@ -569,35 +569,22 @@ async function clickButtonToggleOption(
toggleGroup: Locator,
label: string
): Promise<void> {
const radio = toggleGroup.getByRole('radio', {
name: label,
exact: true,
});
if ((await radio.count()) > 0) {
await expect(radio.first()).toBeVisible();
await radio.first().click();
return;
}
const button = toggleGroup.getByRole('button', {
name: label,
exact: true,
});
if ((await button.count()) > 0) {
await expect(button.first()).toBeVisible();
await button.first().click();
return;
}
const toggle = toggleGroup
.locator('mat-button-toggle')
.filter({ hasText: flexibleTextPattern(label) })
.first();
await expect(toggle).toBeVisible();
await toggle.click();
if (!(await isButtonToggleSelected(toggle))) {
await toggle.click();
}
await expect
.poll(() => isButtonToggleSelected(toggle), {
timeout: 10000,
})
.toBe(true);
}
export function channelItemByTitle(page: Page, title: string): Locator {
@@ -616,6 +603,25 @@ export function contentCardByTitle(page: Page, title: string): Locator {
});
}
export async function expectVisibleContentCardTitle(
page: Page,
title: string
): Promise<void> {
await expect
.poll(
async () => {
const titles = await visibleContentCardTitles(page);
return titles.some(
(visibleTitle) =>
normalizeVisibleText(visibleTitle) ===
normalizeVisibleText(title)
);
},
{ timeout: 20000 }
)
.toBe(true);
}
export function gridListCardByTitle(page: Page, title: string): Locator {
return page.locator('.category-content-layout mat-card').filter({
has: page.locator('.title', {
@@ -624,6 +630,36 @@ export function gridListCardByTitle(page: Page, title: string): Locator {
});
}
/**
* Waits for the first grid card to appear (skeleton loading done) and returns
* its display title. Use this instead of picking from fixture order, since the
* grid sorts by date-desc and may paginate items off the first page.
*/
export async function waitForFirstGridListCardTitle(page: Page): Promise<string> {
const card = page.locator('.category-content-layout mat-card').first();
await expect(card).toBeVisible({ timeout: 20000 });
return ((await card.locator('.title').textContent()) ?? '').trim();
}
/**
* Waits for the first grid card to appear with a non-empty title, clicks it,
* and returns the title. Use this instead of waitForFirstGridListCardTitle +
* clickGridListCardByTitle to avoid a race condition where the grid re-renders
* between the title read and the subsequent search-by-title click.
*/
export async function clickFirstGridListCard(page: Page): Promise<string> {
const card = page.locator('.category-content-layout mat-card').first();
await expect(card).toBeVisible({ timeout: 20000 });
const titleEl = card.locator('.title');
let title = '';
await expect(async () => {
title = ((await titleEl.textContent()) ?? '').trim();
expect(title.length).toBeGreaterThan(0);
}).toPass({ timeout: 10000 });
await card.click();
return title;
}
export async function clickGridListCardByTitle(
page: Page,
title: string
@@ -675,9 +711,7 @@ export async function getVisibleSourceTitles(page: Page): Promise<string[]> {
return page.locator('app-playlist-item').evaluateAll((elements) =>
elements
.map((element) => {
const titleElement = element.querySelector(
'[matlistitemtitle], [matListItemTitle], .mdc-list-item__primary-text'
);
const titleElement = element.querySelector('.playlist-title');
return titleElement?.textContent?.trim() ?? '';
})
.filter((title) => title.length > 0)
@@ -709,7 +743,7 @@ export async function dragSourceBefore(
targetTitle: string
): Promise<void> {
const source = sourceRowByTitle(page, sourceTitle).locator('.drag-icon');
const target = sourceRowByTitle(page, targetTitle).locator('mat-list-item');
const target = sourceRowByTitle(page, targetTitle).locator('.playlist-item');
await expect(source.first()).toBeVisible();
await expect(target.first()).toBeVisible();
@@ -1344,3 +1378,34 @@ function flexibleTextPattern(value: string): RegExp {
'i'
);
}
async function isButtonToggleSelected(toggle: Locator): Promise<boolean> {
try {
return await toggle.evaluate((element) => {
const host = element as HTMLElement;
const selectedDescendant = host.querySelector(
'[aria-checked="true"], [aria-pressed="true"]'
);
return (
host.classList.contains('mat-button-toggle-checked') ||
host.getAttribute('aria-checked') === 'true' ||
host.getAttribute('aria-pressed') === 'true' ||
selectedDescendant !== null
);
});
} catch {
return false;
}
}
async function visibleContentCardTitles(page: Page): Promise<string[]> {
return page
.locator('app-content-card h3')
.allInnerTexts()
.then((titles) => titles.map((title) => title.trim()).filter(Boolean));
}
function normalizeVisibleText(value: string): string {
return value.trim().replace(/\s+/g, ' ').toLowerCase();
}
+79 -15
View File
@@ -4,12 +4,14 @@ import {
addXtreamPortal,
channelItemByTitle,
clickCategoryById,
clickCategoryByNameExact,
clickGridListCardByTitle,
clickCategoryByNameExact,
clickFirstGridListCard,
closeElectronApp,
contentCardByTitle,
defaultXtreamPassword,
defaultXtreamUsername,
expectVisibleContentCardTitle,
expect,
importM3uPlaylistFromNativeDialog,
launchElectronApp,
@@ -116,8 +118,6 @@ test.describe('Electron Favorites', () => {
xtreamCredentials
);
const [liveTitle] = pickDistinctTitles(liveFixture.items, getXtreamTitle);
const [movieTitle] = pickDistinctTitles(vodFixture.items, getXtreamTitle);
const [seriesTitle] = pickDistinctTitles(seriesFixture.items, getXtreamTitle);
const portalTitle = 'Xtream Favorites Source';
const app = await launchElectronApp(dataDir);
@@ -139,7 +139,11 @@ test.describe('Electron Favorites', () => {
app.mainWindow,
vodFixture.categoryName
);
await clickGridListCardByTitle(app.mainWindow, movieTitle);
// 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);
@@ -148,7 +152,7 @@ test.describe('Electron Favorites', () => {
app.mainWindow,
seriesFixture.categoryName
);
await clickGridListCardByTitle(app.mainWindow, seriesTitle);
const seriesTitle = await clickFirstGridListCard(app.mainWindow);
await addCurrentDetailToFavorites(app.mainWindow);
await goBackFromDetail(app.mainWindow);
@@ -156,18 +160,18 @@ test.describe('Electron Favorites', () => {
await expect(channelItemByTitle(app.mainWindow, liveTitle)).toHaveCount(1);
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
await expect(contentCardByTitle(app.mainWindow, movieTitle)).toHaveCount(1);
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expect(contentCardByTitle(app.mainWindow, seriesTitle)).toHaveCount(1);
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 expect(contentCardByTitle(app.mainWindow, movieTitle)).toHaveCount(1);
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expect(contentCardByTitle(app.mainWindow, seriesTitle)).toHaveCount(1);
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
@@ -186,13 +190,9 @@ test.describe('Electron Favorites', () => {
channelItemByTitle(app.mainWindow, liveTitle)
).toHaveCount(1);
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
await expect(
contentCardByTitle(app.mainWindow, movieTitle)
).toHaveCount(1);
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expect(
contentCardByTitle(app.mainWindow, seriesTitle)
).toHaveCount(1);
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
} finally {
await closeElectronApp(app);
}
@@ -279,6 +279,70 @@ test.describe('Electron Favorites', () => {
await closeElectronApp(app);
}
});
test('opens Stalker favorite movies and series in source detail routes and returns back to favorites', 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 app = await launchElectronApp(dataDir);
try {
await addStalkerPortal(app.mainWindow, {
name: 'Stalker Favorite Detail Source',
});
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 switchUnifiedCollectionContent(app.mainWindow, 'Movies');
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await contentCardByTitle(app.mainWindow, movieTitle).first().click();
await app.mainWindow.waitForURL(/\/workspace\/stalker\/[^/]+\/vod\/[^/]+$/);
await expect(app.mainWindow.locator('app-content-hero')).toContainText(
movieTitle
);
await goBackFromDetail(app.mainWindow);
await expect
.poll(() => new URL(app.mainWindow.url()).pathname)
.toMatch(/\/favorites$/);
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
await contentCardByTitle(app.mainWindow, seriesTitle).first().click();
await app.mainWindow.waitForURL(/\/workspace\/stalker\/[^/]+\/series\/[^/]+$/);
await expect(app.mainWindow.locator('app-content-hero')).toContainText(
seriesTitle
);
await goBackFromDetail(app.mainWindow);
await expect
.poll(() => new URL(app.mainWindow.url()).pathname)
.toMatch(/\/favorites$/);
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
} finally {
await closeElectronApp(app);
}
});
});
const xtreamCredentials = {
+129 -13
View File
@@ -4,12 +4,14 @@ import {
addXtreamPortal,
channelItemByTitle,
clickCategoryById,
clickCategoryByNameExact,
clickGridListCardByTitle,
clickCategoryByNameExact,
clickFirstGridListCard,
closeElectronApp,
contentCardByTitle,
defaultXtreamPassword,
defaultXtreamUsername,
expectVisibleContentCardTitle,
expect,
importM3uPlaylistFromNativeDialog,
launchElectronApp,
@@ -39,6 +41,46 @@ import {
} from './portal-mock-fixtures';
test.describe('Electron Recently Viewed', () => {
test('keeps unified live detail open when re-clicking the active M3U recent item', async ({
dataDir,
}) => {
const filePath = writeTemporaryM3uFile(dataDir, 'm3u-live-reclick.m3u', [
{
groupTitle: 'Live',
name: 'Stable Recent Channel',
url: 'https://streams.example.test/stable-recent.m3u8',
},
]);
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromNativeDialog(app, filePath);
await waitForM3uCatalog(app.mainWindow);
await channelItemByTitle(app.mainWindow, 'Stable Recent Channel')
.first()
.click();
await openPlaylistRecent(app.mainWindow);
const item = channelItemByTitle(
app.mainWindow,
'Stable Recent Channel'
).first();
const closeButton = app.mainWindow
.locator('.player-toolbar .close-btn')
.first();
await item.click();
await expect(closeButton).toBeVisible({ timeout: 20000 });
await item.click();
await expect(closeButton).toBeVisible({ timeout: 20000 });
} finally {
await closeElectronApp(app);
}
});
test('tracks M3U recent channels in newest-first order, supports all-playlists scope, and persists removals after restart', async ({
dataDir,
}) => {
@@ -115,8 +157,6 @@ test.describe('Electron Recently Viewed', () => {
xtreamCredentials
);
const [liveTitle] = pickDistinctTitles(liveFixture.items, getXtreamTitle);
const [movieTitle] = pickDistinctTitles(vodFixture.items, getXtreamTitle);
const [seriesTitle] = pickDistinctTitles(seriesFixture.items, getXtreamTitle);
const portalTitle = 'Xtream Recent Source';
const app = await launchElectronApp(dataDir);
@@ -141,7 +181,12 @@ test.describe('Electron Recently Viewed', () => {
app.mainWindow,
vodFixture.categoryName
);
await clickGridListCardByTitle(app.mainWindow, movieTitle);
// Pick from the displayed grid: fixture order ≠ display order (date-desc
// sort + pagination), so the first fixture item may not be on page 1.
// Use clickFirstGridListCard to atomically read + click the first visible
// card, avoiding a race where the grid re-renders between title read and
// a separate search-by-title click.
const movieTitle = await clickFirstGridListCard(app.mainWindow);
await playCurrentDetail(app.mainWindow);
await goBackFromDetail(app.mainWindow);
@@ -150,23 +195,23 @@ test.describe('Electron Recently Viewed', () => {
app.mainWindow,
seriesFixture.categoryName
);
await clickGridListCardByTitle(app.mainWindow, seriesTitle);
const seriesTitle = await clickFirstGridListCard(app.mainWindow);
await playFirstSeriesEpisode(app.mainWindow);
await openPlaylistRecent(app.mainWindow);
await expect(channelItemByTitle(app.mainWindow, liveTitle)).toHaveCount(1);
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
await expect(contentCardByTitle(app.mainWindow, movieTitle)).toHaveCount(1);
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expect(contentCardByTitle(app.mainWindow, seriesTitle)).toHaveCount(1);
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 expect(contentCardByTitle(app.mainWindow, movieTitle)).toHaveCount(1);
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expect(contentCardByTitle(app.mainWindow, seriesTitle)).toHaveCount(1);
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
@@ -180,22 +225,93 @@ test.describe('Electron Recently Viewed', () => {
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 expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expect(contentCardByTitle(app.mainWindow, seriesTitle)).toHaveCount(1);
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
await switchUnifiedCollectionContent(app.mainWindow, 'Live TV');
await clearRecentItems(app.mainWindow);
// After clearing all items, the content toggle disappears.
// All content types are cleared simultaneously, so no toggle switch needed.
await expect(channelItemByTitle(app.mainWindow, liveTitle)).toHaveCount(0);
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
await expect(contentCardByTitle(app.mainWindow, movieTitle)).toHaveCount(0);
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expect(contentCardByTitle(app.mainWindow, seriesTitle)).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('opens Xtream recent movies and series in source detail routes and returns back to recent', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const vodFixture = await fetchXtreamVodFixture(request, xtreamCredentials);
const seriesFixture = await fetchXtreamSeriesFixture(
request,
xtreamCredentials
);
const portalTitle = 'Xtream Recent Detail Source';
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
name: portalTitle,
});
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 playCurrentDetail(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 playFirstSeriesEpisode(app.mainWindow);
await openPlaylistRecent(app.mainWindow);
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await contentCardByTitle(app.mainWindow, movieTitle).first().click();
await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/);
await expect(app.mainWindow.locator('app-content-hero')).toContainText(
movieTitle
);
await goBackFromDetail(app.mainWindow);
await expect
.poll(() => new URL(app.mainWindow.url()).pathname)
.toMatch(/\/recent$/);
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
await contentCardByTitle(app.mainWindow, seriesTitle).first().click();
await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/);
await expect(app.mainWindow.locator('app-content-hero')).toContainText(
seriesTitle
);
await goBackFromDetail(app.mainWindow);
await expect
.poll(() => new URL(app.mainWindow.url()).pathname)
.toMatch(/\/recent$/);
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
} finally {
await closeElectronApp(app);
}
});
test('tracks Stalker live, movie, and series history across playlist and all-playlists scope, and preserves it after restart', async ({
dataDir,
request,
@@ -1,14 +1,27 @@
import { Page } from '@playwright/test';
import {
addXtreamPortal,
clickCategoryByNameExact,
clickFirstGridListCard,
closeElectronApp,
createMutableTextServer,
enableRemoteControl,
expect,
launchElectronApp,
openGlobalRecent,
openSettings,
openWorkspaceSection,
resetMockServers,
restartElectronApp,
saveSettings,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import {
defaultXtreamPassword,
defaultXtreamUsername,
} from './electron-test-fixtures';
import { fetchXtreamVodFixture } from './portal-mock-fixtures';
const epgFixtureXml = `<?xml version="1.0" encoding="UTF-8"?>
<tv>
@@ -108,6 +121,111 @@ test.describe('Electron Settings', () => {
await epgServer.close();
}
});
test('starts on sources when dashboard is disabled', async ({ dataDir }) => {
const firstLaunch = await launchElectronApp(dataDir);
try {
await openSettings(firstLaunch.mainWindow);
await firstLaunch.mainWindow
.locator(
'mat-checkbox[formcontrolname="showDashboard"] input[type="checkbox"]'
)
.uncheck();
await saveSettings(firstLaunch.mainWindow);
} finally {
await closeElectronApp(firstLaunch);
}
const secondLaunch = await launchElectronApp(dataDir);
try {
await secondLaunch.mainWindow.waitForURL(/\/workspace\/sources$/);
await expect(
secondLaunch.mainWindow.getByRole('link', {
name: 'Dashboard',
exact: true,
})
).toHaveCount(0);
await expect(secondLaunch.mainWindow.locator('a.brand')).toHaveAttribute(
'href',
/\/workspace\/sources$/
);
} finally {
await closeElectronApp(secondLaunch);
}
});
test('restores the last section-level view across restart when configured', async ({
dataDir,
}) => {
let app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await selectSettingsOption(
app.mainWindow,
'select-startup-behavior',
'restore-last-view'
);
await saveSettings(app.mainWindow);
await openGlobalRecent(app.mainWindow);
} finally {
app = await restartElectronApp(app, dataDir);
}
try {
await app.mainWindow.waitForURL(/\/workspace\/global-recent$/);
} finally {
await closeElectronApp(app);
}
});
test('ignores settings and restores only the section root after a detail route', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const vodFixture = await fetchXtreamVodFixture(request, {
password: defaultXtreamPassword,
username: defaultXtreamUsername,
});
let app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await selectSettingsOption(
app.mainWindow,
'select-startup-behavior',
'restore-last-view'
);
await saveSettings(app.mainWindow);
await addXtreamPortal(app.mainWindow);
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Movies');
await clickCategoryByNameExact(
app.mainWindow,
vodFixture.categoryName
);
await clickFirstGridListCard(app.mainWindow);
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/
);
await openSettings(app.mainWindow);
} finally {
app = await restartElectronApp(app, dataDir);
}
try {
await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod$/);
} finally {
await closeElectronApp(app);
}
});
});
async function selectSettingsOption(
@@ -26,6 +26,7 @@ import {
waitForM3uCatalog,
waitForPortalDebugEvent,
waitForXtreamCatalog,
waitForSourceRowIdle,
writeTemporaryM3uFile,
} from './electron-test-fixtures';
@@ -447,6 +448,13 @@ https://streams.example.test/refreshed-url.m3u8
provider: 'xtream',
timeoutMs: 30000,
});
await openSources(app.mainWindow);
await waitForSourceRowIdle(app.mainWindow, 'Refresh Xtream Source');
await expect(
sourceRowByTitle(app.mainWindow, 'Refresh Xtream Source')
.first()
.locator('.meta')
).toContainText('Updated:');
} finally {
await closeElectronApp(app);
await urlServer.close();
@@ -9,6 +9,7 @@ import {
test,
waitForDbOperationEvent,
waitForXtreamCatalog,
xtreamMockServer,
} from './electron-test-fixtures';
const stressPortalName = 'Stress Xtream Portal';
@@ -25,6 +26,10 @@ async function openSources(page: Parameters<typeof addXtreamPortal>[0]) {
await page.waitForURL(/\/workspace\/sources$/);
}
function xtreamBlockedStateTitle(page: Parameters<typeof addXtreamPortal>[0]) {
return page.locator('.xtream-content-gate .error-view__title');
}
test.describe('Electron Xtream Responsiveness', () => {
test.describe.configure({ mode: 'serial' });
test.setTimeout(120000);
@@ -52,7 +57,7 @@ test.describe('Electron Xtream Responsiveness', () => {
await expect(overlay).toBeVisible({ timeout: 15000 });
await expect(
overlay.getByRole('button', { name: /cancel import/i })
).toBeVisible({ timeout: 10000 });
).toBeVisible({ timeout: 20000 });
await waitForDbOperationEvent(app.mainWindow, {
operation: 'save-content',
@@ -101,6 +106,139 @@ test.describe('Electron Xtream Responsiveness', () => {
}
});
test('stops importing after cancel until the user explicitly retries', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir, {
env: {
IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs,
},
});
try {
await addXtreamPortal(app.mainWindow, {
name: stressPortalName,
password: stressXtreamPassword,
username: stressXtreamUsername,
});
const overlay = app.mainWindow.locator('.workspace-loading-overlay');
const cancelButton = overlay.getByRole('button', {
name: /cancel import/i,
});
await expect(overlay).toBeVisible({ timeout: 15000 });
await expect(cancelButton).toBeVisible({ timeout: 20000 });
await waitForDbOperationEvent(app.mainWindow, {
operation: 'save-content',
predicate: (event) =>
event.status === 'progress' &&
(event.current ?? 0) > 0 &&
(event.total ?? 0) > 0,
timeoutMs: 30000,
});
await cancelButton.click();
await expect(overlay).toHaveCount(0, { timeout: 20000 });
await expect(
xtreamBlockedStateTitle(app.mainWindow)
).toHaveText('Import cancelled', { timeout: 20000 });
await app.mainWindow.waitForTimeout(1200);
await expect(overlay).toHaveCount(0);
const retryButton = app.mainWindow.getByRole('button', {
name: 'Retry',
exact: true,
});
await retryButton.click();
await waitForXtreamCatalog(app.mainWindow);
} finally {
await closeElectronApp(app);
}
});
test('blocks expired and inactive Xtream accounts before import starts', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
name: 'Expired Xtream Portal',
password: 'expired',
username: 'expired',
});
await expect(xtreamBlockedStateTitle(app.mainWindow)).toHaveText(
'Account expired',
{ timeout: 20000 }
);
await expect(
app.mainWindow.locator('.workspace-loading-overlay')
).toHaveCount(0);
await openSources(app.mainWindow);
await addXtreamPortal(app.mainWindow, {
name: 'Inactive Xtream Portal',
password: 'inactive',
username: 'inactive',
});
await expect(xtreamBlockedStateTitle(app.mainWindow)).toHaveText(
'Account inactive',
{ timeout: 20000 }
);
await expect(
app.mainWindow.locator('.workspace-loading-overlay')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('blocks unreachable Xtream portals immediately without an import loop', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
name: 'Unavailable Xtream Portal',
password: stressXtreamPassword,
serverUrl: xtreamMockServer.replace(/:\d+$/, ':65530'),
username: stressXtreamUsername,
});
await expect(xtreamBlockedStateTitle(app.mainWindow)).toHaveText(
'Portal unavailable',
{ timeout: 20000 }
);
await expect(
app.mainWindow.locator('.workspace-loading-overlay')
).toHaveCount(0);
await app.mainWindow.waitForTimeout(1200);
await expect(
app.mainWindow.locator('.workspace-loading-overlay')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('keeps the UI responsive during large Xtream playlist deletion', async ({
dataDir,
request,
@@ -365,7 +365,7 @@ export function generateSeasons(
return Array.from({ length: seasonCount }, (_, s) => {
const seasonId = `${series.id}-s${s + 1}`;
const episodes = Array.from({ length: episodesPerSeason }, (_, e) =>
`${series.id}-s${s + 1}-e${e + 1}`
String(e + 1)
);
return {
id: seasonId,
+1 -1
View File
@@ -12,7 +12,7 @@ test('basic test', async ({ page }) => {
// Upload playlist test
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
await dialog.getByRole('button', { name: 'Add via file upload' }).click();
await dialog.locator('mat-button-toggle[value="file"]').click();
await page.setInputFiles('input[type="file"]', fixturePath);
await page.waitForURL(/\/workspace\/playlists\/.+\/all$/);
await expect(page.getByText('test.m3u')).toBeVisible();
+1 -1
View File
@@ -76,7 +76,7 @@ async function addStalkerPortal(
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
await dialog.getByRole('button', { name: 'Stalker' }).click();
await dialog.locator('mat-button-toggle[value="stalker"]').click();
await setInputValue(dialog.locator('input#title'), name);
await setInputValue(dialog.locator('input#portalUrl'), PORTAL_URL);
+1 -1
View File
@@ -56,7 +56,7 @@ async function addXtreamPortal(
await page.getByRole('button', { name: 'Add playlist' }).click();
const dialog = page.locator('mat-dialog-container');
await dialog.getByRole('button', { name: 'Xtream' }).click();
await dialog.locator('mat-button-toggle[value="xtream"]').click();
await dialog.locator('#title').fill(name);
await dialog.locator('#serverUrl').fill(MOCK_SERVER);
@@ -23,6 +23,7 @@ export interface XtreamPlaylistData {
id: string;
name: string;
title?: string; // Alias for name, used in some templates
updateDate?: number;
serverUrl: string;
username: string;
password: string;
@@ -55,7 +55,11 @@
<div class="categories-list">
@for (category of filteredCategories(); track category.id) {
<div class="category-item" (click)="toggleCategory(category)">
<div
class="category-item"
[attr.data-category-id]="category.xtream_id"
(click)="toggleCategory(category)"
>
<mat-checkbox
[checked]="category.selected"
(click)="$event.stopPropagation()"
@@ -86,10 +86,7 @@ export class CategoryManagementDialogComponent implements OnInit {
private async loadCategories(): Promise<void> {
try {
const type = this.getDbType();
const allCategories = await this.dbService.getAllXtreamCategories(
this.data.playlistId,
type
);
const allCategories = await this.waitForCategories(type);
this.categories.set(
allCategories.map((c) => ({
...c,
@@ -103,6 +100,30 @@ export class CategoryManagementDialogComponent implements OnInit {
}
}
private async waitForCategories(
type: 'live' | 'movies' | 'series'
): Promise<XCategoryFromDb[]> {
const retryDeadlineMs = Date.now() + 10000;
const expectedCategoryCount = this.data.itemCounts.size;
while (true) {
const categories = await this.dbService.getAllXtreamCategories(
this.data.playlistId,
type
);
if (
categories.length > 0 ||
expectedCategoryCount === 0 ||
Date.now() >= retryDeadlineMs
) {
return categories;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
clearSearch(): void {
this.searchTerm.set('');
}
@@ -33,6 +33,7 @@ function toXtreamPlaylistData(
id: playlist._id,
name: playlist.title || playlist.filename || 'Untitled playlist',
title: playlist.title,
updateDate: playlist.updateDate,
serverUrl: playlist.serverUrl,
username: playlist.username,
password: playlist.password,
@@ -51,6 +52,7 @@ export class XtreamWorkspaceRouteSession {
private readonly xtreamStore = inject(XtreamStore);
private currentPlaylistId: string | null = null;
private currentPlaylistUpdateDate: number | null = null;
constructor() {
effect(() => {
@@ -95,19 +97,22 @@ export class XtreamWorkspaceRouteSession {
? toXtreamPlaylistData(this.playlistContext.activePlaylist())
: null;
const currentPlaylist = this.xtreamStore.currentPlaylist();
const routePlaylistUpdateDate = routePlaylist?.updateDate ?? null;
const needsPlaylistBootstrap = Boolean(
playlistId &&
routePlaylist &&
currentPlaylist?.id !== playlistId
(currentPlaylist?.id !== playlistId ||
this.currentPlaylistUpdateDate !== routePlaylistUpdateDate)
);
if (
playlistId &&
(this.currentPlaylistId !== playlistId || needsPlaylistBootstrap)
) {
if (this.currentPlaylistId !== playlistId) {
if (this.currentPlaylistId !== playlistId || needsPlaylistBootstrap) {
this.xtreamStore.resetStore(playlistId);
this.currentPlaylistId = playlistId;
this.currentPlaylistUpdateDate = routePlaylistUpdateDate;
}
this.xtreamStore.setCurrentPlaylist(routePlaylist);
@@ -3,7 +3,9 @@
@for (item of items(); track $index) {
<a
class="nav-item category-item"
[attr.data-category-id]="$any(item).category_id ?? item.id"
[attr.data-category-id]="
$any(item).category_id ?? $any(item).xtream_id ?? item.id
"
[class.selected]="isSelected(item)"
[class.category-item--selected]="isSelected(item)"
[attr.aria-current]="isSelected(item) ? 'true' : null"