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