feat: enhance EPG functionality with new types, fixtures, and tests for Xtream integration

This commit is contained in:
4gray committed 2026-04-05 19:05:03 +02:00
1 parent 0a8a9c9637
commit c42ddeaef2
7 files changed
+717 -83

No files matched your search

@@ -48,9 +48,9 @@ test.describe('Electron Xtream Category Management', () => {
exact: true,
})
.click();
await expect(dialog.locator('.category-item mat-checkbox input:checked')).toHaveCount(
0
);
await expect(
dialog.locator('.category-item mat-checkbox input:checked')
).toHaveCount(0);
await dialog
.getByRole('button', {
@@ -59,19 +59,25 @@ test.describe('Electron Xtream Category Management', () => {
})
.click();
await expect(
dialog.locator('.category-item mat-checkbox input:checked').first()
dialog
.locator('.category-item mat-checkbox input:checked')
.first()
).toBeVisible();
await dialog.locator('.search-field input').fill(targetCategory.name);
await dialog
.locator('input[type="search"]')
.fill(targetCategory.name);
await toggleManagedCategory(dialog, targetCategory, false);
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
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 expect(
sidebarCategoryById(app.mainWindow, targetCategory.id)
).toHaveCount(0);
await openSources(app.mainWindow);
await refreshSource(app.mainWindow, portalName, { confirm: true });
@@ -89,26 +95,34 @@ test.describe('Electron Xtream Category Management', () => {
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/live/
);
await expect(sidebarCategoryById(app.mainWindow, targetCategory.id)).toHaveCount(
0
);
await expect(
sidebarCategoryById(app.mainWindow, targetCategory.id)
).toHaveCount(0);
dialog = await openManageCategoriesDialog(app.mainWindow);
await dialog.locator('.search-field input').fill(targetCategory.name);
await dialog
.locator('input[type="search"]')
.fill(targetCategory.name);
await toggleManagedCategory(dialog, targetCategory, true);
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
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('.search-field input').fill(targetCategory.name);
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 dialog
.getByRole('button', { name: 'Close', exact: true })
.click();
await app.mainWindow.waitForSelector('mat-dialog-container', {
state: 'detached',
});
@@ -148,9 +162,13 @@ test.describe('Electron Xtream Category Management', () => {
dialog.locator('.category-item mat-checkbox input:checked')
).toHaveCount(0);
await dialog.locator('.search-field input').fill(targetCategory.name);
await dialog
.locator('input[type="search"]')
.fill(targetCategory.name);
await toggleManagedCategory(dialog, targetCategory, true);
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await dialog
.getByRole('button', { name: 'Save', exact: true })
.click();
await app.mainWindow.waitForSelector('mat-dialog-container', {
state: 'detached',
});
@@ -165,8 +183,8 @@ test.describe('Electron Xtream Category Management', () => {
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/live/
);
await expectVisibleSidebarCategoryIds(app.mainWindow, [
targetCategory.id,
await expectVisibleSidebarCategoryNames(app.mainWindow, [
targetCategory.name,
]);
const restarted = await restartElectronApp(app, dataDir);
@@ -180,8 +198,8 @@ test.describe('Electron Xtream Category Management', () => {
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/live/
);
await expectVisibleSidebarCategoryIds(app.mainWindow, [
targetCategory.id,
await expectVisibleSidebarCategoryNames(app.mainWindow, [
targetCategory.name,
]);
} finally {
await closeElectronApp(app);
@@ -245,6 +263,33 @@ async function expectVisibleSidebarCategoryIds(
expect(actualIds).toEqual(expectedIds);
}
async function expectVisibleSidebarCategoryNames(
page: Page,
expectedNames: string[]
): Promise<void> {
const categories = page.locator(
'app-workspace-context-panel .category-item:visible'
);
const actualNames: string[] = [];
const count = await categories.count();
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);
}
}
expect(actualNames).toEqual(expectedNames);
}
async function pickSidebarCategory(
page: Page
): Promise<{ id: string; itemCount: number; name: string }> {
@@ -252,13 +297,16 @@ async function pickSidebarCategory(
'app-workspace-context-panel .category-item:visible'
);
const count = await categories.count();
const candidates: Array<{ id: string; itemCount: number; name: string }> = [];
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 id =
(await category.getAttribute('data-category-id'))?.trim() ?? '';
const name =
(await category.locator('.nav-item-label').textContent())?.trim() ?? '';
(await category.locator('.nav-item-label').textContent())?.trim() ??
'';
const countText =
(await category.locator('.item-count').textContent())?.trim() ?? '';
const itemCount = Number.parseInt(countText, 10) || 0;
@@ -270,7 +318,10 @@ async function pickSidebarCategory(
const nameCounts = new Map<string, number>();
for (const candidate of candidates) {
nameCounts.set(candidate.name, (nameCounts.get(candidate.name) ?? 0) + 1);
nameCounts.set(
candidate.name,
(nameCounts.get(candidate.name) ?? 0) + 1
);
}
const preferredCandidate =
@@ -28,13 +28,22 @@ test.describe('Dashboard Activation', () => {
request,
}) => {
await resetMockServers(request, ['xtream']);
const liveFixture = await fetchXtreamLiveFixture(request, xtreamCredentials);
const vodFixture = await fetchXtreamVodFixture(request, xtreamCredentials);
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 [liveTitle] = pickDistinctTitles(
liveFixture.items,
getXtreamTitle
);
const app = await launchElectronApp(dataDir);
try {
@@ -50,7 +59,9 @@ test.describe('Dashboard Activation', () => {
);
await toggleFavoriteForChannel(app.mainWindow, liveTitle);
await app.mainWindow.getByRole('link', { name: 'Movies', exact: true }).click();
await app.mainWindow
.getByRole('link', { name: 'Movies', exact: true })
.click();
await clickCategoryByNameExact(
app.mainWindow,
vodFixture.categoryName
@@ -59,7 +70,9 @@ test.describe('Dashboard Activation', () => {
await playCurrentDetail(app.mainWindow);
await goBackFromDetail(app.mainWindow);
await app.mainWindow.getByRole('link', { name: 'Series', exact: true }).click();
await app.mainWindow
.getByRole('link', { name: 'Series', exact: true })
.click();
await clickCategoryByNameExact(
app.mainWindow,
seriesFixture.categoryName
@@ -74,7 +87,9 @@ test.describe('Dashboard Activation', () => {
'Global Favorites',
liveTitle
).click();
await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/favorites$/);
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/favorites$/
);
await expect(
app.mainWindow.locator('.player-toolbar .close-btn').first()
).toBeVisible({ timeout: 20000 });
@@ -87,10 +102,12 @@ test.describe('Dashboard Activation', () => {
'Recently Watched',
movieTitle
).click();
await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/);
await expect(app.mainWindow.locator('app-content-hero')).toContainText(
movieTitle
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/vod\/[^/]+\/[^/]+$/
);
await expect(
app.mainWindow.locator('app-content-hero')
).toContainText(movieTitle);
await app.mainWindow.goBack();
await app.mainWindow.waitForURL(/\/workspace\/dashboard$/);
@@ -100,10 +117,12 @@ test.describe('Dashboard Activation', () => {
'Recently Watched',
seriesTitle
).click();
await app.mainWindow.waitForURL(/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/);
await expect(app.mainWindow.locator('app-content-hero')).toContainText(
seriesTitle
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/
);
await expect(
app.mainWindow.locator('app-content-hero')
).toContainText(seriesTitle);
} finally {
await closeElectronApp(app);
}
@@ -130,7 +149,9 @@ function dashboardActivityItemByTitle(
}
async function goBackFromDetail(page: Page): Promise<void> {
const backButton = page.locator('app-content-hero .hero__back-button').first();
const backButton = page
.locator('app-content-hero .hero__back-button')
.first();
await expect(backButton).toBeVisible({ timeout: 20000 });
await backButton.click();
@@ -145,7 +166,9 @@ async function playCurrentDetail(page: Page): Promise<void> {
async function playFirstSeriesEpisode(page: Page): Promise<void> {
const seasonCard = page.locator('.season-card').first();
const episodeCard = page.locator('.episode-card, .episode-list-item').first();
const episodeCard = page
.locator('.episode-card, .episode-list-item')
.first();
await expect
.poll(
@@ -166,7 +189,10 @@ async function playFirstSeriesEpisode(page: Page): Promise<void> {
await episodeCard.click();
}
async function toggleFavoriteForChannel(page: Page, title: string): Promise<void> {
async function toggleFavoriteForChannel(
page: Page,
title: string
): Promise<void> {
const item = page
.locator('[data-test-id="channel-item"]')
.filter({ hasText: title })
@@ -175,5 +201,7 @@ async function toggleFavoriteForChannel(page: Page, title: string): Promise<void
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/);
await expect(item.locator('.favorite-button mat-icon').first()).toHaveText(
/star/
);
}
@@ -14,8 +14,12 @@ export type XtreamCategory = {
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 = {
@@ -40,6 +44,44 @@ export type XtreamCategoryFixture<T> = {
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;
@@ -104,6 +146,60 @@ export async function fetchXtreamSeriesFixture(
});
}
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'
@@ -261,6 +357,44 @@ async function fetchJson<T>(
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>
+67 -25
View File
@@ -45,13 +45,17 @@ test.describe('Electron Sources View', () => {
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 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
@@ -67,7 +71,10 @@ https://streams.example.test/url-omega.m3u8
try {
await importM3uPlaylistFromNativeDialog(app, localFilePath);
await importM3uPlaylistFromUrl(app.mainWindow, urlServer.resourceUrl);
await importM3uPlaylistFromUrl(
app.mainWindow,
urlServer.resourceUrl
);
await addXtreamPortal(app.mainWindow, {
name: xtreamSourceName,
});
@@ -223,7 +230,10 @@ https://streams.example.test/url-omega.m3u8
});
await saveSourceDialog(app.mainWindow, dialog);
dialog = await openSourceEditor(app.mainWindow, 'Editable Xtream Source');
dialog = await openSourceEditor(
app.mainWindow,
'Editable Xtream Source'
);
await updateSourceDialog(dialog, {
password: 'pass1',
serverUrl: 'http://127.0.0.1:3211',
@@ -232,7 +242,10 @@ https://streams.example.test/url-omega.m3u8
});
await saveSourceDialog(app.mainWindow, dialog);
dialog = await openSourceEditor(app.mainWindow, 'Editable Stalker Source');
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',
@@ -240,30 +253,45 @@ https://streams.example.test/url-omega.m3u8
});
await saveSourceDialog(app.mainWindow, dialog);
dialog = await openSourceEditor(app.mainWindow, 'Edited Local Source');
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();
await dialog
.getByRole('button', { name: 'Close', exact: true })
.click();
dialog = await openSourceEditor(app.mainWindow, 'Edited Xtream Source');
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();
await dialog
.getByRole('button', { name: 'Close', exact: true })
.click();
dialog = await openSourceEditor(app.mainWindow, 'Edited Stalker Source');
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();
await dialog
.getByRole('button', { name: 'Close', exact: true })
.click();
const restarted = await restartElectronApp(app, dataDir);
app.electronApp = restarted.electronApp;
@@ -271,24 +299,37 @@ https://streams.example.test/url-omega.m3u8
await openSources(app.mainWindow);
dialog = await openSourceEditor(app.mainWindow, 'Edited Local Source');
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();
await dialog
.getByRole('button', { name: 'Close', exact: true })
.click();
dialog = await openSourceEditor(app.mainWindow, 'Edited Xtream Source');
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();
await dialog
.getByRole('button', { name: 'Close', exact: true })
.click();
dialog = await openSourceEditor(app.mainWindow, 'Edited Stalker Source');
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',
@@ -327,10 +368,7 @@ https://streams.example.test/url-omega.m3u8
});
await openSources(app.mainWindow);
await deleteSource(
app.mainWindow,
deletableLocalSourceDisplayName
);
await deleteSource(app.mainWindow, deletableLocalSourceDisplayName);
await expect(
sourceRowByTitle(
app.mainWindow,
@@ -428,6 +466,10 @@ 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();
@@ -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);
}
@@ -53,10 +53,14 @@ test.describe('Electron Xtream Responsiveness', () => {
username: stressXtreamUsername,
});
const overlay = app.mainWindow.locator('.workspace-loading-overlay');
const overlay = app.mainWindow.locator(
'.workspace-loading-overlay'
);
await expect(overlay).toBeVisible({ timeout: 15000 });
await expect(
overlay.getByRole('button', { name: /cancel import/i })
overlay.getByRole('button', {
name: /stop sync|cancel import/i,
})
).toBeVisible({ timeout: 20000 });
await waitForDbOperationEvent(app.mainWindow, {
@@ -71,15 +75,14 @@ test.describe('Electron Xtream Responsiveness', () => {
await expect
.poll(
async () =>
(
await app.mainWindow.evaluate(() =>
(await app.mainWindow.evaluate(
() =>
(window.__dbOperationEvents ?? []).filter(
(event) =>
event.operation === 'save-content' &&
event.status === 'progress'
).length
)
) > 2,
)) > 2,
{ timeout: 30000 }
)
.toBeTruthy();
@@ -125,9 +128,11 @@ test.describe('Electron Xtream Responsiveness', () => {
username: stressXtreamUsername,
});
const overlay = app.mainWindow.locator('.workspace-loading-overlay');
const overlay = app.mainWindow.locator(
'.workspace-loading-overlay'
);
const cancelButton = overlay.getByRole('button', {
name: /cancel import/i,
name: /stop sync|cancel import/i,
});
await expect(overlay).toBeVisible({ timeout: 15000 });
@@ -145,9 +150,10 @@ test.describe('Electron Xtream Responsiveness', () => {
await cancelButton.click();
await expect(overlay).toHaveCount(0, { timeout: 20000 });
await expect(
xtreamBlockedStateTitle(app.mainWindow)
).toHaveText('Import cancelled', { timeout: 20000 });
await expect(xtreamBlockedStateTitle(app.mainWindow)).toHaveText(
'Import cancelled',
{ timeout: 20000 }
);
await app.mainWindow.waitForTimeout(1200);
await expect(overlay).toHaveCount(0);
@@ -267,7 +273,9 @@ test.describe('Electron Xtream Responsiveness', () => {
await playlistRow.locator('.delete-btn').click();
const confirmDialog = app.mainWindow.locator('mat-dialog-container');
const confirmDialog = app.mainWindow.locator(
'mat-dialog-container'
);
await expect(confirmDialog).toBeVisible();
await confirmDialog
.getByRole('button', { name: 'Yes', exact: true })
@@ -279,9 +287,9 @@ test.describe('Electron Xtream Responsiveness', () => {
timeoutMs: 20000,
});
await expect(playlistRow.locator('.busy-state__message')).toBeVisible(
{ timeout: 20000 }
);
await expect(
playlistRow.locator('.busy-state__message')
).toBeVisible({ timeout: 20000 });
await expect(playlistRow.locator('.cancel-btn')).toBeVisible();
await waitForDbOperationEvent(app.mainWindow, {
+143 -1
View File
@@ -1,4 +1,4 @@
import { test, expect, Page } from '@playwright/test';
import { APIRequestContext, test, expect, Page } from '@playwright/test';
/**
* Xtream Codes E2E Tests
@@ -21,6 +21,8 @@ const MOCK_SERVER = `http://localhost:${XTREAM_MOCK_PORT}`;
/** Default scenario credentials */
const DEFAULT_USERNAME = 'user1';
const DEFAULT_PASSWORD = 'pass1';
const EPG_USERNAME = 'epg';
const EPG_PASSWORD = 'epg';
// ---------------------------------------------------------------------------
// Helpers
@@ -282,6 +284,92 @@ test('@xtream get_short_epg — returns base64-encoded listings', async ({
expect(listing.stop_timestamp).toBeDefined();
});
test('@xtream epg fixture — short epg starts at the current program, respects limit, and exposes timestamp-vs-string mismatches', async ({
request,
}) => {
const stream = await getEpgFixtureStream(request);
const response = await request.get(
`${MOCK_SERVER}/player_api.php?username=${EPG_USERNAME}&password=${EPG_PASSWORD}&action=get_short_epg&stream_id=${stream.stream_id}&limit=2`
);
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.epg_listings).toHaveLength(2);
const [currentListing, nextListing] = body.epg_listings;
expect(decodeXtreamText(currentListing.title)).toBe('Global Headlines');
expect(decodeXtreamText(nextListing.title)).toBe('Market Wrap');
const now = Math.floor(Date.now() / 1000);
expect(Number.parseInt(currentListing.start_timestamp, 10)).toBeLessThanOrEqual(
now
);
expect(Number.parseInt(currentListing.stop_timestamp, 10)).toBeGreaterThanOrEqual(
now
);
expect(currentListing.start).not.toBe(
formatXtreamDateTime(
Number.parseInt(currentListing.start_timestamp, 10)
)
);
expect(currentListing.end).not.toBe(
formatXtreamDateTime(
Number.parseInt(currentListing.stop_timestamp, 10)
)
);
});
test('@xtream epg fixture — full epg and legacy alias return the same ordered schedule with a midnight boundary program', async ({
request,
}) => {
const stream = await getEpgFixtureStream(request);
const fullResponse = await request.get(
`${MOCK_SERVER}/player_api.php?username=${EPG_USERNAME}&password=${EPG_PASSWORD}&action=get_simple_data_table&stream_id=${stream.stream_id}`
);
const aliasResponse = await request.get(
`${MOCK_SERVER}/player_api.php?username=${EPG_USERNAME}&password=${EPG_PASSWORD}&action=get_simple_date_table&stream_id=${stream.stream_id}`
);
expect(fullResponse.ok()).toBeTruthy();
expect(aliasResponse.ok()).toBeTruthy();
const fullBody = await fullResponse.json();
const aliasBody = await aliasResponse.json();
expect(aliasBody).toEqual(fullBody);
const titles = fullBody.epg_listings.map((listing: XtreamRawEpgListing) =>
decodeXtreamText(listing.title)
);
expect(titles).toEqual([
'Earlier Bulletin',
'Global Headlines',
'Market Wrap',
'Overnight Update',
'Late Edition',
'After Midnight',
]);
const boundaryListing = fullBody.epg_listings.find(
(listing: XtreamRawEpgListing) =>
decodeXtreamText(listing.title) === 'Late Edition'
);
if (!boundaryListing) {
throw new Error('Expected the full EPG fixture to include Late Edition.');
}
const boundaryStart = new Date(
Number.parseInt(boundaryListing.start_timestamp, 10) * 1000
);
const boundaryStop = new Date(
Number.parseInt(boundaryListing.stop_timestamp, 10) * 1000
);
expect(boundaryStart.getUTCDate()).not.toBe(boundaryStop.getUTCDate());
});
test('@xtream reset — data regenerates identically after reset', async ({
request,
}) => {
@@ -357,3 +445,57 @@ test('@xtream minimal scenario — reduced item count', async ({ request }) => {
// 2 categories × 5 items = 10
expect(streams.length).toBe(10);
});
type XtreamLiveStream = {
category_id: string;
name: string;
stream_id: number;
};
type XtreamRawEpgListing = {
end: string;
start: string;
start_timestamp: string;
stop_timestamp: string;
title: string;
};
async function getEpgFixtureStream(
request: APIRequestContext
): Promise<XtreamLiveStream> {
const categories = (await (
await request.get(
`${MOCK_SERVER}/player_api.php?username=${EPG_USERNAME}&password=${EPG_PASSWORD}&action=get_live_categories`
)
).json()) as Array<{ category_id: string; category_name: string }>;
const epgCategory = categories.find(
(category) => category.category_name === 'EPG Focus'
);
if (!epgCategory) {
throw new Error('Expected the EPG fixture category to exist.');
}
const streams = (await (
await request.get(
`${MOCK_SERVER}/player_api.php?username=${EPG_USERNAME}&password=${EPG_PASSWORD}&action=get_live_streams&category_id=${epgCategory.category_id}`
)
).json()) as XtreamLiveStream[];
const stream = streams.find((item) => item.name === 'Timezone News');
if (!stream) {
throw new Error('Expected the EPG fixture stream to exist.');
}
return stream;
}
function decodeXtreamText(value: string): string {
return Buffer.from(value, 'base64').toString('utf-8');
}
function formatXtreamDateTime(timestampSeconds: number): string {
return new Date(timestampSeconds * 1000)
.toISOString()
.replace('T', ' ')
.replace('.000Z', '');
}