1import { beforeEach, expect, it, vi } from "vite-plus/test";
2
3const shell = vi.hoisted(() => ({
4 windows: [],
5 actors: [],
6 timers: new Map(),
7 watchers: new Map(),
8 mapped: new Map(),
9 next: 1,
10 animations: true,
11 locked: false,
12 activate: vi.fn(),
13}));
14vi.mock("gi://Clutter", () => ({
15 default: {
16 Actor: class {
17 constructor(props) {
18 Object.assign(this, props);
19 shell.actors.push(this);
20 }
21 ease(props) {
22 this.transition = props;
23 }
24 destroy() {
25 this.destroyed = true;
26 this.transition?.onStopped?.();
27 }
28 },
29 AnimationMode: { EASE_OUT_QUAD: 1, EASE_OUT_CUBIC: 2 },
30 },
31}));
32vi.mock("gi://Gio", () => ({
33 default: {
34 DBus: { session: {} },
35 BusNameWatcherFlags: { NONE: 0 },
36 bus_watch_name_on_connection: (_bus, name, _flags, _appeared, vanished) => {
37 const id = shell.next++;
38 shell.watchers.set(id, { name, vanished });
39 return id;
40 },
41 bus_unwatch_name: (id) => shell.watchers.delete(id),
42 },
43}));
44vi.mock("gi://GLib", () => ({
45 default: {
46 PRIORITY_DEFAULT: 0,
47 SOURCE_REMOVE: false,
48 timeout_add: (_priority, _ms, callback) => {
49 const id = shell.next++;
50 shell.timers.set(id, callback);
51 return id;
52 },
53 source_remove: (id) => shell.timers.delete(id),
54 },
55}));
56vi.mock("gi://Meta", () => ({ default: { WindowType: { NORMAL: 0 } } }));
57vi.mock("gi://St", () => ({
58 default: { Settings: { get: () => ({ enable_animations: shell.animations }) } },
59}));
60vi.mock("resource:///org/gnome/shell/ui/main.js", () => ({
61 sessionMode: {
62 get isLocked() {
63 return shell.locked;
64 },
65 isGreeter: false,
66 },
67 uiGroup: { add_child: () => {} },
68 activateWindow: shell.activate,
69}));
70
71import { CaptureFeedback } from "./captureFeedback.js";
72
73beforeEach(() => {
74 shell.windows = [];
75 shell.actors = [];
76 shell.timers.clear();
77 shell.watchers.clear();
78 shell.mapped.clear();
79 shell.activate.mockClear();
80 shell.animations = true;
81 shell.locked = false;
82 Object.assign(globalThis, {
83 get_window_actors: () => shell.windows.map((meta_window) => ({ meta_window })),
84 get_current_time: () => 123,
85 window_manager: {
86 connect: (_signal, callback) => {
87 const id = shell.next++;
88 shell.mapped.set(id, callback);
89 return id;
90 },
91 disconnect: (id) => shell.mapped.delete(id),
92 },
93 });
94});
95
96const bounds = { x: -1200, y: 20, width: 1000, height: 800 };
97function begin(animate = true) {
98 const feedback = new CaptureFeedback();
99 const started = feedback.begin(
100 ":1.23",
101 42,
102 { content: "frozen pixels", bounds, bufferBounds: bounds },
103 { flash: false, animate },
104 );
105 return { feedback, started };
106}
107function target(pid = 42) {
108 return {
109 get_pid: () => pid,
110 get_title: () => "T3",
111 get_window_type: () => 0,
112 get_frame_rect: () => bounds,
113 frame_rect_to_client_rect: (rect) => rect,
114 };
115}
116
117it("activates the caller, flies its frozen capture, and retains the landed image until disconnect", async () => {
118 const window = target();
119 shell.windows = [target(99), window];
120 const { feedback, started } = begin();
121 expect(started).toBe(true);
122 await feedback.activate(":1.23", "T3");
123 expect(shell.activate).toHaveBeenCalledWith(window, 123);
124 const flight = feedback.animate(":1.23", { x: 0.1, y: 0.8, width: 0.2, height: 0.1 });
125 const actor = shell.actors[0];
126 expect(actor.content).toBe("frozen pixels");
127 expect(actor.transition).toMatchObject({ x: -1100, y: 660, scale_x: 0.2, scale_y: 0.1 });
128 actor.transition.onStopped();
129 await flight;
130 expect(actor.destroyed).not.toBe(true);
131 for (const { vanished } of shell.watchers.values()) vanished();
132 expect(actor.destroyed).toBe(true);
133 expect(shell.watchers.size).toBe(0);
134 expect(shell.timers.size).toBe(0);
135});
136
137it("waits for T3 to remap and cancels that wait when disabled", async () => {
138 const { feedback } = begin();
139 const activation = feedback.activate(":1.23", "T3");
140 expect(shell.activate).not.toHaveBeenCalled();
141 shell.windows = [target()];
142 for (const mapped of shell.mapped.values()) mapped();
143 await activation;
144 expect(shell.activate).toHaveBeenCalledOnce();
145 feedback.dispose();
146 shell.windows = [];
147 const next = begin().feedback;
148 const cancelled = next.activate(":1.23", "T3");
149 next.dispose();
150 await expect(cancelled).rejects.toThrow("No active capture");
151 expect(shell.mapped.size).toBe(0);
152});
153
154it("does not animate under reduced motion but still activates T3", async () => {
155 shell.animations = false;
156 shell.windows = [target()];
157 const { feedback, started } = begin();
158 expect(started).toBe(false);
159 expect(shell.actors).toHaveLength(0);
160 await feedback.activate(":1.23", "T3");
161 expect(shell.activate).toHaveBeenCalledOnce();
162 feedback.dispose();
163});
164
165it("rejects another sender and locked-session activation", async () => {
166 shell.windows = [target()];
167 const { feedback } = begin();
168 await expect(feedback.activate(":1.99", "T3")).rejects.toThrow("No active capture");
169 shell.locked = true;
170 await expect(feedback.activate(":1.23", "T3")).rejects.toThrow("No active capture");
171 expect(shell.activate).not.toHaveBeenCalled();
172 feedback.dispose();
173});
174
175it("disposes an abandoned flight and resolves its pending response", async () => {
176 shell.windows = [target()];
177 const { feedback } = begin();
178 await feedback.activate(":1.23", "T3");
179 const flight = feedback.animate(":1.23", { x: 0.1, y: 0.8, width: 0.2, height: 0.1 });
180 const callbacks = [...shell.timers.values()];
181 shell.timers.clear();
182 for (const timeout of callbacks) timeout();
183 await flight;
184 expect(shell.actors.every((actor) => actor.destroyed)).toBe(true);
185 expect(shell.watchers.size).toBe(0);
186});