Files
inkling/tests/unit/HealthChecker.test.ts
altair823 9fef2edb6e feat(ollama): ProviderHolder + AiWorker/HealthChecker refactor (v0.2.3.1)
- ProviderHolder: mutable holder + listeners, indirection layer
- AiWorker: constructor InferenceProvider → ProviderHolder
  this.provider.x → this.holder.get().x 전환
- HealthChecker: 동일 패턴
- src/main/index.ts: provider 를 ProviderHolder 로 감싸서 생성
- 기존 AiWorker / HealthChecker 테스트의 constructor 호출에 ProviderHolder wrap
- 단위 +2 cases (ProviderHolder)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 23:32:20 +09:00

120 lines
4.8 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { HealthChecker, type HealthTelemetryEvent } from '@main/services/HealthChecker.js';
import type { InferenceProvider, HealthResult, GenerateInput } from '@main/ai/InferenceProvider.js';
import type { AiResponse } from '@main/ai/schema.js';
import { ProviderHolder } from '@main/ai/ProviderHolder.js';
class FakeProvider implements InferenceProvider {
readonly name = 'fake';
results: HealthResult[] = [];
private idx = 0;
async healthCheck(): Promise<HealthResult> {
const r = this.results[Math.min(this.idx, this.results.length - 1)] ?? { ok: true };
this.idx += 1;
return r;
}
async generate(_input: GenerateInput): Promise<AiResponse> {
throw new Error('not used');
}
}
describe('HealthChecker — start/stop polling', () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it('start() runs runOnce immediately + every intervalMs', async () => {
const provider = new FakeProvider();
provider.results = [{ ok: true }, { ok: true }, { ok: true }];
const hc = new HealthChecker(new ProviderHolder(provider), { intervalMs: 1000 });
hc.start();
await vi.runOnlyPendingTimersAsync();
await vi.advanceTimersByTimeAsync(1000);
await vi.advanceTimersByTimeAsync(1000);
expect((provider as any).idx).toBeGreaterThanOrEqual(3);
hc.stop();
});
it('start() is idempotent — second call does not duplicate timer', async () => {
const provider = new FakeProvider();
provider.results = [{ ok: true }];
const hc = new HealthChecker(new ProviderHolder(provider), { intervalMs: 1000 });
hc.start();
hc.start();
// 즉시 1회 + 1s 후 1회 = 정확히 2. 두 timer 가 잘못 등록됐으면 4 (각 timer 마다 즉시+1s).
await vi.advanceTimersByTimeAsync(1000);
expect((provider as any).idx).toBe(2);
hc.stop();
});
it('stop() clears timer (no further runOnce)', async () => {
const provider = new FakeProvider();
provider.results = [{ ok: true }, { ok: true }];
const hc = new HealthChecker(new ProviderHolder(provider), { intervalMs: 1000 });
hc.start();
await vi.runOnlyPendingTimersAsync();
const before = (provider as any).idx;
hc.stop();
await vi.advanceTimersByTimeAsync(5000);
expect((provider as any).idx).toBe(before);
});
});
describe('HealthChecker — delta transitions + telemetry', () => {
it('ok=true → ok=false 전이 시 onUpdate + ollama_unreachable emit', async () => {
const provider = new FakeProvider();
provider.results = [{ ok: true }, { ok: false, reason: 'connection refused' }];
const updates: HealthResult[] = [];
const events: HealthTelemetryEvent[] = [];
const hc = new HealthChecker(new ProviderHolder(provider), {
onUpdate: (s) => updates.push(s),
onTelemetry: (e) => events.push(e)
});
await hc.runOnce();
await hc.runOnce();
expect(updates).toEqual([{ ok: false, reason: 'connection refused' }]);
expect(events).toEqual([{ kind: 'ollama_unreachable', reason: 'connection refused' }]);
});
it('ok=false → ok=true 전이 시 onUpdate + ollama_recovered emit (downtimeMs 정확)', async () => {
const provider = new FakeProvider();
provider.results = [{ ok: false, reason: 'refused' }, { ok: true }];
const events: HealthTelemetryEvent[] = [];
let nowCounter = 0;
const hc = new HealthChecker(new ProviderHolder(provider), {
onTelemetry: (e) => events.push(e),
now: () => { nowCounter += 1; return nowCounter * 1000; }
});
await hc.runOnce();
await hc.runOnce();
const recovered = events.find((e) => e.kind === 'ollama_recovered');
expect(recovered).toEqual({ kind: 'ollama_recovered', downtimeMs: 1000 });
});
it('reason 변경만 (ok=false 유지) 시 onUpdate fire 하지만 telemetry emit 안 함', async () => {
const provider = new FakeProvider();
provider.results = [
{ ok: false, reason: 'refused' },
{ ok: false, reason: 'timeout' }
];
const updates: HealthResult[] = [];
const events: HealthTelemetryEvent[] = [];
const hc = new HealthChecker(new ProviderHolder(provider), {
onUpdate: (s) => updates.push(s),
onTelemetry: (e) => events.push(e)
});
await hc.runOnce();
await hc.runOnce();
expect(updates).toHaveLength(2);
expect(events).toEqual([{ kind: 'ollama_unreachable', reason: 'refused' }]);
});
it('runOnce({manual:true}) 가 ollama_recheck_manual 1회 fire', async () => {
const provider = new FakeProvider();
provider.results = [{ ok: true }];
const events: HealthTelemetryEvent[] = [];
const hc = new HealthChecker(new ProviderHolder(provider), { onTelemetry: (e) => events.push(e) });
await hc.runOnce({ manual: true });
expect(events).toEqual([{ kind: 'ollama_recheck_manual' }]);
});
});