Files
iptvnator/apps/electron-backend-e2e/src/window-controls.e2e.ts
5e5b483dca fix(workspace): keep the macOS header clear of the lights when zoomed out (#1815)
* fix(workspace): keep the macOS header clear of the lights when zoomed out

App zoom scales CSS pixels but not the native traffic lights. At zoom
-3/-4 the header column starts near 29 window pixels, so Back and the
playlist switcher slid under the lights, and the 27px header band let
the lights overlap the context panel below.

A shell-level TrafficLightsClearanceDirective now publishes the lights'
clearance in CSS pixels (84 x 48 window pixels, from the page zoom
factor) on macOS. The header band grows to the vertical clearance (the
rail starts its first link at the same band, replacing the rail's own
zoom listener), and the header's leading padding grows to the
horizontal clearance less the rail column. Both equal the default
layout at 100 %; Windows/Linux and the phone layout are unchanged. The
native position is shared with the main process as
MACOS_TRAFFIC_LIGHTS_POSITION.

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

* test(workspace): pass the header clearance poll labels as options

Equivalent to the string form, which Playwright 1.62 also accepts, but
explicit in every version (Greptile review).

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

* test(workspace): check the header switcher before history exists

Since the header's history fallback, a list reached by navigation
leads with Back. The macOS check now takes the switcher on the first
page, which has nothing to go back to, and Back on a detail page.

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-10-06 19:15:58 +02:00

388 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Page } from '@playwright/test';
import {
addXtreamPortal,
clickFirstGridListCard,
closeElectronApp,
expect,
launchElectronApp,
LaunchedElectronApp,
resetMockServers,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
// Custom window controls are only rendered on Windows/Linux; macOS keeps
// the native traffic lights.
//
// Linux CI runs Electron under xvfb, which has no window manager, so
// maximize/minimize never take effect there — those tests are skipped and
// rely on Windows CI plus local Linux/macOS runs for coverage.
const isWindowManagerlessCi =
process.platform === 'linux' && !!process.env['CI'];
test.describe('Custom window controls', () => {
test.skip(
process.platform === 'darwin',
'macOS uses native traffic lights instead of custom controls'
);
test('@electron renders the window control buttons', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
await expect(
app.mainWindow.getByTestId('window-minimize')
).toBeVisible();
await expect(
app.mainWindow.getByTestId('window-maximize')
).toBeVisible();
await expect(
app.mainWindow.getByTestId('window-close')
).toBeVisible();
await expect(
app.mainWindow.getByTestId('window-maximize-glyph')
).toBeVisible();
} finally {
await closeElectronApp(app);
}
});
test('@electron maximize button toggles the native window state', async ({
dataDir,
}) => {
test.skip(
isWindowManagerlessCi,
'xvfb on Linux CI has no window manager'
);
const app = await launchElectronApp(dataDir);
try {
const isMaximized = () =>
app.electronApp.evaluate(({ BrowserWindow }) => {
const mainWindow = BrowserWindow.getAllWindows()[0];
return mainWindow ? mainWindow.isMaximized() : false;
});
await app.mainWindow.getByTestId('window-maximize').click();
await expect.poll(isMaximized, { timeout: 10_000 }).toBe(true);
await expect(
app.mainWindow.getByTestId('window-restore-glyph')
).toBeVisible();
await app.mainWindow.getByTestId('window-maximize').click();
await expect.poll(isMaximized, { timeout: 10_000 }).toBe(false);
await expect(
app.mainWindow.getByTestId('window-maximize-glyph')
).toBeVisible();
} finally {
await closeElectronApp(app);
}
});
test('@electron reflects window state changes triggered from the main process', async ({
dataDir,
}) => {
test.skip(
isWindowManagerlessCi,
'xvfb on Linux CI has no window manager'
);
const app = await launchElectronApp(dataDir);
try {
await app.electronApp.evaluate(({ BrowserWindow }) => {
BrowserWindow.getAllWindows()[0]?.maximize();
});
await expect(
app.mainWindow.getByTestId('window-restore-glyph')
).toBeVisible({ timeout: 10_000 });
await app.electronApp.evaluate(({ BrowserWindow }) => {
BrowserWindow.getAllWindows()[0]?.unmaximize();
});
await expect(
app.mainWindow.getByTestId('window-maximize-glyph')
).toBeVisible({ timeout: 10_000 });
} finally {
await closeElectronApp(app);
}
});
test('@electron controls reappear after leaving HTML element fullscreen', async ({
dataDir,
}) => {
test.skip(
isWindowManagerlessCi,
'xvfb on Linux CI has no window manager'
);
const app = await launchElectronApp(dataDir);
try {
const controls = app.mainWindow.locator('app-window-controls');
await expect(controls).not.toHaveClass(/is-hidden/);
// The video players fullscreen their player root through the
// HTML element-fullscreen API. That API needs transient user
// activation, so trigger it from a real click on a temporary
// overlay instead of calling it directly from evaluate().
await app.mainWindow.evaluate(() => {
const overlay = document.createElement('div');
overlay.id = 'e2e-fullscreen-trigger';
overlay.style.cssText =
'position: fixed; inset: 0; z-index: 2147483647;';
overlay.addEventListener('click', () => {
overlay.remove();
void document.documentElement.requestFullscreen();
});
document.body.append(overlay);
});
await app.mainWindow.locator('#e2e-fullscreen-trigger').click();
const isHtmlFullScreen = () =>
app.mainWindow.evaluate(
() => document.fullscreenElement !== null
);
await expect.poll(isHtmlFullScreen, { timeout: 10_000 }).toBe(
true
);
await expect(controls).toHaveClass(/is-hidden/, {
timeout: 10_000,
});
await app.mainWindow.evaluate(() => document.exitFullscreen());
await expect.poll(isHtmlFullScreen, { timeout: 10_000 }).toBe(
false
);
// Regression: the exit push must not carry a stale
// isFullScreen=true read mid-transition — the controls have to
// come back once fullscreen is left.
await expect(controls).not.toHaveClass(/is-hidden/, {
timeout: 10_000,
});
await expect(
app.mainWindow.getByTestId('window-minimize')
).toBeVisible();
} finally {
await closeElectronApp(app);
}
});
test('@electron minimize button minimizes the window', async ({
dataDir,
}) => {
test.skip(
isWindowManagerlessCi,
'xvfb on Linux CI has no window manager'
);
const app = await launchElectronApp(dataDir);
try {
const isMinimized = () =>
app.electronApp.evaluate(({ BrowserWindow }) => {
const mainWindow = BrowserWindow.getAllWindows()[0];
return mainWindow ? mainWindow.isMinimized() : false;
});
await app.mainWindow.getByTestId('window-minimize').click();
await expect.poll(isMinimized, { timeout: 10_000 }).toBe(true);
} finally {
await closeElectronApp(app);
}
});
});
// The native buttons are 14pt circles. At 100 % zoom the first header
// control starts 60pt right of their origin, where macOS 26 ends them
// (earlier releases end them at 52pt).
const lightsHeight = 14;
const headerControlOffset = 60;
/** Where macOS drew the native window buttons, in window points. */
async function trafficLights(
app: LaunchedElectronApp
): Promise<{ x: number; y: number }> {
const lights = await app.electronApp.evaluate(({ BrowserWindow }) =>
BrowserWindow.getAllWindows()[0]?.getWindowButtonPosition()
);
expect(lights, 'native window button position').toBeTruthy();
return lights ?? { x: Number.NaN, y: Number.NaN };
}
/** Window pixels per CSS pixel. */
function zoomFactor(page: Page): Promise<number> {
return page.evaluate(() => window.outerWidth / window.innerWidth);
}
/**
* Steps the app zoom to its minimum (−4, ≈48 %). App zoom scales CSS pixels
* but not the native buttons.
*/
async function zoomOutFully(page: Page): Promise<void> {
for (let step = 0; step < 8; step++) {
await page.evaluate(() => window.electron.adjustZoomLevel('out'));
}
await expect.poll(() => zoomFactor(page)).toBeLessThan(0.6);
}
async function resetZoom(page: Page): Promise<void> {
await page.evaluate(() => window.electron.adjustZoomLevel('reset'));
await expect.poll(() => zoomFactor(page)).toBeCloseTo(1, 2);
}
/**
* The header's first rendered control and the top of the content area, in
* window pixels (CSS pixels times the zoom factor).
*/
function headerLayout(
page: Page
): Promise<{ control?: string; left: number; contentTop: number }> {
return page.locator('.workspace-header').evaluate((header) => {
const zoom = window.outerWidth / window.innerWidth;
const first = [...header.children].find(
(child) => child.getBoundingClientRect().width > 0
);
const body = document.querySelector('.workspace-body');
return {
control:
first?.getAttribute('data-test-id') ??
first?.tagName.toLowerCase(),
left: (first?.getBoundingClientRect().left ?? 0) * zoom,
contentTop: (body?.getBoundingClientRect().top ?? 0) * zoom,
};
});
}
/**
* The first header control starts right of the lights and the content area
* below them. Polled: the layout follows a zoom change after its resize.
*/
async function expectHeaderClearOfLights(
page: Page,
lights: { x: number; y: number },
control: string,
label: string
): Promise<void> {
const layout = () => headerLayout(page);
await expect
.poll(async () => (await layout()).control, { message: label })
.toBe(control);
await expect
.poll(async () => (await layout()).left, {
message: `${label}: first control`,
})
.toBeGreaterThanOrEqual(lights.x + headerControlOffset);
await expect
.poll(async () => (await layout()).contentTop, {
message: `${label}: content top`,
})
.toBeGreaterThanOrEqual(lights.y + lightsHeight);
}
test.describe('macOS traffic lights', () => {
test.skip(
process.platform !== 'darwin',
'Only macOS draws the native traffic lights over the rail'
);
test('@electron the first rail link starts with the content area, clear of the lights', async ({
dataDir,
}) => {
const app = await launchElectronApp(dataDir);
try {
const page = app.mainWindow;
await expect(page.locator('.app-rail')).toHaveClass(/is-macos/);
const firstLink = page.locator('.app-rail a').first();
await expect(firstLink).toBeVisible();
// Aligned with the content area, where the dashboard hero starts.
const linkOffsetFromContent = async (): Promise<number> => {
const [linkBox, contentBox] = await Promise.all([
firstLink.boundingBox(),
page.locator('.workspace-content').boundingBox(),
]);
return Math.abs(
(linkBox?.y ?? 0) - (contentBox?.y ?? Number.NaN)
);
};
expect(await linkOffsetFromContent()).toBeLessThanOrEqual(1);
const lights = await trafficLights(app);
// Keep a visible gap below the buttons, measured in window pixels
// (CSS pixels times the zoom factor).
const linkTopInWindowPixels = async (): Promise<number> => {
const [box, zoom] = await Promise.all([
firstLink.boundingBox(),
zoomFactor(page),
]);
return (box?.y ?? Number.NaN) * zoom;
};
expect(await linkTopInWindowPixels()).toBeGreaterThanOrEqual(
lights.y + lightsHeight + 16
);
// At the smallest zoom the inset must still clear the buttons,
// and the link still starts with the content area.
await zoomOutFully(page);
await expect
.poll(linkTopInWindowPixels)
.toBeGreaterThanOrEqual(lights.y + lightsHeight + 8);
await expect.poll(linkOffsetFromContent).toBeLessThanOrEqual(1);
await resetZoom(page);
} finally {
await closeElectronApp(app);
}
});
test('@xtream @electron the first header control clears the lights at default and minimum zoom', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
try {
const page = app.mainWindow;
const lights = await trafficLights(app);
const switcher = 'app-playlist-switcher';
const back = 'workspace-header-back';
// The first page has no history to go back to: the switcher leads.
await expectHeaderClearOfLights(page, lights, switcher, 'start');
await zoomOutFully(page);
await expectHeaderClearOfLights(
page,
lights,
switcher,
'start at min zoom'
);
await resetZoom(page);
// A detail page puts its Back first, pulled 8px toward the edge.
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
await page
.getByRole('link', { name: 'Series', exact: true })
.click();
await clickFirstGridListCard(page);
await expect(page.getByTestId(back)).toBeVisible({
timeout: 20_000,
});
await expectHeaderClearOfLights(page, lights, back, 'detail');
await zoomOutFully(page);
await expectHeaderClearOfLights(
page,
lights,
back,
'detail at min zoom'
);
await resetZoom(page);
} finally {
await closeElectronApp(app);
}
});
});