import { describe, expect, it } from "vitest"; import type { PluginSummary, RegistryPluginEntry } from "@/api/plugins-api"; import { betaFeedbackUrl, betaToTry, isPrerelease, categoriesOf, describeContributions, formatBytes, formatCount, groupChanges, kindsOf, matchesQuery, mergeReleaseNotes, mergePlugins, orderByRisk, pageOf, pluginSource, pluginStatus, reportIssueUrl, requestFeatureUrl, sortPlugins, uploadEntry, youtubeEmbedUrl, } from "@/plugins/plugin-model"; function summary(overrides: Partial = {}): PluginSummary { return { id: "docker", name: "Docker", version: "1.0.0", enabled: true, state: "active", contributes: null, capabilities: ["kv:own"], ...overrides, }; } function entry( overrides: Partial = {}, ): RegistryPluginEntry { return { id: "docker", name: "Docker", description: "Containers", author: "Termix", category: "Infrastructure", versions: [ { version: "1.1.0", compatible: true, capabilities: ["kv:own", "ssh:connect"], publishedAt: "2026-10-02", size: 1, }, ], latestVersion: "1.1.0", installed: true, installedVersion: "1.0.0", updateAvailable: true, addedCapabilities: ["ssh:connect"], pinnedVersion: null, autoUpdate: false, bundled: true, downloads: null, ...overrides, }; } describe("mergePlugins", () => { it("joins an installed plugin with its registry entry", () => { const [row] = mergePlugins([summary()], [entry()]); expect(row).toMatchObject({ installed: true, version: "1.0.0", latestVersion: "1.1.0", updateAvailable: true, addedCapabilities: ["ssh:connect"], capabilities: ["kv:own"], category: "Infrastructure", inRegistry: true, status: "running", }); }); it("says whether the linked server decides an installed plugin", () => { const rows = mergePlugins( [summary({ managedByServer: true })], [entry({ id: "other" })], ); expect(rows.map((row) => row.managedByServer)).toEqual([true, false]); }); it("lists registry plugins that are not installed with the latest capabilities", () => { const rows = mergePlugins( [], [entry({ installed: false, installedVersion: null })], ); expect(rows[0]).toMatchObject({ installed: false, version: null, status: null, capabilities: ["kv:own", "ssh:connect"], }); }); it("uses the installed features and falls back to the registry", () => { const registry = entry({ features: ["From registry"] }); expect( mergePlugins([summary({ features: ["Installed"] })], [registry])[0] .features, ).toEqual(["Installed"]); expect(mergePlugins([summary()], [registry])[0].features).toEqual([ "From registry", ]); expect(mergePlugins([summary()], null)[0].features).toEqual([]); }); it("uses the installed docs link and falls back to the registry", () => { const registry = entry({ docs: "https://docs.example.com/registry" }); expect( mergePlugins( [summary({ docs: "https://docs.example.com/installed" })], [registry], )[0].docs, ).toBe("https://docs.example.com/installed"); expect(mergePlugins([summary()], [registry])[0].docs).toBe( "https://docs.example.com/registry", ); expect(mergePlugins([summary()], null)[0].docs).toBeUndefined(); expect(mergePlugins([], [registry])[0].docs).toBe( "https://docs.example.com/registry", ); }); it("offers no update for a plugin a linked desktop leaves to its server", () => { const [row] = mergePlugins( [summary({ enabled: false, state: "disabled", runsOnServer: true })], [entry()], ); expect(row).toMatchObject({ status: "server", updateAvailable: false }); }); it("keeps installed plugins when the registry is unreachable", () => { const rows = mergePlugins([summary()], null); expect(rows).toHaveLength(1); expect(rows[0].inRegistry).toBe(false); }); }); describe("pluginStatus", () => { it("maps loader and stored states", () => { expect(pluginStatus(summary({ state: "failed" }))).toBe("failed"); expect(pluginStatus(summary({ state: "blocked" }))).toBe("blocked"); expect(pluginStatus(summary({ enabled: false, state: "disabled" }))).toBe( "disabled", ); expect(pluginStatus(summary())).toBe("running"); expect(pluginStatus(summary({ state: "safe-mode" }))).toBe("safeMode"); expect(pluginStatus(summary({ state: "failed", runsOnServer: true }))).toBe( "server", ); }); }); describe("sorting and filtering", () => { const rows = mergePlugins( [], [ entry({ id: "b", name: "Beta", category: "Monitoring" }), entry({ id: "a", name: "Alpha", category: "Access", description: "Sign in", features: ["Works with Podman"], versions: [ { version: "1.0.0", compatible: true, capabilities: [], publishedAt: "2026-10-05", size: 1, }, ], }), ], ); it("sorts by name, date and category", () => { expect(sortPlugins(rows, "name").map((r) => r.id)).toEqual(["a", "b"]); expect(sortPlugins(rows, "updated").map((r) => r.id)).toEqual(["a", "b"]); expect(sortPlugins(rows, "category").map((r) => r.id)).toEqual(["a", "b"]); }); it("searches name, description and id", () => { expect( rows.filter((r) => matchesQuery(r, "sign")).map((r) => r.id), ).toEqual(["a"]); expect( rows.filter((r) => matchesQuery(r, "podman")).map((r) => r.id), ).toEqual(["a"]); expect(rows.filter((r) => matchesQuery(r, " ")).length).toBe(2); }); it("collects categories", () => { expect(categoriesOf(rows)).toEqual(["Access", "Monitoring"]); }); }); describe("helpers", () => { it("orders capabilities worst first", () => { expect(orderByRisk(["kv:own", "hosts:write", "credentials:read"])).toEqual([ "credentials:read", "hosts:write", "kv:own", ]); }); it("counts contributions", () => { expect( describeContributions({ tabs: [{}, {}], settings: { admin: [] }, auth: { loginMethods: [{}], secondFactors: [] }, panels: [], }), ).toEqual([ { kind: "tabs", count: 2 }, { kind: "auth", count: 1 }, { kind: "settings", count: 1 }, ]); }); it("formats sizes", () => { expect(formatBytes(512)).toBe("512 B"); expect(formatBytes(2048)).toBe("2.0 KB"); expect(formatBytes(50 * 1024 * 1024)).toBe("50 MB"); }); }); describe("popularity", () => { it("sorts by install count, unknown counts last", () => { const rows = mergePlugins( [], [ entry({ id: "a", name: "A", installed: false, downloads: 5 }), entry({ id: "b", name: "B", installed: false, downloads: null }), entry({ id: "c", name: "C", installed: false, downloads: 90 }), ], ); expect(sortPlugins(rows, "popular").map((r) => r.id)).toEqual([ "c", "a", "b", ]); }); it("formats counts roughly", () => { expect(formatCount(950)).toBe("950"); expect(formatCount(1234)).toBe("1.2k"); expect(formatCount(45_000)).toBe("45k"); expect(formatCount(2_500_000)).toBe("2.5M"); }); }); describe("source and trust", () => { it("tells bundled, signed and unverified copies apart", () => { expect(pluginSource(summary({ source: "bundled", tier: "bundled" }))).toBe( "bundled", ); expect( pluginSource( summary({ source: "user", tier: "official", signedBy: "k" }), ), ).toBe("official"); expect(pluginSource(summary({ source: "user", tier: "community" }))).toBe( "unverified", ); expect( pluginSource( summary({ source: "user", tier: "unverified", signedBy: "k" }), ), ).toBe("unverified"); }); it("marks an upload as unverified", () => { const row = uploadEntry({ token: "t", id: "notes", name: "Notes", version: "0.2.0", description: "", author: "me", capabilities: ["kv:own"], replaces: "0.1.0", }); expect(row).toMatchObject({ unverified: true, installed: true, version: "0.1.0", latestVersion: "0.2.0", }); }); }); describe("reportIssueUrl", () => { const base = { id: "docker", repository: "https://github.com/Termix-SSH/Plugin-Docker.git", version: "1.0.0", latestVersion: "1.1.0", status: "failed" as const, source: "official" as const, lastError: "boom", }; it("fills the bug form on an official plugin repo", () => { const parsed = new URL(reportIssueUrl(base, "26.10.0")!); expect(parsed.origin + parsed.pathname).toBe( "https://github.com/Termix-SSH/Plugin-Docker/issues/new", ); expect(parsed.searchParams.get("template")).toBe("bug_report.yml"); expect(parsed.searchParams.get("termix-version")).toBe("26.10.0"); expect(parsed.searchParams.get("plugin-version")).toBe("1.0.0"); expect(parsed.searchParams.get("environment")).toContain( "Plugin docker, official, failed", ); expect(parsed.searchParams.get("logs")).toBe("boom"); expect(parsed.searchParams.has("body")).toBe(false); }); it("fills the same bug form on an unverified plugin repo", () => { const parsed = new URL( reportIssueUrl({ ...base, source: "unverified" }, "26.10.0")!, ); expect(parsed.searchParams.get("template")).toBe("bug_report.yml"); expect(parsed.searchParams.get("plugin-version")).toBe("1.0.0"); expect(parsed.searchParams.get("environment")).toContain( "Plugin docker, unverified, failed", ); expect(parsed.searchParams.has("body")).toBe(false); }); it("gives nothing for a repo that is not on GitHub", () => { expect( reportIssueUrl({ ...base, repository: "https://gitlab.com/a/b", }), ).toBeNull(); }); }); describe("requestFeatureUrl", () => { it("opens the feature form on the plugin repo", () => { const parsed = new URL( requestFeatureUrl({ repository: "https://github.com/Termix-SSH/Plugin-Docker/", })!, ); expect(parsed.origin + parsed.pathname).toBe( "https://github.com/Termix-SSH/Plugin-Docker/issues/new", ); expect(parsed.searchParams.get("template")).toBe("feature_request.yml"); }); it("gives nothing for a repo that is not on GitHub", () => { expect( requestFeatureUrl({ repository: "https://gitlab.com/a/b" }), ).toBeNull(); }); }); describe("mergeReleaseNotes", () => { const registryVersion = (version: string, extra = {}) => ({ version, compatible: true, capabilities: [], size: 1, publishedAt: "2026-10-01T00:00:00Z", ...extra, }); it("joins registry and local notes newest first, local winning", () => { const rows = mergeReleaseNotes( [ registryVersion("1.2.0", { compatible: false, notes: { changes: [{ type: "added", text: "Registry" }] }, }), registryVersion("1.0.0", { releaseNotesUrl: "https://github.com/x/y/releases/tag/v1.0.0", notes: { changes: [{ type: "added", text: "Old" }] }, }), ], [ { version: "1.0.0", date: "2026-09-01", summary: "First.", changes: [{ type: "added", text: "Local" }], }, { version: "0.9.0", changes: [{ type: "fixed", text: "Beta" }] }, ], ); expect(rows.map((r) => r.version)).toEqual(["1.2.0", "1.0.0", "0.9.0"]); expect(rows[0]).toMatchObject({ compatible: false }); expect(rows[1]).toEqual({ version: "1.0.0", date: "2026-09-01", summary: "First.", changes: [{ type: "added", text: "Local" }], compatible: true, releaseNotesUrl: "https://github.com/x/y/releases/tag/v1.0.0", }); expect(rows[2]).toMatchObject({ date: null, compatible: true }); }); it("is empty with nothing to show", () => { expect(mergeReleaseNotes([], [])).toEqual([]); }); }); describe("groupChanges", () => { it("groups by type in a fixed order", () => { expect( groupChanges([ { type: "fixed", text: "b" }, { type: "added", text: "a" }, { type: "fixed", text: "c" }, ]), ).toEqual([ { type: "added", items: ["a"] }, { type: "fixed", items: ["b", "c"] }, ]); }); }); describe("youtubeEmbedUrl", () => { it("builds a privacy embed from a video id only", () => { expect(youtubeEmbedUrl("dQw4w9WgXcQ")).toBe( "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?rel=0", ); }); it("refuses anything that is not an id", () => { for (const value of [ undefined, "", "https://evil.example/x", "dQw4w9WgXcQ/../../x", "javascript:alert(1)", ]) { expect(youtubeEmbedUrl(value)).toBeNull(); } }); }); describe("betas", () => { it("knows a prerelease", () => { expect(isPrerelease("1.2.0-beta.1")).toBe(true); expect(isPrerelease("1.2.0")).toBe(false); expect(isPrerelease(null)).toBe(false); }); it("offers a beta only when newer than what is installed on stable", () => { const base = { installed: true, version: "1.1.0", latestBeta: "1.2.0-beta.1", channel: "stable" as const, }; expect(betaToTry(base)).toBe("1.2.0-beta.1"); expect(betaToTry({ ...base, channel: "beta" })).toBeNull(); expect(betaToTry({ ...base, version: "1.2.0-beta.1" })).toBeNull(); expect(betaToTry({ ...base, installed: false })).toBeNull(); expect(betaToTry({ ...base, latestBeta: null })).toBeNull(); }); it("sends beta feedback to the plugin's repo", () => { const url = new URL( betaFeedbackUrl( { id: "docker", repository: "https://github.com/Termix-SSH/Plugin-Docker", version: "1.2.0-beta.1", lastError: null, }, "26.11.0", )!, ); expect(url.pathname).toBe("/Termix-SSH/Plugin-Docker/issues/new"); expect(url.searchParams.get("template")).toBe("beta_feedback.yml"); expect(url.searchParams.get("plugin-version")).toBe("1.2.0-beta.1"); expect( betaFeedbackUrl({ id: "x", repository: "https://gitlab.com/a/b", version: "1.0.0", lastError: null, }), ).toBeNull(); }); }); describe("plugin kinds", () => { it("joins installed contributions with the registry list", () => { const [row] = mergePlugins( [summary({ contributes: { tabs: [{}] } as never })], [entry({ contributes: ["layouts"] })], ); expect(row.kinds.sort()).toEqual(["layouts", "tabs"]); }); it("counts only the kinds worth filtering by", () => { const rows = mergePlugins( [], [ entry({ id: "a", installed: false, contributes: ["layouts", "uses"] }), entry({ id: "b", installed: false, contributes: ["layouts", "tabs"] }), ], ); expect(kindsOf(rows)).toEqual([ { kind: "layouts", count: 2 }, { kind: "tabs", count: 1 }, ]); }); }); describe("unsupported plugins", () => { it("marks a listed plugin with no release this build runs", () => { const [row] = mergePlugins( [], [ entry({ installed: false, latestVersion: null, versions: [ { version: "1.0.0", compatible: false, needsTermix: ">=26.11.0", capabilities: [], size: 1, }, ], }), ], ); expect(row.unsupported).toBe(true); expect(row.needsTermix).toBe(">=26.11.0"); }); it("leaves a runnable plugin alone", () => { const [row] = mergePlugins([], [entry({ installed: false })]); expect(row.unsupported).toBe(false); expect(row.needsTermix).toBeNull(); }); }); describe("pageOf", () => { const rows = Array.from({ length: 50 }, (_, i) => i); it("slices one page", () => { expect(pageOf(rows, 2, 24)).toEqual({ items: rows.slice(24, 48), page: 2, pages: 3, }); }); it("clamps a page past the end", () => { expect(pageOf(rows, 9, 24).page).toBe(3); expect(pageOf([], 4, 24)).toEqual({ items: [], page: 1, pages: 1 }); }); });