test(e2e): streamline input handling in addStalkerPortal and enhance locator usage

This commit is contained in:
4gray committed 2026-03-28 21:44:24 +01:00
1 parent 83b6604c41
commit 2eb608992a
3 files changed
+116 -84

No files matched your search

+78 -56
View File
@@ -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<StalkerCategoryFixture> {
const categoriesResponse = await fetchJson<StalkerProxyPayload<
StalkerCategory[]
>>(
const categoriesResponse = await fetchJson<
StalkerProxyPayload<StalkerCategory[]>
>(
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<StalkerProxyPayload<
StalkerOrderedList<StalkerContentItem>
>>(
const itemsResponse = await fetchJson<
StalkerProxyPayload<StalkerOrderedList<StalkerContentItem>>
>(
request,
buildStalkerProxyUrl('get_ordered_list', {
category: category.id,
@@ -1069,7 +1089,8 @@ function loadM3uSearchFixture(): M3uSearchFixture {
}, new Map<string, M3uFixtureItem[]>());
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<void> {
async function clickCategoryById(
page: Page,
categoryId: string
): Promise<void> {
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<void> {
}
async function waitForXtreamWorkspaceReady(page: Page): Promise<void> {
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<void> {
@@ -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<void> {
}
async function goBackFromDetail(page: Page): Promise<void> {
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<T>(
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<void> {
await expect
.poll(() => new URL(page.url()).pathname)
.toMatch(pattern);
await expect.poll(() => new URL(page.url()).pathname).toMatch(pattern);
}
async function expectQueryParam(
+35 -26
View File
@@ -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<void> {
});
}
async function setInputValue(input: Locator, value: string): Promise<void> {
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,
});
});
+3 -2
View File
@@ -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();
});