test(cli): refactor tests for async render utilities (#23252)

This commit is contained in:
Tommaso Sciortino
2026-03-20 20:08:29 +00:00
committed by GitHub
parent 86a3a913b5
commit 6c78eb7a39
198 changed files with 3592 additions and 4802 deletions

View File

@@ -6,6 +6,7 @@
import { describe, it, expect, vi } from 'vitest';
import { act, useState } from 'react';
import type { FzfResultItem } from 'fzf';
import { renderHook } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { useSlashCompletion } from './useSlashCompletion.js';
@@ -38,8 +39,26 @@ const getConstructorCallCount = () => asyncFzfConstructorCalls;
// Note: This is a simplified reimplementation that may diverge from real fzf behavior.
// Integration tests in useSlashCompletion.integration.test.ts use the real fzf library
// to catch any behavioral differences and serve as our "canary in a coal mine."
let deferredMatch: { resolve: (val?: unknown) => void } | null = null;
export const resolveMatch = async () => {
// Wait up to 1s for deferredMatch to be set by the hook
const start = Date.now();
while (!deferredMatch && Date.now() - start < 1000) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
if (deferredMatch) {
await act(async () => {
deferredMatch?.resolve(null);
});
deferredMatch = null;
}
};
function simulateFuzzyMatching(items: readonly string[], query: string) {
const results = [];
const results: Array<FzfResultItem<string>> = [];
if (query) {
const lowerQuery = query.toLowerCase();
for (const item of items) {
@@ -98,7 +117,13 @@ function simulateFuzzyMatching(items: readonly string[], query: string) {
// Sort by score descending (better matches first)
results.sort((a, b) => b.score - a.score);
return Promise.resolve(results);
return new Promise((resolve) => {
deferredMatch = {
resolve: () => {
resolve(results);
},
};
});
}
// Mock the fzf module to provide a working fuzzy search implementation for tests
@@ -199,38 +224,25 @@ describe('useSlashCompletion', () => {
}),
createTestCommand({ name: 'chat', description: 'Manage chat history' }),
];
let result: {
current: ReturnType<typeof useTestHarnessForSlashCompletion>;
};
let unmount: () => void;
await act(async () => {
const hook = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/',
slashCommands,
mockCommandContext,
),
);
result = hook.result;
unmount = hook.unmount;
});
await act(async () => {
await waitFor(() => {
expect(result.current.suggestions.length).toBe(slashCommands.length);
expect(result.current.suggestions.map((s) => s.label)).toEqual(
expect.arrayContaining([
'help',
'clear',
'memory',
'chat',
'stats',
]),
);
});
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/',
slashCommands,
mockCommandContext,
),
);
await resolveMatch();
await waitFor(() => {
expect(result.current.suggestions.length).toBe(slashCommands.length);
expect(result.current.suggestions.map((s) => s.label)).toEqual(
expect.arrayContaining(['help', 'clear', 'memory', 'chat', 'stats']),
);
});
unmount!();
unmount();
});
it('should filter commands based on partial input', async () => {
@@ -241,44 +253,33 @@ describe('useSlashCompletion', () => {
const setIsLoadingSuggestions = vi.fn();
const setIsPerfectMatch = vi.fn();
let result: {
current: { completionStart: number; completionEnd: number };
};
let unmount: () => void;
await act(async () => {
const hook = renderHook(() =>
useSlashCompletion({
enabled: true,
query: '/mem',
slashCommands,
commandContext: mockCommandContext,
setSuggestions,
setIsLoadingSuggestions,
setIsPerfectMatch,
}),
);
result = hook.result;
unmount = hook.unmount;
});
const { result, unmount } = await renderHook(() =>
useSlashCompletion({
enabled: true,
query: '/mem',
slashCommands,
commandContext: mockCommandContext,
setSuggestions,
setIsLoadingSuggestions,
setIsPerfectMatch,
}),
);
await act(async () => {
await waitFor(() => {
expect(setSuggestions).toHaveBeenCalledWith([
{
label: 'memory',
value: 'memory',
description: 'Manage memory',
commandKind: CommandKind.BUILT_IN,
},
]);
expect(result.current.completionStart).toBe(1);
expect(result.current.completionEnd).toBe(4);
});
await resolveMatch();
await waitFor(() => {
expect(setSuggestions).toHaveBeenCalledWith([
{
label: 'memory',
value: 'memory',
description: 'Manage memory',
commandKind: CommandKind.BUILT_IN,
},
]);
expect(result.current.completionStart).toBe(1);
expect(result.current.completionEnd).toBe(4);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
});
unmount!();
unmount();
});
it('should suggest commands based on partial altNames', async () => {
@@ -290,22 +291,17 @@ describe('useSlashCompletion', () => {
'check session stats. Usage: /stats [session|model|tools]',
}),
];
let result: {
current: ReturnType<typeof useTestHarnessForSlashCompletion>;
};
let unmount: () => void;
await act(async () => {
const hook = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/usage',
slashCommands,
mockCommandContext,
),
);
result = hook.result;
unmount = hook.unmount;
});
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/usage',
slashCommands,
mockCommandContext,
),
);
await resolveMatch();
await waitFor(() => {
expect(result.current.suggestions).toEqual([
@@ -319,7 +315,7 @@ describe('useSlashCompletion', () => {
]);
expect(result.current.completionStart).toBe(1);
});
unmount!();
unmount();
});
it('should provide suggestions even for a perfectly typed command that is a leaf node', async () => {
@@ -330,28 +326,24 @@ describe('useSlashCompletion', () => {
action: vi.fn(),
}),
];
let result: {
current: ReturnType<typeof useTestHarnessForSlashCompletion>;
};
let unmount: () => void;
await act(async () => {
const hook = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/clear',
slashCommands,
mockCommandContext,
),
);
result = hook.result;
unmount = hook.unmount;
});
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/clear',
slashCommands,
mockCommandContext,
),
);
await resolveMatch();
await waitFor(() => {
expect(result.current.suggestions).toHaveLength(1);
expect(result.current.suggestions[0].label).toBe('clear');
expect(result.current.completionStart).toBe(1);
});
unmount!();
unmount();
});
it.each([['/?'], ['/usage']])(
@@ -373,28 +365,22 @@ describe('useSlashCompletion', () => {
}),
];
let result: {
current: ReturnType<typeof useTestHarnessForSlashCompletion>;
};
let unmount: () => void;
await act(async () => {
const hook = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
query,
mockSlashCommands,
mockCommandContext,
),
);
result = hook.result;
unmount = hook.unmount;
});
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
query,
mockSlashCommands,
mockCommandContext,
),
);
await resolveMatch();
await waitFor(() => {
expect(result.current.suggestions).toHaveLength(1);
expect(result.current.completionStart).toBe(1);
});
unmount!();
unmount();
},
);
@@ -417,7 +403,7 @@ describe('useSlashCompletion', () => {
}),
];
const { result, unmount } = renderHook(() =>
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/review',
@@ -426,6 +412,8 @@ describe('useSlashCompletion', () => {
),
);
await resolveMatch();
await waitFor(() => {
// All three should match 'review' in our fuzzy mock or as prefix/exact
expect(result.current.suggestions.length).toBe(3);
@@ -472,15 +460,18 @@ describe('useSlashCompletion', () => {
}),
];
const { result: chatResult, unmount: unmountChat } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/chat',
slashCommands,
mockCommandContext,
),
const { result: chatResult, unmount: unmountChat } = await renderHook(
() =>
useTestHarnessForSlashCompletion(
true,
'/chat',
slashCommands,
mockCommandContext,
),
);
await resolveMatch();
await waitFor(() => {
expect(chatResult.current.suggestions[0]).toMatchObject({
label: 'list',
@@ -489,15 +480,18 @@ describe('useSlashCompletion', () => {
});
});
const { result: resumeResult, unmount: unmountResume } = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/resume',
slashCommands,
mockCommandContext,
),
const { result: resumeResult, unmount: unmountResume } = await renderHook(
() =>
useTestHarnessForSlashCompletion(
true,
'/resume',
slashCommands,
mockCommandContext,
),
);
await resolveMatch();
await waitFor(() => {
expect(resumeResult.current.suggestions[0]).toMatchObject({
label: 'list',
@@ -540,7 +534,7 @@ describe('useSlashCompletion', () => {
}),
];
const { result, unmount } = renderHook(() =>
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/resum',
@@ -549,6 +543,8 @@ describe('useSlashCompletion', () => {
),
);
await resolveMatch();
await waitFor(() => {
expect(result.current.suggestions[0]).toMatchObject({
label: 'list',
@@ -579,7 +575,7 @@ describe('useSlashCompletion', () => {
}),
];
const { result, unmount } = renderHook(() =>
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/?',
@@ -588,6 +584,8 @@ describe('useSlashCompletion', () => {
),
);
await resolveMatch();
await waitFor(() => {
// 'help' should be first because '?' is an exact altName match
expect(result.current.suggestions[0].label).toBe('help');
@@ -608,7 +606,7 @@ describe('useSlashCompletion', () => {
}),
];
const { result, unmount } = renderHook(() =>
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/chat',
@@ -617,6 +615,8 @@ describe('useSlashCompletion', () => {
),
);
await resolveMatch();
await waitFor(() => {
// Should show the auto-session entry plus subcommands of 'chat'
expect(result.current.suggestions).toHaveLength(3);
@@ -638,55 +638,45 @@ describe('useSlashCompletion', () => {
const slashCommands = [
createTestCommand({ name: 'clear', description: 'Clear the screen' }),
];
let result: {
current: ReturnType<typeof useTestHarnessForSlashCompletion>;
};
let unmount: () => void;
await act(async () => {
const hook = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/clear ',
slashCommands,
mockCommandContext,
),
);
result = hook.result;
unmount = hook.unmount;
});
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/clear ',
slashCommands,
mockCommandContext,
),
);
await resolveMatch();
await waitFor(() => {
expect(result.current.suggestions).toHaveLength(0);
});
unmount!();
unmount();
});
it('should not provide suggestions for an unknown command', async () => {
const slashCommands = [
createTestCommand({ name: 'help', description: 'Show help' }),
];
let result: {
current: ReturnType<typeof useTestHarnessForSlashCompletion>;
};
let unmount: () => void;
await act(async () => {
const hook = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/unknown-command',
slashCommands,
mockCommandContext,
),
);
result = hook.result;
unmount = hook.unmount;
});
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/unknown-command',
slashCommands,
mockCommandContext,
),
);
await resolveMatch();
await waitFor(() => {
expect(result.current.suggestions).toHaveLength(0);
expect(result.current.completionStart).toBe(1);
});
unmount!();
unmount();
});
it('should not suggest hidden commands', async () => {
@@ -701,28 +691,23 @@ describe('useSlashCompletion', () => {
hidden: true,
}),
];
let result: {
current: ReturnType<typeof useTestHarnessForSlashCompletion>;
};
let unmount: () => void;
await act(async () => {
const hook = renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/',
slashCommands,
mockCommandContext,
),
);
result = hook.result;
unmount = hook.unmount;
});
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/',
slashCommands,
mockCommandContext,
),
);
await resolveMatch();
await waitFor(() => {
expect(result.current.suggestions.length).toBe(1);
expect(result.current.suggestions[0].label).toBe('visible');
});
unmount!();
unmount();
});
});
@@ -739,7 +724,7 @@ describe('useSlashCompletion', () => {
}),
];
const { result, unmount } = renderHook(() =>
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/memory ',
@@ -748,6 +733,8 @@ describe('useSlashCompletion', () => {
),
);
await resolveMatch();
await waitFor(() => {
expect(result.current.suggestions).toHaveLength(2);
expect(result.current.suggestions).toEqual(
@@ -785,7 +772,7 @@ describe('useSlashCompletion', () => {
}),
];
const { result } = renderHook(() =>
const { result } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/memory',
@@ -794,6 +781,8 @@ describe('useSlashCompletion', () => {
),
);
await resolveMatch();
// Should verify that we see BOTH 'memory' and 'memory-leak'
await waitFor(() => {
expect(result.current.suggestions).toHaveLength(2);
@@ -827,7 +816,7 @@ describe('useSlashCompletion', () => {
],
}),
];
const { result, unmount } = renderHook(() =>
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/memory ',
@@ -836,6 +825,8 @@ describe('useSlashCompletion', () => {
),
);
await resolveMatch();
await waitFor(() => {
expect(result.current.suggestions).toHaveLength(2);
expect(result.current.suggestions).toEqual(
@@ -869,7 +860,7 @@ describe('useSlashCompletion', () => {
],
}),
];
const { result, unmount } = renderHook(() =>
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/memory a',
@@ -878,6 +869,8 @@ describe('useSlashCompletion', () => {
),
);
await resolveMatch();
await waitFor(() => {
expect(result.current.suggestions).toEqual([
{
@@ -903,7 +896,7 @@ describe('useSlashCompletion', () => {
],
}),
];
const { result, unmount } = renderHook(() =>
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/memory dothisnow',
@@ -911,11 +904,12 @@ describe('useSlashCompletion', () => {
mockCommandContext,
),
);
await act(async () => {
await waitFor(() => {
expect(result.current.suggestions).toHaveLength(0);
expect(result.current.completionStart).toBe(8);
});
await resolveMatch();
await waitFor(() => {
expect(result.current.suggestions).toHaveLength(0);
expect(result.current.completionStart).toBe(8);
});
unmount();
});
@@ -928,12 +922,18 @@ describe('useSlashCompletion', () => {
'my-chat-tag-2',
'another-channel',
];
const mockCompletionFn = vi
.fn()
.mockImplementation(
async (_context: CommandContext, partialArg: string) =>
availableTags.filter((tag) => tag.startsWith(partialArg)),
);
let deferredCompletion: { resolve: (v: string[]) => void } | null = null;
const mockCompletionFn = vi.fn().mockImplementation(
(_context: CommandContext, partialArg: string) =>
new Promise((resolve) => {
deferredCompletion = {
resolve: () =>
resolve(
availableTags.filter((tag) => tag.startsWith(partialArg)),
),
};
}),
);
const slashCommands = [
createTestCommand({
@@ -949,7 +949,7 @@ describe('useSlashCompletion', () => {
}),
];
const { result, unmount } = renderHook(() =>
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/chat resume my-ch',
@@ -958,38 +958,45 @@ describe('useSlashCompletion', () => {
),
);
await act(async () => {
await waitFor(() => {
expect(mockCompletionFn).toHaveBeenCalledWith(
expect.objectContaining({
invocation: {
raw: '/chat resume my-ch',
name: 'resume',
args: 'my-ch',
},
}),
'my-ch',
);
});
await waitFor(() => {
expect(mockCompletionFn).toHaveBeenCalledWith(
expect.objectContaining({
invocation: {
raw: '/chat resume my-ch',
name: 'resume',
args: 'my-ch',
},
}),
'my-ch',
);
});
await act(async () => {
await waitFor(() => {
expect(result.current.suggestions).toEqual([
{ label: 'my-chat-tag-1', value: 'my-chat-tag-1' },
{ label: 'my-chat-tag-2', value: 'my-chat-tag-2' },
]);
expect(result.current.completionStart).toBe(13);
expect(result.current.isLoadingSuggestions).toBe(false);
});
deferredCompletion?.resolve([]);
});
await waitFor(() => {
expect(result.current.suggestions).toEqual([
{ label: 'my-chat-tag-1', value: 'my-chat-tag-1' },
{ label: 'my-chat-tag-2', value: 'my-chat-tag-2' },
]);
expect(result.current.completionStart).toBe(13);
expect(result.current.isLoadingSuggestions).toBe(false);
});
unmount();
});
it('should call command.completion with an empty string when args start with a space', async () => {
const mockCompletionFn = vi
.fn()
.mockResolvedValue(['my-chat-tag-1', 'my-chat-tag-2', 'my-channel']);
let deferredCompletion: { resolve: (v: string[]) => void } | null = null;
const mockCompletionFn = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
deferredCompletion = {
resolve: () =>
resolve(['my-chat-tag-1', 'my-chat-tag-2', 'my-channel']),
};
}),
);
const slashCommands = [
createTestCommand({
@@ -1005,7 +1012,7 @@ describe('useSlashCompletion', () => {
}),
];
const { result, unmount } = renderHook(() =>
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/chat resume ',
@@ -1014,32 +1021,38 @@ describe('useSlashCompletion', () => {
),
);
await act(async () => {
await waitFor(() => {
expect(mockCompletionFn).toHaveBeenCalledWith(
expect.objectContaining({
invocation: {
raw: '/chat resume ',
name: 'resume',
args: '',
},
}),
'',
);
});
await waitFor(() => {
expect(mockCompletionFn).toHaveBeenCalledWith(
expect.objectContaining({
invocation: {
raw: '/chat resume ',
name: 'resume',
args: '',
},
}),
'',
);
});
await act(async () => {
await waitFor(() => {
expect(result.current.suggestions).toHaveLength(3);
expect(result.current.completionStart).toBe(13);
});
deferredCompletion?.resolve([]);
});
await waitFor(() => {
expect(result.current.suggestions).toHaveLength(3);
expect(result.current.completionStart).toBe(13);
});
unmount();
});
it('should handle completion function that returns null', async () => {
const mockCompletionFn = vi.fn().mockResolvedValue(null);
let deferredCompletion: { resolve: (v: null) => void } | null = null;
const mockCompletionFn = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
deferredCompletion = { resolve: () => resolve(null) };
}),
);
const slashCommands = [
createTestCommand({
@@ -1049,7 +1062,7 @@ describe('useSlashCompletion', () => {
}),
];
const { result, unmount } = renderHook(() =>
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/test arg',
@@ -1058,6 +1071,10 @@ describe('useSlashCompletion', () => {
),
);
await act(async () => {
deferredCompletion?.resolve(null);
});
await waitFor(() => {
expect(result.current.suggestions).toEqual([]);
expect(result.current.isLoadingSuggestions).toBe(false);
@@ -1083,7 +1100,7 @@ describe('useSlashCompletion', () => {
},
] as SlashCommand[];
const { result, unmount } = renderHook(() =>
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/',
@@ -1092,6 +1109,8 @@ describe('useSlashCompletion', () => {
),
);
await resolveMatch();
await waitFor(() => {
expect(result.current.suggestions).toEqual(
expect.arrayContaining([
@@ -1129,7 +1148,7 @@ describe('useSlashCompletion', () => {
},
] as SlashCommand[];
const { result, unmount } = renderHook(() =>
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/summ',
@@ -1138,6 +1157,8 @@ describe('useSlashCompletion', () => {
),
);
await resolveMatch();
await waitFor(() => {
expect(result.current.suggestions).toEqual([
{
@@ -1175,7 +1196,7 @@ describe('useSlashCompletion', () => {
},
] as SlashCommand[];
const { result, unmount } = renderHook(() =>
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/memory ',
@@ -1184,6 +1205,8 @@ describe('useSlashCompletion', () => {
),
);
await resolveMatch();
await waitFor(() => {
expect(result.current.suggestions).toEqual(
expect.arrayContaining([
@@ -1215,7 +1238,7 @@ describe('useSlashCompletion', () => {
},
] as SlashCommand[];
const { result, unmount } = renderHook(() =>
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/custom',
@@ -1224,6 +1247,8 @@ describe('useSlashCompletion', () => {
),
);
await resolveMatch();
await waitFor(() => {
expect(result.current.suggestions).toEqual([
{
@@ -1251,7 +1276,7 @@ describe('useSlashCompletion', () => {
}),
];
const { rerender, unmount } = renderHook(
const { rerender, unmount } = await renderHook(
({ enabled, query }) =>
useSlashCompletion({
enabled,