Proyecto App Linux

This commit is contained in:
2026-05-03 23:43:09 +02:00
commit 7482733843
1248 changed files with 189750 additions and 0 deletions
@@ -0,0 +1,12 @@
import playwright from 'eslint-plugin-playwright';
import baseConfig from '../../eslint.config.mjs';
export default [
playwright.configs['flat/recommended'],
...baseConfig,
{
files: ['**/*.ts', '**/*.js'],
// Override or add rules here
rules: {},
},
];
@@ -0,0 +1,70 @@
import { defineConfig } from '@playwright/test';
import { workspaceRoot } from '@nx/devkit';
/**
* Playwright configuration for Electron e2e tests.
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './src',
/* Match .e2e.ts files */
testMatch: '**/*.e2e.ts',
/* Run tests sequentially since we're testing an Electron app */
fullyParallel: false,
/* Fail the build on CI if you accidentally left test.only in the source code */
forbidOnly: !!process.env['CI'],
/* Retry on CI only */
retries: process.env['CI'] ? 2 : 0,
/* Single worker since we're testing an Electron app */
workers: 1,
/* Reporter to use */
reporter: [
['list'],
[
'html',
{
outputFolder:
'../../dist/playwright-report/electron-backend-e2e',
},
],
],
/* Shared settings for all the projects below */
use: {
/* Align with the app's existing test ids */
testIdAttribute: 'data-test-id',
/* Collect trace when retrying the failed test */
trace: 'on-first-retry',
/* Screenshots on failure */
screenshot: 'on',
/* Video on failure */
video: 'on-first-retry',
},
webServer: [
{
command: 'pnpm nx run stalker-mock-server:serve',
url: `http://localhost:${process.env['MOCK_PORT'] ?? '3210'}/health`,
reuseExistingServer: !process.env['CI'],
cwd: workspaceRoot,
},
{
command: 'pnpm nx run xtream-mock-server:serve',
url: `http://localhost:${process.env['XTREAM_MOCK_PORT'] ?? '3211'}/health`,
reuseExistingServer: !process.env['CI'],
cwd: workspaceRoot,
},
],
/* Output folder for test artifacts */
outputDir: '../../dist/test-results/electron-backend-e2e',
/* Timeout for each test */
timeout: 60000,
/* Timeout for expect() assertions */
expect: {
timeout: 10000,
},
projects: [
{
name: 'electron',
use: {},
},
],
});
+16
View File
@@ -0,0 +1,16 @@
{
"name": "electron-backend-e2e",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "apps/electron-backend-e2e/src",
"implicitDependencies": [
"electron-backend",
"stalker-mock-server",
"xtream-mock-server"
],
"targets": {
"e2e": {
"dependsOn": ["electron-backend:build-e2e"]
}
}
}
@@ -0,0 +1,527 @@
import { Page } from '@playwright/test';
import {
addStalkerPortal,
addXtreamPortal,
clickCategoryByNameExact,
clickFirstGridListCard,
closeElectronApp,
defaultXtreamPassword,
defaultXtreamUsername,
expect,
expectPathname,
launchElectronApp,
openWorkspaceSection,
resetMockServers,
test,
waitForStalkerCatalog,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import {
fetchStalkerCategoryFixture,
fetchXtreamLiveFixture,
fetchXtreamSeriesFixture,
fetchXtreamVodFixture,
getXtreamDateValue,
getXtreamTitle,
} from './portal-mock-fixtures';
test.describe('Electron Catalog Sorting', () => {
test('sorts Xtream live channels by server order and name, with persistence after revisit', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const fixture = await fetchXtreamLiveFixture(
request,
xtreamCredentials
);
const expectedServerOrder = fixture.items
.map((item) => getXtreamTitle(item))
.filter((title, index, titles) => titles.indexOf(title) === index)
.slice(0, 5);
const expectedAscending = fixture.items
.map((item) => getXtreamTitle(item))
.filter((title, index, titles) => titles.indexOf(title) === index)
.sort(collator.compare)
.slice(0, 5);
const expectedDescending = fixture.items
.map((item) => getXtreamTitle(item))
.filter((title, index, titles) => titles.indexOf(title) === index)
.sort(collator.compare)
.reverse()
.slice(0, 5);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow);
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await clickCategoryByNameExact(
app.mainWindow,
fixture.categoryName
);
await expectVisibleChannelTitles(
app.mainWindow,
expectedServerOrder
);
await setLiveSortMode(app.mainWindow, 'Name A-Z');
await expectVisibleChannelTitles(app.mainWindow, expectedAscending);
await setLiveSortMode(app.mainWindow, 'Name Z-A');
await expectVisibleChannelTitles(
app.mainWindow,
expectedDescending
);
await setLiveSortMode(app.mainWindow, 'Name A-Z');
await openWorkspaceSection(app.mainWindow, 'Movies');
await openWorkspaceSection(app.mainWindow, 'Live TV');
await clickCategoryByNameExact(
app.mainWindow,
fixture.categoryName
);
await expectVisibleChannelTitles(app.mainWindow, expectedAscending);
} finally {
await closeElectronApp(app);
}
});
test('sorts Xtream VOD and series content, and keeps the chosen sort after revisiting the section', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const vodFixture = await fetchXtreamVodFixture(
request,
xtreamCredentials
);
const seriesFixture = await fetchXtreamSeriesFixture(
request,
xtreamCredentials
);
const expectedVodDateDesc = [...vodFixture.items]
.sort(
(left, right) =>
getXtreamDateValue(right) - getXtreamDateValue(left)
)
.map((item) => getXtreamTitle(item))
.slice(0, visibleComparisonSize);
const expectedVodDateAsc = [...vodFixture.items]
.sort(
(left, right) =>
getXtreamDateValue(left) - getXtreamDateValue(right)
)
.map((item) => getXtreamTitle(item))
.slice(0, visibleComparisonSize);
const expectedVodNameAsc = [...vodFixture.items]
.map((item) => getXtreamTitle(item))
.sort(collator.compare)
.slice(0, visibleComparisonSize);
const expectedVodNameDesc = [...vodFixture.items]
.map((item) => getXtreamTitle(item))
.sort(collator.compare)
.reverse()
.slice(0, visibleComparisonSize);
const expectedSeriesNameAsc = [...seriesFixture.items]
.map((item) => getXtreamTitle(item))
.sort(collator.compare)
.slice(0, visibleComparisonSize);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow);
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Movies');
await clickCategoryByNameExact(
app.mainWindow,
vodFixture.categoryName
);
await expectVisibleGridTitles(app.mainWindow, expectedVodDateDesc);
await setContentSortMode(
app.mainWindow,
'Date Added (Oldest First)'
);
await expectVisibleGridTitles(app.mainWindow, expectedVodDateAsc);
await setContentSortMode(app.mainWindow, 'Name A-Z');
await expectVisibleGridTitles(app.mainWindow, expectedVodNameAsc);
await setContentSortMode(app.mainWindow, 'Name Z-A');
await expectVisibleGridTitles(app.mainWindow, expectedVodNameDesc);
await openWorkspaceSection(app.mainWindow, 'Series');
await clickCategoryByNameExact(
app.mainWindow,
seriesFixture.categoryName
);
await setContentSortMode(app.mainWindow, 'Name A-Z');
await expectVisibleGridTitles(
app.mainWindow,
expectedSeriesNameAsc
);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await openWorkspaceSection(app.mainWindow, 'Series');
await clickCategoryByNameExact(
app.mainWindow,
seriesFixture.categoryName
);
await expectVisibleGridTitles(
app.mainWindow,
expectedSeriesNameAsc
);
} finally {
await closeElectronApp(app);
}
});
test('does not expose local sorting controls on Stalker category routes', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['stalker']);
const app = await launchElectronApp(dataDir);
try {
await addStalkerPortal(app.mainWindow);
await waitForStalkerCatalog(app.mainWindow);
await expect(
app.mainWindow.getByRole('button', { name: 'Sort content' })
).toHaveCount(0);
await expect(
app.mainWindow.getByRole('button', { name: 'Sort channels' })
).toHaveCount(0);
await openWorkspaceSection(app.mainWindow, 'Movies');
await expect(
app.mainWindow.getByRole('button', { name: 'Sort content' })
).toHaveCount(0);
await openWorkspaceSection(app.mainWindow, 'Series');
await expect(
app.mainWindow.getByRole('button', { name: 'Sort content' })
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('keeps Xtream catalog page after opening VOD and series details, and resets grid scroll on page changes', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const vodFixture = await fetchXtreamVodFixture(
request,
xtreamCredentials
);
const seriesFixture = await fetchXtreamSeriesFixture(
request,
xtreamCredentials
);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow);
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Movies');
await clickCategoryByNameExact(
app.mainWindow,
vodFixture.categoryName
);
await expectCatalogGridReady(app.mainWindow);
await expectCatalogScrollResetAfterNextPage(app.mainWindow);
await clickFirstGridListCard(app.mainWindow);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/
);
await goBackFromDetail(app.mainWindow);
await expectCatalogPageQuery(app.mainWindow, '2');
await expectCatalogGridReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Series');
await clickCategoryByNameExact(
app.mainWindow,
seriesFixture.categoryName
);
await expectCatalogGridReady(app.mainWindow);
await expectCatalogScrollResetAfterNextPage(app.mainWindow);
await clickFirstGridListCard(app.mainWindow);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/
);
await goBackFromDetail(app.mainWindow);
await expectCatalogPageQuery(app.mainWindow, '2');
await expectCatalogGridReady(app.mainWindow);
} finally {
await closeElectronApp(app);
}
});
test('resets Stalker VOD and series grid scroll when changing pages', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['stalker']);
const vodFixture = await fetchStalkerCategoryFixture(request, 'vod');
const seriesFixture = await fetchStalkerCategoryFixture(
request,
'series'
);
const app = await launchElectronApp(dataDir);
try {
await addStalkerPortal(app.mainWindow);
await waitForStalkerCatalog(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Movies');
await clickCategoryByVisibleName(
app.mainWindow,
vodFixture.categoryName
);
await expectCatalogGridReady(app.mainWindow);
await expectCatalogScrollResetAfterNextPage(app.mainWindow, {
expectContentChange: false,
});
await expectStalkerDetailBackPreservesCatalogPage(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Series');
await clickCategoryByVisibleName(
app.mainWindow,
seriesFixture.categoryName
);
await expectCatalogGridReady(app.mainWindow);
await expectCatalogScrollResetAfterNextPage(app.mainWindow, {
expectContentChange: false,
});
await expectStalkerDetailBackPreservesCatalogPage(app.mainWindow);
} finally {
await closeElectronApp(app);
}
});
});
const collator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: 'base',
});
const visibleComparisonSize = 8;
const xtreamCredentials = {
username: defaultXtreamUsername,
password: defaultXtreamPassword,
};
async function setLiveSortMode(
page: Page,
label: 'Server Order' | 'Name A-Z' | 'Name Z-A'
): Promise<void> {
await page.getByRole('button', { name: 'Sort channels' }).click();
await page.getByRole('menuitem', { name: label, exact: true }).click();
}
async function setContentSortMode(
page: Page,
label:
| 'Date Added (Latest First)'
| 'Date Added (Oldest First)'
| 'Name A-Z'
| 'Name Z-A'
): Promise<void> {
await page.getByRole('button', { name: 'Sort content' }).click();
await page.getByRole('menuitem', { name: label, exact: true }).click();
}
async function expectVisibleChannelTitles(
page: Page,
expectedTitles: string[]
): Promise<void> {
await expect
.poll(async () => {
const titles = await visibleChannelTitles(page);
return titles.slice(0, expectedTitles.length);
})
.toEqual(expectedTitles);
}
async function expectVisibleGridTitles(
page: Page,
expectedTitles: string[]
): Promise<void> {
await expect
.poll(async () => {
const titles = await visibleGridTitles(page);
return titles.slice(0, expectedTitles.length);
})
.toEqual(expectedTitles);
}
async function expectCatalogGridReady(page: Page): Promise<void> {
await expect(
page.locator('.category-content-layout mat-card').first()
).toBeVisible({
timeout: 20000,
});
}
async function expectCatalogScrollResetAfterNextPage(
page: Page,
options: { expectContentChange?: boolean } = {}
): Promise<string[]> {
const grid = catalogGrid(page);
await expect(grid).toBeVisible({ timeout: 20000 });
await ensureCatalogCanGoNext(page);
const rangeBefore = await catalogRangeText(page);
const titlesBefore = await visibleGridTitles(page);
await grid.evaluate((element: HTMLElement) => {
element.scrollTo({ top: element.scrollHeight });
});
await expect.poll(() => getCatalogGridScrollTop(page)).toBeGreaterThan(0);
await page
.locator('.category-content-header')
.getByRole('button', { name: 'Next page' })
.click();
await expectCatalogPageQuery(page, '2');
await expect.poll(() => catalogRangeText(page)).not.toBe(rangeBefore);
if (options.expectContentChange !== false) {
await expect
.poll(() => visibleGridTitles(page))
.not.toEqual(titlesBefore);
}
await expect.poll(() => getCatalogGridScrollTop(page)).toBeLessThan(2);
return visibleGridTitles(page);
}
async function expectStalkerDetailBackPreservesCatalogPage(
page: Page
): Promise<void> {
const titlesOnPage = await visibleGridTitles(page);
await clickFirstGridListCard(page);
await goBackFromDetail(page);
await expectCatalogPageQuery(page, '2');
await expectCatalogGridReady(page);
await expect.poll(() => visibleGridTitles(page)).toEqual(titlesOnPage);
}
async function expectCatalogPageQuery(
page: Page,
expectedPage: string
): Promise<void> {
await expect
.poll(() => new URL(page.url()).searchParams.get('page'))
.toBe(expectedPage);
}
async function goBackFromDetail(page: Page): Promise<void> {
const backButton = page
.locator('app-content-hero .hero__back-button')
.first();
await expect(backButton).toBeVisible({ timeout: 20000 });
try {
await backButton.click({ timeout: 5000 });
} catch {
await backButton.evaluate((button: HTMLButtonElement) =>
button.click()
);
}
}
async function clickCategoryByVisibleName(
page: Page,
categoryName: string
): Promise<void> {
const category = page
.locator('app-workspace-context-panel .category-item:visible')
.filter({
has: page.locator('.nav-item-label', {
hasText: new RegExp(`^\\s*${escapeRegex(categoryName)}\\s*$`),
}),
})
.first();
await expect(category).toBeVisible({ timeout: 20000 });
await category.click();
await expect.poll(() => category.getAttribute('aria-current')).toBe('true');
}
function catalogGrid(page: Page) {
return page.locator('app-category-content-view app-grid-list').first();
}
async function ensureCatalogCanGoNext(page: Page): Promise<void> {
const header = page.locator('.category-content-header');
const nextButton = header.getByRole('button', { name: 'Next page' });
await expect(nextButton).toBeVisible({ timeout: 20000 });
if (await nextButton.isDisabled()) {
await header.getByRole('button', { name: 'Previous page' }).click();
await expect
.poll(() => new URL(page.url()).searchParams.get('page'))
.toBe(null);
}
await expect(nextButton).toBeEnabled({ timeout: 20000 });
}
async function catalogRangeText(page: Page): Promise<string> {
return (
(await page
.locator('.category-content-header .mat-mdc-paginator-range-label')
.first()
.textContent()) ?? ''
).trim();
}
async function getCatalogGridScrollTop(page: Page): Promise<number> {
return catalogGrid(page).evaluate((element: HTMLElement) =>
Math.round(element.scrollTop)
);
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
async function visibleChannelTitles(page: Page): Promise<string[]> {
return page
.locator('[data-test-id="channel-item"] .channel-name')
.allInnerTexts()
.then((titles: string[]) =>
uniqueTitles(titles.map((title) => title.trim()))
);
}
async function visibleGridTitles(page: Page): Promise<string[]> {
return page
.locator('.category-content-layout mat-card .title')
.allInnerTexts()
.then((titles: string[]) =>
uniqueTitles(titles.map((title) => title.trim()))
);
}
function uniqueTitles(titles: string[]): string[] {
const seen = new Set<string>();
return titles.filter((title) => {
if (seen.has(title)) {
return false;
}
seen.add(title);
return true;
});
}
@@ -0,0 +1,388 @@
import { Locator, Page } from '@playwright/test';
import {
addXtreamPortal,
clickCategoryById,
closeElectronApp,
expect,
launchElectronApp,
openSources,
openWorkspaceSection,
refreshSource,
resetMockServers,
restartElectronApp,
sourceRowByTitle,
test,
waitForSourceRowIdle,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
test.describe('Electron Xtream Category Management', () => {
test('hides and restores categories, supports search and bulk actions, and persists hidden selections after refresh', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const portalName = 'Category Managed Xtream';
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
name: portalName,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
// Wait for the route to settle on the live TV section so that
// the sidebar shows live categories (not VOD/series from a
// previous section) before we read from it.
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/live/
);
const targetCategory = await pickSidebarCategory(app.mainWindow);
let dialog = await openManageCategoriesDialog(app.mainWindow);
await dialog
.getByRole('button', {
name: 'Deselect All',
exact: true,
})
.click();
await expect(
dialog.locator('.category-item mat-checkbox input:checked')
).toHaveCount(0);
await dialog
.getByRole('button', {
name: 'Select All',
exact: true,
})
.click();
await expect(
dialog
.locator('.category-item mat-checkbox input:checked')
.first()
).toBeVisible();
await dialog
.locator('input[type="search"]')
.fill(targetCategory.name);
await toggleManagedCategory(dialog, targetCategory, false);
await dialog
.getByRole('button', { name: 'Save', exact: true })
.click();
await app.mainWindow.waitForSelector('mat-dialog-container', {
state: 'detached',
});
await expect(
sidebarCategoryById(app.mainWindow, targetCategory.id)
).toHaveCount(0);
await openSources(app.mainWindow);
await refreshSource(app.mainWindow, portalName, { confirm: true });
await openSources(app.mainWindow);
await waitForSourceRowIdle(app.mainWindow, portalName);
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
app.mainWindow = restarted.mainWindow;
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, portalName).first().click();
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/live/
);
await expect(
sidebarCategoryById(app.mainWindow, targetCategory.id)
).toHaveCount(0);
dialog = await openManageCategoriesDialog(app.mainWindow);
await dialog
.locator('input[type="search"]')
.fill(targetCategory.name);
await toggleManagedCategory(dialog, targetCategory, true);
await dialog
.getByRole('button', { name: 'Save', exact: true })
.click();
await app.mainWindow.waitForSelector('mat-dialog-container', {
state: 'detached',
});
dialog = await openManageCategoriesDialog(app.mainWindow);
await dialog
.locator('input[type="search"]')
.fill(targetCategory.name);
const restoredRows = dialog.locator('.category-item');
await expect(restoredRows).toHaveCount(1, { timeout: 15000 });
await expect(
restoredRows.first().locator('mat-checkbox input')
).toBeChecked();
await dialog
.getByRole('button', { name: 'Close', exact: true })
.click();
await app.mainWindow.waitForSelector('mat-dialog-container', {
state: 'detached',
});
} finally {
await closeElectronApp(app);
}
});
test('persists hidden selections after refreshing from the workspace header', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const portalName = 'Header Refresh Xtream';
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
name: portalName,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/live/
);
const targetCategory = await pickSidebarCategory(app.mainWindow);
const dialog = await openManageCategoriesDialog(app.mainWindow);
await dialog
.getByRole('button', {
name: 'Deselect All',
exact: true,
})
.click();
await expect(
dialog.locator('.category-item mat-checkbox input:checked')
).toHaveCount(0);
await dialog
.locator('input[type="search"]')
.fill(targetCategory.name);
await toggleManagedCategory(dialog, targetCategory, true);
await dialog
.getByRole('button', { name: 'Save', exact: true })
.click();
await app.mainWindow.waitForSelector('mat-dialog-container', {
state: 'detached',
});
await expect(
sidebarCategoryById(app.mainWindow, targetCategory.id)
).toBeVisible();
await refreshFromWorkspaceHeader(app.mainWindow);
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/live/
);
await expectVisibleSidebarCategoryNames(app.mainWindow, [
targetCategory.name,
]);
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
app.mainWindow = restarted.mainWindow;
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, portalName).first().click();
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/live/
);
await expectVisibleSidebarCategoryNames(app.mainWindow, [
targetCategory.name,
]);
} finally {
await closeElectronApp(app);
}
});
});
async function openManageCategoriesDialog(page: Page) {
await page.getByRole('button', { name: 'Manage categories' }).click();
const dialog = page.locator('mat-dialog-container').last();
await expect(dialog).toBeVisible();
await expect(
dialog.getByRole('button', { name: 'Select All', exact: true })
).toBeVisible();
// Wait for the category list to render — Angular needs a CD cycle after
// isLoading() flips to false before the @for items are painted.
await expect(dialog.locator('.category-item').first()).toBeVisible({
timeout: 15000,
});
return dialog;
}
async function refreshFromWorkspaceHeader(page: Page): Promise<void> {
await page
.getByRole('button', { name: 'Refresh playlist', exact: true })
.click();
const dialog = page.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Yes', exact: true }).click();
await page.waitForSelector('mat-dialog-container', { state: 'detached' });
}
function sidebarCategoryById(page: Page, categoryId: string): Locator {
return page.locator(
`app-workspace-context-panel .category-item[data-category-id="${categoryId}"]`
);
}
async function expectVisibleSidebarCategoryIds(
page: Page,
expectedIds: string[]
): Promise<void> {
const categories = page.locator(
'app-workspace-context-panel .category-item:visible'
);
const actualIds: string[] = [];
const count = await categories.count();
for (let index = 0; index < count; index += 1) {
const categoryId = await categories
.nth(index)
.getAttribute('data-category-id');
if (categoryId) {
actualIds.push(categoryId.trim());
}
}
expect(actualIds).toEqual(expectedIds);
}
async function expectVisibleSidebarCategoryNames(
page: Page,
expectedNames: string[]
): Promise<void> {
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() ?? '';
if (categoryName) {
actualNames.push(categoryName);
}
}
return actualNames;
})
.toEqual(expectedNames);
}
async function pickSidebarCategory(
page: Page
): Promise<{ id: string; itemCount: number; name: string }> {
let preferredCandidate:
| { id: string; itemCount: number; name: string }
| null = null;
await expect
.poll(async () => {
const categories = page.locator(
'app-workspace-context-panel .category-item:visible'
);
const count = await categories.count();
const candidates: Array<{
id: string;
itemCount: number;
name: string;
}> = [];
for (let index = 0; index < count; index += 1) {
const category = categories.nth(index);
const id =
(await category.getAttribute('data-category-id'))?.trim() ??
'';
const name =
(
await category.locator('.nav-item-label').textContent()
)?.trim() ?? '';
const countText =
(
await category.locator('.item-count').textContent()
)?.trim() ?? '';
const itemCount = Number.parseInt(countText, 10) || 0;
if (id && name && itemCount > 0) {
candidates.push({ id, itemCount, name });
}
}
if (candidates.length === 0) {
preferredCandidate = null;
return false;
}
const nameCounts = new Map<string, number>();
for (const candidate of candidates) {
nameCounts.set(
candidate.name,
(nameCounts.get(candidate.name) ?? 0) + 1
);
}
preferredCandidate =
candidates.find(
(candidate) => nameCounts.get(candidate.name) === 1
) ?? candidates[0];
return preferredCandidate !== null;
})
.toBe(true, {
message: 'No visible Xtream category with content was found.',
timeout: 15000,
});
return preferredCandidate!;
}
async function toggleManagedCategory(
dialog: Locator,
targetCategory: {
id: string;
itemCount: number;
name: string;
},
shouldBeSelected: boolean
): Promise<void> {
void targetCategory.id;
const categoryRows = dialog.locator('.category-item');
await expect(categoryRows).toHaveCount(1, { timeout: 15000 });
const categoryRow = categoryRows.first();
const checkbox = categoryRow.locator('mat-checkbox input');
await expect(categoryRow).toBeVisible({ timeout: 15000 });
if (shouldBeSelected) {
await checkbox.check();
await expect(checkbox).toBeChecked();
return;
}
await checkbox.uncheck();
await expect(checkbox).not.toBeChecked();
}
@@ -0,0 +1,257 @@
import { Page } from '@playwright/test';
import {
addXtreamPortal,
clickCategoryByNameExact,
clickFirstGridListCard,
closeElectronApp,
defaultXtreamPassword,
defaultXtreamUsername,
expectPathname,
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 global collection detail views 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 addCurrentDetailToFavorites(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 expectDashboardRail(app.mainWindow, 'dashboard-global-favorites-rail');
await dashboardRailCardByTitle(
app.mainWindow,
'dashboard-global-favorites-rail',
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 dashboardRailCardByTitle(
app.mainWindow,
'dashboard-global-favorites-rail',
movieTitle
).click();
await expectInlineCollectionDetail(app.mainWindow, {
pathname: /\/workspace\/global-favorites$/,
title: movieTitle,
});
await goBackFromDetail(app.mainWindow);
await expectPathname(app.mainWindow, /\/workspace\/dashboard$/);
await dashboardRailCardByTitle(
app.mainWindow,
'dashboard-recently-watched-rail',
movieTitle
).click();
await expectInlineCollectionDetail(app.mainWindow, {
pathname: /\/workspace\/global-recent$/,
title: movieTitle,
});
await app.mainWindow.goBack();
await expectPathname(app.mainWindow, /\/workspace\/dashboard$/);
await dashboardRailCardByTitle(
app.mainWindow,
'dashboard-recently-watched-rail',
seriesTitle
).click();
await expectInlineCollectionDetail(app.mainWindow, {
pathname: /\/workspace\/global-recent$/,
title: seriesTitle,
});
await goBackFromDetail(app.mainWindow);
await expectPathname(app.mainWindow, /\/workspace\/dashboard$/);
} finally {
await closeElectronApp(app);
}
});
});
const xtreamCredentials = {
username: defaultXtreamUsername,
password: defaultXtreamPassword,
};
async function expectDashboardRail(page: Page, railTestId: string): Promise<void> {
await expect(page.locator(`[data-test-id="${railTestId}"]`)).toBeVisible({
timeout: 20000,
});
}
function dashboardRailCardByTitle(
page: Page,
railTestId: string,
title: string
) {
return page.locator(`[data-test-id="${railTestId}-card"]`).filter({
hasText: title,
}).first();
}
async function goBackFromDetail(page: Page): Promise<void> {
const backButton = page
.locator('app-content-hero .hero__back-button')
.first();
await expect(backButton).toBeVisible({ timeout: 20000 });
try {
await backButton.click({ timeout: 5000 });
} catch {
await backButton.evaluate((button: HTMLButtonElement) =>
button.click()
);
}
}
async function addCurrentDetailToFavorites(page: Page): Promise<void> {
const addButton = page.locator('button.favorite-btn').first();
await expect(addButton).toBeVisible({ timeout: 20000 });
await addButton.click();
await expect(
page.locator('button.favorite-btn--active').first()
).toBeVisible({
timeout: 20000,
});
}
async function expectInlineCollectionDetail(
page: Page,
params: {
pathname: RegExp;
title: string;
}
): Promise<void> {
await expectPathname(page, params.pathname);
await expect(page.locator('app-workspace-context-panel')).toHaveCount(0);
await expect(page.locator('app-content-hero')).toContainText(params.title);
await expect(
page.locator('app-content-hero .hero__back-button').first()
).toBeVisible({ timeout: 20000 });
}
async function playCurrentDetail(page: Page): Promise<void> {
const playButton = page.locator('button.play-btn').first();
await expect(playButton).toBeVisible({ timeout: 20000 });
await playButton.click();
}
async function playFirstSeriesEpisode(page: Page): Promise<void> {
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<void> {
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/
);
}
File diff suppressed because it is too large Load Diff
+173
View File
@@ -0,0 +1,173 @@
import { Page } from '@playwright/test';
import {
buildM3uContent,
channelItemByTitle,
closeElectronApp,
createMutableTextServer,
expect,
importM3uPlaylistFromUrl,
launchElectronApp,
openWorkspaceSection,
openSettings,
test,
} from './electron-test-fixtures';
const fallbackLogoSvg = Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><rect width="96" height="96" rx="20" fill="#16436b"/><text x="48" y="58" text-anchor="middle" font-size="28" font-family="Arial" fill="#f5f7fb">GN</text></svg>`
).toString('base64');
const fallbackLogoDataUrl = `data:image/svg+xml;base64,${fallbackLogoSvg}`;
const epgFixtureXml = `<?xml version="1.0" encoding="UTF-8"?>
<tv>
<channel id="guide-news">
<display-name>Guide News</display-name>
</channel>
<programme start="20260328070000 +0000" stop="20260328080000 +0000" channel="guide-news">
<title>Guide Bulletin</title>
<desc>EPG refresh smoke test.</desc>
</programme>
</tv>
`;
test.describe('Electron EPG', () => {
test('adds an EPG source, fetches guide data, removes the source row, and clears stored EPG data', async ({
dataDir,
}) => {
const epgServer = await createMutableTextServer(epgFixtureXml, {
contentType: 'application/xml; charset=utf-8',
resourcePath: '/guide.xml',
});
const app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await app.mainWindow
.getByRole('button', { name: 'Add EPG source' })
.click();
await app.mainWindow
.locator('.epg-source-row input')
.first()
.fill(epgServer.resourceUrl);
await app.mainWindow.locator('.epg-source-row button').first().click();
await expect(
app.mainWindow.locator('.epg-progress-panel')
).toBeVisible();
await expect
.poll(() => getEpgChannelCount(app.mainWindow), {
timeout: 30000,
})
.toBeGreaterThan(0);
await expect(
app.mainWindow.locator('.epg-progress-panel .stat-badge').first()
).toBeVisible();
await app.mainWindow.locator('.epg-source-row button').nth(1).click();
await expect(app.mainWindow.locator('.epg-source-row')).toHaveCount(0);
await app.mainWindow
.getByRole('button', { name: 'Clear EPG data' })
.click();
const dialog = app.mainWindow.locator('mat-dialog-container');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Yes', exact: true }).click();
await app.mainWindow.waitForSelector('mat-dialog-container', {
state: 'detached',
});
await expect
.poll(() => getEpgChannelCount(app.mainWindow), {
timeout: 20000,
})
.toBe(0);
} finally {
await closeElectronApp(app);
await epgServer.close();
}
});
test('uses the XMLTV channel icon as a fallback when the playlist has no tvg-logo', async ({
dataDir,
}) => {
const playlistServer = await createMutableTextServer(
buildM3uContent([
{
name: 'Guide News Live',
tvgId: 'guide-news',
url: 'https://example.com/live/guide-news.m3u8',
},
]),
{
contentType: 'application/x-mpegurl; charset=utf-8',
resourcePath: '/playlist.m3u',
}
);
const epgServer = await createMutableTextServer(
`<?xml version="1.0" encoding="UTF-8"?>
<tv>
<channel id="guide-news">
<display-name>Guide News Live</display-name>
<icon src="${fallbackLogoDataUrl}"/>
</channel>
<programme start="20260328070000 +0000" stop="20260328080000 +0000" channel="guide-news">
<title>Guide Bulletin</title>
<desc>EPG logo fallback test.</desc>
</programme>
</tv>
`,
{
contentType: 'application/xml; charset=utf-8',
resourcePath: '/guide.xml',
}
);
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromUrl(
app.mainWindow,
playlistServer.resourceUrl
);
await openSettings(app.mainWindow);
await app.mainWindow
.getByRole('button', { name: 'Add EPG source' })
.click();
await app.mainWindow
.locator('.epg-source-row input')
.first()
.fill(epgServer.resourceUrl);
await app.mainWindow.locator('.epg-source-row button').first().click();
await expect
.poll(() => getEpgChannelCount(app.mainWindow), {
timeout: 30000,
})
.toBeGreaterThan(0);
await openWorkspaceSection(app.mainWindow, 'All channels');
const channelItem = channelItemByTitle(
app.mainWindow,
'Guide News Live'
);
await expect(channelItem).toBeVisible();
await expect(channelItem.locator('.channel-logo')).toHaveAttribute(
'src',
fallbackLogoDataUrl
);
await expect(
channelItem.locator('.channel-logo-fallback')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
await playlistServer.close();
await epgServer.close();
}
});
});
async function getEpgChannelCount(page: Page): Promise<number> {
return page.evaluate(async () => {
const channels = await window.electron?.getEpgChannelsByRange?.(0, 20);
return Array.isArray(channels) ? channels.length : 0;
});
}
@@ -0,0 +1,633 @@
import { Page } from '@playwright/test';
import {
addStalkerPortal,
addXtreamPortal,
channelItemByTitle,
clearCurrentUnifiedCollection,
clickCategoryById,
clickGridListCardByTitle,
clickCategoryByNameExact,
clickFirstGridListCard,
closeElectronApp,
contentCardByTitle,
defaultXtreamPassword,
defaultXtreamUsername,
expectPathname,
expectVisibleContentCardTitle,
expect,
importM3uPlaylistFromNativeDialog,
launchElectronApp,
openWorkspaceSection,
openPlaylistFavorites,
openSources,
resetMockServers,
restartElectronApp,
sourceRowByTitle,
switchUnifiedCollectionContent,
switchUnifiedCollectionScope,
test,
playlistSwitcherTitle,
waitForM3uCatalog,
waitForStalkerCatalog,
waitForXtreamWorkspaceReady,
writeTemporaryM3uFile,
} from './electron-test-fixtures';
import {
fetchStalkerCategoryFixture,
fetchXtreamLiveFixture,
fetchXtreamSeriesFixture,
fetchXtreamVodFixture,
getStalkerTitle,
getXtreamTitle,
pickDistinctTitles,
} from './portal-mock-fixtures';
test.describe('Electron Favorites', () => {
test('shows M3U favorites in playlist and all-playlists scope, and preserves them after restart', async ({
dataDir,
}) => {
const playlistTitle = 'm3u-favorites-source.m3u';
const playlistSourceTitle = 'm3u-favorites-source';
const filePath = writeTemporaryM3uFile(dataDir, playlistTitle, [
{
groupTitle: 'News',
name: 'M3U Favorite One',
url: 'https://streams.example.test/m3u-favorite-one.m3u8',
},
{
groupTitle: 'News',
name: 'M3U Favorite Two',
url: 'https://streams.example.test/m3u-favorite-two.m3u8',
},
]);
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromNativeDialog(app, filePath);
await waitForM3uCatalog(app.mainWindow);
await toggleFavoriteForChannel(app.mainWindow, 'M3U Favorite One');
await toggleFavoriteForChannel(app.mainWindow, 'M3U Favorite Two');
await openPlaylistFavorites(app.mainWindow);
await expect(
channelItemByTitle(app.mainWindow, 'M3U Favorite One')
).toHaveCount(1);
await expect(
channelItemByTitle(app.mainWindow, 'M3U Favorite Two')
).toHaveCount(1);
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
await expect(
channelItemByTitle(app.mainWindow, 'M3U Favorite One')
).toHaveCount(1);
await expect(
channelItemByTitle(app.mainWindow, 'M3U Favorite Two')
).toHaveCount(1);
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
app.mainWindow = restarted.mainWindow;
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, playlistSourceTitle)
.first()
.click();
await waitForM3uCatalog(app.mainWindow);
await openPlaylistFavorites(app.mainWindow);
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
await expect(
channelItemByTitle(app.mainWindow, 'M3U Favorite One')
).toHaveCount(1);
await expect(
channelItemByTitle(app.mainWindow, 'M3U Favorite Two')
).toHaveCount(1);
} finally {
await closeElectronApp(app);
}
});
test('keeps playlist M3U favorites cleared after navigating away and back', async ({
dataDir,
}) => {
const playlistTitle = 'm3u-clear-favorites-source.m3u';
const playlistSourceTitle = 'm3u-clear-favorites-source';
const filePath = writeTemporaryM3uFile(dataDir, playlistTitle, [
{
groupTitle: 'News',
name: 'M3U Clear Favorite One',
url: 'https://streams.example.test/m3u-clear-favorite-one.m3u8',
},
{
groupTitle: 'News',
name: 'M3U Clear Favorite Two',
url: 'https://streams.example.test/m3u-clear-favorite-two.m3u8',
},
]);
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromNativeDialog(app, filePath);
await waitForM3uCatalog(app.mainWindow);
await toggleFavoriteForChannel(
app.mainWindow,
'M3U Clear Favorite One'
);
await toggleFavoriteForChannel(
app.mainWindow,
'M3U Clear Favorite Two'
);
await openPlaylistFavorites(app.mainWindow);
await expect(
channelItemByTitle(app.mainWindow, 'M3U Clear Favorite One')
).toHaveCount(1);
await expect(
channelItemByTitle(app.mainWindow, 'M3U Clear Favorite Two')
).toHaveCount(1);
await clearCurrentUnifiedCollection(app.mainWindow);
await expect(
channelItemByTitle(app.mainWindow, 'M3U Clear Favorite One')
).toHaveCount(0);
await expect(
channelItemByTitle(app.mainWindow, 'M3U Clear Favorite Two')
).toHaveCount(0);
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, playlistSourceTitle)
.first()
.click();
await waitForM3uCatalog(app.mainWindow);
await openPlaylistFavorites(app.mainWindow);
await expect(
channelItemByTitle(app.mainWindow, 'M3U Clear Favorite One')
).toHaveCount(0);
await expect(
channelItemByTitle(app.mainWindow, 'M3U Clear Favorite Two')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('shows Xtream live, movie, and series favorites in playlist and all-playlists scope, and preserves them after restart', 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 portalTitle = 'Xtream Favorites Source';
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
name: portalTitle,
});
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
);
// 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);
await app.mainWindow
.getByRole('link', { name: 'Series', exact: true })
.click();
await clickCategoryByNameExact(
app.mainWindow,
seriesFixture.categoryName
);
const seriesTitle = await clickFirstGridListCard(app.mainWindow);
await addCurrentDetailToFavorites(app.mainWindow);
await goBackFromDetail(app.mainWindow);
await openPlaylistFavorites(app.mainWindow);
await expect(
channelItemByTitle(app.mainWindow, liveTitle)
).toHaveCount(1);
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
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 expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
app.mainWindow = restarted.mainWindow;
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, portalTitle).first().click();
await waitForXtreamWorkspaceReady(app.mainWindow);
await openPlaylistFavorites(app.mainWindow);
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 expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
} finally {
await closeElectronApp(app);
}
});
test('opens Xtream favorite movies and series inline from favorites without switching the playlist or showing the sidebar', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const vodFixture = await fetchXtreamVodFixture(
request,
xtreamCredentials
);
const seriesFixture = await fetchXtreamSeriesFixture(
request,
xtreamCredentials
);
const hostPortalTitle = 'Xtream Favorite Detail Host';
const sourcePortalTitle = 'Xtream Favorite Detail Source';
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
name: hostPortalTitle,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await addXtreamPortal(app.mainWindow, {
name: sourcePortalTitle,
});
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 addCurrentDetailToFavorites(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 addCurrentDetailToFavorites(app.mainWindow);
await goBackFromDetail(app.mainWindow);
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, hostPortalTitle)
.first()
.click();
await waitForXtreamWorkspaceReady(app.mainWindow);
await openPlaylistFavorites(app.mainWindow);
await expect(playlistSwitcherTitle(app.mainWindow)).toContainText(
hostPortalTitle
);
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await contentCardByTitle(app.mainWindow, movieTitle)
.first()
.click();
await expectInlineCollectionDetail(app.mainWindow, {
pathname: /\/workspace\/global-favorites$/,
title: movieTitle,
playlistTitle: hostPortalTitle,
});
await goBackFromDetail(app.mainWindow);
await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/);
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
await contentCardByTitle(app.mainWindow, seriesTitle)
.first()
.click();
await expectInlineCollectionDetail(app.mainWindow, {
pathname: /\/workspace\/global-favorites$/,
title: seriesTitle,
playlistTitle: hostPortalTitle,
});
await goBackFromDetail(app.mainWindow);
await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/);
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
} finally {
await closeElectronApp(app);
}
});
test('shows Stalker live, movie, and series favorites in playlist and all-playlists scope, and preserves them after restart', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['stalker']);
const liveFixture = await fetchStalkerCategoryFixture(request, 'itv');
const vodFixture = await fetchStalkerCategoryFixture(request, 'vod');
const seriesFixture = await fetchStalkerCategoryFixture(
request,
'series'
);
const [liveTitle] = pickDistinctTitles(
liveFixture.items,
getStalkerTitle
);
const [movieTitle] = pickDistinctTitles(
vodFixture.items,
getStalkerTitle
);
const [seriesTitle] = pickDistinctTitles(
seriesFixture.items,
getStalkerTitle
);
const portalTitle = 'Stalker Favorites Source';
const app = await launchElectronApp(dataDir);
try {
await addStalkerPortal(app.mainWindow, {
name: portalTitle,
});
await waitForStalkerCatalog(app.mainWindow);
await app.mainWindow
.getByRole('link', { name: 'Live TV', exact: true })
.click();
await clickCategoryById(app.mainWindow, liveFixture.categoryId);
await toggleFavoriteForChannel(app.mainWindow, liveTitle);
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 expect(
channelItemByTitle(app.mainWindow, liveTitle)
).toHaveCount(1);
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
await expect(
contentCardByTitle(app.mainWindow, movieTitle)
).toHaveCount(1);
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expect(
contentCardByTitle(app.mainWindow, seriesTitle)
).toHaveCount(1);
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 switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expect(
contentCardByTitle(app.mainWindow, seriesTitle)
).toHaveCount(1);
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
app.mainWindow = restarted.mainWindow;
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, portalTitle).first().click();
await waitForStalkerCatalog(app.mainWindow);
await openPlaylistFavorites(app.mainWindow);
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 switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expect(
contentCardByTitle(app.mainWindow, seriesTitle)
).toHaveCount(1);
} finally {
await closeElectronApp(app);
}
});
test('opens Stalker favorite movies and series inline from favorites without showing the sidebar', 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 portalTitle = 'Stalker Favorite Detail Source';
const app = await launchElectronApp(dataDir);
try {
await addStalkerPortal(app.mainWindow, {
name: portalTitle,
});
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 expect(playlistSwitcherTitle(app.mainWindow)).toContainText(
portalTitle
);
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await contentCardByTitle(app.mainWindow, movieTitle)
.first()
.click();
await expectInlineCollectionDetail(app.mainWindow, {
pathname: /\/workspace\/global-favorites$/,
title: movieTitle,
playlistTitle: portalTitle,
});
await goBackFromDetail(app.mainWindow);
await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/);
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
await contentCardByTitle(app.mainWindow, seriesTitle)
.first()
.click();
await expectInlineCollectionDetail(app.mainWindow, {
pathname: /\/workspace\/global-favorites$/,
title: seriesTitle,
playlistTitle: portalTitle,
});
await goBackFromDetail(app.mainWindow);
await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/);
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
} finally {
await closeElectronApp(app);
}
});
});
const xtreamCredentials = {
username: defaultXtreamUsername,
password: defaultXtreamPassword,
};
async function addCurrentDetailToFavorites(page: Page): Promise<void> {
const addButton = page.locator('button.favorite-btn').first();
await expect(addButton).toBeVisible({ timeout: 20000 });
await addButton.click();
await expect(
page.locator('button.favorite-btn--active').first()
).toBeVisible({
timeout: 20000,
});
}
async function goBackFromDetail(page: Page): Promise<void> {
const backButton = page
.locator('app-content-hero .hero__back-button')
.first();
await expect(backButton).toBeVisible({ timeout: 20000 });
try {
await backButton.click({ timeout: 5000 });
} catch {
await backButton.evaluate((button: HTMLButtonElement) =>
button.click()
);
}
}
async function expectInlineCollectionDetail(
page: Page,
params: {
pathname: RegExp;
title: string;
playlistTitle: string;
}
): Promise<void> {
await expectPathname(page, params.pathname);
await expect(playlistSwitcherTitle(page)).toContainText(
params.playlistTitle
);
await expect(page.locator('app-workspace-context-panel')).toHaveCount(0);
await expect(page.locator('app-content-hero')).toContainText(params.title);
await expect(
page.locator('app-content-hero .hero__back-button').first()
).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/
);
}
@@ -0,0 +1,27 @@
import {
closeElectronApp,
expect,
importM3uPlaylistFromNativeDialog,
launchElectronApp,
m3uFixturePath,
test,
} from './electron-test-fixtures';
test.describe('Electron Native Playlist Import', () => {
test('imports an M3U playlist via the native file picker path', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromNativeDialog(app, m3uFixturePath);
await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+/);
await expect(
app.mainWindow.getByTestId('channel-item')
).toHaveCount(4);
} finally {
await closeElectronApp(app);
}
});
});
@@ -0,0 +1,504 @@
import { Page } from '@playwright/test';
import {
channelItemByTitle,
addStalkerPortal,
addXtreamPortal,
closeElectronApp,
defaultXtreamPassword,
defaultXtreamUsername,
expect,
importM3uPlaylistFromNativeDialog,
launchElectronApp,
openPlaylistFavorites,
openPlaylistRecent,
openSettings,
openSourceEditor,
openSources,
openWorkspaceSection,
playlistSwitcherTitle,
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 openPlaylistRecent(app.mainWindow);
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
await switchPlaylistFromHeader(app.mainWindow, localBDisplayName);
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
await expect(playlistSwitcherTitle(app.mainWindow)).toContainText(
localBDisplayName
);
await switchPlaylistFromHeader(app.mainWindow, xtreamAName);
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
await expect(playlistSwitcherTitle(app.mainWindow)).toContainText(
xtreamAName
);
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 openPlaylistFavorites(app.mainWindow);
await expectPathname(
app.mainWindow,
/\/workspace\/global-favorites$/
);
await switchPlaylistFromHeader(app.mainWindow, stalkerBName);
await expectPathname(
app.mainWindow,
/\/workspace\/global-favorites$/
);
await expect(playlistSwitcherTitle(app.mainWindow)).toContainText(
stalkerBName
);
} 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\/global-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\/global-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/);
}
@@ -0,0 +1,453 @@
import { APIRequestContext } from '@playwright/test';
import {
defaultStalkerMacAddress,
expect,
stalkerMockServer,
xtreamMockServer,
} from './electron-test-fixtures';
export type XtreamCategory = {
category_id: string;
category_name: string;
};
export type XtreamLiveStream = {
added?: string;
category_id?: string;
epg_channel_id?: string;
name?: string;
stream_id?: number | string;
stream_type?: string;
tv_archive?: number | string;
tv_archive_duration?: number | string;
};
export type XtreamVodStream = {
added?: string;
category_id?: string;
name?: string;
stream_id?: number | string;
};
export type XtreamSeriesItem = {
added?: string;
category_id?: number | string;
last_modified?: string;
name?: string;
releaseDate?: string;
series_id?: number | string;
};
export type XtreamCategoryFixture<T> = {
categoryId: string;
categoryName: string;
items: T[];
};
export type XtreamRawEpgListing = {
channel_id?: string;
description?: string;
end?: string;
epg_id?: string;
id?: string;
lang?: string;
start?: string;
start_timestamp?: string;
stop_timestamp?: string;
title?: string;
};
export type XtreamNormalizedEpgListing = {
channelId: string;
description: string;
end: string;
id: string;
rawDescription: string;
rawEnd: string;
rawStart: string;
rawTitle: string;
start: string;
startTimestamp: number;
stopTimestamp: number;
title: string;
};
export type XtreamEpgFixture = {
categoryId: string;
categoryName: string;
fullEpg: XtreamNormalizedEpgListing[];
rawFullEpg: XtreamRawEpgListing[];
rawShortEpg: XtreamRawEpgListing[];
shortEpg: XtreamNormalizedEpgListing[];
stream: XtreamLiveStream;
};
type StalkerCategory = {
id: string;
title: string;
};
type StalkerProxyPayload<T> = {
payload: {
js: T;
};
};
type StalkerOrderedList<T> = {
data: T[];
};
type StalkerSeasonLike = {
id?: number | string;
};
export type StalkerContentItem = {
id?: number | string;
name?: string;
o_name?: string;
};
export type StalkerCategoryFixture = {
categoryId: string;
categoryName: string;
items: StalkerContentItem[];
};
export async function fetchXtreamLiveFixture(
request: APIRequestContext,
credentials = { username: 'minimal', password: 'minimal' }
): Promise<XtreamCategoryFixture<XtreamLiveStream>> {
return fetchXtreamFixture<XtreamLiveStream>(request, {
categoriesAction: 'get_live_categories',
itemsAction: 'get_live_streams',
...credentials,
});
}
export async function fetchXtreamVodFixture(
request: APIRequestContext,
credentials = { username: 'minimal', password: 'minimal' }
): Promise<XtreamCategoryFixture<XtreamVodStream>> {
return fetchXtreamFixture<XtreamVodStream>(request, {
categoriesAction: 'get_vod_categories',
itemsAction: 'get_vod_streams',
...credentials,
});
}
export async function fetchXtreamSeriesFixture(
request: APIRequestContext,
credentials = { username: 'minimal', password: 'minimal' }
): Promise<XtreamCategoryFixture<XtreamSeriesItem>> {
return fetchXtreamFixture<XtreamSeriesItem>(request, {
categoriesAction: 'get_series_categories',
itemsAction: 'get_series',
...credentials,
});
}
export async function fetchXtreamEpgFixture(
request: APIRequestContext,
credentials = { username: 'epg', password: 'epg' }
): Promise<XtreamEpgFixture> {
const liveFixture = await fetchXtreamLiveFixture(request, credentials);
const stream = liveFixture.items[0];
if (!stream) {
throw new Error('Xtream EPG fixture returned no live stream.');
}
const streamId = Number(stream.stream_id);
if (!Number.isFinite(streamId) || streamId <= 0) {
throw new Error('Xtream EPG fixture returned an invalid stream id.');
}
const rawShortResponse = await fetchJson<{
epg_listings?: XtreamRawEpgListing[];
}>(
request,
`${xtreamMockServer}/player_api.php?action=get_short_epg&username=${encodeURIComponent(
credentials.username
)}&password=${encodeURIComponent(
credentials.password
)}&stream_id=${streamId}&limit=3`
);
const rawFullResponse = await fetchJson<{
epg_listings?: XtreamRawEpgListing[];
}>(
request,
`${xtreamMockServer}/player_api.php?action=get_simple_data_table&username=${encodeURIComponent(
credentials.username
)}&password=${encodeURIComponent(
credentials.password
)}&stream_id=${streamId}`
);
const rawShortEpg = rawShortResponse.epg_listings ?? [];
const rawFullEpg = rawFullResponse.epg_listings ?? [];
if (rawShortEpg.length === 0 || rawFullEpg.length === 0) {
throw new Error('Xtream EPG fixture returned no schedule data.');
}
return {
categoryId: liveFixture.categoryId,
categoryName: liveFixture.categoryName,
stream,
rawShortEpg,
rawFullEpg,
shortEpg: normalizeXtreamEpgListings(rawShortEpg),
fullEpg: normalizeXtreamEpgListings(rawFullEpg),
};
}
export async function fetchStalkerCategoryFixture(
request: APIRequestContext,
type: 'itv' | 'series' | 'vod'
): Promise<StalkerCategoryFixture> {
const categoriesResponse = await fetchJson<
StalkerProxyPayload<StalkerCategory[]>
>(
request,
buildStalkerProxyUrl('get_categories', {
type,
})
);
const categories = categoriesResponse.payload.js ?? [];
for (const category of categories) {
const itemsResponse = await fetchJson<
StalkerProxyPayload<StalkerOrderedList<StalkerContentItem>>
>(
request,
buildStalkerProxyUrl('get_ordered_list', {
category: String(category.id),
p: '1',
type,
})
);
const items = itemsResponse.payload.js.data ?? [];
if (items.length > 0) {
if (type === 'series') {
const playableSeries = await findPlayableStalkerSeriesItem(
request,
items
);
if (!playableSeries) {
continue;
}
return {
categoryId: String(category.id),
categoryName: category.title,
items: [
playableSeries,
...items.filter(
(item) =>
String(item.id ?? '') !==
String(playableSeries.id ?? '')
),
],
};
}
return {
categoryId: String(category.id),
categoryName: category.title,
items,
};
}
}
throw new Error(`Stalker mock server returned no ${type} items.`);
}
export function getXtreamTitle(
item: XtreamLiveStream | XtreamVodStream | XtreamSeriesItem
): string {
return `${item.name ?? ''}`.trim();
}
export function getXtreamDateValue(
item: XtreamVodStream | XtreamSeriesItem
): number {
if ('last_modified' in item && item.last_modified) {
return Number.parseInt(item.last_modified, 10) || 0;
}
return Number.parseInt(item.added ?? '', 10) || 0;
}
export function getStalkerTitle(item: StalkerContentItem): string {
return `${item.o_name ?? item.name ?? ''}`.trim();
}
export function pickDistinctTitles<T>(
items: T[],
getTitle: (item: T) => string
): [string, string] {
const titles = items
.map((item) => getTitle(item))
.map((title) => title.trim())
.filter((title, index, allTitles) => {
return (
title.length > 0 &&
allTitles.findIndex((candidate) => candidate === title) ===
index
);
});
if (titles.length < 2) {
throw new Error('Expected at least two distinct item titles.');
}
return [titles[0], titles[1]];
}
async function fetchXtreamFixture<T extends { category_id?: string | number }>(
request: APIRequestContext,
options: {
categoriesAction: string;
itemsAction: string;
password: string;
username: string;
}
): Promise<XtreamCategoryFixture<T>> {
const { categoriesAction, itemsAction, password, username } = options;
const categories = await fetchJson<XtreamCategory[]>(
request,
`${xtreamMockServer}/player_api.php?action=${encodeURIComponent(
categoriesAction
)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(
password
)}`
);
for (const category of categories) {
const items = await fetchJson<T[]>(
request,
`${xtreamMockServer}/player_api.php?action=${encodeURIComponent(
itemsAction
)}&username=${encodeURIComponent(username)}&password=${encodeURIComponent(
password
)}&category_id=${encodeURIComponent(category.category_id)}`
);
if (items.length > 0) {
return {
categoryId: String(category.category_id),
categoryName: category.category_name,
items,
};
}
}
throw new Error(`Xtream mock server returned no items for ${itemsAction}.`);
}
async function fetchJson<T>(
request: APIRequestContext,
url: string
): Promise<T> {
const response = await request.get(url);
expect(response.ok()).toBeTruthy();
return (await response.json()) as T;
}
function normalizeXtreamEpgListings(
listings: XtreamRawEpgListing[]
): XtreamNormalizedEpgListing[] {
return listings
.map((listing, index) => {
const startTimestamp = Number.parseInt(
String(listing.start_timestamp ?? ''),
10
);
const stopTimestamp = Number.parseInt(
String(listing.stop_timestamp ?? ''),
10
);
return {
id: String(listing.id ?? index),
title: decodeXtreamText(String(listing.title ?? '')),
description: decodeXtreamText(
String(listing.description ?? '')
),
rawTitle: String(listing.title ?? ''),
rawDescription: String(listing.description ?? ''),
rawStart: String(listing.start ?? ''),
rawEnd: String(listing.end ?? ''),
start: new Date(startTimestamp * 1000).toISOString(),
end: new Date(stopTimestamp * 1000).toISOString(),
startTimestamp,
stopTimestamp,
channelId: String(listing.channel_id ?? listing.epg_id ?? ''),
};
})
.sort((left, right) => left.startTimestamp - right.startTimestamp);
}
function decodeXtreamText(value: string): string {
return Buffer.from(value, 'base64').toString('utf-8');
}
function buildStalkerProxyUrl(
action: string,
params: Record<string, string>
): string {
const searchParams = new URLSearchParams({
action,
macAddress: defaultStalkerMacAddress,
url: `${stalkerMockServer}/portal.php`,
...params,
});
return `${stalkerMockServer}/stalker?${searchParams.toString()}`;
}
async function findPlayableStalkerSeriesItem(
request: APIRequestContext,
items: StalkerContentItem[]
): Promise<StalkerContentItem | null> {
for (const item of items) {
const itemId = String(item.id ?? '').trim();
if (!itemId) {
continue;
}
const seasonsResponse = await fetchJson<
StalkerProxyPayload<
StalkerOrderedList<StalkerSeasonLike> | StalkerSeasonLike[]
>
>(
request,
buildStalkerProxyUrl('get_ordered_list', {
movie_id: itemId,
p: '1',
type: 'series',
})
);
const seasons = extractStalkerSeriesItems(seasonsResponse.payload.js);
if (seasons.length > 0) {
return item;
}
}
return null;
}
function extractStalkerSeriesItems<T>(
payload: StalkerOrderedList<T> | T[] | undefined
): T[] {
if (Array.isArray(payload)) {
return payload;
}
return payload?.data ?? [];
}
@@ -0,0 +1,87 @@
import type { Page } from '@playwright/test';
import {
addStalkerPortal,
addXtreamPortal,
closeElectronApp,
defaultStalkerPortalName,
defaultXtreamPortalName,
expect,
expectPortalDebugSuccess,
goToDashboard,
launchElectronApp,
resetMockServers,
stalkerMockServer,
test,
waitForStalkerCatalog,
waitForXtreamCatalog,
} from './electron-test-fixtures';
test.describe('Electron Provider Smoke Tests', () => {
test('loads Xtream content through the Electron IPC path', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow);
await waitForXtreamCatalog(app.mainWindow);
await expectPortalDebugSuccess(app.mainWindow, 'xtream');
await goToDashboard(app.mainWindow);
await expectRecentSourceCard(
app.mainWindow,
defaultXtreamPortalName
);
} finally {
await closeElectronApp(app);
}
});
test('loads Stalker content through the Electron IPC path', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['stalker']);
const app = await launchElectronApp(dataDir);
try {
await addStalkerPortal(app.mainWindow, {
portalUrl: `${stalkerMockServer}/portal.php`,
});
await waitForStalkerCatalog(app.mainWindow);
await expectPortalDebugSuccess(app.mainWindow, 'stalker');
await goToDashboard(app.mainWindow);
await expectRecentSourceCard(
app.mainWindow,
defaultStalkerPortalName
);
} finally {
await closeElectronApp(app);
}
});
});
async function expectRecentSourceCard(
page: Page,
title: string
): Promise<void> {
await expect(
page.getByTestId('dashboard-recent-sources-rail')
).toBeVisible({
timeout: 20000,
});
await expect(
page.getByTestId('dashboard-recent-sources-rail-card').filter({
hasText: title,
}).first()
).toBeVisible({
timeout: 20000,
});
}
+661
View File
@@ -0,0 +1,661 @@
import { Page } from '@playwright/test';
import {
addStalkerPortal,
addXtreamPortal,
channelItemByTitle,
clickCategoryById,
clickGridListCardByTitle,
clickCategoryByNameExact,
clickFirstGridListCard,
closeElectronApp,
contentCardByTitle,
defaultXtreamPassword,
defaultXtreamUsername,
expectPathname,
expectVisibleContentCardTitle,
expect,
importM3uPlaylistFromNativeDialog,
launchElectronApp,
openPlaylistFavorites,
openPlaylistRecent,
openWorkspaceSection,
openSources,
resetMockServers,
restartElectronApp,
sourceRowByTitle,
switchUnifiedCollectionContent,
switchUnifiedCollectionScope,
test,
playlistSwitcherTitle,
waitForM3uCatalog,
waitForStalkerCatalog,
waitForXtreamWorkspaceReady,
writeTemporaryM3uFile,
} from './electron-test-fixtures';
import {
fetchStalkerCategoryFixture,
fetchXtreamLiveFixture,
fetchXtreamSeriesFixture,
fetchXtreamVodFixture,
getStalkerTitle,
getXtreamTitle,
pickDistinctTitles,
} 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,
}) => {
const playlistTitle = 'm3u-recent-source.m3u';
const playlistSourceTitle = 'm3u-recent-source';
const filePath = writeTemporaryM3uFile(dataDir, playlistTitle, [
{
groupTitle: 'Live',
name: 'Recent Channel One',
url: 'https://streams.example.test/recent-one.m3u8',
},
{
groupTitle: 'Live',
name: 'Recent Channel Two',
url: 'https://streams.example.test/recent-two.m3u8',
},
]);
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromNativeDialog(app, filePath);
await waitForM3uCatalog(app.mainWindow);
await channelItemByTitle(app.mainWindow, 'Recent Channel One')
.first()
.click();
await channelItemByTitle(app.mainWindow, 'Recent Channel Two')
.first()
.click();
await openPlaylistRecent(app.mainWindow);
await expect
.poll(() => visibleLiveTitles(app.mainWindow))
.toEqual(['Recent Channel Two', 'Recent Channel One']);
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
await expect
.poll(() => visibleLiveTitles(app.mainWindow))
.toEqual(['Recent Channel Two', 'Recent Channel One']);
await removeLiveRecentItem(app.mainWindow, 'Recent Channel Two');
await expect
.poll(() => visibleLiveTitles(app.mainWindow))
.toEqual(['Recent Channel One']);
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
app.mainWindow = restarted.mainWindow;
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, playlistSourceTitle)
.first()
.click();
await waitForM3uCatalog(app.mainWindow);
await openPlaylistRecent(app.mainWindow);
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
await expect
.poll(() => visibleLiveTitles(app.mainWindow))
.toEqual(['Recent Channel One']);
} finally {
await closeElectronApp(app);
}
});
test('tracks Xtream live, movie, and series history across playlist and all-playlists scope, persists after restart, and supports clearing', 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 portalTitle = 'Xtream Recent Source';
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
name: portalTitle,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await clickCategoryByNameExact(
app.mainWindow,
liveFixture.categoryName
);
await toggleFavoriteForChannel(app.mainWindow, liveTitle);
await openPlaylistFavorites(app.mainWindow);
await channelItemByTitle(app.mainWindow, liveTitle).first().click();
await closeUnifiedLiveDetail(app.mainWindow);
await app.mainWindow
.getByRole('link', { name: 'Movies', exact: true })
.click();
await clickCategoryByNameExact(
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.
// 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);
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 expect(
channelItemByTitle(app.mainWindow, liveTitle)
).toHaveCount(1);
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
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 expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
app.mainWindow = restarted.mainWindow;
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, portalTitle).first().click();
await waitForXtreamWorkspaceReady(app.mainWindow);
await openPlaylistRecent(app.mainWindow);
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 expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
await switchUnifiedCollectionContent(app.mainWindow, 'Live TV');
await clearRecentItems(app.mainWindow, 'Live TV');
await expect(
channelItemByTitle(app.mainWindow, liveTitle)
).toHaveCount(0);
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await clearRecentItems(app.mainWindow, 'Movies');
await expect(
contentCardByTitle(app.mainWindow, movieTitle)
).toHaveCount(0);
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
await clearRecentItems(app.mainWindow, 'Series');
await expect(
contentCardByTitle(app.mainWindow, seriesTitle)
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('opens Xtream recent movies and series inline from recent without switching the playlist or showing the sidebar', 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 expect(playlistSwitcherTitle(app.mainWindow)).toContainText(
portalTitle
);
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await contentCardByTitle(app.mainWindow, movieTitle)
.first()
.click();
await expectInlineCollectionDetail(app.mainWindow, {
pathname: /\/workspace\/global-recent$/,
title: movieTitle,
playlistTitle: portalTitle,
});
await goBackFromDetail(app.mainWindow);
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
await contentCardByTitle(app.mainWindow, seriesTitle)
.first()
.click();
await expectInlineCollectionDetail(app.mainWindow, {
pathname: /\/workspace\/global-recent$/,
title: seriesTitle,
playlistTitle: portalTitle,
});
await goBackFromDetail(app.mainWindow);
await expectPathname(app.mainWindow, /\/workspace\/global-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,
}) => {
await resetMockServers(request, ['stalker']);
const liveFixture = await fetchStalkerCategoryFixture(request, 'itv');
const vodFixture = await fetchStalkerCategoryFixture(request, 'vod');
const seriesFixture = await fetchStalkerCategoryFixture(
request,
'series'
);
const [liveTitle] = pickDistinctTitles(
liveFixture.items,
getStalkerTitle
);
const [movieTitle] = pickDistinctTitles(
vodFixture.items,
getStalkerTitle
);
const [seriesTitle] = pickDistinctTitles(
seriesFixture.items,
getStalkerTitle
);
const portalTitle = 'Stalker Recent Source';
const app = await launchElectronApp(dataDir);
try {
await addStalkerPortal(app.mainWindow, {
name: portalTitle,
});
await waitForStalkerCatalog(app.mainWindow);
await app.mainWindow
.getByRole('link', { name: 'Live TV', exact: true })
.click();
await clickCategoryById(app.mainWindow, liveFixture.categoryId);
await toggleFavoriteForChannel(app.mainWindow, liveTitle);
await openPlaylistFavorites(app.mainWindow);
await channelItemByTitle(app.mainWindow, liveTitle).first().click();
await closeUnifiedLiveDetail(app.mainWindow);
await app.mainWindow
.getByRole('link', { name: 'Movies', exact: true })
.click();
await clickCategoryById(app.mainWindow, vodFixture.categoryId);
await clickGridListCardByTitle(app.mainWindow, movieTitle);
await playCurrentDetail(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 playFirstSeriesEpisode(app.mainWindow);
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 switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expect(
contentCardByTitle(app.mainWindow, seriesTitle)
).toHaveCount(1);
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 switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expect(
contentCardByTitle(app.mainWindow, seriesTitle)
).toHaveCount(1);
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
app.mainWindow = restarted.mainWindow;
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, portalTitle).first().click();
await waitForStalkerCatalog(app.mainWindow);
await openPlaylistRecent(app.mainWindow);
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 switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expect(
contentCardByTitle(app.mainWindow, seriesTitle)
).toHaveCount(1);
} finally {
await closeElectronApp(app);
}
});
test('opens Stalker recent movies and series inline from recent without showing the sidebar', 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 portalTitle = 'Stalker Recent Detail Source';
const app = await launchElectronApp(dataDir);
try {
await addStalkerPortal(app.mainWindow, {
name: portalTitle,
});
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 playCurrentDetail(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 playFirstSeriesEpisode(app.mainWindow);
await openPlaylistRecent(app.mainWindow);
await expect(playlistSwitcherTitle(app.mainWindow)).toContainText(
portalTitle
);
await switchUnifiedCollectionContent(app.mainWindow, 'Movies');
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await contentCardByTitle(app.mainWindow, movieTitle)
.first()
.click();
await expectInlineCollectionDetail(app.mainWindow, {
pathname: /\/workspace\/global-recent$/,
title: movieTitle,
playlistTitle: portalTitle,
});
await goBackFromDetail(app.mainWindow);
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
await contentCardByTitle(app.mainWindow, seriesTitle)
.first()
.click();
await expectInlineCollectionDetail(app.mainWindow, {
pathname: /\/workspace\/global-recent$/,
title: seriesTitle,
playlistTitle: portalTitle,
});
await goBackFromDetail(app.mainWindow);
await expectPathname(app.mainWindow, /\/workspace\/global-recent$/);
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
} finally {
await closeElectronApp(app);
}
});
});
const xtreamCredentials = {
username: defaultXtreamUsername,
password: defaultXtreamPassword,
};
async function clearRecentItems(page: Page, typeLabel: string): Promise<void> {
await page
.getByRole('button', { name: `Clear recently viewed ${typeLabel}` })
.click();
await page.getByRole('button', { name: 'Yes' }).click();
}
async function closeUnifiedLiveDetail(page: Page): Promise<void> {
const closeButton = page.locator('.player-toolbar .close-btn').first();
await expect(closeButton).toBeVisible({ timeout: 20000 });
await closeButton.click();
}
async function goBackFromDetail(page: Page): Promise<void> {
const backButton = page
.locator('app-content-hero .hero__back-button')
.first();
await expect(backButton).toBeVisible({ timeout: 20000 });
try {
await backButton.click({ timeout: 5000 });
} catch {
await backButton.evaluate((button: HTMLButtonElement) =>
button.click()
);
}
}
async function expectInlineCollectionDetail(
page: Page,
params: {
pathname: RegExp;
title: string;
playlistTitle: string;
}
): Promise<void> {
await expectPathname(page, params.pathname);
await expect(playlistSwitcherTitle(page)).toContainText(
params.playlistTitle
);
await expect(page.locator('app-workspace-context-panel')).toHaveCount(0);
await expect(page.locator('app-content-hero')).toContainText(params.title);
await expect(
page.locator('app-content-hero .hero__back-button').first()
).toBeVisible({ timeout: 20000 });
}
async function playCurrentDetail(page: Page): Promise<void> {
const playButton = page.locator('button.play-btn').first();
await expect(playButton).toBeVisible({ timeout: 20000 });
await playButton.click();
}
async function playFirstSeriesEpisode(page: Page): Promise<void> {
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 removeLiveRecentItem(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();
}
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();
}
async function visibleLiveTitles(page: Page): Promise<string[]> {
return page
.locator('[data-test-id="channel-item"] .channel-name')
.allInnerTexts()
.then((titles) => titles.map((title) => title.trim()));
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,238 @@
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 = `<?xml version="1.0" encoding="UTF-8"?>
<tv>
<channel id="news-1">
<display-name>News One</display-name>
</channel>
<programme start="20260328070000 +0000" stop="20260328080000 +0000" channel="news-1">
<title>Morning Bulletin</title>
<desc>Daily morning news.</desc>
</programme>
</tv>
`;
test.describe('Electron Settings', () => {
test('persists changed desktop settings across app restart', async ({
dataDir,
}) => {
const epgServer = await createMutableTextServer(epgFixtureXml, {
contentType: 'application/xml; charset=utf-8',
resourcePath: '/settings-guide.xml',
});
const firstLaunch = await launchElectronApp(dataDir);
try {
await openSettings(firstLaunch.mainWindow);
await selectSettingsOption(
firstLaunch.mainWindow,
'select-language',
'de'
);
await firstLaunch.mainWindow
.locator('[data-test-id="DARK_THEME"]')
.click();
await selectSettingsOption(
firstLaunch.mainWindow,
'select-video-player',
'html5'
);
await selectSettingsOption(
firstLaunch.mainWindow,
'select-stream-format',
'ts'
);
await firstLaunch.mainWindow
.locator(
'mat-checkbox[formcontrolname="showExternalPlaybackBar"] input[type="checkbox"]'
)
.uncheck();
await enableRemoteControl(firstLaunch.mainWindow, 8877);
await firstLaunch.mainWindow
.getByRole('button', { name: 'Add EPG source' })
.click();
await firstLaunch.mainWindow
.locator('.epg-source-row input')
.first()
.fill(epgServer.resourceUrl);
await saveSettings(firstLaunch.mainWindow);
} finally {
await closeElectronApp(firstLaunch);
}
const secondLaunch = await launchElectronApp(dataDir);
try {
await openSettings(secondLaunch.mainWindow);
await expect(
secondLaunch.mainWindow.getByTestId('select-language')
).toContainText('Deutsch');
await expect(
secondLaunch.mainWindow.locator('[data-test-id="DARK_THEME"]')
).toHaveAttribute('aria-checked', 'true');
await expect(
secondLaunch.mainWindow.getByTestId('select-video-player')
).toContainText(/HTML5/i);
await expect(
secondLaunch.mainWindow.getByTestId('select-stream-format')
).toContainText('ts');
await expect(
secondLaunch.mainWindow.locator(
'mat-checkbox[formcontrolname="showExternalPlaybackBar"] input[type="checkbox"]'
)
).not.toBeChecked();
await expect(
secondLaunch.mainWindow.locator(
'mat-checkbox[formcontrolname="remoteControl"] input[type="checkbox"]'
)
).toBeChecked();
await expect(
secondLaunch.mainWindow.locator('#remoteControlPort')
).toHaveValue('8877');
await expect(
secondLaunch.mainWindow.locator('.epg-source-row input').first()
).toHaveValue(epgServer.resourceUrl);
} finally {
await closeElectronApp(secondLaunch);
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(
page: Page,
selectTestId: string,
optionTestId: string
): Promise<void> {
await page.getByTestId(selectTestId).click();
await page.getByTestId(optionTestId).click();
}
@@ -0,0 +1,81 @@
import {
closeElectronApp,
expect,
launchElectronApp,
test,
} from './electron-test-fixtures';
test.describe('Electron App Smoke Test', () => {
test('app should start and display the dashboard', async ({ dataDir }) => {
const app = await launchElectronApp(dataDir);
try {
await expect
.poll(async () => app.mainWindow.title())
.toContain('IPTVnator');
await expect(
app.mainWindow.getByRole('link', {
name: 'Dashboard',
exact: true,
})
).toBeVisible();
await expect(
app.mainWindow.getByRole('link', { name: 'Open settings' })
).toBeVisible();
} finally {
await closeElectronApp(app);
}
});
test('app should expose the expected main window properties', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
const isVisible = await app.electronApp.evaluate(
async ({ BrowserWindow }) => {
const mainWindow = BrowserWindow.getAllWindows()[0];
return mainWindow ? mainWindow.isVisible() : false;
}
);
expect(isVisible).toBe(true);
const bounds = await app.electronApp.evaluate(
async ({ BrowserWindow }) => {
const mainWindow = BrowserWindow.getAllWindows()[0];
return mainWindow ? mainWindow.getBounds() : null;
}
);
expect(bounds).not.toBeNull();
expect(bounds?.width).toBeGreaterThan(800);
expect(bounds?.height).toBeGreaterThan(600);
} finally {
await closeElectronApp(app);
}
});
test('app should render workspace content', async ({ dataDir }) => {
const app = await launchElectronApp(dataDir);
try {
await expect(
app.mainWindow.getByRole('button', { name: 'Add playlist' })
).toBeVisible();
const modifier =
process.platform === 'darwin' ? 'Meta' : 'Control';
await app.mainWindow.locator('body').focus();
await app.mainWindow.keyboard.press(`${modifier}+K`);
await expect(
app.mainWindow.locator(
'mat-dialog-container app-workspace-command-palette'
)
).toBeVisible();
} finally {
await closeElectronApp(app);
}
});
});
@@ -0,0 +1,505 @@
import {
addStalkerPortal,
addXtreamPortal,
closeElectronApp,
createMutableTextServer,
deleteSource,
dragSourceBefore,
expect,
expectPlaylistUpdatedToast,
expectSourceDialogValues,
getVisibleSourceTitles,
importM3uPlaylistFromNativeDialog,
importM3uPlaylistFromUrl,
launchElectronApp,
openSources,
openSourceEditor,
refreshSource,
resetMockServers,
restartElectronApp,
saveSourceDialog,
selectSourceSort,
selectSourceTypeFilter,
sourceRowByTitle,
test,
updateSourceDialog,
waitForM3uCatalog,
waitForPortalDebugEvent,
waitForXtreamCatalog,
waitForSourceRowIdle,
writeTemporaryM3uFile,
} from './electron-test-fixtures';
const localSourceFileName = 'alpha-local-source.m3u';
const localSourceDisplayName = 'alpha-local-source';
const urlSourceFileName = 'omega-url-source.m3u';
const xtreamSourceName = 'Charlie Xtream Portal';
const stalkerSourceName = 'Bravo Stalker Portal';
const editableLocalSourceDisplayName = 'editable-local-source';
const deletableLocalSourceDisplayName = 'deletable-local-source';
const refreshLocalSourceDisplayName = 'refresh-local-source';
test.describe('Electron Sources View', () => {
test('filters and sorts sources, including persisted custom order', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream', 'stalker']);
const localFilePath = writeTemporaryM3uFile(
dataDir,
localSourceFileName,
[
{
groupTitle: 'News',
name: 'Alpha Local News',
url: 'https://streams.example.test/local-alpha.m3u8',
},
]
);
const urlServer = await createMutableTextServer(
`#EXTM3U
#EXTINF:-1 group-title="Sports",Omega URL Sports
https://streams.example.test/url-omega.m3u8
`,
{
contentType: 'application/vnd.apple.mpegurl',
resourcePath: `/${urlSourceFileName}`,
}
);
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromNativeDialog(app, localFilePath);
await importM3uPlaylistFromUrl(
app.mainWindow,
urlServer.resourceUrl
);
await addXtreamPortal(app.mainWindow, {
name: xtreamSourceName,
});
await addStalkerPortal(app.mainWindow, {
name: stalkerSourceName,
});
await openSources(app.mainWindow);
await expect
.poll(() => getVisibleSourceTitles(app.mainWindow))
.toHaveLength(4);
await selectSourceTypeFilter(app.mainWindow, 'M3U');
await expect
.poll(() => getVisibleSourceTitles(app.mainWindow))
.toEqual([urlSourceFileName, localSourceDisplayName]);
await selectSourceTypeFilter(app.mainWindow, 'Xtream');
await expect
.poll(() => getVisibleSourceTitles(app.mainWindow))
.toEqual([xtreamSourceName]);
await selectSourceTypeFilter(app.mainWindow, 'Stalker');
await expect
.poll(() => getVisibleSourceTitles(app.mainWindow))
.toEqual([stalkerSourceName]);
await selectSourceTypeFilter(app.mainWindow, 'All');
await selectSourceSort(app.mainWindow, 'Date added (Oldest first)');
await expect
.poll(() => getVisibleSourceTitles(app.mainWindow))
.toEqual([
localSourceDisplayName,
urlSourceFileName,
xtreamSourceName,
stalkerSourceName,
]);
await selectSourceSort(app.mainWindow, 'Name (A-Z)');
await expect
.poll(() => getVisibleSourceTitles(app.mainWindow))
.toEqual([
localSourceDisplayName,
stalkerSourceName,
xtreamSourceName,
urlSourceFileName,
]);
await selectSourceSort(app.mainWindow, 'Name (Z-A)');
await expect
.poll(() => getVisibleSourceTitles(app.mainWindow))
.toEqual([
urlSourceFileName,
xtreamSourceName,
stalkerSourceName,
localSourceDisplayName,
]);
await selectSourceSort(app.mainWindow, 'Date added (Newest first)');
await expect
.poll(() => getVisibleSourceTitles(app.mainWindow))
.toEqual([
stalkerSourceName,
xtreamSourceName,
urlSourceFileName,
localSourceDisplayName,
]);
await selectSourceSort(app.mainWindow, 'Custom order');
await dragSourceBefore(
app.mainWindow,
xtreamSourceName,
localSourceDisplayName
);
await expect
.poll(() => getVisibleSourceTitles(app.mainWindow))
.toEqual([
xtreamSourceName,
localSourceDisplayName,
urlSourceFileName,
stalkerSourceName,
]);
await app.mainWindow
.getByRole('link', { name: 'Dashboard', exact: true })
.click();
await openSources(app.mainWindow);
await expect
.poll(() => getVisibleSourceTitles(app.mainWindow))
.toEqual([
xtreamSourceName,
localSourceDisplayName,
urlSourceFileName,
stalkerSourceName,
]);
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
app.mainWindow = restarted.mainWindow;
await openSources(app.mainWindow);
await expect
.poll(() => getVisibleSourceTitles(app.mainWindow))
.toEqual([
xtreamSourceName,
localSourceDisplayName,
urlSourceFileName,
stalkerSourceName,
]);
} finally {
await closeElectronApp(app);
await urlServer.close();
}
});
test('edits M3U, Xtream, and Stalker source details and keeps them after restart', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream', 'stalker']);
const localFilePath = writeTemporaryM3uFile(
dataDir,
'editable-local-source.m3u',
[
{
groupTitle: 'Movies',
name: 'Editable Local Channel',
url: 'https://streams.example.test/editable-local.m3u8',
},
]
);
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromNativeDialog(app, localFilePath);
await addXtreamPortal(app.mainWindow, {
name: 'Editable Xtream Source',
});
await addStalkerPortal(app.mainWindow, {
name: 'Editable Stalker Source',
});
await openSources(app.mainWindow);
let dialog = await openSourceEditor(
app.mainWindow,
editableLocalSourceDisplayName
);
await updateSourceDialog(dialog, {
autoRefresh: true,
title: 'Edited Local Source',
userAgent: 'Electron E2E Local/1.0',
});
await saveSourceDialog(app.mainWindow, dialog);
dialog = await openSourceEditor(
app.mainWindow,
'Editable Xtream Source'
);
await updateSourceDialog(dialog, {
password: 'pass1',
serverUrl: 'http://127.0.0.1:3211',
title: 'Edited Xtream Source',
username: 'user1',
});
await saveSourceDialog(app.mainWindow, dialog);
dialog = await openSourceEditor(
app.mainWindow,
'Editable Stalker Source'
);
await updateSourceDialog(dialog, {
macAddress: '00:1A:79:00:00:99',
portalUrl: 'http://127.0.0.1:3210/portal.php',
title: 'Edited Stalker Source',
});
await saveSourceDialog(app.mainWindow, dialog);
dialog = await openSourceEditor(
app.mainWindow,
'Edited Local Source'
);
await expectSourceDialogValues(dialog, {
autoRefresh: true,
title: 'Edited Local Source',
userAgent: 'Electron E2E Local/1.0',
});
await dialog
.getByRole('button', { name: 'Close', exact: true })
.click();
dialog = await openSourceEditor(
app.mainWindow,
'Edited Xtream Source'
);
await expectSourceDialogValues(dialog, {
password: 'pass1',
serverUrl: 'http://127.0.0.1:3211',
title: 'Edited Xtream Source',
username: 'user1',
});
await dialog
.getByRole('button', { name: 'Close', exact: true })
.click();
dialog = await openSourceEditor(
app.mainWindow,
'Edited Stalker Source'
);
await expectSourceDialogValues(dialog, {
macAddress: '00:1A:79:00:00:99',
portalUrl: 'http://127.0.0.1:3210/portal.php',
title: 'Edited Stalker Source',
});
await dialog
.getByRole('button', { name: 'Close', exact: true })
.click();
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
app.mainWindow = restarted.mainWindow;
await openSources(app.mainWindow);
dialog = await openSourceEditor(
app.mainWindow,
'Edited Local Source'
);
await expectSourceDialogValues(dialog, {
autoRefresh: true,
title: 'Edited Local Source',
userAgent: 'Electron E2E Local/1.0',
});
await dialog
.getByRole('button', { name: 'Close', exact: true })
.click();
dialog = await openSourceEditor(
app.mainWindow,
'Edited Xtream Source'
);
await expectSourceDialogValues(dialog, {
password: 'pass1',
serverUrl: 'http://127.0.0.1:3211',
title: 'Edited Xtream Source',
username: 'user1',
});
await dialog
.getByRole('button', { name: 'Close', exact: true })
.click();
dialog = await openSourceEditor(
app.mainWindow,
'Edited Stalker Source'
);
await expectSourceDialogValues(dialog, {
macAddress: '00:1A:79:00:00:99',
portalUrl: 'http://127.0.0.1:3210/portal.php',
title: 'Edited Stalker Source',
});
} finally {
await closeElectronApp(app);
}
});
test('deletes M3U, Stalker, and Xtream sources from the sources view', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream', 'stalker']);
const localFilePath = writeTemporaryM3uFile(
dataDir,
'deletable-local-source.m3u',
[
{
groupTitle: 'Live',
name: 'Delete Me Local',
url: 'https://streams.example.test/delete-local.m3u8',
},
]
);
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromNativeDialog(app, localFilePath);
await addXtreamPortal(app.mainWindow, {
name: 'Delete Me Xtream',
});
await addStalkerPortal(app.mainWindow, {
name: 'Delete Me Stalker',
});
await openSources(app.mainWindow);
await deleteSource(app.mainWindow, deletableLocalSourceDisplayName);
await expect(
sourceRowByTitle(
app.mainWindow,
deletableLocalSourceDisplayName
)
).toHaveCount(0, {
timeout: 20000,
});
await deleteSource(app.mainWindow, 'Delete Me Stalker');
await expect(
sourceRowByTitle(app.mainWindow, 'Delete Me Stalker')
).toHaveCount(0, {
timeout: 20000,
});
await deleteSource(app.mainWindow, 'Delete Me Xtream');
await expect(
sourceRowByTitle(app.mainWindow, 'Delete Me Xtream')
).toHaveCount(0, {
timeout: 60000,
});
} finally {
await closeElectronApp(app);
}
});
test('refreshes local-file M3U, URL M3U, and Xtream sources from the sources view', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const localFilePath = writeTemporaryM3uFile(
dataDir,
'refresh-local-source.m3u',
[
{
groupTitle: 'News',
name: 'Original Local Channel',
url: 'https://streams.example.test/original-local.m3u8',
},
]
);
const urlServer = await createMutableTextServer(
`#EXTM3U
#EXTINF:-1 group-title="News",Original URL Channel
https://streams.example.test/original-url.m3u8
`,
{
contentType: 'application/vnd.apple.mpegurl',
resourcePath: '/refresh-url-source.m3u',
}
);
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromNativeDialog(app, localFilePath);
await importM3uPlaylistFromUrl(
app.mainWindow,
urlServer.resourceUrl
);
await addXtreamPortal(app.mainWindow, {
name: 'Refresh Xtream Source',
});
await openSources(app.mainWindow);
writeTemporaryM3uFile(dataDir, 'refresh-local-source.m3u', [
{
groupTitle: 'News',
name: 'Refreshed Local Channel',
url: 'https://streams.example.test/refreshed-local.m3u8',
},
]);
await refreshSource(app.mainWindow, refreshLocalSourceDisplayName);
await expectPlaylistUpdatedToast(app.mainWindow);
await sourceRowByTitle(
app.mainWindow,
refreshLocalSourceDisplayName
)
.first()
.click();
await waitForM3uCatalog(app.mainWindow);
await expect(
app.mainWindow.getByTestId('channel-item').filter({
hasText: 'Refreshed Local Channel',
})
).toHaveCount(1);
await openSources(app.mainWindow);
urlServer.setBody(
`#EXTM3U
#EXTINF:-1 group-title="News",Refreshed URL Channel
https://streams.example.test/refreshed-url.m3u8
`
);
await refreshSource(app.mainWindow, 'refresh-url-source.m3u');
await expectPlaylistUpdatedToast(app.mainWindow);
await waitForSourceRowIdle(
app.mainWindow,
'refresh-url-source.m3u'
);
await sourceRowByTitle(app.mainWindow, 'refresh-url-source.m3u')
.first()
.click();
await waitForM3uCatalog(app.mainWindow);
await expect(
app.mainWindow.getByTestId('channel-item').filter({
hasText: 'Refreshed URL Channel',
})
).toHaveCount(1);
await openSources(app.mainWindow);
await refreshSource(app.mainWindow, 'Refresh Xtream Source', {
confirm: true,
});
await waitForXtreamCatalog(app.mainWindow);
await waitForPortalDebugEvent(app.mainWindow, {
operation: 'get_live_categories',
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();
}
});
});
@@ -0,0 +1,229 @@
import {
addXtreamPortal,
channelItemByTitle,
clickCategoryByNameExact,
closeElectronApp,
expect,
launchElectronApp,
openWorkspaceSection,
resetMockServers,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import {
fetchXtreamEpgFixture,
XtreamEpgFixture,
XtreamNormalizedEpgListing,
} from './portal-mock-fixtures';
const epgPortalName = 'Xtream EPG Fixture';
const epgCredentials = {
username: 'epg',
password: 'epg',
};
for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
test(`renders Xtream EPG previews and selected-channel schedule in ${timeZone}`, async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const fixture = await fetchXtreamEpgFixture(request, epgCredentials);
const currentProgram = fixture.shortEpg[0];
if (!currentProgram) {
throw new Error('Expected the Xtream EPG fixture to include a current program.');
}
const app = await launchElectronApp(dataDir, {
env: { TZ: timeZone },
});
try {
await addXtreamPortal(app.mainWindow, {
name: `${epgPortalName} ${timeZone}`,
username: epgCredentials.username,
password: epgCredentials.password,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
const channelRow = channelItemByTitle(
app.mainWindow,
fixture.stream.name ?? ''
).first();
await expect(channelRow).toBeVisible({ timeout: 20000 });
await expect
.poll(async () =>
((await channelRow.locator('.epg-title').textContent()) ?? '').trim()
)
.toBe(currentProgram.title);
await expect
.poll(async () => {
return (await channelRow.locator('.epg-time').allInnerTexts()).map(
(value) => value.trim()
);
})
.toEqual([
formatTimeInZone(currentProgram.startTimestamp, timeZone),
formatTimeInZone(currentProgram.stopTimestamp, timeZone),
]);
await expect
.poll(async () => getProgressWidthPercent(channelRow))
.toBeGreaterThan(0);
await channelRow.click();
await expect(app.mainWindow.locator('app-epg-list')).toBeVisible({
timeout: 20000,
});
const currentDateKey = formatDateKeyInZone(Date.now(), timeZone);
const currentDayTitles = visibleTitlesForCurrentDate(
fixture.fullEpg,
currentDateKey,
timeZone
);
await expect.poll(() => visibleProgramTitles(app.mainWindow)).toEqual(
currentDayTitles
);
const currentProgramRow = app.mainWindow
.locator('app-epg-list .program-item.current-program')
.first();
await expect(currentProgramRow).toBeVisible();
await expect(currentProgramRow.locator('.program-title')).toHaveText(
currentProgram.title
);
await expect(currentProgramRow.locator('.time')).toHaveText(
`${formatTimeInZone(
currentProgram.startTimestamp,
timeZone
)} - ${formatTimeInZone(currentProgram.stopTimestamp, timeZone)}`
);
const firstFutureDateKey = getFirstFutureDateKey(
fixture.fullEpg,
currentDateKey,
timeZone
);
expect(firstFutureDateKey).not.toBeNull();
const futureDayTitles = titlesForDate(
fixture.fullEpg,
firstFutureDateKey!,
timeZone
);
const nextDayClicks = dayDifference(currentDateKey, firstFutureDateKey!);
for (let index = 0; index < nextDayClicks; index += 1) {
await app.mainWindow.locator('app-epg-list .next-day').click();
}
await expect.poll(() => visibleProgramTitles(app.mainWindow)).toEqual(
futureDayTitles
);
} finally {
await closeElectronApp(app);
}
});
}
function formatTimeInZone(
timestampSeconds: number,
timeZone: string
): string {
return new Intl.DateTimeFormat('en-US', {
timeZone,
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(new Date(timestampSeconds * 1000));
}
function formatDateKeyInZone(
timestampMilliseconds: number,
timeZone: string
): string {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(new Date(timestampMilliseconds));
const year = parts.find((part) => part.type === 'year')?.value;
const month = parts.find((part) => part.type === 'month')?.value;
const day = parts.find((part) => part.type === 'day')?.value;
if (!year || !month || !day) {
throw new Error(`Failed to format a date key for timezone ${timeZone}.`);
}
return `${year}-${month}-${day}`;
}
function titlesForDate(
listings: XtreamNormalizedEpgListing[],
dateKey: string,
timeZone: string
): string[] {
return listings
.filter(
(listing) =>
formatDateKeyInZone(listing.startTimestamp * 1000, timeZone) ===
dateKey
)
.map((listing) => listing.title);
}
function visibleTitlesForCurrentDate(
listings: XtreamNormalizedEpgListing[],
currentDateKey: string,
timeZone: string
): string[] {
const nowSeconds = Math.floor(Date.now() / 1000);
return listings
.filter(
(listing) =>
formatDateKeyInZone(listing.startTimestamp * 1000, timeZone) ===
currentDateKey && listing.stopTimestamp >= nowSeconds
)
.map((listing) => listing.title);
}
function getFirstFutureDateKey(
listings: XtreamNormalizedEpgListing[],
currentDateKey: string,
timeZone: string
): string | null {
const uniqueDateKeys = Array.from(
new Set(
listings.map((listing) =>
formatDateKeyInZone(listing.startTimestamp * 1000, timeZone)
)
)
).sort();
return uniqueDateKeys.find((dateKey) => dateKey > currentDateKey) ?? null;
}
function dayDifference(fromDateKey: string, toDateKey: string): number {
const from = Date.parse(`${fromDateKey}T00:00:00.000Z`);
const to = Date.parse(`${toDateKey}T00:00:00.000Z`);
return Math.round((to - from) / (24 * 60 * 60 * 1000));
}
async function visibleProgramTitles(page: Parameters<typeof channelItemByTitle>[0]) {
return page
.locator('app-epg-list .program-title')
.allInnerTexts()
.then((titles) => titles.map((title) => title.trim()).filter(Boolean));
}
async function getProgressWidthPercent(row: ReturnType<typeof channelItemByTitle>) {
const style = await row.locator('.epg-progress-fill').getAttribute('style');
const width = style?.match(/width:\s*([\d.]+)%/)?.[1] ?? '0';
return Number.parseFloat(width);
}
@@ -0,0 +1,320 @@
import {
addXtreamPortal,
closeElectronApp,
expect,
expectPortalDebugSuccess,
expectRendererFramesAdvance,
launchElectronApp,
resetMockServers,
test,
waitForDbOperationEvent,
waitForXtreamCatalog,
xtreamMockServer,
} from './electron-test-fixtures';
const stressPortalName = 'Stress Xtream Portal';
const stressXtreamUsername = 'stress';
const stressXtreamPassword = 'stress';
const dbWorkerBatchDelayMs = '20';
function playlistRowByName(name: string) {
return `app-playlist-item:has-text("${name}")`;
}
async function openSources(page: Parameters<typeof addXtreamPortal>[0]) {
await page.getByRole('link', { name: 'Sources', exact: true }).click();
await page.waitForURL(/\/workspace\/sources$/);
}
function xtreamBlockedStateTitle(page: Parameters<typeof addXtreamPortal>[0]) {
return page.locator('.xtream-content-gate .error-view__title');
}
test.describe('Electron Xtream Responsiveness', () => {
test.describe.configure({ mode: 'serial' });
test.setTimeout(120000);
test('keeps the UI responsive during large Xtream imports', 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'
);
await expect(overlay).toBeVisible({ timeout: 15000 });
await expect(
overlay.getByRole('button', {
name: /stop sync|cancel import/i,
})
).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 expect
.poll(
async () =>
(await app.mainWindow.evaluate(
() =>
(window.__dbOperationEvents ?? []).filter(
(event) =>
event.operation === 'save-content' &&
event.status === 'progress'
).length
)) > 2,
{ timeout: 30000 }
)
.toBeTruthy();
await expectRendererFramesAdvance(app.mainWindow, {
minimumDelta: 5,
sampleMs: 400,
});
await expect
.poll(
async () =>
(await overlay.locator('p').allInnerTexts()).some(
(text) =>
/(Live TV|Movies|Series).*?\d+\s*\/\s*\d+/.test(
text
)
),
{ timeout: 30000 }
)
.toBeTruthy();
await waitForXtreamCatalog(app.mainWindow);
await expectPortalDebugSuccess(app.mainWindow, 'xtream');
} finally {
await closeElectronApp(app);
}
});
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: /stop sync|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,
}) => {
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,
});
await waitForXtreamCatalog(app.mainWindow);
await openSources(app.mainWindow);
const playlistRow = app.mainWindow.locator(
playlistRowByName(stressPortalName)
);
await expect(playlistRow).toBeVisible({ timeout: 20000 });
await playlistRow.locator('.delete-btn').click();
const confirmDialog = app.mainWindow.locator(
'mat-dialog-container'
);
await expect(confirmDialog).toBeVisible();
await confirmDialog
.getByRole('button', { name: 'Yes', exact: true })
.click();
await waitForDbOperationEvent(app.mainWindow, {
operation: 'delete-playlist',
status: 'started',
timeoutMs: 20000,
});
await expect(
playlistRow.locator('.busy-state__message')
).toBeVisible({ timeout: 20000 });
await expect(playlistRow.locator('.cancel-btn')).toBeVisible();
await waitForDbOperationEvent(app.mainWindow, {
operation: 'delete-playlist',
predicate: (event) =>
event.status === 'progress' &&
(event.current ?? 0) > 0 &&
(event.total ?? 0) > 0,
timeoutMs: 30000,
});
await expectRendererFramesAdvance(app.mainWindow, {
minimumDelta: 5,
sampleMs: 400,
});
await expect(playlistRow).toHaveCount(0, { timeout: 60000 });
await expect(
app.mainWindow.locator(playlistRowByName(stressPortalName))
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
});
@@ -0,0 +1,75 @@
import {
addXtreamPortal,
clickCategoryByNameExact,
clickGridListCardByTitle,
closeElectronApp,
expect,
launchElectronApp,
resetMockServers,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import {
fetchXtreamVodFixture,
getXtreamTitle,
pickDistinctTitles,
} from './portal-mock-fixtures';
const emptyMetadataCredentials = {
password: 'emptyvod',
username: 'emptyvod',
};
test.describe('Xtream VOD Details', () => {
test('shows a curated fallback when the portal returns empty VOD metadata', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const vodFixture = await fetchXtreamVodFixture(
request,
emptyMetadataCredentials
);
const [movieTitle] = pickDistinctTitles(vodFixture.items, getXtreamTitle);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
...emptyMetadataCredentials,
name: 'Xtream Empty Metadata',
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await app.mainWindow
.getByRole('link', { name: 'Movies', exact: true })
.click();
await clickCategoryByNameExact(app.mainWindow, vodFixture.categoryName);
await clickGridListCardByTitle(app.mainWindow, movieTitle);
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/
);
await expect(app.mainWindow.locator('app-content-hero')).toContainText(
movieTitle
);
await expect(
app.mainWindow.locator('[data-testid="xtream-vod-fallback-status"]')
).toContainText('Portal metadata unavailable');
await expect(
app.mainWindow.locator('[data-testid="xtream-vod-fallback"]')
).toContainText(
'Extended metadata was not provided by this portal.'
);
await expect(app.mainWindow.locator('button.play-btn')).toHaveCount(0);
await expect(app.mainWindow.locator('button.favorite-btn')).toHaveCount(
0
);
await expect(app.mainWindow.locator('button.download-btn')).toHaveCount(
0
);
} finally {
await closeElectronApp(app);
}
});
});
+21
View File
@@ -0,0 +1,21 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowJs": true,
"outDir": "../../dist/out-tsc",
"sourceMap": false,
"module": "commonjs",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"include": [
"**/*.ts",
"**/*.js",
"playwright.config.ts",
"src/**/*.d.ts",
"src/**/*.e2e.ts"
]
}