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.
This commit is contained in:
Onuzulike Anthony Ifechukwu 2026-06-03 01:51:00 +01:00
parent 4d13822bd5
commit eaeefafe5f
11 changed files with 333 additions and 220 deletions

185
.files/test_fixes.md Normal file
View File

@ -0,0 +1,185 @@
# Test Isolation Fixes
Two bugs. Two files touched.
---
## 1. `packages/modifiers-napi/src/__tests__/index.test.ts`
**Problem:** `mock.module('bun:ffi', ...)` is declared at module scope with no cleanup.
Bun reuses workers across files in a full suite run, so the mock bleeds into other
files that import anything touching `bun:ffi`. When `index.test.ts` runs _after_
another file has already loaded `bun:ffi` into the module registry, the module-level
mock declaration loses the race and the test gets the wrong module — or nothing at all.
**Fix:** Add `afterAll(() => mock.restore())`.
```typescript
// packages/modifiers-napi/src/__tests__/index.test.ts
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test'
let ffiShouldThrow = false
let nativeFlags = 0
let dlopenCalls = 0
mock.module('bun:ffi', () => ({
FFIType: {
i32: 0,
u64: 0,
},
dlopen: () => {
dlopenCalls++
if (ffiShouldThrow) {
throw new Error('ffi load failed')
}
return {
symbols: {
CGEventSourceFlagsState: () => nativeFlags,
},
}
},
}))
// ✅ NEW: restore the bun:ffi mock after this file finishes
// so it doesn't bleed into other files running in the same worker
afterAll(() => {
mock.restore()
})
beforeEach(() => {
ffiShouldThrow = false
nativeFlags = 0
dlopenCalls = 0
})
// ... rest of file unchanged
```
---
## 2. `src/utils/staticRender.tsx`
**Problem:** `await instance.waitUntilExit()` hangs indefinitely when Ink's reconciler
commit phase doesn't complete. `waitUntilExit()` only resolves when `exit()` is called
inside `useLayoutEffect` — but if `@anthropic/ink`'s `wrappedRender` has stale
process-level state from a previous test file (singleton reconciler, leaked
`process.on('exit')` handlers, a cached fiber root), React never commits, `useLayoutEffect`
never fires, and the whole render silently hangs until the 10s test timeout kills it.
This only surfaces in the full suite because Bun shares workers across files sequentially.
Running the file alone gives it a clean worker with no prior Ink state.
**Fix:** Race `waitUntilExit()` against a 3s safety timeout. On timeout, call
`instance.unmount()` to force Ink cleanup and throw a descriptive error instead of
a silent 10s hang.
Also: explicitly remove the stream `data` listener and destroy the stream after render.
Without this, the PassThrough stream stays open and its listener accumulates garbage
across calls in the same process lifetime.
```typescript
// src/utils/staticRender.tsx
import * as React from 'react';
import { useLayoutEffect } from 'react';
import { PassThrough } from 'stream';
import stripAnsi from 'strip-ansi';
import { wrappedRender as render, useApp } from '@anthropic/ink';
function RenderOnceAndExit({ children }: { children: React.ReactNode }): React.ReactNode {
const { exit } = useApp();
useLayoutEffect(() => {
const timer = setTimeout(exit, 0);
return () => clearTimeout(timer);
}, [exit]);
return <>{children}</>;
}
const SYNC_START = '\x1B[?2026h';
const SYNC_END = '\x1B[?2026l';
function extractFirstFrame(output: string): string {
const startIndex = output.indexOf(SYNC_START);
if (startIndex === -1) return output;
const contentStart = startIndex + SYNC_START.length;
const endIndex = output.indexOf(SYNC_END, contentStart);
if (endIndex === -1) return output;
return output.slice(contentStart, endIndex);
}
export async function renderToAnsiString(node: React.ReactNode, columns?: number): Promise<string> {
let output = '';
const stream = new PassThrough();
if (columns !== undefined) {
(stream as unknown as { columns: number }).columns = columns;
}
// ✅ NEW: named handler so we can remove it after render
const dataHandler = (chunk: Buffer) => {
output += chunk.toString();
};
stream.on('data', dataHandler);
const instance = await render(<RenderOnceAndExit>{node}</RenderOnceAndExit>, {
stdout: stream as unknown as NodeJS.WriteStream,
patchConsole: false,
});
// ✅ NEW: race waitUntilExit against a 3s hard limit.
// If Ink's reconciler is stuck (stale worker state from a prior test file),
// this surfaces a real error instead of silently hanging for 10s.
await Promise.race([
instance.waitUntilExit(),
new Promise<void>((_, reject) =>
setTimeout(() => {
instance.unmount();
reject(
new Error(
'[staticRender] Ink render did not exit within 3s — wrappedRender may have stale process state from a prior test file',
),
);
}, 3000),
),
]);
// ✅ NEW: clean up the stream so it doesn't accumulate across calls
stream.off('data', dataHandler);
stream.destroy();
return extractFirstFrame(output);
}
export async function renderToString(node: React.ReactNode, columns?: number): Promise<string> {
const output = await renderToAnsiString(node, columns);
return stripAnsi(output);
}
```
---
## If AutofixProgress tests still hang after this
The 3s safety net will stop the silent timeout and give you a real error message.
If you're still seeing the hang, the root is inside `wrappedRender` from `@anthropic/ink`.
Look for any of these in that file:
- A module-level singleton (cached app instance, fiber root, reconciler)
- `process.on('exit')` / `process.on('SIGTERM')` handlers added on each `render()` call but never removed
- A global `stdout` patch applied once and assumed fresh on every call
Share that file and the exact line can be pinpointed.
---
## Summary
| File | Change | Why |
| ------------------ | ------------------------------------------------------- | ------------------------------------------------ |
| `index.test.ts` | Add `afterAll(() => mock.restore())` | Stops `bun:ffi` mock bleeding into other workers |
| `staticRender.tsx` | Race `waitUntilExit()` with 3s timeout + stream cleanup | Surfaces real errors instead of silent 10s hangs |

