Files
kandev/apps/web/components/settings/plugins/plugin-row.test.tsx
T
João Salavisa 91bdee3d3a feat(plugins): show source repo link on installed plugin list and settings (#1821)
* feat(plugins): show source repo link on installed plugin list and settings

Installed plugins carried no repository URL — a "Repo" link existed only
on marketplace catalog cards, whose repo_url is registry-derived from
plugins.yaml and never reaches an installed record. Let the manifest
declare an optional repo_url so sideloaded and directly-installed plugins
surface the same link.

- manifest: add optional RepoURL (repo_url); validate http(s)-only at
  registration so a javascript: scheme can't reach the operator's session.
- Record embeds the manifest, so repo_url flows through JSON to the
  frontend PluginRecord with no extra plumbing.
- frontend: extract the shared, guarded PluginRepoLink (+ isHttpUrl) from
  the marketplace row and render it on the installed plugin row and the
  plugin detail header.
- fixture + docs: exercise repo_url in the e2e fixture manifest and
  document the field in the public manifest reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(plugins): harden repo_url handling per review

- PluginRepoLink accepts string|undefined|null and isHttpUrl null-checks
  before trim, so it can't throw on an absent repo_url (CodeRabbit); drop
  the now-redundant truthiness guards at the call sites.
- validateRepoURL trims m.RepoURL in place so the stored/serialised value
  matches what was validated (Greptile).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 14:03:52 +01:00

125 lines
3.2 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { PluginRow } from "./plugin-row";
import type { MarketplaceEntry, PluginRecord } from "@/lib/types/plugins";
afterEach(() => cleanup());
function plugin(overrides: Partial<PluginRecord> = {}): 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 updateEntry(): 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",
};
}
const noop = () => undefined;
describe("PluginRow update button", () => {
it("shows an Update button with the new version and fires onUpdate", () => {
const onUpdate = vi.fn();
render(
<PluginRow
plugin={plugin()}
busy={false}
update={updateEntry()}
onEnable={noop}
onDisable={noop}
onUninstall={noop}
onUpdate={onUpdate}
/>,
);
const button = screen.getByTestId("plugin-update-acme");
expect(button.textContent).toContain("Update to v2.0.0");
fireEvent.click(button);
expect(onUpdate).toHaveBeenCalledWith(updateEntry());
});
it("renders no Update button when there is no pending update", () => {
render(
<PluginRow
plugin={plugin()}
busy={false}
onEnable={noop}
onDisable={noop}
onUninstall={noop}
/>,
);
expect(screen.queryByTestId("plugin-update-acme")).toBeNull();
});
});
describe("PluginRow repo link", () => {
it("renders a Repo link when the plugin declares an http(s) repo_url", () => {
render(
<PluginRow
plugin={plugin({ repo_url: "https://github.com/kdlbs/kandev-plugin-acme" })}
busy={false}
onEnable={noop}
onDisable={noop}
onUninstall={noop}
/>,
);
const link = screen.getByTestId("plugin-repo-link");
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(
<PluginRow
plugin={plugin()}
busy={false}
onEnable={noop}
onDisable={noop}
onUninstall={noop}
/>,
);
expect(screen.queryByTestId("plugin-repo-link")).toBeNull();
});
it("renders no Repo link for a non-http(s) repo_url scheme", () => {
render(
<PluginRow
plugin={plugin({ repo_url: "javascript:alert(document.cookie)" })}
busy={false}
onEnable={noop}
onDisable={noop}
onUninstall={noop}
/>,
);
expect(screen.queryByTestId("plugin-repo-link")).toBeNull();
});
});