1import {
2 AuthOrchestrationOperateScope,
3 EnvironmentId,
4 ThreadId,
5 type ContextMenuItem,
6} from "@t3tools/contracts";
7import { AsyncResult } from "effect/reactivity";
8import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
9
10import type { ThreadActionMenuId } from "../components/threadActionMenu.logic";
11
12function deferred<T>() {
13 let resolve!: (value: T) => void;
14 const promise = new Promise<T>((complete) => {
15 resolve = complete;
16 });
17 return { promise, resolve };
18}
19
20const state = vi.hoisted(() => ({
21 granted: new Set<string>(),
22 effects: [] as string[],
23 completed: deferred<void>(),
24 show: vi.fn<
25 (
26 items: ReadonlyArray<ContextMenuItem<ThreadActionMenuId>>,
27 position: { x: number; y: number },
28 ) => Promise<ThreadActionMenuId | null>
29 >(),
30}));
31
32function recordEffect(action: string) {
33 state.effects.push(action);
34 state.completed.resolve();
35}
36
37vi.mock("../components/CustomSnoozeDialog", () => ({ requestCustomSnooze: vi.fn() }));
38vi.mock("react", () => ({
39 useCallback: (callback: unknown) => callback,
40 useMemo: (factory: () => unknown) => factory(),
41}));
42vi.mock("@tanstack/react-router", () => ({
43 useRouter: () => ({ navigate: async () => recordEffect("project-settings") }),
44}));
45vi.mock("../state/session", () => ({
46 readEnvironmentScope: (environmentId: string, scope: string) =>
47 scope === AuthOrchestrationOperateScope && state.granted.has(environmentId),
48}));
49vi.mock("../state/entities", () => ({
50 readEnvironmentServerConfigLoaded: () => true,
51 readEnvironmentSupportsAutoSettleOptOut: () => true,
52 readThreadShell: () => ({
53 id: "thread",
54 environmentId: "secondary",
55 projectId: "project",
56 title: "Thread",
57 branch: "main",
58 worktreePath: null,
59 runtime: null,
60 latestRun: null,
61 }),
62 useProjects: () => [{ id: "project", environmentId: "secondary" }],
63}));
64vi.mock("../state/environments", () => ({ usePrimaryEnvironmentId: () => "primary" }));
65vi.mock("../state/threads", () => ({ threadEnvironment: { updateMetadata: "metadata" } }));
66vi.mock("../state/use-atom-command", () => ({
67 useAtomCommand: () => async () => {
68 recordEffect("metadata");
69 return AsyncResult.success(undefined);
70 },
71}));
72vi.mock("../localApi", () => ({
73 readLocalApi: () => ({
74 contextMenu: { show: state.show, close: () => {} },
75 dialogs: {
76 confirm: async () => {
77 recordEffect("confirm");
78 return false;
79 },
80 },
81 }),
82}));
83vi.mock("@t3tools/client-runtime/state/project-grouping", () => ({
84 deriveLogicalProjectKeyFromSettings: () => "project",
85 derivePhysicalProjectKey: () => "project",
86 selectProjectGroupingSettings: () => ({}),
87}));
88vi.mock("../sidebarProjectGrouping", () => ({
89 buildPhysicalToLogicalProjectKeyMap: () => new Map(),
90}));
91vi.mock("../uiStateStore", () => ({
92 useUiStateStore: (select: (store: unknown) => unknown) =>
93 select({
94 markThreadUnread: () => recordEffect("mark-unread"),
95 }),
96}));
97vi.mock("../components/ui/toast", () => ({
98 stackedThreadToast: (toast: unknown) => toast,
99 toastManager: { add: () => state.completed.resolve() },
100}));
101vi.mock("../components/Sidebar.snooze", () => ({
102 resolveSnoozePresets: () => [
103 { id: "hour", label: "In 1 hour", whenLabel: "3 PM", snoozedUntil: "2099-01-01T00:00:00Z" },
104 ],
105 snoozeWakeDescription: () => "later",
106}));
107vi.mock("./useCopyToClipboard", () => ({
108 useCopyToClipboard: () => ({ copyToClipboard: () => recordEffect("copy") }),
109}));
110vi.mock("./useHandleNewThread", () => ({
111 useNewThreadHandler: () => async () => recordEffect("draft"),
112}));
113vi.mock("./useSettings", () => ({
114 useClientSettings: (select: (settings: unknown) => unknown) =>
115 select({
116 confirmThreadDelete: true,
117 confirmThreadArchive: true,
118 timestampFormat: "12-hour",
119 }),
120}));
121vi.mock("./useThreadActions", () => ({
122 canTurnThreadIntoProject: () => false,
123 useThreadActions: () =>
124 Object.fromEntries(
125 [
126 "markThreadUnread",
127 "settleThread",
128 "unsettleThread",
129 "snoozeThread",
130 "unsnoozeThread",
131 "pinThread",
132 "confirmAndUnpinThread",
133 "archiveThread",
134 "deleteThread",
135 ].map((action) => [
136 action,
137 async () => {
138 recordEffect(action === "markThreadUnread" ? "mark-unread" : action);
139 return AsyncResult.success(undefined);
140 },
141 ]),
142 ),
143}));
144
145import { useThreadActionMenu } from "./useThreadActionMenu";
146
147const target = {
148 environmentId: EnvironmentId.make("secondary"),
149 threadId: ThreadId.make("thread"),
150};
151const position = { x: 10, y: 20 };
152const createMenu = (options: { readonly onFind?: (threadRef: typeof target) => void } = {}) =>
153 useThreadActionMenu({
154 threadRef: target,
155 projectCwd: "/project",
156 onStartRename: () => recordEffect("rename"),
157 onFind: options.onFind,
158 canShareWithGuest: false,
159 });
160
161beforeEach(() => {
162 state.granted = new Set(["primary"]);
163 state.effects = [];
164 state.completed = deferred<void>();
165 state.show.mockReset().mockResolvedValue(null);
166});
167
168describe("thread menu permissions", () => {
169 it.each(["rename", "regenerate-title", "delete", "pin", "settle", "archive"] as const)(
170 "%s rechecks after the native menu closes",
171 async (action) => {
172 state.granted.add("secondary");
173 const choice = deferred<ThreadActionMenuId | null>();
174 state.show.mockReturnValue(choice.promise);
175 createMenu().openMenu(position);
176 state.granted.delete("secondary");
177 choice.resolve(action);
178 await state.completed.promise;
179 expect(state.effects).toEqual([]);
180 },
181 );
182
183 it("offers find only to a header that can open it, without task permission", async () => {
184 createMenu().openMenu(position);
185 expect(state.show.mock.calls[0]![0].some((item) => item.id === "find")).toBe(false);
186 state.show.mockResolvedValue("find");
187 const onFind = vi.fn(() => recordEffect("find"));
188 createMenu({ onFind }).openMenu(position);
189 await state.completed.promise;
190 const items = state.show.mock.calls[1]![0];
191 expect(items[0]).toMatchObject({ id: "find", label: "Find in thread" });
192 expect(items[0]?.disabled).not.toBe(true);
193 expect(state.effects).toEqual(["find"]);
194 expect(onFind).toHaveBeenCalledExactlyOnceWith(target);
195 });
196});