Files
iptvnator/apps/electron-backend-e2e/src/ipc-channel-hold.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

109 lines
3.6 KiB
TypeScript

import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import type { LaunchedElectronApp } from './electron-test-fixtures';
import {
electronMainPath,
expect,
launchElectronApp,
} from './electron-test-fixtures';
interface ChannelHold {
/** Invocations parked behind the hold, waiting for `release`. */
waiting: number;
gate: Promise<void>;
release: () => void;
}
type HoldGlobal = typeof globalThis & {
__ipcChannelHolds?: Record<string, ChannelHold | undefined>;
__ipcChannelHoldInstalled?: string[];
};
// Serialized into the test bootstrap; all runtime dependencies are arguments.
function installChannelHolds(
{ ipcMain }: Pick<typeof import('electron'), 'ipcMain'>,
channels: readonly string[]
): void {
const register = ipcMain.handle;
const scope = globalThis as HoldGlobal;
scope.__ipcChannelHolds = {};
scope.__ipcChannelHoldInstalled = [];
// Wraps the listener at registration through the public API; no
// Electron-private handler registry is read or mutated.
ipcMain.handle = function (channel, listener) {
if (!channels.includes(channel)) {
return register.call(this, channel, listener);
}
scope.__ipcChannelHoldInstalled?.push(channel);
return register.call(this, channel, async (...args) => {
const hold = scope.__ipcChannelHolds?.[channel];
if (hold) {
hold.waiting += 1;
await hold.gate;
}
return listener(...args);
});
};
}
/**
* Launches the app with `channels` wrapped so a test can park their
* invocations (`holdIpcChannel`) and keep a loading state on screen for as
* long as it measures it.
*/
export function launchElectronWithChannelHolds(
dataDir: string,
channels: readonly string[]
): Promise<LaunchedElectronApp> {
const entryPoint = join(dataDir, 'ipc-channel-holds.cjs');
writeFileSync(
entryPoint,
[
`(${installChannelHolds.toString()})(require('electron'), ${JSON.stringify(channels)});`,
`require(${JSON.stringify(electronMainPath)});`,
].join('\n')
);
return launchElectronApp(dataDir, { entryPoint });
}
/** Parks every later invocation of `channel` until the returned release. */
export async function holdIpcChannel(
app: LaunchedElectronApp,
channel: string
) {
await app.electronApp.evaluate((_electron, name) => {
const scope = globalThis as HoldGlobal;
if (!scope.__ipcChannelHoldInstalled?.includes(name)) {
throw new Error(`IPC channel ${name} has no hold installed`);
}
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const holds = (scope.__ipcChannelHolds ??= {});
holds[name] = { waiting: 0, gate, release };
}, channel);
return {
waitUntilHeld: () =>
expect
.poll(() =>
app.electronApp.evaluate(
(_electron, name) =>
(globalThis as HoldGlobal).__ipcChannelHolds?.[name]
?.waiting ?? 0,
channel
)
)
.toBeGreaterThan(0),
release: () =>
app.electronApp.evaluate((_electron, name) => {
const holds = (globalThis as HoldGlobal).__ipcChannelHolds;
const hold = holds?.[name];
if (!hold) throw new Error(`IPC channel ${name} is not held`);
delete holds[name];
hold.release();
}, channel),
};
}