diff --git a/app/[lang]/(dashboard)/inspector/page.tsx b/app/[lang]/(dashboard)/inspector/page.tsx new file mode 100644 index 0000000..c4e9f40 --- /dev/null +++ b/app/[lang]/(dashboard)/inspector/page.tsx @@ -0,0 +1,49 @@ +import type { Metadata } from "next"; +import { getDictionary } from "@/features/shared/i18n/get-dictionary"; +import { DEFAULT_LOCALE, isLocale, type Locale } from "@/features/shared/i18n/locale"; +import { getEntries } from "@/lib/aws/inspector-buffer"; +import { InspectorClient } from "@/features/inspector/components/inspector-client/inspector-client"; + +export const dynamic = "force-dynamic"; + +type Props = { + params: Promise<{ lang: string }>; +}; + +export async function generateMetadata({ params }: Props): Promise { + const { lang } = await params; + const locale: Locale = isLocale(lang) ? lang : DEFAULT_LOCALE; + const dict = getDictionary(locale); + return { + title: dict.inspector.title, + }; +} + +export default async function InspectorPage({ params }: Props) { + const { lang } = await params; + const locale: Locale = isLocale(lang) ? lang : DEFAULT_LOCALE; + const dict = getDictionary(locale); + + // Read directly from buffer on RSC — no SDK call needed. + const initialEntries = [...getEntries()].sort((a, b) => b.timestamp - a.timestamp); + + return ( +
+
+
+

{dict.inspector.title}

+

{dict.inspector.description}

+
+
+ +
+ ); +} diff --git a/features/inspector/components/inspector-client/inspector-client.test.tsx b/features/inspector/components/inspector-client/inspector-client.test.tsx new file mode 100644 index 0000000..20e338f --- /dev/null +++ b/features/inspector/components/inspector-client/inspector-client.test.tsx @@ -0,0 +1,224 @@ +import { cleanup, render, screen, act } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +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"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── +// vi.mock() is hoisted to the top of the file, BEFORE any const declarations. +// To reference mock functions inside a vi.mock factory, they must be declared +// with vi.hoisted() which runs at the same "hoisted" phase as vi.mock itself. + +const { + mockSeedEntries, + mockStartPolling, + mockStopPolling, + mockRehydrate, + 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: mockStoreState.entries, + filters: { service: "", status: "all", text: "" }, + status: mockStoreState.isLoading ? "polling" : "idle", + isLoading: mockStoreState.isLoading, + lastUpdatedAt: null, + isPolling: mockStoreState.isLoading, + view: mockStoreState.view, + seedEntries: mockSeedEntries, + startPolling: mockStartPolling, + stopPolling: mockStopPolling, + setFilter: vi.fn(), + clearBuffer: vi.fn(), + setView: vi.fn(), + })); + mockStore.persist = { rehydrate: mockRehydrate }; + return { useInspectorStore: mockStore }; + }, +); + +vi.mock("@/features/inspector/components/inspector-toolbar/inspector-toolbar", () => ({ + InspectorToolbar: () =>
, +})); + +vi.mock("@/features/inspector/components/request-list/request-list", () => ({ + RequestList: ({ entries }: { entries: RequestEntry[] }) => ( +
+ ), +})); + +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 ────────────────────────────────────────────────────────────────── + +type ClientDict = Pick< + WidenStringLiterals, + "toolbar" | "empty" | "card" | "detail" +>; + +const dict: ClientDict = { + toolbar: { + filters: { + service: { label: "Service", all: "All services" }, + 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", + statusIdle: "Idle", + lastUpdated: "Updated {time} ago", + }, + empty: { title: "No requests", body: "Make some AWS calls" }, + card: { duration: "{ms}ms", attempts: "{n} attempts", retries: "{n} retries" }, + detail: { + title: "Detail", + input: "Input", + output: "Output", + attempts: "Attempts", + duration: "Duration", + timestamp: "Timestamp", + error: "Error", + closeLabel: "Close", + }, +}; + +function makeEntry(id: string): RequestEntry { + return { + id, + timestamp: 1700000000000, + service: "SQS", + operation: "SendMessageCommand", + input: {}, + output: {}, + durationMs: 10, + status: "success", + attempts: 1, + }; +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockRehydrate.mockResolvedValue(undefined); + mockStoreState.entries = []; + mockStoreState.isLoading = false; + mockStoreState.view = "list"; +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("InspectorClient", () => { + it("renders the toolbar", async () => { + await act(async () => { + render(); + }); + expect(screen.getByTestId("inspector-toolbar")).toBeInTheDocument(); + }); + + it("renders the request list when entries exist", async () => { + mockStoreState.entries = [makeEntry("e1")]; + await act(async () => { + render(); + }); + expect(screen.getByTestId("request-list")).toBeInTheDocument(); + }); + + it("calls rehydrate on mount", async () => { + await act(async () => { + render(); + }); + expect(mockRehydrate).toHaveBeenCalledOnce(); + }); + + it("calls seedEntries with initialEntries on mount", async () => { + const entries = [makeEntry("e1"), makeEntry("e2")]; + await act(async () => { + render(); + }); + expect(mockSeedEntries).toHaveBeenCalledWith(entries); + }); + + it("calls startPolling on mount", async () => { + await act(async () => { + render(); + }); + 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 new file mode 100644 index 0000000..171e431 --- /dev/null +++ b/features/inspector/components/inspector-client/inspector-client.tsx @@ -0,0 +1,87 @@ +"use client"; + +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"; + +// ── Types ────────────────────────────────────────────────────────────────────── + +type ClientDict = Pick< + WidenStringLiterals, + "toolbar" | "empty" | "card" | "detail" +>; + +type InspectorClientProps = { + initialEntries: RequestEntry[]; + dict: ClientDict; +}; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +const SERVICES = ["SQS", "SNS", "S3", "Lambda", "DynamoDB", "CloudWatchLogs"]; + +// ── Component ───────────────────────────────────────────────────────────────── + +export function InspectorClient({ initialEntries, dict }: InspectorClientProps) { + const { entries, filters, view, isPolling, seedEntries, startPolling, stopPolling } = + useInspectorStore(); + + // Mount: rehydrate → seed RSC entries → start polling + useEffect(() => { + void (async () => { + await useInspectorStore.persist.rehydrate(); + seedEntries(initialEntries); + startPolling(); + })(); + + return () => { + stopPolling(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Client-side filter: pure derived state, no store mutation + const filtered = useMemo(() => { + return entries.filter((entry) => { + if (filters.service && entry.service !== filters.service) return false; + if (filters.status !== "all" && entry.status !== filters.status) return false; + if (filters.text) { + const needle = filters.text.toLowerCase(); + const haystack = `${entry.operation} ${JSON.stringify(entry.input)} ${JSON.stringify(entry.output)}`.toLowerCase(); + if (!haystack.includes(needle)) return false; + } + return true; + }); + }, [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 new file mode 100644 index 0000000..b648ce8 --- /dev/null +++ b/features/inspector/components/inspector-toolbar/inspector-toolbar.test.tsx @@ -0,0 +1,175 @@ +import { cleanup, render, screen, fireEvent } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { InspectorDict } from "@/features/inspector/i18n/en"; +import type { WidenStringLiterals } from "@/features/shared/i18n/widen-literals"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +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", + () => ({ + useInspectorStore: vi.fn(() => ({ + filters: { service: "", status: "all", text: "" }, + status: "idle", + lastUpdatedAt: null, + view: mockState.view, + setFilter: mockSetFilter, + clearBuffer: mockClearBuffer, + setView: mockSetView, + })), + }), +); + +// Mock Select components to avoid jsdom limitations with portals +vi.mock("@/components/ui/select", () => ({ + Select: ({ children, onValueChange }: { children: React.ReactNode; onValueChange?: (v: string) => void; value?: string }) => +
onValueChange?.("DynamoDB")}>{children}
, + SelectTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + SelectValue: ({ children }: { children?: React.ReactNode }) => {children}, + SelectContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => +
{children}
, +})); + +import { InspectorToolbar } from "./inspector-toolbar"; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +type ToolbarDict = Pick, "toolbar">; + +const dict: ToolbarDict = { + toolbar: { + filters: { + service: { + label: "Service", + all: "All services", + }, + 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", + statusIdle: "Idle", + lastUpdated: "Updated {time} ago", + }, +}; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockState.view = "list"; +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("InspectorToolbar", () => { + it("renders the service filter label", () => { + render(); + expect(screen.getByText("Service")).toBeInTheDocument(); + }); + + it("renders the status filter label", () => { + render(); + expect(screen.getByText("Status")).toBeInTheDocument(); + }); + + it("renders the clear buffer button", () => { + render(); + expect(screen.getByRole("button", { name: /clear/i })).toBeInTheDocument(); + }); + + it("calls clearBuffer when clear button is clicked", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /clear/i })); + expect(mockClearBuffer).toHaveBeenCalledOnce(); + }); + + it("clear button has min-h-11 touch target", () => { + render(); + const btn = screen.getByRole("button", { name: /clear/i }); + // Check that the button has min-h-11 applied (via className) + expect(btn.className).toMatch(/min-h-11/); + }); + + it("shows the service filter all-services option", () => { + render(); + // "All services" appears in both trigger and dropdown item + expect(screen.getAllByText("All services").length).toBeGreaterThanOrEqual(1); + }); + + it("shows status filter all option", () => { + render(); + // "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 new file mode 100644 index 0000000..63d43e9 --- /dev/null +++ b/features/inspector/components/inspector-toolbar/inspector-toolbar.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useInspectorStore } from "@/features/inspector/stores/use-inspector-store/use-inspector-store"; +import type { InspectorDict } from "@/features/inspector/i18n/en"; +import type { WidenStringLiterals } from "@/features/shared/i18n/widen-literals"; + +// ── Types ────────────────────────────────────────────────────────────────────── + +type ToolbarDict = Pick, "toolbar">; + +type InspectorToolbarProps = { + dict: ToolbarDict; + services: string[]; +}; + +const STATUS_OPTIONS = ["all", "success", "error"] as const; + +// ── Component ───────────────────────────────────────────────────────────────── + +export function InspectorToolbar({ dict, services }: InspectorToolbarProps) { + const { filters, setFilter, clearBuffer, view, setView } = useInspectorStore(); + const t = dict.toolbar; + + return ( +
+ {/* Service filter */} +
+ {t.filters.service.label} + +
+ + {/* Status filter */} +
+ {t.filters.status.label} + +
+ + {/* View toggle — segmented control */} +
+ + +
+ + {/* Clear buffer */} + +
+ ); +} diff --git a/features/inspector/components/request-card/request-card.test.tsx b/features/inspector/components/request-card/request-card.test.tsx new file mode 100644 index 0000000..6aa2415 --- /dev/null +++ b/features/inspector/components/request-card/request-card.test.tsx @@ -0,0 +1,122 @@ +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"; +import type { InspectorDict } from "@/features/inspector/i18n/en"; +import type { WidenStringLiterals } from "@/features/shared/i18n/widen-literals"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock( + "@/features/inspector/components/request-detail-dialog/request-detail-dialog", + () => ({ + RequestDetailDialog: vi.fn( + ({ open }: { open: boolean }) => + open ?
: null, + ), + }), +); + +import { RequestCard } from "./request-card"; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +type CardDict = Pick, "card">; + +const defaultDict: CardDict = { + card: { + duration: "{ms}ms", + attempts: "{n} attempts", + retries: "{n} retries", + }, +}; + +function makeEntry(overrides: Partial = {}): RequestEntry { + return { + id: "entry-1", + timestamp: 1700000000000, + service: "SQS", + operation: "SendMessageCommand", + input: { QueueUrl: "https://sqs.us-east-1.localhost.localstack.cloud/000000000000/test" }, + output: { MessageId: "msg-1" }, + durationMs: 42, + status: "success", + attempts: 1, + ...overrides, + }; +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("RequestCard", () => { + it("renders the service name as a badge", () => { + render(); + expect(screen.getByText("SQS")).toBeInTheDocument(); + }); + + it("renders the operation name", () => { + render(); + expect(screen.getByText("SendMessageCommand")).toBeInTheDocument(); + }); + + it("renders the duration pill with ms value", () => { + render(); + expect(screen.getByText("42ms")).toBeInTheDocument(); + }); + + it("renders a green status indicator for success", () => { + render(); + const indicator = screen.getByTestId("status-indicator"); + expect(indicator).toBeInTheDocument(); + expect(indicator.getAttribute("data-status")).toBe("success"); + }); + + it("renders a red status indicator for error", () => { + render(); + const indicator = screen.getByTestId("status-indicator"); + expect(indicator.getAttribute("data-status")).toBe("error"); + }); + + it("does NOT render retry badge when attempts === 1", () => { + render(); + expect(screen.queryByTestId("retry-badge")).not.toBeInTheDocument(); + }); + + it("renders a retry badge when attempts > 1", () => { + render(); + expect(screen.getByTestId("retry-badge")).toBeInTheDocument(); + }); + + it("retry badge shows correct text for attempts > 1", () => { + render(); + // attempts=3 means 2 retries (attempts - 1) + expect(screen.getByTestId("retry-badge")).toHaveTextContent("2 retries"); + }); + + it("retry badge shows '1 retries' when attempts === 2", () => { + render(); + expect(screen.getByTestId("retry-badge")).toHaveTextContent("1 retries"); + }); + + it("renders with a different service (DynamoDB)", () => { + render(); + expect(screen.getByText("DynamoDB")).toBeInTheDocument(); + }); + + it("retry badge text comes from dict.card.retries template", () => { + const customDict: CardDict = { + card: { + duration: "{ms}ms", + attempts: "{n} attempts", + retries: "{n} reintentos", + }, + }; + render(); + // 3 attempts → 2 retries: should use the custom dict template + expect(screen.getByTestId("retry-badge")).toHaveTextContent("2 reintentos"); + }); +}); diff --git a/features/inspector/components/request-card/request-card.tsx b/features/inspector/components/request-card/request-card.tsx new file mode 100644 index 0000000..1cfe6da --- /dev/null +++ b/features/inspector/components/request-card/request-card.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useState } from "react"; +import { getServiceColorClasses } from "@/features/inspector/lib/service-color/service-color"; +import type { RequestEntry } from "@/features/inspector/lib/types/types"; +import { RequestDetailDialog } from "@/features/inspector/components/request-detail-dialog/request-detail-dialog"; +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 RequestCardProps = { + entry: RequestEntry; + dict: CardDict; +}; + +// ── Component ───────────────────────────────────────────────────────────────── + +export function RequestCard({ entry, dict }: RequestCardProps) { + const [dialogOpen, setDialogOpen] = useState(false); + const colorClasses = getServiceColorClasses(entry.service); + + return ( + <> + + + setDialogOpen(false)} + dict={{ + title: "Request Detail", + input: "Input", + output: "Output", + attempts: "Attempts", + duration: "Duration", + timestamp: "Timestamp", + error: "Error", + closeLabel: "Close", + }} + /> + + ); +} diff --git a/features/inspector/components/request-detail-dialog/request-detail-dialog.test.tsx b/features/inspector/components/request-detail-dialog/request-detail-dialog.test.tsx new file mode 100644 index 0000000..b526fda --- /dev/null +++ b/features/inspector/components/request-detail-dialog/request-detail-dialog.test.tsx @@ -0,0 +1,114 @@ +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"; +import type { InspectorDict } from "@/features/inspector/i18n/en"; +import type { WidenStringLiterals } from "@/features/shared/i18n/widen-literals"; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTrigger: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogContent: ({ children }: { children: React.ReactNode; closeLabel: string }) => ( +
{children}
+ ), + DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +import { RequestDetailDialog } from "./request-detail-dialog"; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const dict: WidenStringLiterals["detail"] = { + title: "Request Detail", + input: "Input", + output: "Output", + attempts: "Attempts", + duration: "Duration", + timestamp: "Timestamp", + error: "Error", + closeLabel: "Close", +}; + +function makeEntry(overrides: Partial = {}): RequestEntry { + return { + id: "entry-1", + timestamp: 1700000000000, + service: "SQS", + operation: "SendMessageCommand", + input: { QueueUrl: "https://sqs.example.com" }, + output: { MessageId: "msg-1" }, + durationMs: 42, + status: "success", + attempts: 1, + ...overrides, + }; +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("RequestDetailDialog", () => { + it("renders the service and operation in the header", () => { + render( {}} />); + expect(screen.getByText("SQS")).toBeInTheDocument(); + expect(screen.getByText("SendMessageCommand")).toBeInTheDocument(); + }); + + it("renders input as JSON in a pre block", () => { + const { container } = render( {}} />); + // Input label is shown + expect(screen.getByText("Input")).toBeInTheDocument(); + // A
 element exists with the JSON content
+    const pre = container.querySelector("pre");
+    expect(pre).not.toBeNull();
+    expect(pre!.textContent).toContain("QueueUrl");
+  });
+
+  it("renders output as JSON when available", () => {
+    render( {}} />);
+    expect(screen.getByText("Output")).toBeInTheDocument();
+  });
+
+  it("renders attempts count", () => {
+    render( {}} />);
+    expect(screen.getByText("Attempts")).toBeInTheDocument();
+    expect(screen.getByText("2")).toBeInTheDocument();
+  });
+
+  it("renders duration label", () => {
+    render( {}} />);
+    expect(screen.getByText("Duration")).toBeInTheDocument();
+    expect(screen.getByText("99ms")).toBeInTheDocument();
+  });
+
+  it("shows error section when status is error", () => {
+    render(
+       {}}
+      />
+    );
+    expect(screen.getByText("Error")).toBeInTheDocument();
+    expect(screen.getByText("Table not found")).toBeInTheDocument();
+  });
+
+  it("does NOT show error section for successful entries", () => {
+    render( {}} />);
+    // Error label shouldn't appear for success entries
+    const errorLabels = screen.queryAllByText("Error");
+    // None or zero error sections
+    expect(errorLabels.length).toBe(0);
+  });
+});
diff --git a/features/inspector/components/request-detail-dialog/request-detail-dialog.tsx b/features/inspector/components/request-detail-dialog/request-detail-dialog.tsx
new file mode 100644
index 0000000..72b7b19
--- /dev/null
+++ b/features/inspector/components/request-detail-dialog/request-detail-dialog.tsx
@@ -0,0 +1,103 @@
+"use client";
+
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { getServiceColorClasses } from "@/features/inspector/lib/service-color/service-color";
+import type { RequestEntry } from "@/features/inspector/lib/types/types";
+import { cn } from "@/lib/utils";
+
+// ── Types ──────────────────────────────────────────────────────────────────────
+
+type DetailDict = {
+  title: string;
+  input: string;
+  output: string;
+  attempts: string;
+  duration: string;
+  timestamp: string;
+  error: string;
+  closeLabel: string;
+};
+
+type RequestDetailDialogProps = {
+  open: boolean;
+  entry: RequestEntry;
+  dict: DetailDict;
+  onClose: () => void;
+};
+
+// ── Component ─────────────────────────────────────────────────────────────────
+
+export function RequestDetailDialog({ open, entry, dict, onClose }: RequestDetailDialogProps) {
+  const colorClasses = getServiceColorClasses(entry.service);
+
+  return (
+     { if (!o) onClose(); }}>
+      
+        
+          
+            
+              
+                {entry.service}
+              
+              {entry.operation}
+            
+          
+        
+
+        {/* Meta grid */}
+        
+
{dict.timestamp}
+
{new Date(entry.timestamp).toISOString()}
+ +
{dict.duration}
+
{entry.durationMs}ms
+ +
{dict.attempts}
+
{entry.attempts}
+
+ + {/* Error section */} + {entry.status === "error" && entry.error && ( +
+

{dict.error}

+

{entry.error.message}

+ {entry.error.name && ( +

{entry.error.name}

+ )} +
+ )} + + {/* Input */} +
+

{dict.input}

+
+            {JSON.stringify(entry.input, null, 2)}
+          
+
+ + {/* Output */} + {entry.output != null && ( +
+

{dict.output}

+
+              {JSON.stringify(entry.output, null, 2)}
+            
+
+ )} +
+
+ ); +} diff --git a/features/inspector/components/request-list/request-list.test.tsx b/features/inspector/components/request-list/request-list.test.tsx new file mode 100644 index 0000000..996ae9d --- /dev/null +++ b/features/inspector/components/request-list/request-list.test.tsx @@ -0,0 +1,59 @@ +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 }) => ( +
{entry.service}
+ ), +})); + +import { RequestList } from "./request-list"; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +function makeEntry(id: string, service = "SQS"): RequestEntry { + return { + id, + timestamp: 1700000000000, + service, + operation: "SendMessageCommand", + input: {}, + output: {}, + durationMs: 10, + status: "success", + attempts: 1, + }; +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("RequestList", () => { + it("renders one RequestCard per entry", () => { + const entries = [makeEntry("e-1"), makeEntry("e-2"), makeEntry("e-3")]; + render(); + expect(screen.getAllByTestId(/^request-card-/)).toHaveLength(3); + expect(screen.getByTestId("request-card-e-1")).toBeInTheDocument(); + expect(screen.getByTestId("request-card-e-2")).toBeInTheDocument(); + expect(screen.getByTestId("request-card-e-3")).toBeInTheDocument(); + }); + + it("renders zero cards for empty entries", () => { + render(); + expect(screen.queryAllByTestId(/^request-card-/)).toHaveLength(0); + }); + + it("renders cards with correct service names", () => { + const entries = [makeEntry("e-1", "SQS"), makeEntry("e-2", "DynamoDB")]; + render(); + expect(screen.getByText("SQS")).toBeInTheDocument(); + expect(screen.getByText("DynamoDB")).toBeInTheDocument(); + }); +}); diff --git a/features/inspector/components/request-list/request-list.tsx b/features/inspector/components/request-list/request-list.tsx new file mode 100644 index 0000000..81df650 --- /dev/null +++ b/features/inspector/components/request-list/request-list.tsx @@ -0,0 +1,25 @@ +import { RequestCard } from "@/features/inspector/components/request-card/request-card"; +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"; + +// ── Types ────────────────────────────────────────────────────────────────────── + +type CardDict = Pick, "card">; + +type RequestListProps = { + entries: RequestEntry[]; + dict: CardDict; +}; + +// ── Component ───────────────────────────────────────────────────────────────── + +export function RequestList({ entries, dict }: RequestListProps) { + return ( +
+ {entries.map((entry) => ( + + ))} +
+ ); +} diff --git a/features/inspector/i18n/en.test.ts b/features/inspector/i18n/en.test.ts new file mode 100644 index 0000000..c4149c7 --- /dev/null +++ b/features/inspector/i18n/en.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from "vitest"; +import en from "./en"; +import es from "./es"; + +// ── helpers ────────────────────────────────────────────────────────────────── + +function collectStrings(obj: object, acc: string[] = []): string[] { + for (const v of Object.values(obj)) { + if (typeof v === "string") { + acc.push(v); + } else if (v !== null && typeof v === "object") { + collectStrings(v as object, acc); + } + } + return acc; +} + +function collectKeys(obj: object, prefix = ""): string[] { + const keys: string[] = []; + for (const [k, v] of Object.entries(obj)) { + const path = prefix ? `${prefix}.${k}` : k; + if (v !== null && typeof v === "object") { + keys.push(...collectKeys(v as object, path)); + } else { + keys.push(path); + } + } + return keys; +} + +// ── LEGAL GATE ─────────────────────────────────────────────────────────────── + +const FORBIDDEN = ["localstack", "LocalStack", "local stack"]; + +describe("inspector/i18n — legal gate", () => { + it("en.ts must not mention any forbidden product name", () => { + const strings = collectStrings(en); + for (const s of strings) { + for (const f of FORBIDDEN) { + expect(s.toLowerCase()).not.toContain(f.toLowerCase()); + } + } + }); + + it("es.ts must not mention any forbidden product name", () => { + const strings = collectStrings(es); + for (const s of strings) { + for (const f of FORBIDDEN) { + expect(s.toLowerCase()).not.toContain(f.toLowerCase()); + } + } + }); +}); + +// ── KEY PARITY ─────────────────────────────────────────────────────────────── + +describe("inspector/i18n — en/es key parity", () => { + it("es has exactly the same keys as en", () => { + const enKeys = collectKeys(en).sort(); + const esKeys = collectKeys(es).sort(); + expect(esKeys).toEqual(enKeys); + }); +}); + +// ── REQUIRED KEYS ──────────────────────────────────────────────────────────── + +describe("inspector/i18n/en — required keys present", () => { + it("has title", () => expect(en.title).toBeTruthy()); + it("has description", () => expect(en.description).toBeTruthy()); + + it("has toolbar.filters.service.label", () => expect(en.toolbar.filters.service.label).toBeTruthy()); + it("has toolbar.filters.service.all", () => expect(en.toolbar.filters.service.all).toBeTruthy()); + + it("has toolbar.filters.status.label", () => expect(en.toolbar.filters.status.label).toBeTruthy()); + it("has toolbar.filters.status.all", () => expect(en.toolbar.filters.status.all).toBeTruthy()); + it("has toolbar.filters.status.success", () => expect(en.toolbar.filters.status.success).toBeTruthy()); + it("has toolbar.filters.status.error", () => expect(en.toolbar.filters.status.error).toBeTruthy()); + + it("has toolbar.filters.text.placeholder", () => expect(en.toolbar.filters.text.placeholder).toBeTruthy()); + + it("has toolbar.clearBuffer", () => expect(en.toolbar.clearBuffer).toBeTruthy()); + it("has toolbar.statusPolling", () => expect(en.toolbar.statusPolling).toBeTruthy()); + it("has toolbar.statusError", () => expect(en.toolbar.statusError).toBeTruthy()); + it("has toolbar.statusIdle", () => expect(en.toolbar.statusIdle).toBeTruthy()); + it("has toolbar.lastUpdated", () => expect(en.toolbar.lastUpdated).toBeTruthy()); + + it("has empty.title", () => expect(en.empty.title).toBeTruthy()); + it("has empty.body", () => expect(en.empty.body).toBeTruthy()); + + it("has card.duration", () => expect(en.card.duration).toBeTruthy()); + it("has card.attempts", () => expect(en.card.attempts).toBeTruthy()); + + it("has detail.title", () => expect(en.detail.title).toBeTruthy()); + it("has detail.input", () => expect(en.detail.input).toBeTruthy()); + it("has detail.output", () => expect(en.detail.output).toBeTruthy()); + it("has detail.attempts", () => expect(en.detail.attempts).toBeTruthy()); + it("has detail.duration", () => expect(en.detail.duration).toBeTruthy()); + it("has detail.timestamp", () => expect(en.detail.timestamp).toBeTruthy()); + it("has detail.error", () => expect(en.detail.error).toBeTruthy()); + it("has detail.closeLabel", () => expect(en.detail.closeLabel).toBeTruthy()); +}); + +describe("inspector/i18n/es — required keys present", () => { + it("has title", () => expect(es.title).toBeTruthy()); + it("has description", () => expect(es.description).toBeTruthy()); +}); diff --git a/features/inspector/i18n/en.ts b/features/inspector/i18n/en.ts new file mode 100644 index 0000000..7431ce9 --- /dev/null +++ b/features/inspector/i18n/en.ts @@ -0,0 +1,53 @@ +const dict = { + title: "AWS Request Inspector", + description: "Inspect every AWS SDK call made by Server Actions — filter, search, and replay.", + toolbar: { + filters: { + service: { + label: "Service", + all: "All services", + }, + status: { + label: "Status", + all: "All", + success: "Success", + error: "Error", + }, + text: { + placeholder: "Search operation or payload…", + }, + }, + view: { + label: "View", + list: "List", + timeline: "Timeline", + }, + clearBuffer: "Clear", + statusPolling: "Live", + statusError: "Error", + statusIdle: "Idle", + lastUpdated: "Updated {time} ago", + }, + empty: { + title: "No requests yet", + body: "AWS SDK calls made by Server Actions will appear here.", + }, + card: { + duration: "{ms}ms", + attempts: "{n} attempts", + retries: "{n} retries", + }, + detail: { + title: "Request Detail", + input: "Input", + output: "Output", + attempts: "Attempts", + duration: "Duration", + timestamp: "Timestamp", + error: "Error", + closeLabel: "Close", + }, +} as const; + +export default dict; +export type InspectorDict = typeof dict; diff --git a/features/inspector/i18n/es.ts b/features/inspector/i18n/es.ts new file mode 100644 index 0000000..98f4ddc --- /dev/null +++ b/features/inspector/i18n/es.ts @@ -0,0 +1,57 @@ +import type { InspectorDict } from "./en"; +import type { WidenStringLiterals } from "@/features/shared/i18n/widen-literals"; + +type InspectorDictTranslated = WidenStringLiterals; + +const dict = { + title: "Inspector de Solicitudes AWS", + description: "Inspeccioná cada llamada al SDK de AWS realizada por Server Actions — filtrá, buscá y repetí.", + toolbar: { + filters: { + service: { + label: "Servicio", + all: "Todos los servicios", + }, + status: { + label: "Estado", + all: "Todos", + success: "Éxito", + error: "Error", + }, + text: { + placeholder: "Buscar operación o payload…", + }, + }, + view: { + label: "Vista", + list: "Lista", + timeline: "Línea de tiempo", + }, + clearBuffer: "Limpiar", + statusPolling: "En vivo", + statusError: "Error", + statusIdle: "Inactivo", + lastUpdated: "Actualizado hace {time}", + }, + empty: { + title: "Sin solicitudes aún", + body: "Las llamadas al SDK de AWS realizadas por Server Actions aparecerán aquí.", + }, + card: { + duration: "{ms}ms", + attempts: "{n} intentos", + retries: "{n} reintentos", + }, + detail: { + title: "Detalle de solicitud", + input: "Entrada", + output: "Salida", + attempts: "Intentos", + duration: "Duración", + timestamp: "Marca de tiempo", + error: "Error", + closeLabel: "Cerrar", + }, +} as const satisfies InspectorDictTranslated; + +export default dict; diff --git a/features/inspector/lib/service-color/service-color.ts b/features/inspector/lib/service-color/service-color.ts new file mode 100644 index 0000000..7c19cab --- /dev/null +++ b/features/inspector/lib/service-color/service-color.ts @@ -0,0 +1,40 @@ +export type ServiceColorClasses = { + badge: string; + spine: string; +}; + +const SERVICE_COLORS: Record = { + lambda: { + badge: "bg-blue-500/10 text-blue-600 border-blue-500/20", + spine: "bg-blue-500", + }, + s3: { + badge: "bg-green-500/10 text-green-600 border-green-500/20", + spine: "bg-green-500", + }, + sns: { + badge: "bg-purple-500/10 text-purple-600 border-purple-500/20", + spine: "bg-purple-500", + }, + sqs: { + badge: "bg-orange-500/10 text-orange-600 border-orange-500/20", + spine: "bg-orange-500", + }, + dynamodb: { + badge: "bg-yellow-500/10 text-yellow-700 border-yellow-500/20", + spine: "bg-yellow-500", + }, + cloudwatchlogs: { + badge: "bg-muted text-muted-foreground border-border", + spine: "bg-muted-foreground", + }, +}; + +const DEFAULT_COLOR: ServiceColorClasses = { + badge: "bg-muted text-muted-foreground border-border", + spine: "bg-muted-foreground", +}; + +export function getServiceColorClasses(service: string): ServiceColorClasses { + return SERVICE_COLORS[service.toLowerCase()] ?? DEFAULT_COLOR; +} diff --git a/features/inspector/stores/use-inspector-store/use-inspector-store.persist.test.ts b/features/inspector/stores/use-inspector-store/use-inspector-store.persist.test.ts new file mode 100644 index 0000000..1671506 --- /dev/null +++ b/features/inspector/stores/use-inspector-store/use-inspector-store.persist.test.ts @@ -0,0 +1,136 @@ +import { describe, beforeEach, afterEach, expect, it, vi } from "vitest"; +import type { RequestEntry } from "@/features/inspector/lib/types/types"; + +vi.mock( + "@/features/inspector/use-cases/get-inspector-entries/get-inspector-entries", + () => ({ + getInspectorEntriesAction: vi.fn().mockResolvedValue({ + status: "success", + data: { entries: [] }, + }), + clearInspectorBufferAction: vi.fn().mockResolvedValue({ + status: "success", + data: undefined, + }), + }), +); + +// Simulate localStorage via a simple in-memory store +const fakeStorage: Record = {}; +vi.stubGlobal("localStorage", { + getItem: (key: string) => fakeStorage[key] ?? null, + setItem: (key: string, value: string) => { fakeStorage[key] = value; }, + removeItem: (key: string) => { delete fakeStorage[key]; }, +}); + +import { useInspectorStore } from "./use-inspector-store"; +import type { RequestFilters } from "@/features/inspector/lib/types/types"; + +function makeEntry(id: string): RequestEntry { + return { + id, + timestamp: 1700000000000, + service: "S3", + operation: "GetObjectCommand", + input: {}, + output: {}, + durationMs: 5, + status: "success", + attempts: 1, + }; +} + +describe("useInspectorStore — persist layer", () => { + beforeEach(() => { + // Clear fake storage before each test + Object.keys(fakeStorage).forEach((k) => delete fakeStorage[k]); + useInspectorStore.setState({ + entries: [], + isPolling: false, + status: "idle", + lastUpdatedAt: null, + filters: { service: "", status: "all", text: "" }, + view: "list", + }); + }); + + afterEach(() => { + useInspectorStore.getState().stopPolling(); + vi.clearAllMocks(); + }); + + it("entries are NOT persisted to storage", () => { + // Set entries + update a filter to trigger persist + useInspectorStore.setState({ + entries: [makeEntry("e1")], + filters: { service: "S3", status: "all", text: "" }, + }); + + const stored = fakeStorage["aws-local-ui/inspector"]; + if (stored) { + const parsed = JSON.parse(stored) as { state?: Record }; + expect(parsed.state).not.toHaveProperty("entries"); + } + // If nothing was stored yet, that's fine too (persist hasn't flushed synchronously) + }); + + it("filters are included in persisted state", () => { + // Trigger persist by setting state + const filters: RequestFilters = { service: "DynamoDB", status: "error", text: "test" }; + useInspectorStore.setState({ filters }); + + // Manually trigger persist flush by calling setFilter (which writes to storage) + useInspectorStore.getState().setFilter("service", "DynamoDB"); + + const stored = fakeStorage["aws-local-ui/inspector"]; + if (stored) { + const parsed = JSON.parse(stored) as { state?: Record }; + if (parsed.state) { + expect(parsed.state).toHaveProperty("filters"); + expect(parsed.state).not.toHaveProperty("entries"); + } + } + }); + + it("view is included in persisted state", () => { + useInspectorStore.getState().setView("timeline"); + + const stored = fakeStorage["aws-local-ui/inspector"]; + if (stored) { + const parsed = JSON.parse(stored) as { state?: Record }; + if (parsed.state) { + expect(parsed.state).toHaveProperty("view"); + expect(parsed.state).not.toHaveProperty("entries"); + } + } + }); + + it("rehydrate is a callable function on the store", () => { + expect(typeof useInspectorStore.persist.rehydrate).toBe("function"); + expect(() => useInspectorStore.persist.rehydrate()).not.toThrow(); + }); + + it("getOptions returns skipHydration: true", () => { + const opts = useInspectorStore.persist.getOptions(); + expect(opts.skipHydration).toBe(true); + }); + + it("after rehydrate, entries are NOT restored (entries not persisted)", async () => { + // Pre-seed storage with entries (simulating corruption / manual set) + fakeStorage["aws-local-ui/inspector"] = JSON.stringify({ + state: { + entries: [makeEntry("should-not-restore")], + filters: { service: "S3", status: "all", text: "" }, + view: "list", + }, + version: 1, + }); + + await useInspectorStore.persist.rehydrate(); + + // entries should remain empty — they are not in partialize + expect(useInspectorStore.getState().entries).toHaveLength(0); + // filters should be restored from storage + expect(useInspectorStore.getState().filters.service).toBe("S3"); + }); +}); diff --git a/features/inspector/stores/use-inspector-store/use-inspector-store.test.ts b/features/inspector/stores/use-inspector-store/use-inspector-store.test.ts new file mode 100644 index 0000000..cf64e07 --- /dev/null +++ b/features/inspector/stores/use-inspector-store/use-inspector-store.test.ts @@ -0,0 +1,245 @@ +import { describe, beforeEach, afterEach, expect, it, vi } from "vitest"; +import type { RequestEntry } from "@/features/inspector/lib/types/types"; + +// Mock the server actions — must come before importing the store +vi.mock( + "@/features/inspector/use-cases/get-inspector-entries/get-inspector-entries", + () => ({ + getInspectorEntriesAction: vi.fn(), + clearInspectorBufferAction: vi.fn(), + }), +); + +import { useInspectorStore } from "./use-inspector-store"; +import { + getInspectorEntriesAction, + clearInspectorBufferAction, +} from "@/features/inspector/use-cases/get-inspector-entries/get-inspector-entries"; + +const mockGetEntries = vi.mocked(getInspectorEntriesAction); +const mockClearBuffer = vi.mocked(clearInspectorBufferAction); + +function makeEntry(id: string, overrides: Partial = {}): RequestEntry { + return { + id, + timestamp: Date.now(), + service: "SQS", + operation: "SendMessageCommand", + input: {}, + output: {}, + durationMs: 10, + status: "success", + attempts: 1, + ...overrides, + }; +} + +const INITIAL_STATE = { + entries: [] as RequestEntry[], + isPolling: false, + status: "idle" as const, + lastUpdatedAt: null as number | null, + filters: { service: "", status: "all" as const, text: "" }, + view: "list" as const, +}; + +describe("useInspectorStore", () => { + beforeEach(() => { + vi.useFakeTimers(); + mockGetEntries.mockResolvedValue({ status: "success", data: { entries: [] } }); + mockClearBuffer.mockResolvedValue({ status: "success", data: undefined }); + useInspectorStore.setState({ ...INITIAL_STATE }); + }); + + afterEach(() => { + useInspectorStore.getState().stopPolling(); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + // ── initial state ────────────────────────────────────────────────────────── + + it("initialises with empty entries, not polling, idle status, list view", () => { + const s = useInspectorStore.getState(); + expect(s.entries).toEqual([]); + expect(s.isPolling).toBe(false); + expect(s.status).toBe("idle"); + expect(s.view).toBe("list"); + expect(s.filters).toEqual({ service: "", status: "all", text: "" }); + }); + + // ── seedEntries ─────────────────────────────────────────────────────────── + + it("seedEntries populates and sorts entries descending by timestamp", () => { + const old = makeEntry("old", { timestamp: 1000 }); + const recent = makeEntry("recent", { timestamp: 2000 }); + useInspectorStore.getState().seedEntries([old, recent]); + const entries = useInspectorStore.getState().entries; + expect(entries).toHaveLength(2); + expect(entries[0].id).toBe("recent"); + expect(entries[1].id).toBe("old"); + }); + + it("seedEntries replaces existing entries", () => { + useInspectorStore.setState({ entries: [makeEntry("stale")] }); + useInspectorStore.getState().seedEntries([makeEntry("fresh")]); + expect(useInspectorStore.getState().entries).toHaveLength(1); + expect(useInspectorStore.getState().entries[0].id).toBe("fresh"); + }); + + // ── setFilter ───────────────────────────────────────────────────────────── + + it("setFilter updates the specified filter key", () => { + useInspectorStore.getState().setFilter("service", "DynamoDB"); + expect(useInspectorStore.getState().filters.service).toBe("DynamoDB"); + expect(useInspectorStore.getState().filters.status).toBe("all"); + expect(useInspectorStore.getState().filters.text).toBe(""); + }); + + it("setFilter can update status filter", () => { + useInspectorStore.getState().setFilter("status", "error"); + expect(useInspectorStore.getState().filters.status).toBe("error"); + }); + + it("setFilter does not touch entries", () => { + useInspectorStore.setState({ entries: [makeEntry("e1")] }); + useInspectorStore.getState().setFilter("service", "S3"); + expect(useInspectorStore.getState().entries).toHaveLength(1); + }); + + // ── setView ──────────────────────────────────────────────────────────────── + + it("setView updates view", () => { + useInspectorStore.getState().setView("timeline"); + expect(useInspectorStore.getState().view).toBe("timeline"); + }); + + // ── clearBuffer ──────────────────────────────────────────────────────────── + + it("clearBuffer calls clearInspectorBufferAction and resets entries", async () => { + useInspectorStore.setState({ entries: [makeEntry("e1"), makeEntry("e2")] }); + await useInspectorStore.getState().clearBuffer(); + expect(mockClearBuffer).toHaveBeenCalledOnce(); + expect(useInspectorStore.getState().entries).toHaveLength(0); + expect(useInspectorStore.getState().lastUpdatedAt).toBeNull(); + }); + + // ── startPolling / stopPolling ───────────────────────────────────────────── + + it("startPolling sets isPolling to true", () => { + useInspectorStore.getState().startPolling(); + expect(useInspectorStore.getState().isPolling).toBe(true); + }); + + it("startPolling is idempotent — second call does nothing", async () => { + useInspectorStore.getState().startPolling(); + useInspectorStore.getState().startPolling(); + await vi.advanceTimersByTimeAsync(0); + expect(mockGetEntries).toHaveBeenCalledTimes(1); + }); + + it("stopPolling sets isPolling to false", () => { + useInspectorStore.getState().startPolling(); + useInspectorStore.getState().stopPolling(); + expect(useInspectorStore.getState().isPolling).toBe(false); + expect(useInspectorStore.getState().status).toBe("idle"); + }); + + it("stopPolling prevents further polls", async () => { + mockGetEntries.mockResolvedValue({ status: "success", data: { entries: [] } }); + useInspectorStore.getState().startPolling(); + await vi.advanceTimersByTimeAsync(0); + useInspectorStore.getState().stopPolling(); + vi.clearAllMocks(); + await vi.advanceTimersByTimeAsync(4000); + await vi.advanceTimersByTimeAsync(0); + expect(mockGetEntries).not.toHaveBeenCalled(); + }); + + it("polls and merges novel entries sorted descending", async () => { + const entries = [makeEntry("e1", { timestamp: 2000 }), makeEntry("e2", { timestamp: 1000 })]; + mockGetEntries.mockResolvedValueOnce({ status: "success", data: { entries } }); + useInspectorStore.getState().startPolling(); + await vi.advanceTimersByTimeAsync(0); + const state = useInspectorStore.getState(); + expect(state.entries).toHaveLength(2); + expect(state.entries[0].id).toBe("e1"); + expect(state.entries[1].id).toBe("e2"); + }); + + it("deduplicates entries by id across polls", async () => { + const entry = makeEntry("dup-1"); + mockGetEntries + .mockResolvedValueOnce({ status: "success", data: { entries: [entry] } }) + .mockResolvedValue({ status: "success", data: { entries: [entry] } }); + + useInspectorStore.getState().startPolling(); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(2000); + await vi.advanceTimersByTimeAsync(0); + + expect(useInspectorStore.getState().entries).toHaveLength(1); + }); + + it("status transitions to error when action returns error", async () => { + mockGetEntries.mockResolvedValue({ status: "error", message: "fail" }); + useInspectorStore.getState().startPolling(); + await vi.advanceTimersByTimeAsync(0); + expect(useInspectorStore.getState().status).toBe("error"); + }); + + it("status recovers to polling after error clears", async () => { + mockGetEntries + .mockResolvedValueOnce({ status: "error", message: "fail" }) + .mockResolvedValue({ status: "success", data: { entries: [] } }); + useInspectorStore.getState().startPolling(); + await vi.advanceTimersByTimeAsync(0); + expect(useInspectorStore.getState().status).toBe("error"); + await vi.advanceTimersByTimeAsync(2000); + await vi.advanceTimersByTimeAsync(0); + expect(useInspectorStore.getState().status).toBe("polling"); + }); + + it("lastUpdatedAt is set when novel entries arrive", async () => { + mockGetEntries.mockResolvedValue({ + status: "success", + data: { entries: [makeEntry("e1")] }, + }); + useInspectorStore.getState().startPolling(); + await vi.advanceTimersByTimeAsync(0); + expect(useInspectorStore.getState().lastUpdatedAt).toBeTypeOf("number"); + }); + + it("lastUpdatedAt remains null when no entries arrive", async () => { + useInspectorStore.setState({ lastUpdatedAt: null }); + mockGetEntries.mockResolvedValue({ status: "success", data: { entries: [] } }); + useInspectorStore.getState().startPolling(); + await vi.advanceTimersByTimeAsync(0); + expect(useInspectorStore.getState().lastUpdatedAt).toBeNull(); + }); + + // ── skipHydration ───────────────────────────────────────────────────────── + + it("store has skipHydration enabled (persist.rehydrate is a function)", () => { + expect(typeof useInspectorStore.persist.rehydrate).toBe("function"); + }); + + // ── partialize ──────────────────────────────────────────────────────────── + + it("partialize persists only filters and view (not entries)", () => { + const options = useInspectorStore.persist.getOptions(); + // Verify partialize returns only filters + view + const full = { + ...INITIAL_STATE, + entries: [makeEntry("e1")], + isPolling: true, + status: "polling" as const, + lastUpdatedAt: 123, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const partial = options.partialize!(full as any) as Record; + expect(Object.keys(partial).sort()).toEqual(["filters", "view"]); + expect(partial).not.toHaveProperty("entries"); + expect(partial).not.toHaveProperty("isPolling"); + }); +}); diff --git a/features/inspector/stores/use-inspector-store/use-inspector-store.ts b/features/inspector/stores/use-inspector-store/use-inspector-store.ts new file mode 100644 index 0000000..bbbb7a9 --- /dev/null +++ b/features/inspector/stores/use-inspector-store/use-inspector-store.ts @@ -0,0 +1,174 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { getPersistStorage } from "@/features/shared/stores/no-op-storage"; +import type { PersistStorage } from "zustand/middleware"; +import type { RequestEntry, RequestFilters } from "@/features/inspector/lib/types/types"; +import { + getInspectorEntriesAction, + clearInspectorBufferAction, +} from "@/features/inspector/use-cases/get-inspector-entries/get-inspector-entries"; + +// ── Lazy storage adapter ─────────────────────────────────────────────────────── +// Resolves `getPersistStorage()` on EACH access so that test environments that +// stub `localStorage` after module import still work correctly. + +type PersistedSlice = { filters: RequestFilters; view: "list" | "timeline" }; + +const lazyStorage: PersistStorage = { + getItem(name) { + const raw = getPersistStorage().getItem(name); + if (raw == null) return null; + try { + const parsed = JSON.parse(raw as string) as { + state: Record; + version: number; + }; + // Only restore the persisted slice (filters + view) — never entries. + // This makes the store resilient to storage corruption or manual writes. + const { filters, view } = parsed.state ?? {}; + return { + state: { filters, view } as unknown as PersistedSlice, + version: parsed.version, + }; + } catch { + return null; + } + }, + setItem(name, value) { + getPersistStorage().setItem(name, JSON.stringify(value)); + }, + removeItem(name) { + getPersistStorage().removeItem(name); + }, +}; + +// ── Constants ────────────────────────────────────────────────────────────────── + +const POLL_INTERVAL_MS = 2000; +const BUFFER_CAP = 200; + +// ── Types ────────────────────────────────────────────────────────────────────── + +export type InspectorStoreStatus = "idle" | "polling" | "error"; + +export interface InspectorStoreState { + entries: RequestEntry[]; // sorted desc by timestamp + isPolling: boolean; + status: InspectorStoreStatus; + lastUpdatedAt: number | null; + filters: RequestFilters; + view: "list" | "timeline"; // defaults to "list"; PR3 adds timeline branch + + seedEntries(entries: RequestEntry[]): void; + startPolling(): void; + stopPolling(): void; + setFilter(key: K, value: RequestFilters[K]): void; + setView(view: "list" | "timeline"): void; + clearBuffer(): Promise; +} + +// ── Store ────────────────────────────────────────────────────────────────────── + +export const useInspectorStore = create()( + persist( + (set, get) => { + let intervalRef: ReturnType | null = null; + const seenIds = new Set(); + let visibilityHandler: (() => void) | null = null; + + async function poll(): Promise { + const result = await getInspectorEntriesAction({}); + if (result.status !== "success") { + set({ status: result.status === "error" ? "error" : "polling" }); + return; + } + const incoming = result.data.entries; + if (incoming.length === 0) { + set({ status: "polling" }); + return; + } + set((s) => { + // Sync seenIds with current store entries so that external setState + // calls (e.g., test beforeEach resets) are reflected automatically. + const currentIds = new Set(s.entries.map((e) => e.id)); + // Keep seenIds consistent: remove IDs no longer in entries, keep those still there + for (const id of seenIds) { + if (!currentIds.has(id)) seenIds.delete(id); + } + const novel = incoming.filter((e) => !seenIds.has(e.id)); + novel.forEach((e) => seenIds.add(e.id)); + if (novel.length === 0) return { status: "polling" }; + const merged = [...s.entries, ...novel].sort((a, b) => b.timestamp - a.timestamp); + const trimmed = merged.length > BUFFER_CAP ? merged.slice(0, BUFFER_CAP) : merged; + return { entries: trimmed, status: "polling", lastUpdatedAt: Date.now() }; + }); + } + + return { + entries: [], + isPolling: false, + status: "idle", + lastUpdatedAt: null, + filters: { service: "", status: "all", text: "" }, + view: "list", + + seedEntries(entries) { + entries.forEach((e) => seenIds.add(e.id)); + set({ entries: [...entries].sort((a, b) => b.timestamp - a.timestamp) }); + }, + + startPolling() { + if (get().isPolling) return; + set({ isPolling: true, status: "polling" }); + void poll(); + intervalRef = setInterval(() => void poll(), POLL_INTERVAL_MS); + if (typeof document !== "undefined") { + visibilityHandler = () => { + if (document.visibilityState === "hidden" && intervalRef !== null) { + clearInterval(intervalRef); + intervalRef = null; + } else if (get().isPolling && intervalRef === null) { + void poll(); + intervalRef = setInterval(() => void poll(), POLL_INTERVAL_MS); + } + }; + document.addEventListener("visibilitychange", visibilityHandler); + } + }, + + stopPolling() { + if (intervalRef !== null) { + clearInterval(intervalRef); + intervalRef = null; + } + if (visibilityHandler !== null && typeof document !== "undefined") { + document.removeEventListener("visibilitychange", visibilityHandler); + visibilityHandler = null; + } + set({ isPolling: false, status: "idle" }); + }, + + setFilter(key, value) { + set((s) => ({ filters: { ...s.filters, [key]: value } })); + }, + + setView(view) { + set({ view }); + }, + + async clearBuffer() { + await clearInspectorBufferAction(); + seenIds.clear(); + set({ entries: [], lastUpdatedAt: null }); + }, + }; + }, + { + name: "aws-local-ui/inspector", + storage: lazyStorage, + partialize: (s) => ({ filters: s.filters, view: s.view }), + skipHydration: true, + version: 1, + }, + ), +); diff --git a/features/inspector/use-cases/get-inspector-entries/get-inspector-entries.test.ts b/features/inspector/use-cases/get-inspector-entries/get-inspector-entries.test.ts new file mode 100644 index 0000000..928c1fd --- /dev/null +++ b/features/inspector/use-cases/get-inspector-entries/get-inspector-entries.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +vi.mock("server-only", () => ({})); +vi.mock("@/lib/aws/inspector-buffer", () => ({ + getEntries: vi.fn(), + clearEntries: vi.fn(), +})); + +import { getEntries, clearEntries } from "@/lib/aws/inspector-buffer"; +import { + getInspectorEntriesAction, + clearInspectorBufferAction, +} from "./get-inspector-entries"; +import type { RequestEntry } from "@/features/inspector/lib/types/types"; + +const makeEntry = (id: string): RequestEntry => ({ + id, + timestamp: 1700000000000, + service: "SQS", + operation: "SendMessageCommand", + input: { QueueUrl: "http://sqs.us-east-1.localhost:4566/000000000000/test" }, + output: { MessageId: "msg-1" }, + durationMs: 42, + status: "success", + attempts: 1, +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("getInspectorEntriesAction", () => { + it("returns entries from the buffer on success", async () => { + const entries = [makeEntry("e-1"), makeEntry("e-2")]; + vi.mocked(getEntries).mockReturnValue(entries); + + const result = await getInspectorEntriesAction({}); + + expect(result).toEqual({ status: "success", data: { entries } }); + }); + + it("returns all entries when no since filter", async () => { + const entries = [makeEntry("e-1")]; + vi.mocked(getEntries).mockReturnValue(entries); + + const result = await getInspectorEntriesAction(); + + expect(result).toMatchObject({ status: "success", data: { entries } }); + }); + + it("filters entries by since when provided", async () => { + const entries = [ + makeEntry("old"), + { ...makeEntry("new"), timestamp: 1700000010000 }, + ]; + vi.mocked(getEntries).mockReturnValue(entries); + + const result = await getInspectorEntriesAction({ since: 1700000005000 }); + + expect(result.status).toBe("success"); + if (result.status === "success") { + expect(result.data.entries).toHaveLength(1); + expect(result.data.entries[0].id).toBe("new"); + } + }); + + it("returns error status when getEntries throws", async () => { + vi.mocked(getEntries).mockImplementation(() => { + throw new Error("buffer exploded"); + }); + + const result = await getInspectorEntriesAction({}); + + expect(result).toMatchObject({ status: "error", message: expect.any(String) }); + }); + + it("returns empty entries when buffer is empty", async () => { + vi.mocked(getEntries).mockReturnValue([]); + + const result = await getInspectorEntriesAction({}); + + expect(result).toEqual({ status: "success", data: { entries: [] } }); + }); +}); + +describe("clearInspectorBufferAction", () => { + it("calls clearEntries and returns success", async () => { + const result = await clearInspectorBufferAction(); + + expect(vi.mocked(clearEntries)).toHaveBeenCalledOnce(); + expect(result).toEqual({ status: "success", data: undefined }); + }); + + it("returns error status when clearEntries throws", async () => { + vi.mocked(clearEntries).mockImplementation(() => { + throw new Error("clear failed"); + }); + + const result = await clearInspectorBufferAction(); + + expect(result).toMatchObject({ status: "error", message: expect.any(String) }); + }); +}); diff --git a/features/inspector/use-cases/get-inspector-entries/get-inspector-entries.ts b/features/inspector/use-cases/get-inspector-entries/get-inspector-entries.ts new file mode 100644 index 0000000..b2294aa --- /dev/null +++ b/features/inspector/use-cases/get-inspector-entries/get-inspector-entries.ts @@ -0,0 +1,42 @@ +"use server"; + +import "server-only"; +import { getEntries, clearEntries } from "@/lib/aws/inspector-buffer"; +import type { RequestEntry } from "@/features/inspector/lib/types/types"; +import type { ActionState } from "@/features/shared/types/action-state"; + +export type GetInspectorEntriesInput = { + since?: number; // epoch ms; client passes lastUpdatedAt for incremental polls +}; + +export type GetInspectorEntriesData = { + entries: RequestEntry[]; +}; + +export async function getInspectorEntriesAction( + input: GetInspectorEntriesInput = {}, +): Promise> { + try { + const all = getEntries(); + const filtered = + input.since == null + ? [...all] + : all.filter((e) => e.timestamp >= input.since!); + return { status: "success", data: { entries: filtered } }; + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to read inspector buffer."; + return { status: "error", message }; + } +} + +export async function clearInspectorBufferAction(): Promise> { + try { + clearEntries(); + return { status: "success", data: undefined }; + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to clear inspector buffer."; + return { status: "error", message }; + } +} diff --git a/features/shared/i18n/get-dictionary.ts b/features/shared/i18n/get-dictionary.ts index e398d59..d63d578 100644 --- a/features/shared/i18n/get-dictionary.ts +++ b/features/shared/i18n/get-dictionary.ts @@ -18,6 +18,8 @@ import enLogs from "@/features/logs/i18n/en"; import esLogs from "@/features/logs/i18n/es"; import enSeed from "@/features/seed/i18n/en"; import esSeed from "@/features/seed/i18n/es"; +import enInspector from "@/features/inspector/i18n/en"; +import esInspector from "@/features/inspector/i18n/es"; import type { Locale } from "./locale"; import type { WidenStringLiterals } from "./widen-literals"; @@ -32,6 +34,7 @@ export type AppDict = { terminal: WidenStringLiterals; logs: WidenStringLiterals; seed: WidenStringLiterals; + inspector: WidenStringLiterals; }; const dictionaries: Record = { @@ -46,6 +49,7 @@ const dictionaries: Record = { terminal: enTerminal, logs: enLogs, seed: enSeed, + inspector: enInspector, }, es: { shared: esShared, @@ -58,6 +62,7 @@ const dictionaries: Record = { terminal: esTerminal, logs: esLogs, seed: esSeed, + inspector: esInspector, }, }; diff --git a/features/shared/stores/no-op-storage.ts b/features/shared/stores/no-op-storage.ts new file mode 100644 index 0000000..f75654d --- /dev/null +++ b/features/shared/stores/no-op-storage.ts @@ -0,0 +1,14 @@ +import type { StateStorage } from "zustand/middleware"; + +/** Fallback when `localStorage` is unavailable (SSR, tests without --localstorage-file). */ +export const NOOP_STORAGE: StateStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, +}; + +export function getPersistStorage(): StateStorage { + return typeof localStorage !== "undefined" && localStorage !== null + ? localStorage + : NOOP_STORAGE; +} diff --git a/lib/aws/inspector-middleware.test.ts b/lib/aws/inspector-middleware.test.ts index 89bd522..3c4913c 100644 --- a/lib/aws/inspector-middleware.test.ts +++ b/lib/aws/inspector-middleware.test.ts @@ -167,6 +167,74 @@ describe("withInspectorMiddleware — success path", () => { expect(recorded.operation).toBe("UnknownCommand"); }); + it("durationMs is >= 0 on success", async () => { + const client = makeClient(); + withInspectorMiddleware(client, "SQS"); + + const [middleware] = client.middlewareStack.add.mock.calls[0] as [ + (next: unknown, ctx: unknown) => (args: unknown) => Promise, + unknown, + ]; + + const next = vi.fn().mockResolvedValue({ + output: { $metadata: { attempts: 1 } }, + }); + const ctx = { commandName: "SendMessageCommand" }; + const args = { input: {}, request: {} }; + + await middleware(next, ctx)(args); + + const recorded = mockPushEntry.mock.calls[0][0]; + expect(recorded.durationMs).toBeGreaterThanOrEqual(0); + }); + + it("id is a UUID (v4 format) on success", async () => { + const client = makeClient(); + withInspectorMiddleware(client, "SQS"); + + const [middleware] = client.middlewareStack.add.mock.calls[0] as [ + (next: unknown, ctx: unknown) => (args: unknown) => Promise, + unknown, + ]; + + const next = vi.fn().mockResolvedValue({ + output: { $metadata: { attempts: 1 } }, + }); + const ctx = { commandName: "SendMessageCommand" }; + const args = { input: {}, request: {} }; + + await middleware(next, ctx)(args); + + const recorded = mockPushEntry.mock.calls[0][0]; + expect(recorded.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + }); + + it("timestamp is an epoch ms number on success", async () => { + const before = Date.now(); + const client = makeClient(); + withInspectorMiddleware(client, "SQS"); + + const [middleware] = client.middlewareStack.add.mock.calls[0] as [ + (next: unknown, ctx: unknown) => (args: unknown) => Promise, + unknown, + ]; + + const next = vi.fn().mockResolvedValue({ + output: { $metadata: { attempts: 1 } }, + }); + const ctx = { commandName: "SendMessageCommand" }; + const args = { input: {}, request: {} }; + + await middleware(next, ctx)(args); + const after = Date.now(); + + const recorded = mockPushEntry.mock.calls[0][0]; + expect(recorded.timestamp).toBeGreaterThanOrEqual(before); + expect(recorded.timestamp).toBeLessThanOrEqual(after); + }); + it("returns the result from next (transparent)", async () => { const client = makeClient(); withInspectorMiddleware(client, "SQS"); diff --git a/lib/tools-registry.ts b/lib/tools-registry.ts index 355710f..cf67dc2 100644 --- a/lib/tools-registry.ts +++ b/lib/tools-registry.ts @@ -1,7 +1,8 @@ -import { TerminalIcon, DatabaseZapIcon } from "lucide-react"; +import { TerminalIcon, DatabaseZapIcon, SearchIcon } from "lucide-react"; import type { ToolEntry } from "@/features/shared/types/service-entry"; export const tools: ToolEntry[] = [ { id: "terminal", label: "Terminal", href: "/terminal", icon: TerminalIcon }, { id: "seed", label: "Demo Data", href: "/seed", icon: DatabaseZapIcon }, + { id: "inspector", label: "Inspector", href: "/inspector", icon: SearchIcon }, ];