Files
AgentHub/client/src/pages/__tests__/Artifacts.test.tsx
T
nntrivi2001 d09c9d6633 feat(artifacts): last updated time on skills and files; each installer's copy marked
A row said nothing about when its content last changed, and the install list
could not tell who still runs an old version. `artifacts.version_at` (new
column, existing rows from `updated_at`) is stamped only when a snapshot is
replaced — a rename or share toggle leaves it — and served as `versionAt`:
skill and file rows read "Updated 3h ago", the detail sheet "Last updated".
`GET /api/artifacts/:id/installs` gives each skill install a `state` —
latest / outdated / removed / unknown — from its Claude skill copies' hash
against the newest snapshot, shown as a badge.
2026-10-30 15:21:31 +07:00

694 lines
28 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.
* Rows carry one primary action; Preview, Share and Edit live in the ⋯ menu.
* A section shows 30 entries before "Show more", and three or more skills of
* one owner sharing a name prefix are one pack row that installs them all.
* An installed skill whose owner changed it shows Update (after a confirmation).
* @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(),
updateInstalled: 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(),
reshare: 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",
versionAt: "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 }));
};
/** Run one item of the first (or the given) row's ⋯ menu. */
async function menuAction(
user: ReturnType<typeof userEvent.setup>,
name: string,
scope: HTMLElement = document.body
) {
await user.click(within(scope).getAllByRole("button", { name: "More actions" })[0]!);
await user.click(within(screen.getByTestId("artifact-menu")).getByRole("menuitem", { name }));
}
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();
await user.click(screen.getAllByRole("button", { name: "More actions" })[0]!);
expect(
within(screen.getByTestId("artifact-menu")).getByRole("menuitem", { name: "Share" })
).toBeInTheDocument();
await user.keyboard("{Escape}");
// One merged list by default; Hub is a filter over it. The Suggested
// count's own `unowned` list runs whenever settings land, so it is left
// out: only the order of the LIST loads is under test.
const listScopes = () =>
vi
.mocked(api.artifacts.list)
.mock.calls.map(([opts]) => opts?.scope)
.filter((s) => s !== "unowned");
expect(listScopes().pop()).toBe("all");
await user.click(screen.getByRole("button", { name: "Hub" }));
await waitFor(() => expect(listScopes().pop()).toBe("hub"));
});
it("splits the Hub and My library into yours and others'", async () => {
vi.mocked(api.artifacts.settings).mockResolvedValue({
serverMode: true,
canShare: true,
installsEnabled: true,
canManage: false,
});
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [
item({ id: 1, title: "my-page", shared: true }),
item({
id: 2,
kind: "skill",
title: "their-skill",
mine: false,
owner: "an",
shared: true,
}),
],
});
const user = userEvent.setup();
open();
// All is split too, others' first.
const allOthers = await screen.findByTestId("half-all.others");
expect(within(allOthers).getByText("their-skill")).toBeInTheDocument();
expect(
allOthers.compareDocumentPosition(screen.getByTestId("half-all.own")) &
Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
await user.click(await screen.findByRole("button", { name: "Hub" }));
const own = await screen.findByTestId("half-hub.own");
expect(own).toHaveTextContent("Shared by me");
expect(within(own).getByText("my-page")).toBeInTheDocument();
const others = screen.getByTestId("half-hub.others");
expect(within(others).getByText("their-skill")).toBeInTheDocument();
expect(within(others).queryByText("my-page")).toBeNull();
await user.click(screen.getByRole("button", { name: "My library" }));
expect(await screen.findByTestId("half-mine.others")).toHaveTextContent(
"Installed from others"
);
expect(within(screen.getByTestId("half-mine.own")).getByText("my-page")).toBeInTheDocument();
});
it("shows a long section 30 entries at a time, then the rest on Show more", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: Array.from({ length: 34 }, (_, i) =>
item({ id: i + 1, kind: "page", title: `p${i + 1}`, files: [] })
),
});
const user = userEvent.setup();
open();
await screen.findByText("p1");
expect(screen.getByText("p30")).toBeInTheDocument();
expect(screen.queryByText("p31")).toBeNull();
await user.click(screen.getByRole("button", { name: "Show 4 more" }));
expect(screen.getByText("p34")).toBeInTheDocument();
expect(screen.getByText("p1")).toBeInTheDocument();
});
it("folds three skills sharing a name prefix into one pack that installs them all", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [
item({ id: 1, kind: "skill", title: "sg-qa", source: "sg-qa" }),
item({ id: 2, kind: "skill", title: "sg-ship", source: "sg-ship", installed: true }),
item({ id: 3, kind: "skill", title: "sg-fix", source: "sg-fix" }),
item({ id: 4, kind: "skill", title: "lone", source: "lone" }),
],
});
vi.mocked(api.artifacts.install).mockResolvedValue({ installed: "/h/x" } as never);
const user = userEvent.setup();
open();
const pack = await screen.findByTestId("pack-sg");
expect(within(pack).getByText("sg-* pack")).toBeInTheDocument();
expect(within(pack).getByText("3 skills")).toBeInTheDocument();
expect(screen.queryByTestId("artifact-1")).toBeNull();
expect(screen.getByTestId("artifact-4")).toBeInTheDocument();
await user.click(within(pack).getByRole("button", { expanded: false }));
expect(within(pack).getByTestId("artifact-1")).toBeInTheDocument();
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
await user.click(within(pack).getByRole("button", { name: "Install 2 more" }));
await waitFor(() => expect(api.artifacts.install).toHaveBeenCalledTimes(2));
expect(api.artifacts.install).toHaveBeenCalledWith(1);
expect(api.artifacts.install).toHaveBeenCalledWith(3);
});
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,
state: "latest",
paths: ["/h/.claude/skills/deploy"],
},
{
user: "khoa",
mine: false,
at: "2026-10-18T10:00:00Z",
replaced: true,
state: "outdated",
},
],
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();
// Each install says whether its copy is the newest version.
expect(
within(box)
.getAllByTestId("install-state")
.map((b) => b.textContent)
).toEqual(["Latest", "Old version"]);
// Over the page, outside the card list that clips its overflow — and a
// click inside it keeps it open.
expect(box.parentElement).toBe(document.body);
await user.click(within(box).getByText(/khoa/));
expect(screen.getByTestId("artifact-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 menuAction(user, "Remove from Hub", card);
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("Share again takes the server's copy, else uploads the picked folder onto that skill", async () => {
vi.mocked(api.artifacts.uploadSkills).mockClear();
vi.mocked(api.artifacts.settings).mockResolvedValue({
serverMode: true,
canShare: true,
installsEnabled: true,
canManage: true,
});
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [item({ id: 2, kind: "skill", title: "sg-flow", source: "sg-flow", shared: true })],
});
vi.mocked(api.artifacts.reshare).mockResolvedValueOnce({
item: item({ id: 2 }),
changed: true,
});
const user = userEvent.setup();
open();
const card = await screen.findByTestId("artifact-2");
await menuAction(user, "Share again", card);
await waitFor(() => expect(api.artifacts.reshare).toHaveBeenCalledWith(2));
expect(api.artifacts.uploadSkills).not.toHaveBeenCalled();
// No copy on the server: the folder is asked for and lands on THIS row.
vi.mocked(api.artifacts.reshare).mockRejectedValueOnce(
Object.assign(new Error("no copy"), { code: "ENOSOURCE" })
);
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
vi.mocked(api.artifacts.uploadSkills).mockResolvedValueOnce({
items: [{ ...item({ id: 2 }), changed: true }],
});
await menuAction(user, "Share again", card);
await waitFor(() => expect(confirmDialog).toHaveBeenCalled());
const md = new File(["---\nname: sg-flow\n---\n"], "SKILL.md");
await user.upload(screen.getByTestId("artifact-reshare-folder"), md);
await waitFor(() =>
expect(api.artifacts.uploadSkills).toHaveBeenCalledWith([{ file: md, path: "SKILL.md" }], {
id: "2",
})
);
});
it("says when a skill's content last changed, not when it was renamed", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [
item({
id: 2,
kind: "skill",
title: "deploy",
source: "deploy",
updatedAt: new Date().toISOString(),
versionAt: new Date(Date.now() - 3 * 3600_000).toISOString(),
}),
],
});
open();
const card = await screen.findByTestId("artifact-2");
expect(within(card).getByText("Updated 3h ago")).toBeInTheDocument();
});
it("offers Update for an installed skill its owner changed, after a confirmation", async () => {
vi.mocked(api.artifacts.list).mockResolvedValue({
items: [
item({
id: 2,
kind: "skill",
title: "deploy",
source: "deploy",
installed: true,
updateAvailable: true,
}),
],
});
vi.mocked(api.artifacts.updateInstalled).mockResolvedValue({ installed: "/h/x" });
const user = userEvent.setup();
open();
const card = await screen.findByTestId("artifact-2");
expect(within(card).queryByTestId("artifact-installed")).toBeNull();
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
await user.click(within(card).getByRole("button", { name: "Update" }));
expect(api.artifacts.updateInstalled).not.toHaveBeenCalled();
vi.mocked(confirmDialog).mockResolvedValueOnce(true);
await user.click(within(card).getByRole("button", { name: "Update" }));
await waitFor(() => expect(api.artifacts.updateInstalled).toHaveBeenCalledWith(2));
});
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();
// No Reinstall anywhere, and Uninstall lives only in the ⋯ menu.
expect(within(card).queryByRole("button", { name: "Reinstall" })).toBeNull();
expect(within(card).queryByRole("button", { name: "Uninstall" })).toBeNull();
await userEvent.setup().click(within(card).getByRole("button", { name: "More actions" }));
const menu = screen.getByTestId("artifact-menu");
expect(within(menu).queryByRole("menuitem", { name: "Reinstall" })).toBeNull();
expect(within(menu).getByRole("menuitem", { name: "Uninstall" })).toBeInTheDocument();
});
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);
const uninstall = async () => {
await user.click(within(card).getByRole("button", { name: "More actions" }));
await user.click(
within(screen.getByTestId("artifact-menu")).getByRole("menuitem", { name: "Uninstall" })
);
};
await 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 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 menuAction(user, "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 menuAction(user, "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();
});
});