1import type { EnvironmentId } from "@t3tools/contracts";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
4import { visitElements } from "../../test/reactElementTree";
5import { reactHookHarness as hooks } from "../../test/reactHookHarness";
6
7const state = vi.hoisted(() => ({
8 canOperate: false,
9 canWriteSettings: true,
10 connected: true,
11 openEditor: vi.fn(),
12 upsert: vi.fn(),
13 remove: vi.fn(),
14}));
15
16vi.mock("react", async (importOriginal) => {
17 const actual = await importOriginal<typeof import("react")>();
18 const { reactHookHarness } = await import("../../test/reactHookHarness");
19 return {
20 ...actual,
21 useCallback: reactHookHarness.useCallback,
22 useEffect: () => undefined,
23 useMemo: reactHookHarness.useMemo,
24 useRef: reactHookHarness.useRef,
25 useState: reactHookHarness.useState,
26 };
27});
28
29vi.mock("react/compiler-runtime", async () => {
30 const { reactHookHarness } = await import("../../test/reactHookHarness");
31 return { c: reactHookHarness.useMemoCache };
32});
33
34vi.mock("@effect/atom-react", () => ({
35 useAtomValue: (atom: string) => (atom === "config-path" ? "/fixture/keybindings.json" : []),
36}));
37
38vi.mock("@tanstack/react-router", () => ({ useLocation: () => "" }));
39vi.mock("../ProjectFavicon", () => ({ ProjectFavicon: () => null }));
40vi.mock("../../state/server", () => ({
41 primaryServerKeybindingsAtom: "keybindings",
42 primaryServerKeybindingsConfigPathAtom: "config-path",
43 primaryServerAvailableEditorsAtom: "editors",
44 serverEnvironment: { upsertKeybinding: state.upsert, removeKeybinding: state.remove },
45}));
46
47vi.mock("../../state/environments", () => ({
48 usePrimaryEnvironment: () => ({ environmentId: "primary-settings" }),
49}));
50
51vi.mock("./SettingsScopeContext", () => ({
52 useSettingsScope: () => {
53 const environment = {
54 environmentId: "primary-settings",
55 serverConfig: {
56 keybindings: [],
57 keybindingsConfigPath: "/fixture/keybindings.json",
58 availableEditors: [],
59 },
60 };
61 return state.connected
62 ? { environment, connectedEnvironments: [environment] }
63 : { environment: null, connectedEnvironments: [] };
64 },
65}));
66
67vi.mock("../../state/session", () => {
68 const hasScope = (environmentId: EnvironmentId | null, scope: string) =>
69 environmentId === "primary-settings" &&
70 (scope === "orchestration:operate"
71 ? state.canOperate
72 : scope === "settings:write" && state.canWriteSettings);
73 return {
74 useEnvironmentScope: hasScope,
75 readEnvironmentScope: hasScope,
76 useEnvironmentsWithScope: () => new Set(state.canWriteSettings ? ["primary-settings"] : []),
77 };
78});
79
80vi.mock("../../state/use-atom-command", () => ({
81 useAtomCommand: (command: unknown) => command,
82}));
83
84vi.mock("../../editorPreferences", () => ({
85 useOpenInPreferredEditor: (environmentId: EnvironmentId) => (path: string) =>
86 state.openEditor({ environmentId, path }),
87}));
88
89import { KeybindingsSettingsPanel } from "./KeybindingsSettings";
90
91function renderPanel() {
92 hooks.beginRender();
93 return KeybindingsSettingsPanel();
94}
95
96function openButton(panel: unknown) {
97 const button = visitElements(
98 panel,
99 (element) => element.props["aria-label"] === "Open keybindings.json",
100 );
101 if (!button) throw new Error("Missing Open keybindings.json action.");
102 return button;
103}
104
105describe("KeybindingsSettings editor permission", () => {
106 beforeEach(() => {
107 hooks.reset();
108 state.canOperate = false;
109 state.canWriteSettings = true;
110 state.connected = true;
111 state.openEditor.mockReset().mockResolvedValue({ _tag: "Success", value: undefined });
112 state.upsert.mockReset().mockResolvedValue({ _tag: "Success", value: undefined });
113 state.remove.mockReset().mockResolvedValue({ _tag: "Success", value: undefined });
114 });
115
116 it("rejects editor launches when settings writes are the only write grant", () => {
117 const button = openButton(renderPanel());
118 (button.props.onClick as () => void)();
119
120 expect(state.openEditor).not.toHaveBeenCalled();
121 expect(button.props.disabled).toBe(true);
122 });
123
124 it("opens the primary environment's file after operate is granted without settings writes", () => {
125 state.canWriteSettings = false;
126 renderPanel();
127 state.canOperate = true;
128 const button = openButton(renderPanel());
129 expect(button.props.disabled).toBe(false);
130 (button.props.onClick as () => void)();
131
132 expect(state.openEditor).toHaveBeenCalledWith({
133 environmentId: "primary-settings",
134 path: "/fixture/keybindings.json",
135 });
136 });
137
138 it("rejects a queued editor launch after operate is revoked", () => {
139 state.canOperate = true;
140 const open = openButton(renderPanel()).props.onClick as () => void;
141 state.canOperate = false;
142 open();
143
144 expect(state.openEditor).not.toHaveBeenCalled();
145 expect(openButton(renderPanel()).props.disabled).toBe(true);
146 });
147});
148
149function noticeText(panel: unknown) {
150 const notice = visitElements(panel, (element) => element.type === "p");
151 return notice?.props.children;
152}
153
154describe("KeybindingsSettings write notice", () => {
155 beforeEach(() => {
156 hooks.reset();
157 state.canWriteSettings = true;
158 state.connected = true;
159 });
160
161 // The hosted app has no environment until one is paired, so the editor is
162 // locked for a missing connection, not a missing permission.
163 it("asks for a connected environment when none is connected", () => {
164 state.connected = false;
165 expect(noticeText(renderPanel())).toBe("Connect an environment to change keybindings.");
166 });
167
168 it("names the permission when the connection cannot write settings", () => {
169 state.canWriteSettings = false;
170 expect(noticeText(renderPanel())).toBe(
171 "This connection can view keybindings but cannot change them.",
172 );
173 });
174});