Upstream jupyterhub/jupyterhub state of the touched files before bbc1469794

This commit is contained in:
upstream committed 2026-09-11 22:53:07 +02:00
commit 971e731056
2 files changed
+1540

No files matched your search

@@ -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 }) => (
<span className="server-dashboard-row-list-item">{text}</span>
);
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 <div data-testid="no-show"></div>;
}
const handleSearch = debounce(async (event) => {
setNameFilter(event.target.value);
}, 300);
const ServerButton = ({
server,
user,
action,
name,
variant,
extraClass,
}) => {
var [isDisabled, setIsDisabled] = useState(false);
return (
<Button
size="xs"
variant={variant}
className={extraClass}
disabled={isDisabled || server.pending}
onClick={() => {
setIsDisabled(true);
action(user.name, server.name)
.then((res) => {
if (res.status < 300) {
loadPageData();
} else {
setErrorAlert(`Failed to ${name.toLowerCase()}.`);
setIsDisabled(false);
}
return res;
})
.catch(() => {
setErrorAlert(`Failed to ${name.toLowerCase()}.`);
setIsDisabled(false);
});
}}
>
{name}
</Button>
);
};
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 (
<a
href={`${base_url}spawn/${user.name}${
server.name ? "/" + server.name : ""
}`}
>
<Button variant="light" size="xs">
Spawn Page
</Button>
</a>
);
};
SpawnPageButton.propTypes = {
server: Server,
user: User,
};
const AccessServerButton = ({ server }) => {
if (!server.ready) {
return null;
}
return (
<a href={server.url || ""}>
<Button variant="primary" size="xs">
Access Server
</Button>
</a>
);
};
AccessServerButton.propTypes = {
server: Server,
};
const EditUserButton = ({ user }) => {
return (
<Button
size="xs"
variant="light"
onClick={() =>
navigate("/edit-user", {
state: {
username: user.name,
has_admin: user.admin,
},
})
}
>
Edit User
</Button>
);
};
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 = (
<Fragment>
{value.sort().flatMap((v) => (
<RowListItem text={v} />
))}
</Fragment>
);
}
result[key] = value;
return result;
}, {});
return (
<ReactObjectTableViewer
className="table table-striped table-bordered"
style={{
padding: "3px 6px",
margin: "auto",
}}
keyStyle={{
padding: "4px",
}}
valueStyle={{
padding: "4px",
}}
data={sortedData}
/>
);
};
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 (
<Fragment key={`${userServerName}-row`}>
<tr
key={`${userServerName}-row`}
data-testid={`user-row-${userServerName}`}
className="user-row"
>
<td data-testid="user-row-name">
<span>
<Button
onClick={() =>
setCollapseStates({
...collapseStates,
[userServerName]: !open,
})
}
aria-controls={`${userServerName}-collapse`}
aria-expanded={open}
data-testid={`${userServerName}-collapse-button`}
variant={open ? "secondary" : "primary"}
size="sm"
>
<span className="fa fa-caret-down"></span>
</Button>{" "}
</span>
<span data-testid={`user-name-div-${userServerName}`}>
{user.name}
</span>
</td>
<td data-testid="user-row-server">
<p className="text-secondary">{server.name}</p>
</td>
<td data-testid="user-row-last-activity">
{server.last_activity ? timeSince(server.last_activity) : "Never"}
</td>
<td data-testid="user-row-server-start-time">
{server.started ? timeSince(server.started) : "Never"}
</td>
<td data-testid="user-row-profile-used">
{(server.user_options && server.user_options.profile) || ""}
</td>
<td data-testid="user-row-server-activity" className="actions">
<StartServerButton server={server} user={user} />
<StopServerButton server={server} user={user} />
<DeleteServerButton server={server} user={user} />
<AccessServerButton server={server} />
<SpawnPageButton server={server} user={user} />
<EditUserButton user={user} />
</td>
</tr>
<tr key={`${userServerName}-detail`}>
<td
colSpan={7}
style={{ padding: 0 }}
data-testid={`${userServerName}-td`}
>
<Collapse in={open} data-testid={`${userServerName}-collapse`}>
<CardGroup
id={`${userServerName}-card-group`}
style={{ width: "100%", margin: "0 auto", float: "none" }}
>
<Card style={{ width: "100%", padding: 3, margin: "0 auto" }}>
<Card.Title>User</Card.Title>
<ServerRowTable data={user} exclude={["server", "servers"]} />
</Card>
<Card style={{ width: "100%", padding: 3, margin: "0 auto" }}>
<Card.Title>Server</Card.Title>
<ServerRowTable data={server} />
</Card>
</CardGroup>
</Collapse>
</td>
</tr>
</Fragment>
);
};
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 (
<div className="container" data-testid="container">
<ErrorAlert errorAlert={errorAlert} setErrorAlert={setErrorAlert} />
<div className="server-dashboard-container">
<Stack direction="horizontal" className="pb-3 mb-2" gap={3}>
<Button
variant="primary"
className="start-all"
data-testid="start-all"
title="Start all default servers on the current page"
onClick={() => {
Promise.all(startAll(user_data.map((e) => e.name)))
.then((res) => {
let failedServers = res.filter((e) => !e.ok);
if (failedServers.length > 0) {
setErrorAlert(
`Failed to start ${failedServers.length} ${
failedServers.length > 1 ? "servers" : "server"
}. ${
failedServers.length > 1 ? "Are they " : "Is it "
} already running?`,
);
}
return res;
})
.then((res) => {
loadPageData();
return res;
})
.catch(() => setErrorAlert(`Failed to start servers.`));
}}
>
Start All
</Button>
<Button
variant="danger"
className="stop-all"
data-testid="stop-all"
title="Stop all servers including named servers on the current page"
onClick={() => {
Promise.all(stopAll(user_data.map((e) => e.name)))
.then((res) => {
// Array of arrays of servers for each user
let failedServers = res.flat().filter((e) => !e.ok);
if (failedServers.length > 0) {
setErrorAlert(
`Failed to stop ${failedServers.length} ${
failedServers.length > 1 ? "servers" : "server"
}. ${
failedServers.length > 1 ? "Are they " : "Is it "
} already stopped?`,
);
}
return res;
})
.then((res) => {
loadPageData();
return res;
})
.catch(() => setErrorAlert(`Failed to stop servers.`));
}}
>
Stop All
</Button>
<div className="ms-auto">
<Button
className=""
variant="danger"
id="shutdown-button"
onClick={shutdownHub}
>
Shutdown Hub
</Button>
</div>
</Stack>
<Stack direction="horizontal" className="pb-1 " gap={3}>
<div>
<FormControl
type="text"
name="user_search"
placeholder="Search Users"
aria-label="user-search"
defaultValue={name_filter}
onChange={handleSearch}
/>
</div>
<div className="me-auto">
<Form.Check
inline
title="check to only show running servers, otherwise show all"
>
<Form.Check.Input
type="checkbox"
name="active_servers"
id="active-servers-filter"
checked={state_filter == "active"}
onChange={(event) => {
setStateFilter(event.target.checked ? "active" : null);
}}
/>
<Form.Check.Label htmlFor="active-servers-filter">
Only Active Servers
</Form.Check.Label>
</Form.Check>
</div>
<Link to="/add-users">
<Button variant="light" className="add-users-button">
Add Users
</Button>
</Link>
<Link to="/groups">
<Button variant="light">Manage Groups</Button>
</Link>
</Stack>
<table className="table table-bordered table-hover">
<thead className="admin-table-head">
<tr>
<th id="user-header">
User{" "}
<SortHandler
currentSort={sort}
setSort={setSort}
sortKey="name"
testid="user-sort"
/>
</th>
<th id="server-header">Server</th>
<th id="last-activity-header">
Last Activity{" "}
<SortHandler
currentSort={sort}
setSort={setSort}
sortKey="last_activity"
testid="last-activity-sort"
/>
</th>
<th id="server-start-time-header">Server Start Time</th>
<th id="profile-used-header">Profile</th>
<th id="actions-header">Actions</th>
</tr>
</thead>
<tbody>{serverRows}</tbody>
</table>
<PaginationFooter
// use user_page for display, which is what's on the page
// setOffset immediately updates url state and _requests_ an update
// but takes finite time before user_page is updated
offset={user_page.offset}
limit={limit}
visible={user_data.length}
total={total}
next={() => setOffset(user_page.offset + limit)}
prev={() =>
setOffset(limit > user_page.offset ? 0 : user_page.offset - limit)
}
handleLimit={handleLimit}
/>
<br></br>
</div>
</div>
);
};
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 (
<div
className="sort-icon"
data-testid={testid}
onClick={() => {
if (!currentlySorted) {
setSort(sortKey);
} else if (descending) {
setSort(sortKey);
} else {
setSort("-" + sortKey);
}
}}
>
{!currentlySorted ? (
<FaSort />
) : descending ? (
<FaSortDown />
) : (
<FaSortUp />
)}
</div>
);
};
SortHandler.propTypes = {
currentSort: PropTypes.string,
setSort: PropTypes.func,
sortKey: PropTypes.string,
testid: PropTypes.string,
};
export default ServerDashboard;
@@ -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 }) => <a href={to}>{children}</a>,
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 (
<Provider store={createStore(mockReducers, mockAppState())}>
<HashRouter>
<Routes>
<Route path="/" element={withProps(props)(ServerDashboard)()} />
</Routes>
</HashRouter>
</Provider>
);
};
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");
});