1// @vitest-environment jsdom
2
3import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
4import { act } from "react";
5import { createRoot } from "react-dom/client";
6import { expect, it, vi } from "vite-plus/test";
7
8const state = vi.hoisted(() => ({
9 worktreePath: "/tmp/worktree" as string | null,
10 showContextMenu: vi.fn().mockResolvedValue("copy-path"),
11 writeTextToClipboard: vi.fn().mockResolvedValue(true),
12}));
13
14vi.mock("../localApi", () => ({
15 readLocalApi: () => ({ contextMenu: { show: state.showContextMenu } }),
16}));
17vi.mock("../hooks/useCopyToClipboard", () => ({
18 writeTextToClipboard: state.writeTextToClipboard,
19}));
20vi.mock("./BranchToolbarBranchSelector", () => ({ BranchToolbarBranchSelector: () => null }));
21vi.mock("../composerDraftStore", () => ({
22 useComposerDraftStore: (select: (store: unknown) => unknown) =>
23 select({ getDraftThreadByRef: () => null, setDraftThreadContext: vi.fn() }),
24}));
25vi.mock("../state/entities", () => ({
26 useThreadShell: () => ({
27 environmentId: "local",
28 projectId: "project",
29 worktreePath: state.worktreePath,
30 }),
31 useProject: () => ({ workspaceRoot: "/tmp/project" }),
32 useThreadShellsForProjectRefs: () => [],
33}));
34
35import { BranchToolbar } from "./BranchToolbar";
36
37it("keeps machine choices usable when the combined row's workspace is locked", async () => {
38 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
39 const container = document.createElement("div");
40 document.body.append(container);
41 const root = createRoot(container);
42 const onEnvironmentChange = vi.fn();
43 try {
44 await act(async () => {
45 root.render(
46 <BranchToolbar
47 layout="panel"
48 panelSection="workspace"
49 environmentId={EnvironmentId.make("local")}
50 threadId={ThreadId.make("thread")}
51 showGitControls
52 envMode="local"
53 envLocked={false}
54 startFromOrigin={false}
55 onStartFromOriginChange={vi.fn()}
56 onEnvModeChange={vi.fn()}
57 onEnvironmentChange={onEnvironmentChange}
58 availableEnvironments={["local", "remote"].map((id) => ({
59 environmentId: EnvironmentId.make(id),
60 projectId: ProjectId.make("project"),
61 label: id,
62 isPrimary: id === "local",
63 machine: "server",
64 }))}
65 />,
66 );
67 });
68 const trigger = container.querySelector("button")!;
69 expect(trigger.textContent).toBe("local");
70 expect(container.querySelectorAll("button")).toHaveLength(1);
71 await act(async () => {
72 trigger.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, ctrlKey: true }));
73 });
74 expect(document.querySelector('[role="menu"]')).toBeNull();
75 await act(async () => trigger.click());
76 const items = [...document.querySelectorAll<HTMLElement>('[role="menuitemradio"]')];
77 expect(
78 items.find((item) => item.textContent === "New worktree")?.getAttribute("aria-disabled"),
79 ).toBe("true");
80 await act(async () => items.find((item) => item.textContent === "remote")!.click());
81 expect(onEnvironmentChange).toHaveBeenCalledWith("remote");
82 } finally {
83 await act(async () => root.unmount());
84 container.remove();
85 vi.unstubAllGlobals();
86 }
87});
88
89it.each([
90 ["local", null, "/tmp/project"],
91 ["worktree", null, null],
92 ["worktree", "/tmp/worktree", "/tmp/worktree"],
93] as const)(
94 "copies only an existing workspace path with mode %s and worktree %s",
95 async (envMode, worktreePath, copiedPath) => {
96 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
97 state.worktreePath = worktreePath;
98 state.showContextMenu.mockClear();
99 state.writeTextToClipboard.mockClear();
100 const container = document.createElement("div");
101 document.body.append(container);
102 const root = createRoot(container);
103 try {
104 await act(async () => {
105 root.render(
106 <BranchToolbar
107 layout="panel"
108 panelSection="workspace"
109 environmentId={EnvironmentId.make("local")}
110 threadId={ThreadId.make("thread")}
111 showGitControls
112 envMode={envMode}
113 envLocked={false}
114 startFromOrigin={false}
115 onStartFromOriginChange={vi.fn()}
116 onEnvModeChange={vi.fn()}
117 />,
118 );
119 });
120 const event = new MouseEvent("contextmenu", { bubbles: true, cancelable: true });
121 await act(async () => {
122 container.querySelector('[aria-label="Run context"]')!.dispatchEvent(event);
123 });
124 expect(event.defaultPrevented).toBe(copiedPath !== null);
125 if (copiedPath === null) {
126 expect(state.showContextMenu).not.toHaveBeenCalled();
127 expect(state.writeTextToClipboard).not.toHaveBeenCalled();
128 } else {
129 expect(state.writeTextToClipboard).toHaveBeenCalledWith(copiedPath, "workspace path");
130 }
131 } finally {
132 await act(async () => root.unmount());
133 container.remove();
134 state.worktreePath = "/tmp/worktree";
135 vi.unstubAllGlobals();
136 }
137 },
138);