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.
This commit is contained in:
ethernet
2026-09-05 22:55:10 -04:00
parent a3eef50d0d
commit aef539b47a
7 changed files with 95 additions and 48 deletions
@@ -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<void> }
// 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()
}
})
}
+3 -23
View File
@@ -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.
+3 -23
View File
@@ -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<string, string>,
@@ -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');
+29
View File
@@ -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 }
+1 -1
View File
@@ -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" \
+1 -1
View File
@@ -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" \
+2
View File
@@ -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 |