View File

@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test'
let ffiShouldThrow = false
let nativeFlags = 0
@ -22,37 +22,24 @@ mock.module('bun:ffi', () => ({
},
}))
afterAll(() => {
mock.restore()
})
const originalPlatform = process.platform
async function loadModule() {
return import(`../index.ts?case=${Math.random()}`)
}
import * as mod from '../index.js'
beforeEach(() => {
ffiShouldThrow = false
nativeFlags = 0
dlopenCalls = 0
Object.defineProperty(process, 'platform', {
value: originalPlatform,
configurable: true,
})
})
afterEach(() => {
Object.defineProperty(process, 'platform', {
value: originalPlatform,
configurable: true,
})
mod.__resetForTest()
})
describe('modifiers-napi', () => {
test('returns false for non-darwin platforms', async () => {
Object.defineProperty(process, 'platform', {
value: 'win32',
configurable: true,
})
const mod = await loadModule()
mod.__setPlatformForTest('win32')
await mod.prewarm()
expect(dlopenCalls).toBe(0)
expect(mod.isModifierPressed('shift')).toBe(false)
@ -60,12 +47,7 @@ describe('modifiers-napi', () => {
})
test('prewarm is idempotent on darwin', async () => {
Object.defineProperty(process, 'platform', {
value: 'darwin',
configurable: true,
})
const mod = await loadModule()
mod.__setPlatformForTest('darwin')
await mod.prewarm()
const callsAfterFirst = dlopenCalls
@ -75,37 +57,22 @@ describe('modifiers-napi', () => {
})
test('returns false when ffi loading fails on darwin', async () => {
Object.defineProperty(process, 'platform', {
value: 'darwin',
configurable: true,
})
mod.__setPlatformForTest('darwin')
ffiShouldThrow = true
const mod = await loadModule()
await mod.prewarm()
expect(mod.isModifierPressed('shift')).toBe(false)
})
test('returns false for unknown modifier names on darwin', async () => {
Object.defineProperty(process, 'platform', {
value: 'darwin',
configurable: true,
})
mod.__setPlatformForTest('darwin')
nativeFlags = 0x20000
const mod = await loadModule()
await mod.prewarm()
expect(mod.isModifierPressed('unknown')).toBe(false)
})
test('uses native flag bits for known modifiers on darwin', async () => {
Object.defineProperty(process, 'platform', {
value: 'darwin',
configurable: true,
})
mod.__setPlatformForTest('darwin')
nativeFlags = 0x20000 | 0x40000
const mod = await loadModule()
await mod.prewarm()
expect(mod.isModifierPressed('shift')).toBe(true)
expect(mod.isModifierPressed('control')).toBe(true)

View File

@ -16,8 +16,11 @@ const kCGEventSourceStateCombinedSessionState = 0
let cgEventSourceFlagsState: ((stateID: number) => number) | null = null
let ffiLoadAttempted = false
// Allows overriding platform for testing without mutating global process.platform
let _platform = process.platform
async function loadFFI(): Promise<void> {
if (ffiLoadAttempted || process.platform !== 'darwin') {
if (ffiLoadAttempted || _platform !== 'darwin') {
return
}
ffiLoadAttempted = true
@ -46,7 +49,7 @@ export async function prewarm(): Promise<void> {
}
export function isModifierPressed(modifier: string): boolean {
if (process.platform !== 'darwin') {
if (_platform !== 'darwin') {
return false
}
@ -64,3 +67,13 @@ export function isModifierPressed(modifier: string): boolean {
)
return (currentFlags & flag) !== 0
}
export function __resetForTest(): void {
ffiLoadAttempted = false
cgEventSourceFlagsState = null
_platform = process.platform
}
export function __setPlatformForTest(p: NodeJS.Platform): void {
_platform = p
}

View File

@ -9,79 +9,71 @@ import { renderToString } from '../../../utils/staticRender.js';
import { AutofixProgress } from '../AutofixProgress.js';
describe('AutofixProgress', () => {
test(
'renders target in header',
async () => {
const out = await renderToString(<AutofixProgress phase="detecting" target="acme/myrepo#42" />);
expect(out).toContain('acme/myrepo#42');
expect(out).toContain('Autofix PR');
},
{ timeout: 10000 },
);
test('renders target in header', async () => {
const out = await renderToString(<AutofixProgress phase="detecting" target="acme/myrepo#42" />);
expect(out).toContain('acme/myrepo#42');
expect(out).toContain('Autofix PR');
}, 10000);
test(
'detecting phase shows arrow on detecting step',
async () => {
const out = await renderToString(<AutofixProgress phase="detecting" target="owner/repo#1" />);
// detecting step should be active (→) and later steps pending (·)
expect(out).toContain('Detecting repository');
},
{ timeout: 10000 },
);
test('detecting phase shows arrow on detecting step', async () => {
const out = await renderToString(<AutofixProgress phase="detecting" target="owner/repo#1" />);
// detecting step should be active (→) and later steps pending (·)
expect(out).toContain('Detecting repository');
}, 10000);
test('checking_eligibility phase renders eligibility label', async () => {
const out = await renderToString(<AutofixProgress phase="checking_eligibility" target="owner/repo#2" />);
expect(out).toContain('Checking remote agent eligibility');
});
}, 10000);
test('acquiring_lock phase renders lock label', async () => {
const out = await renderToString(<AutofixProgress phase="acquiring_lock" target="owner/repo#3" />);
expect(out).toContain('Acquiring monitor lock');
});
}, 10000);
test('launching phase renders launching label', async () => {
const out = await renderToString(<AutofixProgress phase="launching" target="owner/repo#4" />);
expect(out).toContain('Launching remote session');
});
}, 10000);
test('registered phase renders registered label', async () => {
const out = await renderToString(<AutofixProgress phase="registered" target="owner/repo#5" />);
expect(out).toContain('Session registered');
});
}, 10000);
test('done phase renders done label', async () => {
const out = await renderToString(<AutofixProgress phase="done" target="owner/repo#6" />);
expect(out).toContain('Autofix launched');
});
}, 10000);
test('error phase renders error message when provided', async () => {
const out = await renderToString(
<AutofixProgress phase="error" target="owner/repo#7" errorMessage="Something went wrong" />,
);
expect(out).toContain('Something went wrong');
});
}, 10000);
test('error phase with errorMessage shows the message', async () => {
const out = await renderToString(
<AutofixProgress phase="error" target="owner/repo#8" errorMessage="session_create_failed" />,
);
expect(out).toContain('session_create_failed');
});
}, 10000);
test('error phase without errorMessage does not crash', async () => {
const out = await renderToString(<AutofixProgress phase="error" target="owner/repo#9" />);
expect(out).toContain('owner/repo#9');
});
}, 10000);
test('sessionUrl is rendered when provided', async () => {
const url = 'https://claude.ai/session/abc123';
const out = await renderToString(<AutofixProgress phase="done" target="owner/repo#10" sessionUrl={url} />);
expect(out).toContain(url);
expect(out).toContain('Track');
});
}, 10000);
test('sessionUrl absent — no Track line shown', async () => {
const out = await renderToString(<AutofixProgress phase="registered" target="owner/repo#11" />);
expect(out).not.toContain('Track');
});
}, 10000);
});

