Upstream jupyterhub/jupyterhub state of the touched files before bbc1469794
This commit is contained in:
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");
|
||||
});
|
||||
Reference in new issue
Block a user