Files
iptvnator/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts
T
4grayandClaude Fable 5.1 c76a901e8d fix(ui): keep one Back arrow on detail pages (#1627)
Movie and series detail pages showed two arrow_back controls while the
inline player was open: the shell's sticky arrow (added in #1576 so Back
survives scrolling) meant "Close player" in watch state, while the
now-playing bar carried a second arrow that meant route-level Back — the
same icon with two meanings, next to a "Close player" button that
duplicated the first.

The shell's sticky arrow is now route-level Back in browse and watch
alike, and the bar carries no arrow of its own. Closing the player is
the bar's "Close player" button and Escape, which still unwinds one
level (close, then back). Hosts without a browse Back target (M3U,
downloads) render no arrow in either state.

Unit specs for the shell and the inline player cover the new contract;
the Electron and web E2E helpers that pressed Back from watch are
updated, and the M3U flow closes the player through the bar's button.
Docs, the mirrored CLAUDE.md/AGENTS.md paragraph and a release note
follow the change.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 10:44:17 +02:00

349 lines
12 KiB
TypeScript

import { Page } from '@playwright/test';
import {
addXtreamPortal,
channelItemByTitle,
clickCategoryByNameExact,
clickFirstGridListCard,
closeElectronApp,
expectVisibleContentCardTitle,
defaultXtreamPassword,
defaultXtreamUsername,
expectPathname,
expect,
goToDashboard,
launchElectronApp,
openWorkspaceSection,
resetMockServers,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import {
fetchXtreamLiveFixture,
fetchXtreamSeriesFixture,
fetchXtreamVodFixture,
getXtreamTitle,
pickDistinctTitles,
} from './portal-mock-fixtures';
test.describe('Dashboard Activation', () => {
test('opens live favorites in the collection route and movies/series in global collection detail views from the dashboard', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
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 app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
name: 'Xtream Dashboard Activation Source',
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openWorkspaceSection(app.mainWindow, 'Live TV');
await clickCategoryByNameExact(
app.mainWindow,
liveFixture.categoryName
);
await toggleFavoriteForChannel(app.mainWindow, liveTitle);
await app.mainWindow
.getByRole('link', { name: 'Movies', exact: true })
.click();
await clickCategoryByNameExact(
app.mainWindow,
vodFixture.categoryName
);
const movieTitle = await clickFirstGridListCard(app.mainWindow);
await addCurrentDetailToFavorites(app.mainWindow);
await playCurrentDetail(app.mainWindow);
await goBackFromDetail(app.mainWindow);
await app.mainWindow
.getByRole('link', { name: 'Series', exact: true })
.click();
await clickCategoryByNameExact(
app.mainWindow,
seriesFixture.categoryName
);
const seriesTitle = await clickFirstGridListCard(app.mainWindow);
await addCurrentDetailToFavorites(app.mainWindow);
await playFirstSeriesEpisode(app.mainWindow);
await goToDashboard(app.mainWindow);
// Favorited movies/series are shown as cover cards in their own
// rail. The rail's "See all" opens Global Favorites directly on
// Movies instead of defaulting back to Live TV.
await expectDashboardRail(
app.mainWindow,
'dashboard-favorite-vod-rail'
);
await app.mainWindow
.getByTestId('dashboard-favorite-vod-rail-manage-all')
.click();
await expectPathname(
app.mainWindow,
/\/workspace\/global-favorites$/
);
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
await app.mainWindow.goBack();
await expectPathname(app.mainWindow, /\/workspace\/dashboard$/);
await dashboardRailCardByTitle(
app.mainWindow,
'dashboard-favorite-vod-rail',
movieTitle
).click();
await expectInlineCollectionDetail(app.mainWindow, {
pathname: /\/workspace\/global-favorites$/,
title: movieTitle,
});
await app.mainWindow.goBack();
await expectPathname(app.mainWindow, /\/workspace\/dashboard$/);
await dashboardRailCardByTitle(
app.mainWindow,
'dashboard-favorite-vod-rail',
seriesTitle
).click();
await expectInlineCollectionDetail(app.mainWindow, {
pathname: /\/workspace\/global-favorites$/,
title: seriesTitle,
});
await app.mainWindow.goBack();
await expectPathname(app.mainWindow, /\/workspace\/dashboard$/);
// Live favorites no longer fall back to recent live history.
// Clicking a favorited live card still opens the Xtream favorites
// collection with the channel playing.
await expectDashboardRail(
app.mainWindow,
'dashboard-live-favorites-rail'
);
await dashboardRailCardByTitle(
app.mainWindow,
'dashboard-live-favorites-rail',
liveTitle
).click();
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/favorites$/
);
await expect(
app.mainWindow
.locator(
'app-unified-live-tab .content-container .video-player'
)
.first()
).toBeVisible({ timeout: 20000 });
await expect(
channelItemByTitle(app.mainWindow, liveTitle).first()
).toHaveClass(/(^|\s)active(\s|$)/);
await app.mainWindow.goBack();
await app.mainWindow.waitForURL(/\/workspace\/dashboard$/);
// Once the live card has been played, the separate recently
// watched live rail appears with the same channel layout.
await expectDashboardRail(
app.mainWindow,
'dashboard-recent-live-rail'
);
await dashboardRailCardByTitle(
app.mainWindow,
'dashboard-recent-live-rail',
liveTitle
).click();
await expectPathname(
app.mainWindow,
/\/workspace\/xtreams\/[^/]+\/recent$/
);
await expect(
channelItemByTitle(app.mainWindow, liveTitle).first()
).toBeVisible({ timeout: 20000 });
await app.mainWindow.goBack();
await expectPathname(app.mainWindow, /\/workspace\/dashboard$/);
// Movies and series that were played land in the Continue
// Watching rail (formerly "recently-watched"); clicking a card
// opens it inline in the global-recent collection detail view.
await expectDashboardRail(
app.mainWindow,
'dashboard-continue-watching-rail'
);
await dashboardRailCardByTitle(
app.mainWindow,
'dashboard-continue-watching-rail',
movieTitle
).click();
await expectInlineCollectionDetail(app.mainWindow, {
pathname: /\/workspace\/global-recent$/,
title: movieTitle,
});
await app.mainWindow.goBack();
await expectPathname(app.mainWindow, /\/workspace\/dashboard$/);
await dashboardRailCardByTitle(
app.mainWindow,
'dashboard-continue-watching-rail',
seriesTitle
).click();
await expectInlineCollectionDetail(app.mainWindow, {
pathname: /\/workspace\/global-recent$/,
title: seriesTitle,
});
await goBackFromDetail(app.mainWindow);
await expectPathname(app.mainWindow, /\/workspace\/dashboard$/);
} finally {
await closeElectronApp(app);
}
});
});
const xtreamCredentials = {
username: defaultXtreamUsername,
password: defaultXtreamPassword,
};
async function expectDashboardRail(
page: Page,
railTestId: string
): Promise<void> {
await expect(page.locator(`[data-test-id="${railTestId}"]`)).toBeVisible({
timeout: 20000,
});
}
function dashboardRailCardByTitle(
page: Page,
railTestId: string,
title: string
) {
return page
.locator(`[data-test-id="${railTestId}-card"]`)
.filter({
hasText: title,
})
.first();
}
async function goBackFromDetail(page: Page): Promise<void> {
// Return to the list: the shell's sticky Back is route-level in browse
// and watch alike (closing the player is the bar's own Close button).
const backButton = page
.locator('app-portal-detail-shell')
.first()
.getByRole('button', { name: 'Back', exact: true });
await expect(backButton).toBeVisible({ timeout: 20000 });
try {
await backButton.click({ timeout: 5000 });
} catch {
await backButton.evaluate((button: HTMLButtonElement) =>
button.click()
);
}
}
// By accessible name, not class: the Xtream movie detail's favorite control is
// an icon-only button that carries its label in aria-label, while series and
// Stalker details still use the labeled variant. This matches both.
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 expectInlineCollectionDetail(
page: Page,
params: {
pathname: RegExp;
title: string;
}
): Promise<void> {
await expectPathname(page, params.pathname);
await expect(page.locator('app-workspace-context-panel')).toHaveCount(0);
await expect(page.locator('app-content-hero')).toContainText(params.title);
await expect(
page.locator('app-portal-detail-shell .shell__back-button').first()
).toBeVisible({ timeout: 20000 });
}
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 playFirstSeriesEpisode(page: Page): Promise<void> {
const seasonCard = page.locator('.season-card').first();
const episodeCard = page
.locator('.episode-card, .episode-list-item')
.first();
await expect
.poll(
async () =>
(await seasonCard.count()) + (await episodeCard.count()),
{ timeout: 20000 }
)
.toBeGreaterThan(0);
if ((await seasonCard.count()) > 0) {
await seasonCard.scrollIntoViewIfNeeded();
await expect(seasonCard).toBeVisible({ timeout: 20000 });
await seasonCard.click();
}
await episodeCard.scrollIntoViewIfNeeded();
await expect(episodeCard).toBeVisible({ timeout: 20000 });
await episodeCard.click();
}
async function toggleFavoriteForChannel(
page: Page,
title: string
): Promise<void> {
const item = page
.locator('[data-test-id="channel-item"]')
.filter({ hasText: title })
.first();
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/
);
}