Files
AgentHub/client/src/pages/__tests__/Artifacts.test.tsx
T
nntrivi2001 7a88b74016 feat: v2.8.0 — Artifacts uploads and plugin links, console tab combos, account avatars
- Artifacts hub: upload a file from the computer; make a skill by uploading a
  Markdown file, files, a folder or a .zip/.skill, or by writing it in the
  dialog; add plugins by picking from the list a marketplace link answers
  (POST /api/artifacts/plugin/inspect); Settings → AI tools → Artifacts Hub.
- Console tabs are combos of up to four split panes (ConsoleTabs, PaneGrid).
- Accounts: an own avatar (PUT/DELETE /api/me/avatar, raster only, ≤ 1 MB,
  <data dir>/avatars/), shown with name and role at the sidebar's foot and on
  the workspace owner groups (GET /api/users/:id/avatar, owner_avatar_at).
- Users page: per-user storage (GET /api/users/storage, measured in a worker)
  and account-pool spend/tokens per account (server/lib/pool-usage.js).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:21:33 +07:00

233 lines
9.1 KiB
TypeScript

/**
* @file Tests for the `/artifacts` page: the library lists what the server
* answers, the Hub tab and Share appear only in server mode, a skill is
* installed only after a confirmation (and a taken name asks to replace),
* and a file preview is a sandboxed iframe with no `allow-*` token. 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.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { 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(),
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 }],
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,
});
open();
expect(await screen.findByRole("button", { name: "Hub" })).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: /^Share$/ }).length).toBe(2);
});
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();
await screen.findByText("report.html");
await user.click(screen.getAllByRole("button", { name: "Preview" })[0]!);
const frame = await screen.findByTestId("artifact-preview");
expect(frame.getAttribute("sandbox")).toBe("");
expect(frame.getAttribute("src")).toBe("/api/artifacts/1/raw?path=report.html");
});
});