0c2f49876d
- Users page: one Edit user form (display name, email, role, active, a new or generated password, workspaces given or taken back, sign out everywhere), validated as a whole before anything is written (PATCH /api/users/:id, GET /api/users/:id); users.display_name/email added migration-safe. - Settings → Account for every signed-in user: profile, own password (the current one required, this login kept), sign out other devices (/api/me/account, /api/me/password, /api/me/sign-out-others; login cookie only, never a pane token). - Display names wherever a person is named (sidebar, workspace owner groups, Users page and audit log); owner_display_name / reader_display additive. - Users page: admins add and remove Claude/Codex pool accounts. - Folder browser: an admin with their own Linux account opens in their home; the "Server folder" tick opens the host's (never remembered). - AI Proxy: "Config 9router" beside Add profile when 9router is installed, opened on the dashboard's own host (GET /api/ai-proxies/9router). - Terminal: a tmux selection reaches the clipboard over plain HTTP too (writeClipboard's textarea fallback, focus handed back). - Chat: a file the session touched outside the workspace previews (GET /api/sessions/:id/file). - install-system: `!requiretty` only when visudo accepts it (sudo-rs on Ubuntu 25.10+ rejects the setting). - openapi.yaml regenerated; docs (API, SERVER-MODE, LANES) updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
79 lines
3.5 KiB
TypeScript
79 lines
3.5 KiB
TypeScript
/**
|
|
* @file PathPickButton.test.tsx
|
|
* @description Unit tests for the Browse button beside path fields: a chosen
|
|
* path fills the field, a Cancel changes nothing, and a folder or a file falls
|
|
* back to the in-app browser on ANY native-picker failure (a LAN browser's
|
|
* EREMOTE included), where a double-click on a file picks it.
|
|
* @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 { api } from "../../lib/api";
|
|
import { toast } from "../../lib/toast";
|
|
import { PathPickButton } from "../PathPickButton";
|
|
|
|
vi.mock("../../lib/api", () => ({
|
|
api: { lanes: { pickPath: vi.fn(), browse: vi.fn(), list: vi.fn(() => new Promise(() => {})) } },
|
|
}));
|
|
vi.mock("../../lib/toast", () => ({
|
|
toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() },
|
|
}));
|
|
|
|
const fail = (code: string) => Object.assign(new Error(code), { code });
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(api.lanes.pickPath).mockReset();
|
|
vi.mocked(api.lanes.browse)
|
|
.mockReset()
|
|
.mockResolvedValue({ path: "/tmp", parent: "/", entries: [] });
|
|
vi.mocked(toast.info).mockReset();
|
|
vi.mocked(toast.error).mockReset();
|
|
});
|
|
|
|
describe("PathPickButton", () => {
|
|
it("fills the field with the chosen path and leaves it alone on Cancel", async () => {
|
|
const onPick = vi.fn();
|
|
vi.mocked(api.lanes.pickPath).mockResolvedValueOnce({ path: "/home/u/proj" });
|
|
vi.mocked(api.lanes.pickPath).mockResolvedValueOnce({ cancelled: true });
|
|
render(<PathPickButton start=" /home/u " onPick={onPick} />);
|
|
const button = screen.getByTitle("Browse for a folder");
|
|
await userEvent.click(button);
|
|
await waitFor(() => expect(onPick).toHaveBeenCalledWith("/home/u/proj"));
|
|
expect(api.lanes.pickPath).toHaveBeenCalledWith("dir", "/home/u");
|
|
await userEvent.click(button);
|
|
await waitFor(() => expect(api.lanes.pickPath).toHaveBeenCalledTimes(2));
|
|
expect(onPick).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("opens the in-app folder browser on any failure, not only a missing picker", async () => {
|
|
vi.mocked(api.lanes.pickPath).mockRejectedValue(fail("EBUSY"));
|
|
render(<PathPickButton onPick={vi.fn()} />);
|
|
await userEvent.click(screen.getByTitle("Browse for a folder"));
|
|
expect(await screen.findByRole("dialog", { name: "Browse for a folder" })).toBeInTheDocument();
|
|
expect(toast.error).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("opens the in-app browser for a file too, and a double-click on a file picks it", async () => {
|
|
const onPick = vi.fn();
|
|
vi.mocked(api.lanes.pickPath).mockRejectedValue(fail("EREMOTE"));
|
|
vi.mocked(api.lanes.browse).mockResolvedValue({
|
|
path: "/home/u/.ssh",
|
|
parent: "/home/u",
|
|
entries: [
|
|
{ name: "id_ed25519", path: "/home/u/.ssh/id_ed25519", isGitRepo: false, file: true },
|
|
],
|
|
});
|
|
render(<PathPickButton kind="file" start="~/.ssh" onPick={onPick} />);
|
|
await userEvent.click(screen.getByRole("button"));
|
|
const dialog = await screen.findByRole("dialog", { name: "Browse for a file" });
|
|
expect(api.lanes.browse).toHaveBeenCalledWith("~/.ssh", true, false, false);
|
|
expect(within(dialog).queryByText("Select this folder")).toBeNull();
|
|
await userEvent.dblClick(await within(dialog).findByText("id_ed25519"));
|
|
expect(onPick).toHaveBeenCalledWith("/home/u/.ssh/id_ed25519");
|
|
expect(screen.queryByRole("dialog")).toBeNull();
|
|
expect(toast.info).not.toHaveBeenCalled();
|
|
});
|
|
});
|