mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
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:
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();
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+5
-1
@@ -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()"
|
||||
|
||||
+25
-4
@@ -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
-1
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user