import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { PluginRow, type PluginRowUpdateState } from "./plugin-row"; import type { MarketplaceEntry, PluginRecord } from "@/lib/types/plugins"; afterEach(() => cleanup()); function plugin(overrides: Partial = {}): PluginRecord { return { id: "acme", api_version: 1, version: "1.0.0", display_name: "Acme", description: "", author: "acme", categories: [], capabilities: {}, status: "active", install_path: "/p", signed: true, installed_at: "2026-01-01T00:00:00Z", restart_count: 0, ...overrides, }; } function marketplaceEntry(overrides: Partial = {}): MarketplaceEntry { return { id: "acme", name: "Acme", description: "", author: "acme", categories: [], icon_url: "", repo_url: "", version: "2.0.0", min_kandev_version: "", package_url: "https://ex/acme-2.0.0.tar.gz", package_sha256: "", stars: 0, updated_at: "", install_state: "update_available", source_id: "official", source_name: "Kandev Official", ...overrides, }; } function updateState(overrides: Partial = {}): PluginRowUpdateState { return { latest: marketplaceEntry(), hasUpdate: true, checked: true, busy: false, ...overrides, }; } const noop = () => undefined; const UPDATE_BUTTON_TESTID = "plugin-update-acme"; const LATEST_VERSION_TESTID = "plugin-latest-version-acme"; const UPDATE_BADGE_TESTID = "plugin-update-available-acme"; const NOT_IN_MARKETPLACE_TESTID = "plugin-not-in-marketplace-acme"; // baseProps carries the always-required callbacks/flags so each test only // spells out the props it is actually asserting on. const baseProps = { busy: false, autoUpdateDefault: false, autoUpdateBusy: false, onEnable: noop, onDisable: noop, onSetAutoUpdate: noop, }; /** Test ids reused across these cases. */ const REPO_LINK_TESTID = "plugin-repo-link"; const AUTO_UPDATE_TESTID = "plugin-auto-update-acme"; /** The class that lifts a control above the card's overlay link. */ const ABOVE_OVERLAY = "z-10"; describe("PluginRow settings link", () => { it("covers the whole card with a named link to the plugin's settings page", () => { render(); const link = screen.getByTestId("plugin-row-link-acme"); expect(link.getAttribute("href")).toBe("/settings/plugins/acme"); // The link carries no text of its own, so the accessible name has to come // from the label — without it the card is an unnamed link. expect(link.getAttribute("aria-label")).toBe("Open settings for Acme"); expect(link.className).toContain("absolute"); expect(link.className).toContain("inset-0"); }); it("percent-encodes a plugin id that is not URL-safe", () => { render(); expect(screen.getByTestId("plugin-row-link-acme/one").getAttribute("href")).toBe( "/settings/plugins/acme%2Fone", ); }); it("keeps the row's own controls above the overlay link", () => { render( , ); // Anything interactive must sit at z-10; under the overlay it is unclickable. expect(screen.getByTestId(REPO_LINK_TESTID).className).toContain(ABOVE_OVERLAY); expect(screen.getByRole("button", { name: "Disable" }).parentElement?.className).toContain( ABOVE_OVERLAY, ); expect(screen.getByTestId(AUTO_UPDATE_TESTID).parentElement?.className).toContain( ABOVE_OVERLAY, ); }); }); describe("PluginRow setup-required badge", () => { it("flags a plugin whose required settings are unset", () => { render(); expect(screen.getByTestId("plugin-setup-required-acme").textContent).toBe("Setup required"); }); it("renders no badge when the plugin needs no setup", () => { render(); expect(screen.queryByTestId("plugin-setup-required-acme")).toBeNull(); }); }); describe("PluginRow update button", () => { it("shows an Update button with the new version and fires onUpdate", () => { const onUpdate = vi.fn(); render( , ); const button = screen.getByTestId(UPDATE_BUTTON_TESTID); expect(button.textContent).toContain("Update to v2.0.0"); fireEvent.click(button); expect(onUpdate).toHaveBeenCalledWith(marketplaceEntry()); }); it("renders no Update button when there is no pending update", () => { render(); expect(screen.queryByTestId(UPDATE_BUTTON_TESTID)).toBeNull(); }); it("shows a spinner and 'Updating…' while a manual update is in flight, and disables Enable/Disable/Uninstall", () => { render( , ); const button = screen.getByTestId(UPDATE_BUTTON_TESTID); expect(button.textContent).toContain("Updating"); expect(button.querySelector(".animate-spin")).not.toBeNull(); expect(button.getAttribute("aria-busy")).toBe("true"); expect((button as HTMLButtonElement).disabled).toBe(true); expect((screen.getByRole("button", { name: "Uninstall" }) as HTMLButtonElement).disabled).toBe( true, ); }); it("shows an inline error after a failed manual update, and keeps the button clickable", () => { const onUpdate = vi.fn(); render( , ); expect(screen.getByTestId("plugin-update-error-acme").textContent).toContain("bad checksum"); const button = screen.getByTestId(UPDATE_BUTTON_TESTID); expect((button as HTMLButtonElement).disabled).toBe(false); fireEvent.click(button); expect(onUpdate).toHaveBeenCalled(); }); }); describe("PluginRow latest version info", () => { it("shows the latest version without duplicating the update button with a badge", () => { render(); expect(screen.getByTestId(LATEST_VERSION_TESTID).textContent).toContain("Latest v2.0.0"); expect(screen.queryByTestId(UPDATE_BADGE_TESTID)).toBeNull(); expect(screen.getByTestId(UPDATE_BUTTON_TESTID).getAttribute("data-variant")).toBe("default"); }); it("shows the latest version with no badge and no button when already up to date", () => { render( , ); expect(screen.getByTestId(LATEST_VERSION_TESTID).textContent).toContain("Latest v1.0.0"); expect(screen.queryByTestId(UPDATE_BADGE_TESTID)).toBeNull(); expect(screen.queryByTestId(UPDATE_BUTTON_TESTID)).toBeNull(); }); it("shows a not-in-marketplace hint once checked and absent from every catalog", () => { render( , ); expect(screen.getByTestId(NOT_IN_MARKETPLACE_TESTID)).toBeTruthy(); expect(screen.queryByTestId(LATEST_VERSION_TESTID)).toBeNull(); }); // Regression: a plugin carried only by a source that failed this check is // unknown, not delisted — claiming "Not in the marketplace" reads as removal. it("withholds the not-in-marketplace hint when the check only reached some sources", () => { render( , ); expect(screen.queryByTestId(NOT_IN_MARKETPLACE_TESTID)).toBeNull(); expect(screen.queryByTestId(LATEST_VERSION_TESTID)).toBeNull(); }); it("still shows a known latest version when some other source is degraded", () => { render( , ); expect(screen.getByTestId(LATEST_VERSION_TESTID).textContent).toContain("Latest v2.0.0"); expect(screen.queryByTestId(UPDATE_BADGE_TESTID)).toBeNull(); expect(screen.getByTestId(UPDATE_BUTTON_TESTID).getAttribute("data-variant")).toBe("default"); }); it("shows neither the latest version nor the not-in-marketplace hint before the first successful check", () => { render( , ); expect(screen.queryByTestId(LATEST_VERSION_TESTID)).toBeNull(); expect(screen.queryByTestId(NOT_IN_MARKETPLACE_TESTID)).toBeNull(); }); it("renders nothing update-related when no update prop is passed at all", () => { render(); expect(screen.queryByTestId(LATEST_VERSION_TESTID)).toBeNull(); expect(screen.queryByTestId(NOT_IN_MARKETPLACE_TESTID)).toBeNull(); expect(screen.queryByTestId(UPDATE_BADGE_TESTID)).toBeNull(); }); }); describe("PluginRow repo link", () => { it("renders a Repo link when the plugin declares an http(s) repo_url", () => { render( , ); const link = screen.getByTestId(REPO_LINK_TESTID); expect(link.getAttribute("href")).toBe("https://github.com/kdlbs/kandev-plugin-acme"); }); it("renders no Repo link when the plugin declares no repo_url", () => { render(); expect(screen.queryByTestId(REPO_LINK_TESTID)).toBeNull(); }); it("renders no Repo link for a non-http(s) repo_url scheme", () => { render( , ); expect(screen.queryByTestId(REPO_LINK_TESTID)).toBeNull(); }); }); describe("PluginRow settings link", () => { it("shows a labeled settings link beside the row actions", () => { render(); const settingsLink = screen.getByTestId("plugin-settings-link-acme"); expect(settingsLink.textContent).toContain("Settings"); expect(settingsLink.getAttribute("href")).toBe("/settings/plugins/acme"); expect(settingsLink.className).toContain("min-h-11"); }); }); describe("PluginRow error recovery", () => { it("shows the failure diagnostic and an Enable action for errored plugins", () => { const onEnable = vi.fn(); const p = plugin({ status: "error", last_error: "plugins/runtime: handshake failed", last_error_at: "2026-08-02T12:34:56Z", }); render(); expect(screen.getByRole("alert").textContent).toContain("plugins/runtime: handshake failed"); const enable = screen.getByRole("button", { name: "Enable" }); fireEvent.click(enable); expect(onEnable).toHaveBeenCalledWith(p); }); }); describe("PluginRow auto-update toggle", () => { it("reflects the global default when the plugin has no override", () => { render(); const toggle = screen.getByTestId(AUTO_UPDATE_TESTID); expect(toggle.getAttribute("aria-checked")).toBe("true"); // No override → no "override" badge and no Reset affordance. expect(screen.queryByTestId("plugin-auto-update-reset-acme")).toBeNull(); }); it("prefers the per-plugin override over the global default", () => { render(); const toggle = screen.getByTestId(AUTO_UPDATE_TESTID); expect(toggle.getAttribute("aria-checked")).toBe("false"); expect(screen.getByTestId("plugin-auto-update-reset-acme")).toBeTruthy(); }); it("sets an explicit override when toggled", () => { const onSetAutoUpdate = vi.fn(); const p = plugin({ auto_update: null }); render( , ); fireEvent.click(screen.getByTestId(AUTO_UPDATE_TESTID)); expect(onSetAutoUpdate).toHaveBeenCalledWith(p, true); }); it("clears the override via Reset", () => { const onSetAutoUpdate = vi.fn(); const p = plugin({ auto_update: true }); render(); fireEvent.click(screen.getByTestId("plugin-auto-update-reset-acme")); expect(onSetAutoUpdate).toHaveBeenCalledWith(p, null); }); }); describe("PluginRow uninstall confirmation", () => { const pluginName = "Acme Tools"; it("anchors fine-pointer confirmation to the row action and names the target", () => { const p = plugin({ display_name: pluginName }); const onConfirmUninstall = vi.fn(); render( , ); fireEvent.click(screen.getByRole("button", { name: /^Uninstall$/i })); expect(screen.getByTestId("plugin-uninstall-confirm-popover").textContent).toContain( pluginName, ); expect(document.querySelector('[data-slot="dialog-overlay"]')).toBeNull(); fireEvent.click(screen.getByRole("button", { name: /^Cancel$/i })); expect(screen.queryByTestId("plugin-uninstall-confirm-popover")).toBeNull(); }); it("uses inline touch actions on coarse pointers and passes the row target on confirm", async () => { const p = plugin({ display_name: pluginName }); const onConfirmUninstall = vi.fn(); render( , ); fireEvent.click(screen.getByRole("button", { name: /^Uninstall$/i })); const confirmation = screen.getByTestId("plugin-uninstall-inline-confirmation"); expect(confirmation.textContent).toContain(pluginName); expect(screen.queryByRole("button", { name: /^Uninstall$/i })).toBeNull(); expect(screen.getByTestId("plugin-uninstall-confirm").className).toContain("h-11"); expect(screen.getByTestId("plugin-uninstall-confirm").className).toContain("min-w-11"); fireEvent.click(screen.getByTestId("plugin-uninstall-confirm")); await waitFor(() => expect(onConfirmUninstall).toHaveBeenCalledWith(p)); }); });