| 1 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | |
| 4 | import { visitElements } from "../../test/reactElementTree"; |
| 5 | import { reactHookHarness as hooks } from "../../test/reactHookHarness"; |
| 6 | |
| 7 | const 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 | |
| 16 | vi.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 | |
| 29 | vi.mock("react/compiler-runtime", async () => { |
| 30 | const { reactHookHarness } = await import("../../test/reactHookHarness"); |
| 31 | return { c: reactHookHarness.useMemoCache }; |
| 32 | }); |
| 33 | |
| 34 | vi.mock("@effect/atom-react", () => ({ |
| 35 | useAtomValue: (atom: string) => (atom === "config-path" ? "/fixture/keybindings.json" : []), |
| 36 | })); |
| 37 | |
| 38 | vi.mock("@tanstack/react-router", () => ({ useLocation: () => "" })); |
| 39 | vi.mock("../ProjectFavicon", () => ({ ProjectFavicon: () => null })); |
| 40 | vi.mock("../../state/server", () => ({ |
| 41 | primaryServerKeybindingsAtom: "keybindings", |
| 42 | primaryServerKeybindingsConfigPathAtom: "config-path", |
| 43 | primaryServerAvailableEditorsAtom: "editors", |
| 44 | serverEnvironment: { upsertKeybinding: state.upsert, removeKeybinding: state.remove }, |
| 45 | })); |
| 46 | |
| 47 | vi.mock("../../state/environments", () => ({ |
| 48 | usePrimaryEnvironment: () => ({ environmentId: "primary-settings" }), |
| 49 | })); |
| 50 | |
| 51 | vi.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 | |
| 67 | vi.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 | |
| 80 | vi.mock("../../state/use-atom-command", () => ({ |
| 81 | useAtomCommand: (command: unknown) => command, |
| 82 | })); |
| 83 | |
| 84 | vi.mock("../../editorPreferences", () => ({ |
| 85 | useOpenInPreferredEditor: (environmentId: EnvironmentId) => (path: string) => |
| 86 | state.openEditor({ environmentId, path }), |
| 87 | })); |
| 88 | |
| 89 | import { KeybindingsSettingsPanel } from "./KeybindingsSettings"; |
| 90 | |
| 91 | function renderPanel() { |
| 92 | hooks.beginRender(); |
| 93 | return KeybindingsSettingsPanel(); |
| 94 | } |
| 95 | |
| 96 | function 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 | |
| 105 | describe("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 | |
| 149 | function noticeText(panel: unknown) { |
| 150 | const notice = visitElements(panel, (element) => element.type === "p"); |
| 151 | return notice?.props.children; |
| 152 | } |
| 153 | |
| 154 | describe("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 | }); |