From 971e73105644a2ab771c1a481a473cfe3c124b22 Mon Sep 17 00:00:00 2001 From: upstream Date: Fri, 11 Sep 2026 22:53:07 +0200 Subject: [PATCH] Upstream jupyterhub/jupyterhub state of the touched files before bbc1469794 --- .../ServerDashboard/ServerDashboard.jsx | 705 +++++++++++++++ .../ServerDashboard/ServerDashboard.test.js | 835 ++++++++++++++++++ 2 files changed, 1540 insertions(+) create mode 100644 jsx/src/components/ServerDashboard/ServerDashboard.jsx create mode 100644 jsx/src/components/ServerDashboard/ServerDashboard.test.js diff --git a/jsx/src/components/ServerDashboard/ServerDashboard.jsx b/jsx/src/components/ServerDashboard/ServerDashboard.jsx new file mode 100644 index 0000000..a0a9421 --- /dev/null +++ b/jsx/src/components/ServerDashboard/ServerDashboard.jsx @@ -0,0 +1,705 @@ +import React, { useEffect, useState, Fragment } from "react"; +import { useSelector, useDispatch } from "react-redux"; +import { debounce } from "lodash"; +import PropTypes from "prop-types"; +import ErrorAlert from "../../util/error"; +import { User, Server } from "../../util/jhapiUtil"; + +import { + Button, + Form, + FormControl, + Card, + CardGroup, + Collapse, + Stack, +} from "react-bootstrap"; +import ReactObjectTableViewer from "../ReactObjectTableViewer/ReactObjectTableViewer"; + +import { Link, useSearchParams, useNavigate } from "react-router"; +import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa"; + +import "./server-dashboard.css"; +import { timeSince } from "../../util/timeSince"; +import { usePaginationParams } from "../../util/paginationParams"; +import PaginationFooter from "../PaginationFooter/PaginationFooter"; + +const RowListItem = ({ text }) => ( + {text} +); +RowListItem.propTypes = { + text: PropTypes.string, +}; + +const ServerDashboard = (props) => { + const base_url = window.base_url || "/"; + const [searchParams, setSearchParams] = useSearchParams(); + + const [errorAlert, setErrorAlert] = useState(null); + const [collapseStates, setCollapseStates] = useState({}); + + let user_data = useSelector((state) => state.user_data); + const user_page = useSelector((state) => state.user_page); + + const { offset, setOffset, handleLimit, limit } = usePaginationParams(); + + const name_filter = searchParams.get("name_filter") || ""; + const sort = searchParams.get("sort") || "id"; + const state_filter = searchParams.get("state") || ""; + + const total = user_page ? user_page.total : undefined; + + const dispatch = useDispatch(); + const navigate = useNavigate(); + + var { + updateUsers, + shutdownHub, + startServer, + stopServer, + deleteServer, + startAll, + stopAll, + } = props; + + const dispatchPageUpdate = (data, page) => { + // persist user data, triggers rerender + dispatch({ + type: "USER_PAGE", + value: { + data: data, + page: page, + }, + }); + }; + + const setNameFilter = (new_name_filter) => { + // persist ?name_filter parameter + // store in url param, clear when value is empty + setSearchParams((params) => { + // clear offset when name filter changes + if (new_name_filter !== name_filter) { + params.delete("offset"); + } + if (new_name_filter) { + params.set("name_filter", new_name_filter); + } else { + params.delete("name_filter"); + } + return params; + }); + }; + + const setSort = (sort) => { + // persist ?sort parameter + // store in url param, clear when value is default ('id') + setSearchParams((params) => { + if (sort === "id") { + params.delete("id"); + } else { + params.set("sort", sort); + } + return params; + }); + }; + + const setStateFilter = (new_state_filter) => { + // persist ?state filter + // store in url param, clear when value is default ('') + setSearchParams((params) => { + // clear offset when filter changes + if (new_state_filter !== state_filter) { + params.delete("offset"); + } + if (!new_state_filter) { + params.delete("state"); + } else { + params.set("state", new_state_filter); + } + return params; + }); + }; + + // single callback to reload the page + // uses current state + const loadPageData = () => { + const abortHandle = { cancelled: false }; + (async () => { + try { + const data = await updateUsers({ + offset, + limit, + name_filter, + sort, + state: state_filter, + }); + // cancelled (e.g. param changed while waiting for response) + if (abortHandle.cancelled) return; + if ( + data._pagination.offset && + data._pagination.total <= data._pagination.offset + ) { + // reset offset if we're out of bounds, + // then load again + setOffset(0); + return; + } + // actually update page data + dispatchPageUpdate(data.items, data._pagination); + } catch (e) { + console.error("Failed to update user list.", e); + setErrorAlert("Failed to update user list."); + } + })(); + // returns cancellation callback + return () => { + // cancel stale load + abortHandle.cancelled = true; + }; + }; + + useEffect(() => { + return loadPageData(); + }, [limit, name_filter, offset, sort, state_filter]); + + if (!user_data || !user_page) { + return
; + } + + const handleSearch = debounce(async (event) => { + setNameFilter(event.target.value); + }, 300); + + const ServerButton = ({ + server, + user, + action, + name, + variant, + extraClass, + }) => { + var [isDisabled, setIsDisabled] = useState(false); + return ( + + ); + }; + + ServerButton.propTypes = { + server: Server, + user: User, + action: PropTypes.string, + name: PropTypes.string, + variant: PropTypes.string, + extraClass: PropTypes.string, + }; + + const StopServerButton = ({ server, user }) => { + if (!server.ready) { + return null; + } + return ServerButton({ + server, + user, + action: stopServer, + name: "Stop Server", + variant: "danger", + extraClass: "stop-button", + }); + }; + + StopServerButton.propTypes = { + server: Server, + user: User, + }; + + const DeleteServerButton = ({ server, user }) => { + if (!server.name) { + // It's not possible to delete unnamed servers + return null; + } + if (server.ready || server.pending) { + return null; + } + return ServerButton({ + server, + user, + action: deleteServer, + name: "Delete Server", + variant: "danger", + extraClass: "stop-button", + }); + }; + + DeleteServerButton.propTypes = { + server: Server, + user: User, + }; + + const StartServerButton = ({ server, user }) => { + if (server.ready) { + return null; + } + return ServerButton({ + server, + user, + action: startServer, + name: server.pending ? "Server is pending" : "Start Server", + variant: "success", + extraClass: "start-button", + }); + }; + + StartServerButton.propTypes = { + server: Server, + user: User, + }; + + const SpawnPageButton = ({ server, user }) => { + if (server.ready) { + return null; + } + return ( + + + + ); + }; + + SpawnPageButton.propTypes = { + server: Server, + user: User, + }; + + const AccessServerButton = ({ server }) => { + if (!server.ready) { + return null; + } + return ( + + + + ); + }; + AccessServerButton.propTypes = { + server: Server, + }; + + const EditUserButton = ({ user }) => { + return ( + + ); + }; + + EditUserButton.propTypes = { + user: User, + }; + + const ServerRowTable = ({ data, exclude }) => { + const sortedData = Object.keys(data) + .sort() + .reduce(function (result, key) { + if (exclude && exclude.includes(key)) { + return result; + } + let value = data[key]; + switch (key) { + case "last_activity": + case "created": + case "started": + // format timestamps + value = value ? timeSince(value) : value; + break; + } + if (Array.isArray(value)) { + value = ( + + {value.sort().flatMap((v) => ( + + ))} + + ); + } + result[key] = value; + return result; + }, {}); + return ( + + ); + }; + + ServerRowTable.propTypes = { + data: Server, + exclude: PropTypes.arrayOf(PropTypes.string), + }; + + const ServerRow = ({ user, server }) => { + const serverNameDash = server.name ? `-${server.name}` : ""; + const userServerName = user.name + serverNameDash; + const open = collapseStates[userServerName] || false; + return ( + + + + + {" "} + + + {user.name} + + + + +

{server.name}

+ + + {server.last_activity ? timeSince(server.last_activity) : "Never"} + + + {server.started ? timeSince(server.started) : "Never"} + + + {(server.user_options && server.user_options.profile) || ""} + + + + + + + + + + + + + + + + User + + + + Server + + + + + + +
+ ); + }; + + ServerRow.propTypes = { + user: User, + server: Server, + }; + + const serverRows = user_data.flatMap((user) => { + const userServers = Object.values({ + // eslint-disable-next-line react/prop-types + "": user.server || {}, + // eslint-disable-next-line react/prop-types + ...(user.servers || {}), + }); + return userServers.map((server) => ServerRow({ user, server })); + }); + + return ( +
+ +
+ + + + +
+ +
+
+ + +
+ +
+
+ + { + setStateFilter(event.target.checked ? "active" : null); + }} + /> + + Only Active Servers + + +
+ + + + + + +
+ + + + + + + + + + + + + {serverRows} +
+ User{" "} + + Server + Last Activity{" "} + + Server Start TimeProfileActions
+ setOffset(user_page.offset + limit)} + prev={() => + setOffset(limit > user_page.offset ? 0 : user_page.offset - limit) + } + handleLimit={handleLimit} + /> +

+
+
+ ); +}; + +ServerDashboard.propTypes = { + user_data: PropTypes.arrayOf(User), + updateUsers: PropTypes.func, + shutdownHub: PropTypes.func, + startServer: PropTypes.func, + stopServer: PropTypes.func, + deleteServer: PropTypes.func, + startAll: PropTypes.func, + stopAll: PropTypes.func, + dispatch: PropTypes.func, +}; + +const SortHandler = (props) => { + const { currentSort, setSort, sortKey, testid } = props; + + const currentlySorted = currentSort && currentSort.endsWith(sortKey); + const descending = currentSort && currentSort.startsWith("-"); + return ( +
{ + if (!currentlySorted) { + setSort(sortKey); + } else if (descending) { + setSort(sortKey); + } else { + setSort("-" + sortKey); + } + }} + > + {!currentlySorted ? ( + + ) : descending ? ( + + ) : ( + + )} +
+ ); +}; + +SortHandler.propTypes = { + currentSort: PropTypes.string, + setSort: PropTypes.func, + sortKey: PropTypes.string, + testid: PropTypes.string, +}; + +export default ServerDashboard; diff --git a/jsx/src/components/ServerDashboard/ServerDashboard.test.js b/jsx/src/components/ServerDashboard/ServerDashboard.test.js new file mode 100644 index 0000000..02355f9 --- /dev/null +++ b/jsx/src/components/ServerDashboard/ServerDashboard.test.js @@ -0,0 +1,835 @@ +import React, { act } from "react"; +import { withProps } from "../../util/_recompose"; +import "@testing-library/jest-dom"; +import userEvent from "@testing-library/user-event"; +import { + render, + screen, + fireEvent, + getByText, + getAllByRole, +} from "@testing-library/react"; +import { Provider, useSelector } from "react-redux"; +import { createStore } from "redux"; +// eslint-disable-next-line +import regeneratorRuntime from "regenerator-runtime"; + +import ServerDashboard from "./ServerDashboard"; +import { initialState, reducers } from "../../Store"; + +jest.mock("react-redux", () => ({ + ...jest.requireActual("react-redux"), + useSelector: jest.fn(), +})); + +// can't use requireActual now that react-router is ESM +// surely there's a better fix? +jest.mock("react-router", () => ({ + HashRouter: ({ children }) => children, + Routes: ({ children }) => children, + Route: ({ element }) => element, + Link: ({ to, children }) => {children}, + useSearchParams: jest.fn(), + useLocation: jest.fn(), + useNavigate: jest.fn(), + useParams: jest.fn(), +})); + +const { HashRouter, Routes, Route, useSearchParams } = require("react-router"); + +const serverDashboardJsx = (props) => { + // create mock ServerDashboard + // spies is a dict of properties to mock in + // any API calls that will fire during the test should be mocked + props = props || {}; + if (!props.updateUsers) { + props.updateUsers = mockUpdateUsers; + } + return ( + + + + + + + + ); +}; + +var mockAsync = (data) => + jest.fn().mockImplementation(() => Promise.resolve(data ? data : { k: "v" })); + +var mockAsyncRejection = () => + jest.fn().mockImplementation(() => Promise.reject()); + +const defaultUpdateUsersParams = { + offset: 0, + limit: 2, + name_filter: "", + sort: "id", + state: "", +}; + +var bar_servers = { + "": { + name: "", + last_activity: "2020-12-07T20:58:02.437408Z", + started: "2020-12-07T20:58:01.508266Z", + pending: null, + ready: false, + state: { pid: 12345 }, + url: "/user/bar/", + user_options: { profile: "datascience" }, + progress_url: "/hub/api/users/bar/progress", + }, + servername: { + name: "servername", + last_activity: "2020-12-07T20:58:02.437408Z", + started: "2020-12-07T20:58:01.508266Z", + pending: null, + ready: false, + state: { pid: 12345 }, + url: "/user/bar/servername", + user_options: { profile: "minimal" }, + progress_url: "/hub/api/users/bar/servername/progress", + }, +}; + +/* create new user models */ +const newUser = (name) => { + return { + kind: "user", + name: name, + admin: false, + groups: [], + server: `/user/${name}`, + created: "2020-12-07T18:46:27.112695Z", + last_activity: "2020-12-07T21:00:33.336354Z", + servers: {}, + }; +}; + +const allUsers = [ + { + kind: "user", + name: "foo", + admin: true, + groups: [], + server: "/user/foo/", + pending: null, + created: "2020-12-07T18:46:27.112695Z", + last_activity: "2020-12-07T21:00:33.336354Z", + servers: { + "": { + name: "", + last_activity: "2020-12-07T20:58:02.437408Z", + started: "2020-12-07T20:58:01.508266Z", + pending: null, + ready: true, + state: { pid: 28085 }, + url: "/user/foo/", + user_options: { profile: "gpu-enabled" }, + progress_url: "/hub/api/users/foo/server/progress", + }, + }, + }, + { + kind: "user", + name: "bar", + admin: false, + groups: [], + server: null, + pending: null, + created: "2020-12-07T18:46:27.115528Z", + last_activity: "2020-12-07T20:43:51.013613Z", + servers: bar_servers, + }, +]; + +for (var i = 2; i < 10; i++) { + allUsers.push(newUser(`test-${i}`)); +} + +var mockAppState = () => + Object.assign({}, initialState, { + user_data: allUsers.slice(0, 2), + user_page: { + offset: 0, + limit: 2, + total: 4, + next: { + offset: 2, + limit: 2, + url: "http://localhost:8000/hub/api/users?offset=2&limit=2", + }, + }, + }); + +var mockReducers = jest.fn((state, action) => { + if (action.type === "USER_PAGE" && !action.value.data) { + // no-op from mock, don't update state + return state; + } + state = reducers(state, action); + // mocked useSelector seems to cause a problem + // this should get the right state back? + // not sure + // useSelector.mockImplementation((callback) => callback(state); + return state; +}); + +let mockUpdateUsers = jest.fn(({ offset, limit, name_filter, state }) => { + /* mock updating users + + this has tom implement the server-side filtering, sorting, etc. + (at least whatever we want to test of it) + */ + let matchingUsers = allUsers; + if (state === "active") { + // only first user is active + matchingUsers = allUsers.slice(0, 1); + } + if (name_filter) { + matchingUsers = matchingUsers.filter((user) => + user.name.startsWith(name_filter), + ); + } + + const total = matchingUsers.length; + const items = matchingUsers.slice(offset, offset + limit); + + return Promise.resolve({ + items: items, + _pagination: { + offset: offset, + limit: limit, + total: total, + next: { + offset: offset + limit, + limit: limit, + }, + }, + }); +}); + +let searchParams = new URLSearchParams(); + +beforeEach(() => { + useSelector.mockImplementation((callback) => { + return callback(mockAppState()); + }); + searchParams = new URLSearchParams(); + searchParams.set("limit", "2"); + + useSearchParams.mockImplementation(() => [ + searchParams, + (callback) => { + searchParams = callback(searchParams); + }, + ]); +}); + +afterEach(() => { + useSearchParams.mockClear(); + useSelector.mockClear(); + mockReducers.mockClear(); + mockUpdateUsers.mockClear(); + jest.runAllTimers(); +}); + +test("Renders", async () => { + await act(async () => { + render(serverDashboardJsx()); + }); + + expect(screen.getByTestId("container")).toBeVisible(); +}); + +test("Renders users from props.user_data into table", async () => { + await act(async () => { + render(serverDashboardJsx()); + }); + + let foo = screen.getByTestId("user-name-div-foo"); + let bar = screen.getByTestId("user-name-div-bar"); + let bar_server = screen.getByTestId("user-name-div-bar-servername"); + + expect(foo).toBeVisible(); + expect(bar).toBeVisible(); + expect(bar_server).toBeVisible(); +}); + +test("Renders correctly the status of a single-user server", async () => { + await act(async () => { + render(serverDashboardJsx()); + }); + + let start_elems = screen.getAllByText("Start Server"); + expect(start_elems.length).toBe(Object.keys(bar_servers).length); + start_elems.forEach((start) => { + expect(start).toBeVisible(); + }); + + let stop = screen.getByText("Stop Server"); + expect(stop).toBeVisible(); +}); + +test("Renders spawn page link", async () => { + await act(async () => { + render(serverDashboardJsx()); + }); + + for (let server in bar_servers) { + let row = screen.getByTestId(`user-row-bar${server ? "-" + server : ""}`); + let link = getByText(row, "Spawn Page").closest("a"); + let url = new URL(link.href); + expect(url.pathname).toEqual("/spawn/bar" + (server ? "/" + server : "")); + } +}); + +test("Invokes the startServer event on button click", async () => { + let callbackSpy = mockAsync(); + + await act(async () => { + render(serverDashboardJsx({ startServer: callbackSpy })); + }); + + let start_elems = screen.getAllByText("Start Server"); + expect(start_elems.length).toBe(Object.keys(bar_servers).length); + + await act(async () => { + await fireEvent.click(start_elems[0]); + }); + + expect(callbackSpy).toHaveBeenCalled(); +}); + +test("Invokes the stopServer event on button click", async () => { + let callbackSpy = mockAsync(); + + await act(async () => { + render(serverDashboardJsx({ stopServer: callbackSpy })); + }); + + let stop = screen.getByText("Stop Server"); + + await act(async () => { + await fireEvent.click(stop); + }); + + expect(callbackSpy).toHaveBeenCalled(); +}); + +test("Invokes the shutdownHub event on button click", async () => { + let callbackSpy = mockAsync(); + + await act(async () => { + render(serverDashboardJsx({ shutdownHub: callbackSpy })); + }); + + let shutdown = screen.getByText("Shutdown Hub"); + + await act(async () => { + await fireEvent.click(shutdown); + }); + + expect(callbackSpy).toHaveBeenCalled(); +}); + +test("Sorts according to username", async () => { + let rerender; + const testId = "user-sort"; + await act(async () => { + rerender = render(serverDashboardJsx()).rerender; + }); + + expect(searchParams.get("sort")).toEqual(null); + let handler = screen.getByTestId(testId); + await fireEvent.click(handler); + expect(searchParams.get("sort")).toEqual("name"); + + await act(async () => { + rerender(serverDashboardJsx()); + handler = screen.getByTestId(testId); + }); + + await fireEvent.click(handler); + expect(searchParams.get("sort")).toEqual("-name"); + + await act(async () => { + rerender(serverDashboardJsx()); + handler = screen.getByTestId(testId); + }); + + await fireEvent.click(handler); + expect(searchParams.get("sort")).toEqual("name"); +}); + +test("Sorts according to last activity", async () => { + let rerender; + const testId = "last-activity-sort"; + await act(async () => { + rerender = render(serverDashboardJsx()).rerender; + }); + + expect(searchParams.get("sort")).toEqual(null); + let handler = screen.getByTestId(testId); + await fireEvent.click(handler); + expect(searchParams.get("sort")).toEqual("last_activity"); + + await act(async () => { + rerender(serverDashboardJsx()); + handler = screen.getByTestId(testId); + }); + + await fireEvent.click(handler); + expect(searchParams.get("sort")).toEqual("-last_activity"); + + await act(async () => { + rerender(serverDashboardJsx()); + handler = screen.getByTestId(testId); + }); + + await fireEvent.click(handler); + expect(searchParams.get("sort")).toEqual("last_activity"); +}); + +test("Filter according to server status (running/not running)", async () => { + let rerender; + await act(async () => { + rerender = render(serverDashboardJsx()).rerender; + }); + const label = "Only Active Servers"; + let handler = screen.getByLabelText(label); + expect(handler.checked).toEqual(false); + await fireEvent.click(handler); + + // FIXME: need to force a rerender to get updated checkbox + // I don't think this should be required + await act(async () => { + rerender(serverDashboardJsx()); + handler = screen.getByLabelText(label); + }); + expect(searchParams.get("state")).toEqual("active"); + expect(handler.checked).toEqual(true); + + await fireEvent.click(handler); + + await act(async () => { + rerender(serverDashboardJsx()); + handler = screen.getByLabelText(label); + }); + handler = screen.getByLabelText(label); + expect(handler.checked).toEqual(false); + expect(searchParams.get("state")).toEqual(null); +}); + +test("Shows server details with button click", async () => { + await act(async () => { + render(serverDashboardJsx()); + }); + let button = screen.getByTestId("foo-collapse-button"); + let collapse = screen.getByTestId("foo-collapse"); + let collapseBar = screen.getByTestId("bar-collapse"); + + // expect().toBeVisible does not work here with collapse. + expect(collapse).toHaveClass("collapse"); + expect(collapse).not.toHaveClass("show"); + expect(collapseBar).not.toHaveClass("show"); + await fireEvent.click(button); + await act(async () => { + jest.runAllTimers(); + }); + expect(collapse).toHaveClass("collapse show"); + expect(collapseBar).not.toHaveClass("show"); + await fireEvent.click(button); + await act(async () => { + jest.runAllTimers(); + }); + + expect(collapse).toHaveClass("collapse"); + expect(collapse).not.toHaveClass("show"); + expect(collapseBar).not.toHaveClass("show"); + + await fireEvent.click(button); + await act(async () => { + jest.runAllTimers(); + }); + + expect(collapse).toHaveClass("collapse show"); + expect(collapseBar).not.toHaveClass("show"); +}); + +test("Renders nothing if required data is not available", async () => { + useSelector.mockImplementation((callback) => { + return callback({}); + }); + + await act(async () => { + render(serverDashboardJsx()); + }); + + let noShow = screen.getByTestId("no-show"); + + expect(noShow).toBeVisible(); +}); + +test("Shows a UI error dialogue when start all servers fails", async () => { + await act(async () => { + render(serverDashboardJsx({ startAll: mockAsyncRejection })); + }); + + let startAll = screen.getByTestId("start-all"); + + await act(async () => { + await fireEvent.click(startAll); + }); + + let errorDialog = screen.getByText("Failed to start servers."); + + expect(errorDialog).toBeVisible(); +}); + +test("Shows a UI error dialogue when stop all servers fails", async () => { + await act(async () => { + render(serverDashboardJsx({ stopAll: mockAsyncRejection })); + }); + + let stopAll = screen.getByTestId("stop-all"); + + await act(async () => { + await fireEvent.click(stopAll); + }); + + let errorDialog = screen.getByText("Failed to stop servers."); + + expect(errorDialog).toBeVisible(); +}); + +test("Shows a UI error dialogue when start user server fails", async () => { + await act(async () => { + render(serverDashboardJsx({ startServer: mockAsyncRejection() })); + }); + + let start_elems = screen.getAllByText("Start Server"); + expect(start_elems.length).toBe(Object.keys(bar_servers).length); + + await act(async () => { + await fireEvent.click(start_elems[0]); + }); + + let errorDialog = screen.getByText("Failed to start server."); + + expect(errorDialog).toBeVisible(); +}); + +test("Shows a UI error dialogue when start user server returns an improper status code", async () => { + let rejectSpy = mockAsync({ status: 403 }); + await act(async () => { + render(serverDashboardJsx({ startServer: rejectSpy })); + }); + + let start_elems = screen.getAllByText("Start Server"); + expect(start_elems.length).toBe(Object.keys(bar_servers).length); + + await act(async () => { + await fireEvent.click(start_elems[0]); + }); + + let errorDialog = screen.getByText("Failed to start server."); + + expect(errorDialog).toBeVisible(); +}); + +test("Shows a UI error dialogue when stop user servers fails", async () => { + let rejectSpy = mockAsyncRejection(); + + await act(async () => { + render(serverDashboardJsx({ stopServer: rejectSpy })); + }); + + let stop = screen.getByText("Stop Server"); + + await act(async () => { + await fireEvent.click(stop); + }); + + let errorDialog = screen.getByText("Failed to stop server."); + + expect(errorDialog).toBeVisible(); +}); + +test("Shows a UI error dialogue when stop user server returns an improper status code", async () => { + let rejectSpy = mockAsync({ status: 403 }); + + await act(async () => { + render(serverDashboardJsx({ stopServer: rejectSpy })); + }); + + let stop = screen.getByText("Stop Server"); + + await act(async () => { + await fireEvent.click(stop); + }); + + let errorDialog = screen.getByText("Failed to stop server."); + + expect(errorDialog).toBeVisible(); +}); + +test("Search for user calls updateUsers with name filter", async () => { + await act(async () => { + searchParams.set("offset", "2"); + render(serverDashboardJsx()); + }); + + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + let search = screen.getByLabelText("user-search"); + + expect(mockUpdateUsers.mock.calls).toHaveLength(1); + + expect(searchParams.get("offset")).toEqual("2"); + await user.type(search, "a"); + expect(search.value).toEqual("a"); + await act(async () => { + jest.runAllTimers(); + }); + expect(searchParams.get("name_filter")).toEqual("a"); + expect(searchParams.get("offset")).toEqual(null); + // FIXME: useSelector mocks prevent updateUsers from being called + // expect(mockUpdateUsers.mock.calls).toHaveLength(2); + // expect(mockUpdateUsers).toHaveBeenCalledWith(0, 100, "a"); + await user.type(search, "b"); + expect(search.value).toEqual("ab"); + await act(async () => { + jest.runAllTimers(); + }); + expect(searchParams.get("name_filter")).toEqual("ab"); + // expect(mockUpdateUsers).toHaveBeenCalledWith(0, 100, "ab"); +}); + +test("Interacting with PaginationFooter requests page update", async () => { + await act(async () => { + render(serverDashboardJsx()); + }); + + expect(mockUpdateUsers).toHaveBeenCalledWith(defaultUpdateUsersParams); + + expect(searchParams.get("offset")).toEqual(null); + expect(searchParams.get("limit")).toEqual("2"); + + let next = screen.getByTestId("paginate-next"); + await act(async () => { + fireEvent.click(next); + jest.runAllTimers(); + }); + expect(searchParams.get("offset")).toEqual("2"); + // FIXME: useSelector mocks prevent updateUsers from being called + // expect(mockUpdateUsers).toHaveBeenCalledWith({ + // ...defaultUpdateUsersParams, + // offset: 2, + // }); +}); + +test("Server delete button exists for named servers", async () => { + await act(async () => { + render(serverDashboardJsx()); + }); + + for (let server in bar_servers) { + if (server === "") { + continue; + } + let row = screen.getByTestId(`user-row-bar-${server}`); + let delete_button = getByText(row, "Delete Server"); + expect(delete_button).toBeEnabled(); + } +}); + +test("Start server and confirm pending state", async () => { + let mockStartServer = jest.fn(() => { + return new Promise(async (resolve) => + setTimeout(() => { + resolve({ status: 200 }); + }, 100), + ); + }); + + await act(async () => { + render( + serverDashboardJsx({ + startServer: mockStartServer, + }), + ); + }); + + let actions = screen.getAllByTestId("user-row-server-activity")[1]; + let buttons = getAllByRole(actions, "button"); + + expect(buttons.length).toBe(3); + expect(buttons[0].textContent).toBe("Start Server"); + expect(buttons[1].textContent).toBe("Spawn Page"); + expect(buttons[2].textContent).toBe("Edit User"); + + await act(async () => { + await fireEvent.click(buttons[0]); + }); + expect(mockUpdateUsers.mock.calls).toHaveLength(1); + + expect(buttons.length).toBe(3); + expect(buttons[0].textContent).toBe("Start Server"); + expect(buttons[0]).toBeDisabled(); + expect(buttons[1].textContent).toBe("Spawn Page"); + expect(buttons[1]).toBeEnabled(); + + await act(async () => { + jest.runAllTimers(); + }); + expect(mockUpdateUsers.mock.calls).toHaveLength(2); +}); + +test("Renders Server Start Time column header", async () => { + await act(async () => { + render(serverDashboardJsx()); + }); + + let serverStartTimeHeader = screen.getByText("Server Start Time"); + expect(serverStartTimeHeader).toBeVisible(); + + // Server Start Time column should NOT have a sort button + let sortButton = screen.queryByTestId("server-start-time-sort"); + expect(sortButton).toBeNull(); +}); + +test("Renders Profile Used column header", async () => { + await act(async () => { + render(serverDashboardJsx()); + }); + + let profileHeader = screen.getByText("Profile"); + expect(profileHeader).toBeVisible(); +}); + +test("Displays server start time data correctly", async () => { + await act(async () => { + render(serverDashboardJsx()); + }); + + // Check that server start time cells are rendered + let startTimeCells = screen.getAllByTestId(/user-row-server-start-time/); + expect(startTimeCells.length).toBeGreaterThan(0); + + // All test servers have start times, so none should show "Never" + startTimeCells.forEach((cell) => { + expect(cell.textContent).not.toBe("Never"); + expect(cell.textContent).toBeTruthy(); + }); +}); + +test("Shows 'Default' when no profile is specified", async () => { + // Create a mock user with server that has no profile + const mockUserWithoutProfile = { + kind: "user", + name: "testuser", + admin: false, + groups: [], + server: null, + pending: null, + created: "2020-12-07T18:46:27.115528Z", + last_activity: "2020-12-07T20:43:51.013613Z", + servers: { + "": { + name: "", + last_activity: "2020-12-07T20:58:02.437408Z", + started: "2020-12-07T20:58:01.508266Z", + pending: null, + ready: false, + state: { pid: 12345 }, + url: "/user/testuser/", + user_options: {}, // No profile specified + progress_url: "/hub/api/users/testuser/progress", + }, + }, + }; + + useSelector.mockImplementation((callback) => { + return callback({ + user_data: [mockUserWithoutProfile], + user_page: { + offset: 0, + limit: 2, + total: 1, + }, + limit: 2, + }); + }); + + await act(async () => { + render(serverDashboardJsx()); + }); + + let profileCell = screen.getByTestId("user-row-profile-used"); + expect(profileCell.textContent).toBe(""); +}); + +test("Shows 'Never' for server start time when server never started", async () => { + // Create a mock user with server that was never started + const mockUserNeverStarted = { + kind: "user", + name: "testuser", + admin: false, + groups: [], + server: null, + pending: null, + created: "2020-12-07T18:46:27.115528Z", + last_activity: "2020-12-07T20:43:51.013613Z", + servers: { + "": { + name: "", + last_activity: null, + started: null, // Never started + pending: null, + ready: false, + state: {}, + url: "/user/testuser/", + user_options: { profile: "test" }, + progress_url: "/hub/api/users/testuser/progress", + }, + }, + }; + + useSelector.mockImplementation((callback) => { + return callback({ + user_data: [mockUserNeverStarted], + user_page: { + offset: 0, + limit: 2, + total: 1, + }, + limit: 2, + }); + }); + + await act(async () => { + render(serverDashboardJsx()); + }); + + let startTimeCell = screen.getByTestId("user-row-server-start-time"); + expect(startTimeCell.textContent).toBe("Never"); +}); + +test("Displays profile information correctly", async () => { + await act(async () => { + render(serverDashboardJsx()); + }); + + // Check that profile cells are rendered + let profileCells = screen.getAllByTestId(/user-row-profile-used/); + expect(profileCells.length).toBeGreaterThan(0); + + // Should find our test profiles in the rendered cells + let profileTexts = profileCells.map((cell) => cell.textContent); + expect(profileTexts).toContain("datascience"); + expect(profileTexts).toContain("minimal"); + expect(profileTexts).toContain("gpu-enabled"); +});