1import {
2 AuthOrchestrationOperateScope,
3 AuthSettingsWriteScope,
4 EnvironmentId,
5 type AuthEnvironmentScope,
6} from "@t3tools/contracts";
7import * as Cause from "effect/Cause";
8import { AsyncResult } from "effect/reactivity";
9import { act, create, type ReactTestRenderer } from "react-test-renderer";
10import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
11
12const permissions = vi.hoisted(() => ({ canWriteSettings: false, canEditProject: true }));
13
14vi.mock("~/state/session", () => ({
15 readEnvironmentScope: (_id: EnvironmentId, scope: AuthEnvironmentScope) =>
16 scope === AuthOrchestrationOperateScope
17 ? permissions.canEditProject
18 : permissions.canWriteSettings,
19 useEnvironmentScope: (_id: EnvironmentId, scope: AuthEnvironmentScope) =>
20 scope === AuthOrchestrationOperateScope
21 ? permissions.canEditProject
22 : permissions.canWriteSettings,
23}));
24vi.mock("./ui/dialog", () => ({
25 Dialog: "div",
26 DialogDescription: "div",
27 DialogFooter: "div",
28 DialogHeader: "div",
29 DialogPanel: "div",
30 DialogPopup: "div",
31 DialogTitle: "div",
32}));
33vi.mock("./ui/alert-dialog", () => ({
34 AlertDialog: "div",
35 AlertDialogClose: "button",
36 AlertDialogDescription: "div",
37 AlertDialogFooter: "div",
38 AlertDialogHeader: "div",
39 AlertDialogPopup: "div",
40 AlertDialogTitle: "div",
41}));
42vi.mock("./ui/popover", () => ({
43 Popover: "div",
44 PopoverPopup: "div",
45 PopoverTrigger: "button",
46}));
47vi.mock("./ui/button", () => ({ Button: "button" }));
48vi.mock("./ui/input", () => ({ Input: "input" }));
49vi.mock("./ui/label", () => ({ Label: "label" }));
50vi.mock("./ui/switch", () => ({ Switch: "input" }));
51vi.mock("./ui/textarea", () => ({ Textarea: "textarea" }));
52
53import {
54 ProjectScriptEditorDialog,
55 type NewProjectScriptInput,
56 type ProjectScriptEditorRequest,
57} from "./projectScriptEditor";
58
59const request: ProjectScriptEditorRequest = {
60 scriptId: "test",
61 initial: {
62 name: "Test",
63 command: "vp test",
64 icon: "test",
65 runOnWorktreeCreate: false,
66 waitForSetup: false,
67 runOnSettle: false,
68 keybinding: "mod+k",
69 previewUrl: null,
70 autoOpenPreview: false,
71 },
72};
73
74describe("ProjectScriptEditorDialog", () => {
75 let renderer: ReactTestRenderer | undefined;
76
77 beforeEach(() => {
78 permissions.canWriteSettings = false;
79 permissions.canEditProject = true;
80 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
81 });
82
83 afterEach(async () => {
84 await act(() => renderer?.unmount());
85 vi.unstubAllGlobals();
86 });
87
88 async function openEditor(
89 onSubmit = vi.fn().mockResolvedValue(AsyncResult.success(undefined)),
90 editorRequest = request,
91 actions = { onDelete: vi.fn(), onClose: vi.fn() },
92 editScope:
93 | typeof AuthOrchestrationOperateScope
94 | typeof AuthSettingsWriteScope = AuthOrchestrationOperateScope,
95 ) {
96 await act(() => {
97 renderer = create(
98 <ProjectScriptEditorDialog
99 environmentId={EnvironmentId.make("script-editor-test")}
100 editScope={editScope}
101 request={editorRequest}
102 scripts={[]}
103 onSubmit={onSubmit}
104 onDelete={actions.onDelete}
105 onClose={actions.onClose}
106 />,
107 );
108 });
109 return renderer!.root;
110 }
111
112 it.each([null, "test"])(
113 "saves a settings action without task access (id: %s)",
114 async (scriptId) => {
115 permissions.canEditProject = false;
116 permissions.canWriteSettings = true;
117 const onSubmit = vi.fn().mockResolvedValue(AsyncResult.success(undefined));
118 const root = await openEditor(
119 onSubmit,
120 { ...request, scriptId },
121 undefined,
122 AuthSettingsWriteScope,
123 );
124 const submit = root.findByType("form").props.onSubmit;
125 permissions.canWriteSettings = false;
126 await act(async () => {
127 await submit({ preventDefault() {} });
128 });
129 expect(onSubmit).not.toHaveBeenCalled();
130 permissions.canWriteSettings = true;
131 await act(async () => {
132 await submit({ preventDefault() {} });
133 });
134 expect(onSubmit).toHaveBeenCalledExactlyOnceWith(
135 scriptId,
136 expect.objectContaining({ command: "vp test" }),
137 );
138 },
139 );
140
141 it("rechecks settings access before deleting a settings action", async () => {
142 permissions.canEditProject = false;
143 permissions.canWriteSettings = true;
144 const actions = { onDelete: vi.fn(), onClose: vi.fn() };
145 const root = await openEditor(undefined, request, actions, AuthSettingsWriteScope);
146 await act(() => {
147 root.findByProps({ variant: "destructive-outline" }).props.onClick();
148 });
149 const confirmDelete = root.findByProps({ variant: "destructive" }).props.onClick;
150 permissions.canWriteSettings = false;
151 await act(() => confirmDelete());
152 expect(actions.onDelete).not.toHaveBeenCalled();
153 permissions.canWriteSettings = true;
154 await act(() => confirmDelete());
155 expect(actions.onDelete).toHaveBeenCalledExactlyOnceWith(request.scriptId);
156 });
157
158 it("clears an old shortcut when adding an action with no shortcut", async () => {
159 permissions.canWriteSettings = true;
160 let persistedKeybinding: string | null = "mod+k";
161 const onSubmit = vi.fn(async (_id: string | null, input: NewProjectScriptInput) => {
162 if (input.keybinding !== undefined) persistedKeybinding = input.keybinding;
163 return AsyncResult.success(undefined);
164 });
165 const root = await openEditor(onSubmit, {
166 scriptId: null,
167 initial: { ...request.initial, keybinding: null },
168 });
169
170 await act(async () => {
171 await root.findByType("form").props.onSubmit({ preventDefault() {} });
172 });
173
174 expect(persistedKeybinding).toBeNull();
175 expect(onSubmit).toHaveBeenCalledWith(null, expect.objectContaining({ keybinding: null }));
176 });
177
178 it.each([false, true])(
179 "re-adds a deleted action while preserving its shortcut without settings access (revoked: %s)",
180 async (revokeBeforeSubmit) => {
181 permissions.canWriteSettings = revokeBeforeSubmit;
182 const persisted = { name: null as string | null, keybinding: "mod+k" as string | null };
183 const onSubmit = vi.fn(async (_id: string | null, input: NewProjectScriptInput) => {
184 if (input.keybinding !== undefined && !permissions.canWriteSettings) {
185 return AsyncResult.failure(
186 Cause.fail(new Error("This connection cannot change keyboard shortcuts.")),
187 );
188 }
189 persisted.name = input.name;
190 if (input.keybinding !== undefined) persisted.keybinding = input.keybinding;
191 return AsyncResult.success(undefined);
192 });
193 const root = await openEditor(onSubmit, {
194 scriptId: null,
195 initial: { ...request.initial, keybinding: null },
196 });
197
198 permissions.canWriteSettings = false;
199 await act(async () => {
200 await root.findByType("form").props.onSubmit({ preventDefault() {} });
201 });
202
203 expect(persisted).toEqual({ name: "Test", keybinding: "mod+k" });
204 expect(onSubmit).toHaveBeenCalledOnce();
205 },
206 );
207
208 it.each([false, true])(
209 "requires settings access to override a retained shortcut when re-adding an action (revoked: %s)",
210 async (revokeBeforeSubmit) => {
211 permissions.canWriteSettings = true;
212 vi.stubGlobal("navigator", { platform: "Linux" });
213 const persisted = { name: null as string | null, keybinding: "mod+k" as string | null };
214 const onSubmit = vi.fn(async (_id: string | null, input: NewProjectScriptInput) => {
215 persisted.name = input.name;
216 if (input.keybinding !== undefined) persisted.keybinding = input.keybinding;
217 return AsyncResult.success(undefined);
218 });
219 const root = await openEditor(onSubmit, {
220 scriptId: null,
221 initial: { ...request.initial, keybinding: null },
222 });
223 await act(() => {
224 root.findByProps({ id: "script-keybinding" }).props.onKeyDown({
225 key: "j",
226 ctrlKey: true,
227 metaKey: false,
228 altKey: false,
229 shiftKey: false,
230 preventDefault() {},
231 });
232 });
233 if (revokeBeforeSubmit) permissions.canWriteSettings = false;
234 await act(async () => {
235 await root.findByType("form").props.onSubmit({ preventDefault() {} });
236 });
237
238 expect(persisted).toEqual(
239 revokeBeforeSubmit
240 ? { name: null, keybinding: "mod+k" }
241 : { name: "Test", keybinding: "mod+j" },
242 );
243 },
244 );
245
246 it.each([false, true])(
247 "preserves a concurrent shortcut change when saving only the script (settings access: %s)",
248 async (canWriteSettings) => {
249 permissions.canWriteSettings = canWriteSettings;
250 const persisted = { name: request.initial.name, keybinding: request.initial.keybinding };
251 const onSubmit = vi.fn(async (_id: string | null, input: NewProjectScriptInput) => {
252 persisted.name = input.name;
253 if (input.keybinding !== undefined) persisted.keybinding = input.keybinding;
254 return AsyncResult.success(undefined);
255 });
256 const root = await openEditor(onSubmit);
257
258 // A second client changes the shortcut after this dialog snapshots it.
259 persisted.keybinding = "mod+j";
260 await act(() => {
261 root.findByProps({ id: "script-name" }).props.onChange({ target: { value: "Renamed" } });
262 });
263 await act(async () => {
264 await root.findByType("form").props.onSubmit({ preventDefault() {} });
265 });
266
267 expect(persisted).toEqual({ name: "Renamed", keybinding: "mod+j" });
268 expect(onSubmit).toHaveBeenCalledOnce();
269 },
270 );
271
272 it.each([false, true])(
273 "enforces settings access when submitting an explicit shortcut clear (revoked: %s)",
274 async (revokeBeforeSubmit) => {
275 permissions.canWriteSettings = true;
276 const onSubmit = vi.fn().mockResolvedValue(AsyncResult.success(undefined));
277 const root = await openEditor(onSubmit);
278 await act(() => {
279 root.findByProps({ id: "script-keybinding" }).props.onKeyDown({
280 key: "Backspace",
281 preventDefault() {},
282 });
283 });
284 if (revokeBeforeSubmit) permissions.canWriteSettings = false;
285 await act(async () => {
286 await root.findByType("form").props.onSubmit({ preventDefault() {} });
287 });
288
289 if (revokeBeforeSubmit) {
290 expect(onSubmit).not.toHaveBeenCalled();
291 } else {
292 expect(onSubmit).toHaveBeenCalledWith(
293 "test",
294 expect.objectContaining({ keybinding: null }),
295 );
296 }
297 },
298 );
299
300 it.each([false, true])(
301 "requires project access to save even with settings access (revoked: %s)",
302 async (revokeBeforeSubmit) => {
303 permissions.canEditProject = revokeBeforeSubmit;
304 permissions.canWriteSettings = true;
305 const onSubmit = vi.fn().mockResolvedValue(AsyncResult.success(undefined));
306 const root = await openEditor(onSubmit);
307 const submit = root.findByType("form").props.onSubmit;
308
309 permissions.canEditProject = false;
310 await act(async () => {
311 await submit({ preventDefault() {} });
312 });
313 expect(onSubmit).not.toHaveBeenCalled();
314
315 permissions.canEditProject = true;
316 await act(async () => {
317 await submit({ preventDefault() {} });
318 });
319 expect(onSubmit).toHaveBeenCalledOnce();
320 },
321 );
322
323 it("rechecks project access before deleting from an already open confirmation", async () => {
324 const actions = { onDelete: vi.fn(), onClose: vi.fn() };
325 const root = await openEditor(undefined, request, actions);
326 await act(() => {
327 root.findByProps({ variant: "destructive-outline" }).props.onClick();
328 });
329 const confirmDelete = root.findByProps({ variant: "destructive" }).props.onClick;
330
331 permissions.canEditProject = false;
332 await act(() => confirmDelete());
333 expect(actions.onDelete).not.toHaveBeenCalled();
334 expect(actions.onClose).not.toHaveBeenCalled();
335
336 permissions.canEditProject = true;
337 await act(() => confirmDelete());
338 expect(actions.onDelete).toHaveBeenCalledExactlyOnceWith(request.scriptId);
339 expect(actions.onClose).toHaveBeenCalledOnce();
340 });
341});