test(e2e): enhance E2E tests for Xtream and Stalker portals, improve category selection logic, and streamline IndexedDB cleanup

This commit is contained in:
4gray committed 2026-04-06 12:56:23 +02:00
1 parent e01d7caf0b
commit f229b3b1f2
7 files changed
+647 -95

No files matched your search

@@ -267,72 +267,98 @@ async function expectVisibleSidebarCategoryNames(
page: Page,
expectedNames: string[]
): Promise<void> {
const categories = page.locator(
'app-workspace-context-panel .category-item:visible'
);
const actualNames: string[] = [];
const count = await categories.count();
await expect
.poll(async () => {
const categories = page.locator(
'app-workspace-context-panel .category-item:visible'
);
const actualNames: string[] = [];
const count = await categories.count();
for (let index = 0; index < count; index += 1) {
const categoryName =
(
await categories
.nth(index)
.locator('.nav-item-label')
.textContent()
)?.trim() ?? '';
for (let index = 0; index < count; index += 1) {
const categoryName =
(
await categories
.nth(index)
.locator('.nav-item-label')
.textContent()
)?.trim() ?? '';
if (categoryName) {
actualNames.push(categoryName);
}
}
if (categoryName) {
actualNames.push(categoryName);
}
}
expect(actualNames).toEqual(expectedNames);
return actualNames;
})
.toEqual(expectedNames);
}
async function pickSidebarCategory(
page: Page
): Promise<{ id: string; itemCount: number; name: string }> {
const categories = page.locator(
'app-workspace-context-panel .category-item:visible'
);
const count = await categories.count();
const candidates: Array<{ id: string; itemCount: number; name: string }> =
[];
let preferredCandidate:
| { id: string; itemCount: number; name: string }
| null = null;
for (let index = 0; index < count; index += 1) {
const category = categories.nth(index);
const id =
(await category.getAttribute('data-category-id'))?.trim() ?? '';
const name =
(await category.locator('.nav-item-label').textContent())?.trim() ??
'';
const countText =
(await category.locator('.item-count').textContent())?.trim() ?? '';
const itemCount = Number.parseInt(countText, 10) || 0;
await expect
.poll(async () => {
const categories = page.locator(
'app-workspace-context-panel .category-item:visible'
);
const count = await categories.count();
const candidates: Array<{
id: string;
itemCount: number;
name: string;
}> = [];
if (id && name && itemCount > 0) {
candidates.push({ id, itemCount, name });
}
}
for (let index = 0; index < count; index += 1) {
const category = categories.nth(index);
const id =
(await category.getAttribute('data-category-id'))?.trim() ??
'';
const name =
(
await category.locator('.nav-item-label').textContent()
)?.trim() ?? '';
const countText =
(
await category.locator('.item-count').textContent()
)?.trim() ?? '';
const itemCount = Number.parseInt(countText, 10) || 0;
const nameCounts = new Map<string, number>();
for (const candidate of candidates) {
nameCounts.set(
candidate.name,
(nameCounts.get(candidate.name) ?? 0) + 1
);
}
if (id && name && itemCount > 0) {
candidates.push({ id, itemCount, name });
}
}
const preferredCandidate =
candidates.find((candidate) => nameCounts.get(candidate.name) === 1) ??
candidates[0];
if (candidates.length === 0) {
preferredCandidate = null;
return false;
}
if (!preferredCandidate) {
throw new Error('No visible Xtream category with content was found.');
}
const nameCounts = new Map<string, number>();
for (const candidate of candidates) {
nameCounts.set(
candidate.name,
(nameCounts.get(candidate.name) ?? 0) + 1
);
}
return preferredCandidate;
preferredCandidate =
candidates.find(
(candidate) => nameCounts.get(candidate.name) === 1
) ?? candidates[0];
return preferredCandidate !== null;
})
.toBe(true, {
message: 'No visible Xtream category with content was found.',
timeout: 15000,
});
return preferredCandidate!;
}
async function toggleManagedCategory(
@@ -681,6 +681,20 @@ export async function clickCategoryById(
await expect(category.first()).toBeVisible({ timeout: 20000 });
await category.first().scrollIntoViewIfNeeded();
await category.first().click();
await expect
.poll(async () => {
const pathname = new URL(page.url()).pathname;
const isSelected =
(await category.first().getAttribute('aria-current')) ===
'true';
return (
isSelected ||
pathname.endsWith(`/${categoryId}`) ||
pathname.includes(`/${categoryId}/`)
);
})
.toBe(true);
}
export async function clickCategoryByNameExact(
@@ -698,7 +712,23 @@ export async function clickCategoryByNameExact(
await expect(category).toBeVisible();
await category.scrollIntoViewIfNeeded();
const categoryId =
(await category.getAttribute('data-category-id'))?.trim() ?? '';
await category.click();
await expect
.poll(async () => {
const pathname = new URL(page.url()).pathname;
const isSelected =
(await category.getAttribute('aria-current')) === 'true';
return (
isSelected ||
(categoryId.length > 0 &&
(pathname.endsWith(`/${categoryId}`) ||
pathname.includes(`/${categoryId}/`)))
);
})
.toBe(true);
}
export function sourceRowByTitle(page: Page, title: string): Locator {
@@ -0,0 +1,503 @@
import { Page } from '@playwright/test';
import {
channelItemByTitle,
addStalkerPortal,
addXtreamPortal,
closeElectronApp,
defaultXtreamPassword,
defaultXtreamUsername,
expect,
importM3uPlaylistFromNativeDialog,
launchElectronApp,
openPlaylistFavorites,
openPlaylistRecent,
openSettings,
openSourceEditor,
openSources,
openWorkspaceSection,
resetMockServers,
restartElectronApp,
saveSettings,
saveSourceDialog,
sourceRowByTitle,
test,
updateSourceDialog,
waitForDbOperationEvent,
waitForM3uCatalog,
waitForStalkerCatalog,
waitForXtreamCatalog,
writeTemporaryM3uFile,
xtreamMockServer,
} from './electron-test-fixtures';
const xtreamStressUsername = 'stress';
const xtreamStressPassword = 'stress';
const dbWorkerBatchDelayMs = '20';
const localAFileName = 'switcher-local-a.m3u';
const localADisplayName = 'switcher-local-a';
const localBFileName = 'switcher-local-b.m3u';
const localBDisplayName = 'switcher-local-b';
const localAChannelName = 'Switcher Favorite A';
const localBChannelName = 'Switcher Favorite B';
const offlineLocalFileName = 'offline-local-source.m3u';
const offlineLocalDisplayName = 'offline-local-source';
const xtreamAName = 'Switcher Xtream A';
const xtreamBName = 'Switcher Xtream B';
const stalkerAName = 'Switcher Stalker A';
const stalkerBName = 'Switcher Stalker B';
const offlineXtreamName = 'Offline Cached Xtream';
const importingXtreamName = 'Importing Xtream Portal';
test.describe('Electron Playlist Switcher', () => {
test.describe.configure({ mode: 'serial' });
test.setTimeout(120000);
test('preserves same-type sections and cross-type mappings when switching from the workspace header', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream', 'stalker']);
const localAPath = writeTemporaryM3uFile(dataDir, localAFileName, [
{
groupTitle: 'News',
name: 'Switcher Local A',
url: 'https://streams.example.test/switcher-local-a.m3u8',
},
]);
const localBPath = writeTemporaryM3uFile(dataDir, localBFileName, [
{
groupTitle: 'Sports',
name: 'Switcher Local B',
url: 'https://streams.example.test/switcher-local-b.m3u8',
},
]);
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromNativeDialog(app, localAPath);
await importM3uPlaylistFromNativeDialog(app, localBPath);
await addXtreamPortal(app.mainWindow, {
name: xtreamAName,
});
await addXtreamPortal(app.mainWindow, {
name: xtreamBName,
});
await addStalkerPortal(app.mainWindow, {
name: stalkerAName,
});
await addStalkerPortal(app.mainWindow, {
name: stalkerBName,
});
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, localADisplayName)
.first()
.click();
await waitForM3uCatalog(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Recently viewed');
await expectPathname(
app.mainWindow,
/\/workspace\/playlists\/[^/]+\/recent$/
);
await switchPlaylistFromHeader(app.mainWindow, localBDisplayName);
await expectPathname(
app.mainWindow,
/\/workspace\/playlists\/[^/]+\/recent$/
);
await switchPlaylistFromHeader(app.mainWindow, xtreamAName);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/recent$/
);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await waitForXtreamCatalog(app.mainWindow);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/live$/
);
await switchPlaylistFromHeader(app.mainWindow, xtreamBName);
await waitForXtreamCatalog(app.mainWindow);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/live$/
);
await openWorkspaceSection(app.mainWindow, 'Recently added');
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/recently-added$/
);
await switchPlaylistFromHeader(app.mainWindow, stalkerAName);
await expectPathname(
app.mainWindow,
/\/workspace\/stalker\/[^/]+\/recent$/
);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await waitForStalkerCatalog(app.mainWindow);
await expectPathname(
app.mainWindow,
/\/workspace\/stalker\/[^/]+\/itv$/
);
await switchPlaylistFromHeader(app.mainWindow, stalkerBName);
await waitForStalkerCatalog(app.mainWindow);
await expectPathname(
app.mainWindow,
/\/workspace\/stalker\/[^/]+\/itv$/
);
await switchPlaylistFromHeader(app.mainWindow, xtreamAName);
await waitForXtreamCatalog(app.mainWindow);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/live$/
);
await switchPlaylistFromHeader(app.mainWindow, localADisplayName);
await waitForM3uCatalog(app.mainWindow);
await expectPathname(
app.mainWindow,
/\/workspace\/playlists\/[^/]+\/all$/
);
await openWorkspaceSection(app.mainWindow, 'Favorites');
await expectPathname(
app.mainWindow,
/\/workspace\/playlists\/[^/]+\/favorites$/
);
await switchPlaylistFromHeader(app.mainWindow, stalkerBName);
await expectPathname(
app.mainWindow,
/\/workspace\/stalker\/[^/]+\/favorites$/
);
} finally {
await closeElectronApp(app);
}
});
test('refreshes M3U unified collections immediately when switching same-type playlists from the header', async ({
dataDir,
}) => {
const localAPath = writeTemporaryM3uFile(dataDir, localAFileName, [
{
groupTitle: 'News',
name: localAChannelName,
url: 'https://streams.example.test/switcher-favorite-a.m3u8',
},
]);
const localBPath = writeTemporaryM3uFile(dataDir, localBFileName, [
{
groupTitle: 'Sports',
name: localBChannelName,
url: 'https://streams.example.test/switcher-favorite-b.m3u8',
},
]);
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromNativeDialog(app, localAPath);
await importM3uPlaylistFromNativeDialog(app, localBPath);
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, localADisplayName)
.first()
.click();
await waitForM3uCatalog(app.mainWindow);
await toggleFavoriteForChannel(app.mainWindow, localAChannelName);
await channelItemByTitle(app.mainWindow, localAChannelName)
.first()
.click();
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, localBDisplayName)
.first()
.click();
await waitForM3uCatalog(app.mainWindow);
await toggleFavoriteForChannel(app.mainWindow, localBChannelName);
await channelItemByTitle(app.mainWindow, localBChannelName)
.first()
.click();
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, localADisplayName)
.first()
.click();
await waitForM3uCatalog(app.mainWindow);
await openPlaylistFavorites(app.mainWindow);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
0
);
await switchPlaylistFromHeader(app.mainWindow, localBDisplayName);
await expectPathname(
app.mainWindow,
/\/workspace\/playlists\/[^/]+\/favorites$/
);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
0
);
await openPlaylistRecent(app.mainWindow);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
0
);
await switchPlaylistFromHeader(app.mainWindow, localADisplayName);
await expectPathname(
app.mainWindow,
/\/workspace\/playlists\/[^/]+\/recent$/
);
await expect(channelItemByTitle(app.mainWindow, localAChannelName)).toHaveCount(
1
);
await expect(channelItemByTitle(app.mainWindow, localBChannelName)).toHaveCount(
0
);
} finally {
await closeElectronApp(app);
}
});
test('switches away from importing Xtream playlists, returns before completion, and avoids redundant reimport after caching', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const localAPath = writeTemporaryM3uFile(dataDir, localAFileName, [
{
groupTitle: 'News',
name: 'Import Switch Local',
url: 'https://streams.example.test/import-switch-local.m3u8',
},
]);
const app = await launchElectronApp(dataDir, {
env: {
IPTVNATOR_DB_WORKER_BATCH_DELAY_MS: dbWorkerBatchDelayMs,
},
});
try {
await importM3uPlaylistFromNativeDialog(app, localAPath);
await waitForM3uCatalog(app.mainWindow);
await addXtreamPortal(app.mainWindow, {
name: importingXtreamName,
password: xtreamStressPassword,
username: xtreamStressUsername,
});
const overlay = app.mainWindow.locator('.workspace-loading-overlay');
await expect(overlay).toBeVisible({ timeout: 15000 });
await waitForDbOperationEvent(app.mainWindow, {
operation: 'save-content',
predicate: (event) =>
event.status === 'progress' &&
(event.current ?? 0) > 0 &&
(event.total ?? 0) > 0,
timeoutMs: 30000,
});
await switchPlaylistFromHeader(app.mainWindow, localADisplayName);
await waitForM3uCatalog(app.mainWindow);
await expect(overlay).toHaveCount(0);
await switchPlaylistFromHeader(app.mainWindow, importingXtreamName);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/vod$/
);
await Promise.race([
overlay.waitFor({ state: 'visible', timeout: 15000 }),
waitForXtreamCatalog(app.mainWindow),
]);
await waitForXtreamCatalog(app.mainWindow);
await switchPlaylistFromHeader(app.mainWindow, localADisplayName);
await waitForM3uCatalog(app.mainWindow);
const startedBeforeReturn = await countDbEvents(
app.mainWindow,
'save-content',
'started'
);
await switchPlaylistFromHeader(app.mainWindow, importingXtreamName);
await waitForXtreamCatalog(app.mainWindow);
await expect(overlay).toHaveCount(0);
await app.mainWindow.waitForTimeout(1200);
const startedAfterReturn = await countDbEvents(
app.mainWindow,
'save-content',
'started'
);
expect(startedAfterReturn).toBe(startedBeforeReturn);
} finally {
await closeElectronApp(app);
}
});
test('keeps cached Xtream content browseable with an unavailable warning after the portal goes offline and after restart', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const offlineLocalPath = writeTemporaryM3uFile(
dataDir,
offlineLocalFileName,
[
{
groupTitle: 'News',
name: 'Offline Local Channel',
url: 'https://streams.example.test/offline-local.m3u8',
},
]
);
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, {
name: offlineXtreamName,
password: defaultXtreamPassword,
username: defaultXtreamUsername,
});
await openWorkspaceSection(app.mainWindow, 'Movies');
await waitForXtreamCatalog(app.mainWindow);
await importM3uPlaylistFromNativeDialog(app, offlineLocalPath);
await waitForM3uCatalog(app.mainWindow);
await openSources(app.mainWindow);
const dialog = await openSourceEditor(
app.mainWindow,
offlineXtreamName
);
await updateSourceDialog(dialog, {
serverUrl: xtreamMockServer.replace(/:\d+$/, ':65530'),
});
await saveSourceDialog(app.mainWindow, dialog);
await sourceRowByTitle(app.mainWindow, offlineLocalDisplayName)
.first()
.click();
await waitForM3uCatalog(app.mainWindow);
await switchPlaylistFromHeader(app.mainWindow, offlineXtreamName);
await waitForXtreamCatalog(app.mainWindow);
await expectXtreamUnavailableWarning(app.mainWindow);
await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount(
0
);
app = await restartElectronApp(app, dataDir);
await waitForXtreamCatalog(app.mainWindow);
await expectXtreamUnavailableWarning(app.mainWindow);
await expect(app.mainWindow.locator('.xtream-content-gate')).toHaveCount(
0
);
} finally {
await closeElectronApp(app);
}
});
});
async function openPlaylistSwitcher(page: Page): Promise<void> {
await page
.locator('app-playlist-switcher .playlist-switcher-trigger')
.click();
await expect(
page.locator('.cdk-overlay-pane .playlist-item').first()
).toBeVisible();
}
async function switchPlaylistFromHeader(
page: Page,
title: string
): Promise<void> {
await openPlaylistSwitcher(page);
const item = page
.locator('.cdk-overlay-pane .playlist-item')
.filter({ hasText: title })
.first();
await expect(item).toBeVisible();
await item.click();
await expect(page.locator('.cdk-overlay-pane .playlist-item')).toHaveCount(
0
);
}
async function expectPathname(page: Page, pattern: RegExp): Promise<void> {
await expect
.poll(() => new URL(page.url()).pathname, { timeout: 20000 })
.toMatch(pattern);
}
async function countDbEvents(
page: Page,
operation: string,
status: string
): Promise<number> {
return page.evaluate(
({ operation, status: desiredStatus }) =>
(window.__dbOperationEvents ?? []).filter(
(event) =>
event.operation === operation &&
event.status === desiredStatus
).length,
{ operation, status }
);
}
async function selectSettingsOption(
page: Page,
selectTestId: string,
optionTestId: string
): Promise<void> {
await page.getByTestId(selectTestId).click();
await page.getByTestId(optionTestId).click();
}
async function expectXtreamUnavailableWarning(page: Page): Promise<void> {
await expect(
page.getByTestId('xtream-offline-warning')
).toBeVisible({ timeout: 20000 });
}
async function toggleFavoriteForChannel(page: Page, title: string): Promise<void> {
const item = channelItemByTitle(page, 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/);
}
@@ -1180,6 +1180,20 @@ async function clickCategoryById(
await expect(category.first()).toBeVisible();
await category.first().click();
await expect
.poll(async () => {
const pathname = new URL(page.url()).pathname;
const isSelected =
(await category.first().getAttribute('aria-current')) ===
'true';
return (
isSelected ||
pathname.endsWith(`/${categoryId}`) ||
pathname.includes(`/${categoryId}/`)
);
})
.toBe(true);
}
async function clickCategoryByNameExact(
@@ -1197,7 +1211,23 @@ async function clickCategoryByNameExact(
await expect(category).toBeVisible();
await category.scrollIntoViewIfNeeded();
const categoryId =
(await category.getAttribute('data-category-id'))?.trim() ?? '';
await category.click();
await expect
.poll(async () => {
const pathname = new URL(page.url()).pathname;
const isSelected =
(await category.getAttribute('aria-current')) === 'true';
return (
isSelected ||
(categoryId.length > 0 &&
(pathname.endsWith(`/${categoryId}`) ||
pathname.includes(`/${categoryId}/`)))
);
})
.toBe(true);
}
async function openWorkspaceSection(page: Page, label: string): Promise<void> {
+2 -11
View File
@@ -17,18 +17,9 @@ async function saveSettings(page: Page) {
test.describe('Settings', () => {
test.beforeEach(async ({ page }) => {
// Playwright creates a fresh browser context per test, so extra
// IndexedDB cleanup here only risks racing with app-managed DB handles.
await page.goto('/');
// Clear IndexedDB before each test
await page.evaluate(async () => {
const dbNames = (await window.indexedDB.databases()).map(
(db) => db.name
);
dbNames.forEach((name) =>
name !== undefined
? window.indexedDB.deleteDatabase(name)
: null
);
});
});
test('Check settings page', async ({ page }) => {
+2 -16
View File
@@ -90,20 +90,6 @@ async function addStalkerPortal(
await page.waitForURL(/stalker.*vod/);
}
/**
* Clear IndexedDB state between tests so each test starts fresh.
*/
async function clearStorage(page: Page): Promise<void> {
await page.evaluate(async () => {
const dbs = await window.indexedDB.databases();
await Promise.all(
dbs
.filter((db) => db.name != null)
.map((db) => window.indexedDB.deleteDatabase(db.name!))
);
});
}
// ---------------------------------------------------------------------------
// Test setup
// ---------------------------------------------------------------------------
@@ -112,9 +98,9 @@ test.beforeEach(async ({ page, request }) => {
// Reset mock server state (clears in-memory favorites and cache)
await request.post(`${MOCK_SERVER}/reset`);
// Playwright creates a fresh browser context per test, so extra
// IndexedDB cleanup here only risks racing with app-managed DB handles.
await page.goto('/');
await clearStorage(page);
await page.reload();
// Redirect backend proxy calls to the mock server
await interceptStalkerRequests(page);
+2 -16
View File
@@ -70,20 +70,6 @@ async function addXtreamPortal(
await page.waitForURL(/xtreams.*vod/);
}
/**
* Clear IndexedDB state between tests so each test starts fresh.
*/
async function clearStorage(page: Page): Promise<void> {
await page.evaluate(async () => {
const dbs = await window.indexedDB.databases();
await Promise.all(
dbs
.filter((db) => db.name != null)
.map((db) => window.indexedDB.deleteDatabase(db.name!))
);
});
}
// ---------------------------------------------------------------------------
// Test setup
// ---------------------------------------------------------------------------
@@ -91,9 +77,9 @@ async function clearStorage(page: Page): Promise<void> {
test.beforeEach(async ({ page, request }) => {
await request.post(`${MOCK_SERVER}/reset`);
// Playwright creates a fresh browser context per test, so extra
// IndexedDB cleanup here only risks racing with app-managed DB handles.
await page.goto('/');
await clearStorage(page);
await page.reload();
await interceptXtreamRequests(page);
});