Files
headlamp-argocd-plugin/src/__tests__/ApplicationsList.test.tsx
T
Test User 04f149cdaa feat(ApplicationsList): implement ArgoCD Applications List view
Implement the Applications List view for headlamp-argocd-plugin (PRI-189).

- Add src/components/ApplicationsList.tsx with table of all ArgoCD Applications
  showing: app name, namespace, project, health status, sync status,
  target revision, and last synced time
- Health/sync status badges using ArgoCD color conventions
- Filter controls: health dropdown, sync dropdown, project dropdown
- Friendly "ArgoCD not detected" error state when ArgoCD is unreachable
- Add src/api/argocd.ts with ArgoCD API types (Application, ApplicationsList)
- Add unit tests in src/__tests__/ApplicationsList.test.tsx:
  - Pure function tests for healthStatusToColor and syncStatusToColor
  - Filter logic unit tests
  - Component smoke tests (loading, error, data, empty states)
- Replace stub view in src/index.tsx with ApplicationsList component

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-21 20:39:25 +00:00

364 lines
9.6 KiB
TypeScript

import { ApiProxy } from "@kinvolk/headlamp-plugin/lib";
import { render, screen } from "@testing-library/react";
import React from "react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import {
ApplicationRow,
healthStatusToColor,
healthStatusToLabel,
syncStatusToColor,
} from "../components/ApplicationsList";
import ApplicationsList from "../components/ApplicationsList";
// --- Pure function unit tests ---
describe("healthStatusToColor", () => {
it("maps Healthy to success", () => {
expect(healthStatusToColor("Healthy")).toBe("success");
});
it("maps Degraded to error", () => {
expect(healthStatusToColor("Degraded")).toBe("error");
});
it("maps Progressing to warning", () => {
expect(healthStatusToColor("Progressing")).toBe("warning");
});
it("maps Missing to default", () => {
expect(healthStatusToColor("Missing")).toBe("default");
});
it("maps Unknown to default", () => {
expect(healthStatusToColor("Unknown")).toBe("default");
});
});
describe("syncStatusToColor", () => {
it("maps Synced to success", () => {
expect(syncStatusToColor("Synced")).toBe("success");
});
it("maps OutOfSync to warning", () => {
expect(syncStatusToColor("OutOfSync")).toBe("warning");
});
it("maps Unknown to default", () => {
expect(syncStatusToColor("Unknown")).toBe("default");
});
});
describe("healthStatusToLabel", () => {
it("returns the status string as-is", () => {
expect(healthStatusToLabel("Healthy")).toBe("Healthy");
expect(healthStatusToLabel("Degraded")).toBe("Degraded");
expect(healthStatusToLabel("Progressing")).toBe("Progressing");
expect(healthStatusToLabel("Missing")).toBe("Missing");
expect(healthStatusToLabel("Unknown")).toBe("Unknown");
});
});
// --- Filter logic unit tests ---
const makeApp = (overrides: Partial<ApplicationRow> = {}): ApplicationRow => ({
name: "test-app",
namespace: "argocd",
project: "default",
healthStatus: "Healthy",
syncStatus: "Synced",
targetRevision: "HEAD",
lastSynced: "2024-01-01T00:00:00Z",
...overrides,
});
const ALL_HEALTH = "All" as const;
const ALL_SYNC = "All" as const;
function applyFilters(
apps: ApplicationRow[],
healthFilter: ApplicationRow["healthStatus"] | "All",
syncFilter: ApplicationRow["syncStatus"] | "All",
projectFilter: string
): ApplicationRow[] {
return apps.filter((app) => {
if (healthFilter !== ALL_HEALTH && app.healthStatus !== healthFilter)
return false;
if (syncFilter !== ALL_SYNC && app.syncStatus !== syncFilter) return false;
if (projectFilter !== "All" && app.project !== projectFilter) return false;
return true;
});
}
describe("ApplicationsList filter logic", () => {
const apps = [
makeApp({
name: "app-1",
healthStatus: "Healthy",
syncStatus: "Synced",
project: "proj-a",
}),
makeApp({
name: "app-2",
healthStatus: "Healthy",
syncStatus: "OutOfSync",
project: "proj-a",
}),
makeApp({
name: "app-3",
healthStatus: "Degraded",
syncStatus: "OutOfSync",
project: "proj-b",
}),
makeApp({
name: "app-4",
healthStatus: "Progressing",
syncStatus: "Synced",
project: "proj-b",
}),
makeApp({
name: "app-5",
healthStatus: "Unknown",
syncStatus: "Unknown",
project: "proj-c",
}),
];
it("returns all apps when all filters are All", () => {
const result = applyFilters(apps, ALL_HEALTH, ALL_SYNC, "All");
expect(result).toHaveLength(5);
});
it("filters by health status", () => {
const result = applyFilters(apps, "Healthy", ALL_SYNC, "All");
expect(result).toHaveLength(2);
expect(result.map((a) => a.name)).toEqual(["app-1", "app-2"]);
});
it("filters by sync status", () => {
const result = applyFilters(apps, ALL_HEALTH, "OutOfSync", "All");
expect(result).toHaveLength(2);
expect(result.map((a) => a.name)).toEqual(["app-2", "app-3"]);
});
it("filters by project", () => {
const result = applyFilters(apps, ALL_HEALTH, ALL_SYNC, "proj-a");
expect(result).toHaveLength(2);
expect(result.map((a) => a.name)).toEqual(["app-1", "app-2"]);
});
it("combines multiple filters", () => {
const result = applyFilters(apps, "Healthy", "OutOfSync", "All");
expect(result).toHaveLength(1);
expect(result[0].name).toBe("app-2");
});
it("returns empty array when no apps match", () => {
const result = applyFilters(apps, "Degraded", "Synced", "All");
expect(result).toHaveLength(0);
});
});
// --- Component smoke test ---
// Mock Headlamp lib
vi.mock("@kinvolk/headlamp-plugin/lib", () => ({
ApiProxy: { request: vi.fn() },
}));
// Mock MUI
vi.mock("@mui/material/FormControl", () => ({
default: ({ children }: { children: React.ReactNode }) => (
<div data-testid="form-control">{children}</div>
),
}));
vi.mock("@mui/material/InputLabel", () => ({
default: ({ children, id }: { children: React.ReactNode; id?: string }) => (
<label data-testid="input-label" id={id}>
{children}
</label>
),
}));
vi.mock("@mui/material/Select", () => ({
default: ({
children,
value,
onChange,
label,
}: {
children: React.ReactNode;
value: string;
onChange: (e: { target: { value: string } }) => void;
label?: string;
}) => (
<select
data-testid="select"
value={value}
onChange={(e) => onChange({ target: { value: e.target.value } })}
aria-label={label}
>
{children}
</select>
),
}));
vi.mock("@mui/material/MenuItem", () => ({
default: ({
children,
value,
}: {
children: React.ReactNode;
value: string;
}) => (
<option data-testid="menu-item" value={value}>
{children}
</option>
),
}));
vi.mock("@mui/material/Box", () => ({
default: ({ children }: { children: React.ReactNode }) => (
<div data-testid="box">{children}</div>
),
}));
// Mock CommonComponents
vi.mock("@kinvolk/headlamp-plugin/lib/CommonComponents", () => ({
SectionBox: ({ children }: { children?: React.ReactNode }) => (
<div data-testid="section-box">{children}</div>
),
SectionHeader: ({ title }: { title: string }) => (
<div data-testid="section-header">{title}</div>
),
StatusLabel: ({
status,
children,
}: {
status: string;
children?: React.ReactNode;
}) => (
<span data-testid="status-label" data-status={status}>
{children}
</span>
),
SimpleTable: ({
columns,
data,
emptyMessage,
}: {
columns: Array<{
label: string;
getter: (row: unknown) => React.ReactNode;
}>;
data: unknown[];
emptyMessage?: string;
}) =>
data.length === 0 ? (
<div data-testid="simple-table-empty">{emptyMessage}</div>
) : (
<table data-testid="simple-table">
<thead>
<tr>
{columns.map((col) => (
<th key={col.label}>{col.label}</th>
))}
</tr>
</thead>
<tbody>
{data.map((row, i) => (
<tr key={i}>
{columns.map((col) => (
<td key={col.label}>{col.getter(row)}</td>
))}
</tr>
))}
</tbody>
</table>
),
}));
function renderWithRouter(ui: React.ReactElement) {
return render(<MemoryRouter>{ui}</MemoryRouter>);
}
describe("ApplicationsList component", () => {
it("renders loading state initially", async () => {
vi.mocked(ApiProxy.request).mockImplementation(
() => new Promise(() => {}) // never resolves — keeps loading
);
renderWithRouter(<ApplicationsList />);
expect(screen.getByTestId("applications-loading")).toHaveTextContent(
"Loading ArgoCD applications"
);
});
it("renders error state when API fails", async () => {
vi.mocked(ApiProxy.request).mockRejectedValue(
new Error("connection refused")
);
renderWithRouter(<ApplicationsList />);
await vi.waitFor(() => {
expect(screen.getByTestId("applications-error")).toBeInTheDocument();
});
expect(screen.getByText(/connection refused/)).toBeInTheDocument();
});
it("renders table with applications when API succeeds", async () => {
const mockResponse = {
items: [
{
metadata: { name: "app-1", namespace: "argocd" },
spec: { project: "default", targetRevision: "v1.0.0" },
status: {
health: { status: "Healthy" },
sync: { status: "Synced" },
history: [
{ dexKey: "2024-06-01T10:00:00Z", id: 0, revision: "v1.0.0" },
],
},
},
{
metadata: { name: "app-2", namespace: "argocd" },
spec: { project: "default", targetRevision: "HEAD" },
status: {
health: { status: "Degraded" },
sync: { status: "OutOfSync" },
history: [],
},
},
],
};
vi.mocked(ApiProxy.request).mockResolvedValue(mockResponse);
renderWithRouter(<ApplicationsList />);
await vi.waitFor(() => {
expect(screen.getByTestId("simple-table")).toBeInTheDocument();
});
const rows = screen.getAllByRole("row");
expect(rows.length).toBe(3); // 1 header + 2 data rows
});
it("renders empty message when no applications", async () => {
vi.mocked(ApiProxy.request).mockResolvedValue({ items: [] });
renderWithRouter(<ApplicationsList />);
await vi.waitFor(() => {
expect(screen.getByTestId("simple-table-empty")).toBeInTheDocument();
});
expect(
screen.getByText("No ArgoCD applications found.")
).toBeInTheDocument();
});
});