appium-uiwatchers-plugin 1.0.0

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/src/plugin.ts ADDED
@@ -0,0 +1,437 @@
1
+ import { BasePlugin } from '@appium/base-plugin';
2
+ import { logger } from '@appium/support';
3
+ import { WatcherStore } from './watcher-store.js';
4
+ import { ElementReferenceCache } from './element-cache.js';
5
+ import { registerUIWatcher } from './commands/register.js';
6
+ import { unregisterUIWatcher } from './commands/unregister.js';
7
+ import { clearAllUIWatchers } from './commands/clear.js';
8
+ import { listUIWatchers } from './commands/list.js';
9
+ import { disableUIWatchers, enableUIWatchers } from './commands/toggle.js';
10
+ import { checkWatchers } from './watcher-checker.js';
11
+ import { DefaultPluginConfig, type PluginConfig } from './config.js';
12
+ import { extractElementId } from './utils.js';
13
+
14
+ const log = logger.getLogger('AppiumUIWatchers');
15
+
16
+ /**
17
+ * Element action commands to intercept for StaleElement recovery
18
+ * These are Appium command names that operate on elements
19
+ */
20
+ const ELEMENT_ACTION_COMMANDS = [
21
+ 'click',
22
+ 'getText',
23
+ 'getAttribute',
24
+ 'elementDisplayed',
25
+ 'elementEnabled',
26
+ 'elementSelected',
27
+ 'getName',
28
+ 'getLocation',
29
+ 'getSize',
30
+ 'setValue',
31
+ 'setValueImmediate',
32
+ 'clear',
33
+ 'getElementScreenshot',
34
+ 'getElementRect',
35
+ ];
36
+
37
+ /**
38
+ * UIWatchersPlugin - Appium plugin for automatic UI element handling
39
+ *
40
+ * This plugin provides automatic detection and handling of unexpected UI elements
41
+ * (popups, banners, dialogs) during test execution without explicit waits.
42
+ */
43
+ class UIWatchersPlugin extends BasePlugin {
44
+ /** Per-session watcher storage (keyed by sessionId) */
45
+ private stores: Map<string, WatcherStore>;
46
+
47
+ /** Per-session element reference cache (keyed by sessionId) */
48
+ private caches: Map<string, ElementReferenceCache>;
49
+
50
+ /** Plugin configuration */
51
+ private config: Required<PluginConfig>;
52
+
53
+ /**
54
+ * Creates an instance of UIWatchersPlugin
55
+ * @param pluginName - The name of the plugin
56
+ * @param cliArgs - Optional CLI arguments passed to the plugin
57
+ */
58
+ constructor(pluginName: string, cliArgs?: Record<string, unknown>) {
59
+ super(pluginName, cliArgs);
60
+
61
+ this.config = Object.assign({}, DefaultPluginConfig, cliArgs as PluginConfig);
62
+
63
+ this.stores = new Map();
64
+ this.caches = new Map();
65
+ log.info('[UIWatchers] Plugin initialized with config:', this.config);
66
+ }
67
+
68
+ /**
69
+ * Get or create WatcherStore for a specific session
70
+ * @param driver - The driver instance containing sessionId
71
+ * @returns The WatcherStore for this session
72
+ * @throws Error if no sessionId is available
73
+ */
74
+ private getStore(driver: any): WatcherStore {
75
+ const sessionId = driver.sessionId;
76
+ if (!sessionId) {
77
+ throw new Error('No session ID available');
78
+ }
79
+
80
+ if (!this.stores.has(sessionId)) {
81
+ this.stores.set(sessionId, new WatcherStore(this.config));
82
+ log.info(`[UIWatchers] Created watcher store for session ${sessionId}`);
83
+ }
84
+
85
+ return this.stores.get(sessionId)!;
86
+ }
87
+
88
+ /**
89
+ * Get or create ElementReferenceCache for a specific session
90
+ * @param driver - The driver instance containing sessionId
91
+ * @returns The ElementReferenceCache for this session
92
+ * @throws Error if no sessionId is available
93
+ */
94
+ private getCache(driver: any): ElementReferenceCache {
95
+ const sessionId = driver.sessionId;
96
+ if (!sessionId) {
97
+ throw new Error('No session ID available');
98
+ }
99
+
100
+ if (!this.caches.has(sessionId)) {
101
+ this.caches.set(sessionId, new ElementReferenceCache(this.config));
102
+ log.info(`[UIWatchers] Created element cache for session ${sessionId}`);
103
+ }
104
+
105
+ return this.caches.get(sessionId)!;
106
+ }
107
+
108
+ /**
109
+ * Check if error is a StaleElementReferenceException
110
+ */
111
+ private isStaleElementException(error: any): boolean {
112
+ const errorName = error?.name || error?.error || '';
113
+ return (
114
+ errorName.includes('StaleElementReference') ||
115
+ error?.message?.includes('stale element reference') ||
116
+ error?.message?.includes('element is not attached')
117
+ );
118
+ }
119
+
120
+ /**
121
+ * Override execute command to handle mobile: commands
122
+ */
123
+ async execute(next: () => Promise<any>, driver: any, script: string, args: any[]): Promise<any> {
124
+ const params = args && args.length > 0 ? args[0] : null;
125
+ const store = this.getStore(driver);
126
+
127
+ switch (script) {
128
+ case 'mobile: registerUIWatcher':
129
+ return await registerUIWatcher(store, params);
130
+ case 'mobile: unregisterUIWatcher':
131
+ return await unregisterUIWatcher(store, params.name);
132
+ case 'mobile: clearAllUIWatchers':
133
+ return await clearAllUIWatchers(store);
134
+ case 'mobile: listUIWatchers':
135
+ return await listUIWatchers(store);
136
+ case 'mobile: disableUIWatchers':
137
+ return await disableUIWatchers(store);
138
+ case 'mobile: enableUIWatchers':
139
+ return await enableUIWatchers(store);
140
+ default:
141
+ return await next();
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Intercept findElement to check watchers on exceptions and cache on success
147
+ */
148
+ async findElement(next: () => Promise<any>, driver: any, ...args: any[]): Promise<any> {
149
+ const [using, value] = args;
150
+
151
+ try {
152
+ // Try the original findElement
153
+ const result = await next();
154
+
155
+ // On success, cache the element reference for stale element recovery
156
+ if (result && using && value) {
157
+ const elementId = extractElementId(result);
158
+ if (elementId) {
159
+ const cache = this.getCache(driver);
160
+ cache.cacheElement(elementId, using, value);
161
+ }
162
+ }
163
+
164
+ return result;
165
+ } catch (error: any) {
166
+ // Check if this is NoSuchElementException or StaleElementReferenceException
167
+ const errorName = error.name || error.error || '';
168
+ const shouldCheckWatchers =
169
+ errorName.includes('NoSuchElement') ||
170
+ errorName.includes('StaleElementReference') ||
171
+ error.message?.includes('An element could not be located');
172
+
173
+ if (shouldCheckWatchers) {
174
+ log.debug('[UIWatchers] findElement failed, checking watchers');
175
+
176
+ try {
177
+ const store = this.getStore(driver);
178
+ // Check all watchers
179
+ await checkWatchers(driver, store);
180
+
181
+ // Retry findElement once
182
+ log.debug('[UIWatchers] Retrying findElement after watcher checking');
183
+ const result = await next();
184
+
185
+ // Cache the successful retry result
186
+ if (result && using && value) {
187
+ const elementId = extractElementId(result);
188
+ if (elementId) {
189
+ const cache = this.getCache(driver);
190
+ cache.cacheElement(elementId, using, value);
191
+ }
192
+ }
193
+
194
+ return result;
195
+ } catch {
196
+ // If retry also fails, throw the original exception
197
+ log.debug('[UIWatchers] Retry failed, throwing original exception');
198
+ throw error;
199
+ }
200
+ }
201
+
202
+ // Not a handled exception type, re-throw
203
+ throw error;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Intercept findElements to check watchers on empty results and cache on success
209
+ */
210
+ async findElements(next: () => Promise<any>, driver: any, ...args: any[]): Promise<any> {
211
+ const [using, value] = args;
212
+
213
+ try {
214
+ // Try the original findElements
215
+ const result = await next();
216
+
217
+ // Check if result is empty array
218
+ if (Array.isArray(result) && result.length === 0) {
219
+ log.debug('[UIWatchers] findElements returned empty array, checking watchers');
220
+
221
+ try {
222
+ const store = this.getStore(driver);
223
+ // Check all watchers
224
+ await checkWatchers(driver, store);
225
+
226
+ // Retry findElements once
227
+ log.debug('[UIWatchers] Retrying findElements after watcher checking');
228
+ const retryResult = await next();
229
+
230
+ // Cache successful retry results
231
+ if (Array.isArray(retryResult) && retryResult.length > 0 && using && value) {
232
+ const cache = this.getCache(driver);
233
+ cache.cacheElements(retryResult, using, value);
234
+ }
235
+
236
+ return retryResult;
237
+ } catch {
238
+ // If retry fails, return empty array (original result)
239
+ log.debug('[UIWatchers] Retry failed, returning empty array');
240
+ return result;
241
+ }
242
+ }
243
+
244
+ // Non-empty result, cache all elements and return
245
+ if (Array.isArray(result) && result.length > 0 && using && value) {
246
+ const cache = this.getCache(driver);
247
+ cache.cacheElements(result, using, value);
248
+ }
249
+
250
+ return result;
251
+ } catch (error: any) {
252
+ // Exception thrown by findElements
253
+ log.debug('[UIWatchers] findElements threw exception, checking watchers');
254
+
255
+ try {
256
+ const store = this.getStore(driver);
257
+ // Check all watchers
258
+ await checkWatchers(driver, store);
259
+
260
+ // Retry findElements once
261
+ log.debug('[UIWatchers] Retrying findElements after watcher checking');
262
+ const result = await next();
263
+
264
+ // Cache successful retry results
265
+ if (Array.isArray(result) && result.length > 0 && using && value) {
266
+ const cache = this.getCache(driver);
267
+ cache.cacheElements(result, using, value);
268
+ }
269
+
270
+ return result;
271
+ } catch {
272
+ // If retry also fails, throw the original exception
273
+ log.debug('[UIWatchers] Retry failed, throwing original exception');
274
+ throw error;
275
+ }
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Handle method intercepts all Appium commands not specifically handled by named methods
281
+ * We use this to intercept element action commands (click, getText, etc.) for StaleElement recovery
282
+ */
283
+ async handle(
284
+ next: () => Promise<any>,
285
+ driver: any,
286
+ cmdName: string,
287
+ ...args: any[]
288
+ ): Promise<any> {
289
+ // Only intercept element action commands
290
+ if (!ELEMENT_ACTION_COMMANDS.includes(cmdName)) {
291
+ return await next();
292
+ }
293
+
294
+ // Extract elementId from args (first argument for element commands)
295
+ const elementId = args[0];
296
+ if (!elementId) {
297
+ return await next();
298
+ }
299
+
300
+ return this.interceptAction(next, driver, elementId, cmdName, args);
301
+ }
302
+
303
+ /**
304
+ * Intercept element action commands for StaleElement recovery
305
+ */
306
+ private async interceptAction(
307
+ next: () => Promise<any>,
308
+ driver: any,
309
+ elementId: string,
310
+ cmdName: string,
311
+ args: any[]
312
+ ): Promise<any> {
313
+ const cache = this.getCache(driver);
314
+
315
+ // Check for mapped ID first (from previous recovery)
316
+ const actualId = cache.getMappedId(elementId) || elementId;
317
+
318
+ try {
319
+ return await next();
320
+ } catch (error: any) {
321
+ // Only handle StaleElementReferenceException
322
+ if (!this.isStaleElementException(error)) {
323
+ throw error;
324
+ }
325
+
326
+ log.debug(`[UIWatchers] StaleElement on ${cmdName}, checking watchers`);
327
+
328
+ // Step 1: Check watchers FIRST
329
+ const store = this.getStore(driver);
330
+ const watchersTriggered = await checkWatchers(driver, store);
331
+
332
+ // Step 2: If no watchers handled, throw original exception
333
+ if (!watchersTriggered) {
334
+ log.debug('[UIWatchers] No watchers triggered, throwing original exception');
335
+ throw error;
336
+ }
337
+
338
+ // Step 3: Lookup cached reference (only if watchers handled something)
339
+ const ref = cache.getRef(actualId);
340
+ if (!ref) {
341
+ log.error(`[UIWatchers] Element reference not found in cache for ${actualId}`);
342
+ throw new Error(
343
+ 'Element reference not found in cache. Unable to recover from StaleElementReferenceException.'
344
+ );
345
+ }
346
+
347
+ log.debug(
348
+ `[UIWatchers] Attempting to recover element using cached locator (${ref.using}=${ref.value})`
349
+ );
350
+
351
+ // Step 4: Re-find element
352
+ let newElement: any;
353
+ if (ref.source === 'findElement') {
354
+ newElement = await driver.findElement(ref.using, ref.value);
355
+ } else {
356
+ // findElements - need to get element at specific index
357
+ const elements = await driver.findElements(ref.using, ref.value);
358
+ if (elements.length <= ref.index) {
359
+ throw new Error(
360
+ `Element at index ${ref.index} not found in findElements result. Expected at least ${ref.index + 1} elements.`
361
+ );
362
+ }
363
+ newElement = elements[ref.index];
364
+ }
365
+
366
+ // Step 5: Create mapping with transitive update
367
+ const newId = extractElementId(newElement);
368
+ if (newId) {
369
+ cache.setMapping(elementId, newId);
370
+ log.debug(`[UIWatchers] Created element ID mapping: ${elementId} → ${newId}`);
371
+
372
+ // Step 6: Retry action with new element ID
373
+ // Replace elementId in args with newId, then call driver command
374
+ const newArgs = [newId, ...args.slice(1)];
375
+ log.debug(`[UIWatchers] Retrying ${cmdName} with recovered element`);
376
+ return await driver[cmdName](...newArgs);
377
+ }
378
+
379
+ // If we couldn't extract newId, throw original error
380
+ throw error;
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Cleanup watcher store and element cache when session ends normally
386
+ * Appium lifecycle hook called when deleteSession is invoked
387
+ */
388
+ async deleteSession(next: () => Promise<any>, driver: any, ..._args: any[]): Promise<any> {
389
+ const sessionId = driver.sessionId;
390
+
391
+ // Call the original deleteSession first
392
+ const result = await next();
393
+
394
+ // Clean up our session-specific store and cache
395
+ if (sessionId) {
396
+ if (this.stores.has(sessionId)) {
397
+ this.stores.delete(sessionId);
398
+ log.info(`[UIWatchers] Cleaned up watcher store for session ${sessionId}`);
399
+ }
400
+ if (this.caches.has(sessionId)) {
401
+ this.caches.get(sessionId)!.clear();
402
+ this.caches.delete(sessionId);
403
+ log.info(`[UIWatchers] Cleaned up element cache for session ${sessionId}`);
404
+ }
405
+ }
406
+
407
+ return result;
408
+ }
409
+
410
+ /**
411
+ * Cleanup watcher store and element cache when session ends unexpectedly (crash, timeout, etc.)
412
+ * Appium lifecycle hook called on unexpected shutdown
413
+ */
414
+ async onUnexpectedShutdown(driver: any, cause: string): Promise<void> {
415
+ const sessionId = driver.sessionId;
416
+
417
+ if (sessionId) {
418
+ if (this.stores.has(sessionId)) {
419
+ this.stores.delete(sessionId);
420
+ log.info(
421
+ `[UIWatchers] Cleaned up watcher store for session ${sessionId} (cause: ${cause})`
422
+ );
423
+ }
424
+ if (this.caches.has(sessionId)) {
425
+ this.caches.get(sessionId)!.clear();
426
+ this.caches.delete(sessionId);
427
+ log.info(
428
+ `[UIWatchers] Cleaned up element cache for session ${sessionId} (cause: ${cause})`
429
+ );
430
+ }
431
+ }
432
+ }
433
+ }
434
+
435
+ // Export as both default and named export for compatibility
436
+ export { UIWatchersPlugin };
437
+ export default UIWatchersPlugin;
package/src/types.ts ADDED
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Type definitions for Appium UI Watchers Plugin
3
+ */
4
+
5
+ /**
6
+ * Appium locator strategy definition
7
+ * Represents a standard Appium element locator
8
+ */
9
+ export interface Locator {
10
+ /** Locator strategy (e.g., 'id', 'xpath', 'accessibility id') */
11
+ using: string;
12
+ /** Locator value/selector */
13
+ value: string;
14
+ }
15
+
16
+ /**
17
+ * UI Watcher registration parameters
18
+ * These are the parameters passed when registering a new watcher
19
+ */
20
+ export interface UIWatcher {
21
+ /** Unique identifier for the watcher */
22
+ name: string;
23
+
24
+ /**
25
+ * Priority level for watcher execution (higher values checked first)
26
+ * @default 0
27
+ */
28
+ priority?: number;
29
+
30
+ /** Locator for the element to watch for (e.g., popup, banner) */
31
+ referenceLocator: Locator;
32
+
33
+ /** Locator for the element to click when reference is found */
34
+ actionLocator: Locator;
35
+
36
+ /**
37
+ * Time in milliseconds before watcher auto-expires from registration time
38
+ * Must be ≤ 60000ms (1 minute)
39
+ */
40
+ duration: number;
41
+
42
+ /**
43
+ * If true, deactivate watcher after first successful trigger
44
+ * @default false
45
+ */
46
+ stopOnFound?: boolean;
47
+
48
+ /**
49
+ * Cooldown period in milliseconds after successful action click
50
+ * Watcher skipped during cooldown. Ignored if stopOnFound=true
51
+ * @default 0
52
+ */
53
+ cooldownMs?: number;
54
+ }
55
+
56
+ /**
57
+ * Internal watcher state representation
58
+ * Used for managing watcher lifecycle and tracking trigger information
59
+ */
60
+ export interface WatcherState {
61
+ /** Unique identifier for the watcher */
62
+ name: string;
63
+
64
+ /** Priority level for watcher execution */
65
+ priority: number;
66
+
67
+ /** Locator for the element to watch for */
68
+ referenceLocator: Locator;
69
+
70
+ /** Locator for the element to click when reference is found */
71
+ actionLocator: Locator;
72
+
73
+ /** Duration in milliseconds before auto-expiry */
74
+ duration: number;
75
+
76
+ /** If true, deactivate after first trigger */
77
+ stopOnFound: boolean;
78
+
79
+ /** Cooldown period in milliseconds */
80
+ cooldownMs: number;
81
+
82
+ /** Timestamp when watcher was registered (milliseconds since epoch) */
83
+ registeredAt: number;
84
+
85
+ /** Timestamp when watcher expires (milliseconds since epoch) */
86
+ expiresAt: number;
87
+
88
+ /** Current watcher status */
89
+ status: 'active' | 'inactive';
90
+
91
+ /** Number of times watcher has been triggered */
92
+ triggerCount: number;
93
+
94
+ /** Timestamp of last trigger (null if never triggered) */
95
+ lastTriggeredAt: number | null;
96
+ }
97
+
98
+ /**
99
+ * Result returned by registerUIWatcher command
100
+ */
101
+ export interface RegisterWatcherResult {
102
+ /** Operation success status */
103
+ success: boolean;
104
+
105
+ /** Watcher information after registration */
106
+ watcher: {
107
+ name: string;
108
+ priority: number;
109
+ registeredAt: number;
110
+ expiresAt: number;
111
+ status: 'active' | 'inactive';
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Result returned by unregisterUIWatcher command
117
+ */
118
+ export interface UnregisterWatcherResult {
119
+ /** Operation success status */
120
+ success: boolean;
121
+
122
+ /** Name of the removed watcher */
123
+ removed: string;
124
+ }
125
+
126
+ /**
127
+ * Result returned by clearAllUIWatchers command
128
+ */
129
+ export interface ClearAllWatchersResult {
130
+ /** Operation success status */
131
+ success: boolean;
132
+
133
+ /** Number of watchers removed */
134
+ removedCount: number;
135
+ }
136
+
137
+ /**
138
+ * Result returned by listUIWatchers command
139
+ */
140
+ export interface ListWatchersResult {
141
+ /** Operation success status */
142
+ success: boolean;
143
+
144
+ /** List of all active watchers with their state */
145
+ watchers: WatcherState[];
146
+
147
+ /** Total count of watchers */
148
+ totalCount: number;
149
+ }
150
+
151
+ /**
152
+ * Result returned by disableUIWatchers/enableUIWatchers commands
153
+ */
154
+ export interface ToggleWatchersResult {
155
+ /** Operation success status */
156
+ success: boolean;
157
+
158
+ /** Status message */
159
+ message: string;
160
+ }
161
+
162
+ // ============================================================================
163
+ // Element Reference Caching Types (for StaleElement recovery)
164
+ // ============================================================================
165
+
166
+ /**
167
+ * Cached reference for element from findElement
168
+ */
169
+ export interface CachedElementRef {
170
+ /** Locator strategy used to find the element */
171
+ using: string;
172
+
173
+ /** Locator value used to find the element */
174
+ value: string;
175
+
176
+ /** Source operation that found this element */
177
+ source: 'findElement';
178
+
179
+ /** Timestamp when this reference was cached */
180
+ createdAt: number;
181
+ }
182
+
183
+ /**
184
+ * Cached reference for element from findElements
185
+ * Includes the index position in the result array
186
+ */
187
+ export interface CachedElementsRef {
188
+ /** Locator strategy used to find the elements */
189
+ using: string;
190
+
191
+ /** Locator value used to find the elements */
192
+ value: string;
193
+
194
+ /** Index position of this element in the findElements result array */
195
+ index: number;
196
+
197
+ /** Source operation that found this element */
198
+ source: 'findElements';
199
+
200
+ /** Timestamp when this reference was cached */
201
+ createdAt: number;
202
+ }
203
+
204
+ /**
205
+ * Union type for cached element references
206
+ */
207
+ export type CachedRef = CachedElementRef | CachedElementsRef;
package/src/utils.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Utility functions for UI Watchers Plugin
3
+ */
4
+
5
+ /**
6
+ * W3C WebDriver Element Identifier key
7
+ * This is the standard key used in W3C WebDriver protocol to identify elements
8
+ */
9
+ export const W3C_ELEMENT_KEY = 'element-6066-11e4-a52e-4f735466cecf';
10
+
11
+ /**
12
+ * Element object type that supports both W3C and JSONWP formats
13
+ */
14
+ export interface ElementObject {
15
+ ELEMENT?: string;
16
+ [W3C_ELEMENT_KEY]?: string;
17
+ }
18
+
19
+ /**
20
+ * Extract element ID from element object
21
+ * Handles W3C format, JSONWP format, and direct string IDs
22
+ *
23
+ * @param element - Element object or string ID
24
+ * @returns The element ID string, or undefined if not found
25
+ */
26
+ export function extractElementId(
27
+ element: ElementObject | string | null | undefined
28
+ ): string | undefined {
29
+ if (!element) return undefined;
30
+
31
+ // Direct string ID
32
+ if (typeof element === 'string') {
33
+ return element;
34
+ }
35
+
36
+ // W3C format
37
+ if (element[W3C_ELEMENT_KEY]) {
38
+ return element[W3C_ELEMENT_KEY];
39
+ }
40
+
41
+ // JSONWP format
42
+ if (element.ELEMENT) {
43
+ return element.ELEMENT;
44
+ }
45
+
46
+ return undefined;
47
+ }