175 lines
5.3 KiB
TypeScript
175 lines
5.3 KiB
TypeScript
import { describe, expect, test } from "bun:test"
|
|
|
|
import { createAutomationRunnerConnectCoordinator } from "../src/react-app/domains/automations/automation-runner-connect-coordinator"
|
|
|
|
function deferred() {
|
|
let resolve = () => {}
|
|
const promise = new Promise<void>((done) => { resolve = done })
|
|
return { promise, resolve }
|
|
}
|
|
|
|
function scheduler() {
|
|
const entries: Array<{ active: boolean; callback: () => void; delayMs: number }> = []
|
|
return {
|
|
entries,
|
|
schedule(callback: () => void, delayMs: number) {
|
|
const entry = { active: true, callback, delayMs }
|
|
entries.push(entry)
|
|
return () => { entry.active = false }
|
|
},
|
|
activeCount() {
|
|
return entries.filter((entry) => entry.active).length
|
|
},
|
|
fireActive() {
|
|
const entry = entries.find((candidate) => candidate.active)
|
|
if (!entry) throw new Error("no scheduled refresh")
|
|
entry.active = false
|
|
entry.callback()
|
|
},
|
|
}
|
|
}
|
|
|
|
describe("Automation runner connect lifecycle", () => {
|
|
test("a rejection request remints immediately without duplicating the refresh timer", async () => {
|
|
const timers = scheduler()
|
|
let connects = 0
|
|
const coordinator = createAutomationRunnerConnectCoordinator({
|
|
refreshMs: 300_000,
|
|
schedule: timers.schedule,
|
|
connect: async () => { connects += 1 },
|
|
})
|
|
|
|
await coordinator.request()
|
|
await Promise.resolve()
|
|
expect(connects).toBe(1)
|
|
expect(timers.activeCount()).toBe(1)
|
|
|
|
await coordinator.request()
|
|
await Promise.resolve()
|
|
expect(connects).toBe(2)
|
|
expect(timers.activeCount()).toBe(1)
|
|
coordinator.dispose()
|
|
expect(timers.activeCount()).toBe(0)
|
|
})
|
|
|
|
test("a failed immediate remint leaves one periodic retry", async () => {
|
|
const timers = scheduler()
|
|
const configured: number[] = []
|
|
let connects = 0
|
|
const coordinator = createAutomationRunnerConnectCoordinator({
|
|
refreshMs: 300_000,
|
|
schedule: timers.schedule,
|
|
connect: async () => {
|
|
connects += 1
|
|
if (connects === 2) throw new Error("mint failed")
|
|
configured.push(connects)
|
|
},
|
|
})
|
|
|
|
await coordinator.request()
|
|
await expect(coordinator.request()).rejects.toThrow("mint failed")
|
|
await Promise.resolve()
|
|
expect(configured).toEqual([1])
|
|
expect(timers.activeCount()).toBe(1)
|
|
|
|
timers.fireActive()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
expect(configured).toEqual([1, 3])
|
|
expect(timers.activeCount()).toBe(1)
|
|
coordinator.dispose()
|
|
})
|
|
|
|
test("connects never overlap and an older mint cannot configure over newer settings", async () => {
|
|
const timers = scheduler()
|
|
const firstMint = deferred()
|
|
const configured: number[] = []
|
|
let attempts = 0
|
|
let active = 0
|
|
let maximumActive = 0
|
|
const coordinator = createAutomationRunnerConnectCoordinator({
|
|
refreshMs: 300_000,
|
|
schedule: timers.schedule,
|
|
connect: async (isCurrent) => {
|
|
attempts += 1
|
|
const attempt = attempts
|
|
active += 1
|
|
maximumActive = Math.max(maximumActive, active)
|
|
if (attempt === 1) await firstMint.promise
|
|
if (isCurrent()) configured.push(attempt)
|
|
active -= 1
|
|
},
|
|
})
|
|
|
|
const first = coordinator.request()
|
|
await Promise.resolve()
|
|
const latest = coordinator.request()
|
|
expect(attempts).toBe(1)
|
|
expect(active).toBe(1)
|
|
firstMint.resolve()
|
|
await Promise.all([first, latest])
|
|
await Promise.resolve()
|
|
|
|
expect(attempts).toBe(2)
|
|
expect(maximumActive).toBe(1)
|
|
expect(configured).toEqual([2])
|
|
expect(timers.activeCount()).toBe(1)
|
|
coordinator.dispose()
|
|
})
|
|
|
|
test("repeated fresh credential rejections use capped remint backoff", async () => {
|
|
const timers = scheduler()
|
|
let connects = 0
|
|
const coordinator = createAutomationRunnerConnectCoordinator({
|
|
refreshMs: 300_000,
|
|
schedule: timers.schedule,
|
|
connect: async () => { connects += 1 },
|
|
})
|
|
|
|
await coordinator.request()
|
|
coordinator.credentialRejected()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
expect(connects).toBe(2)
|
|
|
|
coordinator.credentialRejected()
|
|
expect(connects).toBe(2)
|
|
expect(timers.entries.find((entry) => entry.active)?.delayMs).toBe(500)
|
|
timers.fireActive()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
expect(connects).toBe(3)
|
|
|
|
coordinator.credentialRejected()
|
|
expect(timers.entries.find((entry) => entry.active)?.delayMs).toBe(1_000)
|
|
coordinator.dispose()
|
|
})
|
|
|
|
test("a settling mint cannot add a refresh timer beside a rejection retry", async () => {
|
|
const timers = scheduler()
|
|
const secondMint = deferred()
|
|
let connects = 0
|
|
const coordinator = createAutomationRunnerConnectCoordinator({
|
|
refreshMs: 300_000,
|
|
schedule: timers.schedule,
|
|
connect: async () => {
|
|
connects += 1
|
|
if (connects === 2) await secondMint.promise
|
|
},
|
|
})
|
|
|
|
await coordinator.request()
|
|
coordinator.credentialRejected()
|
|
await Promise.resolve()
|
|
expect(connects).toBe(2)
|
|
coordinator.credentialRejected()
|
|
expect(timers.entries.filter((entry) => entry.active).map((entry) => entry.delayMs)).toEqual([500])
|
|
|
|
secondMint.resolve()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
expect(timers.entries.filter((entry) => entry.active).map((entry) => entry.delayMs)).toEqual([500])
|
|
coordinator.dispose()
|
|
})
|
|
})
|