Skip to content
Open
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
1,138 changes: 1,138 additions & 0 deletions clients/tui/__tests__/App.test.tsx

Large diffs are not rendered by default.

617 changes: 617 additions & 0 deletions clients/tui/__tests__/AuthTab.test.tsx

Large diffs are not rendered by default.

97 changes: 97 additions & 0 deletions clients/tui/__tests__/DetailsModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { render } from "ink-testing-library";
import { Text } from "ink";

// ScrollView: passthrough so `content` mounts and the imperative ref API
// (scrollBy / getViewportHeight) exists for the scroll-key handlers.
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));

import { DetailsModal } from "../src/components/DetailsModal.js";

// Ink processes stdin keypresses asynchronously — await this after stdin.write.
const tick = () => new Promise((resolve) => setTimeout(resolve, 20));

const ESC = String.fromCharCode(27);
const UP = `${ESC}[A`;
const DOWN = `${ESC}[B`;
const PAGE_UP = `${ESC}[5~`;
const PAGE_DOWN = `${ESC}[6~`;

describe("DetailsModal", () => {
it("renders without crashing with content", () => {
const { unmount } = render(
<DetailsModal
title="Details"
content={<Text>some content</Text>}
width={120}
height={30}
onClose={() => {}}
/>,
);
// Modal is position="absolute" so lastFrame is empty; just confirm it
// mounted and unmounts cleanly (running the resize cleanup effect).
unmount();
});

it("handles all scroll keys via the ScrollView ref", async () => {
const { stdin } = render(
<DetailsModal
title="Details"
content={<Text>scrollable</Text>}
width={120}
height={30}
onClose={() => {}}
/>,
);

await tick();
stdin.write(DOWN);
await tick();
stdin.write(UP);
await tick();
stdin.write(PAGE_DOWN);
await tick();
stdin.write(PAGE_UP);
await tick();
});

it("ignores keys it does not handle", async () => {
const onClose = vi.fn();
const { stdin } = render(
<DetailsModal
title="Details"
content={<Text>x</Text>}
width={120}
height={30}
onClose={onClose}
/>,
);

await tick();
// A plain character key matches none of the branches.
stdin.write("a");
await tick();

expect(onClose).not.toHaveBeenCalled();
});

it("calls onClose on ESC", async () => {
const onClose = vi.fn();
const { stdin } = render(
<DetailsModal
title="Details"
content={<Text>x</Text>}
width={120}
height={30}
onClose={onClose}
/>,
);

await tick();
stdin.write(ESC);
await tick();

expect(onClose).toHaveBeenCalledTimes(1);
});
});
270 changes: 270 additions & 0 deletions clients/tui/__tests__/HistoryTab.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
import React from "react";
import { describe, it, expect, vi } from "vitest";
import { render } from "ink-testing-library";
import type { MessageEntry } from "@inspector/core/mcp/index.js";

// MUST mock ink-scroll-view: the real ScrollView renders a placeholder minimap
// in the non-TTY test env and never mounts its children. This passthrough
// renders children directly and stubs scrollBy/scrollTo/getViewportHeight.
vi.mock("ink-scroll-view", () => import("./helpers/inkScrollViewMock.js"));

import { HistoryTab } from "../src/components/HistoryTab.js";

// Ink processes stdin keypresses asynchronously — await this after stdin.write
// and after rerender() before asserting.
const tick = () => new Promise((resolve) => setTimeout(resolve, 20));

// Real terminal escape sequences so ink parses them as arrow / page keys.
const ESC = String.fromCharCode(27);
const UP = `${ESC}[A`;
const DOWN = `${ESC}[B`;
const PAGE_UP = `${ESC}[5~`;
const PAGE_DOWN = `${ESC}[6~`;

const ts = new Date("2024-01-01T12:34:56Z");

const entry = (over: Partial<MessageEntry>): MessageEntry =>
({
id: "id",
timestamp: ts,
direction: "request",
message: { jsonrpc: "2.0", id: 1, method: "ping" },
...over,
}) as unknown as MessageEntry;

// One entry exercising each label / direction / detail branch.
const reqWithResponse = entry({
id: "m0",
direction: "request",
message: { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} },
response: { jsonrpc: "2.0", id: 1, result: { tools: [] } },
duration: 5,
});
const reqPending = entry({
id: "m1",
direction: "request",
message: { jsonrpc: "2.0", id: 2, method: "tools/call" },
});
const respResult = entry({
id: "m2",
direction: "response",
message: { jsonrpc: "2.0", id: 3, result: { ok: true } },
});
const respError = entry({
id: "m3",
direction: "response",
message: { jsonrpc: "2.0", id: 4, error: { code: -32601, message: "no" } },
});
const respPlain = entry({
id: "m4",
direction: "response",
message: { jsonrpc: "2.0", id: 5 },
});
const notification = entry({
id: "m5",
direction: "notification",
message: { jsonrpc: "2.0", method: "notifications/message" },
});
const unknownEntry = entry({
id: "m6",
direction: "notification",
message: { jsonrpc: "2.0" },
});