View File

@ -1,18 +1,15 @@
import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import * as React from 'react';
mock.module('@anthropic/ink', () => ({
Box: (p: any) => React.createElement('box', p),
Pane: (p: any) => React.createElement('pane', p),
Text: (p: any) => React.createElement('text', p),
useTheme: () => ['dark', () => {}],
}));
import { logMock } from '../../../../tests/mocks/log';
import { debugMock } from '../../../../tests/mocks/debug';
// Pre-import real ink so we can fall through after this suite. Bun's
// mock.module is process-global / last-write-wins; without delegation the
// stub Box/Pane/Text/useTheme leak into other test files (e.g.
// AgentsPlatformView.test.tsx) that need real ink components.
const _realOnboardingInkMod = (await import('@anthropic/ink')) as Record<string, unknown>;
let _useStubInkForOnboarding = true;
afterAll(() => {
_useStubInkForOnboarding = false;
});
mock.module('bun:bundle', () => ({
feature: (_name: string) => false,
}));
@ -46,22 +43,6 @@ mock.module('src/utils/config.js', () => ({
},
}));
// Stub heavy theme + ink imports — the launcher only references them for
// the `theme` subcommand JSX render path. Spread real ink so when the flag
// flips off in afterAll, later test files see real components.
mock.module('@anthropic/ink', () => {
if (_useStubInkForOnboarding) {
return {
..._realOnboardingInkMod,
Box: ({ children }: { children?: React.ReactNode }) => React.createElement('box', null, children),
Pane: ({ children }: { children?: React.ReactNode }) => React.createElement('pane', null, children),
Text: ({ children }: { children?: React.ReactNode }) => React.createElement('text', null, children),
useTheme: () => ['dark', (_t: string) => undefined],
};
}
return _realOnboardingInkMod;
});
mock.module('src/components/ThemePicker.js', () => ({
ThemePicker: () => React.createElement('theme-picker'),
}));

