feat: community and custom plugin registries

This commit is contained in:
LukeGus committed 2026-10-11 13:29:05 -05:00
1 parent 1aa1bde5f7
commit 6a7afa94a0
31 files changed
+2903 -134

No files matched your search

+73 -1
View File
@@ -291,8 +291,14 @@ export interface RegistryPluginVersion {
size: number;
}
export type RegistryTier = "official" | "community" | "custom";
export interface RegistryPluginEntry {
id: string;
/** Which registry this entry comes from. */
registryId?: string;
registryName?: string;
tier?: RegistryTier;
name: string;
description: string;
author: string;
@@ -320,6 +326,16 @@ export interface RegistryPluginEntry {
installCountSource: string | null;
}
export interface RegistryListingSource {
id: string;
name: string;
tier: RegistryTier;
url: string;
lastCheckedAt: string | null;
error: string | null;
plugins: number;
}
export interface RegistryListing {
registry: {
id: string;
@@ -327,6 +343,7 @@ export interface RegistryListing {
lastCheckedAt: string | null;
error: string | null;
};
registries?: RegistryListingSource[];
managedByServer: boolean;
plugins: RegistryPluginEntry[];
}
@@ -375,7 +392,7 @@ export async function getPluginRegistry(
/** `capabilities` is exactly what the consent prompt showed. */
export async function installPlugin(
pluginId: string,
options: { version?: string; capabilities: string[] },
options: { version?: string; capabilities: string[]; registryId?: string },
): Promise<{ id: string; version: string; state: string }> {
try {
const response = await rbacApi.post(
@@ -407,6 +424,61 @@ export async function updatePlugin(
}
}
export interface CustomRegistryInfo {
id: string;
name: string;
url: string;
keyId: string;
enabled: boolean;
addedAt: string;
}
export interface RegistriesState {
managedByServer: boolean;
official: { id: string; url: string };
community: { id: string; url: string; enabled: boolean };
custom: CustomRegistryInfo[];
}
export interface RegistryPreview {
id: string;
name: string;
url: string;
keyId: string;
plugins: number;
}
export async function getRegistries(): Promise<RegistriesState> {
const response = await rbacApi.get("/plugins/registries");
return response.data;
}
export async function previewRegistry(url: string): Promise<RegistryPreview> {
const response = await rbacApi.post("/plugins/registries/preview", { url });
return response.data;
}
export async function addRegistry(
url: string,
keyId: string,
): Promise<CustomRegistryInfo> {
const response = await rbacApi.post("/plugins/registries", { url, keyId });
return response.data;
}
export async function setRegistryEnabled(
id: string,
enabled: boolean,
): Promise<void> {
await rbacApi.patch(`/plugins/registries/${encodeURIComponent(id)}`, {
enabled,
});
}
export async function removeRegistry(id: string): Promise<void> {
await rbacApi.delete(`/plugins/registries/${encodeURIComponent(id)}`);
}
export async function updateAllPlugins(): Promise<UpdateAllResult> {
try {
const response = await rbacApi.post("/plugins/update-all");
+3 -1
View File
@@ -38,5 +38,7 @@
"pluginCatalog": "plugins",
"cli": "cli",
"develop": "develop",
"communityRegistry": "develop/community-registry"
"communityRegistry": "develop/community-registry",
"pluginRegistries": "guide/plugins#registries",
"hostRegistry": "develop/registries"
}
+39 -2
View File
@@ -1899,7 +1899,8 @@
"tabs": {
"installed": "Installed",
"browse": "Browse",
"updates": "Updates"
"updates": "Updates",
"registries": "Registries"
},
"status": {
"summary": "{{installed}} installed, {{running}} running",
@@ -2084,8 +2085,44 @@
"noneInstalled": "No plugins installed yet. Browse to add some.",
"nothingHere": "Nothing here.",
"nothingHereHint": "Try another category or search.",
"communitySoon": "Community plugins are coming in a later update. Plugin authors can already submit theirs to the community registry.",
"communitySubmit": "How to submit",
"registries": {
"intro": "Registries are lists of plugins you can install. The official one is always on. You can turn on the community registry or add your own.",
"hostYourOwn": "Host your own registry",
"add": "Add registry",
"addTitle": "Add a registry",
"addHint": "Paste the link to the registry's index.json. Termix reads it and shows the key it signs plugins with. Only add registries from people you trust.",
"urlLabel": "Index URL",
"check": "Check",
"addConfirm": "Trust and add",
"added": "Added {{name}}",
"customWarning": "Plugins from this registry are not reviewed by the Termix team. Only the owner of the registry decides what is in it. Every download must be signed by the key above.",
"keyFingerprint": "Key {{keyId}}",
"pluginCount_one": "{{count}} plugin",
"pluginCount_other": "{{count}} plugins",
"officialName": "Official",
"communityName": "Community",
"alwaysOn": "Always on",
"communityHint": "Plugins made by other people. Each version is checked by a person and by automated checks before it is listed.",
"communityConfirmTitle": "Turn on community plugins?",
"communityConfirmBody": "Community plugins are made by other people, not the Termix team. Every version is checked by a person and by automated checks before it is listed, but mistakes can still get through. Only install plugins you trust and check what each one asks for.",
"communityConfirm": "Turn on",
"communityBadge": "Community",
"communityBadgeHint": "From the community registry. Made by other people and reviewed before it was listed.",
"customBadge": "Custom",
"customBadgeHint": "From {{name}}, a registry an admin added. Not reviewed by the Termix team.",
"remove": "Remove",
"removeTitle": "Remove {{name}}?",
"removeBody": "Its plugins will no longer show in Browse. You can add it again later.",
"removed": "Removed {{name}}",
"inUse": "Uninstall the plugins from {{name}} first: {{list}}",
"changeFailed": "Could not change the registry: {{error}}",
"loadFailed": "Could not load registries: {{error}}",
"unreachable": "Could not read it: {{error}}",
"filter": "Registry",
"allRegistries": "All registries",
"browseHint": "Turn on community plugins or add your own registry in the Registries tab."
},
"upToDate": "Everything is up to date.",
"lastChecked": "Last checked {{time}}",
"updatesReady_one": "{{count}} update ready.",
+5
View File
@@ -33,6 +33,7 @@ import {
CapabilityRow,
InstallCountFact,
PluginIconBox,
SourceBadge,
UnverifiedBadge,
} from "./plugin-bits";
import {
@@ -108,6 +109,10 @@ export function PluginDetail({
</span>
<Facts className="text-[11px] text-muted-foreground/70">
{plugin.unverified && <UnverifiedBadge />}
<SourceBadge
source={plugin.source}
registryName={plugin.registryName}
/>
{plugin.isBeta && <BetaBadge />}
{plugin.author && <span>{plugin.author}</span>}
{plugin.version ? (
+58 -4
View File
@@ -6,6 +6,7 @@ import {
Columns3,
Download,
Globe,
Library,
Puzzle,
RefreshCw,
RotateCcw,
@@ -42,8 +43,10 @@ import {
BetaBadge,
InstallCountFact,
PluginIconBox,
SourceBadge,
UnverifiedBadge,
} from "./plugin-bits";
import { RegistriesSection } from "./RegistriesSection";
import { PluginConsentPrompt } from "./PluginConsentPrompt";
import { PluginDetail } from "./PluginDetail";
import {
@@ -60,7 +63,7 @@ import { usePluginsManager, type PluginsManager } from "./use-plugins-manager";
import { docsUrl } from "@/lib/docs";
import { DocsLink } from "@/components/docs-link";
type Section = "installed" | "browse" | "updates";
type Section = "installed" | "browse" | "updates" | "registries";
const GRID_SIZES = ["auto", "2", "3", "4", "5", "6"] as const;
type GridSize = (typeof GRID_SIZES)[number];
@@ -146,6 +149,11 @@ function PluginsTabBody() {
icon: <Download className="size-3" />,
count: updates.filter((p) => !p.pinnedVersion).length || undefined,
},
{
id: "registries",
label: t("plugins.manager.tabs.registries"),
icon: <Library className="size-3" />,
},
];
return (
@@ -217,12 +225,14 @@ function PluginsTabBody() {
layout={layout}
layoutControls={layoutControls}
/>
) : (
) : section === "updates" ? (
<UpdatesSection
plugins={updates}
manager={manager}
onOpen={setDetailId}
/>
) : (
<RegistriesSection manager={manager} />
)}
</div>
</PanelShell>
@@ -442,6 +452,7 @@ function NameCell({ plugin, muted }: { plugin: PluginEntry; muted?: boolean }) {
{plugin.name}
</span>
{plugin.unverified && <UnverifiedBadge />}
<SourceBadge source={plugin.source} registryName={plugin.registryName} />
{plugin.isBeta && <BetaBadge />}
</div>
);
@@ -704,6 +715,10 @@ function InstalledCard({
facts={
<>
{plugin.unverified && <UnverifiedBadge />}
<SourceBadge
source={plugin.source}
registryName={plugin.registryName}
/>
{plugin.isBeta && <BetaBadge />}
{plugin.author && <span>{plugin.author}</span>}
<span>v{plugin.version}</span>
@@ -935,14 +950,20 @@ function BrowseSection({
}) {
const { t } = useTranslation();
const [category, setCategory] = useState<string | null>(null);
const [registry, setRegistry] = useState<string | null>(null);
const [sort, setSort] = useState<SortKey>("popular");
const categories = useMemo(() => categoriesOf(plugins), [plugins]);
const registries = manager.registries;
const registryFilter = registries.some((entry) => entry.id === registry)
? registry
: null;
const rows = sortPlugins(
plugins.filter(
(p) =>
matchesQuery(p, query) &&
(category === null || p.category === category),
(category === null || p.category === category) &&
(registryFilter === null || p.registryId === registryFilter),
),
sort,
);
@@ -1011,6 +1032,35 @@ function BrowseSection({
placeholder={t("plugins.manager.searchPlugins")}
>
<div className="ml-auto flex items-center gap-2">
{registries.length > 1 && (
<Select
value={registryFilter ?? "__all"}
onValueChange={(v) => setRegistry(v === "__all" ? null : v)}
>
<SelectTrigger
size="sm"
className="w-auto gap-1.5 text-xs"
aria-label={t("plugins.manager.registries.filter")}
>
<Library className="size-3.5 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all" className="text-xs">
{t("plugins.manager.registries.allRegistries")}
</SelectItem>
{registries.map((entry) => (
<SelectItem
key={entry.id}
value={entry.id}
className="text-xs"
>
{entry.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<SortSelect value={sort} onChange={setSort} />
{layoutControls}
</div>
@@ -1063,7 +1113,7 @@ function BrowseSection({
)}
<p className="text-[11px] text-muted-foreground">
{t("plugins.manager.communitySoon")}{" "}
{t("plugins.manager.registries.browseHint")}{" "}
<DocsLink core="communityRegistry" className="text-[11px]">
{t("plugins.manager.communitySubmit")}
</DocsLink>
@@ -1091,6 +1141,10 @@ function BrowseCard({
onOpen={onOpen}
facts={
<>
<SourceBadge
source={plugin.source}
registryName={plugin.registryName}
/>
{plugin.author && <span>{plugin.author}</span>}
{plugin.latestVersion && <span>v{plugin.latestVersion}</span>}
{plugin.category && <span>{plugin.category}</span>}
+390
View File
@@ -0,0 +1,390 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Library, Trash2 } from "lucide-react";
import { Input } from "@/components/input";
import { FakeSwitch } from "@/components/section-card";
import { PANEL } from "@/components/panel-layout";
import {
AddButton,
FormFooter,
ListBadge,
ListRow,
ListRowAction,
} from "@/components/list-kit";
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
import { DocsLink } from "@/components/docs-link";
import {
addRegistry,
getRegistries,
previewRegistry,
removeRegistry,
setRegistryEnabled,
type RegistriesState,
type RegistryListingSource,
type RegistryPreview,
} from "@/api/plugins-api";
import type { PluginsManager } from "./use-plugins-manager";
function errorOf(error: unknown): {
message: string;
code?: string;
data: Record<string, unknown>;
} {
const data = (error as { response?: { data?: Record<string, unknown> } })
?.response?.data;
return {
message:
typeof data?.error === "string"
? data.error
: error instanceof Error
? error.message
: String(error),
code: typeof data?.code === "string" ? data.code : undefined,
data: data ?? {},
};
}
export function RegistriesSection({ manager }: { manager: PluginsManager }) {
const { t } = useTranslation();
const confirm = useConfirm();
const [state, setState] = useState<RegistriesState | null>(null);
const [adding, setAdding] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setState(await getRegistries());
} catch (error) {
toast.error(
t("plugins.manager.registries.loadFailed", {
error: errorOf(error).message,
}),
);
}
}, [t]);
useEffect(() => {
void load();
}, [load]);
const statusOf = (id: string): RegistryListingSource | undefined =>
manager.registries.find((entry) => entry.id === id);
const toggle = async (id: string, enabled: boolean) => {
if (enabled && id === "community") {
const ok = await confirm({
title: t("plugins.manager.registries.communityConfirmTitle"),
description: t("plugins.manager.registries.communityConfirmBody"),
confirmLabel: t("plugins.manager.registries.communityConfirm"),
destructive: false,
});
if (!ok) return;
}
setBusy(id);
try {
await setRegistryEnabled(id, enabled);
await load();
await manager.refresh();
} catch (error) {
toast.error(
t("plugins.manager.registries.changeFailed", {
error: errorOf(error).message,
}),
);
} finally {
setBusy(null);
}
};
const remove = async (id: string, name: string) => {
const ok = await confirm({
title: t("plugins.manager.registries.removeTitle", { name }),
description: t("plugins.manager.registries.removeBody"),
confirmLabel: t("plugins.manager.registries.remove"),
});
if (!ok) return;
setBusy(id);
try {
await removeRegistry(id);
toast.success(t("plugins.manager.registries.removed", { name }));
await load();
await manager.refresh();
} catch (error) {
const info = errorOf(error);
toast.error(
info.code === "REGISTRY_IN_USE"
? t("plugins.manager.registries.inUse", {
name,
list: ((info.data.plugins as string[]) ?? []).join(", "),
})
: t("plugins.manager.registries.changeFailed", {
error: info.message,
}),
);
} finally {
setBusy(null);
}
};
const readOnly = manager.managedByServer || state?.managedByServer;
const meta = (id: string, url: string, extra?: string) => {
const status = statusOf(id);
const parts = [url];
if (status && !status.error) {
parts.push(
t("plugins.manager.registries.pluginCount", { count: status.plugins }),
);
}
if (extra) parts.push(extra);
return parts.join(" | ");
};
const errorLine = (id: string) => {
const error = statusOf(id)?.error;
return error ? (
<span className="text-[11px] leading-snug text-destructive">
{t("plugins.manager.registries.unreachable", { error })}
</span>
) : null;
};
return (
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
<div className="flex flex-wrap items-center gap-2">
<p className="min-w-0 flex-1 text-[11px] leading-snug text-muted-foreground">
{t("plugins.manager.registries.intro")}{" "}
<DocsLink core="hostRegistry" className="text-[11px]">
{t("plugins.manager.registries.hostYourOwn")}
</DocsLink>
</p>
{!readOnly && (
<AddButton
label={t("plugins.manager.registries.add")}
onClick={() => setAdding(true)}
/>
)}
</div>
{state && (
<div className="flex flex-col border-t border-border/40">
<ListRow
stripe={0}
icon={<Library />}
title={t("plugins.manager.registries.officialName")}
badges={
<ListBadge tone="brand">
{t("plugins.manager.registries.alwaysOn")}
</ListBadge>
}
meta={meta("official", state.official.url)}
>
{errorLine("official")}
</ListRow>
<ListRow
stripe={1}
icon={<Library />}
tone={state.community.enabled ? "brand" : "muted"}
dimmed={!state.community.enabled}
title={t("plugins.manager.registries.communityName")}
meta={meta("community", state.community.url)}
trailing={
<FakeSwitch
checked={state.community.enabled}
disabled={readOnly || busy === "community"}
onChange={(next) => void toggle("community", next)}
/>
}
>
<span className="text-[11px] leading-snug text-muted-foreground">
{t("plugins.manager.registries.communityHint")}{" "}
<DocsLink core="communityRegistry" className="text-[11px]">
{t("plugins.manager.communitySubmit")}
</DocsLink>
</span>
{state.community.enabled && errorLine("community")}
</ListRow>
{state.custom.map((entry, index) => (
<ListRow
key={entry.id}
stripe={index}
icon={<Library />}
tone={entry.enabled ? "warning" : "muted"}
dimmed={!entry.enabled}
title={entry.name}
badges={
<ListBadge tone="warning">
{t("plugins.manager.registries.customBadge")}
</ListBadge>
}
meta={meta(
entry.id,
entry.url,
t("plugins.manager.registries.keyFingerprint", {
keyId: entry.keyId,
}),
)}
trailing={
<FakeSwitch
checked={entry.enabled}
disabled={readOnly || busy === entry.id}
onChange={(next) => void toggle(entry.id, next)}
/>
}
actions={
readOnly ? undefined : (
<ListRowAction
label={t("plugins.manager.registries.remove")}
tone="destructive"
disabled={busy === entry.id}
onClick={() => void remove(entry.id, entry.name)}
>
<Trash2 />
</ListRowAction>
)
}
>
{entry.enabled && errorLine(entry.id)}
</ListRow>
))}
</div>
)}
<AddRegistryView
open={adding}
onOpenChange={setAdding}
onAdded={async () => {
setAdding(false);
await load();
await manager.refresh();
}}
/>
</div>
);
}
function AddRegistryView({
open,
onOpenChange,
onAdded,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdded: () => Promise<void>;
}) {
const { t } = useTranslation();
const [url, setUrl] = useState("");
const [preview, setPreview] = useState<RegistryPreview | null>(null);
const [error, setError] = useState<string | null>(null);
const [working, setWorking] = useState(false);
useEffect(() => {
if (!open) {
setUrl("");
setPreview(null);
setError(null);
}
}, [open]);
const check = async () => {
setWorking(true);
setError(null);
setPreview(null);
try {
setPreview(await previewRegistry(url.trim()));
} catch (failure) {
setError(errorOf(failure).message);
} finally {
setWorking(false);
}
};
const add = async () => {
if (!preview) return;
setWorking(true);
setError(null);
try {
const added = await addRegistry(preview.url, preview.keyId);
toast.success(
t("plugins.manager.registries.added", { name: added.name }),
);
await onAdded();
} catch (failure) {
const info = errorOf(failure);
if (info.code === "KEY_MISMATCH") setPreview(null);
setError(info.message);
} finally {
setWorking(false);
}
};
return (
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("plugins.manager.registries.addTitle")}
icon={<Library className="size-4" />}
footer={
<FormFooter
onCancel={() => onOpenChange(false)}
onSave={() => void (preview ? add() : check())}
saveLabel={
preview
? t("plugins.manager.registries.addConfirm")
: t("plugins.manager.registries.check")
}
saving={working}
disabled={!url.trim()}
/>
}
>
<div className="flex flex-col gap-3 p-3">
<p className="text-[11px] leading-snug text-muted-foreground">
{t("plugins.manager.registries.addHint")}
</p>
<Input
value={url}
onChange={(e) => {
setUrl(e.target.value);
setPreview(null);
setError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && url.trim() && !working) {
e.preventDefault();
void (preview ? add() : check());
}
}}
placeholder="https://example.com/termix/index.json"
className="h-8 text-xs"
aria-label={t("plugins.manager.registries.urlLabel")}
autoFocus
/>
{error && (
<p className="text-[11px] leading-snug text-destructive">{error}</p>
)}
{preview && (
<div className="flex flex-col gap-1.5 border border-warning/40 bg-warning/5 p-3">
<span className="text-sm font-semibold">{preview.name}</span>
<span className="text-[11px] text-muted-foreground">
{t("plugins.manager.registries.pluginCount", {
count: preview.plugins,
})}
</span>
<span className="text-[11px] text-muted-foreground">
{t("plugins.manager.registries.keyFingerprint", {
keyId: preview.keyId,
})}
</span>
<p className="text-[11px] leading-snug text-muted-foreground">
{t("plugins.manager.registries.customWarning")}
</p>
</div>
)}
</div>
</InlineView>
);
}
+26
View File
@@ -88,6 +88,32 @@ export function UnverifiedBadge() {
);
}
/** Marks a plugin from the community registry or a custom one. */
export function SourceBadge({
source,
registryName,
}: {
source: string;
registryName?: string | null;
}) {
const { t } = useTranslation();
if (source !== "community" && source !== "custom") return null;
const label =
source === "community"
? t("plugins.manager.registries.communityBadge")
: (registryName ?? t("plugins.manager.registries.customBadge"));
return (
<span
className="max-w-32 shrink-0 truncate border border-border bg-muted/40 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-muted-foreground"
title={t(`plugins.manager.registries.${source}BadgeHint`, {
name: label,
})}
>
{label}
</span>
);
}
export function BetaBadge() {
const { t } = useTranslation();
return (
+16 -3
View File
@@ -67,9 +67,13 @@ export interface PluginEntry {
source: PluginSource;
/** Installed from a file or folder with no registry signature behind it. */
unverified: boolean;
/** The registry it is listed in or was installed from. */
registryId: string | null;
registryName: string | null;
}
export type PluginSource = "bundled" | "official" | "unverified";
export type PluginSource =
"bundled" | "official" | "community" | "custom" | "unverified";
export function isPrerelease(version: string | null | undefined): boolean {
return Boolean(
@@ -97,7 +101,10 @@ export function pluginSource(summary: PluginSummary): PluginSource {
if (summary.source === "bundled" || summary.tier === "bundled") {
return "bundled";
}
return summary.signedBy ? "official" : "unverified";
if (!summary.signedBy) return "unverified";
return summary.tier === "community" || summary.tier === "custom"
? summary.tier
: "official";
}
export function pluginStatus(summary: PluginSummary): PluginStatus {
@@ -158,6 +165,8 @@ export function mergePlugins(
installCountSource: entry?.installCountSource ?? null,
source: pluginSource(summary),
unverified: pluginSource(summary) === "unverified",
registryId: entry?.registryId ?? summary.registryId ?? null,
registryName: entry?.registryName ?? null,
});
}
@@ -198,8 +207,10 @@ export function mergePlugins(
publishedAt: latestPublished(entry.versions),
installCount: entry.installCount ?? null,
installCountSource: entry.installCountSource ?? null,
source: "official",
source: entry.tier ?? "official",
unverified: false,
registryId: entry.registryId ?? "official",
registryName: entry.registryName ?? null,
});
}
@@ -239,6 +250,8 @@ export function uploadEntry(preview: PluginUploadPreview): PluginEntry {
installCountSource: null,
source: "unverified",
unverified: true,
registryId: null,
registryName: null,
};
}
+3
View File
@@ -183,6 +183,7 @@ export function usePluginsManager() {
const result = await installPlugin(plugin.id, {
version: version === plugin.latestVersion ? undefined : version,
capabilities,
registryId: plugin.registryId ?? undefined,
});
if (result.state === "failed" || result.state === "blocked") {
toast.error(
@@ -585,6 +586,8 @@ export function usePluginsManager() {
registryError,
managedByServer: registry?.managedByServer ?? false,
lastCheckedAt: registry?.registry.lastCheckedAt ?? null,
registries: registry?.registries ?? [],
reload: () => load(),
busy,
consent,
setConsent,
+5 -2
View File
@@ -40,6 +40,7 @@ vi.mock("@/plugins/use-plugins-manager", () => ({
confirmConsent: vi.fn(),
developerMode: false,
signedOnly: false,
registries: [],
}),
}));
@@ -60,12 +61,14 @@ beforeEach(() => localStorage.clear());
afterEach(cleanup);
describe("PluginsTab", () => {
it("tells people community plugins are coming and links the guide", async () => {
it("points to the Registries tab and links the submit guide", async () => {
await renderTab();
fireEvent.click(screen.getByText(en.plugins.manager.tabs.browse));
expect(
screen.getByText(en.plugins.manager.communitySoon, { exact: false }),
screen.getByText(en.plugins.manager.registries.browseHint, {
exact: false,
}),
).toBeTruthy();
const link = screen.getByText(en.plugins.manager.communitySubmit);
expect(link.getAttribute("href")).toBe(docsUrl("communityRegistry"));
@@ -0,0 +1,135 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import i18next from "i18next";
import { I18nextProvider, initReactI18next } from "react-i18next";
import en from "@/locales/en.json";
import { RegistriesSection } from "@/plugins/RegistriesSection";
import type { PluginsManager } from "@/plugins/use-plugins-manager";
const api = vi.hoisted(() => ({
getRegistries: vi.fn(),
previewRegistry: vi.fn(),
addRegistry: vi.fn(),
setRegistryEnabled: vi.fn(),
removeRegistry: vi.fn(),
}));
vi.mock("@/api/plugins-api", () => api);
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const manager = {
registries: [],
managedByServer: false,
refresh: vi.fn(async () => {}),
} as unknown as PluginsManager;
function state(communityEnabled = false) {
return {
managedByServer: false,
official: { id: "official", url: "https://example.com/official.json" },
community: {
id: "community",
url: "https://example.com/community.json",
enabled: communityEnabled,
},
custom: [],
};
}
async function renderSection() {
const i18n = i18next.createInstance();
await i18n.use(initReactI18next).init({
lng: "en",
resources: { en: { translation: en } },
});
return render(
<I18nextProvider i18n={i18n}>
<RegistriesSection manager={manager} />
</I18nextProvider>,
);
}
beforeEach(() => {
for (const fn of Object.values(api)) fn.mockReset();
api.getRegistries.mockResolvedValue(state());
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("RegistriesSection", () => {
it("asks before turning community plugins on", async () => {
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
await renderSection();
const toggle = await screen.findByRole("switch");
fireEvent.click(toggle);
await waitFor(() => expect(confirm).toHaveBeenCalled());
expect(confirm.mock.calls[0][0]).toContain(
en.plugins.manager.registries.communityConfirmBody,
);
expect(api.setRegistryEnabled).not.toHaveBeenCalled();
confirm.mockReturnValue(true);
api.getRegistries.mockResolvedValue(state(true));
fireEvent.click(toggle);
await waitFor(() =>
expect(api.setRegistryEnabled).toHaveBeenCalledWith("community", true),
);
});
it("does not ask when turning community plugins off", async () => {
const confirm = vi.spyOn(window, "confirm");
api.getRegistries.mockResolvedValue(state(true));
await renderSection();
fireEvent.click(await screen.findByRole("switch"));
await waitFor(() =>
expect(api.setRegistryEnabled).toHaveBeenCalledWith("community", false),
);
expect(confirm).not.toHaveBeenCalled();
});
it("checks a registry before adding it with the key it showed", async () => {
api.previewRegistry.mockResolvedValue({
id: "acme",
name: "Acme Plugins",
url: "https://acme.example/index.json",
keyId: "abcd1234abcd1234",
plugins: 3,
});
api.addRegistry.mockResolvedValue({
id: "acme",
name: "Acme Plugins",
url: "https://acme.example/index.json",
keyId: "abcd1234abcd1234",
enabled: true,
addedAt: "",
});
await renderSection();
fireEvent.click(await screen.findByText(en.plugins.manager.registries.add));
fireEvent.change(
screen.getByLabelText(en.plugins.manager.registries.urlLabel),
{ target: { value: "https://acme.example/index.json" } },
);
fireEvent.click(screen.getByText(en.plugins.manager.registries.check));
expect(await screen.findByText("Acme Plugins")).toBeTruthy();
expect(screen.getByText("Key abcd1234abcd1234")).toBeTruthy();
expect(api.addRegistry).not.toHaveBeenCalled();
fireEvent.click(screen.getByText(en.plugins.manager.registries.addConfirm));
await waitFor(() =>
expect(api.addRegistry).toHaveBeenCalledWith(
"https://acme.example/index.json",
"abcd1234abcd1234",
),
);
});
});