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 { afterEach, beforeEach, expect, it as test, vi } from "vite-plus/test";
6import { CaptureShortcutConfig } from "./CaptureShortcutConfig.ts";
7import { captureConfigBinding } from "./captureConfigEdit.ts";
8
9// oxlint-disable-next-line t3code/no-global-process-runtime -- Test collection checks the host before starting these Linux-native filesystem tests.
10const it = test.runIf(process.platform === "linux");
11let directory: string;
12let path: string;
13let setup: CaptureShortcutConfig;
14const tools = {
15 validateNiri: vi.fn<(path: string) => Promise<void>>(),
16 hyprlandBindings:
17 vi.fn<() => Promise<{ modmask: number; key: string; dispatcher: string; arg: string }[]>>(),
18 reloadHyprland: vi.fn<() => Promise<void>>(),
19};
20const appId = "com.t3tools.T3Code";
21const install = { operation: "install", chooseFile: false } as const;
22const target = () => ({ desktop: "niri" as const, path, appId });
23beforeEach(async () => {
24 directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-capture-config-"));
25 path = NodePath.join(directory, "config.kdl");
26 await NodeFSP.writeFile(path, "binds {\n Mod+Q { quit; }\n}\n", { mode: 0o640 });
27 tools.validateNiri.mockReset().mockResolvedValue(undefined);
28 tools.hyprlandBindings.mockReset().mockResolvedValue([]);
29 tools.reloadHyprland.mockReset().mockResolvedValue(undefined);
30 setup = new CaptureShortcutConfig(NodeOS.homedir(), tools);
31});
32afterEach(async () => {
33 await NodeFSP.rm(directory, { recursive: true, force: true });
34});
35
36it("reads without writing, then applies only the approved diff with a backup", async () => {
37 const before = await NodeFSP.readFile(path, "utf8");
38 const preview = await setup.preview(target(), install);
39 expect(await NodeFSP.readFile(path, "utf8")).toBe(before);
40 expect(await NodeFSP.readdir(directory)).toEqual(["config.kdl"]);
41 expect(tools.validateNiri).not.toHaveBeenCalled();
42 expect(preview.before).toBe(before);
43 expect(preview.after).toContain("Ctrl+Shift+2");
44 const applied = await setup.apply(preview.id, "niri");
45 expect(await NodeFSP.readFile(path, "utf8")).toBe(preview.after);
46 expect(await NodeFSP.readFile(applied.backupPath!, "utf8")).toBe(before);
47 expect((await NodeFSP.stat(path)).mode & 0o777).toBe(0o640);
48 expect(tools.validateNiri).toHaveBeenCalledOnce();
49 await expect(setup.apply(preview.id, "niri")).rejects.toThrow("expired");
50});
51it("rechecks Hyprland conflicts before writing", async () => {
52 const hypr = {
53 ...target(),
54 desktop: "hyprland" as const,
55 path: NodePath.join(directory, "hyprland.conf"),
56 };
57 await NodeFSP.writeFile(hypr.path, "# unchanged\n");
58 const preview = await setup.preview(hypr, install);
59 tools.hyprlandBindings.mockResolvedValue([
60 { modmask: 5, key: "2", dispatcher: "exec", arg: "kitty" },
61 ]);
62 await expect(setup.apply(preview.id, "hyprland")).rejects.toThrow("already used");
63 expect(await NodeFSP.readFile(hypr.path, "utf8")).toBe(preview.before);
64});
65it("notices an optional include created while the diff is open", async () => {
66 await NodeFSP.appendFile(path, 'include optional=true "later.kdl"\n');
67 const preview = await setup.preview(target(), install);
68 await NodeFSP.writeFile(
69 NodePath.join(directory, "later.kdl"),
70 "binds {\n Ctrl+Shift+2 { quit; }\n}\n",
71 );
72 await expect(setup.apply(preview.id, "niri")).rejects.toThrow("changed since");
73 expect(await NodeFSP.readFile(path, "utf8")).toBe(preview.before);
74});
75it("does not overwrite edits made after preview", async () => {
76 const preview = await setup.preview(target(), install);
77 await NodeFSP.appendFile(path, "// edited elsewhere\n");
78 await expect(setup.apply(preview.id, "niri")).rejects.toThrow("changed since");
79 expect(await NodeFSP.readFile(path, "utf8")).toBe(preview.before + "// edited elsewhere\n");
80 expect(await NodeFSP.readdir(directory)).toEqual(["config.kdl"]);
81});
82it("rechecks after validation and cleans the staged file on concurrent edits", async () => {
83 const preview = await setup.preview(target(), install);
84 tools.validateNiri.mockImplementation(async () => {
85 await NodeFSP.appendFile(path, "// keep this\n");
86 });
87 await expect(setup.apply(preview.id, "niri")).rejects.toThrow("changed since");
88 expect(await NodeFSP.readdir(directory)).toEqual(["config.kdl"]);
89 expect(await NodeFSP.readFile(path, "utf8")).toContain("keep this");
90});
91it("preserves symlink-managed dotfiles and identifies their actual target", async () => {
92 const link = NodePath.join(directory, "linked.kdl");
93 await NodeFSP.symlink(path, link);
94 const preview = await setup.preview({ ...target(), path: link }, install);
95 expect(preview.path).toBe(link);
96 expect(preview.resolvedPath).toBe(path);
97 await setup.apply(preview.id, "niri");
98 expect((await NodeFSP.lstat(link)).isSymbolicLink()).toBe(true);
99 expect(await NodeFSP.readFile(link, "utf8")).toBe(preview.after);
100});
101it("refuses a symlink retargeted since preview", async () => {
102 const link = NodePath.join(directory, "linked.kdl");
103 const other = NodePath.join(directory, "other.kdl");
104 await NodeFSP.writeFile(other, "binds {}\n");
105 await NodeFSP.symlink(path, link);
106 const preview = await setup.preview({ ...target(), path: link }, install);
107 await NodeFSP.unlink(link);
108 await NodeFSP.symlink(other, link);
109 await expect(setup.apply(preview.id, "niri")).rejects.toThrow("changed since");
110 expect(await NodeFSP.readFile(other, "utf8")).toBe("binds {}\n");
111});
112it("validates symlinked configs from their original directory so relative includes stay valid", async () => {
113 const active = NodePath.join(directory, "active");
114 await NodeFSP.mkdir(active);
115 const link = NodePath.join(active, "config.kdl");
116 await NodeFSP.symlink(path, link);
117 await NodeFSP.appendFile(path, 'include "keys.kdl"\n');
118 await NodeFSP.writeFile(NodePath.join(active, "keys.kdl"), "binds {}\n");
119 tools.validateNiri.mockImplementation(async (file) => {
120 expect(NodePath.dirname(file)).toBe(active);
121 expect(await NodeFSP.readFile(file, "utf8")).toContain("Ctrl+Shift+2");
122 expect(await NodeFSP.readFile(NodePath.join(NodePath.dirname(file), "keys.kdl"), "utf8")).toBe(
123 "binds {}\n",
124 );
125 });
126 const preview = await setup.preview({ ...target(), path: link }, install);
127 await setup.apply(preview.id, "niri");
128 expect((await NodeFSP.readdir(active)).sort()).toEqual(["config.kdl", "keys.kdl"]);
129 expect((await NodeFSP.lstat(link)).isSymbolicLink()).toBe(true);
130});
131it("does not apply invalid Niri configs", async () => {
132 const preview = await setup.preview(target(), install);
133 tools.validateNiri.mockRejectedValue(new Error("Invalid config"));
134 await expect(setup.apply(preview.id, "niri")).rejects.toThrow("Invalid config");
135 expect(await NodeFSP.readFile(path, "utf8")).toBe(preview.before);
136 expect(await NodeFSP.readdir(directory)).toEqual(["config.kdl"]);
137});
138it("leaves already configured files unchanged, without creating a backup", async () => {
139 await NodeFSP.writeFile(path, `binds { ${captureConfigBinding("niri", appId, "Ctrl+Alt+Y")} }`);
140 const preview = await setup.preview(target(), install);
141 expect(preview.after).toBe(preview.before);
142 expect(preview.shortcut).toBe("Ctrl+Alt+Y");
143 expect(await setup.apply(preview.id, "niri")).toEqual({ backupPath: null, warning: null });
144 expect(await NodeFSP.readdir(directory)).toEqual(["config.kdl"]);
145});
146it("accepts an unchanged capture shortcut reported as a Lua callback", async () => {
147 const hypr = {
148 ...target(),
149 desktop: "hyprland" as const,
150 path: NodePath.join(directory, "bindings.lua"),
151 };
152 const before = `${captureConfigBinding("hyprland-lua", appId, "Ctrl+Shift+2")}\n`;
153 await NodeFSP.writeFile(hypr.path, before);
154 tools.hyprlandBindings.mockResolvedValue([
155 { modmask: 5, key: "2", dispatcher: "__lua", arg: "92" },
156 ]);
157
158 const preview = await setup.preview(hypr, install);
159 expect(preview.after).toBe(before);
160 expect(preview.shortcut).toBe("Ctrl+Shift+2");
161 expect(await setup.apply(preview.id, "hyprland")).toEqual({ backupPath: null, warning: null });
162 expect(await NodeFSP.readFile(hypr.path, "utf8")).toBe(before);
163 expect((await NodeFSP.readdir(directory)).sort()).toEqual(["bindings.lua", "config.kdl"]);
164 expect(tools.reloadHyprland).not.toHaveBeenCalled();
165
166 const unchanged = await setup.preview(hypr, install);
167 await NodeFSP.appendFile(hypr.path, "-- edited elsewhere\n");
168 await expect(setup.apply(unchanged.id, "hyprland")).rejects.toThrow("changed since");
169 expect(await NodeFSP.readFile(hypr.path, "utf8")).toBe(before + "-- edited elsewhere\n");
170});
171it.each(["preview", "apply"] as const)(
172 "checks unchanged Hyprland .conf shortcuts for conflicts during %s",
173 async (stage) => {
174 const hypr = {
175 ...target(),
176 desktop: "hyprland" as const,
177 path: NodePath.join(directory, "hyprland.conf"),
178 };
179 const before = `${captureConfigBinding("hyprland", appId, "Ctrl+Shift+2")}\n`;
180 await NodeFSP.writeFile(hypr.path, before);
181 const ownBinding = {
182 modmask: 5,
183 key: "2",
184 dispatcher: "global",
185 arg: `${appId}:capture-window`,
186 };
187 tools.hyprlandBindings.mockResolvedValue([ownBinding]);
188 const unchanged = await setup.preview(hypr, install);
189 expect(unchanged.after).toBe(before);
190 expect(await setup.apply(unchanged.id, "hyprland")).toEqual({
191 backupPath: null,
192 warning: null,
193 });
194
195 const preview = stage === "apply" ? await setup.preview(hypr, install) : undefined;
196 tools.hyprlandBindings.mockResolvedValue([
197 ownBinding,
198 { modmask: 5, key: "2", dispatcher: "exec", arg: "kitty" },
199 ]);
200 await expect(
201 preview ? setup.apply(preview.id, "hyprland") : setup.preview(hypr, install),
202 ).rejects.toThrow("already used");
203 expect(await NodeFSP.readFile(hypr.path, "utf8")).toBe(before);
204 expect((await NodeFSP.readdir(directory)).sort()).toEqual(["config.kdl", "hyprland.conf"]);
205 expect(tools.reloadHyprland).not.toHaveBeenCalled();
206 },
207);
208it("rejects changing an existing capture shortcut to keys used by another Lua callback", async () => {
209 const hypr = {
210 ...target(),
211 desktop: "hyprland" as const,
212 path: NodePath.join(directory, "bindings.lua"),
213 };
214 const before = `${captureConfigBinding("hyprland-lua", appId, "Ctrl+Alt+Y")}\n`;
215 await NodeFSP.writeFile(hypr.path, before);
216 const request = { ...install, shortcut: "Ctrl+Shift+2" };
217 const occupied = [{ modmask: 5, key: "2", dispatcher: "__lua", arg: "93" }];
218 tools.hyprlandBindings.mockResolvedValue(occupied);
219 await expect(setup.preview(hypr, request)).rejects.toThrow("already used");
220
221 tools.hyprlandBindings.mockResolvedValue([]);
222 const preview = await setup.preview(hypr, request);
223 tools.hyprlandBindings.mockResolvedValue(occupied);
224 await expect(setup.apply(preview.id, "hyprland")).rejects.toThrow("already used");
225 expect(await NodeFSP.readFile(hypr.path, "utf8")).toBe(before);
226});
227it("detects conflicts in included Niri configs without changing either file", async () => {
228 await NodeFSP.appendFile(path, 'include "keys.kdl"\n');
229 await NodeFSP.writeFile(NodePath.join(directory, "keys.kdl"), "binds { Shift+Ctrl+2 { quit; } }");
230 await expect(setup.preview(target(), install)).rejects.toThrow("already used");
231 expect((await NodeFSP.readdir(directory)).sort()).toEqual(["config.kdl", "keys.kdl"]);
232});
233it("does not edit Omarchy's shipped defaults", async () => {
234 const defaults = NodePath.join(directory, "omarchy", "default", "hypr");
235 await NodeFSP.mkdir(defaults, { recursive: true });
236 const file = NodePath.join(defaults, "bindings.conf");
237 await NodeFSP.writeFile(file, "# defaults\n");
238 await expect(
239 setup.preview({ ...target(), desktop: "hyprland", path: file }, install),
240 ).rejects.toThrow("not system or Omarchy");
241});