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,304 @@
1
+ .main-inspector {
2
+ display: flex;
3
+ flex-direction: column;
4
+ height: 100%;
5
+ overflow: hidden;
6
+ }
7
+
8
+ .inspector-empty {
9
+ display: flex;
10
+ align-items: center;
11
+ justify-content: center;
12
+ height: 100%;
13
+ background: var(--color-bg-primary);
14
+ }
15
+
16
+ .inspector-empty-content {
17
+ display: flex;
18
+ flex-direction: column;
19
+ align-items: center;
20
+ gap: var(--spacing-4);
21
+ color: var(--color-text-tertiary);
22
+ }
23
+
24
+ .inspector-empty-icon {
25
+ font-size: 3rem;
26
+ opacity: 0.4;
27
+ }
28
+
29
+ .inspector-empty-text {
30
+ font-size: var(--font-size-base);
31
+ }
32
+
33
+ /* Query Section */
34
+ .query-section {
35
+ background: var(--color-bg-secondary);
36
+ padding: var(--spacing-4) var(--spacing-5);
37
+ border-bottom: 1px solid var(--color-border);
38
+ flex-shrink: 0;
39
+ }
40
+
41
+ .section-title {
42
+ color: var(--color-text-primary);
43
+ font-size: var(--font-size-sm);
44
+ margin: 0 0 var(--spacing-3) 0;
45
+ font-weight: var(--font-weight-semibold);
46
+ letter-spacing: 0.01em;
47
+ }
48
+
49
+ .query-row {
50
+ display: flex;
51
+ gap: var(--spacing-2);
52
+ margin-bottom: var(--spacing-3);
53
+ }
54
+
55
+ .query-select,
56
+ .query-input {
57
+ background: var(--color-bg-primary);
58
+ color: var(--color-text-primary);
59
+ border: 1px solid var(--color-border);
60
+ padding: var(--spacing-2) var(--spacing-3);
61
+ border-radius: var(--radius-lg);
62
+ font-size: var(--font-size-sm);
63
+ font-family: var(--font-family);
64
+ transition: border-color var(--transition-fast);
65
+ }
66
+
67
+ .query-select:focus,
68
+ .query-input:focus {
69
+ outline: none;
70
+ border-color: var(--color-accent-primary);
71
+ }
72
+
73
+ .query-select {
74
+ min-width: 170px;
75
+ }
76
+
77
+ .query-input {
78
+ flex: 1;
79
+ }
80
+
81
+ .query-btn {
82
+ background: var(--color-accent-primary);
83
+ color: white;
84
+ border: none;
85
+ padding: var(--spacing-2) var(--spacing-5);
86
+ border-radius: var(--radius-lg);
87
+ cursor: pointer;
88
+ font-weight: var(--font-weight-medium);
89
+ font-size: var(--font-size-sm);
90
+ transition: all var(--transition-fast);
91
+ }
92
+
93
+ .query-btn:hover {
94
+ background: var(--color-accent-secondary);
95
+ }
96
+
97
+ .query-result {
98
+ padding: var(--spacing-2) var(--spacing-3);
99
+ border-radius: var(--radius-md);
100
+ font-size: var(--font-size-sm);
101
+ margin-bottom: var(--spacing-3);
102
+ }
103
+
104
+ .query-result.success {
105
+ background: #E8F5EE;
106
+ color: var(--color-accent-success);
107
+ }
108
+
109
+ .query-result.error {
110
+ background: #FDECEB;
111
+ color: var(--color-accent-error);
112
+ display: flex;
113
+ align-items: center;
114
+ gap: var(--spacing-3);
115
+ }
116
+
117
+ .error-icon {
118
+ font-size: var(--font-size-sm);
119
+ }
120
+
121
+ .error-dismiss {
122
+ margin-left: auto;
123
+ background: transparent;
124
+ border: none;
125
+ color: var(--color-accent-error);
126
+ cursor: pointer;
127
+ font-size: var(--font-size-sm);
128
+ padding: 0;
129
+ opacity: 0.6;
130
+ transition: opacity var(--transition-fast);
131
+ }
132
+
133
+ .error-dismiss:hover {
134
+ opacity: 1;
135
+ }
136
+
137
+ /* Element Panel */
138
+ .element-panel {
139
+ display: flex;
140
+ gap: var(--spacing-6);
141
+ padding-top: var(--spacing-3);
142
+ border-top: 1px solid var(--color-border);
143
+ margin-top: var(--spacing-3);
144
+ }
145
+
146
+ .element-details {
147
+ display: flex;
148
+ flex-direction: column;
149
+ gap: var(--spacing-2);
150
+ min-width: 240px;
151
+ }
152
+
153
+ .element-attr {
154
+ display: flex;
155
+ gap: var(--spacing-3);
156
+ font-size: var(--font-size-sm);
157
+ }
158
+
159
+ .attr-name {
160
+ color: var(--color-text-tertiary);
161
+ min-width: 55px;
162
+ font-weight: var(--font-weight-medium);
163
+ }
164
+
165
+ .attr-value {
166
+ color: var(--color-text-primary);
167
+ }
168
+
169
+ .locators-section {
170
+ flex: 1;
171
+ }
172
+
173
+ .locators-section h4 {
174
+ color: var(--color-text-secondary);
175
+ font-size: var(--font-size-sm);
176
+ margin: 0 0 var(--spacing-2) 0;
177
+ font-weight: var(--font-weight-medium);
178
+ }
179
+
180
+ .locators-list {
181
+ display: flex;
182
+ flex-direction: column;
183
+ gap: var(--spacing-1);
184
+ }
185
+
186
+ .locator-row {
187
+ display: flex;
188
+ align-items: center;
189
+ gap: var(--spacing-3);
190
+ padding: var(--spacing-2) var(--spacing-3);
191
+ background: var(--color-bg-primary);
192
+ border-radius: var(--radius-md);
193
+ cursor: pointer;
194
+ transition: all var(--transition-fast);
195
+ position: relative;
196
+ border: 1px solid transparent;
197
+ }
198
+
199
+ .locator-row:hover {
200
+ background: var(--color-bg-tertiary);
201
+ border-color: var(--color-border);
202
+ }
203
+
204
+ .locator-row.copied {
205
+ background: #E8F5EE;
206
+ border-color: #C6E7D4;
207
+ }
208
+
209
+ .locator-strategy {
210
+ color: var(--color-accent-primary);
211
+ font-size: var(--font-size-xs);
212
+ min-width: 130px;
213
+ font-weight: var(--font-weight-medium);
214
+ font-family: var(--font-mono);
215
+ }
216
+
217
+ .locator-value {
218
+ flex: 1;
219
+ font-size: var(--font-size-xs);
220
+ color: var(--color-text-secondary);
221
+ word-break: break-all;
222
+ font-family: var(--font-mono);
223
+ }
224
+
225
+ .copied-badge {
226
+ font-size: var(--font-size-xs);
227
+ color: var(--color-accent-success);
228
+ font-weight: var(--font-weight-medium);
229
+ }
230
+
231
+ /* Content Area: Screenshot Left, XML Right */
232
+ .content-area {
233
+ flex: 1;
234
+ display: flex;
235
+ gap: var(--spacing-4);
236
+ padding: var(--spacing-4);
237
+ overflow: hidden;
238
+ background: var(--color-bg-primary);
239
+ }
240
+
241
+ /* Screenshot Section */
242
+ .screenshot-section {
243
+ flex-shrink: 0;
244
+ display: flex;
245
+ align-items: flex-start;
246
+ justify-content: center;
247
+ overflow: auto;
248
+ }
249
+
250
+ .screenshot-image {
251
+ max-height: 100%;
252
+ max-width: 350px;
253
+ border-radius: var(--radius-xl);
254
+ box-shadow: var(--shadow-md);
255
+ object-fit: contain;
256
+ border: 1px solid var(--color-border);
257
+ }
258
+
259
+ /* XML Section */
260
+ .xml-section {
261
+ flex: 1;
262
+ display: flex;
263
+ flex-direction: column;
264
+ overflow: hidden;
265
+ }
266
+
267
+ .xml-section .section-title {
268
+ flex-shrink: 0;
269
+ margin-bottom: var(--spacing-3);
270
+ }
271
+
272
+ .xml-source {
273
+ flex: 1;
274
+ background: var(--color-bg-secondary);
275
+ padding: var(--spacing-4);
276
+ border-radius: var(--radius-lg);
277
+ font-size: var(--font-size-xs);
278
+ font-family: var(--font-mono);
279
+ overflow: auto;
280
+ white-space: pre;
281
+ color: var(--color-text-secondary);
282
+ border: 1px solid var(--color-border);
283
+ margin: 0;
284
+ line-height: 1.7;
285
+ }
286
+
287
+ .xml-source::-webkit-scrollbar {
288
+ width: 6px;
289
+ height: 6px;
290
+ }
291
+
292
+ .xml-source::-webkit-scrollbar-track {
293
+ background: transparent;
294
+ border-radius: var(--radius-sm);
295
+ }
296
+
297
+ .xml-source::-webkit-scrollbar-thumb {
298
+ background: var(--color-border);
299
+ border-radius: var(--radius-sm);
300
+ }
301
+
302
+ .xml-source::-webkit-scrollbar-thumb:hover {
303
+ background: var(--color-text-tertiary);
304
+ }
@@ -0,0 +1,304 @@
1
+ import { type Component, createSignal, Show, For, createEffect, createRenderEffect, onCleanup } from 'solid-js';
2
+ import type { Interaction } from '../types';
3
+ import { parseXmlSource } from '../utils/xml-parser';
4
+ import { generateLocators } from '../utils/locators';
5
+ import type { ParsedElement, Locator } from '../types';
6
+ import './MainInspector.css';
7
+
8
+ type MainInspectorProps = {
9
+ interaction: Interaction | null;
10
+ };
11
+
12
+ export const MainInspector: Component<MainInspectorProps> = (props) => {
13
+ const [selectedElement, setSelectedElement] = createSignal<ParsedElement | null>(null);
14
+ const [queryStrategy, setQueryStrategy] = createSignal('accessibility id');
15
+ const [queryValue, setQueryValue] = createSignal('');
16
+ const [foundElements, setFoundElements] = createSignal<ParsedElement[]>([]);
17
+ const [copiedText, setCopiedText] = createSignal<string | null>(null);
18
+ const [queryError, setQueryError] = createSignal<string | null>(null);
19
+ const [xmlPreRef, setXmlPreRef] = createSignal<HTMLPreElement | undefined>(undefined);
20
+
21
+ // Reset state when interaction changes
22
+ createEffect(() => {
23
+ if (props.interaction) {
24
+ setSelectedElement(null);
25
+ setQueryValue('');
26
+ setFoundElements([]);
27
+ }
28
+ });
29
+
30
+ const parsedElements = () => {
31
+ if (!props.interaction?.source) return [];
32
+ return parseXmlSource(props.interaction.source);
33
+ };
34
+
35
+ const runQuery = () => {
36
+ const strategy = queryStrategy();
37
+ const value = queryValue().trim();
38
+
39
+ setQueryError(null);
40
+
41
+ if (!value) return;
42
+
43
+ const elements = parsedElements();
44
+ let found: ParsedElement[] = [];
45
+
46
+ switch (strategy) {
47
+ case 'accessibility id':
48
+ found = elements.filter(el => el.name === value || el.label === value);
49
+ break;
50
+ case 'class name':
51
+ found = elements.filter(el => el.type === value);
52
+ break;
53
+ case 'xpath':
54
+ if (props.interaction?.source) {
55
+ try {
56
+ const parser = new DOMParser();
57
+ const doc = parser.parseFromString(props.interaction.source, 'text/xml');
58
+ const result = doc.evaluate(value, doc, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
59
+ const matchedNodes: Element[] = [];
60
+ for (let i = 0; i < result.snapshotLength; i++) {
61
+ const node = result.snapshotItem(i);
62
+ if (node && node.nodeType === 1) {
63
+ matchedNodes.push(node as Element);
64
+ }
65
+ }
66
+ found = elements.filter(el => matchedNodes.some(node => el.node.isEqualNode(node)));
67
+ } catch (e) {
68
+ console.error('Invalid XPath expression:', e);
69
+ }
70
+ }
71
+ break;
72
+ case '-ios predicate string':
73
+ found = elements.filter(el => {
74
+ const predicateLower = value.toLowerCase();
75
+ if (predicateLower.includes('name')) {
76
+ const match = value.match(/name\s*(==|CONTAINS)\s*['"](.*)['"]/i);
77
+ if (match) {
78
+ return match[1] === '==' ? el.name === match[2] : el.name.includes(match[2]);
79
+ }
80
+ }
81
+ if (predicateLower.includes('label')) {
82
+ const match = value.match(/label\s*(==|CONTAINS)\s*['"](.*)['"]/i);
83
+ if (match) {
84
+ return match[1] === '==' ? el.label === match[2] : el.label.includes(match[2]);
85
+ }
86
+ }
87
+ if (predicateLower.includes('type')) {
88
+ const match = value.match(/type\s*(==|CONTAINS)\s*['"](.*)['"]/i);
89
+ if (match) {
90
+ return match[1] === '==' ? el.type === match[2] : el.type.includes(match[2]);
91
+ }
92
+ }
93
+ return false;
94
+ });
95
+ break;
96
+ case '-ios class chain':
97
+ const classChainMatch = value.match(/\*\*\/(\w+)(?:\[`(.+?)`\])?/);
98
+ if (classChainMatch) {
99
+ const targetType = classChainMatch[1];
100
+ const predicate = classChainMatch[2];
101
+ found = elements.filter(el => {
102
+ if (el.type !== targetType) return false;
103
+ if (!predicate) return true;
104
+ const nameMatch = predicate.match(/name\s*==\s*['"](.*)['"]/i);
105
+ if (nameMatch) return el.name === nameMatch[1];
106
+ const labelMatch = predicate.match(/label\s*==\s*['"](.*)['"]/i);
107
+ if (labelMatch) return el.label === labelMatch[1];
108
+ return true;
109
+ });
110
+ }
111
+ break;
112
+ }
113
+
114
+ setFoundElements(found);
115
+ if (found.length > 0) {
116
+ setSelectedElement(found[0]);
117
+ setQueryError(null);
118
+ } else {
119
+ setSelectedElement(null);
120
+ setQueryError(`No elements found for ${strategy}: "${value}"`);
121
+ }
122
+ };
123
+
124
+ const copyText = (text: string) => {
125
+ navigator.clipboard.writeText(text);
126
+ setCopiedText(text);
127
+ setTimeout(() => setCopiedText(null), 2000);
128
+ };
129
+
130
+ const locators = (): Locator[] => {
131
+ const el = selectedElement();
132
+ return el ? generateLocators(el) : [];
133
+ };
134
+
135
+ const formatXml = (xml: string) => {
136
+ // Simple XML formatting for better readability
137
+ let formatted = '';
138
+ let indent = 0;
139
+ const lines = xml.replace(/></g, '>\n<').split('\n');
140
+
141
+ for (const line of lines) {
142
+ const trimmed = line.trim();
143
+ if (!trimmed) continue;
144
+
145
+ if (trimmed.startsWith('</')) {
146
+ indent = Math.max(0, indent - 1);
147
+ }
148
+
149
+ formatted += ' '.repeat(indent) + trimmed + '\n';
150
+
151
+ if (trimmed.startsWith('<') && !trimmed.startsWith('</') && !trimmed.endsWith('/>') && !trimmed.includes('</')) {
152
+ indent++;
153
+ }
154
+ }
155
+
156
+ return formatted;
157
+ };
158
+
159
+ // Defense-in-depth: render XML as textContent (never HTML)
160
+ createRenderEffect(() => {
161
+ const el = xmlPreRef();
162
+ if (!el) return;
163
+ el.textContent = formatXml(props.interaction?.source || '');
164
+ });
165
+
166
+ return (
167
+ <div class="main-inspector">
168
+ <Show
169
+ when={props.interaction}
170
+ fallback={
171
+ <div class="inspector-empty">
172
+ <div class="inspector-empty-content">
173
+ <span class="inspector-empty-icon">🔍</span>
174
+ <span class="inspector-empty-text">Select an action to inspect</span>
175
+ </div>
176
+ </div>
177
+ }
178
+ >
179
+ {/* Query Tester Section */}
180
+ <div class="query-section">
181
+ <h3 class="section-title">Query Tester</h3>
182
+ <div class="query-row">
183
+ <select
184
+ value={queryStrategy()}
185
+ onChange={(e) => setQueryStrategy(e.currentTarget.value)}
186
+ class="query-select"
187
+ >
188
+ <option value="accessibility id">accessibility id</option>
189
+ <option value="xpath">xpath</option>
190
+ <option value="class name">class name</option>
191
+ <option value="-ios predicate string">-ios predicate string</option>
192
+ <option value="-ios class chain">-ios class chain</option>
193
+ </select>
194
+ <input
195
+ type="text"
196
+ value={queryValue()}
197
+ onInput={(e) => setQueryValue(e.currentTarget.value)}
198
+ onKeyPress={(e) => e.key === 'Enter' && runQuery()}
199
+ placeholder="Enter locator value..."
200
+ class="query-input"
201
+ />
202
+ <button onClick={runQuery} class="query-btn">
203
+ Find
204
+ </button>
205
+ </div>
206
+
207
+ <Show when={foundElements().length > 0}>
208
+ <div class="query-result success">
209
+ Found {foundElements().length} element(s)
210
+ </div>
211
+ </Show>
212
+
213
+ <Show when={queryError()}>
214
+ <div class="query-result error">
215
+ <span class="error-icon">⚠️</span>
216
+ <span>{queryError()}</span>
217
+ <button class="error-dismiss" onClick={() => setQueryError(null)}>✕</button>
218
+ </div>
219
+ </Show>
220
+
221
+ {/* Element Details */}
222
+ <Show when={selectedElement()}>
223
+ <div class="element-panel">
224
+ <div class="element-details">
225
+ <div class="element-attr">
226
+ <span class="attr-name">Type:</span>
227
+ <span class="attr-value">{selectedElement()!.type}</span>
228
+ </div>
229
+ <Show when={selectedElement()!.name}>
230
+ <div class="element-attr">
231
+ <span class="attr-name">Name:</span>
232
+ <span class="attr-value">{selectedElement()!.name}</span>
233
+ </div>
234
+ </Show>
235
+ <Show when={selectedElement()!.label}>
236
+ <div class="element-attr">
237
+ <span class="attr-name">Label:</span>
238
+ <span class="attr-value">{selectedElement()!.label}</span>
239
+ </div>
240
+ </Show>
241
+ <div class="element-attr">
242
+ <span class="attr-name">Bounds:</span>
243
+ <span class="attr-value">
244
+ x={selectedElement()!.x}, y={selectedElement()!.y},
245
+ w={selectedElement()!.width}, h={selectedElement()!.height}
246
+ </span>
247
+ </div>
248
+ </div>
249
+
250
+ <div class="locators-section">
251
+ <h4>Locators (click to copy)</h4>
252
+ <div class="locators-list">
253
+ <For each={locators()}>
254
+ {(locator) => (
255
+ <div
256
+ class="locator-row"
257
+ classList={{ copied: copiedText() === locator.value }}
258
+ onClick={() => copyText(locator.value)}
259
+ >
260
+ <span class="locator-strategy">{locator.strategy}</span>
261
+ <span class="locator-value">{locator.value}</span>
262
+ <Show when={copiedText() === locator.value}>
263
+ <span class="copied-badge">Copied!</span>
264
+ </Show>
265
+ </div>
266
+ )}
267
+ </For>
268
+ </div>
269
+ </div>
270
+ </div>
271
+ </Show>
272
+ </div>
273
+
274
+ {/* Content Area: Screenshot Left, XML Right */}
275
+ <div class="content-area">
276
+ {/* Screenshot Section */}
277
+ <div class="screenshot-section">
278
+ <Show when={props.interaction!.screenshot}>
279
+ <img
280
+ src={`data:image/png;base64,${props.interaction!.screenshot}`}
281
+ alt="Screenshot"
282
+ class="screenshot-image"
283
+ />
284
+ </Show>
285
+ </div>
286
+
287
+ {/* XML Source Section */}
288
+ <div class="xml-section">
289
+ <h3 class="section-title">XML Source</h3>
290
+ <pre
291
+ ref={(el) => {
292
+ setXmlPreRef(el);
293
+ onCleanup(() => {
294
+ setXmlPreRef(undefined);
295
+ });
296
+ }}
297
+ class="xml-source"
298
+ />
299
+ </div>
300
+ </div>
301
+ </Show>
302
+ </div>
303
+ );
304
+ };
@@ -0,0 +1,27 @@
1
+ .stats {
2
+ display: flex;
3
+ gap: var(--spacing-6);
4
+ padding: var(--spacing-5);
5
+ background: var(--color-bg-secondary);
6
+ border-radius: var(--radius-xl);
7
+ margin-bottom: var(--spacing-6);
8
+ box-shadow: var(--shadow-sm);
9
+ border: 1px solid var(--color-border);
10
+ }
11
+
12
+ .stat {
13
+ text-align: center;
14
+ flex: 1;
15
+ }
16
+
17
+ .stat-value {
18
+ font-size: var(--font-size-3xl);
19
+ font-weight: var(--font-weight-bold);
20
+ color: var(--color-accent-primary);
21
+ }
22
+
23
+ .stat-label {
24
+ font-size: var(--font-size-sm);
25
+ color: var(--color-text-tertiary);
26
+ margin-top: var(--spacing-2);
27
+ }
@@ -0,0 +1,31 @@
1
+ .timeline {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: var(--spacing-4);
5
+ }
6
+
7
+ .empty-state {
8
+ text-align: center;
9
+ padding: var(--spacing-12) var(--spacing-6);
10
+ background: var(--color-bg-secondary);
11
+ border-radius: var(--radius-2xl);
12
+ border: 1px dashed var(--color-border);
13
+ }
14
+
15
+ .empty-icon {
16
+ font-size: 3rem;
17
+ margin-bottom: var(--spacing-4);
18
+ opacity: 0.4;
19
+ }
20
+
21
+ .empty-title {
22
+ font-size: var(--font-size-lg);
23
+ font-weight: var(--font-weight-semibold);
24
+ color: var(--color-text-primary);
25
+ margin-bottom: var(--spacing-2);
26
+ }
27
+
28
+ .empty-text {
29
+ color: var(--color-text-tertiary);
30
+ font-size: var(--font-size-sm);
31
+ }
@@ -0,0 +1,37 @@
1
+ import { type Component, For, Show } from 'solid-js';
2
+ import type { Interaction } from '../types';
3
+ import { InteractionCard } from './InteractionCard';
4
+ import './Timeline.css';
5
+
6
+ type TimelineProps = {
7
+ interactions: Interaction[];
8
+ onInspect?: (interaction: Interaction) => void;
9
+ };
10
+
11
+ export const Timeline: Component<TimelineProps> = (props) => {
12
+ return (
13
+ <div class="timeline">
14
+ <Show
15
+ when={props.interactions.length > 0}
16
+ fallback={
17
+ <div class="empty-state">
18
+ <div class="empty-icon">📱</div>
19
+ <div class="empty-title">No interactions recorded yet</div>
20
+ <div class="empty-text">
21
+ Connect Appium Inspector to port 4724 and start interacting
22
+ </div>
23
+ </div>
24
+ }
25
+ >
26
+ <For each={props.interactions}>
27
+ {(interaction) => (
28
+ <InteractionCard
29
+ interaction={interaction}
30
+ onInspect={() => props.onInspect?.(interaction)}
31
+ />
32
+ )}
33
+ </For>
34
+ </Show>
35
+ </div>
36
+ );
37
+ };