| 1 | // @effect-diagnostics nodeBuiltinImport:off -- Private Unix sockets exercise the real compositor transport. |
| 2 | import * as NodeFSP from "node:fs/promises"; |
| 3 | import * as NodeNet from "node:net"; |
| 4 | import * as NodeOS from "node:os"; |
| 5 | import * as NodePath from "node:path"; |
| 6 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; |
| 7 | |
| 8 | vi.mock("electron", () => ({ |
| 9 | nativeImage: { |
| 10 | createFromBuffer: () => ({ |
| 11 | isEmpty: () => false, |
| 12 | getSize: () => ({ width: 800, height: 600 }), |
| 13 | }), |
| 14 | }, |
| 15 | })); |
| 16 | import { captureNiriWindow, checkNiriCaptureSupport, niriSocketPath } from "./NiriSnapShot.ts"; |
| 17 | import { captureLinuxWindow, getLinuxCaptureSupport } from "./LinuxSnapShot.ts"; |
| 18 | |
| 19 | const png = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0]); |
| 20 | const window = { |
| 21 | id: 42, |
| 22 | title: "Editor", |
| 23 | app_id: "org.example.Editor", |
| 24 | pid: 123, |
| 25 | layout: { window_size: [800, 600] }, |
| 26 | }; |
| 27 | type Request = |
| 28 | | string |
| 29 | | { Action: { ScreenshotWindow?: { id: number; path: string }; FocusWindow?: { id: number } } }; |
| 30 | let directory: string; |
| 31 | let socketPath: string; |
| 32 | let server: NodeNet.Server; |
| 33 | let sockets: Set<NodeNet.Socket>; |
| 34 | let events: NodeNet.Socket[]; |
| 35 | let calls: Request[]; |
| 36 | let handler: (request: Request, socket: NodeNet.Socket) => Promise<void>; |
| 37 | let focused: typeof window | null; |
| 38 | let windows: (typeof window)[]; |
| 39 | let capturePath: string | undefined; |
| 40 | let version: string; |
| 41 | const send = (socket: NodeNet.Socket, value: unknown) => socket.write(`${JSON.stringify(value)}\n`); |
| 42 | |
| 43 | beforeEach(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 | }); |
| 103 | afterEach(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 | |
| 111 | it("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 | |
| 132 | it("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 | |
| 144 | it("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 | |
| 158 | it("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 | |
| 164 | it("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 | |
| 179 | it("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 | |
| 186 | it("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 | |
| 192 | it("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 | |
| 204 | it.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 | |
| 209 | it.each(["25.11", "26.04", "niri 26.04 (abc)"])("accepts Niri version %s", async (value) => { |
| 210 | version = value; |
| 211 | await checkNiriCaptureSupport(socketPath); |
| 212 | }); |
| 213 | |
| 214 | it.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 | |
| 224 | it("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 | |
| 231 | it("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 | |
| 241 | it("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 | }); |