Files
iptvnator/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts
T
4grayandClaude Opus 5.5 c031a150d8 fix(workspace): move detail Back into the header and drop the rail brand
The detail Back lane from #1763 reserved a 72px column beside a sticky
arrow on every detail page. Beside the rail and the category panel that
is about 8% of the pane, and in watch state the player is width-bound,
so it also cost video height. A single button in an empty full-height
column read as a layout fault.

The shell now registers its Back action with WorkspaceBackNavigationService
(a stack where the newest registration wins and each release removes only
its own target), and the workspace header renders it in its leading slot,
where browsers, desktop apps and Material's top app bar keep navigation.
Detail columns get their full width back; the lane, the phone bar and their
ResizeObserver are gone. Escape behaviour is unchanged, and Escape on the
focused header Back keeps the advertised shortcut. At phone width Back takes
the context drawer toggle's slot.

The rail brand mark is removed: it linked to the page the first workspace
link opens, and the phone layout already hid it for that reason.

The web detail-scroll E2E now waits for the loaded heading: PageDown sent to
the scrollable loading skeleton was lost when the loaded shell replaced it
(also flaky on master).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:37:09 +02:00

403 lines
14 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 {
fetchXtreamEpgFixture,
fetchXtreamLiveFixture,
fetchXtreamSeriesFixture,
fetchXtreamVodFixture,
getXtreamTitle,
pickDistinctTitles,
} from './portal-mock-fixtures';
import {
routePlayableStreams,
startAndConfirmPlayback,
} from './playable-stream-fixture';
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
);
// The mock's guide for the channel favourited below (its first live
// stream). The programme on air is read off the full guide at
// assertion time: the mock cuts its slots from the second the guide
// was generated, so the "current" listing of a fixture fetched in
// that same second is the slot that ended just then.
const epgFixture = await fetchXtreamEpgFixture(
request,
xtreamCredentials
);
expect(getXtreamTitle(epgFixture.stream)).toBe(liveTitle);
const liveNowTitle = () => {
const nowSeconds = Math.floor(Date.now() / 1000);
const index = epgFixture.fullEpg.findIndex(
(listing) =>
listing.startTimestamp <= nowSeconds &&
nowSeconds < listing.stopTimestamp
);
expect(index).toBeGreaterThanOrEqual(0);
// Tolerate a slot boundary passing between the app's answer and
// this assertion.
const titles = epgFixture.fullEpg
.slice(index, index + 2)
.map((listing) =>
listing.title.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
);
return new RegExp(titles.join('|'));
};
const app = await launchElectronApp(dataDir);
await routePlayableStreams(app.mainWindow);
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);
// Recorded as recently viewed once it has really played.
await startAndConfirmPlayback(app.mainWindow, () =>
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 startAndConfirmPlayback(app.mainWindow, () =>
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'
);
// An Xtream card has no XMLTV key: its "now on air" line comes
// from the portal, asked for lazily once the card is on screen.
await expect(
dashboardRailCardByTitle(
app.mainWindow,
'dashboard-live-favorites-rail',
liveTitle
).locator('.rail__channel-now')
).toContainText(liveNowTitle(), { timeout: 30000 });
await startAndConfirmPlayback(app.mainWindow, () =>
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'
);
// Same channel, same key: the recent card shares the answer.
await expect(
dashboardRailCardByTitle(
app.mainWindow,
'dashboard-recent-live-rail',
liveTitle
).locator('.rail__channel-now')
).toContainText(liveNowTitle(), { timeout: 30000 });
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 header's Back is route-level in browse and
// watch alike (closing the player is the bar's own Close button).
const backButton = page.getByTestId('workspace-header-back');
await expect(backButton).toBeVisible({ timeout: 20000 });
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.getByTestId('workspace-header-back')).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/
);
}