1// @effect-diagnostics nodeBuiltinImport:off -- Private Unix sockets exercise the real compositor transport.
2import * as NodeFSP from "node:fs/promises";
3import * as NodeNet from "node:net";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
7
8vi.mock("electron", () => ({
9 nativeImage: {
10 createFromBuffer: () => ({
11 isEmpty: () => false,
12 getSize: () => ({ width: 800, height: 600 }),
13 }),
14 },
15}));
16import { captureNiriWindow, checkNiriCaptureSupport, niriSocketPath } from "./NiriSnapShot.ts";
17import { captureLinuxWindow, getLinuxCaptureSupport } from "./LinuxSnapShot.ts";
18
19const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0]);
20const window = {
21 id: 42,
22 title: "Editor",
23 app_id: "org.example.Editor",
24 pid: 123,
25 layout: { window_size: [800, 600] },
26};
27type Request =
28 | string
29 | { Action: { ScreenshotWindow?: { id: number; path: string }; FocusWindow?: { id: number } } };
30let directory: string;
31let socketPath: string;
32let server: NodeNet.Server;
33let sockets: Set<NodeNet.Socket>;
34let events: NodeNet.Socket[];
35let calls: Request[];
36let handler: (request: Request, socket: NodeNet.Socket) => Promise<void>;
37let focused: typeof window | null;
38let windows: (typeof window)[];
39let capturePath: string | undefined;
40let version: string;
41const send = (socket: NodeNet.Socket, value: unknown) => socket.write(`${JSON.stringify(value)}\n`);
42
43beforeEach(async () => {
44 directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-niri-test-"));
45 socketPath = NodePath.join(directory, "ipc");
46 sockets = new Set();
47 events = [];
48 calls = [];
49 focused = window;
50 windows = [window];
51 capturePath = undefined;
52 version = "26.04 (v26.04)";
53 handler = async (request, socket) => {
54 if (request === "EventStream") {
55 events.push(socket);
56 send(socket, { Ok: "Handled" });
57 send(socket, { WindowsChanged: { windows } });
58 } else if (request === "Version") send(socket, { Ok: { Version: version } });
59 else if (request === "FocusedWindow") send(socket, { Ok: { FocusedWindow: focused } });
60 else if (request === "Windows") send(socket, { Ok: { Windows: windows } });
61 else if (typeof request !== "string" && request.Action.ScreenshotWindow) {
62 const screenshot = request.Action.ScreenshotWindow;
63 capturePath = screenshot.path;
64 // Focus may change between discovery and capture. The request must keep the original ID.
65 focused = { ...window, id: 99, title: "Another app" };
66 await NodeFSP.writeFile(screenshot.path, png);
67 for (const event of events) {
68 send(event, { ScreenshotCaptured: { path: "/another-app.png" } });
69 send(event, { ScreenshotCaptured: { path: screenshot.path } });
70 }
71 // The event may beat the command reply.
72 send(socket, { Ok: "Handled" });
73 } else send(socket, { Ok: "Handled" });
74 };
75 server = NodeNet.createServer((socket) => {
76 sockets.add(socket);
77 socket.on("close", () => sockets.delete(socket));
78 socket.on("error", () => undefined);
79 socket.setEncoding("utf8");
80 let pending = "";
81 socket.on("data", (chunk: string) => {
82 pending += chunk;
83 let end: number;
84 while ((end = pending.indexOf("\n")) !== -1) {
85 const request = JSON.parse(pending.slice(0, end)) as Request;
86 pending = pending.slice(end + 1);
87 calls.push(request);
88 void handler(request, socket).catch((error: unknown) =>
89 socket.destroy(error instanceof Error ? error : undefined),
90 );
91 }
92 });
93 });
94 await new Promise<void>((resolve, reject) => {
95 server.once("error", reject);
96 server.listen(socketPath, resolve);
97 });
98 vi.stubEnv("NIRI_SOCKET", socketPath);
99 vi.stubEnv("XDG_CURRENT_DESKTOP", "niri");
100 vi.stubEnv("FLATPAK_ID", "");
101 vi.stubEnv("SNAP", "");
102});
103afterEach(async () => {
104 vi.useRealTimers();
105 vi.unstubAllEnvs();
106 for (const socket of sockets) socket.destroy();
107 await new Promise<void>((resolve) => server.close(() => resolve()));
108 await NodeFSP.rm(directory, { recursive: true, force: true });
109});
110
111it("selects the native adapter without needing a portal or GNOME extension", async () => {
112 expect(await getLinuxCaptureSupport("com.t3tools.T3Code")).toEqual({
113 linuxBackend: "niri",
114 linuxFeedbackAvailable: false,
115 });
116 const snapshot = await captureLinuxWindow("com.t3tools.T3Code");
117 expect(snapshot?.png).toEqual(png);
118 expect(snapshot?.window).toMatchObject({
119 processId: 123,
120 title: "Editor",
121 accessibilityBoundsReliable: false,
122 });
123 expect(calls).toContainEqual({
124 Action: {
125 ScreenshotWindow: { id: 42, path: capturePath, write_to_disk: true, show_pointer: false },
126 },
127 });
128 expect(snapshot?.feedback?.animationStarted).toBe(false);
129 expect(await NodeFSP.stat(NodePath.dirname(capturePath!)).catch(() => undefined)).toBeUndefined();
130});
131
132it("does not activate T3 until requested, then matches PID and title", async () => {
133 const snapshot = await captureNiriWindow(socketPath);
134 expect(calls.some((call) => typeof call !== "string" && call.Action.FocusWindow)).toBe(false);
135 windows = [
136 { ...window, id: 1, pid: 999, title: "T3 Code" },
137 { ...window, id: 2, pid: process.pid, title: "Other T3" },
138 { ...window, id: 3, pid: process.pid, title: "T3 Code" },
139 ];
140 await snapshot.feedback!.activate("T3 Code");
141 expect(calls).toContainEqual({ Action: { FocusWindow: { id: 3 } } });
142});
143
144it("waits for the restored T3 window to map instead of polling", async () => {
145 const snapshot = await captureNiriWindow(socketPath);
146 const original = handler;
147 handler = async (request, socket) => {
148 await original(request, socket);
149 if (request === "EventStream")
150 send(socket, {
151 WindowOpenedOrChanged: { window: { ...window, id: 4, pid: process.pid, title: "T3 Code" } },
152 });
153 };
154 await snapshot.feedback!.activate("T3 Code");
155 expect(calls).toContainEqual({ Action: { FocusWindow: { id: 4 } } });
156});
157
158it("rejects ambiguous activation targets", async () => {
159 const snapshot = await captureNiriWindow(socketPath);
160 windows = [1, 2].map((id) => ({ ...window, id, pid: process.pid, title: "T3 Code" }));
161 await expect(snapshot.feedback!.activate("T3 Code")).rejects.toThrow("More than one");
162});
163
164it("cancels pending activation when capture feedback is closed", async () => {
165 const snapshot = await captureNiriWindow(socketPath);
166 const started = Promise.withResolvers<void>();
167 const original = handler;
168 handler = async (request, socket) => {
169 await original(request, socket);
170 if (request === "EventStream") started.resolve();
171 };
172 const activation = expect(snapshot.feedback!.activate("T3 Code")).rejects.toThrow("cancelled");
173 await started.promise;
174 snapshot.feedback!.close();
175 await activation;
176 expect(calls.some((call) => typeof call !== "string" && call.Action.FocusWindow)).toBe(false);
177});
178
179it("does not attach accessibility identity if the captured window changed", async () => {
180 windows = [{ ...window, title: "Different document" }];
181 const snapshot = await captureNiriWindow(socketPath);
182 expect(snapshot.png).toEqual(png);
183 expect(snapshot.window).toBeUndefined();
184});
185
186it("fails without a focused window rather than taking the screen", async () => {
187 focused = null;
188 await expect(captureNiriWindow(socketPath)).rejects.toThrow("no focused window");
189 expect(capturePath).toBeUndefined();
190});
191
192it("rejects compositor errors and cleans up its temporary image", async () => {
193 const original = handler;
194 handler = async (request, socket) => {
195 if (typeof request !== "string" && request.Action.ScreenshotWindow) {
196 capturePath = request.Action.ScreenshotWindow.path;
197 send(socket, { Err: "window disappeared" });
198 } else await original(request, socket);
199 };
200 await expect(captureLinuxWindow("com.t3tools.T3Code")).rejects.toThrow("window disappeared");
201 expect(await NodeFSP.stat(NodePath.dirname(capturePath!)).catch(() => undefined)).toBeUndefined();
202});
203
204it.each(["24.11", "25.05", "unknown"])("rejects unsupported Niri version %s", async (value) => {
205 version = value;
206 await expect(checkNiriCaptureSupport(socketPath)).rejects.toThrow("25.11 or newer");
207});
208
209it.each(["25.11", "26.04", "niri 26.04 (abc)"])("accepts Niri version %s", async (value) => {
210 version = value;
211 await checkNiriCaptureSupport(socketPath);
212});
213
214it.each(["garbage\n", "x".repeat(4 * 1024 * 1024 + 1)])(
215 "bounds malformed compositor replies",
216 async (reply) => {
217 handler = async (_request, socket) => {
218 socket.write(reply);
219 };
220 await expect(checkNiriCaptureSupport(socketPath)).rejects.toThrow(/invalid|oversized/);
221 },
222);
223
224it("fails promptly when Niri disconnects", async () => {
225 handler = async (_request, socket) => {
226 socket.end();
227 };
228 await expect(checkNiriCaptureSupport(socketPath)).rejects.toThrow("disconnected");
229});
230
231it("bounds requests even when the compositor never answers", async () => {
232 vi.useFakeTimers();
233 const received = Promise.withResolvers<void>();
234 handler = async () => received.resolve();
235 const result = expect(checkNiriCaptureSupport(socketPath)).rejects.toThrow("timed out");
236 await received.promise;
237 await vi.advanceTimersByTimeAsync(5_000);
238 await result;
239});
240
241it("does not use stale, relative, or sandboxed Niri sockets", () => {
242 expect(niriSocketPath()).toBe(socketPath);
243 expect(niriSocketPath({ XDG_CURRENT_DESKTOP: "GNOME", NIRI_SOCKET: socketPath })).toBeUndefined();
244 expect(niriSocketPath({ XDG_CURRENT_DESKTOP: "niri", NIRI_SOCKET: "relative" })).toBeUndefined();
245 expect(
246 niriSocketPath({ XDG_CURRENT_DESKTOP: "niri", NIRI_SOCKET: socketPath, FLATPAK_ID: "app" }),
247 ).toBeUndefined();
248});