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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user