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/.c8rc.json +12 -0
- package/.github/workflows/npm-publish.yml +28 -0
- package/.husky/pre-commit +4 -0
- package/.lintstagedrc.json +4 -0
- package/.mocharc.json +10 -0
- package/.prettierignore +6 -0
- package/.prettierrc +11 -0
- package/README.md +376 -0
- package/eslint.config.js +65 -0
- package/package.json +114 -0
- package/src/commands/clear.ts +28 -0
- package/src/commands/list.ts +23 -0
- package/src/commands/register.ts +47 -0
- package/src/commands/toggle.ts +43 -0
- package/src/commands/unregister.ts +43 -0
- package/src/config.ts +30 -0
- package/src/element-cache.ts +262 -0
- package/src/plugin.ts +437 -0
- package/src/types.ts +207 -0
- package/src/utils.ts +47 -0
- package/src/validators.ts +131 -0
- package/src/watcher-checker.ts +113 -0
- package/src/watcher-store.ts +210 -0
- package/test/e2e/config.e2e.spec.cjs +420 -0
- package/test/e2e/plugin.e2e.spec.cjs +312 -0
- package/test/unit/element-cache.spec.js +269 -0
- package/test/unit/plugin.spec.js +52 -0
- package/test/unit/utils.spec.js +85 -0
- package/test/unit/validators.spec.js +246 -0
- package/test/unit/watcher-checker.spec.js +274 -0
- package/test/unit/watcher-store.spec.js +405 -0
- package/tsconfig.json +31 -0
|
@@ -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
|
+
}
|