← Back to team overview

sts-sponsors team mailing list archive

[Merge] ~petermakowski/maas-site-manager:add-test-coverage-ci into maas-site-manager:main

 

Peter Makowski has proposed merging ~petermakowski/maas-site-manager:add-test-coverage-ci into maas-site-manager:main.

Commit message:
test: enforce minumum test coverage
- set Document title for each page

Requested reviews:
  MAAS Lander (maas-lander): unittests
  MAAS Committers (maas-committers)

For more details, see:
https://code.launchpad.net/~petermakowski/maas-site-manager/+git/site-manager/+merge/440316

enforce minimum 80% test coverage
-- 
Your team MAAS Committers is requested to review the proposed merge of ~petermakowski/maas-site-manager:add-test-coverage-ci into maas-site-manager:main.
diff --git a/Makefile b/Makefile
index 8976954..e73f7f8 100644
--- a/Makefile
+++ b/Makefile
@@ -78,7 +78,7 @@ ci-frontend-lint:
 .PHONY: ci-frontend-lint
 
 ci-frontend-test:
-	env -C frontend VITEST_JUNIT_SUITE_NAME='maas-site-manager frontend tests' yarnpkg run test --silent --reporter=junit --reporter=default --outputFile.junit=../junit-frontend.xml run
+	env -C frontend VITEST_JUNIT_SUITE_NAME='maas-site-manager frontend tests' yarnpkg run coverage --silent --reporter=junit --reporter=default --outputFile.junit=../junit-frontend.xml run
 .PHONY: ci-test
 
 ci-e2e-test:
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
new file mode 100644
index 0000000..7c50b95
--- /dev/null
+++ b/frontend/src/App.test.tsx
@@ -0,0 +1,14 @@
+/* eslint-disable testing-library/no-container */
+import App from "./App";
+
+import { waitFor, render, within } from "@/test-utils";
+
+it("renders vanilla layout components correctly", async () => {
+  const { container } = render(<App />);
+  await waitFor(() => expect(container.querySelector(".l-application")).toBeInTheDocument());
+  const application = container.querySelector(".l-application") as HTMLElement;
+  expect(application.querySelector(".l-navigation-bar")).toBeInTheDocument();
+  expect(application.querySelector(".l-main")).toBeInTheDocument();
+  expect(application.querySelector(".l-navigation-bar")).toBeInTheDocument();
+  expect(within(container).getByRole("heading", { name: /MAAS Site Manager/i })).toBeInTheDocument();
+});
diff --git a/frontend/src/components/DocumentTitle/DocumentTitle.tsx b/frontend/src/components/DocumentTitle/DocumentTitle.tsx
new file mode 100644
index 0000000..f36302b
--- /dev/null
+++ b/frontend/src/components/DocumentTitle/DocumentTitle.tsx
@@ -0,0 +1,12 @@
+import { useEffect, Children } from "react";
+
+const DocumentTitle = ({ children }: { children: string | string[] }) => {
+  useEffect(() => {
+    const title = Children.toArray(children).join("");
+    document.title = title;
+  }, [children]);
+
+  return null;
+};
+
+export default DocumentTitle;
diff --git a/frontend/src/components/DocumentTitle/index.ts b/frontend/src/components/DocumentTitle/index.ts
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/frontend/src/components/DocumentTitle/index.ts
diff --git a/frontend/src/components/MainLayout/MainLayout.tsx b/frontend/src/components/MainLayout/MainLayout.tsx
index f3b4f34..b2ce99a 100644
--- a/frontend/src/components/MainLayout/MainLayout.tsx
+++ b/frontend/src/components/MainLayout/MainLayout.tsx
@@ -5,10 +5,13 @@ import { Col, Row, usePrevious } from "@canonical/react-components";
 import classNames from "classnames";
 import { Outlet, useLocation } from "react-router-dom";
 
+import DocumentTitle from "@/components/DocumentTitle/DocumentTitle";
 import Navigation from "@/components/Navigation";
 import RemoveRegions from "@/components/RemoveRegions";
 import { useAppContext } from "@/context";
 import TokensCreate from "@/pages/tokens/create";
