appium-session-recorder 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/LICENSE +21 -0
- package/README.md +362 -0
- package/bun.lock +731 -0
- package/package.json +62 -0
- package/skills/appium-cli-selector-navigator/SKILL.md +349 -0
- package/src/cli/arg-parser.ts +311 -0
- package/src/cli/commands/drive.ts +147 -0
- package/src/cli/commands/index.ts +54 -0
- package/src/cli/commands/proxy.ts +41 -0
- package/src/cli/commands/screen.ts +73 -0
- package/src/cli/commands/selectors.ts +42 -0
- package/src/cli/commands/session.ts +64 -0
- package/src/cli/commands/types.ts +11 -0
- package/src/cli/index.ts +158 -0
- package/src/cli/prompts.ts +64 -0
- package/src/cli/response.ts +44 -0
- package/src/core/appium/client.ts +248 -0
- package/src/core/index.ts +5 -0
- package/src/core/selectors/generate-candidates.ts +155 -0
- package/src/core/selectors/score-candidates.ts +184 -0
- package/src/core/types.ts +79 -0
- package/src/core/xml/parse-source.ts +197 -0
- package/src/index.ts +7 -0
- package/src/server/appium-client.ts +24 -0
- package/src/server/index.ts +6 -0
- package/src/server/interaction-recorder.ts +74 -0
- package/src/server/proxy-middleware.ts +68 -0
- package/src/server/routes.ts +53 -0
- package/src/server/server.ts +43 -0
- package/src/server/types.ts +34 -0
- package/src/ui/bun.lock +311 -0
- package/src/ui/index.html +16 -0
- package/src/ui/package.json +20 -0
- package/src/ui/src/App.css +12 -0
- package/src/ui/src/App.tsx +41 -0
- package/src/ui/src/components/ActionCarousel.css +128 -0
- package/src/ui/src/components/ActionCarousel.tsx +92 -0
- package/src/ui/src/components/Inspector.css +314 -0
- package/src/ui/src/components/Inspector.tsx +265 -0
- package/src/ui/src/components/InteractionCard.css +159 -0
- package/src/ui/src/components/InteractionCard.tsx +60 -0
- package/src/ui/src/components/MainInspector.css +304 -0
- package/src/ui/src/components/MainInspector.tsx +304 -0
- package/src/ui/src/components/Stats.css +27 -0
- package/src/ui/src/components/Timeline.css +31 -0
- package/src/ui/src/components/Timeline.tsx +37 -0
- package/src/ui/src/hooks/useInteractions.ts +73 -0
- package/src/ui/src/index.tsx +11 -0
- package/src/ui/src/services/api.ts +41 -0
- package/src/ui/src/styles/tokens.css +126 -0
- package/src/ui/src/types.ts +34 -0
- package/src/ui/src/utils/__tests__/locators.test.ts +304 -0
- package/src/ui/src/utils/__tests__/xml-parser.test.ts +326 -0
- package/src/ui/src/utils/locators.ts +14 -0
- package/src/ui/src/utils/xml-parser.ts +45 -0
- package/src/ui/tsconfig.json +34 -0
- package/src/ui/tsconfig.node.json +11 -0
- package/src/ui/vite.config.ts +22 -0
- package/tests/cli/arg-parser.test.ts +397 -0
- package/tests/cli/drive-commands.test.ts +151 -0
- package/tests/cli/selectors-best.test.ts +42 -0
- package/tests/cli/session-commands.test.ts +53 -0
- package/tests/core/selector-candidates.test.ts +83 -0
- package/tests/core/selector-scoring.test.ts +75 -0
- package/tests/core/xml-parser.test.ts +56 -0
- package/tests/server/appium-client.test.ts +229 -0
- package/tests/server/interaction-recorder.test.ts +377 -0
- package/tests/server/proxy-middleware.test.ts +343 -0
- package/tests/server/routes.test.ts +305 -0
- package/tsconfig.json +26 -0
- package/vitest.config.ts +16 -0
- package/vitest.ui.config.ts +15 -0
- package/workflow.gif +0 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { ParsedElement, Platform, SelectorCandidate } from '../types';
|
|
2
|
+
|
|
3
|
+
function pushUnique(candidates: SelectorCandidate[], candidate: SelectorCandidate): void {
|
|
4
|
+
if (!candidate.value) return;
|
|
5
|
+
if (candidates.some(existing => existing.strategy === candidate.strategy && existing.value === candidate.value)) {
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
candidates.push(candidate);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function escapeDoubleQuoted(value: string): string {
|
|
12
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function generateSelectorCandidates(element: ParsedElement): SelectorCandidate[] {
|
|
16
|
+
const candidates: SelectorCandidate[] = [];
|
|
17
|
+
const platform: Platform = element.platform;
|
|
18
|
+
|
|
19
|
+
const primaryAccessibility = element.name || element.label || element.contentDesc;
|
|
20
|
+
if (primaryAccessibility) {
|
|
21
|
+
pushUnique(candidates, {
|
|
22
|
+
strategy: 'accessibility id',
|
|
23
|
+
value: primaryAccessibility,
|
|
24
|
+
platform,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (platform === 'ios') {
|
|
29
|
+
if (element.label && element.label !== primaryAccessibility) {
|
|
30
|
+
pushUnique(candidates, {
|
|
31
|
+
strategy: 'accessibility id',
|
|
32
|
+
value: element.label,
|
|
33
|
+
platform,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (element.name) {
|
|
38
|
+
pushUnique(candidates, {
|
|
39
|
+
strategy: '-ios predicate string',
|
|
40
|
+
value: `name == "${escapeDoubleQuoted(element.name)}"`,
|
|
41
|
+
platform,
|
|
42
|
+
});
|
|
43
|
+
pushUnique(candidates, {
|
|
44
|
+
strategy: '-ios class chain',
|
|
45
|
+
value: `**/${element.type}[\`name == "${escapeDoubleQuoted(element.name)}"\`]`,
|
|
46
|
+
platform,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (element.label) {
|
|
51
|
+
pushUnique(candidates, {
|
|
52
|
+
strategy: '-ios predicate string',
|
|
53
|
+
value: `label == "${escapeDoubleQuoted(element.label)}"`,
|
|
54
|
+
platform,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (platform === 'android') {
|
|
60
|
+
if (element.resourceId) {
|
|
61
|
+
pushUnique(candidates, {
|
|
62
|
+
strategy: 'id',
|
|
63
|
+
value: element.resourceId,
|
|
64
|
+
platform,
|
|
65
|
+
});
|
|
66
|
+
pushUnique(candidates, {
|
|
67
|
+
strategy: '-android uiautomator',
|
|
68
|
+
value: `new UiSelector().resourceId("${escapeDoubleQuoted(element.resourceId)}")`,
|
|
69
|
+
platform,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (element.contentDesc) {
|
|
74
|
+
pushUnique(candidates, {
|
|
75
|
+
strategy: 'accessibility id',
|
|
76
|
+
value: element.contentDesc,
|
|
77
|
+
platform,
|
|
78
|
+
});
|
|
79
|
+
pushUnique(candidates, {
|
|
80
|
+
strategy: '-android uiautomator',
|
|
81
|
+
value: `new UiSelector().description("${escapeDoubleQuoted(element.contentDesc)}")`,
|
|
82
|
+
platform,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (element.text) {
|
|
87
|
+
pushUnique(candidates, {
|
|
88
|
+
strategy: '-android uiautomator',
|
|
89
|
+
value: `new UiSelector().text("${escapeDoubleQuoted(element.text)}")`,
|
|
90
|
+
platform,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (element.type && (element.name || element.label || element.text)) {
|
|
96
|
+
const attrName = element.platform === 'android' ? 'text' : 'name';
|
|
97
|
+
const attrValue = element.platform === 'android' ? element.text || element.label : element.name || element.label;
|
|
98
|
+
if (attrValue) {
|
|
99
|
+
pushUnique(candidates, {
|
|
100
|
+
strategy: 'xpath',
|
|
101
|
+
value: `//*[@type="${escapeDoubleQuoted(element.type)}" and @${attrName}="${escapeDoubleQuoted(attrValue)}"]`,
|
|
102
|
+
platform: 'generic',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
pushUnique(candidates, { strategy: 'xpath', value: element.xpath, platform: 'generic' });
|
|
108
|
+
pushUnique(candidates, { strategy: 'class name', value: element.type, platform: 'generic' });
|
|
109
|
+
|
|
110
|
+
return candidates;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type LegacyLocatorInput = {
|
|
114
|
+
type: string;
|
|
115
|
+
name: string;
|
|
116
|
+
label: string;
|
|
117
|
+
xpath: string;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export function generateLegacyLocators(element: LegacyLocatorInput): SelectorCandidate[] {
|
|
121
|
+
const locators: SelectorCandidate[] = [];
|
|
122
|
+
|
|
123
|
+
if (element.name) {
|
|
124
|
+
locators.push({ strategy: 'accessibility id', value: element.name, platform: 'ios' });
|
|
125
|
+
}
|
|
126
|
+
if (element.label && element.label !== element.name) {
|
|
127
|
+
locators.push({ strategy: 'accessibility id', value: element.label, platform: 'ios' });
|
|
128
|
+
}
|
|
129
|
+
locators.push({ strategy: 'xpath', value: element.xpath, platform: 'generic' });
|
|
130
|
+
locators.push({ strategy: 'class name', value: element.type, platform: 'generic' });
|
|
131
|
+
|
|
132
|
+
if (element.name) {
|
|
133
|
+
locators.push({
|
|
134
|
+
strategy: '-ios predicate string',
|
|
135
|
+
value: `name == "${escapeDoubleQuoted(element.name)}"`,
|
|
136
|
+
platform: 'ios',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
if (element.label) {
|
|
140
|
+
locators.push({
|
|
141
|
+
strategy: '-ios predicate string',
|
|
142
|
+
value: `label == "${escapeDoubleQuoted(element.label)}"`,
|
|
143
|
+
platform: 'ios',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if (element.name) {
|
|
147
|
+
locators.push({
|
|
148
|
+
strategy: '-ios class chain',
|
|
149
|
+
value: `**/${element.type}[\`name == "${escapeDoubleQuoted(element.name)}"\`]`,
|
|
150
|
+
platform: 'ios',
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return locators;
|
|
155
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { ParsedElement, RankedSelector, SelectorCandidate, SelectorReason, SelectorStrategy } from '../types';
|
|
2
|
+
|
|
3
|
+
const baseScoreByStrategy: Record<SelectorStrategy, number> = {
|
|
4
|
+
id: 100,
|
|
5
|
+
'accessibility id': 95,
|
|
6
|
+
'-ios predicate string': 90,
|
|
7
|
+
'-ios class chain': 85,
|
|
8
|
+
'-android uiautomator': 85,
|
|
9
|
+
xpath: 70,
|
|
10
|
+
'class name': 55,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function isDynamicValue(value: string): boolean {
|
|
14
|
+
if (/(?:^|[^a-zA-Z])\d{5,}(?:$|[^a-zA-Z])/.test(value)) return true;
|
|
15
|
+
if (/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}/.test(value)) {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
if (/autogen|generated|tmp|temp|anonymous/i.test(value)) return true;
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function xpathFragilityPenalty(xpath: string): number {
|
|
23
|
+
if (!xpath.startsWith('/')) return 0;
|
|
24
|
+
|
|
25
|
+
const depth = xpath.split('/').filter(Boolean).length;
|
|
26
|
+
const indexCount = (xpath.match(/\[\d+\]/g) || []).length;
|
|
27
|
+
|
|
28
|
+
let penalty = 0;
|
|
29
|
+
if (depth > 5) penalty += 8;
|
|
30
|
+
if (indexCount > 3) penalty += 8;
|
|
31
|
+
|
|
32
|
+
return penalty;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function valueEquals(value: string, expected: string): boolean {
|
|
36
|
+
return value === expected;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function matchIosPredicate(element: ParsedElement, predicate: string): boolean {
|
|
40
|
+
const equalsMatch = predicate.match(/(name|label|type)\s*==\s*['"](.+?)['"]/i);
|
|
41
|
+
if (equalsMatch) {
|
|
42
|
+
const [, field, expected] = equalsMatch;
|
|
43
|
+
if (field === 'name') return valueEquals(element.name, expected);
|
|
44
|
+
if (field === 'label') return valueEquals(element.label, expected);
|
|
45
|
+
return valueEquals(element.type, expected);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const containsMatch = predicate.match(/(name|label|type)\s*CONTAINS\s*['"](.+?)['"]/i);
|
|
49
|
+
if (containsMatch) {
|
|
50
|
+
const [, field, expected] = containsMatch;
|
|
51
|
+
if (field === 'name') return element.name.includes(expected);
|
|
52
|
+
if (field === 'label') return element.label.includes(expected);
|
|
53
|
+
return element.type.includes(expected);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function matchIosClassChain(element: ParsedElement, classChain: string): boolean {
|
|
60
|
+
const classChainMatch = classChain.match(/\*\*\/(\w+)(?:\[`(.+?)`\])?/);
|
|
61
|
+
if (!classChainMatch) return false;
|
|
62
|
+
|
|
63
|
+
const targetType = classChainMatch[1];
|
|
64
|
+
const predicate = classChainMatch[2];
|
|
65
|
+
if (element.type !== targetType) return false;
|
|
66
|
+
|
|
67
|
+
if (!predicate) return true;
|
|
68
|
+
return matchIosPredicate(element, predicate);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function matchAndroidUiAutomator(element: ParsedElement, value: string): boolean {
|
|
72
|
+
const matches = [...value.matchAll(/\.(\w+)\("(.+?)"\)/g)];
|
|
73
|
+
if (matches.length === 0) return false;
|
|
74
|
+
|
|
75
|
+
return matches.every(([, method, expected]) => {
|
|
76
|
+
if (method === 'resourceId') return element.resourceId === expected;
|
|
77
|
+
if (method === 'description') return element.contentDesc === expected;
|
|
78
|
+
if (method === 'text') return element.text === expected || element.label === expected;
|
|
79
|
+
if (method === 'className') return element.type === expected;
|
|
80
|
+
return false;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function matchSimpleXPath(element: ParsedElement, xpath: string): boolean {
|
|
85
|
+
if (xpath === element.xpath) return true;
|
|
86
|
+
|
|
87
|
+
const attrMatches = [...xpath.matchAll(/@(\w+)="([^"]+)"/g)];
|
|
88
|
+
if (attrMatches.length === 0) return false;
|
|
89
|
+
|
|
90
|
+
return attrMatches.every(([, attribute, expected]) => {
|
|
91
|
+
if (attribute === 'type') return element.type === expected;
|
|
92
|
+
if (attribute === 'name') return element.name === expected;
|
|
93
|
+
if (attribute === 'label') return element.label === expected;
|
|
94
|
+
if (attribute === 'text') return element.text === expected;
|
|
95
|
+
if (attribute === 'resource-id') return element.resourceId === expected;
|
|
96
|
+
if (attribute === 'content-desc') return element.contentDesc === expected;
|
|
97
|
+
return element.attributes[attribute] === expected;
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function candidateMatchesElement(candidate: SelectorCandidate, element: ParsedElement): boolean {
|
|
102
|
+
switch (candidate.strategy) {
|
|
103
|
+
case 'id':
|
|
104
|
+
return element.resourceId === candidate.value;
|
|
105
|
+
case 'accessibility id':
|
|
106
|
+
return (
|
|
107
|
+
element.name === candidate.value ||
|
|
108
|
+
element.label === candidate.value ||
|
|
109
|
+
element.contentDesc === candidate.value
|
|
110
|
+
);
|
|
111
|
+
case 'class name':
|
|
112
|
+
return element.type === candidate.value;
|
|
113
|
+
case 'xpath':
|
|
114
|
+
return matchSimpleXPath(element, candidate.value);
|
|
115
|
+
case '-ios predicate string':
|
|
116
|
+
return matchIosPredicate(element, candidate.value);
|
|
117
|
+
case '-ios class chain':
|
|
118
|
+
return matchIosClassChain(element, candidate.value);
|
|
119
|
+
case '-android uiautomator':
|
|
120
|
+
return matchAndroidUiAutomator(element, candidate.value);
|
|
121
|
+
default:
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function rankSelectorCandidates(
|
|
127
|
+
target: ParsedElement,
|
|
128
|
+
allElements: ParsedElement[],
|
|
129
|
+
candidates: SelectorCandidate[],
|
|
130
|
+
): RankedSelector[] {
|
|
131
|
+
const ranked = candidates.map((candidate) => {
|
|
132
|
+
const reasons: SelectorReason[] = ['BASE_STRATEGY_PRIORITY'];
|
|
133
|
+
let score = baseScoreByStrategy[candidate.strategy] ?? 50;
|
|
134
|
+
|
|
135
|
+
const matchCount = allElements.filter(element => candidateMatchesElement(candidate, element)).length;
|
|
136
|
+
|
|
137
|
+
if (matchCount === 1) {
|
|
138
|
+
score += 25;
|
|
139
|
+
reasons.push('UNIQUE_MATCH');
|
|
140
|
+
} else if (matchCount > 1) {
|
|
141
|
+
score -= 10;
|
|
142
|
+
reasons.push('MULTIPLE_MATCHES');
|
|
143
|
+
} else {
|
|
144
|
+
score -= 40;
|
|
145
|
+
reasons.push('NO_MATCH');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (matchCount > 0) {
|
|
149
|
+
score += 5;
|
|
150
|
+
reasons.push('VALID_MATCH');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (target.enabled && target.visible && (target.clickable || target.accessible)) {
|
|
154
|
+
score += 8;
|
|
155
|
+
reasons.push('ACTIONABLE_ELEMENT_BONUS');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (isDynamicValue(candidate.value)) {
|
|
159
|
+
score -= 18;
|
|
160
|
+
reasons.push('DYNAMIC_TOKEN_PENALTY');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (candidate.strategy === 'xpath') {
|
|
164
|
+
const penalty = xpathFragilityPenalty(candidate.value);
|
|
165
|
+
if (penalty > 0) {
|
|
166
|
+
score -= penalty;
|
|
167
|
+
reasons.push('FRAGILE_XPATH_PENALTY');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
...candidate,
|
|
173
|
+
score: Math.max(0, Math.min(100, Math.round(score))),
|
|
174
|
+
matchCount,
|
|
175
|
+
reasons,
|
|
176
|
+
};
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
return ranked.sort((a, b) => {
|
|
180
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
181
|
+
if (a.matchCount !== b.matchCount) return a.matchCount - b.matchCount;
|
|
182
|
+
return a.value.localeCompare(b.value);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export type Platform = 'ios' | 'android' | 'unknown';
|
|
2
|
+
|
|
3
|
+
export type SelectorStrategy =
|
|
4
|
+
| 'accessibility id'
|
|
5
|
+
| 'id'
|
|
6
|
+
| 'xpath'
|
|
7
|
+
| 'class name'
|
|
8
|
+
| '-ios predicate string'
|
|
9
|
+
| '-ios class chain'
|
|
10
|
+
| '-android uiautomator';
|
|
11
|
+
|
|
12
|
+
export type SelectorReason =
|
|
13
|
+
| 'BASE_STRATEGY_PRIORITY'
|
|
14
|
+
| 'UNIQUE_MATCH'
|
|
15
|
+
| 'MULTIPLE_MATCHES'
|
|
16
|
+
| 'NO_MATCH'
|
|
17
|
+
| 'VALID_MATCH'
|
|
18
|
+
| 'ACTIONABLE_ELEMENT_BONUS'
|
|
19
|
+
| 'DYNAMIC_TOKEN_PENALTY'
|
|
20
|
+
| 'FRAGILE_XPATH_PENALTY';
|
|
21
|
+
|
|
22
|
+
export type ParsedElement = {
|
|
23
|
+
elementRef: string;
|
|
24
|
+
index: number;
|
|
25
|
+
platform: Platform;
|
|
26
|
+
type: string;
|
|
27
|
+
xpath: string;
|
|
28
|
+
name: string;
|
|
29
|
+
label: string;
|
|
30
|
+
value: string;
|
|
31
|
+
text: string;
|
|
32
|
+
resourceId: string;
|
|
33
|
+
contentDesc: string;
|
|
34
|
+
enabled: boolean;
|
|
35
|
+
visible: boolean;
|
|
36
|
+
accessible: boolean;
|
|
37
|
+
clickable: boolean;
|
|
38
|
+
x: number;
|
|
39
|
+
y: number;
|
|
40
|
+
width: number;
|
|
41
|
+
height: number;
|
|
42
|
+
attributes: Record<string, string>;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type SelectorCandidate = {
|
|
46
|
+
strategy: SelectorStrategy;
|
|
47
|
+
value: string;
|
|
48
|
+
platform: Platform | 'generic';
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type RankedSelector = SelectorCandidate & {
|
|
52
|
+
score: number;
|
|
53
|
+
matchCount: number;
|
|
54
|
+
reasons: SelectorReason[];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type ParsedSource = {
|
|
58
|
+
platform: Platform;
|
|
59
|
+
elements: ParsedElement[];
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type CommandError = {
|
|
63
|
+
code: string;
|
|
64
|
+
message: string;
|
|
65
|
+
details?: unknown;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type CommandResponse<T = unknown> = {
|
|
69
|
+
ok: boolean;
|
|
70
|
+
command: string;
|
|
71
|
+
timestamp: string;
|
|
72
|
+
result?: T;
|
|
73
|
+
error?: CommandError;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type Point = {
|
|
77
|
+
x: number;
|
|
78
|
+
y: number;
|
|
79
|
+
};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { XMLParser } from 'fast-xml-parser';
|
|
2
|
+
import type { ParsedElement, ParsedSource, Platform } from '../types';
|
|
3
|
+
|
|
4
|
+
type XmlNode = Record<string, unknown>;
|
|
5
|
+
|
|
6
|
+
const parser = new XMLParser({
|
|
7
|
+
ignoreAttributes: false,
|
|
8
|
+
attributeNamePrefix: '',
|
|
9
|
+
parseTagValue: false,
|
|
10
|
+
parseAttributeValue: false,
|
|
11
|
+
trimValues: false,
|
|
12
|
+
textNodeName: '#text',
|
|
13
|
+
preserveOrder: false,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
function asString(value: unknown): string {
|
|
17
|
+
if (value === undefined || value === null) return '';
|
|
18
|
+
return String(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function asBoolean(value: unknown): boolean {
|
|
22
|
+
if (typeof value === 'boolean') return value;
|
|
23
|
+
return asString(value).toLowerCase() === 'true' || asString(value) === '1';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function asNumber(value: unknown): number {
|
|
27
|
+
const n = Number(asString(value));
|
|
28
|
+
return Number.isFinite(n) ? n : 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseAndroidBounds(bounds: string): { x: number; y: number; width: number; height: number } {
|
|
32
|
+
const match = bounds.match(/\[(\-?\d+),(\-?\d+)\]\[(\-?\d+),(\-?\d+)\]/);
|
|
33
|
+
if (!match) {
|
|
34
|
+
return { x: 0, y: 0, width: 0, height: 0 };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const x1 = Number(match[1]);
|
|
38
|
+
const y1 = Number(match[2]);
|
|
39
|
+
const x2 = Number(match[3]);
|
|
40
|
+
const y2 = Number(match[4]);
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
x: x1,
|
|
44
|
+
y: y1,
|
|
45
|
+
width: Math.max(0, x2 - x1),
|
|
46
|
+
height: Math.max(0, y2 - y1),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function detectPlatform(type: string, attrs: Record<string, string>): Platform {
|
|
51
|
+
if (type.startsWith('XCUIElementType') || attrs.type?.startsWith('XCUIElementType')) {
|
|
52
|
+
return 'ios';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (
|
|
56
|
+
type.startsWith('android.') ||
|
|
57
|
+
attrs.class?.startsWith('android.') ||
|
|
58
|
+
attrs['resource-id'] ||
|
|
59
|
+
attrs['content-desc']
|
|
60
|
+
) {
|
|
61
|
+
return 'android';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return 'unknown';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function extractAttributes(node: XmlNode): Record<string, string> {
|
|
68
|
+
const attrs: Record<string, string> = {};
|
|
69
|
+
|
|
70
|
+
for (const [key, value] of Object.entries(node)) {
|
|
71
|
+
if (value === null || value === undefined || key === '#text') continue;
|
|
72
|
+
if (Array.isArray(value)) continue;
|
|
73
|
+
if (typeof value === 'object') continue;
|
|
74
|
+
attrs[key] = String(value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return attrs;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function extractChildren(node: XmlNode): Array<{ tag: string; child: XmlNode }> {
|
|
81
|
+
const children: Array<{ tag: string; child: XmlNode }> = [];
|
|
82
|
+
|
|
83
|
+
for (const [key, value] of Object.entries(node)) {
|
|
84
|
+
if (key === '#text' || value === null || value === undefined) continue;
|
|
85
|
+
|
|
86
|
+
if (Array.isArray(value)) {
|
|
87
|
+
for (const item of value) {
|
|
88
|
+
if (item && typeof item === 'object') {
|
|
89
|
+
children.push({ tag: key, child: item as XmlNode });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (typeof value === 'object') {
|
|
96
|
+
children.push({ tag: key, child: value as XmlNode });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return children;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function makeElementRef(platform: Platform, xpath: string): string {
|
|
104
|
+
return `${platform}:${xpath}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function parseSource(xmlString: string): ParsedSource {
|
|
108
|
+
if (!xmlString || !xmlString.trim()) {
|
|
109
|
+
return { platform: 'unknown', elements: [] };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let parsed: Record<string, unknown>;
|
|
113
|
+
try {
|
|
114
|
+
parsed = parser.parse(xmlString) as Record<string, unknown>;
|
|
115
|
+
} catch {
|
|
116
|
+
return { platform: 'unknown', elements: [] };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const rootEntry = Object.entries(parsed).find(([, value]) => value && typeof value === 'object');
|
|
120
|
+
if (!rootEntry) {
|
|
121
|
+
return { platform: 'unknown', elements: [] };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const [rootTag, rootNode] = rootEntry;
|
|
125
|
+
const elements: ParsedElement[] = [];
|
|
126
|
+
let nextIndex = 0;
|
|
127
|
+
|
|
128
|
+
function visit(tag: string, node: XmlNode, parentXpath: string, siblingIndex: number): void {
|
|
129
|
+
const attrs = extractAttributes(node);
|
|
130
|
+
const type = attrs.type || attrs.class || tag;
|
|
131
|
+
const xpath = `${parentXpath}/${type}[${siblingIndex}]`;
|
|
132
|
+
|
|
133
|
+
const platform = detectPlatform(type, attrs);
|
|
134
|
+
const bounds = attrs.bounds ? parseAndroidBounds(attrs.bounds) : undefined;
|
|
135
|
+
|
|
136
|
+
const x = attrs.x !== undefined ? asNumber(attrs.x) : (bounds?.x ?? 0);
|
|
137
|
+
const y = attrs.y !== undefined ? asNumber(attrs.y) : (bounds?.y ?? 0);
|
|
138
|
+
const width = attrs.width !== undefined ? asNumber(attrs.width) : (bounds?.width ?? 0);
|
|
139
|
+
const height = attrs.height !== undefined ? asNumber(attrs.height) : (bounds?.height ?? 0);
|
|
140
|
+
|
|
141
|
+
const name = attrs.name || attrs['content-desc'] || attrs.resourceId || '';
|
|
142
|
+
const label = attrs.label || attrs.text || attrs['content-desc'] || '';
|
|
143
|
+
const value = attrs.value || '';
|
|
144
|
+
const text = attrs.text || value || '';
|
|
145
|
+
const resourceId = attrs['resource-id'] || attrs.resourceId || attrs.id || '';
|
|
146
|
+
const contentDesc = attrs['content-desc'] || attrs.contentDesc || '';
|
|
147
|
+
|
|
148
|
+
elements.push({
|
|
149
|
+
elementRef: makeElementRef(platform, xpath),
|
|
150
|
+
index: nextIndex++,
|
|
151
|
+
platform,
|
|
152
|
+
type,
|
|
153
|
+
xpath,
|
|
154
|
+
name,
|
|
155
|
+
label,
|
|
156
|
+
value,
|
|
157
|
+
text,
|
|
158
|
+
resourceId,
|
|
159
|
+
contentDesc,
|
|
160
|
+
enabled: asBoolean(attrs.enabled),
|
|
161
|
+
visible: asBoolean(attrs.visible) || asBoolean(attrs.displayed),
|
|
162
|
+
accessible: asBoolean(attrs.accessible),
|
|
163
|
+
clickable: asBoolean(attrs.clickable),
|
|
164
|
+
x,
|
|
165
|
+
y,
|
|
166
|
+
width,
|
|
167
|
+
height,
|
|
168
|
+
attributes: attrs,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const childCounters: Record<string, number> = {};
|
|
172
|
+
const children = extractChildren(node);
|
|
173
|
+
for (const { tag: childTag, child } of children) {
|
|
174
|
+
const childType = asString((child as XmlNode).type || (child as XmlNode).class || childTag);
|
|
175
|
+
childCounters[childType] = (childCounters[childType] || 0) + 1;
|
|
176
|
+
visit(childTag, child, xpath, childCounters[childType]);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
visit(rootTag, rootNode as XmlNode, '', 1);
|
|
181
|
+
|
|
182
|
+
const platform =
|
|
183
|
+
elements.find(element => element.platform === 'ios')?.platform ||
|
|
184
|
+
elements.find(element => element.platform === 'android')?.platform ||
|
|
185
|
+
'unknown';
|
|
186
|
+
|
|
187
|
+
const withResolvedPlatform = elements.map(element => ({
|
|
188
|
+
...element,
|
|
189
|
+
platform: element.platform === 'unknown' ? platform : element.platform,
|
|
190
|
+
elementRef: makeElementRef(element.platform === 'unknown' ? platform : element.platform, element.xpath),
|
|
191
|
+
}));
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
platform,
|
|
195
|
+
elements: withResolvedPlatform,
|
|
196
|
+
};
|
|
197
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export class AppiumClient {
|
|
2
|
+
constructor(private appiumUrl: string) { }
|
|
3
|
+
|
|
4
|
+
async fetchFromAppium(sessionId: string, endpoint: string): Promise<any> {
|
|
5
|
+
try {
|
|
6
|
+
const response = await fetch(`${this.appiumUrl}/session/${sessionId}/${endpoint}`);
|
|
7
|
+
const data = await response.json();
|
|
8
|
+
if (data && typeof data === 'object' && 'value' in data) {
|
|
9
|
+
return data.value;
|
|
10
|
+
}
|
|
11
|
+
return null;
|
|
12
|
+
} catch (e) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async captureState(sessionId: string): Promise<{ screenshot?: string; source?: string }> {
|
|
18
|
+
const [screenshot, source] = await Promise.all([
|
|
19
|
+
this.fetchFromAppium(sessionId, 'screenshot'),
|
|
20
|
+
this.fetchFromAppium(sessionId, 'source'),
|
|
21
|
+
]);
|
|
22
|
+
return { screenshot, source };
|
|
23
|
+
}
|
|
24
|
+
}
|