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,377 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { InteractionRecorder } from '../../src/server/interaction-recorder';
3
+ import type { ServerEvent, Interaction } from '../../src/server/types';
4
+
5
+ describe('InteractionRecorder', () => {
6
+ let recorder: InteractionRecorder;
7
+
8
+ beforeEach(() => {
9
+ recorder = new InteractionRecorder();
10
+ });
11
+
12
+ describe('shouldRecord', () => {
13
+ it('should return true for POST requests', () => {
14
+ expect(recorder.shouldRecord('POST', '/session/123/element')).toBe(true);
15
+ });
16
+
17
+ it('should return false for GET requests', () => {
18
+ expect(recorder.shouldRecord('GET', '/session/123/element')).toBe(false);
19
+ });
20
+
21
+ it('should return false for PUT requests', () => {
22
+ expect(recorder.shouldRecord('PUT', '/session/123/element')).toBe(false);
23
+ });
24
+
25
+ it('should return false for DELETE requests', () => {
26
+ expect(recorder.shouldRecord('DELETE', '/session/123/element')).toBe(false);
27
+ });
28
+
29
+ it('should return false for PATCH requests', () => {
30
+ expect(recorder.shouldRecord('PATCH', '/session/123/element')).toBe(false);
31
+ });
32
+ });
33
+
34
+ describe('isActionEndpoint', () => {
35
+ describe('POST method', () => {
36
+ it('should identify click endpoint', () => {
37
+ expect(recorder.isActionEndpoint('POST', '/session/123/element/abc123/click')).toBe(true);
38
+ });
39
+
40
+ it('should identify value endpoint', () => {
41
+ expect(recorder.isActionEndpoint('POST', '/session/123/element/abc123/value')).toBe(true);
42
+ });
43
+
44
+ it('should identify clear endpoint', () => {
45
+ expect(recorder.isActionEndpoint('POST', '/session/123/element/abc123/clear')).toBe(true);
46
+ });
47
+
48
+ it('should identify element endpoint', () => {
49
+ expect(recorder.isActionEndpoint('POST', '/session/123/element')).toBe(true);
50
+ });
51
+
52
+ it('should identify elements endpoint', () => {
53
+ expect(recorder.isActionEndpoint('POST', '/session/123/elements')).toBe(true);
54
+ });
55
+
56
+ it('should identify touch/perform endpoint', () => {
57
+ expect(recorder.isActionEndpoint('POST', '/session/123/touch/perform')).toBe(true);
58
+ });
59
+
60
+ it('should identify actions endpoint', () => {
61
+ expect(recorder.isActionEndpoint('POST', '/session/123/actions')).toBe(true);
62
+ });
63
+
64
+ it('should identify back endpoint', () => {
65
+ expect(recorder.isActionEndpoint('POST', '/session/123/back')).toBe(true);
66
+ });
67
+
68
+ it('should identify forward endpoint', () => {
69
+ expect(recorder.isActionEndpoint('POST', '/session/123/forward')).toBe(true);
70
+ });
71
+
72
+ it('should identify refresh endpoint', () => {
73
+ expect(recorder.isActionEndpoint('POST', '/session/123/refresh')).toBe(true);
74
+ });
75
+ });
76
+
77
+ describe('DELETE method', () => {
78
+ it('should identify action endpoints for DELETE', () => {
79
+ expect(recorder.isActionEndpoint('DELETE', '/session/123/element/abc123/click')).toBe(true);
80
+ });
81
+ });
82
+
83
+ describe('non-action endpoints', () => {
84
+ it('should return false for GET requests', () => {
85
+ expect(recorder.isActionEndpoint('GET', '/session/123/element/abc123/click')).toBe(false);
86
+ });
87
+
88
+ it('should return false for screenshot endpoint', () => {
89
+ expect(recorder.isActionEndpoint('POST', '/session/123/screenshot')).toBe(false);
90
+ });
91
+
92
+ it('should return false for source endpoint', () => {
93
+ expect(recorder.isActionEndpoint('POST', '/session/123/source')).toBe(false);
94
+ });
95
+
96
+ it('should return false for window endpoint', () => {
97
+ expect(recorder.isActionEndpoint('POST', '/session/123/window')).toBe(false);
98
+ });
99
+ });
100
+
101
+ describe('edge cases', () => {
102
+ it('should handle element IDs with special characters', () => {
103
+ expect(recorder.isActionEndpoint('POST', '/session/123/element/abc-123_xyz/click')).toBe(true);
104
+ });
105
+
106
+ it('should handle UUIDs as element IDs', () => {
107
+ expect(recorder.isActionEndpoint('POST', '/session/123/element/550e8400-e29b-41d4-a716-446655440000/click')).toBe(true);
108
+ });
109
+ });
110
+ });
111
+
112
+ describe('recordInteraction', () => {
113
+ it('should create interaction with auto-incrementing ID', () => {
114
+ const interaction1 = recorder.recordInteraction({
115
+ method: 'POST',
116
+ path: '/session/123/element',
117
+ });
118
+ const interaction2 = recorder.recordInteraction({
119
+ method: 'POST',
120
+ path: '/session/123/elements',
121
+ });
122
+
123
+ expect(interaction1.id).toBe(1);
124
+ expect(interaction2.id).toBe(2);
125
+ });
126
+
127
+ it('should add timestamp to interaction', () => {
128
+ const before = new Date().toISOString();
129
+ const interaction = recorder.recordInteraction({
130
+ method: 'POST',
131
+ path: '/session/123/element',
132
+ });
133
+ const after = new Date().toISOString();
134
+
135
+ expect(interaction.timestamp).toBeDefined();
136
+ expect(new Date(interaction.timestamp).getTime()).toBeGreaterThanOrEqual(new Date(before).getTime());
137
+ expect(new Date(interaction.timestamp).getTime()).toBeLessThanOrEqual(new Date(after).getTime());
138
+ });
139
+
140
+ it('should store interaction in history', () => {
141
+ const interaction = recorder.recordInteraction({
142
+ method: 'POST',
143
+ path: '/session/123/element',
144
+ });
145
+
146
+ const history = recorder.getHistory();
147
+ expect(history).toHaveLength(1);
148
+ expect(history[0]).toEqual(interaction);
149
+ });
150
+
151
+ it('should include body if provided', () => {
152
+ const interaction = recorder.recordInteraction({
153
+ method: 'POST',
154
+ path: '/session/123/element',
155
+ body: { using: 'xpath', value: '//button' },
156
+ });
157
+
158
+ expect(interaction.body).toEqual({ using: 'xpath', value: '//button' });
159
+ });
160
+
161
+ it('should include elementInfo if provided', () => {
162
+ const interaction = recorder.recordInteraction({
163
+ method: 'POST',
164
+ path: '/session/123/element',
165
+ elementInfo: { using: 'xpath', value: '//button' },
166
+ });
167
+
168
+ expect(interaction.elementInfo).toEqual({ using: 'xpath', value: '//button' });
169
+ });
170
+
171
+ it('should emit interaction event', () => {
172
+ const listener = vi.fn();
173
+ recorder.on(listener);
174
+
175
+ const interaction = recorder.recordInteraction({
176
+ method: 'POST',
177
+ path: '/session/123/element',
178
+ });
179
+
180
+ expect(listener).toHaveBeenCalledTimes(1);
181
+ expect(listener).toHaveBeenCalledWith({
182
+ type: 'interaction',
183
+ data: interaction,
184
+ });
185
+ });
186
+ });
187
+
188
+ describe('updateInteraction', () => {
189
+ it('should update existing interaction', () => {
190
+ const interaction = recorder.recordInteraction({
191
+ method: 'POST',
192
+ path: '/session/123/element',
193
+ });
194
+
195
+ recorder.updateInteraction(interaction.id, {
196
+ screenshot: 'base64screenshot',
197
+ source: '<xml>source</xml>',
198
+ });
199
+
200
+ const history = recorder.getHistory();
201
+ expect(history[0].screenshot).toBe('base64screenshot');
202
+ expect(history[0].source).toBe('<xml>source</xml>');
203
+ });
204
+
205
+ it('should emit event on update', () => {
206
+ const listener = vi.fn();
207
+ const interaction = recorder.recordInteraction({
208
+ method: 'POST',
209
+ path: '/session/123/element',
210
+ });
211
+
212
+ recorder.on(listener);
213
+ recorder.updateInteraction(interaction.id, {
214
+ screenshot: 'base64screenshot',
215
+ });
216
+
217
+ expect(listener).toHaveBeenCalledTimes(1);
218
+ expect(listener).toHaveBeenCalledWith(
219
+ expect.objectContaining({
220
+ type: 'interaction',
221
+ data: expect.objectContaining({
222
+ screenshot: 'base64screenshot',
223
+ }),
224
+ })
225
+ );
226
+ });
227
+
228
+ it('should not emit event for non-existent interaction', () => {
229
+ const listener = vi.fn();
230
+ recorder.on(listener);
231
+
232
+ recorder.updateInteraction(999, {
233
+ screenshot: 'base64screenshot',
234
+ });
235
+
236
+ expect(listener).not.toHaveBeenCalled();
237
+ });
238
+
239
+ it('should not affect other interactions', () => {
240
+ const interaction1 = recorder.recordInteraction({
241
+ method: 'POST',
242
+ path: '/session/123/element',
243
+ });
244
+ const interaction2 = recorder.recordInteraction({
245
+ method: 'POST',
246
+ path: '/session/123/elements',
247
+ });
248
+
249
+ recorder.updateInteraction(interaction1.id, {
250
+ screenshot: 'base64screenshot',
251
+ });
252
+
253
+ const history = recorder.getHistory();
254
+ expect(history[0].screenshot).toBe('base64screenshot');
255
+ expect(history[1].screenshot).toBeUndefined();
256
+ });
257
+ });
258
+
259
+ describe('getHistory', () => {
260
+ it('should return empty array initially', () => {
261
+ expect(recorder.getHistory()).toEqual([]);
262
+ });
263
+
264
+ it('should return all recorded interactions', () => {
265
+ recorder.recordInteraction({ method: 'POST', path: '/session/123/element' });
266
+ recorder.recordInteraction({ method: 'POST', path: '/session/123/elements' });
267
+ recorder.recordInteraction({ method: 'POST', path: '/session/123/back' });
268
+
269
+ const history = recorder.getHistory();
270
+ expect(history).toHaveLength(3);
271
+ });
272
+
273
+ it('should return interactions in order', () => {
274
+ recorder.recordInteraction({ method: 'POST', path: '/path1' });
275
+ recorder.recordInteraction({ method: 'POST', path: '/path2' });
276
+ recorder.recordInteraction({ method: 'POST', path: '/path3' });
277
+
278
+ const history = recorder.getHistory();
279
+ expect(history[0].path).toBe('/path1');
280
+ expect(history[1].path).toBe('/path2');
281
+ expect(history[2].path).toBe('/path3');
282
+ });
283
+ });
284
+
285
+ describe('clearHistory', () => {
286
+ it('should clear all interactions', () => {
287
+ recorder.recordInteraction({ method: 'POST', path: '/path1' });
288
+ recorder.recordInteraction({ method: 'POST', path: '/path2' });
289
+
290
+ recorder.clearHistory();
291
+
292
+ expect(recorder.getHistory()).toEqual([]);
293
+ });
294
+
295
+ it('should reset interaction ID counter', () => {
296
+ recorder.recordInteraction({ method: 'POST', path: '/path1' });
297
+ recorder.recordInteraction({ method: 'POST', path: '/path2' });
298
+
299
+ recorder.clearHistory();
300
+
301
+ const newInteraction = recorder.recordInteraction({ method: 'POST', path: '/path3' });
302
+ expect(newInteraction.id).toBe(1);
303
+ });
304
+
305
+ it('should emit clear event', () => {
306
+ const listener = vi.fn();
307
+ recorder.on(listener);
308
+
309
+ recorder.clearHistory();
310
+
311
+ expect(listener).toHaveBeenCalledWith({
312
+ type: 'clear',
313
+ data: null,
314
+ });
315
+ });
316
+ });
317
+
318
+ describe('event listener management', () => {
319
+ it('should register listener', () => {
320
+ const listener = vi.fn();
321
+ recorder.on(listener);
322
+
323
+ recorder.recordInteraction({ method: 'POST', path: '/path' });
324
+
325
+ expect(listener).toHaveBeenCalled();
326
+ });
327
+
328
+ it('should support multiple listeners', () => {
329
+ const listener1 = vi.fn();
330
+ const listener2 = vi.fn();
331
+
332
+ recorder.on(listener1);
333
+ recorder.on(listener2);
334
+
335
+ recorder.recordInteraction({ method: 'POST', path: '/path' });
336
+
337
+ expect(listener1).toHaveBeenCalled();
338
+ expect(listener2).toHaveBeenCalled();
339
+ });
340
+
341
+ it('should unsubscribe listener', () => {
342
+ const listener = vi.fn();
343
+ const unsubscribe = recorder.on(listener);
344
+
345
+ unsubscribe();
346
+
347
+ recorder.recordInteraction({ method: 'POST', path: '/path' });
348
+
349
+ expect(listener).not.toHaveBeenCalled();
350
+ });
351
+
352
+ it('should not affect other listeners when unsubscribing', () => {
353
+ const listener1 = vi.fn();
354
+ const listener2 = vi.fn();
355
+
356
+ const unsubscribe1 = recorder.on(listener1);
357
+ recorder.on(listener2);
358
+
359
+ unsubscribe1();
360
+
361
+ recorder.recordInteraction({ method: 'POST', path: '/path' });
362
+
363
+ expect(listener1).not.toHaveBeenCalled();
364
+ expect(listener2).toHaveBeenCalled();
365
+ });
366
+
367
+ it('should handle unsubscribing same listener multiple times', () => {
368
+ const listener = vi.fn();
369
+ const unsubscribe = recorder.on(listener);
370
+
371
+ unsubscribe();
372
+ unsubscribe(); // Should not throw
373
+
374
+ expect(listener).not.toHaveBeenCalled();
375
+ });
376
+ });
377
+ });