Rebase services-as-hub-ia onto mobile-responsive-parity

Combine both branches into a single coherent branch:
- Full mobile responsive parity (useIsMobile, MobileCardRow, SheetForm,
  .mobile-touch-target, mobile cards, SheetForm forms, 44px targets,
  dirty-state confirm, TablePagination, refetchIntervalInBackground).
- Full services-as-hub IA (data-driven nav, service-page tab skeleton,
  new service types, Authentik directory + messaging, named dashboards,
  legacy routes 404, Observability split, Jellyseerr absorbed).

Enhancement: service tabs now use mobile-parity primitives:
- MediaTab: MobileCardRow below md (title/size/HDR/library/year) +
  TablePagination; DataTable at md+ (desktop branch preserved).
- FilesTab: MobileCardRow below md (name/type/size/modified) +
  handleRowClick; DataTable at md+.
- ServicePage: SheetForm branch below md (open-on-mount, sticky header
  + save bar, cancel navigates back to /services, dirty-state guard).
- Dashboard: single-column + section anchors below md (from mobile-parity)
  + empty-state CTA (from services-hub).
- App.tsx: useIsMobile() replaces inline matchMedia (from mobile-parity)
  + data-driven useNavItems (from services-hub).
- Backup tables (BackupAlerts/Jobs/Runs) already have MobileCardRow from
  mobile-parity; JobsTab inherits mobile behavior through its sub-components.

