Marketplace UI: browse, item detail, install controls, hook confirmation, add marketplace
Implements Task 13: BrowsePane (marketplace list, kind/search filters, item detail), InstallControls (global/per-project install, opt-out, hook confirm gate), HookConfirmModal, AddMarketplaceModal, and ItemDetail. Also applies pre-flight ruling F6: a per-marketplace Remove button with a confirm dialog (mp.remove) warning that surviving installs become "Source removed" and can be dropped via Forget on the Installed tab, plus an inline account reassignment select (updateMarketplace + reloadState). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import type { AppSettings, CatalogItem, MarketplaceSnapshot } from "../../lib/types";
|
||||
import type { MarketplaceApi } from "../../hooks/useMarketplace";
|
||||
|
||||
vi.mock("./InstallControls", () => ({ default: () => <div>install controls</div> }));
|
||||
vi.mock("./AddMarketplaceModal", () => ({ default: () => <div>add modal</div> }));
|
||||
|
||||
import BrowsePane from "./BrowsePane";
|
||||
|
||||
const it_ = (kind: CatalogItem["kind"], key: string, patch: Partial<CatalogItem> = {}): CatalogItem => ({
|
||||
kind,
|
||||
key,
|
||||
name: key,
|
||||
description: `${key} description`,
|
||||
path: key,
|
||||
invalid: null,
|
||||
hook_commands: [],
|
||||
preview: `${key} preview body`,
|
||||
...patch,
|
||||
});
|
||||
|
||||
const snapshot: MarketplaceSnapshot = {
|
||||
marketplace_id: "m1",
|
||||
head_commit: "a".repeat(40),
|
||||
fetched_at: "2026-09-27T12:00:00Z",
|
||||
fetch_error: "network unreachable",
|
||||
items: [it_("agent", "code-reviewer"), it_("hook", "notify-on-stop"), it_("skill", "broken", { invalid: "SKILL.md missing" })],
|
||||
};
|
||||
|
||||
function api(patch: Partial<MarketplaceApi> = {}): MarketplaceApi {
|
||||
return {
|
||||
snapshots: [snapshot],
|
||||
updates: [],
|
||||
loading: false,
|
||||
refreshing: [],
|
||||
load: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
reloadState: vi.fn(),
|
||||
install: vi.fn(),
|
||||
uninstall: vi.fn(),
|
||||
setDisabled: vi.fn(),
|
||||
update: vi.fn(),
|
||||
forget: vi.fn(),
|
||||
remove: vi.fn(async () => true),
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
describe("BrowsePane", () => {
|
||||
beforeEach(() => {
|
||||
useAppState.setState({
|
||||
appSettings: {
|
||||
marketplaces: [{ id: "m1", name: "Starter", url: "https://github.com/s/m.git", branch: null, account_id: null }],
|
||||
marketplace_accounts: [],
|
||||
global_marketplace_installs: [],
|
||||
} as unknown as AppSettings,
|
||||
projects: [],
|
||||
marketplaceFilterProjectId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("lists items, filters by kind and search, and shows detail", () => {
|
||||
render(<BrowsePane mp={api()} />);
|
||||
expect(screen.getByText("network unreachable")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /code-reviewer/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /notify-on-stop/ })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Hooks" }));
|
||||
expect(screen.queryByRole("button", { name: /code-reviewer/ })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "All" }));
|
||||
fireEvent.change(screen.getByLabelText("Search items"), { target: { value: "review" } });
|
||||
expect(screen.queryByRole("button", { name: /notify-on-stop/ })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /code-reviewer/ }));
|
||||
expect(screen.getByText("code-reviewer preview body")).toBeInTheDocument();
|
||||
expect(screen.getByText("install controls")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows why an item is invalid", () => {
|
||||
render(<BrowsePane mp={api()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /broken/ }));
|
||||
expect(screen.getByText("SKILL.md missing")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("refreshes one marketplace", () => {
|
||||
const mp = api();
|
||||
render(<BrowsePane mp={mp} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh Starter" }));
|
||||
expect(mp.refresh).toHaveBeenCalledWith("m1");
|
||||
});
|
||||
|
||||
it("offers Add when there are no marketplaces", () => {
|
||||
useAppState.setState({
|
||||
appSettings: { marketplaces: [], marketplace_accounts: [], global_marketplace_installs: [] } as unknown as AppSettings,
|
||||
});
|
||||
render(<BrowsePane mp={api({ snapshots: [] })} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add marketplace" }));
|
||||
expect(screen.getByText("add modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("confirms before removing a marketplace (F6)", () => {
|
||||
const mp = api();
|
||||
render(<BrowsePane mp={mp} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove Starter" }));
|
||||
expect(screen.getByText(/Source removed/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Forget/)).toBeInTheDocument();
|
||||
expect(mp.remove).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Remove marketplace" }));
|
||||
expect(mp.remove).toHaveBeenCalledWith("m1");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user