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.
@@ -0,0 +1,47 @@
1
+ /**
2
+ * registerUIWatcher command implementation
3
+ */
4
+
5
+ import { logger } from '@appium/support';
6
+ import type { WatcherStore } from '../watcher-store.js';
7
+ import type { UIWatcher, RegisterWatcherResult } from '../types.js';
8
+ import { validateWatcherParams } from '../validators.js';
9
+
10
+ const log = logger.getLogger('AppiumUIWatchers');
11
+
12
+ /**
13
+ * Register a new UI watcher
14
+ * @param store - Watcher store instance
15
+ * @param params - Watcher registration parameters
16
+ * @returns Registration result
17
+ */
18
+ export async function registerUIWatcher(
19
+ store: WatcherStore,
20
+ params: UIWatcher
21
+ ): Promise<RegisterWatcherResult> {
22
+ // Get configuration from store
23
+ const config = store.getConfig();
24
+
25
+ // Validate parameters with configured max duration
26
+ validateWatcherParams(params, config.maxDurationMs);
27
+
28
+ // Add watcher to store (this will throw if validation fails at store level)
29
+ const watcherState = store.add(params);
30
+
31
+ // Log successful registration
32
+ log.info(
33
+ `[UIWatchers] UIWatcher '${params.name}' registered (priority=${watcherState.priority}, duration=${params.duration}ms)`
34
+ );
35
+
36
+ // Return success response
37
+ return {
38
+ success: true,
39
+ watcher: {
40
+ name: watcherState.name,
41
+ priority: watcherState.priority,
42
+ registeredAt: watcherState.registeredAt,
43
+ expiresAt: watcherState.expiresAt,
44
+ status: watcherState.status,
45
+ },
46
+ };
47
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * disableUIWatchers and enableUIWatchers command implementations
3
+ */
4
+
5
+ import { logger } from '@appium/support';
6
+ import type { WatcherStore } from '../watcher-store.js';
7
+ import type { ToggleWatchersResult } from '../types.js';
8
+
9
+ const log = logger.getLogger('AppiumUIWatchers');
10
+
11
+ /**
12
+ * Disable all UI watcher checking
13
+ * @param store - Watcher store instance
14
+ * @returns Disable result
15
+ */
16
+ export async function disableUIWatchers(store: WatcherStore): Promise<ToggleWatchersResult> {
17
+ store.disable();
18
+
19
+ // Log successful disable
20
+ log.info('[UIWatchers] All UI watchers disabled');
21
+
22
+ return {
23
+ success: true,
24
+ message: 'All UI watchers disabled',
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Enable all UI watcher checking
30
+ * @param store - Watcher store instance
31
+ * @returns Enable result
32
+ */
33
+ export async function enableUIWatchers(store: WatcherStore): Promise<ToggleWatchersResult> {
34
+ store.enable();
35
+
36
+ // Log successful enable
37
+ log.info('[UIWatchers] All UI watchers enabled');
38
+
39
+ return {
40
+ success: true,
41
+ message: 'All UI watchers enabled',
42
+ };
43
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * unregisterUIWatcher command implementation
3
+ */
4
+
5
+ import { logger } from '@appium/support';
6
+ import type { WatcherStore } from '../watcher-store.js';
7
+ import type { UnregisterWatcherResult } from '../types.js';
8
+
9
+ const log = logger.getLogger('AppiumUIWatchers');
10
+
11
+ /**
12
+ * Unregister a specific UI watcher by name
13
+ * @param store - Watcher store instance
14
+ * @param name - Name of the watcher to remove
15
+ * @returns Unregistration result
16
+ */
17
+ export async function unregisterUIWatcher(
18
+ store: WatcherStore,
19
+ name: string
20
+ ): Promise<UnregisterWatcherResult> {
21
+ // Validate name parameter
22
+ if (!name || typeof name !== 'string') {
23
+ throw new Error('UIWatcher name is required');
24
+ }
25
+
26
+ // Check if watcher exists
27
+ const watcher = store.get(name);
28
+ if (!watcher) {
29
+ throw new Error(`UIWatcher '${name}' not found`);
30
+ }
31
+
32
+ // Remove watcher from store
33
+ store.remove(name);
34
+
35
+ // Log successful removal
36
+ log.info(`[UIWatchers] UIWatcher '${name}' unregistered`);
37
+
38
+ // Return success response
39
+ return {
40
+ success: true,
41
+ removed: name,
42
+ };
43
+ }
package/src/config.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Configuration module for UI Watchers Plugin
3
+ */
4
+
5
+ /**
6
+ * Plugin configuration interface
7
+ */
8
+ export interface PluginConfig {
9
+ /** Maximum number of watchers allowed per session (1-20) */
10
+ maxWatchers?: number;
11
+
12
+ /** Maximum duration per watcher in milliseconds (1000-600000) */
13
+ maxDurationMs?: number;
14
+
15
+ /** Maximum element references to cache for stale recovery (10-200) */
16
+ maxCacheEntries?: number;
17
+
18
+ /** TTL for cached element references in milliseconds (5000-300000) */
19
+ elementTtlMs?: number;
20
+ }
21
+
22
+ /**
23
+ * Default configuration values
24
+ */
25
+ export const DefaultPluginConfig: Required<PluginConfig> = {
26
+ maxWatchers: 5,
27
+ maxDurationMs: 60000,
28
+ maxCacheEntries: 50,
29
+ elementTtlMs: 60000,
30
+ };
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Element Reference Cache for StaleElement Recovery
3
+ *
4
+ * This module provides caching of element locators to enable automatic recovery
5
+ * from StaleElementReferenceException on element action commands.
6
+ */
7
+
8
+ import { logger } from '@appium/support';
9
+ import type { CachedRef, CachedElementRef, CachedElementsRef } from './types.js';
10
+ import type { PluginConfig } from './config.js';
11
+ import { extractElementId, type ElementObject } from './utils.js';
12
+
13
+ const log = logger.getLogger('AppiumUIWatchers');
14
+
15
+ /**
16
+ * Configuration for ElementReferenceCache
17
+ */
18
+ export interface CacheConfig {
19
+ maxCacheEntries: number;
20
+ elementTtlMs: number;
21
+ }
22
+
23
+ /**
24
+ * ElementReferenceCache manages cached element locators for stale element recovery.
25
+ *
26
+ * Features:
27
+ * - Caches element locators on successful findElement/findElements
28
+ * - Provides element ID mapping for recovered elements
29
+ * - Supports transitive mapping updates (abc→efg, efg→hij => abc→hij)
30
+ * - LRU eviction when max entries reached
31
+ * - TTL-based expiry for old entries
32
+ */
33
+ export class ElementReferenceCache {
34
+ /** Cache storage: elementId → CachedRef */
35
+ private cache: Map<string, CachedRef>;
36
+
37
+ /** Element ID mappings: oldId → newId */
38
+ private idMappings: Map<string, string>;
39
+
40
+ /** Reverse index for transitive updates: newId → Set<oldId> */
41
+ private reverseIndex: Map<string, Set<string>>;
42
+
43
+ /** LRU order tracking (most recent at end) */
44
+ private accessOrder: string[];
45
+
46
+ /** Configuration */
47
+ private config: CacheConfig;
48
+
49
+ constructor(config: Required<PluginConfig>) {
50
+ this.cache = new Map();
51
+ this.idMappings = new Map();
52
+ this.reverseIndex = new Map();
53
+ this.accessOrder = [];
54
+ this.config = {
55
+ maxCacheEntries: config.maxCacheEntries,
56
+ elementTtlMs: config.elementTtlMs,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Cache an element reference from findElement
62
+ */
63
+ cacheElement(elementId: string, using: string, value: string): void {
64
+ const ref: CachedElementRef = {
65
+ using,
66
+ value,
67
+ source: 'findElement',
68
+ createdAt: Date.now(),
69
+ };
70
+
71
+ this.addToCache(elementId, ref);
72
+ log.debug(`[UIWatchers] Cached element reference: ${elementId} (${using}=${value})`);
73
+ }
74
+
75
+ /**
76
+ * Cache element references from findElements
77
+ * Each element is cached with its index position
78
+ */
79
+ cacheElements(elements: ElementObject[], using: string, value: string): void {
80
+ for (let i = 0; i < elements.length; i++) {
81
+ const element = elements[i];
82
+ const elementId = extractElementId(element);
83
+ if (!elementId) continue;
84
+
85
+ const ref: CachedElementsRef = {
86
+ using,
87
+ value,
88
+ index: i,
89
+ source: 'findElements',
90
+ createdAt: Date.now(),
91
+ };
92
+
93
+ this.addToCache(elementId, ref);
94
+ }
95
+ log.debug(
96
+ `[UIWatchers] Cached ${elements.length} element references from findElements (${using}=${value})`
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Get cached reference for an element
102
+ * Returns undefined if not found or expired
103
+ */
104
+ getRef(elementId: string): CachedRef | undefined {
105
+ const ref = this.cache.get(elementId);
106
+ if (!ref) return undefined;
107
+
108
+ // Check TTL
109
+ if (this.isExpired(ref)) {
110
+ this.removeFromCache(elementId);
111
+ return undefined;
112
+ }
113
+
114
+ // Update LRU order
115
+ this.updateAccessOrder(elementId);
116
+
117
+ return ref;
118
+ }
119
+
120
+ /**
121
+ * Create element ID mapping with transitive update
122
+ *
123
+ * When we map oldId → newId, we also update any existing mappings
124
+ * that point to oldId to instead point to newId.
125
+ *
126
+ * Example:
127
+ * Existing: abc → efg
128
+ * New mapping: efg → hij
129
+ * Result: abc → hij, efg → hij
130
+ */
131
+ setMapping(oldId: string, newId: string): void {
132
+ // Step 1: Find all mappings where value == oldId (using reverse index)
133
+ const pointingToOld = this.reverseIndex.get(oldId);
134
+ if (pointingToOld) {
135
+ // Update all these mappings to point to newId
136
+ for (const sourceId of pointingToOld) {
137
+ this.idMappings.set(sourceId, newId);
138
+ // Update reverse index
139
+ this.addToReverseIndex(sourceId, newId);
140
+ }
141
+ // Remove old reverse index entry
142
+ this.reverseIndex.delete(oldId);
143
+ }
144
+
145
+ // Step 2: Add the new mapping
146
+ this.idMappings.set(oldId, newId);
147
+ this.addToReverseIndex(oldId, newId);
148
+
149
+ log.debug(`[UIWatchers] Created element ID mapping: ${oldId} → ${newId}`);
150
+ }
151
+
152
+ /**
153
+ * Get mapped element ID if exists
154
+ * Returns the new ID if a mapping exists, otherwise returns undefined
155
+ */
156
+ getMappedId(elementId: string): string | undefined {
157
+ return this.idMappings.get(elementId);
158
+ }
159
+
160
+ /**
161
+ * Remove expired entries from cache
162
+ */
163
+ cleanup(): void {
164
+ const now = Date.now();
165
+ const toRemove: string[] = [];
166
+
167
+ for (const [elementId, ref] of this.cache) {
168
+ if (now - ref.createdAt >= this.config.elementTtlMs) {
169
+ toRemove.push(elementId);
170
+ }
171
+ }
172
+
173
+ for (const elementId of toRemove) {
174
+ this.removeFromCache(elementId);
175
+ }
176
+
177
+ if (toRemove.length > 0) {
178
+ log.debug(`[UIWatchers] Cleaned up ${toRemove.length} expired cache entries`);
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Clear all cache entries and mappings
184
+ */
185
+ clear(): void {
186
+ const count = this.cache.size;
187
+ this.cache.clear();
188
+ this.idMappings.clear();
189
+ this.reverseIndex.clear();
190
+ this.accessOrder = [];
191
+ log.debug(`[UIWatchers] Cleared element cache (${count} entries)`);
192
+ }
193
+
194
+ /**
195
+ * Get current cache size
196
+ */
197
+ get size(): number {
198
+ return this.cache.size;
199
+ }
200
+
201
+ /**
202
+ * Get current mappings count
203
+ */
204
+ get mappingsCount(): number {
205
+ return this.idMappings.size;
206
+ }
207
+
208
+ // ============================================================================
209
+ // Private helper methods
210
+ // ============================================================================
211
+
212
+ private addToCache(elementId: string, ref: CachedRef): void {
213
+ // Evict if at capacity
214
+ while (this.cache.size >= this.config.maxCacheEntries) {
215
+ this.evictLRU();
216
+ }
217
+
218
+ this.cache.set(elementId, ref);
219
+ this.updateAccessOrder(elementId);
220
+ }
221
+
222
+ private removeFromCache(elementId: string): void {
223
+ this.cache.delete(elementId);
224
+ // Remove from access order
225
+ const index = this.accessOrder.indexOf(elementId);
226
+ if (index !== -1) {
227
+ this.accessOrder.splice(index, 1);
228
+ }
229
+ }
230
+
231
+ private evictLRU(): void {
232
+ if (this.accessOrder.length === 0) return;
233
+
234
+ // Remove least recently used (first in array)
235
+ const lruId = this.accessOrder.shift();
236
+ if (lruId) {
237
+ this.cache.delete(lruId);
238
+ log.debug(`[UIWatchers] Evicted LRU cache entry: ${lruId}`);
239
+ }
240
+ }
241
+
242
+ private updateAccessOrder(elementId: string): void {
243
+ // Remove from current position
244
+ const index = this.accessOrder.indexOf(elementId);
245
+ if (index !== -1) {
246
+ this.accessOrder.splice(index, 1);
247
+ }
248
+ // Add to end (most recently used)
249
+ this.accessOrder.push(elementId);
250
+ }
251
+
252
+ private isExpired(ref: CachedRef): boolean {
253
+ return Date.now() - ref.createdAt >= this.config.elementTtlMs;
254
+ }
255
+
256
+ private addToReverseIndex(oldId: string, newId: string): void {
257
+ if (!this.reverseIndex.has(newId)) {
258
+ this.reverseIndex.set(newId, new Set());
259
+ }
260
+ this.reverseIndex.get(newId)!.add(oldId);
261
+ }
262
+ }