diff --git a/tests/search-focus.test.tsx b/tests/search-focus.test.tsx new file mode 100644 index 0000000..b02734c --- /dev/null +++ b/tests/search-focus.test.tsx @@ -0,0 +1,267 @@ +/** + * search-focus.test.tsx — the search query input's focus flag must follow its + * REAL focus, not a flag that outlives it. + * + * Regression: clicking off the query input (opentui's mouse dispatch + * auto-focuses the clicked target's nearest focusable ancestor, blurring the + * input) used to leave `nav.inputFocused()` stuck true while the renderable + * was unfocused. The Shell keyboard router then yielded every key to a + * non-existent input: Esc wouldn't defocus, `s` wouldn't refocus, j/k/h did + * nothing. The input now drives the flag through useInputFocusNav + * (FOCUSED/BLURRED), so click-off drops the flag and keyboard control resumes; + * clicking the input focuses it. + * + * Mounts the real app (sandboxed, silent audio) via the same provider tree as + * scripts/tui-harness.tsx and drives it with the test renderer's mock keys + + * mouse. Layout is deterministic at 100x30: tab list 20 cols, current pane + * x=20..69 (content padded 1), so the query input row is y=1, x=29..57 and the + * pane interior is blank at (60,5). + * + * KNOWN FLAKE (full-suite CPU contention): both tests occasionally fail inside + * openSearch — the tab-digit press or the post-Enter input focus doesn't land + * within the retry budget (20 × press+render+40ms, then a 5s waitFor). Passes + * reliably in isolation (`bun test tests/search-focus.test.tsx`, ~2s). + * Observed 2026-08-11 on an M3 Pro: two consecutive red full-suite runs + * (`timed out waiting for: input focused on tab entry` / `Expected: 4, + * Received: 1`), then two consecutive green — timing under load, not state. + * If a full-suite run reports these two, re-run the file alone to confirm + * before treating it as a regression. + */ + +import { test, expect, afterAll, mock } from "bun:test"; +import { testRender } from "@opentui/solid"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import type { AudioControls } from "../src/hooks/useAudio"; + +// Other test files that `mock.module("../src/hooks/useAudio")` can share this +// file's worker (bun reuses workers; the mock leaks into the module registry +// here). That stub only has duration/position/seek, so the Shell's render +// throws on audio.currentEpisode() and no key router ever attaches — presses +// go nowhere. Register a complete no-op stub FIRST so the app mounts +// regardless of what leaked in before us. +const stubAudio: AudioControls = { + isPlaying: () => false, + position: () => 0, + duration: () => 0, + volume: () => 1, + speed: () => 1, + backendName: () => "none", + error: () => null, + currentEpisode: () => null, + availablePlayers: () => [], + play: async () => {}, + pause: async () => {}, + resume: async () => {}, + togglePlayback: async () => {}, + stop: async () => {}, + seek: async () => {}, + seekRelative: async () => {}, + setVolume: async () => {}, + setSpeed: async () => {}, + switchBackend: async () => {}, + prev: async () => {}, + next: async () => {}, +}; +mock.module("../src/hooks/useAudio", () => ({ + useAudio: () => stubAudio, +})); + +// Sandbox BEFORE any app module evaluates — config-dir/persistence read these +// env vars at import time. Static imports are hoisted above this code, so the +// app modules must be loaded dynamically (mirrors scripts/tui-harness.tsx). +const SANDBOX = join(process.cwd(), ".harness", "test-focus"); +mkdirSync(join(SANDBOX, "config-home"), { recursive: true }); +mkdirSync(join(SANDBOX, "data-home"), { recursive: true }); +process.env.XDG_CONFIG_HOME = join(SANDBOX, "config-home"); +process.env.XDG_DATA_HOME = join(SANDBOX, "data-home"); +process.env.PODTUI_AUDIO_BACKEND = "none"; + +const { App } = await import("../src/App"); +const { ThemeProvider } = await import("../src/context/ThemeContext"); +const toast = await import("../src/ui/toast"); +const { KeybindProvider, useKeybinds } = await import( + "../src/context/KeybindContext" +); +const { NavigationProvider, useNavigation } = await import( + "../src/context/NavigationContext" +); +const { DialogProvider } = await import("../src/ui/dialog"); +const { CommandProvider } = await import("../src/ui/command"); +const { TABS } = await import("../src/utils/navigation"); + +// Click coordinates (0-based) in the 100x30 layout — see file header. +const INPUT_CLICK = { x: 35, y: 1 }; +const PILL_EPISODES = { x: 44, y: 3 }; +const BLANK_PANE = { x: 60, y: 5 }; + +type Mounted = { + renderer: any; + renderOnce: () => Promise; + mockInput: any; + mockMouse: any; + nav: () => { + inputFocused: () => boolean; + currentDepth: () => number; + activeTab: () => number; + }; + keybindsReady: () => boolean; +}; + +/** Mount the real app and return drivers + nav/keybind probes (contexts are + * only readable inside the provider tree). */ +async function mountApp(): Promise { + let navRef: any = null; + let keybindsRef: any = null; + const StateProbe = () => { + navRef = useNavigation(); + keybindsRef = useKeybinds(); + return null; + }; + const HarnessRoot = () => ( + + + + + + + + + + + + + + + + ); + const setup = await testRender(() => , { + width: 100, + height: 30, + useThread: false, + }); + (setup.renderer as unknown as { disableStdoutInterception?: () => void }).disableStdoutInterception?.(); + // Initial mount settle; keybind readiness is awaited separately in + // settleReady before any keypress. + await setup.renderOnce(); + await new Promise((r) => setTimeout(r, 60)); + return { + renderer: setup.renderer, + renderOnce: setup.renderOnce, + mockInput: setup.mockInput, + mockMouse: setup.mockMouse, + nav: () => navRef, + keybindsReady: () => keybindsRef?.ready ?? false, + }; +} + +/** Render + settle until the keybind router is ready (it loads keybinds from + * disk asynchronously on mount); the first keypress would otherwise be lost + * under full-suite CPU contention. */ +async function settleReady(m: Mounted): Promise { + for (let i = 0; i < 80; i++) { + await m.renderOnce(); + await new Promise((r) => setTimeout(r, 60)); + if (m.keybindsReady()) return; + } + throw new Error("keybinds never became ready"); +} + +/** Navigate to the Search tab's query depth (input auto-focuses on entry). + * The tab-digit press is retried until it lands: the first press can fire + * before the Shell's key router attaches (keybinds load asynchronously), so + * re-pressing self-heals instead of flaking under suite contention. */ +async function openSearch(m: Mounted): Promise { + await settleReady(m); + for (let i = 0; i < 20 && m.nav().activeTab() !== TABS.SEARCH; i++) { + m.mockInput.pressKey("4"); + await m.renderOnce(); + await new Promise((r) => setTimeout(r, 40)); + } + expect(m.nav().activeTab()).toBe(TABS.SEARCH); + m.mockInput.pressEnter(); + await waitFor(m, () => m.nav().inputFocused(), "input focused on tab entry"); +} + +/** Render + sleep until `cond` holds or the timeout elapses. Fixed sleeps after + * input/mouse actions flake under full-suite CPU contention, so state + * transitions are polled instead. */ +async function waitFor( + m: Mounted, + cond: () => boolean, + what: string, + timeoutMs = 5000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (cond()) return; + await m.renderOnce(); + await new Promise((r) => setTimeout(r, 25)); + } + throw new Error(`timed out waiting for: ${what}`); +} + +/** The renderable the renderer currently considers focused (if any). */ +function focusedName(m: Mounted): string | null { + const r = m.renderer.currentFocusedRenderable; + return r ? `${r.constructor?.name} id=${r.id}` : null; +} + +afterAll(() => { + rmSync(SANDBOX, { recursive: true, force: true }); +}); + +test("clicking off the query input drops inputFocused; s and Esc restore keyboard control", async () => { + const m = await mountApp(); + try { + await openSearch(m); + expect(m.nav().inputFocused()).toBe(true); + + // Click off the input (scope pill). Real focus moves to the pane's + // scrollbox, blurring the input — the flag must follow. + await m.mockMouse.click(PILL_EPISODES.x, PILL_EPISODES.y); + await waitFor(m, () => !m.nav().inputFocused(), "flag off after pill click"); + expect(focusedName(m)).not.toMatch(/^Input/); + + // `s` (search action) must refocus the input for typing. + m.mockInput.pressKey("s"); + await waitFor(m, () => m.nav().inputFocused(), "s refocuses the input"); + expect(focusedName(m)).toMatch(/^Input/); + + // Escape defocuses; the flag stays off (no depth change re-seeds it). + m.mockInput.pressEscape(); + await waitFor(m, () => !m.nav().inputFocused(), "escape defocuses"); + + // Click-off a second time (blank pane interior). The flag is already + // false from the escape, so a state-poll can't observe the click; check + // directly that the click did NOT re-focus the input, then that j is + // not swallowed (flag stays false, nothing re-grabs focus). + await m.mockMouse.click(BLANK_PANE.x, BLANK_PANE.y); + await m.renderOnce(); + await new Promise((r) => setTimeout(r, 40)); + expect(m.nav().inputFocused()).toBe(false); + m.mockInput.pressKey("j"); + await m.renderOnce(); + await new Promise((r) => setTimeout(r, 40)); + expect(m.nav().inputFocused()).toBe(false); + } finally { + m.renderer.destroy(); + } +}); + +test("clicking the query input focuses it (typing mode)", async () => { + const m = await mountApp(); + try { + await openSearch(m); + // Defocus first so the click has something to restore. + m.mockInput.pressEscape(); + await waitFor(m, () => !m.nav().inputFocused(), "escape defocuses"); + + await m.mockMouse.click(INPUT_CLICK.x, INPUT_CLICK.y); + await waitFor(m, () => m.nav().inputFocused(), "input click focuses"); + expect(focusedName(m)).toMatch(/^Input/); + } finally { + m.renderer.destroy(); + } +}); +