Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,29 @@ import stripAnsi from 'strip-ansi';

const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
const clean = (value: string | undefined) => stripAnsi(value ?? '');
const waitForFrame = async (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] waitForFrame is functionally identical to vi.waitFor from vitest, which is already used 17+ times in neighboring test files (e.g., HooksManagementDialog.test.tsx). The custom helper adds ~20 lines of duplicate infrastructure.

Replace with vi.waitFor and delete the helper:

await vi.waitFor(() => {
  expect(clean(lastFrame())).toContain('❯ 4.');
}, { interval: 10 });

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done, switched the dialog waits to vi.waitFor and removed the custom helper.

predicate: () => void,
options: { timeout?: number; interval?: number } = {},
) => {
const { timeout = 1000, interval = 10 } = options;
const start = Date.now();
let lastError: unknown;

while (Date.now() - start < timeout) {
try {
predicate();
return;
} catch (error) {
lastError = error;
}
await wait(interval);
}

if (lastError) {
throw lastError;
}
throw new Error('waitForFrame timed out');
};

const createSingleQuestion = (
overrides: Partial<
Expand Down Expand Up @@ -301,22 +324,36 @@ describe('<AskUserQuestionDialog />', () => {
await wait();

stdin.write('4'); // Select "Other" custom input
await wait(150);
expect(clean(lastFrame())).toContain('❯ 4.');
await waitForFrame(() => {
expect(clean(lastFrame())).toContain('❯ 4.');
});
await wait();

stdin.write('j');
await wait(150);
await waitForFrame(() => {
const frame = clean(lastFrame());
expect(frame).toContain('❯ 4.');
expect(frame).toContain('j');
});

stdin.write('k');
await wait(150);
expect(clean(lastFrame())).toContain('❯ 4.');
await waitForFrame(() => {
const frame = clean(lastFrame());
expect(frame).toContain('❯ 4.');
expect(frame).toContain('jk');
});

stdin.write('\u0010'); // Ctrl+P
await wait();
expect(clean(lastFrame())).toContain('❯ 3. Green');
await waitForFrame(() => {
expect(clean(lastFrame())).toContain('❯ 3. Green');
});

stdin.write('\u000E'); // Ctrl+N
await wait();
expect(clean(lastFrame())).toContain('❯ 4.');
await waitForFrame(() => {
expect(clean(lastFrame())).toContain('❯ 4.');
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] After the Ctrl+P/N round-trip, the assertion only checks ❯ 4. — it doesn't verify that the custom input text ('jk') is still present. Data loss during navigation would go undetected.

Suggested change
});
await waitForFrame(() => {
const frame = clean(lastFrame());
expect(frame).toContain('❯ 4.');
expect(frame).toContain('jk');
});

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done, the Ctrl+P/N round-trip now also checks that the custom input text is still there.


unmount();
});
Expand Down
15 changes: 11 additions & 4 deletions packages/cli/src/ui/hooks/useKeypress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { useEffect } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import type { KeypressHandler, Key } from '../contexts/KeypressContext.js';
import { useKeypressContext } from '../contexts/KeypressContext.js';

Expand All @@ -22,15 +22,22 @@ export function useKeypress(
{ isActive }: { isActive: boolean },
) {
const { subscribe, unsubscribe } = useKeypressContext();
const onKeypressRef = useRef(onKeypress);

onKeypressRef.current = onKeypress;

const handleKeypress = useCallback<KeypressHandler>((key) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The core fix (ref + useCallback) ensures the latest onKeypress is always invoked even when its identity changes across renders — but useKeypress.test.ts has no test that exercises this behavior. Every existing test passes a single vi.fn() that never changes identity. If a future refactor silently reverts to the stale-closure behavior, the hook's own unit tests will still pass; the regression would only surface in an unrelated component test.

Consider adding a regression test:

it('always invokes the latest onKeypress callback after re-render', () => {
  const first = vi.fn();
  const second = vi.fn();
  const { rerender } = renderHook(
    ({ handler }) => useKeypress(handler, { isActive: true }),
    { initialProps: { handler: first }, wrapper },
  );
  rerender({ handler: second });
  act(() => stdin.pressKey({ name: 'a', sequence: 'a' }));
  expect(first).not.toHaveBeenCalled();
  expect(second).toHaveBeenCalledTimes(1);
});

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a focused hook-level regression test for rerendered handlers.

onKeypressRef.current(key);
}, []);

useEffect(() => {
if (!isActive) {
return;
}

subscribe(onKeypress);
subscribe(handleKeypress);
return () => {
unsubscribe(onKeypress);
unsubscribe(handleKeypress);
};
}, [isActive, onKeypress, subscribe, unsubscribe]);
}, [isActive, handleKeypress, subscribe, unsubscribe]);
}
Loading