Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
39 changes: 35 additions & 4 deletions mcpjam-inspector/client/src/components/CiEvalsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ import {
} from "@/components/ui/resizable";
import { useSharedAppState } from "@/state/app-state-context";
import { useCiEvalsRoute, navigateToCiEvalsRoute } from "@/lib/ci-evals-router";
import { aggregateSuite, groupSuitesByTag } from "./evals/helpers";
import { aggregateSuite, groupSuitesByTag, groupRunsByCommit } from "./evals/helpers";
import { OverviewPanel } from "./evals/overview-panel";
import { useEvalMutations } from "./evals/use-eval-mutations";
import { useEvalQueries } from "./evals/use-eval-queries";
import { useEvalHandlers } from "./evals/use-eval-handlers";
import { CiSuiteListSidebar } from "./evals/ci-suite-list-sidebar";
import { CiSuiteListSidebar, type SidebarMode } from "./evals/ci-suite-list-sidebar";
import { CiSuiteDetail } from "./evals/ci-suite-detail";
import { CommitDetailView } from "./evals/commit-detail-view";
import { useWorkspaceMembers } from "@/hooks/useWorkspaces";
import type { EvalSuite } from "./evals/types";

Expand All @@ -35,6 +36,7 @@ export function CiEvalsTab({ convexWorkspaceId }: CiEvalsTabProps) {
const [deletingSuiteId, setDeletingSuiteId] = useState<string | null>(null);
const [deletingRunId, setDeletingRunId] = useState<string | null>(null);
const [filterTag, setFilterTag] = useState<string | null>(null);
const [sidebarMode, setSidebarMode] = useState<SidebarMode>("suites");

const selectedSuiteId =
route.type === "suite-overview" ||
Expand Down Expand Up @@ -88,6 +90,21 @@ export function CiEvalsTab({ convexWorkspaceId }: CiEvalsTabProps) {

const tagGroups = useMemo(() => groupSuitesByTag(sdkSuites), [sdkSuites]);
const hasTags = tagGroups.some((g) => g.tag !== "Untagged");

const commitGroups = useMemo(
() => groupRunsByCommit(sdkSuites),
[sdkSuites],
);

const selectedCommitSha =
route.type === "commit-detail" ? route.commitSha : null;

const selectedCommitGroup = useMemo(() => {
if (!selectedCommitSha) return null;
return (
commitGroups.find((g) => g.commitSha === selectedCommitSha) ?? null
);
}, [commitGroups, selectedCommitSha]);
const allTags = useMemo(
() =>
Array.from(new Set(sdkSuites.flatMap((e) => e.suite.tags ?? []))).sort(),
Expand Down Expand Up @@ -141,6 +158,10 @@ export function CiEvalsTab({ convexWorkspaceId }: CiEvalsTabProps) {
navigateToCiEvalsRoute({ type: "list" });
}, []);

const handleSelectCommit = useCallback((commitSha: string) => {
navigateToCiEvalsRoute({ type: "commit-detail", commitSha });
}, []);

const handleDeleteSuite = useCallback(
async (suite: EvalSuite) => {
if (deletingSuiteId) return;
Expand Down Expand Up @@ -261,10 +282,15 @@ export function CiEvalsTab({ convexWorkspaceId }: CiEvalsTabProps) {
selectedSuiteId={selectedSuiteId}
onSelectSuite={handleSelectSuite}
onSelectOverview={handleSelectOverview}
isOverviewSelected={!selectedSuiteId}
isOverviewSelected={!selectedSuiteId && route.type !== "commit-detail"}
isLoading={queries.isOverviewLoading}
filterTag={filterTag}
hasTags={true}
sidebarMode={sidebarMode}
onSidebarModeChange={setSidebarMode}
commitGroups={commitGroups}
selectedCommitSha={selectedCommitSha}
onSelectCommit={handleSelectCommit}
/>
</ResizablePanel>

Expand All @@ -274,7 +300,12 @@ export function CiEvalsTab({ convexWorkspaceId }: CiEvalsTabProps) {
defaultSize={70}
className="flex flex-col overflow-hidden"
>
{sdkSuites.length === 0 ? (
{route.type === "commit-detail" && selectedCommitGroup ? (
<CommitDetailView
commitGroup={selectedCommitGroup}
allCommitGroups={commitGroups}
/>
) : sdkSuites.length === 0 ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center max-w-md mx-auto p-8">
<div className="w-20 h-20 bg-muted rounded-full flex items-center justify-center mx-auto mb-6">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import type { CommitGroup, EvalSuiteRun } from "../types";

// Test the helper logic used by CommitDetailView without rendering
// (categorizeRuns, getRunDuration, getTotalCases, getModelsUsed)

function makeRun(overrides: Partial<EvalSuiteRun> = {}): EvalSuiteRun {
return {
_id: "run_1",
suiteId: "suite_1",
createdBy: "user_1",
runNumber: 1,
configRevision: "rev1",
configSnapshot: { tests: [], environment: { servers: [] } },
status: "completed",
result: "passed",
createdAt: 1000,
...overrides,
};
}

function makeCommitGroup(overrides: Partial<CommitGroup> = {}): CommitGroup {
return {
commitSha: "abc1234567890",
shortSha: "abc1234",
branch: "main",
timestamp: 5000,
status: "passed",
runs: [],
suiteMap: new Map(),
summary: { total: 0, passed: 0, failed: 0, running: 0 },
...overrides,
};
}

// Replicate categorizeRuns logic for testing
function categorizeRuns(runs: EvalSuiteRun[]) {
const failed: EvalSuiteRun[] = [];
const passed: EvalSuiteRun[] = [];
const notRun: EvalSuiteRun[] = [];
const running: EvalSuiteRun[] = [];

for (const run of runs) {
if (run.status === "running" || run.status === "pending") {
running.push(run);
} else if (run.result === "failed") {
failed.push(run);
} else if (run.result === "passed") {
passed.push(run);
} else {
notRun.push(run);
}
}

return { failed, passed, notRun, running };
}

describe("categorizeRuns", () => {
it("separates runs by status/result", () => {
const runs = [
makeRun({ _id: "r1", result: "passed", status: "completed" }),
makeRun({ _id: "r2", result: "failed", status: "completed" }),
makeRun({ _id: "r3", status: "running" }),
makeRun({ _id: "r4", result: "cancelled", status: "cancelled" }),
];

const { passed, failed, running, notRun } = categorizeRuns(runs);

expect(passed).toHaveLength(1);
expect(passed[0]._id).toBe("r1");
expect(failed).toHaveLength(1);
expect(failed[0]._id).toBe("r2");
expect(running).toHaveLength(1);
expect(running[0]._id).toBe("r3");
expect(notRun).toHaveLength(1);
expect(notRun[0]._id).toBe("r4");
});

it("handles empty runs array", () => {
const { passed, failed, running, notRun } = categorizeRuns([]);
expect(passed).toHaveLength(0);
expect(failed).toHaveLength(0);
expect(running).toHaveLength(0);
expect(notRun).toHaveLength(0);
});

it("treats pending runs as running", () => {
const runs = [makeRun({ _id: "r1", status: "pending" })];
const { running } = categorizeRuns(runs);
expect(running).toHaveLength(1);
});
});

describe("CommitGroup structure", () => {
it("manual group has correct identifiers", () => {
const group = makeCommitGroup({
commitSha: "manual",
shortSha: "Manual",
branch: null,
});
expect(group.commitSha).toBe("manual");
expect(group.shortSha).toBe("Manual");
expect(group.branch).toBeNull();
});

it("suiteMap maps suiteId to name", () => {
const map = new Map([
["s1", "Suite A"],
["s2", "Suite B"],
]);
const group = makeCommitGroup({ suiteMap: map });
expect(group.suiteMap.get("s1")).toBe("Suite A");
expect(group.suiteMap.size).toBe(2);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { describe, expect, it } from "vitest";
import { groupRunsByCommit } from "../helpers";
import type { EvalSuiteOverviewEntry, EvalSuiteRun } from "../types";

function makeRun(overrides: Partial<EvalSuiteRun> = {}): EvalSuiteRun {
return {
_id: "run_1",
suiteId: "suite_1",
createdBy: "user_1",
runNumber: 1,
configRevision: "rev1",
configSnapshot: { tests: [], environment: { servers: [] } },
status: "completed",
result: "passed",
createdAt: 1000,
...overrides,
};
}

function makeEntry(
suiteId: string,
suiteName: string,
runs: EvalSuiteRun[],
): EvalSuiteOverviewEntry {
return {
suite: {
_id: suiteId,
createdBy: "user_1",
name: suiteName,
description: "",
configRevision: "rev1",
environment: { servers: [] },
createdAt: 0,
updatedAt: 0,
},
latestRun: runs[0] ?? null,
recentRuns: runs,
passRateTrend: [],
totals: { passed: 0, failed: 0, runs: runs.length },
};
}

describe("groupRunsByCommit", () => {
it("groups runs by commitSha", () => {
const runs = [
makeRun({
_id: "r1",
suiteId: "s1",
ciMetadata: { commitSha: "abc1234567890" },
createdAt: 2000,
}),
makeRun({
_id: "r2",
suiteId: "s1",
ciMetadata: { commitSha: "abc1234567890" },
createdAt: 3000,
}),
makeRun({
_id: "r3",
suiteId: "s1",
ciMetadata: { commitSha: "def4567890123" },
createdAt: 1000,
}),
];

const result = groupRunsByCommit([makeEntry("s1", "Suite 1", runs)]);

expect(result).toHaveLength(2);
expect(result[0].shortSha).toBe("abc1234");
expect(result[0].runs).toHaveLength(2);
expect(result[1].shortSha).toBe("def4567");
expect(result[1].runs).toHaveLength(1);
});

it("puts manual runs (no commitSha) last", () => {
const runs = [
makeRun({ _id: "r1", createdAt: 5000 }), // no ciMetadata
makeRun({
_id: "r2",
ciMetadata: { commitSha: "abc1234567890" },
createdAt: 1000,
}),
];

const result = groupRunsByCommit([makeEntry("s1", "Suite 1", runs)]);

expect(result).toHaveLength(2);
expect(result[0].commitSha).toBe("abc1234567890");
expect(result[1].commitSha).toBe("manual");
expect(result[1].shortSha).toBe("Manual");
});

it("sorts by most recent timestamp first", () => {
const runs = [
makeRun({
_id: "r1",
ciMetadata: { commitSha: "old1234567890" },
createdAt: 1000,
}),
makeRun({
_id: "r2",
ciMetadata: { commitSha: "new1234567890" },
createdAt: 5000,
}),
];

const result = groupRunsByCommit([makeEntry("s1", "Suite 1", runs)]);

expect(result[0].shortSha).toBe("new1234");
expect(result[1].shortSha).toBe("old1234");
});

it("computes aggregate status correctly", () => {
const runs = [
makeRun({
_id: "r1",
ciMetadata: { commitSha: "mix1234567890" },
result: "passed",
status: "completed",
createdAt: 1000,
}),
makeRun({
_id: "r2",
ciMetadata: { commitSha: "mix1234567890" },
result: "failed",
status: "completed",
createdAt: 2000,
}),
];

const result = groupRunsByCommit([makeEntry("s1", "Suite 1", runs)]);

expect(result[0].status).toBe("mixed");
expect(result[0].summary.passed).toBe(1);
expect(result[0].summary.failed).toBe(1);
});

it("picks up branch from ciMetadata", () => {
const runs = [
makeRun({
_id: "r1",
ciMetadata: { commitSha: "abc1234567890", branch: "feature/test" },
createdAt: 1000,
}),
];

const result = groupRunsByCommit([makeEntry("s1", "Suite 1", runs)]);
expect(result[0].branch).toBe("feature/test");
});

it("builds suiteMap from multiple overview entries", () => {
const entry1 = makeEntry("s1", "Suite A", [
makeRun({
_id: "r1",
suiteId: "s1",
ciMetadata: { commitSha: "abc1234567890" },
createdAt: 1000,
}),
]);
const entry2 = makeEntry("s2", "Suite B", [
makeRun({
_id: "r2",
suiteId: "s2",
ciMetadata: { commitSha: "abc1234567890" },
createdAt: 2000,
}),
]);

const result = groupRunsByCommit([entry1, entry2]);

expect(result).toHaveLength(1);
expect(result[0].suiteMap.get("s1")).toBe("Suite A");
expect(result[0].suiteMap.get("s2")).toBe("Suite B");
expect(result[0].runs).toHaveLength(2);
});

it("returns empty array for empty input", () => {
expect(groupRunsByCommit([])).toEqual([]);
});
});
Loading
Loading