Ports the engine off the second JS runtime onto Node 26.3 (node:ffi) so the repo ships a single JavaScript runtime: child_process for the gateway, vitest for tests, an esbuild + Solid build step. Mouse selection copies the rendered text you highlight, and the clipboard path is crash-proofed (a broken copy pipe no longer quits the UI). Renames the engine dir ui-tui-opentui-v2/ -> ui-opentui/ and updates the launcher/installer/Docker references.
77 lines
2.9 KiB
TypeScript
77 lines
2.9 KiB
TypeScript
/**
|
|
* Recovery-budget policy test (LOGIC side, pure). The crash-loop bound: attempts
|
|
* are capped within a sliding window, stale attempts are pruned, and recovery is
|
|
* refused with no session. Plus opencode-style exponential backoff (1s→30s cap).
|
|
*/
|
|
import { describe, expect, test } from 'vitest'
|
|
|
|
import {
|
|
backoffMs,
|
|
GATEWAY_RECOVERY_LIMIT,
|
|
GATEWAY_RECOVERY_WINDOW_MS,
|
|
planGatewayRecovery
|
|
} from '../logic/gatewayRecovery.ts'
|
|
|
|
describe('planGatewayRecovery — crash-loop budget', () => {
|
|
test('allows GATEWAY_RECOVERY_LIMIT attempts within the window, refuses the next', () => {
|
|
const sid = 'sess-1'
|
|
let attempts: number[] = []
|
|
const now = 1_000_000
|
|
|
|
// The first LIMIT exits all recover, each recording its timestamp.
|
|
for (let i = 0; i < GATEWAY_RECOVERY_LIMIT; i++) {
|
|
const plan = planGatewayRecovery(sid, null, attempts, now + i)
|
|
expect(plan.recover).toBe(true)
|
|
expect(plan.sid).toBe(sid)
|
|
attempts = plan.attempts
|
|
}
|
|
expect(attempts).toHaveLength(GATEWAY_RECOVERY_LIMIT)
|
|
|
|
// The (LIMIT+1)th within the window is refused; attempts are NOT extended.
|
|
const refused = planGatewayRecovery(sid, null, attempts, now + GATEWAY_RECOVERY_LIMIT)
|
|
expect(refused.recover).toBe(false)
|
|
expect(refused.attempts).toHaveLength(GATEWAY_RECOVERY_LIMIT)
|
|
})
|
|
|
|
test('prunes attempts older than GATEWAY_RECOVERY_WINDOW_MS, freeing the budget', () => {
|
|
const sid = 'sess-1'
|
|
const now = 1_000_000
|
|
// Three stale attempts (all outside the window) + one fresh.
|
|
const stale = [now - GATEWAY_RECOVERY_WINDOW_MS - 5, now - GATEWAY_RECOVERY_WINDOW_MS - 4, now - 30_000]
|
|
const plan = planGatewayRecovery(sid, null, stale, now)
|
|
// The two truly-stale ones are pruned; the in-window one survives + `now` added.
|
|
expect(plan.recover).toBe(true)
|
|
expect(plan.attempts).toEqual([now - 30_000, now])
|
|
})
|
|
|
|
test('refuses recovery when there is no session id (live nor recover)', () => {
|
|
const plan = planGatewayRecovery(null, null, [], 1_000_000)
|
|
expect(plan.recover).toBe(false)
|
|
expect(plan.sid).toBeNull()
|
|
expect(plan.attempts).toEqual([])
|
|
})
|
|
|
|
test('falls back to the recoverSid when the live sid was already cleared', () => {
|
|
const plan = planGatewayRecovery(null, 'pending-sess', [], 1_000_000)
|
|
expect(plan.recover).toBe(true)
|
|
expect(plan.sid).toBe('pending-sess')
|
|
})
|
|
})
|
|
|
|
describe('backoffMs — exponential delay (1s→30s cap)', () => {
|
|
test('doubles per attempt (1-based) and caps at 30000ms', () => {
|
|
expect(backoffMs(1)).toBe(1000)
|
|
expect(backoffMs(2)).toBe(2000)
|
|
expect(backoffMs(3)).toBe(4000)
|
|
expect(backoffMs(4)).toBe(8000)
|
|
expect(backoffMs(5)).toBe(16000)
|
|
expect(backoffMs(6)).toBe(30000) // 32000 clamped to the cap
|
|
expect(backoffMs(10)).toBe(30000) // stays at the cap
|
|
})
|
|
|
|
test('clamps a non-positive attempt to the first delay', () => {
|
|
expect(backoffMs(0)).toBe(1000)
|
|
expect(backoffMs(-3)).toBe(1000)
|
|
})
|
|
})
|