import { Locator, Page } from '@playwright/test'; import { addXtreamPortal, closeElectronApp, expect, launchElectronApp, openSources, openWorkspaceSection, refreshSource, resetMockServers, restartElectronApp, sourceRowByTitle, test, waitForSourceRowIdle, waitForXtreamWorkspaceReady, } from './electron-test-fixtures'; test.describe('Electron Xtream Category Management', () => { test('hides and restores categories, supports search and bulk actions, and persists hidden selections after refresh', async ({ dataDir, request, }) => { await resetMockServers(request, ['xtream']); const portalName = 'Category Managed Xtream'; const app = await launchElectronApp(dataDir); try { await addXtreamPortal(app.mainWindow, { name: portalName, }); 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); let dialog = await openManageCategoriesDialog(app.mainWindow); await dialog .getByRole('button', { name: 'Deselect All', exact: true, }) .click(); await expect( dialog.locator('.category-item mat-checkbox input:checked') ).toHaveCount(0); await dialog .getByRole('button', { name: 'Select All', exact: true, }) .click(); await expect( dialog .locator('.category-item mat-checkbox input:checked') .first() ).toBeVisible(); await dialog .locator('input[type="search"]') .fill(targetCategory.name); await toggleManagedCategory(dialog, targetCategory, false); 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(0); 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('input[type="search"]') .fill(targetCategory.name); await toggleManagedCategory(dialog, targetCategory, true); await dialog .getByRole('button', { name: 'Save', exact: true }) .click(); await app.mainWindow.waitForSelector('mat-dialog-container', { state: 'detached', }); dialog = await openManageCategoriesDialog(app.mainWindow); await dialog .locator('input[type="search"]') .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); } }); test('persists hidden selections after refreshing from the workspace header', async ({ dataDir, request, }) => { await resetMockServers(request, ['xtream']); const portalName = 'Header Refresh Xtream'; const app = await launchElectronApp(dataDir); try { await addXtreamPortal(app.mainWindow, { name: portalName, }); await waitForXtreamWorkspaceReady(app.mainWindow); await openWorkspaceSection(app.mainWindow, 'Live TV'); await app.mainWindow.waitForURL( /\/workspace\/xtreams\/[^/]+\/live/ ); const targetCategory = await pickSidebarCategory(app.mainWindow); const dialog = await openManageCategoriesDialog(app.mainWindow); await dialog .getByRole('button', { name: 'Deselect All', exact: true, }) .click(); await expect( dialog.locator('.category-item mat-checkbox input:checked') ).toHaveCount(0); await dialog .locator('input[type="search"]') .fill(targetCategory.name); 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) ).toBeVisible(); await refreshFromWorkspaceHeader(app.mainWindow); await waitForXtreamWorkspaceReady(app.mainWindow); await openWorkspaceSection(app.mainWindow, 'Live TV'); await app.mainWindow.waitForURL( /\/workspace\/xtreams\/[^/]+\/live/ ); await expectVisibleSidebarCategoryNames(app.mainWindow, [ targetCategory.name, ]); 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 expectVisibleSidebarCategoryNames(app.mainWindow, [ targetCategory.name, ]); } finally { await closeElectronApp(app); } }); test('allows restoring live categories after every category is hidden', async ({ dataDir, request, }) => { await resetMockServers(request, ['xtream']); const portalName = 'All Hidden Categories Xtream'; const app = await launchElectronApp(dataDir); try { await addXtreamPortal(app.mainWindow, { name: portalName, }); await waitForXtreamWorkspaceReady(app.mainWindow); await openWorkspaceSection(app.mainWindow, 'Live TV'); await app.mainWindow.waitForURL( /\/workspace\/xtreams\/[^/]+\/live/ ); await expect .poll(() => readVisibleSidebarCategoryNames(app.mainWindow), { timeout: 15000, }) .not.toEqual([]); const initialCategoryNames = await readVisibleSidebarCategoryNames( app.mainWindow ); let dialog = await openManageCategoriesDialog(app.mainWindow); await dialog .getByRole('button', { name: 'Deselect All', exact: true, }) .click(); await expect( dialog.locator('.category-item mat-checkbox input:checked') ).toHaveCount(0); await dialog .getByRole('button', { name: 'Save', exact: true }) .click(); await app.mainWindow.waitForSelector('mat-dialog-container', { state: 'detached', }); await expect .poll(() => readVisibleSidebarCategoryNames(app.mainWindow), { timeout: 15000, }) .toEqual([]); await expect( app.mainWindow.getByRole('button', { name: 'Manage categories', exact: true, }) ).toBeEnabled(); dialog = await openManageCategoriesDialog(app.mainWindow); await dialog .getByRole('button', { name: 'Select All', exact: true }) .click(); await expect( dialog.locator('.category-item mat-checkbox input:checked') ).toHaveCount(initialCategoryNames.length); await dialog .getByRole('button', { name: 'Save', exact: true }) .click(); await app.mainWindow.waitForSelector('mat-dialog-container', { state: 'detached', }); await expectVisibleSidebarCategoryNames( app.mainWindow, initialCategoryNames ); } finally { await closeElectronApp(app); } }); }); async function openManageCategoriesDialog(page: Page) { await page.getByRole('button', { name: 'Manage categories' }).click(); const dialog = page.locator('mat-dialog-container').last(); await expect(dialog).toBeVisible(); 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; } async function refreshFromWorkspaceHeader(page: Page): Promise { await page .getByRole('button', { name: 'Refresh playlist', exact: true }) .click(); const dialog = page.locator('mat-dialog-container'); await expect(dialog).toBeVisible(); await dialog.getByRole('button', { name: 'Yes', exact: true }).click(); const refreshOverlay = page.locator('app-workspace-shell-import-overlay'); await expect(refreshOverlay).toBeVisible({ timeout: 5000 }); await expect( refreshOverlay.getByRole('heading', { name: 'Refreshing playlist', exact: true, }) ).toBeVisible(); await expect(refreshOverlay).toContainText(/Local library/); await expect(refreshOverlay).toContainText( /Preserving your library data|Removing cached streams|Removing cached categories/ ); await page.waitForSelector('mat-dialog-container', { state: 'detached' }); } function sidebarCategoryById(page: Page, categoryId: string): Locator { return page.locator( `app-workspace-context-panel .category-item[data-category-id="${categoryId}"]` ); } async function expectVisibleSidebarCategoryNames( page: Page, expectedNames: string[] ): Promise { try { await expect .poll(() => readVisibleSidebarCategoryNames(page), { timeout: 30000, }) .toEqual(expectedNames); } catch (error) { const actualNames = await readVisibleSidebarCategoryNames(page); if (stringArraysEqual(actualNames, expectedNames)) { return; } throw new Error( `Expected visible sidebar categories ${JSON.stringify( expectedNames )}, received ${JSON.stringify(actualNames)}`, { cause: error } ); } } async function readVisibleSidebarCategoryNames(page: Page): Promise { const categories = page.locator( 'app-workspace-context-panel .category-item' ); const actualNames: string[] = []; const count = await categories.count(); for (let index = 0; index < count; index += 1) { const category = categories.nth(index); if (!(await category.isVisible())) { continue; } const categoryName = (await category.locator('.nav-item-label').textContent())?.trim() ?? ''; if (categoryName) { actualNames.push(categoryName); } } return actualNames; } function stringArraysEqual(left: string[], right: string[]): boolean { return ( left.length === right.length && left.every((value, index) => value === right[index]) ); } async function pickSidebarCategory( page: Page ): Promise<{ id: string; itemCount: number; name: string }> { let preferredCandidate: { id: string; itemCount: number; name: string; } | null = null; await expect .poll(async () => { const categories = page.locator( 'app-workspace-context-panel .category-item:visible' ); const count = await categories.count(); const candidates: Array<{ id: string; itemCount: number; name: string; }> = []; for (let index = 0; index < count; index += 1) { const category = categories.nth(index); const id = (await category.getAttribute('data-category-id'))?.trim() ?? ''; const name = ( await category.locator('.nav-item-label').textContent() )?.trim() ?? ''; const countText = ( await category.locator('.item-count').textContent() )?.trim() ?? ''; const itemCount = Number.parseInt(countText, 10) || 0; if (id && name && itemCount > 0) { candidates.push({ id, itemCount, name }); } } if (candidates.length === 0) { preferredCandidate = null; return false; } const nameCounts = new Map(); for (const candidate of candidates) { nameCounts.set( candidate.name, (nameCounts.get(candidate.name) ?? 0) + 1 ); } preferredCandidate = candidates.find( (candidate) => nameCounts.get(candidate.name) === 1 ) ?? candidates[0]; return preferredCandidate !== null; }) .toBe(true, { message: 'No visible Xtream category with content was found.', timeout: 15000, }); return preferredCandidate!; } async function toggleManagedCategory( dialog: Locator, targetCategory: { id: string; itemCount: number; name: string; }, shouldBeSelected: boolean ): Promise { 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 }); if (shouldBeSelected) { await checkbox.check(); await expect(checkbox).toBeChecked(); return; } await checkbox.uncheck(); await expect(checkbox).not.toBeChecked(); }