diff --git a/package.json b/package.json index c15eae0f..14289ed6 100644 --- a/package.json +++ b/package.json @@ -157,7 +157,11 @@ "test:visible": "./scripts/timed.sh sh -c 'yarn build && HEADLESS=0 BACKEND=electron npx playwright test tests/desktop/ --headed'", "test:debug": "./scripts/timed.sh sh -c 'yarn build && HEADLESS=0 npx playwright test --debug'", "test:grep": "./scripts/timed.sh sh -c 'yarn build && HEADLESS=1 BACKEND=electron ./scripts/playwright-with-display.sh test tests/desktop/ --grep \"$0\"'", - "test:electron:bg": "nohup yarn test:electron > /tmp/test-electron.log 2>&1 & disown; echo 'Tests running in background, see: /tmp/test-electron.log'", + "test:electron:grep": "HEADLESS=1 PACKAGED=1 BACKEND=electron ./scripts/playwright-with-display.sh test tests/desktop/ --reporter=line --grep \"$0\"", + "test:electron:spec": "HEADLESS=1 PACKAGED=1 BACKEND=electron ./scripts/playwright-with-display.sh test --reporter=line \"$0\"", + "test:electron:spec:bg": "setsid -f nice -n 19 sh -c 'read SPEC < /tmp/test-spec && exec yarn test:electron:spec \"$SPEC\" > /tmp/test-electron.log 2>&1 < /dev/null'", + "test:electron:bg": "setsid -f nice -n 19 sh -c 'exec yarn test:electron > /tmp/test-electron.log 2>&1 < /dev/null'", + "test:electron:grep:bg": "setsid -f nice -n 19 sh -c 'read PATTERN < /tmp/test-grep-pattern && exec yarn test:electron:grep \"$PATTERN\" > /tmp/test-electron.log 2>&1 < /dev/null'", "test:log": "tail -f /tmp/test-electron.log", "//-- Manual debugging harness (see tests/manual/README.md) --//": "", "harness": "./scripts/timed.sh sh -c 'yarn build && BACKEND=electron HEADLESS=0 npx playwright test --project=manual --workers=1 --headed'", diff --git a/scripts/playwright-with-display.sh b/scripts/playwright-with-display.sh index f9de4baa..08eeae94 100755 --- a/scripts/playwright-with-display.sh +++ b/scripts/playwright-with-display.sh @@ -1,24 +1,48 @@ #!/usr/bin/env bash # Run `npx playwright ` with a display attached. # -# On Linux we run under Xvfb so test BrowserWindows actually render to a -# virtual framebuffer instead of being created with show:false. The -# show:false path triggers Chromium's PageVisibility=hidden state on -# Linux, which suspends requestAnimationFrame and hangs Playwright's -# default RAF-based waitForFunction polling — tests time out at 30s -# even though the underlying state has long since transitioned. macOS -# happens to keep RAF alive for show:false windows so it doesn't need -# this; we keep the existing HEADLESS=1 behavior there. +# On Linux we run under Xvfb + openbox so test BrowserWindows actually +# render to a virtual framebuffer and so X11 WM hints like maximize are +# honored. The show:false path triggers Chromium's PageVisibility=hidden +# state on Linux, which suspends requestAnimationFrame and hangs +# Playwright's default RAF-based waitForFunction polling — tests time +# out at 30s even though the underlying state has long since +# transitioned. macOS happens to keep RAF alive for show:false windows +# so it doesn't need this; we keep the existing HEADLESS=1 behavior +# there. # # We unset HEADLESS/PEEK_HEADLESS on Linux so the test fixture takes the # normal "windows are shown" path; the windows appear inside Xvfb's # virtual display and never reach the user's real session. +# +# On Wayland sessions we also unset WAYLAND_DISPLAY/WAYLAND_SOCKET. +# Electron 38+ defaults to Ozone Wayland whenever WAYLAND_DISPLAY is +# present and bypasses xvfb-run's DISPLAY redirect entirely — windows +# end up on the real compositor. Pair this with --ozone-platform=x11 +# in the fixture's launch args so Electron stays on XWayland. +# +# Openbox: bare Xvfb has no window manager, so _NET_WM_STATE_MAXIMIZED_* +# requests get briefly honored then snapped back when other X events +# arrive. That breaks maximize-after-N-events tests like +# page-layout.spec.ts "panels clamped to screen edges when maximized" +# and window-targeting.spec.ts "maximize is non-toggling" — windows +# laid out as maximized, then measured at their pre-maximize width. +# Openbox is a tiny EWMH-compliant WM that makes maximize stick. set -euo pipefail if [ "$(uname -s)" = "Linux" ]; then - unset HEADLESS PEEK_HEADLESS - exec xvfb-run -a -s "-screen 0 1920x1080x24" npx playwright "$@" + unset HEADLESS PEEK_HEADLESS WAYLAND_DISPLAY WAYLAND_SOCKET + exec xvfb-run -a -s "-screen 0 1920x1080x24" bash -c ' + openbox & + OB_PID=$! + # Give openbox a moment to claim the WM selection before any + # BrowserWindow is created; otherwise the first window can be + # created in pre-WM state. + sleep 0.3 + trap "kill $OB_PID 2>/dev/null || true" EXIT + exec npx playwright "$@" + ' bash "$@" fi exec npx playwright "$@" diff --git a/tests/desktop/page-layout.spec.ts b/tests/desktop/page-layout.spec.ts index b1547a18..4588c1b0 100644 --- a/tests/desktop/page-layout.spec.ts +++ b/tests/desktop/page-layout.spec.ts @@ -618,6 +618,19 @@ test.describe('Page Layout Maximize @desktop', () => { { timeout: 5000 } ); + // The body.maximized class flips via the FSM as soon as the IPC + // returns, but setBounds(workArea) is an async X11 configure-request + // — actual window.innerWidth doesn't reflect the new size until the + // server processes it. Without this wait, panel positions get laid + // out against the now-wide window while we then sample + // window.innerWidth against the not-yet-wide window, and the + // clamp assertion sees a 1632px panel.left against an 816px window. + await pageWindow.waitForFunction( + () => window.innerWidth > 1000, + undefined, + { timeout: 5000 } + ); + // Show navbar to reveal panels await sharedBgWindow.evaluate(async (wid: number) => { (window as any).app.publish( diff --git a/tests/desktop/window-targeting.spec.ts b/tests/desktop/window-targeting.spec.ts index 716565ff..e2203f1c 100644 --- a/tests/desktop/window-targeting.spec.ts +++ b/tests/desktop/window-targeting.spec.ts @@ -252,13 +252,23 @@ test.describe('Window Targeting @desktop', () => { // (X11/Wayland) isMaximized() reflects the real WM state and stays // false. Compare bounds against the work area directly so the // assertion has the same meaning across platforms. + // Tolerance: real X11 WMs (e.g. openbox) often constrain a maximized + // window's bounds to workArea minus 1px borders for resize affordance. + // The test's intent is "second maximize didn't UN-maximize" — being + // within a handful of pixels of the work area still satisfies that. + // macOS/Wayland-native give exact workArea, but the test must pass + // across all WMs. const isFillingWorkArea = (id: number) => app.evaluateMain!( ({ BrowserWindow, screen }, wid) => { const w = BrowserWindow.fromId(wid); if (!w) return false; const b = w.getBounds(); const wa = screen.getDisplayMatching(b).workArea; - return b.x === wa.x && b.y === wa.y && b.width === wa.width && b.height === wa.height; + const slack = 4; + return Math.abs(b.x - wa.x) <= slack + && Math.abs(b.y - wa.y) <= slack + && Math.abs(b.width - wa.width) <= slack + && Math.abs(b.height - wa.height) <= slack; }, id, ); diff --git a/tests/fixtures/desktop-app.ts b/tests/fixtures/desktop-app.ts index d9f7b9fb..db84c79d 100644 --- a/tests/fixtures/desktop-app.ts +++ b/tests/fixtures/desktop-app.ts @@ -171,6 +171,20 @@ function isPackaged(): boolean { return val === '1' || val === 'true'; } +function packagedExecutableRelPath(): string { + const arch = process.arch; + switch (process.platform) { + case 'darwin': + return `out/mac-${arch}/Peek.app/Contents/MacOS/Peek`; + case 'linux': + return `out/linux-${arch}-unpacked/peek`; + case 'win32': + return `out/win-${arch}-unpacked/Peek.exe`; + default: + throw new Error(`Unsupported platform for packaged Electron: ${process.platform}`); + } +} + /** * Launch Electron backend * @@ -198,13 +212,18 @@ async function launchElectron(profile: string, options: LaunchOptions = {}): Pro ? [] : [ROOT]; + // Force XWayland on Linux. Electron 38+ picks Wayland whenever + // WAYLAND_DISPLAY is set (Ozone auto-detect) and the window escapes + // xvfb-run's DISPLAY redirect onto the real compositor. + const linuxOzoneArgs = process.platform === 'linux' ? ['--ozone-platform=x11'] : []; + const launchConfig = packaged ? { - executablePath: path.join(ROOT, 'out/mac-arm64/Peek.app/Contents/MacOS/Peek'), - args: [`--user-data-dir=${tempDir}`] + executablePath: path.join(ROOT, packagedExecutableRelPath()), + args: [...linuxOzoneArgs, `--user-data-dir=${tempDir}`] } : { - args: [...baseArgs, `--user-data-dir=${tempDir}`] + args: [...baseArgs, ...linuxOzoneArgs, `--user-data-dir=${tempDir}`] }; const electronApp = await electron.launch({ @@ -729,7 +748,7 @@ export async function closeSharedApp(): Promise { * * The app is launched before each test and closed after. */ -export const test = base.extend<{ desktopApp: DesktopApp }>({ +export const test = base.extend<{ desktopApp: DesktopApp; sharedAppQuiesce: void }>({ desktopApp: async ({}, use, testInfo) => { // Manual harness specs (project=manual, e.g. tests/manual/*.harness.ts) // use a stable `test-harness-` profile so cookies, localStorage, @@ -744,6 +763,61 @@ export const test = base.extend<{ desktopApp: DesktopApp }>({ await use(app); await app.close(); }, + + // Auto-fixture: between every test, wait for the shared app's window set + // to stop changing. Per-spec closeWindow() helpers return as soon as the + // close IPC is acked, but BrowserWindow destruction (and the playwright + // CDP helper context that piggybacks on it) takes longer to settle. If + // the next test starts mid-teardown, its first evaluateMain call hits + // "Execution context was destroyed" and the existing bounded retry + // (500ms) sometimes isn't enough. Quiescing here removes the race at + // its source instead of bandaging the symptom with bigger retry budgets. + sharedAppQuiesce: [async ({}, use) => { + await use(); + if (sharedApp) { + await waitForSharedAppWindowsStable(sharedApp); + } + }, { auto: true }], }); +/** + * Poll the shared Electron app's BrowserWindow count until it has been + * stable (unchanged) for `stableForMs`, or `maxWaitMs` elapses. Tolerates + * the navigation race that can occur mid-poll — if the helper context dies + * during evaluate, treat that as a non-stable sample and keep polling. + */ +async function waitForSharedAppWindowsStable( + app: DesktopApp, + { maxWaitMs = 3000, stableForMs = 120, pollMs = 30 }: { + maxWaitMs?: number; stableForMs?: number; pollMs?: number; + } = {}, +): Promise { + if (!app.evaluateMain) return; + const deadline = Date.now() + maxWaitMs; + let lastCount = -1; + let stableSince = -1; + while (Date.now() < deadline) { + let count: number; + try { + count = await app.evaluateMain(({ BrowserWindow }) => BrowserWindow.getAllWindows().length); + } catch { + // navigation race during poll itself — reset stability tracking + lastCount = -1; + stableSince = -1; + await new Promise(r => setTimeout(r, pollMs)); + continue; + } + if (count === lastCount) { + if (stableSince < 0) stableSince = Date.now(); + if (Date.now() - stableSince >= stableForMs) return; + } else { + lastCount = count; + stableSince = -1; + } + await new Promise(r => setTimeout(r, pollMs)); + } + // Timed out waiting for quiescence — downstream evaluateMain retries + // still backstop the case where the app genuinely won't settle. +} + export { expect } from '@playwright/test';