test(e2e): enhance category management dialog and update playlist handling

This commit is contained in:
4gray committed 2026-04-01 22:41:15 +02:00
1 parent 0f3a75f2b2
commit a15ac7f7c1
9 files changed
+80 -25

No files matched your search

@@ -9,6 +9,7 @@ import {
openWorkspaceSection,
refreshSource,
resetMockServers,
restartElectronApp,
sourceRowByTitle,
test,
waitForSourceRowIdle,
@@ -62,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',
@@ -76,6 +77,12 @@ test.describe('Electron Xtream Category Management', () => {
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');
@@ -88,16 +95,23 @@ test.describe('Electron Xtream Category Management', () => {
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);
}
@@ -168,19 +182,25 @@ async function pickSidebarCategory(
async function toggleManagedCategory(
dialog: Locator,
targetCategory: {
id: string;
itemCount: number;
name: string;
}
},
shouldBeSelected: boolean
): Promise<void> {
// Use hasText on the whole row so Playwright's retry loop waits for Angular
// to re-render filteredCategories() after the search field is populated.
// Avoid nesting dialog.locator() inside filter({ has: }) — Playwright
// cannot scope an absolute locator to each candidate element.
const categoryRow = dialog
.locator('.category-item')
.filter({ hasText: targetCategory.name })
.first();
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');
await expect(categoryRow).toBeVisible({ timeout: 15000 });
await categoryRow.click();
if (shouldBeSelected) {
await checkbox.check();
await expect(checkbox).toBeChecked();
return;
}
await checkbox.uncheck();
await expect(checkbox).not.toBeChecked();
}
@@ -4,6 +4,7 @@ import {
addXtreamPortal,
channelItemByTitle,
clickCategoryById,
clickGridListCardByTitle,
clickCategoryByNameExact,
clickFirstGridListCard,
closeElectronApp,
@@ -4,6 +4,7 @@ import {
addXtreamPortal,
channelItemByTitle,
clickCategoryById,
clickGridListCardByTitle,
clickCategoryByNameExact,
clickFirstGridListCard,
closeElectronApp,
@@ -52,7 +52,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',
@@ -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"