From 992ca6641f1de0f7a44a0526dee1df3e81baa515 Mon Sep 17 00:00:00 2001 From: Marcelo Lotif Date: Wed, 7 Aug 2024 13:06:32 -0400 Subject: [PATCH] Job details page, part 1 (#71) Adding the basic information for the Job details page, and a button to access it on the list jobs page. Additionally: Added an endpoint to get the job details given its ID. Renamed the endpoint to get status to be under job/status/{status} so the GET method under /job/{id} returns the job by id. Moved some front end definitions to their own file so they can be better shared between pages. Progress component and error coming on follow up PRs. --- florist/api/routes/server/job.py | 31 +- florist/app/assets/css/florist.css | 8 + florist/app/jobs/definitions.tsx | 26 ++ florist/app/jobs/details/page.tsx | 288 ++++++++++++++++++ florist/app/jobs/hooks.tsx | 8 +- florist/app/jobs/page.tsx | 62 ++-- .../tests/unit/api/routes/server/test_job.py | 39 ++- .../tests/unit/app/jobs/details/page.test.tsx | 200 ++++++++++++ florist/tests/unit/app/jobs/page.test.tsx | 19 +- package.json | 1 + yarn.lock | 5 + 11 files changed, 652 insertions(+), 35 deletions(-) create mode 100644 florist/app/jobs/definitions.tsx create mode 100644 florist/app/jobs/details/page.tsx create mode 100644 florist/tests/unit/app/jobs/details/page.test.tsx diff --git a/florist/api/routes/server/job.py b/florist/api/routes/server/job.py index ab336b81..be9dcdad 100644 --- a/florist/api/routes/server/job.py +++ b/florist/api/routes/server/job.py @@ -1,6 +1,6 @@ """FastAPI routes for the job.""" -from typing import List +from typing import List, Union from fastapi import APIRouter, Body, Request, status from fastapi.responses import JSONResponse @@ -11,6 +11,29 @@ router = APIRouter() +@router.get( + path="/{job_id}", + response_description="Retrieves a job by ID", + status_code=status.HTTP_200_OK, + response_model=Job, +) +async def get_job(job_id: str, request: Request) -> Union[Job, JSONResponse]: + """ + Retrieve a training job by its ID. + + :param request: (fastapi.Request) the FastAPI request object. + :param job_id: (str) The ID of the job to be retrieved. + + :return: (Union[Job, JSONResponse]) The job with the given ID, or a 400 JSONResponse if it hasn't been found. + """ + job = await Job.find_by_id(job_id, request.app.database) + + if job is None: + return JSONResponse(content={"error": f"Job with ID {job_id} does not exist."}, status_code=400) + + return job + + @router.post( path="", response_description="Create a new job", @@ -36,7 +59,11 @@ async def new_job(request: Request, job: Job = Body(...)) -> Job: # noqa: B008 return job_in_db -@router.get(path="/{status}", response_description="List jobs with the specified status", response_model=List[Job]) +@router.get( + path="/status/{status}", + response_description="List jobs with the specified status", + response_model=List[Job], +) async def list_jobs_with_status(status: JobStatus, request: Request) -> List[Job]: """ List jobs with specified status. diff --git a/florist/app/assets/css/florist.css b/florist/app/assets/css/florist.css index 8fea8042..b294ffca 100644 --- a/florist/app/assets/css/florist.css +++ b/florist/app/assets/css/florist.css @@ -53,3 +53,11 @@ .save-btn { width: 100px; } + +.status-pill { + display: inline-flex; + align-items: center; + padding: 5px 10px; + border-radius: 5px; + color: white; +} diff --git a/florist/app/jobs/definitions.tsx b/florist/app/jobs/definitions.tsx new file mode 100644 index 00000000..5fd5de50 --- /dev/null +++ b/florist/app/jobs/definitions.tsx @@ -0,0 +1,26 @@ +// Must be in same order as array returned from useGetJobsByJobStatus +export const validStatuses = { + NOT_STARTED: "Not Started", + IN_PROGRESS: "In Progress", + FINISHED_SUCCESSFULLY: "Finished Successfully", + FINISHED_WITH_ERROR: "Finished with Error", +}; + +export interface JobData { + _id: string; + status: string; + model: string; + server_address: string; + server_info: string; + redis_host: string; + redis_port: string; + clients_info: Array; +} + +export interface ClientInfo { + client: string; + service_address: string; + data_path: string; + redis_host: string; + redis_port: string; +} diff --git a/florist/app/jobs/details/page.tsx b/florist/app/jobs/details/page.tsx new file mode 100644 index 00000000..f632143e --- /dev/null +++ b/florist/app/jobs/details/page.tsx @@ -0,0 +1,288 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import Image from "next/image"; + +import { ReactElement } from "react/React"; + +import { useGetJob } from "../hooks"; +import { validStatuses, ClientInfo } from "../definitions"; +import loading_gif from "../../assets/img/loading.gif"; + +export default function JobDetails(): ReactElement { + return ( +
+ + +
+ ); +} + +export function JobDetailsHeader(): ReactElement { + return ( +
+

Job Details

+
+ ); +} + +export function JobDetailsBody(): ReactElement { + const searchParams = useSearchParams(); + const jobId = searchParams.get("id"); + + const { data: job, error, isLoading } = useGetJob(jobId); + + if (isLoading) { + return ( +
+ Loading +
+ ); + } + + if (!job || error) { + if (error) { + console.error(error); + } + return ( +
+
+ Error retrieving job. +
+
+ ); + } + + return ( +
+
+
+ Job ID: +
+
+ {job._id} +
+
+
+
+ Status: +
+
+ +
+
+
+
+ Model: +
+
+ {job.model} +
+
+
+
+ Server Address: +
+
+ {job.server_address} +
+
+
+
+ Redis Host: +
+
+ {job.redis_host} +
+
+
+
+ Redis Port: +
+
+ {job.redis_port} +
+
+ + + + +
+ ); +} + +export function JobDetailsStatus({ status }: { status: string }): ReactElement { + let pillClasses = "status-pill text-sm "; + let iconName; + let statusDescription; + switch (String(validStatuses[status])) { + case validStatuses.NOT_STARTED: + pillClasses += "alert-info"; + iconName = "radio_button_checked"; + statusDescription = validStatuses[status]; + break; + case validStatuses.IN_PROGRESS: + pillClasses += "alert-warning"; + iconName = "sync"; + statusDescription = validStatuses[status]; + break; + case validStatuses.FINISHED_SUCCESSFULLY: + pillClasses += "alert-success"; + iconName = "check_circle"; + statusDescription = validStatuses[status]; + break; + case validStatuses.FINISHED_WITH_ERROR: + pillClasses += "alert-danger"; + iconName = "error"; + statusDescription = validStatuses[status]; + break; + default: + pillClasses += "alert-secondary"; + iconName = ""; + statusDescription = status; + break; + } + return ( +
+ + {iconName} + +   + {statusDescription} +
+ ); +} + +export function JobDetailsTable({ Component, title, data }): ReactElement { + return ( +
+
+
+
+
+
{title}
+
+
+ +
+
+ +
+
+
+
+
+ ); +} + +export function JobDetailsServerConfigTable({ data }: { data: string }): ReactElement { + const emptyResponse = ( +
+ Empty. +
+ ); + + if (!data) { + return emptyResponse; + } + + const serverConfigJson = JSON.parse(data); + + if (typeof serverConfigJson != "object" || Array.isArray(serverConfigJson)) { + return ( +
+ Error parsing server configuration. +
+ ); + } + + const serverConfigNames = Object.keys(serverConfigJson); + + if (serverConfigNames.length === 0) { + return emptyResponse; + } + + return ( + + + + + + + + + {serverConfigNames.map((serverConfigName, i) => ( + + + + + ))} + +
NameValue
+
+ {serverConfigName} +
+
+
+ + {serverConfigJson[serverConfigName]} + +
+
+ ); +} + +export function JobDetailsClientsInfoTable({ data }: { data: Array }): ReactElement { + return ( + + + + + + + + + + + + {data.map((clientInfo, i) => ( + + + + + + + + ))} + +
ClientAddressData PathRedis HostRedis Port
+
+ {clientInfo.client} +
+
+
+ {clientInfo.service_address} +
+
+
+ {clientInfo.data_path} +
+
+
+ {clientInfo.redis_host} +
+
+
+ {clientInfo.redis_port} +
+
+ ); +} diff --git a/florist/app/jobs/hooks.tsx b/florist/app/jobs/hooks.tsx index c3e19663..15bd5dec 100644 --- a/florist/app/jobs/hooks.tsx +++ b/florist/app/jobs/hooks.tsx @@ -4,13 +4,17 @@ import useSWR, { mutate } from "swr"; import { fetcher } from "../client_imports"; export function useGetJobsByJobStatus(status: string) { - const endpoint = "/api/server/job/".concat(status); + const endpoint = `/api/server/job/status/${status}`; const { data, error, isLoading } = useSWR(endpoint, fetcher, { refresh_interval: 1000, }); return { data, error, isLoading }; } +export function useGetJob(jobId: string) { + return useSWR(`/api/server/job/${jobId}`, fetcher); +} + export function useGetModels() { return useSWR("/api/server/models", fetcher); } @@ -49,5 +53,5 @@ export const usePost = () => { }; export function refreshJobsByJobStatus(statuses: Array) { - statuses.forEach((status: string) => mutate(`/api/server/job/${status}`)); + statuses.forEach((status: string) => mutate(`/api/server/job/status/${status}`)); } diff --git a/florist/app/jobs/page.tsx b/florist/app/jobs/page.tsx index 97239aaf..215967b2 100644 --- a/florist/app/jobs/page.tsx +++ b/florist/app/jobs/page.tsx @@ -4,39 +4,13 @@ import { useEffect } from "react"; import { ReactElement } from "react/React"; import { refreshJobsByJobStatus, useGetJobsByJobStatus, usePost } from "./hooks"; +import { validStatuses, JobData, ClientInfo } from "./definitions"; import Link from "next/link"; import Image from "next/image"; import loading_gif from "../assets/img/loading.gif"; -// Must be in same order as array returned from useGetJobsByJobStatus -export const validStatuses = { - NOT_STARTED: "Not Started", - IN_PROGRESS: "In Progress", - FINISHED_SUCCESSFULLY: "Finished Successfully", - FINISHED_WITH_ERROR: "Finished with Error", -}; - -interface JobData { - _id: string; - status: string; - model: string; - server_address: string; - server_info: string; - redis_host: string; - redis_port: string; - clients_info: Array; -} - -interface ClientInfo { - client: string; - service_address: string; - data_path: string; - redis_host: string; - redis_port: string; -} - interface StatusProp { status: string; } @@ -138,6 +112,32 @@ export function StartJobButton({ rowId, jobId }: { rowId: number; jobId: string ); } +export function JobDetailsButton({ + rowId, + jobId, + status, +}: { + rowId: number; + jobId: string; + status: string; +}): ReactElement { + return ( +
+ + settings + +
+ ); +} + export function Status({ status, data }: { status: StatusProp; data: Object }): ReactElement { return (
@@ -174,7 +174,8 @@ export function StatusTable({ data, status }: { data: Array; status: St Client Service Addresses - + + @@ -247,7 +248,10 @@ export function TableRow({
- {validStatuses[status] == "Not Started" ? : null} + + + + {validStatuses[status] === "Not Started" ? : null} ); } diff --git a/florist/tests/unit/api/routes/server/test_job.py b/florist/tests/unit/api/routes/server/test_job.py index 3e448b56..d4d7d0ef 100644 --- a/florist/tests/unit/api/routes/server/test_job.py +++ b/florist/tests/unit/api/routes/server/test_job.py @@ -3,9 +3,45 @@ from unittest.mock import patch, Mock, AsyncMock from fastapi.responses import JSONResponse -from florist.api.routes.server.job import change_job_status +from florist.api.routes.server.job import change_job_status, get_job from florist.api.db.entities import JobStatus + +@patch("florist.api.db.entities.Job.find_by_id") +async def test_get_job_success(mock_find_by_id: Mock) -> None: + mock_job = Mock() + mock_find_by_id.return_value = mock_job + + mock_request = Mock() + mock_request.app.database = Mock() + + test_id = "test_id" + + response = await get_job(test_id, mock_request) + + mock_find_by_id.assert_called_once_with(test_id, mock_request.app.database) + + assert response == mock_job + + +@patch("florist.api.db.entities.Job.find_by_id") +async def test_get_job_fail_none_job(mock_find_by_id: Mock) -> None: + mock_find_by_id.return_value = None + + mock_request = Mock() + mock_request.app.database = Mock() + + test_id = "test_id" + + response = await get_job(test_id, mock_request) + + mock_find_by_id.assert_called_once_with(test_id, mock_request.app.database) + + assert isinstance(response, JSONResponse) + assert response.status_code == 400 + assert json.loads(response.body.decode("utf-8")) == {"error": f"Job with ID {test_id} does not exist."} + + @patch("florist.api.db.entities.Job.find_by_id") async def test_change_job_status_success(mock_find_by_id: Mock) -> None: mock_job = Mock() @@ -28,6 +64,7 @@ async def test_change_job_status_success(mock_find_by_id: Mock) -> None: assert response.status_code == 200 assert json.loads(response.body.decode("utf-8")) == {"status": "success"} + @patch("florist.api.db.entities.Job.find_by_id") async def test_change_job_status_failure_in_find_by_id(mock_find_by_id: Mock) -> None: mock_find_by_id.return_value = None diff --git a/florist/tests/unit/app/jobs/details/page.test.tsx b/florist/tests/unit/app/jobs/details/page.test.tsx new file mode 100644 index 00000000..cd55068f --- /dev/null +++ b/florist/tests/unit/app/jobs/details/page.test.tsx @@ -0,0 +1,200 @@ +import "@testing-library/jest-dom"; +import { render, cleanup } from "@testing-library/react"; +import { describe, it, expect, afterEach } from "@jest/globals"; + +import { useGetJob } from "../../../../../app/jobs/hooks"; +import { validStatuses, JobData } from "../../../../../app/jobs/definitions"; +import JobDetails from "../../../../../app/jobs/details/page"; + +const testJobId = "test-job-id"; + +jest.mock("../../../../../app/jobs/hooks"); +jest.mock("next/navigation", () => ({ + ...require("next-router-mock"), + useSearchParams: () => new Map([["id", testJobId]]), +})); + +afterEach(() => { + jest.clearAllMocks(); + cleanup(); +}); + +function setupGetJobMock(data: JobData, isLoading: boolean = false, error = null) { + useGetJob.mockImplementation((jobId: string) => { + return { data, error, isLoading }; + }); +} + +function makeTestJob(): JobData { + return { + _id: testJobId, + status: "NOT_STARTED", + model: "test-model", + server_address: "test-server-address", + redis_host: "test-redis-host", + redis_port: "test-redis-port", + server_config: JSON.stringify({ + test_attribute_1: "test-value-1", + test_attribute_2: "test-value-2", + }), + clients_info: [ + { + client: "test-client-1", + service_address: "test-service-address-1", + data_path: "test-data-path-1", + redis_host: "test-redis-host-1", + redis_port: "test-redis-port-1", + }, + { + client: "test-client-2", + service_address: "test-service-address-2", + data_path: "test-data-path-2", + redis_host: "test-redis-host-2", + redis_port: "test-redis-port-2", + }, + ], + }; +} + +describe("Job Details Page", () => { + it("Renders correctly", () => { + const testJob = makeTestJob(); + setupGetJobMock(testJob); + const { container } = render(); + + expect(useGetJob).toBeCalledWith(testJobId); + + expect(container.querySelector("h1")).toHaveTextContent("Job Details"); + expect(container.querySelector("#job-details-id")).toHaveTextContent(testJob._id); + expect(container.querySelector("#job-details-status")).toHaveTextContent(validStatuses[testJob.status]); + expect(container.querySelector("#job-details-status")).toHaveClass("status-pill"); + expect(container.querySelector("#job-details-server-address")).toHaveTextContent(testJob.server_address); + expect(container.querySelector("#job-details-redis-host")).toHaveTextContent(testJob.redis_host); + const testServerConfig = JSON.parse(testJob.server_config); + const serverConfigNames = Object.keys(testServerConfig); + for (let i = 0; i < serverConfigNames.length; i++) { + expect(container.querySelector(`#job-details-server-config-name-${i}`)).toHaveTextContent( + serverConfigNames[i], + ); + expect(container.querySelector(`#job-details-server-config-value-${i}`)).toHaveTextContent( + testServerConfig[serverConfigNames[i]], + ); + } + for (let i = 0; i < testJob.clients_info.length; i++) { + expect(container.querySelector(`#job-details-client-config-client-${i}`)).toHaveTextContent( + testJob.clients_info[i].client, + ); + expect(container.querySelector(`#job-details-client-config-service-address-${i}`)).toHaveTextContent( + testJob.clients_info[i].service_address, + ); + expect(container.querySelector(`#job-details-client-config-data-path-${i}`)).toHaveTextContent( + testJob.clients_info[i].data_path, + ); + expect(container.querySelector(`#job-details-client-config-redis-host-${i}`)).toHaveTextContent( + testJob.clients_info[i].redis_host, + ); + expect(container.querySelector(`#job-details-client-config-redis-port-${i}`)).toHaveTextContent( + testJob.clients_info[i].redis_port, + ); + } + }); + describe("Status", () => { + it("Renders NOT_STARTED correctly", () => { + const testJob = makeTestJob(); + testJob.status = "NOT_STARTED"; + setupGetJobMock(testJob); + const { container } = render(); + const statusComponent = container.querySelector("#job-details-status"); + expect(statusComponent).toHaveTextContent(validStatuses[testJob.status]); + expect(statusComponent).toHaveClass("alert-info"); + const iconComponent = statusComponent.querySelector("#job-details-status-icon"); + expect(iconComponent).toHaveTextContent("radio_button_checked"); + }); + it("Renders IN_PROGRESS correctly", () => { + const testJob = makeTestJob(); + testJob.status = "IN_PROGRESS"; + setupGetJobMock(testJob); + const { container } = render(); + const statusComponent = container.querySelector("#job-details-status"); + expect(statusComponent).toHaveTextContent(validStatuses[testJob.status]); + expect(statusComponent).toHaveClass("alert-warning"); + const iconComponent = statusComponent.querySelector("#job-details-status-icon"); + expect(iconComponent).toHaveTextContent("sync"); + }); + it("Renders FINISHED_SUCCESSFULLY correctly", () => { + const testJob = makeTestJob(); + testJob.status = "FINISHED_SUCCESSFULLY"; + setupGetJobMock(testJob); + const { container } = render(); + const statusComponent = container.querySelector("#job-details-status"); + expect(statusComponent).toHaveTextContent(validStatuses[testJob.status]); + expect(statusComponent).toHaveClass("alert-success"); + const iconComponent = statusComponent.querySelector("#job-details-status-icon"); + expect(iconComponent).toHaveTextContent("check_circle"); + }); + it("Renders FINISHED_WITH_ERROR correctly", () => { + const testJob = makeTestJob(); + testJob.status = "FINISHED_WITH_ERROR"; + setupGetJobMock(testJob); + const { container } = render(); + const statusComponent = container.querySelector("#job-details-status"); + expect(statusComponent).toHaveTextContent(validStatuses[testJob.status]); + expect(statusComponent).toHaveClass("alert-danger"); + const iconComponent = statusComponent.querySelector("#job-details-status-icon"); + expect(iconComponent).toHaveTextContent("error"); + }); + it("Renders unknown status correctly", () => { + const testJob = makeTestJob(); + testJob.status = "some inexistent status"; + setupGetJobMock(testJob); + const { container } = render(); + const statusComponent = container.querySelector("#job-details-status"); + expect(statusComponent).toHaveTextContent(testJob.status); + expect(statusComponent).toHaveClass("alert-secondary"); + const iconComponent = statusComponent.querySelector("#job-details-status-icon"); + expect(iconComponent).toHaveTextContent(""); + }); + }); + describe("Server config", () => { + it("Does not break when it's null", () => { + const testJob = makeTestJob(); + testJob.server_config = null; + setupGetJobMock(testJob); + const { container } = render(); + const serverConfigComponent = container.querySelector("#job-details-server-config-empty"); + expect(serverConfigComponent).toHaveTextContent("Empty."); + }); + it("Does not break when it's an empty dictionary", () => { + const testJob = makeTestJob(); + testJob.server_config = JSON.stringify({}); + setupGetJobMock(testJob); + const { container } = render(); + const serverConfigComponent = container.querySelector("#job-details-server-config-empty"); + expect(serverConfigComponent).toHaveTextContent("Empty."); + }); + it("Does not break when it's not a dictionary", () => { + const testJob = makeTestJob(); + testJob.server_config = JSON.stringify(["bad server config"]); + setupGetJobMock(testJob); + const { container } = render(); + const serverConfigComponent = container.querySelector("#job-details-server-config-error"); + expect(serverConfigComponent).toHaveTextContent("Error parsing server configuration."); + }); + }); + it("Renders loading gif correctly", () => { + setupGetJobMock(null, true); + const { container } = render(); + const loadingComponent = container.querySelector("img#job-details-loading"); + expect(loadingComponent.getAttribute("alt")).toBe("Loading"); + }); + it("Renders error message when job is null", () => { + setupGetJobMock(null); + const { container } = render(); + expect(container.querySelector("#job-details-error")).toHaveTextContent("Error retrieving job."); + }); + it("Renders error message when there is an error", () => { + setupGetJobMock({}, false, "error"); + const { container } = render(); + expect(container.querySelector("#job-details-error")).toHaveTextContent("Error retrieving job."); + }); +}); diff --git a/florist/tests/unit/app/jobs/page.test.tsx b/florist/tests/unit/app/jobs/page.test.tsx index 0171ba46..a3e88226 100644 --- a/florist/tests/unit/app/jobs/page.test.tsx +++ b/florist/tests/unit/app/jobs/page.test.tsx @@ -2,7 +2,8 @@ import "@testing-library/jest-dom"; import { getByText, render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; import { describe, it, expect, afterEach } from "@jest/globals"; -import Page, { validStatuses } from "../../../../app/jobs/page"; +import Page from "../../../../app/jobs/page"; +import { validStatuses } from "../../../../app/jobs/definitions"; import { useGetJobsByJobStatus, usePost } from "../../../../app/jobs/hooks"; import { after } from "node:test"; @@ -15,6 +16,7 @@ afterEach(() => { function mockJobData(model: string, serverAddress: string, clientServicesAddresses: Array) { const data = { + _id: "test-id", model: model, server_address: serverAddress, clients_info: clientServicesAddresses.map((clientServicesAddress) => ({ @@ -149,6 +151,7 @@ describe("List Jobs Page", () => { expect(getByText(element, "No jobs to display.")).toBeInTheDocument(); } }); + it("Renders Loading GIF only when all isLoading", () => { setupMock(["NOT_STARTED", "IN_PROGRESS", "FINISHED_SUCCESSFULLY", "FINISHED_WITH_ERROR"], [], false, true); const { getByTestId } = render(); @@ -163,6 +166,20 @@ describe("List Jobs Page", () => { expect(element).not.toBeInTheDocument(); }); + it("Details button is present on all statuses", () => { + const data = mockJobData("MNIST", "localhost:8080", ["localhost:7080"]); + const validStatusesKeys = Object.keys(validStatuses); + + setupMock(validStatusesKeys, [data], false, false); + const { queryByTestId } = render(); + + for (let status of validStatusesKeys) { + const element = queryByTestId(`job-details-button-${status}-0`); + expect(element.getAttribute("alt")).toBe("Details"); + expect(element.getAttribute("href")).toBe(`jobs/details?id=${data._id}`); + } + }); + it("Start training button present in NOT_STARTED jobs", () => { const data = [mockJobData("MNIST", "localhost:8080", ["localhost:7080"])]; const validStatusesKeys = Object.keys(validStatuses); diff --git a/package.json b/package.json index 87cf3448..bc93e864 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@testing-library/react": "^15.0.4", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", + "next-router-mock": "^0.9.13", "ts-node": "^10.9.2" } } diff --git a/yarn.lock b/yarn.lock index f2c800f4..2e326d34 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3629,6 +3629,11 @@ natural-compare@^1.4.0: resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +next-router-mock@^0.9.13: + version "0.9.13" + resolved "https://registry.yarnpkg.com/next-router-mock/-/next-router-mock-0.9.13.tgz#bdee2011ea6c09e490121c354ef917f339767f72" + integrity sha512-906n2RRaE6Y28PfYJbaz5XZeJ6Tw8Xz1S6E31GGwZ0sXB6/XjldD1/2azn1ZmBmRk5PQRkzjg+n+RHZe5xQzWA== + next@14.1.1: version "14.1.1" resolved "https://registry.yarnpkg.com/next/-/next-14.1.1.tgz#92bd603996c050422a738e90362dff758459a171"