Files
iptvnator/apps/electron-backend-e2e/src/skeleton-geometry.e2e-support.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

160 lines
5.4 KiB
TypeScript

import type { Page } from '@playwright/test';
import { expect } from './electron-test-fixtures';
export interface Box {
top: number;
left: number;
width: number;
height: number;
bottom: number;
}
export interface LayoutShiftReport {
score: number;
shifts: {
value: number;
sources: { node: string; deltaY: number; deltaHeight: number }[];
}[];
}
/** Header and row positions must match to this many pixels. */
export const GEOMETRY_TOLERANCE_PX = 0.5;
type ShiftWindow = typeof window & { __skeletonShifts?: LayoutShiftReport };
/** Border boxes of the first match of each selector, in viewport pixels. */
export async function measureBoxes<Key extends string>(
page: Page,
selectors: Record<Key, string>
): Promise<Record<Key, Box>> {
const boxes = await page.evaluate(
(map) => {
const result: Record<string, Box | null> = {};
for (const [key, selector] of Object.entries(map)) {
const rect = document
.querySelector(selector)
?.getBoundingClientRect();
result[key] = rect
? {
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
bottom: rect.bottom,
}
: null;
}
return result;
},
selectors as Record<string, string>
);
for (const [key, box] of Object.entries(boxes)) {
expect(
box,
`${key} (${selectors[key as Key]}) is rendered`
).not.toBeNull();
}
return boxes as Record<Key, Box>;
}
/**
* Starts summing `layout-shift` entries from now on, with the nodes each
* shift moved, until `readLayoutShifts`.
*/
export async function observeLayoutShifts(page: Page): Promise<void> {
await page.evaluate(() => {
const report: LayoutShiftReport = { score: 0, shifts: [] };
(window as ShiftWindow).__skeletonShifts = report;
const nodeLabel = (node: Node | null): string => {
if (!(node instanceof Element)) return String(node?.nodeName);
const testId = node.getAttribute('data-test-id');
return [
node.tagName.toLowerCase(),
...[...node.classList].map((name) => `.${name}`),
testId ? `[data-test-id="${testId}"]` : '',
].join('');
};
new PerformanceObserver((list) => {
for (const entry of list.getEntries() as (PerformanceEntry & {
value: number;
sources?: {
node: Node | null;
previousRect: DOMRectReadOnly;
currentRect: DOMRectReadOnly;
}[];
})[]) {
report.score += entry.value;
report.shifts.push({
value: entry.value,
sources: (entry.sources ?? []).map((source) => ({
node: nodeLabel(source.node),
deltaY: source.currentRect.y - source.previousRect.y,
deltaHeight:
source.currentRect.height -
source.previousRect.height,
})),
});
}
}).observe({ type: 'layout-shift', buffered: false });
});
}
export async function readLayoutShifts(page: Page): Promise<LayoutShiftReport> {
const report = await page.evaluate(
() => (window as ShiftWindow).__skeletonShifts
);
if (!report) throw new Error('observeLayoutShifts was not called');
return report;
}
/**
* Resolves once the workspace content has not mutated for 300 ms (at most
* 3 s), then after one more frame, so pending layout shifts are delivered.
*/
export async function waitForContentQuiet(page: Page): Promise<void> {
await page.evaluate(
() =>
new Promise<void>((resolve) => {
const target =
document.querySelector('main.workspace-content') ??
document.documentElement;
let quiet = 0;
const finish = () => {
observer.disconnect();
clearTimeout(cap);
clearTimeout(quiet);
requestAnimationFrame(() =>
requestAnimationFrame(() => resolve())
);
};
const observer = new MutationObserver(() => {
clearTimeout(quiet);
quiet = window.setTimeout(finish, 300);
});
observer.observe(target, {
attributes: true,
characterData: true,
childList: true,
subtree: true,
});
quiet = window.setTimeout(finish, 300);
const cap = window.setTimeout(finish, 3000);
})
);
}
/** `actual` within the geometry tolerance of `expected`, or a message
* naming both boxes. */
export function expectSameEdge(
label: string,
actual: number,
expected: number,
evidence: unknown
): void {
expect(
Math.abs(actual - expected),
`${label}: ${actual.toFixed(2)} vs skeleton ${expected.toFixed(2)}\n${JSON.stringify(evidence, null, 1)}`
).toBeLessThanOrEqual(GEOMETRY_TOLERANCE_PX);
}