From 464d2eda016b4ba56f362710da04d642fce0fa61 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 06:48:49 +0000 Subject: [PATCH 01/11] fix(e2e): use mat-button-toggle value selectors for playlist type selection mat-button-toggle elements inside a group render their internal buttons with role="radio", not role="button", so getByRole('button') selectors fail. Switch to attribute selectors targeting the value attribute instead. https://claude.ai/code/session_01VU6ZZTG7YrhKhKBQvBfmpp --- apps/web-e2e/src/basic.e2e.ts | 2 +- apps/web-e2e/src/stalker.e2e.ts | 2 +- apps/web-e2e/src/xtream.e2e.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web-e2e/src/basic.e2e.ts b/apps/web-e2e/src/basic.e2e.ts index d9dac42d6..65e672787 100644 --- a/apps/web-e2e/src/basic.e2e.ts +++ b/apps/web-e2e/src/basic.e2e.ts @@ -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(); diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index a12b44148..39e2b90d4 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -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); diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index a7bd97653..7152fa0e9 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -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); From fc878e7c09da5fc2ea902c9b2de34599fcceec9c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 17:52:12 +0000 Subject: [PATCH 02/11] fix(e2e): update electron e2e fixtures for new unified add-playlist dialog Replace menuitem selectors with mat-button-toggle[value] selectors in all four playlist import helpers (file, url, xtream, stalker) and rename openAddPlaylistMenu to openAddPlaylistDialog to reflect that clicking "Add playlist" now opens a dialog directly instead of a dropdown menu. https://claude.ai/code/session_01VU6ZZTG7YrhKhKBQvBfmpp --- .../src/electron-test-fixtures.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 276ee88ef..f1ce9df92 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -193,7 +193,7 @@ async function waitForAppReady(page: Page): Promise { ); } -export async function openAddPlaylistMenu(page: Page): Promise { +export async function openAddPlaylistDialog(page: Page): Promise { await page.getByRole('button', { name: 'Add playlist' }).click(); } @@ -215,9 +215,9 @@ export async function importM3uPlaylistFromNativeDialog( filePath: string ): Promise { 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 { - 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"]'), From ef344f4ee8716e7771b1ac38551a5cb410ea4ab3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 18:35:17 +0000 Subject: [PATCH 03/11] fix(e2e): resolve 4 remaining test failures in electron e2e suite - Fix getVisibleSourceTitles: use .playlist-title selector instead of .mdc-list-item__primary-text which no longer matches playlist-item component - Fix category-management test: re-navigate to sources page after refreshSource since refresh navigates away from sources - Fix recent.e2e test: remove switchUnifiedCollectionContent calls after clearRecentItems since the content toggle disappears when all items cleared - Fix stalker mock server: generate numeric episode IDs ("1","2",...) in season.series array so toSeriesNumbers() can parse them; previously used non-numeric IDs like "30000-s1-e1" which parsed to NaN, producing empty episode arrays and causing playFirstSeriesEpisode to time out https://claude.ai/code/session_01VU6ZZTG7YrhKhKBQvBfmpp --- apps/electron-backend-e2e/src/category-management.e2e.ts | 1 + apps/electron-backend-e2e/src/electron-test-fixtures.ts | 4 +--- apps/electron-backend-e2e/src/recent.e2e.ts | 4 ++-- apps/stalker-mock-server/src/app/data-generator.ts | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index 84238514c..ee5c298dd 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -68,6 +68,7 @@ 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); await sourceRowByTitle(app.mainWindow, portalName).first().click(); await waitForXtreamWorkspaceReady(app.mainWindow); diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index f1ce9df92..20cd4ae23 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -675,9 +675,7 @@ export async function getVisibleSourceTitles(page: Page): Promise { 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) diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts index dbe264c7b..77127fbb4 100644 --- a/apps/electron-backend-e2e/src/recent.e2e.ts +++ b/apps/electron-backend-e2e/src/recent.e2e.ts @@ -186,10 +186,10 @@ test.describe('Electron Recently Viewed', () => { 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); diff --git a/apps/stalker-mock-server/src/app/data-generator.ts b/apps/stalker-mock-server/src/app/data-generator.ts index 2cdfeea21..842e7ae24 100644 --- a/apps/stalker-mock-server/src/app/data-generator.ts +++ b/apps/stalker-mock-server/src/app/data-generator.ts @@ -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, From 3f08aba550206770c894c9a48955bad4128deeb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 07:25:47 +0000 Subject: [PATCH 04/11] fix(e2e): fix dragSourceBefore and toggleManagedCategory selectors - dragSourceBefore: replace mat-list-item with .playlist-item as the drag target; playlist-item.component has no mat-list-item element, the inner container div carries the .playlist-item class - toggleManagedCategory: match categories by name only instead of name+itemCount; after an Xtream refresh the itemCounts Map passed to the dialog may be empty (streams not yet counted), causing a false mismatch even though the category is present and uniquely named https://claude.ai/code/session_01VU6ZZTG7YrhKhKBQvBfmpp --- apps/electron-backend-e2e/src/category-management.e2e.ts | 2 +- apps/electron-backend-e2e/src/electron-test-fixtures.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index ee5c298dd..ab0be6178 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -170,7 +170,7 @@ async function toggleManagedCategory( (await categoryRow.locator('.item-count').textContent())?.trim() ?? ''; const itemCount = Number.parseInt(countText.replace(/[()]/g, ''), 10) || 0; - if (name !== targetCategory.name || itemCount !== targetCategory.itemCount) { + if (name !== targetCategory.name) { continue; } diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 20cd4ae23..fd3d5a592 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -707,7 +707,7 @@ export async function dragSourceBefore( targetTitle: string ): Promise { 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(); From 78a2dd3766b058f6cf80219d3ecec1fd1954b4a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 21:58:40 +0000 Subject: [PATCH 05/11] fix(e2e): fix category-management race condition and favorites grid pagination - toggleManagedCategory: replace manual count-loop with Playwright filter so the locator retries internally until Angular re-renders the filtered list after the search field is populated (previously called .count() before the @for loop had reacted to the updated searchTerm signal) - favorites Xtream test: pick movie/series titles from the first displayed grid card instead of vodFixture.items[0]. The grid sorts by date-desc and paginates at 25 items; the first API item may land on page 2 making clickGridListCardByTitle time out with "element not found". Reading the title from the DOM after navigation ensures we always target a visible card. - electron-test-fixtures: export waitForFirstGridListCardTitle helper https://claude.ai/code/session_01VU6ZZTG7YrhKhKBQvBfmpp --- .../src/category-management.e2e.ts | 35 +++++++------------ .../src/electron-test-fixtures.ts | 11 ++++++ .../electron-backend-e2e/src/favorites.e2e.ts | 7 ++-- 3 files changed, 28 insertions(+), 25 deletions(-) diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index ab0be6178..b50f0b92a 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -158,28 +158,17 @@ async function toggleManagedCategory( name: string; } ): Promise { - const categories = dialog.locator('.category-item'); - const count = await categories.count(); + // Use Playwright's built-in filter so it retries until Angular re-renders + // the filtered list after the search field is populated. + const categoryRow = dialog + .locator('.category-item') + .filter({ + has: dialog.locator('.category-name', { + hasText: targetCategory.name, + }), + }) + .first(); - 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) { - continue; - } - - await expect(categoryRow).toBeVisible(); - await categoryRow.click(); - return; - } - - throw new Error( - `Could not find category "${targetCategory.name}" with count ${targetCategory.itemCount} in the management dialog.` - ); + await expect(categoryRow).toBeVisible({ timeout: 5000 }); + await categoryRow.click(); } diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index fd3d5a592..e05ecb13a 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -624,6 +624,17 @@ 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 { + const card = page.locator('.category-content-layout mat-card').first(); + await expect(card).toBeVisible({ timeout: 20000 }); + return ((await card.locator('.title').textContent()) ?? '').trim(); +} + export async function clickGridListCardByTitle( page: Page, title: string diff --git a/apps/electron-backend-e2e/src/favorites.e2e.ts b/apps/electron-backend-e2e/src/favorites.e2e.ts index e79b9e07f..bdba8d573 100644 --- a/apps/electron-backend-e2e/src/favorites.e2e.ts +++ b/apps/electron-backend-e2e/src/favorites.e2e.ts @@ -22,6 +22,7 @@ import { switchUnifiedCollectionContent, switchUnifiedCollectionScope, test, + waitForFirstGridListCardTitle, waitForM3uCatalog, waitForStalkerCatalog, waitForXtreamWorkspaceReady, @@ -116,8 +117,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,6 +138,9 @@ test.describe('Electron Favorites', () => { app.mainWindow, vodFixture.categoryName ); + // Pick the title from the first displayed card (grid sorts by date-desc, + // so fixture order ≠ display order; pagination may hide some items). + const movieTitle = await waitForFirstGridListCardTitle(app.mainWindow); await clickGridListCardByTitle(app.mainWindow, movieTitle); await addCurrentDetailToFavorites(app.mainWindow); await goBackFromDetail(app.mainWindow); @@ -148,6 +150,7 @@ test.describe('Electron Favorites', () => { app.mainWindow, seriesFixture.categoryName ); + const seriesTitle = await waitForFirstGridListCardTitle(app.mainWindow); await clickGridListCardByTitle(app.mainWindow, seriesTitle); await addCurrentDetailToFavorites(app.mainWindow); await goBackFromDetail(app.mainWindow); From ffa5b59a5ceb48bde78b6020733fa9cfee124625 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 05:08:30 +0000 Subject: [PATCH 06/11] fix(e2e): fix category-management filter scoping and recent grid pagination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - category-management: replace filter({ has: dialog.locator(...) }) with filter({ hasText }) on the .category-item row. Playwright cannot scope an absolute locator (created via dialog.locator()) to each candidate element when used inside filter({ has: }), so the query always returned empty. Using hasText on the whole row correctly retries until Angular's @for loop re-renders filteredCategories() after the search field is populated. - recent.e2e.ts Xtream test: same pagination fix as favorites — derive movieTitle and seriesTitle from waitForFirstGridListCardTitle() instead of pickDistinctTitles(fixture.items)[0], which may be on page 2 of the date-sorted grid and never visible. https://claude.ai/code/session_01VU6ZZTG7YrhKhKBQvBfmpp --- .../src/category-management.e2e.ts | 12 +++++------- apps/electron-backend-e2e/src/recent.e2e.ts | 7 +++++-- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index b50f0b92a..ab43ee62e 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -158,15 +158,13 @@ async function toggleManagedCategory( name: string; } ): Promise { - // Use Playwright's built-in filter so it retries until Angular re-renders - // the filtered list after the search field is populated. + // 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({ - has: dialog.locator('.category-name', { - hasText: targetCategory.name, - }), - }) + .filter({ hasText: targetCategory.name }) .first(); await expect(categoryRow).toBeVisible({ timeout: 5000 }); diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts index 77127fbb4..4257ad69c 100644 --- a/apps/electron-backend-e2e/src/recent.e2e.ts +++ b/apps/electron-backend-e2e/src/recent.e2e.ts @@ -23,6 +23,7 @@ import { switchUnifiedCollectionContent, switchUnifiedCollectionScope, test, + waitForFirstGridListCardTitle, waitForM3uCatalog, waitForStalkerCatalog, waitForXtreamWorkspaceReady, @@ -115,8 +116,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,6 +140,9 @@ test.describe('Electron Recently Viewed', () => { app.mainWindow, vodFixture.categoryName ); + // Pick from the displayed grid: fixture order ≠ display order (date-desc + // sort + pagination), so the first fixture item may not be on page 1. + const movieTitle = await waitForFirstGridListCardTitle(app.mainWindow); await clickGridListCardByTitle(app.mainWindow, movieTitle); await playCurrentDetail(app.mainWindow); await goBackFromDetail(app.mainWindow); @@ -150,6 +152,7 @@ test.describe('Electron Recently Viewed', () => { app.mainWindow, seriesFixture.categoryName ); + const seriesTitle = await waitForFirstGridListCardTitle(app.mainWindow); await clickGridListCardByTitle(app.mainWindow, seriesTitle); await playFirstSeriesEpisode(app.mainWindow); From df0c7f79d139031eb381864eb88d5288c675c20c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 05:32:53 +0000 Subject: [PATCH 07/11] fix(e2e): wait for category-item list to render before interacting with manage-categories dialog The dialog's 'Select All' button appears as soon as isLoading() flips, but Angular still needs a change-detection cycle to paint the @for loop. On slower CI runners (especially after a refreshSource cycle) the .category-item rows were not yet in the DOM when toggleManagedCategory tried to locate them, causing a spurious 5 s timeout. Fix: add an explicit `toBeVisible` guard on the first .category-item inside openManageCategoriesDialog, and raise the per-row timeout in toggleManagedCategory from 5 s to 15 s. https://claude.ai/code/session_01VU6ZZTG7YrhKhKBQvBfmpp --- apps/electron-backend-e2e/src/category-management.e2e.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index ab43ee62e..77f94b635 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -103,6 +103,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; } @@ -167,6 +172,6 @@ async function toggleManagedCategory( .filter({ hasText: targetCategory.name }) .first(); - await expect(categoryRow).toBeVisible({ timeout: 5000 }); + await expect(categoryRow).toBeVisible({ timeout: 15000 }); await categoryRow.click(); } From 0f3a75f2b2165f94ed2d42584e5a7e6ecf8fe395 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Apr 2026 06:07:26 +0000 Subject: [PATCH 08/11] fix(e2e): fix category-management navigation race and grid card click race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two separate root causes: 1. category-management.e2e.ts: openWorkspaceSection('Live TV') was not awaited — pickSidebarCategory ran before the route settled on the live section, so it read VOD categories from the stale sidebar (e.g. 'Action' instead of a live category). The manage-categories dialog opens for the live section, so searching for a VOD name found nothing. Fix: add waitForURL(/\/workspace\/xtreams\/[^/]+\/live/) after both openWorkspaceSection('Live TV') calls. 2. recent.e2e.ts / favorites.e2e.ts: waitForFirstGridListCardTitle read the first card's title, but clickGridListCardByTitle then did a fresh DOM search by that title. Between those two calls the grid could re-sort/paginate, making the card unfindable. Fix: new clickFirstGridListCard helper atomically reads the title and clicks the same card reference, replacing the two-step pattern. https://claude.ai/code/session_01VU6ZZTG7YrhKhKBQvBfmpp --- .../src/category-management.e2e.ts | 9 +++++++++ .../src/electron-test-fixtures.ts | 19 +++++++++++++++++++ .../electron-backend-e2e/src/favorites.e2e.ts | 15 +++++++-------- apps/electron-backend-e2e/src/recent.e2e.ts | 12 ++++++------ 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index 77f94b635..a7480e4db 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -30,6 +30,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); @@ -73,6 +79,9 @@ test.describe('Electron Xtream Category Management', () => { 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 ); diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index e05ecb13a..247f1e10c 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -635,6 +635,25 @@ export async function waitForFirstGridListCardTitle(page: Page): Promise 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 { + 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 diff --git a/apps/electron-backend-e2e/src/favorites.e2e.ts b/apps/electron-backend-e2e/src/favorites.e2e.ts index bdba8d573..05660ff32 100644 --- a/apps/electron-backend-e2e/src/favorites.e2e.ts +++ b/apps/electron-backend-e2e/src/favorites.e2e.ts @@ -5,7 +5,7 @@ import { channelItemByTitle, clickCategoryById, clickCategoryByNameExact, - clickGridListCardByTitle, + clickFirstGridListCard, closeElectronApp, contentCardByTitle, defaultXtreamPassword, @@ -22,7 +22,6 @@ import { switchUnifiedCollectionContent, switchUnifiedCollectionScope, test, - waitForFirstGridListCardTitle, waitForM3uCatalog, waitForStalkerCatalog, waitForXtreamWorkspaceReady, @@ -138,10 +137,11 @@ test.describe('Electron Favorites', () => { app.mainWindow, vodFixture.categoryName ); - // Pick the title from the first displayed card (grid sorts by date-desc, - // so fixture order ≠ display order; pagination may hide some items). - const movieTitle = await waitForFirstGridListCardTitle(app.mainWindow); - 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); @@ -150,8 +150,7 @@ test.describe('Electron Favorites', () => { app.mainWindow, seriesFixture.categoryName ); - const seriesTitle = await waitForFirstGridListCardTitle(app.mainWindow); - await clickGridListCardByTitle(app.mainWindow, seriesTitle); + const seriesTitle = await clickFirstGridListCard(app.mainWindow); await addCurrentDetailToFavorites(app.mainWindow); await goBackFromDetail(app.mainWindow); diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts index 4257ad69c..31a741bc7 100644 --- a/apps/electron-backend-e2e/src/recent.e2e.ts +++ b/apps/electron-backend-e2e/src/recent.e2e.ts @@ -5,7 +5,7 @@ import { channelItemByTitle, clickCategoryById, clickCategoryByNameExact, - clickGridListCardByTitle, + clickFirstGridListCard, closeElectronApp, contentCardByTitle, defaultXtreamPassword, @@ -23,7 +23,6 @@ import { switchUnifiedCollectionContent, switchUnifiedCollectionScope, test, - waitForFirstGridListCardTitle, waitForM3uCatalog, waitForStalkerCatalog, waitForXtreamWorkspaceReady, @@ -142,8 +141,10 @@ test.describe('Electron Recently Viewed', () => { ); // Pick from the displayed grid: fixture order ≠ display order (date-desc // sort + pagination), so the first fixture item may not be on page 1. - const movieTitle = await waitForFirstGridListCardTitle(app.mainWindow); - await clickGridListCardByTitle(app.mainWindow, movieTitle); + // 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); @@ -152,8 +153,7 @@ test.describe('Electron Recently Viewed', () => { app.mainWindow, seriesFixture.categoryName ); - const seriesTitle = await waitForFirstGridListCardTitle(app.mainWindow); - await clickGridListCardByTitle(app.mainWindow, seriesTitle); + const seriesTitle = await clickFirstGridListCard(app.mainWindow); await playFirstSeriesEpisode(app.mainWindow); await openPlaylistRecent(app.mainWindow); From a15ac7f7c1f640746e040ec97ace77303acfc579 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 1 Apr 2026 22:41:15 +0200 Subject: [PATCH 09/11] test(e2e): enhance category management dialog and update playlist handling --- .../src/category-management.e2e.ts | 52 +++++++++++++------ .../electron-backend-e2e/src/favorites.e2e.ts | 1 + apps/electron-backend-e2e/src/recent.e2e.ts | 1 + .../src/xtream-responsiveness.e2e.ts | 2 +- .../xtream-data-source.interface.ts | 1 + .../category-management-dialog.component.html | 6 ++- .../category-management-dialog.component.ts | 29 +++++++++-- .../xtream-workspace-route-session.service.ts | 9 +++- ...space-context-category-view.component.html | 4 +- 9 files changed, 80 insertions(+), 25 deletions(-) diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index a7480e4db..8ab3a5b38 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -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 { - // 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(); } diff --git a/apps/electron-backend-e2e/src/favorites.e2e.ts b/apps/electron-backend-e2e/src/favorites.e2e.ts index 05660ff32..858aaa38b 100644 --- a/apps/electron-backend-e2e/src/favorites.e2e.ts +++ b/apps/electron-backend-e2e/src/favorites.e2e.ts @@ -4,6 +4,7 @@ import { addXtreamPortal, channelItemByTitle, clickCategoryById, + clickGridListCardByTitle, clickCategoryByNameExact, clickFirstGridListCard, closeElectronApp, diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts index 31a741bc7..cb996d86e 100644 --- a/apps/electron-backend-e2e/src/recent.e2e.ts +++ b/apps/electron-backend-e2e/src/recent.e2e.ts @@ -4,6 +4,7 @@ import { addXtreamPortal, channelItemByTitle, clickCategoryById, + clickGridListCardByTitle, clickCategoryByNameExact, clickFirstGridListCard, closeElectronApp, diff --git a/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts b/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts index c1ab6b8cc..66f452690 100644 --- a/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts @@ -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', diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts index abd266093..0e87e9cb8 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts @@ -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; diff --git a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html index 6ea7fc584..375ef6d6d 100644 --- a/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html +++ b/libs/portal/xtream/feature/src/lib/category-management-dialog/category-management-dialog.component.html @@ -55,7 +55,11 @@
@for (category of filteredCategories(); track category.id) { -
+
{ 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 { + 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(''); } diff --git a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts index f5a81e1bc..3266fb9a4 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-workspace-route-session.service.ts @@ -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); diff --git a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html index dbc7ea82c..24dfde787 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-context-panel/components/workspace-context-category-view.component.html @@ -3,7 +3,9 @@ @for (item of items(); track $index) { Date: Thu, 2 Apr 2026 08:45:50 +0200 Subject: [PATCH 10/11] test(e2e): replace content card visibility checks with dedicated function for better readability --- .../src/electron-test-fixtures.ts | 83 ++++++++++++++----- .../electron-backend-e2e/src/favorites.e2e.ts | 17 ++-- apps/electron-backend-e2e/src/recent.e2e.ts | 13 +-- 3 files changed, 74 insertions(+), 39 deletions(-) diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 247f1e10c..8353aef3d 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -569,35 +569,22 @@ async function clickButtonToggleOption( toggleGroup: Locator, label: string ): Promise { - 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 { + 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', { @@ -1372,3 +1378,34 @@ function flexibleTextPattern(value: string): RegExp { 'i' ); } + +async function isButtonToggleSelected(toggle: Locator): Promise { + 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 { + 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(); +} diff --git a/apps/electron-backend-e2e/src/favorites.e2e.ts b/apps/electron-backend-e2e/src/favorites.e2e.ts index 858aaa38b..aacbdceff 100644 --- a/apps/electron-backend-e2e/src/favorites.e2e.ts +++ b/apps/electron-backend-e2e/src/favorites.e2e.ts @@ -11,6 +11,7 @@ import { contentCardByTitle, defaultXtreamPassword, defaultXtreamUsername, + expectVisibleContentCardTitle, expect, importM3uPlaylistFromNativeDialog, launchElectronApp, @@ -159,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; @@ -189,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); } diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts index cb996d86e..2bae352ed 100644 --- a/apps/electron-backend-e2e/src/recent.e2e.ts +++ b/apps/electron-backend-e2e/src/recent.e2e.ts @@ -11,6 +11,7 @@ import { contentCardByTitle, defaultXtreamPassword, defaultXtreamUsername, + expectVisibleContentCardTitle, expect, importM3uPlaylistFromNativeDialog, launchElectronApp, @@ -160,17 +161,17 @@ test.describe('Electron Recently Viewed', () => { 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; @@ -184,9 +185,9 @@ 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); From 26fa697afeb34db6b179a6ea14034e9ec0a48bb1 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 4 Apr 2026 10:09:27 +0200 Subject: [PATCH 11/11] test(e2e): add comprehensive tests for dashboard activation, favorites, recent items, and settings behavior Entire-Checkpoint: 02062a64b591 --- .../src/dashboard-activation.e2e.ts | 179 ++++++++++++++++++ .../electron-backend-e2e/src/favorites.e2e.ts | 64 +++++++ apps/electron-backend-e2e/src/recent.e2e.ts | 111 +++++++++++ apps/electron-backend-e2e/src/settings.e2e.ts | 118 ++++++++++++ apps/electron-backend-e2e/src/sources.e2e.ts | 8 + .../src/xtream-responsiveness.e2e.ts | 138 ++++++++++++++ 6 files changed, 618 insertions(+) create mode 100644 apps/electron-backend-e2e/src/dashboard-activation.e2e.ts diff --git a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts new file mode 100644 index 000000000..1497d016a --- /dev/null +++ b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts @@ -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 { + 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 { + const playButton = page.locator('button.play-btn').first(); + + await expect(playButton).toBeVisible({ timeout: 20000 }); + await playButton.click(); +} + +async function playFirstSeriesEpisode(page: Page): Promise { + 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 { + 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/); +} diff --git a/apps/electron-backend-e2e/src/favorites.e2e.ts b/apps/electron-backend-e2e/src/favorites.e2e.ts index aacbdceff..ff46bb0d8 100644 --- a/apps/electron-backend-e2e/src/favorites.e2e.ts +++ b/apps/electron-backend-e2e/src/favorites.e2e.ts @@ -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 = { diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts index 2bae352ed..8b40a5fa0 100644 --- a/apps/electron-backend-e2e/src/recent.e2e.ts +++ b/apps/electron-backend-e2e/src/recent.e2e.ts @@ -41,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, }) => { @@ -201,6 +241,77 @@ test.describe('Electron Recently Viewed', () => { } }); + 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, diff --git a/apps/electron-backend-e2e/src/settings.e2e.ts b/apps/electron-backend-e2e/src/settings.e2e.ts index 47233cff5..2b4fbda65 100644 --- a/apps/electron-backend-e2e/src/settings.e2e.ts +++ b/apps/electron-backend-e2e/src/settings.e2e.ts @@ -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 = ` @@ -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( diff --git a/apps/electron-backend-e2e/src/sources.e2e.ts b/apps/electron-backend-e2e/src/sources.e2e.ts index c95276a0c..7d91c0907 100644 --- a/apps/electron-backend-e2e/src/sources.e2e.ts +++ b/apps/electron-backend-e2e/src/sources.e2e.ts @@ -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(); diff --git a/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts b/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts index 66f452690..fd621e65c 100644 --- a/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts @@ -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[0]) { await page.waitForURL(/\/workspace\/sources$/); } +function xtreamBlockedStateTitle(page: Parameters[0]) { + return page.locator('.xtream-content-gate .error-view__title'); +} + test.describe('Electron Xtream Responsiveness', () => { test.describe.configure({ mode: 'serial' }); test.setTimeout(120000); @@ -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,