7a88b74016
- 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>
171 lines
5.7 KiB
TypeScript
171 lines
5.7 KiB
TypeScript
/**
|
|
* @file WorkspaceTree.owners.test.tsx
|
|
* @description Server mode, the sidebar workspace list for an analyst: every
|
|
* user's workspaces, grouped under their owner (with their avatar, else an
|
|
* initial; yours first, no owner last),
|
|
* groups folding and remembered; another user's workspace is read only — no
|
|
* menu, no new session, and a session opens its analysis page, never a pane.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
|
import type { Lane, Session } from "../../lib/types";
|
|
|
|
const ME = { id: 2, username: "ana", role: "analyst" as const };
|
|
|
|
const lane = (id: number, owner: number | null, name: string | null, title: string) =>
|
|
({
|
|
id,
|
|
title,
|
|
cwd: `/w/${id}`,
|
|
kind: "adopted",
|
|
tool: "claude",
|
|
status: "idle",
|
|
liveness: "idle",
|
|
pipeline_nodes: [],
|
|
owner_id: owner,
|
|
owner_name: name,
|
|
}) as unknown as Lane;
|
|
|
|
const lanes = [
|
|
{
|
|
...lane(1, 3, "bob", "Bob app"),
|
|
owner_display_name: "Bob Nguyen",
|
|
owner_avatar_at: "t1",
|
|
} as Lane,
|
|
lane(2, null, null, "Old"),
|
|
lane(3, ME.id, "ana", "Mine"),
|
|
lane(4, 1, "admin", "Admin site"),
|
|
];
|
|
|
|
vi.mock("../AuthGate", () => ({
|
|
useAuth: () => ({ state: { mode: "server", user: ME } }),
|
|
usePermissions: () => ({
|
|
isAdmin: false,
|
|
seesAll: true,
|
|
canEditLane: (l?: { owner_id?: number | null } | null) => l?.owner_id === ME.id,
|
|
}),
|
|
}));
|
|
vi.mock("../../lib/api", async (importOriginal) => {
|
|
const actual = await importOriginal<Record<string, unknown>>();
|
|
return {
|
|
...actual,
|
|
api: {
|
|
lanes: { list: vi.fn(async () => ({ lanes, counts: {} })) },
|
|
sessions: {
|
|
list: vi.fn(async () => ({
|
|
sessions: [
|
|
{ id: "s-1", name: "Bob's run", status: "completed", started_at: "2026-10-01" },
|
|
] as Session[],
|
|
total: 1,
|
|
limit: 6,
|
|
offset: 0,
|
|
})),
|
|
},
|
|
run: { list: vi.fn(async () => ({ items: [] })) },
|
|
users: {
|
|
avatarUrl: (id: number, at?: string | null) =>
|
|
at ? `/api/users/${id}/avatar?v=${at}` : null,
|
|
},
|
|
me: { avatarUrl: () => null },
|
|
aiTools: { list: vi.fn(async () => ({ tools: [], default: "claude" })), iconUrl: () => "" },
|
|
},
|
|
};
|
|
});
|
|
|
|
import { WorkspaceTree } from "../WorkspaceTree";
|
|
|
|
function Where() {
|
|
const loc = useLocation();
|
|
return <span data-testid="where">{loc.pathname + loc.search}</span>;
|
|
}
|
|
|
|
function renderTree() {
|
|
return render(
|
|
<MemoryRouter>
|
|
<Routes>
|
|
<Route
|
|
path="*"
|
|
element={
|
|
<>
|
|
<WorkspaceTree />
|
|
<Where />
|
|
</>
|
|
}
|
|
/>
|
|
</Routes>
|
|
</MemoryRouter>
|
|
);
|
|
}
|
|
|
|
beforeEach(() => localStorage.clear());
|
|
|
|
describe("WorkspaceTree for an analyst", () => {
|
|
it("groups every workspace under its owner: yours first, no owner last", async () => {
|
|
renderTree();
|
|
await screen.findByTestId("workspace-row-1");
|
|
const order = screen
|
|
.getAllByTestId(/^workspace-(owner-\w+|row-\d+)$/)
|
|
.map((el) => el.dataset.testid);
|
|
expect(order).toEqual([
|
|
"workspace-owner-2",
|
|
"workspace-row-3",
|
|
"workspace-owner-1",
|
|
"workspace-row-4",
|
|
"workspace-owner-3",
|
|
"workspace-row-1",
|
|
"workspace-owner-none",
|
|
"workspace-row-2",
|
|
]);
|
|
expect(screen.getByTestId("workspace-owner-2")).toHaveTextContent("ana (you)");
|
|
});
|
|
|
|
it("names an owner by their display name when they set one", async () => {
|
|
renderTree();
|
|
await screen.findByTestId("workspace-row-1");
|
|
expect(screen.getByTestId("workspace-owner-3")).toHaveTextContent("Bob Nguyen");
|
|
expect(screen.getByTestId("workspace-owner-3")).not.toHaveTextContent("bob");
|
|
// Bob set a picture: shown beside the name; Ana did not: her initial.
|
|
expect(
|
|
within(screen.getByTestId("workspace-owner-3"))
|
|
.getByTestId("user-avatar-img")
|
|
.getAttribute("src")
|
|
).toBe("/api/users/3/avatar?v=t1");
|
|
expect(
|
|
within(screen.getByTestId("workspace-owner-2")).getByTestId("user-avatar-initials")
|
|
).toHaveTextContent("a");
|
|
expect(screen.getByTestId("workspace-owner-1")).toHaveTextContent("admin");
|
|
});
|
|
|
|
it("folds a group and remembers it", async () => {
|
|
const user = userEvent.setup();
|
|
renderTree();
|
|
await user.click(await screen.findByTestId("workspace-owner-3"));
|
|
expect(screen.queryByTestId("workspace-row-1")).toBeNull();
|
|
expect(screen.getByTestId("workspace-owner-3")).toHaveAttribute("aria-expanded", "false");
|
|
expect(JSON.parse(localStorage.getItem("agenthub.sidebar.projects")!).collapsedOwners).toEqual({
|
|
"3": true,
|
|
});
|
|
});
|
|
|
|
it("keeps another user's workspace read only", async () => {
|
|
const user = userEvent.setup();
|
|
renderTree();
|
|
await screen.findByTestId("workspace-row-1");
|
|
// Yours keeps its controls; theirs has none.
|
|
expect(screen.getByTestId("workspace-menu-3")).toBeInTheDocument();
|
|
expect(screen.queryByTestId("workspace-menu-1")).toBeNull();
|
|
expect(screen.queryByTestId("workspace-new-1")).toBeNull();
|
|
// Picking it unfolds it instead of opening a workspace home.
|
|
await user.click(screen.getByTestId("workspace-open-1"));
|
|
expect(screen.getByTestId("where")).toHaveTextContent(/^\/$/);
|
|
const list = await screen.findByTestId("workspace-sessions-1");
|
|
expect(within(list).queryByTestId("workspace-session-archive-s-1")).toBeNull();
|
|
await user.click(within(list).getByTestId("workspace-session-s-1"));
|
|
expect(screen.getByTestId("where")).toHaveTextContent("/sessions/s-1");
|
|
});
|
|
});
|