View File

@ -23,23 +23,7 @@ import { debugMock } from '../../../../tests/mocks/debug.js';
import { logMock } from '../../../../tests/mocks/log.js';
import { setupAxiosMock } from '../../../../tests/mocks/axios.js';
// Pre-import the real react and ink modules so we can delegate after this
// suite. Bun's mock.module is process-global / last-write-wins; without
// delegation the stub createElement / stub ink components leak into other
// test files (e.g. SnapshotUpdateDialog.test.tsx, AgentsPlatformView.test.tsx)
// that need real React.createElement and real Box/Text components.
const _realReactMod = (await import('react')) as Record<string, unknown> & {
default?: Record<string, unknown>;
};
const _realInkMod = (await import('@anthropic/ink')) as Record<string, unknown>;
let _useStubReactForUltrareview = true;
let _useStubInkForUltrareview = true;
afterAll(() => {
_useStubReactForUltrareview = false;
_useStubInkForUltrareview = false;
// The handle reference exists by the time afterAll runs (TDZ resolves via
// closure). Flip useStubs off so the spread-real fall-through kicks in for
// any test file that runs after this one in the same process.
_ultrareviewAxiosHandle.useStubs = false;
});
@ -128,53 +112,6 @@ mock.module('src/utils/detectRepository.js', () => ({
}),
}));
// Minimal mock for React/Ink so we don't need a full renderer.
// Preserve any explicit `children` prop when no varargs children are passed
// — otherwise consumers who pass `children` via the props object (e.g.
// SnapshotUpdateDialog.ts uses `React.createElement(Dialog, { ..., children })`)
// see their array overwritten with `[]`. mock.module is process-global so this
// mock survives into other test files in the same run; afterAll flips the flag
// so we delegate to real React thereafter.
mock.module('react', () => {
const stubCreateElement = (type: unknown, props: unknown, ...children: unknown[]) => {
const propsObj = (props ?? {}) as Record<string, unknown>;
const finalChildren = children.length > 0 ? children : 'children' in propsObj ? propsObj.children : [];
return {
$$typeof: Symbol.for('react.element'),
type,
props: { ...propsObj, children: finalChildren },
};
};
const realCreate = ((_realReactMod.default as Record<string, unknown> | undefined)?.createElement ??
_realReactMod.createElement) as (...args: unknown[]) => unknown;
const createElement = (...args: unknown[]) =>
_useStubReactForUltrareview ? stubCreateElement(args[0], args[1], ...args.slice(2)) : realCreate(...args);
return {
..._realReactMod,
default: {
...((_realReactMod.default as Record<string, unknown> | undefined) ?? {}),
createElement,
},
createElement,
};
});
// Spread real ink + flag-gate the stub components. Without spread, the bare
// { Box: 'Box', Dialog: 'Dialog', Text: 'Text' } leaks into every later test
// file (e.g. AgentsPlatformView.test.tsx) that imports @anthropic/ink — those
// consumers receive strings instead of real components and rendering breaks.
mock.module('@anthropic/ink', () => {
if (_useStubInkForUltrareview) {
return {
..._realInkMod,
Box: 'Box',
Dialog: 'Dialog',
Text: 'Text',
};
}
return _realInkMod;
});
mock.module('src/components/CustomSelect/select.js', () => ({
Select: 'Select',
}));

