Files
hermes-agent/apps/desktop/electron/update-api-check.test.ts
T
Teknium 6dfcf15685 fix(desktop): passive update checks use the GitHub API once a day, never git fetch
Every desktop client ran `git fetch origin main` twice every 30 minutes
(client + backend check), plus on every window focus, with no cache in the
Electron main process and `force=true` hardcoded on the backend poll so the
backend's 6h cache was bypassed too. Across the install base GitHub measured
~33.8M fetch/clone requests in 24h against the repo and asked us to poll via
the API and releases instead.

Passive checks now:
- read the branch tip with GET /repos/{slug}/commits/{branch} using the
  application/vnd.github.sha media type (40-byte body), and only when the tips
  differ call the compare endpoint for the exact behind count and the commit
  list the overlay renders. No pack negotiation; `git fetch` runs only when
  the user applies an update.
- cache the answer on disk for 24h (1h on failure), keyed on local HEAD and
  branch so applying an update or switching branch invalidates immediately.
- run from the renderer every 24h instead of 30min; window focus re-checks
  only once the daily cadence has elapsed; the poller never passes `force`.
  Menu "Check for Updates", Settings "Check now", opening the overlay and the
  post-apply re-checks still force a fresh read.

Non-GitHub origins keep a single `ls-remote` (ref advertisement only).
runGit resolves on 'close' rather than 'exit' — the early-resolving
`remote get-url` returned "" often enough to route checks down the
non-GitHub path.

update-count.ts (shallow/full-clone counting heuristics for the fetch path)
is deleted; its compare-payload parsing moved to update-api-check.ts.
2026-09-10 18:15:54 -07:00

82 lines
3.1 KiB
TypeScript

/**
* Tests for electron/update-api-check.ts — the API-first passive update check.
*
* Why this exists: every desktop client used to `git fetch` twice every 30
* minutes. GitHub measured tens of millions of fetch/clone requests per day
* from the install base and asked us to poll via the API instead. These pin
* the two load-bearing contracts: the cache answers passive checks for a full
* day but invalidates the moment HEAD moves, and the compare payload maps to
* an honest behind count (never a fabricated one).
*/
import assert from 'node:assert/strict'
import { test } from 'vitest'
import {
branchTipApiUrl,
cacheIsFresh,
githubRepoSlug,
parseCompare,
UPDATE_CHECK_FAILURE_TTL_MS,
UPDATE_CHECK_TTL_MS
} from './update-api-check'
const SHA_A = 'a'.repeat(40)
const SHA_B = 'b'.repeat(40)
const HOUR = 60 * 60 * 1000
test('cache serves a passive check for 24h, but not once HEAD or the branch changes', () => {
const cached = { fetchedAt: 0, currentSha: SHA_A, branch: 'main', status: { behind: 0 } }
assert.equal(cacheIsFresh(cached, { branch: 'main', currentSha: SHA_A, now: UPDATE_CHECK_TTL_MS - 1 }), true)
assert.equal(cacheIsFresh(cached, { branch: 'main', currentSha: SHA_A, now: UPDATE_CHECK_TTL_MS }), false)
// Applying an update moves HEAD: a stale "update available" must never survive it.
assert.equal(cacheIsFresh(cached, { branch: 'main', currentSha: SHA_B, now: 1 }), false)
assert.equal(cacheIsFresh(cached, { branch: 'bb/gui', currentSha: SHA_A, now: 1 }), false)
// Failures retry sooner than successes, but still not on every tick.
const failed = { ...cached, status: { error: 'fetch-failed' } }
assert.equal(cacheIsFresh(failed, { branch: 'main', currentSha: SHA_A, now: UPDATE_CHECK_FAILURE_TTL_MS - 1 }), true)
assert.equal(cacheIsFresh(failed, { branch: 'main', currentSha: SHA_A, now: 2 * HOUR }), false)
})
test('compare payload maps to the behind count and a newest-first commit list; malformed = null', () => {
const payload = {
ahead_by: 2,
status: 'ahead',
commits: [
{
sha: SHA_A,
commit: { message: 'fix: older\n\nbody', author: { name: 'A' }, committer: { date: '2026-09-10T00:00:00Z' } }
},
{
sha: SHA_B,
commit: { message: 'feat: newer', author: { name: 'B' }, committer: { date: '2026-09-10T01:00:00Z' } }
}
]
}
const parsed = parseCompare(payload)
assert.equal(parsed?.behind, 2)
assert.deepEqual(
parsed?.commits.map(c => [c.sha, c.summary, c.author]),
[
[SHA_B, 'feat: newer', 'B'],
[SHA_A, 'fix: older', 'A']
]
)
assert.equal(parseCompare({ ahead_by: -1 }), null)
assert.equal(parseCompare({ status: 'ahead' }), null)
assert.equal(parseCompare('nope'), null)
// Forks and SSH forms hit the API for their own repo; non-GitHub origins don't.
assert.equal(githubRepoSlug('git@github.com:Someone/hermes-agent.git'), 'someone/hermes-agent')
assert.equal(githubRepoSlug('https://gitlab.example/x/y.git'), null)
assert.equal(
branchTipApiUrl('nousresearch/hermes-agent', 'bb/gui'),
'https://api.github.com/repos/nousresearch/hermes-agent/commits/bb%2Fgui'
)
})