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

270 lines
9.2 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 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 page = await app.firstWindow({ timeout: 120_000 })
log('first window acquired')
// 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')
// ── Dismiss onboarding if present ─────────────────────────────────────
// 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
)
if (dismissedOnboarding) {
log('dismissed onboarding overlay')
await page.waitForTimeout(2500)
await shot(page, '01b-onboarding-dismissed')
}
// ── 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')
}
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) {
log('Update now not visible yet — clicking Check now')
await clickFirstVisible(page, [p => p.getByRole('button', { name: 'Check now' })], 'Check now', 20_000)
try {
await updateNow.waitFor({ state: 'visible', timeout: 120_000 })
visible = true
} catch {
visible = false
}
}
if (!visible) {
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.
await page.waitForTimeout(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)')
}
log('hand-off confirmed — detached updater owns the rest')
}
main()
.then(() => process.exit(0))
.catch(err => {
console.error(`[drive-update] FAILED: ${err.message}`)
process.exit(1)
})