mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
Some cleanup
This commit is contained in:
parent
245008a43c
commit
c83e67eedb
37 changed files with 30 additions and 1450 deletions
|
|
@ -1,187 +0,0 @@
|
|||
/**
|
||||
* Tests for Escape key cancel/pause functionality
|
||||
*
|
||||
* When the CLI is in a loading state (streaming LLM API calls),
|
||||
* pressing Escape should send a "cancelTask" message to the extension,
|
||||
* similar to the Cancel button in the webview-ui.
|
||||
*/
|
||||
|
||||
describe("Escape key cancel behavior", () => {
|
||||
describe("escape key detection logic", () => {
|
||||
/**
|
||||
* Simulates the escape key handling logic from App.tsx
|
||||
*
|
||||
* @param key - The key object from ink's useInput
|
||||
* @param isLoading - Whether the app is currently loading (streaming)
|
||||
* @param hasHostRef - Whether the extension host reference is available
|
||||
* @param isPickerOpen - Whether an autocomplete picker is currently open
|
||||
* @returns An object describing what action should be taken
|
||||
*/
|
||||
const handleEscapeKey = (
|
||||
key: { escape: boolean },
|
||||
isLoading: boolean,
|
||||
hasHostRef: boolean,
|
||||
isPickerOpen: boolean,
|
||||
): { shouldCancel: boolean; reason?: string } => {
|
||||
if (!key.escape) {
|
||||
return { shouldCancel: false, reason: "Not escape key" }
|
||||
}
|
||||
|
||||
if (!isLoading) {
|
||||
return { shouldCancel: false, reason: "Not in loading state" }
|
||||
}
|
||||
|
||||
if (!hasHostRef) {
|
||||
return { shouldCancel: false, reason: "No host reference" }
|
||||
}
|
||||
|
||||
if (isPickerOpen) {
|
||||
// Let picker handle escape first
|
||||
return { shouldCancel: false, reason: "Picker is open" }
|
||||
}
|
||||
|
||||
return { shouldCancel: true }
|
||||
}
|
||||
|
||||
it("should cancel task when escape is pressed during loading", () => {
|
||||
const result = handleEscapeKey(
|
||||
{ escape: true },
|
||||
true, // isLoading
|
||||
true, // hasHostRef
|
||||
false, // isPickerOpen
|
||||
)
|
||||
expect(result.shouldCancel).toBe(true)
|
||||
})
|
||||
|
||||
it("should not cancel when not loading", () => {
|
||||
const result = handleEscapeKey(
|
||||
{ escape: true },
|
||||
false, // isLoading - not loading
|
||||
true, // hasHostRef
|
||||
false, // isPickerOpen
|
||||
)
|
||||
expect(result.shouldCancel).toBe(false)
|
||||
expect(result.reason).toBe("Not in loading state")
|
||||
})
|
||||
|
||||
it("should not cancel when host reference is not available", () => {
|
||||
const result = handleEscapeKey(
|
||||
{ escape: true },
|
||||
true, // isLoading
|
||||
false, // hasHostRef - no host reference
|
||||
false, // isPickerOpen
|
||||
)
|
||||
expect(result.shouldCancel).toBe(false)
|
||||
expect(result.reason).toBe("No host reference")
|
||||
})
|
||||
|
||||
it("should not cancel when picker is open", () => {
|
||||
const result = handleEscapeKey(
|
||||
{ escape: true },
|
||||
true, // isLoading
|
||||
true, // hasHostRef
|
||||
true, // isPickerOpen - picker is open
|
||||
)
|
||||
expect(result.shouldCancel).toBe(false)
|
||||
expect(result.reason).toBe("Picker is open")
|
||||
})
|
||||
|
||||
it("should not do anything for non-escape keys", () => {
|
||||
const result = handleEscapeKey(
|
||||
{ escape: false }, // Not escape key
|
||||
true, // isLoading
|
||||
true, // hasHostRef
|
||||
false, // isPickerOpen
|
||||
)
|
||||
expect(result.shouldCancel).toBe(false)
|
||||
expect(result.reason).toBe("Not escape key")
|
||||
})
|
||||
})
|
||||
|
||||
describe("cancel message format", () => {
|
||||
it("should create the correct message format for cancelTask", () => {
|
||||
// The message sent to extension should match webview-ui format
|
||||
const cancelMessage = { type: "cancelTask" }
|
||||
|
||||
expect(cancelMessage).toEqual({ type: "cancelTask" })
|
||||
expect(cancelMessage.type).toBe("cancelTask")
|
||||
})
|
||||
|
||||
it("should match the webview-ui cancel message format", () => {
|
||||
// From webview-ui/src/components/chat/ChatView.tsx line 750:
|
||||
// vscode.postMessage({ type: "cancelTask" })
|
||||
const webviewCancelMessage = { type: "cancelTask" }
|
||||
const cliCancelMessage = { type: "cancelTask" }
|
||||
|
||||
expect(cliCancelMessage).toEqual(webviewCancelMessage)
|
||||
})
|
||||
})
|
||||
|
||||
describe("loading state scenarios", () => {
|
||||
/**
|
||||
* The isLoading state in the CLI store represents:
|
||||
* - Active API request in progress
|
||||
* - Task is streaming responses
|
||||
* - Agent is "thinking" or processing
|
||||
*/
|
||||
|
||||
it("should identify loading state during agent response", () => {
|
||||
const view = "AgentResponse"
|
||||
const isLoading = true
|
||||
|
||||
// During agent response, cancel should be available
|
||||
expect(view).toBe("AgentResponse")
|
||||
expect(isLoading).toBe(true)
|
||||
})
|
||||
|
||||
it("should identify loading state during tool use", () => {
|
||||
const view = "ToolUse"
|
||||
const isLoading = true
|
||||
|
||||
// During tool use, cancel should be available
|
||||
expect(view).toBe("ToolUse")
|
||||
expect(isLoading).toBe(true)
|
||||
})
|
||||
|
||||
it("should not identify loading state during user input", () => {
|
||||
const view = "UserInput"
|
||||
const isLoading = false
|
||||
|
||||
// During user input, no need for cancel
|
||||
expect(view).toBe("UserInput")
|
||||
expect(isLoading).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("cancel behavior expectations", () => {
|
||||
it("should pause the task (not terminate)", () => {
|
||||
// The cancelTask message pauses the task, allowing the user to:
|
||||
// 1. Review the current state
|
||||
// 2. Provide additional input
|
||||
// 3. Resume the task by typing something
|
||||
const cancelBehavior = {
|
||||
action: "pause",
|
||||
terminates: false,
|
||||
allowsResume: true,
|
||||
resumeMethod: "user provides input",
|
||||
}
|
||||
|
||||
expect(cancelBehavior.action).toBe("pause")
|
||||
expect(cancelBehavior.terminates).toBe(false)
|
||||
expect(cancelBehavior.allowsResume).toBe(true)
|
||||
})
|
||||
|
||||
it("should allow resuming by typing after cancel", () => {
|
||||
// After cancel, the user can resume by typing a message
|
||||
const postCancelState = {
|
||||
isLoading: false, // Loading stops
|
||||
canTypeMessage: true, // User can type
|
||||
messageResumesTask: true, // Typing resumes the task
|
||||
}
|
||||
|
||||
expect(postCancelState.isLoading).toBe(false)
|
||||
expect(postCancelState.canTypeMessage).toBe(true)
|
||||
expect(postCancelState.messageResumesTask).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,568 +0,0 @@
|
|||
/**
|
||||
* Tests for MultilineTextInput component
|
||||
*/
|
||||
|
||||
describe("MultilineTextInput", () => {
|
||||
describe("cursor position calculations", () => {
|
||||
// Test the getCursorPosition logic
|
||||
const getCursorPosition = (value: string, cursorIndex: number): { line: number; col: number } => {
|
||||
const lines = value.split("\n")
|
||||
let pos = 0
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]!
|
||||
const lineEnd = pos + line.length
|
||||
if (cursorIndex <= lineEnd) {
|
||||
return { line: i, col: cursorIndex - pos }
|
||||
}
|
||||
pos = lineEnd + 1 // +1 for newline
|
||||
}
|
||||
// Cursor at very end
|
||||
return { line: lines.length - 1, col: (lines[lines.length - 1] || "").length }
|
||||
}
|
||||
|
||||
// Test the getIndexFromPosition logic
|
||||
const getIndexFromPosition = (value: string, line: number, col: number): number => {
|
||||
const lines = value.split("\n")
|
||||
let index = 0
|
||||
for (let i = 0; i < line && i < lines.length; i++) {
|
||||
index += lines[i]!.length + 1 // +1 for newline
|
||||
}
|
||||
const targetLine = lines[line] || ""
|
||||
index += Math.min(col, targetLine.length)
|
||||
return index
|
||||
}
|
||||
|
||||
it("should calculate cursor position for single line", () => {
|
||||
const value = "hello"
|
||||
expect(getCursorPosition(value, 0)).toEqual({ line: 0, col: 0 })
|
||||
expect(getCursorPosition(value, 2)).toEqual({ line: 0, col: 2 })
|
||||
expect(getCursorPosition(value, 5)).toEqual({ line: 0, col: 5 })
|
||||
})
|
||||
|
||||
it("should calculate cursor position for multiple lines", () => {
|
||||
const value = "hello\nworld"
|
||||
// "hello" is 5 chars, newline at index 5
|
||||
// "world" starts at index 6
|
||||
expect(getCursorPosition(value, 0)).toEqual({ line: 0, col: 0 })
|
||||
expect(getCursorPosition(value, 5)).toEqual({ line: 0, col: 5 }) // End of first line
|
||||
expect(getCursorPosition(value, 6)).toEqual({ line: 1, col: 0 }) // Start of second line
|
||||
expect(getCursorPosition(value, 8)).toEqual({ line: 1, col: 2 }) // Middle of second line
|
||||
expect(getCursorPosition(value, 11)).toEqual({ line: 1, col: 5 }) // End of second line
|
||||
})
|
||||
|
||||
it("should calculate cursor position for three lines", () => {
|
||||
const value = "foo\nbar\nbaz"
|
||||
// "foo" = 3 chars, newline at 3
|
||||
// "bar" starts at 4, ends at 6, newline at 7
|
||||
// "baz" starts at 8
|
||||
expect(getCursorPosition(value, 0)).toEqual({ line: 0, col: 0 })
|
||||
expect(getCursorPosition(value, 3)).toEqual({ line: 0, col: 3 })
|
||||
expect(getCursorPosition(value, 4)).toEqual({ line: 1, col: 0 })
|
||||
expect(getCursorPosition(value, 7)).toEqual({ line: 1, col: 3 })
|
||||
expect(getCursorPosition(value, 8)).toEqual({ line: 2, col: 0 })
|
||||
expect(getCursorPosition(value, 11)).toEqual({ line: 2, col: 3 })
|
||||
})
|
||||
|
||||
it("should calculate index from position for single line", () => {
|
||||
const value = "hello"
|
||||
expect(getIndexFromPosition(value, 0, 0)).toBe(0)
|
||||
expect(getIndexFromPosition(value, 0, 2)).toBe(2)
|
||||
expect(getIndexFromPosition(value, 0, 5)).toBe(5)
|
||||
})
|
||||
|
||||
it("should calculate index from position for multiple lines", () => {
|
||||
const value = "hello\nworld"
|
||||
expect(getIndexFromPosition(value, 0, 0)).toBe(0)
|
||||
expect(getIndexFromPosition(value, 0, 5)).toBe(5)
|
||||
expect(getIndexFromPosition(value, 1, 0)).toBe(6)
|
||||
expect(getIndexFromPosition(value, 1, 2)).toBe(8)
|
||||
expect(getIndexFromPosition(value, 1, 5)).toBe(11)
|
||||
})
|
||||
|
||||
it("should clamp column to line length", () => {
|
||||
const value = "hi\nworld"
|
||||
// First line "hi" is only 2 chars, requesting col 5 should clamp to 2
|
||||
expect(getIndexFromPosition(value, 0, 5)).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("line splitting", () => {
|
||||
it("should split empty string into single empty line", () => {
|
||||
const value = ""
|
||||
const lines = value.split("\n")
|
||||
expect(lines).toEqual([""])
|
||||
})
|
||||
|
||||
it("should split single line correctly", () => {
|
||||
const value = "hello world"
|
||||
const lines = value.split("\n")
|
||||
expect(lines).toEqual(["hello world"])
|
||||
})
|
||||
|
||||
it("should split multiple lines correctly", () => {
|
||||
const value = "foo\nbar\nbaz"
|
||||
const lines = value.split("\n")
|
||||
expect(lines).toEqual(["foo", "bar", "baz"])
|
||||
})
|
||||
|
||||
it("should handle trailing newline", () => {
|
||||
const value = "foo\nbar\n"
|
||||
const lines = value.split("\n")
|
||||
expect(lines).toEqual(["foo", "bar", ""])
|
||||
})
|
||||
|
||||
it("should handle empty lines in middle", () => {
|
||||
const value = "foo\n\nbaz"
|
||||
const lines = value.split("\n")
|
||||
expect(lines).toEqual(["foo", "", "baz"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("line normalization", () => {
|
||||
const normalizeLineEndings = (text: string): string => {
|
||||
if (text == null) return ""
|
||||
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
}
|
||||
|
||||
it("should normalize CRLF to LF", () => {
|
||||
expect(normalizeLineEndings("hello\r\nworld")).toBe("hello\nworld")
|
||||
})
|
||||
|
||||
it("should normalize CR to LF", () => {
|
||||
expect(normalizeLineEndings("hello\rworld")).toBe("hello\nworld")
|
||||
})
|
||||
|
||||
it("should leave LF unchanged", () => {
|
||||
expect(normalizeLineEndings("hello\nworld")).toBe("hello\nworld")
|
||||
})
|
||||
|
||||
it("should handle null/undefined", () => {
|
||||
expect(normalizeLineEndings(null as unknown as string)).toBe("")
|
||||
expect(normalizeLineEndings(undefined as unknown as string)).toBe("")
|
||||
})
|
||||
|
||||
it("should handle mixed line endings", () => {
|
||||
expect(normalizeLineEndings("a\r\nb\rc\nd")).toBe("a\nb\nc\nd")
|
||||
})
|
||||
})
|
||||
|
||||
describe("key binding behavior", () => {
|
||||
it("should detect Ctrl+Enter for newline insertion", () => {
|
||||
const isNewlineKey = (key: { return: boolean; ctrl: boolean }) => key.return && key.ctrl
|
||||
expect(isNewlineKey({ return: true, ctrl: true })).toBe(true)
|
||||
expect(isNewlineKey({ return: true, ctrl: false })).toBe(false)
|
||||
expect(isNewlineKey({ return: false, ctrl: true })).toBe(false)
|
||||
})
|
||||
|
||||
it("should detect Enter for submit", () => {
|
||||
const isSubmitKey = (key: { return: boolean; ctrl: boolean }) => key.return && !key.ctrl
|
||||
expect(isSubmitKey({ return: true, ctrl: false })).toBe(true)
|
||||
expect(isSubmitKey({ return: true, ctrl: true })).toBe(false)
|
||||
expect(isSubmitKey({ return: false, ctrl: false })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("newline insertion", () => {
|
||||
it("should insert newline at cursor position", () => {
|
||||
const value = "hello"
|
||||
const cursorIndex = 2
|
||||
const newValue = value.slice(0, cursorIndex) + "\n" + value.slice(cursorIndex)
|
||||
expect(newValue).toBe("he\nllo")
|
||||
})
|
||||
|
||||
it("should insert newline at end", () => {
|
||||
const value = "hello"
|
||||
const cursorIndex = 5
|
||||
const newValue = value.slice(0, cursorIndex) + "\n" + value.slice(cursorIndex)
|
||||
expect(newValue).toBe("hello\n")
|
||||
})
|
||||
|
||||
it("should insert newline at start", () => {
|
||||
const value = "hello"
|
||||
const cursorIndex = 0
|
||||
const newValue = value.slice(0, cursorIndex) + "\n" + value.slice(cursorIndex)
|
||||
expect(newValue).toBe("\nhello")
|
||||
})
|
||||
})
|
||||
|
||||
describe("backspace behavior", () => {
|
||||
it("should delete character before cursor", () => {
|
||||
const value = "hello"
|
||||
const cursorIndex = 3
|
||||
const newValue = value.slice(0, cursorIndex - 1) + value.slice(cursorIndex)
|
||||
expect(newValue).toBe("helo")
|
||||
})
|
||||
|
||||
it("should delete newline character (merge lines)", () => {
|
||||
const value = "hello\nworld"
|
||||
const cursorIndex = 6 // Start of "world" line
|
||||
const newValue = value.slice(0, cursorIndex - 1) + value.slice(cursorIndex)
|
||||
expect(newValue).toBe("helloworld")
|
||||
})
|
||||
|
||||
it("should do nothing at start of input", () => {
|
||||
const value = "hello"
|
||||
const cursorIndex = 0
|
||||
// In real implementation, we check if cursorIndex > 0
|
||||
if (cursorIndex > 0) {
|
||||
const newValue = value.slice(0, cursorIndex - 1) + value.slice(cursorIndex)
|
||||
expect(newValue).not.toBe(value)
|
||||
}
|
||||
// At position 0, backspace does nothing
|
||||
expect(value).toBe("hello")
|
||||
})
|
||||
})
|
||||
|
||||
describe("arrow key navigation", () => {
|
||||
describe("up arrow", () => {
|
||||
it("should move to previous line preserving column", () => {
|
||||
const value = "hello\nworld"
|
||||
const getCursorPosition = (v: string, i: number) => {
|
||||
const lines = v.split("\n")
|
||||
let pos = 0
|
||||
for (let li = 0; li < lines.length; li++) {
|
||||
const line = lines[li]!
|
||||
const lineEnd = pos + line.length
|
||||
if (i <= lineEnd) {
|
||||
return { line: li, col: i - pos }
|
||||
}
|
||||
pos = lineEnd + 1
|
||||
}
|
||||
return { line: lines.length - 1, col: (lines[lines.length - 1] || "").length }
|
||||
}
|
||||
|
||||
const getIndexFromPosition = (v: string, line: number, col: number) => {
|
||||
const lines = v.split("\n")
|
||||
let index = 0
|
||||
for (let i = 0; i < line && i < lines.length; i++) {
|
||||
index += lines[i]!.length + 1
|
||||
}
|
||||
const targetLine = lines[line] || ""
|
||||
index += Math.min(col, targetLine.length)
|
||||
return index
|
||||
}
|
||||
|
||||
// Cursor at "world"[2] (index 8)
|
||||
const cursorIndex = 8
|
||||
const { line, col } = getCursorPosition(value, cursorIndex)
|
||||
expect(line).toBe(1)
|
||||
expect(col).toBe(2)
|
||||
|
||||
// Move up: should go to line 0, same column
|
||||
const targetLine = 0
|
||||
const newIndex = getIndexFromPosition(value, targetLine, col)
|
||||
expect(newIndex).toBe(2) // "he|llo"
|
||||
})
|
||||
|
||||
it("should clamp column if target line is shorter", () => {
|
||||
const value = "hi\nworld"
|
||||
const getIndexFromPosition = (v: string, line: number, col: number) => {
|
||||
const lines = v.split("\n")
|
||||
let index = 0
|
||||
for (let i = 0; i < line && i < lines.length; i++) {
|
||||
index += lines[i]!.length + 1
|
||||
}
|
||||
const targetLine = lines[line] || ""
|
||||
index += Math.min(col, targetLine.length)
|
||||
return index
|
||||
}
|
||||
|
||||
// Cursor at "world"[4] (index 7)
|
||||
// Moving up to "hi" which is only 2 chars, should clamp to col 2
|
||||
const targetLine = 0
|
||||
const col = 4
|
||||
const newIndex = getIndexFromPosition(value, targetLine, col)
|
||||
expect(newIndex).toBe(2) // End of "hi"
|
||||
})
|
||||
})
|
||||
|
||||
describe("down arrow", () => {
|
||||
it("should move to next line preserving column", () => {
|
||||
const value = "hello\nworld"
|
||||
const getIndexFromPosition = (v: string, line: number, col: number) => {
|
||||
const lines = v.split("\n")
|
||||
let index = 0
|
||||
for (let i = 0; i < line && i < lines.length; i++) {
|
||||
index += lines[i]!.length + 1
|
||||
}
|
||||
const targetLine = lines[line] || ""
|
||||
index += Math.min(col, targetLine.length)
|
||||
return index
|
||||
}
|
||||
|
||||
// Cursor at "hello"[2] (index 2)
|
||||
const col = 2
|
||||
const targetLine = 1
|
||||
const newIndex = getIndexFromPosition(value, targetLine, col)
|
||||
expect(newIndex).toBe(8) // "wo|rld"
|
||||
})
|
||||
})
|
||||
|
||||
describe("left/right arrows", () => {
|
||||
it("should move left by 1", () => {
|
||||
const cursorIndex = 5
|
||||
const newIndex = Math.max(0, cursorIndex - 1)
|
||||
expect(newIndex).toBe(4)
|
||||
})
|
||||
|
||||
it("should not move left past 0", () => {
|
||||
const cursorIndex = 0
|
||||
const newIndex = Math.max(0, cursorIndex - 1)
|
||||
expect(newIndex).toBe(0)
|
||||
})
|
||||
|
||||
it("should move right by 1", () => {
|
||||
const value = "hello"
|
||||
const cursorIndex = 2
|
||||
const newIndex = Math.min(value.length, cursorIndex + 1)
|
||||
expect(newIndex).toBe(3)
|
||||
})
|
||||
|
||||
it("should not move right past end", () => {
|
||||
const value = "hello"
|
||||
const cursorIndex = 5
|
||||
const newIndex = Math.min(value.length, cursorIndex + 1)
|
||||
expect(newIndex).toBe(5)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("word-boundary line wrapping", () => {
|
||||
// Represents a visual row after wrapping a logical line
|
||||
interface VisualRow {
|
||||
text: string
|
||||
logicalLineIndex: number
|
||||
isFirstRowOfLine: boolean
|
||||
startCol: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a logical line into visual rows based on available width.
|
||||
* Uses word-boundary wrapping: prefers to break at spaces rather than
|
||||
* in the middle of words.
|
||||
*/
|
||||
function wrapLine(lineText: string, logicalLineIndex: number, availableWidth: number): VisualRow[] {
|
||||
if (availableWidth <= 0 || lineText.length <= availableWidth) {
|
||||
return [
|
||||
{
|
||||
text: lineText,
|
||||
logicalLineIndex,
|
||||
isFirstRowOfLine: true,
|
||||
startCol: 0,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const rows: VisualRow[] = []
|
||||
let remaining = lineText
|
||||
let startCol = 0
|
||||
let isFirst = true
|
||||
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.length <= availableWidth) {
|
||||
rows.push({
|
||||
text: remaining,
|
||||
logicalLineIndex,
|
||||
isFirstRowOfLine: isFirst,
|
||||
startCol,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
// Find a good break point - prefer breaking at a space
|
||||
let breakPoint = availableWidth
|
||||
|
||||
// Look backwards from availableWidth for a space
|
||||
const searchStart = Math.min(availableWidth, remaining.length)
|
||||
let spaceIndex = -1
|
||||
for (let i = searchStart - 1; i >= 0; i--) {
|
||||
if (remaining[i] === " ") {
|
||||
spaceIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (spaceIndex > 0) {
|
||||
// Found a space - break after it (include the space in this row)
|
||||
breakPoint = spaceIndex + 1
|
||||
}
|
||||
// else: no space found, break at availableWidth (mid-word break as fallback)
|
||||
|
||||
const chunk = remaining.slice(0, breakPoint)
|
||||
rows.push({
|
||||
text: chunk,
|
||||
logicalLineIndex,
|
||||
isFirstRowOfLine: isFirst,
|
||||
startCol,
|
||||
})
|
||||
|
||||
remaining = remaining.slice(breakPoint)
|
||||
startCol += breakPoint
|
||||
isFirst = false
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
it("should not wrap text shorter than available width", () => {
|
||||
const rows = wrapLine("hello world", 0, 20)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]!.text).toBe("hello world")
|
||||
expect(rows[0]!.isFirstRowOfLine).toBe(true)
|
||||
expect(rows[0]!.startCol).toBe(0)
|
||||
})
|
||||
|
||||
it("should wrap at word boundary when possible", () => {
|
||||
const rows = wrapLine("hello world foo", 0, 10)
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]!.text).toBe("hello ")
|
||||
expect(rows[0]!.isFirstRowOfLine).toBe(true)
|
||||
expect(rows[0]!.startCol).toBe(0)
|
||||
expect(rows[1]!.text).toBe("world foo")
|
||||
expect(rows[1]!.isFirstRowOfLine).toBe(false)
|
||||
expect(rows[1]!.startCol).toBe(6) // "hello " is 6 chars
|
||||
})
|
||||
|
||||
it("should break mid-word when no space found", () => {
|
||||
const rows = wrapLine("superlongwordwithoutspaces", 0, 10)
|
||||
expect(rows).toHaveLength(3)
|
||||
// Falls back to breaking at availableWidth when no space is found
|
||||
expect(rows[0]!.text).toBe("superlongw")
|
||||
expect(rows[1]!.text).toBe("ordwithout")
|
||||
expect(rows[2]!.text).toBe("spaces")
|
||||
})
|
||||
|
||||
it("should handle multiple word wraps", () => {
|
||||
const rows = wrapLine("one two three four five six", 0, 8)
|
||||
expect(rows).toHaveLength(4)
|
||||
expect(rows[0]!.text).toBe("one two ")
|
||||
expect(rows[1]!.text).toBe("three ")
|
||||
expect(rows[2]!.text).toBe("four ")
|
||||
expect(rows[3]!.text).toBe("five six")
|
||||
})
|
||||
|
||||
it("should preserve logical line index", () => {
|
||||
const rows = wrapLine("hello world", 2, 6)
|
||||
expect(rows.every((r) => r.logicalLineIndex === 2)).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle empty string", () => {
|
||||
const rows = wrapLine("", 0, 10)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]!.text).toBe("")
|
||||
})
|
||||
|
||||
it("should handle string that exactly matches width", () => {
|
||||
const rows = wrapLine("hello", 0, 5)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]!.text).toBe("hello")
|
||||
})
|
||||
|
||||
it("should track correct startCol for wrapped rows", () => {
|
||||
const rows = wrapLine("aa bb cc dd", 0, 5)
|
||||
// "aa bb cc dd" = 11 chars, width = 5
|
||||
// "aa bb cc dd": a(0) a(1) ' '(2) b(3) b(4) ' '(5) c(6) c(7) ' '(8) d(9) d(10)
|
||||
// Search backwards from index 4:
|
||||
// index 4='b', 3='b', 2=' ' -> space at 2, breakPoint=3
|
||||
// Row 0: "aa " (3 chars), startCol=0
|
||||
// Remaining: "bb cc dd" (8 chars), startCol=3
|
||||
// Search backwards from index 4:
|
||||
// "bb cc dd": b(0) b(1) ' '(2) c(3) c(4)...
|
||||
// index 4='c', 3='c', 2=' ' -> space at 2, breakPoint=3
|
||||
// Row 1: "bb " (3 chars), startCol=3
|
||||
// Remaining: "cc dd" (5 chars), startCol=6
|
||||
// 5 <= 5, fits in one row
|
||||
// Row 2: "cc dd", startCol=6
|
||||
expect(rows).toHaveLength(3)
|
||||
expect(rows[0]!.text).toBe("aa ")
|
||||
expect(rows[0]!.startCol).toBe(0)
|
||||
expect(rows[1]!.text).toBe("bb ")
|
||||
expect(rows[1]!.startCol).toBe(3)
|
||||
expect(rows[2]!.text).toBe("cc dd")
|
||||
expect(rows[2]!.startCol).toBe(6)
|
||||
})
|
||||
})
|
||||
|
||||
describe("multi-line history integration", () => {
|
||||
it("should store multi-line entries with newlines", () => {
|
||||
const entry = "foo\nbar\nbaz"
|
||||
expect(entry.includes("\n")).toBe(true)
|
||||
expect(entry.split("\n").length).toBe(3)
|
||||
})
|
||||
|
||||
it("should restore multi-line entries correctly", () => {
|
||||
const storedEntry = "foo\nbar\nbaz"
|
||||
const lines = storedEntry.split("\n")
|
||||
expect(lines).toEqual(["foo", "bar", "baz"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("cursor overflow prevention", () => {
|
||||
/**
|
||||
* Tests the logic that prevents visual shift when cursor is at the end
|
||||
* of a max-width row. Adding a cursor space character would overflow
|
||||
* the terminal width, causing text to shift left.
|
||||
*/
|
||||
|
||||
it("should detect when cursor space would overflow", () => {
|
||||
// Simulates the overflow detection logic from renderVisualRow
|
||||
const checkWouldOverflow = (
|
||||
columns: number | undefined,
|
||||
cursorAtEnd: boolean,
|
||||
prefixLen: number,
|
||||
textLen: number,
|
||||
): boolean => {
|
||||
return columns !== undefined && cursorAtEnd && prefixLen + textLen + 1 > columns
|
||||
}
|
||||
|
||||
// Terminal width 80, prefix "> " (2 chars), text 78 chars = exactly full
|
||||
// Adding cursor space would make it 81 chars -> overflow
|
||||
expect(checkWouldOverflow(80, true, 2, 78)).toBe(true)
|
||||
|
||||
// Same scenario but cursor not at end -> no overflow issue
|
||||
expect(checkWouldOverflow(80, false, 2, 78)).toBe(false)
|
||||
|
||||
// Text shorter than max width -> no overflow
|
||||
expect(checkWouldOverflow(80, true, 2, 50)).toBe(false)
|
||||
|
||||
// No columns specified -> no overflow detection
|
||||
expect(checkWouldOverflow(undefined, true, 2, 78)).toBe(false)
|
||||
|
||||
// Continuation line with shorter indent
|
||||
expect(checkWouldOverflow(80, true, 2, 77)).toBe(false)
|
||||
|
||||
// Exactly at boundary (prefixLen + textLen + 1 === columns)
|
||||
expect(checkWouldOverflow(80, true, 2, 77)).toBe(false)
|
||||
|
||||
// One character over would overflow
|
||||
expect(checkWouldOverflow(80, true, 3, 77)).toBe(true)
|
||||
})
|
||||
|
||||
it("should not overflow when cursor is in the middle of text", () => {
|
||||
const checkWouldOverflow = (
|
||||
columns: number | undefined,
|
||||
cursorAtEnd: boolean,
|
||||
prefixLen: number,
|
||||
textLen: number,
|
||||
): boolean => {
|
||||
return columns !== undefined && cursorAtEnd && prefixLen + textLen + 1 > columns
|
||||
}
|
||||
|
||||
// Cursor in middle of max-width row - no extra space added, no overflow
|
||||
expect(checkWouldOverflow(80, false, 2, 78)).toBe(false)
|
||||
expect(checkWouldOverflow(80, false, 2, 100)).toBe(false)
|
||||
})
|
||||
|
||||
it("should correctly identify cursor at end of row", () => {
|
||||
// Simulates the cursorAtEnd check
|
||||
const isCursorAtEnd = (cursorColInRow: number, textLen: number): boolean => {
|
||||
return cursorColInRow >= textLen
|
||||
}
|
||||
|
||||
expect(isCursorAtEnd(5, 5)).toBe(true) // cursor at position 5, text length 5
|
||||
expect(isCursorAtEnd(10, 5)).toBe(true) // cursor beyond text (clamped case)
|
||||
expect(isCursorAtEnd(4, 5)).toBe(false) // cursor before end
|
||||
expect(isCursorAtEnd(0, 0)).toBe(true) // empty text, cursor at start/end
|
||||
})
|
||||
})
|
||||
|
|
@ -1,485 +0,0 @@
|
|||
/**
|
||||
* Unit tests for ScrollArea component reducer logic
|
||||
*/
|
||||
|
||||
// Since we can't easily test React components without a proper Ink test setup,
|
||||
// we'll test the reducer logic that powers the ScrollArea behavior.
|
||||
|
||||
interface ScrollAreaState {
|
||||
innerHeight: number
|
||||
height: number
|
||||
scrollTop: number
|
||||
autoScroll: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate scrollbar handle position and size
|
||||
*/
|
||||
function calculateScrollbar(
|
||||
viewportHeight: number,
|
||||
contentHeight: number,
|
||||
scrollTop: number,
|
||||
): { handleStart: number; handleHeight: number; maxScroll: number } {
|
||||
const maxScroll = Math.max(0, contentHeight - viewportHeight)
|
||||
|
||||
if (contentHeight <= viewportHeight || maxScroll === 0) {
|
||||
// No scrolling needed - handle fills entire track
|
||||
return { handleStart: 0, handleHeight: viewportHeight, maxScroll: 0 }
|
||||
}
|
||||
|
||||
// Calculate handle height as ratio of viewport to content (minimum 1 line)
|
||||
const handleHeight = Math.max(1, Math.round((viewportHeight / contentHeight) * viewportHeight))
|
||||
|
||||
// Calculate handle position
|
||||
const trackSpace = viewportHeight - handleHeight
|
||||
const scrollRatio = maxScroll > 0 ? scrollTop / maxScroll : 0
|
||||
const handleStart = Math.round(scrollRatio * trackSpace)
|
||||
|
||||
return { handleStart, handleHeight, maxScroll }
|
||||
}
|
||||
|
||||
type ScrollAreaAction =
|
||||
| { type: "SET_INNER_HEIGHT"; innerHeight: number }
|
||||
| { type: "SET_HEIGHT"; height: number }
|
||||
| { type: "SCROLL_DOWN"; amount?: number }
|
||||
| { type: "SCROLL_UP"; amount?: number }
|
||||
| { type: "SCROLL_TO_BOTTOM" }
|
||||
| { type: "SET_AUTO_SCROLL"; autoScroll: boolean }
|
||||
|
||||
// Copy of the reducer from ScrollArea.tsx for testing
|
||||
function reducer(state: ScrollAreaState, action: ScrollAreaAction): ScrollAreaState {
|
||||
const maxScroll = Math.max(0, state.innerHeight - state.height)
|
||||
|
||||
switch (action.type) {
|
||||
case "SET_INNER_HEIGHT": {
|
||||
const newMaxScroll = Math.max(0, action.innerHeight - state.height)
|
||||
if (state.autoScroll && action.innerHeight > state.innerHeight) {
|
||||
return {
|
||||
...state,
|
||||
innerHeight: action.innerHeight,
|
||||
scrollTop: newMaxScroll,
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
innerHeight: action.innerHeight,
|
||||
scrollTop: Math.min(state.scrollTop, newMaxScroll),
|
||||
}
|
||||
}
|
||||
|
||||
case "SET_HEIGHT": {
|
||||
const newMaxScroll = Math.max(0, state.innerHeight - action.height)
|
||||
if (state.autoScroll) {
|
||||
return {
|
||||
...state,
|
||||
height: action.height,
|
||||
scrollTop: newMaxScroll,
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
height: action.height,
|
||||
scrollTop: Math.min(state.scrollTop, newMaxScroll),
|
||||
}
|
||||
}
|
||||
|
||||
case "SCROLL_DOWN": {
|
||||
const amount = action.amount || 1
|
||||
const newScrollTop = Math.min(maxScroll, state.scrollTop + amount)
|
||||
const atBottom = newScrollTop >= maxScroll
|
||||
return {
|
||||
...state,
|
||||
scrollTop: newScrollTop,
|
||||
autoScroll: atBottom,
|
||||
}
|
||||
}
|
||||
|
||||
case "SCROLL_UP": {
|
||||
const amount = action.amount || 1
|
||||
const newScrollTop = Math.max(0, state.scrollTop - amount)
|
||||
return {
|
||||
...state,
|
||||
scrollTop: newScrollTop,
|
||||
autoScroll: newScrollTop >= maxScroll,
|
||||
}
|
||||
}
|
||||
|
||||
case "SCROLL_TO_BOTTOM":
|
||||
return {
|
||||
...state,
|
||||
scrollTop: maxScroll,
|
||||
autoScroll: true,
|
||||
}
|
||||
|
||||
case "SET_AUTO_SCROLL":
|
||||
return {
|
||||
...state,
|
||||
autoScroll: action.autoScroll,
|
||||
scrollTop: action.autoScroll ? maxScroll : state.scrollTop,
|
||||
}
|
||||
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
describe("ScrollArea reducer", () => {
|
||||
const initialState: ScrollAreaState = {
|
||||
innerHeight: 0,
|
||||
height: 10,
|
||||
scrollTop: 0,
|
||||
autoScroll: true,
|
||||
}
|
||||
|
||||
describe("SET_INNER_HEIGHT", () => {
|
||||
it("should update inner height", () => {
|
||||
const state = reducer(initialState, { type: "SET_INNER_HEIGHT", innerHeight: 20 })
|
||||
expect(state.innerHeight).toBe(20)
|
||||
})
|
||||
|
||||
it("should auto-scroll to bottom when content grows and autoScroll is enabled", () => {
|
||||
const state: ScrollAreaState = {
|
||||
...initialState,
|
||||
innerHeight: 15,
|
||||
autoScroll: true,
|
||||
}
|
||||
const newState = reducer(state, { type: "SET_INNER_HEIGHT", innerHeight: 25 })
|
||||
expect(newState.innerHeight).toBe(25)
|
||||
// maxScroll = 25 - 10 = 15
|
||||
expect(newState.scrollTop).toBe(15)
|
||||
})
|
||||
|
||||
it("should NOT auto-scroll when autoScroll is disabled", () => {
|
||||
const state: ScrollAreaState = {
|
||||
...initialState,
|
||||
innerHeight: 15,
|
||||
scrollTop: 3,
|
||||
autoScroll: false,
|
||||
}
|
||||
const newState = reducer(state, { type: "SET_INNER_HEIGHT", innerHeight: 25 })
|
||||
expect(newState.innerHeight).toBe(25)
|
||||
expect(newState.scrollTop).toBe(3) // Unchanged
|
||||
})
|
||||
|
||||
it("should NOT auto-scroll when content grows if autoScroll is disabled (picker use case)", () => {
|
||||
const state: ScrollAreaState = {
|
||||
...initialState,
|
||||
innerHeight: 5,
|
||||
scrollTop: 0,
|
||||
autoScroll: false,
|
||||
}
|
||||
const newState = reducer(state, { type: "SET_INNER_HEIGHT", innerHeight: 20 })
|
||||
expect(newState.innerHeight).toBe(20)
|
||||
// scrollTop should remain at 0, not jump to bottom
|
||||
expect(newState.scrollTop).toBe(0)
|
||||
})
|
||||
|
||||
it("should clamp scrollTop when content shrinks", () => {
|
||||
const state: ScrollAreaState = {
|
||||
...initialState,
|
||||
innerHeight: 30,
|
||||
scrollTop: 15,
|
||||
autoScroll: false,
|
||||
}
|
||||
const newState = reducer(state, { type: "SET_INNER_HEIGHT", innerHeight: 15 })
|
||||
// maxScroll = 15 - 10 = 5, scrollTop was 15 which is > 5
|
||||
expect(newState.scrollTop).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SET_HEIGHT", () => {
|
||||
it("should update viewport height", () => {
|
||||
const state: ScrollAreaState = {
|
||||
...initialState,
|
||||
innerHeight: 20,
|
||||
}
|
||||
const newState = reducer(state, { type: "SET_HEIGHT", height: 15 })
|
||||
expect(newState.height).toBe(15)
|
||||
})
|
||||
|
||||
it("should scroll to bottom when autoScroll is enabled and viewport changes", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 30,
|
||||
height: 10,
|
||||
scrollTop: 20, // at bottom
|
||||
autoScroll: true,
|
||||
}
|
||||
const newState = reducer(state, { type: "SET_HEIGHT", height: 15 })
|
||||
// maxScroll = 30 - 15 = 15
|
||||
expect(newState.scrollTop).toBe(15)
|
||||
})
|
||||
|
||||
it("should clamp scrollTop when viewport grows", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 20,
|
||||
height: 10,
|
||||
scrollTop: 10, // maxScroll was 10
|
||||
autoScroll: false,
|
||||
}
|
||||
const newState = reducer(state, { type: "SET_HEIGHT", height: 15 })
|
||||
// maxScroll = 20 - 15 = 5
|
||||
expect(newState.scrollTop).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SCROLL_DOWN", () => {
|
||||
it("should scroll down by 1 by default", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 30,
|
||||
height: 10,
|
||||
scrollTop: 5,
|
||||
autoScroll: false,
|
||||
}
|
||||
const newState = reducer(state, { type: "SCROLL_DOWN" })
|
||||
expect(newState.scrollTop).toBe(6)
|
||||
})
|
||||
|
||||
it("should scroll down by specified amount", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 30,
|
||||
height: 10,
|
||||
scrollTop: 5,
|
||||
autoScroll: false,
|
||||
}
|
||||
const newState = reducer(state, { type: "SCROLL_DOWN", amount: 5 })
|
||||
expect(newState.scrollTop).toBe(10)
|
||||
})
|
||||
|
||||
it("should not scroll past maxScroll", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 30,
|
||||
height: 10,
|
||||
scrollTop: 18,
|
||||
autoScroll: false,
|
||||
}
|
||||
// maxScroll = 30 - 10 = 20
|
||||
const newState = reducer(state, { type: "SCROLL_DOWN", amount: 10 })
|
||||
expect(newState.scrollTop).toBe(20)
|
||||
})
|
||||
|
||||
it("should re-enable autoScroll when reaching bottom", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 30,
|
||||
height: 10,
|
||||
scrollTop: 19,
|
||||
autoScroll: false,
|
||||
}
|
||||
const newState = reducer(state, { type: "SCROLL_DOWN" })
|
||||
expect(newState.scrollTop).toBe(20)
|
||||
expect(newState.autoScroll).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SCROLL_UP", () => {
|
||||
it("should scroll up by 1 by default", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 30,
|
||||
height: 10,
|
||||
scrollTop: 10,
|
||||
autoScroll: false,
|
||||
}
|
||||
const newState = reducer(state, { type: "SCROLL_UP" })
|
||||
expect(newState.scrollTop).toBe(9)
|
||||
})
|
||||
|
||||
it("should scroll up by specified amount", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 30,
|
||||
height: 10,
|
||||
scrollTop: 10,
|
||||
autoScroll: false,
|
||||
}
|
||||
const newState = reducer(state, { type: "SCROLL_UP", amount: 5 })
|
||||
expect(newState.scrollTop).toBe(5)
|
||||
})
|
||||
|
||||
it("should not scroll past 0", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 30,
|
||||
height: 10,
|
||||
scrollTop: 3,
|
||||
autoScroll: false,
|
||||
}
|
||||
const newState = reducer(state, { type: "SCROLL_UP", amount: 10 })
|
||||
expect(newState.scrollTop).toBe(0)
|
||||
})
|
||||
|
||||
it("should disable autoScroll when scrolling up from bottom", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 30,
|
||||
height: 10,
|
||||
scrollTop: 20, // at bottom
|
||||
autoScroll: true,
|
||||
}
|
||||
const newState = reducer(state, { type: "SCROLL_UP" })
|
||||
expect(newState.scrollTop).toBe(19)
|
||||
expect(newState.autoScroll).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SCROLL_TO_BOTTOM", () => {
|
||||
it("should scroll to bottom and enable autoScroll", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 30,
|
||||
height: 10,
|
||||
scrollTop: 5,
|
||||
autoScroll: false,
|
||||
}
|
||||
const newState = reducer(state, { type: "SCROLL_TO_BOTTOM" })
|
||||
expect(newState.scrollTop).toBe(20) // maxScroll
|
||||
expect(newState.autoScroll).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("SET_AUTO_SCROLL", () => {
|
||||
it("should enable autoScroll and scroll to bottom", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 30,
|
||||
height: 10,
|
||||
scrollTop: 5,
|
||||
autoScroll: false,
|
||||
}
|
||||
const newState = reducer(state, { type: "SET_AUTO_SCROLL", autoScroll: true })
|
||||
expect(newState.autoScroll).toBe(true)
|
||||
expect(newState.scrollTop).toBe(20) // scrolled to bottom
|
||||
})
|
||||
|
||||
it("should disable autoScroll without changing scrollTop", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 30,
|
||||
height: 10,
|
||||
scrollTop: 20,
|
||||
autoScroll: true,
|
||||
}
|
||||
const newState = reducer(state, { type: "SET_AUTO_SCROLL", autoScroll: false })
|
||||
expect(newState.autoScroll).toBe(false)
|
||||
expect(newState.scrollTop).toBe(20)
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle content smaller than viewport", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 5, // smaller than viewport
|
||||
height: 10,
|
||||
scrollTop: 0,
|
||||
autoScroll: true,
|
||||
}
|
||||
const downState = reducer(state, { type: "SCROLL_DOWN" })
|
||||
expect(downState.scrollTop).toBe(0) // maxScroll is 0
|
||||
|
||||
const bottomState = reducer(state, { type: "SCROLL_TO_BOTTOM" })
|
||||
expect(bottomState.scrollTop).toBe(0)
|
||||
})
|
||||
|
||||
it("should handle empty content", () => {
|
||||
const state: ScrollAreaState = {
|
||||
innerHeight: 0,
|
||||
height: 10,
|
||||
scrollTop: 0,
|
||||
autoScroll: true,
|
||||
}
|
||||
const newState = reducer(state, { type: "SCROLL_DOWN" })
|
||||
expect(newState.scrollTop).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("calculateScrollbar", () => {
|
||||
it("should return full height handle when content fits in viewport", () => {
|
||||
const result = calculateScrollbar(10, 5, 0)
|
||||
expect(result.handleHeight).toBe(10)
|
||||
expect(result.handleStart).toBe(0)
|
||||
expect(result.maxScroll).toBe(0)
|
||||
})
|
||||
|
||||
it("should return full height handle when content equals viewport", () => {
|
||||
const result = calculateScrollbar(10, 10, 0)
|
||||
expect(result.handleHeight).toBe(10)
|
||||
expect(result.handleStart).toBe(0)
|
||||
expect(result.maxScroll).toBe(0)
|
||||
})
|
||||
|
||||
it("should calculate handle height proportional to content ratio", () => {
|
||||
// Viewport is half of content, handle should be ~half of viewport
|
||||
const result = calculateScrollbar(10, 20, 0)
|
||||
expect(result.handleHeight).toBe(5) // 10 / 20 * 10 = 5
|
||||
expect(result.maxScroll).toBe(10)
|
||||
})
|
||||
|
||||
it("should position handle at top when scrollTop is 0", () => {
|
||||
const result = calculateScrollbar(10, 20, 0)
|
||||
expect(result.handleStart).toBe(0)
|
||||
})
|
||||
|
||||
it("should position handle at bottom when scrolled to max", () => {
|
||||
// Viewport 10, content 20, maxScroll = 10
|
||||
// Handle height = 5, track space = 10 - 5 = 5
|
||||
// At max scroll, handle should be at position 5
|
||||
const result = calculateScrollbar(10, 20, 10)
|
||||
expect(result.handleStart).toBe(5)
|
||||
})
|
||||
|
||||
it("should position handle in middle when scrolled halfway", () => {
|
||||
// Viewport 10, content 20, maxScroll = 10
|
||||
// Handle height = 5, track space = 5
|
||||
// At scroll 5 (50%), handle should be at position 2-3
|
||||
const result = calculateScrollbar(10, 20, 5)
|
||||
expect(result.handleStart).toBe(3) // Math.round(0.5 * 5) = 3
|
||||
})
|
||||
|
||||
it("should enforce minimum handle height of 1", () => {
|
||||
// Very large content relative to viewport
|
||||
const result = calculateScrollbar(10, 1000, 0)
|
||||
expect(result.handleHeight).toBe(1) // Math.max(1, Math.round(10/1000 * 10)) = 1
|
||||
})
|
||||
|
||||
it("should handle small viewports", () => {
|
||||
const result = calculateScrollbar(3, 10, 0)
|
||||
expect(result.handleHeight).toBe(1) // Math.round(3/10 * 3) = 1
|
||||
expect(result.maxScroll).toBe(7)
|
||||
})
|
||||
|
||||
it("should handle edge case where scrollTop exceeds maxScroll", () => {
|
||||
// This shouldn't happen in practice, but test for robustness
|
||||
const result = calculateScrollbar(10, 20, 15) // maxScroll is 10
|
||||
// scrollRatio = 15/10 = 1.5, but handleStart should be clamped by trackSpace
|
||||
expect(result.handleStart).toBe(8) // Math.round(1.5 * 5) = 8 (will be past track but shows calculation)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* Helper function that mirrors the scrollbar visibility logic from ScrollArea.tsx
|
||||
* This is used to test the visibility behavior without needing to render the component.
|
||||
*/
|
||||
function shouldShowScrollbar(showScrollbar: boolean, maxScroll: number, isActive: boolean): boolean {
|
||||
// Show scrollbar when: there's content to scroll, OR when focused (to indicate focus state)
|
||||
// Hide scrollbar only when: not focused AND nothing to scroll
|
||||
return showScrollbar && (maxScroll > 0 || isActive)
|
||||
}
|
||||
|
||||
describe("scrollbar visibility", () => {
|
||||
it("should show scrollbar when there is content to scroll (regardless of focus)", () => {
|
||||
// When maxScroll > 0, scrollbar should show regardless of isActive
|
||||
expect(shouldShowScrollbar(true, 10, true)).toBe(true)
|
||||
expect(shouldShowScrollbar(true, 10, false)).toBe(true)
|
||||
})
|
||||
|
||||
it("should show scrollbar when focused, even if nothing to scroll", () => {
|
||||
// When isActive is true but maxScroll is 0, scrollbar should show for focus indication
|
||||
expect(shouldShowScrollbar(true, 0, true)).toBe(true)
|
||||
})
|
||||
|
||||
it("should hide scrollbar when not focused and nothing to scroll", () => {
|
||||
// Only hide when both: not focused AND nothing to scroll
|
||||
expect(shouldShowScrollbar(true, 0, false)).toBe(false)
|
||||
})
|
||||
|
||||
it("should respect showScrollbar prop", () => {
|
||||
// When showScrollbar is false, never show scrollbar
|
||||
expect(shouldShowScrollbar(false, 10, true)).toBe(false)
|
||||
expect(shouldShowScrollbar(false, 0, true)).toBe(false)
|
||||
expect(shouldShowScrollbar(false, 10, false)).toBe(false)
|
||||
expect(shouldShowScrollbar(false, 0, false)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
import * as historyStorage from "../utils/historyStorage.js"
|
||||
|
||||
vi.mock("../utils/historyStorage.js")
|
||||
|
||||
// Track state and callbacks for testing.
|
||||
let mockState: Record<string, unknown> = {}
|
||||
let effectCallbacks: Array<() => void | (() => void)> = []
|
||||
|
||||
vi.mock("react", () => ({
|
||||
useState: vi.fn((initial: unknown) => {
|
||||
const key = `state_${Object.keys(mockState).length}`
|
||||
if (!(key in mockState)) {
|
||||
mockState[key] = initial
|
||||
}
|
||||
return [
|
||||
mockState[key],
|
||||
(newValue: unknown) => {
|
||||
if (typeof newValue === "function") {
|
||||
mockState[key] = (newValue as (prev: unknown) => unknown)(mockState[key])
|
||||
} else {
|
||||
mockState[key] = newValue
|
||||
}
|
||||
},
|
||||
]
|
||||
}),
|
||||
useEffect: vi.fn((callback: () => void | (() => void)) => {
|
||||
effectCallbacks.push(callback)
|
||||
}),
|
||||
useCallback: vi.fn((callback: unknown) => callback),
|
||||
useRef: vi.fn((initial: unknown) => ({ current: initial })),
|
||||
}))
|
||||
|
||||
describe("useInputHistory", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockState = {}
|
||||
effectCallbacks = []
|
||||
|
||||
// Default mock for loadHistory
|
||||
vi.mocked(historyStorage.loadHistory).mockResolvedValue([])
|
||||
vi.mocked(historyStorage.addToHistory).mockImplementation(async (entry) => [entry])
|
||||
})
|
||||
|
||||
describe("historyStorage functions", () => {
|
||||
it("loadHistory should be called when hook effect runs", async () => {
|
||||
vi.mocked(historyStorage.loadHistory).mockResolvedValue(["entry1", "entry2"])
|
||||
|
||||
// Import the hook (this triggers the module initialization)
|
||||
const { useInputHistory } = await import("../ui/hooks/useInputHistory.js")
|
||||
useInputHistory()
|
||||
|
||||
// Run the effect callbacks
|
||||
for (const cb of effectCallbacks) {
|
||||
cb()
|
||||
}
|
||||
|
||||
expect(historyStorage.loadHistory).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("addToHistory should be called with trimmed entry", async () => {
|
||||
vi.mocked(historyStorage.addToHistory).mockResolvedValue(["new entry"])
|
||||
|
||||
const { useInputHistory } = await import("../ui/hooks/useInputHistory.js")
|
||||
const result = useInputHistory()
|
||||
|
||||
await result.addEntry(" new entry ")
|
||||
|
||||
expect(historyStorage.addToHistory).toHaveBeenCalledWith("new entry")
|
||||
})
|
||||
|
||||
it("addToHistory should not be called for empty entries", async () => {
|
||||
const { useInputHistory } = await import("../ui/hooks/useInputHistory.js")
|
||||
const result = useInputHistory()
|
||||
|
||||
await result.addEntry("")
|
||||
|
||||
expect(historyStorage.addToHistory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("addToHistory should not be called for whitespace-only entries", async () => {
|
||||
const { useInputHistory } = await import("../ui/hooks/useInputHistory.js")
|
||||
const result = useInputHistory()
|
||||
|
||||
await result.addEntry(" ")
|
||||
|
||||
expect(historyStorage.addToHistory).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("navigation logic", () => {
|
||||
it("should have initial state with no history value", async () => {
|
||||
const { useInputHistory } = await import("../ui/hooks/useInputHistory.js")
|
||||
const result = useInputHistory()
|
||||
|
||||
// Initial state should have null history value (not browsing)
|
||||
expect(result.historyValue).toBeNull()
|
||||
expect(result.isBrowsing).toBe(false)
|
||||
})
|
||||
|
||||
it("should export navigateUp and navigateDown functions for manual navigation", async () => {
|
||||
const { useInputHistory } = await import("../ui/hooks/useInputHistory.js")
|
||||
const result = useInputHistory()
|
||||
|
||||
expect(typeof result.navigateUp).toBe("function")
|
||||
expect(typeof result.navigateDown).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("resetBrowsing", () => {
|
||||
it("should be a function", async () => {
|
||||
const { useInputHistory } = await import("../ui/hooks/useInputHistory.js")
|
||||
const result = useInputHistory()
|
||||
|
||||
expect(typeof result.resetBrowsing).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("return value structure", () => {
|
||||
it("should return the expected interface", async () => {
|
||||
const { useInputHistory } = await import("../ui/hooks/useInputHistory.js")
|
||||
const result = useInputHistory()
|
||||
|
||||
expect(result).toHaveProperty("addEntry")
|
||||
expect(result).toHaveProperty("historyValue")
|
||||
expect(result).toHaveProperty("isBrowsing")
|
||||
expect(result).toHaveProperty("resetBrowsing")
|
||||
expect(result).toHaveProperty("history")
|
||||
expect(result).toHaveProperty("draft")
|
||||
expect(result).toHaveProperty("navigateUp")
|
||||
expect(result).toHaveProperty("navigateDown")
|
||||
|
||||
expect(typeof result.addEntry).toBe("function")
|
||||
expect(typeof result.resetBrowsing).toBe("function")
|
||||
expect(typeof result.navigateUp).toBe("function")
|
||||
expect(typeof result.navigateDown).toBe("function")
|
||||
expect(Array.isArray(result.history)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("historyStorage integration", () => {
|
||||
// Test the actual historyStorage functions directly
|
||||
// These are more reliable than hook tests with mocked React
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
it("MAX_HISTORY_ENTRIES should be 500", async () => {
|
||||
const { MAX_HISTORY_ENTRIES } = await import("../utils/historyStorage.js")
|
||||
expect(MAX_HISTORY_ENTRIES).toBe(500)
|
||||
})
|
||||
|
||||
it("getHistoryFilePath should return path in ~/.roo directory", async () => {
|
||||
// Un-mock for this test
|
||||
vi.doUnmock("../utils/historyStorage.js")
|
||||
const { getHistoryFilePath } = await import("../utils/historyStorage.js")
|
||||
|
||||
const path = getHistoryFilePath()
|
||||
expect(path).toContain(".roo")
|
||||
expect(path).toContain("cli-history.json")
|
||||
})
|
||||
})
|
||||
|
|
@ -18,7 +18,7 @@ import {
|
|||
import { setLogger } from "@roo-code/vscode-shim"
|
||||
|
||||
import { ExtensionHost } from "./extension-host.js"
|
||||
import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "./utils.js"
|
||||
import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "./utils/extensionHostUtils.js"
|
||||
|
||||
const DEFAULTS = {
|
||||
mode: "code",
|
||||
|
|
|
|||
|
|
@ -8,15 +8,15 @@ import type { ExtensionMessage, ClineMessage, ClineAsk, ClineSay, TodoItem, Webv
|
|||
import { consolidateTokenUsage, consolidateApiRequests, consolidateCommands } from "@roo-code/core/message-utils"
|
||||
|
||||
import { FOLLOWUP_TIMEOUT_SECONDS } from "../constants.js"
|
||||
import { getGlobalCommand, getGlobalCommandsForAutocomplete } from "../globalCommands.js"
|
||||
import { getGlobalCommand, getGlobalCommandsForAutocomplete } from "../utils/globalCommands.js"
|
||||
import { toolInspectorLog, clearToolInspectorLog } from "../utils/toolInspectorLogger.js"
|
||||
import { arePathsEqual } from "../utils/pathUtils.js"
|
||||
import { getContextWindow } from "../utils/getContextWindow.js"
|
||||
|
||||
import type { AppProps, TUIMessage, PendingAsk, View, ToolData } from "./types.js"
|
||||
|
||||
import * as theme from "./utils/theme.js"
|
||||
import { matchesGlobalSequence } from "./utils/globalInputSequences.js"
|
||||
import * as theme from "./theme.js"
|
||||
import { matchesGlobalSequence } from "../utils/globalInputSequences.js"
|
||||
|
||||
import { useCLIStore } from "./store.js"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCLIStore } from "../ui/store.js"
|
||||
import { useCLIStore } from "../store.js"
|
||||
|
||||
describe("useCLIStore", () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { memo } from "react"
|
||||
import { Box, Newline, Text } from "ink"
|
||||
|
||||
import * as theme from "../utils/theme.js"
|
||||
import * as theme from "../theme.js"
|
||||
import type { TUIMessage } from "../types.js"
|
||||
import TodoDisplay from "./TodoDisplay.js"
|
||||
import { getToolRenderer } from "./tools/index.js"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Text, Box } from "ink"
|
|||
import type { TokenUsage } from "@roo-code/types"
|
||||
|
||||
import { useTerminalSize } from "../hooks/TerminalSizeContext.js"
|
||||
import * as theme from "../utils/theme.js"
|
||||
import * as theme from "../theme.js"
|
||||
import MetricsDisplay from "./MetricsDisplay.js"
|
||||
|
||||
interface HeaderProps {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Text, Box } from "ink"
|
|||
|
||||
import type { TokenUsage } from "@roo-code/types"
|
||||
|
||||
import * as theme from "../utils/theme.js"
|
||||
import * as theme from "../theme.js"
|
||||
import ProgressBar from "./ProgressBar.js"
|
||||
|
||||
interface MetricsDisplayProps {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@
|
|||
import { useState, useEffect, useMemo, useCallback, useRef } from "react"
|
||||
import { Box, Text, useInput, type Key } from "ink"
|
||||
|
||||
import { isGlobalInputSequence } from "../utils/globalInputSequences.js"
|
||||
import { isGlobalInputSequence } from "../../utils/globalInputSequences.js"
|
||||
|
||||
export interface MultilineTextInputProps {
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { memo } from "react"
|
||||
import { Text } from "ink"
|
||||
|
||||
import * as theme from "../utils/theme.js"
|
||||
import * as theme from "../theme.js"
|
||||
|
||||
interface ProgressBarProps {
|
||||
/** Current value (e.g., contextTokens) */
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Box, DOMElement, measureElement, Text, useInput } from "ink"
|
||||
import { useEffect, useReducer, useRef, useCallback, useMemo, useState } from "react"
|
||||
|
||||
import * as theme from "../utils/theme.js"
|
||||
import * as theme from "../theme.js"
|
||||
|
||||
interface ScrollAreaState {
|
||||
innerHeight: number
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Box, Text } from "ink"
|
||||
import { memo } from "react"
|
||||
|
||||
import * as theme from "../utils/theme.js"
|
||||
import * as theme from "../theme.js"
|
||||
|
||||
interface ScrollIndicatorProps {
|
||||
scrollTop: number
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { memo } from "react"
|
|||
import { Text, Box } from "ink"
|
||||
|
||||
import type { Toast, ToastType } from "../hooks/useToast.js"
|
||||
import * as theme from "../utils/theme.js"
|
||||
import * as theme from "../theme.js"
|
||||
|
||||
interface ToastDisplayProps {
|
||||
/** The current toast to display (null if no toast) */
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Box, Text } from "ink"
|
|||
|
||||
import type { TodoItem } from "@roo-code/types"
|
||||
|
||||
import * as theme from "../utils/theme.js"
|
||||
import * as theme from "../theme.js"
|
||||
|
||||
/**
|
||||
* Status icons for TODO items using Unicode characters
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Box, Text } from "ink"
|
|||
|
||||
import type { TodoItem } from "@roo-code/types"
|
||||
|
||||
import * as theme from "../utils/theme.js"
|
||||
import * as theme from "../theme.js"
|
||||
import ProgressBar from "./ProgressBar.js"
|
||||
import { Icon, type IconName } from "./Icon.js"
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Box, Text } from "ink"
|
|||
import fuzzysort from "fuzzysort"
|
||||
|
||||
import type { AutocompleteTrigger, AutocompleteItem, TriggerDetectionResult } from "../types.js"
|
||||
import { GlobalCommandAction } from "../../../../globalCommands.js"
|
||||
import { GlobalCommandAction } from "../../../../utils/globalCommands.js"
|
||||
|
||||
export interface SlashCommandResult extends AutocompleteItem {
|
||||
name: string
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import { Box, Text } from "ink"
|
||||
|
||||
import * as theme from "../../utils/theme.js"
|
||||
import * as theme from "../../theme.js"
|
||||
import { Icon } from "../Icon.js"
|
||||
import type { ToolRendererProps } from "./types.js"
|
||||
import { getToolDisplayName, getToolIconName } from "./utils.js"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Box, Text } from "ink"
|
||||
|
||||
import * as theme from "../../utils/theme.js"
|
||||
import * as theme from "../../theme.js"
|
||||
import { Icon } from "../Icon.js"
|
||||
import type { ToolRendererProps } from "./types.js"
|
||||
import { truncateText, sanitizeContent, getToolIconName } from "./utils.js"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Box, Text } from "ink"
|
||||
|
||||
import * as theme from "../../utils/theme.js"
|
||||
import * as theme from "../../theme.js"
|
||||
import type { ToolRendererProps } from "./types.js"
|
||||
import { truncateText, sanitizeContent } from "./utils.js"
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import { Box, Text } from "ink"
|
||||
|
||||
import * as theme from "../../utils/theme.js"
|
||||
import * as theme from "../../theme.js"
|
||||
import { Icon } from "../Icon.js"
|
||||
import type { ToolRendererProps } from "./types.js"
|
||||
import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName } from "./utils.js"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import { Box, Text } from "ink"
|
||||
|
||||
import * as theme from "../../utils/theme.js"
|
||||
import * as theme from "../../theme.js"
|
||||
import { Icon } from "../Icon.js"
|
||||
import type { ToolRendererProps } from "./types.js"
|
||||
import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName, parseDiff } from "./utils.js"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import { Box, Text } from "ink"
|
||||
|
||||
import * as theme from "../../utils/theme.js"
|
||||
import * as theme from "../../theme.js"
|
||||
import { Icon } from "../Icon.js"
|
||||
import type { ToolRendererProps } from "./types.js"
|
||||
import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName } from "./utils.js"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import { Box, Text } from "ink"
|
||||
|
||||
import * as theme from "../../utils/theme.js"
|
||||
import * as theme from "../../theme.js"
|
||||
import { Icon } from "../Icon.js"
|
||||
import type { ToolRendererProps } from "./types.js"
|
||||
import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName } from "./utils.js"
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import { Box, Text } from "ink"
|
||||
|
||||
import * as theme from "../../utils/theme.js"
|
||||
import * as theme from "../../theme.js"
|
||||
import { Icon } from "../Icon.js"
|
||||
import type { ToolRendererProps } from "./types.js"
|
||||
import { truncateText, sanitizeContent, getToolDisplayName, getToolIconName } from "./utils.js"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useToastStore } from "../ui/hooks/useToast.js"
|
||||
import { useToastStore } from "../useToast.js"
|
||||
|
||||
describe("useToastStore", () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -17,7 +17,7 @@ export type { UseInputHistoryOptions, UseInputHistoryReturn } from "./hooks/useI
|
|||
export { useCLIStore } from "./store.js"
|
||||
|
||||
// Theme
|
||||
export * as theme from "./utils/theme.js"
|
||||
export * as theme from "./theme.js"
|
||||
|
||||
// Types
|
||||
export * from "./types.js"
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
/**
|
||||
* Unit tests for CLI utility functions
|
||||
*/
|
||||
|
||||
import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "../utils.js"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
|
||||
// Mock fs module
|
||||
import { getEnvVarName, getApiKeyFromEnv, getDefaultExtensionPath } from "../extensionHostUtils.js"
|
||||
|
||||
vi.mock("fs")
|
||||
|
||||
describe("getEnvVarName", () => {
|
||||
|
|
@ -1,14 +1,7 @@
|
|||
import type { Key } from "ink"
|
||||
|
||||
import {
|
||||
GLOBAL_INPUT_SEQUENCES,
|
||||
isGlobalInputSequence,
|
||||
matchesGlobalSequence,
|
||||
} from "../ui/utils/globalInputSequences.js"
|
||||
import { GLOBAL_INPUT_SEQUENCES, isGlobalInputSequence, matchesGlobalSequence } from "../globalInputSequences.js"
|
||||
|
||||
/**
|
||||
* Helper to create a minimal Key object for testing
|
||||
*/
|
||||
function createKey(overrides: Partial<Key> = {}): Key {
|
||||
return {
|
||||
upArrow: false,
|
||||
|
|
@ -1,13 +1,7 @@
|
|||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
|
||||
import {
|
||||
getHistoryFilePath,
|
||||
loadHistory,
|
||||
saveHistory,
|
||||
addToHistory,
|
||||
MAX_HISTORY_ENTRIES,
|
||||
} from "../utils/historyStorage.js"
|
||||
import { getHistoryFilePath, loadHistory, saveHistory, addToHistory, MAX_HISTORY_ENTRIES } from "../historyStorage.js"
|
||||
|
||||
vi.mock("fs/promises")
|
||||
|
||||
Loading…
Add table
Reference in a new issue