fix(install-e2e): reach Settings through the late-mounting onboarding overlay

The overlay mounts in phases (boot-progress card, then the provider picker) and the settings gear reports visible while sitting under it, so a one-shot dismiss probe and a visibility gate both race it. Alternate short-timeout dismiss clicks with settings clicks until a settings click lands; a landed click is proof the overlay is gone. Also pick the window that renders the app UI instead of trusting firstWindow(), which can return a helper webContents.
This commit is contained in:
yoniebans
2026-08-25 09:47:23 +03:00
parent 08f6a6de23
commit f0e3bcdac9
2 changed files with 134 additions and 53 deletions

View File

@@ -93,7 +93,28 @@ async function main() {
timeout: 120_000
})
const page = await app.firstWindow({ timeout: 120_000 })
// firstWindow() can grab a helper webContents (wake indicator etc.), not
// the main app window. Pick the window that actually renders UI (has a
// <button>), retrying as windows appear.
await app.firstWindow({ timeout: 120_000 })
let page = null
const windowDeadline = Date.now() + 120_000
while (!page) {
for (const candidate of app.windows()) {
const hasUi = await candidate
.evaluate(() => document.querySelector('button') !== null)
.catch(() => false)
if (hasUi) { page = candidate; break }
}
if (!page) {
if (Date.now() > windowDeadline) {
for (const c of app.windows()) log(` window seen: url=${c.url()}`)
throw new Error('no window with app UI (a <button>) appeared within 120s')
}
await new Promise(r => setTimeout(r, 1_000))
}
}
log(`window picked (${app.windows().length} windows, url=${page.url()})`)
log('first window acquired')
// Boot: wait for the composer to exist — the shell is mounted by then.
@@ -106,41 +127,61 @@ async function main() {
await page.waitForTimeout(3000)
await shot(page, '01-app-booted')
// ── Dismiss onboarding if present ─────────────────────────────────────
// ── Reach Settings through the onboarding overlay ─────────────────────
// A fresh install with no configured provider shows the onboarding card
// ("Let's get you setup..."). The update path needs no provider, so skip
// it via "I'll choose a provider later" to reach the app shell (which
// has the settings gear). Harmless no-op if onboarding isn't shown.
const dismissedOnboarding = await clickFirstVisible(
page,
[
p => p.getByRole('button', { name: /choose a provider later/i }),
p => p.getByText(/choose a provider later/i),
p => p.getByRole('button', { name: /skip/i })
],
'skip onboarding',
8_000
)
// ("Let's get you setup..."). It mounts LATE and in phases: first a
// buttonless boot-progress card ("Starting Hermes... 86%"), then the
// provider picker with "I'll choose a provider later".
// Two traps: a one-shot dismiss probe fires before the picker's button
// exists, and isVisible() on the settings gear reports true while the
// gear sits UNDER the fullscreen overlay that intercepts every click.
// So: alternate short-timeout dismiss clicks with short-timeout settings
// clicks until a settings click actually LANDS - Playwright's click
// checks the hit target, so a landed click is proof the overlay is gone.
// Harmless when onboarding never shows: the first settings click lands.
const laterLocators = [
p => p.getByRole('button', { name: /choose a provider later/i }),
p => p.getByText(/choose a provider later/i),
p => p.getByRole('button', { name: /skip/i })
]
const settingsLocators = [
p => p.getByLabel('Open settings'),
p => p.locator('[aria-label="Open settings"]'),
p => p.locator('[title="Open settings"]'),
p => p.getByRole('button', { name: 'Open settings' })
]
if (dismissedOnboarding) {
log('dismissed onboarding overlay')
await page.waitForTimeout(2500)
await shot(page, '01b-onboarding-dismissed')
const overlayDeadline = Date.now() + 180_000
let openedSettings = false
const brief = e => String((e && e.message) || e).split('\n').slice(0, 25).join(' | ')
let iter = 0
while (!openedSettings) {
iter++
for (const make of laterLocators) {
try {
await make(page).first().click({ timeout: 1_500 })
log('dismissed onboarding overlay')
await page.waitForTimeout(2500)
await shot(page, '01b-onboarding-dismissed')
break
} catch (e) {
log(`[overlay] iter ${iter} dismiss click failed: ${brief(e)}`)
}
}
for (const make of settingsLocators) {
try {
await make(page).first().click({ timeout: 2_500 })
openedSettings = true
log('clicked: Open settings')
break
} catch (e) {
log(`[overlay] iter ${iter} settings click failed: ${brief(e)}`)
}
}
if (!openedSettings && Date.now() > overlayDeadline) break
}
// ── Open Settings (titlebar gear) ─────────────────────────────────────
const openedSettings = await clickFirstVisible(
page,
[
p => p.getByLabel('Open settings'),
p => p.locator('[aria-label="Open settings"]'),
p => p.locator('[title="Open settings"]'),
p => p.getByRole('button', { name: 'Open settings' })
],
'Open settings',
60_000
)
if (!openedSettings) {
await shot(page, 'ERROR-no-settings-button')
throw new Error('could not find the Open settings control')

View File

@@ -29,7 +29,6 @@
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { parseArgs } from 'node:util';
import { _electron } from '@playwright/test';
@@ -81,6 +80,7 @@ function log(msg) {
console.log(`[launch-from-spec] ${msg}`);
}
async function main() {
const { values } = parseArgs({
options: {
@@ -104,9 +104,31 @@ async function main() {
cwd: launch.cwd,
env: launch.env,
});
const window = await app.firstWindow({ timeout: 120_000 });
// The app spawns several BrowserWindows (wake indicator, helper surfaces)
// and firstWindow() grabs whichever webContents came first, which is not
// always the main app window. Pick the window that actually renders the
// app UI (a button renders only in the real renderer), retrying as
// windows appear.
await app.firstWindow({ timeout: 120_000 });
let window = null;
const windowDeadline = Date.now() + 120_000;
while (!window) {
for (const candidate of app.windows()) {
const hasUi = await candidate
.evaluate(() => document.querySelector('button') !== null)
.catch(() => false);
if (hasUi) { window = candidate; break; }
}
if (!window) {
if (Date.now() > windowDeadline) {
for (const c of app.windows()) log(` window seen: url=${c.url()}`);
throw new Error('no window with app UI (a <button>) appeared within 120s');
}
await new Promise((r) => setTimeout(r, 1_000));
}
}
await window.waitForLoadState('domcontentloaded');
log(`window up: ${await window.title()}`);
log(`window up: ${await window.title()} (${app.windows().length} windows, picked url=${window.url()})`);
await window.screenshot({ path: `${values.spec}.window.png` }).catch(() => {});
if (values['no-update']) {
@@ -121,31 +143,48 @@ async function main() {
const deadline = Date.now() + Number(values['timeout-ms']);
// Dismiss the onboarding overlay when present. Two layers of defense:
// the drivers seed a provider key so the app's runtime check reports
// configured=true and the overlay never mounts; if it shows anyway
// (fresh HERMES_HOME, slow readiness check), click the real escape
// hatch - "I'll choose a provider later" (i18n en: chooseLater). The
// overlay is a fullscreen div that intercepts ALL clicks, so this must
// resolve before any Settings navigation.
// Dismiss the onboarding overlay when present. The drivers seed a
// provider so the overlay SHOULD never mount, but it has a real boot
// window: the renderer inits `configured` from a localStorage cache
// (null on a fresh install) and only flips after gateway probes, so the
// overlay can mount late - first as a buttonless boot-progress card,
// then as the provider picker with the real escape hatch, "I'll choose
// a provider later" (i18n en: chooseLater). Two traps this loop avoids:
// a one-shot dismiss probe loses to the late mount, and visibility is
// the wrong readiness signal - the settings gear is "visible" UNDER the
// fullscreen overlay while the overlay intercepts every click. So:
// alternate short-timeout dismiss clicks with short-timeout settings
// clicks until a settings click actually LANDS (Playwright's hit-target
// check makes a landed click proof the overlay is gone).
const later = window.getByRole('button', { name: /choose a provider later|skip/i }).first()
const settingsButton = window.getByRole('button', { name: /open settings|settings/i }).first()
await Promise.race([
settingsButton.isVisible({ timeout: 60_000 }).catch(() => false),
later
.isVisible({ timeout: 90_000 })
.catch(() => false)
const overlayDeadline = Date.now() + 180_000
let settingsOpened = false
const brief = (e) => String(e && e.message || e).split('\n').slice(0, 25).join(' | ')
for (let iter = 1; ; iter++) {
await later
.click({ timeout: 2_000 })
.then(async () => {
await later.click().catch(() => {})
log('dismissed onboarding overlay')
await later.waitFor({ state: 'hidden', timeout: 15_000 }).catch(() => {})
})
])
.catch((e) => log(`[overlay] iter ${iter} chooseLater click failed: ${brief(e)}`))
try {
await settingsButton.click({ timeout: 4_000 })
settingsOpened = true
break
} catch (e) {
log(`[overlay] iter ${iter} settings click failed: ${brief(e)}`)
}
if (Date.now() > overlayDeadline) break
}
if (!settingsOpened) {
await window.screenshot({ path: `${values.spec}.overlay-stuck.png` }).catch(() => {})
throw new Error('onboarding overlay never cleared: Settings not clickable within 180s')
}
// Settings -> About -> Update now. The settings trigger is an icon
// button whose accessible name is "Open settings".
await window.getByRole('button', { name: /open settings|settings/i }).first().click();
// Settings is open: About -> Update now.
await window.getByRole('tab', { name: /about/i }).or(
window.getByRole('button', { name: /about/i })).first().click();
const updateNow = window.getByRole('button', { name: /update now/i }).first();
@@ -183,6 +222,7 @@ async function main() {
}
await new Promise((r) => setTimeout(r, 2_000));
}
await app.close().catch(() => {});
}