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:
+58
-125
@@ -2,11 +2,14 @@
|
||||
* @file Artifacts.tsx
|
||||
* @description `/artifacts` — the Artifacts hub (`/api/artifacts`,
|
||||
* server/lib/artifacts.js): claude.ai pages sessions published, pinned
|
||||
* workspace files, skills and plugins. **My library** lists the caller's
|
||||
* workspace files (or one uploaded from the computer), skills (picked,
|
||||
* uploaded as a file/folder/zip, or written — `ArtifactAddPanels.tsx`) and
|
||||
* plugins (read from a marketplace link, or typed). **My library** lists the caller's
|
||||
* own; in server mode the **Hub** tab lists what every user shared, and a
|
||||
* shared skill or plugin is installed into the viewer's own account from
|
||||
* there (after a confirmation that lists what will be written or run). An
|
||||
* admin's switch turns Hub installs off for everyone. Previews of a file are
|
||||
* admin's switch in Settings → AI tools (`ArtifactsHubSettings`) turns Hub
|
||||
* installs off for everyone. Previews of a file are
|
||||
* the server's sandboxed `raw` route in a sandboxed iframe, so nothing a
|
||||
* shared HTML file carries runs on the dashboard's origin.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
@@ -40,7 +43,11 @@ import { eventBus } from "../lib/eventBus";
|
||||
import { toast } from "../lib/toast";
|
||||
import { confirmDialog } from "../lib/confirm";
|
||||
import { useDialog } from "../hooks/useDialog";
|
||||
import { Switch } from "../components/Switch";
|
||||
import {
|
||||
FileUploadPanel,
|
||||
PluginAddPanel,
|
||||
SkillAddPanel,
|
||||
} from "../components/artifacts/ArtifactAddPanels";
|
||||
|
||||
const KIND_ICON = { page: Globe, file: FileText, skill: Sparkles, plugin: Package } as const;
|
||||
const KINDS: ArtifactKind[] = ["page", "file", "skill", "plugin"];
|
||||
@@ -176,26 +183,6 @@ export function Artifacts() {
|
||||
<p className="text-sm text-fg-muted">{t(server ? "subtitleServer" : "subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{server && settings?.canManage && (
|
||||
<label
|
||||
className="flex items-center gap-2 text-xs text-fg-secondary"
|
||||
title={t("installsSwitchHint")}
|
||||
>
|
||||
<Switch
|
||||
checked={settings.installsEnabled}
|
||||
label={t("installsSwitch")}
|
||||
onChange={async (v) => {
|
||||
try {
|
||||
const r = await api.artifacts.setInstallsEnabled(v);
|
||||
setSettings({ ...settings, installsEnabled: r.installsEnabled });
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{t("installsSwitch")}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
@@ -301,16 +288,7 @@ export function Artifacts() {
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{adding && (
|
||||
<AddDialog
|
||||
kind={adding}
|
||||
onClose={() => setAdding(null)}
|
||||
onAdded={() => {
|
||||
toast.success(t("added"));
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{adding && <AddDialog kind={adding} onClose={() => setAdding(null)} onAdded={load} />}
|
||||
{preview && <PreviewDialog {...preview} onClose={() => setPreview(null)} />}
|
||||
{renaming && (
|
||||
<RenameDialog
|
||||
@@ -372,9 +350,7 @@ function ArtifactCard({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{a.description && (
|
||||
<p className="line-clamp-2 text-xs text-fg-secondary">{a.description}</p>
|
||||
)}
|
||||
{a.description && <p className="line-clamp-2 text-xs text-fg-secondary">{a.description}</p>}
|
||||
<p className="text-[11px] text-fg-muted">
|
||||
{server && a.owner && !a.mine ? `${t("by", { name: a.owner })} · ` : ""}
|
||||
{new Date(a.createdAt).toLocaleString()}
|
||||
@@ -495,6 +471,9 @@ function RenameDialog({
|
||||
const { t } = useTranslation("artifacts");
|
||||
const [title, setTitle] = useState(item.title);
|
||||
const [description, setDescription] = useState(item.description);
|
||||
const [source, setSource] = useState(item.source ?? "");
|
||||
const [market, setMarket] = useState(item.market ?? "");
|
||||
const plugin = item.kind === "plugin";
|
||||
const field =
|
||||
"w-full rounded border border-border bg-surface-1 px-2 py-1 text-sm text-fg-primary";
|
||||
return (
|
||||
@@ -504,7 +483,12 @@ function RenameDialog({
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.artifacts.update(item.id, { title, description });
|
||||
await api.artifacts.update(item.id, {
|
||||
title,
|
||||
description,
|
||||
...(plugin && source.trim() !== item.source ? { source: source.trim() } : {}),
|
||||
...(plugin && market.trim() !== item.market ? { market: market.trim() } : {}),
|
||||
});
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
toast.error(err);
|
||||
@@ -525,8 +509,30 @@ function RenameDialog({
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
{plugin && (
|
||||
<>
|
||||
<input
|
||||
className={field}
|
||||
aria-label={t("form.pluginKey")}
|
||||
placeholder="my-plugin@my-marketplace"
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className={field}
|
||||
aria-label={t("form.market")}
|
||||
placeholder="owner/repo · https://…"
|
||||
value={market}
|
||||
onChange={(e) => setMarket(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<button type="submit" className="btn-primary text-sm" disabled={!title.trim()}>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary text-sm"
|
||||
disabled={!title.trim() || (plugin && (!source.trim() || !market.trim()))}
|
||||
>
|
||||
{t("form.saveEdit")}
|
||||
</button>
|
||||
</div>
|
||||
@@ -602,14 +608,9 @@ function AddDialog({
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [key, setKey] = useState("");
|
||||
const [market, setMarket] = useState("");
|
||||
const [lanes, setLanes] = useState<Lane[]>([]);
|
||||
const [laneId, setLaneId] = useState<number | null>(null);
|
||||
const [candidates, setCandidates] = useState<ArtifactCandidate[] | null>(null);
|
||||
const [skills, setSkills] = useState<{ name: string; from: "user" | "workspace" }[] | null>(
|
||||
null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (kind !== "file" && kind !== "skill") return;
|
||||
@@ -631,18 +632,13 @@ function AddDialog({
|
||||
toast.error(err);
|
||||
});
|
||||
}
|
||||
if (kind === "skill") {
|
||||
api.artifacts
|
||||
.skills(laneId ?? undefined)
|
||||
.then((r) => setSkills(r.items))
|
||||
.catch(() => setSkills([]));
|
||||
}
|
||||
}, [kind, laneId]);
|
||||
|
||||
const run = async (fn: () => Promise<unknown>, close = true) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await fn();
|
||||
toast.success(t("added"));
|
||||
onAdded();
|
||||
if (close) onClose();
|
||||
} catch (err) {
|
||||
@@ -719,6 +715,10 @@ function AddDialog({
|
||||
|
||||
{kind === "file" && (
|
||||
<>
|
||||
<FileUploadPanel onAdded={onAdded} />
|
||||
<p className="border-t border-border-light pt-2 text-[11px] font-medium text-fg-secondary">
|
||||
{t("file.fromWorkspace")}
|
||||
</p>
|
||||
{workspacePicker}
|
||||
{laneId && candidates === null && (
|
||||
<p className="text-xs text-fg-muted">{t("loading")}</p>
|
||||
@@ -730,7 +730,10 @@ function AddDialog({
|
||||
<ul className="max-h-80 space-y-0.5 overflow-auto">
|
||||
{candidates.map((c) => (
|
||||
<li key={c.path} className="flex items-center gap-2 text-xs">
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-fg-secondary" title={c.path}>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-mono text-fg-secondary"
|
||||
title={c.path}
|
||||
>
|
||||
{c.path}
|
||||
</span>
|
||||
{!c.exists ? (
|
||||
@@ -750,7 +753,9 @@ function AddDialog({
|
||||
sessionId: c.sessionId,
|
||||
});
|
||||
setCandidates((list) =>
|
||||
(list ?? []).map((x) => (x.path === c.path ? { ...x, pinned: true } : x))
|
||||
(list ?? []).map((x) =>
|
||||
x.path === c.path ? { ...x, pinned: true } : x
|
||||
)
|
||||
);
|
||||
}, false)
|
||||
}
|
||||
@@ -765,81 +770,9 @@ function AddDialog({
|
||||
</>
|
||||
)}
|
||||
|
||||
{kind === "skill" && (
|
||||
<>
|
||||
{workspacePicker}
|
||||
{skills && skills.length === 0 && (
|
||||
<p className="text-xs text-fg-muted">{t("form.noSkills")}</p>
|
||||
)}
|
||||
{skills && skills.length > 0 && (
|
||||
<ul className="max-h-72 space-y-0.5 overflow-auto">
|
||||
{skills.map((s) => (
|
||||
<li key={`${s.from}:${s.name}`} className="flex items-center gap-2 text-xs">
|
||||
<Sparkles className="h-3 w-3 shrink-0 text-fg-muted" />
|
||||
<span className="min-w-0 flex-1 truncate text-fg-secondary">{s.name}</span>
|
||||
<span className="text-fg-muted">
|
||||
{t(s.from === "user" ? "form.fromUser" : "form.fromWorkspace")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-ghost text-xs"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void run(() =>
|
||||
api.artifacts.addSkill({
|
||||
name: s.name,
|
||||
...(s.from === "workspace" && laneId ? { laneId } : {}),
|
||||
title: title || undefined,
|
||||
description: description || undefined,
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("form.save")}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{meta}
|
||||
</>
|
||||
)}
|
||||
{kind === "skill" && <SkillAddPanel lanes={lanes} onAdded={onAdded} onClose={onClose} />}
|
||||
|
||||
{kind === "plugin" && (
|
||||
<form
|
||||
className="space-y-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void run(() => api.artifacts.addPlugin({ key, market, title, description }));
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className={field}
|
||||
placeholder="my-plugin@my-marketplace"
|
||||
aria-label={t("form.pluginKey")}
|
||||
value={key}
|
||||
autoFocus
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className={field}
|
||||
placeholder="owner/repo"
|
||||
aria-label={t("form.market")}
|
||||
value={market}
|
||||
onChange={(e) => setMarket(e.target.value)}
|
||||
/>
|
||||
{meta}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary text-sm"
|
||||
disabled={busy || !key.trim() || !market.trim()}
|
||||
>
|
||||
{t("form.save")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{kind === "plugin" && <PluginAddPanel onAdded={onAdded} onClose={onClose} />}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -67,6 +67,7 @@ import { SnapshotSettings } from "../components/settings/SnapshotSettings";
|
||||
import { ReportSettings } from "../components/settings/ReportSettings";
|
||||
import { AiToolsSettings } from "../components/settings/AiToolsSettings";
|
||||
import { AgentInstructionsSettings } from "../components/settings/AgentInstructionsSettings";
|
||||
import { ArtifactsHubSettings } from "../components/settings/ArtifactsHubSettings";
|
||||
import { PricingSettings } from "../components/settings/PricingSettings";
|
||||
import { WorktreeVisibilitySettings } from "../components/settings/WorktreeVisibilitySettings";
|
||||
import { useAiTools } from "../hooks/useAiTools";
|
||||
@@ -292,6 +293,8 @@ export function SettingsHub({ section }: { section?: string } = {}) {
|
||||
<div className="space-y-6">
|
||||
<AgentInstructionsSettings />
|
||||
<AiToolsSettings settingsHref={toolHref} />
|
||||
{/* The Hub's install switch: server mode, an admin's (it hides itself). */}
|
||||
<ArtifactsHubSettings />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
+81
-10
@@ -2,8 +2,10 @@
|
||||
* @file Users.tsx
|
||||
* @description The Users page (`/users`, a sidebar item), server mode only and
|
||||
* only for an admin or analyst (docs/SERVER-MODE.md): every account with its
|
||||
* role, state, workspaces, sessions and the tokens/$ it used in the last 7 and
|
||||
* 30 days (`GET /api/users`), the audit log (`GET /api/users/audit`), and — for
|
||||
* role, state, workspaces, sessions, the tokens/$ it used in the last 7 and
|
||||
* 30 days (`GET /api/users`) and how much disk its home folder takes
|
||||
* (`GET /api/users/storage`, measured in the background — polled while the
|
||||
* server says `measuring`; Refresh re-measures), the audit log (`GET /api/users/audit`), and — for
|
||||
* an admin — add a user, and ONE Edit per row (`UserEditDialog`: profile,
|
||||
* role, active/disabled, password, owned workspaces, sign out everywhere, sent
|
||||
* as one change), and the Claude/Codex account pool every user's sessions run
|
||||
@@ -30,8 +32,14 @@ import {
|
||||
UserPlus,
|
||||
Users as UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { api, type AuditEntry, type AuthUser, type ServerUserUsage } from "../lib/api";
|
||||
import { fmt, fmtCost, formatDateTime } from "../lib/format";
|
||||
import {
|
||||
api,
|
||||
type AuditEntry,
|
||||
type AuthUser,
|
||||
type ServerUserUsage,
|
||||
type UserStorage,
|
||||
} from "../lib/api";
|
||||
import { fmt, fmtCost, formatBytes, formatDateTime } from "../lib/format";
|
||||
import { toast } from "../lib/toast";
|
||||
import { isSubmitEnter } from "../lib/keys";
|
||||
import { useSpin } from "../hooks/useSpin";
|
||||
@@ -56,10 +64,25 @@ export function Users() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [editing, setEditing] = useState<ServerUserUsage | null>(null);
|
||||
const [storage, setStorage] = useState<Record<string, UserStorage> | null>(null);
|
||||
const [measuring, setMeasuring] = useState(false);
|
||||
|
||||
const loadStorage = useCallback(
|
||||
(remeasure = false) =>
|
||||
api.users
|
||||
.storage(remeasure)
|
||||
.then((r) => {
|
||||
setStorage(r.storage);
|
||||
setMeasuring(r.measuring);
|
||||
})
|
||||
.catch(() => setMeasuring(false)),
|
||||
[]
|
||||
);
|
||||
|
||||
const load = useCallback(
|
||||
() =>
|
||||
(remeasure = false) =>
|
||||
Promise.all([
|
||||
loadStorage(remeasure),
|
||||
api.users
|
||||
.list()
|
||||
.then((r) => {
|
||||
@@ -72,14 +95,23 @@ export function Users() {
|
||||
.then((r) => setAudit(r.entries))
|
||||
.catch(() => setAudit([])),
|
||||
]),
|
||||
[]
|
||||
[loadStorage]
|
||||
);
|
||||
const [spinning, refresh] = useSpin(load);
|
||||
// The Refresh button also re-measures every home; a plain load reuses the
|
||||
// server's cached numbers.
|
||||
const [spinning, refresh] = useSpin(useCallback(() => load(true), [load]));
|
||||
|
||||
useEffect(() => {
|
||||
if (me && me.role !== "developer") void load();
|
||||
}, [me, load]);
|
||||
|
||||
// Homes are measured in the background: ask again until every number is in.
|
||||
useEffect(() => {
|
||||
if (!measuring) return;
|
||||
const id = setTimeout(() => void loadStorage(), 4000);
|
||||
return () => clearTimeout(id);
|
||||
}, [measuring, storage, loadStorage]);
|
||||
|
||||
// Local mode, or a developer: there is no Users page for them.
|
||||
if (!me || me.role === "developer") return <Navigate to="/" replace />;
|
||||
|
||||
@@ -168,7 +200,7 @@ export function Users() {
|
||||
)}
|
||||
|
||||
<div className="card overflow-x-auto">
|
||||
<table className="w-full min-w-[760px]" data-testid="users-table">
|
||||
<table className="w-full min-w-[860px]" data-testid="users-table">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left">
|
||||
<th className={th}>{t("name")}</th>
|
||||
@@ -177,6 +209,7 @@ export function Users() {
|
||||
<th className={`${th} text-right`}>{t("sessions")}</th>
|
||||
<th className={`${th} text-right`}>{t("last7d")}</th>
|
||||
<th className={`${th} text-right`}>{t("last30d")}</th>
|
||||
<th className={`${th} text-right`}>{t("storage")}</th>
|
||||
{isAdmin && <th className={`${th} text-right`}>{t("actions")}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -186,8 +219,8 @@ export function Users() {
|
||||
Array.from({ length: 4 }).map((_, i) => (
|
||||
<TableRowSkeleton
|
||||
key={i}
|
||||
columns={isAdmin ? 7 : 6}
|
||||
widths={["w-36", "w-20", "w-8", "w-8", "w-14", "w-14", "w-28"]}
|
||||
columns={isAdmin ? 8 : 7}
|
||||
widths={["w-36", "w-20", "w-8", "w-8", "w-14", "w-14", "w-14", "w-28"]}
|
||||
/>
|
||||
))}
|
||||
{users?.map((u) => {
|
||||
@@ -244,6 +277,10 @@ export function Users() {
|
||||
</td>
|
||||
<UsageCell usage={u.last7d} />
|
||||
<UsageCell usage={u.last30d} />
|
||||
<StorageCell
|
||||
storage={storage ? (storage[u.id] ?? null) : undefined}
|
||||
measuring={measuring}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<td className="px-5 py-2 text-right whitespace-nowrap">
|
||||
<RowAction label={t("edit.button")} onClick={() => setEditing(u)}>
|
||||
@@ -341,6 +378,40 @@ function UsageCell({ usage }: { usage: { cost: number; tokens: number } }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A user's home folder size (`undefined` = not loaded yet). `null` while the
|
||||
* server is still measuring reads "Measuring…", otherwise "—" (no home of
|
||||
* their own).
|
||||
*/
|
||||
function StorageCell({ storage, measuring }: { storage?: UserStorage; measuring: boolean }) {
|
||||
const { t } = useTranslation("users");
|
||||
if (!storage) {
|
||||
const pending = storage === undefined || measuring;
|
||||
return (
|
||||
<td
|
||||
className="px-5 py-3 text-right text-sm tabular-nums text-fg-muted"
|
||||
title={pending ? undefined : t("storageNone")}
|
||||
>
|
||||
{pending ? t("storageMeasuring") : "—"}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<td
|
||||
className="px-5 py-3 text-right tabular-nums"
|
||||
title={storage.partial ? t("storagePartial") : undefined}
|
||||
>
|
||||
<div className="text-sm text-fg-primary">
|
||||
{storage.partial ? "≥ " : ""}
|
||||
{formatBytes(storage.bytes)}
|
||||
</div>
|
||||
<div className="text-[11px] text-fg-muted">
|
||||
{t("storageHint", { files: fmt(storage.files), at: formatDateTime(storage.measuredAt) })}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
function RowAction({
|
||||
label,
|
||||
disabled,
|
||||
|
||||
@@ -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