1// @effect-diagnostics nodeBuiltinImport:off -- Isolated config fixtures, never the user's desktop.
2import * as NodeFSP from "node:fs/promises";
3import * as NodePath from "node:path";
4import * as NodeOS from "node:os";
5import * as NodeChildProcess from "node:child_process";
6import * as NodeUtil from "node:util";
7import { afterEach, beforeEach, expect, it as test, vi } from "vite-plus/test";
8import { CaptureShortcutConfig, niriCaptureConfigPath } from "./CaptureShortcutConfig.ts";
9import { 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.
12const it = test.runIf(process.platform === "linux");
13let directory: string;
14let path: string;
15let setup: CaptureShortcutConfig;
16const 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};
22const appId = "com.t3tools.T3Code";
23const install = { operation: "install", chooseFile: false } as const;
24const target = () => ({ desktop: "niri" as const, path, appId });
25beforeEach(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});
34afterEach(async () => {
35 await NodeFSP.rm(directory, { recursive: true, force: true });
36});
37
38it("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});
53it("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});
59it("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});
73it("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});
83it("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});
88it("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});
95it("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});
104it("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});
114it("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});
125it("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});
144it("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});
151it("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});
159it("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});
165it("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});
173it("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});
186it("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});
198it("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});
211it("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});
220it("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});
232it("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});
238it("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});