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:
parent
4d13822bd5
commit
eaeefafe5f
185
.files/test_fixes.md
Normal file
185
.files/test_fixes.md
Normal 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 |
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
9
test-mock.ts
Normal 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', () => {}],
|
||||
}))
|
||||
Loading…
Reference in New Issue
Block a user