Files
iptvnator/apps/electron-backend-e2e/src/window-visibility.e2e.ts
T
78a6648c8d perf(electron): let hidden and minimized windows report themselves hidden (#1724)
* perf(electron): let hidden and minimized windows report themselves hidden

The main window was created with backgroundThrottling: false (since #1123,
without a stated reason). Electron then keeps document.visibilityState at
"visible" for a hidden, minimized or fully covered window and never lets
Chromium throttle it, so every renderer timer, rAF and CSS transition ran at
full rate in the background, and the playback keep-awake gate, which
releases the display for a minimized window, could never see one.

Use Chromium's default. Audible media and picture-in-picture are exempt
from background throttling in Chromium, and a local check confirmed HLS
playback continues unchanged through more than six minutes minimized,
audible and muted.

Playwright's focus emulation pins every page it attaches to as visible, so
the new window-visibility E2E launches the app without Playwright and
drives it over raw CDP (electron-unautomated-launch.ts).

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

* test(e2e): harden the unautomated Electron launch

Review follow-ups for the window-visibility E2E:

- Resolve `electron` in the main process through a require created from
  the `node:module` builtin instead of `process.mainModule`, which only
  exists when the app entry is CommonJS.
- Bound teardown like closeElectronApplicationAndConfirmExit: SIGTERM,
  then SIGKILL, 5 s each, then fail instead of waiting forever.
- Surface CDP protocol errors from Runtime.evaluate instead of returning
  undefined.
- Wait until the window is actually shown before hiding it. The app shows
  its window on ready-to-show, and a hide() that lands earlier is undone
  by that show(); this was the first-attempt failure on the macOS shard.

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

* test(e2e): check the playback display lock is released while hidden

Review follow-ups for #1724:

- Add an E2E that plays the webm fixture in the unautomated launch,
  records the main process's prevent-display-sleep blockers, and asserts
  the keep-awake lock is taken while visible, released when the window is
  hidden, and taken again when it is shown. The visibility tests alone
  would still pass if the renderer gate or the bridge stopped updating
  powerSaveBlocker.
- Validate CDP replies before dispatch (CodeQL
  js/unvalidated-dynamic-method-call): only a numeric id with a pending
  settle function is called.

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

* test(e2e): skip killing an Electron that already exited

stopElectron now checks the recorded exit state before each signal and
tolerates a kill that races the exit: on Windows taskkill throws for a PID
that no longer exists. Startup cleanup can no longer replace the startup
error that explains the failure; a cleanup failure there is logged.

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

* fix(stalker): keep the watchdog cadence while the window is hidden

With background throttling on, Chromium wakes a hidden, silent page's
timers at most once per minute after five minutes, so a portal that asks
for get_events every 30 s would see pings at half its cadence while the
window is minimized. Tick the watchdog from a dedicated worker
(createBackgroundInterval, an inline blob worker allowed by the renderer
CSP), whose timers are not subject to page throttling. It falls back to a
page setInterval where no worker is available or the worker fails to load.

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

* fix(stalker): keep a stopped watchdog interval stopped

A worker error that arrived after stop() started the page fallback
interval, which nothing cleared, so pings continued for an inactive
playlist. stop() now marks the interval stopped, detaches the worker
handlers, and the fallback refuses to start afterwards.

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

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 07:17:30 +02:00

148 lines
5.2 KiB
TypeScript

import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { expect, test } from './electron-test-fixtures';
import {
launchUnautomatedElectronApp,
type UnautomatedElectronApp,
} from './electron-unautomated-launch';
// Linux CI runs Electron under xvfb without a window manager, so a minimize
// request never takes effect there (see window-controls.e2e.ts).
const isWindowManagerlessCi =
process.platform === 'linux' && !!process.env['CI'];
const videoFixtureUrl = pathToFileURL(
join(__dirname, '../../web-e2e/src/fixtures/playback/episode.webm')
).href;
const visibility = (app: UnautomatedElectronApp) =>
app.evaluateInPage<string>('document.visibilityState');
const setWindowState = (
app: UnautomatedElectronApp,
action: 'hide' | 'show' | 'minimize' | 'restore'
) =>
app.evaluateInMain(
`electron.BrowserWindow.getAllWindows()[0].${action}();`
);
/**
* The app creates its window with `show: false` and shows it on
* `ready-to-show`; a `hide()` sent earlier would be undone by that `show()`.
*/
async function waitUntilShown(app: UnautomatedElectronApp): Promise<void> {
await expect
.poll(
() =>
app.evaluateInMain<boolean>(
'return electron.BrowserWindow.getAllWindows()[0]?.isVisible() ?? false;'
),
{ timeout: 30_000 }
)
.toBe(true);
await expect.poll(() => visibility(app)).toBe('visible');
}
/**
* The renderer must see a hidden or minimized window as hidden: idle timers
* pause on `visibilitychange`, and the playback keep-awake gate releases the
* display for a minimized window. A main window created with
* `backgroundThrottling: false` keeps reporting `visible` in both cases.
*
* Launched without Playwright: its focus emulation pins every page it
* attaches to as visible (see `electron-unautomated-launch.ts`).
*/
test.describe('Main window visibility', () => {
test('@electron reports a hidden window as hidden and a shown one as visible', async ({
dataDir,
}) => {
const app = await launchUnautomatedElectronApp(dataDir);
try {
await waitUntilShown(app);
await setWindowState(app, 'hide');
await expect
.poll(() => visibility(app), { timeout: 10_000 })
.toBe('hidden');
await setWindowState(app, 'show');
await expect
.poll(() => visibility(app), { timeout: 10_000 })
.toBe('visible');
} finally {
await app.close();
}
});
test('@electron reports a minimized window as hidden until it is restored', async ({
dataDir,
}) => {
test.skip(
isWindowManagerlessCi,
'xvfb on Linux CI has no window manager'
);
const app = await launchUnautomatedElectronApp(dataDir);
try {
await waitUntilShown(app);
await setWindowState(app, 'minimize');
await expect
.poll(() => visibility(app), { timeout: 10_000 })
.toBe('hidden');
await setWindowState(app, 'restore');
await expect
.poll(() => visibility(app), { timeout: 10_000 })
.toBe('visible');
} finally {
await app.close();
}
});
test('@electron releases the playback display lock while the window is hidden', async ({
dataDir,
}) => {
const app = await launchUnautomatedElectronApp(dataDir);
try {
await waitUntilShown(app);
// Record the display-sleep blockers the keep-awake service holds.
await app.evaluateInMain(`
const blocker = electron.powerSaveBlocker;
const active = (globalThis.__e2eDisplayBlockers = new Set());
const start = blocker.start.bind(blocker);
const stop = blocker.stop.bind(blocker);
blocker.start = (type) => {
const id = start(type);
if (type === 'prevent-display-sleep') active.add(id);
return id;
};
blocker.stop = (id) => {
active.delete(id);
return stop(id);
};
`);
const heldBlockers = () =>
app.evaluateInMain<number>(
'return globalThis.__e2eDisplayBlockers.size;'
);
await app.evaluateInPage(`(() => {
const video = document.createElement('video');
video.muted = true;
video.loop = true;
video.src = ${JSON.stringify(videoFixtureUrl)};
document.body.append(video);
return video.play().then(() => true);
})()`);
await expect.poll(heldBlockers, { timeout: 10_000 }).toBe(1);
await setWindowState(app, 'hide');
await expect.poll(heldBlockers, { timeout: 10_000 }).toBe(0);
await setWindowState(app, 'show');
await expect.poll(heldBlockers, { timeout: 10_000 }).toBe(1);
} finally {
await app.close();
}
});
});