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>
This commit is contained in:
@@ -2,11 +2,14 @@
|
||||
* @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.
|
||||
* 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 } from "@testing-library/react";
|
||||
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";
|
||||
@@ -24,6 +27,14 @@ vi.mock("../../lib/api", () => ({
|
||||
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 ?? ""}`,
|
||||
},
|
||||
},
|
||||
@@ -75,6 +86,99 @@ beforeEach(() => {
|
||||
});
|
||||
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", () => {
|
||||
|
||||
@@ -61,6 +61,9 @@ vi.mock("../../components/settings/AiToolsSettings", () => ({
|
||||
vi.mock("../../components/settings/AgentInstructionsSettings", () => ({
|
||||
AgentInstructionsSettings: () => <div data-testid="agent-instructions" />,
|
||||
}));
|
||||
vi.mock("../../components/settings/ArtifactsHubSettings", () => ({
|
||||
ArtifactsHubSettings: () => null,
|
||||
}));
|
||||
vi.mock("../../components/settings/PricingSettings", () => ({
|
||||
PricingSettings: ({ vendor }: { vendor: string }) => <div data-testid={`pricing-${vendor}`} />,
|
||||
}));
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import type { AuthState, ServerUserUsage } from "../../lib/api";
|
||||
@@ -34,6 +34,7 @@ const row = (id: number, username: string, role: ServerUserUsage["role"]): Serve
|
||||
const users = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
audit: vi.fn(),
|
||||
storage: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
setPassword: vi.fn(),
|
||||
@@ -74,6 +75,7 @@ beforeEach(() => {
|
||||
users: [row(1, "boss", "admin"), row(2, "mia", "developer")],
|
||||
});
|
||||
users.audit.mockResolvedValue({ entries: [] });
|
||||
users.storage.mockResolvedValue({ storage: {}, measuring: false });
|
||||
users.get.mockImplementation(async (id: number) => ({
|
||||
user: { id, username: "x", role: "developer", disabled: false },
|
||||
workspaces: [],
|
||||
@@ -91,6 +93,28 @@ describe("Users page", () => {
|
||||
expect(users.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows each user's home size, and re-measures on Refresh", async () => {
|
||||
authState = { mode: "server", user: { id: 1, username: "boss", role: "admin" } };
|
||||
users.storage.mockResolvedValue({
|
||||
storage: {
|
||||
1: null,
|
||||
2: {
|
||||
bytes: 3 * 1024 ** 3,
|
||||
files: 1200,
|
||||
partial: false,
|
||||
measuredAt: "2026-10-14T09:00:00Z",
|
||||
},
|
||||
},
|
||||
measuring: false,
|
||||
});
|
||||
renderPage();
|
||||
expect(await screen.findByText("3.0 GB")).toBeTruthy();
|
||||
expect(screen.getByText("Storage")).toBeTruthy();
|
||||
expect(users.storage).toHaveBeenLastCalledWith(false);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Refresh/ }));
|
||||
await waitFor(() => expect(users.storage).toHaveBeenLastCalledWith(true));
|
||||
});
|
||||
|
||||
it("shows an analyst the accounts with no controls", async () => {
|
||||
authState = { mode: "server", user: { id: 3, username: "ana", role: "analyst" } };
|
||||
renderPage();
|
||||
|
||||
@@ -7178,7 +7178,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.7.9 (rebuild client to match)
|
||||
UI build v2.8.0 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user