Proyecto App Linux
This commit is contained in:
@@ -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: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user