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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +362 -0
  3. package/bun.lock +731 -0
  4. package/package.json +62 -0
  5. package/skills/appium-cli-selector-navigator/SKILL.md +349 -0
  6. package/src/cli/arg-parser.ts +311 -0
  7. package/src/cli/commands/drive.ts +147 -0
  8. package/src/cli/commands/index.ts +54 -0
  9. package/src/cli/commands/proxy.ts +41 -0
  10. package/src/cli/commands/screen.ts +73 -0
  11. package/src/cli/commands/selectors.ts +42 -0
  12. package/src/cli/commands/session.ts +64 -0
  13. package/src/cli/commands/types.ts +11 -0
  14. package/src/cli/index.ts +158 -0
  15. package/src/cli/prompts.ts +64 -0
  16. package/src/cli/response.ts +44 -0
  17. package/src/core/appium/client.ts +248 -0
  18. package/src/core/index.ts +5 -0
  19. package/src/core/selectors/generate-candidates.ts +155 -0
  20. package/src/core/selectors/score-candidates.ts +184 -0
  21. package/src/core/types.ts +79 -0
  22. package/src/core/xml/parse-source.ts +197 -0
  23. package/src/index.ts +7 -0
  24. package/src/server/appium-client.ts +24 -0
  25. package/src/server/index.ts +6 -0
  26. package/src/server/interaction-recorder.ts +74 -0
  27. package/src/server/proxy-middleware.ts +68 -0
  28. package/src/server/routes.ts +53 -0
  29. package/src/server/server.ts +43 -0
  30. package/src/server/types.ts +34 -0
  31. package/src/ui/bun.lock +311 -0
  32. package/src/ui/index.html +16 -0
  33. package/src/ui/package.json +20 -0
  34. package/src/ui/src/App.css +12 -0
  35. package/src/ui/src/App.tsx +41 -0
  36. package/src/ui/src/components/ActionCarousel.css +128 -0
  37. package/src/ui/src/components/ActionCarousel.tsx +92 -0
  38. package/src/ui/src/components/Inspector.css +314 -0
  39. package/src/ui/src/components/Inspector.tsx +265 -0
  40. package/src/ui/src/components/InteractionCard.css +159 -0
  41. package/src/ui/src/components/InteractionCard.tsx +60 -0
  42. package/src/ui/src/components/MainInspector.css +304 -0
  43. package/src/ui/src/components/MainInspector.tsx +304 -0
  44. package/src/ui/src/components/Stats.css +27 -0
  45. package/src/ui/src/components/Timeline.css +31 -0
  46. package/src/ui/src/components/Timeline.tsx +37 -0
  47. package/src/ui/src/hooks/useInteractions.ts +73 -0
  48. package/src/ui/src/index.tsx +11 -0
  49. package/src/ui/src/services/api.ts +41 -0
  50. package/src/ui/src/styles/tokens.css +126 -0
  51. package/src/ui/src/types.ts +34 -0
  52. package/src/ui/src/utils/__tests__/locators.test.ts +304 -0
  53. package/src/ui/src/utils/__tests__/xml-parser.test.ts +326 -0
  54. package/src/ui/src/utils/locators.ts +14 -0
  55. package/src/ui/src/utils/xml-parser.ts +45 -0
  56. package/src/ui/tsconfig.json +34 -0
  57. package/src/ui/tsconfig.node.json +11 -0
  58. package/src/ui/vite.config.ts +22 -0
  59. package/tests/cli/arg-parser.test.ts +397 -0
  60. package/tests/cli/drive-commands.test.ts +151 -0
  61. package/tests/cli/selectors-best.test.ts +42 -0
  62. package/tests/cli/session-commands.test.ts +53 -0
  63. package/tests/core/selector-candidates.test.ts +83 -0
  64. package/tests/core/selector-scoring.test.ts +75 -0
  65. package/tests/core/xml-parser.test.ts +56 -0
  66. package/tests/server/appium-client.test.ts +229 -0
  67. package/tests/server/interaction-recorder.test.ts +377 -0
  68. package/tests/server/proxy-middleware.test.ts +343 -0
  69. package/tests/server/routes.test.ts +305 -0
  70. package/tsconfig.json +26 -0
  71. package/vitest.config.ts +16 -0
  72. package/vitest.ui.config.ts +15 -0
  73. package/workflow.gif +0 -0
