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 |