diff --git a/src/vertex/app/changelog.md b/src/vertex/app/changelog.md index 4154de352f..54e37c4f23 100644 --- a/src/vertex/app/changelog.md +++ b/src/vertex/app/changelog.md @@ -2,6 +2,58 @@ > **Note**: This changelog consolidates all recent improvements, features, and fixes to the AirQo Vertex frontend. +## Version 2.0.8 +**Released:** June 28, 2026 + +### Comprehensive Unit Testing & CI Integration + +Introduced a robust unit testing infrastructure using Vitest and React Testing Library, alongside comprehensive CI/CD pipeline integration. This release focuses on improving code quality, ensuring robust component rendering, and validating API proxy behavior through extensive boundary testing. + +
+Test Infrastructure & CI Integration (3) + +- **Vitest & React Testing Library**: Replaced legacy test runners with a fast, modern Vitest setup configured to work seamlessly with Next.js App Router and absolute imports. +- **CI/CD Pipeline**: Added dedicated GitHub Actions workflows for automated type checking (`tsc --noEmit`) and unit testing (`vitest run --coverage`) on pull requests. +- **Testing Guidelines**: Introduced `TESTING.md` internal documentation to standardize component rendering, user-event interactions, and mocking strategies across the team. + +
+ +
+UI & Shared Component Testing (3) + +- **Core UI Components**: Added comprehensive tests for 30+ shadcn/radix UI components (Dialogs, Accords, Tooltips, Buttons, Inputs, Forms, etc.) verifying accessibility and interaction states without snapshot testing. +- **Shared Abstractions**: Extracted and tested shared wrappers including `ReusableTable`, `ReusableSelectInput`, `ReusableButton`, and `ReusableFileUpload`, ensuring robust prop forwarding and DOM nesting compliance. +- **Warning Resolutions**: Proactively fixed React DOM nesting warnings (e.g., `` cannot appear as a descendant of ``) and accessibility label warnings across components during test implementation. + +
+ +
+Service & API Boundary Testing (2) + +- **Mock Factories**: Created `apiResponseFactory.ts` and NextAuth session mocks to simulate full HTTP environments locally without hitting live staging endpoints or needing `axios-mock-adapter`. +- **Proxy Validation**: Validated both the Next.js App Router API handlers (`proxyClient`) and client-side Axios instances (`secureApiProxyClient`), verifying `JWT` vs `API_TOKEN` injection, error normalization, and custom event dispatching (`vertex-network-degraded`, `auth-token-expired`). + +
+ +
+Files Modified & Added (40+) + +- `package.json` [MODIFIED] +- `vitest.config.ts` [NEW] +- `.github/workflows/test.yml` [NEW] +- `src/vertex/app/_docs/internal/TESTING.md` [NEW] +- `src/vertex/components/ui/*.test.tsx` (30+ files) [NEW] +- `src/vertex/components/shared/**/*.test.tsx` [NEW] +- `src/vertex/core/services/*.test.ts` [NEW] +- `src/vertex/core/utils/*.test.ts` [NEW] +- `src/vertex/test/factories/apiResponseFactory.ts` [NEW] +- `src/vertex/test/mocks/nextAuth.ts` [NEW] +- `src/vertex/vitest.setup.ts` [NEW] + +
+ +--- + ## Version 2.0.7 **Released:** June 17, 2026 diff --git a/src/vertex/components/shared/button/ReusableButton.test.tsx b/src/vertex/components/shared/button/ReusableButton.test.tsx new file mode 100644 index 0000000000..a29a4b26f3 --- /dev/null +++ b/src/vertex/components/shared/button/ReusableButton.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import ReusableButton from "./ReusableButton"; + +describe("ReusableButton", () => { + it("renders as a button by default", () => { + render(Click me); + expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument(); + }); + + it("renders as an anchor when path is provided", () => { + render(Go Home); + expect(screen.getByRole("link", { name: "Go Home" })).toBeInTheDocument(); + }); + + it("handles loading state", () => { + render(Submit); + const button = screen.getByRole("button", { name: "Submit" }); + expect(button).toHaveAttribute("aria-busy", "true"); + expect(button).toBeDisabled(); + }); + + it("renders tooltip span when disabled with permission", () => { + render(Action); + const actionText = screen.getByText("Action"); + expect(actionText).toBeInTheDocument(); + }); +}); diff --git a/src/vertex/components/shared/card/CardWrapper.test.tsx b/src/vertex/components/shared/card/CardWrapper.test.tsx new file mode 100644 index 0000000000..e9507e3a31 --- /dev/null +++ b/src/vertex/components/shared/card/CardWrapper.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import Card from "./CardWrapper"; + +describe("CardWrapper", () => { + it("renders with basic content", () => { + render(Card Content); + expect(screen.getByText("Card Content")).toBeInTheDocument(); + }); + + it("renders with headers and footers", () => { + render( + Header} footer={
Footer
}> + Content +
+ ); + expect(screen.getByTestId("header")).toBeInTheDocument(); + expect(screen.getByTestId("footer")).toBeInTheDocument(); + }); + + it("handles clicks", async () => { + const handleClick = vi.fn(); + render(Content); + await userEvent.click(screen.getByTestId("clickable-card")); + expect(handleClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/vertex/components/shared/fileupload/ReusableFileUpload.test.tsx b/src/vertex/components/shared/fileupload/ReusableFileUpload.test.tsx new file mode 100644 index 0000000000..9b54d69065 --- /dev/null +++ b/src/vertex/components/shared/fileupload/ReusableFileUpload.test.tsx @@ -0,0 +1,27 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import ReusableFileUpload from "./ReusableFileUpload"; + +describe("ReusableFileUpload", () => { + it("renders with placeholder and label", () => { + render(); + expect(screen.getByLabelText("Upload File")).toBeInTheDocument(); + expect(screen.getByText("Upload file")).toBeInTheDocument(); // default placeholder + }); + + it("handles file selection", () => { + const handleChange = vi.fn(); + render(); + + const file = new File(["hello"], "hello.png", { type: "image/png" }); + const input = screen.getByLabelText("Upload"); + + fireEvent.change(input, { target: { files: [file] } }); + expect(handleChange).toHaveBeenCalledWith(file); + }); + + it("shows error state", () => { + render(); + expect(screen.getByText("File too large")).toBeInTheDocument(); + }); +}); diff --git a/src/vertex/components/shared/inputfield/ReusableInputField.test.tsx b/src/vertex/components/shared/inputfield/ReusableInputField.test.tsx new file mode 100644 index 0000000000..9a5eca5fb2 --- /dev/null +++ b/src/vertex/components/shared/inputfield/ReusableInputField.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import ReusableInputField from "./ReusableInputField"; + +// Mock toast to avoid missing canvas/window functions in jsdom sometimes or to just isolate +vi.mock("../toast/ReusableToast", () => ({ + default: vi.fn(), +})); + +// Mock clipboard +Object.assign(navigator, { + clipboard: { + writeText: vi.fn(), + }, +}); + +describe("ReusableInputField", () => { + it("renders basic input", () => { + render(); + expect(screen.getByLabelText("Email")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Enter email")).toBeInTheDocument(); + }); + + it("shows error message", () => { + render(); + expect(screen.getByText("Invalid email")).toBeInTheDocument(); + }); + + it("toggles password visibility", async () => { + render(); + const input = screen.getByLabelText("Password"); + expect(input).toHaveAttribute("type", "password"); + + const toggleBtn = screen.getByRole("button", { name: /show password/i }); + await userEvent.click(toggleBtn); + expect(input).toHaveAttribute("type", "text"); + + await userEvent.click(screen.getByRole("button", { name: /hide password/i })); + expect(input).toHaveAttribute("type", "password"); + }); + + it("calls custom action", async () => { + const handleAction = vi.fn(); + const MockIcon = () =>
; + + render( + + ); + + const actionBtn = screen.getByRole("button", { name: "Do search" }); + await userEvent.click(actionBtn); + expect(handleAction).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/vertex/components/shared/select/ReusableSelectInput.test.tsx b/src/vertex/components/shared/select/ReusableSelectInput.test.tsx new file mode 100644 index 0000000000..28a877149e --- /dev/null +++ b/src/vertex/components/shared/select/ReusableSelectInput.test.tsx @@ -0,0 +1,60 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; +import ReusableSelectInput from "./ReusableSelectInput"; + +window.HTMLElement.prototype.scrollIntoView = vi.fn(); + +describe("ReusableSelectInput", () => { + it("renders placeholder correctly", () => { + render( + + + + ); + + const button = screen.getByRole("button"); + expect(button).toBeInTheDocument(); + expect(screen.getByText("Pick fruit")).toBeInTheDocument(); + }); + + it("shows options and handles selection", async () => { + const handleChange = vi.fn(); + render( + + + + + ); + + const button = screen.getByRole("button"); + await userEvent.click(button); + + expect(screen.getByRole("listbox")).toBeInTheDocument(); + const bananaOption = screen.getByText("Banana"); + + await userEvent.click(bananaOption); + + expect(handleChange).toHaveBeenCalledWith( + expect.objectContaining({ + target: expect.objectContaining({ value: "banana" }) + }) + ); + }); + + it("handles keyboard navigation", async () => { + render( + + + + + ); + + const button = screen.getByRole("button"); + button.focus(); + + // Open with arrow down + fireEvent.keyDown(button, { key: "ArrowDown" }); + expect(screen.getByRole("listbox")).toBeInTheDocument(); + }); +}); diff --git a/src/vertex/components/shared/table/ReusableTable.test.tsx b/src/vertex/components/shared/table/ReusableTable.test.tsx new file mode 100644 index 0000000000..d0689573e5 --- /dev/null +++ b/src/vertex/components/shared/table/ReusableTable.test.tsx @@ -0,0 +1,35 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import ReusableTable from "./ReusableTable"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ replace: vi.fn() }), + usePathname: () => "/test-path", + useSearchParams: () => new URLSearchParams(), +})); + +vi.mock("@/context/banner-context", () => ({ + useBanner: () => ({ showBanner: vi.fn(), hideBanner: vi.fn() }), +})); + +describe("ReusableTable", () => { + it("renders with empty data", () => { + render(); + expect(screen.getByText("Test Table")).toBeInTheDocument(); + expect(screen.getByText("No data available")).toBeInTheDocument(); + }); + + it("renders data correctly", () => { + const columns = [ + { key: "name", render: (val: unknown) => String(val) }, + ]; + const data = [ + { id: 1, name: "Alice" }, + { id: 2, name: "Bob" } + ]; + + render(); + expect(screen.getByText("Alice")).toBeInTheDocument(); + expect(screen.getByText("Bob")).toBeInTheDocument(); + }); +}); diff --git a/src/vertex/components/shared/table/ReusableTable.tsx b/src/vertex/components/shared/table/ReusableTable.tsx index caa9e8835c..2b6c25c752 100644 --- a/src/vertex/components/shared/table/ReusableTable.tsx +++ b/src/vertex/components/shared/table/ReusableTable.tsx @@ -1472,7 +1472,7 @@ const ReusableTable = ({ )} {/* Add CSS to hide scrollbar on thead */} -