@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.
- package/EnnioCore.podspec +61 -0
- package/LICENSE +21 -0
- package/README.md +50 -0
- package/android/CMakeLists.txt +40 -0
- package/android/build.gradle +64 -0
- package/cpp/ElementMatcher.cpp +661 -0
- package/cpp/ElementMatcher.hpp +244 -0
- package/cpp/EnnioLog.hpp +182 -0
- package/cpp/HybridEnnio.cpp +1161 -0
- package/cpp/HybridEnnio.hpp +174 -0
- package/cpp/IdleMonitor.hpp +277 -0
- package/cpp/Protocol.cpp +135 -0
- package/cpp/Protocol.hpp +47 -0
- package/cpp/SelectorCriteria.hpp +281 -0
- package/cpp/SelectorParser.cpp +649 -0
- package/cpp/SelectorParser.hpp +94 -0
- package/cpp/ShadowTreeTraverser.cpp +305 -0
- package/cpp/ShadowTreeTraverser.hpp +142 -0
- package/cpp/TestIDRegistry.cpp +109 -0
- package/cpp/TestIDRegistry.hpp +84 -0
- package/dist/cli.js +16221 -0
- package/ios/EnnioAutoInit.mm +338 -0
- package/ios/EnnioDebugBanner.h +19 -0
- package/ios/EnnioDebugBanner.mm +178 -0
- package/ios/EnnioRuntimeHelper.h +264 -0
- package/ios/EnnioRuntimeHelper.mm +2443 -0
- package/lib/Ennio.nitro.d.ts +263 -0
- package/lib/Ennio.nitro.d.ts.map +1 -0
- package/lib/Ennio.nitro.js +2 -0
- package/lib/Ennio.nitro.js.map +1 -0
- package/lib/index.d.ts +16 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +45 -0
- package/lib/index.js.map +1 -0
- package/nitro.json +24 -0
- package/nitrogen/generated/.gitattributes +1 -0
- package/nitrogen/generated/android/EnnioCore+autolinking.cmake +81 -0
- package/nitrogen/generated/android/EnnioCore+autolinking.gradle +27 -0
- package/nitrogen/generated/android/EnnioCoreOnLoad.cpp +49 -0
- package/nitrogen/generated/android/EnnioCoreOnLoad.hpp +34 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/ennio/EnnioCoreOnLoad.kt +35 -0
- package/nitrogen/generated/ios/EnnioCore+autolinking.rb +62 -0
- package/nitrogen/generated/ios/EnnioCore-Swift-Cxx-Bridge.cpp +17 -0
- package/nitrogen/generated/ios/EnnioCore-Swift-Cxx-Bridge.hpp +27 -0
- package/nitrogen/generated/ios/EnnioCore-Swift-Cxx-Umbrella.hpp +38 -0
- package/nitrogen/generated/ios/EnnioCoreAutolinking.mm +35 -0
- package/nitrogen/generated/ios/EnnioCoreAutolinking.swift +16 -0
- package/nitrogen/generated/shared/c++/ExtendedElementInfo.hpp +118 -0
- package/nitrogen/generated/shared/c++/HybridEnnioSpec.cpp +44 -0
- package/nitrogen/generated/shared/c++/HybridEnnioSpec.hpp +93 -0
- package/nitrogen/generated/shared/c++/LayoutMetrics.hpp +103 -0
- package/nitrogen/generated/shared/c++/ScrollDirection.hpp +84 -0
- package/package.json +78 -0
- package/react-native.config.js +14 -0
- package/src/Ennio.nitro.ts +363 -0
- package/src/cli/hid-daemon.py +129 -0
- package/src/index.ts +72 -0
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
#include "ElementMatcher.hpp"
|
|
2
|
+
|
|
3
|
+
#include <react/renderer/components/view/ViewProps.h>
|
|
4
|
+
#include <react/renderer/core/LayoutableShadowNode.h>
|
|
5
|
+
|
|
6
|
+
#include <cmath>
|
|
7
|
+
|
|
8
|
+
// Logging for ElementMatcher
|
|
9
|
+
#ifdef __APPLE__
|
|
10
|
+
extern "C" void EnnioLogMessage(const char* message);
|
|
11
|
+
#define EM_LOG(fmt, ...) do { char buf[512]; snprintf(buf, sizeof(buf), "[Ennio EM] " fmt, ##__VA_ARGS__); EnnioLogMessage(buf); } while(0)
|
|
12
|
+
#else
|
|
13
|
+
#define EM_LOG(fmt, ...) ((void)0)
|
|
14
|
+
#endif
|
|
15
|
+
|
|
16
|
+
namespace ennio {
|
|
17
|
+
|
|
18
|
+
// Helper to check if a node is likely a button (interactive element)
|
|
19
|
+
static bool isLikelyButton(const ShadowTreeTraverser::ShadowNodePtr& node) {
|
|
20
|
+
if (!node) return false;
|
|
21
|
+
|
|
22
|
+
// Check ViewProps for accessibility traits
|
|
23
|
+
auto viewProps = std::dynamic_pointer_cast<const facebook::react::ViewProps>(
|
|
24
|
+
node->getProps()
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
if (viewProps) {
|
|
28
|
+
// Check accessibility role
|
|
29
|
+
auto role = viewProps->accessibilityRole;
|
|
30
|
+
if (role == "button" || role == "link" || role == "tab") {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Check if accessible (buttons are usually accessible)
|
|
35
|
+
if (viewProps->accessible) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
ShadowTreeTraverser::ShadowNodePtr ElementMatcher::findFirst(
|
|
44
|
+
ShadowNodePtr root,
|
|
45
|
+
const SelectorCriteria& criteria
|
|
46
|
+
) {
|
|
47
|
+
EM_LOG("findFirst: START");
|
|
48
|
+
if (!root) {
|
|
49
|
+
EM_LOG("findFirst: no root");
|
|
50
|
+
return nullptr;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Fast path: id-only selector -> O(1) registry lookup
|
|
54
|
+
if (criteria.isIdOnly() && criteria.id) {
|
|
55
|
+
EM_LOG("findFirst: id-only fast path, id=%s", criteria.id->c_str());
|
|
56
|
+
auto& registry = TestIDRegistry::getInstance();
|
|
57
|
+
auto node = registry.findByTestID(*criteria.id);
|
|
58
|
+
if (node) {
|
|
59
|
+
EM_LOG("findFirst: found in registry");
|
|
60
|
+
return node;
|
|
61
|
+
}
|
|
62
|
+
// Fallback to tree search
|
|
63
|
+
EM_LOG("findFirst: fallback to tree search");
|
|
64
|
+
return ShadowTreeTraverser::findByTestID(root, *criteria.id);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Complex selector: tree traversal with matching
|
|
68
|
+
EM_LOG("findFirst: complex selector, calling findAll");
|
|
69
|
+
auto results = findAll(root, criteria);
|
|
70
|
+
EM_LOG("findFirst: findAll returned %zu results", results.size());
|
|
71
|
+
|
|
72
|
+
// Apply index if specified
|
|
73
|
+
if (criteria.index) {
|
|
74
|
+
int idx = *criteria.index;
|
|
75
|
+
if (idx >= 0 && idx < static_cast<int>(results.size())) {
|
|
76
|
+
EM_LOG("findFirst: returning indexed result");
|
|
77
|
+
return results[idx];
|
|
78
|
+
}
|
|
79
|
+
EM_LOG("findFirst: index out of bounds");
|
|
80
|
+
return nullptr;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (results.empty()) {
|
|
84
|
+
EM_LOG("findFirst: END returning null");
|
|
85
|
+
return nullptr;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// For text selectors with multiple matches, prefer interactive elements (buttons)
|
|
89
|
+
if (criteria.text && results.size() > 1) {
|
|
90
|
+
EM_LOG("findFirst: text selector with %zu matches, looking for buttons", results.size());
|
|
91
|
+
for (const auto& node : results) {
|
|
92
|
+
if (isLikelyButton(node)) {
|
|
93
|
+
EM_LOG("findFirst: found button match");
|
|
94
|
+
return node;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// No button found, fall back to first match
|
|
98
|
+
EM_LOG("findFirst: no button found, using first match");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
EM_LOG("findFirst: END returning first result");
|
|
102
|
+
return results[0];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
std::vector<ShadowTreeTraverser::ShadowNodePtr> ElementMatcher::findAll(
|
|
106
|
+
ShadowNodePtr root,
|
|
107
|
+
const SelectorCriteria& criteria
|
|
108
|
+
) {
|
|
109
|
+
std::vector<ShadowNodePtr> results;
|
|
110
|
+
|
|
111
|
+
if (!root) {
|
|
112
|
+
return results;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Initialize match context
|
|
116
|
+
MatchContext ctx;
|
|
117
|
+
ctx.root = root;
|
|
118
|
+
|
|
119
|
+
// Resolve spatial reference elements
|
|
120
|
+
resolveSpatialRefs(criteria, ctx);
|
|
121
|
+
|
|
122
|
+
// Traverse tree and collect matches
|
|
123
|
+
collectMatches(root, criteria, ctx, results);
|
|
124
|
+
|
|
125
|
+
return results;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
std::optional<ExtendedElementInfo> ElementMatcher::getExtendedElementInfo(
|
|
129
|
+
ShadowNodePtr root,
|
|
130
|
+
ShadowNodePtr node
|
|
131
|
+
) {
|
|
132
|
+
auto baseInfo = ShadowTreeTraverser::getElementInfo(node);
|
|
133
|
+
if (!baseInfo) {
|
|
134
|
+
return std::nullopt;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
ExtendedElementInfo info;
|
|
138
|
+
// Copy base info
|
|
139
|
+
info.testID = baseInfo->testID;
|
|
140
|
+
info.type = baseInfo->type;
|
|
141
|
+
info.text = baseInfo->text;
|
|
142
|
+
info.accessible = baseInfo->accessible;
|
|
143
|
+
info.enabled = baseInfo->enabled;
|
|
144
|
+
info.layout = baseInfo->layout;
|
|
145
|
+
|
|
146
|
+
// Extract additional state props
|
|
147
|
+
extractStateProps(node, info.checked, info.focused, info.selected);
|
|
148
|
+
|
|
149
|
+
return info;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
void ElementMatcher::resolveSpatialRefs(
|
|
153
|
+
const SelectorCriteria& criteria,
|
|
154
|
+
MatchContext& ctx
|
|
155
|
+
) {
|
|
156
|
+
// Resolve 'below' reference
|
|
157
|
+
if (criteria.below) {
|
|
158
|
+
auto refNode = findFirst(ctx.root, *criteria.below);
|
|
159
|
+
if (refNode) {
|
|
160
|
+
ctx.belowRef = getNodeMetrics(ctx.root, refNode);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Resolve 'above' reference
|
|
165
|
+
if (criteria.above) {
|
|
166
|
+
auto refNode = findFirst(ctx.root, *criteria.above);
|
|
167
|
+
if (refNode) {
|
|
168
|
+
ctx.aboveRef = getNodeMetrics(ctx.root, refNode);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Resolve 'leftOf' reference
|
|
173
|
+
if (criteria.leftOf) {
|
|
174
|
+
auto refNode = findFirst(ctx.root, *criteria.leftOf);
|
|
175
|
+
if (refNode) {
|
|
176
|
+
ctx.leftOfRef = getNodeMetrics(ctx.root, refNode);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Resolve 'rightOf' reference
|
|
181
|
+
if (criteria.rightOf) {
|
|
182
|
+
auto refNode = findFirst(ctx.root, *criteria.rightOf);
|
|
183
|
+
if (refNode) {
|
|
184
|
+
ctx.rightOfRef = getNodeMetrics(ctx.root, refNode);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
bool ElementMatcher::matches(
|
|
190
|
+
ShadowNodePtr node,
|
|
191
|
+
const SelectorCriteria& criteria,
|
|
192
|
+
const MatchContext& ctx
|
|
193
|
+
) {
|
|
194
|
+
if (!node) {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Check primary criteria
|
|
199
|
+
if (!matchesPrimary(node, criteria)) {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Check state criteria
|
|
204
|
+
if (!matchesState(node, criteria)) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Get metrics for spatial/dimension checks
|
|
209
|
+
auto metrics = getNodeMetrics(ctx.root, node);
|
|
210
|
+
|
|
211
|
+
// Check spatial criteria (if refs resolved)
|
|
212
|
+
if (criteria.hasSpatialCriteria()) {
|
|
213
|
+
if (!metrics) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
if (!matchesSpatial(node, *metrics, ctx)) {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Check hierarchical criteria
|
|
222
|
+
if (criteria.hasHierarchicalCriteria()) {
|
|
223
|
+
if (!matchesHierarchical(node, criteria, ctx)) {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Check dimension criteria
|
|
229
|
+
if (criteria.hasDimensionCriteria()) {
|
|
230
|
+
if (!metrics) {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
if (!matchesDimensions(*metrics, criteria)) {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Check trait criteria
|
|
239
|
+
if (!criteria.traits.empty()) {
|
|
240
|
+
if (!metrics) {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
if (!matchesTraits(node, *metrics, criteria.traits)) {
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
bool ElementMatcher::matchesPrimary(
|
|
252
|
+
ShadowNodePtr node,
|
|
253
|
+
const SelectorCriteria& criteria
|
|
254
|
+
) {
|
|
255
|
+
// Match id
|
|
256
|
+
if (criteria.id) {
|
|
257
|
+
auto nodeTestID = ShadowTreeTraverser::getTestID(*node);
|
|
258
|
+
if (!nodeTestID || *nodeTestID != *criteria.id) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Match text
|
|
264
|
+
if (criteria.text) {
|
|
265
|
+
auto nodeText = ShadowTreeTraverser::getText(node);
|
|
266
|
+
if (!nodeText) {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
if (!matchesText(*nodeText, *criteria.text)) {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Point matching is handled separately (coordinate-based selection)
|
|
275
|
+
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
bool ElementMatcher::matchesText(
|
|
280
|
+
const std::string& actual,
|
|
281
|
+
const TextMatcher& matcher
|
|
282
|
+
) {
|
|
283
|
+
switch (matcher.mode) {
|
|
284
|
+
case TextMatchMode::Exact:
|
|
285
|
+
return actual == matcher.pattern;
|
|
286
|
+
|
|
287
|
+
case TextMatchMode::Contains:
|
|
288
|
+
return actual.find(matcher.pattern) != std::string::npos;
|
|
289
|
+
|
|
290
|
+
case TextMatchMode::StartsWith:
|
|
291
|
+
return actual.size() >= matcher.pattern.size() &&
|
|
292
|
+
actual.compare(0, matcher.pattern.size(), matcher.pattern) == 0;
|
|
293
|
+
|
|
294
|
+
case TextMatchMode::EndsWith:
|
|
295
|
+
return actual.size() >= matcher.pattern.size() &&
|
|
296
|
+
actual.compare(actual.size() - matcher.pattern.size(),
|
|
297
|
+
matcher.pattern.size(), matcher.pattern) == 0;
|
|
298
|
+
|
|
299
|
+
case TextMatchMode::Regex:
|
|
300
|
+
try {
|
|
301
|
+
std::regex re(matcher.pattern);
|
|
302
|
+
return std::regex_search(actual, re);
|
|
303
|
+
} catch (...) {
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
bool ElementMatcher::matchesState(
|
|
312
|
+
ShadowNodePtr node,
|
|
313
|
+
const SelectorCriteria& criteria
|
|
314
|
+
) {
|
|
315
|
+
// Get ViewProps for state info
|
|
316
|
+
auto viewProps = std::dynamic_pointer_cast<const facebook::react::ViewProps>(
|
|
317
|
+
node->getProps()
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
// Match enabled
|
|
321
|
+
if (criteria.enabled.has_value()) {
|
|
322
|
+
// Default to enabled if no pointerEvents prop
|
|
323
|
+
bool isEnabled = true;
|
|
324
|
+
if (viewProps) {
|
|
325
|
+
// Check pointerEvents - none means disabled for interactions
|
|
326
|
+
isEnabled = viewProps->pointerEvents != facebook::react::PointerEventsMode::None;
|
|
327
|
+
}
|
|
328
|
+
if (isEnabled != *criteria.enabled) {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// For checked, focused, selected - extract from node props
|
|
334
|
+
bool checked = false, focused = false, selected = false;
|
|
335
|
+
extractStateProps(node, checked, focused, selected);
|
|
336
|
+
|
|
337
|
+
if (criteria.checked.has_value() && checked != *criteria.checked) {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (criteria.focused.has_value() && focused != *criteria.focused) {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (criteria.selected.has_value() && selected != *criteria.selected) {
|
|
346
|
+
return false;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return true;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
bool ElementMatcher::matchesSpatial(
|
|
353
|
+
ShadowNodePtr node,
|
|
354
|
+
const LayoutMetrics& nodeMetrics,
|
|
355
|
+
const MatchContext& ctx
|
|
356
|
+
) {
|
|
357
|
+
// Check 'below' - node must be below the reference
|
|
358
|
+
if (ctx.belowRef) {
|
|
359
|
+
if (!isBelow(nodeMetrics, *ctx.belowRef)) {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Check 'above' - node must be above the reference
|
|
365
|
+
if (ctx.aboveRef) {
|
|
366
|
+
if (!isAbove(nodeMetrics, *ctx.aboveRef)) {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Check 'leftOf' - node must be left of the reference
|
|
372
|
+
if (ctx.leftOfRef) {
|
|
373
|
+
if (!isLeftOf(nodeMetrics, *ctx.leftOfRef)) {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Check 'rightOf' - node must be right of the reference
|
|
379
|
+
if (ctx.rightOfRef) {
|
|
380
|
+
if (!isRightOf(nodeMetrics, *ctx.rightOfRef)) {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
bool ElementMatcher::isBelow(const LayoutMetrics& candidate, const LayoutMetrics& reference) {
|
|
389
|
+
// Candidate's top edge must be below reference's bottom edge
|
|
390
|
+
return candidate.screenY > (reference.screenY + reference.height);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
bool ElementMatcher::isAbove(const LayoutMetrics& candidate, const LayoutMetrics& reference) {
|
|
394
|
+
// Candidate's bottom edge must be above reference's top edge
|
|
395
|
+
return (candidate.screenY + candidate.height) < reference.screenY;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
bool ElementMatcher::isLeftOf(const LayoutMetrics& candidate, const LayoutMetrics& reference) {
|
|
399
|
+
// Candidate's right edge must be left of reference's left edge
|
|
400
|
+
return (candidate.screenX + candidate.width) < reference.screenX;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
bool ElementMatcher::isRightOf(const LayoutMetrics& candidate, const LayoutMetrics& reference) {
|
|
404
|
+
// Candidate's left edge must be right of reference's right edge
|
|
405
|
+
return candidate.screenX > (reference.screenX + reference.width);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
bool ElementMatcher::matchesHierarchical(
|
|
409
|
+
ShadowNodePtr node,
|
|
410
|
+
const SelectorCriteria& criteria,
|
|
411
|
+
const MatchContext& ctx
|
|
412
|
+
) {
|
|
413
|
+
// Check containsChild
|
|
414
|
+
if (criteria.containsChild) {
|
|
415
|
+
if (!containsChild(node, *criteria.containsChild, ctx)) {
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Check childOf
|
|
421
|
+
if (criteria.childOf) {
|
|
422
|
+
if (!isChildOf(ctx.parentChain, *criteria.childOf, ctx)) {
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Check containsDescendants
|
|
428
|
+
if (!criteria.containsDescendants.empty()) {
|
|
429
|
+
if (!containsDescendants(node, criteria.containsDescendants, ctx)) {
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
bool ElementMatcher::containsChild(
|
|
438
|
+
ShadowNodePtr node,
|
|
439
|
+
const SelectorCriteria& criteria,
|
|
440
|
+
const MatchContext& ctx
|
|
441
|
+
) {
|
|
442
|
+
// Check direct children only
|
|
443
|
+
for (const auto& child : node->getChildren()) {
|
|
444
|
+
// Create a simple criteria check (without recursion for children)
|
|
445
|
+
if (matchesPrimary(child, criteria) && matchesState(child, criteria)) {
|
|
446
|
+
return true;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
bool ElementMatcher::isChildOf(
|
|
453
|
+
const std::vector<const facebook::react::ShadowNode*>& parentChain,
|
|
454
|
+
const SelectorCriteria& criteria,
|
|
455
|
+
const MatchContext& ctx
|
|
456
|
+
) {
|
|
457
|
+
// Walk up the parent chain
|
|
458
|
+
for (const auto* parent : parentChain) {
|
|
459
|
+
// Create shared_ptr wrapper for the parent (without ownership)
|
|
460
|
+
// We need to check if parent matches criteria
|
|
461
|
+
auto parentPtr = std::shared_ptr<const facebook::react::ShadowNode>(
|
|
462
|
+
parent, [](const facebook::react::ShadowNode*) {} // no-op deleter
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
if (matchesPrimary(parentPtr, criteria)) {
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
bool ElementMatcher::containsDescendants(
|
|
473
|
+
ShadowNodePtr node,
|
|
474
|
+
const std::vector<SelectorCriteriaPtr>& criteriaList,
|
|
475
|
+
const MatchContext& ctx
|
|
476
|
+
) {
|
|
477
|
+
// All criteria must match at least one descendant
|
|
478
|
+
for (const auto& criteria : criteriaList) {
|
|
479
|
+
bool found = false;
|
|
480
|
+
|
|
481
|
+
// DFS to find any matching descendant
|
|
482
|
+
std::function<bool(ShadowNodePtr)> search = [&](ShadowNodePtr n) -> bool {
|
|
483
|
+
if (matchesPrimary(n, *criteria) && matchesState(n, *criteria)) {
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
for (const auto& child : n->getChildren()) {
|
|
487
|
+
if (search(child)) {
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return false;
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
// Search in children (not including the node itself)
|
|
495
|
+
for (const auto& child : node->getChildren()) {
|
|
496
|
+
if (search(child)) {
|
|
497
|
+
found = true;
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if (!found) {
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return true;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
bool ElementMatcher::matchesDimensions(
|
|
510
|
+
const LayoutMetrics& metrics,
|
|
511
|
+
const SelectorCriteria& criteria
|
|
512
|
+
) {
|
|
513
|
+
float tol = criteria.tolerance.value_or(0.0f);
|
|
514
|
+
|
|
515
|
+
if (criteria.width) {
|
|
516
|
+
float diff = std::abs(metrics.width - *criteria.width);
|
|
517
|
+
if (diff > tol) {
|
|
518
|
+
return false;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (criteria.height) {
|
|
523
|
+
float diff = std::abs(metrics.height - *criteria.height);
|
|
524
|
+
if (diff > tol) {
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
bool ElementMatcher::matchesTraits(
|
|
533
|
+
ShadowNodePtr node,
|
|
534
|
+
const LayoutMetrics& metrics,
|
|
535
|
+
const std::vector<Trait>& traits
|
|
536
|
+
) {
|
|
537
|
+
for (const auto& trait : traits) {
|
|
538
|
+
switch (trait) {
|
|
539
|
+
case Trait::Text: {
|
|
540
|
+
auto text = ShadowTreeTraverser::getText(node);
|
|
541
|
+
if (!text || text->empty()) {
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
break;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
case Trait::LongText: {
|
|
548
|
+
auto text = ShadowTreeTraverser::getText(node);
|
|
549
|
+
if (!text || text->size() < 200) {
|
|
550
|
+
return false;
|
|
551
|
+
}
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
case Trait::Square: {
|
|
556
|
+
// Width approximately equals height (within 10%)
|
|
557
|
+
float ratio = metrics.width / std::max(metrics.height, 0.001f);
|
|
558
|
+
if (ratio < 0.9f || ratio > 1.1f) {
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return true;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
std::optional<LayoutMetrics> ElementMatcher::getNodeMetrics(
|
|
569
|
+
ShadowNodePtr root,
|
|
570
|
+
ShadowNodePtr node
|
|
571
|
+
) {
|
|
572
|
+
// Get testID to use existing layout metrics function
|
|
573
|
+
auto testID = ShadowTreeTraverser::getTestID(*node);
|
|
574
|
+
if (testID) {
|
|
575
|
+
return ShadowTreeTraverser::getLayoutMetrics(root, *testID);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Fallback: get local metrics
|
|
579
|
+
auto layoutable = dynamic_cast<const facebook::react::LayoutableShadowNode*>(node.get());
|
|
580
|
+
if (!layoutable) {
|
|
581
|
+
return std::nullopt;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
auto fbMetrics = layoutable->getLayoutMetrics();
|
|
585
|
+
LayoutMetrics metrics;
|
|
586
|
+
metrics.x = fbMetrics.frame.origin.x;
|
|
587
|
+
metrics.y = fbMetrics.frame.origin.y;
|
|
588
|
+
metrics.width = fbMetrics.frame.size.width;
|
|
589
|
+
metrics.height = fbMetrics.frame.size.height;
|
|
590
|
+
// Note: screenX/Y won't be accurate without parent chain
|
|
591
|
+
metrics.screenX = metrics.x;
|
|
592
|
+
metrics.screenY = metrics.y;
|
|
593
|
+
|
|
594
|
+
return metrics;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
void ElementMatcher::extractStateProps(
|
|
598
|
+
ShadowNodePtr node,
|
|
599
|
+
bool& outChecked,
|
|
600
|
+
bool& outFocused,
|
|
601
|
+
bool& outSelected
|
|
602
|
+
) {
|
|
603
|
+
outChecked = false;
|
|
604
|
+
outFocused = false;
|
|
605
|
+
outSelected = false;
|
|
606
|
+
|
|
607
|
+
// Try to get accessibilityState from ViewProps
|
|
608
|
+
auto viewProps = std::dynamic_pointer_cast<const facebook::react::ViewProps>(
|
|
609
|
+
node->getProps()
|
|
610
|
+
);
|
|
611
|
+
|
|
612
|
+
if (viewProps && viewProps->accessibilityState.has_value()) {
|
|
613
|
+
// Check accessibilityState
|
|
614
|
+
const auto& accState = viewProps->accessibilityState.value();
|
|
615
|
+
// checked is an inline enum: { Unchecked=0, Checked=1, Mixed=2, None=3 }
|
|
616
|
+
// Treat Checked (1) and Mixed (2) as true
|
|
617
|
+
outChecked = (accState.checked == 1 || accState.checked == 2);
|
|
618
|
+
outSelected = accState.selected;
|
|
619
|
+
|
|
620
|
+
// Focused state is typically tracked at runtime, not in props
|
|
621
|
+
// For now, we can't reliably determine focus from shadow tree
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
void ElementMatcher::collectMatches(
|
|
626
|
+
ShadowNodePtr node,
|
|
627
|
+
const SelectorCriteria& criteria,
|
|
628
|
+
MatchContext& ctx,
|
|
629
|
+
std::vector<ShadowNodePtr>& results
|
|
630
|
+
) {
|
|
631
|
+
if (!node) {
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Check if this node matches
|
|
636
|
+
if (matches(node, criteria, ctx)) {
|
|
637
|
+
results.push_back(node);
|
|
638
|
+
// Debug log first 5 matches
|
|
639
|
+
if (results.size() <= 5 && criteria.text) {
|
|
640
|
+
auto nodeText = ShadowTreeTraverser::getText(node);
|
|
641
|
+
auto testID = ShadowTreeTraverser::getTestID(*node);
|
|
642
|
+
EM_LOG("collectMatches: MATCH #%zu testID=%s text='%.30s...'",
|
|
643
|
+
results.size(),
|
|
644
|
+
testID ? testID->c_str() : "none",
|
|
645
|
+
nodeText ? nodeText->c_str() : "null");
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Track parent chain for hierarchical queries
|
|
650
|
+
ctx.parentChain.push_back(node.get());
|
|
651
|
+
|
|
652
|
+
// Recurse into children
|
|
653
|
+
for (const auto& child : node->getChildren()) {
|
|
654
|
+
collectMatches(child, criteria, ctx, results);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Pop from parent chain
|
|
658
|
+
ctx.parentChain.pop_back();
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
} // namespace ennio
|