test(search): query input focus follows real focus
Regression coverage for the search input's focus flag: it must track the renderable's REAL focus (useInputFocusNav FOCUSED/BLURRED), not a flag that outlives it. Clicking off the input drops inputFocused and keyboard control resumes; s re-enters typing; Esc defocuses; clicking the input refocuses. Documents the observed full-suite CPU-contention flake in the header: both tests occasionally time out in openSearch under suite load while passing reliably in isolation.
This commit is contained in:
267
tests/search-focus.test.tsx
Normal file
267
tests/search-focus.test.tsx
Normal file
@@ -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<void>;
|
||||||
|
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<Mounted> {
|
||||||
|
let navRef: any = null;
|
||||||
|
let keybindsRef: any = null;
|
||||||
|
const StateProbe = () => {
|
||||||
|
navRef = useNavigation();
|
||||||
|
keybindsRef = useKeybinds();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const HarnessRoot = () => (
|
||||||
|
<toast.ToastProvider>
|
||||||
|
<ThemeProvider mode="dark">
|
||||||
|
<KeybindProvider>
|
||||||
|
<NavigationProvider>
|
||||||
|
<StateProbe />
|
||||||
|
<DialogProvider>
|
||||||
|
<CommandProvider>
|
||||||
|
<App />
|
||||||
|
<toast.Toast />
|
||||||
|
</CommandProvider>
|
||||||
|
</DialogProvider>
|
||||||
|
</NavigationProvider>
|
||||||
|
</KeybindProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</toast.ToastProvider>
|
||||||
|
);
|
||||||
|
const setup = await testRender(() => <HarnessRoot />, {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user