diff --git a/apps/electron-backend-e2e/src/category-management.e2e.ts b/apps/electron-backend-e2e/src/category-management.e2e.ts index a7696d3a9..63d72d9ac 100644 --- a/apps/electron-backend-e2e/src/category-management.e2e.ts +++ b/apps/electron-backend-e2e/src/category-management.e2e.ts @@ -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 { + 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(); 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 = diff --git a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts index 1497d016a..0c2422e68 100644 --- a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts +++ b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts @@ -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 { - 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 { async function playFirstSeriesEpisode(page: Page): Promise { 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 { await episodeCard.click(); } -async function toggleFavoriteForChannel(page: Page, title: string): Promise { +async function toggleFavoriteForChannel( + page: Page, + title: string +): Promise { const item = page .locator('[data-test-id="channel-item"]') .filter({ hasText: title }) @@ -175,5 +201,7 @@ async function toggleFavoriteForChannel(page: Page, title: string): Promise = { 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 { + 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( 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 diff --git a/apps/electron-backend-e2e/src/sources.e2e.ts b/apps/electron-backend-e2e/src/sources.e2e.ts index 7d91c0907..2deeb8b40 100644 --- a/apps/electron-backend-e2e/src/sources.e2e.ts +++ b/apps/electron-backend-e2e/src/sources.e2e.ts @@ -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(); diff --git a/apps/electron-backend-e2e/src/xtream-epg.e2e.ts b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts new file mode 100644 index 000000000..59876cc51 --- /dev/null +++ b/apps/electron-backend-e2e/src/xtream-epg.e2e.ts @@ -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[0]) { + return page + .locator('app-epg-list .program-title') + .allInnerTexts() + .then((titles) => titles.map((title) => title.trim()).filter(Boolean)); +} + +async function getProgressWidthPercent(row: ReturnType) { + const style = await row.locator('.epg-progress-fill').getAttribute('style'); + const width = style?.match(/width:\s*([\d.]+)%/)?.[1] ?? '0'; + return Number.parseFloat(width); +} diff --git a/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts b/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts index fd621e65c..00efcd744 100644 --- a/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-responsiveness.e2e.ts @@ -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, { diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index 7152fa0e9..6db10027f 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -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 { + 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', ''); +}