Trim the salvaged ladder to its env rung and make the 403 copy actionable. - github-api-auth.ts keeps githubTokenFromEnv / githubApiHeaders; the `gh auth token` rung (resolveGithubToken, readGhCliToken, the per-process cache and the execFile import in main.ts) is dropped. Whether a GUI app may silently spend the user's gh CLI login on a passive background check is a product call, not a bug fix; the pure seam makes re-adding it a ~15-line follow-up. - fetchGitHubApi reads GITHUB_TOKEN / GH_TOKEN from process.env per request (nothing stored) and attaches x-ratelimit-remaining / x-ratelimit-reset plus whether the call was authenticated to the rejected error. - describeUpdateCheckFailure moves out of the unimportable main.ts into update-api-check.ts. A 403/429 with x-ratelimit-remaining: 0 now says the anonymous budget is 60/hour per network address shared with everyone behind the same connection, gives the real reset time and names GITHUB_TOKEN as the remedy; a 403 without rate-limit headers is reported as a plain HTTP 403 instead of a rate limit the user did not cause. - Docs: desktop.md "Updating" and the GITHUB_TOKEN row in environment-variables.md. - Tests trimmed to two invariants per module. Why: behind a shared exit IP "try again in an hour" is advice the user cannot act on, and the old copy hid the one thing that does help.
114 lines
4.5 KiB
TypeScript
114 lines
4.5 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,
|
|
describeUpdateCheckFailure,
|
|
githubRepoSlug,
|
|
parseCompare,
|
|
rateLimitFromHeaders,
|
|
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'
|
|
)
|
|
})
|
|
|
|
// #112615: behind a shared exit IP the anonymous 60/hour budget is spent by
|
|
// neighbours, so "try again in an hour" is advice the user cannot act on. A
|
|
// genuine rate limit (x-ratelimit-remaining: 0) must name the shared-address
|
|
// cause, the real reset time and the GITHUB_TOKEN remedy; any other 403 must
|
|
// not be reported as a rate limit.
|
|
test('a rate-limited 403 names the shared-IP cause, the reset time and GITHUB_TOKEN; a plain 403 is not a rate limit', () => {
|
|
const now = 1_700_000_000_000
|
|
|
|
const limited = {
|
|
statusCode: 403,
|
|
...rateLimitFromHeaders({ 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': String(now / 1000 + 25 * 60) }),
|
|
authenticated: false
|
|
}
|
|
|
|
const message = describeUpdateCheckFailure(limited, now)
|
|
|
|
assert.match(message, /60 per hour per network address/)
|
|
assert.match(message, /in about 25 minutes/)
|
|
assert.match(message, /GITHUB_TOKEN/)
|
|
|
|
assert.match(describeUpdateCheckFailure({ ...limited, authenticated: true }, now), /for your GITHUB_TOKEN/)
|
|
|
|
// Missing or non-zero rate-limit headers: an ordinary 403, reported as such.
|
|
assert.equal(describeUpdateCheckFailure({ statusCode: 403 }), 'api.github.com answered HTTP 403.')
|
|
assert.equal(
|
|
describeUpdateCheckFailure({ statusCode: 403, ...rateLimitFromHeaders({ 'x-ratelimit-remaining': '57' }) }),
|
|
'api.github.com answered HTTP 403.'
|
|
)
|
|
})
|