appium-session-recorder 0.0.2 → 0.0.4
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/dist/index.js +33319 -0
- package/dist/ui/assets/index-CnJwu_Mc.js +8 -0
- package/dist/ui/assets/index-VIFL67d5.css +1 -0
- package/{src → dist}/ui/index.html +2 -1
- package/package.json +20 -13
- package/bun.lock +0 -731
- package/src/cli/arg-parser.ts +0 -311
- package/src/cli/commands/drive.ts +0 -147
- package/src/cli/commands/index.ts +0 -54
- package/src/cli/commands/proxy.ts +0 -41
- package/src/cli/commands/screen.ts +0 -73
- package/src/cli/commands/selectors.ts +0 -42
- package/src/cli/commands/session.ts +0 -64
- package/src/cli/commands/types.ts +0 -11
- package/src/cli/index.ts +0 -158
- package/src/cli/prompts.ts +0 -64
- package/src/cli/response.ts +0 -44
- package/src/core/appium/client.ts +0 -248
- package/src/core/index.ts +0 -5
- package/src/core/selectors/generate-candidates.ts +0 -155
- package/src/core/selectors/score-candidates.ts +0 -184
- package/src/core/types.ts +0 -79
- package/src/core/xml/parse-source.ts +0 -197
- package/src/index.ts +0 -7
- package/src/server/appium-client.ts +0 -24
- package/src/server/index.ts +0 -6
- package/src/server/interaction-recorder.ts +0 -74
- package/src/server/proxy-middleware.ts +0 -68
- package/src/server/routes.ts +0 -64
- package/src/server/server.ts +0 -43
- package/src/server/types.ts +0 -34
- package/src/ui/bun.lock +0 -311
- package/src/ui/package.json +0 -20
- package/src/ui/src/App.css +0 -12
- package/src/ui/src/App.tsx +0 -41
- package/src/ui/src/components/ActionCarousel.css +0 -128
- package/src/ui/src/components/ActionCarousel.tsx +0 -92
- package/src/ui/src/components/Inspector.css +0 -314
- package/src/ui/src/components/Inspector.tsx +0 -265
- package/src/ui/src/components/InteractionCard.css +0 -159
- package/src/ui/src/components/InteractionCard.tsx +0 -60
- package/src/ui/src/components/MainInspector.css +0 -304
- package/src/ui/src/components/MainInspector.tsx +0 -304
- package/src/ui/src/components/Stats.css +0 -27
- package/src/ui/src/components/Timeline.css +0 -31
- package/src/ui/src/components/Timeline.tsx +0 -37
- package/src/ui/src/hooks/useInteractions.ts +0 -73
- package/src/ui/src/index.tsx +0 -11
- package/src/ui/src/services/api.ts +0 -41
- package/src/ui/src/styles/tokens.css +0 -126
- package/src/ui/src/types.ts +0 -34
- package/src/ui/src/utils/__tests__/locators.test.ts +0 -304
- package/src/ui/src/utils/__tests__/xml-parser.test.ts +0 -326
- package/src/ui/src/utils/locators.ts +0 -14
- package/src/ui/src/utils/xml-parser.ts +0 -45
- package/src/ui/tsconfig.json +0 -34
- package/src/ui/tsconfig.node.json +0 -11
- package/src/ui/vite.config.ts +0 -22
- package/tests/cli/arg-parser.test.ts +0 -397
- package/tests/cli/drive-commands.test.ts +0 -151
- package/tests/cli/selectors-best.test.ts +0 -42
- package/tests/cli/session-commands.test.ts +0 -53
- package/tests/core/selector-candidates.test.ts +0 -83
- package/tests/core/selector-scoring.test.ts +0 -75
- package/tests/core/xml-parser.test.ts +0 -56
- package/tests/server/appium-client.test.ts +0 -229
- package/tests/server/interaction-recorder.test.ts +0 -377
- package/tests/server/proxy-middleware.test.ts +0 -343
- package/tests/server/routes.test.ts +0 -305
- package/tsconfig.json +0 -26
- package/vitest.config.ts +0 -16
- package/vitest.ui.config.ts +0 -15
- package/workflow.gif +0 -0
|
@@ -1,343 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
-
import type { Request, Response, NextFunction } from 'express';
|
|
3
|
-
import { createSessionMiddleware } from '../../src/server/proxy-middleware';
|
|
4
|
-
import { InteractionRecorder } from '../../src/server/interaction-recorder';
|
|
5
|
-
import { AppiumClient } from '../../src/server/appium-client';
|
|
6
|
-
|
|
7
|
-
// Mock Express request
|
|
8
|
-
function createMockRequest(overrides: Partial<Request> = {}): Request {
|
|
9
|
-
return {
|
|
10
|
-
method: 'POST',
|
|
11
|
-
originalUrl: '/session/abc123/element',
|
|
12
|
-
path: '/session/abc123/element',
|
|
13
|
-
params: { sessionId: 'abc123' },
|
|
14
|
-
body: {},
|
|
15
|
-
...overrides,
|
|
16
|
-
} as Request;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
// Mock Express response
|
|
20
|
-
function createMockResponse(): Response {
|
|
21
|
-
const listeners: Record<string, Function[]> = {};
|
|
22
|
-
return {
|
|
23
|
-
on: vi.fn((event: string, callback: Function) => {
|
|
24
|
-
if (!listeners[event]) listeners[event] = [];
|
|
25
|
-
listeners[event].push(callback);
|
|
26
|
-
}),
|
|
27
|
-
// Helper to trigger events
|
|
28
|
-
emit: (event: string) => {
|
|
29
|
-
listeners[event]?.forEach(cb => cb());
|
|
30
|
-
},
|
|
31
|
-
} as unknown as Response;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
describe('createSessionMiddleware', () => {
|
|
35
|
-
let recorder: InteractionRecorder;
|
|
36
|
-
let appiumClient: AppiumClient;
|
|
37
|
-
let middleware: ReturnType<typeof createSessionMiddleware>;
|
|
38
|
-
let mockNext: NextFunction;
|
|
39
|
-
|
|
40
|
-
beforeEach(() => {
|
|
41
|
-
recorder = new InteractionRecorder();
|
|
42
|
-
appiumClient = new AppiumClient('http://localhost:4723');
|
|
43
|
-
middleware = createSessionMiddleware(recorder, appiumClient);
|
|
44
|
-
mockNext = vi.fn();
|
|
45
|
-
|
|
46
|
-
// Mock console.log to prevent output during tests
|
|
47
|
-
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
describe('request filtering', () => {
|
|
51
|
-
it('should skip non-POST requests', async () => {
|
|
52
|
-
const req = createMockRequest({ method: 'GET' });
|
|
53
|
-
const res = createMockResponse();
|
|
54
|
-
|
|
55
|
-
await middleware(req, res, mockNext);
|
|
56
|
-
|
|
57
|
-
expect(mockNext).toHaveBeenCalled();
|
|
58
|
-
expect(recorder.getHistory()).toHaveLength(0);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('should record POST requests', async () => {
|
|
62
|
-
const req = createMockRequest({ method: 'POST' });
|
|
63
|
-
const res = createMockResponse();
|
|
64
|
-
|
|
65
|
-
await middleware(req, res, mockNext);
|
|
66
|
-
|
|
67
|
-
expect(mockNext).toHaveBeenCalled();
|
|
68
|
-
expect(recorder.getHistory()).toHaveLength(1);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it('should skip DELETE requests (not recorded by shouldRecord)', async () => {
|
|
72
|
-
const req = createMockRequest({ method: 'DELETE' });
|
|
73
|
-
const res = createMockResponse();
|
|
74
|
-
|
|
75
|
-
await middleware(req, res, mockNext);
|
|
76
|
-
|
|
77
|
-
expect(mockNext).toHaveBeenCalled();
|
|
78
|
-
expect(recorder.getHistory()).toHaveLength(0);
|
|
79
|
-
});
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
describe('interaction recording', () => {
|
|
83
|
-
it('should record interaction with method and path', async () => {
|
|
84
|
-
const req = createMockRequest({
|
|
85
|
-
method: 'POST',
|
|
86
|
-
originalUrl: '/session/abc123/element',
|
|
87
|
-
});
|
|
88
|
-
const res = createMockResponse();
|
|
89
|
-
|
|
90
|
-
await middleware(req, res, mockNext);
|
|
91
|
-
|
|
92
|
-
const history = recorder.getHistory();
|
|
93
|
-
expect(history[0].method).toBe('POST');
|
|
94
|
-
expect(history[0].path).toBe('/session/abc123/element');
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it('should record body when present', async () => {
|
|
98
|
-
const req = createMockRequest({
|
|
99
|
-
body: { using: 'xpath', value: '//button' },
|
|
100
|
-
});
|
|
101
|
-
const res = createMockResponse();
|
|
102
|
-
|
|
103
|
-
await middleware(req, res, mockNext);
|
|
104
|
-
|
|
105
|
-
const history = recorder.getHistory();
|
|
106
|
-
expect(history[0].body).toEqual({ using: 'xpath', value: '//button' });
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('should not record body when empty', async () => {
|
|
110
|
-
const req = createMockRequest({
|
|
111
|
-
body: {},
|
|
112
|
-
});
|
|
113
|
-
const res = createMockResponse();
|
|
114
|
-
|
|
115
|
-
await middleware(req, res, mockNext);
|
|
116
|
-
|
|
117
|
-
const history = recorder.getHistory();
|
|
118
|
-
expect(history[0].body).toBeUndefined();
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it('should not record body when undefined', async () => {
|
|
122
|
-
const req = createMockRequest({
|
|
123
|
-
body: undefined,
|
|
124
|
-
});
|
|
125
|
-
const res = createMockResponse();
|
|
126
|
-
|
|
127
|
-
await middleware(req, res, mockNext);
|
|
128
|
-
|
|
129
|
-
const history = recorder.getHistory();
|
|
130
|
-
expect(history[0].body).toBeUndefined();
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
describe('element info extraction', () => {
|
|
135
|
-
it('should extract element info from find element request', async () => {
|
|
136
|
-
const req = createMockRequest({
|
|
137
|
-
body: { using: 'accessibility id', value: 'loginButton' },
|
|
138
|
-
});
|
|
139
|
-
const res = createMockResponse();
|
|
140
|
-
|
|
141
|
-
await middleware(req, res, mockNext);
|
|
142
|
-
|
|
143
|
-
const history = recorder.getHistory();
|
|
144
|
-
expect(history[0].elementInfo).toEqual({
|
|
145
|
-
using: 'accessibility id',
|
|
146
|
-
value: 'loginButton',
|
|
147
|
-
});
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
it('should extract element info with xpath strategy', async () => {
|
|
151
|
-
const req = createMockRequest({
|
|
152
|
-
body: { using: 'xpath', value: '//XCUIElementTypeButton[@name="Login"]' },
|
|
153
|
-
});
|
|
154
|
-
const res = createMockResponse();
|
|
155
|
-
|
|
156
|
-
await middleware(req, res, mockNext);
|
|
157
|
-
|
|
158
|
-
const history = recorder.getHistory();
|
|
159
|
-
expect(history[0].elementInfo).toEqual({
|
|
160
|
-
using: 'xpath',
|
|
161
|
-
value: '//XCUIElementTypeButton[@name="Login"]',
|
|
162
|
-
});
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it('should not set elementInfo when using is missing', async () => {
|
|
166
|
-
const req = createMockRequest({
|
|
167
|
-
body: { value: 'loginButton' },
|
|
168
|
-
});
|
|
169
|
-
const res = createMockResponse();
|
|
170
|
-
|
|
171
|
-
await middleware(req, res, mockNext);
|
|
172
|
-
|
|
173
|
-
const history = recorder.getHistory();
|
|
174
|
-
expect(history[0].elementInfo).toBeUndefined();
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
it('should not set elementInfo when value is missing', async () => {
|
|
178
|
-
const req = createMockRequest({
|
|
179
|
-
body: { using: 'accessibility id' },
|
|
180
|
-
});
|
|
181
|
-
const res = createMockResponse();
|
|
182
|
-
|
|
183
|
-
await middleware(req, res, mockNext);
|
|
184
|
-
|
|
185
|
-
const history = recorder.getHistory();
|
|
186
|
-
expect(history[0].elementInfo).toBeUndefined();
|
|
187
|
-
});
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
describe('action endpoints and state capture', () => {
|
|
191
|
-
it('should register finish listener for action endpoints', async () => {
|
|
192
|
-
const req = createMockRequest({
|
|
193
|
-
method: 'POST',
|
|
194
|
-
path: '/session/abc123/element/xyz/click',
|
|
195
|
-
originalUrl: '/session/abc123/element/xyz/click',
|
|
196
|
-
});
|
|
197
|
-
const res = createMockResponse();
|
|
198
|
-
|
|
199
|
-
await middleware(req, res, mockNext);
|
|
200
|
-
|
|
201
|
-
expect(res.on).toHaveBeenCalledWith('finish', expect.any(Function));
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
it('should not register finish listener for non-action endpoints', async () => {
|
|
205
|
-
const req = createMockRequest({
|
|
206
|
-
method: 'POST',
|
|
207
|
-
path: '/session/abc123/screenshot',
|
|
208
|
-
originalUrl: '/session/abc123/screenshot',
|
|
209
|
-
});
|
|
210
|
-
const res = createMockResponse();
|
|
211
|
-
|
|
212
|
-
await middleware(req, res, mockNext);
|
|
213
|
-
|
|
214
|
-
expect(res.on).not.toHaveBeenCalled();
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
it('should capture state on response finish for action endpoints', async () => {
|
|
218
|
-
const req = createMockRequest({
|
|
219
|
-
method: 'POST',
|
|
220
|
-
path: '/session/abc123/element/xyz/click',
|
|
221
|
-
originalUrl: '/session/abc123/element/xyz/click',
|
|
222
|
-
});
|
|
223
|
-
const res = createMockResponse();
|
|
224
|
-
|
|
225
|
-
// Mock captureState
|
|
226
|
-
vi.spyOn(appiumClient, 'captureState').mockResolvedValue({
|
|
227
|
-
screenshot: 'base64Screenshot',
|
|
228
|
-
source: '<xml>source</xml>',
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
await middleware(req, res, mockNext);
|
|
232
|
-
|
|
233
|
-
// Trigger finish event
|
|
234
|
-
(res as any).emit('finish');
|
|
235
|
-
|
|
236
|
-
// Wait for async operation
|
|
237
|
-
await new Promise(resolve => setTimeout(resolve, 10));
|
|
238
|
-
|
|
239
|
-
expect(appiumClient.captureState).toHaveBeenCalledWith('abc123');
|
|
240
|
-
|
|
241
|
-
const history = recorder.getHistory();
|
|
242
|
-
expect(history[0].screenshot).toBe('base64Screenshot');
|
|
243
|
-
expect(history[0].source).toBe('<xml>source</xml>');
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
it('should handle captureState returning partial data', async () => {
|
|
247
|
-
const req = createMockRequest({
|
|
248
|
-
method: 'POST',
|
|
249
|
-
path: '/session/abc123/element/xyz/click',
|
|
250
|
-
originalUrl: '/session/abc123/element/xyz/click',
|
|
251
|
-
});
|
|
252
|
-
const res = createMockResponse();
|
|
253
|
-
|
|
254
|
-
vi.spyOn(appiumClient, 'captureState').mockResolvedValue({
|
|
255
|
-
screenshot: 'base64Screenshot',
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
await middleware(req, res, mockNext);
|
|
259
|
-
|
|
260
|
-
(res as any).emit('finish');
|
|
261
|
-
await new Promise(resolve => setTimeout(resolve, 10));
|
|
262
|
-
|
|
263
|
-
const history = recorder.getHistory();
|
|
264
|
-
expect(history[0].screenshot).toBe('base64Screenshot');
|
|
265
|
-
expect(history[0].source).toBeUndefined();
|
|
266
|
-
});
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
describe('next() behavior', () => {
|
|
270
|
-
it('should always call next()', async () => {
|
|
271
|
-
const req = createMockRequest();
|
|
272
|
-
const res = createMockResponse();
|
|
273
|
-
|
|
274
|
-
await middleware(req, res, mockNext);
|
|
275
|
-
|
|
276
|
-
expect(mockNext).toHaveBeenCalledTimes(1);
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
it('should call next() even for skipped requests', async () => {
|
|
280
|
-
const req = createMockRequest({ method: 'GET' });
|
|
281
|
-
const res = createMockResponse();
|
|
282
|
-
|
|
283
|
-
await middleware(req, res, mockNext);
|
|
284
|
-
|
|
285
|
-
expect(mockNext).toHaveBeenCalledTimes(1);
|
|
286
|
-
});
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
describe('various action endpoints', () => {
|
|
290
|
-
const actionEndpoints = [
|
|
291
|
-
'/session/abc123/element/xyz/click',
|
|
292
|
-
'/session/abc123/element/xyz/value',
|
|
293
|
-
'/session/abc123/element/xyz/clear',
|
|
294
|
-
'/session/abc123/element',
|
|
295
|
-
'/session/abc123/elements',
|
|
296
|
-
'/session/abc123/touch/perform',
|
|
297
|
-
'/session/abc123/actions',
|
|
298
|
-
'/session/abc123/back',
|
|
299
|
-
'/session/abc123/forward',
|
|
300
|
-
'/session/abc123/refresh',
|
|
301
|
-
];
|
|
302
|
-
|
|
303
|
-
actionEndpoints.forEach(endpoint => {
|
|
304
|
-
it(`should recognize ${endpoint} as action endpoint`, async () => {
|
|
305
|
-
const req = createMockRequest({
|
|
306
|
-
method: 'POST',
|
|
307
|
-
path: endpoint,
|
|
308
|
-
originalUrl: endpoint,
|
|
309
|
-
});
|
|
310
|
-
const res = createMockResponse();
|
|
311
|
-
|
|
312
|
-
await middleware(req, res, mockNext);
|
|
313
|
-
|
|
314
|
-
expect(res.on).toHaveBeenCalledWith('finish', expect.any(Function));
|
|
315
|
-
});
|
|
316
|
-
});
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
describe('non-action endpoints', () => {
|
|
320
|
-
const nonActionEndpoints = [
|
|
321
|
-
'/session/abc123/screenshot',
|
|
322
|
-
'/session/abc123/source',
|
|
323
|
-
'/session/abc123/window',
|
|
324
|
-
'/session/abc123/url',
|
|
325
|
-
'/session/abc123/title',
|
|
326
|
-
];
|
|
327
|
-
|
|
328
|
-
nonActionEndpoints.forEach(endpoint => {
|
|
329
|
-
it(`should not recognize ${endpoint} as action endpoint`, async () => {
|
|
330
|
-
const req = createMockRequest({
|
|
331
|
-
method: 'POST',
|
|
332
|
-
path: endpoint,
|
|
333
|
-
originalUrl: endpoint,
|
|
334
|
-
});
|
|
335
|
-
const res = createMockResponse();
|
|
336
|
-
|
|
337
|
-
await middleware(req, res, mockNext);
|
|
338
|
-
|
|
339
|
-
expect(res.on).not.toHaveBeenCalled();
|
|
340
|
-
});
|
|
341
|
-
});
|
|
342
|
-
});
|
|
343
|
-
});
|
|
@@ -1,305 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
2
|
-
import type { Request, Response } from 'express';
|
|
3
|
-
import { createRoutes } from '../../src/server/routes';
|
|
4
|
-
import { InteractionRecorder } from '../../src/server/interaction-recorder';
|
|
5
|
-
|
|
6
|
-
// Mock request
|
|
7
|
-
function createMockRequest(overrides: Partial<Request> = {}): Request {
|
|
8
|
-
const listeners: Record<string, Function[]> = {};
|
|
9
|
-
return {
|
|
10
|
-
on: vi.fn((event: string, callback: Function) => {
|
|
11
|
-
if (!listeners[event]) listeners[event] = [];
|
|
12
|
-
listeners[event].push(callback);
|
|
13
|
-
return this;
|
|
14
|
-
}),
|
|
15
|
-
emit: (event: string) => {
|
|
16
|
-
listeners[event]?.forEach(cb => cb());
|
|
17
|
-
},
|
|
18
|
-
...overrides,
|
|
19
|
-
} as unknown as Request;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// Mock response
|
|
23
|
-
function createMockResponse(): Response & { _written: string[]; _json: any } {
|
|
24
|
-
const res = {
|
|
25
|
-
_written: [] as string[],
|
|
26
|
-
_json: null as any,
|
|
27
|
-
json: vi.fn(function(data: any) {
|
|
28
|
-
res._json = data;
|
|
29
|
-
return res;
|
|
30
|
-
}),
|
|
31
|
-
setHeader: vi.fn().mockReturnThis(),
|
|
32
|
-
write: vi.fn(function(data: string) {
|
|
33
|
-
res._written.push(data);
|
|
34
|
-
return res;
|
|
35
|
-
}),
|
|
36
|
-
sendFile: vi.fn().mockReturnThis(),
|
|
37
|
-
};
|
|
38
|
-
return res as unknown as Response & { _written: string[]; _json: any };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
describe('createRoutes', () => {
|
|
42
|
-
let recorder: InteractionRecorder;
|
|
43
|
-
let router: ReturnType<typeof createRoutes>;
|
|
44
|
-
|
|
45
|
-
beforeEach(() => {
|
|
46
|
-
recorder = new InteractionRecorder();
|
|
47
|
-
router = createRoutes(recorder);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
// Helper to find route handler
|
|
51
|
-
function getRouteHandler(method: string, path: string) {
|
|
52
|
-
const stack = (router as any).stack;
|
|
53
|
-
for (const layer of stack) {
|
|
54
|
-
if (layer.route) {
|
|
55
|
-
const routePath = layer.route.path;
|
|
56
|
-
const routeMethods = Object.keys(layer.route.methods);
|
|
57
|
-
if (routePath === path && routeMethods.includes(method.toLowerCase())) {
|
|
58
|
-
return layer.route.stack[0].handle;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return null;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
describe('GET /_recorder/api/history', () => {
|
|
66
|
-
it('should return empty array when no history', () => {
|
|
67
|
-
const handler = getRouteHandler('GET', '/_recorder/api/history');
|
|
68
|
-
expect(handler).not.toBeNull();
|
|
69
|
-
|
|
70
|
-
const req = createMockRequest();
|
|
71
|
-
const res = createMockResponse();
|
|
72
|
-
|
|
73
|
-
handler(req, res);
|
|
74
|
-
|
|
75
|
-
expect(res.json).toHaveBeenCalledWith([]);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
it('should return recorded interactions', () => {
|
|
79
|
-
recorder.recordInteraction({ method: 'POST', path: '/test1' });
|
|
80
|
-
recorder.recordInteraction({ method: 'POST', path: '/test2' });
|
|
81
|
-
|
|
82
|
-
const handler = getRouteHandler('GET', '/_recorder/api/history');
|
|
83
|
-
const req = createMockRequest();
|
|
84
|
-
const res = createMockResponse();
|
|
85
|
-
|
|
86
|
-
handler(req, res);
|
|
87
|
-
|
|
88
|
-
expect(res._json).toHaveLength(2);
|
|
89
|
-
expect(res._json[0].path).toBe('/test1');
|
|
90
|
-
expect(res._json[1].path).toBe('/test2');
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
describe('DELETE /_recorder/api/history', () => {
|
|
95
|
-
it('should clear history', () => {
|
|
96
|
-
recorder.recordInteraction({ method: 'POST', path: '/test' });
|
|
97
|
-
expect(recorder.getHistory()).toHaveLength(1);
|
|
98
|
-
|
|
99
|
-
const handler = getRouteHandler('DELETE', '/_recorder/api/history');
|
|
100
|
-
const req = createMockRequest();
|
|
101
|
-
const res = createMockResponse();
|
|
102
|
-
|
|
103
|
-
handler(req, res);
|
|
104
|
-
|
|
105
|
-
expect(recorder.getHistory()).toHaveLength(0);
|
|
106
|
-
expect(res.json).toHaveBeenCalledWith({ ok: true });
|
|
107
|
-
});
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
describe('GET /_recorder/api/stream (SSE)', () => {
|
|
111
|
-
it('should set correct headers for SSE', () => {
|
|
112
|
-
const handler = getRouteHandler('GET', '/_recorder/api/stream');
|
|
113
|
-
const req = createMockRequest();
|
|
114
|
-
const res = createMockResponse();
|
|
115
|
-
|
|
116
|
-
handler(req, res);
|
|
117
|
-
|
|
118
|
-
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/event-stream');
|
|
119
|
-
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache');
|
|
120
|
-
expect(res.setHeader).toHaveBeenCalledWith('Connection', 'keep-alive');
|
|
121
|
-
});
|
|
122
|
-
|
|
123
|
-
it('should send initial history on connection', () => {
|
|
124
|
-
recorder.recordInteraction({ method: 'POST', path: '/existing' });
|
|
125
|
-
|
|
126
|
-
const handler = getRouteHandler('GET', '/_recorder/api/stream');
|
|
127
|
-
const req = createMockRequest();
|
|
128
|
-
const res = createMockResponse();
|
|
129
|
-
|
|
130
|
-
handler(req, res);
|
|
131
|
-
|
|
132
|
-
expect(res._written.length).toBeGreaterThan(0);
|
|
133
|
-
const initData = JSON.parse(res._written[0].replace('data: ', '').replace('\n\n', ''));
|
|
134
|
-
expect(initData.type).toBe('init');
|
|
135
|
-
expect(initData.data).toHaveLength(1);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it('should stream new interactions', () => {
|
|
139
|
-
const handler = getRouteHandler('GET', '/_recorder/api/stream');
|
|
140
|
-
const req = createMockRequest();
|
|
141
|
-
const res = createMockResponse();
|
|
142
|
-
|
|
143
|
-
handler(req, res);
|
|
144
|
-
|
|
145
|
-
const initialWriteCount = res._written.length;
|
|
146
|
-
|
|
147
|
-
// Record new interaction
|
|
148
|
-
recorder.recordInteraction({ method: 'POST', path: '/new' });
|
|
149
|
-
|
|
150
|
-
expect(res._written.length).toBe(initialWriteCount + 1);
|
|
151
|
-
const eventData = JSON.parse(res._written[res._written.length - 1].replace('data: ', '').replace('\n\n', ''));
|
|
152
|
-
expect(eventData.type).toBe('interaction');
|
|
153
|
-
expect(eventData.data.path).toBe('/new');
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it('should stream interaction updates', () => {
|
|
157
|
-
const handler = getRouteHandler('GET', '/_recorder/api/stream');
|
|
158
|
-
const req = createMockRequest();
|
|
159
|
-
const res = createMockResponse();
|
|
160
|
-
|
|
161
|
-
handler(req, res);
|
|
162
|
-
|
|
163
|
-
const interaction = recorder.recordInteraction({ method: 'POST', path: '/test' });
|
|
164
|
-
const afterRecordCount = res._written.length;
|
|
165
|
-
|
|
166
|
-
recorder.updateInteraction(interaction.id, { screenshot: 'base64data' });
|
|
167
|
-
|
|
168
|
-
expect(res._written.length).toBe(afterRecordCount + 1);
|
|
169
|
-
const eventData = JSON.parse(res._written[res._written.length - 1].replace('data: ', '').replace('\n\n', ''));
|
|
170
|
-
expect(eventData.data.screenshot).toBe('base64data');
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it('should stream clear events', () => {
|
|
174
|
-
const handler = getRouteHandler('GET', '/_recorder/api/stream');
|
|
175
|
-
const req = createMockRequest();
|
|
176
|
-
const res = createMockResponse();
|
|
177
|
-
|
|
178
|
-
handler(req, res);
|
|
179
|
-
|
|
180
|
-
recorder.recordInteraction({ method: 'POST', path: '/test' });
|
|
181
|
-
const beforeClearCount = res._written.length;
|
|
182
|
-
|
|
183
|
-
recorder.clearHistory();
|
|
184
|
-
|
|
185
|
-
expect(res._written.length).toBe(beforeClearCount + 1);
|
|
186
|
-
const eventData = JSON.parse(res._written[res._written.length - 1].replace('data: ', '').replace('\n\n', ''));
|
|
187
|
-
expect(eventData.type).toBe('clear');
|
|
188
|
-
expect(eventData.data).toBeNull();
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it('should unsubscribe on client disconnect', () => {
|
|
192
|
-
const handler = getRouteHandler('GET', '/_recorder/api/stream');
|
|
193
|
-
const req = createMockRequest();
|
|
194
|
-
const res = createMockResponse();
|
|
195
|
-
|
|
196
|
-
handler(req, res);
|
|
197
|
-
|
|
198
|
-
// Simulate client disconnect
|
|
199
|
-
(req as any).emit('close');
|
|
200
|
-
|
|
201
|
-
// Record interaction after disconnect
|
|
202
|
-
const currentCount = res._written.length;
|
|
203
|
-
recorder.recordInteraction({ method: 'POST', path: '/after-disconnect' });
|
|
204
|
-
|
|
205
|
-
// Should not receive new events
|
|
206
|
-
expect(res._written.length).toBe(currentCount);
|
|
207
|
-
});
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
describe('GET /_recorder', () => {
|
|
211
|
-
it('should have route for serving UI', () => {
|
|
212
|
-
const handler = getRouteHandler('GET', '/_recorder');
|
|
213
|
-
expect(handler).not.toBeNull();
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
it('should call sendFile with UI path', () => {
|
|
217
|
-
const handler = getRouteHandler('GET', '/_recorder');
|
|
218
|
-
const req = createMockRequest();
|
|
219
|
-
const res = createMockResponse();
|
|
220
|
-
|
|
221
|
-
handler(req, res);
|
|
222
|
-
|
|
223
|
-
expect(res.sendFile).toHaveBeenCalled();
|
|
224
|
-
const sentPath = (res.sendFile as any).mock.calls[0][0];
|
|
225
|
-
expect(sentPath).toContain('dist/ui/index.html');
|
|
226
|
-
});
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
describe('SSE data format', () => {
|
|
230
|
-
it('should format SSE data correctly with proper line endings', () => {
|
|
231
|
-
const handler = getRouteHandler('GET', '/_recorder/api/stream');
|
|
232
|
-
const req = createMockRequest();
|
|
233
|
-
const res = createMockResponse();
|
|
234
|
-
|
|
235
|
-
handler(req, res);
|
|
236
|
-
|
|
237
|
-
// Check format of init message
|
|
238
|
-
const initMessage = res._written[0];
|
|
239
|
-
expect(initMessage).toMatch(/^data: .+\n\n$/);
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
it('should send valid JSON in SSE data field', () => {
|
|
243
|
-
recorder.recordInteraction({ method: 'POST', path: '/test', body: { key: 'value' } });
|
|
244
|
-
|
|
245
|
-
const handler = getRouteHandler('GET', '/_recorder/api/stream');
|
|
246
|
-
const req = createMockRequest();
|
|
247
|
-
const res = createMockResponse();
|
|
248
|
-
|
|
249
|
-
handler(req, res);
|
|
250
|
-
|
|
251
|
-
// Verify all written data can be parsed as JSON
|
|
252
|
-
res._written.forEach(msg => {
|
|
253
|
-
const jsonStr = msg.replace('data: ', '').replace('\n\n', '');
|
|
254
|
-
expect(() => JSON.parse(jsonStr)).not.toThrow();
|
|
255
|
-
});
|
|
256
|
-
});
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
describe('multiple SSE clients', () => {
|
|
260
|
-
it('should support multiple simultaneous SSE connections', () => {
|
|
261
|
-
const handler = getRouteHandler('GET', '/_recorder/api/stream');
|
|
262
|
-
|
|
263
|
-
const req1 = createMockRequest();
|
|
264
|
-
const res1 = createMockResponse();
|
|
265
|
-
handler(req1, res1);
|
|
266
|
-
|
|
267
|
-
const req2 = createMockRequest();
|
|
268
|
-
const res2 = createMockResponse();
|
|
269
|
-
handler(req2, res2);
|
|
270
|
-
|
|
271
|
-
// Record interaction
|
|
272
|
-
recorder.recordInteraction({ method: 'POST', path: '/test' });
|
|
273
|
-
|
|
274
|
-
// Both clients should receive the event
|
|
275
|
-
// Each client receives: init + interaction = at least 2 writes
|
|
276
|
-
expect(res1._written.length).toBeGreaterThanOrEqual(2);
|
|
277
|
-
expect(res2._written.length).toBeGreaterThanOrEqual(2);
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
it('should only unsubscribe disconnected client', () => {
|
|
281
|
-
const handler = getRouteHandler('GET', '/_recorder/api/stream');
|
|
282
|
-
|
|
283
|
-
const req1 = createMockRequest();
|
|
284
|
-
const res1 = createMockResponse();
|
|
285
|
-
handler(req1, res1);
|
|
286
|
-
|
|
287
|
-
const req2 = createMockRequest();
|
|
288
|
-
const res2 = createMockResponse();
|
|
289
|
-
handler(req2, res2);
|
|
290
|
-
|
|
291
|
-
// Disconnect first client
|
|
292
|
-
(req1 as any).emit('close');
|
|
293
|
-
|
|
294
|
-
const res1CountBeforeNew = res1._written.length;
|
|
295
|
-
const res2CountBeforeNew = res2._written.length;
|
|
296
|
-
|
|
297
|
-
// Record new interaction
|
|
298
|
-
recorder.recordInteraction({ method: 'POST', path: '/after-disconnect' });
|
|
299
|
-
|
|
300
|
-
// First client should not receive, second should
|
|
301
|
-
expect(res1._written.length).toBe(res1CountBeforeNew);
|
|
302
|
-
expect(res2._written.length).toBe(res2CountBeforeNew + 1);
|
|
303
|
-
});
|
|
304
|
-
});
|
|
305
|
-
});
|
package/tsconfig.json
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2020",
|
|
4
|
-
"module": "ES2020",
|
|
5
|
-
"lib": [
|
|
6
|
-
"ES2020"
|
|
7
|
-
],
|
|
8
|
-
"moduleResolution": "node",
|
|
9
|
-
"esModuleInterop": true,
|
|
10
|
-
"allowSyntheticDefaultImports": true,
|
|
11
|
-
"strict": true,
|
|
12
|
-
"skipLibCheck": true,
|
|
13
|
-
"resolveJsonModule": true,
|
|
14
|
-
"types": [
|
|
15
|
-
"bun-types"
|
|
16
|
-
]
|
|
17
|
-
},
|
|
18
|
-
"include": [
|
|
19
|
-
"src/**/*"
|
|
20
|
-
],
|
|
21
|
-
"exclude": [
|
|
22
|
-
"src/ui/**/*",
|
|
23
|
-
"node_modules",
|
|
24
|
-
"dist"
|
|
25
|
-
]
|
|
26
|
-
}
|
package/vitest.config.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { defineConfig } from 'vitest/config';
|
|
2
|
-
|
|
3
|
-
export default defineConfig({
|
|
4
|
-
test: {
|
|
5
|
-
globals: true,
|
|
6
|
-
environment: 'node',
|
|
7
|
-
include: ['src/**/*.test.ts', 'tests/**/*.test.ts'],
|
|
8
|
-
exclude: ['src/ui/**/*'],
|
|
9
|
-
coverage: {
|
|
10
|
-
provider: 'v8',
|
|
11
|
-
reporter: ['text', 'json', 'html'],
|
|
12
|
-
include: ['src/server/**/*.ts', 'src/cli/**/*.ts'],
|
|
13
|
-
exclude: ['src/ui/**/*', '**/*.test.ts', '**/index.ts'],
|
|
14
|
-
},
|
|
15
|
-
},
|
|
16
|
-
});
|