Skip to content
Draft
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
143 changes: 143 additions & 0 deletions src/tui/components/assistant-message.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { AssistantMessageComponent } from "./assistant-message.js";

let markdownInstances: any[] = [];
let imageInstances: any[] = [];

vi.mock("@mariozechner/pi-tui", () => {
return {
Markdown: class Markdown {
constructor(public text: string, top: number, left: number, theme: any, options: any) {
markdownInstances.push(this);
}
setText = vi.fn((text) => { this.text = text; });
render = vi.fn().mockImplementation((w) => this.text ? [this.text] : []);
invalidate = vi.fn();
},
Image: class Image {
constructor(base64: string, mimeType: string, theme: any, options: any) {
imageInstances.push(this);
}
render = vi.fn().mockReturnValue(["[Image]"]);
invalidate = vi.fn();
}
};
});

vi.mock("../theme.js", () => {
return {
miladyMarkdownTheme: { mockTheme: true },
tuiTheme: {
muted: vi.fn((t) => `muted:${t}`),
dim: vi.fn((t) => `dim:${t}`)
}
};
});

describe("AssistantMessageComponent", () => {
beforeEach(() => {
vi.clearAllMocks();
markdownInstances = [];
imageInstances = [];
});

it("initializes empty and streaming", () => {
const component = new AssistantMessageComponent();
expect(component).toBeDefined();

// Empty output initially
const rendered = component.render(100);
expect(rendered).toEqual([]);
});

it("updates content and renders with streaming cursor", () => {
const component = new AssistantMessageComponent();

component.updateContent("Hello");

// The main markdown should be instantiated on construction
const md = markdownInstances[0];
expect(md.setText).toHaveBeenCalledWith("Hello β–Š");

const rendered = component.render(100);
expect(rendered).toEqual(["", "Hello β–Š"]);
});

it("finalizes and removes streaming cursor", () => {
const component = new AssistantMessageComponent();

component.updateContent("Hello");
component.finalize();

const md = markdownInstances[0];
expect(md.setText).toHaveBeenCalledWith("Hello");

const rendered = component.render(100);
expect(rendered).toEqual(["", "Hello"]);
});

it("shows thinking text when enabled", () => {
const component = new AssistantMessageComponent(true);

// updateThinking triggers a rebuild which creates a new Markdown instance
component.updateThinking("Hmm...");
component.updateContent("Yes");
component.finalize();

// We expect multiple instances due to rebuilds, but we care about the render output
const rendered = component.render(100);
expect(rendered).toEqual([
"",
"Hmm...",
"",
"Yes"
]);
});

it("ignores thinking text when disabled", () => {
const component = new AssistantMessageComponent(false);

component.updateThinking("Hmm...");
component.updateContent("Yes");
component.finalize();

const rendered = component.render(100);
expect(rendered).toEqual(["", "Yes"]);
});

it("adds and renders images", () => {
const component = new AssistantMessageComponent();

component.updateContent("Look at this");
component.finalize();

component.addImage({
base64: "dGVzdA==",
mimeType: "image/png",
filename: "test.png"
});

expect(imageInstances.length).toBe(1);

const rendered = component.render(100);
expect(rendered).toEqual([
"",
"Look at this",
"", // spacer
"[Image]"
]);
});
Comment on lines +108 to +129

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The test for image addition does not verify the component's behavior when invalid or incomplete image data is provided. This could mask bugs in error handling logic. Consider adding test cases that pass malformed or missing fields in the image object to ensure the component handles such cases gracefully and does not throw uncaught exceptions.


it("invalidates all sub-components", () => {
const component = new AssistantMessageComponent(true);
component.updateThinking("Hmm");
component.addImage({ base64: "dGVzdA==", mimeType: "image/png" });

component.invalidate();

// Check that at least some invalidates were called
const someMarkdownInvalidated = markdownInstances.some(md => md.invalidate.mock.calls.length > 0);
expect(someMarkdownInvalidated).toBe(true);
Comment on lines +139 to +140

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The assertion using .some() is relatively weak as it only verifies that at least one markdown instance was invalidated. Since the component is expected to invalidate all its active sub-components (both the main markdown and the thinking markdown), it's better to verify each one specifically.

Suggested change
const someMarkdownInvalidated = markdownInstances.some(md => md.invalidate.mock.calls.length > 0);
expect(someMarkdownInvalidated).toBe(true);
expect(markdownInstances[0].invalidate).toHaveBeenCalled();
expect(markdownInstances[markdownInstances.length - 1].invalidate).toHaveBeenCalled();

expect(imageInstances[0].invalidate).toHaveBeenCalled();
});
Comment on lines +131 to +142

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The invalidation test only checks that at least one markdown instance is invalidated (someMarkdownInvalidated), but does not verify that all markdown and image instances are invalidated as expected. This could allow partial invalidation bugs to go undetected. Consider asserting that every markdown and image instance's invalidate method is called to ensure complete invalidation.

});
181 changes: 181 additions & 0 deletions src/tui/components/tool-execution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { ToolExecutionComponent } from "./tool-execution.js";

