From 2eb608992a81704b43bd80efec7442749895405c Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 28 Mar 2026 21:44:24 +0100 Subject: [PATCH] test(e2e): streamline input handling in addStalkerPortal and enhance locator usage --- apps/electron-backend-e2e/src/search.e2e.ts | 134 ++++++++++++-------- apps/web-e2e/src/stalker.e2e.ts | 61 +++++---- apps/web-e2e/src/xtream.e2e.ts | 5 +- 3 files changed, 116 insertions(+), 84 deletions(-) diff --git a/apps/electron-backend-e2e/src/search.e2e.ts b/apps/electron-backend-e2e/src/search.e2e.ts index b9c0cc693..07c99b3ac 100644 --- a/apps/electron-backend-e2e/src/search.e2e.ts +++ b/apps/electron-backend-e2e/src/search.e2e.ts @@ -1,7 +1,4 @@ -import { - APIRequestContext, - Page, -} from '@playwright/test'; +import { APIRequestContext, Page } from '@playwright/test'; import { readFileSync } from 'fs'; import { addStalkerPortal, @@ -22,7 +19,7 @@ import { waitForM3uCatalog, waitForPortalDebugEvent, waitForStalkerCatalog, - waitForXtreamImportToFinish, + waitForXtreamCatalog, xtreamMockServer, } from './electron-test-fixtures'; @@ -149,13 +146,16 @@ test.describe('Electron Workspace Search', () => { ); await expectQueryParam(app.mainWindow, 'q', sample.groupTitle); await expectWorkspaceSearchScope(app.mainWindow, 'Groups'); - await expect(groupPanelHeader(app.mainWindow, sample.groupTitle)).toHaveCount( - 1 - ); + await expect( + groupPanelHeader(app.mainWindow, sample.groupTitle) + ).toHaveCount(1); await expect( groupPanelHeader(app.mainWindow, sample.groupTitle).first() - ).toContainText(`${sample.groupTitle} (2)`); - await ensureGroupExpanded(app.mainWindow, sample.groupTitle); + ).toContainText(sample.groupTitle); + await expect( + groupPanelHeader(app.mainWindow, sample.groupTitle).first() + ).toContainText('2'); + await ensureGroupSelected(app.mainWindow, sample.groupTitle); await expect( channelItemByTitle(app.mainWindow, sample.targetTitle).first() ).toBeVisible(); @@ -171,8 +171,11 @@ test.describe('Electron Workspace Search', () => { await expectQueryParam(app.mainWindow, 'q', sample.targetTitle); await expect( groupPanelHeader(app.mainWindow, sample.groupTitle).first() - ).toContainText(`${sample.groupTitle} (1)`); - await ensureGroupExpanded(app.mainWindow, sample.groupTitle); + ).toContainText(sample.groupTitle); + await expect( + groupPanelHeader(app.mainWindow, sample.groupTitle).first() + ).toContainText('1'); + await ensureGroupSelected(app.mainWindow, sample.groupTitle); await expect( channelItemByTitle(app.mainWindow, sample.targetTitle).first() ).toBeVisible(); @@ -237,7 +240,10 @@ test.describe('Electron Workspace Search', () => { }) .click(); - await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/); + await expectPathname( + app.mainWindow, + /\/workspace\/global-favorites$/ + ); await fillWorkspaceSearch(app.mainWindow, sample.targetTitle); await expectQueryParam(app.mainWindow, 'q', sample.targetTitle); @@ -278,7 +284,9 @@ test.describe('Electron Workspace Search', () => { await clickCategoryByNameExact(app.mainWindow, sample.categoryName); await expect( - app.mainWindow.locator('.category-content-layout mat-card').first() + app.mainWindow + .locator('.category-content-layout mat-card') + .first() ).toBeVisible({ timeout: 20000, }); @@ -286,7 +294,10 @@ test.describe('Electron Workspace Search', () => { app.mainWindow, sample ); - await fillWorkspaceSearch(app.mainWindow, resolvedTitles.targetTitle); + await fillWorkspaceSearch( + app.mainWindow, + resolvedTitles.targetTitle + ); await expectPathname( app.mainWindow, @@ -351,6 +362,10 @@ test.describe('Electron Workspace Search', () => { app.mainWindow, /\/workspace\/xtreams\/[^/]+\/series$/ ); + await clickCategoryByNameExact( + app.mainWindow, + sample.categoryName + ); await expect( app.mainWindow .locator('.category-content-layout mat-card') @@ -363,7 +378,10 @@ test.describe('Electron Workspace Search', () => { app.mainWindow, sample ); - await fillWorkspaceSearch(app.mainWindow, resolvedTitles.targetTitle); + await fillWorkspaceSearch( + app.mainWindow, + resolvedTitles.targetTitle + ); await expectPathname( app.mainWindow, @@ -416,10 +434,7 @@ test.describe('Electron Workspace Search', () => { app.mainWindow, xtreamQueries ); - await expectWorkspaceSearchScope( - app.mainWindow, - 'Advanced search' - ); + await expectWorkspaceSearchScope(app.mainWindow, 'Advanced search'); await expect( app.mainWindow.locator('app-search-layout app-search-form') ).toHaveCount(0); @@ -501,7 +516,9 @@ test.describe('Electron Workspace Search', () => { await clickCategoryByNameExact(app.mainWindow, sample.categoryName); await expect( - app.mainWindow.locator('.category-content-layout mat-card').first() + app.mainWindow + .locator('.category-content-layout mat-card') + .first() ).toBeVisible({ timeout: 20000 }); const resolvedTitles = await resolveVisibleXtreamTitles( app.mainWindow, @@ -555,7 +572,10 @@ test.describe('Electron Workspace Search', () => { ).first() ).toBeVisible({ timeout: 20000 }); - await fillWorkspaceSearch(app.mainWindow, resolvedTitles.targetTitle); + await fillWorkspaceSearch( + app.mainWindow, + resolvedTitles.targetTitle + ); await expectQueryParam( app.mainWindow, @@ -715,10 +735,7 @@ test.describe('Electron Workspace Search', () => { app.mainWindow, /\/workspace\/stalker\/[^/]+\/search$/ ); - await expectWorkspaceSearchScope( - app.mainWindow, - 'Advanced search' - ); + await expectWorkspaceSearchScope(app.mainWindow, 'Advanced search'); await expect( app.mainWindow.locator('app-search-layout app-search-form') ).toHaveCount(0); @@ -823,7 +840,10 @@ test.describe('Electron Workspace Search', () => { await app.mainWindow .getByRole('link', { name: 'Live TV', exact: true }) .click(); - await expectPathname(app.mainWindow, /\/workspace\/stalker\/[^/]+\/itv$/); + await expectPathname( + app.mainWindow, + /\/workspace\/stalker\/[^/]+\/itv$/ + ); await clickCategoryById(app.mainWindow, sample.categoryId); await expect( @@ -999,9 +1019,9 @@ async function fetchStalkerCategoryFixture( request: APIRequestContext, type: 'itv' | 'series' | 'vod' ): Promise { - const categoriesResponse = await fetchJson>( + const categoriesResponse = await fetchJson< + StalkerProxyPayload + >( request, buildStalkerProxyUrl('get_categories', { type, @@ -1013,9 +1033,9 @@ async function fetchStalkerCategoryFixture( throw new Error(`Stalker mock server returned no ${type} categories.`); } - const itemsResponse = await fetchJson - >>( + const itemsResponse = await fetchJson< + StalkerProxyPayload> + >( request, buildStalkerProxyUrl('get_ordered_list', { category: category.id, @@ -1069,7 +1089,8 @@ function loadM3uSearchFixture(): M3uSearchFixture { }, new Map()); const groupedSample = [...groups.entries()].find( - ([groupTitle, channels]) => groupTitle.length > 0 && channels.length >= 2 + ([groupTitle, channels]) => + groupTitle.length > 0 && channels.length >= 2 ); if (!groupedSample) { @@ -1139,7 +1160,10 @@ function parseM3uFixture(filePath: string): M3uFixtureItem[] { return items.filter((item) => item.name.length > 0); } -async function clickCategoryById(page: Page, categoryId: string): Promise { +async function clickCategoryById( + page: Page, + categoryId: string +): Promise { const category = page.locator( `app-workspace-context-panel .category-item[data-category-id="${categoryId}"]:visible` ); @@ -1170,20 +1194,16 @@ async function openWorkspaceSection(page: Page, label: string): Promise { } async function waitForXtreamWorkspaceReady(page: Page): Promise { - await expectPathname(page, /\/workspace\/xtreams\/[^/]+\/vod$/); - await expect( - page.locator('app-workspace-context-panel .category-item').first() - ).toBeVisible({ timeout: 20000 }); - await waitForXtreamImportToFinish(page); + await waitForXtreamCatalog(page); } function groupPanelHeader(page: Page, groupTitle: string) { - return page.locator('mat-expansion-panel-header').filter({ + return page.locator('.group-nav-item').filter({ hasText: flexibleTextPattern(groupTitle), }); } -async function ensureGroupExpanded( +async function ensureGroupSelected( page: Page, groupTitle: string ): Promise { @@ -1191,7 +1211,7 @@ async function ensureGroupExpanded( await expect(header).toBeVisible(); - if ((await header.getAttribute('aria-expanded')) !== 'true') { + if ((await header.getAttribute('aria-current')) !== 'true') { await header.click(); } } @@ -1233,9 +1253,11 @@ async function resolveVisibleXtreamTitles( controlTitle: string; targetTitle: string; }> { - const visibleTitles = (await page - .locator('.category-content-layout mat-card .title') - .allTextContents()) + const visibleTitles = ( + await page + .locator('.category-content-layout mat-card .title') + .allTextContents() + ) .map((title) => title.trim()) .filter((title) => title.length > 0); const matchedTitles = fixture.candidateTitles @@ -1282,7 +1304,9 @@ async function addCurrentDetailToFavorites(page: Page): Promise { } async function goBackFromDetail(page: Page): Promise { - const backButton = page.locator('app-content-hero .hero__back-button').first(); + const backButton = page + .locator('app-content-hero .hero__back-button') + .first(); await expect(backButton).toBeVisible({ timeout: 20000 }); await backButton.click(); @@ -1301,9 +1325,11 @@ async function performXtreamPlaylistSearch( await expectQueryParam(page, 'q', query); try { - await expect(xtreamSearchResultCards(page).first()).toBeVisible({ - timeout: 5000, - }); + await expect(xtreamSearchResultCards(page).first()).toBeVisible( + { + timeout: 5000, + } + ); return query; } catch { // Try the next candidate token. @@ -1376,9 +1402,7 @@ function pickDistinctItems( const control = preferredItems.find((item) => getTitle(item) !== getTitle(target)) ?? - titledItems.find( - (item) => getTitle(item) !== getTitle(target) - ); + titledItems.find((item) => getTitle(item) !== getTitle(target)); if (!control) { throw new Error('Expected at least two items with distinct titles.'); @@ -1488,9 +1512,7 @@ function flexibleTextPattern(value: string): RegExp { } async function expectPathname(page: Page, pattern: RegExp): Promise { - await expect - .poll(() => new URL(page.url()).pathname) - .toMatch(pattern); + await expect.poll(() => new URL(page.url()).pathname).toMatch(pattern); } async function expectQueryParam( diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 20671e032..a35258745 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -1,4 +1,4 @@ -import { expect, Page, test } from '@playwright/test'; +import { expect, Locator, Page, test } from '@playwright/test'; /** * Stalker Portal E2E Tests @@ -47,6 +47,21 @@ async function interceptStalkerRequests(page: Page): Promise { }); } +async function setInputValue(input: Locator, value: string): Promise { + await input.fill(''); + await input.fill(value); + + if ((await input.inputValue()) === value) { + return; + } + + await input.click(); + await input.press('ControlOrMeta+A'); + await input.press('Backspace'); + await input.type(value); + await expect(input).toHaveValue(value); +} + /** * Add a Stalker portal via the UI: * 1. Click the "add playlist" button @@ -60,16 +75,19 @@ async function addStalkerPortal( const { name = 'Mock Stalker Portal', mac = DEFAULT_MAC } = options; await page.getByRole('button', { name: 'Add playlist' }).click(); - await page.getByText('Stalker Portal').click(); + await page.getByRole('menuitem', { name: /Add Stalker Portal/i }).click(); const dialog = page.locator('mat-dialog-container'); - await dialog.locator('#title').fill(name); - await dialog.locator('#portalUrl').fill(PORTAL_URL); - await dialog.locator('#macAddress').fill(mac); + await setInputValue(dialog.locator('input#title'), name); + await setInputValue(dialog.locator('input#portalUrl'), PORTAL_URL); + await setInputValue(dialog.locator('input#macAddress'), mac); - await dialog.getByRole('button', { name: 'Add', exact: true }).click(); + const addButton = dialog.getByRole('button', { name: 'Add', exact: true }); + await expect(addButton).toBeEnabled({ timeout: 10_000 }); + await addButton.click(); // Wait for dialog to close await page.waitForSelector('mat-dialog-container', { state: 'detached' }); + await page.waitForURL(/stalker.*vod/); } /** @@ -127,29 +145,21 @@ test('@stalker add a Stalker portal and see it in the playlist list', async ({ test('@stalker VOD — categories load from mock server', async ({ page }) => { await addStalkerPortal(page); - // Navigate into the portal — click the portal card - await page.getByText('Mock Stalker Portal').click(); - - // Wait for VOD route to load - await page.waitForURL(/stalker.*vod/); - // Default scenario has 8 VOD categories (+ 1 "All categories" prepended by the store) - const categoryItems = page.locator('.category-item, [class*="category"]'); + const categoryItems = page.locator('.category-item'); await expect(categoryItems.first()).toBeVisible({ timeout: 10_000 }); const count = await categoryItems.count(); - expect(count).toBeGreaterThanOrEqual(8); + expect(count).toBeGreaterThanOrEqual(9); }); test('@stalker VOD — content list loads after selecting a category', async ({ page, }) => { await addStalkerPortal(page); - await page.getByText('Mock Stalker Portal').click(); - await page.waitForURL(/stalker.*vod/); // Click the first non-"All" category const categories = page.locator('.category-item'); - await categories.first().click(); + await categories.nth(1).click(); // Content grid / list should appear with items const contentItems = page.locator( @@ -165,8 +175,6 @@ test('@stalker minimal scenario — correct item counts', async ({ page }) => { name: 'Minimal Portal', mac: MINIMAL_MAC, }); - await page.getByText('Minimal Portal').click(); - await page.waitForURL(/stalker.*vod/); // Minimal scenario: 2 categories (+ "All" = 3 visible) const categories = page.locator('.category-item'); @@ -178,23 +186,24 @@ test('@stalker minimal scenario — correct item counts', async ({ page }) => { test('@stalker EPG data loads for ITV channel', async ({ page }) => { await addStalkerPortal(page); - await page.getByText('Mock Stalker Portal').click(); - await page.waitForURL(/stalker.*vod/); // Navigate to ITV tab await page.getByRole('link', { name: /live|itv/i }).click(); await page.waitForURL(/stalker.*itv/); + // ITV view requires an explicit category selection before channels render + const categories = page.locator('.category-item'); + await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 }); + await categories.nth(1).click(); + // Wait for channels to appear - const channels = page.locator( - '[data-test-id="channel-item"], [class*="channel"]' - ); + const channels = page.locator('[data-test-id="channel-item"]'); await expect(channels.first()).toBeVisible({ timeout: 10_000 }); // Click a channel — EPG info should appear await channels.first().click(); - // EPG view should become visible - await expect(page.locator('[class*="epg"], app-epg-view')).toBeVisible({ + await expect(channels.first()).toHaveClass(/active/, { timeout: 10_000 }); + await expect(page.locator('main app-epg-view')).toBeVisible({ timeout: 10_000, }); }); diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index ae81ae00a..aeda11857 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -55,7 +55,7 @@ async function addXtreamPortal( } = options; await page.getByRole('button', { name: 'Add playlist' }).click(); - await page.getByText('Xtream').click(); + await page.getByRole('menuitem', { name: /Add Xtreme Code/i }).click(); const dialog = page.locator('mat-dialog-container'); await dialog.locator('#title').fill(name); @@ -65,6 +65,7 @@ async function addXtreamPortal( await dialog.getByRole('button', { name: 'Add', exact: true }).click(); await page.waitForSelector('mat-dialog-container', { state: 'detached' }); + await page.waitForURL(/xtreams.*vod/); } /** @@ -117,7 +118,7 @@ test('@xtream get_account_info — active account returns correct fields', async const body = await response.json(); expect(body.user_info.username).toBe(DEFAULT_USERNAME); - expect(body.user_info.status).toBe('active'); + expect(body.user_info.status).toBe('Active'); expect(body.server_info.url).toBeDefined(); expect(Array.isArray(body.user_info.allowed_output_formats)).toBeTruthy(); });