diff --git a/functions/src/logic/buildQueue/dockerImageReconciler.ts b/functions/src/logic/buildQueue/dockerImageReconciler.ts index 3793757..972acd3 100644 --- a/functions/src/logic/buildQueue/dockerImageReconciler.ts +++ b/functions/src/logic/buildQueue/dockerImageReconciler.ts @@ -4,25 +4,57 @@ import { Discord } from '../../service/discord'; import { GitHubWorkflow } from '../../model/gitHubWorkflow'; import { EditorVersionInfo } from '../../model/editorVersionInfo'; import { RepoVersionInfo } from '../../model/repoVersionInfo'; +import { ReconciliationState, ReconciliationStateData } from '../../model/reconciliationState'; const DOCKERHUB_API = 'https://hub.docker.com/v2/repositories'; -const MAX_IMAGES_PER_CYCLE = 20; -const RECENT_VERSIONS_TO_CHECK = 5; + +const VERSIONS_PER_CYCLE = 5; +const MAX_DISPATCHES_PER_CYCLE = 10; +const MAX_TAG_PAGES_PER_QUERY = 3; +const DISPATCH_COOLDOWN_MS = 2 * 60 * 60 * 1000; +const BASE_HUB_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; +const COOLDOWN_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; + +const UBUNTU_PLATFORMS = [ + 'base', + 'linux-il2cpp', + 'windows-mono', + 'mac-mono', + 'ios', + 'android', + 'webgl', +] as const; +const WINDOWS_PLATFORMS = [ + 'base', + 'windows-il2cpp', + 'universal-windows-platform', + 'appletv', + 'android', +] as const; interface DockerImage { repository: string; tag: string; - baseOs: string; + baseOs: 'ubuntu' | 'windows'; imageType: 'base' | 'hub' | 'editor'; targetPlatform?: string; editorVersion?: string; changeset?: string; } -interface MissingImage { - image: DockerImage; - dispatchedRetry: boolean; - error?: string; +interface CycleSummary { + versionsScanned: number; + imagesExpected: number; + imagesMissing: number; + dispatchesAttempted: number; + dispatchesSucceeded: number; + cooldownSkipped: number; + cappedAtLimit: boolean; +} + +interface ReconcilerOptions { + now?: () => number; + dockerHubToken?: string; } export class DockerImageReconciler { @@ -30,131 +62,260 @@ export class DockerImageReconciler { private repoVersionFull: string; private repoVersionMinor: string; private repoVersionMajor: string; - private imagesChecked = 0; - private missingImages: MissingImage[] = []; + private now: () => number; + private authHeader: Record; - constructor(gitHubClient: Octokit, repoVersionInfo: RepoVersionInfo) { + constructor( + gitHubClient: Octokit, + repoVersionInfo: RepoVersionInfo, + options: ReconcilerOptions = {}, + ) { this.gitHubClient = gitHubClient; const { major, minor, patch } = repoVersionInfo; this.repoVersionFull = `${major}.${minor}.${patch}`; this.repoVersionMinor = `${major}.${minor}`; this.repoVersionMajor = String(major); + this.now = options.now ?? (() => Date.now()); + const token = options.dockerHubToken ?? process.env.DOCKERHUB_RECONCILE_TOKEN; + this.authHeader = token ? { Authorization: `Bearer ${token}` } : {}; } - private async isDockerImageMissing(repository: string, tag: string): Promise { - try { - const response = await fetch(`${DOCKERHUB_API}/${repository}/tags/${tag}`, { - headers: { 'User-Agent': 'game-ci-versioning-backend/1.0' }, - }); - if (response.status === 404) return true; - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return false; - } catch (error) { - logger.warn(`DockerHub API error checking ${repository}:${tag}`, error); - return false; + async reconcileEditorImages(versions: EditorVersionInfo[]): Promise { + if (versions.length === 0) { + return; } - } - async reconcileEditorImages(versions: EditorVersionInfo[]): Promise { - if (versions.length === 0) return; - const versionsToCheck = versions.slice(0, RECENT_VERSIONS_TO_CHECK); - for (const version of versionsToCheck) { - if (this.imagesChecked >= MAX_IMAGES_PER_CYCLE) { + const state = await ReconciliationState.load(); + const summary: CycleSummary = { + versionsScanned: 0, + imagesExpected: 0, + imagesMissing: 0, + dispatchesAttempted: 0, + dispatchesSucceeded: 0, + cooldownSkipped: 0, + cappedAtLimit: false, + }; + + if (this.shouldCheckBaseHub(state)) { + await this.processBaseHub(state, summary); + state.baseHubCheckedAt = this.now(); + } + + const versionsToScan = this.pickVersionsForCycle(versions, state); + for (const version of versionsToScan) { + if (summary.dispatchesAttempted >= MAX_DISPATCHES_PER_CYCLE) { + summary.cappedAtLimit = true; break; } - await this.checkVersionImages(version); + await this.processVersion(version, state, summary); + summary.versionsScanned += 1; + state.cursorVersion = version.version; } - await this.reportResults(); + + state.cycleCount += 1; + this.pruneCooldownEntries(state); + await ReconciliationState.save(state); + await this.reportSummary(summary, versions.length); } - private async checkVersionImages(version: EditorVersionInfo): Promise { - const { version: editorVersion, changeSet: changeset } = version; + private shouldCheckBaseHub(state: ReconciliationStateData): boolean { + if (state.baseHubCheckedAt === null) return true; + return this.now() - state.baseHubCheckedAt >= BASE_HUB_CHECK_INTERVAL_MS; + } - const baseImages = [ - { repo: 'base' as const, tag: `ubuntu-${this.repoVersionFull}`, os: 'ubuntu' as const }, - { repo: 'base' as const, tag: `windows-${this.repoVersionFull}`, os: 'windows' as const }, - { repo: 'hub' as const, tag: `ubuntu-${this.repoVersionFull}`, os: 'ubuntu' as const }, - { repo: 'hub' as const, tag: `windows-${this.repoVersionFull}`, os: 'windows' as const }, + private async processBaseHub( + state: ReconciliationStateData, + summary: CycleSummary, + ): Promise { + const checks: DockerImage[] = [ + { + repository: 'unityci/base', + tag: `ubuntu-${this.repoVersionFull}`, + baseOs: 'ubuntu', + imageType: 'base', + }, + { + repository: 'unityci/base', + tag: `windows-${this.repoVersionFull}`, + baseOs: 'windows', + imageType: 'base', + }, + { + repository: 'unityci/hub', + tag: `ubuntu-${this.repoVersionFull}`, + baseOs: 'ubuntu', + imageType: 'hub', + }, + { + repository: 'unityci/hub', + tag: `windows-${this.repoVersionFull}`, + baseOs: 'windows', + imageType: 'hub', + }, ]; - for (const { repo, tag, os } of baseImages) { + const baseTags = await this.fetchTags('unityci/base', this.repoVersionFull); + const hubTags = await this.fetchTags('unityci/hub', this.repoVersionFull); + const tagSet: Record | null> = { + 'unityci/base': baseTags, + 'unityci/hub': hubTags, + }; + + for (const image of checks) { + await this.evaluateExpected(image, tagSet[image.repository], state, summary); + } + } + + private async processVersion( + version: EditorVersionInfo, + state: ReconciliationStateData, + summary: CycleSummary, + ): Promise { + const ubuntuExisting = await this.fetchTags('unityci/editor', `ubuntu-${version.version}`); + const windowsExisting = await this.fetchTags('unityci/editor', `windows-${version.version}`); + + for (const platform of UBUNTU_PLATFORMS) { const image: DockerImage = { - repository: `unityci/${repo}`, - tag, - baseOs: os, - imageType: repo, - }; - await this.checkImage(image); - } - - const ubuntuPlatforms = [ - 'base', - 'linux-il2cpp', - 'windows-mono', - 'mac-mono', - 'ios', - 'android', - 'webgl', - ] as const; - for (const platform of ubuntuPlatforms) { - if (this.imagesChecked >= MAX_IMAGES_PER_CYCLE) break; - await this.checkImage({ repository: 'unityci/editor', - tag: `ubuntu-${editorVersion}-${platform}-${this.repoVersionFull}`, + tag: `ubuntu-${version.version}-${platform}-${this.repoVersionFull}`, baseOs: 'ubuntu', imageType: 'editor', targetPlatform: platform, - editorVersion, - changeset, - }); + editorVersion: version.version, + changeset: version.changeSet, + }; + await this.evaluateExpected(image, ubuntuExisting, state, summary); + if (summary.dispatchesAttempted >= MAX_DISPATCHES_PER_CYCLE) return; } - const windowsPlatforms = [ - 'base', - 'windows-il2cpp', - 'universal-windows-platform', - 'appletv', - 'android', - ] as const; - for (const platform of windowsPlatforms) { - if (this.imagesChecked >= MAX_IMAGES_PER_CYCLE) break; - await this.checkImage({ + for (const platform of WINDOWS_PLATFORMS) { + const image: DockerImage = { repository: 'unityci/editor', - tag: `windows-${editorVersion}-${platform}-${this.repoVersionFull}`, + tag: `windows-${version.version}-${platform}-${this.repoVersionFull}`, baseOs: 'windows', imageType: 'editor', targetPlatform: platform, - editorVersion, - changeset, - }); + editorVersion: version.version, + changeset: version.changeSet, + }; + await this.evaluateExpected(image, windowsExisting, state, summary); + if (summary.dispatchesAttempted >= MAX_DISPATCHES_PER_CYCLE) return; } } - private async checkImage(image: DockerImage): Promise { - this.imagesChecked += 1; - try { - const isMissing = await this.isDockerImageMissing(image.repository, image.tag); - if (!isMissing) { - logger.debug(`OK ${image.repository}:${image.tag}`); - return; + private async evaluateExpected( + image: DockerImage, + existing: Set | null, + state: ReconciliationStateData, + summary: CycleSummary, + ): Promise { + summary.imagesExpected += 1; + + if (existing === null) { + logger.debug(`Skipping ${image.tag}: existing-tag fetch failed, assuming present`); + return; + } + + if (existing.has(image.tag)) { + return; + } + + summary.imagesMissing += 1; + const cooldownKey = `${image.repository}:${image.tag}`; + const lastDispatchedAt = state.recentDispatches[cooldownKey]; + if (lastDispatchedAt && this.now() - lastDispatchedAt < DISPATCH_COOLDOWN_MS) { + summary.cooldownSkipped += 1; + return; + } + + if (summary.dispatchesAttempted >= MAX_DISPATCHES_PER_CYCLE) { + summary.cappedAtLimit = true; + return; + } + + summary.dispatchesAttempted += 1; + const dispatched = await this.dispatchRetry(image); + if (dispatched) { + summary.dispatchesSucceeded += 1; + state.recentDispatches[cooldownKey] = this.now(); + } + } + + private pickVersionsForCycle( + versions: EditorVersionInfo[], + state: ReconciliationStateData, + ): EditorVersionInfo[] { + if (versions.length === 0) return []; + const startIndex = state.cursorVersion + ? Math.max(0, versions.findIndex((v) => v.version === state.cursorVersion) + 1) + : 0; + const effectiveStart = startIndex >= versions.length ? 0 : startIndex; + const slice = versions.slice(effectiveStart, effectiveStart + VERSIONS_PER_CYCLE); + if (slice.length < VERSIONS_PER_CYCLE && effectiveStart > 0) { + const remainder = VERSIONS_PER_CYCLE - slice.length; + slice.push(...versions.slice(0, remainder)); + } + return slice; + } + + private async fetchTags(repository: string, nameFilter: string): Promise | null> { + const tags = new Set(); + let nextUrl: string | null = + `${DOCKERHUB_API}/${repository}/tags?page_size=100&name=${encodeURIComponent(nameFilter)}`; + let pagesFetched = 0; + + while (nextUrl && pagesFetched < MAX_TAG_PAGES_PER_QUERY) { + try { + const response = await fetch(nextUrl, { + headers: { + 'User-Agent': 'game-ci-versioning-backend/1.0', + ...this.authHeader, + }, + }); + + if (response.status === 429) { + logger.warn(`DockerHub rate-limited for ${repository} (${nameFilter})`); + return null; + } + if (!response.ok) { + logger.warn( + `DockerHub list tags failed for ${repository} (${nameFilter}): HTTP ${response.status}`, + ); + return null; + } + + const body = (await response.json()) as { + results?: { name: string }[]; + next?: string | null; + }; + for (const result of body.results ?? []) { + tags.add(result.name); + } + nextUrl = body.next ?? null; + pagesFetched += 1; + } catch (error) { + logger.warn(`DockerHub list tags error for ${repository} (${nameFilter})`, error); + return null; + } + } + + return tags; + } + + private pruneCooldownEntries(state: ReconciliationStateData): void { + const cutoff = this.now() - COOLDOWN_RETENTION_MS; + for (const [key, ts] of Object.entries(state.recentDispatches)) { + if (ts < cutoff) { + delete state.recentDispatches[key]; } - logger.warn(`Missing: ${image.repository}:${image.tag}`); - const dispatchedRetry = await this.dispatchRetry(image); - this.missingImages.push({ image, dispatchedRetry }); - } catch (error) { - this.missingImages.push({ - image, - dispatchedRetry: false, - error: String(error), - }); } } private async dispatchRetry(image: DockerImage): Promise { try { const eventType = this.getEventType(image); - const payload: Record = { - jobId: `reconciliation-${Date.now()}-${image.imageType}-${image.tag}`, + const payload: Record = { + jobId: `reconciliation-${image.imageType}-${image.tag}`, repoVersionFull: this.repoVersionFull, repoVersionMinor: this.repoVersionMinor, repoVersionMajor: this.repoVersionMajor, @@ -173,7 +334,7 @@ export class DockerImageReconciler { return response.status >= 200 && response.status < 300; } catch (error) { - logger.error('Error dispatching', error); + logger.error(`Error dispatching retry for ${image.tag}`, error); return false; } } @@ -194,18 +355,20 @@ export class DockerImageReconciler { : GitHubWorkflow.eventTypes.retryWindowsEditorImage; } - private async reportResults(): Promise { - if (this.missingImages.length === 0) { - await Discord.sendDebug(`[DockerImageReconciler] Checked ${this.imagesChecked} images OK`); + private async reportSummary(summary: CycleSummary, totalVersions: number): Promise { + if (summary.imagesMissing === 0) { + await Discord.sendDebug( + `[DockerImageReconciler] Scanned ${summary.versionsScanned}/${totalVersions} versions, ` + + `${summary.imagesExpected} images verified, all present`, + ); return; } - const successful = this.missingImages.filter((m) => m.dispatchedRetry).length; - const failedCount = this.missingImages.length - successful; - await Discord.sendAlert( - `DockerHub Reconciliation: Found ${this.missingImages.length} missing, ` + - `retried ${successful}, failed ${failedCount}`, + `DockerHub Reconciliation: ${summary.imagesMissing} missing across ` + + `${summary.versionsScanned} versions; dispatched ${summary.dispatchesSucceeded}/${summary.dispatchesAttempted}, ` + + `${summary.cooldownSkipped} in cooldown` + + (summary.cappedAtLimit ? `, capped at ${MAX_DISPATCHES_PER_CYCLE} dispatches/cycle` : ''), ); } } diff --git a/functions/src/model/reconciliationState.ts b/functions/src/model/reconciliationState.ts new file mode 100644 index 0000000..757d880 --- /dev/null +++ b/functions/src/model/reconciliationState.ts @@ -0,0 +1,43 @@ +import { admin, db } from '../service/firebase'; +import Timestamp = admin.firestore.Timestamp; + +export const RECONCILIATION_COLLECTION = 'reconciliationState'; +export const DOCKER_HUB_DOC = 'dockerHub'; + +export interface ReconciliationStateData { + cursorVersion: string | null; + recentDispatches: Record; + baseHubCheckedAt: number | null; + cycleCount: number; + updatedAt?: Timestamp; +} + +export class ReconciliationState { + static async load(): Promise { + const snapshot = await db.collection(RECONCILIATION_COLLECTION).doc(DOCKER_HUB_DOC).get(); + + if (!snapshot.exists) { + return { + cursorVersion: null, + recentDispatches: {}, + baseHubCheckedAt: null, + cycleCount: 0, + }; + } + + const data = snapshot.data() as Partial; + return { + cursorVersion: data.cursorVersion ?? null, + recentDispatches: data.recentDispatches ?? {}, + baseHubCheckedAt: data.baseHubCheckedAt ?? null, + cycleCount: data.cycleCount ?? 0, + }; + } + + static async save(state: ReconciliationStateData): Promise { + await db + .collection(RECONCILIATION_COLLECTION) + .doc(DOCKER_HUB_DOC) + .set({ ...state, updatedAt: Timestamp.now() }, { merge: false }); + } +} diff --git a/functions/test/dockerImageReconciler.test.ts b/functions/test/dockerImageReconciler.test.ts new file mode 100644 index 0000000..b0b4544 --- /dev/null +++ b/functions/test/dockerImageReconciler.test.ts @@ -0,0 +1,234 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { DockerImageReconciler } from '../src/logic/buildQueue/dockerImageReconciler'; + +vi.mock('firebase-functions/v2', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +vi.mock('../src/service/discord', () => ({ + Discord: { + sendDebug: vi.fn().mockResolvedValue(undefined), + sendAlert: vi.fn().mockResolvedValue(undefined), + }, +})); + +const stateStore: { current: any } = { current: null }; +vi.mock('../src/model/reconciliationState', () => ({ + ReconciliationState: { + load: vi.fn(async () => { + const fallback = { + cursorVersion: null, + recentDispatches: {}, + baseHubCheckedAt: null, + cycleCount: 0, + }; + return JSON.parse(JSON.stringify(stateStore.current ?? fallback)); + }), + save: vi.fn(async (next: any) => { + stateStore.current = JSON.parse(JSON.stringify(next)); + }), + }, +})); + +const fetchMock = vi.fn(); +vi.stubGlobal('fetch', fetchMock); + +const { Discord } = await import('../src/service/discord'); +const { ReconciliationState } = await import('../src/model/reconciliationState'); + +const repoVersionInfo = { major: 3, minor: 2, patch: 2 } as any; + +const UBUNTU_PLATFORMS = [ + 'base', + 'linux-il2cpp', + 'windows-mono', + 'mac-mono', + 'ios', + 'android', + 'webgl', +] as const; +const WINDOWS_PLATFORMS = [ + 'base', + 'windows-il2cpp', + 'universal-windows-platform', + 'appletv', + 'android', +] as const; + +const buildVersion = (version: string, changeSet = 'abc123def456') => ({ + version, + changeSet, + major: Number(version.split('.')[0]), + minor: Number(version.split('.')[1]), + patch: version.split('.')[2], +}); + +const tagsResponse = (tags: string[]) => ({ + ok: true, + status: 200, + json: async () => ({ results: tags.map((name) => ({ name })), next: null }), +}); + +const allPresentMock = (versionList: string[]) => + fetchMock.mockImplementation(async (url: string) => { + const u = String(url); + if (u.includes('unityci/base')) { + return tagsResponse(['ubuntu-3.2.2', 'windows-3.2.2']); + } + if (u.includes('unityci/hub')) { + return tagsResponse(['ubuntu-3.2.2', 'windows-3.2.2']); + } + const match = u.match(/name=(ubuntu|windows)-([^&]+)/); + if (match) { + const os = match[1]; + const v = decodeURIComponent(match[2]); + if (!versionList.includes(v)) return tagsResponse([]); + const platforms = os === 'ubuntu' ? UBUNTU_PLATFORMS : WINDOWS_PLATFORMS; + return tagsResponse(platforms.map((p) => `${os}-${v}-${p}-3.2.2`)); + } + return tagsResponse([]); + }); + +const createDispatchEvent = vi.fn().mockResolvedValue({ status: 204 }); +const gitHubClient = { repos: { createDispatchEvent } } as any; + +beforeEach(() => { + fetchMock.mockReset(); + createDispatchEvent.mockClear(); + (Discord.sendDebug as any).mockClear(); + (Discord.sendAlert as any).mockClear(); + (ReconciliationState.load as any).mockClear(); + (ReconciliationState.save as any).mockClear(); + stateStore.current = null; +}); + +describe('DockerImageReconciler (incremental)', () => { + it('returns early when no versions to reconcile', async () => { + const reconciler = new DockerImageReconciler(gitHubClient, repoVersionInfo); + await reconciler.reconcileEditorImages([]); + expect(fetchMock).not.toHaveBeenCalled(); + expect(createDispatchEvent).not.toHaveBeenCalled(); + expect(ReconciliationState.save).not.toHaveBeenCalled(); + }); + + it('reports debug when all expected tags present', async () => { + const v = '6000.4.10f1'; + allPresentMock([v]); + + const reconciler = new DockerImageReconciler(gitHubClient, repoVersionInfo, { + now: () => 1_000_000, + }); + await reconciler.reconcileEditorImages([buildVersion(v)]); + + expect(createDispatchEvent).not.toHaveBeenCalled(); + expect(Discord.sendDebug).toHaveBeenCalled(); + }); + + it('advances cursor and resumes from next version on subsequent cycle', async () => { + const versions = Array.from({ length: 12 }, (_, i) => buildVersion(`6000.4.${i}f1`)); + allPresentMock(versions.map((v) => v.version)); + + const r1 = new DockerImageReconciler(gitHubClient, repoVersionInfo, { now: () => 1_000_000 }); + await r1.reconcileEditorImages(versions); + const cursor1 = stateStore.current.cursorVersion; + expect(cursor1).toBe('6000.4.4f1'); + + const r2 = new DockerImageReconciler(gitHubClient, repoVersionInfo, { now: () => 2_000_000 }); + await r2.reconcileEditorImages(versions); + const cursor2 = stateStore.current.cursorVersion; + expect(cursor2).toBe('6000.4.9f1'); + expect(stateStore.current.cycleCount).toBe(2); + }); + + it('honors per-tag dispatch cooldown to prevent re-dispatching', async () => { + const v = '6000.4.10f1'; + const presentUbuntu = UBUNTU_PLATFORMS.filter((p) => p !== 'webgl').map( + (p) => `ubuntu-${v}-${p}-3.2.2`, + ); + const presentWindows = WINDOWS_PLATFORMS.map((p) => `windows-${v}-${p}-3.2.2`); + + fetchMock.mockImplementation(async (url: string) => { + const u = String(url); + if (u.includes('unityci/base')) return tagsResponse(['ubuntu-3.2.2', 'windows-3.2.2']); + if (u.includes('unityci/hub')) return tagsResponse(['ubuntu-3.2.2', 'windows-3.2.2']); + if (u.includes(`name=ubuntu-${v}`)) return tagsResponse(presentUbuntu); + if (u.includes(`name=windows-${v}`)) return tagsResponse(presentWindows); + return tagsResponse([]); + }); + + const r1 = new DockerImageReconciler(gitHubClient, repoVersionInfo, { now: () => 1_000_000 }); + await r1.reconcileEditorImages([buildVersion(v)]); + expect(createDispatchEvent).toHaveBeenCalledTimes(1); + + createDispatchEvent.mockClear(); + const r2 = new DockerImageReconciler(gitHubClient, repoVersionInfo, { now: () => 1_000_500 }); + await r2.reconcileEditorImages([buildVersion(v)]); + expect(createDispatchEvent).not.toHaveBeenCalled(); + }); + + it('caps dispatches per cycle at MAX_DISPATCHES_PER_CYCLE', async () => { + fetchMock.mockResolvedValue(tagsResponse([])); + const versions = Array.from({ length: 5 }, (_, i) => buildVersion(`6000.4.${i}f1`)); + const reconciler = new DockerImageReconciler(gitHubClient, repoVersionInfo, { + now: () => 1_000_000, + }); + await reconciler.reconcileEditorImages(versions); + expect(createDispatchEvent.mock.calls.length).toBeLessThanOrEqual(10); + }); + + it('skips dispatch and does not crash when DockerHub returns 429', async () => { + fetchMock.mockResolvedValue({ ok: false, status: 429, json: async () => ({}) }); + const reconciler = new DockerImageReconciler(gitHubClient, repoVersionInfo, { + now: () => 1_000_000, + }); + await reconciler.reconcileEditorImages([buildVersion('6000.4.10f1')]); + expect(createDispatchEvent).not.toHaveBeenCalled(); + }); + + it('skips base/hub if checked recently', async () => { + allPresentMock(['6000.4.10f1']); + stateStore.current = { + cursorVersion: null, + recentDispatches: {}, + baseHubCheckedAt: 1_000_000, + cycleCount: 1, + }; + + const reconciler = new DockerImageReconciler(gitHubClient, repoVersionInfo, { + now: () => 1_000_500, + }); + await reconciler.reconcileEditorImages([buildVersion('6000.4.10f1')]); + + const baseHubCalls = fetchMock.mock.calls.filter( + (c) => String(c[0]).includes('unityci/base') || String(c[0]).includes('unityci/hub'), + ); + expect(baseHubCalls.length).toBe(0); + }); + + it('persists cooldown timestamps in state for next cycle', async () => { + fetchMock.mockResolvedValue(tagsResponse([])); + const reconciler = new DockerImageReconciler(gitHubClient, repoVersionInfo, { + now: () => 1_000_000, + }); + await reconciler.reconcileEditorImages([buildVersion('6000.4.10f1')]); + expect(stateStore.current).toBeTruthy(); + expect(Object.keys(stateStore.current.recentDispatches).length).toBeGreaterThan(0); + }); + + it('wraps cursor around to beginning when reaching end of version list', async () => { + const versions = Array.from({ length: 8 }, (_, i) => buildVersion(`6000.4.${i}f1`)); + allPresentMock(versions.map((v) => v.version)); + stateStore.current = { + cursorVersion: '6000.4.7f1', + recentDispatches: {}, + baseHubCheckedAt: 999_999_999_999, + cycleCount: 5, + }; + + const reconciler = new DockerImageReconciler(gitHubClient, repoVersionInfo, { + now: () => 2_000_000, + }); + await reconciler.reconcileEditorImages(versions); + expect(stateStore.current.cursorVersion).toBe('6000.4.4f1'); + }); +});