562 lines
19 KiB
TypeScript
562 lines
19 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
import * as React from "react";
|
|
import { render } from "@testing-library/react";
|
|
import { createAppStore } from "@/lib/state/store";
|
|
import { useDockviewStore } from "@/lib/state/dockview-store";
|
|
import { reviewItemId } from "@/components/task/review-selection";
|
|
import { buildHostApi } from "./host-api";
|
|
import { registerPluginTranslations, unregisterPluginTranslations } from "./plugin-translations";
|
|
|
|
const { changeRequestDetailModuleLoaded, translate } = vi.hoisted(() => ({
|
|
changeRequestDetailModuleLoaded: vi.fn(),
|
|
translate: vi.fn((key: string) => `translated:${key}`),
|
|
}));
|
|
|
|
vi.mock("@/lib/i18n", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("@/lib/i18n")>()),
|
|
t: translate,
|
|
}));
|
|
|
|
vi.mock("@/components/integrations/change-request-detail", () => {
|
|
changeRequestDetailModuleLoaded();
|
|
return { ChangeRequestDetail: () => null };
|
|
});
|
|
|
|
/** Curated primitives a plugin needs to build a full native-feeling page. */
|
|
const EXPECTED_UI_PRIMITIVES = [
|
|
"Accordion",
|
|
"AccordionContent",
|
|
"AccordionItem",
|
|
"AccordionTrigger",
|
|
"Alert",
|
|
"Badge",
|
|
"Button",
|
|
"Card",
|
|
"ChartContainer",
|
|
"ChartLegend",
|
|
"ChartLegendContent",
|
|
"ChartStyle",
|
|
"ChartTooltip",
|
|
"ChartTooltipContent",
|
|
"Checkbox",
|
|
"Collapsible",
|
|
"CollapsibleContent",
|
|
"CollapsibleTrigger",
|
|
"Dialog",
|
|
"Drawer",
|
|
"DrawerClose",
|
|
"DrawerContent",
|
|
"DrawerDescription",
|
|
"DrawerFooter",
|
|
"DrawerHeader",
|
|
"DrawerOverlay",
|
|
"DrawerPortal",
|
|
"DrawerTitle",
|
|
"DrawerTrigger",
|
|
"DropdownMenu",
|
|
"Empty",
|
|
"EmptyContent",
|
|
"EmptyDescription",
|
|
"EmptyHeader",
|
|
"EmptyMedia",
|
|
"EmptyTitle",
|
|
"Input",
|
|
"Kbd",
|
|
"KbdGroup",
|
|
"Label",
|
|
"Pagination",
|
|
"Popover",
|
|
"PopoverAnchor",
|
|
"PopoverContent",
|
|
"PopoverDescription",
|
|
"PopoverHeader",
|
|
"PopoverTitle",
|
|
"PopoverTrigger",
|
|
"Progress",
|
|
"ScrollArea",
|
|
"Select",
|
|
"Sheet",
|
|
"SheetClose",
|
|
"SheetContent",
|
|
"SheetDescription",
|
|
"SheetFooter",
|
|
"SheetHeader",
|
|
"SheetTitle",
|
|
"SheetTrigger",
|
|
"Spinner",
|
|
"Switch",
|
|
"Table",
|
|
"Tabs",
|
|
"Textarea",
|
|
"Tooltip",
|
|
"TooltipProvider",
|
|
];
|
|
|
|
const originalFetch = global.fetch;
|
|
const I18N_PLUGIN_ID = "translation-host-test";
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch;
|
|
unregisterPluginTranslations(I18N_PLUGIN_ID);
|
|
vi.unstubAllEnvs();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("buildHostApi — API", () => {
|
|
it("scopes api.fetch to /api/plugins/{pluginId}/... and forwards init", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const host = buildHostApi("jira", createAppStore());
|
|
await host.api.fetch("/issues", { method: "POST" });
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [url, init] = fetchMock.mock.calls[0];
|
|
expect(url).toContain("/api/plugins/jira/issues");
|
|
expect(init).toEqual({ method: "POST", credentials: "include" });
|
|
});
|
|
|
|
it("forces credentials: include even when init omits it, so the session cookie survives a split origin", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const host = buildHostApi("jira", createAppStore());
|
|
await host.api.fetch("/issues");
|
|
|
|
const [, init] = fetchMock.mock.calls[0];
|
|
expect(init).toMatchObject({ credentials: "include" });
|
|
});
|
|
|
|
it("normalizes a path that doesn't start with a slash", async () => {
|
|
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const host = buildHostApi("jira", createAppStore());
|
|
await host.api.fetch("issues");
|
|
|
|
const [url] = fetchMock.mock.calls[0];
|
|
expect(url).toContain("/api/plugins/jira/issues");
|
|
});
|
|
});
|
|
|
|
describe("buildHostApi", () => {
|
|
afterEach(() => {
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it("invokes declared authenticated actions with scoped resources and parses their response", async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValue(new Response(JSON.stringify({ connected: true }), { status: 200 }));
|
|
global.fetch = fetchMock as unknown as typeof fetch;
|
|
|
|
const host = buildHostApi("jira", createAppStore());
|
|
const controller = new AbortController();
|
|
await expect(
|
|
host.api.invokeAction<{ connected: boolean }>(
|
|
"connection.save",
|
|
{
|
|
workspaceId: "workspace-a",
|
|
taskId: "task-a",
|
|
sessionId: "session-a",
|
|
repositoryId: "repository-a",
|
|
body: { token: "redacted" },
|
|
},
|
|
{ signal: controller.signal },
|
|
),
|
|
).resolves.toEqual({ connected: true });
|
|
|
|
const [url, init] = fetchMock.mock.calls[0];
|
|
expect(url).toContain("/api/plugins/jira/actions/connection.save");
|
|
expect(init).toMatchObject({
|
|
method: "POST",
|
|
credentials: "include",
|
|
signal: controller.signal,
|
|
});
|
|
expect(JSON.parse(init.body)).toEqual({
|
|
workspaceId: "workspace-a",
|
|
taskId: "task-a",
|
|
sessionId: "session-a",
|
|
repositoryId: "repository-a",
|
|
body: { token: "redacted" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("buildHostApi — host contract", () => {
|
|
it("exposes a plugin-scoped localization API", () => {
|
|
const host = buildHostApi("jira", createAppStore());
|
|
|
|
expect(host.i18n).toBeDefined();
|
|
});
|
|
|
|
it("translates plugin messages through both imperative and reactive APIs", () => {
|
|
const greeting = "Hello {{name}}";
|
|
registerPluginTranslations(I18N_PLUGIN_ID, {
|
|
en: { greeting },
|
|
"pt-pt": { greeting },
|
|
"zh-cn": { greeting },
|
|
pseudo: { greeting },
|
|
});
|
|
const host = buildHostApi(I18N_PLUGIN_ID, createAppStore());
|
|
function Greeting() {
|
|
const translation = host.i18n.useTranslation();
|
|
return React.createElement(
|
|
"span",
|
|
{ "data-testid": "plugin-greeting" },
|
|
`${translation.locale}:${translation.t("greeting", { values: { name: "Ada" } })}`,
|
|
);
|
|
}
|
|
|
|
const view = render(React.createElement(Greeting));
|
|
|
|
expect(host.i18n.t("greeting", { values: { name: "Ada" } })).toBe("Hello Ada");
|
|
expect(view.getByTestId("plugin-greeting").textContent).toBe(`${host.i18n.locale}:Hello Ada`);
|
|
});
|
|
|
|
it("exposes the host React instance and a jsx alias for React.createElement", () => {
|
|
const host = buildHostApi("jira", createAppStore());
|
|
|
|
expect(host.React).toBe(React);
|
|
expect(host.jsx).toBe(React.createElement);
|
|
});
|
|
|
|
it("wires store.getState/setState/subscribe to the passed StoreApi", () => {
|
|
const store = createAppStore();
|
|
const getStateSpy = vi.spyOn(store, "getState");
|
|
const setStateSpy = vi.spyOn(store, "setState");
|
|
const subscribeSpy = vi.spyOn(store, "subscribe");
|
|
|
|
const host = buildHostApi("jira", store);
|
|
|
|
expect(host.store.getState()).toBe(store.getState());
|
|
host.store.setState({});
|
|
const listener = vi.fn();
|
|
const unsubscribe = host.store.subscribe(listener);
|
|
|
|
expect(getStateSpy).toHaveBeenCalled();
|
|
expect(setStateSpy).toHaveBeenCalled();
|
|
expect(subscribeSpy).toHaveBeenCalled();
|
|
unsubscribe();
|
|
});
|
|
|
|
it("exposes a curated ui component subset", () => {
|
|
const host = buildHostApi("jira", createAppStore());
|
|
|
|
// Expanded primitive set for full native-feeling plugin pages.
|
|
for (const name of EXPECTED_UI_PRIMITIVES) {
|
|
expect(host.ui[name], `host.ui.${name}`).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it("exports the supported responsive breakpoint hook", () => {
|
|
const host = buildHostApi("jira", createAppStore());
|
|
|
|
expect(host.useResponsiveBreakpoint).toBeTypeOf("function");
|
|
});
|
|
|
|
it("exposes first-party app components for native flows and page chrome", () => {
|
|
const host = buildHostApi("jira", createAppStore());
|
|
expect(host.ui.PageTopbar).toBeDefined();
|
|
expect(host.ui.TaskCreateDialog).toBeDefined();
|
|
expect(host.ui.Combobox).toBeDefined();
|
|
expect(host.ui.ChangeRequestList).toBeDefined();
|
|
expect(host.ui.ChangeRequestRow).toBeDefined();
|
|
expect(host.ui.IntegrationStartTaskMenu).toBeDefined();
|
|
expect(host.ui.IntegrationListToolbar).toBeDefined();
|
|
expect(host.ui.IntegrationChangeRequestStatus).toBeDefined();
|
|
expect(host.ui.ChangeRequestDetail).toBeTypeOf("function");
|
|
expect(changeRequestDetailModuleLoaded).not.toHaveBeenCalled();
|
|
expect(host.ui.IntegrationScopeBar).toBeDefined();
|
|
expect(host.ui.IntegrationSaveQueryDialog).toBeDefined();
|
|
expect(host.ui.IntegrationRepositoryFilter).toBeDefined();
|
|
expect(host.ui.IntegrationCursorPagination).toBeDefined();
|
|
const IntegrationIcon = host.ui.IntegrationIcon as React.ComponentType<{
|
|
name: string;
|
|
}>;
|
|
expect(IntegrationIcon).toBeTypeOf("function");
|
|
const rendered = render(React.createElement(IntegrationIcon, { name: "merged" }));
|
|
expect(rendered.container.querySelector('[data-integration-icon="merged"]')).not.toBeNull();
|
|
expect(host.ui.TaskRowIndicator).toBeDefined();
|
|
});
|
|
|
|
it("localizes the lazy change-request loading state", () => {
|
|
const host = buildHostApi("jira", createAppStore());
|
|
const ChangeRequestDetail = host.ui.ChangeRequestDetail as React.ComponentType<object>;
|
|
|
|
const rendered = render(React.createElement(ChangeRequestDetail, {}));
|
|
|
|
expect(rendered.getByLabelText("translated:integrations:loadingChangeRequest")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("buildHostApi — navigation and modal contract", () => {
|
|
it("exposes navigate() that soft-navigates via history push/replace", () => {
|
|
const host = buildHostApi("jira", createAppStore());
|
|
const pushSpy = vi.spyOn(window.history, "pushState");
|
|
const replaceSpy = vi.spyOn(window.history, "replaceState");
|
|
|
|
host.navigate("/somewhere");
|
|
expect(pushSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({ __kandevNavigationPosition: expect.any(Number) }),
|
|
"",
|
|
"/somewhere",
|
|
);
|
|
|
|
host.navigate("/elsewhere", { replace: true });
|
|
expect(replaceSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({ __kandevNavigationPosition: expect.any(Number) }),
|
|
"",
|
|
"/elsewhere",
|
|
);
|
|
|
|
pushSpy.mockRestore();
|
|
replaceSpy.mockRestore();
|
|
});
|
|
|
|
it("exposes the backend API origin on api.baseUrl", () => {
|
|
const host = buildHostApi("jira", createAppStore());
|
|
expect(typeof host.api.baseUrl).toBe("string");
|
|
});
|
|
|
|
it("sets pluginId on the returned host api", () => {
|
|
const host = buildHostApi("jira", createAppStore());
|
|
expect(host.pluginId).toBe("jira");
|
|
});
|
|
|
|
it("routes openModal to the modal manager, scoped to this plugin's id", async () => {
|
|
const { pluginModalManager } = await import("./modal-manager");
|
|
const host = buildHostApi("jira", createAppStore());
|
|
|
|
const before = pluginModalManager.getSnapshot().length;
|
|
const handle = host.openModal({ content: () => null, title: "Test" });
|
|
|
|
const snapshot = pluginModalManager.getSnapshot();
|
|
expect(snapshot).toHaveLength(before + 1);
|
|
expect(snapshot[snapshot.length - 1]).toMatchObject({
|
|
pluginId: "jira",
|
|
options: { title: "Test" },
|
|
});
|
|
|
|
handle.close();
|
|
expect(pluginModalManager.getSnapshot()).toHaveLength(before);
|
|
});
|
|
|
|
it("opens a host-owned task link dialog instead of requiring plugin form markup", async () => {
|
|
const { pluginModalManager } = await import("./modal-manager");
|
|
const host = buildHostApi("jira", createAppStore());
|
|
const before = pluginModalManager.getSnapshot().length;
|
|
|
|
const handle = host.openTaskLinkDialog({
|
|
title: "Link Acme pull request",
|
|
description: "Use an Acme pull request URL for this task.",
|
|
inputLabel: "Pull request",
|
|
emptyError: "Enter a pull request.",
|
|
failureMessage: "Failed to link pull request.",
|
|
successMessage: "Pull request linked",
|
|
onSubmit: vi.fn().mockResolvedValue(undefined),
|
|
});
|
|
|
|
expect(pluginModalManager.getSnapshot().at(-1)).toMatchObject({
|
|
pluginId: "jira",
|
|
layout: "task-link",
|
|
options: {
|
|
title: "Link Acme pull request",
|
|
description: "Use an Acme pull request URL for this task.",
|
|
presentation: "dialog",
|
|
},
|
|
});
|
|
handle.close();
|
|
expect(pluginModalManager.getSnapshot()).toHaveLength(before);
|
|
});
|
|
|
|
it("opens provider-neutral desktop and mobile task review surfaces", () => {
|
|
const reviewKey = "cloud|workspace/repo|42";
|
|
const repositoryId = "repository-uuid";
|
|
const connectionScope = "https://bitbucket.example.test";
|
|
const changeRequestNumber = 42;
|
|
const reviewTitle = "Review #42";
|
|
const review = {
|
|
providerId: "bitbucket",
|
|
reviewKey,
|
|
connectionScope,
|
|
repositoryId,
|
|
changeRequestNumber,
|
|
title: reviewTitle,
|
|
};
|
|
const store = createAppStore();
|
|
const addReviewPanel = vi.spyOn(useDockviewStore.getState(), "addReviewPanel");
|
|
const setMobileSessionReview = vi.spyOn(store.getState(), "setMobileSessionReview");
|
|
const host = buildHostApi("bitbucket", store);
|
|
|
|
host.openTaskReview({
|
|
...review,
|
|
presentation: "desktop",
|
|
});
|
|
expect(addReviewPanel).toHaveBeenCalledWith(expect.objectContaining(review));
|
|
|
|
host.openTaskReview({
|
|
...review,
|
|
presentation: "mobile",
|
|
sessionId: "session-1",
|
|
});
|
|
expect(setMobileSessionReview).toHaveBeenCalledWith("session-1", reviewItemId(review));
|
|
});
|
|
});
|
|
|
|
/**
|
|
* jsdom delivers MutationObserver records on a microtask, so awaiting an
|
|
* already-resolved promise is enough to let a pending class mutation land.
|
|
*/
|
|
function flushMutationObservers(): Promise<void> {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
/**
|
|
* `host` is built once per plugin load, so a theme captured into it at boot
|
|
* can never follow a light/dark switch. These pin the live-read contract and
|
|
* the change notification a canvas-painting plugin needs on top of it.
|
|
*/
|
|
describe("buildHostApi — host.theme / host.onThemeChange", () => {
|
|
afterEach(() => {
|
|
document.documentElement.classList.remove("dark", "light");
|
|
});
|
|
|
|
it("reads the resolved theme live rather than freezing it at build time", () => {
|
|
document.documentElement.classList.remove("dark");
|
|
const host = buildHostApi("jira", createAppStore());
|
|
expect(host.theme).toBe("light");
|
|
|
|
document.documentElement.classList.add("dark");
|
|
expect(host.theme).toBe("dark");
|
|
|
|
document.documentElement.classList.remove("dark");
|
|
expect(host.theme).toBe("light");
|
|
});
|
|
|
|
it("notifies onThemeChange subscribers once per flip and stops after unsubscribe", async () => {
|
|
document.documentElement.classList.remove("dark");
|
|
const host = buildHostApi("jira", createAppStore());
|
|
const listener = vi.fn();
|
|
const unsubscribe = host.onThemeChange(listener);
|
|
|
|
document.documentElement.classList.add("dark");
|
|
await flushMutationObservers();
|
|
expect(listener).toHaveBeenCalledExactlyOnceWith("dark");
|
|
|
|
unsubscribe();
|
|
document.documentElement.classList.remove("dark");
|
|
await flushMutationObservers();
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// Cross-plugin fan-out: one buggy plugin must not silently stop theme
|
|
// updates for every other plugin. The throwing listener is registered
|
|
// FIRST so an unguarded loop would abort before reaching the healthy one.
|
|
it("keeps notifying later listeners when an earlier one throws", async () => {
|
|
document.documentElement.classList.remove("dark");
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
const host = buildHostApi("jira", createAppStore());
|
|
|
|
const boom = vi.fn(() => {
|
|
throw new Error("plugin theme listener blew up");
|
|
});
|
|
const healthy = vi.fn();
|
|
const unsubscribeBoom = host.onThemeChange(boom);
|
|
const unsubscribeHealthy = host.onThemeChange(healthy);
|
|
|
|
document.documentElement.classList.add("dark");
|
|
await flushMutationObservers();
|
|
|
|
expect(boom).toHaveBeenCalledTimes(1);
|
|
expect(healthy).toHaveBeenCalledExactlyOnceWith("dark");
|
|
expect(consoleError).toHaveBeenCalledWith(
|
|
"[plugins] theme change listener threw:",
|
|
expect.any(Error),
|
|
);
|
|
|
|
unsubscribeBoom();
|
|
unsubscribeHealthy();
|
|
consoleError.mockRestore();
|
|
});
|
|
|
|
it("ignores class mutations that leave the resolved theme unchanged", async () => {
|
|
document.documentElement.classList.add("dark");
|
|
const host = buildHostApi("jira", createAppStore());
|
|
const listener = vi.fn();
|
|
const unsubscribe = host.onThemeChange(listener);
|
|
|
|
// Unrelated class churn on <html> — the rendering-engine marker, a
|
|
// transition-suppression class, etc. — must not wake every plugin.
|
|
document.documentElement.classList.add("some-unrelated-class");
|
|
await flushMutationObservers();
|
|
expect(listener).not.toHaveBeenCalled();
|
|
|
|
unsubscribe();
|
|
document.documentElement.classList.remove("some-unrelated-class");
|
|
});
|
|
});
|
|
|
|
/**
|
|
* `toast` and `utils` are functions, so they sit beside `navigate`/`openModal`
|
|
* rather than in `ui`, which is a component map.
|
|
*/
|
|
describe("buildHostApi — host.toast / host.utils", () => {
|
|
it("exposes sonner's imperative toast, which needs no host rendering", () => {
|
|
const host = buildHostApi("jira", createAppStore());
|
|
|
|
expect(typeof host.toast).toBe("function");
|
|
for (const method of ["success", "error", "warning", "info", "dismiss"] as const) {
|
|
expect(typeof host.toast[method], `host.toast.${method}`).toBe("function");
|
|
}
|
|
});
|
|
|
|
// Scoped per plugin: an unattributed plugin error toast would land in
|
|
// kandev's own frontend error log as an application error.
|
|
it("scopes toast.error to the calling plugin rather than the app reporting seam", () => {
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
const host = buildHostApi("kandev-plugin-github-status", createAppStore());
|
|
|
|
host.toast.error("Poll failed");
|
|
|
|
expect(consoleError).toHaveBeenCalledWith(
|
|
'[plugins] toast.error from "kandev-plugin-github-status":',
|
|
"Poll failed",
|
|
);
|
|
consoleError.mockRestore();
|
|
});
|
|
|
|
it("exposes cn, merging conflicting tailwind classes the way host components do", () => {
|
|
const host = buildHostApi("jira", createAppStore());
|
|
|
|
expect(typeof host.utils.cn).toBe("function");
|
|
// tailwind-merge semantics, not plain concatenation — the later padding wins.
|
|
expect(host.utils.cn("p-2", "p-4")).toBe("p-4");
|
|
const hidden = false;
|
|
expect(host.utils.cn("text-sm", hidden && "hidden", "font-bold")).toBe("text-sm font-bold");
|
|
});
|
|
|
|
it("exposes formatRelativeTime rather than leaving plugins to hand-roll one", () => {
|
|
const host = buildHostApi("jira", createAppStore());
|
|
|
|
expect(typeof host.utils.formatRelativeTime).toBe("function");
|
|
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000);
|
|
expect(host.utils.formatRelativeTime(threeHoursAgo)).toContain("3");
|
|
});
|
|
|
|
it("exposes the host UUID fallback for plugins on insecure origins", () => {
|
|
const host = buildHostApi("jira", createAppStore());
|
|
const utils = host.utils as unknown as Record<string, unknown>;
|
|
|
|
expect(utils.generateUUID).toBeTypeOf("function");
|
|
});
|
|
});
|
|
|
|
describe("buildHostApi — ui", () => {
|
|
it("exposes RichTextEditor/RichTextReadOnly for plugin notes-style UIs", () => {
|
|
const host = buildHostApi("jira", createAppStore());
|
|
expect(host.ui.RichTextEditor).toBeDefined();
|
|
expect(host.ui.RichTextReadOnly).toBeDefined();
|
|
});
|
|
});
|