mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix: prevent fullscreen hover and playlist refresh races (#1591)
This commit is contained in:
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.
|
||||
|
||||
Reference in new issue
Block a user