Files
iptvnator/apps/electron-backend-e2e/src/search.e2e.ts
T

1811 lines
59 KiB
TypeScript

import { APIRequestContext, Locator, Page } from '@playwright/test';
import { readFileSync } from 'fs';
import {
addStalkerPortal,
addXtreamPortal,
closeElectronApp,
defaultStalkerMacAddress,
expect,
expectNoWorkspaceSearchStatus,
expectWorkspaceSearchScope,
fillWorkspaceSearch,
goToDashboard,
importM3uPlaylistFromNativeDialog,
launchElectronApp,
m3uFixturePath,
openGlobalFavorites,
resetMockServers,
stalkerMockServer,
test,
waitForM3uCatalog,
waitForPortalDebugEvent,
waitForStalkerCatalog,
waitForXtreamCatalog,
xtreamMockServer,
} from './electron-test-fixtures';
type XtreamCategory = {
category_id: string;
category_name: string;
};
type XtreamVodStream = {
category_id?: string;
id?: number | string;
name?: string;
series_id?: number | string;
stream_id?: number | string;
title?: string;
xtream_id?: number | string;
};
type StalkerCategory = {
id: string;
title: string;
};
type StalkerProxyPayload<T> = {
payload: {
js: T;
};
};
type StalkerOrderedList<T> = {
cur_page: number;
data: T[];
max_page_items: number;
total_items: number;
total_pages: number;
};
type StalkerContentItem = {
id?: number | string;
name?: string;
o_name?: string;
};
type XtreamVodFixture = {
candidateTitles: string[];
categoryId: string;
categoryName: string;
controlTitle: string;
itemId: string;
targetTitle: string;
};
type StalkerCategoryFixture = {
categoryId: string;
categoryName: string;
controlTitle: string;
targetTitle: string;
};
type M3uFixtureItem = {
groupTitle: string;
name: string;
url: string;
};
type M3uSearchFixture = {
controlTitle: string;
groupTitle: string;
siblingTitle: string;
targetTitle: string;
};
const headerSearchSelector =
'app-workspace-shell-header .search-field input[type="search"]';
const xtreamSearchUsername = 'minimal';
const xtreamSearchPassword = 'minimal';
test.describe('Electron Workspace Search', () => {
test('@search @m3u filters all channels from the workspace header on playlist routes', async ({
dataDir,
}) => {
const sample = loadM3uSearchFixture();
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromNativeDialog(app, m3uFixturePath);
await waitForM3uCatalog(app.mainWindow);
await fillWorkspaceSearch(app.mainWindow, sample.targetTitle);
await expectPathname(
app.mainWindow,
/\/workspace\/playlists\/[^/]+\/all$/
);
await expectQueryParam(app.mainWindow, 'q', sample.targetTitle);
await expectWorkspaceSearchScope(app.mainWindow, 'All channels');
await expect(
channelItemByTitle(app.mainWindow, sample.targetTitle).first()
).toBeVisible();
await expect(
channelItemByTitle(app.mainWindow, sample.controlTitle)
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('@search @m3u filters grouped channels by group and channel title from the workspace header', async ({
dataDir,
}) => {
const sample = loadM3uSearchFixture();
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromNativeDialog(app, m3uFixturePath);
await waitForM3uCatalog(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Groups');
await fillWorkspaceSearch(app.mainWindow, sample.groupTitle);
await expectPathname(
app.mainWindow,
/\/workspace\/playlists\/[^/]+\/groups$/
);
await expectQueryParam(app.mainWindow, 'q', sample.groupTitle);
await expectWorkspaceSearchScope(app.mainWindow, 'Groups');
await expect(
groupPanelHeader(app.mainWindow, sample.groupTitle)
).toHaveCount(1);
await expect(
groupPanelHeader(app.mainWindow, sample.groupTitle).first()
).toContainText(sample.groupTitle);
await expect(
groupPanelHeader(app.mainWindow, sample.groupTitle).first()
).toContainText('2');
await ensureGroupSelected(app.mainWindow, sample.groupTitle);
await expect(
channelItemByTitle(app.mainWindow, sample.targetTitle).first()
).toBeVisible();
await expect(
channelItemByTitle(app.mainWindow, sample.siblingTitle).first()
).toBeVisible();
await expect(
channelItemByTitle(app.mainWindow, sample.controlTitle)
).toHaveCount(0);
await fillWorkspaceSearch(app.mainWindow, sample.targetTitle);
await expectQueryParam(app.mainWindow, 'q', sample.targetTitle);
await expect(
groupPanelHeader(app.mainWindow, sample.groupTitle).first()
).toContainText(sample.groupTitle);
await expect(
groupPanelHeader(app.mainWindow, sample.groupTitle).first()
).toContainText('1');
await ensureGroupSelected(app.mainWindow, sample.groupTitle);
await expect(
channelItemByTitle(app.mainWindow, sample.targetTitle).first()
).toBeVisible();
await expect(
channelItemByTitle(app.mainWindow, sample.siblingTitle)
).toHaveCount(0);
await expect(
channelItemByTitle(app.mainWindow, sample.controlTitle)
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('@search @m3u filters playlist favorites from the workspace header', async ({
dataDir,
}) => {
const sample = loadM3uSearchFixture();
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromNativeDialog(app, m3uFixturePath);
await waitForM3uCatalog(app.mainWindow);
await toggleFavoriteForChannel(app.mainWindow, sample.targetTitle);
await toggleFavoriteForChannel(app.mainWindow, sample.controlTitle);
await openWorkspaceSection(app.mainWindow, 'Favorites');
await fillWorkspaceSearch(app.mainWindow, sample.groupTitle);
await expectPathname(
app.mainWindow,
/\/workspace\/global-favorites$/
);
await expectQueryParam(app.mainWindow, 'q', sample.groupTitle);
await expectWorkspaceSearchScope(
app.mainWindow,
'Global favorites'
);
await expect(
channelItemByTitle(app.mainWindow, sample.targetTitle).first()
).toBeVisible();
await expect(
channelItemByTitle(app.mainWindow, sample.controlTitle)
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('@search @m3u filters global favorites from the workspace rail without leaving the persisted query model', async ({
dataDir,
}) => {
const sample = loadM3uSearchFixture();
const app = await launchElectronApp(dataDir);
try {
await importM3uPlaylistFromNativeDialog(app, m3uFixturePath);
await waitForM3uCatalog(app.mainWindow);
await toggleFavoriteForChannel(app.mainWindow, sample.targetTitle);
await toggleFavoriteForChannel(app.mainWindow, sample.controlTitle);
await openGlobalFavorites(app.mainWindow);
await expectPathname(
app.mainWindow,
/\/workspace\/global-favorites$/
);
await fillWorkspaceSearch(app.mainWindow, sample.targetTitle);
await expectQueryParam(app.mainWindow, 'q', sample.targetTitle);
await expectWorkspaceSearchScope(
app.mainWindow,
'Global favorites'
);
await expect(
channelItemByTitle(app.mainWindow, sample.targetTitle).first()
).toBeVisible();
await expect(
channelItemByTitle(app.mainWindow, sample.controlTitle)
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('@search @xtream filters VOD content from the workspace header on a category route', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const sample = await fetchXtreamVodFixture(request);
const xtreamQueries = buildXtreamSearchQueries(
sample.targetTitle,
sample.controlTitle
);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
password: xtreamSearchPassword,
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(
app.mainWindow
.locator('.category-content-layout mat-card')
.first()
).toBeVisible({
timeout: 20000,
});
const resolvedTitles = await resolveVisibleXtreamTitles(
app.mainWindow,
sample
);
await fillWorkspaceSearch(
app.mainWindow,
resolvedTitles.targetTitle
);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+$/
);
await expectQueryParam(
app.mainWindow,
'q',
resolvedTitles.targetTitle
);
await expectWorkspaceSearchScope(
app.mainWindow,
`Movies / ${sample.categoryName}`
);
const contentLayout = app.mainWindow.locator(
'.category-content-layout'
);
await expect(contentLayout).toContainText(
flexibleTextPattern(resolvedTitles.targetTitle)
);
await expect(contentLayout).not.toContainText(
flexibleTextPattern(resolvedTitles.controlTitle)
);
} finally {
await closeElectronApp(app);
}
});
test('@search @xtream filters series content from the workspace header on a category route', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const sample = await fetchXtreamSeriesFixture(request);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
password: xtreamSearchPassword,
username: xtreamSearchUsername,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Series');
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/series$/
);
await clickCategoryByNameExact(app.mainWindow, sample.categoryName);
try {
await expect(
app.mainWindow
.locator('.category-content-layout mat-card')
.first()
).toBeVisible({
timeout: 5000,
});
} catch {
await openWorkspaceSection(app.mainWindow, 'Series');
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/series$/
);
await clickCategoryByNameExact(
app.mainWindow,
sample.categoryName
);
await expect(
app.mainWindow
.locator('.category-content-layout mat-card')
.first()
).toBeVisible({
timeout: 20000,
});
}
const resolvedTitles = await resolveVisibleXtreamTitles(
app.mainWindow,
sample
);
await fillWorkspaceSearch(
app.mainWindow,
resolvedTitles.targetTitle
);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/series(?:\/[^/]+)?$/
);
await expectQueryParam(
app.mainWindow,
'q',
resolvedTitles.targetTitle
);
await expectWorkspaceSearchScope(
app.mainWindow,
`Series / ${sample.categoryName}`
);
const contentLayout = app.mainWindow.locator(
'.category-content-layout'
);
await expect(contentLayout).toContainText(
flexibleTextPattern(resolvedTitles.targetTitle)
);
await expect(contentLayout).not.toContainText(
flexibleTextPattern(resolvedTitles.controlTitle)
);
} finally {
await closeElectronApp(app);
}
});
test('@search @xtream filters VOD and series root views across categories', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const vodSample = await fetchXtreamVodFixture(request);
const seriesSample = await fetchXtreamSeriesFixture(request);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
password: xtreamSearchPassword,
username: xtreamSearchUsername,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await expectXtreamRootCatalogSearch(app.mainWindow, {
pathPattern: /\/workspace\/xtreams\/[^/]+\/vod$/,
sample: vodSample,
sectionLabel: 'Movies',
});
await expect(
app.mainWindow.locator(headerSearchSelector)
).toHaveValue(vodSample.targetTitle);
await fillWorkspaceSearch(app.mainWindow, '');
await openWorkspaceSection(app.mainWindow, 'Series');
await expectXtreamRootCatalogSearch(app.mainWindow, {
pathPattern: /\/workspace\/xtreams\/[^/]+\/series$/,
sample: seriesSample,
sectionLabel: 'Series',
});
await expect(
app.mainWindow.locator(headerSearchSelector)
).toHaveValue(seriesSample.targetTitle);
} finally {
await closeElectronApp(app);
}
});
test('@search @xtream filters live root and category scopes while preserving playback sidebar', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const sample = await fetchXtreamLiveFixture(request);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
password: xtreamSearchPassword,
username: xtreamSearchUsername,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/live$/
);
await fillWorkspaceSearch(app.mainWindow, sample.targetTitle);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/live$/
);
await expectQueryParam(app.mainWindow, 'q', sample.targetTitle);
await expectWorkspaceSearchScope(
app.mainWindow,
'Live TV / All Items'
);
await expect(
liveChannelSidebar(app.mainWindow)
.locator('[data-test-id="channel-item"]')
.first()
).toBeVisible({ timeout: 20000 });
await expect(
channelItemByTitle(app.mainWindow, sample.targetTitle).first()
).toBeVisible({ timeout: 20000 });
await expect(
channelItemByTitle(app.mainWindow, sample.controlTitle)
).toHaveCount(0);
await channelItemByTitle(app.mainWindow, sample.targetTitle)
.first()
.click();
await expect(
app.mainWindow.locator(
'app-live-stream-layout .content-container .video-player'
)
).toBeVisible({ timeout: 20000 });
await expect(
liveChannelSidebar(app.mainWindow)
.locator('[data-test-id="channel-item"]')
.first()
).toBeVisible();
await expect(
channelItemByTitle(app.mainWindow, sample.targetTitle).first()
).toHaveClass(/(^|\s)active(\s|$)/);
await fillWorkspaceSearch(app.mainWindow, '');
await expectQueryParamAbsent(app.mainWindow, 'q');
await clickCategoryByNameExact(app.mainWindow, sample.categoryName);
await expectWorkspaceSearchScope(
app.mainWindow,
`Live TV / ${sample.categoryName}`
);
await expect(
app.mainWindow.locator(
'app-live-stream-layout .content-container .video-player'
)
).toBeVisible({ timeout: 20000 });
await expect(
channelItemByTitle(app.mainWindow, sample.targetTitle).first()
).toHaveClass(/(^|\s)active(\s|$)/);
await fillWorkspaceSearch(app.mainWindow, sample.targetTitle);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/live(?:\/[^/]+)?$/
);
await expectQueryParam(app.mainWindow, 'q', sample.targetTitle);
await expectWorkspaceSearchScope(
app.mainWindow,
`Live TV / ${sample.categoryName}`
);
await expect(
channelItemByTitle(app.mainWindow, sample.targetTitle).first()
).toBeVisible({ timeout: 20000 });
await expect(
channelItemByTitle(app.mainWindow, sample.controlTitle)
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('@search @xtream promotes dashboard header search into advanced playlist search', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const sample = await fetchXtreamVodFixture(request);
const xtreamQueries = buildXtreamSearchQueries(
sample.targetTitle,
sample.controlTitle
);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
password: xtreamSearchPassword,
username: xtreamSearchUsername,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await goToDashboard(app.mainWindow);
const xtreamQuery = await performXtreamPlaylistSearch(
app.mainWindow,
xtreamQueries
);
await expectWorkspaceSearchScope(app.mainWindow, 'Advanced search');
await expect(
app.mainWindow.locator('app-search-layout app-search-form')
).toHaveCount(0);
await expect(
xtreamSearchResultCards(app.mainWindow).first()
).toBeVisible({ timeout: 20000 });
} finally {
await closeElectronApp(app);
}
});
test('@search @xtream restores the search term and results after returning from a result detail route', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const sample = await fetchXtreamVodFixture(request);
const xtreamQueries = buildXtreamSearchQueries(
sample.targetTitle,
sample.controlTitle
);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
password: xtreamSearchPassword,
username: xtreamSearchUsername,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await goToDashboard(app.mainWindow);
const xtreamQuery = await performXtreamPlaylistSearch(
app.mainWindow,
xtreamQueries
);
const resultCard = xtreamSearchResultCards(app.mainWindow).first();
await expect(resultCard).toBeVisible({ timeout: 20000 });
await resultCard.click();
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/(?:vod|live|series)\/[^/]+\/[^/]+$/
);
await app.mainWindow.goBack();
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/search$/
);
await expectQueryParam(app.mainWindow, 'q', xtreamQuery);
await expect(
app.mainWindow.locator(headerSearchSelector)
).toHaveValue(xtreamQuery);
await expect(
xtreamSearchResultCards(app.mainWindow).first()
).toBeVisible({ timeout: 20000 });
} finally {
await closeElectronApp(app);
}
});
test('@search @xtream filters playlist favorites from the workspace header', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const sample = await fetchXtreamVodFixture(request);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
password: xtreamSearchPassword,
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(
app.mainWindow
.locator('.category-content-layout mat-card')
.first()
).toBeVisible({ timeout: 20000 });
const resolvedTitles = await resolveVisibleXtreamTitles(
app.mainWindow,
sample
);
await clickGridListCardByTitle(
app.mainWindow,
resolvedTitles.targetTitle
);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/
);
await addCurrentDetailToFavorites(app.mainWindow);
await goBackFromDetail(app.mainWindow);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+$/
);
await clickGridListCardByTitle(
app.mainWindow,
resolvedTitles.controlTitle
);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/
);
await addCurrentDetailToFavorites(app.mainWindow);
await goBackFromDetail(app.mainWindow);
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+$/
);
await openWorkspaceSection(app.mainWindow, 'Favorites');
await expectPathname(
app.mainWindow,
/\/workspace\/global-favorites$/
);
await expect(
contentCardByTitle(
app.mainWindow,
resolvedTitles.targetTitle
).first()
).toBeVisible({ timeout: 20000 });
await expect(
contentCardByTitle(
app.mainWindow,
resolvedTitles.controlTitle
).first()
).toBeVisible({ timeout: 20000 });
await fillWorkspaceSearch(
app.mainWindow,
resolvedTitles.targetTitle
);
await expectQueryParam(
app.mainWindow,
'q',
resolvedTitles.targetTitle
);
await expectWorkspaceSearchScope(
app.mainWindow,
'Global favorites'
);
await expect(
contentCardByTitle(
app.mainWindow,
resolvedTitles.targetTitle
).first()
).toBeVisible();
await expect(
contentCardByTitle(app.mainWindow, resolvedTitles.controlTitle)
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('@search @stalker sends category search through Electron IPC on the VOD route', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['stalker']);
const sample = await fetchStalkerCategoryFixture(request, 'vod');
const app = await launchElectronApp(dataDir);
try {
await addStalkerPortal(app.mainWindow);
await waitForStalkerCatalog(app.mainWindow);
await clickCategoryById(app.mainWindow, sample.categoryId);
await fillWorkspaceSearch(app.mainWindow, sample.targetTitle);
await expectPathname(
app.mainWindow,
new RegExp(
`/workspace/stalker/[^/]+/vod/${escapeRegex(
sample.categoryId
)}$`
)
);
await expectQueryParam(app.mainWindow, 'q', sample.targetTitle);
await expectWorkspaceSearchScope(
app.mainWindow,
`Movies / ${sample.categoryName}`
);
await waitForPortalDebugEvent(app.mainWindow, {
provider: 'stalker',
operation: 'get_ordered_list',
predicate: (event) => {
const requestPayload = event.request as {
params?: Record<string, string | number>;
};
return (
requestPayload.params?.['type'] === 'vod' &&
String(requestPayload.params?.['category']) ===
sample.categoryId &&
requestPayload.params?.['search'] === sample.targetTitle
);
},
});
const contentLayout = app.mainWindow.locator(
'.category-content-layout'
);
await expect(contentLayout).toContainText(
flexibleTextPattern(sample.targetTitle)
);
await expect(contentLayout).not.toContainText(
flexibleTextPattern(sample.controlTitle)
);
} finally {
await closeElectronApp(app);
}
});
test('@search @stalker sends category search through Electron IPC on the series route', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['stalker']);
const sample = await fetchStalkerCategoryFixture(request, 'series');
const app = await launchElectronApp(dataDir);
try {
await addStalkerPortal(app.mainWindow);
await waitForStalkerCatalog(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Series');
await expectPathname(
app.mainWindow,
/\/workspace\/stalker\/[^/]+\/series$/
);
await clickCategoryById(app.mainWindow, sample.categoryId);
await fillWorkspaceSearch(app.mainWindow, sample.targetTitle);
await expectPathname(
app.mainWindow,
new RegExp(
`/workspace/stalker/[^/]+/series/${escapeRegex(
sample.categoryId
)}$`
)
);
await expectQueryParam(app.mainWindow, 'q', sample.targetTitle);
await expectWorkspaceSearchScope(
app.mainWindow,
`Series / ${sample.categoryName}`
);
await waitForPortalDebugEvent(app.mainWindow, {
provider: 'stalker',
operation: 'get_ordered_list',
predicate: (event) => {
const requestPayload = event.request as {
params?: Record<string, string | number>;
};
return (
requestPayload.params?.['type'] === 'series' &&
String(requestPayload.params?.['category']) ===
sample.categoryId &&
requestPayload.params?.['search'] === sample.targetTitle
);
},
});
const contentLayout = app.mainWindow.locator(
'.category-content-layout'
);
await expect(contentLayout).toContainText(
flexibleTextPattern(sample.targetTitle)
);
await expect(contentLayout).not.toContainText(
flexibleTextPattern(sample.controlTitle)
);
} finally {
await closeElectronApp(app);
}
});
test('@search @stalker drives advanced search from the workspace header without an inline form', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['stalker']);
const sample = await fetchStalkerCategoryFixture(request, 'vod');
const app = await launchElectronApp(dataDir);
try {
await addStalkerPortal(app.mainWindow);
await waitForStalkerCatalog(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Advanced search');
await expectPathname(
app.mainWindow,
/\/workspace\/stalker\/[^/]+\/search$/
);
await expectWorkspaceSearchScope(app.mainWindow, 'Advanced search');
await expect(
app.mainWindow.locator('app-search-layout app-search-form')
).toHaveCount(0);
await fillWorkspaceSearch(app.mainWindow, sample.targetTitle);
await expectQueryParam(app.mainWindow, 'q', sample.targetTitle);
await waitForPortalDebugEvent(app.mainWindow, {
provider: 'stalker',
operation: 'get_ordered_list',
predicate: (event) => {
const requestPayload = event.request as {
params?: Record<string, string | number>;
};
return (
requestPayload.params?.['type'] === 'vod' &&
requestPayload.params?.['search'] === sample.targetTitle
);
},
});
await expect(
contentCardByTitle(app.mainWindow, sample.targetTitle).first()
).toBeVisible({ timeout: 20000 });
await contentCardByTitle(app.mainWindow, sample.targetTitle)
.first()
.click();
await expect(
app.mainWindow.locator('app-content-hero')
).toContainText(flexibleTextPattern(sample.targetTitle), {
timeout: 20000,
});
await playCurrentDetail(app.mainWindow);
await expectInlinePlayerWithoutDialog(app.mainWindow);
} finally {
await closeElectronApp(app);
}
});
test('@search @stalker filters playlist favorites from the workspace header', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['stalker']);
const sample = await fetchStalkerCategoryFixture(request, 'itv');
const app = await launchElectronApp(dataDir);
try {
await addStalkerPortal(app.mainWindow);
await waitForStalkerCatalog(app.mainWindow);
await app.mainWindow
.getByRole('link', { name: 'Live TV', exact: true })
.click();
await expectPathname(
app.mainWindow,
/\/workspace\/stalker\/[^/]+\/itv$/
);
await clickCategoryById(app.mainWindow, sample.categoryId);
await expect(
channelItemByTitle(app.mainWindow, sample.targetTitle).first()
).toBeVisible({ timeout: 20000 });
await expect(
channelItemByTitle(app.mainWindow, sample.controlTitle).first()
).toBeVisible({ timeout: 20000 });
await toggleFavoriteForChannel(app.mainWindow, sample.targetTitle);
await toggleFavoriteForChannel(app.mainWindow, sample.controlTitle);
await openWorkspaceSection(app.mainWindow, 'Favorites');
await expectPathname(
app.mainWindow,
/\/workspace\/global-favorites$/
);
await expect(
channelItemByTitle(app.mainWindow, sample.targetTitle).first()
).toBeVisible({ timeout: 20000 });
await expect(
channelItemByTitle(app.mainWindow, sample.controlTitle).first()
).toBeVisible({ timeout: 20000 });
await fillWorkspaceSearch(app.mainWindow, sample.targetTitle);
await expectQueryParam(app.mainWindow, 'q', sample.targetTitle);
await expectWorkspaceSearchScope(
app.mainWindow,
'Global favorites'
);
await expect(
channelItemByTitle(app.mainWindow, sample.targetTitle).first()
).toBeVisible();
await expect(
channelItemByTitle(app.mainWindow, sample.controlTitle)
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('@search @stalker searches the complete channel list on ITV routes', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['stalker']);
const sample = await fetchStalkerCategoryFixture(request, 'itv');
const itvQuery = createUniquePrefix(
sample.targetTitle,
sample.controlTitle
);
const app = await launchElectronApp(dataDir);
try {
await addStalkerPortal(app.mainWindow);
await waitForStalkerCatalog(app.mainWindow);
await app.mainWindow
.getByRole('link', { name: 'Live TV', exact: true })
.click();
await expectPathname(
app.mainWindow,
/\/workspace\/stalker\/[^/]+\/itv$/
);
await clickCategoryById(app.mainWindow, sample.categoryId);
await expect(
channelItemByTitle(app.mainWindow, sample.targetTitle).first()
).toBeVisible();
await expect(
channelItemByTitle(app.mainWindow, sample.controlTitle).first()
).toBeVisible();
await fillWorkspaceSearch(app.mainWindow, itvQuery);
await expectPathname(
app.mainWindow,
/\/workspace\/stalker\/[^/]+\/itv$/
);
await expectQueryParam(app.mainWindow, 'q', itvQuery);
await expectWorkspaceSearchScope(
app.mainWindow,
`Live TV / ${sample.categoryName}`
);
// Entering Live TV preloads the COMPLETE channel list (Ministra
// get_all_channels), so search runs locally over every channel:
// no portal-side search request, and no "Loaded channels only"
// degraded hint.
await waitForPortalDebugEvent(app.mainWindow, {
provider: 'stalker',
operation: 'get_all_channels',
predicate: (event) => {
const requestPayload = event.request as {
params?: Record<string, string | number>;
};
return requestPayload.params?.['type'] === 'itv';
},
});
await expectNoWorkspaceSearchStatus(app.mainWindow);
await expect(
channelItemByTitle(app.mainWindow, sample.targetTitle).first()
).toBeVisible();
await expect(
channelItemByTitle(app.mainWindow, sample.controlTitle)
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
});
async function fetchXtreamVodFixture(
request: APIRequestContext
): Promise<XtreamVodFixture> {
return fetchXtreamFixture(request, {
categoriesAction: 'get_vod_categories',
itemsAction: 'get_vod_streams',
});
}
async function fetchXtreamLiveFixture(
request: APIRequestContext
): Promise<XtreamVodFixture> {
return fetchXtreamFixture(request, {
categoriesAction: 'get_live_categories',
itemsAction: 'get_live_streams',
});
}
async function fetchXtreamSeriesFixture(
request: APIRequestContext
): Promise<XtreamVodFixture> {
return fetchXtreamFixture(request, {
categoriesAction: 'get_series_categories',
itemsAction: 'get_series',
});
}
async function fetchXtreamFixture(
request: APIRequestContext,
options: {
categoriesAction: string;
itemsAction: string;
}
): Promise<XtreamVodFixture> {
const { categoriesAction, itemsAction } = options;
const categories = await fetchJson<XtreamCategory[]>(
request,
`${xtreamMockServer}/player_api.php?action=${encodeURIComponent(
categoriesAction
)}&username=${encodeURIComponent(
xtreamSearchUsername
)}&password=${encodeURIComponent(xtreamSearchPassword)}`
);
if (categories.length === 0) {
throw new Error(
`Xtream mock server returned no categories for ${categoriesAction}.`
);
}
let fallbackFixture: XtreamVodFixture | null = null;
for (const category of categories) {
const items = await fetchJson<XtreamVodStream[]>(
request,
`${xtreamMockServer}/player_api.php?action=${encodeURIComponent(
itemsAction
)}&username=${encodeURIComponent(
xtreamSearchUsername
)}&password=${encodeURIComponent(
xtreamSearchPassword
)}&category_id=${encodeURIComponent(category.category_id)}`
);
const fixture = buildXtreamVodFixture(category, items);
if (!fixture) {
continue;
}
if (!fallbackFixture) {
fallbackFixture = fixture;
}
if (isStableXtreamSearchTitle(fixture.targetTitle)) {
return fixture;
}
}
if (fallbackFixture) {
return fallbackFixture;
}
throw new Error(
`Xtream mock server returned no usable fixture for ${itemsAction}.`
);
}
function buildXtreamVodFixture(
category: XtreamCategory,
items: XtreamVodStream[]
): XtreamVodFixture | null {
try {
const distinctItems = items.filter((item, index, allItems) => {
const title = getXtreamTitle(item);
if (!title) {
return false;
}
return (
allItems.findIndex(
(candidate) =>
normalizeSearchText(getXtreamTitle(candidate)) ===
normalizeSearchText(title)
) === index
);
});
const [targetItem, controlItem] = pickDistinctItems(
distinctItems,
getXtreamTitle,
isStableXtreamSearchTitle
);
const itemId = getXtreamItemId(targetItem);
if (!itemId) {
return null;
}
return {
candidateTitles: distinctItems.map((item) => getXtreamTitle(item)),
categoryId: String(category.category_id),
categoryName: category.category_name,
controlTitle: getXtreamTitle(controlItem),
itemId,
targetTitle: getXtreamTitle(targetItem),
};
} catch {
return null;
}
}
async function fetchStalkerCategoryFixture(
request: APIRequestContext,
type: 'itv' | 'series' | 'vod'
): Promise<StalkerCategoryFixture> {
const categoriesResponse = await fetchJson<
StalkerProxyPayload<StalkerCategory[]>
>(
request,
buildStalkerProxyUrl('get_categories', {
type,
})
);
const category = categoriesResponse.payload.js[0];
if (!category) {
throw new Error(`Stalker mock server returned no ${type} categories.`);
}
const itemsResponse = await fetchJson<
StalkerProxyPayload<StalkerOrderedList<StalkerContentItem>>
>(
request,
buildStalkerProxyUrl('get_ordered_list', {
category: category.id,
p: '1',
type,
})
);
const items = itemsResponse.payload.js.data ?? [];
const [targetItem, controlItem] = pickDistinctItems(items, getStalkerTitle);
return {
categoryId: String(category.id),
categoryName: category.title,
controlTitle: getStalkerTitle(controlItem),
targetTitle: getStalkerTitle(targetItem),
};
}
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 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()}`;
}
function loadM3uSearchFixture(): M3uSearchFixture {
const items = parseM3uFixture(m3uFixturePath);
const groups = items.reduce<Map<string, M3uFixtureItem[]>>((acc, item) => {
const existing = acc.get(item.groupTitle) ?? [];
existing.push(item);
acc.set(item.groupTitle, existing);
return acc;
}, new Map<string, M3uFixtureItem[]>());
const groupedSample = [...groups.entries()].find(
([groupTitle, channels]) =>
groupTitle.length > 0 && channels.length >= 2
);
if (!groupedSample) {
throw new Error(
'Expected the M3U fixture to contain at least one group with two channels.'
);
}
const [groupTitle, groupedChannels] = groupedSample;
const [sibling, target] = groupedChannels;
const control = items.find((item) => item.groupTitle !== groupTitle);
if (!target || !sibling || !control) {
throw new Error(
'Expected the M3U fixture to contain both grouped and cross-group channels.'
);
}
return {
controlTitle: control.name,
groupTitle,
siblingTitle: sibling.name,
targetTitle: target.name,
};
}
function parseM3uFixture(filePath: string): M3uFixtureItem[] {
const content = readFileSync(filePath, 'utf8');
const items: M3uFixtureItem[] = [];
const lines = content.split(/\r?\n/);
let pending:
| {
groupTitle: string;
name: string;
}
| undefined;
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) {
continue;
}
if (line.startsWith('#EXTINF:')) {
const groupTitle =
line.match(/group-title="([^"]*)"/)?.[1]?.trim() ?? '';
const name = line.split(',').at(-1)?.trim() ?? '';
pending = {
groupTitle,
name,
};
continue;
}
if (!pending || line.startsWith('#')) {
continue;
}
items.push({
...pending,
url: line,
});
pending = undefined;
}
return items.filter((item) => item.name.length > 0);
}
async function clickCategoryById(
page: Page,
categoryId: string
): Promise<void> {
const category = page.locator(
`app-workspace-context-panel .category-item[data-category-id="${categoryId}"]:visible`
);
await expect(category.first()).toBeVisible();
await category.first().click();
await expect
.poll(async () => {
const pathname = new URL(page.url()).pathname;
const isSelected =
(await category.first().getAttribute('aria-current')) ===
'true';
return (
isSelected ||
pathname.endsWith(`/${categoryId}`) ||
pathname.includes(`/${categoryId}/`)
);
})
.toBe(true);
}
async function clickCategoryByNameExact(
page: Page,
categoryName: string
): Promise<void> {
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*$`),
}),
});
const category = await pickPreferredCategory(categories);
await expect(category).toBeVisible();
await expect(category).toBeEnabled();
await category.scrollIntoViewIfNeeded();
const categoryId =
(await category.getAttribute('data-category-id'))?.trim() ?? '';
await category.click();
const selectedCategory =
categoryId.length > 0
? page
.locator(
`app-workspace-context-panel .category-item[data-category-id="${categoryId}"]:visible`
)
.first()
: category;
await expect(selectedCategory).toHaveAttribute('aria-current', 'true', {
timeout: 20000,
});
}
async function openWorkspaceSection(page: Page, label: string): Promise<void> {
if (label === 'Favorites') {
await openGlobalFavorites(page);
return;
}
await page.getByRole('link', { name: label, exact: true }).click();
}
async function waitForXtreamWorkspaceReady(page: Page): Promise<void> {
await waitForXtreamCatalog(page);
}
function groupPanelHeader(page: Page, groupTitle: string) {
return page.locator('.group-nav-item').filter({
hasText: flexibleTextPattern(groupTitle),
});
}
async function ensureGroupSelected(
page: Page,
groupTitle: string
): Promise<void> {
const header = groupPanelHeader(page, groupTitle).first();
await expect(header).toBeVisible();
if ((await header.getAttribute('aria-current')) !== 'true') {
await header.click();
}
}
function xtreamSearchResultCards(page: Page) {
return page.locator('app-search-results .results-grid .content-card');
}
function liveChannelSidebar(page: Page) {
return page.locator('app-live-stream-layout .sidebar');
}
function gridListCardByTitle(page: Page, title: string) {
return page.locator('.category-content-layout mat-card').filter({
has: page.locator('.title', {
hasText: flexibleTextPattern(title),
}),
});
}
async function clickGridListCardByTitle(
page: Page,
title: string
): Promise<void> {
const card = gridListCardByTitle(page, title).first();
await expect(card).toBeVisible({ timeout: 20000 });
await card.click();
}
function contentCardByTitle(page: Page, title: string) {
return page.locator('app-content-card').filter({
has: page.locator('h3', {
hasText: flexibleTextPattern(title),
}),
});
}
async function resolveVisibleXtreamTitles(
page: Page,
fixture: XtreamVodFixture
): Promise<{
controlTitle: string;
targetTitle: string;
}> {
const visibleTitles = (
await page
.locator('.category-content-layout mat-card .title')
.allTextContents()
)
.map((title) => title.trim())
.filter((title) => title.length > 0);
const matchedTitles = fixture.candidateTitles
.map((title) => {
const normalizedTitle = normalizeSearchText(title);
return (
visibleTitles.find(
(visibleTitle) =>
normalizeSearchText(visibleTitle) === normalizedTitle
) ?? null
);
})
.filter((title): title is string => Boolean(title));
const uniqueMatchedTitles = [...new Set(matchedTitles)];
if (uniqueMatchedTitles.length < 2) {
throw new Error(
`Expected at least two visible Xtream titles from fixture candidates ${fixture.candidateTitles.join(
', '
)}, but found ${visibleTitles.join(', ')}`
);
}
return {
controlTitle: uniqueMatchedTitles[1],
targetTitle: uniqueMatchedTitles[0],
};
}
async function expectXtreamRootCatalogSearch(
page: Page,
options: {
pathPattern: RegExp;
sample: XtreamVodFixture;
sectionLabel: string;
}
): Promise<void> {
await openWorkspaceSection(page, options.sectionLabel);
await expectPathname(page, options.pathPattern);
await fillWorkspaceSearch(page, options.sample.targetTitle);
await expectPathname(page, options.pathPattern);
await expectQueryParam(page, 'q', options.sample.targetTitle);
await expectWorkspaceSearchScope(
page,
`${options.sectionLabel} / All Items`
);
const contentLayout = page.locator('.category-content-layout');
await expect(contentLayout).toContainText(
flexibleTextPattern(options.sample.targetTitle),
{ timeout: 20000 }
);
await expect(contentLayout).not.toContainText(
flexibleTextPattern(options.sample.controlTitle)
);
}
// By accessible name, not class plus visible text: the Xtream movie detail's
// favorite control is an icon-only button whose label lives in aria-label,
// while series and Stalker details still use the labeled variant.
async function addCurrentDetailToFavorites(page: Page): Promise<void> {
const addButton = page
.getByRole('button', { name: /add to favorites/i })
.first();
await expect(addButton).toBeVisible({ timeout: 20000 });
await addButton.click();
await expect(
page.getByRole('button', { name: /remove from favorites/i }).first()
).toBeVisible({ timeout: 20000 });
}
async function goBackFromDetail(page: Page): Promise<void> {
// Return to the list: the header's Back is route-level in browse and
// watch alike (closing the player is the bar's own Close button).
const backButton = page.getByTestId('workspace-header-back');
await expect(backButton).toBeVisible({ timeout: 20000 });
await backButton.click();
}
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 expectInlinePlayerWithoutDialog(page: Page): Promise<void> {
await expect(
page.locator('app-portal-inline-player app-web-player-view').first()
).toBeVisible({ timeout: 20000 });
await expect(
page.locator('mat-dialog-container app-web-player-view')
).toHaveCount(0);
}
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[]
): Promise<string> {
for (let attempt = 0; attempt < 3; attempt += 1) {
for (const query of queries) {
await fillWorkspaceSearch(page, query, {
submit: true,
});
await expectPathname(page, /\/workspace\/xtreams\/[^/]+\/search$/);
await expectQueryParam(page, 'q', query);
try {
await expect(xtreamSearchResultCards(page).first()).toBeVisible(
{
timeout: 5000,
}
);
return query;
} catch {
// Try the next candidate token.
}
}
if (attempt < 2) {
await page.waitForTimeout(1500);
}
}
throw new Error(
`Expected Xtream playlist search to return results for at least one candidate query: ${queries.join(
', '
)}`
);
}
function channelItemByTitle(page: Page, title: string) {
return page.getByTestId('channel-item').filter({
has: page.locator('.channel-name', {
hasText: flexibleTextPattern(title),
}),
});
}
async function toggleFavoriteForChannel(
page: Page,
title: string
): Promise<void> {
const item = channelItemByTitle(page, title).first();
await expect(item).toBeVisible();
await item.hover();
const favoriteButton = item.locator('.favorite-button').first();
await expect(favoriteButton).toBeVisible();
await favoriteButton.click();
await expect(favoriteButton.locator('mat-icon')).toHaveText(/star/);
}
function getXtreamTitle(item: XtreamVodStream): string {
return `${item.title ?? item.name ?? ''}`.trim();
}
function getXtreamItemId(item: XtreamVodStream): string {
return String(
item.xtream_id ?? item.series_id ?? item.stream_id ?? item.id ?? ''
).trim();
}
function getStalkerTitle(item: StalkerContentItem): string {
return `${item.o_name ?? item.name ?? ''}`.trim();
}
function pickDistinctItems<T>(
items: T[],
getTitle: (item: T) => string,
preferredTitle?: (title: string) => boolean
): [T, T] {
const titledItems = items.filter((item) => getTitle(item).length > 0);
const preferredItems = preferredTitle
? titledItems.filter((item) => preferredTitle(getTitle(item)))
: titledItems;
const target = (preferredItems[0] ?? titledItems[0]) as T | undefined;
if (!target) {
throw new Error('Expected at least two items with distinct titles.');
}
const control =
preferredItems.find((item) => getTitle(item) !== getTitle(target)) ??
titledItems.find((item) => getTitle(item) !== getTitle(target));
if (!control) {
throw new Error('Expected at least two items with distinct titles.');
}
return [target, control];
}
function createUniquePrefix(targetTitle: string, controlTitle: string): string {
const normalizedControl = controlTitle.toLowerCase();
const trimmedTarget = targetTitle.trim();
for (let length = 3; length <= trimmedTarget.length; length += 1) {
const candidate = trimmedTarget.slice(0, length).trim();
if (candidate.length < 3) {
continue;
}
if (!normalizedControl.includes(candidate.toLowerCase())) {
return candidate;
}
}
return trimmedTarget;
}
function createUniqueSearchToken(
targetTitle: string,
controlTitle: string
): string {
const normalizedControl = normalizeSearchText(controlTitle);
const targetWords = normalizeSearchText(targetTitle)
.split(/\s+/)
.filter((word) => word.length >= 3);
for (const word of targetWords) {
if (!normalizedControl.includes(word)) {
return word;
}
}
return createUniquePrefix(
normalizeSearchText(targetTitle),
normalizeSearchText(controlTitle)
);
}
function buildXtreamSearchQueries(
targetTitle: string,
controlTitle: string
): string[] {
const normalizedTarget = normalizeSearchText(targetTitle);
const normalizedControl = normalizeSearchText(controlTitle);
const queries = new Set<string>();
queries.add(createUniqueSearchToken(targetTitle, controlTitle));
for (const word of normalizedTarget.split(/\s+/)) {
if (word.length < 3) {
continue;
}
if (!normalizedControl.includes(word)) {
queries.add(word);
}
}
for (const word of normalizedTarget.split(/\s+/)) {
if (word.length >= 3) {
queries.add(word);
}
}
queries.add(normalizedTarget);
return [...queries].filter((query) => query.trim().length >= 3);
}
function normalizeSearchText(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
function isStableXtreamSearchTitle(title: string): boolean {
return (
/^[a-z0-9 ]+$/i.test(title.trim()) &&
normalizeSearchText(title)
.split(/\s+/)
.some((word) => word.length >= 3)
);
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function flexibleTextPattern(value: string): RegExp {
return new RegExp(
value
.trim()
.split(/\s+/)
.map((part) => escapeRegex(part))
.join('\\s+')
);
}
async function expectPathname(page: Page, pattern: RegExp): Promise<void> {
await expect.poll(() => new URL(page.url()).pathname).toMatch(pattern);
}
async function expectQueryParam(
page: Page,
name: string,
value: string
): Promise<void> {
await expect
.poll(() => new URL(page.url()).searchParams.get(name))
.toBe(value);
}
async function expectQueryParamAbsent(page: Page, name: string): Promise<void> {
await expect
.poll(() => new URL(page.url()).searchParams.has(name))
.toBe(false);
}