Files
iptvnator/apps/electron-backend/src/app/app-window-state.spec.ts
T
4gray c0e065da17 fix(window-controls): derive window-state pushes from events so controls reappear after fullscreen (#1178)
The custom minimize/maximize/close controls stayed hidden forever after
leaving HTML-element (video player) fullscreen on Windows: window state was
polled at event time, and isFullScreen() can still report the pre-transition
value while 'leave-full-screen' fires, leaving a stale push with no later
event to correct it. The same polling on the companion flag cleared
isMaximized during fullscreen transitions and stuck the maximize/restore
glyph on the wrong icon.

attachWindowStateEvents now seeds the state once at window creation and each
event patches only the flag it names, sending a copy per push. The
enter/leave-html-full-screen variants are wired too.

Regression coverage: app-window-state.spec.ts (9 cases, 6 of which fail
against the old implementation) and an Electron E2E case that toggles HTML
element fullscreen and asserts the controls come back.
2026-07-26 01:49:08 +02:00

243 lines
7.8 KiB
TypeScript

/**
* Regression coverage for the WINDOW:STATE_CHANGED pushes that drive the
* renderer-drawn window controls on Windows/Linux.
*
* The handlers must never re-read window state at event time: on Windows,
* isFullScreen() can still report true while 'leave-full-screen' fires for
* HTML-element fullscreen (video player) exits, and isMaximized() reports
* false while the window is fullscreen. Polling pushed a stale
* `isFullScreen: true` on exit — leaving the custom window controls hidden
* forever — and cleared the companion `isMaximized`, sticking the
* maximize/restore glyph on the wrong icon. Instead the state is seeded once
* at attach time and each event patches only the flag it names.
*/
jest.mock('electron', () => ({
app: {
getPath: jest.fn(() => '/tmp'),
isPackaged: false,
isReady: jest.fn(() => false),
on: jest.fn(),
},
BrowserWindow: jest.fn(),
Menu: {
buildFromTemplate: jest.fn(),
},
screen: {
getPrimaryDisplay: jest.fn(),
},
session: {
defaultSession: {
clearStorageData: jest.fn(),
},
},
shell: {
openExternal: jest.fn(),
},
}));
jest.mock('./services/store.service', () => ({
store: {
get: jest.fn(),
set: jest.fn(),
},
WINDOW_BOUNDS: 'windowBounds',
}));
import { WINDOW_STATE_CHANGED } from '@iptvnator/shared/interfaces';
import App from './app';
type MockStateWindow = {
isDestroyed: jest.Mock<boolean, []>;
isFullScreen: jest.Mock<boolean, []>;
isMaximized: jest.Mock<boolean, []>;
on: jest.Mock<void, [string, () => void]>;
webContents: {
send: jest.Mock<void, [string, unknown]>;
};
};
function createMockStateWindow(): MockStateWindow {
return {
isDestroyed: jest.fn<boolean, []>().mockReturnValue(false),
isFullScreen: jest.fn<boolean, []>().mockReturnValue(false),
isMaximized: jest.fn<boolean, []>().mockReturnValue(false),
on: jest.fn<void, [string, () => void]>(),
webContents: {
send: jest.fn<void, [string, unknown]>(),
},
};
}
function attachWindowStateEvents(win: MockStateWindow): void {
(
App as unknown as {
attachWindowStateEvents: (win: MockStateWindow) => void;
}
).attachWindowStateEvents(win);
}
function fireWindowEvent(win: MockStateWindow, eventName: string): void {
const handlers = win.on.mock.calls
.filter(([name]) => name === eventName)
.map(([, handler]) => handler);
expect(handlers).toHaveLength(1);
handlers[0]();
}
describe('window state change pushes', () => {
const originalPlatform = Object.getOwnPropertyDescriptor(
process,
'platform'
);
function setProcessPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
configurable: true,
value: platform,
});
}
beforeEach(() => {
setProcessPlatform('win32');
});
afterEach(() => {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
}
});
it('registers no window-state listeners on macOS', () => {
setProcessPlatform('darwin');
const win = createMockStateWindow();
attachWindowStateEvents(win);
expect(win.on).not.toHaveBeenCalled();
});
it('pushes isFullScreen=false on leave-full-screen even while the window still reports fullscreen', () => {
const win = createMockStateWindow();
attachWindowStateEvents(win);
// Windows still reports the old state while the transition runs.
win.isFullScreen.mockReturnValue(true);
fireWindowEvent(win, 'leave-full-screen');
expect(win.webContents.send).toHaveBeenCalledWith(
WINDOW_STATE_CHANGED,
{ isMaximized: false, isFullScreen: false }
);
});
it('pushes isFullScreen=false on leave-html-full-screen even while the window still reports fullscreen', () => {
const win = createMockStateWindow();
attachWindowStateEvents(win);
win.isFullScreen.mockReturnValue(true);
fireWindowEvent(win, 'leave-html-full-screen');
expect(win.webContents.send).toHaveBeenCalledWith(
WINDOW_STATE_CHANGED,
{ isMaximized: false, isFullScreen: false }
);
});
it('pushes isFullScreen=true on enter events before the window reports fullscreen', () => {
const win = createMockStateWindow();
attachWindowStateEvents(win);
win.isFullScreen.mockReturnValue(false);
fireWindowEvent(win, 'enter-full-screen');
fireWindowEvent(win, 'enter-html-full-screen');
expect(win.webContents.send).toHaveBeenCalledTimes(2);
expect(win.webContents.send).toHaveBeenLastCalledWith(
WINDOW_STATE_CHANGED,
{ isMaximized: false, isFullScreen: true }
);
});
it('derives isMaximized from the maximize/unmaximize events instead of polling', () => {
const win = createMockStateWindow();
attachWindowStateEvents(win);
win.isMaximized.mockReturnValue(false);
fireWindowEvent(win, 'maximize');
expect(win.webContents.send).toHaveBeenLastCalledWith(
WINDOW_STATE_CHANGED,
{ isMaximized: true, isFullScreen: false }
);
win.isMaximized.mockReturnValue(true);
fireWindowEvent(win, 'unmaximize');
expect(win.webContents.send).toHaveBeenLastCalledWith(
WINDOW_STATE_CHANGED,
{ isMaximized: false, isFullScreen: false }
);
});
it('keeps the maximized flag across a fullscreen round-trip while the window misreports it', () => {
const win = createMockStateWindow();
win.isMaximized.mockReturnValue(true);
attachWindowStateEvents(win);
// Windows reports a fullscreen window as not maximized, so polling
// the companion flag here would clear it and leave the glyph on
// "maximize" after the controls come back.
win.isMaximized.mockReturnValue(false);
fireWindowEvent(win, 'enter-html-full-screen');
expect(win.webContents.send).toHaveBeenLastCalledWith(
WINDOW_STATE_CHANGED,
{ isMaximized: true, isFullScreen: true }
);
fireWindowEvent(win, 'leave-html-full-screen');
expect(win.webContents.send).toHaveBeenLastCalledWith(
WINDOW_STATE_CHANGED,
{ isMaximized: true, isFullScreen: false }
);
});
it('keeps the fullscreen flag when a maximize event arrives mid-transition', () => {
const win = createMockStateWindow();
win.isFullScreen.mockReturnValue(true);
attachWindowStateEvents(win);
win.isFullScreen.mockReturnValue(false);
fireWindowEvent(win, 'maximize');
expect(win.webContents.send).toHaveBeenLastCalledWith(
WINDOW_STATE_CHANGED,
{ isMaximized: true, isFullScreen: true }
);
});
it('sends an independent payload per push', () => {
const win = createMockStateWindow();
attachWindowStateEvents(win);
fireWindowEvent(win, 'enter-full-screen');
const firstPayload = win.webContents.send.mock.calls[0][1];
fireWindowEvent(win, 'leave-full-screen');
// The renderer must not see the earlier push mutate under it.
expect(firstPayload).toEqual({
isMaximized: false,
isFullScreen: true,
});
});
it('skips pushes for destroyed windows', () => {
const win = createMockStateWindow();
attachWindowStateEvents(win);
win.isDestroyed.mockReturnValue(true);
fireWindowEvent(win, 'leave-full-screen');
expect(win.webContents.send).not.toHaveBeenCalled();
});
});