diff --git a/features/inspector/components/inspector-client/inspector-client.test.tsx b/features/inspector/components/inspector-client/inspector-client.test.tsx index 21a11e0..20e338f 100644 --- a/features/inspector/components/inspector-client/inspector-client.test.tsx +++ b/features/inspector/components/inspector-client/inspector-client.test.tsx @@ -14,23 +14,33 @@ const { mockStartPolling, mockStopPolling, mockRehydrate, -} = vi.hoisted(() => ({ - mockSeedEntries: vi.fn(), - mockStartPolling: vi.fn(), - mockStopPolling: vi.fn(), - mockRehydrate: vi.fn().mockResolvedValue(undefined), -})); + mockStoreState, +} = vi.hoisted(() => { + const state = { + entries: [] as RequestEntry[], + isLoading: false, + view: "list" as "list" | "timeline", + }; + return { + mockSeedEntries: vi.fn(), + mockStartPolling: vi.fn(), + mockStopPolling: vi.fn(), + mockRehydrate: vi.fn().mockResolvedValue(undefined), + mockStoreState: state, + }; +}); vi.mock( "@/features/inspector/stores/use-inspector-store/use-inspector-store", () => { const mockStore = vi.fn(() => ({ - entries: [], + entries: mockStoreState.entries, filters: { service: "", status: "all", text: "" }, - status: "idle", + status: mockStoreState.isLoading ? "polling" : "idle", + isLoading: mockStoreState.isLoading, lastUpdatedAt: null, - isPolling: false, - view: "list", + isPolling: mockStoreState.isLoading, + view: mockStoreState.view, seedEntries: mockSeedEntries, startPolling: mockStartPolling, stopPolling: mockStopPolling, @@ -53,6 +63,22 @@ vi.mock("@/features/inspector/components/request-list/request-list", () => ({ ), })); +vi.mock("@/features/inspector/components/inspector-timeline/inspector-timeline", () => ({ + InspectorTimeline: ({ entries }: { entries: RequestEntry[] }) => ( +
+ ), +})); + +vi.mock("@/features/inspector/components/inspector-empty/inspector-empty", () => ({ + InspectorEmpty: () =>
, +})); + +vi.mock("@/features/inspector/components/inspector-skeleton/inspector-skeleton", () => ({ + InspectorSkeleton: ({ withSpine }: { withSpine: boolean }) => ( +
+ ), +})); + import { InspectorClient } from "./inspector-client"; // ── Fixtures ────────────────────────────────────────────────────────────────── @@ -69,6 +95,7 @@ const dict: ClientDict = { status: { label: "Status", all: "All", success: "Success", error: "Error" }, text: { placeholder: "Search…" }, }, + view: { label: "View", list: "List", timeline: "Timeline" }, clearBuffer: "Clear", statusPolling: "Live", statusError: "Error", @@ -76,7 +103,7 @@ const dict: ClientDict = { lastUpdated: "Updated {time} ago", }, empty: { title: "No requests", body: "Make some AWS calls" }, - card: { duration: "{ms}ms", attempts: "{n} attempts" }, + card: { duration: "{ms}ms", attempts: "{n} attempts", retries: "{n} retries" }, detail: { title: "Detail", input: "Input", @@ -107,6 +134,9 @@ afterEach(() => { cleanup(); vi.clearAllMocks(); mockRehydrate.mockResolvedValue(undefined); + mockStoreState.entries = []; + mockStoreState.isLoading = false; + mockStoreState.view = "list"; }); // ── Tests ───────────────────────────────────────────────────────────────────── @@ -119,7 +149,8 @@ describe("InspectorClient", () => { expect(screen.getByTestId("inspector-toolbar")).toBeInTheDocument(); }); - it("renders the request list", async () => { + it("renders the request list when entries exist", async () => { + mockStoreState.entries = [makeEntry("e1")]; await act(async () => { render(); }); @@ -147,4 +178,47 @@ describe("InspectorClient", () => { }); expect(mockStartPolling).toHaveBeenCalledOnce(); }); + + // ── Task 3.12 / 3.13: view toggle ──────────────────────────────────────────── + + it("renders RequestList when view is 'list' and entries exist", async () => { + mockStoreState.entries = [makeEntry("e1")]; + mockStoreState.view = "list"; + await act(async () => { + render(); + }); + expect(screen.getByTestId("request-list")).toBeInTheDocument(); + expect(screen.queryByTestId("inspector-timeline")).not.toBeInTheDocument(); + }); + + it("renders InspectorTimeline when view is 'timeline' and entries exist", async () => { + mockStoreState.entries = [makeEntry("e1")]; + mockStoreState.view = "timeline"; + await act(async () => { + render(); + }); + expect(screen.getByTestId("inspector-timeline")).toBeInTheDocument(); + expect(screen.queryByTestId("request-list")).not.toBeInTheDocument(); + }); + + it("renders InspectorSkeleton when isLoading is true", async () => { + mockStoreState.isLoading = true; + await act(async () => { + render(); + }); + expect(screen.getByTestId("inspector-skeleton")).toBeInTheDocument(); + expect(screen.queryByTestId("request-list")).not.toBeInTheDocument(); + expect(screen.queryByTestId("inspector-timeline")).not.toBeInTheDocument(); + }); + + it("renders InspectorEmpty when no entries and not loading", async () => { + mockStoreState.entries = []; + mockStoreState.isLoading = false; + await act(async () => { + render(); + }); + expect(screen.getByTestId("inspector-empty")).toBeInTheDocument(); + expect(screen.queryByTestId("request-list")).not.toBeInTheDocument(); + expect(screen.queryByTestId("inspector-timeline")).not.toBeInTheDocument(); + }); }); diff --git a/features/inspector/components/inspector-client/inspector-client.tsx b/features/inspector/components/inspector-client/inspector-client.tsx index 379aaec..171e431 100644 --- a/features/inspector/components/inspector-client/inspector-client.tsx +++ b/features/inspector/components/inspector-client/inspector-client.tsx @@ -4,6 +4,9 @@ import { useEffect, useMemo } from "react"; import { useInspectorStore } from "@/features/inspector/stores/use-inspector-store/use-inspector-store"; import { InspectorToolbar } from "@/features/inspector/components/inspector-toolbar/inspector-toolbar"; import { RequestList } from "@/features/inspector/components/request-list/request-list"; +import { InspectorTimeline } from "@/features/inspector/components/inspector-timeline/inspector-timeline"; +import { InspectorEmpty } from "@/features/inspector/components/inspector-empty/inspector-empty"; +import { InspectorSkeleton } from "@/features/inspector/components/inspector-skeleton/inspector-skeleton"; import type { RequestEntry } from "@/features/inspector/lib/types/types"; import type { InspectorDict } from "@/features/inspector/i18n/en"; import type { WidenStringLiterals } from "@/features/shared/i18n/widen-literals"; @@ -27,15 +30,16 @@ const SERVICES = ["SQS", "SNS", "S3", "Lambda", "DynamoDB", "CloudWatchLogs"]; // ── Component ───────────────────────────────────────────────────────────────── export function InspectorClient({ initialEntries, dict }: InspectorClientProps) { - const { entries, filters, seedEntries, startPolling, stopPolling } = + const { entries, filters, view, isPolling, seedEntries, startPolling, stopPolling } = useInspectorStore(); // Mount: rehydrate → seed RSC entries → start polling useEffect(() => { - void useInspectorStore.persist.rehydrate().then(() => { + void (async () => { + await useInspectorStore.persist.rehydrate(); seedEntries(initialEntries); startPolling(); - }); + })(); return () => { stopPolling(); @@ -57,11 +61,26 @@ export function InspectorClient({ initialEntries, dict }: InspectorClientProps) }); }, [entries, filters]); + // ── Render body ─────────────────────────────────────────────────────────────── + + function renderBody() { + if (isPolling && entries.length === 0) { + return ; + } + if (filtered.length === 0) { + return ; + } + if (view === "timeline") { + return ; + } + return ; + } + return (
- + {renderBody()}
); diff --git a/features/inspector/components/inspector-empty/inspector-empty.test.tsx b/features/inspector/components/inspector-empty/inspector-empty.test.tsx new file mode 100644 index 0000000..4048405 --- /dev/null +++ b/features/inspector/components/inspector-empty/inspector-empty.test.tsx @@ -0,0 +1,38 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { InspectorEmpty } from "./inspector-empty"; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const dict = { + empty: { + title: "No requests yet", + body: "AWS SDK calls made by Server Actions will appear here.", + }, +}; + +afterEach(() => { + cleanup(); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("InspectorEmpty", () => { + it("renders the empty title", () => { + render(); + expect(screen.getByText("No requests yet")).toBeInTheDocument(); + }); + + it("renders the empty body text", () => { + render(); + expect( + screen.getByText("AWS SDK calls made by Server Actions will appear here."), + ).toBeInTheDocument(); + }); + + it("renders a SearchX icon (svg element present)", () => { + const { container } = render(); + expect(container.querySelector("svg")).toBeInTheDocument(); + }); +}); diff --git a/features/inspector/components/inspector-empty/inspector-empty.tsx b/features/inspector/components/inspector-empty/inspector-empty.tsx new file mode 100644 index 0000000..2c95fec --- /dev/null +++ b/features/inspector/components/inspector-empty/inspector-empty.tsx @@ -0,0 +1,25 @@ +import { SearchX } from "lucide-react"; +import type { InspectorDict } from "@/features/inspector/i18n/en"; +import type { WidenStringLiterals } from "@/features/shared/i18n/widen-literals"; + +// ── Types ────────────────────────────────────────────────────────────────────── + +type EmptyDict = Pick, "empty">; + +type InspectorEmptyProps = { + dict: EmptyDict; +}; + +// ── Component ───────────────────────────────────────────────────────────────── + +export function InspectorEmpty({ dict }: InspectorEmptyProps) { + return ( +
+ +
+

{dict.empty.title}

+

{dict.empty.body}

+
+
+ ); +} diff --git a/features/inspector/components/inspector-skeleton/inspector-skeleton.test.tsx b/features/inspector/components/inspector-skeleton/inspector-skeleton.test.tsx new file mode 100644 index 0000000..9109c73 --- /dev/null +++ b/features/inspector/components/inspector-skeleton/inspector-skeleton.test.tsx @@ -0,0 +1,47 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { InspectorSkeleton } from "./inspector-skeleton"; + +afterEach(() => { + cleanup(); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("InspectorSkeleton", () => { + describe("list mode (withSpine=false)", () => { + it("renders skeleton card items", () => { + const { container } = render(); + // Expect at least one skeleton placeholder element + const items = container.querySelectorAll("[data-testid='skeleton-item']"); + expect(items.length).toBeGreaterThan(0); + }); + + it("does NOT render spine dots when withSpine is false", () => { + const { container } = render(); + expect(container.querySelector("[data-testid='spine-dot']")).not.toBeInTheDocument(); + }); + }); + + describe("timeline mode (withSpine=true)", () => { + it("renders skeleton card items with spine dots when withSpine is true", () => { + const { container } = render(); + const items = container.querySelectorAll("[data-testid='skeleton-item']"); + expect(items.length).toBeGreaterThan(0); + }); + + it("renders spine dots when withSpine is true", () => { + const { container } = render(); + const spineDots = container.querySelectorAll("[data-testid='spine-dot']"); + expect(spineDots.length).toBeGreaterThan(0); + }); + + it("spine dot count matches skeleton item count", () => { + const { container } = render(); + const items = container.querySelectorAll("[data-testid='skeleton-item']"); + const spineDots = container.querySelectorAll("[data-testid='spine-dot']"); + expect(spineDots.length).toBe(items.length); + }); + }); +}); diff --git a/features/inspector/components/inspector-skeleton/inspector-skeleton.tsx b/features/inspector/components/inspector-skeleton/inspector-skeleton.tsx new file mode 100644 index 0000000..b6c290f --- /dev/null +++ b/features/inspector/components/inspector-skeleton/inspector-skeleton.tsx @@ -0,0 +1,42 @@ +// ── Types ────────────────────────────────────────────────────────────────────── + +type InspectorSkeletonProps = { + withSpine?: boolean; + count?: number; +}; + +// ── Component ───────────────────────────────────────────────────────────────── + +const DEFAULT_COUNT = 5; + +export function InspectorSkeleton({ + withSpine = false, + count = DEFAULT_COUNT, +}: InspectorSkeletonProps) { + const items = Array.from({ length: count }); + + return ( +
+ {items.map((_, idx) => ( +
+ {withSpine && ( +
+
+ {idx < count - 1 &&
} +
+ )} +
+
+ ))} +
+ ); +} diff --git a/features/inspector/components/inspector-timeline/inspector-timeline.test.tsx b/features/inspector/components/inspector-timeline/inspector-timeline.test.tsx new file mode 100644 index 0000000..eaa68ee --- /dev/null +++ b/features/inspector/components/inspector-timeline/inspector-timeline.test.tsx @@ -0,0 +1,79 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { RequestEntry } from "@/features/inspector/lib/types/types"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock("@/features/inspector/components/request-card/request-card", () => ({ + RequestCard: ({ entry }: { entry: RequestEntry }) => ( +
+ ), +})); + +import { InspectorTimeline } from "./inspector-timeline"; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const dict = { + card: { duration: "{ms}ms", attempts: "{n} attempts", retries: "{n} retries" }, +}; + +function makeEntry(id: string, timestamp: number): RequestEntry { + return { + id, + timestamp, + service: "SQS", + operation: "SendMessageCommand", + input: {}, + output: {}, + durationMs: 10, + status: "success", + attempts: 1, + }; +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("InspectorTimeline", () => { + it("renders one RequestCard per entry", () => { + const entries = [ + makeEntry("e1", 1000), + makeEntry("e2", 2000), + makeEntry("e3", 3000), + ]; + render(); + expect(screen.getAllByTestId("request-card")).toHaveLength(3); + }); + + it("renders entries sorted descending by timestamp (newest first)", () => { + const entries = [ + makeEntry("e1", 1000), + makeEntry("e3", 3000), + makeEntry("e2", 2000), + ]; + render(); + const cards = screen.getAllByTestId("request-card"); + // e3 (timestamp=3000) must appear first + expect(cards[0].getAttribute("data-id")).toBe("e3"); + expect(cards[1].getAttribute("data-id")).toBe("e2"); + expect(cards[2].getAttribute("data-id")).toBe("e1"); + }); + + it("renders a spine container (flex gap structure) for each entry", () => { + const entries = [makeEntry("e1", 1000), makeEntry("e2", 2000)]; + const { container } = render(); + // Each entry is wrapped in a flex row with gap — verify spine dots are rendered + const spineDots = container.querySelectorAll("[data-testid='spine-dot']"); + expect(spineDots.length).toBe(2); + }); + + it("renders nothing when entries is empty", () => { + const { container } = render(); + expect(container.querySelectorAll("[data-testid='request-card']")).toHaveLength(0); + }); +}); diff --git a/features/inspector/components/inspector-timeline/inspector-timeline.tsx b/features/inspector/components/inspector-timeline/inspector-timeline.tsx new file mode 100644 index 0000000..3d76317 --- /dev/null +++ b/features/inspector/components/inspector-timeline/inspector-timeline.tsx @@ -0,0 +1,48 @@ +import { getServiceColorClasses } from "@/features/inspector/lib/service-color/service-color"; +import { RequestCard } from "@/features/inspector/components/request-card/request-card"; +import type { RequestEntry } from "@/features/inspector/lib/types/types"; +import { cn } from "@/lib/utils"; +import type { InspectorDict } from "@/features/inspector/i18n/en"; +import type { WidenStringLiterals } from "@/features/shared/i18n/widen-literals"; + +// ── Types ────────────────────────────────────────────────────────────────────── + +type CardDict = Pick, "card">; + +type InspectorTimelineProps = { + entries: RequestEntry[]; + dict: CardDict; +}; + +// ── Component ───────────────────────────────────────────────────────────────── + +export function InspectorTimeline({ entries, dict }: InspectorTimelineProps) { + const sorted = [...entries].sort((a, b) => b.timestamp - a.timestamp); + + return ( +
+ {sorted.map((entry, idx) => ( +
+ {/* Spine column */} +
+
+ {idx < sorted.length - 1 && ( +
+ )} +
+ + {/* Card column */} +
+ +
+
+ ))} +
+ ); +} diff --git a/features/inspector/components/inspector-toolbar/inspector-toolbar.test.tsx b/features/inspector/components/inspector-toolbar/inspector-toolbar.test.tsx index d3f516d..b648ce8 100644 --- a/features/inspector/components/inspector-toolbar/inspector-toolbar.test.tsx +++ b/features/inspector/components/inspector-toolbar/inspector-toolbar.test.tsx @@ -7,6 +7,10 @@ import type { WidenStringLiterals } from "@/features/shared/i18n/widen-literals" const mockSetFilter = vi.fn(); const mockClearBuffer = vi.fn().mockResolvedValue(undefined); +const mockSetView = vi.fn(); + +// mockView holds the current view for the mock store +const mockState = { view: "list" as "list" | "timeline" }; vi.mock( "@/features/inspector/stores/use-inspector-store/use-inspector-store", @@ -15,8 +19,10 @@ vi.mock( filters: { service: "", status: "all", text: "" }, status: "idle", lastUpdatedAt: null, + view: mockState.view, setFilter: mockSetFilter, clearBuffer: mockClearBuffer, + setView: mockSetView, })), }), ); @@ -55,6 +61,11 @@ const dict: ToolbarDict = { placeholder: "Search…", }, }, + view: { + label: "View", + list: "List", + timeline: "Timeline", + }, clearBuffer: "Clear", statusPolling: "Live", statusError: "Error", @@ -66,6 +77,7 @@ const dict: ToolbarDict = { afterEach(() => { cleanup(); vi.clearAllMocks(); + mockState.view = "list"; }); // ── Tests ───────────────────────────────────────────────────────────────────── @@ -110,4 +122,54 @@ describe("InspectorToolbar", () => { // "All" appears in both trigger and dropdown item expect(screen.getAllByText("All").length).toBeGreaterThanOrEqual(1); }); + + describe("view toggle", () => { + it("renders List and Timeline toggle buttons", () => { + mockState.view = "list"; + render(); + expect(screen.getByRole("tab", { name: /^list$/i })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /^timeline$/i })).toBeInTheDocument(); + }); + + it("List button has aria-pressed=true when view is list", () => { + mockState.view = "list"; + render(); + expect(screen.getByRole("tab", { name: /^list$/i })).toHaveAttribute( + "aria-pressed", + "true", + ); + }); + + it("Timeline button has aria-pressed=false when view is list", () => { + mockState.view = "list"; + render(); + expect(screen.getByRole("tab", { name: /^timeline$/i })).toHaveAttribute( + "aria-pressed", + "false", + ); + }); + + it("Timeline button has aria-pressed=true when view is timeline", () => { + mockState.view = "timeline"; + render(); + expect(screen.getByRole("tab", { name: /^timeline$/i })).toHaveAttribute( + "aria-pressed", + "true", + ); + }); + + it("clicking List button calls setView('list')", () => { + mockState.view = "timeline"; + render(); + fireEvent.click(screen.getByRole("tab", { name: /^list$/i })); + expect(mockSetView).toHaveBeenCalledWith("list"); + }); + + it("clicking Timeline button calls setView('timeline')", () => { + mockState.view = "list"; + render(); + fireEvent.click(screen.getByRole("tab", { name: /^timeline$/i })); + expect(mockSetView).toHaveBeenCalledWith("timeline"); + }); + }); }); diff --git a/features/inspector/components/inspector-toolbar/inspector-toolbar.tsx b/features/inspector/components/inspector-toolbar/inspector-toolbar.tsx index a75221d..63d43e9 100644 --- a/features/inspector/components/inspector-toolbar/inspector-toolbar.tsx +++ b/features/inspector/components/inspector-toolbar/inspector-toolbar.tsx @@ -25,7 +25,7 @@ const STATUS_OPTIONS = ["all", "success", "error"] as const; // ── Component ───────────────────────────────────────────────────────────────── export function InspectorToolbar({ dict, services }: InspectorToolbarProps) { - const { filters, setFilter, clearBuffer } = useInspectorStore(); + const { filters, setFilter, clearBuffer, view, setView } = useInspectorStore(); const t = dict.toolbar; return ( @@ -36,7 +36,7 @@ export function InspectorToolbar({ dict, services }: InspectorToolbarProps) {
+ {/* View toggle — segmented control */} +
+ + +
+ {/* Clear buffer */}