Skip to content

Commit 986955f

Browse files
committed
🤖 fix: stabilize unit tests for bun + window guards
Change-Id: If25e133840a3d7cd29c32a0970cabd432a0e2b2d Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 67e1fb1 commit 986955f

File tree

5 files changed

+104
-52
lines changed

5 files changed

+104
-52
lines changed

src/browser/components/AppLoader.auth.test.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,16 @@ void mock.module("@/browser/contexts/API", () => ({
1515
}));
1616

1717
void mock.module("@/browser/components/AuthTokenModal", () => ({
18+
// Note: Module mocks leak between bun test files.
19+
// Export all commonly-used symbols to avoid cross-test import errors.
1820
AuthTokenModal: (props: { error?: string | null }) => (
1921
<div data-testid="AuthTokenModalMock">{props.error ?? "no-error"}</div>
2022
),
23+
getStoredAuthToken: () => null,
24+
// eslint-disable-next-line @typescript-eslint/no-empty-function
25+
setStoredAuthToken: () => {},
26+
// eslint-disable-next-line @typescript-eslint/no-empty-function
27+
clearStoredAuthToken: () => {},
2128
}));
2229

2330
import { AppLoader } from "./AppLoader";

src/browser/contexts/API.test.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,13 @@ void mock.module("@orpc/client/message-port", () => ({
6666
}));
6767

6868
void mock.module("@/browser/components/AuthTokenModal", () => ({
69+
// Note: Module mocks leak between bun test files.
70+
// Export all commonly-used symbols to avoid cross-test import errors.
71+
AuthTokenModal: () => null,
6972
getStoredAuthToken: () => null,
7073
// eslint-disable-next-line @typescript-eslint/no-empty-function
74+
setStoredAuthToken: () => {},
75+
// eslint-disable-next-line @typescript-eslint/no-empty-function
7176
clearStoredAuthToken: () => {},
7277
}));
7378

