@mediar-ai/terminator 0.23.36 → 0.23.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,341 @@
1
+ //! Window manager bindings for Node.js
2
+
3
+ use napi_derive::napi;
4
+ use std::sync::Arc;
5
+
6
+ #[cfg(target_os = "windows")]
7
+ use terminator::WindowManager as RustWindowManager;
8
+
9
+ /// Information about a window
10
+ #[napi(object)]
11
+ pub struct WindowInfo {
12
+ /// Window handle
13
+ pub hwnd: i64,
14
+ /// Process name (e.g., "notepad.exe")
15
+ pub process_name: String,
16
+ /// Process ID
17
+ pub process_id: u32,
18
+ /// Z-order position (0 = topmost)
19
+ pub z_order: u32,
20
+ /// Whether the window is minimized
21
+ pub is_minimized: bool,
22
+ /// Whether the window is maximized
23
+ pub is_maximized: bool,
24
+ /// Whether the window has WS_EX_TOPMOST style
25
+ pub is_always_on_top: bool,
26
+ /// Window title
27
+ pub title: String,
28
+ }
29
+
30
+ #[cfg(target_os = "windows")]
31
+ impl From<terminator::WindowInfo> for WindowInfo {
32
+ fn from(info: terminator::WindowInfo) -> Self {
33
+ Self {
34
+ hwnd: info.hwnd as i64,
35
+ process_name: info.process_name,
36
+ process_id: info.process_id,
37
+ z_order: info.z_order,
38
+ is_minimized: info.is_minimized,
39
+ is_maximized: info.is_maximized,
40
+ is_always_on_top: info.is_always_on_top,
41
+ title: info.title,
42
+ }
43
+ }
44
+ }
45
+
46
+ /// Window manager for controlling window states
47
+ ///
48
+ /// Provides functionality for:
49
+ /// - Enumerating windows with Z-order tracking
50
+ /// - Bringing windows to front (bypassing Windows focus-stealing prevention)
51
+ /// - Minimizing/maximizing windows
52
+ /// - Capturing and restoring window states for workflows
53
+ #[napi]
54
+ pub struct WindowManager {
55
+ #[cfg(target_os = "windows")]
56
+ inner: Arc<RustWindowManager>,
57
+ }
58
+
59
+ #[napi]
60
+ impl Default for WindowManager {
61
+ fn default() -> Self {
62
+ Self::new()
63
+ }
64
+ }
65
+
66
+ #[napi]
67
+ impl WindowManager {
68
+ /// Create a new WindowManager instance
69
+ #[napi(constructor)]
70
+ pub fn new() -> Self {
71
+ Self {
72
+ #[cfg(target_os = "windows")]
73
+ inner: Arc::new(RustWindowManager::new()),
74
+ }
75
+ }
76
+
77
+ /// Update window cache with current window information
78
+ #[napi]
79
+ pub async fn update_window_cache(&self) -> napi::Result<()> {
80
+ #[cfg(target_os = "windows")]
81
+ {
82
+ self.inner.update_window_cache().await.map_err(|e| {
83
+ napi::Error::from_reason(format!("Failed to update window cache: {}", e))
84
+ })
85
+ }
86
+ #[cfg(not(target_os = "windows"))]
87
+ {
88
+ Err(napi::Error::from_reason(
89
+ "WindowManager is only supported on Windows",
90
+ ))
91
+ }
92
+ }
93
+
94
+ /// Get topmost window for a process by name
95
+ #[napi]
96
+ pub async fn get_topmost_window_for_process(
97
+ &self,
98
+ process: String,
99
+ ) -> napi::Result<Option<WindowInfo>> {
100
+ #[cfg(target_os = "windows")]
101
+ {
102
+ Ok(self
103
+ .inner
104
+ .get_topmost_window_for_process(&process)
105
+ .await
106
+ .map(WindowInfo::from))
107
+ }
108
+ #[cfg(not(target_os = "windows"))]
109
+ {
110
+ Err(napi::Error::from_reason(
111
+ "WindowManager is only supported on Windows",
112
+ ))
113
+ }
114
+ }
115
+
116
+ /// Get topmost window for a specific PID
117
+ #[napi]
118
+ pub async fn get_topmost_window_for_pid(&self, pid: u32) -> napi::Result<Option<WindowInfo>> {
119
+ #[cfg(target_os = "windows")]
120
+ {
121
+ Ok(self
122
+ .inner
123
+ .get_topmost_window_for_pid(pid)
124
+ .await
125
+ .map(WindowInfo::from))
126
+ }
127
+ #[cfg(not(target_os = "windows"))]
128
+ {
129
+ Err(napi::Error::from_reason(
130
+ "WindowManager is only supported on Windows",
131
+ ))
132
+ }
133
+ }
134
+
135
+ /// Get all visible always-on-top windows
136
+ #[napi]
137
+ pub async fn get_always_on_top_windows(&self) -> napi::Result<Vec<WindowInfo>> {
138
+ #[cfg(target_os = "windows")]
139
+ {
140
+ Ok(self
141
+ .inner
142
+ .get_always_on_top_windows()
143
+ .await
144
+ .into_iter()
145
+ .map(WindowInfo::from)
146
+ .collect())
147
+ }
148
+ #[cfg(not(target_os = "windows"))]
149
+ {
150
+ Err(napi::Error::from_reason(
151
+ "WindowManager is only supported on Windows",
152
+ ))
153
+ }
154
+ }
155
+
156
+ /// Minimize only always-on-top windows (excluding target)
157
+ /// Returns the number of windows minimized
158
+ #[napi]
159
+ pub async fn minimize_always_on_top_windows(&self, target_hwnd: i64) -> napi::Result<u32> {
160
+ #[cfg(target_os = "windows")]
161
+ {
162
+ self.inner
163
+ .minimize_always_on_top_windows(target_hwnd as isize)
164
+ .await
165
+ .map_err(|e| {
166
+ napi::Error::from_reason(format!(
167
+ "Failed to minimize always-on-top windows: {}",
168
+ e
169
+ ))
170
+ })
171
+ }
172
+ #[cfg(not(target_os = "windows"))]
173
+ {
174
+ Err(napi::Error::from_reason(
175
+ "WindowManager is only supported on Windows",
176
+ ))
177
+ }
178
+ }
179
+
180
+ /// Minimize all visible windows except the target
181
+ #[napi]
182
+ pub async fn minimize_all_except(&self, target_hwnd: i64) -> napi::Result<u32> {
183
+ #[cfg(target_os = "windows")]
184
+ {
185
+ self.inner
186
+ .minimize_all_except(target_hwnd as isize)
187
+ .await
188
+ .map_err(|e| napi::Error::from_reason(format!("Failed to minimize windows: {}", e)))
189
+ }
190
+ #[cfg(not(target_os = "windows"))]
191
+ {
192
+ Err(napi::Error::from_reason(
193
+ "WindowManager is only supported on Windows",
194
+ ))
195
+ }
196
+ }
197
+
198
+ /// Maximize window if not already maximized
199
+ /// Returns true if the window was maximized (wasn't already maximized)
200
+ #[napi]
201
+ pub async fn maximize_if_needed(&self, hwnd: i64) -> napi::Result<bool> {
202
+ #[cfg(target_os = "windows")]
203
+ {
204
+ self.inner
205
+ .maximize_if_needed(hwnd as isize)
206
+ .await
207
+ .map_err(|e| napi::Error::from_reason(format!("Failed to maximize window: {}", e)))
208
+ }
209
+ #[cfg(not(target_os = "windows"))]
210
+ {
211
+ Err(napi::Error::from_reason(
212
+ "WindowManager is only supported on Windows",
213
+ ))
214
+ }
215
+ }
216
+
217
+ /// Bring window to front using AttachThreadInput trick
218
+ ///
219
+ /// This uses AttachThreadInput to bypass Windows' focus-stealing prevention.
220
+ /// Returns true if the window is now in the foreground.
221
+ #[napi]
222
+ pub async fn bring_window_to_front(&self, hwnd: i64) -> napi::Result<bool> {
223
+ #[cfg(target_os = "windows")]
224
+ {
225
+ self.inner
226
+ .bring_window_to_front(hwnd as isize)
227
+ .await
228
+ .map_err(|e| {
229
+ napi::Error::from_reason(format!("Failed to bring window to front: {}", e))
230
+ })
231
+ }
232
+ #[cfg(not(target_os = "windows"))]
233
+ {
234
+ Err(napi::Error::from_reason(
235
+ "WindowManager is only supported on Windows",
236
+ ))
237
+ }
238
+ }
239
+
240
+ /// Minimize window if not already minimized
241
+ /// Returns true if the window was minimized (wasn't already minimized)
242
+ #[napi]
243
+ pub async fn minimize_if_needed(&self, hwnd: i64) -> napi::Result<bool> {
244
+ #[cfg(target_os = "windows")]
245
+ {
246
+ self.inner
247
+ .minimize_if_needed(hwnd as isize)
248
+ .await
249
+ .map_err(|e| napi::Error::from_reason(format!("Failed to minimize window: {}", e)))
250
+ }
251
+ #[cfg(not(target_os = "windows"))]
252
+ {
253
+ Err(napi::Error::from_reason(
254
+ "WindowManager is only supported on Windows",
255
+ ))
256
+ }
257
+ }
258
+
259
+ /// Capture current state before workflow
260
+ #[napi]
261
+ pub async fn capture_initial_state(&self) -> napi::Result<()> {
262
+ #[cfg(target_os = "windows")]
263
+ {
264
+ self.inner.capture_initial_state().await.map_err(|e| {
265
+ napi::Error::from_reason(format!("Failed to capture initial state: {}", e))
266
+ })
267
+ }
268
+ #[cfg(not(target_os = "windows"))]
269
+ {
270
+ Err(napi::Error::from_reason(
271
+ "WindowManager is only supported on Windows",
272
+ ))
273
+ }
274
+ }
275
+
276
+ /// Restore windows that were minimized and target window to their original state
277
+ /// Returns the number of windows restored
278
+ #[napi]
279
+ pub async fn restore_all_windows(&self) -> napi::Result<u32> {
280
+ #[cfg(target_os = "windows")]
281
+ {
282
+ self.inner
283
+ .restore_all_windows()
284
+ .await
285
+ .map_err(|e| napi::Error::from_reason(format!("Failed to restore windows: {}", e)))
286
+ }
287
+ #[cfg(not(target_os = "windows"))]
288
+ {
289
+ Err(napi::Error::from_reason(
290
+ "WindowManager is only supported on Windows",
291
+ ))
292
+ }
293
+ }
294
+
295
+ /// Clear captured state
296
+ #[napi]
297
+ pub async fn clear_captured_state(&self) -> napi::Result<()> {
298
+ #[cfg(target_os = "windows")]
299
+ {
300
+ self.inner.clear_captured_state().await;
301
+ Ok(())
302
+ }
303
+ #[cfg(not(target_os = "windows"))]
304
+ {
305
+ Err(napi::Error::from_reason(
306
+ "WindowManager is only supported on Windows",
307
+ ))
308
+ }
309
+ }
310
+
311
+ /// Check if a process is a UWP/Modern app
312
+ #[napi]
313
+ pub async fn is_uwp_app(&self, pid: u32) -> napi::Result<bool> {
314
+ #[cfg(target_os = "windows")]
315
+ {
316
+ Ok(self.inner.is_uwp_app(pid).await)
317
+ }
318
+ #[cfg(not(target_os = "windows"))]
319
+ {
320
+ Err(napi::Error::from_reason(
321
+ "WindowManager is only supported on Windows",
322
+ ))
323
+ }
324
+ }
325
+
326
+ /// Track a window as the target for restoration
327
+ #[napi]
328
+ pub async fn set_target_window(&self, hwnd: i64) -> napi::Result<()> {
329
+ #[cfg(target_os = "windows")]
330
+ {
331
+ self.inner.set_target_window(hwnd as isize).await;
332
+ Ok(())
333
+ }
334
+ #[cfg(not(target_os = "windows"))]
335
+ {
336
+ Err(napi::Error::from_reason(
337
+ "WindowManager is only supported on Windows",
338
+ ))
339
+ }
340
+ }
341
+ }
@@ -0,0 +1,243 @@
1
+ const { Desktop } = require("../index.js");
2
+
3
+ /**
4
+ * Cross-Application Verification Test Suite
5
+ * Tests verification methods across multiple Windows built-in applications
6
+ * These apps are available on all Windows 10/11 computers
7
+ */
8
+
9
+ const desktop = new Desktop();
10
+
11
+ // Test configuration for each app
12
+ const APP_TESTS = [
13
+ {
14
+ name: "Notepad",
15
+ launch: "notepad.exe",
16
+ existsSelector: "role:Document || role:Edit",
17
+ existsDescription: "text editor area",
18
+ menuBarSelector: "role:MenuBar",
19
+ notExistsSelector: "role:Window && name:Save As",
20
+ notExistsDescription: "Save As dialog",
21
+ required: true,
22
+ },
23
+ {
24
+ name: "Calculator",
25
+ launch: "calc",
26
+ existsSelector: "role:Button && name:Zero",
27
+ existsDescription: "Zero button",
28
+ menuBarSelector: null, // Calculator has no traditional menu bar
29
+ notExistsSelector: "role:Button && name:NonExistentCalcButton",
30
+ notExistsDescription: "non-existent button",
31
+ required: true,
32
+ },
33
+ {
34
+ name: "Google Chrome",
35
+ launch: "chrome",
36
+ existsSelector: "role:Document || role:Pane",
37
+ existsDescription: "browser content area",
38
+ menuBarSelector: null, // Chrome uses toolbar, not menu bar
39
+ notExistsSelector: "role:Window && name:Downloads Complete",
40
+ notExistsDescription: "Downloads Complete dialog",
41
+ required: false, // Chrome may not be installed
42
+ },
43
+ {
44
+ name: "Microsoft Edge",
45
+ launch: "msedge",
46
+ existsSelector: "role:Document || role:Pane",
47
+ existsDescription: "browser content area",
48
+ menuBarSelector: null, // Edge uses toolbar, not menu bar
49
+ notExistsSelector: "role:Window && name:Downloads Complete",
50
+ notExistsDescription: "Downloads Complete dialog",
51
+ required: false, // Edge can fail if already open or slow to launch
52
+ },
53
+ {
54
+ name: "File Explorer",
55
+ launch: "explorer",
56
+ existsSelector: "role:Pane",
57
+ existsDescription: "content pane",
58
+ menuBarSelector: null, // Modern Explorer uses ribbon
59
+ notExistsSelector: "role:Window && name:Confirm Delete",
60
+ notExistsDescription: "Confirm Delete dialog",
61
+ required: false, // Explorer has complex window structure, can be flaky
62
+ },
63
+ ];
64
+
65
+ async function closeApp(appElement, appName) {
66
+ try {
67
+ await appElement.pressKey("Alt+F4");
68
+ await new Promise(r => setTimeout(r, 500));
69
+
70
+ // Handle save dialogs if they appear
71
+ try {
72
+ const dontSave = await appElement.locator("role:Button && name:Don\\'t Save").first(1000);
73
+ await dontSave.click();
74
+ } catch {
75
+ // Try "No" button for some dialogs
76
+ try {
77
+ const noBtn = await appElement.locator("role:Button && name:No").first(500);
78
+ await noBtn.click();
79
+ } catch {
80
+ // No dialog, that's fine
81
+ }
82
+ }
83
+ } catch (e) {
84
+ console.log(` Note: Could not close ${appName} cleanly: ${e.message.substring(0, 50)}`);
85
+ }
86
+ }
87
+
88
+ async function testApp(config) {
89
+ console.log(`\n ═══ ${config.name} ═══`);
90
+
91
+ let appElement = null;
92
+ let passed = 0;
93
+ let failed = 0;
94
+ let skipped = false;
95
+
96
+ try {
97
+ // Launch app
98
+ console.log(` Launching ${config.name}...`);
99
+ try {
100
+ appElement = await desktop.openApplication(config.launch);
101
+ await new Promise(r => setTimeout(r, 1500)); // Wait for app to stabilize
102
+ } catch (launchError) {
103
+ if (!config.required) {
104
+ console.log(` ⏭️ Skipping ${config.name} (not installed or failed to launch)`);
105
+ return { passed: 0, failed: 0, skipped: true, name: config.name };
106
+ }
107
+ throw launchError;
108
+ }
109
+
110
+ // Test 1: verifyElementExists for known element
111
+ console.log(` Test 1: verifyElementExists (${config.existsDescription})`);
112
+ try {
113
+ const found = await desktop.verifyElementExists(
114
+ appElement,
115
+ config.existsSelector,
116
+ 5000
117
+ );
118
+ if (found) {
119
+ console.log(` ✅ Found ${config.existsDescription}: role=${found.role()}`);
120
+ passed++;
121
+ } else {
122
+ console.log(` ❌ Expected to find ${config.existsDescription}`);
123
+ failed++;
124
+ }
125
+ } catch (e) {
126
+ console.log(` ❌ Error: ${e.message.substring(0, 80)}`);
127
+ failed++;
128
+ }
129
+
130
+ // Test 2: verifyElementExists for MenuBar (if applicable)
131
+ if (config.menuBarSelector) {
132
+ console.log(` Test 2: verifyElementExists (MenuBar)`);
133
+ try {
134
+ const menuBar = await desktop.verifyElementExists(
135
+ appElement,
136
+ config.menuBarSelector,
137
+ 3000
138
+ );
139
+ if (menuBar) {
140
+ console.log(` ✅ Found MenuBar: role=${menuBar.role()}`);
141
+ passed++;
142
+ }
143
+ } catch (e) {
144
+ console.log(` ❌ MenuBar not found: ${e.message.substring(0, 60)}`);
145
+ failed++;
146
+ }
147
+ }
148
+
149
+ // Test 3: verifyElementNotExists for non-existent element
150
+ console.log(` Test 3: verifyElementNotExists (${config.notExistsDescription})`);
151
+ try {
152
+ await desktop.verifyElementNotExists(
153
+ appElement,
154
+ config.notExistsSelector,
155
+ 2000
156
+ );
157
+ console.log(` ✅ Correctly confirmed ${config.notExistsDescription} does not exist`);
158
+ passed++;
159
+ } catch (e) {
160
+ if (e.message.includes("VERIFICATION_FAILED")) {
161
+ console.log(` ❌ Element unexpectedly exists`);
162
+ } else {
163
+ console.log(` ❌ Error: ${e.message.substring(0, 60)}`);
164
+ }
165
+ failed++;
166
+ }
167
+
168
+ // Test 4: Scoped locator search
169
+ console.log(` Test 4: Scoped locator within app window`);
170
+ try {
171
+ const element = await appElement.locator(config.existsSelector).first(3000);
172
+ if (element) {
173
+ console.log(` ✅ Scoped locator found element: role=${element.role()}`);
174
+ passed++;
175
+ }
176
+ } catch (e) {
177
+ console.log(` ❌ Scoped locator failed: ${e.message.substring(0, 60)}`);
178
+ failed++;
179
+ }
180
+
181
+ } finally {
182
+ // Cleanup
183
+ if (appElement) {
184
+ console.log(` Closing ${config.name}...`);
185
+ await closeApp(appElement, config.name);
186
+ }
187
+ }
188
+
189
+ return { passed, failed, name: config.name };
190
+ }
191
+
192
+ async function runTests() {
193
+ console.log("═══════════════════════════════════════════════════════════════════");
194
+ console.log("Cross-Application Verification Test Suite");
195
+ console.log("Tests SDK verification methods across Windows built-in applications");
196
+ console.log("═══════════════════════════════════════════════════════════════════");
197
+
198
+ let totalPassed = 0;
199
+ let totalFailed = 0;
200
+ const results = [];
201
+
202
+ for (const config of APP_TESTS) {
203
+ try {
204
+ const result = await testApp(config);
205
+ totalPassed += result.passed;
206
+ totalFailed += result.failed;
207
+ results.push(result);
208
+ } catch (error) {
209
+ console.log(`\n ❌ ${config.name} test suite crashed: ${error.message}`);
210
+ results.push({ name: config.name, passed: 0, failed: 1, error: error.message });
211
+ totalFailed++;
212
+ }
213
+
214
+ // Brief pause between apps
215
+ await new Promise(r => setTimeout(r, 1000));
216
+ }
217
+
218
+ console.log("\n═══════════════════════════════════════════════════════════════════");
219
+ console.log("Results Summary:");
220
+ console.log("═══════════════════════════════════════════════════════════════════");
221
+
222
+ let totalSkipped = 0;
223
+ for (const result of results) {
224
+ if (result.skipped) {
225
+ console.log(` ⏭️ ${result.name}: SKIPPED (not installed)`);
226
+ totalSkipped++;
227
+ } else {
228
+ const status = result.failed === 0 ? "✅" : "❌";
229
+ console.log(` ${status} ${result.name}: ${result.passed} passed, ${result.failed} failed`);
230
+ }
231
+ }
232
+
233
+ console.log("───────────────────────────────────────────────────────────────────");
234
+ console.log(` TOTAL: ${totalPassed} passed, ${totalFailed} failed, ${totalSkipped} skipped`);
235
+ console.log("═══════════════════════════════════════════════════════════════════");
236
+
237
+ process.exit(totalFailed > 0 ? 1 : 0);
238
+ }
239
+
240
+ runTests().catch(error => {
241
+ console.error("Test suite crashed:", error);
242
+ process.exit(1);
243
+ });