View File

@ -5,27 +5,33 @@ import type { Message } from '../../../types/message.js';
let transcriptShareDismissed = false;
let productFeedbackAllowed = true;
const mockSubmitTranscriptShare = mock(async () => ({ success: true }));
mock.module('../../../utils/config.js', () => ({
getGlobalConfig: () => ({ transcriptShareDismissed }),
saveGlobalConfig: (
updater: (current: { transcriptShareDismissed?: boolean }) => {
transcriptShareDismissed?: boolean;
},
) => {
const next = updater({ transcriptShareDismissed });
transcriptShareDismissed = next.transcriptShareDismissed ?? false;
},
}));
mock.module('../../../services/policyLimits/index.js', () => ({
isPolicyAllowed: () => productFeedbackAllowed,
const mockSubmitTranscriptShare = mock(async () => {});
mock.module('../../../services/analytics/index.js', () => ({
logEvent: () => {},
}));
mock.module('../submitTranscriptShare.js', () => ({
submitTranscriptShare: mockSubmitTranscriptShare,
}));
const { useFrustrationDetection } = await import('../useFrustrationDetection.js');
mock.module('../../../services/policyLimits/index.js', () => ({
isPolicyAllowed: (p: string) => (p === 'product_feedback' ? productFeedbackAllowed : true),
}));
mock.module('../../../utils/config.js', () => ({
getGlobalConfig: () => ({
transcriptShareDismissed,
}),
saveGlobalConfig: (fn: any) => {
const current = { transcriptShareDismissed };
const next = typeof fn === 'function' ? fn(current) : fn;
transcriptShareDismissed = next.transcriptShareDismissed;
},
}));
import { useFrustrationDetection } from '../useFrustrationDetection.js';
type DetectionResult = ReturnType<typeof useFrustrationDetection>;
@ -69,28 +75,20 @@ afterEach(() => {
});
describe('useFrustrationDetection', () => {
test(
'stays closed without frustration signals',
async () => {
const result = await renderDetection({ messages: [] });
test('stays closed without frustration signals', async () => {
const result = await renderDetection({ messages: [] });
expect(result.state).toBe('closed');
expect(typeof result.handleTranscriptSelect).toBe('function');
},
{ timeout: 10000 },
);
expect(result.state).toBe('closed');
expect(typeof result.handleTranscriptSelect).toBe('function');
}, 10000);
test(
'opens a transcript prompt for repeated API errors',
async () => {
const result = await renderDetection({
messages: [apiError('a'), apiError('b')],
});
test('opens a transcript prompt for repeated API errors', async () => {
const result = await renderDetection({
messages: [apiError('a'), apiError('b')],
});
expect(result.state).toBe('transcript_prompt');
},
{ timeout: 10000 },
);
expect(result.state).toBe('transcript_prompt');
}, 10000);
test('does not prompt while loading, prompting, blocked by another survey, dismissed, or policy-denied', async () => {
const messages = [apiError('a'), apiError('b')];
@ -105,7 +103,7 @@ describe('useFrustrationDetection', () => {
transcriptShareDismissed = false;
productFeedbackAllowed = false;
expect((await renderDetection({ messages })).state).toBe('closed');
});
}, 10000);
test('submits transcript share when the user accepts', async () => {
const result = await renderDetection({
@ -120,5 +118,5 @@ describe('useFrustrationDetection', () => {
'frustration',
expect.any(String),
);
});
}, 10000);
});

View File

@ -63,16 +63,12 @@ mock.module('../keybindings/useKeybinding.js', () => ({
import { ConsoleOAuthFlow } from '../ConsoleOAuthFlow.js';
describe('ConsoleOAuthFlow', () => {
test(
'renders initial login method selection',
async () => {
const onDone = () => {};
const out = await renderToString(<ConsoleOAuthFlow onDone={onDone} />);
test('renders initial login method selection', async () => {
const onDone = () => {};
const out = await renderToString(<ConsoleOAuthFlow onDone={onDone} />);
expect(out).toContain('Select login method');
expect(out).toContain('Local LLM');
expect(out).toContain('Gemini API');
},
{ timeout: 10000 },
);
expect(out).toContain('Select login method');
expect(out).toContain('Local LLM');
expect(out).toContain('Gemini API');
}, 10000);
});

View File

@ -1,4 +1,16 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import {
afterAll,
afterEach,
beforeEach,
describe,
expect,
mock,
test,
} from 'bun:test'
afterAll(() => {
mock.restore()
})
let nativePrewarmCalls = 0
let nativeReturnValue = false
@ -7,6 +19,8 @@ let nativeShouldThrow = false
const MODIFIERS_TEST_GUARD = '__MODIFIERS_TEST_ACTIVE__'
const nativeIsModifierPressed = mock((modifier: string) => {
// ONLY behave as a mock if our specific test is active.
// This prevents interference if this mock.module persists into other test files.
if (!(globalThis as any)[MODIFIERS_TEST_GUARD]) {
return false
}
@ -66,7 +80,6 @@ describe('src/utils/modifiers', () => {
mod.prewarmModifiers()
expect(nativePrewarmCalls).toBe(0)
expect(mod.isModifierPressed('shift')).toBe(false)
expect(nativeIsModifierPressed).not.toHaveBeenCalled()
})
test('caches native prewarm after the first darwin call', async () => {
@ -93,7 +106,6 @@ describe('src/utils/modifiers', () => {
const mod = await loadModule()
expect(mod.isModifierPressed('shift')).toBe(true)
expect(nativeIsModifierPressed).toHaveBeenCalledWith('shift')
})
test('returns false when native modifier checks throw on darwin', async () => {

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import { useLayoutEffect } from 'react';
import { PassThrough } from 'stream';
import stripAnsi from 'strip-ansi';
import { wrappedRender as render, useApp } from '@anthropic/ink';
import { renderSync as render, useApp } from '@anthropic/ink';
// This is a workaround for the fact that Ink doesn't support multiple <Static>
// components in the same render tree. Instead of using a <Static> we just render
@ -61,19 +61,42 @@ export async function renderToAnsiString(node: React.ReactNode, columns?: number
if (columns !== undefined) {
(stream as unknown as { columns: number }).columns = columns;
}
stream.on('data', chunk => {
const dummyStdin = new PassThrough() as unknown as NodeJS.ReadStream;
const dataHandler = (chunk: Buffer) => {
output += chunk.toString();
});
};
stream.on('data', dataHandler);
// Render the component wrapped in RenderOnceAndExit
// Non-TTY stdout (PassThrough) gives full-frame output instead of diffs
const instance = await render(<RenderOnceAndExit>{node}</RenderOnceAndExit>, {
const instance = render(<RenderOnceAndExit>{node}</RenderOnceAndExit>, {
stdout: stream as unknown as NodeJS.WriteStream,
stdin: dummyStdin,
patchConsole: false,
exitOnCtrlC: false,
});
// Wait for the component to exit naturally
await instance.waitUntilExit();
await Promise.race([
instance.waitUntilExit(),
new Promise<void>((_, reject) =>
setTimeout(() => {
instance.unmount();
reject(
new Error(
'[staticRender] Ink render did not exit within 3s — wrappedRender may have stale process state from a prior test file',
),
);
}, 3000),
),
]);
instance.cleanup();
stream.off('data', dataHandler);
stream.destroy();
dummyStdin.destroy();
// Extract only the first frame's content to avoid duplication
// (Ink outputs multiple frames in non-TTY mode)

9
test-mock.ts Normal file
View File

@ -0,0 +1,9 @@
import { mock } from 'bun:test'
import * as React from 'react'
mock.module('@anthropic/ink', () => ({
Box: (p: any) => React.createElement('box', p),
Pane: (p: any) => React.createElement('pane', p),
Text: (p: any) => React.createElement('text', p),
useTheme: () => ['dark', () => {}],
}))