**Related issue:** Resolves #41470 Adds support for uploading Python (`.py`) script-only software packages — accepted as script-only (the file contents become the install script; advanced options and automatic install follow `.sh`/`.ps1`), assigned the new `py_packages` source, and installable on macOS and Linux hosts across the UI, REST API, and GitOps. Feature branch combining the backend (#48942) and frontend (#48946) sub-PRs. # Checklist for submitter - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for Python (`.py`) script-only software packages across UI uploads, API/self-service installs, and GitOps parsing. * Python installers now derive metadata correctly and render the proper Python icon, with install eligibility for macOS & Linux. * **Bug Fixes** * Improved installer-script validation and “supported file types” error messages to include `.py` (and consistent handling of related script fields/options). * **Tests** * Expanded unit, integration, and GitOps tests to cover Python package parsing, metadata derivation, platform/host eligibility, and UI rendering. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1551 lines
58 KiB
TypeScript
1551 lines
58 KiB
TypeScript
import createMockConfig from "__mocks__/configMock";
|
|
import paths from "router/paths";
|
|
|
|
import {
|
|
buildFleetSwitchUrl,
|
|
buildPaletteItems,
|
|
computeBestMatch,
|
|
GROUPS,
|
|
highlightMatches,
|
|
ICommandItem,
|
|
ICommandPaletteContext,
|
|
pathSupportsAllFleets,
|
|
pathSupportsUnassigned,
|
|
scoreMatch,
|
|
SCORE_KEYWORD_EXACT,
|
|
SCORE_KEYWORD_PREFIX,
|
|
SCORE_KEYWORD_WORD_PREFIX,
|
|
SCORE_LABEL_EXACT,
|
|
SCORE_LABEL_PREFIX,
|
|
SCORE_LABEL_SUBSTRING,
|
|
SCORE_LABEL_WORD_PREFIX,
|
|
} from "./helpers";
|
|
|
|
const BASE_CONTEXT: ICommandPaletteContext = {
|
|
search: "",
|
|
currentTeam: undefined,
|
|
config: createMockConfig(),
|
|
canAccessControls: true,
|
|
canAccessVariables: true,
|
|
canWrite: true,
|
|
canRunLiveReport: true,
|
|
canAccessSettings: true,
|
|
canManagePolicyAutomations: true,
|
|
canManageSoftwareAutomations: true,
|
|
canManageReportAutomations: true,
|
|
canEditCustomVariable: true,
|
|
canAddSoftware: true,
|
|
isAdminOrMaintainer: true,
|
|
isTechnician: false,
|
|
isPremiumTier: true,
|
|
isMacMdmEnabledAndConfigured: true,
|
|
isWindowsMdmEnabledAndConfigured: true,
|
|
isAndroidMdmEnabledAndConfigured: false,
|
|
isVppEnabled: false,
|
|
hasTeamSelected: false,
|
|
|
|
withTeamId: (path: string) => path,
|
|
onToggleDarkMode: jest.fn(),
|
|
onViewHost: jest.fn(),
|
|
onViewSoftware: jest.fn(),
|
|
onViewSoftwareLibrary: jest.fn(),
|
|
onViewReport: jest.fn(),
|
|
onViewPolicy: jest.fn(),
|
|
};
|
|
|
|
describe("CommandPalette helpers", () => {
|
|
describe("GROUPS", () => {
|
|
it("contains all expected groups in order", () => {
|
|
expect(GROUPS).toEqual([
|
|
"Pages",
|
|
"Controls",
|
|
"Software",
|
|
"Settings",
|
|
"MDM",
|
|
"Automations",
|
|
"Commands",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("buildPaletteItems", () => {
|
|
it("returns items for a global admin with a team selected", () => {
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
hasTeamSelected: true,
|
|
currentTeam: { id: 1, name: "Engineering" },
|
|
});
|
|
expect(items.length).toBeGreaterThan(0);
|
|
|
|
const ids = items.map((i) => i.id);
|
|
expect(ids).toContain("dashboard");
|
|
expect(ids).toContain("hosts");
|
|
expect(ids).toContain("controls-page");
|
|
expect(ids).toContain("software-page");
|
|
expect(ids).toContain("reports");
|
|
expect(ids).toContain("policies");
|
|
expect(ids).toContain("settings-page");
|
|
});
|
|
|
|
it("hides controls on All fleets", () => {
|
|
const items = buildPaletteItems(BASE_CONTEXT);
|
|
const ids = items.map((i) => i.id);
|
|
expect(ids).not.toContain("controls-page");
|
|
expect(ids).not.toContain("controls-os-updates");
|
|
expect(ids).not.toContain("controls-os-settings");
|
|
});
|
|
|
|
it("excludes controls for observers", () => {
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
canAccessControls: false,
|
|
canWrite: false,
|
|
canAccessSettings: false,
|
|
canManagePolicyAutomations: false,
|
|
canManageSoftwareAutomations: false,
|
|
});
|
|
|
|
const ids = items.map((i) => i.id);
|
|
expect(ids).not.toContain("controls-page");
|
|
expect(ids).not.toContain("controls-os-updates");
|
|
expect(ids).not.toContain("settings-page");
|
|
expect(ids).not.toContain("add-hosts");
|
|
});
|
|
|
|
it("keeps toggle-dark-mode and sign-out available for observers (no write)", () => {
|
|
// Theme is a per-user UI preference exposed via My Account → Theme
|
|
// for every signed-in user, so the palette item must survive a
|
|
// canWrite=false context. Sign out is the other always-on item.
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
canAccessControls: false,
|
|
canWrite: false,
|
|
canAccessSettings: false,
|
|
canManagePolicyAutomations: false,
|
|
canManageSoftwareAutomations: false,
|
|
});
|
|
|
|
const ids = items.map((i) => i.id);
|
|
expect(ids).toContain("toggle-dark-mode");
|
|
expect(ids).toContain("sign-out");
|
|
});
|
|
|
|
it("excludes settings for non-global-admins", () => {
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
canAccessSettings: false,
|
|
canManageSoftwareAutomations: false,
|
|
});
|
|
|
|
const ids = items.map((i) => i.id);
|
|
expect(ids).not.toContain("settings-page");
|
|
expect(ids).not.toContain("settings-organization");
|
|
expect(ids).not.toContain("settings-integrations");
|
|
expect(ids).not.toContain("manage-software-automations");
|
|
});
|
|
|
|
it("shows packs only when searching for 'packs'", () => {
|
|
const itemsNoSearch = buildPaletteItems(BASE_CONTEXT);
|
|
expect(itemsNoSearch.map((i) => i.id)).not.toContain("packs");
|
|
|
|
const itemsWithSearch = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
search: "packs",
|
|
});
|
|
const ids = itemsWithSearch.map((i) => i.id);
|
|
expect(ids).toContain("packs");
|
|
expect(ids).toContain("new-pack");
|
|
});
|
|
|
|
it("does not show packs when searching for 'package'", () => {
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
search: "package",
|
|
});
|
|
expect(items.map((i) => i.id)).not.toContain("packs");
|
|
});
|
|
|
|
it("omits the teamName chip when destination matches current context", () => {
|
|
// On Engineering, every action either stays on Engineering or goes
|
|
// there — no chip should render.
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
hasTeamSelected: true,
|
|
currentTeam: { id: 1, name: "Engineering" },
|
|
});
|
|
|
|
const addHosts = items.find((i) => i.id === "add-hosts");
|
|
expect(addHosts?.teamName).toBeUndefined();
|
|
|
|
const addReport = items.find((i) => i.id === "add-report");
|
|
expect(addReport?.teamName).toBeUndefined();
|
|
});
|
|
|
|
it("omits the chip on add-hosts and manage-enroll-secrets when on All fleets", () => {
|
|
// These commands intentionally do NOT switch teams from All fleets —
|
|
// the destination page reads the existing team context, which on
|
|
// All fleets means global enroll secrets. So no chip should render.
|
|
const items = buildPaletteItems(BASE_CONTEXT);
|
|
|
|
const addHosts = items.find((i) => i.id === "add-hosts");
|
|
expect(addHosts?.teamName).toBeUndefined();
|
|
|
|
const enrollSecrets = items.find((i) => i.id === "manage-enroll-secrets");
|
|
expect(enrollSecrets?.teamName).toBeUndefined();
|
|
});
|
|
|
|
it("omits the 'All fleets' chip on default-context actions when already on All fleets", () => {
|
|
// add-report stays on All fleets when invoked from All fleets — no
|
|
// switch, no chip.
|
|
const items = buildPaletteItems(BASE_CONTEXT);
|
|
const addReport = items.find((i) => i.id === "add-report");
|
|
expect(addReport?.teamName).toBeUndefined();
|
|
});
|
|
|
|
it("shows 'All fleets' on default-context actions when on Unassigned", () => {
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
hasTeamSelected: false,
|
|
currentTeam: { id: 0, name: "No team" },
|
|
});
|
|
|
|
const addReport = items.find((i) => i.id === "add-report");
|
|
expect(addReport?.teamName).toBe("All fleets");
|
|
});
|
|
|
|
it("omits the 'Unassigned' chip on add-hosts when already on Unassigned", () => {
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
hasTeamSelected: false,
|
|
currentTeam: { id: 0, name: "No team" },
|
|
});
|
|
|
|
const addHosts = items.find((i) => i.id === "add-hosts");
|
|
expect(addHosts?.teamName).toBeUndefined();
|
|
});
|
|
|
|
it("shows 'Turn on' MDM when not configured", () => {
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
isMacMdmEnabledAndConfigured: false,
|
|
isWindowsMdmEnabledAndConfigured: false,
|
|
isAndroidMdmEnabledAndConfigured: false,
|
|
});
|
|
|
|
const ids = items.map((i) => i.id);
|
|
expect(ids).toContain("turn-on-apple-mdm");
|
|
expect(ids).toContain("turn-on-windows-mdm");
|
|
expect(ids).toContain("turn-on-android-mdm");
|
|
expect(ids).not.toContain("edit-apple-mdm");
|
|
expect(ids).not.toContain("edit-windows-mdm");
|
|
});
|
|
|
|
it("shows 'Edit' MDM when configured", () => {
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
isMacMdmEnabledAndConfigured: true,
|
|
isWindowsMdmEnabledAndConfigured: true,
|
|
isAndroidMdmEnabledAndConfigured: true,
|
|
});
|
|
|
|
const ids = items.map((i) => i.id);
|
|
expect(ids).toContain("edit-apple-mdm");
|
|
expect(ids).toContain("edit-windows-mdm");
|
|
expect(ids).toContain("edit-android-mdm");
|
|
expect(ids).not.toContain("turn-on-apple-mdm");
|
|
});
|
|
|
|
it("shows 'Add AB' when Apple MDM on but AB not configured", () => {
|
|
const configNoAbm = createMockConfig();
|
|
configNoAbm.mdm = {
|
|
...configNoAbm.mdm,
|
|
apple_bm_enabled_and_configured: false,
|
|
};
|
|
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
config: configNoAbm,
|
|
});
|
|
|
|
const abm = items.find((i) => i.id === "add-abm");
|
|
expect(abm).toBeDefined();
|
|
expect(abm?.label).toContain("Add");
|
|
});
|
|
|
|
it("shows 'Edit AB' when AB is configured", () => {
|
|
const items = buildPaletteItems(BASE_CONTEXT);
|
|
|
|
const abm = items.find((i) => i.id === "edit-abm");
|
|
expect(abm).toBeDefined();
|
|
expect(abm?.label).toContain("Edit");
|
|
});
|
|
|
|
it("shows 'Edit VPP' when VPP is enabled", () => {
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
isVppEnabled: true,
|
|
});
|
|
|
|
const vpp = items.find((i) => i.id === "edit-vpp");
|
|
expect(vpp).toBeDefined();
|
|
expect(vpp?.label).toContain("Edit");
|
|
});
|
|
|
|
it("exposes Manage policy automations as a flat entry that deep-links into AutomationsModal", () => {
|
|
// Previously the palette listed Tickets & webhooks / Install
|
|
// software / Run script / Calendar / Conditional access as
|
|
// sub-items, each with its own ?manage_automations=<section> URL.
|
|
// ManagePoliciesPage never parsed those params, so all five were
|
|
// dead links. AutomationsModal also dropped its Install software
|
|
// / Run script sections (those are per-policy now), and the modal
|
|
// has no per-section URL trigger. The palette now mirrors the
|
|
// reports pattern: a single entry whose path is
|
|
// /policies?manage_automations=1, which the page reads to open
|
|
// the modal at its single shared body.
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
hasTeamSelected: true,
|
|
currentTeam: { id: 1, name: "Engineering" },
|
|
});
|
|
|
|
const policyAutomations = items.find(
|
|
(i) => i.id === "manage-policy-automations"
|
|
);
|
|
expect(policyAutomations).toBeDefined();
|
|
expect(policyAutomations?.subItems).toBeUndefined();
|
|
expect(policyAutomations?.path).toContain("manage_automations=1");
|
|
});
|
|
|
|
it("hides calendar + conditional-access keywords on All fleets (modal renders only Webhooks/tickets there)", () => {
|
|
const items = buildPaletteItems(BASE_CONTEXT);
|
|
const policyAutomations = items.find(
|
|
(i) => i.id === "manage-policy-automations"
|
|
);
|
|
const keywords = policyAutomations?.keywords ?? [];
|
|
expect(keywords).toContain("webhook");
|
|
expect(keywords).not.toContain("calendar");
|
|
expect(keywords).not.toContain("google calendar");
|
|
expect(keywords).not.toContain("conditional access");
|
|
expect(keywords).not.toContain("sso");
|
|
});
|
|
|
|
it("surfaces Add custom package when searching py / python (.py is an accepted upload type)", () => {
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
hasTeamSelected: true,
|
|
currentTeam: { id: 1, name: "Engineering" },
|
|
});
|
|
const addCustomPackage = items.find((i) => i.id === "add-custom-package");
|
|
expect(addCustomPackage).toBeDefined();
|
|
const keywords = addCustomPackage?.keywords ?? [];
|
|
expect(keywords).toContain("py");
|
|
expect(keywords).toContain("python");
|
|
});
|
|
|
|
it("hides calendar keywords on Unassigned (Calendar section is disabled there) but keeps conditional access", () => {
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
hasTeamSelected: false,
|
|
currentTeam: { id: 0, name: "No team" },
|
|
});
|
|
const policyAutomations = items.find(
|
|
(i) => i.id === "manage-policy-automations"
|
|
);
|
|
const keywords = policyAutomations?.keywords ?? [];
|
|
expect(keywords).toContain("webhook");
|
|
expect(keywords).not.toContain("calendar");
|
|
expect(keywords).not.toContain("google calendar");
|
|
expect(keywords).toContain("conditional access");
|
|
expect(keywords).toContain("sso");
|
|
});
|
|
|
|
it("includes all keywords on a specific team", () => {
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
hasTeamSelected: true,
|
|
currentTeam: { id: 1, name: "Engineering" },
|
|
});
|
|
const policyAutomations = items.find(
|
|
(i) => i.id === "manage-policy-automations"
|
|
);
|
|
const keywords = policyAutomations?.keywords ?? [];
|
|
expect(keywords).toContain("webhook");
|
|
expect(keywords).toContain("calendar");
|
|
expect(keywords).toContain("google calendar");
|
|
expect(keywords).toContain("conditional access");
|
|
expect(keywords).toContain("sso");
|
|
});
|
|
|
|
it("excludes certificates and passwords for technicians", () => {
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
isTechnician: true,
|
|
isAdminOrMaintainer: false,
|
|
hasTeamSelected: true,
|
|
currentTeam: { id: 1, name: "Engineering" },
|
|
});
|
|
|
|
const osSettings = items.find((i) => i.id === "controls-os-settings");
|
|
const subIds = osSettings?.subItems?.map((s) => s.id) ?? [];
|
|
expect(subIds).not.toContain("controls-certificates");
|
|
expect(subIds).not.toContain("controls-passwords");
|
|
expect(subIds).toContain("controls-disk-encryption");
|
|
});
|
|
|
|
it("includes certificates and passwords for non-technicians", () => {
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
hasTeamSelected: true,
|
|
currentTeam: { id: 1, name: "Engineering" },
|
|
});
|
|
|
|
const osSettings = items.find((i) => i.id === "controls-os-settings");
|
|
const subIds = osSettings?.subItems?.map((s) => s.id) ?? [];
|
|
expect(subIds).toContain("controls-certificates");
|
|
expect(subIds).toContain("controls-passwords");
|
|
});
|
|
|
|
it("includes Variables with its Global variables and Custom host vitals sub-tabs when canAccessVariables", () => {
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
hasTeamSelected: true,
|
|
currentTeam: { id: 1, name: "Engineering" },
|
|
});
|
|
|
|
const variables = items.find((i) => i.id === "controls-variables");
|
|
const subIds = variables?.subItems?.map((s) => s.id) ?? [];
|
|
expect(subIds).toContain("controls-global-variables");
|
|
expect(subIds).toContain("controls-custom-host-vitals");
|
|
});
|
|
|
|
it("hides Variables entirely when !canAccessVariables (technicians)", () => {
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
canAccessVariables: false,
|
|
isTechnician: true,
|
|
hasTeamSelected: true,
|
|
currentTeam: { id: 1, name: "Engineering" },
|
|
});
|
|
|
|
const ids = items.map((i) => i.id);
|
|
expect(ids).not.toContain("controls-variables");
|
|
});
|
|
|
|
it("includes Host names for admin/maintainer on a fleet", () => {
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
hasTeamSelected: true,
|
|
currentTeam: { id: 1, name: "Engineering" },
|
|
});
|
|
|
|
const osSettings = items.find((i) => i.id === "controls-os-settings");
|
|
const subIds = osSettings?.subItems?.map((s) => s.id) ?? [];
|
|
expect(subIds).toContain("controls-host-name-template");
|
|
});
|
|
|
|
it("includes Host names for admin/maintainer on 'No team'", () => {
|
|
// The template is supported for "No team" too. Unassigned satisfies
|
|
// hasTeamOrUnassigned, so the Controls group and the Host names entry
|
|
// both render.
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
hasTeamSelected: false,
|
|
currentTeam: { id: 0, name: "No team" },
|
|
});
|
|
|
|
const osSettings = items.find((i) => i.id === "controls-os-settings");
|
|
const subIds = osSettings?.subItems?.map((s) => s.id) ?? [];
|
|
expect(subIds).toContain("controls-host-name-template");
|
|
});
|
|
|
|
it("excludes Host names for technicians", () => {
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
isTechnician: true,
|
|
isAdminOrMaintainer: false,
|
|
hasTeamSelected: true,
|
|
currentTeam: { id: 1, name: "Engineering" },
|
|
});
|
|
|
|
const osSettings = items.find((i) => i.id === "controls-os-settings");
|
|
const subIds = osSettings?.subItems?.map((s) => s.id) ?? [];
|
|
expect(subIds).not.toContain("controls-host-name-template");
|
|
});
|
|
|
|
it("appends fleet_id via withTeamId for team-scoped paths", () => {
|
|
const mockWithTeamId = (path: string) => `${path}?fleet_id=5`;
|
|
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
withTeamId: mockWithTeamId,
|
|
hasTeamSelected: true,
|
|
currentTeam: { id: 5, name: "Eng" },
|
|
});
|
|
|
|
const dashboard = items.find((i) => i.id === "dashboard");
|
|
expect(dashboard?.path).toContain("fleet_id=5");
|
|
});
|
|
|
|
it("includes manage software automations without a teamName chip on All fleets", () => {
|
|
// Only visible on All fleets, destination is All fleets — no switch,
|
|
// no chip.
|
|
const items = buildPaletteItems(BASE_CONTEXT);
|
|
|
|
const swAuto = items.find((i) => i.id === "manage-software-automations");
|
|
expect(swAuto).toBeDefined();
|
|
expect(swAuto?.teamName).toBeUndefined();
|
|
});
|
|
|
|
it("calls onToggleDarkMode for the dark mode item", () => {
|
|
const mockToggle = jest.fn();
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
onToggleDarkMode: mockToggle,
|
|
});
|
|
|
|
const darkMode = items.find((i) => i.id === "toggle-dark-mode");
|
|
expect(darkMode).toBeDefined();
|
|
darkMode?.onAction?.();
|
|
expect(mockToggle).toHaveBeenCalled();
|
|
});
|
|
|
|
it("toggle-dark-mode label reflects the isDarkMode context flag", () => {
|
|
const lightItems = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
isDarkMode: false,
|
|
});
|
|
expect(lightItems.find((i) => i.id === "toggle-dark-mode")?.label).toBe(
|
|
"Switch to dark mode"
|
|
);
|
|
|
|
const darkItems = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
isDarkMode: true,
|
|
});
|
|
expect(darkItems.find((i) => i.id === "toggle-dark-mode")?.label).toBe(
|
|
"Switch to light mode"
|
|
);
|
|
});
|
|
|
|
it("hides software add, script, and variable actions on All fleets", () => {
|
|
const items = buildPaletteItems(BASE_CONTEXT);
|
|
const ids = items.map((i) => i.id);
|
|
|
|
expect(ids).not.toContain("add-fleet-maintained-app");
|
|
expect(ids).not.toContain("add-vpp-app");
|
|
expect(ids).not.toContain("add-android-app-store-app");
|
|
expect(ids).not.toContain("add-custom-package");
|
|
expect(ids).not.toContain("add-self-service-category");
|
|
expect(ids).not.toContain("add-script");
|
|
expect(ids).not.toContain("add-custom-variable");
|
|
});
|
|
|
|
it("shows software add, script, and variable actions on Unassigned", () => {
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
hasTeamSelected: false,
|
|
currentTeam: { id: 0, name: "No team" },
|
|
});
|
|
const ids = items.map((i) => i.id);
|
|
|
|
expect(ids).toContain("add-fleet-maintained-app");
|
|
expect(ids).toContain("add-vpp-app");
|
|
expect(ids).toContain("add-android-app-store-app");
|
|
expect(ids).toContain("add-custom-package");
|
|
expect(ids).toContain("add-self-service-category");
|
|
expect(ids).toContain("add-script");
|
|
expect(ids).toContain("add-custom-variable");
|
|
});
|
|
|
|
it("hides 'Add script' for technicians (page-side button is disabled for them)", () => {
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
hasTeamSelected: false,
|
|
currentTeam: { id: 0, name: "No team" },
|
|
isTechnician: true,
|
|
});
|
|
const ids = items.map((i) => i.id);
|
|
expect(ids).not.toContain("add-script");
|
|
});
|
|
|
|
it("hides every software-add action when !canAddSoftware (current-team observer / cross-team admin / technician)", () => {
|
|
// A user who is admin of a different team has canWrite (via
|
|
// isAnyTeamAdmin) but isTeamAdmin(currentTeam) is false. The
|
|
// Add software button hides on the page; the palette must too.
|
|
// Same for technicians, who pass canWrite but never canAddSoftware.
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
hasTeamSelected: false,
|
|
currentTeam: { id: 0, name: "No team" },
|
|
canAddSoftware: false,
|
|
});
|
|
const ids = items.map((i) => i.id);
|
|
|
|
expect(ids).not.toContain("add-fleet-maintained-app");
|
|
expect(ids).not.toContain("add-vpp-app");
|
|
expect(ids).not.toContain("add-android-app-store-app");
|
|
expect(ids).not.toContain("add-custom-package");
|
|
expect(ids).not.toContain("add-self-service-category");
|
|
// Sanity: non-software write actions still surface.
|
|
expect(ids).toContain("add-hosts");
|
|
});
|
|
|
|
it("hides 'Add custom variable' for team admins/maintainers (canWrite but !canEditCustomVariable)", () => {
|
|
// Mirrors a team-admin context: they have canWrite (so add-script,
|
|
// add-hosts, etc. show), but the Variables page rejects them, so
|
|
// the variable entry must not surface.
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
hasTeamSelected: false,
|
|
currentTeam: { id: 0, name: "No team" },
|
|
canEditCustomVariable: false,
|
|
});
|
|
const ids = items.map((i) => i.id);
|
|
|
|
expect(ids).toContain("add-script");
|
|
expect(ids).not.toContain("add-custom-variable");
|
|
});
|
|
|
|
it("hides the Users settings item for non-admins", () => {
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
canAccessSettings: false,
|
|
canManageSoftwareAutomations: false,
|
|
});
|
|
|
|
// `settings-users` lives in the Settings group and is gated on
|
|
// canAccessSettings. (The old `users-page` Pages-group entry was
|
|
// removed because it pointed to the same destination.)
|
|
expect(items.map((i) => i.id)).not.toContain("settings-users");
|
|
});
|
|
|
|
it("shows Software library on Unassigned but not All fleets", () => {
|
|
const allFleetsItems = buildPaletteItems(BASE_CONTEXT);
|
|
expect(allFleetsItems.map((i) => i.id)).not.toContain("software-library");
|
|
|
|
const unassignedItems = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
hasTeamSelected: false,
|
|
currentTeam: { id: 0, name: "No team" },
|
|
});
|
|
expect(unassignedItems.map((i) => i.id)).toContain("software-library");
|
|
});
|
|
|
|
it("includes Run live report and Run live policy for writers", () => {
|
|
const items = buildPaletteItems(BASE_CONTEXT);
|
|
const ids = items.map((i) => i.id);
|
|
|
|
expect(ids).toContain("run-live-report");
|
|
expect(ids).toContain("run-live-policy");
|
|
});
|
|
|
|
it("excludes Run live report and Run live policy for observers", () => {
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
canWrite: false,
|
|
canRunLiveReport: false,
|
|
canAccessControls: false,
|
|
canAccessSettings: false,
|
|
canManagePolicyAutomations: false,
|
|
canManageSoftwareAutomations: false,
|
|
});
|
|
|
|
const ids = items.map((i) => i.id);
|
|
expect(ids).not.toContain("run-live-report");
|
|
expect(ids).not.toContain("run-live-policy");
|
|
});
|
|
|
|
it("hides Manage report automations for non-admin writers (maintainers, technicians)", () => {
|
|
// A maintainer / technician: canWrite=true but not an admin anywhere,
|
|
// so the destination page won't expose Manage automations.
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
canWrite: true,
|
|
canManageReportAutomations: false,
|
|
});
|
|
expect(items.map((i) => i.id)).not.toContain("manage-report-automations");
|
|
});
|
|
|
|
it("shows Manage report automations when canManageReportAutomations is true", () => {
|
|
const items = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
canManageReportAutomations: true,
|
|
});
|
|
expect(items.map((i) => i.id)).toContain("manage-report-automations");
|
|
});
|
|
|
|
it("shows Add fleet only for admins", () => {
|
|
const adminItems = buildPaletteItems(BASE_CONTEXT);
|
|
expect(adminItems.map((i) => i.id)).toContain("create-fleet");
|
|
|
|
const nonAdminItems = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
canAccessSettings: false,
|
|
canManageSoftwareAutomations: false,
|
|
});
|
|
expect(nonAdminItems.map((i) => i.id)).not.toContain("create-fleet");
|
|
});
|
|
});
|
|
|
|
describe("Fleet Free (isPremiumTier: false)", () => {
|
|
// Mirror production state on Free: AppContext never sets currentTeam
|
|
// (no team picker exists), so hasTeamSelected stays false. The
|
|
// derivation treats Free as team-or-unassigned regardless.
|
|
const FREE_CONTEXT = {
|
|
...BASE_CONTEXT,
|
|
isPremiumTier: false,
|
|
hasTeamSelected: false as const,
|
|
currentTeam: undefined,
|
|
};
|
|
|
|
it("hides all software-add commands", () => {
|
|
const ids = buildPaletteItems(FREE_CONTEXT).map((i) => i.id);
|
|
expect(ids).not.toContain("add-fleet-maintained-app");
|
|
expect(ids).not.toContain("add-vpp-app");
|
|
expect(ids).not.toContain("add-android-app-store-app");
|
|
expect(ids).not.toContain("add-custom-package");
|
|
});
|
|
|
|
it("hides the Setup Experience parent and all its sub-items", () => {
|
|
const ids = buildPaletteItems(FREE_CONTEXT).map((i) => i.id);
|
|
expect(ids).not.toContain("controls-setup-experience");
|
|
// Sub-items live under controls-setup-experience.subItems; absence
|
|
// of the parent is sufficient.
|
|
});
|
|
|
|
it("hides Disk encryption, Certificates, Passwords, and Host names OS-settings sub-items", () => {
|
|
const osSettings = buildPaletteItems(FREE_CONTEXT).find(
|
|
(i) => i.id === "controls-os-settings"
|
|
);
|
|
const subIds = osSettings?.subItems?.map((s) => s.id) ?? [];
|
|
expect(subIds).not.toContain("controls-disk-encryption");
|
|
expect(subIds).not.toContain("controls-certificates");
|
|
expect(subIds).not.toContain("controls-passwords");
|
|
expect(subIds).not.toContain("controls-host-name-template");
|
|
// Configuration profiles is not premium-gated; keep it.
|
|
expect(subIds).toContain("controls-custom-settings");
|
|
});
|
|
|
|
it("hides MDM AB and VPP commands", () => {
|
|
const ids = buildPaletteItems(FREE_CONTEXT).map((i) => i.id);
|
|
expect(ids).not.toContain("add-abm");
|
|
expect(ids).not.toContain("edit-abm");
|
|
expect(ids).not.toContain("add-vpp");
|
|
expect(ids).not.toContain("edit-vpp");
|
|
});
|
|
|
|
it("hides premium integrations settings sub-items", () => {
|
|
const integrations = buildPaletteItems(FREE_CONTEXT).find(
|
|
(i) => i.id === "settings-integrations"
|
|
);
|
|
const subIds = integrations?.subItems?.map((s) => s.id) ?? [];
|
|
expect(subIds).not.toContain("settings-int-calendars");
|
|
expect(subIds).not.toContain("settings-int-change-management");
|
|
expect(subIds).not.toContain("settings-int-certificate-authorities");
|
|
expect(subIds).not.toContain("add-certificate-authority");
|
|
expect(subIds).not.toContain("settings-int-conditional-access");
|
|
});
|
|
|
|
it("hides settings-fleets, create-fleet, and view-software-library", () => {
|
|
const ids = buildPaletteItems(FREE_CONTEXT).map((i) => i.id);
|
|
expect(ids).not.toContain("settings-fleets");
|
|
expect(ids).not.toContain("create-fleet");
|
|
expect(ids).not.toContain("view-software-library");
|
|
});
|
|
|
|
it("hides the Controls page link (lands on OS updates premium wall on Free)", () => {
|
|
// /controls redirects to the first permitted tab, which is OS
|
|
// updates — a premium-walled page on Free. Free users still reach
|
|
// the tier-free Controls sub-pages via their own palette entries
|
|
// (OS settings, Scripts, Variables).
|
|
const ids = buildPaletteItems(FREE_CONTEXT).map((i) => i.id);
|
|
expect(ids).not.toContain("controls-page");
|
|
});
|
|
|
|
it("hides OS updates, SSO end-users, and Identity provider (each renders <PremiumFeatureMessage /> on Free)", () => {
|
|
const items = buildPaletteItems(FREE_CONTEXT);
|
|
const ids = items.map((i) => i.id);
|
|
expect(ids).not.toContain("controls-os-updates");
|
|
const integrations = items.find((i) => i.id === "settings-integrations");
|
|
const subIds = integrations?.subItems?.map((s) => s.id) ?? [];
|
|
expect(subIds).not.toContain("settings-int-sso-end-users");
|
|
expect(subIds).not.toContain("settings-int-identity-provider");
|
|
});
|
|
|
|
it("surfaces Free-available Controls items: OS settings, Scripts, Variables", () => {
|
|
const ids = buildPaletteItems(FREE_CONTEXT).map((i) => i.id);
|
|
expect(ids).toContain("controls-os-settings");
|
|
expect(ids).toContain("controls-scripts");
|
|
expect(ids).toContain("controls-variables");
|
|
});
|
|
|
|
it("surfaces Free-available Controls sub-items: Configuration profiles, Script library, Script batch progress", () => {
|
|
const items = buildPaletteItems(FREE_CONTEXT);
|
|
const osSettingsSubIds =
|
|
items
|
|
.find((i) => i.id === "controls-os-settings")
|
|
?.subItems?.map((s) => s.id) ?? [];
|
|
expect(osSettingsSubIds).toContain("controls-custom-settings");
|
|
const scriptsSubIds =
|
|
items
|
|
.find((i) => i.id === "controls-scripts")
|
|
?.subItems?.map((s) => s.id) ?? [];
|
|
expect(scriptsSubIds).toContain("controls-scripts-library");
|
|
expect(scriptsSubIds).toContain("controls-scripts-batch-progress");
|
|
const variablesSubIds =
|
|
items
|
|
.find((i) => i.id === "controls-variables")
|
|
?.subItems?.map((s) => s.id) ?? [];
|
|
expect(variablesSubIds).toContain("controls-global-variables");
|
|
expect(variablesSubIds).toContain("controls-custom-host-vitals");
|
|
});
|
|
|
|
it("surfaces Add script on Free (script library is Free-available)", () => {
|
|
const ids = buildPaletteItems(FREE_CONTEXT).map((i) => i.id);
|
|
expect(ids).toContain("add-script");
|
|
});
|
|
});
|
|
|
|
describe("Primo Mode (isPrimoMode: true)", () => {
|
|
const PRIMO_CONTEXT = {
|
|
...BASE_CONTEXT,
|
|
isPrimoMode: true,
|
|
hasTeamSelected: true as const,
|
|
currentTeam: { id: 7, name: "Default" },
|
|
};
|
|
|
|
it("hides settings-fleets and create-fleet", () => {
|
|
const ids = buildPaletteItems(PRIMO_CONTEXT).map((i) => i.id);
|
|
expect(ids).not.toContain("settings-fleets");
|
|
expect(ids).not.toContain("create-fleet");
|
|
});
|
|
|
|
it("shows manage-software-automations (Primo treats single fleet as all)", () => {
|
|
const ids = buildPaletteItems(PRIMO_CONTEXT).map((i) => i.id);
|
|
expect(ids).toContain("manage-software-automations");
|
|
});
|
|
|
|
it("suppresses all teamName chips because Primo never switches fleet context", () => {
|
|
const items = buildPaletteItems(PRIMO_CONTEXT);
|
|
const itemsWithChips = items.filter((i) => i.teamName !== undefined);
|
|
expect(itemsWithChips).toHaveLength(0);
|
|
});
|
|
|
|
it("does not synthesize an 'Unassigned' or 'All fleets' chip on add-hosts", () => {
|
|
const addHosts = buildPaletteItems(PRIMO_CONTEXT).find(
|
|
(i) => i.id === "add-hosts"
|
|
);
|
|
expect(addHosts?.teamName).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("GitOps Mode", () => {
|
|
it("hides create-fleet when GitOps mode is configured", () => {
|
|
const gitopsConfig = createMockConfig();
|
|
gitopsConfig.gitops = {
|
|
...gitopsConfig.gitops,
|
|
gitops_mode_enabled: true,
|
|
repository_url: "https://github.com/fleetdm/fleet-config",
|
|
};
|
|
|
|
const ids = buildPaletteItems({
|
|
...BASE_CONTEXT,
|
|
config: gitopsConfig,
|
|
}).map((i) => i.id);
|
|
|
|
expect(ids).not.toContain("create-fleet");
|
|
});
|
|
});
|
|
|
|
describe("buildFleetSwitchUrl", () => {
|
|
const parse = (url: string) => new URL(url, "http://localhost");
|
|
|
|
describe("team-required page fallback", () => {
|
|
it("redirects to /hosts/manage when switching to All from Controls", () => {
|
|
expect(
|
|
buildFleetSwitchUrl({
|
|
pathname: paths.CONTROLS,
|
|
currentSearch: "?fleet_id=1",
|
|
fleetId: -1,
|
|
})
|
|
).toBe(paths.MANAGE_HOSTS);
|
|
});
|
|
|
|
it("keeps fleet_id=0 on the fallback URL when switching to Unassigned from a page that doesn't support it", () => {
|
|
// NEW_REPORT uses includeNoTeam: false — Unassigned must bounce.
|
|
expect(
|
|
buildFleetSwitchUrl({
|
|
pathname: paths.NEW_REPORT,
|
|
currentSearch: "?fleet_id=1",
|
|
fleetId: 0,
|
|
})
|
|
).toBe(`${paths.MANAGE_HOSTS}?fleet_id=0`);
|
|
});
|
|
|
|
it("stays on Controls when switching to Unassigned (Controls supports includeNoTeam)", () => {
|
|
const url = parse(
|
|
buildFleetSwitchUrl({
|
|
pathname: paths.CONTROLS,
|
|
currentSearch: "?fleet_id=1",
|
|
fleetId: 0,
|
|
})
|
|
);
|
|
expect(url.pathname).toBe(paths.CONTROLS);
|
|
expect(url.searchParams.get("fleet_id")).toBe("0");
|
|
});
|
|
|
|
it("stays on Software Library when switching to Unassigned (Library supports includeNoTeam)", () => {
|
|
const url = parse(
|
|
buildFleetSwitchUrl({
|
|
pathname: paths.SOFTWARE_LIBRARY,
|
|
currentSearch: "?fleet_id=1&self_service=1",
|
|
fleetId: 0,
|
|
})
|
|
);
|
|
expect(url.pathname).toBe(paths.SOFTWARE_LIBRARY);
|
|
expect(url.searchParams.get("fleet_id")).toBe("0");
|
|
});
|
|
|
|
it("does not trigger the fallback on a non-team-required page", () => {
|
|
// /hosts/manage is not in TEAM_REQUIRED_PREFIXES.
|
|
const url = parse(
|
|
buildFleetSwitchUrl({
|
|
pathname: paths.MANAGE_HOSTS,
|
|
currentSearch: "?fleet_id=1",
|
|
fleetId: -1,
|
|
})
|
|
);
|
|
expect(url.pathname).toBe(paths.MANAGE_HOSTS);
|
|
expect(url.searchParams.has("fleet_id")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("else branch strip rules", () => {
|
|
it("sets fleet_id when switching between specific fleets", () => {
|
|
const url = parse(
|
|
buildFleetSwitchUrl({
|
|
pathname: paths.MANAGE_HOSTS,
|
|
currentSearch: "?fleet_id=1",
|
|
fleetId: 2,
|
|
})
|
|
);
|
|
expect(url.searchParams.get("fleet_id")).toBe("2");
|
|
});
|
|
|
|
it("removes fleet_id when switching to All fleets", () => {
|
|
const url = parse(
|
|
buildFleetSwitchUrl({
|
|
pathname: paths.MANAGE_HOSTS,
|
|
currentSearch: "?fleet_id=1",
|
|
fleetId: -1,
|
|
})
|
|
);
|
|
expect(url.searchParams.has("fleet_id")).toBe(false);
|
|
});
|
|
|
|
it("strips page on any fleet switch", () => {
|
|
const url = parse(
|
|
buildFleetSwitchUrl({
|
|
pathname: paths.MANAGE_HOSTS,
|
|
currentSearch: "?fleet_id=1&page=3",
|
|
fleetId: 2,
|
|
})
|
|
);
|
|
expect(url.searchParams.has("page")).toBe(false);
|
|
});
|
|
|
|
it("strips legacy team_id on any fleet switch", () => {
|
|
const url = parse(
|
|
buildFleetSwitchUrl({
|
|
pathname: paths.MANAGE_HOSTS,
|
|
currentSearch: "?team_id=1",
|
|
fleetId: 2,
|
|
})
|
|
);
|
|
expect(url.searchParams.has("team_id")).toBe(false);
|
|
expect(url.searchParams.get("fleet_id")).toBe("2");
|
|
});
|
|
|
|
it("strips script_batch_execution_id and script_batch_execution_status on any fleet switch", () => {
|
|
const url = parse(
|
|
buildFleetSwitchUrl({
|
|
pathname: paths.MANAGE_HOSTS,
|
|
currentSearch:
|
|
"?fleet_id=1&script_batch_execution_id=abc&script_batch_execution_status=ran",
|
|
fleetId: 2,
|
|
})
|
|
);
|
|
expect(url.searchParams.has("script_batch_execution_id")).toBe(false);
|
|
expect(url.searchParams.has("script_batch_execution_status")).toBe(
|
|
false
|
|
);
|
|
});
|
|
|
|
it("strips software_status when switching to All fleets", () => {
|
|
const url = parse(
|
|
buildFleetSwitchUrl({
|
|
pathname: paths.MANAGE_HOSTS,
|
|
currentSearch: "?fleet_id=1&software_status=pending",
|
|
fleetId: -1,
|
|
})
|
|
);
|
|
expect(url.searchParams.has("software_status")).toBe(false);
|
|
});
|
|
|
|
it("preserves software_status when switching between specific fleets", () => {
|
|
const url = parse(
|
|
buildFleetSwitchUrl({
|
|
pathname: paths.MANAGE_HOSTS,
|
|
currentSearch: "?fleet_id=1&software_status=pending",
|
|
fleetId: 2,
|
|
})
|
|
);
|
|
expect(url.searchParams.get("software_status")).toBe("pending");
|
|
});
|
|
|
|
it("preserves unrelated params across a fleet switch", () => {
|
|
const url = parse(
|
|
buildFleetSwitchUrl({
|
|
pathname: paths.MANAGE_HOSTS,
|
|
currentSearch: "?fleet_id=1&query=foo&order_key=name",
|
|
fleetId: 2,
|
|
})
|
|
);
|
|
expect(url.searchParams.get("query")).toBe("foo");
|
|
expect(url.searchParams.get("order_key")).toBe("name");
|
|
});
|
|
|
|
it("returns a bare pathname when no params remain", () => {
|
|
expect(
|
|
buildFleetSwitchUrl({
|
|
pathname: paths.MANAGE_HOSTS,
|
|
currentSearch: "?fleet_id=1",
|
|
fleetId: -1,
|
|
})
|
|
).toBe(paths.MANAGE_HOSTS);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("pathSupportsUnassigned", () => {
|
|
it("returns true for hosts pages (manage and details)", () => {
|
|
expect(pathSupportsUnassigned(paths.MANAGE_HOSTS)).toBe(true);
|
|
expect(pathSupportsUnassigned(paths.HOST_DETAILS(42))).toBe(true);
|
|
});
|
|
|
|
it("returns true for software pages", () => {
|
|
expect(pathSupportsUnassigned(paths.SOFTWARE)).toBe(true);
|
|
expect(pathSupportsUnassigned(paths.SOFTWARE_TITLE_DETAILS("3"))).toBe(
|
|
true
|
|
);
|
|
});
|
|
|
|
it("returns true for controls pages", () => {
|
|
expect(pathSupportsUnassigned(paths.CONTROLS)).toBe(true);
|
|
expect(pathSupportsUnassigned(paths.CONTROLS_SCRIPTS)).toBe(true);
|
|
});
|
|
|
|
it("returns true for policies pages", () => {
|
|
expect(pathSupportsUnassigned(paths.MANAGE_POLICIES)).toBe(true);
|
|
expect(pathSupportsUnassigned(paths.POLICY_DETAILS(7))).toBe(true);
|
|
expect(pathSupportsUnassigned(paths.EDIT_POLICY(7))).toBe(true);
|
|
expect(pathSupportsUnassigned(paths.NEW_POLICY)).toBe(true);
|
|
});
|
|
|
|
it("returns false for Dashboard", () => {
|
|
expect(pathSupportsUnassigned(paths.DASHBOARD)).toBe(false);
|
|
});
|
|
|
|
it("returns false for Reports pages", () => {
|
|
expect(pathSupportsUnassigned(paths.MANAGE_REPORTS)).toBe(false);
|
|
expect(pathSupportsUnassigned(paths.NEW_REPORT)).toBe(false);
|
|
});
|
|
|
|
it("returns false for admin/settings pages", () => {
|
|
expect(pathSupportsUnassigned("/settings/teams/1")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("pathSupportsAllFleets", () => {
|
|
it("returns true for top-level team-aware pages", () => {
|
|
expect(pathSupportsAllFleets(paths.DASHBOARD)).toBe(true);
|
|
expect(pathSupportsAllFleets(paths.MANAGE_HOSTS)).toBe(true);
|
|
expect(pathSupportsAllFleets(paths.SOFTWARE)).toBe(true);
|
|
expect(pathSupportsAllFleets(paths.MANAGE_POLICIES)).toBe(true);
|
|
expect(pathSupportsAllFleets(paths.MANAGE_REPORTS)).toBe(true);
|
|
});
|
|
|
|
it("returns false for fleet admin detail pages (Users/Options/Settings)", () => {
|
|
expect(pathSupportsAllFleets(paths.FLEET_DETAILS_USERS(1))).toBe(false);
|
|
expect(pathSupportsAllFleets(paths.FLEET_DETAILS_OPTIONS(1))).toBe(false);
|
|
expect(pathSupportsAllFleets(paths.FLEET_DETAILS_SETTINGS(1))).toBe(
|
|
false
|
|
);
|
|
});
|
|
|
|
it("still returns true for the /settings/fleets list page", () => {
|
|
// The list view itself has no fleet scope (no useTeamIdParam), so
|
|
// switching from the palette there is fine. Only the detail sub-pages
|
|
// (which require a specific fleet_id) hide All.
|
|
expect(pathSupportsAllFleets(paths.ADMIN_FLEETS)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("scoreMatch", () => {
|
|
it("returns the label-exact tier when text equals the query (isLabel)", () => {
|
|
expect(scoreMatch("settings", "settings", true)).toBe(SCORE_LABEL_EXACT);
|
|
});
|
|
|
|
it("returns the label-prefix tier on a startsWith match", () => {
|
|
expect(scoreMatch("settings", "sett", true)).toBe(SCORE_LABEL_PREFIX);
|
|
});
|
|
|
|
it("returns the label-word-prefix tier when a non-first word starts with the query", () => {
|
|
// "user settings" — query "sett" word-matches "settings" (word #2).
|
|
expect(scoreMatch("user settings", "sett", true)).toBe(
|
|
SCORE_LABEL_WORD_PREFIX
|
|
);
|
|
});
|
|
|
|
it("treats hyphens as word boundaries for word-prefix matching", () => {
|
|
// "API-only user" splits to ["api", "only", "user"]. Query "only"
|
|
// word-prefix-matches the middle token. Without hyphen splitting
|
|
// this would fall through to substring (label only) or 0 (keyword).
|
|
expect(scoreMatch("api-only user", "only", true)).toBe(
|
|
SCORE_LABEL_WORD_PREFIX
|
|
);
|
|
expect(scoreMatch("fleet-maintained app", "maintained", false)).toBe(
|
|
SCORE_KEYWORD_WORD_PREFIX
|
|
);
|
|
});
|
|
|
|
it("returns the label-substring tier on an interior substring match", () => {
|
|
// "etting" is an interior substring of "settings" — no word prefix.
|
|
expect(scoreMatch("settings", "etting", true)).toBe(
|
|
SCORE_LABEL_SUBSTRING
|
|
);
|
|
});
|
|
|
|
it("returns the keyword tier ladder when isLabel is false", () => {
|
|
expect(scoreMatch("create user", "create user", false)).toBe(
|
|
SCORE_KEYWORD_EXACT
|
|
);
|
|
expect(scoreMatch("create user", "create", false)).toBe(
|
|
SCORE_KEYWORD_PREFIX
|
|
);
|
|
expect(scoreMatch("create user", "user", false)).toBe(
|
|
SCORE_KEYWORD_WORD_PREFIX
|
|
);
|
|
});
|
|
|
|
it("ranks any label tier above the strongest keyword tier", () => {
|
|
// Even the weakest label hit (substring) must outrank an exact
|
|
// keyword match — that's the load-bearing invariant.
|
|
expect(SCORE_LABEL_SUBSTRING).toBeGreaterThan(SCORE_KEYWORD_EXACT);
|
|
});
|
|
|
|
it("does not return a substring tier for keywords", () => {
|
|
// "mal" is an interior substring of keyword "amalgam" — keywords
|
|
// skip the substring tier to keep short-keyword noise out.
|
|
expect(scoreMatch("amalgam", "mal", false)).toBe(0);
|
|
});
|
|
|
|
it("returns 0 when text or query is empty", () => {
|
|
expect(scoreMatch("", "sett", true)).toBe(0);
|
|
expect(scoreMatch("settings", "", true)).toBe(0);
|
|
});
|
|
|
|
it("returns 0 when the query has no match anywhere in the text", () => {
|
|
expect(scoreMatch("dashboard", "xyz", true)).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("computeBestMatch", () => {
|
|
const ITEM = (
|
|
id: string,
|
|
label: string,
|
|
keywords?: string[]
|
|
): ICommandItem => ({
|
|
id,
|
|
label,
|
|
group: "Pages",
|
|
path: `/${id}`,
|
|
keywords,
|
|
});
|
|
|
|
it("returns an empty array when the query is shorter than 2 chars", () => {
|
|
const items = [ITEM("settings-page", "Settings")];
|
|
expect(computeBestMatch(items, "")).toEqual([]);
|
|
expect(computeBestMatch(items, "s")).toEqual([]);
|
|
});
|
|
|
|
it("at 2 chars, restricts to label exact + label prefix tiers", () => {
|
|
const items = [
|
|
// Label prefix match — qualifies at 2 chars.
|
|
ITEM("os-settings", "OS settings"),
|
|
// Word-prefix only — does NOT qualify at 2 chars (tier 80 < 90).
|
|
ITEM("user-os", "User OS"),
|
|
// Keyword match only — does NOT qualify.
|
|
ITEM("dashboard", "Dashboard", ["os"]),
|
|
];
|
|
const result = computeBestMatch(items, "os");
|
|
expect(result.map((e) => e.item.id)).toEqual(["os-settings"]);
|
|
expect(result[0].score).toBe(SCORE_LABEL_PREFIX);
|
|
});
|
|
|
|
it("at 3+ chars, unlocks word-prefix, substring, and keyword tiers", () => {
|
|
const items = [
|
|
ITEM("user-settings", "User settings"), // label word-prefix
|
|
ITEM("zeta", "Zeta", ["settings cmd"]), // keyword prefix
|
|
];
|
|
const result = computeBestMatch(items, "sett");
|
|
expect(result.map((e) => e.item.id)).toEqual(["user-settings", "zeta"]);
|
|
});
|
|
|
|
it("gates on typed characters, not whitespace — 'o s' is treated as 2 chars", () => {
|
|
// "o s" trims to length 3 but only 2 typed letters. Must use the
|
|
// restricted 2-char ladder, not the full one. Without the fix, a
|
|
// multi-token word-prefix match (score 80) would slip past the
|
|
// floor (minScore=1) and surface — but at 2 typed chars the floor
|
|
// is 90, so this must be rejected.
|
|
const items = [ITEM("user-os", "User OS")];
|
|
expect(computeBestMatch(items, "o s")).toEqual([]);
|
|
|
|
// Same shape with 3 typed chars uses the full ladder and accepts.
|
|
const fullResult = computeBestMatch(items, "us os");
|
|
expect(fullResult.length).toBe(1);
|
|
});
|
|
|
|
it("promotes a label prefix match — 'sett' → 'Settings'", () => {
|
|
// The motivating case from #39018 manager feedback: a partial label
|
|
// match should land in Best match, not get jumbled below keyword
|
|
// matches.
|
|
const items = [
|
|
ITEM("dashboard", "Dashboard"),
|
|
ITEM("settings-page", "Settings", ["admin", "organization"]),
|
|
ITEM("hosts", "Hosts"),
|
|
];
|
|
const result = computeBestMatch(items, "sett");
|
|
expect(result.map((e) => e.item.id)).toEqual(["settings-page"]);
|
|
expect(result[0].score).toBe(SCORE_LABEL_PREFIX);
|
|
});
|
|
|
|
it("promotes a keyword exact match — 'create user' → 'Add user'", () => {
|
|
// Mirrors the real "Add user" item which carries "create user"
|
|
// as a synonym keyword. Promoted into Best match — but at the
|
|
// keyword tier (below any label match).
|
|
const items = [
|
|
ITEM("add-user", "Add user", ["create user", "new user"]),
|
|
ITEM("hosts", "Hosts"),
|
|
];
|
|
const result = computeBestMatch(items, "create user");
|
|
expect(result.map((e) => e.item.id)).toEqual(["add-user"]);
|
|
expect(result[0].score).toBe(SCORE_KEYWORD_EXACT);
|
|
});
|
|
|
|
it("ranks every label match above every keyword match", () => {
|
|
const items = [
|
|
// Keyword exact for query "settings".
|
|
ITEM("keyword-only", "Some action", ["settings"]),
|
|
// Label substring (weakest label tier).
|
|
ITEM("contains-settings", "Reset settings cache"),
|
|
];
|
|
const result = computeBestMatch(items, "settings");
|
|
// Label substring outranks keyword exact, even though both items
|
|
// match the query.
|
|
expect(result.map((e) => e.item.id)).toEqual([
|
|
"contains-settings",
|
|
"keyword-only",
|
|
]);
|
|
});
|
|
|
|
it("orders entries by score desc, then alphabetical within a tier", () => {
|
|
const items = [
|
|
ITEM("settings-page", "Settings"), // label exact: 100
|
|
ITEM("settings-page-two", "Settings advanced"), // label prefix: 90
|
|
ITEM("user-settings", "User settings"), // label word-prefix: 80
|
|
ITEM("zeta", "Zeta", ["settings cmd"]), // keyword prefix: 40
|
|
];
|
|
const result = computeBestMatch(items, "settings");
|
|
expect(result.map((e) => e.item.id)).toEqual([
|
|
"settings-page",
|
|
"settings-page-two",
|
|
"user-settings",
|
|
"zeta",
|
|
]);
|
|
});
|
|
|
|
it("ranks an item by its strongest match across label and keywords", () => {
|
|
// Label "Some label" doesn't match "host" at all; keyword "host"
|
|
// does (keyword exact = 50). The item still scores at the keyword
|
|
// tier, not zero.
|
|
const items = [ITEM("a", "Some label", ["host"])];
|
|
const result = computeBestMatch(items, "host");
|
|
expect(result[0].score).toBe(SCORE_KEYWORD_EXACT);
|
|
});
|
|
|
|
it("does not promote items whose only hit is a keyword substring", () => {
|
|
// Keyword "amalgam" contains "mal" — but keywords skip substring,
|
|
// so this item must not appear.
|
|
const items = [ITEM("a", "Unrelated", ["amalgam"]), ITEM("b", "Other")];
|
|
expect(computeBestMatch(items, "mal")).toEqual([]);
|
|
});
|
|
|
|
it("promotes sub-items independently of the parent", () => {
|
|
const parent: ICommandItem = {
|
|
id: "controls",
|
|
label: "Controls",
|
|
group: "Controls",
|
|
subItems: [
|
|
{
|
|
id: "controls-os-updates",
|
|
label: "OS updates",
|
|
path: "/controls/os-updates",
|
|
keywords: ["patch", "minimum version"],
|
|
},
|
|
{
|
|
id: "controls-passwords",
|
|
label: "Passwords",
|
|
path: "/controls/passwords",
|
|
},
|
|
],
|
|
};
|
|
// "passw" matches the sub-item label "Passwords" but not the
|
|
// parent's "Controls" label or keywords.
|
|
const result = computeBestMatch([parent], "passw");
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].sub?.id).toBe("controls-passwords");
|
|
expect(result[0].item.id).toBe("controls");
|
|
});
|
|
|
|
it("emits separate entries for parent and matching sub-item", () => {
|
|
const parent: ICommandItem = {
|
|
id: "settings-org",
|
|
label: "Organization settings",
|
|
group: "Settings",
|
|
subItems: [
|
|
{
|
|
id: "settings-org-smtp",
|
|
label: "SMTP options",
|
|
path: "/settings/smtp",
|
|
keywords: ["settings email"],
|
|
},
|
|
],
|
|
};
|
|
// "settings" matches both:
|
|
// - parent label word-prefix (80)
|
|
// - sub-item keyword prefix (40)
|
|
// Parent's label tier outranks the sub-item's keyword tier.
|
|
const result = computeBestMatch([parent], "settings");
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0].sub).toBeUndefined();
|
|
expect(result[0].item.id).toBe("settings-org");
|
|
expect(result[1].sub?.id).toBe("settings-org-smtp");
|
|
});
|
|
|
|
it("is case-insensitive on both label and query, and trims whitespace", () => {
|
|
const items = [ITEM("a", "Settings")];
|
|
expect(computeBestMatch(items, "SETT")[0]?.score).toBe(
|
|
SCORE_LABEL_PREFIX
|
|
);
|
|
expect(computeBestMatch(items, " Sett ")[0]?.score).toBe(
|
|
SCORE_LABEL_PREFIX
|
|
);
|
|
});
|
|
|
|
describe("multi-token queries", () => {
|
|
it("promotes 'Organization settings' for the order-independent query 'settings org'", () => {
|
|
// The motivating case for multi-token: typing fragments in any
|
|
// order should find the item. Neither "settings org" nor
|
|
// "org settings" matches as a single phrase, but both tokens
|
|
// find homes in the label.
|
|
const items = [
|
|
ITEM("settings-org", "Organization settings"),
|
|
ITEM("unrelated", "Reports"),
|
|
];
|
|
const result = computeBestMatch(items, "settings org");
|
|
expect(result.map((e) => e.item.id)).toEqual(["settings-org"]);
|
|
// Each token word-prefixes a label word; min = label word-prefix.
|
|
expect(result[0].score).toBe(SCORE_LABEL_WORD_PREFIX);
|
|
});
|
|
|
|
it("works regardless of token order", () => {
|
|
const items = [ITEM("settings-org", "Organization settings")];
|
|
const a = computeBestMatch(items, "settings org");
|
|
const b = computeBestMatch(items, "org settings");
|
|
expect(a[0].score).toBe(b[0].score);
|
|
});
|
|
|
|
it("rejects items where any token has no match", () => {
|
|
// Token "xyz" doesn't match anywhere — even though "settings"
|
|
// matches the label, the item must not promote.
|
|
const items = [ITEM("settings-page", "Settings")];
|
|
expect(computeBestMatch(items, "xyz settings")).toEqual([]);
|
|
});
|
|
|
|
it("does not regress single-token queries", () => {
|
|
// Single-token behavior must be unchanged by the multi-token
|
|
// logic. 'sett' against 'Settings' is still label prefix.
|
|
const items = [ITEM("settings-page", "Settings")];
|
|
expect(computeBestMatch(items, "sett")[0].score).toBe(
|
|
SCORE_LABEL_PREFIX
|
|
);
|
|
});
|
|
|
|
it("preserves full-query exact match when tokens individually score lower", () => {
|
|
// Query "create user" exact-matches the keyword "create user"
|
|
// (keyword exact = 50). Per-token min is only 40 (token "create"
|
|
// is keyword prefix, token "user" is label word-prefix → min 40).
|
|
// The full-query single-pass score (50) wins.
|
|
const items = [
|
|
ITEM("add-user", "Add user", ["create user", "new user"]),
|
|
];
|
|
const result = computeBestMatch(items, "create user");
|
|
expect(result[0].score).toBe(SCORE_KEYWORD_EXACT);
|
|
});
|
|
|
|
it("matches tokens against different texts within the same item", () => {
|
|
// Token "settings" matches the label word "settings"; token "ssa"
|
|
// matches a keyword. Different sources, same item.
|
|
const items = [
|
|
ITEM("settings-org", "Organization settings", ["ssa config"]),
|
|
];
|
|
const result = computeBestMatch(items, "settings ssa");
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].item.id).toBe("settings-org");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("highlightMatches", () => {
|
|
it("returns a single non-matched segment when the query is empty", () => {
|
|
expect(highlightMatches("Settings", "")).toEqual([
|
|
{ text: "Settings", matched: false },
|
|
]);
|
|
});
|
|
|
|
it("returns a single non-matched segment when nothing matches", () => {
|
|
expect(highlightMatches("Settings", "xyz")).toEqual([
|
|
{ text: "Settings", matched: false },
|
|
]);
|
|
});
|
|
|
|
it("highlights a prefix match", () => {
|
|
expect(highlightMatches("Settings", "Sett")).toEqual([
|
|
{ text: "Sett", matched: true },
|
|
{ text: "ings", matched: false },
|
|
]);
|
|
});
|
|
|
|
it("highlights a substring match", () => {
|
|
expect(highlightMatches("Settings", "ting")).toEqual([
|
|
{ text: "Set", matched: false },
|
|
{ text: "ting", matched: true },
|
|
{ text: "s", matched: false },
|
|
]);
|
|
});
|
|
|
|
it("highlights every token of a multi-token query", () => {
|
|
expect(highlightMatches("Organization settings", "org sett")).toEqual([
|
|
{ text: "Org", matched: true },
|
|
{ text: "anization ", matched: false },
|
|
{ text: "sett", matched: true },
|
|
{ text: "ings", matched: false },
|
|
]);
|
|
});
|
|
|
|
it("merges overlapping ranges from multiple tokens", () => {
|
|
// "set" and "settings" overlap — should merge into one matched
|
|
// range covering "settings".
|
|
expect(highlightMatches("Settings", "set settings")).toEqual([
|
|
{ text: "Settings", matched: true },
|
|
]);
|
|
});
|
|
|
|
it("is case-insensitive but preserves the original casing in output", () => {
|
|
// Match against "ORG" should highlight "Org" in original case.
|
|
expect(highlightMatches("Organization settings", "ORG")).toEqual([
|
|
{ text: "Org", matched: true },
|
|
{ text: "anization settings", matched: false },
|
|
]);
|
|
});
|
|
|
|
it("handles every occurrence of a token within the text", () => {
|
|
// Both occurrences of "se" should highlight.
|
|
const result = highlightMatches("setup setup", "se");
|
|
expect(result).toEqual([
|
|
{ text: "se", matched: true },
|
|
{ text: "tup ", matched: false },
|
|
{ text: "se", matched: true },
|
|
{ text: "tup", matched: false },
|
|
]);
|
|
});
|
|
|
|
it("slices the original text correctly when a match follows a length-changing case fold", () => {
|
|
// "İ" (U+0130) lowercases to "i̇" (U+0069 + U+0307), expanding
|
|
// 1 char into 2. A naive textLower.indexOf + text.slice would find
|
|
// "stan" at lower-index 2 (the i + combining dot pushed it
|
|
// forward), then slice the original at [2, 6] — producing "tanb"
|
|
// instead of "stan". The offset map translates the lower-coord
|
|
// range back to the original-text range.
|
|
const result = highlightMatches("İstanbul", "stan");
|
|
expect(result.map((s) => s.text).join("")).toBe("İstanbul");
|
|
const matched = result.filter((s) => s.matched).map((s) => s.text);
|
|
expect(matched).toEqual(["stan"]);
|
|
});
|
|
|
|
it("matches an ASCII query against a source char whose lowercase expands (İ → i̇)", () => {
|
|
// utf8mb4_unicode_ci treats İ and i as equivalent, so the backend
|
|
// returns "İstanbul" for query "i". The frontend must do the same
|
|
// or rows render with zero highlights. The match in textLower
|
|
// ("i̇stanbul") ends mid-folded-char; the offset map expands it
|
|
// to cover the whole "İ".
|
|
const result = highlightMatches("İstanbul", "i");
|
|
expect(result.map((s) => s.text).join("")).toBe("İstanbul");
|
|
expect(result[0]).toEqual({ text: "İ", matched: true });
|
|
});
|
|
|
|
it("matches a multi-char-lowercased query against the source char that produced it", () => {
|
|
// Query "İ" lowercases to "i̇" (2 chars); source "İ" also lowercases
|
|
// to "i̇". The whole source char should highlight in its original
|
|
// form.
|
|
const result = highlightMatches("İstanbul", "İ");
|
|
expect(result.map((s) => s.text).join("")).toBe("İstanbul");
|
|
expect(result[0]).toEqual({ text: "İ", matched: true });
|
|
});
|
|
|
|
it("folds supplementary-plane characters (Adlam)", () => {
|
|
// U+1E900 (Adlam capital A) lowercases to U+1E922 (Adlam small a).
|
|
// Per-code-unit folding would leave the lone surrogates unchanged
|
|
// and silently drop the match.
|
|
const result = highlightMatches("\u{1E900}stanbul", "\u{1E922}stanbul");
|
|
expect(result).toEqual([{ text: "\u{1E900}stanbul", matched: true }]);
|
|
});
|
|
|
|
it("matches a multi-char ASCII query across a length-changing fold", () => {
|
|
// 'İ' → 'i' + combining dot splits the run; without combining-mark
|
|
// stripping, indexOf('istanbul') in the folded text returns -1.
|
|
const result = highlightMatches("İstanbul", "istanbul");
|
|
expect(result).toEqual([{ text: "İstanbul", matched: true }]);
|
|
});
|
|
|
|
it("matches accent-insensitively to mirror utf8mb4_unicode_ci", () => {
|
|
// Backend returns 'Café Server' for query 'cafe'; highlighter must
|
|
// do the same or the row renders with zero <mark> tags.
|
|
const result = highlightMatches("Café Server", "cafe");
|
|
expect(result).toEqual([
|
|
{ text: "Café", matched: true },
|
|
{ text: " Server", matched: false },
|
|
]);
|
|
});
|
|
|
|
it("matches when the query itself carries an accent", () => {
|
|
const result = highlightMatches("Cafe Server", "café");
|
|
expect(result).toEqual([
|
|
{ text: "Cafe", matched: true },
|
|
{ text: " Server", matched: false },
|
|
]);
|
|
});
|
|
});
|
|
});
|