@reactiive/ennio 0.0.1

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.
Files changed (57) hide show
  1. package/EnnioCore.podspec +61 -0
  2. package/LICENSE +21 -0
  3. package/README.md +50 -0
  4. package/android/CMakeLists.txt +40 -0
  5. package/android/build.gradle +64 -0
  6. package/cpp/ElementMatcher.cpp +661 -0
  7. package/cpp/ElementMatcher.hpp +244 -0
  8. package/cpp/EnnioLog.hpp +182 -0
  9. package/cpp/HybridEnnio.cpp +1161 -0
  10. package/cpp/HybridEnnio.hpp +174 -0
  11. package/cpp/IdleMonitor.hpp +277 -0
  12. package/cpp/Protocol.cpp +135 -0
  13. package/cpp/Protocol.hpp +47 -0
  14. package/cpp/SelectorCriteria.hpp +281 -0
  15. package/cpp/SelectorParser.cpp +649 -0
  16. package/cpp/SelectorParser.hpp +94 -0
  17. package/cpp/ShadowTreeTraverser.cpp +305 -0
  18. package/cpp/ShadowTreeTraverser.hpp +142 -0
  19. package/cpp/TestIDRegistry.cpp +109 -0
  20. package/cpp/TestIDRegistry.hpp +84 -0
  21. package/dist/cli.js +16221 -0
  22. package/ios/EnnioAutoInit.mm +338 -0
  23. package/ios/EnnioDebugBanner.h +19 -0
  24. package/ios/EnnioDebugBanner.mm +178 -0
  25. package/ios/EnnioRuntimeHelper.h +264 -0
  26. package/ios/EnnioRuntimeHelper.mm +2443 -0
  27. package/lib/Ennio.nitro.d.ts +263 -0
  28. package/lib/Ennio.nitro.d.ts.map +1 -0
  29. package/lib/Ennio.nitro.js +2 -0
  30. package/lib/Ennio.nitro.js.map +1 -0
  31. package/lib/index.d.ts +16 -0
  32. package/lib/index.d.ts.map +1 -0
  33. package/lib/index.js +45 -0
  34. package/lib/index.js.map +1 -0
  35. package/nitro.json +24 -0
  36. package/nitrogen/generated/.gitattributes +1 -0
  37. package/nitrogen/generated/android/EnnioCore+autolinking.cmake +81 -0
  38. package/nitrogen/generated/android/EnnioCore+autolinking.gradle +27 -0
  39. package/nitrogen/generated/android/EnnioCoreOnLoad.cpp +49 -0
  40. package/nitrogen/generated/android/EnnioCoreOnLoad.hpp +34 -0
  41. package/nitrogen/generated/android/kotlin/com/margelo/nitro/ennio/EnnioCoreOnLoad.kt +35 -0
  42. package/nitrogen/generated/ios/EnnioCore+autolinking.rb +62 -0
  43. package/nitrogen/generated/ios/EnnioCore-Swift-Cxx-Bridge.cpp +17 -0
  44. package/nitrogen/generated/ios/EnnioCore-Swift-Cxx-Bridge.hpp +27 -0
  45. package/nitrogen/generated/ios/EnnioCore-Swift-Cxx-Umbrella.hpp +38 -0
  46. package/nitrogen/generated/ios/EnnioCoreAutolinking.mm +35 -0
  47. package/nitrogen/generated/ios/EnnioCoreAutolinking.swift +16 -0
  48. package/nitrogen/generated/shared/c++/ExtendedElementInfo.hpp +118 -0
  49. package/nitrogen/generated/shared/c++/HybridEnnioSpec.cpp +44 -0
  50. package/nitrogen/generated/shared/c++/HybridEnnioSpec.hpp +93 -0
  51. package/nitrogen/generated/shared/c++/LayoutMetrics.hpp +103 -0
  52. package/nitrogen/generated/shared/c++/ScrollDirection.hpp +84 -0
  53. package/package.json +78 -0
  54. package/react-native.config.js +14 -0
  55. package/src/Ennio.nitro.ts +363 -0
  56. package/src/cli/hid-daemon.py +129 -0
  57. package/src/index.ts +72 -0
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Disable React Native autolinking for ennio
3
+ *
4
+ * The ennio-expo-plugin controls whether native code is included.
5
+ * This allows conditional inclusion based on build type (dev/production).
6
+ */
7
+ module.exports = {
8
+ dependency: {
9
+ platforms: {
10
+ ios: null, // Disable iOS autolinking
11
+ android: null, // Disable Android autolinking
12
+ },
13
+ },
14
+ };
@@ -0,0 +1,363 @@
1
+ import type { HybridObject } from 'react-native-nitro-modules';
2
+
3
+ /**
4
+ * Layout metrics for a UI element
5
+ */
6
+ export interface LayoutMetrics {
7
+ x: number;
8
+ y: number;
9
+ width: number;
10
+ height: number;
11
+ screenX: number;
12
+ screenY: number;
13
+ }
14
+
15
+ /**
16
+ * Information about a found element
17
+ */
18
+ export interface ElementInfo {
19
+ testID: string;
20
+ type: string;
21
+ text?: string;
22
+ accessible: boolean;
23
+ enabled: boolean;
24
+ layout: LayoutMetrics;
25
+ }
26
+
27
+ /**
28
+ * Extended element info with additional state properties
29
+ */
30
+ export interface ExtendedElementInfo extends ElementInfo {
31
+ checked: boolean;
32
+ focused: boolean;
33
+ selected: boolean;
34
+ }
35
+
36
+ /**
37
+ * Text matching mode for text selectors
38
+ */
39
+ export type TextMatchMode = 'exact' | 'contains' | 'regex' | 'startsWith' | 'endsWith';
40
+
41
+ /**
42
+ * Text matcher configuration
43
+ */
44
+ export interface TextMatcher {
45
+ pattern: string;
46
+ mode?: TextMatchMode;
47
+ }
48
+
49
+ /**
50
+ * Point for coordinate-based selection
51
+ */
52
+ export interface Point {
53
+ x: number;
54
+ y: number;
55
+ isPercentage?: boolean;
56
+ }
57
+
58
+ /**
59
+ * Trait types for trait-based selection
60
+ */
61
+ export type Trait = 'text' | 'long-text' | 'square';
62
+
63
+ /**
64
+ * Direction for scroll / swipe gestures
65
+ */
66
+ export type ScrollDirection = 'up' | 'down' | 'left' | 'right';
67
+
68
+ /**
69
+ * Selector - Full Maestro selector parity
70
+ *
71
+ * Supports:
72
+ * - Primary: id, text, index, point
73
+ * - State: enabled, checked, focused, selected
74
+ * - Spatial: below, above, leftOf, rightOf
75
+ * - Hierarchical: containsChild, childOf, containsDescendants
76
+ * - Dimensions: width, height, tolerance
77
+ * - Traits: text, long-text, square
78
+ */
79
+ export interface Selector {
80
+ // ============================================
81
+ // Primary Selectors
82
+ // ============================================
83
+
84
+ /**
85
+ * Match by testID (O(1) lookup when used alone)
86
+ */
87
+ id?: string;
88
+
89
+ /**
90
+ * Match by text content (string for exact match, or TextMatcher for advanced)
91
+ */
92
+ text?: string | TextMatcher;
93
+
94
+ /**
95
+ * Return the nth matching element (0-indexed)
96
+ */
97
+ index?: number;
98
+
99
+ /**
100
+ * Select element at specific coordinates
101
+ * String format: "50%,50%" or "100,200"
102
+ */
103
+ point?: Point | string;
104
+
105
+ // ============================================
106
+ // State Selectors
107
+ // ============================================
108
+
109
+ /**
110
+ * Match by enabled state
111
+ */
112
+ enabled?: boolean;
113
+
114
+ /**
115
+ * Match by checked state (checkboxes, switches)
116
+ */
117
+ checked?: boolean;
118
+
119
+ /**
120
+ * Match by focused state
121
+ */
122
+ focused?: boolean;
123
+
124
+ /**
125
+ * Match by selected state
126
+ */
127
+ selected?: boolean;
128
+
129
+ // ============================================
130
+ // Spatial Selectors (relative positioning)
131
+ // ============================================
132
+
133
+ /**
134
+ * Match elements below the reference element
135
+ */
136
+ below?: Selector;
137
+
138
+ /**
139
+ * Match elements above the reference element
140
+ */
141
+ above?: Selector;
142
+
143
+ /**
144
+ * Match elements to the left of the reference element
145
+ */
146
+ leftOf?: Selector;
147
+
148
+ /**
149
+ * Match elements to the right of the reference element
150
+ */
151
+ rightOf?: Selector;
152
+
153
+ // ============================================
154
+ // Hierarchical Selectors
155
+ // ============================================
156
+
157
+ /**
158
+ * Match elements that contain a direct child matching criteria
159
+ */
160
+ containsChild?: Selector;
161
+
162
+ /**
163
+ * Match elements that are children of an element matching criteria
164
+ */
165
+ childOf?: Selector;
166
+
167
+ /**
168
+ * Match elements that contain all descendants matching each criteria
169
+ */
170
+ containsDescendants?: Selector[];
171
+
172
+ // ============================================
173
+ // Dimension Selectors
174
+ // ============================================
175
+
176
+ /**
177
+ * Match by width (in points)
178
+ */
179
+ width?: number;
180
+
181
+ /**
182
+ * Match by height (in points)
183
+ */
184
+ height?: number;
185
+
186
+ /**
187
+ * Tolerance for width/height matching (default: 0)
188
+ */
189
+ tolerance?: number;
190
+
191
+ // ============================================
192
+ // Trait Selectors
193
+ // ============================================
194
+
195
+ /**
196
+ * Match elements with specified traits
197
+ */
198
+ traits?: Trait[];
199
+ }
200
+
201
+ /**
202
+ * Ennio HybridObject - Direct Fabric shadow tree access for E2E testing
203
+ */
204
+ export interface Ennio extends HybridObject<{ ios: 'c++'; android: 'c++' }> {
205
+ // ============================================
206
+ // Element Queries
207
+ // ============================================
208
+
209
+ /**
210
+ * Check if an element with the given testID exists in the tree
211
+ * @param testID - The testID prop value to search for
212
+ */
213
+ exists(testID: string): boolean;
214
+
215
+ /**
216
+ * Check if an element is visible on screen
217
+ * Considers: opacity, display, pointerEvents, and viewport bounds
218
+ * @param testID - The testID prop value
219
+ */
220
+ isVisible(testID: string): boolean;
221
+
222
+ /**
223
+ * Get text content of an element
224
+ * @param testID - The testID prop value
225
+ * @returns Text content if available, null otherwise
226
+ */
227
+ getText(testID: string): string | null;
228
+
229
+ // ============================================
230
+ // Synchronization
231
+ // ============================================
232
+
233
+ /**
234
+ * Wait for the shadow tree to settle (no pending updates)
235
+ * @param timeoutMs - Maximum time to wait in milliseconds
236
+ * @returns true if settled within timeout
237
+ */
238
+ waitForIdle(timeoutMs: number): boolean;
239
+
240
+ /**
241
+ * Force a synchronization point - ensures all pending JS and native updates are processed
242
+ */
243
+ synchronize(): void;
244
+
245
+ /**
246
+ * Block until React fires the next onCommitFiberRoot, or until maxMs
247
+ * elapses. Used by the CLI to wake early from blind settle sleeps —
248
+ * cap is the safety floor, commit signal is the early-wake.
249
+ * @param maxMs - Maximum time to wait in milliseconds
250
+ * @returns true if a commit fired within maxMs, false on timeout
251
+ */
252
+ waitForNextCommit(maxMs: number): boolean;
253
+
254
+ // ============================================
255
+ // Selector-based Queries (Full Maestro Parity)
256
+ // ============================================
257
+
258
+ /**
259
+ * Find an element using a selector (JSON string)
260
+ * @param selectorJson - JSON-encoded Selector object
261
+ * @returns ExtendedElementInfo if found, null otherwise
262
+ */
263
+ findBySelector(selectorJson: string): ExtendedElementInfo | null;
264
+
265
+ /**
266
+ * Find all elements matching a selector (JSON string)
267
+ * @param selectorJson - JSON-encoded Selector object
268
+ * @returns Array of ExtendedElementInfo
269
+ */
270
+ findAllBySelector(selectorJson: string): ExtendedElementInfo[];
271
+
272
+ /**
273
+ * Check if an element matching the selector exists
274
+ * @param selectorJson - JSON-encoded Selector object
275
+ */
276
+ existsBySelector(selectorJson: string): boolean;
277
+
278
+ /**
279
+ * Get text content of an element using a selector (JSON string)
280
+ * @param selectorJson - JSON-encoded Selector object
281
+ * @returns Text content if available, null otherwise
282
+ */
283
+ getTextBySelector(selectorJson: string): string | null;
284
+
285
+ /**
286
+ * Check if an element matching the selector is visible
287
+ * @param selectorJson - JSON-encoded Selector object
288
+ */
289
+ isVisibleBySelector(selectorJson: string): boolean;
290
+
291
+ // ============================================
292
+ // Alert/Modal Handling
293
+ // ============================================
294
+
295
+ /**
296
+ * Check if an alert is currently presented
297
+ */
298
+ isAlertPresent(): boolean;
299
+
300
+ /**
301
+ * Get the text content of the current alert (title + message)
302
+ */
303
+ getAlertText(): string;
304
+
305
+ /**
306
+ * Get the button titles of the current alert
307
+ */
308
+ getAlertButtons(): string[];
309
+
310
+ // ============================================
311
+ // In-app writes (no JSI invokeOnPress, no UITouch synth)
312
+ //
313
+ // CLI actuation goes through idb HID at measured coords — Maestro /
314
+ // XCUITest parity. These methods cover the scroll / keyboard / nav
315
+ // operations that idb can't model cleanly:
316
+ // - scroll / scrollTo -> UIScrollView.setContentOffset
317
+ // - swipeAtPoints -> UITouch-loop pan (cross-view drags)
318
+ // - back -> UINavigationController.popViewController
319
+ // - alerts -> walk UIAlertController.actions
320
+ // - hideKeyboard -> [keyboardWindow firstResponder] resignFirstResponder
321
+ // ============================================
322
+
323
+ scroll(testID: string, direction: ScrollDirection, distance: number): boolean;
324
+ scrollTo(scrollViewTestID: string, elementTestID: string): boolean;
325
+
326
+ /**
327
+ * Synthesize a pan gesture from (x1,y1) to (x2,y2) over `durationMs`.
328
+ * If the start point hits a UIScrollView, takes the fast path
329
+ * (`setContentOffset:animated:NO` with the delta) — no UITouch tax.
330
+ * Otherwise drives a UITouchPhaseMoved loop for cross-view drags
331
+ * (sheet dismiss, page swipe, non-scrollable carousel pan).
332
+ * Coordinates are window-relative. Sim-only (UITouch private API).
333
+ */
334
+ swipeAtPoints(x1: number, y1: number, x2: number, y2: number, durationMs: number): boolean;
335
+
336
+ /**
337
+ * Synthesize a hardware key press by HID keycode against the current
338
+ * first responder when it conforms to UIKeyInput. Currently maps:
339
+ * 42 → deleteBackward (backspace)
340
+ * 40 → insertText("\n") (return)
341
+ * 44 → insertText(" ") (space)
342
+ * Returns false when no first responder accepts text input. Replaces
343
+ * idb's HID `pressKey` for in-app text fields.
344
+ */
345
+ pressHardwareKey(keyCode: number): boolean;
346
+
347
+ /**
348
+ * Drive a back navigation. Pops the top view controller of the
349
+ * current UINavigationController.
350
+ */
351
+ backGesture(): boolean;
352
+
353
+ hideKeyboard(): boolean;
354
+
355
+ // Alert writes (the matching reads — isAlertPresent, getAlertText,
356
+ // getAlertButtons — already exist above).
357
+ tapAlertButton(buttonText: string): boolean;
358
+ dismissAlert(): boolean;
359
+
360
+ // Pasteboard
361
+ copyToClipboard(text: string): boolean;
362
+ pasteFromClipboard(testID: string): boolean;
363
+ }
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Persistent HID injection daemon backed by the python idb client.
4
+
5
+ The python `idb` CLI eats ~250 ms per invocation booting the
6
+ interpreter + loading grpclib + building a gRPC channel — wasted
7
+ work when the runner fires 30+ taps per flow. This daemon pays that
8
+ cost once, then loops on stdin reading one-line commands and dispatches
9
+ them to a long-lived `Client` instance. Each tap is just a gRPC RTT
10
+ over the already-warm channel: ~3-8 ms.
11
+
12
+ Wire protocol (line-delimited, both directions):
13
+
14
+ IN tap <x> <y> <durationMs>
15
+ IN swipe <x1> <y1> <x2> <y2> <durationMs>
16
+ IN exit
17
+ OUT ok — command completed
18
+ OUT err <message> — command failed; daemon stays alive
19
+
20
+ The daemon discovers the companion socket from `IDB_COMPANION` env
21
+ var or `/tmp/idb/<UDID>_companion.sock` if the parent passes a UDID
22
+ positional. The CLI launches one daemon per booted target and keeps
23
+ it alive for the whole `ennio test` session.
24
+ """
25
+
26
+ import asyncio
27
+ import logging
28
+ import os
29
+ import sys
30
+ from pathlib import Path
31
+
32
+ from idb.common.types import Address, DomainSocketAddress
33
+ from idb.grpc.client import Client
34
+
35
+ # Silence idb's noisy info-level chatter — daemon stdout is the wire
36
+ # protocol; any non-`ok` / `err` line would corrupt it.
37
+ logging.basicConfig(level=logging.ERROR)
38
+
39
+
40
+ async def main() -> None:
41
+ udid = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("ENNIO_UDID")
42
+ if not udid:
43
+ print("err missing-udid", flush=True)
44
+ sys.exit(2)
45
+
46
+ sock_path = Path(f"/tmp/idb/{udid}_companion.sock")
47
+ if not sock_path.exists():
48
+ # No companion running yet — the CLI is expected to run
49
+ # `idb connect <UDID>` once before spawning the daemon. Bail
50
+ # loudly so the parent sees a startup failure.
51
+ print(f"err no-socket {sock_path}", flush=True)
52
+ sys.exit(3)
53
+
54
+ address: Address = DomainSocketAddress(path=str(sock_path))
55
+
56
+ # `Client.build` is an async context manager that owns the gRPC
57
+ # channel for the duration of the `async with` block. Hold it open
58
+ # for the whole daemon lifetime so per-call cost is just the RTT.
59
+ async with Client.build(address=address, logger=logging.getLogger("ennio")) as client:
60
+ # Signal readiness — parent CLI waits for this line before
61
+ # sending commands.
62
+ print("ready", flush=True)
63
+
64
+ # `sys.stdin.readline` is blocking; wrap in
65
+ # `run_in_executor` so the event loop stays responsive (idb's
66
+ # underlying grpclib streams need it).
67
+ loop = asyncio.get_event_loop()
68
+ while True:
69
+ line = await loop.run_in_executor(None, sys.stdin.readline)
70
+ if not line:
71
+ # Parent closed stdin — exit cleanly.
72
+ return
73
+ try:
74
+ await handle(client, line.strip())
75
+ except Exception as e:
76
+ # Per-call failures shouldn't kill the daemon; the
77
+ # parent CLI may retry, or fall back to spawning
78
+ # python idb. Log + continue.
79
+ print(f"err {type(e).__name__}: {e}", flush=True)
80
+
81
+
82
+ async def handle(client: Client, line: str) -> None:
83
+ if not line:
84
+ return
85
+ parts = line.split()
86
+ if not parts:
87
+ return
88
+ op = parts[0]
89
+ # Validate arg counts up front so a malformed line returns a clean
90
+ # "err …" instead of crashing the daemon with IndexError (which the
91
+ # Node parent only sees as an unexpected EOF).
92
+ try:
93
+ if op == "tap":
94
+ # tap <x> <y> [durationMs]
95
+ if len(parts) < 3:
96
+ print("err tap-needs-x-y", flush=True)
97
+ return
98
+ x = float(parts[1])
99
+ y = float(parts[2])
100
+ dur_ms = float(parts[3]) if len(parts) > 3 else 80.0
101
+ await client.tap(x=x, y=y, duration=dur_ms / 1000.0)
102
+ print("ok", flush=True)
103
+ elif op == "swipe":
104
+ # swipe <x1> <y1> <x2> <y2> [durationMs]
105
+ if len(parts) < 5:
106
+ print("err swipe-needs-x1-y1-x2-y2", flush=True)
107
+ return
108
+ x1 = float(parts[1])
109
+ y1 = float(parts[2])
110
+ x2 = float(parts[3])
111
+ y2 = float(parts[4])
112
+ dur_ms = float(parts[5]) if len(parts) > 5 else 300.0
113
+ await client.swipe(
114
+ p_start=(x1, y1),
115
+ p_end=(x2, y2),
116
+ duration=dur_ms / 1000.0,
117
+ )
118
+ print("ok", flush=True)
119
+ elif op == "exit":
120
+ sys.exit(0)
121
+ else:
122
+ print(f"err unknown-op {op}", flush=True)
123
+ except ValueError as e:
124
+ # float() on non-numeric arg.
125
+ print(f"err bad-arg {e}", flush=True)
126
+
127
+
128
+ if __name__ == "__main__":
129
+ asyncio.run(main())
package/src/index.ts ADDED
@@ -0,0 +1,72 @@
1
+ import { NitroModules } from 'react-native-nitro-modules';
2
+ import type {
3
+ Ennio,
4
+ ExtendedElementInfo,
5
+ LayoutMetrics,
6
+ Selector,
7
+ TextMatcher,
8
+ TextMatchMode,
9
+ Point,
10
+ Trait,
11
+ ScrollDirection,
12
+ } from './Ennio.nitro';
13
+
14
+ export type {
15
+ Ennio,
16
+ ExtendedElementInfo,
17
+ LayoutMetrics,
18
+ Selector,
19
+ TextMatcher,
20
+ TextMatchMode,
21
+ Point,
22
+ Trait,
23
+ ScrollDirection,
24
+ };
25
+
26
+ let _ennioModule: Ennio | null = null;
27
+ let _initError: Error | null = null;
28
+
29
+ /**
30
+ * Get the Ennio HybridObject instance. Auto-init happens at app start
31
+ * (EnnioAutoInit swizzles RCTHost.start) — this is just a stable
32
+ * handle for callers that want to introspect from JS.
33
+ */
34
+ export function getEnnioModule(): Ennio | null {
35
+ if (_ennioModule) {
36
+ return _ennioModule;
37
+ }
38
+
39
+ if (_initError) {
40
+ return null;
41
+ }
42
+
43
+ try {
44
+ _ennioModule = NitroModules.createHybridObject<Ennio>('Ennio');
45
+ return _ennioModule;
46
+ } catch (error) {
47
+ _initError = error instanceof Error ? error : new Error(String(error));
48
+ if (__DEV__) {
49
+ console.warn('[Ennio] Native module not available:', _initError.message);
50
+ console.warn('[Ennio] E2E testing features will be disabled');
51
+ }
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Returns true when the Ennio JSI surface is reachable. The runtime
58
+ * dispatch surface (`__ennioDispatch` + commit signal) is installed
59
+ * automatically by the pod's `+load` hook — this only exposes the
60
+ * Nitro module to JS callers that want to read state directly.
61
+ */
62
+ export function isNativeModuleAvailable(): boolean {
63
+ return getEnnioModule() !== null;
64
+ }
65
+
66
+ // No JS-side bootstrap. `ennio` autolinks via Pod, and the iOS
67
+ // `EnnioAutoInit` swizzle installs the JSI dispatch surface (commit
68
+ // signal + `__ennioDispatch` host function) natively, on the JS
69
+ // thread, the moment RCTHost finishes booting. The user's app never
70
+ // imports this package; it lands purely through `npm install ennio`.
71
+ // The external CLI drives the runtime via Hermes Inspector
72
+ // (`Runtime.evaluate('__ennioDispatch(...)')`).