m1 — HealthChecker.last={ok:true} sentinel 의도 주석 (line 17).
첫 healthy=ok=true 면 transition 으로 인식 안 됨, ok=false 면 unreachable
transition 으로 정상 인식. telemetry 누락 0.
m2 — runOnce in-flight guard 추가. polling 첫 호출이 늦게 끝나는 동안
setInterval 가 두 번째 호출 시작하면 같은 promise 반환. healthCheck 가
idempotent HTTP 라 race 안전하지만, 이중 onUpdate/telemetry emit 회피.
m3 — main.ts before-quit 핸들러 통합. trayInterval cleanup 별도 핸들러
(line 349) 제거하고 health.stop() 핸들러 안에 흡수. 모든 cleanup 한 곳.
n1 — OllamaBanner 재확인 button 의 onClick 에 .catch 추가.
recheckOllama Promise rejection 시 console.warn (silent swallow 회피).
n2 — App.tsx useEffect deps array 의도 주석 1줄. onOllamaStatus 콜백이
useInbox.setState 직접 호출 — store reference 안정적이라 deps 불필요.
n3 — HealthChecker idempotent test 강화. <=2 → ===2 (정확).
두 timer 등록되면 4 (각 timer 마다 즉시+1s) 가 됨.
n4 — runOnce 의 manual emit 이 healthCheck *전에* fire 인 의도 주석.
provider 실패 시에도 manual 카운트 1:1 보장.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
119 lines
4.6 KiB
TypeScript
119 lines
4.6 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';
|
|
|
|
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(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(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(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(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(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(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(provider, { onTelemetry: (e) => events.push(e) });
|
|
await hc.runOnce({ manual: true });
|
|
expect(events).toEqual([{ kind: 'ollama_recheck_manual' }]);
|
|
});
|
|
});
|