@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.
- package/Cargo.toml +8 -0
- package/build.rs +5 -0
- package/index.d.ts +801 -31
- package/index.js +7 -1
- package/package.json +6 -6
- package/src/desktop.rs +2087 -37
- package/src/element.rs +435 -24
- package/src/exceptions.rs +7 -0
- package/src/lib.rs +10 -4
- package/src/locator.rs +3 -3
- package/src/selector.rs +19 -0
- package/src/types.rs +521 -7
- package/src/window_manager.rs +341 -0
- package/tests/cross-app-verification.test.js +243 -0
- package/tests/desktop-verify.test.js +169 -0
- package/wrapper.js +32 -4
- package/wrapper.ts +48 -3
|
@@ -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
|
+
});
|