Files
iptvnator/libs/ui/components/src/lib/window-controls/window-controls.component.spec.ts
T
4grayandClaude Fable 5 8e0abe6feb feat(ui): custom title bar with window controls for Windows and Linux (#1042)
* feat(ui): add custom title bar window controls for Windows and Linux

Hide the native title bar on win32/linux (titleBarStyle: 'hidden', frame
untouched so native resize borders and snapping keep working) and render
minimize / maximize-restore / close buttons in the renderer, mirroring the
existing macOS traffic-light setup.

- New WINDOW:* IPC contract (minimize, toggle-maximize, close, get-state)
  handled in window.events.ts, resolved from the sender WebContents;
  close goes through win.close() so window-bounds persistence still runs.
- WINDOW:STATE_CHANGED pushed on maximize/unmaximize/fullscreen so the
  maximize/restore glyph stays correct for OS-triggered changes; controls
  hide while fullscreen.
- WindowControlsComponent mounts once in app-root as a manual popover so
  it stays in the browser top layer above CDK overlays (dialogs,
  multi-EPG) - same behavior as macOS traffic lights.
- Theme-aware via CSS vars (--app-on-surface, --app-hover-overlay);
  Windows-red close hover. Drag regions get right padding through a
  body-level frameless-platform class.
- Gated by RuntimeCapabilitiesService.usesCustomWindowControls; PWA and
  macOS never mount the controls.

Includes unit specs for the component and IPC handlers, an Electron E2E
suite (window-controls.e2e.ts), and a window-chrome section in
docs/architecture/workspace-shell.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(linux): upgrade Electron to 41 for frameless window decorations on Wayland

With the native title bar hidden, Linux windows lost the WM-drawn shadow
and rounded corners. Electron draws client-side decorations only on
native Wayland, and frameless-window CSD (GTK drop shadow + extended
resize boundaries) landed in Electron 41 - before that, frameless
windows render as plain rectangles.

- electron ^39.8.5 -> ^41.7.2 (Wayland auto-detected since 38.2; X11
  sessions remain undecorated, matching other frameless Electron apps;
  Windows keeps its DWM shadow and rounded corners).
- better-sqlite3 pinned to exactly 12.9.0: the last release shipping
  prebuilt binaries for both Node 20 (ABI 115, Jest) and Electron 41
  (ABI 145, runtime). 12.10.0 dropped the Node 20 prebuilds, forcing a
  from-source build that fails without a C++ toolchain.
- pnpm override node-abi 3.85.0 -> 3.92.0 so electron-builder
  install-app-deps can map Electron 41 to ABI 145.

Reviewed Electron 40/41 breaking changes: only the renderer clipboard
deprecation, which this app does not use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(e2e): address review feedback and window-managerless Linux CI

- Skip the three window-manager-dependent E2E assertions (maximize
  toggle, main-process state sync, minimize) on Linux CI: GitHub's
  ubuntu runners drive Electron under xvfb without a window manager, so
  maximize/minimize state never materializes there. Windows CI and
  local Linux/macOS runs keep the coverage.
- WINDOW:TOGGLE_MAXIMIZE now returns the requested state instead of
  re-reading isMaximized() right after the call, which races on Linux
  window managers where maximize()/unmaximize() complete
  asynchronously; the WINDOW:STATE_CHANGED push stays authoritative.
- Skip attaching window-state push listeners on macOS, where the
  custom controls never mount and the IPC traffic had no subscriber.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): gate custom window controls on the full bridge surface

Include getWindowState and onWindowStateChange in the
usesCustomWindowControls capability check — the controls rely on both
for initial state and for keeping the maximize/restore glyph in sync
with OS-triggered changes, so a partial bridge should not mount them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:03:27 +02:00

120 lines
4.2 KiB
TypeScript

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ElectronBridgeWindowState } from '@iptvnator/shared/interfaces';
import { WindowControlsComponent } from './window-controls.component';
describe('WindowControlsComponent', () => {
let fixture: ComponentFixture<WindowControlsComponent>;
let stateChangeCallback:
| ((state: ElectronBridgeWindowState) => void)
| undefined;
const unsubscribe = jest.fn();
const electronMock = {
getWindowState: jest.fn(),
onWindowStateChange: jest.fn(),
minimizeWindow: jest.fn(),
toggleMaximizeWindow: jest.fn(),
closeWindow: jest.fn(),
};
const query = (testId: string): HTMLElement | null =>
fixture.nativeElement.querySelector(`[data-test-id="${testId}"]`);
beforeEach(async () => {
jest.clearAllMocks();
stateChangeCallback = undefined;
electronMock.getWindowState.mockResolvedValue({
isMaximized: false,
isFullScreen: false,
});
electronMock.toggleMaximizeWindow.mockResolvedValue({
isMaximized: true,
isFullScreen: false,
});
electronMock.minimizeWindow.mockResolvedValue(undefined);
electronMock.closeWindow.mockResolvedValue(undefined);
electronMock.onWindowStateChange.mockImplementation(
(callback: (state: ElectronBridgeWindowState) => void) => {
stateChangeCallback = callback;
return unsubscribe;
}
);
(window as { electron?: unknown }).electron = electronMock;
await TestBed.configureTestingModule({
imports: [WindowControlsComponent],
}).compileComponents();
fixture = TestBed.createComponent(WindowControlsComponent);
fixture.detectChanges();
});
afterEach(() => {
delete (window as { electron?: unknown }).electron;
});
it('renders minimize, maximize and close buttons with the maximize glyph', () => {
expect(query('window-minimize')).not.toBeNull();
expect(query('window-maximize')).not.toBeNull();
expect(query('window-close')).not.toBeNull();
expect(query('window-maximize-glyph')).not.toBeNull();
expect(query('window-restore-glyph')).toBeNull();
expect(electronMock.getWindowState).toHaveBeenCalled();
});
it('calls the bridge methods when the buttons are clicked', () => {
query('window-minimize')?.click();
expect(electronMock.minimizeWindow).toHaveBeenCalledTimes(1);
query('window-maximize')?.click();
expect(electronMock.toggleMaximizeWindow).toHaveBeenCalledTimes(1);
query('window-close')?.click();
expect(electronMock.closeWindow).toHaveBeenCalledTimes(1);
});
it('swaps to the restore glyph when the window state reports maximized', () => {
stateChangeCallback?.({ isMaximized: true, isFullScreen: false });
fixture.detectChanges();
expect(query('window-restore-glyph')).not.toBeNull();
expect(query('window-maximize-glyph')).toBeNull();
expect(query('window-maximize')?.getAttribute('aria-label')).toBe(
'Restore'
);
});
it('applies the restore state returned by toggleMaximizeWindow', async () => {
query('window-maximize')?.click();
await fixture.whenStable();
fixture.detectChanges();
expect(query('window-restore-glyph')).not.toBeNull();
});
it('hides the host while the window is in fullscreen', () => {
stateChangeCallback?.({ isMaximized: false, isFullScreen: true });
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).classList.contains(
'is-hidden'
)
).toBe(true);
stateChangeCallback?.({ isMaximized: false, isFullScreen: false });
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).classList.contains(
'is-hidden'
)
).toBe(false);
});
it('unsubscribes from window state changes on destroy', () => {
fixture.destroy();
expect(unsubscribe).toHaveBeenCalledTimes(1);
});
});