Files
iptvnator/apps/electron-backend-e2e/src/xtream-epg.e2e.ts
T
4grayandClaude Fable 5 d5f84fb130 feat(xtream): catch-up badge for live channels with archive (#1341)
* feat(xtream): show a catch-up badge on live channels that have archive

Live channels whose provider declares playable catch-up (tv_archive=1
with a positive tv_archive_duration) now show a small history badge in
the channel sidebar next to the name and on the all-channels grid cards,
with the archive window (days) in the tooltip.

Closes #1128

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(xtream): expose the catch-up badge status to assistive technology

The mat-icon is aria-hidden and the tooltip is pointer-only, so the
badge status was invisible to keyboard and screen-reader users. Both
badge surfaces now also render the translated status as visually-hidden
text (Codex review, P2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(xtream): show the catch-up badge in favorites and recent lists

Carries tvArchive/tvArchiveDuration through UnifiedFavoriteChannel so
the shared favorites list (portal favorites/recent tabs and global
favorites) renders the same catch-up badge as the live sidebar.
Requested in PR feedback by the issue author.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(ui): show the programme-info button in portal sidebars, stacked vertically

Adds the (i) programme-info button to the Xtream and Stalker live
sidebars and reworks the row action column: buttons stack vertically
(favorite on top, info below), so the second button costs no horizontal
space — the column is actually narrower than the previous single-button
row. The info slot is reserved (inert, visibility:hidden) while the row
has no programme, so the star never shifts when EPG data arrives.
Requested by the issue author in PR feedback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(ui): move the archive passthrough spec out of the budget-capped file

CI lints the merge with master, where unified-live-tab.component.spec.ts
grew (#1374) to one line under the 1200 max-lines test budget — the
archive passthrough test added here tipped the merged result over. The
test moves to a focused template-less spec (plus a null-normalisation
case), leaving the main spec at master's size.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): constrain Material touch targets to the stacked button bounds

mat-icon-button keeps a 48px touch target; stacked 28px buttons
overlapped by 20px and the later sibling (programme info) stole clicks
from the lower third of the favorite star. Verified via
document.elementFromPoint before/after: the star's visual bounds now hit
the star, and clicks left of the column reach the row again instead of
the button's oversized target (Codex review).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 10:47:44 +02:00

608 lines
21 KiB
TypeScript

import type { Locator } from '@playwright/test';
import {
addStalkerPortal,
addXtreamPortal,
channelItemByTitle,
clickCategoryByNameExact,
closeElectronApp,
expect,
goToDashboard,
launchElectronApp,
openSettings,
openSettingsSection,
openWorkspaceSection,
resetMockServers,
saveSettings,
test,
waitForStalkerCatalog,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import {
fetchStalkerCategoryFixture,
fetchXtreamEpgFixture,
} 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(`@epg @xtream @electron renders Xtream EPG previews and the timeline 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 });
// Provider-declared catch-up (tv_archive=1 in the fixture) is
// surfaced as a badge on the sidebar row (#1128).
await expect(
channelRow.getByTestId('catchup-badge')
).toBeVisible();
// Sidebar channel list shows the per-channel "now" programme line.
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-timeline')
).toBeVisible({ timeout: 20000 });
// The timeline renders the full multi-day window as blocks,
// sorted by start time (no per-day filtering — it scrolls).
const allTitles = [...fixture.fullEpg]
.sort((a, b) => a.startTimestamp - b.startTimestamp)
.map((listing) => listing.title);
await expect
.poll(() => timelineBlockTitles(app.mainWindow))
.toEqual(allTitles);
// The current programme is highlighted as the "now" block.
await expect(
app.mainWindow
.locator(
'app-epg-timeline .epg-timeline__block.is-now .epg-timeline__block-title'
)
.first()
).toHaveText(currentProgram.title);
// The date stepper advances the day label.
const dayLabel = app.mainWindow
.locator('app-epg-timeline .epg-timeline__day small')
.first();
const beforeLabel = (await dayLabel.textContent())?.trim();
await app.mainWindow
.locator(
'app-epg-timeline .epg-timeline__stepper .epg-timeline__nav'
)
.last()
.click();
await expect
.poll(async () => (await dayLabel.textContent())?.trim())
.not.toBe(beforeLabel);
} finally {
await closeElectronApp(app);
}
});
}
test('@epg @xtream @electron keeps EPG context and actions at narrow channel-row widths', 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: 'UTC' },
});
try {
await addXtreamPortal(app.mainWindow, {
name: `${epgPortalName} Narrow`,
username: epgCredentials.username,
password: epgCredentials.password,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
const currentProgramRow = channelItemByTitle(
app.mainWindow,
fixture.stream.name ?? ''
).first();
const noProgramRow = channelItemByTitle(
app.mainWindow,
'Night Sports'
).first();
await expect(currentProgramRow).toBeVisible({ timeout: 20000 });
await expect(noProgramRow).toBeVisible({ timeout: 20000 });
const currentTitle = currentProgramRow.locator('.epg-title');
const progressTrack = currentProgramRow.locator('.epg-progress-track');
const favoriteAction = currentProgramRow.locator('.favorite-button');
const placeholder = noProgramRow.locator('.epg-placeholder');
const logo = currentProgramRow.locator('.channel-logo-shell');
const timeLabels = currentProgramRow.locator('.epg-time');
await setPortalChannelItemWidth(app.mainWindow, 300);
await expect(currentTitle).toHaveText(currentProgram.title);
await expect(currentTitle).toBeVisible();
await expect(progressTrack).toBeVisible();
await expect(favoriteAction).toBeVisible();
await expect(placeholder).toBeVisible();
await expect(logo).toBeVisible();
await expect(timeLabels).toHaveCount(2);
await expect(timeLabels.first()).toBeVisible();
await expect(timeLabels.last()).toBeHidden();
await expectPortalRowHeightAndStride(currentProgramRow, 'Night Sports');
await expectPortalRowHeightAndStride(noProgramRow, 'Archive Cinema');
await expectTimelineStartAndProgressAligned(currentProgramRow);
await expectNarrowRowContentFits(currentProgramRow);
await expectNarrowRowContentFits(noProgramRow);
await setPortalChannelItemWidth(app.mainWindow, 232);
await expect(currentTitle).toHaveText(currentProgram.title);
await expect(currentTitle).toBeVisible();
await expect(progressTrack).toBeVisible();
await expect(favoriteAction).toBeVisible();
await expect(placeholder).toBeVisible();
await expect(logo).toBeHidden();
await expect(timeLabels).toHaveCount(2);
await expect(timeLabels.first()).toBeVisible();
await expect(timeLabels.last()).toBeHidden();
await expectPortalRowHeightAndStride(currentProgramRow, 'Night Sports');
await expectPortalRowHeightAndStride(noProgramRow, 'Archive Cinema');
await expectTimelineStartAndProgressAligned(currentProgramRow);
await expectNarrowRowContentFits(currentProgramRow);
await expectNarrowRowContentFits(noProgramRow);
await setPortalChannelItemWidth(app.mainWindow, 200);
await expect(currentTitle).toHaveText(currentProgram.title);
await expect(currentTitle).toBeVisible();
await expect(progressTrack).toBeVisible();
await expect(favoriteAction).toBeVisible();
await expect(placeholder).toBeVisible();
await expect(logo).toBeHidden();
await expect(timeLabels.first()).toBeHidden();
await expect(timeLabels.last()).toBeHidden();
await expectPortalRowHeightAndStride(currentProgramRow, 'Night Sports');
await expectPortalRowHeightAndStride(noProgramRow, 'Archive Cinema');
await expectNarrowRowContentFits(currentProgramRow);
await expectNarrowRowContentFits(noProgramRow);
} finally {
await closeElectronApp(app);
}
});
test('@radio @stalker @electron keeps radio rows compact at narrow widths', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['stalker']);
const fixture = await fetchStalkerCategoryFixture(request, 'radio');
const [firstItem, secondItem] = fixture.items;
const firstTitle = firstItem?.o_name || firstItem?.name;
const secondTitle = secondItem?.o_name || secondItem?.name;
if (!firstTitle || !secondTitle) {
throw new Error(
'Expected the Stalker radio fixture to include two named stations.'
);
}
const app = await launchElectronApp(dataDir);
try {
await addStalkerPortal(app.mainWindow, {
name: 'Stalker Radio Row Fixture',
});
await waitForStalkerCatalog(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Radio');
const categoryButton = app.mainWindow.getByRole('button', {
name: fixture.categoryName,
exact: true,
});
await expect(categoryButton).toBeVisible();
await categoryButton.click();
const radioRow = channelItemByTitle(
app.mainWindow,
firstTitle
).first();
await expect(radioRow).toBeVisible({ timeout: 20000 });
await expect(radioRow).toHaveClass(/compact/);
await expect(radioRow.locator('.epg-placeholder')).toHaveCount(0);
await expect(radioRow.locator('.epg-title')).toHaveCount(0);
await expect(radioRow.locator('.epg-timeline')).toHaveCount(0);
await expectCompactRadioRowHeightAndStride(radioRow, secondTitle);
await setStalkerChannelItemWidth(app.mainWindow, 232);
await expect(radioRow.locator('.channel-logo-shell')).toBeVisible();
await expect(radioRow.locator('.favorite-button')).toBeVisible();
await expectCompactRadioRowHeightAndStride(radioRow, secondTitle);
await setStalkerChannelItemWidth(app.mainWindow, 200);
await expect(radioRow.locator('.channel-name')).toBeVisible();
await expect(radioRow.locator('.channel-logo-shell')).toBeHidden();
await expect(radioRow.locator('.favorite-button')).toBeHidden();
await expectCompactRadioRowHeightAndStride(radioRow, secondTitle);
} finally {
await closeElectronApp(app);
}
});
test('@epg @xtream @electron renders the vertical list view when the setting is "list"', 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);
try {
// Opt into the list view first (from the fresh workspace) so the portal
// → Live TV → channel flow afterwards mirrors the timeline test exactly.
await openSettings(app.mainWindow);
await openSettingsSection(app.mainWindow, 'epg');
await app.mainWindow
.locator('[data-test-id="epg-view-mode-list"]')
.click();
await saveSettings(app.mainWindow);
await goToDashboard(app.mainWindow);
await addXtreamPortal(app.mainWindow, {
name: `${epgPortalName} List`,
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 channelRow.click();
// The list view renders instead of the timeline.
await expect(app.mainWindow.locator('app-epg-list-view')).toBeVisible({
timeout: 20000,
});
await expect(app.mainWindow.locator('app-epg-timeline')).toHaveCount(0);
// The on-air programme is the highlighted "now" row.
await expect(
app.mainWindow
.locator('app-epg-list-view .g-row[data-when="now"] .title')
.first()
).toHaveText(currentProgram.title);
} 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));
}
async function setPortalChannelItemWidth(
page: Parameters<typeof channelItemByTitle>[0],
width: number
) {
const channelItems = page.locator(
'app-portal-channels-list app-channel-list-item'
);
await channelItems.evaluateAll((elements, itemWidth) => {
for (const element of elements) {
(element as HTMLElement).style.width = `${itemWidth}px`;
}
}, width);
await expect
.poll(() =>
channelItems
.first()
.evaluate((element) =>
Math.round(element.getBoundingClientRect().width)
)
)
.toBe(width);
}
async function setStalkerChannelItemWidth(
page: Parameters<typeof channelItemByTitle>[0],
width: number
) {
const channelItems = page.locator(
'app-stalker-live-stream-layout app-channel-list-item'
);
await channelItems.evaluateAll((elements, itemWidth) => {
for (const element of elements) {
(element as HTMLElement).style.width = `${itemWidth}px`;
}
}, width);
await expect
.poll(() =>
channelItems
.first()
.evaluate((element) =>
Math.round(element.getBoundingClientRect().width)
)
)
.toBe(width);
}
const portalChannelItemSize = 68;
const compactRadioItemSize = 52;
const stalkerChannelItemGap = 2;
const rowGeometryTolerance = 0.25;
const contentGeometryTolerance = 1;
type ElementBox = {
x: number;
y: number;
width: number;
height: number;
};
async function expectPortalRowHeightAndStride(
row: ReturnType<typeof channelItemByTitle>,
expectedFollowingTitle: string
) {
const followingRow = row
.locator('xpath=ancestor::app-channel-list-item')
.locator('xpath=following-sibling::app-channel-list-item[1]')
.getByTestId('channel-item');
await expect(followingRow.locator('.channel-name')).toHaveText(
expectedFollowingTitle
);
const [rowBox, followingRowBox] = await Promise.all([
row.boundingBox(),
followingRow.boundingBox(),
]);
if (!rowBox || !followingRowBox) {
throw new Error(
`Expected visible channel-row geometry before "${expectedFollowingTitle}" for the virtual-stride assertion.`
);
}
expect(Math.abs(rowBox.height - portalChannelItemSize)).toBeLessThanOrEqual(
rowGeometryTolerance
);
expect(
Math.abs(followingRowBox.y - rowBox.y - portalChannelItemSize)
).toBeLessThanOrEqual(rowGeometryTolerance);
}
async function expectCompactRadioRowHeightAndStride(
row: ReturnType<typeof channelItemByTitle>,
expectedFollowingTitle: string
) {
const followingRow = row
.locator('xpath=ancestor::app-channel-list-item')
.locator('xpath=following-sibling::app-channel-list-item[1]')
.getByTestId('channel-item');
await expect(followingRow.locator('.channel-name')).toHaveText(
expectedFollowingTitle
);
const [rowBox, followingRowBox] = await Promise.all([
row.boundingBox(),
followingRow.boundingBox(),
]);
if (!rowBox || !followingRowBox) {
throw new Error(
`Expected compact radio-row geometry before "${expectedFollowingTitle}".`
);
}
expect(Math.abs(rowBox.height - compactRadioItemSize)).toBeLessThanOrEqual(
rowGeometryTolerance
);
expect(
Math.abs(
followingRowBox.y -
rowBox.y -
compactRadioItemSize -
stalkerChannelItemGap
)
).toBeLessThanOrEqual(rowGeometryTolerance);
}
async function expectTimelineStartAndProgressAligned(
row: ReturnType<typeof channelItemByTitle>
) {
const [startTimeBox, progressBox] = await Promise.all([
row.locator('.epg-time').first().boundingBox(),
row.locator('.epg-progress-track').first().boundingBox(),
]);
if (!startTimeBox || !progressBox) {
throw new Error(
'Expected visible start-time and progress geometry in the narrow channel row.'
);
}
expect(
Math.abs(boxCenterY(startTimeBox) - boxCenterY(progressBox))
).toBeLessThanOrEqual(contentGeometryTolerance);
}
async function expectNarrowRowContentFits(
row: ReturnType<typeof channelItemByTitle>
) {
const [rowBox, detailsBox, actionsBox] = await Promise.all([
row.boundingBox(),
row.locator('.channel-details').boundingBox(),
row.locator('.action-buttons').boundingBox(),
]);
if (!rowBox || !detailsBox || !actionsBox) {
throw new Error(
'Expected visible narrow channel-row, details, and action geometry.'
);
}
expectBoxContainedWithin(detailsBox, rowBox);
expectBoxContainedWithin(actionsBox, rowBox);
expect(boxRight(detailsBox)).toBeLessThanOrEqual(
actionsBox.x + contentGeometryTolerance
);
await expectVisibleElementsContainedWithin(
row.locator(
'.channel-details > .channel-name:visible, .channel-details > .epg-title:visible, .channel-details > .epg-placeholder:visible, .channel-details > .epg-timeline:visible'
),
detailsBox,
'channel-details child'
);
await expectVisibleElementsContainedWithin(
row.locator('.action-buttons > button:visible'),
actionsBox,
'channel action button'
);
const progressTrack = row.locator('.epg-progress-track');
if ((await progressTrack.count()) > 0) {
const progressBox = await progressTrack.first().boundingBox();
if (!progressBox) {
throw new Error(
'Expected visible progress-track geometry in the narrow channel row.'
);
}
expectBoxContainedWithin(progressBox, detailsBox);
expect(progressBox.width).toBeGreaterThanOrEqual(24);
}
}
async function expectVisibleElementsContainedWithin(
elements: Locator,
outer: ElementBox,
description: string
) {
const count = await elements.count();
if (count === 0) {
throw new Error(`Expected at least one visible ${description}.`);
}
for (let index = 0; index < count; index += 1) {
const elementBox = await elements.nth(index).boundingBox();
if (!elementBox) {
throw new Error(
`Expected visible ${description} geometry at index ${index}.`
);
}
expectBoxContainedWithin(elementBox, outer);
}
}
function expectBoxContainedWithin(inner: ElementBox, outer: ElementBox) {
expect(inner.x).toBeGreaterThanOrEqual(outer.x - contentGeometryTolerance);
expect(inner.y).toBeGreaterThanOrEqual(outer.y - contentGeometryTolerance);
expect(boxRight(inner)).toBeLessThanOrEqual(
boxRight(outer) + contentGeometryTolerance
);
expect(boxBottom(inner)).toBeLessThanOrEqual(
boxBottom(outer) + contentGeometryTolerance
);
}
function boxRight(box: ElementBox) {
return box.x + box.width;
}
function boxBottom(box: ElementBox) {
return box.y + box.height;
}
function boxCenterY(box: ElementBox) {
return box.y + box.height / 2;
}
async function timelineBlockTitles(
page: Parameters<typeof channelItemByTitle>[0]
) {
return page
.locator('app-epg-timeline .epg-timeline__block-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);
}