src/browser/hooks/useVoiceInput.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,12 @@ export interface UseVoiceInputResult {
5656
* We hide our voice UI on these devices to avoid redundancy with system dictation.
5757
*/
5858
function hasTouchDictation(): boolean {
59-
if (typeof window === "undefined") return false;
60-
const hasTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0;
59+
if (typeof window === "undefined" || typeof navigator === "undefined") return false;
60+
61+
const maxTouchPoints =
62+
typeof navigator.maxTouchPoints === "number" ? navigator.maxTouchPoints : 0;
63+
const hasTouch = "ontouchstart" in window || maxTouchPoints > 0;
64+
6165
// Touch-only check: most touch devices have native dictation.
6266
// We don't check screen size because iPads are large but still have dictation.
6367
return hasTouch;
@@ -66,7 +70,9 @@ function hasTouchDictation(): boolean {
6670
const HAS_TOUCH_DICTATION = hasTouchDictation();
6771
const HAS_MEDIA_RECORDER = typeof window !== "undefined" && typeof MediaRecorder !== "undefined";
6872
const HAS_GET_USER_MEDIA =
69-
typeof window !== "undefined" && typeof navigator.mediaDevices?.getUserMedia === "function";
73+
typeof window !== "undefined" &&
74+
typeof navigator !== "undefined" &&
75+
typeof navigator.mediaDevices?.getUserMedia === "function";
7076

7177
// =============================================================================
7278
// Global Key State Tracking
@@ -79,7 +85,7 @@ const HAS_GET_USER_MEDIA =
7985
*/
8086
let isSpaceCurrentlyHeld = false;
8187

82-
if (typeof window !== "undefined") {
88+
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
8389
window.addEventListener(
8490
"keydown",
8591
(e) => {

src/browser/utils/RefreshController.test.ts

Lines changed: 67 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,51 @@
1-
import { describe, it, expect, beforeEach, afterEach, jest } from "@jest/globals";
1+
import { describe, it, expect, afterEach, jest, setSystemTime } from "bun:test";
22
import { RefreshController } from "./RefreshController";
33

4-
describe("RefreshController", () => {
5-
beforeEach(() => {
6-
jest.useFakeTimers();
7-
});
4+
async function sleep(ms: number): Promise<void> {
5+
await new Promise((resolve) => setTimeout(resolve, ms));
6+
}
87

8+
describe("RefreshController", () => {
99
afterEach(() => {
10-
jest.useRealTimers();
10+
// Some tests manipulate Date.now via setSystemTime(); always restore.
11+
setSystemTime();
1112
});
1213

13-
it("rate-limits multiple schedule() calls (doesn't reset timer)", () => {
14+
it("rate-limits multiple schedule() calls (doesn't reset timer)", async () => {
1415
const onRefresh = jest.fn<() => void>();
15-
const controller = new RefreshController({ onRefresh, debounceMs: 100 });
16+
const controller = new RefreshController({ onRefresh, debounceMs: 50 });
1617

1718
controller.schedule();
18-
jest.advanceTimersByTime(50);
19+
await sleep(20);
1920
controller.schedule(); // Shouldn't reset timer
20-
jest.advanceTimersByTime(50);
2121

22-
// Should fire at 100ms from first call, not 150ms
22+
// Should fire at 50ms from first call, not 70ms
23+
await sleep(40);
2324
expect(onRefresh).toHaveBeenCalledTimes(1);
2425

2526
controller.dispose();
2627
});
2728

28-
it("coalesces calls during rate-limit window", () => {
29+
it("coalesces calls during rate-limit window", async () => {
2930
const onRefresh = jest.fn<() => void>();
30-
const controller = new RefreshController({ onRefresh, debounceMs: 100 });
31+
const controller = new RefreshController({ onRefresh, debounceMs: 50 });
3132

3233
controller.schedule();
3334
controller.schedule();
3435
controller.schedule();
3536

3637
expect(onRefresh).not.toHaveBeenCalled();
3738

38-
jest.advanceTimersByTime(100);
39+
await sleep(60);
3940

4041
expect(onRefresh).toHaveBeenCalledTimes(1);
4142

4243
controller.dispose();
4344
});
4445

45-
it("requestImmediate() bypasses rate-limit timer", () => {
46+
it("requestImmediate() bypasses rate-limit timer", async () => {
4647
const onRefresh = jest.fn<() => void>();
47-
const controller = new RefreshController({ onRefresh, debounceMs: 100 });
48+
const controller = new RefreshController({ onRefresh, debounceMs: 50 });
4849

4950
controller.schedule();
5051
expect(onRefresh).not.toHaveBeenCalled();
@@ -53,7 +54,7 @@ describe("RefreshController", () => {
5354
expect(onRefresh).toHaveBeenCalledTimes(1);
5455

5556
// Original timer should be cleared
56-
jest.advanceTimersByTime(100);
57+
await sleep(60);
5758
expect(onRefresh).toHaveBeenCalledTimes(1);
5859

5960
controller.dispose();
@@ -80,33 +81,45 @@ describe("RefreshController", () => {
8081
});
8182

8283
it("schedule() during in-flight queues refresh for after completion", async () => {
83-
let resolveRefresh: () => void;
84+
setSystemTime(0);
85+
86+
const resolvers: Array<() => void> = [];
8487
const onRefresh = jest.fn(
8588
() =>
8689
new Promise<void>((resolve) => {
87-
resolveRefresh = resolve;
90+
resolvers.push(resolve);
8891
})
8992
);
9093

91-
const controller = new RefreshController({ onRefresh, debounceMs: 100 });
94+
const controller = new RefreshController({ onRefresh, debounceMs: 50 });
9295

9396
// Start first refresh
9497
controller.requestImmediate();
9598
expect(onRefresh).toHaveBeenCalledTimes(1);
99+
expect(resolvers).toHaveLength(1);
96100

97101
// schedule() while in-flight should queue, not start timer
98102
controller.schedule();
99103

100104
// Complete the first refresh and let .finally() run
101-
resolveRefresh!();
105+
resolvers[0]();
102106
await Promise.resolve();
103107
await Promise.resolve(); // Extra tick for .finally()
104108

105-
// Should trigger follow-up refresh, but never more frequently than the minimum interval.
106-
// First tick runs the post-flight setTimeout(0), then we wait out the min interval.
107-
jest.advanceTimersByTime(0);
108-
jest.advanceTimersByTime(500);
109+
// Make the follow-up refresh eligible immediately (skip MIN_REFRESH_INTERVAL_MS wait)
110+
setSystemTime(1000);
111+
112+
// Allow post-flight setTimeout(0) to run
113+
await sleep(0);
114+
await sleep(10);
115+
109116
expect(onRefresh).toHaveBeenCalledTimes(2);
117+
expect(resolvers).toHaveLength(2);
118+
119+
// Resolve the follow-up refresh promise to avoid leaving it in-flight.
120+
resolvers[1]();
121+
await Promise.resolve();
122+
await Promise.resolve();
110123

111124
controller.dispose();
112125
});
@@ -131,43 +144,43 @@ describe("RefreshController", () => {
131144
controller.dispose();
132145
});
133146

134-
it("dispose() cleans up debounce timer", () => {
147+
it("dispose() cleans up debounce timer", async () => {
135148
const onRefresh = jest.fn<() => void>();
136-
const controller = new RefreshController({ onRefresh, debounceMs: 100 });
149+
const controller = new RefreshController({ onRefresh, debounceMs: 50 });
137150

138151
controller.schedule();
139152
controller.dispose();
140153

141-
jest.advanceTimersByTime(100);
154+
await sleep(60);
142155

143156
expect(onRefresh).not.toHaveBeenCalled();
144157
});
145158

146-
it("does not refresh after dispose", () => {
159+
it("does not refresh after dispose", async () => {
147160
const onRefresh = jest.fn<() => void>();
148-
const controller = new RefreshController({ onRefresh, debounceMs: 100 });
161+
const controller = new RefreshController({ onRefresh, debounceMs: 50 });
149162

150163
controller.dispose();
151164
controller.schedule();
152165
controller.requestImmediate();
153166

154-
jest.advanceTimersByTime(100);
167+
await sleep(60);
155168

156169
expect(onRefresh).not.toHaveBeenCalled();
157170
});
158171

159-
it("requestImmediate() bypasses isPaused check (for manual refresh)", () => {
172+
it("requestImmediate() bypasses isPaused check (for manual refresh)", async () => {
160173
const onRefresh = jest.fn<() => void>();
161174
const paused = true;
162175
const controller = new RefreshController({
163176
onRefresh,
164-
debounceMs: 100,
177+
debounceMs: 50,
165178
isPaused: () => paused,
166179
});
167180

168181
// schedule() should be blocked by isPaused
169182
controller.schedule();
170-
jest.advanceTimersByTime(100);
183+
await sleep(60);
171184
expect(onRefresh).not.toHaveBeenCalled();
172185

173186
// requestImmediate() should bypass isPaused (manual refresh)
@@ -177,18 +190,18 @@ describe("RefreshController", () => {
177190
controller.dispose();
178191
});
179192

180-
it("schedule() respects isPaused and flushes on notifyUnpaused", () => {
193+
it("schedule() respects isPaused and flushes on notifyUnpaused", async () => {
181194
const onRefresh = jest.fn<() => void>();
182195
let paused = true;
183196
const controller = new RefreshController({
184197
onRefresh,
185-
debounceMs: 100,
198+
debounceMs: 50,
186199
isPaused: () => paused,
187200
});
188201

189202
// schedule() should queue but not execute while paused
190203
controller.schedule();
191-
jest.advanceTimersByTime(100);
204+
await sleep(60);
192205
expect(onRefresh).not.toHaveBeenCalled();
193206

194207
// Unpausing should flush the pending refresh
@@ -199,9 +212,12 @@ describe("RefreshController", () => {
199212
controller.dispose();
200213
});
201214

202-
it("lastRefreshInfo tracks trigger and timestamp", () => {
215+
it("lastRefreshInfo tracks trigger and timestamp", async () => {
216+
// This test needs to avoid MIN_REFRESH_INTERVAL_MS; use setSystemTime() to simulate time passing.
217+
setSystemTime(0);
218+
203219
const onRefresh = jest.fn<() => void>();
204-
const controller = new RefreshController({ onRefresh, debounceMs: 100 });
220+
const controller = new RefreshController({ onRefresh, debounceMs: 20 });
205221

206222
expect(controller.lastRefreshInfo).toBeNull();
207223

@@ -213,37 +229,44 @@ describe("RefreshController", () => {
213229
expect(controller.lastRefreshInfo!.timestamp).toBeGreaterThanOrEqual(beforeManual);
214230

215231
// Scheduled refresh should record "scheduled" trigger
232+
setSystemTime(1000);
216233
controller.schedule();
217-
jest.advanceTimersByTime(500);
234+
await sleep(30);
218235
expect(controller.lastRefreshInfo!.trigger).toBe("scheduled");
219236

220237
// Priority refresh should record "priority" trigger
238+
setSystemTime(2000);
221239
controller.schedulePriority();
222-
jest.advanceTimersByTime(500);
240+
await sleep(30);
223241
expect(controller.lastRefreshInfo!.trigger).toBe("priority");
224242

225243
controller.dispose();
226244
});
227245

228-
it("onRefreshComplete callback is called with refresh info", () => {
246+
it("onRefreshComplete callback is called with refresh info", async () => {
247+
// This test needs to avoid MIN_REFRESH_INTERVAL_MS; use setSystemTime() to simulate time passing.
248+
setSystemTime(0);
249+
229250
const onRefresh = jest.fn<() => void>();
230251
const onRefreshComplete = jest.fn<(info: { trigger: string; timestamp: number }) => void>();
231252
const controller = new RefreshController({
232253
onRefresh,
233254
onRefreshComplete,
234-
debounceMs: 100,
255+
debounceMs: 20,
235256
});
236257

237258
expect(onRefreshComplete).not.toHaveBeenCalled();
238259

239260
controller.requestImmediate();
240261
expect(onRefreshComplete).toHaveBeenCalledTimes(1);
241262
expect(onRefreshComplete).toHaveBeenCalledWith(
263+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
242264
expect.objectContaining({ trigger: "manual", timestamp: expect.any(Number) })
243265
);
244266

267+
setSystemTime(1000);
245268
controller.schedule();
246-
jest.advanceTimersByTime(500);
269+
await sleep(30);
247270
expect(onRefreshComplete).toHaveBeenCalledTimes(2);
248271
expect(onRefreshComplete).toHaveBeenLastCalledWith(
249272
expect.objectContaining({ trigger: "scheduled" })

src/node/services/tools/task.test.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ describe("task tool", () => {
3131

3232
const result: unknown = await Promise.resolve(
3333
tool.execute!(
34-
{ subagent_type: "explore", prompt: "do it", run_in_background: true },
34+
{ subagent_type: "explore", prompt: "do it", title: "Child task", run_in_background: true },
3535
mockToolCallOptions
3636
)
3737
);
@@ -63,7 +63,12 @@ describe("task tool", () => {
6363

6464
const result: unknown = await Promise.resolve(
6565
tool.execute!(
66-
{ subagent_type: "explore", prompt: "do it", run_in_background: false },
66+
{
67+
subagent_type: "explore",
68+
prompt: "do it",
69+
title: "Child task",
70+
run_in_background: false,
71+
},
6772
mockToolCallOptions
6873
)
6974
);
@@ -95,7 +100,10 @@ describe("task tool", () => {
95100
let caught: unknown = null;
96101
try {
97102
await Promise.resolve(
98-
tool.execute!({ subagent_type: "explore", prompt: "do it" }, mockToolCallOptions)
103+
tool.execute!(
104+
{ subagent_type: "explore", prompt: "do it", title: "Child task" },
105+
mockToolCallOptions
106+
)
99107
);
100108
} catch (error: unknown) {
101109
caught = error;
@@ -131,7 +139,10 @@ describe("task tool", () => {
131139
let caught: unknown = null;
132140
try {
133141
await Promise.resolve(
134-
tool.execute!({ subagent_type: "exec", prompt: "do it" }, mockToolCallOptions)
142+
tool.execute!(
143+
{ subagent_type: "exec", prompt: "do it", title: "Child task" },
144+
mockToolCallOptions
145+
)
135146
);
136147
} catch (error: unknown) {
137148
caught = error;

0 commit comments

Comments
 (0)