fix: prevent fullscreen hover and playlist refresh races (#1591)

This commit is contained in:
4gray authored and GitHub committed 2026-09-12 21:11:11 +02:00
1 parent 621436fb1d
commit 6e3f0d258f
16 files changed
+346 -25

No files matched your search

@@ -426,6 +426,13 @@ for (const player of ['mpv', 'vlc']) {
const isFullscreen = () =>
page.evaluate(() => document.fullscreenElement !== null);
await expect.poll(isFullscreen).toBe(true);
// Model a delayed first fullscreen paint: the hover-open handoff
// must survive longer than the panel's mouse-leave grace period.
await page.addStyleTag({
content: `
.fullscreen-channel-panel--open { transition-delay: 600ms, 0s; }
`,
});
await page.getByTestId('fullscreen-channel-panel-hot-zone').hover();
const panel = page.getByTestId('fullscreen-channel-panel');
await expect(panel).toHaveAttribute('aria-hidden', 'false');
@@ -85,6 +85,8 @@ type ElectronFixtures = {
};
export type LaunchElectronAppOptions = {
/** Test bootstrap that loads the real main entry after installing probes. */
entryPoint?: string;
/** Electron/Chromium switches — they must precede the entry point. */
args?: readonly string[];
/**
@@ -199,9 +201,10 @@ export { expect };
*/
function buildElectronLaunchArgs(
extraArgs: readonly string[] = [],
appArgs: readonly string[] = []
appArgs: readonly string[] = [],
entryPoint = electronMainPath
): string[] {
const args = [...extraArgs, electronMainPath, ...appArgs];
const args = [...extraArgs, entryPoint, ...appArgs];
if (process.platform === 'linux' && process.env['CI']) {
args.unshift('--no-sandbox', '--disable-gpu');
@@ -221,7 +224,11 @@ export async function launchElectronApp(
}
assertPackagedRendererBuildIsElectronSafe();
const args = buildElectronLaunchArgs(options.args, options.appArgs);
const args = buildElectronLaunchArgs(
options.args,
options.appArgs,
options.entryPoint
);
const electronApp = await electron.launch({
args,
@@ -16,6 +16,11 @@ import {
waitForM3uCatalog,
} from './electron-test-fixtures';
import {
holdNextPlaylistWrite,
launchElectronWithWriteGate,
} from './playlist-refresh-write-gate';
const autoRefreshSourceName = 'auto-refresh-source.m3u';
function buildAutoRefreshM3u(channelName: string): string {
@@ -50,7 +55,7 @@ test.describe('Electron startup playlist auto-refresh', () => {
requiredUserAgent: userAgent,
}
);
const app = await launchElectronApp(dataDir);
const app = await launchElectronWithWriteGate(dataDir);
try {
expect((await fetch(urlServer.resourceUrl)).status).toBe(403);
await importM3uPlaylistFromUrl(
@@ -81,8 +86,17 @@ test.describe('Electron startup playlist auto-refresh', () => {
// A refresh result must keep the in-memory default as well as SQLite metadata.
await openSources(app.mainWindow);
urlServer.setBody(buildAutoRefreshM3u('Second UA Channel'));
const writeGate = await holdNextPlaylistWrite(app);
await refreshSource(app.mainWindow, autoRefreshSourceName);
await openAutoRefreshPlaylistCatalog(app.mainWindow);
await writeGate.waitUntilHeld();
// Open the catalog while its refreshed payload is still being saved.
// Waiting for idle here would hide a real route/persistence race.
await sourceRowByTitle(app.mainWindow, autoRefreshSourceName)
.first()
.click();
await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+\/all$/);
await writeGate.release();
await waitForM3uCatalog(app.mainWindow);
await expect(
app.mainWindow
.getByTestId('channel-item')
@@ -0,0 +1,106 @@
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 WriteGateState {
held: boolean;
release: () => void;
finished: Promise<void>;
gate: Promise<void>;
finish: () => void;
}
type GateGlobal = typeof globalThis & {
__playlistWriteGate?: WriteGateState;
__playlistWriteGateInstalled?: boolean;
};
// Serialized into the test bootstrap; all runtime dependencies are arguments.
function installWriteGate({
ipcMain,
}: Pick<typeof import('electron'), 'ipcMain'>): void {
const register = ipcMain.handle;
ipcMain.handle = function (channel, listener) {
if (channel !== 'DB_UPSERT_APP_PLAYLIST') {
return register.call(this, channel, listener);
}
// Capture registration through the public API, then restore it. No
// Electron-private handler registry is read or mutated.
ipcMain.handle = register;
(globalThis as GateGlobal).__playlistWriteGateInstalled = true;
return register.call(this, channel, async (...args) => {
const state = (globalThis as GateGlobal).__playlistWriteGate;
if (!state || state.held) return listener(...args);
state.held = true;
await state.gate;
try {
return await listener(...args);
} finally {
state.finish();
}
});
};
}
export function launchElectronWithWriteGate(
dataDir: string
): Promise<LaunchedElectronApp> {
const entryPoint = join(dataDir, 'playlist-write-gate.cjs');
writeFileSync(
entryPoint,
[
`(${installWriteGate.toString()})(require('electron'));`,
`require(${JSON.stringify(electronMainPath)});`,
].join('\n')
);
return launchElectronApp(dataDir, { entryPoint });
}
/** Hold one real SQLite write so navigation can race refresh deterministically. */
export async function holdNextPlaylistWrite(app: LaunchedElectronApp) {
await app.electronApp.evaluate(() => {
if (!(globalThis as GateGlobal).__playlistWriteGateInstalled) {
throw new Error('Playlist write gate bootstrap was not installed');
}
let release!: () => void;
let finish!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const finished = new Promise<void>((resolve) => {
finish = resolve;
});
const state: WriteGateState = {
held: false,
release,
finished,
gate,
finish,
};
(globalThis as GateGlobal).__playlistWriteGate = state;
});
return {
waitUntilHeld: () =>
expect
.poll(() =>
app.electronApp.evaluate(
() =>
(globalThis as GateGlobal).__playlistWriteGate?.held
)
)
.toBe(true),
release: () =>
app.electronApp.evaluate(async () => {
const state = (globalThis as GateGlobal).__playlistWriteGate;
if (!state) throw new Error('Playlist write gate is missing');
state.release();
await state.finished;
}),
};
}
@@ -129,6 +129,12 @@ async function pastProgramWindows(
credentials: { username: string; password: string }
): Promise<PastProgramWindow> {
const fixture = await fetchXtreamEpgFixture(request, credentials);
return pastProgramWindow(fixture);
}
function pastProgramWindow(
fixture: Awaited<ReturnType<typeof fetchXtreamEpgFixture>>
): PastProgramWindow {
const past = fixture.fullEpg.find(
(listing) => listing.title === PAST_PROGRAM
);
@@ -314,7 +320,9 @@ test('@epg @xtream @electron copies the archive URL without starting archive pla
expect(start).toBeTruthy();
await expectServerClock(
start!,
[await pastProgramWindows(request, credentials)],
// The mock caches this schedule until reset. Use the fixture that
// supplied the UI, without a second HTTP oracle after clipboard success.
[pastProgramWindow(fixture)],
0
);
// Main-process archive probes do not appear as renderer playback requests.