Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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[] }) => (
<div data-testid="inspector-timeline" data-count={entries.length} />
),
}));

vi.mock("@/features/inspector/components/inspector-empty/inspector-empty", () => ({
InspectorEmpty: () => <div data-testid="inspector-empty" />,
}));

vi.mock("@/features/inspector/components/inspector-skeleton/inspector-skeleton", () => ({
InspectorSkeleton: ({ withSpine }: { withSpine: boolean }) => (
<div data-testid="inspector-skeleton" data-with-spine={withSpine} />
),
}));

import { InspectorClient } from "./inspector-client";

// ── Fixtures ──────────────────────────────────────────────────────────────────
Expand All @@ -69,14 +95,15 @@ 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",
statusIdle: "Idle",
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",
Expand Down Expand Up @@ -107,6 +134,9 @@ afterEach(() => {
cleanup();
vi.clearAllMocks();
mockRehydrate.mockResolvedValue(undefined);
mockStoreState.entries = [];
mockStoreState.isLoading = false;
mockStoreState.view = "list";
});

// ── Tests ─────────────────────────────────────────────────────────────────────
Expand All @@ -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(<InspectorClient initialEntries={[]} dict={dict} />);
});
Expand Down Expand Up @@ -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(<InspectorClient initialEntries={[]} dict={dict} />);
});
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(<InspectorClient initialEntries={[]} dict={dict} />);
});
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(<InspectorClient initialEntries={[]} dict={dict} />);
});
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(<InspectorClient initialEntries={[]} dict={dict} />);
});
expect(screen.getByTestId("inspector-empty")).toBeInTheDocument();
expect(screen.queryByTestId("request-list")).not.toBeInTheDocument();
expect(screen.queryByTestId("inspector-timeline")).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();
Expand All @@ -57,11 +61,26 @@ export function InspectorClient({ initialEntries, dict }: InspectorClientProps)
});
}, [entries, filters]);

// ── Render body ───────────────────────────────────────────────────────────────

function renderBody() {
if (isPolling && entries.length === 0) {
return <InspectorSkeleton withSpine={view === "timeline"} />;
}
if (filtered.length === 0) {
return <InspectorEmpty dict={{ empty: dict.empty }} />;
}
if (view === "timeline") {
return <InspectorTimeline entries={filtered} dict={{ card: dict.card }} />;
}
return <RequestList entries={filtered} dict={{ card: dict.card }} />;
}

return (
<div className="flex flex-col gap-0 rounded-lg border border-border overflow-hidden">
<InspectorToolbar dict={{ toolbar: dict.toolbar }} services={SERVICES} />
<div className="p-3">
<RequestList entries={filtered} />
{renderBody()}
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<InspectorEmpty dict={dict} />);
expect(screen.getByText("No requests yet")).toBeInTheDocument();
});

it("renders the empty body text", () => {
render(<InspectorEmpty dict={dict} />);
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(<InspectorEmpty dict={dict} />);
expect(container.querySelector("svg")).toBeInTheDocument();
});
});
25 changes: 25 additions & 0 deletions features/inspector/components/inspector-empty/inspector-empty.tsx
Original file line number Diff line number Diff line change
@@ -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<WidenStringLiterals<InspectorDict>, "empty">;

type InspectorEmptyProps = {
dict: EmptyDict;
};

// ── Component ─────────────────────────────────────────────────────────────────

export function InspectorEmpty({ dict }: InspectorEmptyProps) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-16 text-center text-muted-foreground">
<SearchX className="h-10 w-10 opacity-40" />
<div>
<p className="text-sm font-medium">{dict.empty.title}</p>
<p className="mt-1 text-xs">{dict.empty.body}</p>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { cleanup, render, screen } from "@testing-library/react";

Check warning on line 1 in features/inspector/components/inspector-skeleton/inspector-skeleton.test.tsx

View workflow job for this annotation

GitHub Actions / Build loopback

'screen' is defined but never used
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(<InspectorSkeleton withSpine={false} />);
// 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(<InspectorSkeleton withSpine={false} />);
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(<InspectorSkeleton withSpine={true} />);
const items = container.querySelectorAll("[data-testid='skeleton-item']");
expect(items.length).toBeGreaterThan(0);
});

it("renders spine dots when withSpine is true", () => {
const { container } = render(<InspectorSkeleton withSpine={true} />);
const spineDots = container.querySelectorAll("[data-testid='spine-dot']");
expect(spineDots.length).toBeGreaterThan(0);
});

it("spine dot count matches skeleton item count", () => {
const { container } = render(<InspectorSkeleton withSpine={true} />);
const items = container.querySelectorAll("[data-testid='skeleton-item']");
const spineDots = container.querySelectorAll("[data-testid='spine-dot']");
expect(spineDots.length).toBe(items.length);
});
});
});
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-2 p-3">
{items.map((_, idx) => (
<div
key={idx}
className={withSpine ? "flex gap-3" : undefined}
>
{withSpine && (
<div className="flex flex-col items-center">
<div
data-testid="spine-dot"
className="mt-1 h-3 w-3 shrink-0 rounded-full bg-muted animate-pulse"
/>
{idx < count - 1 && <div className="w-px flex-1 bg-border" />}
</div>
)}
<div
data-testid="skeleton-item"
className="flex-1 h-14 rounded-lg bg-muted animate-pulse"
/>
</div>
))}
</div>
);
}
Loading
Loading