Files
hermes-agent/tests-js/update-ui.test.mjs
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

281 lines
17 KiB
JavaScript

import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { execFileSync, spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { JSDOM } from 'jsdom'
import { afterEach, expect, test, vi } from 'vitest'
import updateUi from '../tests/install/e2e-assets/update-ui.cjs'
import sourceBranchProbe from '../tests/install/e2e-assets/source-branch-probe.cjs'
const windows = []
afterEach(() => {
windows.splice(0).forEach(window => window.close())
vi.useRealTimers()
})
function fixture({ details = true, available = true, statusOverride } = {}) {
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(0)
const { window } = new JSDOM('<body></body>', { runScripts: 'outside-only' })
windows.push(window)
const { document } = window
const clicks = []
const addButton = (text, onClick) => {
const button = document.createElement('button')
button.textContent = text
button.onclick = () => { clicks.push(text); onClick?.() }
document.body.append(button)
return button
}
const revealUpdate = () => addButton('Update now')
const check = addButton('Check now', () => {
check.disabled = true
if (!available) return
if (details) {
const more = addButton("See what's new", () => { more.remove(); revealUpdate() })
} else {
revealUpdate()
}
})
const status = statusOverride || { supported: true, behind: available ? 1 : 0 }
window.hermesDesktop = { updates: { check: async () => status } }
const page = {
getByRole(role, { name }) {
expect(role).toBe('button')
const selected = () => [...document.querySelectorAll('button')].find(button => name.test(button.textContent))
const locator = {
first: () => locator,
isVisible: async () => Boolean(selected()),
click: async () => {
const button = selected()
if (!button || button.disabled) throw new Error('button not actionable')
button.click()
},
}
return locator
},
waitForTimeout: async ms => vi.setSystemTime(Date.now() + ms),
evaluate: async fn => window.eval(`(${fn.toString()})()`),
}
return { page, clicks, log: vi.fn(), shot: vi.fn() }
}
test.each([true, false])('reveals the actual update button without applying (details=%s)', async details => {
const f = fixture({ details })
const update = await updateUi.waitForUpdate(f.page, f)
expect(await update.isVisible()).toBe(true)
expect(f.clicks).toEqual(details ? ['Check now', "See what's new"] : ['Check now'])
expect(f.shot).toHaveBeenCalledWith(f.page, '04-update-available')
})
test('checks the live Desktop bridge against the staged target before opening About', async () => {
const sha = 'a'.repeat(40)
const f = fixture({ statusOverride: { supported: true, branch: 'main', currentSha: 'b'.repeat(40), targetSha: sha, behind: 1, dirty: false } })
await updateUi.assertStagedBranch(f.page, sha, f.log)
expect(f.log).toHaveBeenCalledWith(expect.stringContaining('"targetSha"'))
// The current checker's hosted shape: it cannot count staged commits via GitHub compare.
const current = fixture({ statusOverride: { supported: true, branch: 'main', currentSha: 'b'.repeat(40), targetSha: sha, behind: null, commits: [], updateAvailable: true } })
await updateUi.assertStagedBranch(current.page, sha, current.log)
const racing = fixture()
let checks = 0
racing.page.evaluate = fn => ++checks === 1
? { supported: true, error: 'fetch-failed', message: "error: cannot lock ref 'refs/remotes/origin/main'" }
: current.page.evaluate(fn)
await updateUi.assertStagedBranch(racing.page, sha, racing.log)
expect(checks).toBe(2)
for (const statusOverride of [
{ supported: true, error: 'release-unavailable', branch: 'main' },
{ supported: true, branch: 'main', targetSha: 'b'.repeat(40), behind: 1, updateAvailable: true },
{ supported: true, branch: 'main', targetSha: sha, behind: 1, dirty: true },
{ supported: true, branch: 'main', targetSha: sha, behind: 0 },
{ supported: true, branch: 'main', currentSha: sha, targetSha: sha, behind: 1 },
{ supported: true, branch: 'main', targetSha: sha, behind: 1, updateAvailable: false },
{ supported: true, branch: 'main', currentSha: 'b'.repeat(40), targetSha: sha, behind: null },
]) {
const refused = fixture({ statusOverride })
await expect(updateUi.assertStagedBranch(refused.page, sha, refused.log)).rejects.toThrow(/refusing to click/)
}
})
test('test-only source probe pins staged main without changing other Python invocations', () => {
const root = '/fixture/install'
const probe = ['-c', 'from pathlib import Path; import runpy; p = Path("hermes_cli/source_check.py"); entry = runpy.run_path(str(p)).get("main") if p.is_file() else None; entry()', '--install-root', root, '--home', '/fixture/home', '--git', '/shim/git', '--force']
const expected = [...probe]
expected[expected.indexOf('--git') + 1] = '/real/git'
expected.push('--branch', 'main')
expect(sourceBranchProbe.branchProbeArgs(probe, root, '/real/git')).toEqual(expected)
const managed = ['--run-module', 'hermes_cli.source_check', ...probe.slice(2)]
expect(sourceBranchProbe.branchProbeArgs(managed, root, '/real/git')).toEqual([
'--run-module', 'hermes_cli.source_check', ...expected.slice(2),
])
expect(sourceBranchProbe.branchProbeArgs(probe, '/other/install', '/real/git')).toBe(probe)
expect(sourceBranchProbe.branchProbeArgs(managed, '/other/install', '/real/git')).toBe(managed)
expect(sourceBranchProbe.branchProbeArgs(['-c', 'print("hermes_cli/source_check.py")'], root, '/real/git')).toEqual(['-c', 'print("hermes_cli/source_check.py")'])
expect(sourceBranchProbe.branchProbeArgs(['--run-module', 'hermes_cli.config', ...managed.slice(2)], root, '/real/git')).toEqual(['--run-module', 'hermes_cli.config', ...managed.slice(2)])
expect(sourceBranchProbe.branchProbeArgs([...probe, '--branch', 'topic'], root, '/real/git')).toEqual([...probe, '--branch', 'topic'])
expect(sourceBranchProbe.branchProbeArgs(probe, root, '')).toBe(probe)
const cmd = ['/d', '/v:off', '/s', '/c', `""C:\\fixture\\hermes.cmd" "--run-module" "hermes_cli.source_check" "--install-root" "${root}" "--home" "/profile with spaces" "--git" "/shim/git" "--force""`]
const rewritten = sourceBranchProbe.branchProbeArgs(cmd, root, '/real/git')
expect(rewritten.slice(0, 4)).toEqual(cmd.slice(0, 4))
expect(rewritten[4]).toContain('"--git" "/real/git" "--force" "--branch" "main"')
expect(sourceBranchProbe.branchProbeArgs(cmd, '/other/install', '/real/git')).toBe(cmd)
expect(sourceBranchProbe.branchProbeArgs(cmd, root, '/real&git')).toBe(cmd)
})
test.skipIf(process.platform === 'win32')('probe Git reaches the staged main even with global Git config isolated', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'desktop-staged-git-'))
const git = process.env.HERMES_E2E_REAL_GIT || process.env.PATH.split(path.delimiter)
.map(dir => path.join(dir, process.platform === 'win32' ? 'git.exe' : 'git')).find(file => fs.existsSync(file))
try {
const checkout = path.join(root, 'checkout')
const bare = path.join(root, 'serve.git')
fs.mkdirSync(checkout)
const run = (args, cwd = checkout, env = process.env) => execFileSync(git, args, { cwd, env, encoding: 'utf8' }).trim()
run(['init', '-b', 'main'])
run(['-c', 'user.name=Fixture', '-c', 'user.email=e2e@example.invalid', '-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-m', 'base'])
const base = run(['rev-parse', 'HEAD'])
run(['-c', 'user.name=Fixture', '-c', 'user.email=e2e@example.invalid', '-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-m', 'staged'])
const sha = run(['rev-parse', 'HEAD'])
run(['clone', '--bare', checkout, bare], root)
run(['reset', '--hard', base])
run(['remote', 'add', 'origin', 'https://github.com/NousResearch/hermes-agent.git'])
const cfg = path.join(root, 'gitconfig')
run(['config', '--file', cfg, '--add', `url.file://${bare}.insteadOf`, 'https://github.com/NousResearch/hermes-agent.git'])
const python = process.env.HERMES_PYTHON || 'python3'
const source = fileURLToPath(new URL('../', import.meta.url))
const launcher = path.join(checkout, '.hermes', 'bin', 'hermes')
fs.mkdirSync(path.dirname(launcher), { recursive: true })
const quote = value => `'${value.replace(/'/g, "'\\''")}'`
fs.writeFileSync(launcher, `#!/bin/sh\nif [ "$1" = '--run-module' ]; then shift 2; exec ${quote(python)} -m hermes_cli.source_check "$@"; fi\nprintf '%s\\n' "$@"\n`, { mode: 0o700 })
const capturedEnv = { ...process.env, GIT_CONFIG_GLOBAL: cfg }
const launchEnv = { HERMES_DESKTOP_USER_DATA_DIR: root }
sourceBranchProbe.prepareSourceBranchEnvironment(checkout, sha, git, capturedEnv, launchEnv)
const env = { ...process.env, GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null',
PYTHONPATH: source, GIT_ALLOW_PROTOCOL: 'file' }
const home = path.join(root, 'profile')
fs.mkdirSync(home)
const status = JSON.parse(execFileSync(launcher, ['--run-module', 'hermes_cli.source_check',
'--install-root', checkout, '--home', home, '--git', git, '--force'],
{ cwd: checkout, encoding: 'utf8', env }))
expect(status).toMatchObject({ supported: true, currentSha: base, branch: 'main', targetSha: sha, updateAvailable: true })
expect(execFileSync(launcher, ['--version'], { cwd: checkout, env, encoding: 'utf8' }).trim()).toBe('--version')
const other = path.join(root, 'other-checkout')
fs.mkdirSync(other)
const foreign = JSON.parse(execFileSync(launcher, ['--run-module', 'hermes_cli.source_check',
'--install-root', other, '--home', home, '--git', git, '--force'],
{ cwd: checkout, encoding: 'utf8', env }))
expect(foreign).toMatchObject({ supported: false, reason: 'not-a-git-checkout' })
expect(() => sourceBranchProbe.prepareSourceBranchEnvironment(checkout, '0'.repeat(40), git, capturedEnv, launchEnv)).toThrow(/does not match expected/)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test.skipIf(process.platform === 'win32')('historical venv install without a PM launcher still checks staged Git main', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'desktop-legacy-branch-'))
const git = execFileSync('which', ['git'], { encoding: 'utf8' }).trim()
try {
const checkout = path.join(root, 'checkout')
const bare = path.join(root, 'serve.git')
fs.mkdirSync(checkout)
const run = (args, cwd = checkout) => execFileSync(git, args, { cwd, encoding: 'utf8' }).trim()
run(['init', '-b', 'main'])
run(['-c', 'user.name=Fixture', '-c', 'user.email=e2e@example.invalid', '-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-m', 'old'])
const old = run(['rev-parse', 'HEAD'])
run(['-c', 'user.name=Fixture', '-c', 'user.email=e2e@example.invalid', '-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-m', 'target'])
const sha = run(['rev-parse', 'HEAD'])
run(['clone', '--bare', checkout, bare], root)
run(['reset', '--hard', old])
run(['remote', 'add', 'origin', 'https://github.com/NousResearch/hermes-agent.git'])
const cfg = path.join(root, 'gitconfig')
run(['config', '--file', cfg, '--add', `url.file://${bare}.insteadOf`, 'https://github.com/NousResearch/hermes-agent.git'])
const legacy = path.join(checkout, 'venv', 'bin', 'hermes')
fs.mkdirSync(path.dirname(legacy), { recursive: true })
fs.writeFileSync(legacy, '#!/bin/sh\nexit 0\n', { mode: 0o700 })
const launchEnv = { HERMES_DESKTOP_USER_DATA_DIR: root }
const capturedEnv = { ...process.env, GIT_CONFIG_GLOBAL: cfg }
sourceBranchProbe.prepareSourceBranchEnvironment(checkout, sha, git, capturedEnv, launchEnv)
expect(fs.existsSync(path.join(checkout, '.hermes', 'bin', 'hermes'))).toBe(false)
expect(launchEnv.HERMES_E2E_SOURCE_ROOT).toBe(checkout)
expect(launchEnv.HERMES_E2E_SOURCE_URL).toBe(`file://${bare}`)
expect(launchEnv.NODE_OPTIONS).toContain('source-branch-probe.cjs')
const launcher = path.join(checkout, '.hermes', 'bin', 'hermes')
fs.mkdirSync(path.dirname(launcher), { recursive: true })
fs.symlinkSync(path.join(root, 'missing'), launcher)
expect(() => sourceBranchProbe.prepareSourceBranchEnvironment(checkout, sha, git, capturedEnv, launchEnv)).toThrow(/launcher/)
fs.unlinkSync(launcher)
// A PM tree with no published launcher is unfinished, not legacy.
fs.mkdirSync(path.join(checkout, 'pm'))
fs.writeFileSync(path.join(checkout, 'pm', 'lock.json'), '{}')
expect(() => sourceBranchProbe.prepareSourceBranchEnvironment(checkout, sha, git, { ...process.env, GIT_CONFIG_GLOBAL: cfg }, launchEnv)).toThrow(/launcher/)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test.skipIf(process.platform === 'win32')('preloaded historical Desktop Git check reads staged origin rather than the public API', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'desktop-historical-probe-'))
const git = execFileSync('which', ['git'], { encoding: 'utf8' }).trim()
try {
const checkout = path.join(root, 'checkout')
const bare = path.join(root, 'serve.git')
fs.mkdirSync(checkout)
const run = (args, cwd = checkout) => execFileSync(git, args, { cwd, encoding: 'utf8' }).trim()
run(['init', '-b', 'main'])
run(['-c', 'user.name=Fixture', '-c', 'user.email=e2e@example.invalid', '-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-m', 'base'])
run(['-c', 'user.name=Fixture', '-c', 'user.email=e2e@example.invalid', '-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-m', 'staged'])
const sha = run(['rev-parse', 'HEAD'])
run(['clone', '--bare', checkout, bare], root)
run(['remote', 'add', 'origin', 'https://github.com/NousResearch/hermes-agent.git'])
const cfg = path.join(root, 'gitconfig')
run(['config', '--file', cfg, '--add', `url.file://${bare}.insteadOf`, 'https://github.com/NousResearch/hermes-agent.git'])
const shim = path.join(root, 'git')
fs.writeFileSync(shim, `#!/bin/sh\nif [ "$1 $2 $3" = "remote get-url origin" ]; then printf '%s\\n' 'https://github.com/NousResearch/hermes-agent.git'; else exec '${git}' "$@"; fi\n`, { mode: 0o700 })
const launcher = path.join(checkout, '.hermes', 'bin', 'hermes')
fs.mkdirSync(path.dirname(launcher), { recursive: true })
fs.writeFileSync(launcher, '#!/bin/sh\nexit 0\n', { mode: 0o700 })
const launchEnv = { HERMES_DESKTOP_USER_DATA_DIR: root }
sourceBranchProbe.prepareSourceBranchEnvironment(checkout, sha, git, { ...process.env, GIT_CONFIG_GLOBAL: cfg }, launchEnv)
const script = 'const {spawn} = require("node:child_process"); const child = spawn(process.argv[1], process.argv.slice(3), {cwd:process.argv[2], env:{...process.env, GIT_CONFIG_GLOBAL:"/dev/null"}}); child.stdout.pipe(process.stdout); child.stderr.pipe(process.stderr); child.on("close", code => process.exitCode=code)'
for (const [args, expected] of [
[['remote', 'get-url', 'origin'], `file://${bare}`],
[['ls-remote', 'origin', 'refs/heads/main'], `${sha}\trefs/heads/main`],
]) {
const result = spawnSync(process.execPath, ['-e', script, shim, checkout, ...args], {
encoding: 'utf8', env: { ...process.env, ...launchEnv, GIT_CONFIG_GLOBAL: '/dev/null' },
})
expect(result.status, result.stderr).toBe(0)
expect(result.stdout.trim()).toBe(expected)
}
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('preloaded Electron-style execFile transports explicit branch into the checker', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'desktop-branch-probe-'))
try {
fs.mkdirSync(path.join(root, 'hermes_cli'))
fs.writeFileSync(path.join(root, 'hermes_cli', 'source_check.py'), 'import json, sys\ndef main(): print(json.dumps(sys.argv[1:]))\n')
const probe = 'from pathlib import Path; import runpy; p = Path("hermes_cli/source_check.py"); entry = runpy.run_path(str(p)).get("main") if p.is_file() else None; entry() if callable(entry) else print("null")'
const python = process.env.HERMES_PYTHON || 'python3'
const script = 'const {promisify} = require("node:util"); const {execFile} = require("node:child_process"); promisify(execFile)(process.argv[1], ["-c", process.argv[2], "--install-root", process.argv[3], "--home", process.argv[3], "--git", "fixture-shim", "--force"], {cwd:process.argv[3]}).then(r=>console.log(r.stdout), e=>{console.error(e);process.exitCode=1})'
const result = spawnSync(process.execPath, ['-e', script, python, probe, root], {
encoding: 'utf8', env: { ...process.env, HERMES_E2E_SOURCE_ROOT: root, HERMES_E2E_SOURCE_GIT: '/real/git',
NODE_OPTIONS: `--require=${JSON.stringify(fileURLToPath(new URL('../tests/install/e2e-assets/source-branch-probe.cjs', import.meta.url)))}` },
})
expect(result.status, result.stderr).toBe(0)
expect(JSON.parse(result.stdout)).toEqual(['--install-root', root, '--home', root, '--git', '/real/git', '--force', '--branch', 'main'])
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})
test('does not turn a completed check without an update into success', async () => {
const f = fixture({ available: false })
await expect(updateUi.waitForUpdate(f.page, f)).rejects.toThrow(/"Update now" never appeared/)
expect(f.clicks).toEqual(['Check now'])
expect(f.log).toHaveBeenCalledWith('[update-status] {"supported":true,"behind":0}')
expect(f.shot).toHaveBeenCalledWith(f.page, 'ERROR-no-update-now')
})