/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { renderWithProviders } from '../../../test-utils/render.js'; import { Scrollable } from './Scrollable.js'; import { Text } from 'ink'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import * as ScrollProviderModule from '../../contexts/ScrollProvider.js'; import { act } from 'react'; vi.mock('ink', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getInnerHeight: vi.fn(() => 5), getScrollHeight: vi.fn(() => 10), getBoundingBox: vi.fn(() => ({ x: 0, y: 0, width: 10, height: 5 })), }; }); vi.mock('../../hooks/useAnimatedScrollbar.js', () => ({ useAnimatedScrollbar: ( hasFocus: boolean, scrollBy: (delta: number) => void, ) => ({ scrollbarColor: 'white', flashScrollbar: vi.fn(), scrollByWithAnimation: scrollBy, }), })); describe('', () => { beforeEach(() => { vi.restoreAllMocks(); }); it('renders children', () => { const { lastFrame } = renderWithProviders( Hello World , ); expect(lastFrame()).toContain('Hello World'); }); it('renders multiple children', () => { const { lastFrame } = renderWithProviders( Line 1 Line 2 Line 3 , ); expect(lastFrame()).toContain('Line 1'); expect(lastFrame()).toContain('Line 2'); expect(lastFrame()).toContain('Line 3'); }); it('matches snapshot', () => { const { lastFrame } = renderWithProviders( Line 1 Line 2 Line 3 , ); expect(lastFrame()).toMatchSnapshot(); }); it('updates scroll position correctly when scrollBy is called multiple times in the same tick', () => { let capturedEntry: ScrollProviderModule.ScrollableEntry | undefined; vi.spyOn(ScrollProviderModule, 'useScrollable').mockImplementation( (entry, isActive) => { if (isActive) { capturedEntry = entry as ScrollProviderModule.ScrollableEntry; } }, ); renderWithProviders( Line 1 Line 2 Line 3 Line 4 Line 5 Line 6 Line 7 Line 8 Line 9 Line 10 , ); expect(capturedEntry).toBeDefined(); if (!capturedEntry) { throw new Error('capturedEntry is undefined'); } // Initial state (starts at bottom because of auto-scroll logic) expect(capturedEntry.getScrollState().scrollTop).toBe(5); // Call scrollBy multiple times (upwards) in the same tick act(() => { capturedEntry!.scrollBy(-1); capturedEntry!.scrollBy(-1); }); // Should have moved up by 2 expect(capturedEntry.getScrollState().scrollTop).toBe(3); act(() => { capturedEntry!.scrollBy(-2); }); expect(capturedEntry.getScrollState().scrollTop).toBe(1); }); describe('keypress handling', () => { it.each([ { name: 'scrolls down when overflow exists and not at bottom', initialScrollTop: 0, scrollHeight: 10, keySequence: '\u001B[1;2B', // Shift+Down expectedScrollTop: 1, }, { name: 'scrolls up when overflow exists and not at top', initialScrollTop: 2, scrollHeight: 10, keySequence: '\u001B[1;2A', // Shift+Up expectedScrollTop: 1, }, { name: 'does not scroll up when at top (allows event to bubble)', initialScrollTop: 0, scrollHeight: 10, keySequence: '\u001B[1;2A', // Shift+Up expectedScrollTop: 0, }, { name: 'does not scroll down when at bottom (allows event to bubble)', initialScrollTop: 5, // maxScroll = 10 - 5 = 5 scrollHeight: 10, keySequence: '\u001B[1;2B', // Shift+Down expectedScrollTop: 5, }, { name: 'does not scroll when content fits (allows event to bubble)', initialScrollTop: 0, scrollHeight: 5, // Same as innerHeight (5) keySequence: '\u001B[1;2B', // Shift+Down expectedScrollTop: 0, }, ])( '$name', async ({ initialScrollTop, scrollHeight, keySequence, expectedScrollTop, }) => { // Dynamically import ink to mock getScrollHeight const ink = await import('ink'); vi.mocked(ink.getScrollHeight).mockReturnValue(scrollHeight); let capturedEntry: ScrollProviderModule.ScrollableEntry | undefined; vi.spyOn(ScrollProviderModule, 'useScrollable').mockImplementation( (entry, isActive) => { if (isActive) { capturedEntry = entry as ScrollProviderModule.ScrollableEntry; } }, ); const { stdin } = renderWithProviders( Content , ); // Ensure initial state using existing scrollBy method act(() => { // Reset to top first, then scroll to desired start position capturedEntry!.scrollBy(-100); if (initialScrollTop > 0) { capturedEntry!.scrollBy(initialScrollTop); } }); expect(capturedEntry!.getScrollState().scrollTop).toBe( initialScrollTop, ); act(() => { stdin.write(keySequence); }); expect(capturedEntry!.getScrollState().scrollTop).toBe( expectedScrollTop, ); }, ); }); });