Skip to content
Merged

Dev #214

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
d841085
Merge pull request #210 from sisques-labs/main
JSisques May 22, 2026
fa42b88
feat(inspector): add RequestEntry, RequestFilters, RequestStatus types
JSisques May 22, 2026
a1c5071
feat(inspector): add truncate utility for payload size-capping
JSisques May 22, 2026
d4e85d2
feat(inspector): add request ring buffer with FIFO eviction at cap 200
JSisques May 22, 2026
4cf6cea
feat(inspector): add deserialize-step middleware to capture SDK calls
JSisques May 22, 2026
f485fe7
feat(inspector): wire inspector middleware into all 6 AWS client fact…
JSisques May 22, 2026
7e701f4
fix(inspector): relax AnyClient.middlewareStack.add signature to sati…
JSisques May 23, 2026
8791996
feat(inspector): add Zustand store with persist, polling, and Page Vi…
JSisques May 22, 2026
69c802b
feat(inspector): add RequestCard, RequestDetailDialog, RequestList, I…
JSisques May 22, 2026
10bb4c2
feat(inspector): add /inspector RSC page and register inspector in to…
JSisques May 22, 2026
7c924c3
test(inspector): add durationMs, UUID id, and timestamp assertions to…
JSisques May 22, 2026
4ae94e4
feat(inspector): add i18n keys, server action, store tests, and no-op…
JSisques May 22, 2026
8f617f8
fix(inspector): use vi.hoisted for mock functions in inspector-client…
JSisques May 22, 2026
53fe334
feat(inspector): add i18n keys for view toggle, empty state, and retries
JSisques May 23, 2026
fcb95bd
feat(inspector): add InspectorEmpty and InspectorSkeleton components
JSisques May 23, 2026
b88c0c9
feat(inspector): add InspectorTimeline with descending sort and spine…
JSisques May 23, 2026
47101a5
feat(inspector): add retry badge with i18n to RequestCard
JSisques May 23, 2026
af2f656
feat(inspector): add view toggle and conditional rendering to Inspect…
JSisques May 23, 2026
26f60ed
fix(inspector): await rehydrate with async IIFE to handle void | Prom…
JSisques May 23, 2026
b0ba27e
fix(inspector): handle null from Select onValueChange in service filter
JSisques May 23, 2026
6328cf8
fix(inspector): narrow result to success before accessing data in poll()
JSisques May 23, 2026
d8e8efb
Merge pull request #213 from sisques-labs/feat/inspector-pr3
JSisques May 23, 2026
9832f9d
Merge pull request #212 from sisques-labs/feat/inspector-pr2
JSisques May 23, 2026
d9dec03
Merge branch 'dev' into feat/inspector-pr1
JSisques May 23, 2026
b6ae993
fix(inspector): merge duplicate lucide-react imports in tools-registry
JSisques May 23, 2026
ddec5c8
Merge pull request #211 from sisques-labs/feat/inspector-pr1
JSisques May 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions app/[lang]/(dashboard)/inspector/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div>
<h1 className="text-xl font-semibold">{dict.inspector.title}</h1>
<p className="text-sm text-muted-foreground">{dict.inspector.description}</p>
</div>
</div>
<InspectorClient
initialEntries={initialEntries}
dict={{
toolbar: dict.inspector.toolbar,
empty: dict.inspector.empty,
card: dict.inspector.card,
detail: dict.inspector.detail,
}}
/>
</div>
);
}
11 changes: 11 additions & 0 deletions features/dynamodb/lib/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ vi.mock("@/lib/aws/config", () => ({
import { createAwsConfig } from "@/lib/aws/config";
import { getDynamoDBClient, getDynamoDBDocumentClient } from "./client";

function hasInspectorMiddleware(client: { middlewareStack: { identify: () => string[] } }) {
return client.middlewareStack.identify().some((entry) =>
entry.startsWith("InspectorMiddleware"),
);
}

const fakeConfig = {
endpoint: "http://localhost:4566",
region: "us-east-1",
Expand Down Expand Up @@ -38,6 +44,11 @@ describe("getDynamoDBClient", () => {
const second = await getDynamoDBClient();
expect(first).not.toBe(second);
});

it("has InspectorMiddleware registered (INSPECTOR tag, deserialize step)", async () => {
const client = await getDynamoDBClient();
expect(hasInspectorMiddleware(client)).toBe(true);
});
});

describe("getDynamoDBDocumentClient", () => {
Expand Down
3 changes: 2 additions & 1 deletion features/dynamodb/lib/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import "server-only";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { createAwsConfig } from "@/lib/aws/config";
import { withInspectorMiddleware } from "@/lib/aws/inspector-middleware";

/**
* Returns a fresh DynamoDBClient on every call.
Expand All @@ -15,7 +16,7 @@ import { createAwsConfig } from "@/lib/aws/config";
*/
export async function getDynamoDBClient(): Promise<DynamoDBClient> {
const config = await createAwsConfig();
return new DynamoDBClient(config);
return withInspectorMiddleware(new DynamoDBClient(config), "DynamoDB");
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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: () => <div data-testid="inspector-toolbar" />,
}));

vi.mock("@/features/inspector/components/request-list/request-list", () => ({
RequestList: ({ entries }: { entries: RequestEntry[] }) => (
<div data-testid="request-list" data-count={entries.length} />
),
}));

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 ──────────────────────────────────────────────────────────────────

type ClientDict = Pick<
WidenStringLiterals<InspectorDict>,
"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(<InspectorClient initialEntries={[]} dict={dict} />);
});
expect(screen.getByTestId("inspector-toolbar")).toBeInTheDocument();
});

it("renders the request list when entries exist", async () => {
mockStoreState.entries = [makeEntry("e1")];
await act(async () => {
render(<InspectorClient initialEntries={[]} dict={dict} />);
});
expect(screen.getByTestId("request-list")).toBeInTheDocument();
});

it("calls rehydrate on mount", async () => {
await act(async () => {
render(<InspectorClient initialEntries={[]} dict={dict} />);
});
expect(mockRehydrate).toHaveBeenCalledOnce();
});

it("calls seedEntries with initialEntries on mount", async () => {
const entries = [makeEntry("e1"), makeEntry("e2")];
await act(async () => {
render(<InspectorClient initialEntries={entries} dict={dict} />);
});
expect(mockSeedEntries).toHaveBeenCalledWith(entries);
});

it("calls startPolling on mount", async () => {
await act(async () => {
render(<InspectorClient initialEntries={[]} dict={dict} />);
});
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();
});
});
Loading
Loading