| 1 | // @effect-diagnostics nodeBuiltinImport:off -- Isolated config fixtures, never the user's desktop. |
| 2 | import * as NodeFSP from "node:fs/promises"; |
| 3 | import * as NodePath from "node:path"; |
| 4 | import * as NodeOS from "node:os"; |
| 5 | import * as NodeChildProcess from "node:child_process"; |
| 6 | import * as NodeUtil from "node:util"; |
| 7 | import { afterEach, beforeEach, expect, it as test, vi } from "vite-plus/test"; |
| 8 | import { CaptureShortcutConfig, niriCaptureConfigPath } from "./CaptureShortcutConfig.ts"; |
| 9 | import { captureConfigBinding } from "./captureConfigEdit.ts"; |
| 10 | |
| 11 | // oxlint-disable-next-line t3code/no-global-process-runtime -- Test collection checks the host before starting these Linux-native filesystem tests. |
| 12 | const it = test.runIf(process.platform === "linux"); |
| 13 | let directory: string; |
| 14 | let path: string; |
| 15 | let setup: CaptureShortcutConfig; |
| 16 | const tools = { |
| 17 | validateNiri: vi.fn<(path: string) => Promise<void>>(), |
| 18 | hyprlandBindings: |
| 19 | vi.fn<() => Promise<{ modmask: number; key: string; dispatcher: string; arg: string }[]>>(), |
| 20 | reloadHyprland: vi.fn<() => Promise<void>>(), |
| 21 | }; |
| 22 | const appId = "com.t3tools.T3Code"; |
| 23 | const install = { operation: "install", chooseFile: false } as const; |
| 24 | const target = () => ({ desktop: "niri" as const, path, appId }); |
| 25 | beforeEach(async () => { |
| 26 | directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-capture-config-")); |
| 27 | path = NodePath.join(directory, "config.kdl"); |
| 28 | await NodeFSP.writeFile(path, "binds {\n Mod+Q { quit; }\n}\n", { mode: 0o640 }); |
| 29 | tools.validateNiri.mockReset().mockResolvedValue(undefined); |
| 30 | tools.hyprlandBindings.mockReset().mockResolvedValue([]); |
| 31 | tools.reloadHyprland.mockReset().mockResolvedValue(undefined); |
| 32 | setup = new CaptureShortcutConfig(NodeOS.homedir(), tools); |
| 33 | }); |
| 34 | afterEach(async () => { |
| 35 | await NodeFSP.rm(directory, { recursive: true, force: true }); |
| 36 | }); |
| 37 | |
| 38 | it("reads without writing, then applies only the approved diff with a backup", async () => { |
| 39 | const before = await NodeFSP.readFile(path, "utf8"); |
| 40 | const preview = await setup.preview(target(), install); |
| 41 | expect(await NodeFSP.readFile(path, "utf8")).toBe(before); |
| 42 | expect(await NodeFSP.readdir(directory)).toEqual(["config.kdl"]); |
| 43 | expect(tools.validateNiri).not.toHaveBeenCalled(); |
| 44 | expect(preview.before).toBe(before); |
| 45 | expect(preview.after).toContain("Ctrl+Shift+2"); |
| 46 | const applied = await setup.apply(preview.id, "niri"); |
| 47 | expect(await NodeFSP.readFile(path, "utf8")).toBe(preview.after); |
| 48 | expect(await NodeFSP.readFile(applied.backupPath!, "utf8")).toBe(before); |
| 49 | expect((await NodeFSP.stat(path)).mode & 0o777).toBe(0o640); |
| 50 | expect(tools.validateNiri).toHaveBeenCalledOnce(); |
| 51 | await expect(setup.apply(preview.id, "niri")).rejects.toThrow("expired"); |
| 52 | }); |
| 53 | it("refuses apply without a preview or for the wrong desktop", async () => { |
| 54 | await expect(setup.apply("invented", "niri")).rejects.toThrow("expired"); |
| 55 | const preview = await setup.preview(target(), install); |
| 56 | await expect(setup.apply(preview.id, "hyprland")).rejects.toThrow("expired"); |
| 57 | expect(await NodeFSP.readFile(path, "utf8")).toBe(preview.before); |
| 58 | }); |
| 59 | it("rechecks Hyprland conflicts before writing", async () => { |
| 60 | const hypr = { |
| 61 | ...target(), |
| 62 | desktop: "hyprland" as const, |
| 63 | path: NodePath.join(directory, "hyprland.conf"), |
| 64 | }; |
| 65 | await NodeFSP.writeFile(hypr.path, "# unchanged\n"); |
| 66 | const preview = await setup.preview(hypr, install); |
| 67 | tools.hyprlandBindings.mockResolvedValue([ |
| 68 | { modmask: 5, key: "2", dispatcher: "exec", arg: "kitty" }, |
| 69 | ]); |
| 70 | await expect(setup.apply(preview.id, "hyprland")).rejects.toThrow("already used"); |
| 71 | expect(await NodeFSP.readFile(hypr.path, "utf8")).toBe(preview.before); |
| 72 | }); |
| 73 | it("notices an optional include created while the diff is open", async () => { |
| 74 | await NodeFSP.appendFile(path, 'include optional=true "later.kdl"\n'); |
| 75 | const preview = await setup.preview(target(), install); |
| 76 | await NodeFSP.writeFile( |
| 77 | NodePath.join(directory, "later.kdl"), |
| 78 | "binds {\n Ctrl+Shift+2 { quit; }\n}\n", |
| 79 | ); |
| 80 | await expect(setup.apply(preview.id, "niri")).rejects.toThrow("changed since"); |
| 81 | expect(await NodeFSP.readFile(path, "utf8")).toBe(preview.before); |
| 82 | }); |
| 83 | it("invalidates an earlier proposal when another config is opened", async () => { |
| 84 | const first = await setup.preview(target(), install); |
| 85 | await setup.preview(target(), { ...install, shortcut: "Ctrl+Alt+Y" }); |
| 86 | await expect(setup.apply(first.id, "niri")).rejects.toThrow("expired"); |
| 87 | }); |
| 88 | it("does not overwrite edits made after preview", async () => { |
| 89 | const preview = await setup.preview(target(), install); |
| 90 | await NodeFSP.appendFile(path, "// edited elsewhere\n"); |
| 91 | await expect(setup.apply(preview.id, "niri")).rejects.toThrow("changed since"); |
| 92 | expect(await NodeFSP.readFile(path, "utf8")).toBe(preview.before + "// edited elsewhere\n"); |
| 93 | expect(await NodeFSP.readdir(directory)).toEqual(["config.kdl"]); |
| 94 | }); |
| 95 | it("rechecks after validation and cleans the staged file on concurrent edits", async () => { |
| 96 | const preview = await setup.preview(target(), install); |
| 97 | tools.validateNiri.mockImplementation(async () => { |
| 98 | await NodeFSP.appendFile(path, "// keep this\n"); |
| 99 | }); |
| 100 | await expect(setup.apply(preview.id, "niri")).rejects.toThrow("changed since"); |
| 101 | expect(await NodeFSP.readdir(directory)).toEqual(["config.kdl"]); |
| 102 | expect(await NodeFSP.readFile(path, "utf8")).toContain("keep this"); |
| 103 | }); |
| 104 | it("preserves symlink-managed dotfiles and identifies their actual target", async () => { |
| 105 | const link = NodePath.join(directory, "linked.kdl"); |
| 106 | await NodeFSP.symlink(path, link); |
| 107 | const preview = await setup.preview({ ...target(), path: link }, install); |
| 108 | expect(preview.path).toBe(link); |
| 109 | expect(preview.resolvedPath).toBe(path); |
| 110 | await setup.apply(preview.id, "niri"); |
| 111 | expect((await NodeFSP.lstat(link)).isSymbolicLink()).toBe(true); |
| 112 | expect(await NodeFSP.readFile(link, "utf8")).toBe(preview.after); |
| 113 | }); |
| 114 | it("refuses a symlink retargeted since preview", async () => { |
| 115 | const link = NodePath.join(directory, "linked.kdl"); |
| 116 | const other = NodePath.join(directory, "other.kdl"); |
| 117 | await NodeFSP.writeFile(other, "binds {}\n"); |
| 118 | await NodeFSP.symlink(path, link); |
| 119 | const preview = await setup.preview({ ...target(), path: link }, install); |
| 120 | await NodeFSP.unlink(link); |
| 121 | await NodeFSP.symlink(other, link); |
| 122 | await expect(setup.apply(preview.id, "niri")).rejects.toThrow("changed since"); |
| 123 | expect(await NodeFSP.readFile(other, "utf8")).toBe("binds {}\n"); |
| 124 | }); |
| 125 | it("validates symlinked configs from their original directory so relative includes stay valid", async () => { |
| 126 | const active = NodePath.join(directory, "active"); |
| 127 | await NodeFSP.mkdir(active); |
| 128 | const link = NodePath.join(active, "config.kdl"); |
| 129 | await NodeFSP.symlink(path, link); |
| 130 | await NodeFSP.appendFile(path, 'include "keys.kdl"\n'); |
| 131 | await NodeFSP.writeFile(NodePath.join(active, "keys.kdl"), "binds {}\n"); |
| 132 | tools.validateNiri.mockImplementation(async (file) => { |
| 133 | expect(NodePath.dirname(file)).toBe(active); |
| 134 | expect(await NodeFSP.readFile(file, "utf8")).toContain("Ctrl+Shift+2"); |
| 135 | expect(await NodeFSP.readFile(NodePath.join(NodePath.dirname(file), "keys.kdl"), "utf8")).toBe( |
| 136 | "binds {}\n", |
| 137 | ); |
| 138 | }); |
| 139 | const preview = await setup.preview({ ...target(), path: link }, install); |
| 140 | await setup.apply(preview.id, "niri"); |
| 141 | expect((await NodeFSP.readdir(active)).sort()).toEqual(["config.kdl", "keys.kdl"]); |
| 142 | expect((await NodeFSP.lstat(link)).isSymbolicLink()).toBe(true); |
| 143 | }); |
| 144 | it("does not apply invalid Niri configs", async () => { |
| 145 | const preview = await setup.preview(target(), install); |
| 146 | tools.validateNiri.mockRejectedValue(new Error("Invalid config")); |
| 147 | await expect(setup.apply(preview.id, "niri")).rejects.toThrow("Invalid config"); |
| 148 | expect(await NodeFSP.readFile(path, "utf8")).toBe(preview.before); |
| 149 | expect(await NodeFSP.readdir(directory)).toEqual(["config.kdl"]); |
| 150 | }); |
| 151 | it("leaves already configured files unchanged, without creating a backup", async () => { |
| 152 | await NodeFSP.writeFile(path, `binds { ${captureConfigBinding("niri", appId, "Ctrl+Alt+Y")} }`); |
| 153 | const preview = await setup.preview(target(), install); |
| 154 | expect(preview.after).toBe(preview.before); |
| 155 | expect(preview.shortcut).toBe("Ctrl+Alt+Y"); |
| 156 | expect(await setup.apply(preview.id, "niri")).toEqual({ backupPath: null, warning: null }); |
| 157 | expect(await NodeFSP.readdir(directory)).toEqual(["config.kdl"]); |
| 158 | }); |
| 159 | it("detects conflicts in included Niri configs without changing either file", async () => { |
| 160 | await NodeFSP.appendFile(path, 'include "keys.kdl"\n'); |
| 161 | await NodeFSP.writeFile(NodePath.join(directory, "keys.kdl"), "binds { Shift+Ctrl+2 { quit; } }"); |
| 162 | await expect(setup.preview(target(), install)).rejects.toThrow("already used"); |
| 163 | expect((await NodeFSP.readdir(directory)).sort()).toEqual(["config.kdl", "keys.kdl"]); |
| 164 | }); |
| 165 | it("detects included config changes before applying", async () => { |
| 166 | await NodeFSP.appendFile(path, 'include "keys.kdl"\n'); |
| 167 | const included = NodePath.join(directory, "keys.kdl"); |
| 168 | await NodeFSP.writeFile(included, "binds { Ctrl+Alt+Q { quit; } }"); |
| 169 | const preview = await setup.preview(target(), install); |
| 170 | await NodeFSP.writeFile(included, "binds { Ctrl+Shift+2 { quit; } }"); |
| 171 | await expect(setup.apply(preview.id, "niri")).rejects.toThrow("changed since"); |
| 172 | }); |
| 173 | it("rejects a conflict from another Hyprland config or dynamic Lua bind", async () => { |
| 174 | const hypr = { |
| 175 | ...target(), |
| 176 | desktop: "hyprland" as const, |
| 177 | path: NodePath.join(directory, "bindings.lua"), |
| 178 | }; |
| 179 | await NodeFSP.writeFile(hypr.path, "-- user bindings\n"); |
| 180 | tools.hyprlandBindings.mockResolvedValue([ |
| 181 | { modmask: 5, key: "2", dispatcher: "exec", arg: "kitty" }, |
| 182 | ]); |
| 183 | await expect(setup.preview(hypr, install)).rejects.toThrow("already used"); |
| 184 | expect(await NodeFSP.readFile(hypr.path, "utf8")).toBe("-- user bindings\n"); |
| 185 | }); |
| 186 | it("reloads Hyprland after apply, but not after read", async () => { |
| 187 | const hypr = { |
| 188 | ...target(), |
| 189 | desktop: "hyprland" as const, |
| 190 | path: NodePath.join(directory, "hyprland.conf"), |
| 191 | }; |
| 192 | await NodeFSP.writeFile(hypr.path, "# preferences\n"); |
| 193 | const preview = await setup.preview(hypr, install); |
| 194 | expect(tools.reloadHyprland).not.toHaveBeenCalled(); |
| 195 | await setup.apply(preview.id, "hyprland"); |
| 196 | expect(tools.reloadHyprland).toHaveBeenCalledOnce(); |
| 197 | }); |
| 198 | it("reports reload failure separately from a successfully saved config", async () => { |
| 199 | const hypr = { |
| 200 | ...target(), |
| 201 | desktop: "hyprland" as const, |
| 202 | path: NodePath.join(directory, "hyprland.conf"), |
| 203 | }; |
| 204 | await NodeFSP.writeFile(hypr.path, "# preferences\n"); |
| 205 | const preview = await setup.preview(hypr, install); |
| 206 | tools.reloadHyprland.mockRejectedValue(new Error("Compositor stopped")); |
| 207 | const result = await setup.apply(preview.id, "hyprland"); |
| 208 | expect(result.warning).toContain("Config saved"); |
| 209 | expect(await NodeFSP.readFile(hypr.path, "utf8")).toBe(preview.after); |
| 210 | }); |
| 211 | it("does not edit Omarchy's shipped defaults", async () => { |
| 212 | const defaults = NodePath.join(directory, "omarchy", "default", "hypr"); |
| 213 | await NodeFSP.mkdir(defaults, { recursive: true }); |
| 214 | const file = NodePath.join(defaults, "bindings.conf"); |
| 215 | await NodeFSP.writeFile(file, "# defaults\n"); |
| 216 | await expect( |
| 217 | setup.preview({ ...target(), desktop: "hyprland", path: file }, install), |
| 218 | ).rejects.toThrow("not system or Omarchy"); |
| 219 | }); |
| 220 | it("honors NIRI_CONFIG and XDG_CONFIG_HOME", () => { |
| 221 | expect( |
| 222 | niriCaptureConfigPath("/home/test", { |
| 223 | NIRI_CONFIG: "/custom/test.kdl", |
| 224 | XDG_CONFIG_HOME: "/config", |
| 225 | }), |
| 226 | ).toBe("/custom/test.kdl"); |
| 227 | expect(niriCaptureConfigPath("/home/test", { XDG_CONFIG_HOME: "/config" })).toBe( |
| 228 | "/config/niri/config.kdl", |
| 229 | ); |
| 230 | expect(niriCaptureConfigPath("/home/test", {})).toBe("/home/test/.config/niri/config.kdl"); |
| 231 | }); |
| 232 | it("preserves a UTF-8 BOM through preview and apply", async () => { |
| 233 | await NodeFSP.writeFile(path, "\uFEFFbinds {}\r\n"); |
| 234 | const preview = await setup.preview(target(), install); |
| 235 | await setup.apply(preview.id, "niri"); |
| 236 | expect((await NodeFSP.readFile(path)).subarray(0, 3)).toEqual(Buffer.from([0xef, 0xbb, 0xbf])); |
| 237 | }); |
| 238 | it("the installed Niri parser accepts the generated edit and its relative includes", async (context) => { |
| 239 | try { |
| 240 | await NodeUtil.promisify(NodeChildProcess.execFile)("niri", ["--version"]); |
| 241 | } catch (error) { |
| 242 | if ((error as NodeJS.ErrnoException).code === "ENOENT") { |
| 243 | context.skip(); |
| 244 | return; |
| 245 | } |
| 246 | throw error; |
| 247 | } |
| 248 | // No compositor is launched: validate only parses the temporary fixture. |
| 249 | await NodeFSP.appendFile(path, 'include "keys.kdl"\n'); |
| 250 | await NodeFSP.writeFile( |
| 251 | NodePath.join(directory, "keys.kdl"), |
| 252 | "binds {\n Ctrl+Alt+Q { quit; }\n}\n", |
| 253 | ); |
| 254 | const checked = new CaptureShortcutConfig(NodeOS.homedir(), { |
| 255 | ...tools, |
| 256 | validateNiri: async (file) => { |
| 257 | await NodeUtil.promisify(NodeChildProcess.execFile)("niri", ["validate", "--config", file]); |
| 258 | }, |
| 259 | }); |
| 260 | const preview = await checked.preview(target(), install); |
| 261 | await checked.apply(preview.id, "niri"); |
| 262 | expect(await NodeFSP.readFile(path, "utf8")).toBe(preview.after); |
| 263 | }); |