Files
iptvnator/apps/electron-backend-e2e/src/skeleton-geometry.e2e.ts
T
4grayandClaude Opus 5.5 7d7d691ce4 fix(ui): skeletons that match the M3U header and dashboard rails (#1887)
* fix(ui): skeletons that match the M3U header and dashboard rails

Loading skeletons now take their boxes from the code that draws the
content, so the content lands where the skeleton was.

- M3U channel list: the loading state's headers, divider, groups rail
  and first row share `styles/_channel-list-layout.scss` with the All
  channels, context and groups views. The All channels list moved down
  by 19px when a playlist loaded; the group header was 79px against the
  real 69px (the panel header's 52px minimum plus the padding that
  `.sidebar .sidebar-header` gives it, now one mixin both include).
  Group rows start at the list's top-left like the virtual-scroll rows.
- Dashboard rails: `lib-dashboard-rail-skeleton` replaces six copies of
  the skeleton markup and takes the rail's layout and aspect ratio
  (channel cards for live favorites, 16:9 for sources, posters with a
  title and a meta line otherwise). Its header, track, cards and text
  line boxes come from `_dashboard-rail-geometry.scss`, which the rail
  itself now uses. The skeleton was 328px against the 337.8px poster
  rail and a 2:3 poster in front of the 83.5px channel cards.
- One shimmer in `libs/ui/styles/_skeleton-shimmer.scss`, mixed from
  `--app-on-surface`: the rail sweep between the widget surfaces was
  1.04:1 on the light theme. The dashboard hero and the detail
  skeletons (through a forward) use it too.
- Electron E2E `skeleton-geometry.e2e.ts` holds the IPC channel behind
  each loading state, compares skeleton and content boxes to 0.5px at
  1280x700 and caps the dashboard layout-shift score at 0.01.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(dashboard): keep rail heights when a skeleton stands for a label-less or live rail

Review follow-up (Greptile, local):
- Rails that replace a skeleton (Continue Watching, sources, Xtream
  recently added, TMDB) keep an empty meta line under cards without
  labels (`reserveMetaLine`), as the skeleton always draws one; a
  Continue Watching rail of movies with no known duration came in
  shorter.
- The recent-content skeleton follows the recent rail it waits for:
  posters while Continue Watching is still loading with no cards,
  channel cards otherwise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:16:38 +02:00

367 lines
14 KiB
TypeScript

import type { Page } from '@playwright/test';
import {
addXtreamPortal,
clickCategoryByNameExact,
closeElectronApp,
defaultXtreamPassword,
defaultXtreamUsername,
expect,
goToDashboard,
importM3uPlaylistFromNativeDialog,
m3uFixturePath,
openSettings,
openSettingsSection,
openWorkspaceSection,
resetMockServers,
saveSettings,
setSwitch,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
import { toggleFavoriteForChannel } from './dashboard-e2e-flows';
import {
holdIpcChannel,
launchElectronWithChannelHolds,
} from './ipc-channel-hold';
import { fetchXtreamLiveFixture, getXtreamTitle } from './portal-mock-fixtures';
import {
expectRendererReloadedOnRoute,
reloadFromMainProcess,
} from './renderer-reload.support';
import {
expectSameEdge,
measureBoxes,
observeLayoutShifts,
readLayoutShifts,
waitForContentQuiet,
} from './skeleton-geometry.e2e-support';
// ---------------------------------------------------------------------------
// A skeleton must hold the place of what it precedes: the same header
// heights, the same first-row position and the same rail heights, so content
// arrives without moving anything. Each loading state is kept on screen by
// holding the IPC channel that answers it, measured, then released and
// compared with what replaced it. The M3U skeleton swaps the whole channel
// list subtree, which the browser does not count as a layout shift at all,
// so its positions are compared directly; on the dashboard a rail that
// changes height moves the rails below it, which the layout-shift score
// catches as well.
// ---------------------------------------------------------------------------
const viewport = { width: 1280, height: 700 };
const playlistChannel = 'DB_GET_APP_PLAYLIST';
const favoritesChannel = 'DB_GET_ALL_GLOBAL_FAVORITES';
const recentlyAddedChannel = 'DB_GET_GLOBAL_RECENTLY_ADDED';
const maxDashboardShiftScore = 0.01;
const loadingState = 'app-channel-list-loading-state';
const firstSkeletonRow = `${loadingState} app-channel-list-item-skeleton`;
const firstChannelRow = 'app-channel-list-item';
async function openM3uFromDashboard(page: Page): Promise<void> {
await page
.getByTestId('dashboard-recent-sources-rail-card-link')
.first()
.click();
}
test.describe('Skeleton geometry', () => {
test('M3U channel list skeletons put the headers and the first row where the list lands', async ({
dataDir,
}) => {
test.setTimeout(120_000);
const app = await launchElectronWithChannelHolds(dataDir, [
playlistChannel,
]);
const page = app.mainWindow;
try {
await page.setViewportSize(viewport);
await importM3uPlaylistFromNativeDialog(app, m3uFixturePath);
await goToDashboard(page);
// All channels, the default view.
let hold = await holdIpcChannel(app, playlistChannel);
await openM3uFromDashboard(page);
await hold.waitUntilHeld();
await expect(
page.locator(`${loadingState} .channels-loading-header`)
).toBeVisible();
const allSkeleton = await measureBoxes(page, {
header: `${loadingState} .channels-loading-header`,
divider: `${loadingState} .channels-loading-divider`,
row: firstSkeletonRow,
});
await observeLayoutShifts(page);
await hold.release();
await expect(page.locator(firstChannelRow).first()).toBeVisible();
await waitForContentQuiet(page);
const all = await measureBoxes(page, {
header: '.all-channels-header',
divider: '.all-channels-divider',
row: firstChannelRow,
});
const allEvidence = { skeleton: allSkeleton, loaded: all };
expectSameEdge(
'All channels header top',
all.header.top,
allSkeleton.header.top,
allEvidence
);
expectSameEdge(
'All channels header height',
all.header.height,
allSkeleton.header.height,
allEvidence
);
expectSameEdge(
'All channels divider',
all.divider.bottom,
allSkeleton.divider.bottom,
allEvidence
);
expectSameEdge(
'All channels first row',
all.row.top,
allSkeleton.row.top,
allEvidence
);
expect((await readLayoutShifts(page)).score).toBe(0);
// Groups: entering the section again from the dashboard reloads
// the playlist, so the skeleton shows once more.
await openWorkspaceSection(page, 'Groups');
await expect(page.locator('.groups-content-header')).toBeVisible();
await goToDashboard(page);
hold = await holdIpcChannel(app, playlistChannel);
await page.goBack();
await hold.waitUntilHeld();
await expect(
page.locator(`${loadingState} .groups-loading-content__header`)
).toBeVisible();
const groupsSkeleton = await measureBoxes(page, {
navHeader: `${loadingState} .groups-loading-nav__header`,
// The second item: the selected first one is lifted by 1px.
navItem: `${loadingState} .groups-loading-nav__item:nth-child(2)`,
header: `${loadingState} .groups-loading-content__header`,
row: firstSkeletonRow,
});
await observeLayoutShifts(page);
await hold.release();
await expect(page.locator(firstChannelRow).first()).toBeVisible();
await waitForContentQuiet(page);
const groups = await measureBoxes(page, {
navHeader: '.groups-nav-header',
navItem: '.group-nav-item:nth-child(2)',
header: '.groups-content-header',
row: firstChannelRow,
});
const groupsEvidence = { skeleton: groupsSkeleton, loaded: groups };
expectSameEdge(
'Groups rail header height',
groups.navHeader.height,
groupsSkeleton.navHeader.height,
groupsEvidence
);
expectSameEdge(
'Groups rail second item',
groups.navItem.top,
groupsSkeleton.navItem.top,
groupsEvidence
);
expectSameEdge(
'Groups rail item height',
groups.navItem.height,
groupsSkeleton.navItem.height,
groupsEvidence
);
expectSameEdge(
'Group header top',
groups.header.top,
groupsSkeleton.header.top,
groupsEvidence
);
expectSameEdge(
'Group header height',
groups.header.height,
groupsSkeleton.header.height,
groupsEvidence
);
expectSameEdge(
'Group first row',
groups.row.top,
groupsSkeleton.row.top,
groupsEvidence
);
expect((await readLayoutShifts(page)).score).toBe(0);
} finally {
await closeElectronApp(app);
}
});
test('dashboard rail skeletons have the height of the rails that replace them', async ({
dataDir,
request,
}) => {
test.setTimeout(120_000);
await resetMockServers(request, ['xtream']);
const live = await fetchXtreamLiveFixture(request, {
password: defaultXtreamPassword,
username: defaultXtreamUsername,
});
const app = await launchElectronWithChannelHolds(dataDir, [
favoritesChannel,
recentlyAddedChannel,
]);
const page = app.mainWindow;
try {
await page.setViewportSize(viewport);
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
await openWorkspaceSection(page, 'Live TV');
await clickCategoryByNameExact(page, live.categoryName);
await toggleFavoriteForChannel(page, getXtreamTitle(live.items[0]));
// A skeleton never shows above a rail that already has cards, and
// the sources rail is ready first: without it, the live
// favorites skeleton (channel cards) sits right above the
// recently-added one (posters), both in the viewport.
await openSettings(page);
await openSettingsSection(page, 'dashboard');
await setSwitch(
page
.getByTestId('toggle-dashboard-rail-recent-sources')
.locator('[role="switch"]'),
false
);
await saveSettings(page);
await goToDashboard(page);
await expect(
page.getByTestId('dashboard-xtream-recently-added-rail')
).toBeVisible({ timeout: 20_000 });
// Recently added is asked for once the favorites have loaded, so
// it is held only after the favorites are.
const favorites = await holdIpcChannel(app, favoritesChannel);
await reloadFromMainProcess(app);
await expectRendererReloadedOnRoute(
page,
/\/workspace\/dashboard$/
);
await page.setViewportSize(viewport);
await favorites.waitUntilHeld();
const recentlyAdded = await holdIpcChannel(
app,
recentlyAddedChannel
);
const liveSkeletonRail =
'[data-test-id="dashboard-live-favorites-rail-skeleton"]';
const addedSkeletonRail =
'[data-test-id="dashboard-xtream-recently-added-rail-skeleton"]';
await expect(page.locator(liveSkeletonRail)).toBeVisible({
timeout: 20_000,
});
await expect(page.locator(addedSkeletonRail)).toBeVisible();
await waitForContentQuiet(page);
const skeleton = await measureBoxes(page, {
liveRail: liveSkeletonRail,
liveHeader: `${liveSkeletonRail} .rail-skeleton__header`,
liveCard: `${liveSkeletonRail} .rail-skeleton__card`,
addedRail: addedSkeletonRail,
addedHeader: `${addedSkeletonRail} .rail-skeleton__header`,
addedCard: `${addedSkeletonRail} .rail-skeleton__card`,
});
await observeLayoutShifts(page);
await favorites.release();
const liveRail = '[data-test-id="dashboard-live-favorites-rail"]';
await expect(page.locator(liveRail)).toBeVisible();
await recentlyAdded.waitUntilHeld();
await waitForContentQuiet(page);
const afterLive = await measureBoxes(page, {
liveRail,
liveHeader: `${liveRail} .rail__header`,
liveCard: `${liveRail} .rail__card`,
addedRail: addedSkeletonRail,
});
await recentlyAdded.release();
const addedRail =
'[data-test-id="dashboard-xtream-recently-added-rail"]';
await expect(page.locator(addedRail)).toBeVisible();
await waitForContentQuiet(page);
const loaded = await measureBoxes(page, {
addedRail,
addedHeader: `${addedRail} .rail__header`,
addedCard: `${addedRail} .rail__card`,
});
const shifts = await readLayoutShifts(page);
const evidence = { skeleton, afterLive, loaded, shifts };
expectSameEdge(
'live favorites rail height',
afterLive.liveRail.height,
skeleton.liveRail.height,
evidence
);
expectSameEdge(
'live favorites header height',
afterLive.liveHeader.height,
skeleton.liveHeader.height,
evidence
);
expectSameEdge(
'channel card width',
afterLive.liveCard.width,
skeleton.liveCard.width,
evidence
);
expectSameEdge(
'channel card height',
afterLive.liveCard.height,
skeleton.liveCard.height,
evidence
);
// Relative to the rail above it: the hero may still grow past its
// floor when its slides arrive, which moves both rails alike.
expectSameEdge(
'recently added skeleton stays below the live rail',
afterLive.addedRail.top - afterLive.liveRail.bottom,
skeleton.addedRail.top - skeleton.liveRail.bottom,
evidence
);
expectSameEdge(
'recently added rail height',
loaded.addedRail.height,
skeleton.addedRail.height,
evidence
);
expectSameEdge(
'recently added header height',
loaded.addedHeader.height,
skeleton.addedHeader.height,
evidence
);
expectSameEdge(
'poster card width',
loaded.addedCard.width,
skeleton.addedCard.width,
evidence
);
expectSameEdge(
'poster card height',
loaded.addedCard.height,
skeleton.addedCard.height,
evidence
);
expect(
shifts.score,
JSON.stringify(evidence, null, 1)
).toBeLessThanOrEqual(maxDashboardShiftScore);
} finally {
await closeElectronApp(app);
}
});
});