Files
hermes-agent/tests/install/e2e-assets/update-ui.cjs
ethernet f43c38affc test(install-e2e): accept the current checker's countless update offer and the macOS installer marker
The current source checker reports updateAvailable with behind null when GitHub
compare cannot count staged commits; the app-update predicate demanded an integer
behind and refused every HEAD->NEXT leg. Require behind > 0 only for the historical
shape without updateAvailable.

v2026.6.19's DMG bootstrap runs the same install.sh that writes .install_method,
so its macOS leg saw a dirty tree. Share the Linux driver's guarded exclude through
source-driver.sh and apply it before every app-driven macOS update.
2026-09-24 10:42:43 -04:00

149 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Shared clicks only. Each route owns its receipt/process/relaunch observer.
async function pickAppWindow(app, log) {
await app.firstWindow({ timeout: 120_000 })
const deadline = Date.now() + 120_000
for (;;) {
for (const page of app.windows()) {
if (await page.evaluate(() => document.querySelector('button') !== null).catch(() => false)) {
await page.waitForLoadState('domcontentloaded')
log(`window picked: ${page.url()}`)
return page
}
}
if (Date.now() > deadline) {
for (const page of app.windows()) log(`window seen: ${page.url()}`)
throw new Error('no window with app UI (a <button>) appeared within 120s')
}
await new Promise(resolve => setTimeout(resolve, 1_000))
}
}
async function openAbout(page, { prepare, log, shot, confirmSettings = false, hitDump }) {
const later = [
page.getByRole('button', { name: /choose a provider later|skip/i }),
page.getByText(/choose a provider later/i),
]
const settings = [
page.getByRole('button', { name: /open settings|settings/i }),
page.getByLabel('Open settings'), page.locator('[aria-label="Open settings"]'),
page.locator('[title="Open settings"]'),
]
const deadline = Date.now() + 180_000
let opened = false
for (let attempt = 1; !opened; attempt++) {
await prepare()
for (const locator of later) {
try {
await locator.first().click({ timeout: 1_500 })
log('dismissed onboarding overlay')
await locator.first().waitFor({ state: 'hidden', timeout: 15_000 }).catch(() => {})
await shot(page, '01b-onboarding-dismissed')
break
} catch { /* the overlay may still be mounting */ }
}
for (const locator of settings) {
try {
await locator.first().click({ timeout: 2_500 })
// Source shells can remount during hydration and lose a landed click.
if (confirmSettings) await page.waitForURL(/[#/]settings(?:[/?]|$)/, { timeout: 4_000 })
opened = true
break
} catch (error) { log(`[overlay] settings click failed: ${String(error).split('\n')[0]}`) }
}
if (!opened && hitDump && (attempt === 1 || attempt % 5 === 0)) log(`[overlay] hit-test: ${await hitDump()}`)
if (!opened && Date.now() > deadline) {
await shot(page, 'ERROR-no-settings-button')
throw new Error('Settings not clickable within 180s')
}
}
await page.waitForTimeout(1_500)
await shot(page, '02-settings-open')
const about = [page.getByRole('tab', { name: /about/i }), page.getByRole('button', { name: /about/i }), page.getByText('About', { exact: true })]
const aboutDeadline = Date.now() + 30_000
for (;;) {
for (const locator of about) {
try {
if (!await locator.first().isVisible()) continue
await locator.first().click({ timeout: 2_500 })
await page.waitForTimeout(1_500)
await shot(page, '03-about-panel')
return
} catch { /* old releases use different controls */ }
}
if (Date.now() > aboutDeadline) {
await shot(page, 'ERROR-no-about-tab')
throw new Error('could not find the About section in Settings')
}
await page.waitForTimeout(500)
}
}
async function assertStagedBranch(page, expectedSha, log) {
let status
for (let attempt = 0; attempt < 3; attempt++) {
status = await page.evaluate(() => window.hermesDesktop.updates.check({ force: true }))
log(`[source-branch-check] ${JSON.stringify(status)}`)
// The app's mount-time poller can fetch the same origin/main concurrently;
// Git rejects the losing ref update even though the winning fetch succeeded.
// Retry only that transient lock race, never a missing channel or other error.
if (status.error !== 'fetch-failed' || !/cannot lock ref 'refs\/remotes\/origin\/main'/.test(status.message || '')) break
await page.waitForTimeout(1_000)
}
// Historical Desktop status has no updateAvailable field: its About/overlay
// offers the button when behind > 0. A newer checker states updateAvailable
// and leaves behind null when it cannot count (GitHub compare does not know a
// staged commit). Never accept an explicit false, a dirty source tree, or
// merely a matching remote tip.
const offered = status.updateAvailable === undefined
? Number.isInteger(status.behind) && status.behind > 0
: status.updateAvailable === true
if (status.supported !== true || status.error || status.dirty === true ||
status.branch !== 'main' || status.targetSha !== expectedSha ||
status.currentSha === expectedSha || !offered) {
throw new Error('Desktop source check did not offer staged Git main; refusing to click an unrelated update')
}
}
async function waitForUpdate(page, { log, shot }) {
const update = page.getByRole('button', { name: /update now/i }).first()
const details = page.getByRole('button', { name: /^see what['’]s new$/i }).first()
const deadline = Date.now() + 180_000
while (!await update.isVisible().catch(() => false) && Date.now() < deadline) {
if (await details.isVisible().catch(() => false)) {
await details.click({ timeout: 5_000 })
log("opened See what's new")
continue
}
await page.getByRole('button', { name: /check now/i }).first().click({ timeout: 5_000 })
.then(() => log('nudged Check now')).catch(() => {})
await page.waitForTimeout(15_000)
}
if (!await update.isVisible().catch(() => false)) {
const status = await page.evaluate(() =>
window.hermesDesktop?.updates?.check?.() ?? Promise.resolve('no updates.check bridge')
).catch(error => `updates.check failed: ${error.message}`)
log(`[update-status] ${JSON.stringify(status)}`)
await shot(page, 'ERROR-no-update-now')
throw new Error('"Update now" never appeared after checking for updates and opening available update details')
}
await shot(page, '04-update-available')
return update
}
async function readManualUpdateCommand(page) {
const title = page.getByText(/^Update from your terminal$/i).first()
try {
await title.waitFor({ state: 'visible', timeout: 5_000 })
} catch {
return null
}
const text = await page.locator('code').filter({ hasText: /hermes update/i }).first().textContent()
const command = (text || '').trim().replace(/^\$\s*/, '')
if (!/^hermes update(?:\s|$)/.test(command)) {
throw new Error('manual update card did not present a hermes update command')
}
return command
}
module.exports = { assertStagedBranch, pickAppWindow, openAbout, readManualUpdateCommand, waitForUpdate }