c042519bc9
- Artifacts page laid out like an app store: hero search, category chips, shelves by kind, app-icon cards with one primary pill and a detail sheet - POST /api/artifacts/:id/uninstall: a skill's copies in ~/.claude/skills and ~/.agents/skills are moved to ~/.claude/agenthub-replaced-skills/ (-uninstalled-<time>), never deleted; a plugin runs `claude plugin uninstall --scope user --keep-data` as the caller; ENOTINSTALLED when nothing is there; allowed while installs are off - Uninstall on the card, the ⋯ menu and the detail sheet, after a confirm
473 lines
19 KiB
TypeScript
473 lines
19 KiB
TypeScript
/**
|
|
* @file Tests for the `/artifacts` page: the library lists what the server
|
|
* answers (one merged list; All/My library/Hub filter and Share only in
|
|
* server mode), an installed skill shows Installed, its install count lists
|
|
* who installed it and where, a skill is
|
|
* installed only after a confirmation (and a taken name asks to replace),
|
|
* an HTML preview is a sandboxed iframe with no `allow-*` token, and a
|
|
* skill's SKILL.md is rendered by the page itself. Adding:
|
|
* a plugin is picked from the list its marketplace link answers (a link the
|
|
* server cannot read opens the by-hand form), a skill is uploaded from one
|
|
* Markdown file under the name the user types, or written in the dialog.
|
|
* A card's right-click menu runs the same actions (Preview, Delete). A file
|
|
* the sandbox cannot show (Office, zip, PDF) offers Download only, the
|
|
* Add menu closes on Escape or a click outside it, the front page is shelved
|
|
* by kind until a category is picked, and a card opens its detail sheet.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { MemoryRouter } from "react-router-dom";
|
|
import { Artifacts } from "../Artifacts";
|
|
import { api, type Artifact } from "../../lib/api";
|
|
import { confirmDialog } from "../../lib/confirm";
|
|
|
|
vi.mock("../../lib/confirm", () => ({ confirmDialog: vi.fn() }));
|
|
vi.mock("../../lib/api", () => ({
|
|
api: {
|
|
lanes: { list: vi.fn() },
|
|
artifacts: {
|
|
settings: vi.fn(),
|
|
list: vi.fn(),
|
|
update: vi.fn(),
|
|
remove: vi.fn(),
|
|
install: vi.fn(),
|
|
uninstall: vi.fn(),
|
|
installs: vi.fn(),
|
|
setInstallsEnabled: vi.fn(),
|
|
skills: vi.fn(),
|
|
addSkill: vi.fn(),
|
|
addPlugin: vi.fn(),
|
|
inspectMarket: vi.fn(),
|
|
uploadSkills: vi.fn(),
|
|
writeSkill: vi.fn(),
|
|
uploadFile: vi.fn(),
|
|
candidates: vi.fn(),
|
|
rawUrl: (id: number, path?: string) => `/api/artifacts/${id}/raw?path=${path ?? ""}`,
|
|
},
|
|
},
|
|
}));
|
|
|
|
const item = (over: Partial<Artifact>): Artifact => ({
|
|
id: 1,
|
|
kind: "file",
|
|
title: "report.html",
|
|
description: "",
|
|
ownerId: 1,
|
|
owner: "vi",
|
|
mine: true,
|
|
canChange: true,
|
|
laneId: 1,
|
|
sessionId: null,
|
|
source: "out/report.html",
|
|
market: null,
|
|
url: null,
|
|
files: [{ path: "report.html", size: 10 }],
|
|
inLibrary: true,
|
|
size: 10,
|
|
shared: false,
|
|
sharedAt: null,
|
|
installs: 0,
|
|
createdAt: "2026-10-14T00:00:00Z",
|
|
updatedAt: "2026-10-14T00:00:00Z",
|
|
...over,
|
|
});
|
|
|
|
const open = () =>
|
|
render(
|
|
<MemoryRouter>
|
|
<Artifacts />
|
|
</MemoryRouter>
|
|
);
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(api.artifacts.settings).mockResolvedValue({
|
|
serverMode: false,
|
|
canShare: false,
|
|
installsEnabled: true,
|
|
canManage: true,
|
|
});
|
|
vi.mocked(api.artifacts.list).mockResolvedValue({
|
|
items: [
|
|
item({}),
|
|
item({
|
|
id: 2,
|
|
kind: "skill",
|
|
title: "deploy",
|
|
source: "deploy",
|
|
files: [{ path: "SKILL.md", size: 3 }],
|
|
}),
|
|
],
|
|
});
|
|
vi.mocked(confirmDialog).mockReset();
|
|
vi.mocked(api.artifacts.install).mockReset();
|
|
vi.mocked(api.lanes.list).mockResolvedValue({ lanes: [], counts: {} } as never);
|
|
vi.mocked(api.artifacts.skills).mockResolvedValue({ items: [] });
|
|
});
|
|
|
|
const addMenu = async (user: ReturnType<typeof userEvent.setup>, label: string) => {
|
|
open();
|
|
await screen.findByText("deploy");
|
|
await user.click(screen.getByTestId("artifacts-add"));
|
|
await user.click(screen.getByRole("button", { name: label }));
|
|
};
|
|
|
|
describe("Adding", () => {
|
|
it("adds the plugins picked from a marketplace link", async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(api.artifacts.inspectMarket).mockResolvedValue({
|
|
market: "https://git.x.vn/o/skills.git",
|
|
name: "sg",
|
|
description: "",
|
|
plugins: [
|
|
{ name: "sg-dev", key: "sg-dev@sg", description: "Dev", version: "", category: "" },
|
|
{ name: "sg-common", key: "sg-common@sg", description: "", version: "", category: "" },
|
|
],
|
|
});
|
|
vi.mocked(api.artifacts.addPlugin).mockResolvedValue({ item: item({ kind: "plugin" }) });
|
|
await addMenu(user, "Plugin");
|
|
await user.type(screen.getByLabelText("Marketplace link"), "https://git.x.vn/o/skills.git");
|
|
await user.click(screen.getByRole("button", { name: "Read plugins" }));
|
|
await screen.findByTestId("market-plugins");
|
|
await user.click(screen.getByLabelText("Select all"));
|
|
await user.click(screen.getByRole("button", { name: "Add 2 plugin(s)" }));
|
|
await waitFor(() => expect(api.artifacts.addPlugin).toHaveBeenCalledTimes(2));
|
|
expect(api.artifacts.addPlugin).toHaveBeenCalledWith({
|
|
key: "sg-dev@sg",
|
|
market: "https://git.x.vn/o/skills.git",
|
|
title: "sg-dev",
|
|
description: "Dev",
|
|
});
|
|
});
|
|
|
|
it("opens the by-hand form when the link cannot be read", async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(api.artifacts.inspectMarket).mockRejectedValue(
|
|
Object.assign(new Error("needs a login"), { code: "ENEEDLOGIN" })
|
|
);
|
|
await addMenu(user, "Plugin");
|
|
await user.type(screen.getByLabelText("Marketplace link"), "https://git.x.vn/o/private.git");
|
|
await user.click(screen.getByRole("button", { name: "Read plugins" }));
|
|
expect(await screen.findByRole("alert")).toHaveTextContent("needs a login");
|
|
expect(screen.getByLabelText("Marketplace (owner/repo or git URL)")).toHaveValue(
|
|
"https://git.x.vn/o/private.git"
|
|
);
|
|
});
|
|
|
|
it("uploads one Markdown file as a skill under the typed name", async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(api.artifacts.uploadSkills).mockResolvedValue({ items: [item({ kind: "skill" })] });
|
|
await addMenu(user, "Skill");
|
|
const md = new File(["# Steps"], "notes.md", { type: "text/markdown" });
|
|
await user.upload(screen.getByTestId("skill-files"), md);
|
|
expect(await screen.findByTestId("skill-shape")).toHaveTextContent(
|
|
"becomes the skill's SKILL.md"
|
|
);
|
|
const name = screen.getByLabelText(/Skill name/);
|
|
await waitFor(() => expect(name).toHaveValue("notes"));
|
|
await user.clear(name);
|
|
await user.type(name, "Viết báo cáo");
|
|
expect(screen.getByTestId("skill-slug")).toHaveTextContent("/Viet-bao-cao");
|
|
await user.type(screen.getByLabelText(/Description/), "Use for reports");
|
|
await user.click(within(screen.getByRole("dialog")).getByRole("button", { name: "Add" }));
|
|
await waitFor(() => expect(api.artifacts.uploadSkills).toHaveBeenCalled());
|
|
const [files, fields] = vi.mocked(api.artifacts.uploadSkills).mock.calls[0]!;
|
|
expect(files.map((f) => f.path)).toEqual(["notes.md"]);
|
|
expect(fields).toMatchObject({ name: "Viết báo cáo", description: "Use for reports" });
|
|
});
|
|
|
|
it("writes a new skill", async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(api.artifacts.writeSkill).mockResolvedValue({ item: item({}), items: [] });
|
|
await addMenu(user, "Skill");
|
|
await user.click(screen.getByRole("tab", { name: "Write new" }));
|
|
const add = within(screen.getByRole("dialog")).getByRole("button", { name: "Add" });
|
|
await user.type(screen.getByLabelText(/Skill name/), "standup");
|
|
await user.type(screen.getByLabelText(/Instructions/), "Summarise.");
|
|
expect(add).toBeDisabled(); // no description yet
|
|
await user.type(screen.getByLabelText(/Description/), "Daily standup");
|
|
await user.click(add);
|
|
await waitFor(() =>
|
|
expect(api.artifacts.writeSkill).toHaveBeenCalledWith({
|
|
name: "standup",
|
|
description: "Daily standup",
|
|
content: "Summarise.",
|
|
title: undefined,
|
|
})
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Artifacts", () => {
|
|
it("lists the library, with no Hub or Share in local mode", async () => {
|
|
open();
|
|
expect(await screen.findByText("report.html")).toBeInTheDocument();
|
|
expect(screen.getByText("deploy")).toBeInTheDocument();
|
|
expect(screen.queryByRole("button", { name: "Hub" })).toBeNull();
|
|
expect(screen.queryByRole("button", { name: /Share/ })).toBeNull();
|
|
});
|
|
|
|
it("shows the Hub and Share in server mode", async () => {
|
|
vi.mocked(api.artifacts.settings).mockResolvedValue({
|
|
serverMode: true,
|
|
canShare: true,
|
|
installsEnabled: true,
|
|
canManage: false,
|
|
});
|
|
const user = userEvent.setup();
|
|
open();
|
|
expect(await screen.findByRole("button", { name: "Hub" })).toBeInTheDocument();
|
|
expect(screen.getAllByRole("button", { name: /^Share$/ }).length).toBe(2);
|
|
// One merged list by default; Hub is a filter over it.
|
|
expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "all" }));
|
|
await user.click(screen.getByRole("button", { name: "Hub" }));
|
|
await waitFor(() =>
|
|
expect(api.artifacts.list).toHaveBeenLastCalledWith(expect.objectContaining({ scope: "hub" }))
|
|
);
|
|
});
|
|
|
|
it("lists who installed an artifact, when and where, from its install count", async () => {
|
|
vi.mocked(api.artifacts.list).mockResolvedValue({
|
|
items: [item({ id: 2, kind: "skill", title: "deploy", source: "deploy", installs: 3 })],
|
|
});
|
|
vi.mocked(api.artifacts.installs).mockResolvedValue({
|
|
items: [
|
|
{
|
|
user: "vi",
|
|
mine: true,
|
|
at: "2026-10-19T10:00:00Z",
|
|
replaced: false,
|
|
paths: ["/h/.claude/skills/deploy"],
|
|
},
|
|
{ user: "khoa", mine: false, at: "2026-10-18T10:00:00Z", replaced: true },
|
|
],
|
|
unrecorded: 1,
|
|
});
|
|
const user = userEvent.setup();
|
|
open();
|
|
await user.click(await screen.findByRole("button", { name: "3 installs" }));
|
|
const box = await screen.findByTestId("artifact-installs");
|
|
expect(api.artifacts.installs).toHaveBeenCalledWith(2);
|
|
expect(await within(box).findByText("/h/.claude/skills/deploy")).toBeInTheDocument();
|
|
expect(within(box).getByText(/khoa/)).toBeInTheDocument();
|
|
expect(within(box).getByText(/1 earlier installs/)).toBeInTheDocument();
|
|
await user.keyboard("{Escape}");
|
|
expect(screen.queryByTestId("artifact-installs")).toBeNull();
|
|
});
|
|
|
|
it("keeps a shared row in the Hub when its owner deletes it, and removes it from there on ask", async () => {
|
|
vi.mocked(api.artifacts.settings).mockResolvedValue({
|
|
serverMode: true,
|
|
canShare: true,
|
|
installsEnabled: true,
|
|
canManage: false,
|
|
});
|
|
vi.mocked(api.artifacts.list).mockResolvedValue({
|
|
items: [item({ id: 3, title: "hub-only", shared: true, inLibrary: false })],
|
|
});
|
|
vi.mocked(api.artifacts.update).mockResolvedValue({ item: item({ id: 3 }) } as never);
|
|
const user = userEvent.setup();
|
|
open();
|
|
const card = await screen.findByTestId("artifact-3");
|
|
expect(within(card).getByText("Hub only")).toBeInTheDocument();
|
|
expect(within(card).queryByRole("button", { name: "Delete" })).toBeNull();
|
|
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
|
await user.click(within(card).getByRole("button", { name: "Remove from Hub" }));
|
|
await waitFor(() => expect(api.artifacts.update).toHaveBeenCalledWith(3, { shared: false }));
|
|
const calls = vi.mocked(confirmDialog).mock.calls;
|
|
expect(calls[calls.length - 1]?.[0].title).toMatch(/Remove “hub-only” from the Hub/);
|
|
});
|
|
|
|
it("shows Installed instead of Install for a skill already installed", async () => {
|
|
vi.mocked(api.artifacts.list).mockResolvedValue({
|
|
items: [item({ id: 2, kind: "skill", title: "deploy", source: "deploy", installed: true })],
|
|
});
|
|
open();
|
|
const card = await screen.findByTestId("artifact-2");
|
|
expect(within(card).getByTestId("artifact-installed")).toHaveTextContent("Installed");
|
|
expect(within(card).queryByRole("button", { name: "Install" })).toBeNull();
|
|
|
|
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
|
vi.mocked(api.artifacts.install).mockResolvedValueOnce({
|
|
installed: "/h/.claude/skills/deploy",
|
|
});
|
|
await userEvent.setup().click(within(card).getByRole("button", { name: "Reinstall" }));
|
|
await waitFor(() => expect(api.artifacts.install).toHaveBeenLastCalledWith(2, true));
|
|
});
|
|
|
|
it("uninstalls an installed plugin only after a confirmation", async () => {
|
|
vi.mocked(api.artifacts.list).mockResolvedValue({
|
|
items: [
|
|
item({ id: 4, kind: "plugin", title: "tool", source: "tool@market", installed: true }),
|
|
],
|
|
});
|
|
vi.mocked(api.artifacts.uninstall).mockReset();
|
|
open();
|
|
const card = await screen.findByTestId("artifact-4");
|
|
const user = userEvent.setup();
|
|
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
|
await user.click(within(card).getByRole("button", { name: "Uninstall" }));
|
|
await waitFor(() => expect(confirmDialog).toHaveBeenCalled());
|
|
expect(api.artifacts.uninstall).not.toHaveBeenCalled();
|
|
|
|
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
|
|
vi.mocked(api.artifacts.uninstall).mockResolvedValueOnce({
|
|
uninstalled: "tool@market",
|
|
paths: [],
|
|
});
|
|
await user.click(within(card).getByRole("button", { name: "Uninstall" }));
|
|
await waitFor(() => expect(api.artifacts.uninstall).toHaveBeenCalledWith(4));
|
|
});
|
|
|
|
it("installs a skill only after a confirmation, and asks before replacing", async () => {
|
|
const user = userEvent.setup();
|
|
open();
|
|
await screen.findByText("deploy");
|
|
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
|
|
await user.click(screen.getByRole("button", { name: "Install" }));
|
|
expect(api.artifacts.install).not.toHaveBeenCalled();
|
|
|
|
vi.mocked(confirmDialog).mockResolvedValue(true);
|
|
vi.mocked(api.artifacts.install)
|
|
.mockRejectedValueOnce(Object.assign(new Error("taken"), { code: "EEXISTS" }))
|
|
.mockResolvedValueOnce({ installed: "/h/.claude/skills/deploy", replaced: true });
|
|
await user.click(screen.getByRole("button", { name: "Install" }));
|
|
await waitFor(() => expect(api.artifacts.install).toHaveBeenLastCalledWith(2, true));
|
|
const calls = vi.mocked(confirmDialog).mock.calls;
|
|
expect(calls[calls.length - 1]?.[0].title).toMatch(/Replace/);
|
|
});
|
|
|
|
it("previews a file in a sandboxed iframe that may run nothing", async () => {
|
|
const user = userEvent.setup();
|
|
open();
|
|
const card = await screen.findByTestId("artifact-1");
|
|
await user.click(within(card).getByRole("button", { name: "Preview" }));
|
|
const frame = await screen.findByTestId("artifact-preview");
|
|
expect(frame.getAttribute("sandbox")).toBe("");
|
|
expect(frame.getAttribute("src")).toBe("/api/artifacts/1/raw?path=report.html");
|
|
});
|
|
|
|
it("renders a skill's SKILL.md itself instead of an iframe", async () => {
|
|
vi.mocked(api.artifacts.list).mockResolvedValue({
|
|
items: [
|
|
item({
|
|
kind: "skill",
|
|
title: "standup",
|
|
files: [
|
|
{ path: "SKILL.md", size: 10 },
|
|
{ path: "notes.txt", size: 5 },
|
|
],
|
|
}),
|
|
],
|
|
});
|
|
const fetchMock = vi.fn(async (url: string) =>
|
|
url.includes("SKILL.md")
|
|
? new Response("---\nname: standup\n---\n# Daily standup\n\nSummarise.")
|
|
: new Response("plain notes")
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
const user = userEvent.setup();
|
|
open();
|
|
await screen.findByText("standup");
|
|
await user.click(screen.getByRole("button", { name: "Preview" }));
|
|
expect(await screen.findByRole("heading", { name: "Daily standup" })).toBeInTheDocument();
|
|
expect(screen.getByTestId("skill-summary")).toHaveTextContent("/out/report.html");
|
|
expect(screen.queryByTestId("artifact-preview")).toBeNull();
|
|
await user.click(screen.getByRole("button", { name: "notes.txt" }));
|
|
expect(await screen.findByText("plain notes")).toBeInTheDocument();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("edits a skill's name, description and instructions from its SKILL.md", async () => {
|
|
vi.mocked(api.artifacts.list).mockResolvedValue({
|
|
items: [
|
|
item({
|
|
kind: "skill",
|
|
title: "standup",
|
|
source: "standup",
|
|
files: [{ path: "SKILL.md", size: 10 }],
|
|
}),
|
|
],
|
|
});
|
|
vi.mocked(api.artifacts.update).mockResolvedValue({ item: item({}) } as never);
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async () => new Response("---\nname: standup\ndescription: Old\n---\n\nOld body"))
|
|
);
|
|
const user = userEvent.setup();
|
|
open();
|
|
await screen.findByText("standup");
|
|
await user.click(screen.getByRole("button", { name: "Edit" }));
|
|
const name = await screen.findByLabelText(/Skill name/);
|
|
expect(name).toHaveValue("standup");
|
|
expect(screen.getByLabelText(/Instructions/)).toHaveValue("Old body");
|
|
await user.clear(screen.getByLabelText(/Description/));
|
|
await user.type(screen.getByLabelText(/Description/), "New");
|
|
await user.click(screen.getByRole("button", { name: "Save" }));
|
|
await waitFor(() =>
|
|
expect(api.artifacts.update).toHaveBeenCalledWith(1, {
|
|
title: "standup",
|
|
skill: { name: "standup", description: "New", content: "Old body" },
|
|
})
|
|
);
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("opens a card's actions on right-click", async () => {
|
|
const user = userEvent.setup();
|
|
open();
|
|
await screen.findByText("report.html");
|
|
fireEvent.contextMenu(screen.getByTestId("artifact-1"));
|
|
const menu = screen.getByTestId("artifact-menu");
|
|
expect(within(menu).getByRole("menuitem", { name: "Delete" })).toBeInTheDocument();
|
|
expect(within(menu).queryByRole("menuitem", { name: "Install" })).toBeNull();
|
|
await user.click(within(menu).getByRole("menuitem", { name: "Preview" }));
|
|
expect(screen.queryByTestId("artifact-menu")).toBeNull();
|
|
expect((await screen.findByTestId("artifact-preview")).getAttribute("src")).toBe(
|
|
"/api/artifacts/1/raw?path=report.html"
|
|
);
|
|
});
|
|
|
|
it("offers no preview for a file the sandbox cannot show, only its download", async () => {
|
|
vi.mocked(api.artifacts.list).mockResolvedValue({
|
|
items: [item({ title: "plan.docx", files: [{ path: "plan.docx", size: 10 }] })],
|
|
});
|
|
open();
|
|
const card = await screen.findByTestId("artifact-1");
|
|
expect(within(card).queryByRole("button", { name: "Preview" })).toBeNull();
|
|
expect(within(card).getByRole("link", { name: "Download" })).toBeInTheDocument();
|
|
});
|
|
|
|
it("closes the Add menu on Escape and on a click outside it", async () => {
|
|
const user = userEvent.setup();
|
|
open();
|
|
await screen.findByText("deploy");
|
|
await user.click(screen.getByTestId("artifacts-add"));
|
|
expect(screen.getByRole("button", { name: "Skill" })).toBeInTheDocument();
|
|
await user.keyboard("{Escape}");
|
|
expect(screen.queryByRole("button", { name: "Skill" })).toBeNull();
|
|
await user.click(screen.getByTestId("artifacts-add"));
|
|
await user.click(screen.getByText("report.html"));
|
|
expect(screen.queryByRole("button", { name: "Skill" })).toBeNull();
|
|
});
|
|
|
|
it("shelves the front page by kind, and a card opens its detail sheet", async () => {
|
|
const user = userEvent.setup();
|
|
open();
|
|
await screen.findByText("deploy");
|
|
const shelves = screen.getAllByRole("heading", { level: 2 }).map((h) => h.textContent);
|
|
expect(shelves).toEqual(["Skills1", "Files1"]);
|
|
await user.click(screen.getAllByRole("button", { name: "See all" })[0]!);
|
|
await waitFor(() =>
|
|
expect(api.artifacts.list).toHaveBeenLastCalledWith(
|
|
expect.objectContaining({ kind: "skill" })
|
|
)
|
|
);
|
|
await user.click(screen.getByText("deploy"));
|
|
const sheet = await screen.findByTestId("artifact-detail");
|
|
expect(within(sheet).getByText("/deploy")).toBeInTheDocument();
|
|
expect(within(sheet).getByRole("button", { name: "Install" })).toBeInTheDocument();
|
|
});
|
|
});
|