// Mock tuiTheme first so we can verify color fns
vi.mock("../theme.js", () => {
return {
tuiTheme: {
warning: vi.fn((s) => `warning:${s}`),
muted: vi.fn((s) => `muted:${s}`),
toolPendingBg: vi.fn((s) => `toolPendingBg:${s}`),
toolSuccessBg: vi.fn((s) => `toolSuccessBg:${s}`),
toolErrorBg: vi.fn((s) => `toolErrorBg:${s}`),
bold: vi.fn((s) => `bold:${s}`),
error: vi.fn((s) => `error:${s}`),
dim: vi.fn((s) => `dim:${s}`),
}
};
});

// We'll capture added children to verify logic
let boxChildren: any[] = [];
let boxBgFn: Function | null = null;
let loaderInstance: any = null;
Comment on lines +21 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Consider adding a boxInstance variable to capture the Box instance created by the component, allowing you to verify its methods (like invalidate) in tests.

Suggested change
let boxChildren: any[] = [];
let boxBgFn: Function | null = null;
let loaderInstance: any = null;
let boxChildren: any[] = [];
let boxBgFn: Function | null = null;
let boxInstance: any = null;
let loaderInstance: any = null;


vi.mock("@mariozechner/pi-tui", () => {
return {
TUI: class TUI {},
Box: class Box {
constructor() {
boxChildren = [];
boxBgFn = null;
}
Comment on lines +29 to +32

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Capture the instance here to enable verification in tests.

Suggested change
constructor() {
boxChildren = [];
boxBgFn = null;
}
constructor() {
boxInstance = this;
boxChildren = [];
boxBgFn = null;
}

addChild = vi.fn((child) => { boxChildren.push(child); });
clear = vi.fn(() => { boxChildren = []; });
setBgFn = vi.fn((fn) => { boxBgFn = fn; });
render = vi.fn().mockReturnValue(["rendered box"]);
invalidate = vi.fn();
},
Text: class Text {
constructor(public text: string, public x: number, public y: number) {}
},
Loader: class Loader {
constructor() {
loaderInstance = this;
}
stop = vi.fn();
invalidate = vi.fn();
},
};
});

