Skip to content
Source
Sign inSign up

NiriSnapShot.test.ts

236 lines9.0 KB

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 focused = { ...window, id: 99, title: "Another app" };
65 await NodeFSP.writeFile(screenshot.path, png);
66 for (const event of events) {
67 send(event, { ScreenshotCaptured: { path: "/another-app.png" } });
68 send(event, { ScreenshotCaptured: { path: screenshot.path } });
69 }
70 send(socket, { Ok: "Handled" });
71 } else send(socket, { Ok: "Handled" });
72 };
73 server = NodeNet.createServer((socket) => {
74 sockets.add(socket);
75 socket.on("close", () => sockets.delete(socket));
76 socket.on("error", () => undefined);
77 socket.setEncoding("utf8");
78 let pending = "";
79 socket.on("data", (chunk: string) => {
80 pending += chunk;
81 let end: number;
82 while ((end = pending.indexOf("\n")) !== -1) {
83 const request = JSON.parse(pending.slice(0, end)) as Request;
84 pending = pending.slice(end + 1);
85 calls.push(request);
86 void handler(request, socket).catch((error: unknown) =>
87 socket.destroy(error instanceof Error ? error : undefined),
88 );
89 }
90 });
91 });
92 await new Promise<void>((resolve, reject) => {
93 server.once("error", reject);
94 server.listen(socketPath, resolve);
95 });
96 vi.stubEnv("NIRI_SOCKET", socketPath);
97 vi.stubEnv("XDG_CURRENT_DESKTOP", "niri");
98 vi.stubEnv("FLATPAK_ID", "");
99 vi.stubEnv("SNAP", "");
100});
101afterEach(async () => {
102 vi.useRealTimers();
103 vi.unstubAllEnvs();
104 for (const socket of sockets) socket.destroy();
105 await new Promise<void>((resolve) => server.close(() => resolve()));
106 await NodeFSP.rm(directory, { recursive: true, force: true });
107});
108​
109it("selects the native adapter without needing a portal or GNOME extension", async () => {
110 expect(await getLinuxCaptureSupport("com.t3tools.T3Code")).toEqual({
111 linuxBackend: "niri",
112 linuxFeedbackAvailable: false,
113 });
114 const snapshot = await captureLinuxWindow("com.t3tools.T3Code");
115 expect(snapshot?.png).toEqual(png);
116 expect(snapshot?.window).toMatchObject({
117 processId: 123,
118 title: "Editor",
119 accessibilityBoundsReliable: false,
120 });
121 expect(calls).toContainEqual({
122 Action: {
123 ScreenshotWindow: { id: 42, path: capturePath, write_to_disk: true, show_pointer: false },
124 },
125 });
126 expect(snapshot?.feedback?.animationStarted).toBe(false);
127 expect(await NodeFSP.stat(NodePath.dirname(capturePath!)).catch(() => undefined)).toBeUndefined();
128});
129​
130it("does not activate T3 until requested, then matches PID and title", async () => {
131 const snapshot = await captureNiriWindow(socketPath);
132 expect(calls.some((call) => typeof call !== "string" && call.Action.FocusWindow)).toBe(false);
133 windows = [
134 { ...window, id: 1, pid: 999, title: "T3 Code" },
135 { ...window, id: 2, pid: process.pid, title: "Other T3" },
136 { ...window, id: 3, pid: process.pid, title: "T3 Code" },
137 ];
138 await snapshot.feedback!.activate("T3 Code");
139 expect(calls).toContainEqual({ Action: { FocusWindow: { id: 3 } } });
140});
141​
142it("waits for the restored T3 window to map instead of polling", async () => {
143 const snapshot = await captureNiriWindow(socketPath);
144 const original = handler;
145 handler = async (request, socket) => {
146 await original(request, socket);
147 if (request === "EventStream")
148 send(socket, {
149 WindowOpenedOrChanged: { window: { ...window, id: 4, pid: process.pid, title: "T3 Code" } },
150 });
151 };
152 await snapshot.feedback!.activate("T3 Code");
153 expect(calls).toContainEqual({ Action: { FocusWindow: { id: 4 } } });
154});
155​
156it("rejects ambiguous activation targets", async () => {
157 const snapshot = await captureNiriWindow(socketPath);
158 windows = [1, 2].map((id) => ({ ...window, id, pid: process.pid, title: "T3 Code" }));
159 await expect(snapshot.feedback!.activate("T3 Code")).rejects.toThrow("More than one");
160});
161​
162it("cancels pending activation when capture feedback is closed", async () => {
163 const snapshot = await captureNiriWindow(socketPath);
164 const started = Promise.withResolvers<void>();
165 const original = handler;
166 handler = async (request, socket) => {
167 await original(request, socket);
168 if (request === "EventStream") started.resolve();
169 };
170 const activation = expect(snapshot.feedback!.activate("T3 Code")).rejects.toThrow("cancelled");
171 await started.promise;
172 snapshot.feedback!.close();
173 await activation;
174 expect(calls.some((call) => typeof call !== "string" && call.Action.FocusWindow)).toBe(false);
175});
176​
177it("does not attach accessibility identity if the captured window changed", async () => {
178 windows = [{ ...window, title: "Different document" }];
179 const snapshot = await captureNiriWindow(socketPath);
180 expect(snapshot.png).toEqual(png);
181 expect(snapshot.window).toBeUndefined();
182});
183​
184it("fails without a focused window rather than taking the screen", async () => {
185 focused = null;
186 await expect(captureNiriWindow(socketPath)).rejects.toThrow("no focused window");
187 expect(capturePath).toBeUndefined();
188});
189​
190it("rejects compositor errors and cleans up its temporary image", async () => {
191 const original = handler;
192 handler = async (request, socket) => {
193 if (typeof request !== "string" && request.Action.ScreenshotWindow) {
194 capturePath = request.Action.ScreenshotWindow.path;
195 send(socket, { Err: "window disappeared" });
196 } else await original(request, socket);
197 };
198 await expect(captureLinuxWindow("com.t3tools.T3Code")).rejects.toThrow("window disappeared");
199 expect(await NodeFSP.stat(NodePath.dirname(capturePath!)).catch(() => undefined)).toBeUndefined();
200});
201​
202it.each(["24.11", "25.05", "unknown"])("rejects unsupported Niri version %s", async (value) => {
203 version = value;
204 await expect(checkNiriCaptureSupport(socketPath)).rejects.toThrow("25.11 or newer");
205});
206​
207it.each(["25.11", "26.04", "niri 26.04 (abc)"])("accepts Niri version %s", async (value) => {
208 version = value;
209 await checkNiriCaptureSupport(socketPath);
210});
211​
212it.each(["garbage\n", "x".repeat(4 * 1024 * 1024 + 1)])(
213 "bounds malformed compositor replies",
214 async (reply) => {
215 handler = async (_request, socket) => {
216 socket.write(reply);
217 };
218 await expect(checkNiriCaptureSupport(socketPath)).rejects.toThrow(/invalid|oversized/);
219 },
220);
221​
222it("fails promptly when Niri disconnects", async () => {
223 handler = async (_request, socket) => {
224 socket.end();
225 };
226 await expect(checkNiriCaptureSupport(socketPath)).rejects.toThrow("disconnected");
227});
228​
229it("does not use stale, relative, or sandboxed Niri sockets", () => {
230 expect(niriSocketPath()).toBe(socketPath);
231 expect(niriSocketPath({ XDG_CURRENT_DESKTOP: "GNOME", NIRI_SOCKET: socketPath })).toBeUndefined();
232 expect(niriSocketPath({ XDG_CURRENT_DESKTOP: "niri", NIRI_SOCKET: "relative" })).toBeUndefined();
233 expect(
234 niriSocketPath({ XDG_CURRENT_DESKTOP: "niri", NIRI_SOCKET: socketPath, FLATPAK_ID: "app" }),
235 ).toBeUndefined();
236});