@@ -0,0 +1,265 @@
1
+ import { type Component, createSignal, Show, For } from 'solid-js';
2
+ import { Dialog } from '@kobalte/core/dialog';
3
+ import type { Interaction } from '../types';
4
+ import { parseXmlSource } from '../utils/xml-parser';
5
+ import { generateLocators } from '../utils/locators';
6
+ import type { ParsedElement, Locator } from '../types';
7
+ import './Inspector.css';
8
+
9
+ type InspectorProps = {
10
+ interaction: Interaction | null;
11
+ open: boolean;
12
+ onClose: () => void;
13
+ };
14
+
15
+ export const Inspector: Component<InspectorProps> = (props) => {
16
+ const [selectedElement, setSelectedElement] = createSignal<ParsedElement | null>(null);
17
+ const [queryStrategy, setQueryStrategy] = createSignal('accessibility id');
18
+ const [queryValue, setQueryValue] = createSignal('');
19
+ const [foundElements, setFoundElements] = createSignal<ParsedElement[]>([]);
20
+ const [showSource, setShowSource] = createSignal(false);
21
+
22
+ const parsedElements = () => {
23
+ if (!props.interaction?.source) return [];
24
+ return parseXmlSource(props.interaction.source);
25
+ };
26
+
27
+ const runQuery = () => {
28
+ const strategy = queryStrategy();
29
+ const value = queryValue().trim();
30
+
31
+ if (!value) return;
32
+
33
+ const elements = parsedElements();
34
+ let found: ParsedElement[] = [];
35
+
36
+ switch (strategy) {
37
+ case 'accessibility id':
38
+ found = elements.filter(el => el.name === value || el.label === value);
39
+ break;
40
+ case 'class name':
41
+ found = elements.filter(el => el.type === value);
42
+ break;
43
+ case 'xpath':
44
+ // Properly evaluate XPath against the XML source
45
+ if (props.interaction?.source) {
46
+ try {
47
+ const parser = new DOMParser();
48
+ const doc = parser.parseFromString(props.interaction.source, 'text/xml');
49
+ const result = doc.evaluate(value, doc, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
50
+ const matchedNodes: Element[] = [];
51
+ for (let i = 0; i < result.snapshotLength; i++) {
52
+ const node = result.snapshotItem(i);
53
+ if (node && node.nodeType === 1) {
54
+ matchedNodes.push(node as Element);
55
+ }
56
+ }
57
+ // Match found nodes back to parsed elements
58
+ found = elements.filter(el => matchedNodes.some(node => el.node.isEqualNode(node)));
59
+ } catch (e) {
60
+ console.error('Invalid XPath expression:', e);
61
+ }
62
+ }
63
+ break;
64
+ case '-ios predicate string':
65
+ // iOS predicate string matching (simplified attribute matching)
66
+ found = elements.filter(el => {
67
+ // Parse simple predicates like "name == 'value'" or "label CONTAINS 'text'"
68
+ const predicateLower = value.toLowerCase();
69
+ if (predicateLower.includes('name')) {
70
+ const match = value.match(/name\s*(==|CONTAINS)\s*['"](.*)['"]/i);
71
+ if (match) {
72
+ return match[1] === '=='
73
+ ? el.name === match[2]
74
+ : el.name.includes(match[2]);
75
+ }
76
+ }
77
+ if (predicateLower.includes('label')) {
78
+ const match = value.match(/label\s*(==|CONTAINS)\s*['"](.*)['"]/i);
79
+ if (match) {
80
+ return match[1] === '=='
81
+ ? el.label === match[2]
82
+ : el.label.includes(match[2]);
83
+ }
84
+ }
85
+ if (predicateLower.includes('type')) {
86
+ const match = value.match(/type\s*(==|CONTAINS)\s*['"](.*)['"]/i);
87
+ if (match) {
88
+ return match[1] === '=='
89
+ ? el.type === match[2]
90
+ : el.type.includes(match[2]);
91
+ }
92
+ }
93
+ return false;
94
+ });
95
+ break;
96
+ case '-ios class chain':
97
+ // iOS class chain matching (simplified type/index matching)
98
+ // Format: **/XCUIElementTypeButton[`name == "buttonName"`]
99
+ const classChainMatch = value.match(/\*\*\/(\w+)(?:\[`(.+?)`\])?/);
100
+ if (classChainMatch) {
101
+ const targetType = classChainMatch[1];
102
+ const predicate = classChainMatch[2];
103
+ found = elements.filter(el => {
104
+ if (el.type !== targetType) return false;
105
+ if (!predicate) return true;
106
+ // Simple predicate matching within class chain
107
+ const nameMatch = predicate.match(/name\s*==\s*['"](.*)['"]/i);
108
+ if (nameMatch) return el.name === nameMatch[1];
109
+ const labelMatch = predicate.match(/label\s*==\s*['"](.*)['"]/i);
110
+ if (labelMatch) return el.label === labelMatch[1];
111
+ return true;
112
+ });
113
+ }
114
+ break;
115
+ }
116
+
117
+ setFoundElements(found);
118
+ if (found.length > 0) {
119
+ setSelectedElement(found[0]);
120
+ }
121
+ };
122
+
123
+ const copyText = (text: string) => {
124
+ navigator.clipboard.writeText(text);
125
+ };
126
+
127
+ const locators = (): Locator[] => {
128
+ const el = selectedElement();
129
+ return el ? generateLocators(el) : [];
130
+ };
131
+
132
+ const resetState = () => {
133
+ setSelectedElement(null);
134
+ setQueryStrategy('accessibility id');
135
+ setQueryValue('');
136
+ setFoundElements([]);
137
+ setShowSource(false);
138
+ };
139
+
140
+ const handleClose = () => {
141
+ resetState();
142
+ props.onClose();
143
+ };
144
+
145
+ return (
146
+ <Dialog open={props.open} onOpenChange={(open) => !open && handleClose()}>
147
+ <Dialog.Portal>
148
+ <Dialog.Overlay class="inspector-overlay" />
149
+ <Dialog.Content class="inspector-modal">
150
+ <Dialog.CloseButton class="inspector-close">✕</Dialog.CloseButton>
151
+
152
+ <div class="inspector-panel">
153
+ <div class="inspector-left">
154
+ <Show when={props.interaction?.screenshot}>
155
+ <img
156
+ src={`data:image/png;base64,${props.interaction!.screenshot}`}
157
+ alt="Screenshot"
158
+ class="inspector-screenshot"
159
+ />
160
+ </Show>
161
+ </div>
162
+
163
+ <div class="inspector-right">
164
+ <div class="inspector-section">
165
+ <h3>Query Tester</h3>
166
+ <div class="query-tester">
167
+ <div class="query-row">
168
+ <select
169
+ value={queryStrategy()}
170
+ onChange={(e) => setQueryStrategy(e.currentTarget.value)}
171
+ class="query-select"
172
+ >
173
+ <option value="accessibility id">accessibility id</option>
174
+ <option value="xpath">xpath</option>
175
+ <option value="class name">class name</option>
176
+ <option value="-ios predicate string">-ios predicate string</option>
177
+ <option value="-ios class chain">-ios class chain</option>
178
+ </select>
179
+ <input
180
+ type="text"
181
+ value={queryValue()}
182
+ onInput={(e) => setQueryValue(e.currentTarget.value)}
183
+ onKeyPress={(e) => e.key === 'Enter' && runQuery()}
184
+ placeholder="Enter locator value..."
185
+ class="query-input"
186
+ />
187
+ <button onClick={runQuery} class="query-btn">
188
+ Find
189
+ </button>
190
+ </div>
191
+
192
+ <Show when={foundElements().length > 0}>
193
+ <div class="query-result success">
194
+ Found {foundElements().length} element(s)
195
+ </div>
196
+ </Show>
197
+ </div>
198
+ </div>
199
+ <Show when={selectedElement()}>
200
+ <div class="inspector-section">
201
+ <h3>Element Details</h3>
202
+ <div class="element-details">
203
+ <div class="element-attr">
204
+ <span class="attr-name">Type:</span>
205
+ <span class="attr-value">{selectedElement()!.type}</span>
206
+ </div>
207
+ <Show when={selectedElement()!.name}>
208
+ <div class="element-attr">
209
+ <span class="attr-name">Name:</span>
210
+ <span class="attr-value">{selectedElement()!.name}</span>
211
+ </div>
212
+ </Show>
213
+ <Show when={selectedElement()!.label}>
214
+ <div class="element-attr">
215
+ <span class="attr-name">Label:</span>
216
+ <span class="attr-value">{selectedElement()!.label}</span>
217
+ </div>
218
+ </Show>
219
+ <div class="element-attr">
220
+ <span class="attr-name">Bounds:</span>
221
+ <span class="attr-value">
222
+ x={selectedElement()!.x}, y={selectedElement()!.y},
223
+ w={selectedElement()!.width}, h={selectedElement()!.height}
224
+ </span>
225
+ </div>
226
+ </div>
227
+ </div>
228
+
229
+ <div class="inspector-section">
230
+ <h3>Locators (click to copy)</h3>
231
+ <div class="locators-list">
232
+ <For each={locators()}>
233
+ {(locator) => (
234
+ <div class="locator-row" onClick={() => copyText(locator.value)}>
235
+ <span class="locator-strategy">{locator.strategy}</span>
236
+ <span class="locator-value">{locator.value}</span>
237
+ </div>
238
+ )}
239
+ </For>
240
+ </div>
241
+ </div>
242
+ </Show>
243
+
244
+ {/* XML Source Toggle */}
245
+ <Show when={props.interaction?.source}>
246
+ <div class="inspector-section">
247
+ <button
248
+ class="source-toggle-btn"
249
+ onClick={() => setShowSource(!showSource())}
250
+ >
251
+ {showSource() ? 'Hide' : 'Show'} XML Source
252
+ </button>
253
+ <Show when={showSource()}>
254
+ <pre class="xml-source">{props.interaction!.source}</pre>
255
+ </Show>
256
+ </div>
257
+ </Show>
258
+ </div>
259
+ </div>
260
+ </Dialog.Content>
261
+ </Dialog.Portal>
262
+ </Dialog>
263
+ );
264
+ };
265
+
@@ -0,0 +1,159 @@
1
+ .interaction-card {
2
+ background: var(--color-bg-secondary);
3
+ border-radius: var(--radius-xl);
4
+ padding: var(--spacing-5);
5
+ border-left: 3px solid var(--color-accent-primary);
6
+ transition: all var(--transition-base);
7
+ box-shadow: var(--shadow-xs);
8
+ border-top: 1px solid var(--color-border);
9
+ border-right: 1px solid var(--color-border);
10
+ border-bottom: 1px solid var(--color-border);
11
+ }
12
+
13
+ .interaction-card.action {
14
+ border-left-color: var(--color-accent-warning);
15
+ }
16
+
17
+ .interaction-card:hover {
18
+ box-shadow: var(--shadow-md);
19
+ transform: translateY(-1px);
20
+ }
21
+
22
+ .interaction-header {
23
+ display: flex;
24
+ justify-content: space-between;
25
+ align-items: center;
26
+ margin-bottom: var(--spacing-3);
27
+ flex-wrap: wrap;
28
+ gap: var(--spacing-2);
29
+ }
30
+
31
+ .interaction-header-left {
32
+ display: flex;
33
+ align-items: center;
34
+ gap: var(--spacing-2);
35
+ flex-wrap: wrap;
36
+ }
37
+
38
+ .interaction-id {
39
+ background: var(--color-bg-tertiary);
40
+ color: var(--color-text-secondary);
41
+ padding: 2px var(--spacing-2);
42
+ border-radius: var(--radius-full);
43
+ font-size: var(--font-size-xs);
44
+ font-weight: var(--font-weight-semibold);
45
+ }
46
+
47
+ .interaction-card.action .interaction-id {
48
+ background: #FFF3E0;
49
+ color: var(--color-accent-warning);
50
+ }
51
+
52
+ .interaction-method {
53
+ font-weight: var(--font-weight-semibold);
54
+ padding: 2px var(--spacing-2);
55
+ border-radius: var(--radius-sm);
56
+ font-size: var(--font-size-xs);
57
+ text-transform: uppercase;
58
+ letter-spacing: 0.03em;
59
+ }
60
+
61
+ .interaction-method.POST {
62
+ background: #E8F5EE;
63
+ color: var(--color-accent-success);
64
+ }
65
+
66
+ .interaction-method.GET {
67
+ background: #E8F0FE;
68
+ color: #3B7DD8;
69
+ }
70
+
71
+ .interaction-method.DELETE {
72
+ background: #FDECEB;
73
+ color: var(--color-accent-error);
74
+ }
75
+
76
+ .interaction-path {
77
+ color: var(--color-text-secondary);
78
+ font-size: var(--font-size-sm);
79
+ word-break: break-all;
80
+ overflow-wrap: break-word;
81
+ max-width: 100%;
82
+ }
83
+
84
+ .interaction-time {
85
+ color: var(--color-text-tertiary);
86
+ font-size: var(--font-size-xs);
87
+ }
88
+
89
+ .element-info {
90
+ background: var(--color-bg-tertiary);
91
+ padding: var(--spacing-2) var(--spacing-3);
92
+ border-radius: var(--radius-md);
93
+ margin-top: var(--spacing-3);
94
+ font-size: var(--font-size-sm);
95
+ word-wrap: break-word;
96
+ overflow-wrap: break-word;
97
+ }
98
+
99
+ .element-info-using {
100
+ color: var(--color-accent-primary);
101
+ font-weight: var(--font-weight-medium);
102
+ }
103
+
104
+ .element-info-value {
105
+ color: var(--color-text-primary);
106
+ word-break: break-all;
107
+ }
108
+
109
+ .interaction-body {
110
+ background: var(--color-bg-primary);
111
+ padding: var(--spacing-3);
112
+ border-radius: var(--radius-md);
113
+ margin-top: var(--spacing-3);
114
+ font-size: var(--font-size-xs);
115
+ font-family: var(--font-mono);
116
+ overflow: auto;
117
+ max-height: 200px;
118
+ border: 1px solid var(--color-border);
119
+ white-space: pre-wrap;
120
+ word-break: break-all;
121
+ color: var(--color-text-secondary);
122
+ }
123
+
124
+ .screenshot-container {
125
+ margin-top: var(--spacing-4);
126
+ display: flex;
127
+ flex-direction: column;
128
+ gap: var(--spacing-3);
129
+ align-items: flex-start;
130
+ }
131
+
132
+ .screenshot {
133
+ max-width: 300px;
134
+ border-radius: var(--radius-xl);
135
+ cursor: pointer;
136
+ border: 1px solid var(--color-border);
137
+ transition: all var(--transition-base);
138
+ }
139
+
140
+ .screenshot:hover {
141
+ border-color: var(--color-accent-primary);
142
+ box-shadow: var(--shadow-md);
143
+ }
144
+
145
+ .inspect-btn {
146
+ background: var(--color-accent-primary);
147
+ color: white;
148
+ border: none;
149
+ padding: var(--spacing-2) var(--spacing-5);
150
+ border-radius: var(--radius-lg);
151
+ cursor: pointer;
152
+ font-weight: var(--font-weight-medium);
153
+ font-size: var(--font-size-sm);
154
+ transition: all var(--transition-fast);
155
+ }
156
+
157
+ .inspect-btn:hover {
158
+ background: var(--color-accent-secondary);
159
+ }
@@ -0,0 +1,60 @@
1
+ import { type Component, Show } from 'solid-js';
2
+ import type { Interaction } from '../types';
3
+ import './InteractionCard.css';
4
+
5
+ type InteractionCardProps = {
6
+ interaction: Interaction;
7
+ onInspect?: () => void;
8
+ };
9
+
10
+ export const InteractionCard: Component<InteractionCardProps> = (props) => {
11
+ const formattedTime = () => new Date(props.interaction.timestamp).toLocaleTimeString();
12
+ const isAction = () => !!props.interaction.screenshot;
13
+
14
+ return (
15
+ <div classList={{ 'interaction-card': true, 'action': isAction() }}>
16
+ <div class="interaction-header">
17
+ <div class="interaction-header-left">
18
+ <span class="interaction-id">#{props.interaction.id}</span>
19
+ <span classList={{
20
+ 'interaction-method': true,
21
+ [props.interaction.method]: true,
22
+ }}>
23
+ {props.interaction.method}
24
+ </span>
25
+ <span class="interaction-path">{props.interaction.path}</span>
26
+ </div>
27
+ <span class="interaction-time">{formattedTime()}</span>
28
+ </div>
29
+
30
+ <Show when={props.interaction.elementInfo}>
31
+ <div class="element-info">
32
+ <span class="element-info-using">{props.interaction.elementInfo!.using}:</span>
33
+ {' "'}
34
+ <span class="element-info-value">{props.interaction.elementInfo!.value}</span>
35
+ {'"'}
36
+ </div>
37
+ </Show>
38
+
39
+ <Show when={props.interaction.body}>
40
+ <pre class="interaction-body">
41
+ {JSON.stringify(props.interaction.body, null, 2)}
42
+ </pre>
43
+ </Show>
44
+
45
+ <Show when={props.interaction.screenshot}>
46
+ <div class="screenshot-container">
47
+ <img
48
+ src={`data:image/png;base64,${props.interaction.screenshot}`}
49
+ alt="Screenshot"
50
+ class="screenshot"
51
+ onClick={props.onInspect}
52
+ />
53
+ <button class="inspect-btn" onClick={props.onInspect}>
54
+ Inspect Elements
55
+ </button>
56
+ </div>
57
+ </Show>
58
+ </div>
59
+ );
60
+ };