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