171
codex-cli/tests/multiline-history-behavior.test.tsx
Normal file
171
codex-cli/tests/multiline-history-behavior.test.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
/* --------------------------------------------------------------------------
|
||||
* Regression test – chat history navigation (↑/↓) should *only* activate
|
||||
* once the caret reaches the very first / last line of the multiline input.
|
||||
*
|
||||
* Current buggy behaviour: TerminalChatInput intercepts the up‑arrow at the
|
||||
* outer <useInput> handler regardless of the caret row, causing an immediate
|
||||
* history recall even when the user is still somewhere within a multi‑line
|
||||
* draft. The test captures the *expected* behaviour (matching e.g. Bash,
|
||||
* zsh, Readline, etc.) – the ↑ key must first move the caret vertically to
|
||||
* the topmost row; only a *subsequent* press should start cycling through
|
||||
* previous messages.
|
||||
*
|
||||
* The spec is written *before* the fix so we mark it as an expected failure
|
||||
* (it.todo) until the implementation is aligned.
|
||||
* ----------------------------------------------------------------------- */
|
||||
|
||||
import { renderTui } from "./ui-test-helpers.js";
|
||||
import * as React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module mocks *must* be registered *before* the module under test is
|
||||
// imported so that Vitest can replace the dependency during evaluation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The chat‑input component relies on an async helper that performs filesystem
|
||||
// work when images are referenced. Mock it so our unit test remains fast and
|
||||
// free of side‑effects.
|
||||
vi.mock("../src/utils/input-utils.js", () => ({
|
||||
createInputItem: vi.fn(async (text: string /*, images: Array<string> */) => ({
|
||||
role: "user",
|
||||
type: "message",
|
||||
content: [{ type: "input_text", text }],
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock the optional @lib/* dependencies so the dynamic import in parsers.ts
|
||||
// does not fail during the test environment where the alias isn't configured.
|
||||
vi.mock("@lib/format-command.js", () => ({
|
||||
formatCommandForDisplay: (cmd: Array<string>) => cmd.join(" "),
|
||||
}));
|
||||
vi.mock("@lib/approvals.js", () => ({
|
||||
isSafeCommand: (_cmd: Array<string>) => null,
|
||||
}));
|
||||
|
||||
// After mocks are in place we can safely import the component under test.
|
||||
import TerminalChatInput from "../src/components/chat/terminal-chat-new-input.js";
|
||||
|
||||
// Tiny helper mirroring the one used in other UI tests so we can await Ink's
|
||||
// internal promises between keystrokes.
|
||||
async function type(
|
||||
stdin: NodeJS.WritableStream,
|
||||
text: string,
|
||||
flush: () => Promise<void>,
|
||||
) {
|
||||
stdin.write(text);
|
||||
await flush();
|
||||
}
|
||||
|
||||
/** Build a set of no‑op callbacks so <TerminalChatInput> renders with minimal
|
||||
* scaffolding.
|
||||
*/
|
||||
function stubProps(): any {
|
||||
return {
|
||||
isNew: true,
|
||||
loading: false,
|
||||
submitInput: vi.fn(),
|
||||
confirmationPrompt: null,
|
||||
submitConfirmation: vi.fn(),
|
||||
setLastResponseId: vi.fn(),
|
||||
// Cast to any to satisfy the generic React.Dispatch signature without
|
||||
// pulling the ResponseItem type into the test bundle.
|
||||
setItems: (() => {}) as any,
|
||||
contextLeftPercent: 100,
|
||||
openOverlay: vi.fn(),
|
||||
openModelOverlay: vi.fn(),
|
||||
openHelpOverlay: vi.fn(),
|
||||
interruptAgent: vi.fn(),
|
||||
active: true,
|
||||
};
|
||||
}
|
||||
|
||||
describe("TerminalChatInput – history navigation with multiline drafts", () => {
|
||||
it("should not recall history until caret is on the first line", async () => {
|
||||
const { stdin, lastFrameStripped, flush, cleanup } = renderTui(
|
||||
React.createElement(TerminalChatInput, stubProps()),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 1. Submit one previous message so that history isn't empty.
|
||||
// -------------------------------------------------------------------
|
||||
for (const ch of ["p", "r", "e", "v"]) {
|
||||
await type(stdin, ch, flush);
|
||||
}
|
||||
await type(stdin, "\r", flush); // <Enter/Return> submits the text
|
||||
|
||||
// Let the async onSubmit finish (mocked so it's immediate, but flush once
|
||||
// more to allow state updates to propagate).
|
||||
await flush();
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 2. Start a *multi‑line* draft so that the caret ends up on row 1.
|
||||
// -------------------------------------------------------------------
|
||||
await type(stdin, "line1", flush);
|
||||
await type(stdin, "\n", flush); // newline inside the editor (Shift+Enter)
|
||||
await type(stdin, "line2", flush);
|
||||
|
||||
// Sanity‑check – both lines should be visible in the current frame.
|
||||
const frameBefore = lastFrameStripped();
|
||||
expect(frameBefore.includes("line1")).toBe(true);
|
||||
expect(frameBefore.includes("line2")).toBe(true);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 3. Press ↑ once. Expected: caret moves from (row:1) -> (row:0) but
|
||||
// NO history recall yet, so the text stays unchanged.
|
||||
// -------------------------------------------------------------------
|
||||
await type(stdin, "\x1b[A", flush); // up‑arrow
|
||||
|
||||
const frameAfter = lastFrameStripped();
|
||||
|
||||
// The buffer should be unchanged – we *haven't* entered history‑navigation
|
||||
// mode yet because the caret only moved vertically inside the draft.
|
||||
expect(frameAfter.includes("prev")).toBe(false);
|
||||
expect(frameAfter.includes("line1")).toBe(true);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("should restore the draft when navigating forward (↓) past the newest history entry", async () => {
|
||||
const { stdin, lastFrameStripped, flush, cleanup } = renderTui(
|
||||
React.createElement(TerminalChatInput, stubProps()),
|
||||
);
|
||||
|
||||
// Submit one message so we have history to recall later.
|
||||
for (const ch of ["p", "r", "e", "v"]) {
|
||||
await type(stdin, ch, flush);
|
||||
}
|
||||
await type(stdin, "\r", flush); // <Enter> – submit
|
||||
await flush();
|
||||
|
||||
// Begin a multi‑line draft that we'll want to recover later.
|
||||
await type(stdin, "draft1", flush);
|
||||
await type(stdin, "\n", flush); // newline inside editor
|
||||
await type(stdin, "draft2", flush);
|
||||
|
||||
// Record the frame so we can later assert that it comes back.
|
||||
const draftFrame = lastFrameStripped();
|
||||
expect(draftFrame.includes("draft1")).toBe(true);
|
||||
expect(draftFrame.includes("draft2")).toBe(true);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// 1) Hit ↑ twice: first press just moves the caret to row‑0, second
|
||||
// enters history mode and shows the previous message ("prev").
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
await type(stdin, "\x1b[A", flush); // first up – vertical move only
|
||||
await type(stdin, "\x1b[A", flush); // second up – recall history
|
||||
|
||||
const historyFrame = lastFrameStripped();
|
||||
expect(historyFrame.includes("prev")).toBe(true);
|
||||
|
||||
// 2) Hit ↓ once – should exit history mode and restore the original draft
|
||||
// (multi‑line input).
|
||||
await type(stdin, "\x1b[B", flush); // down‑arrow
|
||||
|
||||
const restoredFrame = lastFrameStripped();
|
||||
expect(restoredFrame.includes("draft1")).toBe(true);
|
||||
expect(restoredFrame.includes("draft2")).toBe(true);
|
||||
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user