Files

594 lines
19 KiB
TypeScript

import React from "react";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { createCustomRenderer } from "test/test-utils";
import createMockConfig from "__mocks__/configMock";
import CommandPalette from "./CommandPalette";
// cmdk uses scrollIntoView which JSDOM doesn't implement
Element.prototype.scrollIntoView = jest.fn();
// CommandPalette branches on navigator.platform to pick the modifier
// (Cmd on macOS, Ctrl elsewhere). jsdom's default is an empty string,
// which would make the whole suite run as "non-Mac" and break every
// {Meta>}…{/Meta} keyboard test. Default the suite to Mac and override
// per-test when we specifically want to exercise the non-Mac branch.
const setPlatform = (value: string) => {
Object.defineProperty(window.navigator, "platform", {
value,
configurable: true,
});
};
// Capture the original descriptor so afterEach can fully restore it —
// not just the value. Otherwise our `configurable: true` override leaks
// into other tests and can mask future descriptor-sensitive bugs.
const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(
window.navigator,
"platform"
);
beforeEach(() => setPlatform("MacIntel"));
afterEach(() => {
if (originalPlatformDescriptor) {
Object.defineProperty(
window.navigator,
"platform",
originalPlatformDescriptor
);
} else {
delete (window.navigator as { platform?: string }).platform;
}
});
const adminRender = createCustomRenderer({
withBackendMock: true,
context: {
app: {
currentUser: {
id: 1,
name: "Test User",
email: "test@fleet.co",
global_role: "admin",
},
config: createMockConfig(),
isGlobalAdmin: true,
isGlobalMaintainer: false,
isAnyTeamAdmin: false,
isAnyTeamMaintainer: false,
isGlobalTechnician: false,
isAnyTeamTechnician: false,
isPremiumTier: true,
isMacMdmEnabledAndConfigured: true,
isWindowsMdmEnabledAndConfigured: true,
isAndroidMdmEnabledAndConfigured: false,
isNoAccess: false,
isOnlyObserver: false,
availableTeams: [
{ id: -1, name: "All fleets" },
{ id: 1, name: "Engineering" },
{ id: 2, name: "Sales" },
],
currentTeam: { id: 1, name: "Engineering" },
},
},
});
const observerRender = createCustomRenderer({
withBackendMock: true,
context: {
app: {
currentUser: {
id: 2,
name: "Observer",
email: "observer@fleet.co",
global_role: "observer",
},
config: createMockConfig(),
isGlobalAdmin: false,
isGlobalMaintainer: false,
isAnyTeamAdmin: false,
isAnyTeamMaintainer: false,
isGlobalTechnician: false,
isAnyTeamTechnician: false,
isPremiumTier: true,
isMacMdmEnabledAndConfigured: true,
isWindowsMdmEnabledAndConfigured: true,
isAndroidMdmEnabledAndConfigured: false,
isNoAccess: false,
isOnlyObserver: true,
availableTeams: [
{ id: -1, name: "All fleets" },
{ id: 1, name: "Engineering" },
],
currentTeam: { id: 1, name: "Engineering" },
},
},
});
const openPalette = async (user: ReturnType<typeof adminRender>["user"]) => {
await user.keyboard("{Meta>}k{/Meta}");
await waitFor(() => {
expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument();
});
};
describe("CommandPalette", () => {
describe("Opening and closing", () => {
it("renders nothing when closed", () => {
adminRender(<CommandPalette />);
expect(
screen.queryByLabelText("Command palette")
).not.toBeInTheDocument();
});
it("opens on Cmd+K", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument();
});
it("Ctrl+K does NOT open the palette on macOS (Cmd is required)", async () => {
// Ctrl+K is readline kill-line on macOS — we must not hijack it.
const { user } = adminRender(<CommandPalette />);
await user.keyboard("{Control>}k{/Control}");
expect(screen.queryByPlaceholderText(/search/i)).not.toBeInTheDocument();
});
it("Ctrl+K opens the palette on non-macOS platforms", async () => {
setPlatform("Win32");
const { user } = adminRender(<CommandPalette />);
await user.keyboard("{Control>}k{/Control}");
await waitFor(() => {
expect(screen.getByPlaceholderText(/search/i)).toBeInTheDocument();
});
});
it("Cmd+K does NOT open the palette on non-macOS platforms", async () => {
setPlatform("Win32");
const { user } = adminRender(<CommandPalette />);
await user.keyboard("{Meta>}k{/Meta}");
expect(screen.queryByPlaceholderText(/search/i)).not.toBeInTheDocument();
});
it("closes on Escape", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
await user.keyboard("{Escape}");
await waitFor(() => {
expect(
screen.queryByPlaceholderText(/search/i)
).not.toBeInTheDocument();
});
});
it("closes when Cmd+K is pressed again", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
await user.keyboard("{Meta>}k{/Meta}");
await waitFor(() => {
expect(
screen.queryByPlaceholderText(/search/i)
).not.toBeInTheDocument();
});
});
it("resets search when reopened", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
// Type something then close
await user.keyboard("dashboard");
await user.keyboard("{Escape}");
// Reopen — input should be empty
await openPalette(user);
expect(screen.getByPlaceholderText(/search/i)).toHaveValue("");
});
});
describe("Rendering items", () => {
it("shows page items when open", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
expect(screen.getByText("Dashboard")).toBeInTheDocument();
expect(screen.getByText("Hosts")).toBeInTheDocument();
expect(screen.getByText("Policies")).toBeInTheDocument();
});
it("shows group headings", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
expect(screen.getByText("Pages")).toBeInTheDocument();
expect(screen.getByText("Commands")).toBeInTheDocument();
});
it("shows team name on team-scoped actions", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
// Multiple items should show the team name
const teamLabels = screen.getAllByText("Engineering");
expect(teamLabels.length).toBeGreaterThan(0);
});
// cmdk only renders Command.Empty when zero items match — can't trigger
// in JSDOM since cmdk filtering doesn't respond to DOM events.
it.todo("shows 'No results found.' for unmatched search");
});
describe("Fleet switcher header", () => {
it("shows the current fleet on the header switcher button", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
const switcher = screen.getByRole("button", { name: /Engineering/ });
expect(switcher).toBeInTheDocument();
});
it("navigates to the switch-fleet page when the header button is clicked", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
const switcher = screen.getByRole("button", { name: /Engineering/ });
await user.click(switcher);
expect(
screen.getByPlaceholderText("Search a fleet...")
).toBeInTheDocument();
});
});
describe("Keyboard shortcuts", () => {
it("opens the switch-fleet picker page on Cmd+Shift+F", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
await user.keyboard("{Meta>}{Shift>}f{/Shift}{/Meta}");
await waitFor(() => {
expect(
screen.getByPlaceholderText("Search a fleet...")
).toBeInTheDocument();
});
});
it("Cmd+Shift+F also opens the palette directly to switch-fleet from closed", async () => {
const { user } = adminRender(<CommandPalette />);
// Don't openPalette first — verify cold-start behavior.
await user.keyboard("{Meta>}{Shift>}f{/Shift}{/Meta}");
await waitFor(() => {
expect(
screen.getByPlaceholderText("Search a fleet...")
).toBeInTheDocument();
});
});
it("Ctrl+Shift+F does NOT open switch-fleet on macOS (Cmd is required)", async () => {
const { user } = adminRender(<CommandPalette />);
await user.keyboard("{Control>}{Shift>}f{/Shift}{/Control}");
expect(
screen.queryByPlaceholderText("Search a fleet...")
).not.toBeInTheDocument();
});
it("Ctrl+Shift+F opens switch-fleet on non-macOS platforms", async () => {
setPlatform("Win32");
const { user } = adminRender(<CommandPalette />);
await user.keyboard("{Control>}{Shift>}f{/Shift}{/Control}");
await waitFor(() => {
expect(
screen.getByPlaceholderText("Search a fleet...")
).toBeInTheDocument();
});
});
it("Cmd+Shift+F does NOT open switch-fleet on non-macOS platforms", async () => {
setPlatform("Win32");
const { user } = adminRender(<CommandPalette />);
await user.keyboard("{Meta>}{Shift>}f{/Shift}{/Meta}");
expect(
screen.queryByPlaceholderText("Search a fleet...")
).not.toBeInTheDocument();
});
it("Escape returns to root from a picker page instead of closing", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
// Navigate into the switch-fleet picker page
await user.keyboard("{Meta>}{Shift>}f{/Shift}{/Meta}");
await waitFor(() => {
expect(
screen.getByPlaceholderText("Search a fleet...")
).toBeInTheDocument();
});
// ESC should take us back to root, not close the dialog
await user.keyboard("{Escape}");
await waitFor(() => {
expect(
screen.getByPlaceholderText("Search for a page or command...")
).toBeInTheDocument();
});
});
it("Escape returns to root from a picker page (view-host)", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
// The root page lists commands; find "View host" and activate it
// to reach the view-host picker page.
const viewHost = await screen.findByText("View host");
await user.click(viewHost);
await waitFor(() => {
expect(
screen.getByPlaceholderText("Search hosts...")
).toBeInTheDocument();
});
// ESC takes us back to root, NOT closing the dialog.
await user.keyboard("{Escape}");
await waitFor(() => {
expect(
screen.getByPlaceholderText("Search for a page or command...")
).toBeInTheDocument();
});
});
it("Escape closes the palette when on the root page", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
await user.keyboard("{Escape}");
await waitFor(() => {
expect(
screen.queryByPlaceholderText(/search/i)
).not.toBeInTheDocument();
});
});
it("Backspace on empty input goes back from a picker page", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
await user.keyboard("{Meta>}{Shift>}f{/Shift}{/Meta}");
await waitFor(() => {
expect(
screen.getByPlaceholderText("Search a fleet...")
).toBeInTheDocument();
});
// Backspace with empty input → root page
await user.keyboard("{Backspace}");
await waitFor(() => {
expect(
screen.getByPlaceholderText("Search for a page or command...")
).toBeInTheDocument();
});
});
});
describe("Sign out", () => {
it("renders Sign out under the Commands group", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
expect(screen.getByText("Sign out")).toBeInTheDocument();
});
});
describe("Dark mode reactivity", () => {
it("updates the toggle-dark-mode label on fleet-theme-change events", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
// Initial render — theme defaults to light in tests.
expect(screen.getByText("Switch to dark mode")).toBeInTheDocument();
// Simulate the theme flipping to dark from elsewhere (system theme,
// another tab, sibling component).
window.dispatchEvent(
new CustomEvent("fleet-theme-change", { detail: { dark: true } })
);
await waitFor(() => {
expect(screen.getByText("Switch to light mode")).toBeInTheDocument();
});
});
});
describe("Sub-items", () => {
it("shows chevron on items with sub-items", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
// OS settings has sub-items
const osSettingsItem = screen
.getByText("OS settings")
.closest(`.command-palette__item`);
expect(
osSettingsItem?.querySelector(`.command-palette__item-more`)
).toBeInTheDocument();
});
it("auto-expands sub-items when a parent is highlighted via arrow keys", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
// Sub-items hidden initially.
expect(screen.queryByText("Disk encryption")).not.toBeInTheDocument();
// Arrow down until OS settings is highlighted (aria-selected="true").
// Derive the upper bound from the number of rendered items so this
// doesn't silently miss if the list grows or reorders.
const itemCount = document.querySelectorAll(`.command-palette__item`)
.length;
const maxPresses = itemCount + 2;
let osSettingsItem: Element | null = null;
for (let i = 0; i < maxPresses; i += 1) {
osSettingsItem = screen
.getByText("OS settings")
.closest(`.command-palette__item`);
if (osSettingsItem?.getAttribute("aria-selected") === "true") {
break;
}
// eslint-disable-next-line no-await-in-loop
await user.keyboard("{ArrowDown}");
}
// Assert OS settings actually got selected so a failure here points
// at the navigation step, not at the expansion check below.
expect(osSettingsItem?.getAttribute("aria-selected")).toBe("true");
await waitFor(() => {
expect(screen.getByText("Disk encryption")).toBeInTheDocument();
});
});
it("does not auto-expand sub-items when a parent is hovered with the mouse", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
const osSettingsItem = screen
.getByText("OS settings")
.closest(`.command-palette__item`);
expect(osSettingsItem).toBeInTheDocument();
// Hovering moves cmdk's selected value (selection-follows-pointer)
// but must not pop sub-items open — that should only happen on
// keyboard nav. The expand/collapse bridge runs through a
// useEffect, so wrap the negative assertion in waitFor to make
// sure pending effects have flushed before we conclude that
// nothing expanded.
await user.hover(osSettingsItem!);
await waitFor(() => {
expect(screen.queryByText("Disk encryption")).not.toBeInTheDocument();
});
});
it("expands sub-items on chevron click", async () => {
const { user } = adminRender(<CommandPalette />);
await openPalette(user);
// Sub-items should not be visible initially
expect(screen.queryByText("Disk encryption")).not.toBeInTheDocument();
// fireEvent here, not user.click — cmdk's userEvent-aware
// selection handlers fire onSelect on the parent Command.Item
// and navigate before the chevron's own click handler runs.
// fireEvent.click dispatches a bare click that respects the
// chevron's stopPropagation. (See cmdk + @testing-library/user-event
// v14 compatibility.)
const chevron = screen
.getByText("OS settings")
.closest(`.command-palette__item`)
?.querySelector(`.command-palette__item-more`);
expect(chevron).toBeInTheDocument();
fireEvent.click(chevron!);
await waitFor(() => {
expect(screen.getByText("Disk encryption")).toBeInTheDocument();
});
});
});
describe("Permission gating", () => {
it("renders nothing for isNoAccess users", () => {
const noAccessRender = createCustomRenderer({
withBackendMock: true,
context: {
app: {
isNoAccess: true,
currentUser: {
id: 1,
name: "No Access",
email: "noaccess@fleet.co",
global_role: null,
},
config: createMockConfig(),
},
},
});
noAccessRender(<CommandPalette />);
expect(
screen.queryByLabelText("Command palette")
).not.toBeInTheDocument();
});
it("does not intercept Cmd+K for isNoAccess users", async () => {
const noAccessRender = createCustomRenderer({
withBackendMock: true,
context: {
app: {
isNoAccess: true,
currentUser: {
id: 1,
name: "No Access",
email: "noaccess@fleet.co",
global_role: null,
},
config: createMockConfig(),
},
},
});
const { user } = noAccessRender(<CommandPalette />);
const onKeyDown = jest.fn();
document.addEventListener("keydown", onKeyDown);
await user.keyboard("{Meta>}k{/Meta}");
// If the palette had registered its handler, it would have called
// preventDefault on the synthetic event before our listener saw it.
expect(onKeyDown).toHaveBeenCalled();
expect(onKeyDown.mock.calls[0][0].defaultPrevented).toBe(false);
// And the palette must remain unrendered.
expect(screen.queryByPlaceholderText(/search/i)).not.toBeInTheDocument();
document.removeEventListener("keydown", onKeyDown);
});
it("hides Actions and Controls for observers", async () => {
const { user } = observerRender(<CommandPalette />);
await openPalette(user);
// Pages should still be visible
expect(screen.getByText("Dashboard")).toBeInTheDocument();
// Actions and Controls should not appear
expect(screen.queryByText("Add hosts")).not.toBeInTheDocument();
expect(screen.queryByText("Add report")).not.toBeInTheDocument();
expect(screen.queryByText("OS updates")).not.toBeInTheDocument();
});
it("hides Settings group for non-admins", async () => {
const { user } = observerRender(<CommandPalette />);
await openPalette(user);
expect(
screen.queryByText("Organization settings")
).not.toBeInTheDocument();
expect(screen.queryByText("Integrations")).not.toBeInTheDocument();
});
});
// cmdk manages its own internal filtering state and doesn't respond to
// DOM events in JSDOM. Filtering logic is covered in helpers.tests.ts.
it.todo("filters items based on search input");
});