1// @effect-diagnostics nodeBuiltinImport:off -- Isolated helper installation fixtures, never the user's data directory.
2import * as NodeFSP from "node:fs/promises";
3import * as NodeOS from "node:os";
4import * as NodePath from "node:path";
5import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
6
7const execute = vi.hoisted(() => vi.fn());
8const startEffects = vi.hoisted(() => vi.fn());
9vi.mock("node:child_process", () => ({ execFile: execute }));
10vi.mock("./NativeCaptureFeedback.ts", () => ({ startNativeCaptureFeedback: startEffects }));
11vi.mock("electron", () => ({
12 nativeImage: {
13 createFromBuffer: () => ({ isEmpty: () => false, getSize: () => ({ width: 2, height: 1 }) }),
14 },
15}));
16import { KdeCaptureSetup, captureKdeWindow, kdeCapturePaths } from "./KdeSnapShot.ts";
17
18let directory: string;
19let paths: { bundle: string; dataHome: string };
20let setup: KdeCaptureSetup;
21let denial = false;
22const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0]);
23const window = {
24 title: "Document",
25 appName: "Kate",
26 appIdentifier: "org.kde.kate",
27 processId: 123,
28 bounds: { x: 20, y: 10, width: 800, height: 600 },
29 clientBounds: { x: 20, y: 39, width: 800, height: 571 },
30};
31
32beforeEach(async () => {
33 directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-kde-test-"));
34 paths = {
35 bundle: NodePath.join(directory, "bundled-helper"),
36 dataHome: NodePath.join(directory, "user data"),
37 };
38 await NodeFSP.writeFile(paths.bundle, "bundled executable");
39 setup = new KdeCaptureSetup(paths);
40 denial = false;
41 startEffects.mockReset();
42 startEffects.mockResolvedValue(undefined);
43 execute.mockReset();
44 execute.mockImplementation(
45 (
46 executable: string,
47 args: string[],
48 _options: unknown,
49 callback: (error: Error | null, stdout: string, stderr: string) => void,
50 ) => {
51 if (args[0] === "capture") {
52 void NodeFSP.writeFile(NodePath.join(args[1]!, "capture.png"), png).then(() =>
53 callback(null, JSON.stringify({ window }), ""),
54 );
55 } else if (args[0] === "check" && denial)
56 callback(new Error("Denied"), "", "org.kde.KWin.ScreenShot2.Error.NoAuthorized");
57 else
58 callback(
59 null,
60 executable === "kbuildsycoca6" ? "" : '{"ready":true,"feedbackAvailable":true}',
61 "",
62 );
63 },
64 );
65});
66afterEach(async () => {
67 await NodeFSP.rm(directory, { recursive: true, force: true });
68});
69
70it("does not mistake installed files for KDE authorization, or fall back to a picker on denial", async () => {
71 await setup.perform("install-kde-helper");
72 denial = true;
73 expect(await setup.state()).toMatchObject({
74 status: "error",
75 message: expect.stringContaining("Reinstall"),
76 });
77 await expect(captureKdeWindow(paths)).rejects.toThrow("Settings → SnapShots");
78 expect(execute.mock.calls.some(([, args]) => args[0] === "capture")).toBe(false);
79});
80
81it("detects a bundled update, updates explicitly, and removes only capture's registration", async () => {
82 await setup.perform("install-kde-helper");
83 const unrelated = NodePath.join(paths.dataHome, "applications", "another-app.desktop");
84 await NodeFSP.writeFile(unrelated, "keep me");
85 await NodeFSP.writeFile(paths.bundle, "updated helper");
86 expect((await setup.state()).status).toBe("update-required");
87 await setup.perform("install-kde-helper");
88 expect((await setup.state()).status).toBe("ready");
89 await setup.perform("remove-kde-helper");
90 expect((await setup.state()).status).toBe("not-installed");
91 expect(await NodeFSP.readFile(unrelated, "utf8")).toBe("keep me");
92});
93
94it("repairs the old semicolon permission entry without replacing unrelated registrations", async () => {
95 await setup.perform("install-kde-helper");
96 const { desktop } = kdeCapturePaths(paths);
97 const installed = await NodeFSP.readFile(desktop, "utf8");
98 const permission = "X-KDE-DBUS-Restricted-Interfaces=org.kde.KWin.ScreenShot2";
99 expect(installed.split("\n")).toContain(permission);
100 await NodeFSP.writeFile(desktop, installed.replace(`${permission}\n`, `${permission};\n`));
101 execute.mockClear();
102 expect((await setup.state()).status).toBe("update-required");
103 expect(execute).not.toHaveBeenCalled();
104 await setup.perform("install-kde-helper");
105 expect((await setup.state()).status).toBe("ready");
106 expect((await NodeFSP.readFile(desktop, "utf8")).split("\n")).toContain(permission);
107 expect(await NodeFSP.readdir(NodePath.dirname(desktop))).toEqual([NodePath.basename(desktop)]);
108});
109
110it("still verifies capture access when a systemd user manager is unavailable", async () => {
111 const executeNormally = execute.getMockImplementation()!;
112 execute.mockImplementation((file, args, options, callback) => {
113 if (file === "systemd-run") callback(new Error("ENOENT"), "", "");
114 else executeNormally(file, args, options, callback);
115 });
116 await setup.perform("install-kde-helper");
117 expect((await setup.state()).status).toBe("ready");
118 denial = true;
119 expect((await setup.state()).status).toBe("error");
120});
121
122it("does not report a successful install when the registry cannot be refreshed", async () => {
123 execute.mockImplementation((_file, _args, _options, callback) =>
124 callback(new Error("Failed"), "", "Failed to rebuild registry"),
125 );
126 await expect(setup.perform("install-kde-helper")).rejects.toThrow("KDE couldn't register");
127});
128
129it("refuses symlink destinations and unrelated desktop entries", async () => {
130 const { desktop, executable } = kdeCapturePaths(paths);
131 await NodeFSP.mkdir(NodePath.dirname(desktop), { recursive: true });
132 await NodeFSP.writeFile(desktop, "[Desktop Entry]\nName=Unrelated");
133 await expect(setup.perform("install-kde-helper")).rejects.toThrow("Another desktop entry");
134 await NodeFSP.unlink(desktop);
135 await NodeFSP.mkdir(NodePath.dirname(executable), { recursive: true });
136 await NodeFSP.symlink(paths.bundle, executable);
137 await expect(setup.perform("install-kde-helper")).rejects.toThrow("regular files");
138 expect(await NodeFSP.readFile(paths.bundle, "utf8")).toBe("bundled executable");
139});
140
141it("captures with verified identity, cleans private files, and activates only the owning T3 PID/title", async () => {
142 await setup.perform("install-kde-helper");
143 const capture = await captureKdeWindow(paths);
144 expect(capture.png).toEqual(png);
145 expect(capture.window).toEqual(window);
146 expect(capture.feedback?.animationStarted).toBe(false);
147 const [, args] = execute.mock.calls.find(([, args]) => args[0] === "capture")!;
148 await expect(NodeFSP.stat(args[1])).rejects.toMatchObject({ code: "ENOENT" });
149 await capture.feedback?.activate('My "Draft"');
150 const [, activation] = execute.mock.calls.find(([, args]) => args[0] === "activate")!;
151 expect(activation.slice(2)).toEqual([String(process.pid), 'My "Draft"']);
152 await expect(NodeFSP.stat(activation[1])).rejects.toMatchObject({ code: "ENOENT" });
153 capture.feedback?.close();
154});
155
156it("retains the frozen screenshot for feedback and removes it after the renderer acknowledges landing", async () => {
157 await setup.perform("install-kde-helper");
158 const closed = Promise.withResolvers<void>();
159 const animateTo = vi.fn().mockResolvedValue(undefined);
160 startEffects.mockResolvedValue({
161 animationStarted: true,
162 closed: closed.promise,
163 animateTo,
164 complete: async () => {
165 closed.resolve();
166 },
167 close: () => closed.resolve(),
168 });
169 const capture = await captureKdeWindow(paths, { flash: true, animate: true });
170 const [, args] = execute.mock.calls.find(([, args]) => args[0] === "capture")!;
171 expect(await NodeFSP.readFile(NodePath.join(args[1], "capture.png"))).toEqual(png);
172 expect(capture.feedback?.animationStarted).toBe(true);
173 await capture.feedback?.activate("Draft");
174 const frame = { x: 0.2, y: 0.5, width: 0.1, height: 0.2 };
175 await capture.feedback?.animateTo(frame);
176 expect(animateTo).toHaveBeenCalledWith("Draft", frame);
177 await capture.feedback?.complete();
178 await expect(NodeFSP.stat(args[1])).rejects.toMatchObject({ code: "ENOENT" });
179});
180
181it("keeps a successfully captured image when optional KDE effects fail", async () => {
182 await setup.perform("install-kde-helper");
183 startEffects.mockRejectedValue(new Error("QML unavailable"));
184 const capture = await captureKdeWindow(paths, { flash: true, animate: true });
185 expect(capture.png).toEqual(png);
186 expect(capture.feedback?.animationStarted).toBe(false);
187 const [, args] = execute.mock.calls.find(([, args]) => args[0] === "capture")!;
188 await expect(NodeFSP.stat(args[1])).rejects.toMatchObject({ code: "ENOENT" });
189 capture.feedback?.close();
190});