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:
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 }
|
||||
@@ -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" \
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user