Skip to content

Commit eaeefaf

Browse files
committed
Refactor tests and improve mocking for better isolation
- Updated test files to use `afterAll` for restoring mocks, ensuring cleaner test environments. - Simplified platform mocking in `modifiers-napi` tests to avoid global state mutations. - Enhanced `useFrustrationDetection` tests with clearer structure and consistent timeout settings. - Consolidated React and Ink mocks into a dedicated `test-mock.ts` file for reuse across tests. - Improved readability and maintainability of tests by standardizing timeout configurations. - Adjusted imports and module mocks to prevent interference between test files.
1 parent 4d13822 commit eaeefaf

11 files changed

Lines changed: 337 additions & 224 deletions

File tree

.files/test_fixes.md

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
# Test Isolation Fixes
2+
3+
Two bugs. Two files touched.
4+
5+
---
6+
7+
## 1. `packages/modifiers-napi/src/__tests__/index.test.ts`
8+
9+
**Problem:** `mock.module('bun:ffi', ...)` is declared at module scope with no cleanup.
10+
Bun reuses workers across files in a full suite run, so the mock bleeds into other
11+
files that import anything touching `bun:ffi`. When `index.test.ts` runs _after_
12+
another file has already loaded `bun:ffi` into the module registry, the module-level
13+
mock declaration loses the race and the test gets the wrong module — or nothing at all.
14+
15+
**Fix:** Add `afterAll(() => mock.restore())`.
16+
17+
```typescript
18+
// packages/modifiers-napi/src/__tests__/index.test.ts
19+
20+
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test'
21+
22+
let ffiShouldThrow = false
23+
let nativeFlags = 0
24+
let dlopenCalls = 0
25+
26+
mock.module('bun:ffi', () => ({
27+
FFIType: {
28+
i32: 0,
29+
u64: 0,
30+
},
31+
dlopen: () => {
32+
dlopenCalls++
33+
if (ffiShouldThrow) {
34+
throw new Error('ffi load failed')
35+
}
36+
return {
37+
symbols: {
38+
CGEventSourceFlagsState: () => nativeFlags,
39+
},
40+
}
41+
},
42+
}))
43+
44+
// ✅ NEW: restore the bun:ffi mock after this file finishes
45+
// so it doesn't bleed into other files running in the same worker
46+
afterAll(() => {
47+
mock.restore()
48+
})
49+
50+
beforeEach(() => {
51+
ffiShouldThrow = false
52+
nativeFlags = 0
53+
dlopenCalls = 0
54+
})
55+
56+
// ... rest of file unchanged
57+
```
58+
59+
---
60+
61+
## 2. `src/utils/staticRender.tsx`
62+
63+
**Problem:** `await instance.waitUntilExit()` hangs indefinitely when Ink's reconciler
64+
commit phase doesn't complete. `waitUntilExit()` only resolves when `exit()` is called
65+
inside `useLayoutEffect` — but if `@anthropic/ink`'s `wrappedRender` has stale
66+
process-level state from a previous test file (singleton reconciler, leaked
67+
`process.on('exit')` handlers, a cached fiber root), React never commits, `useLayoutEffect`
68+
never fires, and the whole render silently hangs until the 10s test timeout kills it.
69+
70+
This only surfaces in the full suite because Bun shares workers across files sequentially.
71+
Running the file alone gives it a clean worker with no prior Ink state.
72+
73+
**Fix:** Race `waitUntilExit()` against a 3s safety timeout. On timeout, call
74+
`instance.unmount()` to force Ink cleanup and throw a descriptive error instead of
75+
a silent 10s hang.
76+
77+
Also: explicitly remove the stream `data` listener and destroy the stream after render.
78+
Without this, the PassThrough stream stays open and its listener accumulates garbage
79+
across calls in the same process lifetime.
80+
81+
```typescript
82+
// src/utils/staticRender.tsx
83+
84+
import * as React from 'react';
85+
import { useLayoutEffect } from 'react';
86+
import { PassThrough } from 'stream';
87+
import stripAnsi from 'strip-ansi';
88+
import { wrappedRender as render, useApp } from '@anthropic/ink';
89+
90+
function RenderOnceAndExit({ children }: { children: React.ReactNode }): React.ReactNode {
91+
const { exit } = useApp();
92+
93+
useLayoutEffect(() => {
94+
const timer = setTimeout(exit, 0);
95+
return () => clearTimeout(timer);
96+
}, [exit]);
97+
98+
return <>{children}</>;
99+
}
100+
101+
const SYNC_START = '\x1B[?2026h';
102+
const SYNC_END = '\x1B[?2026l';
103+
104+
function extractFirstFrame(output: string): string {
105+
const startIndex = output.indexOf(SYNC_START);
106+
if (startIndex === -1) return output;
107+
108+
const contentStart = startIndex + SYNC_START.length;
109+
const endIndex = output.indexOf(SYNC_END, contentStart);
110+
if (endIndex === -1) return output;
111+
112+
return output.slice(contentStart, endIndex);
113+
}
114+
115+
export async function renderToAnsiString(node: React.ReactNode, columns?: number): Promise<string> {
116+
let output = '';
117+
118+
const stream = new PassThrough();
119+
if (columns !== undefined) {
120+
(stream as unknown as { columns: number }).columns = columns;
121+
}
122+
123+
// ✅ NEW: named handler so we can remove it after render
124+
const dataHandler = (chunk: Buffer) => {
125+
output += chunk.toString();
126+
};
127+
stream.on('data', dataHandler);
128+
129+
const instance = await render(<RenderOnceAndExit>{node}</RenderOnceAndExit>, {
130+
stdout: stream as unknown as NodeJS.WriteStream,
131+
patchConsole: false,
132+
});
133+
134+
// ✅ NEW: race waitUntilExit against a 3s hard limit.
135+
// If Ink's reconciler is stuck (stale worker state from a prior test file),
136+
// this surfaces a real error instead of silently hanging for 10s.
137+
await Promise.race([
138+
instance.waitUntilExit(),
139+
new Promise<void>((_, reject) =>
140+
setTimeout(() => {
141+
instance.unmount();
142+
reject(
143+
new Error(
144+
'[staticRender] Ink render did not exit within 3s — wrappedRender may have stale process state from a prior test file',
145+
),
146+
);
147+
}, 3000),
148+
),
149+
]);
150+
151+
// ✅ NEW: clean up the stream so it doesn't accumulate across calls
152+
stream.off('data', dataHandler);
153+
stream.destroy();
154+
155+
return extractFirstFrame(output);
156+
}
157+
158+
export async function renderToString(node: React.ReactNode, columns?: number): Promise<string> {
159+
const output = await renderToAnsiString(node, columns);
160+
return stripAnsi(output);
161+
}
162+
```
163+
164+
---
165+
166+
## If AutofixProgress tests still hang after this
167+
168+
The 3s safety net will stop the silent timeout and give you a real error message.
169+
If you're still seeing the hang, the root is inside `wrappedRender` from `@anthropic/ink`.
170+
Look for any of these in that file:
171+
172+
- A module-level singleton (cached app instance, fiber root, reconciler)
173+
- `process.on('exit')` / `process.on('SIGTERM')` handlers added on each `render()` call but never removed
174+
- A global `stdout` patch applied once and assumed fresh on every call
175+
176+
Share that file and the exact line can be pinpointed.
177+
178+
---
179+
180+
## Summary
181+
182+
| File | Change | Why |
183+
| ------------------ | ------------------------------------------------------- | ------------------------------------------------ |
184+
| `index.test.ts` | Add `afterAll(() => mock.restore())` | Stops `bun:ffi` mock bleeding into other workers |
185+
| `staticRender.tsx` | Race `waitUntilExit()` with 3s timeout + stream cleanup | Surfaces real errors instead of silent 10s hangs |

