| 1 | import { beforeEach, expect, it, vi } from "vite-plus/test"; |
| 2 | |
| 3 | const 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 | })); |
| 14 | vi.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 | })); |
| 32 | vi.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 | })); |
| 44 | vi.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 | })); |
| 56 | vi.mock("gi://Meta", () => ({ default: { WindowType: { NORMAL: 0 } } })); |
| 57 | vi.mock("gi://St", () => ({ |
| 58 | default: { Settings: { get: () => ({ enable_animations: shell.animations }) } }, |
| 59 | })); |
| 60 | vi.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 | |
| 71 | import { CaptureFeedback } from "./captureFeedback.js"; |
| 72 | |
| 73 | beforeEach(() => { |
| 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 | |
| 96 | const bounds = { x: -1200, y: 20, width: 1000, height: 800 }; |
| 97 | function 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 | } |
| 107 | function 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 | |
| 117 | it("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 | |
| 137 | it("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 | |
| 154 | it("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 | |
| 165 | it("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 | |
| 175 | it("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 | }); |