Skip to content

Commit c8ecfd8

Browse files
[codex] Add daemon lifecycle retry and visibility tests (tinyhumansai#796)
Co-authored-by: Jwalin Shah <jshah1331@gmail.com>
1 parent 22b7a8b commit c8ecfd8

1 file changed

Lines changed: 215 additions & 0 deletions

File tree

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3+
4+
import {
5+
incrementConnectionAttempts,
6+
resetConnectionAttempts,
7+
setAutoStartEnabled,
8+
setDaemonStatus,
9+
setIsRecovering,
10+
} from '../../features/daemon/store';
11+
import { isTauri } from '../../utils/tauriCommands';
12+
13+
const mockStartDaemon = vi.fn();
14+
const mockRestartDaemon = vi.fn();
15+
16+
vi.mock('../useDaemonHealth', () => ({
17+
useDaemonHealth: () => ({ startDaemon: mockStartDaemon, restartDaemon: mockRestartDaemon }),
18+
}));
19+
20+
const setVisibility = (value: 'visible' | 'hidden'): void => {
21+
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => value });
22+
document.dispatchEvent(new Event('visibilitychange'));
23+
};
24+
25+
const freshUser = (() => {
26+
let n = 0;
27+
return (prefix: string): string => `${prefix}-${++n}-${Date.now()}`;
28+
})();
29+
30+
const resetUser = (uid: string): void => {
31+
resetConnectionAttempts(uid);
32+
setAutoStartEnabled(uid, false);
33+
setIsRecovering(uid, false);
34+
setDaemonStatus(uid, 'disconnected');
35+
};
36+
37+
describe('useDaemonLifecycle', () => {
38+
beforeEach(() => {
39+
vi.mocked(isTauri).mockReturnValue(true);
40+
mockStartDaemon.mockReset();
41+
mockRestartDaemon.mockReset();
42+
vi.useFakeTimers();
43+
});
44+
45+
afterEach(() => {
46+
vi.useRealTimers();
47+
});
48+
49+
describe('exponential backoff bounds', () => {
50+
it('nextRetryDelay doubles from 1s as attempts increase', async () => {
51+
const { useDaemonLifecycle } = await import('../useDaemonLifecycle');
52+
const uid = freshUser('backoff');
53+
resetUser(uid);
54+
55+
const { result } = renderHook(() => useDaemonLifecycle(uid));
56+
57+
// attempts=0 → next attempt is #1 → 1000 * 2^0 = 1000
58+
expect(result.current.nextRetryDelay).toBe(1000);
59+
60+
const expected = [2000, 4000, 8000, 16000];
61+
for (const delay of expected) {
62+
act(() => incrementConnectionAttempts(uid));
63+
expect(result.current.nextRetryDelay).toBe(delay);
64+
}
65+
});
66+
67+
it('every computed delay stays within [BASE, MAX_RETRY_DELAY_MS] bounds', async () => {
68+
const { useDaemonLifecycle } = await import('../useDaemonLifecycle');
69+
const uid = freshUser('bounds');
70+
resetUser(uid);
71+
72+
const { result } = renderHook(() => useDaemonLifecycle(uid));
73+
const observed: number[] = [];
74+
75+
observed.push(result.current.nextRetryDelay ?? -1);
76+
for (let i = 0; i < result.current.MAX_RECONNECTION_ATTEMPTS - 1; i++) {
77+
act(() => incrementConnectionAttempts(uid));
78+
if (result.current.nextRetryDelay != null) {
79+
observed.push(result.current.nextRetryDelay);
80+
}
81+
}
82+
83+
for (const delay of observed) {
84+
expect(delay).toBeGreaterThanOrEqual(1000);
85+
expect(delay).toBeLessThanOrEqual(30000);
86+
}
87+
// Monotonically non-decreasing (doubling, eventually capped).
88+
for (let i = 1; i < observed.length; i++) {
89+
expect(observed[i]).toBeGreaterThanOrEqual(observed[i - 1]);
90+
}
91+
});
92+
});
93+
94+
describe('maxAttemptsReached', () => {
95+
it('is false below MAX and flips true at/above MAX, with nextRetryDelay null', async () => {
96+
const { useDaemonLifecycle } = await import('../useDaemonLifecycle');
97+
const uid = freshUser('max');
98+
resetUser(uid);
99+
100+
const { result } = renderHook(() => useDaemonLifecycle(uid));
101+
102+
expect(result.current.maxAttemptsReached).toBe(false);
103+
const max = result.current.MAX_RECONNECTION_ATTEMPTS;
104+
105+
act(() => {
106+
for (let i = 0; i < max - 1; i++) incrementConnectionAttempts(uid);
107+
});
108+
expect(result.current.maxAttemptsReached).toBe(false);
109+
expect(result.current.nextRetryDelay).not.toBeNull();
110+
111+
act(() => incrementConnectionAttempts(uid));
112+
expect(result.current.connectionAttempts).toBe(max);
113+
expect(result.current.maxAttemptsReached).toBe(true);
114+
expect(result.current.nextRetryDelay).toBeNull();
115+
});
116+
117+
it('resetRetries clears attempts and re-enables retries', async () => {
118+
const { useDaemonLifecycle } = await import('../useDaemonLifecycle');
119+
const uid = freshUser('reset');
120+
resetUser(uid);
121+
122+
const { result } = renderHook(() => useDaemonLifecycle(uid));
123+
act(() => {
124+
for (let i = 0; i < result.current.MAX_RECONNECTION_ATTEMPTS; i++) {
125+
incrementConnectionAttempts(uid);
126+
}
127+
});
128+
expect(result.current.maxAttemptsReached).toBe(true);
129+
130+
act(() => result.current.resetRetries());
131+
132+
expect(result.current.connectionAttempts).toBe(0);
133+
expect(result.current.maxAttemptsReached).toBe(false);
134+
expect(result.current.nextRetryDelay).toBe(1000);
135+
});
136+
});
137+
138+
describe('background / foreground pause-resume', () => {
139+
it('does not invoke startDaemon while hidden, resumes auto-start on visible', async () => {
140+
const { useDaemonLifecycle } = await import('../useDaemonLifecycle');
141+
const uid = freshUser('vis');
142+
resetUser(uid);
143+
// Enable auto-start before mount so the visibility listener captures the
144+
// "disconnected + autoStart + !recovering" branch on the very first render.
145+
setAutoStartEnabled(uid, true);
146+
mockStartDaemon.mockResolvedValue({ result: { state: 'Running' }, logs: [] });
147+
setVisibility('visible');
148+
149+
renderHook(() => useDaemonLifecycle(uid));
150+
151+
// Going hidden must not schedule any auto-start work.
152+
setVisibility('hidden');
153+
await act(async () => {
154+
await vi.advanceTimersByTimeAsync(500);
155+
});
156+
expect(mockStartDaemon).not.toHaveBeenCalled();
157+
158+
// Returning to foreground schedules a delayed auto-start (1000ms inside the handler).
159+
// We stop asserting before the 3000ms initial auto-start timer window so this test
160+
// isolates the resume branch rather than the mount branch.
161+
setVisibility('visible');
162+
await act(async () => {
163+
await vi.advanceTimersByTimeAsync(1000);
164+
});
165+
expect(mockStartDaemon).toHaveBeenCalledTimes(1);
166+
});
167+
168+
it('visibility handler is a no-op when auto-start is disabled', async () => {
169+
const { useDaemonLifecycle } = await import('../useDaemonLifecycle');
170+
const uid = freshUser('vis-off');
171+
resetUser(uid);
172+
setAutoStartEnabled(uid, false);
173+
setVisibility('visible');
174+
175+
renderHook(() => useDaemonLifecycle(uid));
176+
177+
// No initial auto-start scheduled; no startDaemon call.
178+
await act(async () => {
179+
await vi.advanceTimersByTimeAsync(4000);
180+
});
181+
expect(mockStartDaemon).not.toHaveBeenCalled();
182+
183+
setVisibility('hidden');
184+
setVisibility('visible');
185+
await act(async () => {
186+
await vi.advanceTimersByTimeAsync(2000);
187+
});
188+
expect(mockStartDaemon).not.toHaveBeenCalled();
189+
});
190+
191+
it('skips resume when status is already running', async () => {
192+
const { useDaemonLifecycle } = await import('../useDaemonLifecycle');
193+
const uid = freshUser('vis-running');
194+
resetUser(uid);
195+
setAutoStartEnabled(uid, true);
196+
setDaemonStatus(uid, 'running');
197+
setVisibility('visible');
198+
199+
renderHook(() => useDaemonLifecycle(uid));
200+
201+
await act(async () => {
202+
await vi.advanceTimersByTimeAsync(3000);
203+
});
204+
// Initial auto-start runs but attemptAutoStart bails because status !== 'disconnected'.
205+
expect(mockStartDaemon).not.toHaveBeenCalled();
206+
207+
setVisibility('hidden');
208+
setVisibility('visible');
209+
await act(async () => {
210+
await vi.advanceTimersByTimeAsync(2000);
211+
});
212+
expect(mockStartDaemon).not.toHaveBeenCalled();
213+
});
214+
});
215+
});

0 commit comments

Comments
 (0)