1import { RegistryContext } from "@effect/atom-react";
2import type {
3 DeleteProjectInput,
4 UpdateProjectInput,
5} from "@t3tools/client-runtime/state/projects";
6import {
7 AuthOrchestrationOperateScope,
8 AuthSettingsWriteScope,
9 DEFAULT_SERVER_SETTINGS,
10 EnvironmentId,
11 ProjectId,
12 ProviderDriverKind,
13 ProviderInstanceId,
14 type AuthEnvironmentScope,
15 type AuthSessionState,
16 type ResolvedKeybindingsConfig,
17 type ServerProvider,
18} from "@t3tools/contracts";
19import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
20import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
21import * as Cause from "effect/Cause";
22import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity";
23import { cloneElement, type ReactElement, type ReactNode } from "react";
24import { act, create, type ReactTestRenderer } from "react-test-renderer";
25import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
26
27import type { Project } from "../../types";
28
29type SessionResult = AsyncResult.AsyncResult<AuthSessionState, Error>;
30const state = vi.hoisted(() => ({
31 registry: null as AtomRegistry.AtomRegistry | null,
32 sessions: new Map<EnvironmentId, Atom.Writable<SessionResult>>(),
33 providers: null as Atom.Atom<ReadonlyArray<ServerProvider>> | null,
34 config: null as Atom.Atom<{ keybindings: ResolvedKeybindingsConfig }> | null,
35 projects: [] as Project[],
36 update:
37 vi.fn<
38 (request: {
39 environmentId: EnvironmentId;
40 input: UpdateProjectInput;
41 }) => Promise<AsyncResult.Success<void>>
42 >(),
43 delete:
44 vi.fn<
45 (request: {
46 environmentId: EnvironmentId;
47 input: DeleteProjectInput;
48 }) => Promise<AsyncResult.Success<void>>
49 >(),
50 confirm: vi.fn<() => Promise<boolean>>(),
51 updateSettings: vi.fn(),
52 upsertKeybinding: vi.fn(),
53 removeKeybinding: vi.fn(),
54 updateClientSettings: vi.fn(),
55 releaseUploads: vi.fn(),
56 clearDraft: vi.fn(),
57 copy: vi.fn(),
58 navigate: vi.fn(),
59}));
60
61vi.mock("~/connection/runtime", () => ({ connectionAtomRuntime: undefined }));
62vi.mock("@t3tools/client-runtime/state/session", () => ({
63 createEnvironmentSessionAtoms: () => ({
64 sessionStateAtom: (id: EnvironmentId) => state.sessions.get(id)!,
65 }),
66}));
67vi.mock("~/rpc/atomRegistry", () => ({
68 get appAtomRegistry() {
69 return state.registry;
70 },
71}));
72vi.mock("~/state/environments", () => ({
73 useEnvironments: () => ({ environments }),
74 usePrimaryEnvironmentId: () => primaryId,
75}));
76vi.mock("~/state/entities", () => ({
77 useProjects: () => state.projects,
78 useThreadShells: () => [],
79 waitForProjectRemoval: async () => true,
80}));
81vi.mock("~/state/projects", () => ({
82 projectEnvironment: { update: "update", delete: "delete" },
83}));
84vi.mock("~/state/server", () => ({
85 EMPTY_SERVER_PROVIDERS: [],
86 serverEnvironment: {
87 providersValueAtom: () => state.providers!,
88 configValueAtom: () => state.config!,
89 updateSettings: "updateSettings",
90 upsertKeybinding: "upsertKeybinding",
91 removeKeybinding: "removeKeybinding",
92 },
93}));
94vi.mock("~/state/use-atom-command", () => ({
95 useAtomCommand: (
96 command: "update" | "delete" | "updateSettings" | "upsertKeybinding" | "removeKeybinding",
97 ) => state[command],
98}));
99vi.mock("~/hooks/useSettings", () => ({
100 useClientSettings: () => DEFAULT_CLIENT_SETTINGS,
101 useUpdateClientSettings: () => state.updateClientSettings,
102 usePrimarySettings: () => DEFAULT_SERVER_SETTINGS,
103 useEnvironmentSettings: () => DEFAULT_SERVER_SETTINGS,
104}));
105vi.mock("~/hooks/useT3ProjectFileScripts", () => ({
106 useT3ProjectFileState: () => ({
107 status: "valid",
108 file: null,
109 scripts: [{ name: "Imported", command: "vp test" }],
110 }),
111}));
112vi.mock("~/hooks/useCopyToClipboard", () => ({
113 useCopyToClipboard: () => ({ copyToClipboard: state.copy }),
114}));
115vi.mock("~/localApi", () => ({ readLocalApi: () => ({ dialogs: { confirm: state.confirm } }) }));
116vi.mock("~/composerDraftStore", () => ({
117 useComposerDraftStore: {
118 getState: () => ({
119 getDraftThreadByProjectRef: () => null,
120 clearProjectDraftThreadId: state.clearDraft,
121 }),
122 },
123}));
124vi.mock("~/lib/composerDraftUploads", () => ({ releaseProjectDraftUploads: state.releaseUploads }));
125vi.mock("~/env", () => ({ isElectron: true }));
126vi.mock("@tanstack/react-router", () => ({
127 useNavigate: () => state.navigate,
128 useLocation: () => "/settings/projects",
129}));
130vi.mock("../ui/toast", () => ({
131 toastManager: { add: vi.fn() },
132 stackedThreadToast: (value: unknown) => value,
133}));
134vi.mock("../ui/button", () => ({ Button: "button" }));
135vi.mock("../ui/input", () => ({ Input: "input" }));
136vi.mock("../ui/switch", () => ({ Switch: "input" }));
137vi.mock("../ui/select", () => ({
138 Select: "select",
139 SelectItem: "option",
140 SelectPopup: "div",
141 SelectTrigger: "button",
142 SelectValue: "span",
143}));
144vi.mock("../ui/menu", () => ({
145 Menu: "div",
146 MenuGroup: "div",
147 MenuGroupLabel: "div",
148 MenuItem: "button",
149 MenuPopup: "div",
150 MenuSeparator: "hr",
151 MenuTrigger: ({ render, children }: { render: ReactElement; children: ReactNode }) =>
152 cloneElement(render, {}, children),
153}));
154vi.mock("../ui/tooltip", () => ({
155 Tooltip: "div",
156 TooltipPopup: "span",
157 TooltipTrigger: ({ render }: { render: ReactNode }) => render,
158}));
159vi.mock("./settingsLayout", () => ({
160 SETTINGS_PICKER_TRIGGER_CLASSNAME: "",
161 SettingsPageContainer: "div",
162 SettingsSection: ({
163 children,
164 headerAction,
165 }: {
166 children: ReactNode;
167 headerAction?: ReactNode;
168 }) => (
169 <section>
170 {headerAction}
171 {children}
172 </section>
173 ),
174 SettingsRow: ({ control, resetAction }: { control?: ReactNode; resetAction?: ReactNode }) => (
175 <div>
176 {resetAction}
177 {control}
178 </div>
179 ),
180 SettingResetButton: ({
181 label,
182 ...props
183 }: {
184 label: string;
185 disabled?: boolean;
186 onClick: () => void;
187 }) => <button {...props} aria-label={`Reset ${label} to default`} />,
188}));
189vi.mock("./ProjectDefaultsSettings", () => ({ ProjectDefaultsSettings: () => null }));
190vi.mock("./ProjectActionsSettings", () => ({ ProjectActionsSettings: () => null }));
191vi.mock("../ProjectFavicon", () => ({ ProjectFavicon: () => null }));
192vi.mock("../chat/ProviderModelPicker", () => ({ ProviderModelPicker: "model-picker" }));
193vi.mock("../chat/TraitsPicker", () => ({ TraitsPicker: "traits-picker" }));
194vi.mock("./ProjectFaviconPickerDialog", () => ({
195 canPickExternalProjectFavicon: () => false,
196 ProjectFaviconPickerDialog: "favicon-picker",
197}));
198vi.mock("./ProjectIconPickerDialog", () => ({ ProjectIconPickerDialog: "icon-picker" }));
199vi.mock("../projectScriptEditor", async (importOriginal) => ({
200 ...(await importOriginal<typeof import("../projectScriptEditor")>()),
201 ProjectScriptEditorDialog: "script-editor",
202}));
203
204import { buildSidebarProjectSnapshots } from "../../sidebarProjectGrouping";
205import { ProjectFaviconPickerDialog } from "./ProjectFaviconPickerDialog";
206import { ProjectSettingsPanel } from "./ProjectSettingsPanel";
207
208const primaryId = EnvironmentId.make("primary");
209const remoteId = EnvironmentId.make("remote");
210const instanceId = ProviderInstanceId.make("codex");
211const environments = [primaryId, remoteId].map((environmentId) => ({
212 environmentId,
213 label: environmentId,
214 connection: { phase: "connected" },
215 serverConfig: { keybindings: DEFAULT_RESOLVED_KEYBINDINGS },
216}));
217const session = (scopes: ReadonlyArray<AuthEnvironmentScope>): AuthSessionState => ({
218 authenticated: true,
219 scopes,
220 auth: {
221 policy: "remote-reachable",
222 bootstrapMethods: ["one-time-token"],
223 sessionMethods: ["bearer-access-token"],
224 sessionCookieName: "t3_session",
225 },
226});
227const writable = () => AsyncResult.success(session([AuthOrchestrationOperateScope]));
228
229function deferred<T>() {
230 let resolve!: (value: T) => void;
231 const promise = new Promise<T>((complete) => {
232 resolve = complete;
233 });
234 return { promise, resolve };
235}
236
237let renderer: ReactTestRenderer | undefined;
238
239beforeEach(() => {
240 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
241 vi.clearAllMocks();
242 state.registry = AtomRegistry.make();
243 state.sessions.clear();
244 for (const id of [primaryId, remoteId])
245 state.sessions.set(id, Atom.make<SessionResult>(writable()).pipe(Atom.keepAlive));
246 state.projects = [primaryId, remoteId].map((environmentId) => ({
247 id: ProjectId.make(`project-${environmentId}`),
248 environmentId,
249 title: "Shared repo",
250 workspaceRoot: `/work/${environmentId}`,
251 repositoryIdentity: {
252 canonicalKey: "github.com/example/shared-repo",
253 locator: {
254 source: "git-remote",
255 remoteName: "origin",
256 remoteUrl: "https://github.com/example/shared-repo.git",
257 },
258 },
259 defaultModelSelection: { instanceId, model: "gpt-5-codex" },
260 createdAt: "2026-01-01T00:00:00.000Z",
261 updatedAt: "2026-01-01T00:00:00.000Z",
262 scripts: [],
263 }));
264 state.providers = Atom.make<ReadonlyArray<ServerProvider>>([
265 {
266 instanceId,
267 driver: ProviderDriverKind.make("codex"),
268 enabled: true,
269 installed: true,
270 version: null,
271 status: "ready",
272 auth: { status: "authenticated" },
273 checkedAt: "2026-01-01T00:00:00.000Z",
274 models: [],
275 slashCommands: [],
276 skills: [],
277 },
278 ]);
279 state.config = Atom.make({ keybindings: DEFAULT_RESOLVED_KEYBINDINGS });
280 state.update.mockImplementation(async ({ environmentId, input }) => {
281 const { projectId, ...updates } = input;
282 const patch = Object.fromEntries(
283 Object.entries(updates).filter(([, value]) => value !== undefined),
284 ) as Partial<Project>;
285 state.projects = state.projects.map((project) =>
286 project.environmentId === environmentId && project.id === projectId
287 ? { ...project, ...patch }
288 : project,
289 );
290 return AsyncResult.success(undefined);
291 });
292 state.delete.mockResolvedValue(AsyncResult.success(undefined));
293 state.updateSettings.mockResolvedValue(AsyncResult.success(undefined));
294 state.confirm.mockResolvedValue(true);
295});
296
297afterEach(async () => {
298 await act(() => renderer?.unmount());
299 renderer = undefined;
300 state.registry?.dispose();
301 vi.unstubAllGlobals();
302});
303
304function group() {
305 return buildSidebarProjectSnapshots({
306 projects: state.projects,
307 settings: DEFAULT_CLIENT_SETTINGS,
308 primaryEnvironmentId: primaryId,
309 resolveEnvironmentLabel: (id) => id,
310 })[0]!;
311}
312
313async function mountPanel() {
314 await act(() => {
315 renderer = create(
316 <RegistryContext.Provider value={state.registry!}>
317 <ProjectSettingsPanel projectKey={group().projectKey} />
318 </RegistryContext.Provider>,
319 );
320 });
321 return renderer!.root;
322}
323
324function button(label: string) {
325 return renderer!.root.findAllByType("button").find((node) => node.children.includes(label))!;
326}
327
328async function grant(id: EnvironmentId, result: SessionResult) {
329 await act(() => state.registry!.set(state.sessions.get(id)!, result));
330}
331
332describe("project settings permissions", () => {
333 it("preflights every member before any shared field update", async () => {
334 await grant(remoteId, AsyncResult.success(session([AuthSettingsWriteScope])));
335 const root = await mountPanel();
336 const name = root.findByProps({ "aria-label": "Project name" });
337
338 await act(async () => {
339 name.props.onChange();
340 name.props.onBlur({ currentTarget: { value: "Renamed" } });
341 root.findByType(ProjectFaviconPickerDialog).props.onSelect("/work/icon.png");
342 });
343
344 expect(state.update).not.toHaveBeenCalled();
345 expect(state.projects.every((project) => project.title === "Shared repo")).toBe(true);
346 expect(name.props.disabled).toBe(true);
347 expect(button("Remove all entries").props.disabled).toBe(true);
348 });
349
350 it("updates every member when all have project access without settings access", async () => {
351 const root = await mountPanel();
352 await act(async () => {
353 const name = root.findByProps({ "aria-label": "Project name" });
354 name.props.onChange();
355 name.props.onBlur({ currentTarget: { value: "Renamed" } });
356 });
357 expect(state.update.mock.calls.map(([request]) => request.environmentId)).toEqual([
358 primaryId,
359 remoteId,
360 ]);
361 expect(state.projects.map((project) => project.title)).toEqual(["Renamed", "Renamed"]);
362 });
363
364 it("stops before the next member if its grant is revoked during an earlier update", async () => {
365 const firstUpdate = deferred<AsyncResult.Success<void>>();
366 state.update.mockReturnValueOnce(firstUpdate.promise);
367 const root = await mountPanel();
368 await act(async () => {
369 const name = root.findByProps({ "aria-label": "Project name" });
370 name.props.onChange();
371 name.props.onBlur({ currentTarget: { value: "Renamed" } });
372 });
373 expect(state.update).toHaveBeenCalledOnce();
374
375 await grant(remoteId, AsyncResult.success(session([])));
376 await act(async () => {
377 firstUpdate.resolve(AsyncResult.success(undefined));
378 await firstUpdate.promise;
379 });
380 expect(state.update).toHaveBeenCalledOnce();
381 expect(state.update.mock.calls[0]![0].environmentId).toBe(primaryId);
382 });
383
384 it("disables removal for a checkout whose project grant is missing", async () => {
385 await grant(remoteId, AsyncResult.success(session([])));
386 const root = await mountPanel();
387 const denied = root.findByProps({ "aria-label": "Remove checkout /work/remote" });
388 expect(denied.props.disabled).toBe(true);
389 await act(async () => denied.props.onClick());
390 expect(state.confirm).not.toHaveBeenCalled();
391 expect(state.delete).not.toHaveBeenCalled();
392 expect(root.findByProps({ "aria-label": "Remove checkout /work/primary" }).props.disabled).toBe(
393 false,
394 );
395 });
396
397 it("allows removing a writable checkout while denying removal of the mixed group", async () => {
398 await grant(remoteId, AsyncResult.success(session([])));
399 await mountPanel();
400 await act(async () => button("Remove all entries").props.onClick());
401 expect(state.confirm).not.toHaveBeenCalled();
402 expect(state.delete).not.toHaveBeenCalled();
403 await act(async () =>
404 renderer!.root.findByProps({ "aria-label": "Remove checkout /work/primary" }).props.onClick(),
405 );
406 expect(state.delete).toHaveBeenCalledExactlyOnceWith({
407 environmentId: primaryId,
408 input: { projectId: state.projects[0]!.id, force: true },
409 });
410 expect(state.releaseUploads).toHaveBeenCalledOnce();
411 expect(state.clearDraft).toHaveBeenCalledOnce();
412 });
413
414 it("rechecks every removal target after confirmation before deleting or clearing drafts", async () => {
415 const confirmed = deferred<boolean>();
416 state.confirm.mockReturnValueOnce(confirmed.promise);
417 await mountPanel();
418 await act(async () => button("Remove all entries").props.onClick());
419 expect(state.confirm).toHaveBeenCalledOnce();
420 await grant(remoteId, AsyncResult.success(session([])));
421 await act(async () => {
422 confirmed.resolve(true);
423 await confirmed.promise;
424 });
425 expect(state.delete).not.toHaveBeenCalled();
426 expect(state.releaseUploads).not.toHaveBeenCalled();
427 expect(state.clearDraft).not.toHaveBeenCalled();
428 });
429
430 it("reacts to another member's revoked and restored grant and guards a stale handler", async () => {
431 await grant(
432 primaryId,
433 AsyncResult.success(session([AuthOrchestrationOperateScope, AuthSettingsWriteScope])),
434 );
435 const root = await mountPanel();
436 const name = () => root.findByProps({ "aria-label": "Project name" });
437 const staleSave = name().props.onBlur;
438 await act(async () => {
439 state.registry!.set(state.sessions.get(remoteId)!, AsyncResult.success(session([])));
440 name().props.onChange();
441 staleSave({ currentTarget: { value: "Renamed" } });
442 });
443 expect(state.update).not.toHaveBeenCalled();
444 expect(name().props.disabled).toBe(true);
445
446 await grant(remoteId, writable());
447 expect(name().props.disabled).toBe(false);
448 await act(async () => {
449 name().props.onChange();
450 name().props.onBlur({ currentTarget: { value: "Renamed" } });
451 });
452 expect(state.projects.map((project) => project.title)).toEqual(["Renamed", "Renamed"]);
453 });
454
455 it.each([
456 ["loading", () => AsyncResult.initial<AuthSessionState, Error>(), false],
457 [
458 "failed",
459 () => AsyncResult.failure<AuthSessionState, Error>(Cause.fail(new Error("Disconnected"))),
460 false,
461 ],
462 ["refreshing", () => AsyncResult.waiting(writable()), true],
463 ] as const)(
464 "uses the current grant while a member's session is %s",
465 async (_state, result, allowed) => {
466 await grant(remoteId, result());
467 const root = await mountPanel();
468 const name = root.findByProps({ "aria-label": "Project name" });
469 expect(name.props.disabled).toBe(!allowed);
470 await act(async () => {
471 name.props.onChange();
472 name.props.onBlur({ currentTarget: { value: "Renamed" } });
473 });
474 expect(state.update).toHaveBeenCalledTimes(allowed ? 2 : 0);
475 },
476 );
477});