Conflict resolutions:
- Backend: entirely from services-hub (mobile didn't touch it).
- Deleted pages (Media/FileBrowser/Actions/Users/UsersPage/Applications/
  ObservabilityPage/BackupsPage + hooks/useUsers + tests): kept deleted
  (services-hub deleted them; content moved into service tabs).
- New service-tabs/*: from services-hub, enhanced with mobile patterns.
- App.tsx: services-hub's data-driven nav + mobile-parity's useIsMobile.
- Dashboard.tsx: merged (services-hub CTA + mobile-parity sections/anchors).
- ServicePage.tsx: services-hub's tab skeleton + mobile-parity's SheetForm.
- Primitives (useIsMobile/mobile-card/sheet-form/etc.): from mobile-parity.

117 frontend tests pass (mobile-parity's 122 - 5 deleted page tests +
services-hub's new tab/dashboard tests); 271 backend tests pass; lint/
build green both sides.
This commit is contained in:
Developer
2026-06-26 20:55:00 +00:00
parent b583d5a365
commit 01527ae4f0
75 changed files with 5343 additions and 4926 deletions
@@ -1,125 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Actions } from "../Actions";
import type { SavedTask, ServiceInstance } from "../../types";
const saveTaskMutate = vi.fn().mockResolvedValue({
id: "t1",
name: "Restart svc",
task_type: "shell",
content: "",
enabled: true,
default_service_id: "",
notes: "",
});
const deleteTaskMutate = vi.fn();
const runTaskMutate = vi.fn().mockResolvedValue({});
let sshServices: ServiceInstance[] = [];
let tasks: SavedTask[] = [];
vi.mock("../../hooks/useSettings", () => ({
useTasks: () => ({ data: tasks }),
useSaveTask: () => ({ mutateAsync: saveTaskMutate, isPending: false }),
useDeleteTask: () => ({ mutate: deleteTaskMutate }),
useRunTask: () => ({ mutateAsync: runTaskMutate, isPending: false }),
useTaskRuns: () => ({ data: { items: [] } }),
}));
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({ data: sshServices }),
}));
function sshService(overrides: Partial<ServiceInstance> = {}): ServiceInstance {
return {
id: "s1",
service_type: "ssh_tasks",
name: "Box",
config: { host: "box", username: "u" },
secrets_set: {},
enabled: true,
created_at: 0,
updated_at: 0,
...overrides,
} as ServiceInstance;
}
function task(overrides: Partial<SavedTask> = {}): SavedTask {
return {
id: "t1",
name: "Restart svc",
task_type: "shell",
content: "systemctl restart foo",
enabled: true,
default_service_id: "",
notes: "",
created_at: 0,
updated_at: 0,
...overrides,
} as SavedTask;
}
beforeEach(() => {
saveTaskMutate.mockClear();
deleteTaskMutate.mockClear();
runTaskMutate.mockClear();
sshServices = [];
tasks = [];
});
describe("Actions", () => {
it("shows the empty state and creates a task via the editor dialog", async () => {
render(<Actions />);
expect(screen.getByText("No action selected")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Add action" }),
).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Add action" }));
// Editor dialog opened (Name field is unique to the editor).
expect(screen.getByLabelText("Name")).toBeInTheDocument();
// Controlled input parity: name + default shell type flow through.
await userEvent.type(screen.getByLabelText("Name"), "Restart svc");
await userEvent.click(screen.getByRole("button", { name: "Save action" }));
expect(saveTaskMutate).toHaveBeenCalledTimes(1);
const saved = saveTaskMutate.mock.calls[0][0];
expect(saved.name).toBe("Restart svc");
expect(saved.task_type).toBe("shell");
expect(saved.default_service_id).toBe("");
});
it("disables the Run button until a run service is selected", async () => {
sshServices = [sshService()];
tasks = [task()];
render(<Actions />);
// Selecting a saved task tab exposes the detail + Run control.
await userEvent.click(screen.getByRole("tab", { name: "Restart svc" }));
const runButton = screen.getByRole("button", { name: "Run action" });
expect(runButton).toBeDisabled();
});
it("runs a task on the selected SSH task service", async () => {
sshServices = [sshService()];
tasks = [task()];
render(<Actions />);
await userEvent.click(screen.getByRole("tab", { name: "Restart svc" }));
await userEvent.click(
screen.getByRole("combobox", { name: "Run on SSH task service" }),
);
await userEvent.click(screen.getByRole("option", { name: "Box" }));
await userEvent.click(screen.getByRole("button", { name: "Run action" }));
expect(runTaskMutate).toHaveBeenCalledTimes(1);
expect(runTaskMutate).toHaveBeenCalledWith({
taskId: "t1",
serviceId: "s1",
});
});
});
@@ -1,75 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { Applications } from "../Applications";
// Applications still embeds the MUI Media child (migrated in slice 7). Mock it
// so this slice-4 test stays focused on the migrated Applications shell and
// does not pull the still-MUI DataGrid into the jsdom render.
vi.mock("../Media", () => ({
Media: () => <div data-testid="media-child">Media</div>,
}));
vi.mock("react-router-dom", () => ({
useSearchParams: () => [new URLSearchParams(), vi.fn()],
}));
vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({
data: [
{
id: "m1",
name: "Main",
enabled: true,
services: ["jellyfin"],
},
],
}),
}));
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({
data: [
{ id: "jfs1", service_type: "jellyfin", name: "Main", enabled: true },
],
}),
}));
vi.mock("../../hooks/useDashboard", () => ({
useCounts: () => ({
data: { movies: 10, series: 5, episodes: 100 },
}),
useLibraries: () => ({
data: [
{ library: "Movies", total: 10, movies: 10, series: 0 },
{ library: "Shows", total: 5, movies: 0, series: 5 },
],
}),
}));
describe("Applications", () => {
it("renders the Jellyfin library counts grid and tabs, and keeps the Media child", () => {
render(<Applications />);
// Library stats header.
expect(screen.getByText("Library stats")).toBeInTheDocument();
// Counts: Total = 10 + 5 + 100 = 115, plus the per-type counts.
expect(screen.getByText("115")).toBeInTheDocument();
expect(screen.getByText("Episodes")).toBeInTheDocument();
// Library rows render their per-library totals (unique strings).
expect(
screen.getByText(/Total 10 · Movies 10 · Series 0/),
).toBeInTheDocument();
expect(
screen.getByText(/Total 5 · Movies 0 · Series 5/),
).toBeInTheDocument();
// Tabs present.
expect(screen.getByRole("tab", { name: "Jellyfin" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Nextcloud" })).toBeInTheDocument();
// The still-MUI Media child is rendered unchanged inside the Jellyfin tab.
expect(screen.getByTestId("media-child")).toBeInTheDocument();
});
});
+4 -171
View File
@@ -2,18 +2,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Dashboard } from "../Dashboard";
import type {
DashboardShortcut,
ServiceInstance,
WidgetInstance,
} from "../../types";
import type { DashboardShortcut } from "../../types";
// Stub the composed widgets so the test exercises Dashboard's own behavior
// (shortcut CRUD) without rendering widgets or their data queries.
vi.mock("../../components/WidgetInstance", () => ({
WidgetInstanceCard: ({ widget }: { widget: { title: string } }) => (
<div data-testid="widget-stub">{widget.title}</div>
),
WidgetInstanceCard: () => <div data-testid="widget-stub" />,
}));
vi.mock("../../components/WidgetConfigDialog", () => ({
WidgetConfigDialog: () => <div data-testid="widget-config-stub" />,
@@ -27,15 +21,11 @@ vi.mock("react-router-dom", () => ({
vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: [] }),
}));
// --- Dynamic mock state (reset in beforeEach) ---
let widgetInstances: WidgetInstance[] = [];
let serviceInstances: ServiceInstance[] = [];
vi.mock("../../hooks/useWidgets", () => ({
useWidgetInstances: () => ({ data: widgetInstances }),
useWidgetInstances: () => ({ data: [] }),
}));
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({ data: serviceInstances }),
useServiceInstances: () => ({ data: [] }),
}));
const saveShortcutMutate = vi.fn().mockResolvedValue({});
@@ -75,26 +65,8 @@ beforeEach(() => {
saveShortcutMutate.mockClear();
deleteShortcutMutate.mockClear();
shortcuts = [];
widgetInstances = [];
serviceInstances = [];
setMatchMedia(false); // desktop by default
});
// --- matchMedia mock for useIsMobile (jsdom has no native matchMedia) ---
function setMatchMedia(matches: boolean) {
window.matchMedia = ((query: string) => ({
matches: query === "(max-width: 768px)" ? matches : false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
}
describe("Dashboard", () => {
it("shows the empty-state alert when there are no shortcuts", () => {
render(<Dashboard />);
@@ -142,142 +114,3 @@ describe("Dashboard", () => {
expect(saved.shortcut_type).toBe("website");
});
});
// --- Mobile layout tests (spec R7.1, R7.2) ---
function makeWidget(overrides: Partial<WidgetInstance> = {}): WidgetInstance {
return {
id: "w1",
service_id: null,
widget_kind: "static",
title: "Widget 1",
config: {},
enabled: true,
sort_order: 0,
created_at: 0,
updated_at: 0,
...overrides,
};
}
function makeService(
overrides: Partial<ServiceInstance> = {},
): ServiceInstance {
return {
id: "svc1",
service_type: "jellyfin",
name: "Jellyfin",
config: {},
secrets_set: {},
enabled: true,
created_at: 0,
updated_at: 0,
...overrides,
};
}
describe("Dashboard mobile layout", () => {
it("renders widgets in a single column with an anchor bar below md", () => {
setMatchMedia(true); // mobile
serviceInstances = [
makeService({ id: "graf", service_type: "grafana" }),
makeService({ id: "jelly", service_type: "jellyfin" }),
];
widgetInstances = [
makeWidget({
id: "w-obs",
service_id: "graf",
widget_kind: "link",
title: "Grafana Link",
}),
makeWidget({
id: "w-media",
service_id: "jelly",
widget_kind: "activity",
title: "Jellyfin Activity",
}),
makeWidget({
id: "w-backup",
service_id: null,
widget_kind: "backups",
title: "Backup Summary",
}),
];
render(<Dashboard />);
// Anchor bar pills are visible for populated sections (each label appears
// in both the pill and the section heading, so use getAllByText).
expect(screen.getAllByText("Observability").length).toBeGreaterThanOrEqual(
1,
);
expect(screen.getAllByText("Media").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("Backups").length).toBeGreaterThanOrEqual(1);
// Sections with no widgets are NOT rendered.
expect(screen.queryByText("Custom")).not.toBeInTheDocument();
// Each widget renders.
expect(screen.getByText("Grafana Link")).toBeInTheDocument();
expect(screen.getByText("Jellyfin Activity")).toBeInTheDocument();
expect(screen.getByText("Backup Summary")).toBeInTheDocument();
});
it("does NOT render the anchor bar at desktop width", () => {
setMatchMedia(false); // desktop
serviceInstances = [makeService({ id: "graf", service_type: "grafana" })];
widgetInstances = [
makeWidget({
id: "w-obs",
service_id: "graf",
widget_kind: "link",
title: "Grafana Link",
}),
];
render(<Dashboard />);
// Widget renders (flat list, no section wrappers).
expect(screen.getByText("Grafana Link")).toBeInTheDocument();
// No section headings or anchor pills on desktop.
expect(screen.queryByText("Observability")).not.toBeInTheDocument();
expect(screen.queryByText("Media")).not.toBeInTheDocument();
});
it("anchor bar pills jump to their section via scrollIntoView", async () => {
setMatchMedia(true); // mobile
serviceInstances = [
makeService({ id: "graf", service_type: "grafana" }),
makeService({ id: "jelly", service_type: "jellyfin" }),
];
widgetInstances = [
makeWidget({
id: "w-obs",
service_id: "graf",
widget_kind: "link",
title: "Grafana Link",
}),
makeWidget({
id: "w-media",
service_id: "jelly",
widget_kind: "activity",
title: "Jellyfin Activity",
}),
];
const scrollSpy = vi.spyOn(Element.prototype, "scrollIntoView");
render(<Dashboard />);
// The Media section element exists.
expect(document.getElementById("dashboard-section-media")).not.toBeNull();
// Click the "Media" anchor pill (button role disambiguates from heading).
const mediaPill = screen.getByRole("button", { name: "Media" });
await userEvent.click(mediaPill);
expect(scrollSpy).toHaveBeenCalled();
scrollSpy.mockRestore();
});
});
@@ -1,198 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FileBrowser } from "../FileBrowser.impl";
import type { DirectoryListing, MonitoringMachine } from "../../types";
// usePersistentState (browserState) reads/writes localStorage; clear between tests
// so the selectedPath / currentDir state never leaks across cases.
beforeEach(() => {
window.localStorage.clear();
setMatchMedia(false);
});
function machineFixture(
overrides: Partial<MonitoringMachine> = {},
): MonitoringMachine {
return {
id: "local",
name: "Local",
mode: "local",
enabled: true,
services: ["files", "monitoring"],
host: "",
port: 22,
username: "",
key_directory: "",
key_name: "",
ssh_key_id: "",
ssh_private_key_set: false,
ssh_private_key_passphrase_set: false,
password_set: false,
notes: "",
...overrides,
};
}
function listingFixture(
entries: {
name: string;
type: string;
size: number;
mtime: number;
}[],
): DirectoryListing {
return { path: "/", entries, count: entries.length };
}
let listing: DirectoryListing;
let machines: MonitoringMachine[];
vi.mock("react-router-dom", () => ({
useSearchParams: () => [new URLSearchParams(), vi.fn()],
useNavigate: () => vi.fn(),
}));
vi.mock("../../hooks/useFiles", () => ({
useDirectoryListing: () => ({
data: listing,
isLoading: false,
error: null,
refetch: vi.fn(),
}),
useFfprobe: () => ({ data: undefined, isLoading: false, error: null }),
useJobTemplates: () => ({ data: [] }),
useRunJob: () => ({ isPending: false, mutate: vi.fn(), data: undefined }),
}));
vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: machines }),
}));
beforeEach(() => {
machines = [machineFixture()];
listing = listingFixture([
{ name: "movies", type: "d", size: 0, mtime: 1_700_000_000 },
{ name: "video.mkv", type: "f", size: 1_500_000_000, mtime: 1_700_000_000 },
{ name: "notes.txt", type: "f", size: 12, mtime: 1_700_000_000 },
]);
});
/** Stub window.matchMedia so useIsMobile resolves in jsdom (Slice 4). */
function setMatchMedia(matches: boolean) {
const listeners: ((e: MediaQueryListEvent) => void)[] = [];
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query.includes("768") ? matches : false,
media: query,
onchange: null,
addEventListener: (
_evt: string,
listener: (e: MediaQueryListEvent) => void,
) => listeners.push(listener),
removeEventListener: (
_evt: string,
listener: (e: MediaQueryListEvent) => void,
) => {
const idx = listeners.indexOf(listener);
if (idx >= 0) listeners.splice(idx, 1);
},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}));
}
describe("FileBrowser (slice 7a — TanStack DataTable parity)", () => {
it("renders the 5 locked columns (type/name/ext/size/modified)", () => {
render(<FileBrowser />);
const headers = screen
.getAllByRole("columnheader")
.map((h) => h.textContent);
// The leading selection column header is empty (checkbox); the 5 data
// columns are Type, Name, Ext, Size, Modified in that order.
expect(headers).toEqual(
expect.arrayContaining(["Type", "Name", "Ext", "Size", "Modified"]),
);
expect(headers.filter((h) => h === "Type").length).toBe(1);
expect(headers.filter((h) => h === "Modified").length).toBe(1);
});
it("clicking a file row selects it for ffprobe preview (Media info)", async () => {
render(<FileBrowser />);
// The selected-file path surfaces in the Browser status caption once chosen.
expect(screen.queryByText(/Selected: \/video\.mkv/)).toBeNull();
await userEvent.click(screen.getByText("video.mkv"));
expect(screen.getByText(/Selected: \/video\.mkv/)).toBeInTheDocument();
// A recognized video file enters the ffprobe branch; with empty ffprobe
// data it shows the "No ffprobe data available." status (proving the
// selected file routed into the Media info preview flow).
expect(screen.getByText("No ffprobe data available.")).toBeInTheDocument();
});
it("clicking a directory row navigates into it (no ffprobe selection)", async () => {
render(<FileBrowser />);
await userEvent.click(screen.getByText("movies"));
// After navigating into /movies, the status caption shows the new cwd and
// NO "Selected:" segment (directories are opened, not selected for preview).
expect(screen.getByText(/Current: \/movies\b/)).toBeInTheDocument();
expect(screen.queryByText(/Selected:/)).toBeNull();
});
});
describe("FileBrowser (mobile card layout — slice 4)", () => {
it("renders cards with file/dir name as primary below md", () => {
setMatchMedia(true);
render(<FileBrowser />);
// Card titles (the 'name' field rendered as primary).
expect(screen.getByText("movies")).toBeInTheDocument();
expect(screen.getByText("video.mkv")).toBeInTheDocument();
expect(screen.getByText("notes.txt")).toBeInTheDocument();
// Desktop table column headers must NOT render.
const headers = screen.queryAllByRole("columnheader");
expect(headers).toHaveLength(0);
});
it("tapping a directory card navigates into it", async () => {
setMatchMedia(true);
render(<FileBrowser />);
// Directory card is a button wrapping the 'movies' text.
await userEvent.click(screen.getByText("movies"));
// After navigating into /movies, the status caption shows the new cwd
// and NO 'Selected:' segment (directories are opened, not selected).
expect(screen.getByText(/Current: \/movies\b/)).toBeInTheDocument();
expect(screen.queryByText(/Selected:/)).toBeNull();
});
it("renders the path/breadcrumb controls on mobile", () => {
setMatchMedia(true);
render(<FileBrowser />);
// The 'Remote path' label and its input are part of the Browser section
// card (outside the table), so they render on both breakpoints.
expect(screen.getByLabelText("Remote path")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument();
});
it("renders the DataTable at desktop width (1280px)", () => {
setMatchMedia(false);
render(<FileBrowser />);
// Desktop path: table column headers are present.
const headers = screen
.getAllByRole("columnheader")
.map((h) => h.textContent);
expect(headers).toEqual(
expect.arrayContaining(["Type", "Name", "Ext", "Size", "Modified"]),
);
});
});
-356
View File
@@ -1,356 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Media } from "../Media";
import type {
MediaIndexStatus,
MediaItem,
MediaQueryResponse,
MonitoringMachine,
} from "../../types";
// Shared navigate mock so the row-click test can assert the call. The vi.mock
// factory is hoisted above this const, but it only closes over `navigate`
// lazily (the arrow runs at render time, well after init) — no TDZ access.
const navigate = vi.fn();
function machineFixture(
overrides: Partial<MonitoringMachine> = {},
): MonitoringMachine {
return {
id: "local",
name: "Local",
mode: "local",
enabled: true,
services: ["jellyfin", "monitoring"],
host: "",
port: 22,
username: "",
key_directory: "",
key_name: "",
ssh_key_id: "",
ssh_private_key_set: false,
ssh_private_key_passphrase_set: false,
password_set: false,
notes: "",
...overrides,
};
}
function statusFixture(
overrides: Partial<MediaIndexStatus> = {},
): MediaIndexStatus {
return {
exists: true,
item_count: 2,
updated_at: 1,
updated_at_label: "now",
build_duration_seconds: null,
build_running: false,
build_stage: "",
build_message: "",
build_progress: null,
build_items_processed: 0,
build_items_total: 0,
build_current_library: "",
build_library_index: 0,
build_libraries_total: 0,
build_library_progress: null,
build_library_items_processed: 0,
build_library_items_total: 0,
build_elapsed_seconds: null,
build_eta_seconds: null,
build_library_elapsed_seconds: null,
build_library_eta_seconds: null,
build_cancel_requested: false,
build_pid: null,
build_error: "",
...overrides,
};
}
function mediaItem(overrides: Partial<MediaItem> = {}): MediaItem {
return {
id: "1",
title: "Inception",
series: "",
season: "",
episode: null,
type: "Movie",
year: 2010,
runtime_min: 148,
size: "12.4 GB",
bitrate: "35.0 Mbps",
hdr: "HDR10",
video: "HEVC",
resolution: "4K",
date_added: "2024-01-01",
library: "Movies",
path: "/media/movies/Inception.mkv",
...overrides,
};
}
let status: MediaIndexStatus;
let queryResult: MediaQueryResponse;
vi.mock("react-router-dom", () => ({
useNavigate: () => navigate,
useSearchParams: () => [
new URLSearchParams("jellyfin_service_id=jfs1"),
vi.fn(),
],
}));
vi.mock("../../hooks/useMedia", () => ({
useMediaStatus: () => ({ data: status }),
useMediaQuery: () => ({ data: queryResult, isLoading: false }),
useBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
useStopBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
useForceStopBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
}));
vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: [machineFixture()] }),
}));
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({
data: [
{ id: "jfs1", service_type: "jellyfin", name: "Main", enabled: true },
],
}),
}));
vi.mock("../../hooks/useDashboard", () => ({
useCounts: () => ({ data: undefined }),
useLibraries: () => ({ data: undefined }),
}));
// usePersistentState reads/writes localStorage; clear between tests so the
// offset/pageSize/columnVisibility state never leaks across cases.
// matchMedia must be stubbed so useIsMobile (md:768px) and usePrefersSmallScreen
// (900px) resolve without TypeError in jsdom. Default to desktop (matches:false)
// so the DataTable path renders by default; mobile tests override.
beforeEach(() => {
setMatchMedia(false);
window.localStorage.clear();
navigate.mockClear();
status = statusFixture();
queryResult = {
items: [
mediaItem({
id: "1",
title: "Inception",
path: "/media/movies/Inception.mkv",
}),
mediaItem({
id: "2",
title: "Matrix",
path: "/media/movies/Matrix.mkv",
}),
],
total: 2,
limit: 100,
offset: 0,
};
});
/** Stub window.matchMedia so useIsMobile / usePrefersSmallScreen resolve in jsdom. */
function setMatchMedia(matches: boolean) {
const listeners: ((e: MediaQueryListEvent) => void)[] = [];
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: query.includes("768") ? matches : false,
media: query,
onchange: null,
addEventListener: (
_evt: string,
listener: (e: MediaQueryListEvent) => void,
) => listeners.push(listener),
removeEventListener: (
_evt: string,
listener: (e: MediaQueryListEvent) => void,
) => {
const idx = listeners.indexOf(listener);
if (idx >= 0) listeners.splice(idx, 1);
},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}));
}
describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", () => {
it("exposes exactly the 15 locked toggleable columns", async () => {
render(<Media />);
await userEvent.click(screen.getByRole("button", { name: /Columns/ }));
const toggleable = screen
.getAllByRole("menuitemcheckbox")
.map((item) => (item.textContent ?? "").trim());
expect([...toggleable].sort()).toEqual(
[
"title",
"series",
"season",
"episode",
"type",
"year",
"runtime_min",
"size",
"bitrate",
"hdr",
"video",
"resolution",
"date_added",
"library",
"path",
].sort(),
);
// The leading selection column is never toggleable (enableHiding=false).
expect(toggleable).toHaveLength(15);
expect(toggleable).not.toContain("__select__");
});
it("renders the 15 data column headers", () => {
render(<Media />);
const headers = screen
.getAllByRole("columnheader")
.map((h) => (h.textContent ?? "").trim());
for (const expected of [
"Title",
"Series",
"Season",
"Episode",
"Type",
"Year",
"Runtime",
"Size",
"Bitrate",
"HDR",
"Video codec",
"Resolution",
"Date added",
"Library",
"Path",
]) {
expect(headers).toContain(expected);
}
});
it("navigates to the file browser at the item path on row click", async () => {
render(<Media />);
await userEvent.click(screen.getByText("Inception"));
expect(navigate).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith(
`/files?path=${encodeURIComponent("/media/movies/Inception.mkv")}`,
);
});
it("does NOT navigate when toggling a row selection checkbox", async () => {
render(<Media />);
const firstCheckbox = screen.getAllByRole("checkbox", {
name: "Select row",
})[0];
await userEvent.click(firstCheckbox);
expect(firstCheckbox).toBeChecked();
expect(navigate).not.toHaveBeenCalled();
});
it("renders the server-driven pagination total + page controls", () => {
render(<Media />);
// DataTable manual-pagination footer surfaces the server total + pager.
// ("Page 1 of 1" also appears in the page caption, so match all and assert
// the pager footer text is present alongside the unique total.)
expect(screen.getByText("2 rows")).toBeInTheDocument();
expect(screen.getAllByText(/Page 1 of 1/).length).toBeGreaterThan(0);
expect(
screen.getByRole("button", { name: "Previous page" }),
).toBeDisabled();
});
it("disables Build index while a build is running", () => {
status = statusFixture({ build_running: true });
render(<Media />);
expect(screen.getByRole("button", { name: "Building..." })).toBeDisabled();
// Stop + Force stop surface only while running.
expect(
screen.getByRole("button", { name: "Stop build" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Force stop" }),
).toBeInTheDocument();
});
});
describe("Media (mobile card layout — slice 3)", () => {
it("renders cards with the title as primary below md", () => {
setMatchMedia(true);
render(<Media />);
// Card titles render (primary field).
expect(screen.getByText("Inception")).toBeInTheDocument();
expect(screen.getByText("Matrix")).toBeInTheDocument();
// Card field labels render (at least once per row).
expect(screen.getAllByText("Size").length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText("HDR").length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText("Library").length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText("Year").length).toBeGreaterThanOrEqual(2);
// Desktop table headers do NOT render on mobile.
expect(screen.queryByRole("columnheader", { name: "Title" })).toBeNull();
expect(screen.queryByRole("columnheader", { name: "Bitrate" })).toBeNull();
});
it("hides the column-visibility toggle below md", () => {
setMatchMedia(true);
render(<Media />);
expect(screen.queryByRole("button", { name: /Columns/ })).toBeNull();
});
it("renders pagination controls below the cards on mobile", () => {
setMatchMedia(true);
render(<Media />);
expect(screen.getByText("2 rows")).toBeInTheDocument();
expect(screen.getAllByText(/Page 1 of 1/).length).toBeGreaterThan(0);
expect(
screen.getByRole("button", { name: "Previous page" }),
).toBeDisabled();
expect(
screen.getByRole("button", { name: "Next page" }),
).toBeInTheDocument();
});
it("navigates to the file browser when a card is tapped on mobile", async () => {
setMatchMedia(true);
render(<Media />);
await userEvent.click(screen.getByText("Inception"));
expect(navigate).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith(
`/files?path=${encodeURIComponent("/media/movies/Inception.mkv")}`,
);
});
it("renders the DataTable (not cards) at desktop width", () => {
render(<Media />);
// Desktop column headers render.
expect(
screen.getByRole("columnheader", { name: "Title" }),
).toBeInTheDocument();
// Column-visibility toggle is present.
expect(screen.getByRole("button", { name: /Columns/ })).toBeInTheDocument();
});
});
@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { NamedDashboardPage } from "../NamedDashboardPage";
vi.mock("../../hooks/useDashboards", () => ({
useDashboardBySlug: vi.fn(() => ({ data: undefined, isLoading: true })),
}));
import { useDashboardBySlug } from "../../hooks/useDashboards";
function renderPage(slug: string) {
return render(
<MemoryRouter initialEntries={[`/d/${slug}`]}>
<Routes>
<Route path="/d/:slug" element={<NamedDashboardPage />} />
</Routes>
</MemoryRouter>,
);
}
describe("NamedDashboardPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders loading state", () => {
vi.mocked(useDashboardBySlug).mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
} as never);
renderPage("storage");
// Skeleton renders during load.
expect(document.querySelector(".h-32")).toBeInTheDocument();
});
it("renders 404 when dashboard not found", () => {
vi.mocked(useDashboardBySlug).mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
} as never);
renderPage("nonexistent");
expect(screen.getByText(/Dashboard not found/i)).toBeInTheDocument();
});
it("renders pinned links for a known dashboard", () => {
vi.mocked(useDashboardBySlug).mockReturnValue({
data: {
id: "d1",
label: "Storage",
slug: "storage",
sort_order: 0,
payload: {
items: [
{
type: "link",
label: "My Jellyfin",
target: "/services/jellyfin/svc-1",
},
],
},
created_at: 1,
updated_at: 1,
},
isLoading: false,
isError: false,
} as never);
renderPage("storage");
expect(screen.getByText("Storage")).toBeInTheDocument();
expect(screen.getByText("My Jellyfin")).toBeInTheDocument();
});
it("renders empty state when dashboard has no items", () => {
vi.mocked(useDashboardBySlug).mockReturnValue({
data: {
id: "d2",
label: "Empty",
slug: "empty",
sort_order: 0,
payload: {},
created_at: 1,
updated_at: 1,
},
isLoading: false,
isError: false,
} as never);
renderPage("empty");
expect(screen.getByText("Empty")).toBeInTheDocument();
expect(screen.getByText(/no shortcuts yet/i)).toBeInTheDocument();
});
});
+92 -106
View File
@@ -1,20 +1,14 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { ServicePage } from "../ServicePage";
import type {
ServiceInstance,
ServiceInstanceInput,
ServiceTypeInfo,
} from "../../types";
// --- fixtures ---
import type { ServiceInstance, ServiceTypeInfo } from "../../types";
const instance: ServiceInstance = {
id: "svc-1",
service_type: "grafana",
name: "Production Grafana",
config: { base_url: "https://grafana.example.com", timeout_seconds: 5 },
service_type: "jellyfin",
name: "Main Jellyfin",
config: { base_url: "https://jf.example.com", user_id: "u1" },
secrets_set: { api_key: true },
enabled: true,
created_at: 1_700_000_000,
@@ -22,128 +16,120 @@ const instance: ServiceInstance = {
};
const typeInfo: ServiceTypeInfo = {
service_type: "grafana",
name: "Grafana",
description: "Dashboards, metrics, and logs.",
service_type: "jellyfin",
name: "Jellyfin",
description: "Media server",
config_schema: {
type: "object",
properties: {
base_url: { type: "string", description: "Absolute URL." },
timeout_seconds: { type: "integer" },
},
properties: { base_url: { type: "string" } },
},
secret_fields: [{ key: "api_key", label: "API key", required: false }],
widget_kinds: [],
};
// --- mocks ---
const secondInstance: ServiceInstance = {
...instance,
id: "svc-2",
name: "Backup Jellyfin",
};
const mutateAsync = vi.fn();
const mutate = vi.fn();
const deleteMutate = vi.fn();
const saveMutateAsync = vi.fn();
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({ data: [instance] }),
useServiceInstances: () => ({
data: (window as unknown as { __svcInstances?: ServiceInstance[] })
?.__svcInstances ?? [instance],
}),
useServiceTypes: () => ({ data: [typeInfo] }),
useSaveServiceInstance: () => ({
mutateAsync,
mutate,
mutateAsync: saveMutateAsync,
mutate: vi.fn(),
isPending: false,
}),
useDeleteServiceInstance: () => ({ mutate: deleteMutate, isPending: false }),
useDeleteServiceInstance: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("react-router-dom", () => ({
useParams: () => ({
serviceType: "grafana",
serviceId: "svc-1",
vi.mock("../../integrations/registry", () => ({
getServiceBinding: () => ({
name: "Jellyfin",
description: "Media server",
widgets: [],
}),
useNavigate: () => vi.fn(),
}));
// jsdom has no window.matchMedia; stub it. Default to desktop (matches: false).
function setMatchMedia(matches: boolean) {
window.matchMedia = ((query: string) => ({
matches: query.includes("768") ? matches : false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
function renderServicePage(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route
path="/services/:serviceType/:serviceId"
element={<ServicePage />}
/>
</Routes>
</MemoryRouter>,
);
}
beforeEach(() => {
setMatchMedia(false);
mutateAsync.mockReset();
mutate.mockReset();
deleteMutate.mockReset();
});
describe("ServicePage (desktop)", () => {
it("renders the full-page layout with the service name and connection card", () => {
render(<ServicePage />);
// Page heading (desktop only — mobile uses SheetForm title)
expect(
screen.getByRole("heading", { name: "Production Grafana" }),
).toBeInTheDocument();
// Connection section card title
expect(screen.getByText("Connection")).toBeInTheDocument();
// General Save button
expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
describe("ServicePage tab skeleton", () => {
it("renders Overview + Media + Requests + Widgets + Config for jellyfin", () => {
renderServicePage("/services/jellyfin/svc-1");
expect(screen.getByRole("tab", { name: "Overview" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Media" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Requests" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Widgets" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Config" })).toBeInTheDocument();
});
it("does not render the SheetForm at desktop width", () => {
render(<ServicePage />);
// SheetForm renders a dialog with role="dialog" only when open; on
// desktop the page layout is used, so no dialog should be present.
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
describe("ServicePage (mobile SheetForm — slice 6)", () => {
beforeEach(() => setMatchMedia(true));
it("renders the SheetForm with the service name as title below md", () => {
render(<ServicePage />);
// SheetForm title is rendered inside a SheetTitle (role="heading").
it("does NOT render Media/Requests for non-jellyfin types", () => {
const sshInstance = { ...instance, service_type: "ssh_tasks", id: "ssh-1" };
(
window as unknown as { __svcInstances: ServiceInstance[] }
).__svcInstances = [sshInstance];
renderServicePage("/services/ssh_tasks/ssh-1");
expect(screen.getByRole("tab", { name: "Files" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Actions" })).toBeInTheDocument();
expect(
screen.getByRole("heading", { name: "Production Grafana" }),
).toBeInTheDocument();
// The dialog (Sheet content) should be present on mobile.
expect(screen.getByRole("dialog")).toBeInTheDocument();
// Desktop page header description is NOT rendered inside the SheetForm.
expect(
screen.queryByText("Dashboards, metrics, and logs."),
screen.queryByRole("tab", { name: "Media" }),
).not.toBeInTheDocument();
});
it("edits the name field and Save calls the save mutation", async () => {
render(<ServicePage />);
const nameInput = screen.getByLabelText("Name");
expect(nameInput).toHaveValue("Production Grafana");
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "Renamed Grafana");
const saveButton = screen.getByRole("button", { name: "Save" });
await userEvent.click(saveButton);
expect(mutateAsync).toHaveBeenCalledTimes(1);
const input = mutateAsync.mock.calls[0][0] as ServiceInstanceInput;
expect(input.name).toBe("Renamed Grafana");
expect(input.id).toBe("svc-1");
// Lock the full save payload (config draft, enabled, secrets sentinel).
expect(input.enabled).toBe(true);
expect(input.secrets).toEqual({});
expect(input.config).toMatchObject({ base_url: "https://grafana.example.com" });
it("shows instance switcher when >1 sibling of same type", () => {
(
window as unknown as { __svcInstances: ServiceInstance[] }
).__svcInstances = [instance, secondInstance];
const { container } = renderServicePage("/services/jellyfin/svc-1");
// The switcher renders as a Select trigger (combobox).
expect(container.querySelector("[role='combobox']")).toBeInTheDocument();
});
it("renders the connection config fields as editable inside the SheetForm", () => {
render(<ServicePage />);
const urlInput = screen.getByLabelText("base_url");
expect(urlInput).toHaveValue("https://grafana.example.com");
it("hides instance switcher when only one instance", () => {
(
window as unknown as { __svcInstances: ServiceInstance[] }
).__svcInstances = [instance];
const { container } = renderServicePage("/services/jellyfin/svc-1");
// No select trigger rendered (only one instance).
expect(
container.querySelector("[role='combobox']"),
).not.toBeInTheDocument();
});
it("includes typed secret drafts in the save payload (B1 regression guard)", async () => {
const { userEvent } = await import("@testing-library/user-event");
const user = userEvent.setup();
saveMutateAsync.mockReset();
renderServicePage("/services/jellyfin/svc-1");
// Open the Config tab and type a new api_key.
await user.click(screen.getByRole("tab", { name: "Config" }));
const secretInput = screen.getByLabelText("api_key");
await user.type(secretInput, "new-secret-value");
// Save and assert the typed secret is in the payload (not secrets: {}).
await user.click(screen.getByRole("button", { name: "Save" }));
expect(saveMutateAsync).toHaveBeenCalledTimes(1);
const input = saveMutateAsync.mock.calls[0][0] as {
secrets: Record<string, string>;
};
expect(input.secrets).toEqual({ api_key: "new-secret-value" });
});
});
+6 -104
View File
@@ -6,10 +6,12 @@ import type { MonitoringMachine } from "../../types";
const saveMachineMutate = vi.fn().mockResolvedValue({});
const deleteMachineMutate = vi.fn();
const testSSHMutate = vi.fn().mockResolvedValue({
message: "SSH auth succeeded",
known_hosts_updated: true,
});
const testSSHMutate = vi
.fn()
.mockResolvedValue({
message: "SSH auth succeeded",
known_hosts_updated: true,
});
let machines: MonitoringMachine[] = [];
@@ -111,103 +113,3 @@ describe("Settings", () => {
expect(deleteMachineMutate).toHaveBeenCalledWith("m1");
});
});
// jsdom has no window.matchMedia; default to desktop so existing tests are
// unaffected.
function setMatchMedia(matches: boolean) {
window.matchMedia = ((query: string) => ({
matches: query.includes("768") ? matches : false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
}
describe("Settings (mobile SheetForm — slice 7)", () => {
beforeEach(() => setMatchMedia(true));
it("opens the machine editor in a SheetForm below md", async () => {
machines = [localMachine()];
render(<Settings />);
// Open the editor via the detail-pane Edit button (visible text).
const detailEdit = screen
.getAllByRole("button", { name: "Edit" })
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
await userEvent.click(detailEdit);
// SheetForm renders a dialog; the DialogTitle shows the editor title.
expect(screen.getByText("Edit machine")).toBeInTheDocument();
expect(screen.getByRole("dialog")).toBeInTheDocument();
// Desktop DialogDescription text is not rendered as a dialog description
// on mobile (the MachineEditor has its own hint labels, which is fine).
expect(
screen.queryByRole("heading", { name: "Create machine" }),
).not.toBeInTheDocument();
});
it("saves a machine via the SheetForm on mobile", async () => {
machines = [localMachine()];
render(<Settings />);
const detailEdit = screen
.getAllByRole("button", { name: "Edit" })
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
await userEvent.click(detailEdit);
const nameInput = screen.getByLabelText("Name");
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "Renamed node");
await userEvent.click(screen.getByRole("button", { name: "Save machine" }));
expect(saveMachineMutate).toHaveBeenCalledTimes(1);
const saved = saveMachineMutate.mock.calls[0][0];
expect(saved.name).toBe("Renamed node");
expect(saved.mode).toBe("local");
});
it("cancel closes the SheetForm on mobile", async () => {
machines = [localMachine()];
render(<Settings />);
const detailEdit = screen
.getAllByRole("button", { name: "Edit" })
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
await userEvent.click(detailEdit);
expect(screen.getByRole("dialog")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
// The sheet is now closed — the dialog role should no longer be present.
// (The page content itself is still rendered; only the sheet unmounts.)
expect(screen.queryByText("Edit machine")).not.toBeInTheDocument();
});
it("prompts before discarding unsaved machine edits (R4.5)", async () => {
machines = [localMachine()];
render(<Settings />);
const detailEdit = screen
.getAllByRole("button", { name: "Edit" })
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
await userEvent.click(detailEdit);
// Edit the name to make the form dirty.
const nameInput = screen.getByLabelText("Name");
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "Dirty name");
// Cancel should NOT immediately close — the discard confirm appears.
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(
screen.getByRole("heading", { name: "Discard changes?" }),
).toBeInTheDocument();
// The editor is still open.
expect(screen.getByText("Edit machine")).toBeInTheDocument();
});
});
@@ -1,407 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { UsersPage } from "../UsersPage.impl";
import { TooltipProvider } from "../../components/ui/tooltip";
import type {
NowPlayingSession,
UserDirectoryItem,
UserDirectoryResponse,
} from "../../types";
// jsdom has no window.matchMedia; the shared `useIsMobile` hook and the
// compose dialog viewport hook must not blow up during render. Stub to
// "desktop" (matches: false) by default; the slice-5 describe block flips it
// to mobile for card-layout assertions.
beforeEach(() => {
if (!window.matchMedia) {
window.matchMedia = ((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
}
// The compose formatting actions defer a focus/selection restore via
// requestAnimationFrame (see insertMarkup). jsdom may not flush rAF
// synchronously, so make it synchronous so the slice-6b compose test can
// observe the html-body value update.
window.requestAnimationFrame = ((cb: FrameRequestCallback) => {
cb(0);
return 0;
}) as typeof window.requestAnimationFrame;
});
// Keep the drawer's nested session panel out of the DOM under test.
vi.mock("../../components/SessionActivityPanel", () => ({
SessionActivityPanel: ({
selectedUserLabel,
}: {
selectedUserLabel: string;
}) => <div data-testid="session-panel-stub">{selectedUserLabel}</div>,
}));
let users: UserDirectoryItem[] = [];
let activity: NowPlayingSession[] = [];
function directoryResponse(): UserDirectoryResponse {
return {
items: users,
total: users.length,
jellyseerr_configured: true,
jellyseerr_available: true,
jellyseerr_error: "",
jellyseerr_jellyfin_user_count: 0,
jellyseerr_user_count: 0,
enriched_count: 0,
};
}
vi.mock("../../hooks/useUsers", () => ({
useUsers: () => ({ data: directoryResponse(), isError: false, error: null }),
}));
vi.mock("../../hooks/useDashboard", () => ({
useActivity: () => ({ data: activity }),
}));
vi.mock("../../hooks/useUserMessageQueueStatus", () => ({
useUserMessageQueueStatus: () => ({ data: undefined, isError: false }),
}));
vi.mock("../../hooks/useSendUserMessage", () => ({
useSendUserMessage: () => ({
isPending: false,
isError: false,
isSuccess: false,
reset: vi.fn(),
mutateAsync: vi.fn(),
}),
}));
// useSearchParams backs `?user=<id>` (drawer open) on a module-level object.
// `setSearchParams({ user })` opens the drawer; `setSearchParams({})` closes it.
let currentParams: Record<string, string> = {};
const setSearchParams = vi.fn((next: Record<string, string>) => {
currentParams = { ...next };
});
vi.mock("react-router-dom", () => ({
useSearchParams: () => [new URLSearchParams(currentParams), setSearchParams],
}));
function userFixture(
overrides: Partial<UserDirectoryItem> = {},
): UserDirectoryItem {
return {
jellyfin_id: "u1",
username: "alice",
display_name: "Alice",
email: "alice@example.com",
email_source: "jellyfin",
avatar: "",
avatar_source: "",
contactable: true,
source: "jellyfin",
source_summary: "",
name_source: "jellyfin",
access_source: "jellyfin",
jellyseerr_user_id: null,
jellyseerr_username: "",
user_type: 1,
user_type_label: "User",
role: "admin",
permissions: 1,
permissions_label: "Administrator",
request_count: 0,
...overrides,
};
}
beforeEach(() => {
users = [];
activity = [];
currentParams = {};
setSearchParams.mockClear();
});
describe("UsersPage (slice 6a — directory surface + drawer)", () => {
it("renders the directory table and metric counts", () => {
users = [userFixture()];
render(<UsersPage />);
expect(screen.getByText("Total users")).toBeInTheDocument();
expect(screen.getByText("User list")).toBeInTheDocument();
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("0 selected")).toBeInTheDocument();
});
it("toggles row selection and reflects the selected-count badge", async () => {
users = [
userFixture({ jellyfin_id: "u1" }),
userFixture({
jellyfin_id: "u2",
username: "bob",
display_name: "Bob",
email: "bob@example.com",
}),
];
render(<UsersPage />);
expect(screen.getByText("0 selected")).toBeInTheDocument();
// Selection-across-pagination: toggling a row updates the selected-id set.
await userEvent.click(
screen.getByRole("checkbox", { name: "Select Alice" }),
);
expect(screen.getByText("1 selected")).toBeInTheDocument();
// Toggling again removes it (the set survives, membership flips).
await userEvent.click(
screen.getByRole("checkbox", { name: "Select Alice" }),
);
expect(screen.getByText("0 selected")).toBeInTheDocument();
});
it("selects all visible rows via the header select-all checkbox", async () => {
users = [
userFixture({ jellyfin_id: "u1" }),
userFixture({
jellyfin_id: "u2",
username: "bob",
display_name: "Bob",
email: "bob@example.com",
}),
];
render(<UsersPage />);
await userEvent.click(
screen.getByRole("checkbox", { name: "Select all visible users" }),
);
expect(screen.getByText("2 selected")).toBeInTheDocument();
});
it("opens the user drawer when a row is clicked (setSearchParams user)", async () => {
users = [userFixture({ jellyfin_id: "u1" })];
render(<UsersPage />);
// Clicking the row body (not the checkbox) opens the detail drawer.
await userEvent.click(screen.getByText("Alice"));
expect(setSearchParams).toHaveBeenCalledWith({ user: "u1" });
});
it("maps activity status to Badge variants (Playing→success, Paused→warning)", () => {
users = [
userFixture({
jellyfin_id: "u1",
username: "alice",
display_name: "Alice",
}),
userFixture({
jellyfin_id: "u2",
username: "bob",
display_name: "Bob",
email: "bob@example.com",
}),
];
activity = [
{
user: "alice",
title: "Movie",
type: "Movie",
state: "playing",
transcoding: "no",
transcoding_type: "",
device: "Web",
session_id: "s1",
},
{
user: "bob",
title: "Show",
type: "Episode",
state: "paused",
transcoding: "no",
transcoding_type: "",
device: "TV",
session_id: "s2",
},
];
render(<UsersPage />);
// Design §2.3: healthy/active (Playing) = success (chart-2); Paused = warning.
expect(screen.getByText("Playing").getAttribute("data-variant")).toBe(
"success",
);
expect(screen.getByText("Paused").getAttribute("data-variant")).toBe(
"warning",
);
});
it("renders the user detail drawer (Sheet) when a user is selected", () => {
users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })];
currentParams = { user: "u1" };
render(<UsersPage />);
// buildUserDrawerModel title = display name; rendered as the drawer heading.
expect(screen.getByRole("heading", { name: "Alice" })).toBeInTheDocument();
// Drawer sections (identity / contact actions) + the activity panel render.
expect(screen.getByText("Identity")).toBeInTheDocument();
expect(screen.getByText("Contact actions")).toBeInTheDocument();
expect(screen.getByTestId("session-panel-stub")).toBeInTheDocument();
});
});
describe("UsersPage (slice 6b — compose dialog formatting actions)", () => {
it("opens compose and inserts bold markup into the html body", async () => {
users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })];
// The formatting toolbar renders <Tooltip> (shadcn), which in the app is
// wrapped by a global <TooltipProvider> in App.tsx; supply it here.
render(
<TooltipProvider>
<UsersPage />
</TooltipProvider>,
);
// Select a deliverable user so the "Message selected" button enables.
await userEvent.click(
screen.getByRole("checkbox", { name: "Select Alice" }),
);
await userEvent.click(
screen.getByRole("button", { name: "Message selected" }),
);
// Compose dialog opens (shadcn Dialog family).
expect(
screen.getByRole("heading", { name: "Message selected users" }),
).toBeInTheDocument();
// Bold action wraps the cursor selection in <strong></strong> via the
// preserved insertMarkup helper (markup insertion actions parity).
await userEvent.click(screen.getByRole("button", { name: "Bold" }));
const body = screen.getByRole("textbox", {
name: "HTML message body",
}) as HTMLTextAreaElement;
expect(body.value).toContain("<strong>");
});
});
describe("UsersPage (mobile card layout — slice 5)", () => {
beforeEach(() => {
window.matchMedia = ((query: string) => ({
matches: query.includes("768"),
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
});
it("renders user cards with display name as primary below md", () => {
users = [
userFixture({ jellyfin_id: "u1", display_name: "Alice" }),
userFixture({
jellyfin_id: "u2",
username: "bob",
display_name: "Bob",
}),
];
render(
<TooltipProvider>
<UsersPage />
</TooltipProvider>,
);
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("Bob")).toBeInTheDocument();
// Activity field label should appear per card.
expect(screen.getAllByText("Activity")).toHaveLength(2);
});
it("toggles selection from the card checkbox without opening the drawer", async () => {
users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })];
render(
<TooltipProvider>
<UsersPage />
</TooltipProvider>,
);
const checkbox = screen.getByRole("checkbox", {
name: /Select Alice/i,
});
expect(checkbox).toHaveAttribute("data-state", "unchecked");
await userEvent.click(checkbox);
expect(checkbox).toHaveAttribute("data-state", "checked");
// Drawer stays closed: the session-panel stub only renders when the
// drawer opens via a card-body tap, not via the checkbox.
expect(screen.queryByTestId("session-panel-stub")).not.toBeInTheDocument();
});
it("renders compose in a SheetForm below md with send button", async () => {
users = [
userFixture({
jellyfin_id: "u1",
display_name: "Alice",
email: "alice@example.com",
}),
];
render(
<TooltipProvider>
<UsersPage />
</TooltipProvider>,
);
// Select the deliverable user via the mobile card checkbox.
await userEvent.click(
screen.getByRole("checkbox", { name: /Select Alice/i }),
);
await userEvent.click(
screen.getByRole("button", { name: "Message selected" }),
);
// On mobile, compose opens in a SheetForm (not a Dialog). The SheetForm
// header carries the title and the footer carries the Send button.
expect(screen.getByText("Message selected users")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Send message" }),
).toBeInTheDocument();
expect(screen.getByLabelText("Subject")).toBeInTheDocument();
});
it("prompts before discarding unsaved compose edits (R4.5)", async () => {
users = [
userFixture({
jellyfin_id: "u1",
display_name: "Alice",
email: "alice@example.com",
}),
];
render(
<TooltipProvider>
<UsersPage />
</TooltipProvider>,
);
await userEvent.click(
screen.getByRole("checkbox", { name: /Select Alice/i }),
);
await userEvent.click(
screen.getByRole("button", { name: "Message selected" }),
);
// Type a subject to make the compose form dirty.
await userEvent.type(screen.getByLabelText("Subject"), "Urgent update");
// Cancel should NOT immediately close — the discard confirm appears.
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(
screen.getByRole("heading", { name: "Discard changes?" }),
).toBeInTheDocument();
});
});