inspector/clients/tui/__tests__/useSelectableList.test.tsx
cliffhall a3bb679f1e fix: strip ANSI from TUI test frames so FORCE_COLOR cannot break assertions (#2207)
Ink writes styling as escape sequences inside the styled run, so an
accelerator underline splits the word it decorates: `<Text underline>I</Text>nfo`
reaches the frame buffer with escapes between `I` and `nfo`, and a plain
`toContain("Info")` fails against a component that is rendering correctly.
Chalk only emits color when it detects a TTY, so CI stays green and the six
failures land only on a developer whose shell exports FORCE_COLOR.

Add `__tests__/helpers/renderTui.tsx` — ink-testing-library's `render` with
`lastFrame()` and `frames` ANSI-stripped — and point all 16 renderer test
files at it. `frames` is a getter reading through to the live array, since
Ink appends to it as the component re-renders; the raw bytes stay reachable
as `stdout.lastFrame()`.

Pinning FORCE_COLOR=0 would turn the six green while hiding the class: any
future assertion on styled text has the same problem, and the suite would
stop covering the styled path at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MFp2LVH7UJST5i8bDBJghw
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-06 20:00:15 -04:00

134 lines
4.2 KiB
TypeScript

import React from "react";
import { describe, it, expect } from "vitest";
import { render } from "./helpers/renderTui";
import { Text, useInput } from "ink";
import {
useSelectableList,
type UseSelectableListOptions,
} from "../src/hooks/useSelectableList.js";
/** Let ink flush an async stdin keypress / re-render before asserting. */
const tick = async () => {
// Flush several macrotask cycles so an effect -> setState -> re-render chain
// settles before assertions, even on slow/loaded CI (a single tick can race).
for (let i = 0; i < 8; i++)
await new Promise((resolve) => setTimeout(resolve, 4));
};
/**
* Harness that surfaces the hook's state as rendered text and forwards
* keypresses to setSelection so the hook can be driven from a test.
* Pressing a digit (0-9) selects that index; "x" selects index 99.
*/
function Harness({
itemCount,
visibleCount,
options,
}: {
itemCount: number;
visibleCount: number;
options?: UseSelectableListOptions;
}) {
const { selectedIndex, firstVisible, setSelection } = useSelectableList(
itemCount,
visibleCount,
options,
);
useInput((input) => {
if (input === "x") setSelection(99);
else if (/^[0-9]$/.test(input)) setSelection(Number(input));
});
return (
<Text>
sel={selectedIndex} first={firstVisible}
</Text>
);
}
describe("useSelectableList", () => {
it("starts at index 0 with firstVisible 0", () => {
const { lastFrame } = render(<Harness itemCount={10} visibleCount={5} />);
expect(lastFrame()).toBe("sel=0 first=0");
});
it("keeps firstVisible when selecting within the visible window", async () => {
const { lastFrame, stdin } = render(
<Harness itemCount={10} visibleCount={5} />,
);
stdin.write("3");
await tick();
expect(lastFrame()).toBe("sel=3 first=0");
});
it("scrolls firstVisible forward when selection passes the window end", async () => {
const { lastFrame, stdin } = render(
<Harness itemCount={10} visibleCount={5} />,
);
stdin.write("6");
await tick();
// selected >= first + visibleCount => first = 6 - 5 + 1 = 2
expect(lastFrame()).toBe("sel=6 first=2");
});
it("scrolls firstVisible backward when selection moves before the window", async () => {
const { lastFrame, stdin } = render(
<Harness itemCount={10} visibleCount={5} />,
);
stdin.write("8"); // first becomes 4
await tick();
expect(lastFrame()).toBe("sel=8 first=4");
stdin.write("2"); // selected < first => first = selected = 2
await tick();
expect(lastFrame()).toBe("sel=2 first=2");
});
it("resets selection to 0 when resetWhen changes", async () => {
const { lastFrame, stdin, rerender } = render(
<Harness itemCount={10} visibleCount={5} options={{ resetWhen: "a" }} />,
);
stdin.write("7");
await tick();
expect(lastFrame()).toBe("sel=7 first=3");
rerender(
<Harness itemCount={10} visibleCount={5} options={{ resetWhen: "b" }} />,
);
await tick();
expect(lastFrame()).toBe("sel=0 first=0");
});
it("does not reset when resetWhen is undefined", async () => {
const { lastFrame, stdin, rerender } = render(
<Harness itemCount={10} visibleCount={5} />,
);
stdin.write("4");
await tick();
rerender(<Harness itemCount={10} visibleCount={5} />);
await tick();
expect(lastFrame()).toBe("sel=4 first=0");
});
it("clamps selection when the list shrinks below the selected index", async () => {
const { lastFrame, stdin, rerender } = render(
<Harness itemCount={10} visibleCount={5} />,
);
stdin.write("8");
await tick();
expect(lastFrame()).toBe("sel=8 first=4");
rerender(<Harness itemCount={3} visibleCount={5} />);
await tick();
// itemCount > 0 && selected >= itemCount => newIndex = 2, first clamps to 2
expect(lastFrame()).toBe("sel=2 first=2");
});
it("does not clamp when the list is empty", async () => {
const { lastFrame, stdin, rerender } = render(
<Harness itemCount={10} visibleCount={5} />,
);
stdin.write("8");
await tick();
rerender(<Harness itemCount={0} visibleCount={5} />);
await tick();
// itemCount === 0 => no clamp, selection retained
expect(lastFrame()).toBe("sel=8 first=4");
});
});