const allMessages: MessageEntry[] = [
reqWithResponse,
reqPending,
respResult,
respError,
respPlain,
notification,
unknownEntry,
];

describe("HistoryTab", () => {
it("renders the empty state when there are no messages", () => {
const onCountChange = vi.fn();
const { lastFrame } = render(
<HistoryTab
serverName="srv"
messages={[]}
width={120}
height={30}
onCountChange={onCountChange}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Messages (0)");
expect(frame).toContain("No messages");
expect(frame).toContain("Select a message to view details");
expect(onCountChange).toHaveBeenCalledWith(0);
});

it("renders every list-label and direction-symbol variant", () => {
const { lastFrame } = render(
<HistoryTab
serverName="srv"
messages={allMessages}
width={120}
height={40}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Messages (7)");
// request with response → "✓"; pending request → "..."
expect(frame).toContain("→ tools/list ✓");
expect(frame).toContain("→ tools/call ...");
// response labels
expect(frame).toContain("← Response (result)");
expect(frame).toContain("← Response (error: -32601)");
expect(frame).toContain("← Response");
// notification + unknown
expect(frame).toContain("• notifications/message");
expect(frame).toContain("• Unknown");
expect(frame).toContain("▶ ");
});

it("renders request details with a response section and duration", () => {
const { lastFrame } = render(
<HistoryTab
serverName="srv"
messages={[reqWithResponse]}
width={120}
height={40}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Direction: request");
expect(frame).toContain("(5ms)");
expect(frame).toContain("Request:");
expect(frame).toContain("Response:");
});

it("renders the waiting-for-response placeholder for a pending request", () => {
const { lastFrame } = render(
<HistoryTab
serverName="srv"
messages={[reqPending]}
width={120}
height={40}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Request:");
expect(frame).toContain("Waiting for response...");
});

it("renders response details with a Response label and Response header", () => {
const { lastFrame } = render(
<HistoryTab
serverName="srv"
messages={[respResult]}
width={120}
height={40}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Direction: response");
expect(frame).toContain("Response:");
});

it("renders notification details with a Notification label", () => {
const { lastFrame } = render(
<HistoryTab
serverName="srv"
messages={[notification]}
width={120}
height={40}
/>,
);
const frame = lastFrame() ?? "";
expect(frame).toContain("Direction: notification");
expect(frame).toContain("Notification:");
// header uses the notification method
expect(frame).toContain("notifications/message");
});

it("falls back to the Message header for a methodless notification", () => {
const { lastFrame } = render(
<HistoryTab
serverName="srv"
messages={[unknownEntry]}
width={120}
height={40}
/>,
);
expect(lastFrame() ?? "").toContain("Message");
});

it("moves selection with arrows and page keys when the list is focused", async () => {
const { lastFrame, stdin } = render(
<HistoryTab
serverName="srv"
messages={allMessages}
width={120}
height={12}
focusedPane="messages"
/>,
);
// up at top boundary: no movement
stdin.write(UP);
await tick();
// down to the next message
stdin.write(DOWN);
await tick();
expect(lastFrame() ?? "").toContain("Direction: request");
// pageDown jumps toward the end, pageUp back toward the start
stdin.write(PAGE_DOWN);
await tick();
stdin.write(PAGE_UP);
await tick();
// up to move back toward the top
stdin.write(UP);
await tick();
expect(lastFrame() ?? "").toContain("Messages (7)");
});

it("handles details-pane scrolling, footer, and zoom shortcut", async () => {
const onViewDetails = vi.fn();
const { lastFrame, stdin } = render(
<HistoryTab
serverName="srv"
messages={allMessages}
width={120}
height={40}
focusedPane="details"
onViewDetails={onViewDetails}
/>,
);
expect(lastFrame() ?? "").toContain("↑/↓ to scroll, + to zoom");
stdin.write(UP);
await tick();
stdin.write(DOWN);
await tick();
stdin.write(PAGE_UP);
await tick();
stdin.write(PAGE_DOWN);
await tick();
stdin.write("+");
await tick();
expect(onViewDetails).toHaveBeenCalledWith(allMessages[0]);
});

it("does not fire input handlers when a modal is open", async () => {
const onViewDetails = vi.fn();
const { stdin } = render(
<HistoryTab
serverName="srv"
messages={allMessages}
width={120}
height={40}
focusedPane="details"
onViewDetails={onViewDetails}
modalOpen={true}
/>,
);
stdin.write("+");
await tick();
expect(onViewDetails).not.toHaveBeenCalled();
});
});
Loading
Loading