packages/modifiers-napi/src/__tests__/index.test.ts

Lines changed: 12 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
1+
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test'
22

33
let ffiShouldThrow = false
44
let nativeFlags = 0
@@ -22,50 +22,32 @@ mock.module('bun:ffi', () => ({
2222
},
2323
}))
2424

25+
afterAll(() => {
26+
mock.restore()
27+
})
28+
2529
const originalPlatform = process.platform
2630

27-
async function loadModule() {
28-
return import(`../index.ts?case=${Math.random()}`)
29-
}
31+
import * as mod from '../index.js'
3032

3133
beforeEach(() => {
3234
ffiShouldThrow = false
3335
nativeFlags = 0
3436
dlopenCalls = 0
35-
Object.defineProperty(process, 'platform', {
36-
value: originalPlatform,
37-
configurable: true,
38-
})
39-
})
40-
41-
afterEach(() => {
42-
Object.defineProperty(process, 'platform', {
43-
value: originalPlatform,
44-
configurable: true,
45-
})
37+
mod.__resetForTest()
4638
})
4739

4840
describe('modifiers-napi', () => {
4941
test('returns false for non-darwin platforms', async () => {
50-
Object.defineProperty(process, 'platform', {
51-
value: 'win32',
52-
configurable: true,
53-
})
54-
const mod = await loadModule()
55-
42+
mod.__setPlatformForTest('win32')
5643
await mod.prewarm()
5744
expect(dlopenCalls).toBe(0)
5845
expect(mod.isModifierPressed('shift')).toBe(false)
5946
expect(mod.isModifierPressed('command')).toBe(false)
6047
})
6148

6249
test('prewarm is idempotent on darwin', async () => {
63-
Object.defineProperty(process, 'platform', {
64-
value: 'darwin',
65-
configurable: true,
66-
})
67-
const mod = await loadModule()
68-
50+
mod.__setPlatformForTest('darwin')
6951
await mod.prewarm()
7052
const callsAfterFirst = dlopenCalls
7153

@@ -75,37 +57,22 @@ describe('modifiers-napi', () => {
7557
})
7658

7759
test('returns false when ffi loading fails on darwin', async () => {
78-
Object.defineProperty(process, 'platform', {
79-
value: 'darwin',
80-
configurable: true,
81-
})
60+
mod.__setPlatformForTest('darwin')
8261
ffiShouldThrow = true
83-
const mod = await loadModule()
84-
8562
await mod.prewarm()
8663
expect(mod.isModifierPressed('shift')).toBe(false)
8764
})
8865

8966
test('returns false for unknown modifier names on darwin', async () => {
90-
Object.defineProperty(process, 'platform', {
91-
value: 'darwin',
92-
configurable: true,
93-
})
67+
mod.__setPlatformForTest('darwin')
9468
nativeFlags = 0x20000
95-
const mod = await loadModule()
96-
9769
await mod.prewarm()
9870
expect(mod.isModifierPressed('unknown')).toBe(false)
9971
})
10072

10173
test('uses native flag bits for known modifiers on darwin', async () => {
102-
Object.defineProperty(process, 'platform', {
103-
value: 'darwin',
104-
configurable: true,
105-
})
74+
mod.__setPlatformForTest('darwin')
10675
nativeFlags = 0x20000 | 0x40000
107-
const mod = await loadModule()
108-
10976
await mod.prewarm()
11077
expect(mod.isModifierPressed('shift')).toBe(true)
11178
expect(mod.isModifierPressed('control')).toBe(true)

packages/modifiers-napi/src/index.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,11 @@ const kCGEventSourceStateCombinedSessionState = 0
1616
let cgEventSourceFlagsState: ((stateID: number) => number) | null = null
1717
let ffiLoadAttempted = false
1818

19+
// Allows overriding platform for testing without mutating global process.platform
20+
let _platform = process.platform
21+
1922
async function loadFFI(): Promise<void> {
20-
if (ffiLoadAttempted || process.platform !== 'darwin') {
23+
if (ffiLoadAttempted || _platform !== 'darwin') {
2124
return
2225
}
2326
ffiLoadAttempted = true
@@ -46,7 +49,7 @@ export async function prewarm(): Promise<void> {
4649
}
4750

4851
export function isModifierPressed(modifier: string): boolean {
49-
if (process.platform !== 'darwin') {
52+
if (_platform !== 'darwin') {
5053
return false
5154
}
5255

@@ -64,3 +67,13 @@ export function isModifierPressed(modifier: string): boolean {
6467
)
6568
return (currentFlags & flag) !== 0
6669
}
70+
71+
export function __resetForTest(): void {
72+
ffiLoadAttempted = false
73+
cgEventSourceFlagsState = null
74+
_platform = process.platform
75+
}
76+
77+
export function __setPlatformForTest(p: NodeJS.Platform): void {
78+
_platform = p
79+
}

0 commit comments

Comments
 (0)