Files
hermes-agent/tests/install/e2e-assets/drive-update.cjs
T

336 lines
12 KiB
JavaScript

// drive-update.cjs — launch the INSTALLED Hermes.exe (real Electron desktop
// app) under Playwright's Electron driver and perform the update the way a
// user does: Settings -> About -> "Update now". Screenshots at every step.
//
// Run from the installed checkout's apps/desktop directory (so
// @playwright/test resolves from ITS node_modules — the same deps the
// installed app was built with):
//
// node <this file> <path-to-Hermes.exe> <proof-dir>
//
// Exit codes: 0 = update hand-off started and the app quit (the detached
// updater takes it from there — the PowerShell driver polls for the result);
// 1 = any step failed. The driver treats nonzero as leg failure.
//
// This intentionally does NOT call any store/bridge function directly: only
// real clicks on the real UI, so a regression in the button, the About
// panel, the overlay, or the renderer->main bridge fails the test.
const path = require('node:path')
const fs = require('node:fs')
const { _electron } = require('@playwright/test')
const { prepareWindowForInput } = require('./window-input.cjs')
const { observeProcessClose } = require('./process-close.cjs')
const exePath = process.argv[2]
const proofDir = process.argv[3]
if (!exePath || !proofDir) {
console.error('usage: node drive-update.cjs <Hermes.exe> <proof-dir>')
process.exit(1)
}
fs.mkdirSync(proofDir, { recursive: true })
function log(msg) {
console.log(`[drive-update] ${new Date().toISOString()} ${msg}`)
}
async function shot(page, name) {
const file = path.join(proofDir, `${name}.png`)
try {
await page.screenshot({ path: file })
log(`screenshot: ${file}`)
} catch (err) {
log(`screenshot ${name} failed: ${err.message}`)
}
}
// Hard ceiling so a hung renderer can't wedge the CI job; the driver's own
// step timeout is the real guard, this is belt-and-braces.
const KILL_AFTER_MS = 15 * 60 * 1000
const killer = setTimeout(() => {
console.error('[drive-update] global timeout — aborting')
process.exit(1)
}, KILL_AFTER_MS)
killer.unref()
async function clickFirstVisible(page, locators, description, timeoutMs) {
const deadline = Date.now() + timeoutMs
for (;;) {
for (const make of locators) {
const locator = make(page).first()
try {
if (await locator.isVisible()) {
await locator.click()
log(`clicked: ${description}`)
return true
}
} catch {
// locator invalid in this state; try the next
}
}
if (Date.now() > deadline) {
return false
}
await page.waitForTimeout(500)
}
}
async function main() {
log(`launching ${exePath}`)
const app = await _electron.launch({
executablePath: exePath,
args: ['--disable-gpu', '--no-sandbox'],
// Inherit the driver's env: HERMES_HOME (isolated install) and
// GIT_CONFIG_GLOBAL (URL redirect to the staged serve repo) MUST reach
// the main process so its update check fetches from the staged repo.
env: { ...process.env },
timeout: 120_000
})
const child = app.process()
const waitForProcessClose = observeProcessClose(child)
log(`launched Electron pid=${child.pid}`)
// 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')
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.
await page.waitForSelector('textarea, [contenteditable="true"]', {
state: 'attached',
timeout: 300_000
})
log('renderer booted (composer attached)')
await page.waitForTimeout(3000)
await shot(page, '01-app-booted')
// ── Reach Settings through the onboarding overlay ─────────────────────
// A fresh install with no configured provider shows the onboarding card
// ("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' })
]
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) {
// A boot-time restore or focus event can move the scale after preparation.
await prepareWindowForInput(app, page)
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
}
if (!openedSettings) {
await shot(page, 'ERROR-no-settings-button')
throw new Error('could not find the Open settings control')
}
await page.waitForTimeout(1500)
await shot(page, '02-settings-open')
// ── Go to the About section ───────────────────────────────────────────
const openedAbout = await clickFirstVisible(
page,
[
p => p.getByRole('tab', { name: 'About' }),
p => p.getByRole('button', { name: 'About' }),
p => p.getByText('About', { exact: true })
],
'About section',
30_000
)
if (!openedAbout) {
await shot(page, 'ERROR-no-about-tab')
throw new Error('could not find the About section in Settings')
}
await page.waitForTimeout(1500)
await shot(page, '03-about-panel')
// ── Wait for "Update now" (appears when behind > 0) ──────────────────
// checkUpdates() runs at boot; if its result hasn't landed yet, press
// "Check now" like an impatient user would.
const updateNow = page.getByRole('button', { name: 'Update now' }).first()
let visible = await updateNow.isVisible().catch(() => false)
if (!visible) {
// The boot-time auto-check can fail transiently and latch the error
// UI, while a fresh check succeeds. Nudge loop: click Check now when
// clickable, re-test Update now, 3 minute ceiling.
log('Update now not visible yet — nudging Check now')
const deadline = Date.now() + 180_000
while (!visible && Date.now() < deadline) {
await page.getByRole('button', { name: 'Check now' }).first()
.click({ timeout: 5_000 })
.then(() => log('nudged Check now'))
.catch(() => {})
await page.waitForTimeout(15_000)
visible = await updateNow.isVisible().catch(() => false)
}
}
if (!visible) {
// Surface the real git error behind the UI's generic "couldn't reach
// the update server" line.
const status = await page.evaluate(() =>
window.hermesDesktop?.updates?.check?.() ?? Promise.resolve('no updates.check bridge')
).catch((err) => `updates.check failed: ${err?.message || err}`)
log(`[update-status] ${JSON.stringify(status)}`)
await shot(page, 'ERROR-no-update-now')
throw new Error('"Update now" never appeared — update check did not report behind > 0')
}
await shot(page, '04-update-available')
// ── The click under test ──────────────────────────────────────────────
await updateNow.click()
log('clicked: Update now')
// The "Updating Hermes — this window will close" overlay should appear,
// then the app quits (hand-off dwell). Screenshot the overlay while the
// window is still alive.
// The app can close during the dwell. This wait must outlive its page.
await new Promise(resolve => setTimeout(resolve, 1200))
await shot(page, '05-updating-overlay')
// ── Wait for the hand-off to take over ────────────────────────────────
// Clicking Update now spawns the detached updater (desktop-update.ps1 or
// the staged binary), which claims HERMES_HOME/.hermes-update-in-progress
// and then the desktop quits. We do NOT rely on Playwright's app 'close'
// event: when the app self-quits for the hand-off that event is
// unreliable (attempt 8 timed out on it even though the hand-off log
// proved the desktop had exited and `hermes update` was already running).
//
// The authoritative "hand-off started" signal is the marker file (or the
// result JSON, if the whole update finished fast). Poll for either, and
// also accept a genuine app close. Any one is success — the PowerShell
// driver owns asserting the update's OUTCOME (sha, marker cleanup,
// relaunch) after we return.
const hermesHome = process.env.HERMES_HOME
const markerPath = hermesHome ? path.join(hermesHome, '.hermes-update-in-progress') : null
const resultPath = hermesHome ? path.join(hermesHome, '.hermes-update-result.json') : null
let appClosed = false
app.on('close', () => {
appClosed = true
})
const handoffDeadline = Date.now() + 150_000
let handoffStarted = false
while (Date.now() < handoffDeadline) {
if (markerPath && fs.existsSync(markerPath)) {
log('hand-off marker present — updater has taken over')
handoffStarted = true
break
}
if (resultPath && fs.existsSync(resultPath)) {
log('update result JSON already present — updater finished fast')
handoffStarted = true
break
}
if (appClosed) {
log('app closed — hand-off in progress')
handoffStarted = true
break
}
// Secondary: if the renderer window is gone, evaluate throws.
try {
await page.evaluate(() => true)
} catch {
log('renderer window gone — app quit for hand-off')
handoffStarted = true
break
}
await new Promise(r => setTimeout(r, 2000))
}
if (!handoffStarted) {
await shot(page, 'ERROR-no-handoff')
throw new Error('no hand-off within 150s of Update now (no marker, no result, app still alive)')
}
// A marker appears before Electron exits. Exiting this driver at that point
// lets Playwright taskkill the entire tree, including the detached updater.
await waitForProcessClose()
log('Electron process closed — detached updater owns the rest')
}
main()
.then(() => process.exit(0))
.catch(err => {
console.error(`[drive-update] FAILED: ${err.message}`)
process.exit(1)
})