describe("ToolExecutionComponent", () => {
let tui: any;

beforeEach(async () => {
vi.clearAllMocks();
boxChildren = [];
boxBgFn = null;
loaderInstance = null;
Comment on lines +58 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Reset the boxInstance to ensure test isolation.

Suggested change
boxBgFn = null;
loaderInstance = null;
boxBgFn = null;
boxInstance = null;
loaderInstance = null;

const { TUI } = await import("@mariozechner/pi-tui");
tui = new TUI();
});

it("initializes in running state with loader and pending bg", () => {
const args = { arg1: "value1" };
const component = new ToolExecutionComponent("testTool", args, tui);

expect(component).toBeDefined();

// Header text with arg summary
expect(boxChildren.length).toBe(2);
expect(boxChildren[0].text).toContain("bold:testTool");
expect(boxChildren[0].text).toContain("muted:arg1=value1");

// Loader should be added
expect(boxChildren[1]).toBe(loaderInstance);

// Check render
expect(component.render(100)).toEqual(["rendered box"]);
});

it("summarizes long arguments correctly and clips total summary length", () => {
const args = {
shortStr: "hello",
longStr: "this is a very long string that should be truncated because it exceeds thirty characters",
num: 42,
obj: { key: "val" }
};

new ToolExecutionComponent("testTool", args, tui);

const headerText = boxChildren[0].text;
expect(headerText).toContain("bold:testTool");

// "longStr" value should be truncated to 27 chars + "..." (30 chars)
expect(headerText).toContain("this is a very long string ...");
expect(headerText).not.toContain("exceeds thirty characters");

// other args should be present if they fit
expect(headerText).toContain("shortStr=hello");
expect(headerText).toContain("num=42");
expect(headerText).toContain('obj={"key":"val"}');
});

it("updates result with success", () => {
const component = new ToolExecutionComponent("testTool", {}, tui);

component.updateResult({ text: "Success output", isError: false });

// Loader should be stopped
expect(loaderInstance.stop).toHaveBeenCalled();

// Box bg should be updated to success
expect(boxBgFn).toBeDefined();
if (boxBgFn) expect(boxBgFn("test")).toBe("toolSuccessBg:test");

// Output text should be added
expect(boxChildren.length).toBe(2); // Header + Output
expect(boxChildren[1].text).toContain("muted:Success output");
});

it("updates result with error", () => {
const component = new ToolExecutionComponent("testTool", {}, tui);

component.updateResult({ text: "Error output", isError: true });

// Loader should be stopped
expect(loaderInstance.stop).toHaveBeenCalled();

// Box bg should be updated to error
expect(boxBgFn).toBeDefined();
if (boxBgFn) expect(boxBgFn("test")).toBe("toolErrorBg:test");

// Output text should be added with error color
expect(boxChildren.length).toBe(2); // Header + Output
expect(boxChildren[1].text).toContain("error:Error output");
});

it("collapses long output to 5 lines by default", () => {
const component = new ToolExecutionComponent("testTool", {}, tui);

const longOutput = Array.from({length: 10}, (_, i) => `Line ${i+1}`).join("\n");
component.updateResult({ text: longOutput, isError: false });

// Should have header, 5 lines of output, and the "more lines" indicator
expect(boxChildren.length).toBe(3);

const outputText = boxChildren[1].text;
expect(outputText).toContain("Line 1");
expect(outputText).toContain("Line 5");
expect(outputText).not.toContain("Line 6");

expect(boxChildren[2].text).toContain("dim:... (5 more lines, Ctrl+E to expand)");
});

it("expands long output when requested", () => {
const component = new ToolExecutionComponent("testTool", {}, tui);

const longOutput = Array.from({length: 10}, (_, i) => `Line ${i+1}`).join("\n");
component.updateResult({ text: longOutput, isError: false });

component.setExpanded(true);

// Should have header, and all lines of output
expect(boxChildren.length).toBe(2);

const outputText = boxChildren[1].text;
expect(outputText).toContain("Line 1");
expect(outputText).toContain("Line 10");
});

it("invalidates correctly", () => {
const component = new ToolExecutionComponent("testTool", {}, tui);
// Grab the mock instance from our mock setup
const { Box } = require("@mariozechner/pi-tui");
Comment on lines +174 to +175

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

This require statement is unused and inconsistent with the ESM import syntax used throughout the project. It appears to be leftover code and should be removed.


component.invalidate();

expect(loaderInstance.invalidate).toHaveBeenCalled();
});
});
73 changes: 73 additions & 0 deletions src/tui/components/user-message.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { UserMessageComponent } from "./user-message.js";

let markdownText = "";
let markdownThemeRef = null;
let markdownOptions = null;

vi.mock("@mariozechner/pi-tui", () => {
return {
Markdown: class Markdown {
constructor(text, top, left, theme, options) {
markdownText = text;
markdownThemeRef = theme;
markdownOptions = options;
}
render = vi.fn().mockReturnValue(["md line 1", "md line 2"]);
invalidate = vi.fn();
}
};
});

vi.mock("../theme.js", () => {
return {
miladyMarkdownTheme: { mockTheme: true },
tuiTheme: {
userMsgBg: vi.fn((t) => `bg:${t}`)
}
};
});

describe("UserMessageComponent", () => {
beforeEach(() => {
vi.clearAllMocks();
markdownText = "";
markdownThemeRef = null;
markdownOptions = null;
});

it("initializes with text and default theme", () => {
const text = "Hello world";
const component = new UserMessageComponent(text);

expect(component).toBeDefined();
expect(markdownText).toBe(text);
expect(markdownThemeRef).toEqual({ mockTheme: true });

// Check bgColor mapping
expect(markdownOptions).toBeDefined();
expect(markdownOptions.bgColor).toBeDefined();
expect(markdownOptions.bgColor("test")).toBe("bg:test");
});

it("renders with blank line prepended", () => {
const component = new UserMessageComponent("test");
const rendered = component.render(100);

expect(rendered.length).toBe(3);
expect(rendered[0]).toBe("");
expect(rendered[1]).toBe("md line 1");
expect(rendered[2]).toBe("md line 2");
});

it("invalidates correctly", () => {
const component = new UserMessageComponent("test");

// Get the markdown instance
const mdInstance = (component as any).markdown;

component.invalidate();

expect(mdInstance.invalidate).toHaveBeenCalled();
});
});
Loading