+import type { RoutePath } from "@/routes";
+import { routesConfig } from "@/routes";
 
 export const sidebarLabels: Record<"removeRegions" | "createToken", string> = {
   removeRegions: "Remove regions",
@@ -29,6 +32,11 @@ const Aside = ({ children, isOpen, ...props }: PropsWithChildren<{ isOpen: boole
   </aside>
 );
 
+const getPageTitle = (pathname: RoutePath) => {
+  const title = Object.values(routesConfig).find(({ path }) => path === pathname)?.title;
+  return title ? `${title} | MAAS Site Manager` : "MAAS Site Manager";
+};
+
 const MainLayout: React.FC = () => {
   const { sidebar, setSidebar } = useAppContext();
   const { pathname } = useLocation();
@@ -42,24 +50,27 @@ const MainLayout: React.FC = () => {
   }, [pathname, previousPathname, setSidebar]);
 
   return (
-    <div className="l-application">
-      <Navigation />
-      <main className="l-main is-maas-site-manager">
-        <div className="row">
-          <div className="col-12">
-            <h1 className="u-hide">MAAS Site Manager</h1>
-            <Outlet />
+    <>
+      <DocumentTitle>{getPageTitle(pathname as RoutePath)}</DocumentTitle>
+      <div className="l-application">
+        <Navigation />
+        <main className="l-main is-maas-site-manager">
+          <div className="row">
+            <div className="col-12">
+              <h1 className="u-hide">MAAS Site Manager</h1>
+              <Outlet />
+            </div>
           </div>
-        </div>
-      </main>
-      <Aside aria-label={sidebar ? sidebarLabels[sidebar] : undefined} isOpen={!!sidebar}>
-        {!!sidebar && sidebar === "createToken" ? (
-          <TokensCreate />
-        ) : !!sidebar && sidebar === "removeRegions" ? (
-          <RemoveRegions />
-        ) : null}
-      </Aside>
-    </div>
+        </main>
+        <Aside aria-label={sidebar ? sidebarLabels[sidebar] : undefined} isOpen={!!sidebar}>
+          {!!sidebar && sidebar === "createToken" ? (
+            <TokensCreate />
+          ) : !!sidebar && sidebar === "removeRegions" ? (
+            <RemoveRegions />
+          ) : null}
+        </Aside>
+      </div>
+    </>
   );
 };
 
diff --git a/frontend/src/components/base/TablePagination/TablePagination.test.tsx b/frontend/src/components/base/TablePagination/TablePagination.test.tsx
index e1c4a3d..c7c3344 100644
--- a/frontend/src/components/base/TablePagination/TablePagination.test.tsx
+++ b/frontend/src/components/base/TablePagination/TablePagination.test.tsx
@@ -1,6 +1,6 @@
 import { vi } from "vitest";
 
-import TablePagination from "./TablePagination";
+import TablePagination from "./index";
 
 import { render, screen, userEvent } from "@/test-utils";
 
diff --git a/frontend/src/main.test.tsx b/frontend/src/main.test.tsx
new file mode 100644
index 0000000..66973bf
--- /dev/null
+++ b/frontend/src/main.test.tsx
@@ -0,0 +1,14 @@
+/* eslint-disable testing-library/no-node-access */
+import { waitFor } from "@/test-utils";
+
+beforeAll(() => {
+  const rootElement = document.createElement("div");
+  rootElement.setAttribute("id", "root");
+  document.body.appendChild(rootElement);
+  import("./main");
+});
+
+it("renders the app in the root element", async () => {
+  const container = document.getElementById("root") as HTMLElement;
+  await waitFor(() => expect(container.querySelector(".l-application")).toBeInTheDocument());
+});
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 4d3cd70..785c02c 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -5,6 +5,7 @@ import * as ReactDOM from "react-dom/client";
 import App from "./App";
 import { isDev } from "./constants";
 
+/* c8 ignore next 4 */
 if (isDev) {
   const { worker } = await import("./mocks/browser");
   await worker.start();
diff --git a/frontend/src/mocks/resolvers.ts b/frontend/src/mocks/resolvers.ts
index 6410af8..f35c0ad 100644
--- a/frontend/src/mocks/resolvers.ts
+++ b/frontend/src/mocks/resolvers.ts
@@ -3,8 +3,8 @@ import type { RestRequest, restContext, ResponseResolver } from "msw";
 
 import { siteFactory, tokenFactory, enrollmentRequestFactory } from "./factories";
 
+import type { GetSitesQueryParams, PostTokensData } from "@/api/handlers";
 import urls from "@/api/urls";
-import type { GetSitesQueryParams, PostTokensData } from "api/handlers";
 
 export const sitesList = siteFactory.buildList(155);
 export const tokensList = tokenFactory.buildList(100);
@@ -96,3 +96,4 @@ export const postTokens = rest.post(urls.tokens, createMockTokensResolver());
 export const getTokens = rest.get(urls.tokens, createMockGetTokensResolver());
 export const getEnrollmentRequests = rest.get(urls.enrollmentRequests, createMockGetEnrollmentRequestsResolver());
 export const patchEnrollmentRequests = rest.patch(urls.enrollmentRequests, createMockPostEnrollmentRequestsResolver());
+export const allResolvers = [getSites, postTokens, getTokens, getEnrollmentRequests, patchEnrollmentRequests];
diff --git a/frontend/src/routes.test.tsx b/frontend/src/routes.test.tsx
index 1f00735..d8dd90b 100644
--- a/frontend/src/routes.test.tsx
+++ b/frontend/src/routes.test.tsx
@@ -1,26 +1,23 @@
 import { createMemoryRouter, RouterProvider } from "react-router-dom";
 
-import urls from "./api/urls";
-import { siteFactory } from "./mocks/factories";
-import { createMockSitesResolver } from "./mocks/resolvers";
-import { createMockGetServer } from "./mocks/server";
-import routes from "./routes";
-import { render, waitFor } from "./test-utils";
+import { allResolvers } from "./mocks/resolvers";
+import routes, { routesConfig } from "./routes";
 
-const sites = siteFactory.buildList(1);
-const mockServer = createMockGetServer(urls.sites, createMockSitesResolver(sites));
+import { render, waitFor, setupServer } from "@/test-utils";
 
-beforeAll(() => {
-  mockServer.listen();
-});
-afterEach(() => {
-  mockServer.resetHandlers();
-});
-afterAll(() => {
-  mockServer.close();
-});
+const mockServer = setupServer(...allResolvers);
 
 describe("router", () => {
+  beforeAll(() => {
+    mockServer.listen();
+  });
+  afterEach(() => {
+    mockServer.resetHandlers();
+  });
+  afterAll(() => {
+    mockServer.close();
+  });
+
   it("redirects to the default route", async () => {
     const router = createMemoryRouter(routes);
     render(<RouterProvider router={router} />);
@@ -28,4 +25,12 @@ describe("router", () => {
     expect(router.state.location.pathname).toEqual("/");
     await waitFor(() => expect(router.state.location.pathname).toEqual("/sites"));
   });
+
+  Object.values(routesConfig).forEach(({ title, path }) => {
+    it(`displays correct document title for ${title} page`, async () => {
+      const router = createMemoryRouter(routes, { initialEntries: [path], initialIndex: 0 });
+      render(<RouterProvider router={router} />);
+      expect(document.title).toBe(`${title} | MAAS Site Manager`);
+    });
+  });
 });
diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx
index b9ebdc8..cabc2b2 100644
--- a/frontend/src/routes.tsx
+++ b/frontend/src/routes.tsx
@@ -5,6 +5,24 @@ import Requests from "@/pages/requests";
 import SitesList from "@/pages/sites";
 import Tokens from "@/pages/tokens/tokens";
 
+export const routesConfig = {
+  sites: {
+    path: "/sites",
+    title: "Regions",
+  },
+  requests: {
+    path: "/requests",
+    title: "Requests",
+  },
+  tokens: {
+    path: "/tokens",
+    title: "Tokens",
+  },
+} as const;
+
+type RoutesConfig = typeof routesConfig;
+export type RoutePath = RoutesConfig[keyof RoutesConfig]["path"];
+
 export const routes = createRoutesFromElements(
   <Route element={<MainLayout />} path="/">
     {/*
diff --git a/frontend/src/test-utils.tsx b/frontend/src/test-utils.tsx
index f949896..e34ff1c 100644
--- a/frontend/src/test-utils.tsx
+++ b/frontend/src/test-utils.tsx
@@ -52,3 +52,4 @@ export { renderHook } from "@testing-library/react-hooks";
 export { default as userEvent } from "@testing-library/user-event";
 export { renderWithMemoryRouter };
 export { Providers };
+export { setupServer } from "msw/node";
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
index 1d31aae..eed1bf5 100644
--- a/frontend/vitest.config.ts
+++ b/frontend/vitest.config.ts
@@ -1,4 +1,4 @@
-import { configDefaults, defineConfig } from "vitest/config";
+import { configDefaults, coverageConfigDefaults, defineConfig } from "vitest/config";
 import * as path from "path";
 
 export default defineConfig({
@@ -10,6 +10,18 @@ export default defineConfig({
     environment: "jsdom",
     setupFiles: ["./setupTests.ts"],
     exclude: [...configDefaults.exclude, "**/tests/**"],
+    coverage: {
+      // exclude index files as they're only used to export other files
+      // exclude pages as they're covered by playwright tests
+      exclude: [...coverageConfigDefaults.exclude, "**/index.ts", "src/mocks/**/*", "src/pages/**/*", "**/types.ts"],
+      include: ["src/**/*.{ts,tsx}"],
+      reporter: ["text", "json", "html"],
+      all: true,
+      lines: 80,
+      functions: 80,
+      branches: 80,
+      statements: 80,
+    },
     clearMocks: true,
   },
 });