mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
test(e2e): enhance Xtream category management and sorting with unique title handling
This commit is contained in:
1 parent
654e63de27
commit
3a66bf21a3
9 files changed
+501
-108
No files matched your search
@@ -4,6 +4,8 @@ import {
|
||||
addXtreamPortal,
|
||||
clickCategoryByNameExact,
|
||||
closeElectronApp,
|
||||
defaultXtreamPassword,
|
||||
defaultXtreamUsername,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
openWorkspaceSection,
|
||||
@@ -26,19 +28,28 @@ test.describe('Electron Catalog Sorting', () => {
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const fixture = await fetchXtreamLiveFixture(request);
|
||||
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 = [...expectedServerOrder]
|
||||
const expectedAscending = fixture.items
|
||||
.map((item) => getXtreamTitle(item))
|
||||
.filter((title, index, titles) => titles.indexOf(title) === index)
|
||||
.sort(collator.compare)
|
||||
.slice(0, 5);
|
||||
const expectedDescending = [...expectedAscending].reverse();
|
||||
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);
|
||||
@@ -64,19 +75,32 @@ test.describe('Electron Catalog Sorting', () => {
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const vodFixture = await fetchXtreamVodFixture(request);
|
||||
const seriesFixture = await fetchXtreamSeriesFixture(request);
|
||||
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));
|
||||
const expectedVodDateAsc = [...expectedVodDateDesc].reverse();
|
||||
.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);
|
||||
const expectedVodNameDesc = [...expectedVodNameAsc].reverse();
|
||||
.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);
|
||||
.sort(collator.compare)
|
||||
.slice(0, visibleComparisonSize);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
@@ -147,6 +171,11 @@ const collator = new Intl.Collator(undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
const visibleComparisonSize = 8;
|
||||
const xtreamCredentials = {
|
||||
username: defaultXtreamUsername,
|
||||
password: defaultXtreamPassword,
|
||||
};
|
||||
|
||||
async function setLiveSortMode(
|
||||
page: Page,
|
||||
@@ -173,7 +202,10 @@ async function expectVisibleChannelTitles(
|
||||
expectedTitles: string[]
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(() => visibleChannelTitles(page))
|
||||
.poll(async () => {
|
||||
const titles = await visibleChannelTitles(page);
|
||||
return titles.slice(0, expectedTitles.length);
|
||||
})
|
||||
.toEqual(expectedTitles);
|
||||
}
|
||||
|
||||
@@ -182,7 +214,10 @@ async function expectVisibleGridTitles(
|
||||
expectedTitles: string[]
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(() => visibleGridTitles(page))
|
||||
.poll(async () => {
|
||||
const titles = await visibleGridTitles(page);
|
||||
return titles.slice(0, expectedTitles.length);
|
||||
})
|
||||
.toEqual(expectedTitles);
|
||||
}
|
||||
|
||||
@@ -190,12 +225,29 @@ async function visibleChannelTitles(page: Page): Promise<string[]> {
|
||||
return page
|
||||
.locator('[data-test-id="channel-item"] .channel-name')
|
||||
.allInnerTexts()
|
||||
.then((titles: string[]) => titles.map((title) => title.trim()));
|
||||
.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[]) => titles.map((title) => title.trim()));
|
||||
.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;
|
||||
});
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import { Locator, Page } from '@playwright/test';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
clickCategoryByNameExact,
|
||||
clickCategoryById,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
openSources,
|
||||
openWorkspaceSection,
|
||||
refreshSource,
|
||||
resetMockServers,
|
||||
sourceRowByTitle,
|
||||
test,
|
||||
waitForXtreamCatalog,
|
||||
waitForSourceRowIdle,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
@@ -27,65 +29,65 @@ test.describe('Electron Xtream Category Management', () => {
|
||||
name: portalName,
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
|
||||
const categoryNames = await app.mainWindow
|
||||
.locator('app-workspace-context-panel .category-item .nav-item-label')
|
||||
.allInnerTexts();
|
||||
const targetCategory = categoryNames[0]?.trim();
|
||||
|
||||
expect(targetCategory).toBeTruthy();
|
||||
const targetCategory = await pickSidebarCategory(app.mainWindow);
|
||||
|
||||
let dialog = await openManageCategoriesDialog(app.mainWindow);
|
||||
|
||||
await dialog.getByRole('button', { name: 'Deselect All' }).click();
|
||||
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' }).click();
|
||||
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('.search-field input').fill(targetCategory!);
|
||||
await expect(dialog.locator('.category-item')).toHaveCount(1);
|
||||
await dialog.locator('.category-item').first().click();
|
||||
await dialog.locator('.search-field input').fill(targetCategory.name);
|
||||
await toggleManagedCategory(dialog, targetCategory);
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await app.mainWindow.waitForSelector('mat-dialog-container', {
|
||||
state: 'detached',
|
||||
});
|
||||
|
||||
await expect(
|
||||
app.mainWindow
|
||||
.locator('app-workspace-context-panel .category-item')
|
||||
.filter({ hasText: targetCategory! })
|
||||
).toHaveCount(0);
|
||||
await expect(sidebarCategoryById(app.mainWindow, targetCategory.id)).toHaveCount(
|
||||
0
|
||||
);
|
||||
|
||||
await openSources(app.mainWindow);
|
||||
await refreshSource(app.mainWindow, portalName, { confirm: true });
|
||||
await waitForXtreamCatalog(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow
|
||||
.locator('app-workspace-context-panel .category-item')
|
||||
.filter({ hasText: targetCategory! })
|
||||
).toHaveCount(0);
|
||||
await waitForSourceRowIdle(app.mainWindow, portalName);
|
||||
await sourceRowByTitle(app.mainWindow, portalName).first().click();
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await expect(sidebarCategoryById(app.mainWindow, targetCategory.id)).toHaveCount(
|
||||
0
|
||||
);
|
||||
|
||||
dialog = await openManageCategoriesDialog(app.mainWindow);
|
||||
await dialog.locator('.search-field input').fill(targetCategory!);
|
||||
await expect(dialog.locator('.category-item')).toHaveCount(1);
|
||||
await dialog.locator('.category-item').first().click();
|
||||
await dialog.locator('.search-field input').fill(targetCategory.name);
|
||||
await toggleManagedCategory(dialog, targetCategory);
|
||||
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||||
await app.mainWindow.waitForSelector('mat-dialog-container', {
|
||||
state: 'detached',
|
||||
});
|
||||
|
||||
await expect(
|
||||
app.mainWindow
|
||||
.locator('app-workspace-context-panel .category-item')
|
||||
.filter({ hasText: targetCategory! })
|
||||
.first()
|
||||
).toBeVisible();
|
||||
await clickCategoryByNameExact(app.mainWindow, targetCategory!);
|
||||
await expect(sidebarCategoryById(app.mainWindow, targetCategory.id)).toHaveCount(
|
||||
1
|
||||
);
|
||||
await clickCategoryById(app.mainWindow, targetCategory.id);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
@@ -94,9 +96,89 @@ test.describe('Electron Xtream Category Management', () => {
|
||||
|
||||
async function openManageCategoriesDialog(page: Page) {
|
||||
await page.getByRole('button', { name: 'Manage categories' }).click();
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
const dialog = page.locator('mat-dialog-container').last();
|
||||
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByText('Select All')).toBeVisible();
|
||||
await expect(
|
||||
dialog.getByRole('button', { name: 'Select All', exact: true })
|
||||
).toBeVisible();
|
||||
return dialog;
|
||||
}
|
||||
|
||||
function sidebarCategoryById(page: Page, categoryId: string): Locator {
|
||||
return page.locator(
|
||||
`app-workspace-context-panel .category-item[data-category-id="${categoryId}"]`
|
||||
);
|
||||
}
|
||||
|
||||
async function pickSidebarCategory(
|
||||
page: Page
|
||||
): Promise<{ id: string; itemCount: number; name: string }> {
|
||||
const categories = page.locator(
|
||||
'app-workspace-context-panel .category-item:visible'
|
||||
);
|
||||
const count = await categories.count();
|
||||
const candidates: Array<{ id: string; itemCount: number; name: string }> = [];
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
const nameCounts = new Map<string, number>();
|
||||
for (const candidate of candidates) {
|
||||
nameCounts.set(candidate.name, (nameCounts.get(candidate.name) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const preferredCandidate =
|
||||
candidates.find((candidate) => nameCounts.get(candidate.name) === 1) ??
|
||||
candidates[0];
|
||||
|
||||
if (!preferredCandidate) {
|
||||
throw new Error('No visible Xtream category with content was found.');
|
||||
}
|
||||
|
||||
return preferredCandidate;
|
||||
}
|
||||
|
||||
async function toggleManagedCategory(
|
||||
dialog: Locator,
|
||||
targetCategory: {
|
||||
itemCount: number;
|
||||
name: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const categories = dialog.locator('.category-item');
|
||||
const count = await categories.count();
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const categoryRow = categories.nth(index);
|
||||
const name =
|
||||
(await categoryRow.locator('.category-name').textContent())?.trim() ??
|
||||
'';
|
||||
const countText =
|
||||
(await categoryRow.locator('.item-count').textContent())?.trim() ?? '';
|
||||
const itemCount = Number.parseInt(countText.replace(/[()]/g, ''), 10) || 0;
|
||||
|
||||
if (name !== targetCategory.name || itemCount !== targetCategory.itemCount) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await expect(categoryRow).toBeVisible();
|
||||
await categoryRow.click();
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Could not find category "${targetCategory.name}" with count ${targetCategory.itemCount} in the management dialog.`
|
||||
);
|
||||
}
|
||||
@@ -339,7 +339,12 @@ export async function saveSettings(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
export async function goToDashboard(page: Page): Promise<void> {
|
||||
await page.locator('a[href$="/workspace/dashboard"]').click();
|
||||
const dashboardLink = page
|
||||
.locator('a.brand[href$="/workspace/dashboard"]')
|
||||
.first();
|
||||
|
||||
await expect(dashboardLink).toBeVisible();
|
||||
await dashboardLink.click();
|
||||
await page.waitForURL(/\/workspace\/dashboard$/);
|
||||
}
|
||||
|
||||
@@ -544,21 +549,52 @@ export async function switchUnifiedCollectionScope(
|
||||
page: Page,
|
||||
scopeLabel: 'This playlist' | 'All playlists'
|
||||
): Promise<void> {
|
||||
const toggle = page
|
||||
.locator('.scope-toggle')
|
||||
.getByRole('button', { name: scopeLabel, exact: true });
|
||||
const toggleGroup = page.locator('.scope-toggle');
|
||||
|
||||
await expect(toggle).toBeVisible();
|
||||
await toggle.click();
|
||||
await expect(toggleGroup).toBeVisible();
|
||||
await clickButtonToggleOption(toggleGroup, scopeLabel);
|
||||
}
|
||||
|
||||
export async function switchUnifiedCollectionContent(
|
||||
page: Page,
|
||||
contentLabel: 'Live TV' | 'Movies' | 'Series'
|
||||
): Promise<void> {
|
||||
const toggle = page
|
||||
.locator('.content-toggle')
|
||||
.getByRole('button', { name: contentLabel, exact: true });
|
||||
const toggleGroup = page.locator('.content-toggle');
|
||||
|
||||
await expect(toggleGroup).toBeVisible();
|
||||
await clickButtonToggleOption(toggleGroup, contentLabel);
|
||||
}
|
||||
|
||||
async function clickButtonToggleOption(
|
||||
toggleGroup: Locator,
|
||||
label: string
|
||||
): Promise<void> {
|
||||
const radio = toggleGroup.getByRole('radio', {
|
||||
name: label,
|
||||
exact: true,
|
||||
});
|
||||
|
||||
if ((await radio.count()) > 0) {
|
||||
await expect(radio.first()).toBeVisible();
|
||||
await radio.first().click();
|
||||
return;
|
||||
}
|
||||
|
||||
const button = toggleGroup.getByRole('button', {
|
||||
name: label,
|
||||
exact: true,
|
||||
});
|
||||
|
||||
if ((await button.count()) > 0) {
|
||||
await expect(button.first()).toBeVisible();
|
||||
await button.first().click();
|
||||
return;
|
||||
}
|
||||
|
||||
const toggle = toggleGroup
|
||||
.locator('mat-button-toggle')
|
||||
.filter({ hasText: flexibleTextPattern(label) })
|
||||
.first();
|
||||
|
||||
await expect(toggle).toBeVisible();
|
||||
await toggle.click();
|
||||
@@ -606,7 +642,8 @@ export async function clickCategoryById(
|
||||
`app-workspace-context-panel .category-item[data-category-id="${categoryId}"]:visible`
|
||||
);
|
||||
|
||||
await expect(category.first()).toBeVisible();
|
||||
await expect(category.first()).toBeVisible({ timeout: 20000 });
|
||||
await category.first().scrollIntoViewIfNeeded();
|
||||
await category.first().click();
|
||||
}
|
||||
|
||||
@@ -614,16 +651,17 @@ export async function clickCategoryByNameExact(
|
||||
page: Page,
|
||||
categoryName: string
|
||||
): Promise<void> {
|
||||
const category = page
|
||||
const categories = page
|
||||
.locator('app-workspace-context-panel .category-item:visible')
|
||||
.filter({
|
||||
has: page.locator('.nav-item-label', {
|
||||
hasText: new RegExp(`^\\s*${escapeRegex(categoryName)}\\s*$`),
|
||||
}),
|
||||
})
|
||||
.first();
|
||||
});
|
||||
const category = await pickPreferredCategory(categories);
|
||||
|
||||
await expect(category).toBeVisible();
|
||||
await category.scrollIntoViewIfNeeded();
|
||||
await category.click();
|
||||
}
|
||||
|
||||
@@ -868,7 +906,6 @@ export async function waitForM3uCatalog(page: Page): Promise<void> {
|
||||
export async function waitForXtreamCatalog(page: Page): Promise<void> {
|
||||
await page.waitForURL(/\/workspace\/xtreams\/.+/);
|
||||
await waitForXtreamImportToFinish(page);
|
||||
await waitForXtreamCategoryCounts(page);
|
||||
|
||||
const categories = page.locator(
|
||||
'app-workspace-context-panel .category-item'
|
||||
@@ -883,7 +920,9 @@ export async function waitForXtreamCatalog(page: Page): Promise<void> {
|
||||
await expect(contentItems.first()).toBeVisible({ timeout: 5000 });
|
||||
return;
|
||||
} catch {
|
||||
await categories.first().click();
|
||||
const category = await pickPreferredCategory(categories);
|
||||
await category.scrollIntoViewIfNeeded();
|
||||
await category.click();
|
||||
await expect(contentItems.first()).toBeVisible({ timeout: 20000 });
|
||||
}
|
||||
}
|
||||
@@ -902,23 +941,6 @@ export async function expectPlaylistUpdatedToast(page: Page): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForXtreamCategoryCounts(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const texts = await page
|
||||
.locator(
|
||||
'app-workspace-context-panel .category-item .item-count'
|
||||
)
|
||||
.allInnerTexts();
|
||||
|
||||
return texts.some((text) => Number.parseInt(text, 10) > 0);
|
||||
},
|
||||
{ timeout: 30000 }
|
||||
)
|
||||
.toBeTruthy();
|
||||
}
|
||||
|
||||
export async function waitForXtreamImportToFinish(page: Page): Promise<void> {
|
||||
const overlay = page.locator('.workspace-loading-overlay');
|
||||
|
||||
@@ -931,6 +953,32 @@ export async function waitForXtreamImportToFinish(page: Page): Promise<void> {
|
||||
await expect(overlay).toHaveCount(0, { timeout: 30000 });
|
||||
}
|
||||
|
||||
async function pickPreferredCategory(categories: Locator): Promise<Locator> {
|
||||
const count = await categories.count();
|
||||
let fallback: Locator | null = null;
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const candidate = categories.nth(index);
|
||||
|
||||
if (!(await candidate.isVisible())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
fallback ??= candidate;
|
||||
|
||||
const countText =
|
||||
(await candidate.locator('.item-count').first().textContent()) ??
|
||||
'';
|
||||
const itemCount = Number.parseInt(countText.trim(), 10);
|
||||
|
||||
if (Number.isFinite(itemCount) && itemCount > 0) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return fallback ?? categories.first();
|
||||
}
|
||||
|
||||
export async function waitForStalkerCatalog(page: Page): Promise<void> {
|
||||
await page.waitForURL(/\/workspace\/stalker\/.+/);
|
||||
|
||||
|
||||
@@ -4,12 +4,16 @@ import {
|
||||
addXtreamPortal,
|
||||
channelItemByTitle,
|
||||
clickCategoryById,
|
||||
clickCategoryByNameExact,
|
||||
clickGridListCardByTitle,
|
||||
closeElectronApp,
|
||||
contentCardByTitle,
|
||||
defaultXtreamPassword,
|
||||
defaultXtreamUsername,
|
||||
expect,
|
||||
importM3uPlaylistFromNativeDialog,
|
||||
launchElectronApp,
|
||||
openWorkspaceSection,
|
||||
openPlaylistFavorites,
|
||||
openSources,
|
||||
resetMockServers,
|
||||
@@ -105,9 +109,12 @@ test.describe('Electron Favorites', () => {
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const liveFixture = await fetchXtreamLiveFixture(request);
|
||||
const vodFixture = await fetchXtreamVodFixture(request);
|
||||
const seriesFixture = await fetchXtreamSeriesFixture(request);
|
||||
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 [movieTitle] = pickDistinctTitles(vodFixture.items, getXtreamTitle);
|
||||
const [seriesTitle] = pickDistinctTitles(seriesFixture.items, getXtreamTitle);
|
||||
@@ -120,17 +127,27 @@ test.describe('Electron Favorites', () => {
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
|
||||
await clickCategoryById(app.mainWindow, liveFixture.categoryId);
|
||||
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 clickCategoryById(app.mainWindow, vodFixture.categoryId);
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
vodFixture.categoryName
|
||||
);
|
||||
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 clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
seriesFixture.categoryName
|
||||
);
|
||||
await clickGridListCardByTitle(app.mainWindow, seriesTitle);
|
||||
await addCurrentDetailToFavorites(app.mainWindow);
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
@@ -264,6 +281,11 @@ test.describe('Electron Favorites', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const xtreamCredentials = {
|
||||
username: defaultXtreamUsername,
|
||||
password: defaultXtreamPassword,
|
||||
};
|
||||
|
||||
async function addCurrentDetailToFavorites(page: Page): Promise<void> {
|
||||
const addButton = page.locator('button.favorite-btn').first();
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@ type StalkerOrderedList<T> = {
|
||||
data: T[];
|
||||
};
|
||||
|
||||
type StalkerSeasonLike = {
|
||||
id?: number | string;
|
||||
};
|
||||
|
||||
export type StalkerContentItem = {
|
||||
id?: number | string;
|
||||
name?: string;
|
||||
@@ -128,6 +132,30 @@ export async function fetchStalkerCategoryFixture(
|
||||
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,
|
||||
@@ -246,3 +274,46 @@ function buildStalkerProxyUrl(
|
||||
|
||||
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 ?? [];
|
||||
}
|
||||
@@ -4,14 +4,18 @@ import {
|
||||
addXtreamPortal,
|
||||
channelItemByTitle,
|
||||
clickCategoryById,
|
||||
clickCategoryByNameExact,
|
||||
clickGridListCardByTitle,
|
||||
closeElectronApp,
|
||||
contentCardByTitle,
|
||||
defaultXtreamPassword,
|
||||
defaultXtreamUsername,
|
||||
expect,
|
||||
importM3uPlaylistFromNativeDialog,
|
||||
launchElectronApp,
|
||||
openPlaylistFavorites,
|
||||
openPlaylistRecent,
|
||||
openWorkspaceSection,
|
||||
openSources,
|
||||
resetMockServers,
|
||||
restartElectronApp,
|
||||
@@ -104,9 +108,12 @@ test.describe('Electron Recently Viewed', () => {
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const liveFixture = await fetchXtreamLiveFixture(request);
|
||||
const vodFixture = await fetchXtreamVodFixture(request);
|
||||
const seriesFixture = await fetchXtreamSeriesFixture(request);
|
||||
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 [movieTitle] = pickDistinctTitles(vodFixture.items, getXtreamTitle);
|
||||
const [seriesTitle] = pickDistinctTitles(seriesFixture.items, getXtreamTitle);
|
||||
@@ -119,20 +126,30 @@ test.describe('Electron Recently Viewed', () => {
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
|
||||
await clickCategoryById(app.mainWindow, liveFixture.categoryId);
|
||||
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 clickCategoryById(app.mainWindow, vodFixture.categoryId);
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
vodFixture.categoryName
|
||||
);
|
||||
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 clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
seriesFixture.categoryName
|
||||
);
|
||||
await clickGridListCardByTitle(app.mainWindow, seriesTitle);
|
||||
await playFirstSeriesEpisode(app.mainWindow);
|
||||
|
||||
@@ -253,6 +270,11 @@ test.describe('Electron Recently Viewed', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const xtreamCredentials = {
|
||||
username: defaultXtreamUsername,
|
||||
password: defaultXtreamPassword,
|
||||
};
|
||||
|
||||
async function clearRecentItems(page: Page): Promise<void> {
|
||||
await page
|
||||
.getByRole('button', { name: 'Clear recently viewed for this section' })
|
||||
@@ -282,11 +304,23 @@ async function playCurrentDetail(page: Page): Promise<void> {
|
||||
|
||||
async function playFirstSeriesEpisode(page: Page): Promise<void> {
|
||||
const seasonCard = page.locator('.season-card').first();
|
||||
|
||||
await expect(seasonCard).toBeVisible({ timeout: 20000 });
|
||||
await seasonCard.click();
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { APIRequestContext, Page } from '@playwright/test';
|
||||
import { APIRequestContext, Locator, Page } from '@playwright/test';
|
||||
import { readFileSync } from 'fs';
|
||||
import {
|
||||
addStalkerPortal,
|
||||
@@ -281,6 +281,11 @@ test.describe('Electron Workspace Search', () => {
|
||||
username: xtreamSearchUsername,
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Movies');
|
||||
await expectPathname(
|
||||
app.mainWindow,
|
||||
/\/workspace\/xtreams\/[^/]+\/vod$/
|
||||
);
|
||||
|
||||
await clickCategoryByNameExact(app.mainWindow, sample.categoryName);
|
||||
await expect(
|
||||
@@ -513,6 +518,11 @@ test.describe('Electron Workspace Search', () => {
|
||||
username: xtreamSearchUsername,
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Movies');
|
||||
await expectPathname(
|
||||
app.mainWindow,
|
||||
/\/workspace\/xtreams\/[^/]+\/vod$/
|
||||
);
|
||||
|
||||
await clickCategoryByNameExact(app.mainWindow, sample.categoryName);
|
||||
await expect(
|
||||
@@ -1176,16 +1186,17 @@ async function clickCategoryByNameExact(
|
||||
page: Page,
|
||||
categoryName: string
|
||||
): Promise<void> {
|
||||
const category = page
|
||||
const categories = page
|
||||
.locator('app-workspace-context-panel .category-item:visible')
|
||||
.filter({
|
||||
has: page.locator('.nav-item-label', {
|
||||
hasText: new RegExp(`^\\s*${escapeRegex(categoryName)}\\s*$`),
|
||||
}),
|
||||
})
|
||||
.first();
|
||||
});
|
||||
const category = await pickPreferredCategory(categories);
|
||||
|
||||
await expect(category).toBeVisible();
|
||||
await category.scrollIntoViewIfNeeded();
|
||||
await category.click();
|
||||
}
|
||||
|
||||
@@ -1312,6 +1323,32 @@ async function goBackFromDetail(page: Page): Promise<void> {
|
||||
await backButton.click();
|
||||
}
|
||||
|
||||
async function pickPreferredCategory(categories: Locator): Promise<Locator> {
|
||||
const count = await categories.count();
|
||||
let fallback = categories.first();
|
||||
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const candidate = categories.nth(index);
|
||||
|
||||
if (!(await candidate.isVisible())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
fallback = candidate;
|
||||
|
||||
const countText =
|
||||
(await candidate.locator('.item-count').first().textContent()) ??
|
||||
'';
|
||||
const itemCount = Number.parseInt(countText.trim(), 10);
|
||||
|
||||
if (Number.isFinite(itemCount) && itemCount > 0) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function performXtreamPlaylistSearch(
|
||||
page: Page,
|
||||
queries: string[]
|
||||
|
||||
@@ -52,6 +52,20 @@ export async function saveCategories(
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const existingCategories = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.categories)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.categories.playlistId, playlistId),
|
||||
eq(schema.categories.type, type)
|
||||
)
|
||||
);
|
||||
|
||||
if ((existingCategories[0]?.count ?? 0) > 0) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const hiddenSet = new Set(hiddenCategoryXtreamIds || []);
|
||||
const values = categories.map((category) => ({
|
||||
playlistId,
|
||||
@@ -61,7 +75,16 @@ export async function saveCategories(
|
||||
hidden: hiddenSet.has(category.category_id),
|
||||
}));
|
||||
|
||||
await db.insert(schema.categories).values(values);
|
||||
await db
|
||||
.insert(schema.categories)
|
||||
.values(values)
|
||||
.onConflictDoNothing({
|
||||
target: [
|
||||
schema.categories.playlistId,
|
||||
schema.categories.type,
|
||||
schema.categories.xtreamId,
|
||||
],
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -219,13 +219,28 @@ export async function saveContent(
|
||||
type: 'live' | 'movie' | 'series',
|
||||
control?: OperationControl
|
||||
): Promise<{ success: boolean; count: number }> {
|
||||
console.log(
|
||||
`>>> DB_SAVE_CONTENT called: ${streams.length} items, type: ${type}, playlist: ${playlistId}`
|
||||
);
|
||||
|
||||
const dbType =
|
||||
type === 'series' ? 'series' : type === 'movie' ? 'movies' : 'live';
|
||||
|
||||
const existingContent = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(schema.content)
|
||||
.innerJoin(
|
||||
schema.categories,
|
||||
eq(schema.content.categoryId, schema.categories.id)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.categories.playlistId, playlistId),
|
||||
eq(schema.categories.type, dbType),
|
||||
eq(schema.content.type, type)
|
||||
)
|
||||
);
|
||||
|
||||
if ((existingContent[0]?.count ?? 0) > 0) {
|
||||
return { success: true, count: existingContent[0].count };
|
||||
}
|
||||
|
||||
const categories = await db
|
||||
.select({
|
||||
id: schema.categories.id,
|
||||
@@ -255,7 +270,16 @@ export async function saveContent(
|
||||
for (let index = 0; index < values.length; index += chunkSize) {
|
||||
await checkpointOperation(control);
|
||||
const chunk = values.slice(index, index + chunkSize);
|
||||
await db.insert(schema.content).values(chunk);
|
||||
await db
|
||||
.insert(schema.content)
|
||||
.values(chunk)
|
||||
.onConflictDoNothing({
|
||||
target: [
|
||||
schema.content.categoryId,
|
||||
schema.content.type,
|
||||
schema.content.xtreamId,
|
||||
],
|
||||
});
|
||||
totalInserted += chunk.length;
|
||||
await reportOperationProgress(control, {
|
||||
phase: 'saving-content',
|
||||
|
||||
Reference in new issue
Block a user