bc2f310e73
* feat(plugins): add plugin marketplace with curated registry and updates Discover and install plugins from inside kandev instead of only by URL/upload. - Backend marketplace subpackage: SQLite-backed sources, catalog fetch/merge (dedup, install_state, degraded-source handling), and the /api/plugins/ marketplace[/sources] API. Install reuses the existing install-by-URL pipeline. - Frontend: Settings > Plugins gains a Browse tab (search/category/star sort), source management, one-click install, and an Update button on the Installed tab when the catalog has a newer version. Plugins may ship an icon (manifest `icon` -> catalog icon_url) with a letter-tile fallback. - Registry infra: plugin-registry/ (curated plugins.yaml + schema.json) and two GitHub Actions (zero-dependency Node build-index.mjs -> index.json on Pages, daily star refresh). Ships empty at launch; the empty catalog is valid. - Spec: docs/specs/plugins/marketplace.md. No download telemetry; stars only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): address marketplace PR review feedback - CodeQL: stub fetch routers keyed off a path segment, not a hostname substring. - Nullable stars end to end: a failed star lookup stays unknown (not 0) and sorts last, instead of decoding null -> 0 and corrupting ranking. - Fetch marketplace sources concurrently and dedupe identical in-flight downloads (singleflight), so a slow source can't serialize the Browse tab. - Register /marketplace routes before the /:id wildcard (gin tree ordering). - Duplicate source URL returns 409 ErrDuplicateSource, not a raw SQL error. - Browse: filter category client-side so the category dropdown never collapses. - marketplaceInstall swallows after toasting (no unhandled promise rejection). - Source badge keys off source_id, not a name substring. - build-index: read block-sequence manifest categories, not just inline. - Registry index workflow validates on pull_request; Pages deploy gated to push/schedule/dispatch. Spec: align package_sha256 (advisory in v1) + sort enum. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): guard catalog repo_url against javascript: href injection repo_url comes from an untrusted index.json, so a malicious/compromised source could set a javascript: URL that runs in the operator's session when the Repo link is clicked (rel/target don't block it). Only render the anchor for http(s) schemes. Adds regression tests for the scheme guard and null-star rendering. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): require https for remote sources; test oversized download - Reject remote plain-http marketplace sources (MITM catalog-poisoning risk); loopback http stays allowed for local dev/e2e fixtures. - Add the missing oversized-response assertion to TestDownloadRejectsNon200AndOversized so the maxIndexBytes memory bound has coverage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): detach source download from singleflight leader's context Two concurrent Browse opens after a cache Refresh share one singleflight download; if the leader's request context is canceled (it navigates away), the waiters must not inherit that cancellation. Use context.WithoutCancel so values are preserved but the leader can't cancel everyone; the client's 20s timeout still bounds the download. 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>
83 lines
2.0 KiB
TypeScript
83 lines
2.0 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();
|
|
});
|
|
});
|