import React from "react"; import { describe, it, expect, vi } from "vitest"; import { render } from "ink-testing-library"; import type { FetchRequestEntry } 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 { RequestsTab } from "../src/components/RequestsTab.js"; // Ink processes stdin keypresses asynchronously — await this after stdin.write. const tick = async () => { // Flush several macrotask cycles so an effect -> setState -> re-render chain // settles before assertions, even on slow/loaded CI (a single tick can race). for (let i = 0; i < 8; i++) await new Promise((resolve) => setTimeout(resolve, 4)); }; 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 req = (over: Partial): FetchRequestEntry => ({ id: "id", timestamp: TS, method: "POST", url: "https://example.com/mcp", requestHeaders: { "content-type": "application/json" }, category: "transport", ...over, }); // A fully-populated, successful transport request (2xx, JSON bodies). const fullRequest = req({ id: "r0", category: "auth", method: "GET", url: "https://example.com/oauth", responseStatus: 200, responseStatusText: "OK", duration: 12, requestHeaders: { authorization: "Bearer x" }, requestBody: JSON.stringify({ grant_type: "code" }), responseHeaders: { "content-type": "application/json" }, responseBody: JSON.stringify({ access_token: "tok" }), }); const errorRequest = req({ id: "r1", method: "POST", error: "connection refused", }); const pendingRequest = req({ id: "r2", method: "DELETE", }); const nonJsonRequest = req({ id: "r3", method: "PUT", responseStatus: 404, responseStatusText: "Not Found", requestBody: "this is not json", responseHeaders: {}, responseBody: "neither is this", }); const redirectRequest = req({ id: "r4", method: "GET", responseStatus: 301, }); const informationalRequest = req({ id: "r5", method: "GET", responseStatus: 100, }); describe("RequestsTab", () => { it("renders the empty state and reports a count of 0", () => { const onCountChange = vi.fn(); const { lastFrame } = render( , ); const frame = lastFrame() ?? ""; expect(frame).toContain("Network (0)"); expect(frame).toContain("No requests"); expect(frame).toContain("Select a request to view details"); expect(onCountChange).toHaveBeenCalledWith(0); }); it("works without an onCountChange callback", () => { const { lastFrame } = render( , ); expect(lastFrame() ?? "").toContain("Network (0)"); }); it("renders the list with status colors, labels and durations", () => { const { lastFrame } = render( , ); const frame = lastFrame() ?? ""; expect(frame).toContain("Network (4)"); // category labels expect(frame).toContain("AUTH"); expect(frame).toContain("MCP"); // GET is padded; non-GET methods shown as-is expect(frame).toContain("GET"); expect(frame).toContain("POST"); // status text variants: numeric / ERROR / "..." expect(frame).toContain("200"); expect(frame).toContain("ERROR"); expect(frame).toContain("..."); // duration suffix expect(frame).toContain("12ms"); // selection marker expect(frame).toContain("▶ "); }); it("renders full details for a successful request with JSON bodies", () => { const { lastFrame } = render( , ); const frame = lastFrame() ?? ""; expect(frame).toContain("GET https://example.com/oauth"); expect(frame).toContain("Category:"); expect(frame).toContain("auth"); expect(frame).toContain("Status:"); expect(frame).toContain("200 OK"); expect(frame).toContain("(12ms)"); expect(frame).toContain("Request Headers:"); expect(frame).toContain("authorization: Bearer x"); expect(frame).toContain("Request Body:"); expect(frame).toContain("grant_type"); expect(frame).toContain("Response Headers:"); expect(frame).toContain("Response Body:"); expect(frame).toContain("access_token"); }); it("renders an error request detail", () => { const { lastFrame } = render( , ); const frame = lastFrame() ?? ""; expect(frame).toContain("transport"); expect(frame).toContain("Error: connection refused"); }); it("renders the in-progress placeholder when there is no status or error", () => { const { lastFrame } = render( , ); expect(lastFrame() ?? "").toContain("Request in progress..."); }); it("renders raw (non-JSON) request and response bodies", () => { const { lastFrame } = render( , ); const frame = lastFrame() ?? ""; expect(frame).toContain("404 Not Found"); expect(frame).toContain("this is not json"); expect(frame).toContain("neither is this"); // empty responseHeaders object → section omitted expect(frame).not.toContain("Response Headers:"); }); it("renders a redirect (3xx) status detail", () => { const { lastFrame } = render( , ); expect(lastFrame() ?? "").toContain("301"); }); it("highlights the details header when the details pane is focused", () => { const { lastFrame } = render( , ); const frame = lastFrame() ?? ""; expect(frame).toContain("↑/↓ to scroll, + to zoom"); expect(frame).toContain("GET https://example.com/oauth"); }); it("moves selection with arrows and page keys when the list is focused", async () => { const many: FetchRequestEntry[] = Array.from({ length: 8 }, (_, i) => req({ id: `m${i}`, method: i % 2 === 0 ? "GET" : "POST", url: `https://example.com/r${i}`, responseStatus: 200, }), ); const { lastFrame, stdin } = render( , ); // up at top boundary: no movement stdin.write(UP); await tick(); // down moves selection to the next request stdin.write(DOWN); await tick(); expect(lastFrame() ?? "").toContain("https://example.com/r1"); // pageDown jumps toward the end stdin.write(PAGE_DOWN); await tick(); expect(lastFrame() ?? "").toContain("https://example.com/r"); // pageUp back toward the start stdin.write(PAGE_UP); await tick(); // up moves back toward the top stdin.write(UP); await tick(); expect(lastFrame() ?? "").toContain("Network (8)"); }); it("clamps at the bottom when paging past the end", async () => { const many: FetchRequestEntry[] = Array.from({ length: 4 }, (_, i) => req({ id: `c${i}`, url: `https://example.com/c${i}` }), ); const { lastFrame, stdin } = render( , ); // overshoot down to the last index, then page down past the end stdin.write(DOWN); await tick(); stdin.write(DOWN); await tick(); stdin.write(DOWN); await tick(); stdin.write(PAGE_DOWN); await tick(); stdin.write(DOWN); await tick(); expect(lastFrame() ?? "").toContain("https://example.com/c3"); }); it("handles details-pane scrolling and the zoom shortcut", async () => { const onViewDetails = vi.fn(); const { lastFrame, stdin } = render( , ); 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(fullRequest); }); it("does not fire input handlers when a modal is open", async () => { const onViewDetails = vi.fn(); const { stdin } = render( , ); stdin.write("+"); await tick(); expect(onViewDetails).not.toHaveBeenCalled(); }); it("ignores '+' when no onViewDetails handler is provided", async () => { const { lastFrame, stdin } = render( , ); stdin.write("+"); await tick(); // still rendered, no crash expect(lastFrame() ?? "").toContain("GET https://example.com/oauth"); }); });