Files
kandev/apps/web/lib/navigation/plugin-destinations.test.ts
T

150 lines
4.8 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { pluginDestinationId } from "./plugin-destinations";
import { resolveDestinations } from "./resolve-destinations";
import { NO_WORKSPACE_CONTEXT } from "./surface-policy";
import type { AvailabilityMap, NavContext } from "./types";
import type { PluginNavRegistration } from "@/lib/plugins/registry";
const ALL_INTEGRATIONS: AvailabilityMap = {
"azure-devops": true,
github: true,
gitlab: true,
jira: true,
linear: true,
};
const KANBAN: NavContext = { workspaceId: "ws-1", inOffice: false };
const pluginItems: PluginNavRegistration[] = [
{ pluginId: "acme", id: "hello", label: "Hello", path: "/plugins/hello" },
{
pluginId: "acme",
id: "explicit-main",
label: "Explicit",
path: "/plugins/explicit",
section: "main",
},
{
pluginId: "acme",
id: "tracker",
label: "Tracker",
path: "/plugins/tracker",
section: "integrations",
},
{
pluginId: "acme",
id: "prefs",
label: "Prefs",
path: "/settings/plugins/p",
section: "settings",
},
];
function ids(destinations: { id: string }[]): string[] {
return destinations.map((destination) => destination.id);
}
function pluginsIn(surface: "sidebar" | "mobileMenu", items: PluginNavRegistration[]) {
return resolveDestinations({
surface,
section: "plugins",
ctx: NO_WORKSPACE_CONTEXT,
pluginItems: items,
});
}
describe("plugin destinations", () => {
it("routes main-section items (explicit or omitted) to the plugins group", () => {
const resolved = pluginsIn("mobileMenu", pluginItems);
expect(ids(resolved)).toEqual(["plugin:acme:hello", "plugin:acme:explicit-main"]);
expect(resolved.every((destination) => destination.source === "plugin")).toBe(true);
});
it("keeps the raw item id available for test ids", () => {
const [hello] = pluginsIn("mobileMenu", pluginItems);
expect(hello).toMatchObject({ id: "plugin:acme:hello", pluginItemId: "hello" });
});
it("routes integration-section items alongside the first-party integrations", () => {
const resolved = resolveDestinations({
surface: "sidebar",
section: "integrations",
ctx: KANBAN,
availability: { github: true },
pluginItems,
});
// First-party links keep precedence; plugin items follow.
expect(ids(resolved)).toEqual(["github", "plugin:acme:tracker"]);
});
it("never renders settings-section items as destinations", () => {
const resolved = resolveDestinations({
surface: "mobileMenu",
ctx: NO_WORKSPACE_CONTEXT,
availability: ALL_INTEGRATIONS,
pluginItems,
});
expect(ids(resolved)).not.toContain("plugin:acme:prefs");
});
it("keeps plugin items off the palette, which plugins reach via shortcuts", () => {
const resolved = resolveDestinations({ surface: "palette", ctx: KANBAN, pluginItems });
expect(ids(resolved)).not.toContain("plugin:acme:hello");
});
});
/**
* Destination ids are the React keys every nav surface renders with, and plugin
* item ids come from third-party manifests — plugin-local, never coordinated.
*/
describe("plugin destination identity", () => {
it("keeps a plugin from colliding with a first-party id", () => {
const resolved = resolveDestinations({
surface: "sidebar",
section: "integrations",
ctx: KANBAN,
availability: { github: true },
// A plugin is free to register `id: "github"` in this very section.
pluginItems: [
{
pluginId: "acme",
id: "github",
label: "GitHub Extras",
path: "/plugins/github-extras",
section: "integrations",
},
],
});
expect(ids(resolved)).toEqual(["github", "plugin:acme:github"]);
expect(resolved[1]).toMatchObject({ pluginItemId: "github", label: "GitHub Extras" });
});
it("keeps two plugins that pick the same item id apart", () => {
const resolved = pluginsIn("sidebar", [
{ pluginId: "acme", id: "dashboard", label: "Acme", path: "/plugins/acme" },
{ pluginId: "globex", id: "dashboard", label: "Globex", path: "/plugins/globex" },
]);
expect(ids(resolved)).toEqual(["plugin:acme:dashboard", "plugin:globex:dashboard"]);
expect(new Set(ids(resolved)).size).toBe(resolved.length);
// Both keep the raw id, which is what `plugin-nav-item-<id>` test ids use.
expect(resolved.map((destination) => destination.pluginItemId)).toEqual([
"dashboard",
"dashboard",
]);
});
it("encodes both segments so the separator cannot be forged", () => {
// Without encoding, ("a:b", "c") and ("a", "b:c") would both read as
// "plugin:a:b:c" — a collision the namespace exists to prevent.
expect(pluginDestinationId("a:b", "c")).not.toBe(pluginDestinationId("a", "b:c"));
expect(pluginDestinationId("a:b", "c")).toBe("plugin:a%3Ab:c");
});
});