From aef539b47a5197b1dbbaccc42e8509727b3df39a Mon Sep 17 00:00:00 2001 From: ethernet Date: Sat, 5 Sep 2026 22:55:10 -0400 Subject: [PATCH] fix(install-e2e): persist input zoom across window lifecycle events Share zoom preparation across both launchers and stage the helper with each driver. Use the Appearance preference bridge and verify page zoom rather than display DPR. Real Electron regressions fail with transient zoom on focus/navigation and pass with persistence. Repeated click-throughs, onboarding unit tests, E2E typecheck and lint passed. The historical onboarding timeout and full install/update matrix remain unverified. --- apps/desktop/e2e/onboarding-settings.spec.ts | 56 +++++++++++++++++++ tests/install/e2e-assets/drive-update.cjs | 26 +-------- tests/install/e2e-assets/launch-from-spec.mjs | 26 +-------- tests/install/e2e-assets/window-input.cjs | 29 ++++++++++ tests/install/installer-script-e2e.sh | 2 +- tests/install/macos-desktop-e2e.sh | 2 +- tests/install/windows-e2e.ps1 | 2 + 7 files changed, 95 insertions(+), 48 deletions(-) create mode 100644 apps/desktop/e2e/onboarding-settings.spec.ts create mode 100644 tests/install/e2e-assets/window-input.cjs diff --git a/apps/desktop/e2e/onboarding-settings.spec.ts b/apps/desktop/e2e/onboarding-settings.spec.ts new file mode 100644 index 0000000000..3608f500db --- /dev/null +++ b/apps/desktop/e2e/onboarding-settings.spec.ts @@ -0,0 +1,56 @@ +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import path from 'node:path' + +import { setupNoProvider } from './fixtures' +import { type ElectronApplication, expect, type Page, test } from './test' + +const { prepareWindowForInput } = createRequire(import.meta.url)( + '../../../tests/install/e2e-assets/window-input.cjs', +) as { prepareWindowForInput: (app: ElectronApplication, page: Page) => Promise } + +// Exercise the install driver's input setup against the real renderer/backend, +// with no installer, update, credentials, or live user data. +for (const lifecycleEvent of ['focus', 'navigation'] as const) { + test(`onboarding input zoom survives ${lifecycleEvent} and opens Settings`, async () => { + const fixture = await setupNoProvider() + const { app, page, sandbox } = fixture + + try { + const later = page.getByRole('button', { name: /choose a provider later/i }) + await expect(later).toBeVisible({ timeout: 60_000 }) + const zoomFile = path.join(sandbox.userDataDir, 'zoom-state.json') + const savedLevel = () => JSON.parse(readFileSync(zoomFile, 'utf8')).zoomLevel as number + await page.evaluate(() => { + const desktop = (window as unknown as { hermesDesktop: { zoom: { setPercent: (percent: number) => void } } }).hermesDesktop + desktop.zoom.setPercent(90) + }) + await expect.poll(savedLevel).toBeCloseTo(Math.log(0.9) / Math.log(1.2)) + + await prepareWindowForInput(app, page) + const appWindow = await app.browserWindow(page) + + // The same lifecycle callback that fires when another window takes focus + // must restore our input scale, not the original 90% preference. + if (lifecycleEvent === 'focus') { + await appWindow.evaluate(win => win.emit('focus')) + } else { + await page.evaluate(() => { window.location.hash = '#/settings' }) + } + + await expect.poll(() => appWindow.evaluate(win => win.webContents.getZoomFactor())).toBeCloseTo(1) + expect(savedLevel()).toBe(0) + await later.click({ timeout: 5_000 }) + await expect(later).toBeHidden() + + if (lifecycleEvent === 'navigation') { + await page.evaluate(() => { window.location.hash = '#/' }) + } + + await page.getByRole('button', { name: 'Open settings', exact: true }).click({ timeout: 5_000 }) + await expect(page).toHaveURL(/settings/) + } finally { + await fixture.cleanup() + } + }) +} diff --git a/tests/install/e2e-assets/drive-update.cjs b/tests/install/e2e-assets/drive-update.cjs index edf4838e1b..ffb33d2658 100644 --- a/tests/install/e2e-assets/drive-update.cjs +++ b/tests/install/e2e-assets/drive-update.cjs @@ -20,6 +20,7 @@ const path = require('node:path') const fs = require('node:fs') const { _electron } = require('@playwright/test') +const { prepareWindowForInput } = require('./window-input.cjs') const exePath = process.argv[2] const proofDir = process.argv[3] @@ -117,29 +118,8 @@ async function main() { log(`window picked (${app.windows().length} windows, url=${page.url()})`) log('first window acquired') - // The app ships a 90% UI-zoom default, so a fresh install renders at - // devicePixelRatio 0.9 and Playwright's input coordinates land ~10% off - // target on the CI runners. Force 100% through the same webContents API - // the app's zoom control uses. A single set gets reverted (the boot path - // re-applies the default asynchronously), so set-verify-retry until dpr - // reads 1. - try { - const before = await page.evaluate(() => window.devicePixelRatio) - let after = before - for (let i = 0; i < 20; i++) { - await app.evaluate(({ BrowserWindow }) => { - for (const w of BrowserWindow.getAllWindows()) { - w.webContents.setZoomLevel(0) - } - }) - await page.waitForTimeout(1000) - after = await page.evaluate(() => window.devicePixelRatio) - if (Math.abs(after - 1) < 0.001) break - } - log(`[zoom] forced 100% via webContents.setZoomLevel(0): dpr ${before} -> ${after}`) - } catch (e) { - log(`[zoom] direct zoom set failed (continuing): ${e.message}`) - } + await prepareWindowForInput(app, page) + log('[zoom] app window prepared at 100%') // Boot: wait for the composer to exist — the shell is mounted by then. // The real backend (`hermes serve`) is booting underneath; give it time. diff --git a/tests/install/e2e-assets/launch-from-spec.mjs b/tests/install/e2e-assets/launch-from-spec.mjs index eaf87f28d1..49db869218 100644 --- a/tests/install/e2e-assets/launch-from-spec.mjs +++ b/tests/install/e2e-assets/launch-from-spec.mjs @@ -32,6 +32,7 @@ import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { parseArgs } from 'node:util'; import { _electron } from '@playwright/test'; +import { prepareWindowForInput } from './window-input.cjs'; /** * @typedef {{argv: string[], cwd: string, env: Record, @@ -150,29 +151,8 @@ async function main() { log(`window up: ${await window.title()} (${app.windows().length} windows, picked url=${window.url()})`); await window.screenshot({ path: `${values.spec}.window.png` }).catch(() => {}); - // The app ships a 90% UI-zoom default, so a fresh install renders at - // devicePixelRatio 0.9 and Playwright's input coordinates land ~10% off - // target on the CI runners. Force 100% through the same webContents API - // the app's zoom control uses. A single set gets reverted (the boot path - // re-applies the default asynchronously), so set-verify-retry until dpr - // reads 1. - try { - const before = await window.evaluate(() => window.devicePixelRatio); - let after = before; - for (let i = 0; i < 20; i++) { - await app.evaluate(({ BrowserWindow }) => { - for (const w of BrowserWindow.getAllWindows()) { - w.webContents.setZoomLevel(0); - } - }); - await window.waitForTimeout(1000); - after = await window.evaluate(() => window.devicePixelRatio); - if (Math.abs(after - 1) < 0.001) break; - } - log(`[zoom] forced 100% via webContents.setZoomLevel(0): dpr ${before} -> ${after}`); - } catch (e) { - log(`[zoom] direct zoom set failed (continuing): ${e.message}`); - } + await prepareWindowForInput(app, window); + log('[zoom] app window prepared at 100%'); if (values['no-update']) { log('smoke mode: window proven, closing'); diff --git a/tests/install/e2e-assets/window-input.cjs b/tests/install/e2e-assets/window-input.cjs new file mode 100644 index 0000000000..a05846209f --- /dev/null +++ b/tests/install/e2e-assets/window-input.cjs @@ -0,0 +1,29 @@ +// Shared input setup for the install drivers. Only the selected app window +// is changed; helper windows retain their own coordinate system. +async function prepareWindowForInput(app, page) { + const window = await app.browserWindow(page) + // Use the same persistent setting as Appearance. A bare setZoomLevel is + // overwritten by the app's focus/navigation handlers restoring saved zoom. + const persistent = await page.evaluate(() => { + const zoom = globalThis.hermesDesktop?.zoom + if (!zoom?.setPercent || !zoom?.get) return false + zoom.setPercent(100) + return true + }) + if (persistent) { + await page.waitForFunction(async () => { + const state = await globalThis.hermesDesktop.zoom.get() + return state.percent === 100 + }, undefined, { timeout: 15_000 }) + } else { + // Older sampled releases have no zoom preference bridge. + await window.evaluate(win => win.webContents.setZoomLevel(0)) + } + // DPR includes OS display scaling; 100% page zoom is not always DPR 1. + const factor = await window.evaluate(win => win.webContents.getZoomFactor()) + if (Math.abs(factor - 1) > 0.001) { + throw new Error(`could not set app window zoom to 100% (factor ${factor})`) + } +} + +module.exports = { prepareWindowForInput } diff --git a/tests/install/installer-script-e2e.sh b/tests/install/installer-script-e2e.sh index 834ec143f2..2c028ce366 100755 --- a/tests/install/installer-script-e2e.sh +++ b/tests/install/installer-script-e2e.sh @@ -397,7 +397,7 @@ case "$UPDATE_METHOD" in (cd "$PW_DIR" && npm install --no-save --no-audit --no-fund \ "@playwright/test@1.58.2" 2>&1 | ts_prefix > "$LOG_DIR/playwright-install.log") \ || { log_group "playwright install transcript" "$LOG_DIR/playwright-install.log"; fail "playwright install failed"; } - cp "$ASSETS/launch-from-spec.mjs" "$PW_DIR/" + cp "$ASSETS/launch-from-spec.mjs" "$ASSETS/window-input.cjs" "$PW_DIR/" rc=0 (cd "$PW_DIR" && node launch-from-spec.mjs \ --spec "$SPEC" \ diff --git a/tests/install/macos-desktop-e2e.sh b/tests/install/macos-desktop-e2e.sh index 613ce8a80f..da74976971 100755 --- a/tests/install/macos-desktop-e2e.sh +++ b/tests/install/macos-desktop-e2e.sh @@ -306,7 +306,7 @@ run_playwright_update() { local spec="$1" local pw_dir pw_dir="$(ensure_playwright)" - cp "$ASSETS/launch-from-spec.mjs" "$pw_dir/" + cp "$ASSETS/launch-from-spec.mjs" "$ASSETS/window-input.cjs" "$pw_dir/" local rc=0 (cd "$pw_dir" && node launch-from-spec.mjs \ --spec "$spec" \ diff --git a/tests/install/windows-e2e.ps1 b/tests/install/windows-e2e.ps1 index ec8bc61d67..e9af0a9ec4 100644 --- a/tests/install/windows-e2e.ps1 +++ b/tests/install/windows-e2e.ps1 @@ -429,6 +429,7 @@ function Invoke-HermesDesktopAppUpdate([string]$TargetSha) { Assert-True ($npmExit -eq 0) "npm install @playwright/test@$PlaywrightVersion into the driver dir" Copy-Item (Join-Path $AssetsDir "launch-from-spec.mjs") (Join-Path $driverDir "launch-from-spec.mjs") -Force + Copy-Item (Join-Path $AssetsDir "window-input.cjs") (Join-Path $driverDir "window-input.cjs") -Force $prevEap = $ErrorActionPreference; $ErrorActionPreference = "Continue" Push-Location $driverDir try { @@ -755,6 +756,7 @@ function Invoke-GuiUpdateDesktopRoute([string]$TargetSha) { # node_modules. $driver = Join-Path $driverDir "e2e-drive-update.cjs" Copy-Item (Join-Path $AssetsDir "drive-update.cjs") $driver -Force + Copy-Item (Join-Path $AssetsDir "window-input.cjs") (Join-Path $driverDir "window-input.cjs") -Force Push-Location $driverDir try { & $node $driver $desktopExe $proof 2>&1 |