Migrate core render util to use xterm.js as part of the rendering loop. (#19044)

This commit is contained in:
Jacob Richman
2026-02-18 16:46:50 -08:00
committed by GitHub
parent 04c52513e7
commit 04f65f3d55
213 changed files with 7065 additions and 3852 deletions

View File

@@ -6,7 +6,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderWithProviders } from '../../../test-utils/render.js';
import { waitFor } from '../../../test-utils/async.js';
import {
BaseSelectionList,
type BaseSelectionListProps,
@@ -42,7 +41,7 @@ describe('BaseSelectionList', () => {
];
// Helper to render the component with default props
const renderComponent = (
const renderComponent = async (
props: Partial<
BaseSelectionListProps<
string,
@@ -74,7 +73,9 @@ describe('BaseSelectionList', () => {
...props,
};
return renderWithProviders(<BaseSelectionList {...defaultProps} />);
const result = renderWithProviders(<BaseSelectionList {...defaultProps} />);
await result.waitUntilReady();
return result;
};
beforeEach(() => {
@@ -82,8 +83,8 @@ describe('BaseSelectionList', () => {
});
describe('Rendering and Structure', () => {
it('should render all items using the renderItem prop', () => {
const { lastFrame } = renderComponent();
it('should render all items using the renderItem prop', async () => {
const { lastFrame, unmount } = await renderComponent();
expect(lastFrame()).toContain('Item A');
expect(lastFrame()).toContain('Item B');
@@ -91,32 +92,39 @@ describe('BaseSelectionList', () => {
expect(mockRenderItem).toHaveBeenCalledTimes(3);
expect(mockRenderItem).toHaveBeenCalledWith(items[0], expect.any(Object));
unmount();
});
it('should render the selection indicator (● or space) and layout', () => {
const { lastFrame } = renderComponent({}, 0);
it('should render the selection indicator (● or space) and layout', async () => {
const { lastFrame, unmount } = await renderComponent({}, 0);
const output = lastFrame();
// Use regex to assert the structure: Indicator + Whitespace + Number + Label
expect(output).toMatch(/●\s+1\.\s+Item A/);
expect(output).toMatch(/\s+2\.\s+Item B/);
expect(output).toMatch(/\s+3\.\s+Item C/);
unmount();
});
it('should handle an empty list gracefully', () => {
const { lastFrame } = renderComponent({ items: [] });
it('should handle an empty list gracefully', async () => {
const { lastFrame, unmount } = await renderComponent({ items: [] });
expect(mockRenderItem).not.toHaveBeenCalled();
expect(lastFrame()).toBe('');
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
});
describe('useSelectionList Integration', () => {
it('should pass props correctly to useSelectionList', () => {
it('should pass props correctly to useSelectionList', async () => {
const initialIndex = 1;
const isFocused = false;
const showNumbers = false;
renderComponent({ initialIndex, isFocused, showNumbers });
const { unmount } = await renderComponent({
initialIndex,
isFocused,
showNumbers,
});
expect(useSelectionList).toHaveBeenCalledWith({
items,
@@ -127,10 +135,11 @@ describe('BaseSelectionList', () => {
showNumbers,
wrapAround: true,
});
unmount();
});
it('should use the activeIndex returned by the hook', () => {
renderComponent({}, 2); // Active index is C
it('should use the activeIndex returned by the hook', async () => {
const { unmount } = await renderComponent({}, 2); // Active index is C
expect(mockRenderItem).toHaveBeenCalledWith(
items[0],
@@ -140,12 +149,13 @@ describe('BaseSelectionList', () => {
items[2],
expect.objectContaining({ isSelected: true }),
);
unmount();
});
});
describe('Styling and Colors', () => {
it('should apply success color to the selected item', () => {
renderComponent({}, 0); // Item A selected
it('should apply success color to the selected item', async () => {
const { unmount } = await renderComponent({}, 0); // Item A selected
// Check renderItem context colors against the mocked theme
expect(mockRenderItem).toHaveBeenCalledWith(
@@ -156,10 +166,11 @@ describe('BaseSelectionList', () => {
isSelected: true,
}),
);
unmount();
});
it('should apply primary color to unselected, enabled items', () => {
renderComponent({}, 0); // Item A selected, Item C unselected/enabled
it('should apply primary color to unselected, enabled items', async () => {
const { unmount } = await renderComponent({}, 0); // Item A selected, Item C unselected/enabled
// Check renderItem context colors for Item C
expect(mockRenderItem).toHaveBeenCalledWith(
@@ -170,10 +181,11 @@ describe('BaseSelectionList', () => {
isSelected: false,
}),
);
unmount();
});
it('should apply secondary color to disabled items (when not selected)', () => {
renderComponent({}, 0); // Item A selected, Item B disabled
it('should apply secondary color to disabled items (when not selected)', async () => {
const { unmount } = await renderComponent({}, 0); // Item A selected, Item B disabled
// Check renderItem context colors for Item B
expect(mockRenderItem).toHaveBeenCalledWith(
@@ -184,11 +196,12 @@ describe('BaseSelectionList', () => {
isSelected: false,
}),
);
unmount();
});
it('should apply success color to disabled items if they are selected', () => {
it('should apply success color to disabled items if they are selected', async () => {
// The component should visually reflect the selection even if the item is disabled.
renderComponent({}, 1); // Item B (disabled) selected
const { unmount } = await renderComponent({}, 1); // Item B (disabled) selected
// Check renderItem context colors for Item B
expect(mockRenderItem).toHaveBeenCalledWith(
@@ -199,29 +212,34 @@ describe('BaseSelectionList', () => {
isSelected: true,
}),
);
unmount();
});
});
describe('Numbering (showNumbers)', () => {
it('should show numbers by default with correct formatting', () => {
const { lastFrame } = renderComponent();
it('should show numbers by default with correct formatting', async () => {
const { lastFrame, unmount } = await renderComponent();
const output = lastFrame();
expect(output).toContain('1.');
expect(output).toContain('2.');
expect(output).toContain('3.');
unmount();
});
it('should hide numbers when showNumbers is false', () => {
const { lastFrame } = renderComponent({ showNumbers: false });
it('should hide numbers when showNumbers is false', async () => {
const { lastFrame, unmount } = await renderComponent({
showNumbers: false,
});
const output = lastFrame();
expect(output).not.toContain('1.');
expect(output).not.toContain('2.');
expect(output).not.toContain('3.');
unmount();
});
it('should apply correct padding for alignment in long lists', () => {
it('should apply correct padding for alignment in long lists', async () => {
const longList = Array.from({ length: 15 }, (_, i) => ({
value: `Item ${i + 1}`,
label: `Item ${i + 1}`,
@@ -229,7 +247,7 @@ describe('BaseSelectionList', () => {
}));
// We must increase maxItemsToShow (default 10) to see the 10th item and beyond
const { lastFrame } = renderComponent({
const { lastFrame, unmount } = await renderComponent({
items: longList,
maxItemsToShow: 15,
});
@@ -239,10 +257,11 @@ describe('BaseSelectionList', () => {
// The implementation uses padStart, resulting in " 1." and "10.".
expect(output).toContain(' 1.');
expect(output).toContain('10.');
unmount();
});
it('should apply secondary color to numbers if showNumbers is false (internal logic check)', () => {
renderComponent({ showNumbers: false }, 0);
it('should apply secondary color to numbers if showNumbers is false (internal logic check)', async () => {
const { unmount } = await renderComponent({ showNumbers: false }, 0);
expect(mockRenderItem).toHaveBeenCalledWith(
items[0],
@@ -252,6 +271,7 @@ describe('BaseSelectionList', () => {
numberColor: mockTheme.text.secondary,
}),
);
unmount();
});
});
@@ -263,7 +283,7 @@ describe('BaseSelectionList', () => {
}));
const MAX_ITEMS = 3;
const renderScrollableList = (initialActiveIndex: number = 0) => {
const renderScrollableList = async (initialActiveIndex: number = 0) => {
// Define the props used for the initial render and subsequent rerenders
const componentProps: BaseSelectionListProps<
string,
@@ -287,9 +307,9 @@ describe('BaseSelectionList', () => {
),
);
const { rerender, lastFrame } = renderWithProviders(
<BaseSelectionList {...componentProps} />,
);
const { rerender, lastFrame, waitUntilReady, unmount } =
renderWithProviders(<BaseSelectionList {...componentProps} />);
await waitUntilReady();
// Function to simulate the activeIndex changing over time
const updateActiveIndex = async (newIndex: number) => {
@@ -299,80 +319,76 @@ describe('BaseSelectionList', () => {
});
rerender(<BaseSelectionList {...componentProps} />);
await waitFor(() => {
expect(lastFrame()).toBeTruthy();
});
await waitUntilReady();
};
return { updateActiveIndex, lastFrame };
return { updateActiveIndex, lastFrame, unmount };
};
it('should only show maxItemsToShow items initially', () => {
const { lastFrame } = renderScrollableList(0);
it('should only show maxItemsToShow items initially', async () => {
const { lastFrame, unmount } = await renderScrollableList(0);
const output = lastFrame();
expect(output).toContain('Item 1');
expect(output).toContain('Item 3');
expect(output).not.toContain('Item 4');
unmount();
});
it('should scroll down when activeIndex moves beyond the visible window', async () => {
const { updateActiveIndex, lastFrame } = renderScrollableList(0);
const { updateActiveIndex, lastFrame, unmount } =
await renderScrollableList(0);
// Move to index 3 (Item 4). Should trigger scroll.
// New visible window should be Items 2, 3, 4 (scroll offset 1).
await updateActiveIndex(3);
await waitFor(() => {
const output = lastFrame();
expect(output).not.toContain('Item 1');
expect(output).toContain('Item 2');
expect(output).toContain('Item 4');
expect(output).not.toContain('Item 5');
});
const output = lastFrame();
expect(output).not.toContain('Item 1');
expect(output).toContain('Item 2');
expect(output).toContain('Item 4');
expect(output).not.toContain('Item 5');
unmount();
});
it('should scroll up when activeIndex moves before the visible window', async () => {
const { updateActiveIndex, lastFrame } = renderScrollableList(0);
const { updateActiveIndex, lastFrame, unmount } =
await renderScrollableList(0);
await updateActiveIndex(4);
await waitFor(() => {
const output = lastFrame();
expect(output).toContain('Item 3'); // Should see items 3, 4, 5
expect(output).toContain('Item 5');
expect(output).not.toContain('Item 2');
});
let output = lastFrame();
expect(output).toContain('Item 3'); // Should see items 3, 4, 5
expect(output).toContain('Item 5');
expect(output).not.toContain('Item 2');
// Now test scrolling up: move to index 1 (Item 2)
// This should trigger scroll up to show items 2, 3, 4
await updateActiveIndex(1);
await waitFor(() => {
const output = lastFrame();
expect(output).toContain('Item 2');
expect(output).toContain('Item 4');
expect(output).not.toContain('Item 5'); // Item 5 should no longer be visible
});
output = lastFrame();
expect(output).toContain('Item 2');
expect(output).toContain('Item 4');
expect(output).not.toContain('Item 5'); // Item 5 should no longer be visible
unmount();
});
it('should pin the scroll offset to the end if selection starts near the end', async () => {
// List length 10. Max items 3. Active index 9 (last item).
// Scroll offset should be 10 - 3 = 7.
// Visible items: 8, 9, 10.
const { lastFrame } = renderScrollableList(9);
const { lastFrame, unmount } = await renderScrollableList(9);
await waitFor(() => {
const output = lastFrame();
expect(output).toContain('Item 10');
expect(output).toContain('Item 8');
expect(output).not.toContain('Item 7');
});
const output = lastFrame();
expect(output).toContain('Item 10');
expect(output).toContain('Item 8');
expect(output).not.toContain('Item 7');
unmount();
});
it('should handle dynamic scrolling through multiple activeIndex changes', async () => {
const { updateActiveIndex, lastFrame } = renderScrollableList(0);
const { updateActiveIndex, lastFrame, unmount } =
await renderScrollableList(0);
expect(lastFrame()).toContain('Item 1');
expect(lastFrame()).toContain('Item 3');
@@ -382,23 +398,21 @@ describe('BaseSelectionList', () => {
expect(lastFrame()).toContain('Item 1');
await updateActiveIndex(3); // Should trigger scroll
await waitFor(() => {
const output = lastFrame();
expect(output).toContain('Item 2');
expect(output).toContain('Item 4');
expect(output).not.toContain('Item 1');
});
let output = lastFrame();
expect(output).toContain('Item 2');
expect(output).toContain('Item 4');
expect(output).not.toContain('Item 1');
await updateActiveIndex(5); // Scroll further
await waitFor(() => {
const output = lastFrame();
expect(output).toContain('Item 4');
expect(output).toContain('Item 6');
expect(output).not.toContain('Item 3');
});
output = lastFrame();
expect(output).toContain('Item 4');
expect(output).toContain('Item 6');
expect(output).not.toContain('Item 3');
unmount();
});
it('should correctly identify the selected item within the visible window', () => {
renderScrollableList(1); // activeIndex 1 = Item 2
it('should correctly identify the selected item within the visible window', async () => {
const { unmount } = await renderScrollableList(1); // activeIndex 1 = Item 2
expect(mockRenderItem).toHaveBeenCalledTimes(MAX_ITEMS);
@@ -411,28 +425,28 @@ describe('BaseSelectionList', () => {
expect.objectContaining({ value: 'Item 2' }),
expect.objectContaining({ isSelected: true }),
);
unmount();
});
it('should correctly identify the selected item when scrolled (high index)', async () => {
renderScrollableList(5);
const { unmount } = await renderScrollableList(5);
await waitFor(() => {
// Item 6 (index 5) should be selected
expect(mockRenderItem).toHaveBeenCalledWith(
expect.objectContaining({ value: 'Item 6' }),
expect.objectContaining({ isSelected: true }),
);
// Item 6 (index 5) should be selected
expect(mockRenderItem).toHaveBeenCalledWith(
expect.objectContaining({ value: 'Item 6' }),
expect.objectContaining({ isSelected: true }),
);
// Item 4 (index 3) should not be selected
expect(mockRenderItem).toHaveBeenCalledWith(
expect.objectContaining({ value: 'Item 4' }),
expect.objectContaining({ isSelected: false }),
);
});
// Item 4 (index 3) should not be selected
expect(mockRenderItem).toHaveBeenCalledWith(
expect.objectContaining({ value: 'Item 4' }),
expect.objectContaining({ isSelected: false }),
);
unmount();
});
it('should handle maxItemsToShow larger than the list length', () => {
const { lastFrame } = renderComponent(
it('should handle maxItemsToShow larger than the list length', async () => {
const { lastFrame, unmount } = await renderComponent(
{ items: longList, maxItemsToShow: 15 },
0,
);
@@ -442,6 +456,7 @@ describe('BaseSelectionList', () => {
expect(output).toContain('Item 1');
expect(output).toContain('Item 10');
expect(mockRenderItem).toHaveBeenCalledTimes(10);
unmount();
});
});
@@ -453,8 +468,8 @@ describe('BaseSelectionList', () => {
}));
const MAX_ITEMS = 3;
it('should not show arrows by default', () => {
const { lastFrame } = renderComponent({
it('should not show arrows by default', async () => {
const { lastFrame, unmount } = await renderComponent({
items: longList,
maxItemsToShow: MAX_ITEMS,
});
@@ -462,10 +477,11 @@ describe('BaseSelectionList', () => {
expect(output).not.toContain('▲');
expect(output).not.toContain('▼');
unmount();
});
it('should show arrows with correct colors when enabled (at the top)', async () => {
const { lastFrame } = renderComponent(
const { lastFrame, unmount } = await renderComponent(
{
items: longList,
maxItemsToShow: MAX_ITEMS,
@@ -474,41 +490,39 @@ describe('BaseSelectionList', () => {
0,
);
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot();
});
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should show arrows and correct items when scrolled to the middle', async () => {
const { lastFrame } = renderComponent(
const { lastFrame, unmount } = await renderComponent(
{ items: longList, maxItemsToShow: MAX_ITEMS, showScrollArrows: true },
5,
);
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot();
});
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should show arrows and correct items when scrolled to the end', async () => {
const { lastFrame } = renderComponent(
const { lastFrame, unmount } = await renderComponent(
{ items: longList, maxItemsToShow: MAX_ITEMS, showScrollArrows: true },
9,
);
await waitFor(() => {
expect(lastFrame()).toMatchSnapshot();
});
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should not show arrows when list fits entirely', () => {
const { lastFrame } = renderComponent({
it('should not show arrows when list fits entirely', async () => {
const { lastFrame, unmount } = await renderComponent({
items,
maxItemsToShow: 5,
showScrollArrows: true,
});
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
});

View File

@@ -102,7 +102,7 @@ describe('BaseSettingsDialog', () => {
mockOnScopeChange = vi.fn();
});
const renderDialog = (props: Partial<BaseSettingsDialogProps> = {}) => {
const renderDialog = async (props: Partial<BaseSettingsDialogProps> = {}) => {
const defaultProps: BaseSettingsDialogProps = {
title: 'Test Settings',
items: createMockItems(),
@@ -115,80 +115,94 @@ describe('BaseSettingsDialog', () => {
...props,
};
return render(
const result = render(
<KeypressProvider>
<BaseSettingsDialog {...defaultProps} />
</KeypressProvider>,
);
await result.waitUntilReady();
return result;
};
describe('rendering', () => {
it('should render the dialog with title', () => {
const { lastFrame } = renderDialog();
it('should render the dialog with title', async () => {
const { lastFrame, unmount } = await renderDialog();
expect(lastFrame()).toContain('Test Settings');
unmount();
});
it('should render all items', () => {
const { lastFrame } = renderDialog();
it('should render all items', async () => {
const { lastFrame, unmount } = await renderDialog();
const frame = lastFrame();
expect(frame).toContain('Boolean Setting');
expect(frame).toContain('String Setting');
expect(frame).toContain('Number Setting');
expect(frame).toContain('Enum Setting');
unmount();
});
it('should render help text with Ctrl+L for reset', () => {
const { lastFrame } = renderDialog();
it('should render help text with Ctrl+L for reset', async () => {
const { lastFrame, unmount } = await renderDialog();
const frame = lastFrame();
expect(frame).toContain('Use Enter to select');
expect(frame).toContain('Ctrl+L to reset');
expect(frame).toContain('Tab to change focus');
expect(frame).toContain('Esc to close');
unmount();
});
it('should render scope selector when showScopeSelector is true', () => {
const { lastFrame } = renderDialog({
it('should render scope selector when showScopeSelector is true', async () => {
const { lastFrame, unmount } = await renderDialog({
showScopeSelector: true,
onScopeChange: mockOnScopeChange,
});
expect(lastFrame()).toContain('Apply To');
unmount();
});
it('should not render scope selector when showScopeSelector is false', () => {
const { lastFrame } = renderDialog({
it('should not render scope selector when showScopeSelector is false', async () => {
const { lastFrame, unmount } = await renderDialog({
showScopeSelector: false,
});
expect(lastFrame()).not.toContain('Apply To');
expect(lastFrame({ allowEmpty: true })).not.toContain('Apply To');
unmount();
});
it('should render footer content when provided', () => {
const { lastFrame } = renderDialog({
it('should render footer content when provided', async () => {
const { lastFrame, unmount } = await renderDialog({
footerContent: <Text>Custom Footer</Text>,
});
expect(lastFrame()).toContain('Custom Footer');
unmount();
});
});
describe('keyboard navigation', () => {
it('should close dialog on Escape', async () => {
const { stdin } = renderDialog();
const { stdin, waitUntilReady, unmount } = await renderDialog();
await act(async () => {
stdin.write(TerminalKeys.ESCAPE);
});
// Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act
await act(async () => {
await waitUntilReady();
});
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
});
unmount();
});
it('should navigate down with arrow key', async () => {
const { lastFrame, stdin } = renderDialog();
const { lastFrame, stdin, waitUntilReady, unmount } =
await renderDialog();
// Initially first item is active (indicated by bullet point)
const initialFrame = lastFrame();
@@ -198,6 +212,7 @@ describe('BaseSettingsDialog', () => {
await act(async () => {
stdin.write(TerminalKeys.DOWN_ARROW);
});
await waitUntilReady();
// Navigation should move to next item
await waitFor(() => {
@@ -205,59 +220,70 @@ describe('BaseSettingsDialog', () => {
// The active indicator should now be on a different row
expect(frame).toContain('String Setting');
});
unmount();
});
it('should navigate up with arrow key', async () => {
const { stdin } = renderDialog();
const { stdin, waitUntilReady, unmount } = await renderDialog();
// Press down then up
await act(async () => {
stdin.write(TerminalKeys.DOWN_ARROW);
});
await waitUntilReady();
await act(async () => {
stdin.write(TerminalKeys.UP_ARROW);
});
await waitUntilReady();
// Should be back at first item
await waitFor(() => {
// First item should be active again
expect(mockOnClose).not.toHaveBeenCalled();
});
unmount();
});
it('should wrap around when navigating past last item', async () => {
const items = createMockItems(2); // Only 2 items
const { stdin } = renderDialog({ items });
const { stdin, waitUntilReady, unmount } = await renderDialog({ items });
// Press down twice to go past the last item
await act(async () => {
stdin.write(TerminalKeys.DOWN_ARROW);
});
await waitUntilReady();
await act(async () => {
stdin.write(TerminalKeys.DOWN_ARROW);
});
await waitUntilReady();
// Should wrap to first item - verify no crash
await waitFor(() => {
expect(mockOnClose).not.toHaveBeenCalled();
});
unmount();
});
it('should wrap around when navigating before first item', async () => {
const { stdin } = renderDialog();
const { stdin, waitUntilReady, unmount } = await renderDialog();
// Press up at first item
await act(async () => {
stdin.write(TerminalKeys.UP_ARROW);
});
await waitUntilReady();
// Should wrap to last item - verify no crash
await waitFor(() => {
expect(mockOnClose).not.toHaveBeenCalled();
});
unmount();
});
it('should switch focus with Tab when scope selector is shown', async () => {
const { lastFrame, stdin } = renderDialog({
const { lastFrame, stdin, waitUntilReady, unmount } = await renderDialog({
showScopeSelector: true,
onScopeChange: mockOnScopeChange,
});
@@ -269,46 +295,54 @@ describe('BaseSettingsDialog', () => {
await act(async () => {
stdin.write(TerminalKeys.TAB);
});
await waitUntilReady();
await waitFor(() => {
expect(lastFrame()).toContain('> Apply To');
});
unmount();
});
});
describe('scrolling and resizing list (search filtering)', () => {
it('should preserve focus on the active item if it remains in the filtered list', async () => {
const items = createMockItems(5); // items 0 to 4
const { rerender, stdin, lastFrame } = renderDialog({
items,
maxItemsToShow: 5,
});
const { rerender, stdin, lastFrame, waitUntilReady, unmount } =
await renderDialog({
items,
maxItemsToShow: 5,
});
// Move focus down to item 2 ("Number Setting")
// Separate acts needed so React state updates between keypresses
await act(async () => {
stdin.write(TerminalKeys.DOWN_ARROW);
});
await waitUntilReady();
await act(async () => {
stdin.write(TerminalKeys.DOWN_ARROW);
});
await waitUntilReady();
// Rerender with a filtered list where "Number Setting" is now at index 1
const filteredItems = [items[0], items[2], items[4]];
rerender(
<KeypressProvider>
<BaseSettingsDialog
title="Test Settings"
items={filteredItems}
selectedScope={SettingScope.User}
maxItemsToShow={5}
onItemToggle={mockOnItemToggle}
onEditCommit={mockOnEditCommit}
onItemClear={mockOnItemClear}
onClose={mockOnClose}
/>
</KeypressProvider>,
);
await act(async () => {
rerender(
<KeypressProvider>
<BaseSettingsDialog
title="Test Settings"
items={filteredItems}
selectedScope={SettingScope.User}
maxItemsToShow={5}
onItemToggle={mockOnItemToggle}
onEditCommit={mockOnEditCommit}
onItemClear={mockOnItemClear}
onClose={mockOnClose}
/>
</KeypressProvider>,
);
});
await waitUntilReady();
// Verify the dialog hasn't crashed and the items are displayed
await waitFor(() => {
@@ -324,43 +358,51 @@ describe('BaseSettingsDialog', () => {
await act(async () => {
stdin.write(TerminalKeys.ENTER);
});
await waitUntilReady();
await waitFor(() => {
expect(mockOnItemToggle).not.toHaveBeenCalled();
});
unmount();
});
it('should reset focus to the top if the active item is filtered out', async () => {
const items = createMockItems(5);
const { rerender, stdin, lastFrame } = renderDialog({
items,
maxItemsToShow: 5,
});
const { rerender, stdin, lastFrame, waitUntilReady, unmount } =
await renderDialog({
items,
maxItemsToShow: 5,
});
// Move focus down to item 2 ("Number Setting")
await act(async () => {
stdin.write(TerminalKeys.DOWN_ARROW);
});
await waitUntilReady();
await act(async () => {
stdin.write(TerminalKeys.DOWN_ARROW);
});
await waitUntilReady();
// Rerender with a filtered list that EXCLUDES "Number Setting"
const filteredItems = [items[0], items[1]];
rerender(
<KeypressProvider>
<BaseSettingsDialog
title="Test Settings"
items={filteredItems}
selectedScope={SettingScope.User}
maxItemsToShow={5}
onItemToggle={mockOnItemToggle}
onEditCommit={mockOnEditCommit}
onItemClear={mockOnItemClear}
onClose={mockOnClose}
/>
</KeypressProvider>,
);
await act(async () => {
rerender(
<KeypressProvider>
<BaseSettingsDialog
title="Test Settings"
items={filteredItems}
selectedScope={SettingScope.User}
maxItemsToShow={5}
onItemToggle={mockOnItemToggle}
onEditCommit={mockOnEditCommit}
onItemClear={mockOnItemClear}
onClose={mockOnClose}
/>
</KeypressProvider>,
);
});
await waitUntilReady();
await waitFor(() => {
const frame = lastFrame();
@@ -372,6 +414,7 @@ describe('BaseSettingsDialog', () => {
await act(async () => {
stdin.write(TerminalKeys.ENTER);
});
await waitUntilReady();
await waitFor(() => {
expect(mockOnItemToggle).toHaveBeenCalledWith(
@@ -379,17 +422,19 @@ describe('BaseSettingsDialog', () => {
expect.anything(),
);
});
unmount();
});
});
describe('item interactions', () => {
it('should call onItemToggle for boolean items on Enter', async () => {
const { stdin } = renderDialog();
const { stdin, waitUntilReady, unmount } = await renderDialog();
// Press Enter on first item (boolean)
await act(async () => {
stdin.write(TerminalKeys.ENTER);
});
await waitUntilReady();
await waitFor(() => {
expect(mockOnItemToggle).toHaveBeenCalledWith(
@@ -397,18 +442,22 @@ describe('BaseSettingsDialog', () => {
expect.objectContaining({ type: 'boolean' }),
);
});
unmount();
});
it('should call onItemToggle for enum items on Enter', async () => {
const items = createMockItems(4);
// Move enum to first position
const enumItem = items.find((i) => i.type === 'enum')!;
const { stdin } = renderDialog({ items: [enumItem] });
const { stdin, waitUntilReady, unmount } = await renderDialog({
items: [enumItem],
});
// Press Enter on enum item
await act(async () => {
stdin.write(TerminalKeys.ENTER);
});
await waitUntilReady();
await waitFor(() => {
expect(mockOnItemToggle).toHaveBeenCalledWith(
@@ -416,17 +465,21 @@ describe('BaseSettingsDialog', () => {
expect.objectContaining({ type: 'enum' }),
);
});
unmount();
});
it('should enter edit mode for string items on Enter', async () => {
const items = createMockItems(4);
const stringItem = items.find((i) => i.type === 'string')!;
const { lastFrame, stdin } = renderDialog({ items: [stringItem] });
const { lastFrame, stdin, waitUntilReady, unmount } = await renderDialog({
items: [stringItem],
});
// Press Enter to start editing
await act(async () => {
stdin.write(TerminalKeys.ENTER);
});
await waitUntilReady();
// Should show the edit buffer with cursor
await waitFor(() => {
@@ -434,32 +487,38 @@ describe('BaseSettingsDialog', () => {
// In edit mode, the value should be displayed (possibly with cursor)
expect(frame).toContain('test-value');
});
unmount();
});
it('should enter edit mode for number items on Enter', async () => {
const items = createMockItems(4);
const numberItem = items.find((i) => i.type === 'number')!;
const { lastFrame, stdin } = renderDialog({ items: [numberItem] });
const { lastFrame, stdin, waitUntilReady, unmount } = await renderDialog({
items: [numberItem],
});
// Press Enter to start editing
await act(async () => {
stdin.write(TerminalKeys.ENTER);
});
await waitUntilReady();
// Should show the edit buffer
await waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('42');
});
unmount();
});
it('should call onItemClear on Ctrl+L', async () => {
const { stdin } = renderDialog();
const { stdin, waitUntilReady, unmount } = await renderDialog();
// Press Ctrl+L to reset
await act(async () => {
stdin.write(TerminalKeys.CTRL_L);
});
await waitUntilReady();
await waitFor(() => {
expect(mockOnItemClear).toHaveBeenCalledWith(
@@ -467,6 +526,7 @@ describe('BaseSettingsDialog', () => {
expect.objectContaining({ type: 'boolean' }),
);
});
unmount();
});
});
@@ -474,22 +534,27 @@ describe('BaseSettingsDialog', () => {
it('should commit edit on Enter', async () => {
const items = createMockItems(4);
const stringItem = items.find((i) => i.type === 'string')!;
const { stdin } = renderDialog({ items: [stringItem] });
const { stdin, waitUntilReady, unmount } = await renderDialog({
items: [stringItem],
});
// Enter edit mode
await act(async () => {
stdin.write(TerminalKeys.ENTER);
});
await waitUntilReady();
// Type some characters
await act(async () => {
stdin.write('x');
});
await waitUntilReady();
// Commit with Enter
await act(async () => {
stdin.write(TerminalKeys.ENTER);
});
await waitUntilReady();
await waitFor(() => {
expect(mockOnEditCommit).toHaveBeenCalledWith(
@@ -498,100 +563,127 @@ describe('BaseSettingsDialog', () => {
expect.objectContaining({ type: 'string' }),
);
});
unmount();
});
it('should commit edit on Escape', async () => {
const items = createMockItems(4);
const stringItem = items.find((i) => i.type === 'string')!;
const { stdin } = renderDialog({ items: [stringItem] });
const { stdin, waitUntilReady, unmount } = await renderDialog({
items: [stringItem],
});
// Enter edit mode
await act(async () => {
stdin.write(TerminalKeys.ENTER);
});
await waitUntilReady();
// Commit with Escape
await act(async () => {
stdin.write(TerminalKeys.ESCAPE);
});
// Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act
await act(async () => {
await waitUntilReady();
});
await waitFor(() => {
expect(mockOnEditCommit).toHaveBeenCalled();
});
unmount();
});
it('should commit edit and navigate on Down arrow', async () => {
const items = createMockItems(4);
const stringItem = items.find((i) => i.type === 'string')!;
const numberItem = items.find((i) => i.type === 'number')!;
const { stdin } = renderDialog({ items: [stringItem, numberItem] });
const { stdin, waitUntilReady, unmount } = await renderDialog({
items: [stringItem, numberItem],
});
// Enter edit mode
await act(async () => {
stdin.write(TerminalKeys.ENTER);
});
await waitUntilReady();
// Press Down to commit and navigate
await act(async () => {
stdin.write(TerminalKeys.DOWN_ARROW);
});
await waitUntilReady();
await waitFor(() => {
expect(mockOnEditCommit).toHaveBeenCalled();
});
unmount();
});
it('should commit edit and navigate on Up arrow', async () => {
const items = createMockItems(4);
const stringItem = items.find((i) => i.type === 'string')!;
const numberItem = items.find((i) => i.type === 'number')!;
const { stdin } = renderDialog({ items: [stringItem, numberItem] });
const { stdin, waitUntilReady, unmount } = await renderDialog({
items: [stringItem, numberItem],
});
// Navigate to second item
await act(async () => {
stdin.write(TerminalKeys.DOWN_ARROW);
});
await waitUntilReady();
// Enter edit mode
await act(async () => {
stdin.write(TerminalKeys.ENTER);
});
await waitUntilReady();
// Press Up to commit and navigate
await act(async () => {
stdin.write(TerminalKeys.UP_ARROW);
});
await waitUntilReady();
await waitFor(() => {
expect(mockOnEditCommit).toHaveBeenCalled();
});
unmount();
});
it('should allow number input for number fields', async () => {
const items = createMockItems(4);
const numberItem = items.find((i) => i.type === 'number')!;
const { stdin } = renderDialog({ items: [numberItem] });
const { stdin, waitUntilReady, unmount } = await renderDialog({
items: [numberItem],
});
// Enter edit mode
await act(async () => {
stdin.write(TerminalKeys.ENTER);
});
await waitUntilReady();
// Type numbers one at a time
await act(async () => {
stdin.write('1');
});
await waitUntilReady();
await act(async () => {
stdin.write('2');
});
await waitUntilReady();
await act(async () => {
stdin.write('3');
});
await waitUntilReady();
// Commit
await act(async () => {
stdin.write(TerminalKeys.ENTER);
});
await waitUntilReady();
await waitFor(() => {
expect(mockOnEditCommit).toHaveBeenCalledWith(
@@ -600,24 +692,29 @@ describe('BaseSettingsDialog', () => {
expect.objectContaining({ type: 'number' }),
);
});
unmount();
});
it('should support quick number entry for number fields', async () => {
const items = createMockItems(4);
const numberItem = items.find((i) => i.type === 'number')!;
const { stdin } = renderDialog({ items: [numberItem] });
const { stdin, waitUntilReady, unmount } = await renderDialog({
items: [numberItem],
});
// Type a number directly (without Enter first)
await act(async () => {
stdin.write('5');
});
await waitUntilReady();
// Should start editing with that number
await waitFor(() => {
await waitFor(async () => {
// Commit to verify
act(() => {
await act(async () => {
stdin.write(TerminalKeys.ENTER);
});
await waitUntilReady();
});
await waitFor(() => {
@@ -627,13 +724,14 @@ describe('BaseSettingsDialog', () => {
expect.objectContaining({ type: 'number' }),
);
});
unmount();
});
});
describe('custom key handling', () => {
it('should call onKeyPress and respect its return value', async () => {
const customKeyHandler = vi.fn().mockReturnValue(true);
const { stdin } = renderDialog({
const { stdin, waitUntilReady, unmount } = await renderDialog({
onKeyPress: customKeyHandler,
});
@@ -641,6 +739,7 @@ describe('BaseSettingsDialog', () => {
await act(async () => {
stdin.write('r');
});
await waitUntilReady();
await waitFor(() => {
expect(customKeyHandler).toHaveBeenCalled();
@@ -648,12 +747,13 @@ describe('BaseSettingsDialog', () => {
// Since handler returned true, default behavior should be blocked
expect(mockOnClose).not.toHaveBeenCalled();
unmount();
});
});
describe('focus management', () => {
it('should keep focus on settings when scope selector is hidden', async () => {
const { lastFrame, stdin } = renderDialog({
const { lastFrame, stdin, waitUntilReady, unmount } = await renderDialog({
showScopeSelector: false,
});
@@ -661,11 +761,13 @@ describe('BaseSettingsDialog', () => {
await act(async () => {
stdin.write(TerminalKeys.TAB);
});
await waitUntilReady();
await waitFor(() => {
// Should still show settings as focused
expect(lastFrame()).toContain('> Test Settings');
});
unmount();
});
});
});

View File

@@ -61,7 +61,7 @@ describe('DescriptiveRadioButtonSelect', () => {
},
];
const renderComponent = (
const renderComponent = async (
props: Partial<DescriptiveRadioButtonSelectProps<string>> = {},
) => {
const defaultProps: DescriptiveRadioButtonSelectProps<string> = {
@@ -69,22 +69,25 @@ describe('DescriptiveRadioButtonSelect', () => {
onSelect: mockOnSelect,
...props,
};
return renderWithProviders(
const result = renderWithProviders(
<DescriptiveRadioButtonSelect {...defaultProps} />,
);
await result.waitUntilReady();
return result;
};
beforeEach(() => {
vi.clearAllMocks();
});
it('should render correctly with default props', () => {
const { lastFrame } = renderComponent();
it('should render correctly with default props', async () => {
const { lastFrame, unmount } = await renderComponent();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should render correctly with custom props', () => {
const { lastFrame } = renderComponent({
it('should render correctly with custom props', async () => {
const { lastFrame, unmount } = await renderComponent({
initialIndex: 1,
isFocused: false,
showScrollArrows: true,
@@ -93,5 +96,6 @@ describe('DescriptiveRadioButtonSelect', () => {
onHighlight: mockOnHighlight,
});
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});

View File

@@ -8,6 +8,7 @@ import { renderWithProviders } from '../../../test-utils/render.js';
import { EnumSelector } from './EnumSelector.js';
import type { SettingEnumOption } from '../../../config/settingsSchema.js';
import { describe, it, expect } from 'vitest';
import { act } from 'react';
const LANGUAGE_OPTIONS: readonly SettingEnumOption[] = [
{ label: 'English', value: 'en' },
@@ -23,130 +24,152 @@ const NUMERIC_OPTIONS: readonly SettingEnumOption[] = [
];
describe('<EnumSelector />', () => {
it('renders with string options and matches snapshot', () => {
const { lastFrame } = renderWithProviders(
it('renders with string options and matches snapshot', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<EnumSelector
options={LANGUAGE_OPTIONS}
currentValue="en"
isActive={true}
onValueChange={() => {}}
onValueChange={async () => {}}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders with numeric options and matches snapshot', () => {
const { lastFrame } = renderWithProviders(
it('renders with numeric options and matches snapshot', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<EnumSelector
options={NUMERIC_OPTIONS}
currentValue={2}
isActive={true}
onValueChange={() => {}}
onValueChange={async () => {}}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders inactive state and matches snapshot', () => {
const { lastFrame } = renderWithProviders(
it('renders inactive state and matches snapshot', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<EnumSelector
options={LANGUAGE_OPTIONS}
currentValue="zh"
isActive={false}
onValueChange={() => {}}
onValueChange={async () => {}}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders with single option and matches snapshot', () => {
it('renders with single option and matches snapshot', async () => {
const singleOption: readonly SettingEnumOption[] = [
{ label: 'Only Option', value: 'only' },
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<EnumSelector
options={singleOption}
currentValue="only"
isActive={true}
onValueChange={() => {}}
onValueChange={async () => {}}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders nothing when no options are provided', () => {
const { lastFrame } = renderWithProviders(
it('renders nothing when no options are provided', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<EnumSelector
options={[]}
currentValue=""
isActive={true}
onValueChange={() => {}}
onValueChange={async () => {}}
/>,
);
expect(lastFrame()).toBe('');
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('handles currentValue not found in options', () => {
const { lastFrame } = renderWithProviders(
it('handles currentValue not found in options', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<EnumSelector
options={LANGUAGE_OPTIONS}
currentValue="invalid"
isActive={true}
onValueChange={() => {}}
onValueChange={async () => {}}
/>,
);
await waitUntilReady();
// Should default to first option
expect(lastFrame()).toContain('English');
unmount();
});
it('updates when currentValue changes externally', () => {
const { rerender, lastFrame } = renderWithProviders(
<EnumSelector
options={LANGUAGE_OPTIONS}
currentValue="en"
isActive={true}
onValueChange={() => {}}
/>,
);
it('updates when currentValue changes externally', async () => {
const { rerender, lastFrame, waitUntilReady, unmount } =
renderWithProviders(
<EnumSelector
options={LANGUAGE_OPTIONS}
currentValue="en"
isActive={true}
onValueChange={async () => {}}
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('English');
rerender(
<EnumSelector
options={LANGUAGE_OPTIONS}
currentValue="zh"
isActive={true}
onValueChange={() => {}}
/>,
);
await act(async () => {
rerender(
<EnumSelector
options={LANGUAGE_OPTIONS}
currentValue="zh"
isActive={true}
onValueChange={async () => {}}
/>,
);
});
await waitUntilReady();
expect(lastFrame()).toContain('中文 (简体)');
unmount();
});
it('shows navigation arrows when multiple options available', () => {
const { lastFrame } = renderWithProviders(
it('shows navigation arrows when multiple options available', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<EnumSelector
options={LANGUAGE_OPTIONS}
currentValue="en"
isActive={true}
onValueChange={() => {}}
onValueChange={async () => {}}
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('←');
expect(lastFrame()).toContain('→');
unmount();
});
it('hides navigation arrows when single option available', () => {
it('hides navigation arrows when single option available', async () => {
const singleOption: readonly SettingEnumOption[] = [
{ label: 'Only Option', value: 'only' },
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<EnumSelector
options={singleOption}
currentValue="only"
isActive={true}
onValueChange={() => {}}
onValueChange={async () => {}}
/>,
);
await waitUntilReady();
expect(lastFrame()).not.toContain('←');
expect(lastFrame()).not.toContain('→');
unmount();
});
});

View File

@@ -13,8 +13,8 @@ describe('ExpandableText', () => {
const color = 'white';
const flat = (s: string | undefined) => (s ?? '').replace(/\n/g, '');
it('renders plain label when no match (short label)', () => {
const { lastFrame, unmount } = render(
it('renders plain label when no match (short label)', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<ExpandableText
label="simple command"
userInput=""
@@ -23,13 +23,14 @@ describe('ExpandableText', () => {
isExpanded={false}
/>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('truncates long label when collapsed and no match', () => {
it('truncates long label when collapsed and no match', async () => {
const long = 'x'.repeat(MAX_WIDTH + 25);
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<ExpandableText
label={long}
userInput=""
@@ -37,6 +38,7 @@ describe('ExpandableText', () => {
isExpanded={false}
/>,
);
await waitUntilReady();
const out = lastFrame();
const f = flat(out);
expect(f.endsWith('...')).toBe(true);
@@ -45,9 +47,9 @@ describe('ExpandableText', () => {
unmount();
});
it('shows full long label when expanded and no match', () => {
it('shows full long label when expanded and no match', async () => {
const long = 'y'.repeat(MAX_WIDTH + 25);
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<ExpandableText
label={long}
userInput=""
@@ -55,6 +57,7 @@ describe('ExpandableText', () => {
isExpanded={true}
/>,
);
await waitUntilReady();
const out = lastFrame();
const f = flat(out);
expect(f.length).toBe(long.length);
@@ -62,11 +65,11 @@ describe('ExpandableText', () => {
unmount();
});
it('highlights matched substring when expanded (text only visible)', () => {
it('highlights matched substring when expanded (text only visible)', async () => {
const label = 'run: git commit -m "feat: add search"';
const userInput = 'commit';
const matchedIndex = label.indexOf(userInput);
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<ExpandableText
label={label}
userInput={userInput}
@@ -76,18 +79,19 @@ describe('ExpandableText', () => {
/>,
100,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
expect(lastFrame()).toContain(chalk.inverse(userInput));
unmount();
});
it('creates centered window around match when collapsed', () => {
it('creates centered window around match when collapsed', async () => {
const prefix = 'cd_/very/long/path/that/keeps/going/'.repeat(3);
const core = 'search-here';
const suffix = '/and/then/some/more/components/'.repeat(3);
const label = prefix + core + suffix;
const matchedIndex = prefix.length;
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<ExpandableText
label={label}
userInput={core}
@@ -97,6 +101,7 @@ describe('ExpandableText', () => {
/>,
100,
);
await waitUntilReady();
const out = lastFrame();
const f = flat(out);
expect(f.includes(core)).toBe(true);
@@ -106,13 +111,13 @@ describe('ExpandableText', () => {
unmount();
});
it('truncates match itself when match is very long', () => {
it('truncates match itself when match is very long', async () => {
const prefix = 'find ';
const core = 'x'.repeat(MAX_WIDTH + 25);
const suffix = ' in this text';
const label = prefix + core + suffix;
const matchedIndex = prefix.length;
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<ExpandableText
label={label}
userInput={core}
@@ -121,6 +126,7 @@ describe('ExpandableText', () => {
isExpanded={false}
/>,
);
await waitUntilReady();
const out = lastFrame();
const f = flat(out);
expect(f.includes('...')).toBe(true);
@@ -131,10 +137,10 @@ describe('ExpandableText', () => {
unmount();
});
it('respects custom maxWidth', () => {
it('respects custom maxWidth', async () => {
const customWidth = 50;
const long = 'z'.repeat(100);
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<ExpandableText
label={long}
userInput=""
@@ -143,6 +149,7 @@ describe('ExpandableText', () => {
maxWidth={customWidth}
/>,
);
await waitUntilReady();
const out = lastFrame();
const f = flat(out);
expect(f.endsWith('...')).toBe(true);

View File

@@ -28,12 +28,13 @@ describe('<HalfLinePaddedBox />', () => {
it('renders standard background and blocks when not iTerm2', async () => {
vi.mocked(isITerm2).mockReturnValue(false);
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HalfLinePaddedBox backgroundBaseColor="blue" backgroundOpacity={0.5}>
<Text>Content</Text>
</HalfLinePaddedBox>,
{ width: 10 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
@@ -43,12 +44,13 @@ describe('<HalfLinePaddedBox />', () => {
it('renders iTerm2-specific blocks when iTerm2 is detected', async () => {
vi.mocked(isITerm2).mockReturnValue(true);
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HalfLinePaddedBox backgroundBaseColor="blue" backgroundOpacity={0.5}>
<Text>Content</Text>
</HalfLinePaddedBox>,
{ width: 10 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
@@ -56,7 +58,7 @@ describe('<HalfLinePaddedBox />', () => {
});
it('renders nothing when useBackgroundColor is false', async () => {
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HalfLinePaddedBox
backgroundBaseColor="blue"
backgroundOpacity={0.5}
@@ -66,6 +68,7 @@ describe('<HalfLinePaddedBox />', () => {
</HalfLinePaddedBox>,
{ width: 10 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
@@ -75,12 +78,13 @@ describe('<HalfLinePaddedBox />', () => {
it('renders nothing when screen reader is enabled', async () => {
mockUseIsScreenReaderEnabled.mockReturnValue(true);
const { lastFrame, unmount } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HalfLinePaddedBox backgroundBaseColor="blue" backgroundOpacity={0.5}>
<Text>Content</Text>
</HalfLinePaddedBox>,
{ width: 10 },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();

View File

@@ -5,7 +5,6 @@
*/
import { render, renderWithProviders } from '../../../test-utils/render.js';
import { waitFor } from '../../../test-utils/async.js';
import { OverflowProvider } from '../../contexts/OverflowContext.js';
import { MaxSizedBox } from './MaxSizedBox.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
@@ -14,7 +13,7 @@ import { describe, it, expect } from 'vitest';
describe('<MaxSizedBox />', () => {
it('renders children without truncation when they fit', async () => {
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={10}>
<Box>
@@ -23,13 +22,14 @@ describe('<MaxSizedBox />', () => {
</MaxSizedBox>
</OverflowProvider>,
);
await waitFor(() => expect(lastFrame()).toContain('Hello, World!'));
await waitUntilReady();
expect(lastFrame()).toContain('Hello, World!');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('hides lines when content exceeds maxHeight', async () => {
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2}>
<Box flexDirection="column">
@@ -40,15 +40,14 @@ describe('<MaxSizedBox />', () => {
</MaxSizedBox>
</OverflowProvider>,
);
await waitFor(() =>
expect(lastFrame()).toContain('... first 2 lines hidden ...'),
);
await waitUntilReady();
expect(lastFrame()).toContain('... first 2 lines hidden ...');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('hides lines at the end when content exceeds maxHeight and overflowDirection is bottom', async () => {
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2} overflowDirection="bottom">
<Box flexDirection="column">
@@ -59,15 +58,14 @@ describe('<MaxSizedBox />', () => {
</MaxSizedBox>
</OverflowProvider>,
);
await waitFor(() =>
expect(lastFrame()).toContain('... last 2 lines hidden ...'),
);
await waitUntilReady();
expect(lastFrame()).toContain('... last 2 lines hidden ...');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('shows plural "lines" when more than one line is hidden', async () => {
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2}>
<Box flexDirection="column">
@@ -78,15 +76,14 @@ describe('<MaxSizedBox />', () => {
</MaxSizedBox>
</OverflowProvider>,
);
await waitFor(() =>
expect(lastFrame()).toContain('... first 2 lines hidden ...'),
);
await waitUntilReady();
expect(lastFrame()).toContain('... first 2 lines hidden ...');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('shows singular "line" when exactly one line is hidden', async () => {
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2} additionalHiddenLinesCount={1}>
<Box flexDirection="column">
@@ -95,15 +92,14 @@ describe('<MaxSizedBox />', () => {
</MaxSizedBox>
</OverflowProvider>,
);
await waitFor(() =>
expect(lastFrame()).toContain('... first 1 line hidden ...'),
);
await waitUntilReady();
expect(lastFrame()).toContain('... first 1 line hidden ...');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('accounts for additionalHiddenLinesCount', async () => {
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={2} additionalHiddenLinesCount={5}>
<Box flexDirection="column">
@@ -114,15 +110,14 @@ describe('<MaxSizedBox />', () => {
</MaxSizedBox>
</OverflowProvider>,
);
await waitFor(() =>
expect(lastFrame()).toContain('... first 7 lines hidden ...'),
);
await waitUntilReady();
expect(lastFrame()).toContain('... first 7 lines hidden ...');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('wraps text that exceeds maxWidth', async () => {
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={10} maxHeight={5}>
<Box>
@@ -132,13 +127,14 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await waitFor(() => expect(lastFrame()).toContain('This is a'));
await waitUntilReady();
expect(lastFrame()).toContain('This is a');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('does not truncate when maxHeight is undefined', async () => {
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={undefined}>
<Box flexDirection="column">
@@ -148,25 +144,25 @@ describe('<MaxSizedBox />', () => {
</MaxSizedBox>
</OverflowProvider>,
);
await waitFor(() => expect(lastFrame()).toContain('Line 1'));
await waitUntilReady();
expect(lastFrame()).toContain('Line 1');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders an empty box for empty children', async () => {
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={10}></MaxSizedBox>
</OverflowProvider>,
);
// Use waitFor to ensure ResizeObserver has a chance to run
await waitFor(() => expect(lastFrame()).toBeDefined());
expect(lastFrame()?.trim()).equals('');
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })?.trim()).equals('');
unmount();
});
it('handles React.Fragment as a child', async () => {
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={10}>
<Box flexDirection="column">
@@ -179,7 +175,8 @@ describe('<MaxSizedBox />', () => {
</MaxSizedBox>
</OverflowProvider>,
);
await waitFor(() => expect(lastFrame()).toContain('Line 1 from Fragment'));
await waitUntilReady();
expect(lastFrame()).toContain('Line 1 from Fragment');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -189,7 +186,7 @@ describe('<MaxSizedBox />', () => {
{ length: 30 },
(_, i) => `Line ${i + 1}`,
).join('\n');
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={10} overflowDirection="top">
<Box>
@@ -199,9 +196,8 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await waitFor(() =>
expect(lastFrame()).toContain('... first 21 lines hidden ...'),
);
await waitUntilReady();
expect(lastFrame()).toContain('... first 21 lines hidden ...');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -211,7 +207,7 @@ describe('<MaxSizedBox />', () => {
{ length: 30 },
(_, i) => `Line ${i + 1}`,
).join('\n');
const { lastFrame, unmount } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<OverflowProvider>
<MaxSizedBox maxWidth={80} maxHeight={10} overflowDirection="bottom">
<Box>
@@ -221,9 +217,8 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await waitFor(() =>
expect(lastFrame()).toContain('... last 21 lines hidden ...'),
);
await waitUntilReady();
expect(lastFrame()).toContain('... last 21 lines hidden ...');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -233,7 +228,7 @@ describe('<MaxSizedBox />', () => {
{ length: 20 },
(_, i) => `- Step ${i + 1}: Do something important`,
).join('\n');
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<MaxSizedBox maxWidth={80} maxHeight={5} overflowDirection="bottom">
<MarkdownDisplay
text={`## Plan\n\n${markdownContent}`}
@@ -244,14 +239,16 @@ describe('<MaxSizedBox />', () => {
{ width: 80 },
);
await waitFor(() => expect(lastFrame()).toContain('... last'));
await waitUntilReady();
expect(lastFrame()).toContain('... last');
const frame = lastFrame()!;
const lines = frame.split('\n');
const frame = lastFrame();
const lines = frame.trim().split('\n');
const lastLine = lines[lines.length - 1];
// The last line should only contain the hidden indicator, no leaked content
expect(lastLine).toMatch(/^\.\.\. last \d+ lines? hidden \.\.\.$/);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});

View File

@@ -37,50 +37,56 @@ describe('<Scrollable />', () => {
vi.restoreAllMocks();
});
it('renders children', () => {
const { lastFrame } = renderWithProviders(
it('renders children', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Scrollable hasFocus={false} height={5}>
<Text>Hello World</Text>
</Scrollable>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Hello World');
unmount();
});
it('renders multiple children', () => {
const { lastFrame } = renderWithProviders(
it('renders multiple children', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Scrollable hasFocus={false} height={5}>
<Text>Line 1</Text>
<Text>Line 2</Text>
<Text>Line 3</Text>
</Scrollable>,
);
await waitUntilReady();
expect(lastFrame()).toContain('Line 1');
expect(lastFrame()).toContain('Line 2');
expect(lastFrame()).toContain('Line 3');
unmount();
});
it('matches snapshot', () => {
const { lastFrame } = renderWithProviders(
it('matches snapshot', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<Scrollable hasFocus={false} height={5}>
<Text>Line 1</Text>
<Text>Line 2</Text>
<Text>Line 3</Text>
</Scrollable>,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('updates scroll position correctly when scrollBy is called multiple times in the same tick', () => {
it('updates scroll position correctly when scrollBy is called multiple times in the same tick', async () => {
let capturedEntry: ScrollProviderModule.ScrollableEntry | undefined;
vi.spyOn(ScrollProviderModule, 'useScrollable').mockImplementation(
(entry, isActive) => {
async (entry, isActive) => {
if (isActive) {
capturedEntry = entry as ScrollProviderModule.ScrollableEntry;
}
},
);
renderWithProviders(
const { waitUntilReady, unmount } = renderWithProviders(
<Scrollable hasFocus={true} height={5}>
<Text>Line 1</Text>
<Text>Line 2</Text>
@@ -94,6 +100,7 @@ describe('<Scrollable />', () => {
<Text>Line 10</Text>
</Scrollable>,
);
await waitUntilReady();
expect(capturedEntry).toBeDefined();
@@ -105,17 +112,18 @@ describe('<Scrollable />', () => {
expect(capturedEntry.getScrollState().scrollTop).toBe(5);
// Call scrollBy multiple times (upwards) in the same tick
act(() => {
await act(async () => {
capturedEntry!.scrollBy(-1);
capturedEntry!.scrollBy(-1);
});
// Should have moved up by 2
expect(capturedEntry.getScrollState().scrollTop).toBe(3);
act(() => {
await act(async () => {
capturedEntry!.scrollBy(-2);
});
expect(capturedEntry.getScrollState().scrollTop).toBe(1);
unmount();
});
describe('keypress handling', () => {
@@ -169,21 +177,22 @@ describe('<Scrollable />', () => {
let capturedEntry: ScrollProviderModule.ScrollableEntry | undefined;
vi.spyOn(ScrollProviderModule, 'useScrollable').mockImplementation(
(entry, isActive) => {
async (entry, isActive) => {
if (isActive) {
capturedEntry = entry as ScrollProviderModule.ScrollableEntry;
}
},
);
const { stdin } = renderWithProviders(
const { stdin, waitUntilReady, unmount } = renderWithProviders(
<Scrollable hasFocus={true} height={5}>
<Text>Content</Text>
</Scrollable>,
);
await waitUntilReady();
// Ensure initial state using existing scrollBy method
act(() => {
await act(async () => {
// Reset to top first, then scroll to desired start position
capturedEntry!.scrollBy(-100);
if (initialScrollTop > 0) {
@@ -194,13 +203,15 @@ describe('<Scrollable />', () => {
initialScrollTop,
);
act(() => {
await act(async () => {
stdin.write(keySequence);
});
await waitUntilReady();
expect(capturedEntry!.getScrollState().scrollTop).toBe(
expectedScrollTop,
);
unmount();
},
);
});

View File

@@ -5,13 +5,13 @@
*/
import { useState, useEffect, useRef, act } from 'react';
import { render } from 'ink-testing-library';
import { render } from '../../../test-utils/render.js';
import { Box, Text } from 'ink';
import { ScrollableList, type ScrollableListRef } from './ScrollableList.js';
import { ScrollProvider } from '../../contexts/ScrollProvider.js';
import { KeypressProvider } from '../../contexts/KeypressContext.js';
import { MouseProvider } from '../../contexts/MouseContext.js';
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { waitFor } from '../../../test-utils/async.js';
vi.mock('../../contexts/UIStateContext.js', () => ({
@@ -133,10 +133,19 @@ const TestComponent = ({
);
};
describe('ScrollableList Demo Behavior', () => {
beforeEach(() => {
vi.stubEnv('NODE_ENV', 'test');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('should scroll to bottom when new items are added and stop when scrolled up', async () => {
let addItem: (() => void) | undefined;
let listRef: ScrollableListRef<Item> | null = null;
let lastFrame: () => string | undefined;
let lastFrame: (options?: { allowEmpty?: boolean }) => string | undefined;
let waitUntilReady: () => Promise<void>;
let result: ReturnType<typeof render>;
@@ -146,14 +155,17 @@ describe('ScrollableList Demo Behavior', () => {
onAddItem={(add) => {
addItem = add;
}}
onRef={(ref) => {
onRef={async (ref) => {
listRef = ref;
}}
/>,
);
lastFrame = result.lastFrame;
waitUntilReady = result.waitUntilReady;
});
await waitUntilReady!();
// Initial render should show Item 1000
expect(lastFrame!()).toContain('Item 1000');
expect(lastFrame!()).toContain('Count: 1000');
@@ -162,6 +174,8 @@ describe('ScrollableList Demo Behavior', () => {
await act(async () => {
addItem?.();
});
await waitUntilReady!();
await waitFor(() => {
expect(lastFrame!()).toContain('Count: 1001');
});
@@ -172,6 +186,8 @@ describe('ScrollableList Demo Behavior', () => {
await act(async () => {
addItem?.();
});
await waitUntilReady!();
await waitFor(() => {
expect(lastFrame!()).toContain('Count: 1002');
});
@@ -182,14 +198,14 @@ describe('ScrollableList Demo Behavior', () => {
await act(async () => {
listRef?.scrollBy(-5);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 100));
});
await waitUntilReady!();
// Add item 1003 - should NOT be visible because we scrolled up
await act(async () => {
addItem?.();
});
await waitUntilReady!();
await waitFor(() => {
expect(lastFrame!()).toContain('Count: 1003');
});
@@ -249,12 +265,16 @@ describe('ScrollableList Demo Behavior', () => {
};
let lastFrame: () => string | undefined;
let waitUntilReady: () => Promise<void>;
let result: ReturnType<typeof render>;
await act(async () => {
result = render(<StickyTestComponent />);
lastFrame = result.lastFrame;
waitUntilReady = result.waitUntilReady;
});
await waitUntilReady!();
// Initially at top, should see Normal Item 1
await waitFor(() => {
expect(lastFrame!()).toContain('[Normal] Item 1');
@@ -265,6 +285,7 @@ describe('ScrollableList Demo Behavior', () => {
await act(async () => {
listRef?.scrollBy(2);
});
await waitUntilReady!();
// Now Item 1 should be stuck
await waitFor(() => {
@@ -278,6 +299,7 @@ describe('ScrollableList Demo Behavior', () => {
await act(async () => {
listRef?.scrollTo(10);
});
await waitUntilReady!();
await waitFor(() => {
expect(lastFrame!()).not.toContain('[STICKY] Item 1');
@@ -287,6 +309,7 @@ describe('ScrollableList Demo Behavior', () => {
await act(async () => {
listRef?.scrollTo(0);
});
await waitUntilReady!();
// Should be normal again
await waitFor(() => {
@@ -302,8 +325,9 @@ describe('ScrollableList Demo Behavior', () => {
describe('Keyboard Navigation', () => {
it('should handle scroll keys correctly', async () => {
let listRef: ScrollableListRef<Item> | null = null;
let lastFrame: () => string | undefined;
let lastFrame: (options?: { allowEmpty?: boolean }) => string | undefined;
let stdin: { write: (data: string) => void };
let waitUntilReady: () => Promise<void>;
const items = Array.from({ length: 50 }, (_, i) => ({
id: String(i),
@@ -334,8 +358,11 @@ describe('ScrollableList Demo Behavior', () => {
);
lastFrame = result.lastFrame;
stdin = result.stdin;
waitUntilReady = result.waitUntilReady;
});
await waitUntilReady!();
// Initial state
expect(lastFrame!()).toContain('Item 0');
expect(listRef).toBeDefined();
@@ -345,6 +372,8 @@ describe('ScrollableList Demo Behavior', () => {
await act(async () => {
stdin.write('\x1b[b');
});
await waitUntilReady!();
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBeGreaterThan(0);
});
@@ -353,6 +382,8 @@ describe('ScrollableList Demo Behavior', () => {
await act(async () => {
stdin.write('\x1b[a');
});
await waitUntilReady!();
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(0);
});
@@ -361,6 +392,8 @@ describe('ScrollableList Demo Behavior', () => {
await act(async () => {
stdin.write('\x1b[6~');
});
await waitUntilReady!();
await waitFor(() => {
// Height is 10, so should scroll ~10 units
expect(listRef?.getScrollState()?.scrollTop).toBeGreaterThanOrEqual(9);
@@ -370,6 +403,8 @@ describe('ScrollableList Demo Behavior', () => {
await act(async () => {
stdin.write('\x1b[5~');
});
await waitUntilReady!();
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBeLessThan(2);
});
@@ -378,6 +413,8 @@ describe('ScrollableList Demo Behavior', () => {
await act(async () => {
stdin.write('\x1b[1;5F');
});
await waitUntilReady!();
await waitFor(() => {
// Total 50 items, height 10. Max scroll ~40.
expect(listRef?.getScrollState()?.scrollTop).toBeGreaterThan(30);
@@ -387,11 +424,15 @@ describe('ScrollableList Demo Behavior', () => {
await act(async () => {
stdin.write('\x1b[1;5H');
});
await waitUntilReady!();
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(0);
});
await act(async () => {
// Let the scrollbar fade out animation finish
await new Promise((resolve) => setTimeout(resolve, 1600));
result.unmount();
});
});
@@ -400,7 +441,8 @@ describe('ScrollableList Demo Behavior', () => {
describe('Width Prop', () => {
it('should apply the width prop to the container', async () => {
const items = [{ id: '1', title: 'Item 1' }];
let lastFrame: () => string | undefined;
let lastFrame: (options?: { allowEmpty?: boolean }) => string | undefined;
let waitUntilReady: () => Promise<void>;
let result: ReturnType<typeof render>;
await act(async () => {
@@ -423,8 +465,11 @@ describe('ScrollableList Demo Behavior', () => {
</MouseProvider>,
);
lastFrame = result.lastFrame;
waitUntilReady = result.waitUntilReady;
});
await waitUntilReady!();
await waitFor(() => {
expect(lastFrame()).toContain('Item 1');
});

View File

@@ -108,7 +108,10 @@ function ScrollableList<T>(
useEffect(() => stopSmoothScroll, [stopSmoothScroll]);
const smoothScrollTo = useCallback(
(targetScrollTop: number, duration: number = 200) => {
(
targetScrollTop: number,
duration: number = process.env['NODE_ENV'] === 'test' ? 0 : 200,
) => {
stopSmoothScroll();
const scrollState = virtualizedListRef.current?.getScrollState() ?? {

View File

@@ -65,8 +65,9 @@ describe('SearchableList', () => {
);
};
it('should render all items initially', () => {
const { lastFrame } = renderList();
it('should render all items initially', async () => {
const { lastFrame, waitUntilReady } = renderList();
await waitUntilReady();
const frame = lastFrame();
// Check for title

View File

@@ -30,11 +30,12 @@ describe('<SectionHeader />', () => {
title: 'Narrow Container',
width: 25,
},
])('$description', ({ title, width }) => {
const { lastFrame, unmount } = renderWithProviders(
])('$description', async ({ title, width }) => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<SectionHeader title={title} />,
{ width },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();

View File

@@ -16,90 +16,105 @@ const MOCK_TABS: Tab[] = [
describe('TabHeader', () => {
describe('rendering', () => {
it('renders null for single tab', () => {
const { lastFrame } = renderWithProviders(
it('renders null for single tab', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<TabHeader
tabs={[{ key: '0', header: 'Only Tab' }]}
currentIndex={0}
/>,
);
expect(lastFrame()).toBe('');
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('renders all tab headers', () => {
const { lastFrame } = renderWithProviders(
it('renders all tab headers', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<TabHeader tabs={MOCK_TABS} currentIndex={0} />,
);
await waitUntilReady();
const frame = lastFrame();
expect(frame).toContain('Tab 1');
expect(frame).toContain('Tab 2');
expect(frame).toContain('Tab 3');
expect(frame).toMatchSnapshot();
unmount();
});
it('renders separators between tabs', () => {
const { lastFrame } = renderWithProviders(
it('renders separators between tabs', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<TabHeader tabs={MOCK_TABS} currentIndex={0} />,
);
await waitUntilReady();
const frame = lastFrame();
// Should have 2 separators for 3 tabs
const separatorCount = (frame?.match(/│/g) || []).length;
expect(separatorCount).toBe(2);
expect(frame).toMatchSnapshot();
unmount();
});
});
describe('arrows', () => {
it('shows arrows by default', () => {
const { lastFrame } = renderWithProviders(
it('shows arrows by default', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<TabHeader tabs={MOCK_TABS} currentIndex={0} />,
);
await waitUntilReady();
const frame = lastFrame();
expect(frame).toContain('←');
expect(frame).toContain('→');
expect(frame).toMatchSnapshot();
unmount();
});
it('hides arrows when showArrows is false', () => {
const { lastFrame } = renderWithProviders(
it('hides arrows when showArrows is false', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<TabHeader tabs={MOCK_TABS} currentIndex={0} showArrows={false} />,
);
await waitUntilReady();
const frame = lastFrame();
expect(frame).not.toContain('←');
expect(frame).not.toContain('→');
expect(frame).toMatchSnapshot();
unmount();
});
});
describe('status icons', () => {
it('shows status icons by default', () => {
const { lastFrame } = renderWithProviders(
it('shows status icons by default', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<TabHeader tabs={MOCK_TABS} currentIndex={0} />,
);
await waitUntilReady();
const frame = lastFrame();
// Default uncompleted icon is □
expect(frame).toContain('□');
expect(frame).toMatchSnapshot();
unmount();
});
it('hides status icons when showStatusIcons is false', () => {
const { lastFrame } = renderWithProviders(
it('hides status icons when showStatusIcons is false', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<TabHeader tabs={MOCK_TABS} currentIndex={0} showStatusIcons={false} />,
);
await waitUntilReady();
const frame = lastFrame();
expect(frame).not.toContain('□');
expect(frame).not.toContain('✓');
expect(frame).toMatchSnapshot();
unmount();
});
it('shows checkmark for completed tabs', () => {
const { lastFrame } = renderWithProviders(
it('shows checkmark for completed tabs', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<TabHeader
tabs={MOCK_TABS}
currentIndex={0}
completedIndices={new Set([0, 2])}
/>,
);
await waitUntilReady();
const frame = lastFrame();
// Should have 2 checkmarks and 1 box
const checkmarkCount = (frame?.match(/✓/g) || []).length;
@@ -107,62 +122,71 @@ describe('TabHeader', () => {
expect(checkmarkCount).toBe(2);
expect(boxCount).toBe(1);
expect(frame).toMatchSnapshot();
unmount();
});
it('shows special icon for special tabs', () => {
it('shows special icon for special tabs', async () => {
const tabsWithSpecial: Tab[] = [
{ key: '0', header: 'Tab 1' },
{ key: '1', header: 'Review', isSpecial: true },
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<TabHeader tabs={tabsWithSpecial} currentIndex={0} />,
);
await waitUntilReady();
const frame = lastFrame();
// Special tab shows ≡ icon
expect(frame).toContain('≡');
expect(frame).toMatchSnapshot();
unmount();
});
it('uses tab statusIcon when provided', () => {
it('uses tab statusIcon when provided', async () => {
const tabsWithCustomIcon: Tab[] = [
{ key: '0', header: 'Tab 1', statusIcon: '★' },
{ key: '1', header: 'Tab 2' },
];
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<TabHeader tabs={tabsWithCustomIcon} currentIndex={0} />,
);
await waitUntilReady();
const frame = lastFrame();
expect(frame).toContain('★');
expect(frame).toMatchSnapshot();
unmount();
});
it('uses custom renderStatusIcon when provided', () => {
it('uses custom renderStatusIcon when provided', async () => {
const renderStatusIcon = () => '•';
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<TabHeader
tabs={MOCK_TABS}
currentIndex={0}
renderStatusIcon={renderStatusIcon}
/>,
);
await waitUntilReady();
const frame = lastFrame();
const bulletCount = (frame?.match(/•/g) || []).length;
expect(bulletCount).toBe(3);
expect(frame).toMatchSnapshot();
unmount();
});
it('falls back to default when renderStatusIcon returns undefined', () => {
it('falls back to default when renderStatusIcon returns undefined', async () => {
const renderStatusIcon = () => undefined;
const { lastFrame } = renderWithProviders(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<TabHeader
tabs={MOCK_TABS}
currentIndex={0}
renderStatusIcon={renderStatusIcon}
/>,
);
await waitUntilReady();
const frame = lastFrame();
expect(frame).toContain('□');
expect(frame).toMatchSnapshot();
unmount();
});
});
});

View File

@@ -5,7 +5,9 @@
*/
import { render } from '../../../test-utils/render.js';
import { waitFor } from '../../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { act } from 'react';
import { TextInput } from './TextInput.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { useTextBuffer, type TextBuffer } from './text-buffer.js';
@@ -114,7 +116,7 @@ describe('TextInput', () => {
mockedUseTextBuffer.mockReturnValue(mockBuffer);
});
it('renders with an initial value', () => {
it('renders with an initial value', async () => {
const buffer = {
text: 'test',
lines: ['test'],
@@ -124,17 +126,19 @@ describe('TextInput', () => {
handleInput: vi.fn(),
setText: vi.fn(),
};
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<TextInput
buffer={buffer as unknown as TextBuffer}
onSubmit={onSubmit}
onCancel={onCancel}
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('test');
unmount();
});
it('renders a placeholder', () => {
it('renders a placeholder', async () => {
const buffer = {
text: '',
lines: [''],
@@ -144,7 +148,7 @@ describe('TextInput', () => {
handleInput: vi.fn(),
setText: vi.fn(),
};
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<TextInput
buffer={buffer as unknown as TextBuffer}
placeholder="testing"
@@ -152,23 +156,29 @@ describe('TextInput', () => {
onCancel={onCancel}
/>,
);
await waitUntilReady();
expect(lastFrame()).toContain('testing');
unmount();
});
it('handles character input', () => {
render(
it('handles character input', async () => {
const { waitUntilReady, unmount } = render(
<TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({
name: 'a',
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: 'a',
await act(async () => {
keypressHandler({
name: 'a',
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: 'a',
});
});
await waitUntilReady();
expect(mockBuffer.handleInput).toHaveBeenCalledWith({
name: 'a',
@@ -179,23 +189,28 @@ describe('TextInput', () => {
sequence: 'a',
});
expect(mockBuffer.text).toBe('a');
unmount();
});
it('handles backspace', () => {
it('handles backspace', async () => {
mockBuffer.setText('test');
render(
const { waitUntilReady, unmount } = render(
<TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({
name: 'backspace',
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '',
await act(async () => {
keypressHandler({
name: 'backspace',
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '',
});
});
await waitUntilReady();
expect(mockBuffer.handleInput).toHaveBeenCalledWith({
name: 'backspace',
@@ -206,99 +221,126 @@ describe('TextInput', () => {
sequence: '',
});
expect(mockBuffer.text).toBe('tes');
unmount();
});
it('handles left arrow', () => {
it('handles left arrow', async () => {
mockBuffer.setText('test');
render(
const { waitUntilReady, unmount } = render(
<TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({
name: 'left',
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '',
await act(async () => {
keypressHandler({
name: 'left',
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '',
});
});
await waitUntilReady();
// Cursor moves from end to before 't'
expect(mockBuffer.visualCursor[1]).toBe(3);
unmount();
});
it('handles right arrow', () => {
it('handles right arrow', async () => {
mockBuffer.setText('test');
mockBuffer.visualCursor[1] = 2; // Set initial cursor for right arrow test
render(
const { waitUntilReady, unmount } = render(
<TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({
name: 'right',
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '',
await act(async () => {
keypressHandler({
name: 'right',
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '',
});
});
await waitUntilReady();
expect(mockBuffer.visualCursor[1]).toBe(3);
unmount();
});
it('calls onSubmit on return', () => {
it('calls onSubmit on return', async () => {
mockBuffer.setText('test');
render(
const { waitUntilReady, unmount } = render(
<TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({
name: 'return',
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '',
await act(async () => {
keypressHandler({
name: 'return',
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '',
});
});
await waitUntilReady();
expect(onSubmit).toHaveBeenCalledWith('test');
unmount();
});
it('calls onCancel on escape', async () => {
vi.useFakeTimers();
render(
const { waitUntilReady, unmount } = render(
<TextInput buffer={mockBuffer} onCancel={onCancel} onSubmit={onSubmit} />,
);
await waitUntilReady();
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({
name: 'escape',
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '',
await act(async () => {
keypressHandler({
name: 'escape',
shift: false,
alt: false,
ctrl: false,
cmd: false,
sequence: '',
});
});
// Escape key has a 50ms timeout in KeypressContext, so we need to wrap waitUntilReady in act
await act(async () => {
await waitUntilReady();
});
await vi.runAllTimersAsync();
expect(onCancel).toHaveBeenCalled();
await waitFor(() => {
expect(onCancel).toHaveBeenCalled();
});
vi.useRealTimers();
unmount();
});
it('renders the input value', () => {
it('renders the input value', async () => {
mockBuffer.setText('secret');
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('secret');
unmount();
});
it('does not show cursor when not focused', () => {
it('does not show cursor when not focused', async () => {
mockBuffer.setText('test');
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<TextInput
buffer={mockBuffer}
focus={false}
@@ -306,18 +348,22 @@ describe('TextInput', () => {
onCancel={onCancel}
/>,
);
await waitUntilReady();
expect(lastFrame()).not.toContain('\u001b[7m'); // Inverse video chalk
unmount();
});
it('renders multiple lines when text wraps', () => {
it('renders multiple lines when text wraps', async () => {
mockBuffer.text = 'line1\nline2';
mockBuffer.viewportVisualLines = ['line1', 'line2'];
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<TextInput buffer={mockBuffer} onSubmit={onSubmit} onCancel={onCancel} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('line1');
expect(lastFrame()).toContain('line2');
unmount();
});
});

View File

@@ -24,8 +24,6 @@ vi.mock('../../contexts/UIStateContext.js', () => ({
})),
}));
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
describe('<VirtualizedList />', () => {
const keyExtractor = (item: string) => item;
@@ -60,7 +58,7 @@ describe('<VirtualizedList />', () => {
])(
'renders only visible items ($name)',
async ({ initialScrollIndex, visible, notVisible }) => {
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<Box height={10} width={100} borderStyle="round">
<VirtualizedList
data={longData}
@@ -71,9 +69,7 @@ describe('<VirtualizedList />', () => {
/>
</Box>,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
const frame = lastFrame();
visible.forEach((item) => {
@@ -83,11 +79,12 @@ describe('<VirtualizedList />', () => {
expect(frame).not.toContain(item);
});
expect(frame).toMatchSnapshot();
unmount();
},
);
it('sticks to bottom when new items added', async () => {
const { lastFrame, rerender } = render(
const { lastFrame, rerender, waitUntilReady, unmount } = render(
<Box height={10} width={100} borderStyle="round">
<VirtualizedList
data={longData}
@@ -98,38 +95,37 @@ describe('<VirtualizedList />', () => {
/>
</Box>,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
expect(lastFrame()).toContain('Item 99');
// Add items
const newData = [...longData, 'Item 100', 'Item 101'];
rerender(
<Box height={10} width={100} borderStyle="round">
<VirtualizedList
data={newData}
renderItem={renderItem1px}
keyExtractor={keyExtractor}
estimatedItemHeight={() => itemHeight}
// We don't need to pass initialScrollIndex again for it to stick,
// but passing it doesn't hurt. The component should auto-stick because it was at bottom.
/>
</Box>,
);
await act(async () => {
await delay(0);
rerender(
<Box height={10} width={100} borderStyle="round">
<VirtualizedList
data={newData}
renderItem={renderItem1px}
keyExtractor={keyExtractor}
estimatedItemHeight={() => itemHeight}
// We don't need to pass initialScrollIndex again for it to stick,
// but passing it doesn't hurt. The component should auto-stick because it was at bottom.
/>
</Box>,
);
});
await waitUntilReady();
const frame = lastFrame();
expect(frame).toContain('Item 101');
expect(frame).not.toContain('Item 0');
unmount();
});
it('scrolls down to show new items when requested via ref', async () => {
const ref = createRef<VirtualizedListRef<string>>();
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<Box height={10} width={100} borderStyle="round">
<VirtualizedList
ref={ref}
@@ -140,20 +136,19 @@ describe('<VirtualizedList />', () => {
/>
</Box>,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
expect(lastFrame()).toContain('Item 0');
// Scroll to bottom via ref
await act(async () => {
ref.current?.scrollToEnd();
await delay(0);
});
await waitUntilReady();
const frame = lastFrame();
expect(frame).toContain('Item 99');
unmount();
});
it.each([
@@ -184,7 +179,7 @@ describe('<VirtualizedList />', () => {
(_, i) => `Item ${i}`,
);
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<Box height={20} width={100} borderStyle="round">
<VirtualizedList
data={veryLongData}
@@ -197,13 +192,12 @@ describe('<VirtualizedList />', () => {
/>
</Box>,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
const frame = lastFrame();
expect(mountedCount).toBe(expectedMountedCount);
expect(frame).toMatchSnapshot();
unmount();
},
);
});
@@ -267,10 +261,8 @@ describe('<VirtualizedList />', () => {
return null;
};
const { lastFrame } = render(<TestComponent />);
await act(async () => {
await delay(0);
});
const { lastFrame, waitUntilReady, unmount } = render(<TestComponent />);
await waitUntilReady();
// Initially, only Item 0 (height 10) fills the 10px viewport
expect(lastFrame()).toContain('Item 0');
@@ -279,13 +271,14 @@ describe('<VirtualizedList />', () => {
// Shrink Item 0 to 1px via context
await act(async () => {
setHeightFn(1);
await delay(0);
});
await waitUntilReady();
// Now Item 0 is 1px, so Items 1-9 should also be visible to fill 10px
expect(lastFrame()).toContain('Item 0');
expect(lastFrame()).toContain('Item 1');
expect(lastFrame()).toContain('Item 9');
unmount();
});
it('updates scroll position correctly when scrollBy is called multiple times in the same tick', async () => {
@@ -299,7 +292,7 @@ describe('<VirtualizedList />', () => {
);
const keyExtractor = (item: string) => item;
render(
const { waitUntilReady, unmount } = render(
<Box height={10} width={100} borderStyle="round">
<VirtualizedList
ref={ref}
@@ -310,26 +303,25 @@ describe('<VirtualizedList />', () => {
/>
</Box>,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
expect(ref.current?.getScrollState().scrollTop).toBe(0);
await act(async () => {
ref.current?.scrollBy(1);
ref.current?.scrollBy(1);
await delay(0);
});
await waitUntilReady();
expect(ref.current?.getScrollState().scrollTop).toBe(2);
await act(async () => {
ref.current?.scrollBy(2);
await delay(0);
});
await waitUntilReady();
expect(ref.current?.getScrollState().scrollTop).toBe(4);
unmount();
});
it('renders correctly in copyModeEnabled when scrolled', async () => {
@@ -340,7 +332,7 @@ describe('<VirtualizedList />', () => {
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
// Use copy mode
const { lastFrame } = render(
const { lastFrame, waitUntilReady, unmount } = render(
<Box height={10} width={100}>
<VirtualizedList
data={longData}
@@ -355,9 +347,7 @@ describe('<VirtualizedList />', () => {
/>
</Box>,
);
await act(async () => {
await delay(0);
});
await waitUntilReady();
// Item 50 should be visible
expect(lastFrame()).toContain('Item 50');
@@ -365,5 +355,6 @@ describe('<VirtualizedList />', () => {
expect(lastFrame()).toContain('Item 59');
// But far away items should not be (ensures we are actually scrolled)
expect(lastFrame()).not.toContain('Item 0');
unmount();
});
});

View File

@@ -3,7 +3,8 @@
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should not show arrows when list fits entirely 1`] = `
"● 1. Item A
2. Item B
3. Item C"
3. Item C
"
`;
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the end 1`] = `
@@ -11,7 +12,8 @@ exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arro
8. Item 8
9. Item 9
● 10. Item 10
"
"
`;
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows and correct items when scrolled to the middle 1`] = `
@@ -19,7 +21,8 @@ exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arro
4. Item 4
5. Item 5
● 6. Item 6
"
"
`;
exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arrows with correct colors when enabled (at the top) 1`] = `
@@ -27,5 +30,6 @@ exports[`BaseSelectionList > Scroll Arrows (showScrollArrows) > should show arro
● 1. Item 1
2. Item 2
3. Item 3
"
"
`;

View File

@@ -6,7 +6,8 @@ exports[`DescriptiveRadioButtonSelect > should render correctly with custom prop
● 2. Bar Title
This is Bar.
3. Baz Title
This is Baz."
This is Baz.
"
`;
exports[`DescriptiveRadioButtonSelect > should render correctly with default props 1`] = `
@@ -15,5 +16,6 @@ exports[`DescriptiveRadioButtonSelect > should render correctly with default pro
Bar Title
This is Bar.
Baz Title
This is Baz."
This is Baz.
"
`;

View File

@@ -1,9 +1,21 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<EnumSelector /> > renders inactive state and matches snapshot 1`] = `"← 中文 (简体) →"`;
exports[`<EnumSelector /> > renders inactive state and matches snapshot 1`] = `
"← 中文 (简体) →
"
`;
exports[`<EnumSelector /> > renders with numeric options and matches snapshot 1`] = `"← Medium →"`;
exports[`<EnumSelector /> > renders with numeric options and matches snapshot 1`] = `
"← Medium →
"
`;
exports[`<EnumSelector /> > renders with single option and matches snapshot 1`] = `" Only Option"`;
exports[`<EnumSelector /> > renders with single option and matches snapshot 1`] = `
" Only Option
"
`;
exports[`<EnumSelector /> > renders with string options and matches snapshot 1`] = `"← English →"`;
exports[`<EnumSelector /> > renders with string options and matches snapshot 1`] = `
"← English →
"
`;

View File

@@ -2,26 +2,39 @@
exports[`ExpandableText > creates centered window around match when collapsed 1`] = `
"...ry/long/path/that/keeps/going/cd_/very/long/path/that/keeps/going/search-here/and/then/some/more/
components//and/then/some/more/components//and/..."
components//and/then/some/more/components//and/...
"
`;
exports[`ExpandableText > highlights matched substring when expanded (text only visible) 1`] = `"run: git commit -m "feat: add search""`;
exports[`ExpandableText > highlights matched substring when expanded (text only visible) 1`] = `
"run: git commit -m "feat: add search"
"
`;
exports[`ExpandableText > renders plain label when no match (short label) 1`] = `"simple command"`;
exports[`ExpandableText > renders plain label when no match (short label) 1`] = `
"simple command
"
`;
exports[`ExpandableText > respects custom maxWidth 1`] = `"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz..."`;
exports[`ExpandableText > respects custom maxWidth 1`] = `
"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz...
"
`;
exports[`ExpandableText > shows full long label when expanded and no match 1`] = `
"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
"
`;
exports[`ExpandableText > truncates long label when collapsed and no match 1`] = `
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...
"
`;
exports[`ExpandableText > truncates match itself when match is very long 1`] = `
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...
"
`;

View File

@@ -3,15 +3,23 @@
exports[`<HalfLinePaddedBox /> > renders iTerm2-specific blocks when iTerm2 is detected 1`] = `
"▄▄▄▄▄▄▄▄▄▄
Content
▀▀▀▀▀▀▀▀▀▀"
▀▀▀▀▀▀▀▀▀▀
"
`;
exports[`<HalfLinePaddedBox /> > renders nothing when screen reader is enabled 1`] = `"Content"`;
exports[`<HalfLinePaddedBox /> > renders nothing when screen reader is enabled 1`] = `
"Content
"
`;
exports[`<HalfLinePaddedBox /> > renders nothing when useBackgroundColor is false 1`] = `"Content"`;
exports[`<HalfLinePaddedBox /> > renders nothing when useBackgroundColor is false 1`] = `
"Content
"
`;
exports[`<HalfLinePaddedBox /> > renders standard background and blocks when not iTerm2 1`] = `
"▀▀▀▀▀▀▀▀▀▀
Content
▄▄▄▄▄▄▄▄▄▄"
▄▄▄▄▄▄▄▄▄▄
"
`;

View File

@@ -2,7 +2,8 @@
exports[`<MaxSizedBox /> > accounts for additionalHiddenLinesCount 1`] = `
"... first 7 lines hidden ...
Line 3"
Line 3
"
`;
exports[`<MaxSizedBox /> > clips a long single text child from the bottom 1`] = `
@@ -15,7 +16,8 @@ Line 6
Line 7
Line 8
Line 9
... last 21 lines hidden ..."
... last 21 lines hidden ...
"
`;
exports[`<MaxSizedBox /> > clips a long single text child from the top 1`] = `
@@ -28,7 +30,8 @@ Line 26
Line 27
Line 28
Line 29
Line 30"
Line 30
"
`;
exports[`<MaxSizedBox /> > does not leak content after hidden indicator with bottom overflow 1`] = `
@@ -36,44 +39,55 @@ exports[`<MaxSizedBox /> > does not leak content after hidden indicator with bot
- Step 1: Do something important
- Step 2: Do something important
... last 18 lines hidden ..."
... last 18 lines hidden ...
"
`;
exports[`<MaxSizedBox /> > does not truncate when maxHeight is undefined 1`] = `
"Line 1
Line 2"
Line 2
"
`;
exports[`<MaxSizedBox /> > handles React.Fragment as a child 1`] = `
"Line 1 from Fragment
Line 2 from Fragment
Line 3 direct child"
Line 3 direct child
"
`;
exports[`<MaxSizedBox /> > hides lines at the end when content exceeds maxHeight and overflowDirection is bottom 1`] = `
"Line 1
... last 2 lines hidden ..."
... last 2 lines hidden ...
"
`;
exports[`<MaxSizedBox /> > hides lines when content exceeds maxHeight 1`] = `
"... first 2 lines hidden ...
Line 3"
Line 3
"
`;
exports[`<MaxSizedBox /> > renders children without truncation when they fit 1`] = `"Hello, World!"`;
exports[`<MaxSizedBox /> > renders children without truncation when they fit 1`] = `
"Hello, World!
"
`;
exports[`<MaxSizedBox /> > shows plural "lines" when more than one line is hidden 1`] = `
"... first 2 lines hidden ...
Line 3"
Line 3
"
`;
exports[`<MaxSizedBox /> > shows singular "line" when exactly one line is hidden 1`] = `
"... first 1 line hidden ...
Line 1"
Line 1
"
`;
exports[`<MaxSizedBox /> > wraps text that exceeds maxWidth 1`] = `
"This is a
long line
of text"
of text
"
`;

View File

@@ -4,6 +4,5 @@ exports[`<Scrollable /> > matches snapshot 1`] = `
"Line 1
Line 2
Line 3
"
`;

View File

@@ -1,7 +1,16 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<SectionHeader /> > 'renders correctly in a narrow contain…' 1`] = `"── Narrow Container ─────"`;
exports[`<SectionHeader /> > 'renders correctly in a narrow contain…' 1`] = `
"── Narrow Container ─────
"
`;
exports[`<SectionHeader /> > 'renders correctly when title is trunc…' 1`] = `"── Very Long Hea… ──"`;
exports[`<SectionHeader /> > 'renders correctly when title is trunc…' 1`] = `
"── Very Long Hea… ──
"
`;
exports[`<SectionHeader /> > 'renders correctly with a standard tit…' 1`] = `"── My Header ───────────────────────────"`;
exports[`<SectionHeader /> > 'renders correctly with a standard tit…' 1`] = `
"── My Header ───────────────────────────
"
`;

View File

@@ -20,7 +20,8 @@ exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visi
│Item 3 │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visible items with 1000 items and 10px height (scroll: 500) 1`] = `
@@ -43,7 +44,8 @@ exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visi
│Item 503 │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visible items with 1000 items and 10px height (scroll: 999) 1`] = `
@@ -66,7 +68,8 @@ exports[`<VirtualizedList /> > with 10px height and 100 items > mounts only visi
│ │
│ │
│ █│
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<VirtualizedList /> > with 10px height and 100 items > renders only visible items ('scrolled to bottom') 1`] = `
@@ -79,7 +82,8 @@ exports[`<VirtualizedList /> > with 10px height and 100 items > renders only vis
│Item 97 │
│Item 98 │
│Item 99 █│
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<VirtualizedList /> > with 10px height and 100 items > renders only visible items ('top') 1`] = `
@@ -92,5 +96,6 @@ exports[`<VirtualizedList /> > with 10px height and 100 items > renders only vis
│Item 5 │
│Item 6 │
│Item 7 │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;