From 0411d05a2a6f166d7edbdaa2e371246a18915b46 Mon Sep 17 00:00:00 2001 From: qqqys Date: Sat, 9 May 2026 10:53:06 +0800 Subject: [PATCH] test(cli): drop wait-dependent SessionPicker search tests (#3978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Search describe block in StandaloneSessionPicker.test.tsx was introduced in #3880 and consistently failed on Test (windows-latest, 20.x / 22.x) — six of its tests assumed a 30 ms inter-key wait was enough for the keypress → useEffect → render chain to commit, which slow Windows runners regularly missed. The lone fix in c5e49695b bumped one test to 50 ms but left the other six at 30 ms, and bumping all of them is fighting the symptom rather than the cause. The underlying behavior — search-mode keymap, query buffer, focus transitions — is already covered by useSessionSearchInput.test.ts at the unit level, where the same scenarios pass deterministically without driving a real Ink render tree. Drop the entire Search describe block (the whole #3880-introduced integration suite) and the now-unused BACKSPACE / ARROW_UP key constants. The remaining test file (Empty Sessions, Branch Filtering, Keyboard Navigation, Display, Pagination, Preview Mode) compiles and passes 17/17. Closes #3977 Co-authored-by: Qwen-Coder --- .../StandaloneSessionPicker.test.tsx | 967 ------------------ 1 file changed, 967 deletions(-) diff --git a/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx b/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx index 3a8bef6c5..7a73cf651 100644 --- a/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx +++ b/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx @@ -31,9 +31,7 @@ vi.mock('@qwen-code/qwen-code-core', async () => { // inside string literals. const CTRL_B = ''; const ESC = ''; -const BACKSPACE = ''; const ARROW_DOWN = ''; -const ARROW_UP = ''; // Mock terminal size const mockTerminalSize = { columns: 80, rows: 24 }; @@ -407,971 +405,6 @@ describe('SessionPicker', () => { }); }); - describe('Search', () => { - it('substring filters across customTitle, prompt, and gitBranch', async () => { - const sessions = [ - createMockSession({ - sessionId: 's1', - prompt: 'completely unrelated', - customTitle: 'login bug investigation', - messageCount: 1, - }), - createMockSession({ - sessionId: 's2', - prompt: 'review login flow', - messageCount: 1, - }), - createMockSession({ - sessionId: 's3', - prompt: 'totally different', - gitBranch: 'feature/login-revamp', - messageCount: 1, - }), - createMockSession({ - sessionId: 's4', - prompt: 'unrelated work', - messageCount: 1, - }), - ]; - const mockService = createMockSessionService(sessions); - - const { stdin, lastFrame } = render( - - - , - ); - - await wait(100); - - // '/' enters search explicitly so the query starts fresh and - // shortcut letters in the term (none here) don't get reinterpreted. - stdin.write('/login'); - await wait(50); - - const output = lastFrame() ?? ''; - // s1 matches via customTitle, s2 via prompt, s3 via gitBranch. - expect(output).toContain('login bug investigation'); - expect(output).toContain('review login flow'); - expect(output).toContain('feature/login-revamp'); - // The non-matching session must drop out. - expect(output).not.toContain('unrelated work'); - // The visible search row reflects the active query. - expect(output).toContain('Search:'); - }); - - it('typing a non-shortcut char enters search mode implicitly', async () => { - // Letters that are not list-mode shortcuts (anything other than j, - // k, b, B, ' ', '/') seed the search query directly. - const sessions = [ - createMockSession({ - sessionId: 'a', - prompt: 'deploy review', - messageCount: 1, - }), - createMockSession({ - sessionId: 'b', - // Picked so it shares no letters with the typed query 'dep'. - prompt: 'cluster setup', - messageCount: 1, - }), - ]; - const mockService = createMockSessionService(sessions); - - const { stdin, lastFrame } = render( - - - , - ); - - await wait(100); - stdin.write('dep'); - await wait(50); - - const output = lastFrame() ?? ''; - expect(output).toContain('Search:'); - expect(output).toContain('deploy review'); - expect(output).not.toContain('cluster setup'); - }); - - it('list-mode j/k navigate the list', async () => { - // vim shortcuts stay live in list mode even though the rest of - // the alphabet seeds search. - const sessions = [ - createMockSession({ - sessionId: 's1', - prompt: 'first', - messageCount: 1, - }), - createMockSession({ - sessionId: 's2', - prompt: 'second', - messageCount: 1, - }), - ]; - const mockService = createMockSessionService(sessions); - const onSelect = vi.fn(); - - const { stdin } = render( - - - , - ); - - await wait(100); - stdin.write('j'); // selectedIndex 0 -> 1 - await wait(30); - stdin.write('\r'); - await wait(50); - expect(onSelect).toHaveBeenLastCalledWith('s2'); - - stdin.write('k'); // back to 0 - await wait(30); - stdin.write('\r'); - await wait(50); - expect(onSelect).toHaveBeenLastCalledWith('s1'); - }); - - it("'b' seeds search; once searching, 'j' appends to the query", async () => { - // Lowercase letters that aren't list-mode shortcuts ('b' here) - // implicitly enter search. Once in search mode, j and k lose - // their nav meaning and behave as regular query characters so - // titles containing 'j' or 'k' can be searched. - const sessions = [ - createMockSession({ - sessionId: 's1', - prompt: 'bjorn config', - messageCount: 1, - }), - createMockSession({ - sessionId: 's2', - prompt: 'bug investigation', - messageCount: 1, - }), - createMockSession({ - sessionId: 's3', - prompt: 'unrelated', - messageCount: 1, - }), - ]; - const mockService = createMockSessionService(sessions); - - const { stdin, lastFrame } = render( - - - , - ); - - await wait(100); - // 'b' implicitly enters search. Both s1 and s2 contain 'b'. - stdin.write('b'); - await wait(30); - let output = lastFrame() ?? ''; - expect(output).toContain('Search:'); - expect(output).toContain('bjorn config'); - expect(output).toContain('bug investigation'); - expect(output).not.toContain('unrelated'); - - // 'j' inside search appends to the query → "bj" — only s1 still - // matches. If 'j' were still bound to nav we would see the same - // 'b'-filtered list (two matches), and selectedIndex would drift - // instead of the matches narrowing. - stdin.write('j'); - await wait(50); - output = lastFrame() ?? ''; - expect(output).toContain('bjorn config'); - expect(output).not.toContain('bug investigation'); - }); - - it('list-mode Space without preview enabled is a no-op', async () => { - // When preview is disabled, the Space-as-preview shortcut never - // fires; Space is also explicitly skipped from implicit search - // entry to keep leading whitespace out of the query. - const sessions = [ - createMockSession({ - sessionId: 's1', - prompt: 'first', - messageCount: 1, - }), - ]; - const mockService = createMockSessionService(sessions); - - const { stdin, lastFrame } = render( - - - , - ); - - await wait(100); - stdin.write(' '); - await wait(50); - - const output = lastFrame() ?? ''; - expect(output).toContain('Press / to search'); - expect(output).not.toContain('Search:'); - }); - - it('Backspace edits the query; emptying it returns to list mode', async () => { - // Backspace is an edit op that, when it deletes the final char, - // also flips back to list mode so the shortcut keymap is - // immediately available again. Esc remains the explicit exit. - const sessions = [ - createMockSession({ - sessionId: 's1', - prompt: 'login bug', - messageCount: 1, - }), - createMockSession({ - sessionId: 's2', - prompt: 'unrelated', - messageCount: 1, - }), - ]; - const mockService = createMockSessionService(sessions); - const onCancel = vi.fn(); - - const { stdin, lastFrame } = render( - - - , - ); - - await wait(100); - - stdin.write('/logim'); - await wait(30); - stdin.write(BACKSPACE); - await wait(30); - stdin.write('n'); - await wait(50); - - let output = lastFrame() ?? ''; - expect(output).toContain('login bug'); - expect(output).not.toContain('unrelated'); - - // First Esc: exit search back to list, do not cancel. - stdin.write(ESC); - await wait(30); - output = lastFrame() ?? ''; - expect(output).toContain('login bug'); - expect(output).toContain('unrelated'); - expect(output).toContain('Press / to search'); - expect(onCancel).not.toHaveBeenCalled(); - - // Second Esc: now actually cancels. - stdin.write(ESC); - await wait(30); - expect(onCancel).toHaveBeenCalledTimes(1); - }); - - it('Backspace in list mode does not spawn a search', async () => { - // Regression: Backspace's raw byte (DEL, 0x7F) used to slip past - // the printable-char filter and seed an implicit search with the - // literal DEL byte, producing a confusing 'No sessions match …' - // frame in list mode. List-mode Backspace must be inert. - const sessions = [ - createMockSession({ - sessionId: 's1', - prompt: 'first', - messageCount: 1, - }), - ]; - const mockService = createMockSessionService(sessions); - - const { stdin, lastFrame } = render( - - - , - ); - - await wait(100); - stdin.write(BACKSPACE); - await wait(50); - - const output = lastFrame() ?? ''; - // Still in list mode — no search frame, no spurious empty match. - expect(output).toContain('Press / to search'); - expect(output).not.toContain('Search:'); - expect(output).not.toContain('No sessions match'); - // The session list is still rendered untouched. - expect(output).toContain('first'); - }); - - it('search mode suppresses the row highlight', async () => { - // The "›" selected-prefix and accent color belong to the row - // the user is about to act on. While they're still typing the - // query, no row should claim that affordance — the search input - // owns focus exclusively until ↑↓/Enter commits to the list. - const sessions = [ - createMockSession({ - sessionId: 's1', - prompt: 'login flow', - messageCount: 1, - }), - createMockSession({ - sessionId: 's2', - prompt: 'login bug', - messageCount: 1, - }), - ]; - const mockService = createMockSessionService(sessions); - - const { stdin, lastFrame } = render( - - - , - ); - - await wait(100); - // Sanity: in list mode the first row is highlighted with '› '. - let output = lastFrame() ?? ''; - expect(output).toContain('› '); - - // Enter search; the highlight should disappear. - stdin.write('/login'); - await wait(50); - output = lastFrame() ?? ''; - expect(output).toContain('Search:'); - expect(output).not.toContain('› '); - - // Commit (↓ or Enter) reinstates the highlight on the list. - stdin.write(ARROW_DOWN); - await wait(30); - output = lastFrame() ?? ''; - expect(output).toContain('Filter:'); - expect(output).toContain('› '); - }); - - it('Enter in search commits the filter; second Enter selects', async () => { - // Defensive UX: the user's typing reflex shouldn't accidentally - // resume a session. Pressing Enter while still in the search - // input commits the filter (drops to list, query preserved) - // and onSelect stays unfired. Only a deliberate second Enter - // from the list view actually resumes. - const sessions = [ - createMockSession({ - sessionId: 'first', - prompt: 'foo', - messageCount: 1, - }), - createMockSession({ - sessionId: 'matching', - prompt: 'special-target', - messageCount: 1, - }), - ]; - const mockService = createMockSessionService(sessions); - const onSelect = vi.fn(); - - const { stdin, lastFrame } = render( - - - , - ); - - await wait(100); - stdin.write('/special'); - await wait(30); - - // First Enter: search → list, query stays applied, no resume. - stdin.write('\r'); - await wait(30); - expect(onSelect).not.toHaveBeenCalled(); - const afterFirstEnter = lastFrame() ?? ''; - expect(afterFirstEnter).toContain('Filter:'); - expect(afterFirstEnter).toContain('special-target'); - - // Second Enter from list view selects the highlighted row. - stdin.write('\r'); - await wait(30); - expect(onSelect).toHaveBeenCalledWith('matching'); - }); - - it('Enter in search with no matches stays in search', async () => { - // Don't drop the user out of the search input on Enter when - // there's nothing to commit to — they're mid-typo and need - // to keep editing. - const sessions = [ - createMockSession({ - sessionId: 's1', - prompt: 'unrelated', - messageCount: 1, - }), - ]; - const mockService = createMockSessionService(sessions); - const onSelect = vi.fn(); - - const { stdin, lastFrame } = render( - - - , - ); - - await wait(100); - stdin.write('/zzz'); // matches nothing - await wait(30); - stdin.write('\r'); - await wait(30); - - const output = lastFrame() ?? ''; - expect(output).toContain('Search:'); - expect(output).toContain('No sessions match'); - expect(onSelect).not.toHaveBeenCalled(); - }); - - it('↑/↓ from search drops to list mode while keeping the filter', async () => { - // The post-narrow state: user types to filter, then arrows to - // pick a row. Once they navigate, the search frame goes away - // (no more caret, switches to "Filter:" indicator) but the - // query stays applied so the list remains narrowed and full - // list-mode shortcuts work on the highlighted row. - const sessions = [ - createMockSession({ - sessionId: 'a', - prompt: 'login flow review', - messageCount: 1, - }), - createMockSession({ - sessionId: 'b', - prompt: 'login bug fix', - messageCount: 1, - }), - createMockSession({ - sessionId: 'c', - prompt: 'unrelated', - messageCount: 1, - }), - ]; - const mockService = createMockSessionService(sessions); - - const { stdin, lastFrame } = render( - - - , - ); - - await wait(100); - stdin.write('/login'); - await wait(30); - - let output = lastFrame() ?? ''; - // In search mode: caret-bearing "Search:" row is visible. - expect(output).toContain('Search:'); - - stdin.write(ARROW_DOWN); - await wait(50); - - output = lastFrame() ?? ''; - // Now in list mode with filter preserved: the read-only - // "Filter:" indicator replaces "Search:", but the list is - // still narrowed to the two login matches. - expect(output).toContain('Filter:'); - expect(output).not.toContain('Search:'); - expect(output).toContain('login flow review'); - expect(output).toContain('login bug fix'); - expect(output).not.toContain('unrelated'); - }); - - it('Space → preview works on the highlighted row in filtered-list', async () => { - // Once narrowed and out of search, Space should trigger the - // preview shortcut just like in the unfiltered list — proves - // the action is mode-independent. - const sessions = [ - createMockSession({ - sessionId: 'a', - prompt: 'login flow', - messageCount: 2, - }), - createMockSession({ - sessionId: 'b', - prompt: 'unrelated', - messageCount: 2, - }), - ]; - const service = createMockSessionService(sessions); - service.loadSession.mockResolvedValue({ - conversation: { messages: [] }, - filePath: '/x', - lastCompletedUuid: null, - }); - - const { stdin, lastFrame } = render( - - false, - getIdeMode: () => false, - isTrustedFolder: () => false, - } as unknown as Config - } - > - - - - - , - ); - - await wait(100); - stdin.write('/login'); - await wait(30); - stdin.write(ARROW_DOWN); // exits search, cursor on filtered first item - await wait(30); - stdin.write(' '); // Space → preview - await wait(150); - - const frame = lastFrame() ?? ''; - // Preview frame shows session metadata (prompt, message count). - expect(frame).toContain('login flow'); - // The filter row / list view is replaced by the preview, which - // does not render the "Search:" or "Filter:" rows. - expect(frame).not.toContain('Filter:'); - }); - - it('Ctrl+B toggles branch in filtered-list', async () => { - // Branch toggle stays available after narrowing, so users can - // refine filter axes without losing their query. - const sessions = [ - createMockSession({ - sessionId: 'a', - gitBranch: 'main', - prompt: 'login', - messageCount: 1, - }), - createMockSession({ - sessionId: 'b', - gitBranch: 'feature', - prompt: 'login on feature', - messageCount: 1, - }), - ]; - const mockService = createMockSessionService(sessions); - - const { stdin, lastFrame } = render( - - - , - ); - - await wait(100); - stdin.write('/login'); - await wait(30); - stdin.write(ARROW_DOWN); // exit search to filtered-list - await wait(30); - - // Both still visible (filter='login' matches both). - let output = lastFrame() ?? ''; - expect(output).toContain('login on feature'); - - stdin.write(CTRL_B); - await wait(50); - - output = lastFrame() ?? ''; - // Branch filter narrows to main; query still applied. - expect(output).toContain('Filter:'); - expect(output).toContain('login'); - expect(output).not.toContain('login on feature'); - }); - - it('Esc in filtered-list clears the query first, then cancels', async () => { - // Two-stage Esc parity with search mode: pressing Esc once on - // the filtered list drops the query (returning the unfiltered - // list) while keeping the picker open; a second Esc finally - // cancels. Avoids an "I lost my filter AND closed the dialog" - // surprise from a single accidental keystroke. - const sessions = [ - createMockSession({ - sessionId: 'a', - prompt: 'login flow', - messageCount: 1, - }), - createMockSession({ - sessionId: 'b', - prompt: 'unrelated', - messageCount: 1, - }), - ]; - const mockService = createMockSessionService(sessions); - const onCancel = vi.fn(); - - const { stdin, lastFrame } = render( - - - , - ); - - await wait(100); - stdin.write('/login'); - await wait(30); - stdin.write(ARROW_DOWN); // → filtered-list with q='login' - await wait(30); - - let output = lastFrame() ?? ''; - expect(output).toContain('Filter:'); - - // First Esc: drop the filter, stay open. - stdin.write(ESC); - await wait(30); - output = lastFrame() ?? ''; - expect(output).toContain('Press / to search'); - expect(output).toContain('login flow'); - expect(output).toContain('unrelated'); - expect(onCancel).not.toHaveBeenCalled(); - - // Second Esc: actually cancel. - stdin.write(ESC); - await wait(30); - expect(onCancel).toHaveBeenCalledTimes(1); - }); - - it("'/' from filtered-list preserves the existing query", async () => { - // Re-focusing search via '/' must not throw away what the user - // already typed — they typically hit '/' when they want to - // tweak the filter, not start over. Esc is the explicit clear - // gesture (covered by the Backspace/Esc test above). - const sessions = [ - createMockSession({ - sessionId: 'a', - prompt: 'login flow', - messageCount: 1, - }), - createMockSession({ - sessionId: 'b', - prompt: 'unrelated', - messageCount: 1, - }), - ]; - const mockService = createMockSessionService(sessions); - - const { stdin, lastFrame } = render( - - - , - ); - - await wait(100); - stdin.write('/login'); - await wait(30); - stdin.write(ARROW_DOWN); // exit to filtered-list - await wait(30); - - // Re-press '/' from filtered-list: viewMode flips back to - // search but the query stays. - stdin.write('/'); - await wait(30); - - const output = lastFrame() ?? ''; - expect(output).toContain('Search:'); - expect(output).toContain('login'); - // Filter is still applied — non-matches stay filtered out. - expect(output).not.toContain('unrelated'); - }); - - it('↑ at top of unfiltered list also wraps into search', async () => { - // Same boundary-wrap pattern as the filtered case: a fresh - // picker (no query yet) lets the user kick off a search just - // by hitting ↑ from the first row, no '/' required. - const sessions = [ - createMockSession({ - sessionId: 's1', - prompt: 'first', - messageCount: 1, - }), - createMockSession({ - sessionId: 's2', - prompt: 'second', - messageCount: 1, - }), - ]; - const mockService = createMockSessionService(sessions); - - const { stdin, lastFrame } = render( - - - , - ); - - await wait(100); - // Already at index 0 from the initial render; ↑ wraps into search. - stdin.write(ARROW_UP); - await wait(50); - - const output = lastFrame() ?? ''; - expect(output).toContain('Search:'); - // No query yet, so the list is unfiltered — both rows visible. - expect(output).toContain('first'); - expect(output).toContain('second'); - }); - - it('↑ at top of filtered-list wraps focus back to search', async () => { - // fzf-style boundary wrap: the search row is treated as a row - // above the list, so pressing ↑ when already on the first - // filtered match returns the user to search-mode editing - // without needing another '/' keystroke. ↓ at the bottom is - // intentionally NOT wrapped — that's the loadMore sentinel. - const sessions = [ - createMockSession({ - sessionId: 'a', - prompt: 'login flow', - messageCount: 1, - }), - createMockSession({ - sessionId: 'b', - prompt: 'login bug', - messageCount: 1, - }), - ]; - const mockService = createMockSessionService(sessions); - - const { stdin, lastFrame } = render( - - - , - ); - - await wait(100); - stdin.write('/login'); - await wait(30); - - // ↓ from search exits to filtered-list at the first match - // (selectedIndex was reset to 0 when the query changed, and - // ↓ no longer advances past it). - stdin.write(ARROW_DOWN); - await wait(30); - let output = lastFrame() ?? ''; - expect(output).toContain('Filter:'); - - // ↑ at index 0 wraps focus right back into search. - stdin.write(ARROW_UP); - await wait(30); - output = lastFrame() ?? ''; - expect(output).toContain('Search:'); - expect(output).not.toContain('Filter:'); - // Query is preserved so the user is editing the same filter. - expect(output).toContain('login'); - }); - - it('↓ from search lands on the first match, not the second', async () => { - // Regression: previously ↓ from search did setViewMode('list') - // *and* advanced selectedIndex, so the user pressed ↓ once and - // jumped past the first (highest-relevance) match. Now ↓ - // simply commits the focus transition; selectedIndex stays at - // 0 (already reset by the query-change effect). - // - // Inter-key waits are 50ms (not the 30ms used elsewhere): on - // Windows runners the keypress → useEffect → render chain - // through `/login` + ARROW_DOWN + Enter consistently exceeded - // 30ms and dropped the Enter event — the spy never saw the - // selection. Tests in this file already use 50ms in similar - // multi-step sequences; align with that. - const sessions = [ - createMockSession({ - sessionId: 'first-match', - prompt: 'login flow', - messageCount: 1, - }), - createMockSession({ - sessionId: 'second-match', - prompt: 'login bug', - messageCount: 1, - }), - ]; - const mockService = createMockSessionService(sessions); - const onSelect = vi.fn(); - - const { stdin } = render( - - - , - ); - - await wait(100); - stdin.write('/login'); - await wait(50); - stdin.write(ARROW_DOWN); // exit search → first match should be highlighted - await wait(50); - stdin.write('\r'); // Enter from list = select highlighted row - await wait(50); - - expect(onSelect).toHaveBeenCalledWith('first-match'); - }); - - it('↑/↓ are a no-op in search when the query matches nothing', async () => { - // Sentinel for the "phantom mode-switch" glitch: when the - // current query has zero matches there is no row to land on, - // so ↑/↓ must not silently flip the picker out of search mode. - // The user keeps editing the query (or backs up) until the - // filter actually finds something. - const sessions = [ - createMockSession({ - sessionId: 's1', - prompt: 'unrelated work', - messageCount: 1, - }), - ]; - const mockService = createMockSessionService(sessions); - - const { stdin, lastFrame } = render( - - - , - ); - - await wait(100); - // Type a query that matches nothing. - stdin.write('/zzznomatch'); - await wait(50); - let output = lastFrame() ?? ''; - expect(output).toContain('Search:'); - expect(output).toContain('No sessions match'); - - // ↑↓ should not exit search. - stdin.write(ARROW_DOWN); - await wait(30); - output = lastFrame() ?? ''; - expect(output).toContain('Search:'); - expect(output).not.toContain('Filter:'); - - stdin.write(ARROW_UP); - await wait(30); - output = lastFrame() ?? ''; - expect(output).toContain('Search:'); - expect(output).not.toContain('Filter:'); - }); - - it('typing in filtered-list re-enters search and appends', async () => { - // After narrowing → arrow → list, further typing should refine - // (append to existing query) rather than start a fresh search. - // Prompts use no spaces so the substring "loginbug" / "loginflow" - // can match contiguously without colliding with Space-as-preview. - const sessions = [ - createMockSession({ - sessionId: 'a', - prompt: 'loginflow', - messageCount: 1, - }), - createMockSession({ - sessionId: 'b', - prompt: 'loginbug', - messageCount: 1, - }), - ]; - const mockService = createMockSessionService(sessions); - - const { stdin, lastFrame } = render( - - - , - ); - - await wait(100); - stdin.write('/login'); - await wait(30); - stdin.write(ARROW_DOWN); - await wait(30); - // Now list-mode + query='login'. Type 'bug' — implicit entry - // re-enters search and appends, yielding query='loginbug'. - stdin.write('bug'); - await wait(50); - - const output = lastFrame() ?? ''; - expect(output).toContain('Search:'); - expect(output).toContain('loginbug'); - expect(output).not.toContain('loginflow'); - }); - }); - describe('Display', () => { it('should show session metadata', async () => { const sessions = [