@desplega.ai/qa-use 2.1.5 → 2.2.2
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/lib/api/browser-types.d.ts +175 -0
- package/dist/lib/api/browser-types.d.ts.map +1 -0
- package/dist/lib/api/browser-types.js +5 -0
- package/dist/lib/api/browser-types.js.map +1 -0
- package/dist/lib/api/browser.d.ts +66 -0
- package/dist/lib/api/browser.d.ts.map +1 -0
- package/dist/lib/api/browser.js +223 -0
- package/dist/lib/api/browser.js.map +1 -0
- package/dist/package.json +2 -1
- package/dist/src/cli/commands/browser/back.d.ts +6 -0
- package/dist/src/cli/commands/browser/back.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/back.js +42 -0
- package/dist/src/cli/commands/browser/back.js.map +1 -0
- package/dist/src/cli/commands/browser/check.d.ts +6 -0
- package/dist/src/cli/commands/browser/check.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/check.js +62 -0
- package/dist/src/cli/commands/browser/check.js.map +1 -0
- package/dist/src/cli/commands/browser/click.d.ts +6 -0
- package/dist/src/cli/commands/browser/click.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/click.js +63 -0
- package/dist/src/cli/commands/browser/click.js.map +1 -0
- package/dist/src/cli/commands/browser/close.d.ts +6 -0
- package/dist/src/cli/commands/browser/close.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/close.js +44 -0
- package/dist/src/cli/commands/browser/close.js.map +1 -0
- package/dist/src/cli/commands/browser/create.d.ts +6 -0
- package/dist/src/cli/commands/browser/create.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/create.js +281 -0
- package/dist/src/cli/commands/browser/create.js.map +1 -0
- package/dist/src/cli/commands/browser/fill.d.ts +6 -0
- package/dist/src/cli/commands/browser/fill.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/fill.js +83 -0
- package/dist/src/cli/commands/browser/fill.js.map +1 -0
- package/dist/src/cli/commands/browser/forward.d.ts +6 -0
- package/dist/src/cli/commands/browser/forward.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/forward.js +42 -0
- package/dist/src/cli/commands/browser/forward.js.map +1 -0
- package/dist/src/cli/commands/browser/get-blocks.d.ts +6 -0
- package/dist/src/cli/commands/browser/get-blocks.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/get-blocks.js +35 -0
- package/dist/src/cli/commands/browser/get-blocks.js.map +1 -0
- package/dist/src/cli/commands/browser/goto.d.ts +6 -0
- package/dist/src/cli/commands/browser/goto.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/goto.js +53 -0
- package/dist/src/cli/commands/browser/goto.js.map +1 -0
- package/dist/src/cli/commands/browser/hover.d.ts +6 -0
- package/dist/src/cli/commands/browser/hover.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/hover.js +63 -0
- package/dist/src/cli/commands/browser/hover.js.map +1 -0
- package/dist/src/cli/commands/browser/index.d.ts +9 -0
- package/dist/src/cli/commands/browser/index.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/index.js +71 -0
- package/dist/src/cli/commands/browser/index.js.map +1 -0
- package/dist/src/cli/commands/browser/list.d.ts +6 -0
- package/dist/src/cli/commands/browser/list.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/list.js +85 -0
- package/dist/src/cli/commands/browser/list.js.map +1 -0
- package/dist/src/cli/commands/browser/press.d.ts +6 -0
- package/dist/src/cli/commands/browser/press.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/press.js +67 -0
- package/dist/src/cli/commands/browser/press.js.map +1 -0
- package/dist/src/cli/commands/browser/reload.d.ts +6 -0
- package/dist/src/cli/commands/browser/reload.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/reload.js +42 -0
- package/dist/src/cli/commands/browser/reload.js.map +1 -0
- package/dist/src/cli/commands/browser/run.d.ts +6 -0
- package/dist/src/cli/commands/browser/run.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/run.js +618 -0
- package/dist/src/cli/commands/browser/run.js.map +1 -0
- package/dist/src/cli/commands/browser/screenshot.d.ts +6 -0
- package/dist/src/cli/commands/browser/screenshot.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/screenshot.js +72 -0
- package/dist/src/cli/commands/browser/screenshot.js.map +1 -0
- package/dist/src/cli/commands/browser/scroll-into-view.d.ts +6 -0
- package/dist/src/cli/commands/browser/scroll-into-view.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/scroll-into-view.js +64 -0
- package/dist/src/cli/commands/browser/scroll-into-view.js.map +1 -0
- package/dist/src/cli/commands/browser/scroll.d.ts +6 -0
- package/dist/src/cli/commands/browser/scroll.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/scroll.js +63 -0
- package/dist/src/cli/commands/browser/scroll.js.map +1 -0
- package/dist/src/cli/commands/browser/select.d.ts +6 -0
- package/dist/src/cli/commands/browser/select.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/select.js +83 -0
- package/dist/src/cli/commands/browser/select.js.map +1 -0
- package/dist/src/cli/commands/browser/snapshot.d.ts +6 -0
- package/dist/src/cli/commands/browser/snapshot.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/snapshot.js +72 -0
- package/dist/src/cli/commands/browser/snapshot.js.map +1 -0
- package/dist/src/cli/commands/browser/status.d.ts +6 -0
- package/dist/src/cli/commands/browser/status.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/status.js +91 -0
- package/dist/src/cli/commands/browser/status.js.map +1 -0
- package/dist/src/cli/commands/browser/stream.d.ts +6 -0
- package/dist/src/cli/commands/browser/stream.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/stream.js +135 -0
- package/dist/src/cli/commands/browser/stream.js.map +1 -0
- package/dist/src/cli/commands/browser/tunnel.d.ts +13 -0
- package/dist/src/cli/commands/browser/tunnel.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/tunnel.js +225 -0
- package/dist/src/cli/commands/browser/tunnel.js.map +1 -0
- package/dist/src/cli/commands/browser/type.d.ts +6 -0
- package/dist/src/cli/commands/browser/type.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/type.js +61 -0
- package/dist/src/cli/commands/browser/type.js.map +1 -0
- package/dist/src/cli/commands/browser/uncheck.d.ts +6 -0
- package/dist/src/cli/commands/browser/uncheck.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/uncheck.js +62 -0
- package/dist/src/cli/commands/browser/uncheck.js.map +1 -0
- package/dist/src/cli/commands/browser/url.d.ts +6 -0
- package/dist/src/cli/commands/browser/url.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/url.js +40 -0
- package/dist/src/cli/commands/browser/url.js.map +1 -0
- package/dist/src/cli/commands/browser/wait-for-load.d.ts +6 -0
- package/dist/src/cli/commands/browser/wait-for-load.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/wait-for-load.js +50 -0
- package/dist/src/cli/commands/browser/wait-for-load.js.map +1 -0
- package/dist/src/cli/commands/browser/wait-for-selector.d.ts +6 -0
- package/dist/src/cli/commands/browser/wait-for-selector.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/wait-for-selector.js +52 -0
- package/dist/src/cli/commands/browser/wait-for-selector.js.map +1 -0
- package/dist/src/cli/commands/browser/wait.d.ts +6 -0
- package/dist/src/cli/commands/browser/wait.d.ts.map +1 -0
- package/dist/src/cli/commands/browser/wait.js +60 -0
- package/dist/src/cli/commands/browser/wait.js.map +1 -0
- package/dist/src/cli/commands/test/run.d.ts.map +1 -1
- package/dist/src/cli/commands/test/run.js +39 -8
- package/dist/src/cli/commands/test/run.js.map +1 -1
- package/dist/src/cli/index.js +2 -0
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/cli/lib/browser-sessions.d.ts +72 -0
- package/dist/src/cli/lib/browser-sessions.d.ts.map +1 -0
- package/dist/src/cli/lib/browser-sessions.js +184 -0
- package/dist/src/cli/lib/browser-sessions.js.map +1 -0
- package/dist/src/cli/lib/download.d.ts +2 -2
- package/dist/src/cli/lib/download.d.ts.map +1 -1
- package/dist/src/cli/lib/download.js +21 -5
- package/dist/src/cli/lib/download.js.map +1 -1
- package/dist/src/cli/lib/output.d.ts.map +1 -1
- package/dist/src/cli/lib/output.js +11 -4
- package/dist/src/cli/lib/output.js.map +1 -1
- package/lib/api/browser-types.ts +278 -0
- package/lib/api/browser.test.ts +378 -0
- package/lib/api/browser.ts +279 -0
- package/package.json +2 -1
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BrowserApiClient Unit Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, mock, spyOn } from 'bun:test';
|
|
6
|
+
import axios from 'axios';
|
|
7
|
+
import { BrowserApiClient } from './browser.js';
|
|
8
|
+
|
|
9
|
+
// Mock axios
|
|
10
|
+
const mockAxiosInstance = {
|
|
11
|
+
get: mock(() => Promise.resolve({ data: {} })),
|
|
12
|
+
post: mock(() => Promise.resolve({ data: {} })),
|
|
13
|
+
delete: mock(() => Promise.resolve({ data: {} })),
|
|
14
|
+
defaults: {
|
|
15
|
+
baseURL: 'https://api.desplega.ai/browsers/v1',
|
|
16
|
+
headers: {},
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
mock.module('axios', () => ({
|
|
21
|
+
default: {
|
|
22
|
+
create: () => mockAxiosInstance,
|
|
23
|
+
isAxiosError: (err: unknown) =>
|
|
24
|
+
typeof err === 'object' && err !== null && 'response' in err && 'isAxiosError' in err,
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
describe('BrowserApiClient', () => {
|
|
29
|
+
let client: BrowserApiClient;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
// Reset mocks
|
|
33
|
+
mockAxiosInstance.get.mockReset();
|
|
34
|
+
mockAxiosInstance.post.mockReset();
|
|
35
|
+
mockAxiosInstance.delete.mockReset();
|
|
36
|
+
|
|
37
|
+
// Clear environment variables
|
|
38
|
+
delete process.env.QA_USE_API_KEY;
|
|
39
|
+
delete process.env.QA_USE_API_URL;
|
|
40
|
+
|
|
41
|
+
client = new BrowserApiClient();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('constructor', () => {
|
|
45
|
+
it('should use default API URL', () => {
|
|
46
|
+
const c = new BrowserApiClient();
|
|
47
|
+
expect(c.getBaseUrl()).toContain('api.desplega.ai');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should use custom API URL', () => {
|
|
51
|
+
const c = new BrowserApiClient('https://custom.api.com');
|
|
52
|
+
// The URL should be set via axios.create
|
|
53
|
+
expect(mockAxiosInstance.defaults.baseURL).toBeDefined();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('setApiKey', () => {
|
|
58
|
+
it('should set API key', () => {
|
|
59
|
+
client.setApiKey('test-key-123');
|
|
60
|
+
expect(client.getApiKey()).toBe('test-key-123');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('createSession', () => {
|
|
65
|
+
it('should create session with default options', async () => {
|
|
66
|
+
const mockSession = {
|
|
67
|
+
id: 'session-123',
|
|
68
|
+
status: 'starting',
|
|
69
|
+
created_at: '2026-01-23T10:00:00Z',
|
|
70
|
+
};
|
|
71
|
+
mockAxiosInstance.post.mockResolvedValueOnce({ data: mockSession });
|
|
72
|
+
|
|
73
|
+
const session = await client.createSession();
|
|
74
|
+
|
|
75
|
+
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/sessions', {
|
|
76
|
+
headless: true,
|
|
77
|
+
viewport: 'desktop',
|
|
78
|
+
timeout: 300,
|
|
79
|
+
});
|
|
80
|
+
expect(session.id).toBe('session-123');
|
|
81
|
+
expect(session.status).toBe('starting');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should create session with custom options', async () => {
|
|
85
|
+
const mockSession = {
|
|
86
|
+
id: 'session-456',
|
|
87
|
+
status: 'starting',
|
|
88
|
+
created_at: '2026-01-23T10:00:00Z',
|
|
89
|
+
};
|
|
90
|
+
mockAxiosInstance.post.mockResolvedValueOnce({ data: mockSession });
|
|
91
|
+
|
|
92
|
+
const session = await client.createSession({
|
|
93
|
+
headless: false,
|
|
94
|
+
viewport: 'mobile',
|
|
95
|
+
timeout: 600,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/sessions', {
|
|
99
|
+
headless: false,
|
|
100
|
+
viewport: 'mobile',
|
|
101
|
+
timeout: 600,
|
|
102
|
+
});
|
|
103
|
+
expect(session.id).toBe('session-456');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should create session with ws_url for remote browser', async () => {
|
|
107
|
+
const mockSession = {
|
|
108
|
+
id: 'session-789',
|
|
109
|
+
status: 'starting',
|
|
110
|
+
created_at: '2026-01-23T10:00:00Z',
|
|
111
|
+
};
|
|
112
|
+
mockAxiosInstance.post.mockResolvedValueOnce({ data: mockSession });
|
|
113
|
+
|
|
114
|
+
const session = await client.createSession({
|
|
115
|
+
headless: true,
|
|
116
|
+
viewport: 'desktop',
|
|
117
|
+
timeout: 300,
|
|
118
|
+
ws_url: 'wss://tunnel.example.com/devtools/browser/abc123',
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/sessions', {
|
|
122
|
+
headless: true,
|
|
123
|
+
viewport: 'desktop',
|
|
124
|
+
timeout: 300,
|
|
125
|
+
ws_url: 'wss://tunnel.example.com/devtools/browser/abc123',
|
|
126
|
+
});
|
|
127
|
+
expect(session.id).toBe('session-789');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should not include ws_url when not provided', async () => {
|
|
131
|
+
const mockSession = {
|
|
132
|
+
id: 'session-abc',
|
|
133
|
+
status: 'starting',
|
|
134
|
+
created_at: '2026-01-23T10:00:00Z',
|
|
135
|
+
};
|
|
136
|
+
mockAxiosInstance.post.mockResolvedValueOnce({ data: mockSession });
|
|
137
|
+
|
|
138
|
+
await client.createSession({
|
|
139
|
+
headless: true,
|
|
140
|
+
viewport: 'desktop',
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const callArgs = mockAxiosInstance.post.mock.calls[0];
|
|
144
|
+
expect(callArgs[1]).not.toHaveProperty('ws_url');
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('listSessions', () => {
|
|
149
|
+
it('should list sessions from array response', async () => {
|
|
150
|
+
const mockSessions = [
|
|
151
|
+
{ id: 'session-1', status: 'active' },
|
|
152
|
+
{ id: 'session-2', status: 'starting' },
|
|
153
|
+
];
|
|
154
|
+
mockAxiosInstance.get.mockResolvedValueOnce({ data: mockSessions });
|
|
155
|
+
|
|
156
|
+
const sessions = await client.listSessions();
|
|
157
|
+
|
|
158
|
+
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/sessions');
|
|
159
|
+
expect(sessions).toHaveLength(2);
|
|
160
|
+
expect(sessions[0].id).toBe('session-1');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('should list sessions from object response', async () => {
|
|
164
|
+
const mockResponse = {
|
|
165
|
+
sessions: [
|
|
166
|
+
{ id: 'session-1', status: 'active' },
|
|
167
|
+
{ id: 'session-2', status: 'starting' },
|
|
168
|
+
],
|
|
169
|
+
};
|
|
170
|
+
mockAxiosInstance.get.mockResolvedValueOnce({ data: mockResponse });
|
|
171
|
+
|
|
172
|
+
const sessions = await client.listSessions();
|
|
173
|
+
|
|
174
|
+
expect(sessions).toHaveLength(2);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('getSession', () => {
|
|
179
|
+
it('should get session by ID', async () => {
|
|
180
|
+
const mockSession = {
|
|
181
|
+
id: 'session-123',
|
|
182
|
+
status: 'active',
|
|
183
|
+
url: 'https://example.com',
|
|
184
|
+
};
|
|
185
|
+
mockAxiosInstance.get.mockResolvedValueOnce({ data: mockSession });
|
|
186
|
+
|
|
187
|
+
const session = await client.getSession('session-123');
|
|
188
|
+
|
|
189
|
+
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/sessions/session-123');
|
|
190
|
+
expect(session.id).toBe('session-123');
|
|
191
|
+
expect(session.status).toBe('active');
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('deleteSession', () => {
|
|
196
|
+
it('should delete session by ID', async () => {
|
|
197
|
+
mockAxiosInstance.delete.mockResolvedValueOnce({ data: {} });
|
|
198
|
+
|
|
199
|
+
await client.deleteSession('session-123');
|
|
200
|
+
|
|
201
|
+
expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/sessions/session-123');
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('executeAction', () => {
|
|
206
|
+
it('should execute goto action', async () => {
|
|
207
|
+
const mockResult = { success: true };
|
|
208
|
+
mockAxiosInstance.post.mockResolvedValueOnce({ data: mockResult });
|
|
209
|
+
|
|
210
|
+
const result = await client.executeAction('session-123', {
|
|
211
|
+
type: 'goto',
|
|
212
|
+
url: 'https://example.com',
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/sessions/session-123/action', {
|
|
216
|
+
type: 'goto',
|
|
217
|
+
url: 'https://example.com',
|
|
218
|
+
});
|
|
219
|
+
expect(result.success).toBe(true);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('should execute click action', async () => {
|
|
223
|
+
const mockResult = { success: true };
|
|
224
|
+
mockAxiosInstance.post.mockResolvedValueOnce({ data: mockResult });
|
|
225
|
+
|
|
226
|
+
const result = await client.executeAction('session-123', {
|
|
227
|
+
type: 'click',
|
|
228
|
+
ref: 'e3',
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/sessions/session-123/action', {
|
|
232
|
+
type: 'click',
|
|
233
|
+
ref: 'e3',
|
|
234
|
+
});
|
|
235
|
+
expect(result.success).toBe(true);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('should execute fill action', async () => {
|
|
239
|
+
const mockResult = { success: true };
|
|
240
|
+
mockAxiosInstance.post.mockResolvedValueOnce({ data: mockResult });
|
|
241
|
+
|
|
242
|
+
const result = await client.executeAction('session-123', {
|
|
243
|
+
type: 'fill',
|
|
244
|
+
ref: 'e4',
|
|
245
|
+
value: 'test@example.com',
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/sessions/session-123/action', {
|
|
249
|
+
type: 'fill',
|
|
250
|
+
ref: 'e4',
|
|
251
|
+
value: 'test@example.com',
|
|
252
|
+
});
|
|
253
|
+
expect(result.success).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('should execute scroll action', async () => {
|
|
257
|
+
const mockResult = { success: true };
|
|
258
|
+
mockAxiosInstance.post.mockResolvedValueOnce({ data: mockResult });
|
|
259
|
+
|
|
260
|
+
const result = await client.executeAction('session-123', {
|
|
261
|
+
type: 'scroll',
|
|
262
|
+
direction: 'down',
|
|
263
|
+
amount: 500,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/sessions/session-123/action', {
|
|
267
|
+
type: 'scroll',
|
|
268
|
+
direction: 'down',
|
|
269
|
+
amount: 500,
|
|
270
|
+
});
|
|
271
|
+
expect(result.success).toBe(true);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe('getSnapshot', () => {
|
|
276
|
+
it('should get ARIA snapshot', async () => {
|
|
277
|
+
const mockSnapshot = {
|
|
278
|
+
snapshot: '- heading "Example" [ref=e1]',
|
|
279
|
+
url: 'https://example.com',
|
|
280
|
+
};
|
|
281
|
+
mockAxiosInstance.get.mockResolvedValueOnce({ data: mockSnapshot });
|
|
282
|
+
|
|
283
|
+
const snapshot = await client.getSnapshot('session-123');
|
|
284
|
+
|
|
285
|
+
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/sessions/session-123/snapshot');
|
|
286
|
+
expect(snapshot.snapshot).toContain('Example');
|
|
287
|
+
expect(snapshot.url).toBe('https://example.com');
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe('getScreenshot', () => {
|
|
292
|
+
it('should get screenshot as buffer', async () => {
|
|
293
|
+
const mockData = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG header
|
|
294
|
+
mockAxiosInstance.get.mockResolvedValueOnce({ data: mockData });
|
|
295
|
+
|
|
296
|
+
const buffer = await client.getScreenshot('session-123');
|
|
297
|
+
|
|
298
|
+
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/sessions/session-123/screenshot', {
|
|
299
|
+
responseType: 'arraybuffer',
|
|
300
|
+
});
|
|
301
|
+
expect(Buffer.isBuffer(buffer)).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
describe('getUrl', () => {
|
|
306
|
+
it('should get current URL', async () => {
|
|
307
|
+
const mockResult = { url: 'https://example.com/page' };
|
|
308
|
+
mockAxiosInstance.get.mockResolvedValueOnce({ data: mockResult });
|
|
309
|
+
|
|
310
|
+
const url = await client.getUrl('session-123');
|
|
311
|
+
|
|
312
|
+
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/sessions/session-123/url');
|
|
313
|
+
expect(url).toBe('https://example.com/page');
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
describe('getStreamUrl', () => {
|
|
318
|
+
it('should return WebSocket URL', () => {
|
|
319
|
+
const url = client.getStreamUrl('session-123');
|
|
320
|
+
|
|
321
|
+
expect(url).toContain('ws');
|
|
322
|
+
expect(url).toContain('session-123');
|
|
323
|
+
expect(url).toContain('/stream');
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe('waitForStatus', () => {
|
|
328
|
+
it('should return immediately if session is already active', async () => {
|
|
329
|
+
const mockSession = {
|
|
330
|
+
id: 'session-123',
|
|
331
|
+
status: 'active',
|
|
332
|
+
created_at: '2026-01-23T10:00:00Z',
|
|
333
|
+
};
|
|
334
|
+
mockAxiosInstance.get.mockResolvedValueOnce({ data: mockSession });
|
|
335
|
+
|
|
336
|
+
const session = await client.waitForStatus('session-123', 'active', 5000, 100);
|
|
337
|
+
|
|
338
|
+
expect(session.status).toBe('active');
|
|
339
|
+
expect(mockAxiosInstance.get).toHaveBeenCalledTimes(1);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('should poll until session becomes active', async () => {
|
|
343
|
+
const startingSession = {
|
|
344
|
+
id: 'session-123',
|
|
345
|
+
status: 'starting',
|
|
346
|
+
created_at: '2026-01-23T10:00:00Z',
|
|
347
|
+
};
|
|
348
|
+
const activeSession = {
|
|
349
|
+
id: 'session-123',
|
|
350
|
+
status: 'active',
|
|
351
|
+
created_at: '2026-01-23T10:00:00Z',
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
mockAxiosInstance.get
|
|
355
|
+
.mockResolvedValueOnce({ data: startingSession })
|
|
356
|
+
.mockResolvedValueOnce({ data: startingSession })
|
|
357
|
+
.mockResolvedValueOnce({ data: activeSession });
|
|
358
|
+
|
|
359
|
+
const session = await client.waitForStatus('session-123', 'active', 5000, 50);
|
|
360
|
+
|
|
361
|
+
expect(session.status).toBe('active');
|
|
362
|
+
expect(mockAxiosInstance.get).toHaveBeenCalledTimes(3);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('should throw error if session is closed', async () => {
|
|
366
|
+
const closedSession = {
|
|
367
|
+
id: 'session-123',
|
|
368
|
+
status: 'closed',
|
|
369
|
+
created_at: '2026-01-23T10:00:00Z',
|
|
370
|
+
};
|
|
371
|
+
mockAxiosInstance.get.mockResolvedValueOnce({ data: closedSession });
|
|
372
|
+
|
|
373
|
+
await expect(client.waitForStatus('session-123', 'active', 5000, 100)).rejects.toThrow(
|
|
374
|
+
'closed'
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
});
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BrowserApiClient - Client for the desplega.ai Browser API (/browsers/v1/)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import axios from 'axios';
|
|
6
|
+
import type { AxiosInstance } from 'axios';
|
|
7
|
+
import { getEnv } from '../env/index.js';
|
|
8
|
+
import type {
|
|
9
|
+
BrowserSession,
|
|
10
|
+
BrowserAction,
|
|
11
|
+
ActionResult,
|
|
12
|
+
SnapshotResult,
|
|
13
|
+
UrlResult,
|
|
14
|
+
BlocksResult,
|
|
15
|
+
CreateBrowserSessionOptions,
|
|
16
|
+
BrowserSessionStatus,
|
|
17
|
+
} from './browser-types.js';
|
|
18
|
+
import type { ExtendedStep } from '../../src/types/test-definition.js';
|
|
19
|
+
|
|
20
|
+
export class BrowserApiClient {
|
|
21
|
+
private readonly client: AxiosInstance;
|
|
22
|
+
private apiKey: string | null = null;
|
|
23
|
+
|
|
24
|
+
constructor(baseUrl?: string) {
|
|
25
|
+
// Use environment variable if available, otherwise use provided baseUrl, finally fall back to production
|
|
26
|
+
const apiUrl = getEnv('QA_USE_API_URL') || baseUrl || 'https://api.desplega.ai';
|
|
27
|
+
|
|
28
|
+
this.client = axios.create({
|
|
29
|
+
baseURL: `${apiUrl}/browsers/v1`,
|
|
30
|
+
timeout: 60000, // Longer timeout for browser operations
|
|
31
|
+
headers: {
|
|
32
|
+
'Content-Type': 'application/json',
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Auto-load API key from environment or config file if available
|
|
37
|
+
const envApiKey = getEnv('QA_USE_API_KEY');
|
|
38
|
+
if (envApiKey) {
|
|
39
|
+
this.setApiKey(envApiKey);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
setApiKey(apiKey: string): void {
|
|
44
|
+
this.apiKey = apiKey;
|
|
45
|
+
this.client.defaults.headers['Authorization'] = `Bearer ${apiKey}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
getApiKey(): string | null {
|
|
49
|
+
return this.apiKey;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
getBaseUrl(): string {
|
|
53
|
+
return this.client.defaults.baseURL || 'https://api.desplega.ai/browsers/v1';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ==========================================
|
|
57
|
+
// Session Management
|
|
58
|
+
// ==========================================
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Create a new browser session
|
|
62
|
+
*/
|
|
63
|
+
async createSession(options: CreateBrowserSessionOptions = {}): Promise<BrowserSession> {
|
|
64
|
+
try {
|
|
65
|
+
const response = await this.client.post('/sessions', {
|
|
66
|
+
headless: options.headless ?? true,
|
|
67
|
+
viewport: options.viewport ?? 'desktop',
|
|
68
|
+
timeout: options.timeout ?? 300,
|
|
69
|
+
...(options.ws_url && { ws_url: options.ws_url }),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return response.data as BrowserSession;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
throw this.handleError(error, 'create session');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* List all sessions for the organization
|
|
80
|
+
*/
|
|
81
|
+
async listSessions(): Promise<BrowserSession[]> {
|
|
82
|
+
try {
|
|
83
|
+
const response = await this.client.get('/sessions');
|
|
84
|
+
// API may return { sessions: [...] } or just [...]
|
|
85
|
+
return Array.isArray(response.data) ? response.data : response.data.sessions || [];
|
|
86
|
+
} catch (error) {
|
|
87
|
+
throw this.handleError(error, 'list sessions');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get a specific session by ID
|
|
93
|
+
*/
|
|
94
|
+
async getSession(sessionId: string): Promise<BrowserSession> {
|
|
95
|
+
try {
|
|
96
|
+
const response = await this.client.get(`/sessions/${sessionId}`);
|
|
97
|
+
return response.data as BrowserSession;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw this.handleError(error, 'get session');
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Delete/close a session
|
|
105
|
+
*/
|
|
106
|
+
async deleteSession(sessionId: string): Promise<void> {
|
|
107
|
+
try {
|
|
108
|
+
await this.client.delete(`/sessions/${sessionId}`);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
throw this.handleError(error, 'delete session');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Wait for a session to reach a specific status
|
|
116
|
+
* @param sessionId - The session ID to wait for
|
|
117
|
+
* @param targetStatus - The status to wait for (default: 'active')
|
|
118
|
+
* @param timeoutMs - Maximum time to wait in milliseconds (default: 30000)
|
|
119
|
+
* @param pollIntervalMs - Polling interval in milliseconds (default: 1000)
|
|
120
|
+
*/
|
|
121
|
+
async waitForStatus(
|
|
122
|
+
sessionId: string,
|
|
123
|
+
targetStatus: BrowserSessionStatus = 'active',
|
|
124
|
+
timeoutMs: number = 30000,
|
|
125
|
+
pollIntervalMs: number = 1000
|
|
126
|
+
): Promise<BrowserSession> {
|
|
127
|
+
const startTime = Date.now();
|
|
128
|
+
|
|
129
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
130
|
+
const session = await this.getSession(sessionId);
|
|
131
|
+
|
|
132
|
+
if (session.status === targetStatus) {
|
|
133
|
+
return session;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// If session is closed or failed, throw error
|
|
137
|
+
if (session.status === 'closed') {
|
|
138
|
+
throw new Error(`Session ${sessionId} is closed`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Wait before polling again
|
|
142
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
throw new Error(`Timeout waiting for session ${sessionId} to reach status "${targetStatus}"`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ==========================================
|
|
149
|
+
// Actions
|
|
150
|
+
// ==========================================
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Execute a browser action
|
|
154
|
+
*/
|
|
155
|
+
async executeAction(sessionId: string, action: BrowserAction): Promise<ActionResult> {
|
|
156
|
+
try {
|
|
157
|
+
const response = await this.client.post(`/sessions/${sessionId}/action`, action);
|
|
158
|
+
return response.data as ActionResult;
|
|
159
|
+
} catch (error) {
|
|
160
|
+
throw this.handleError(error, `execute action "${action.type}"`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ==========================================
|
|
165
|
+
// Inspection
|
|
166
|
+
// ==========================================
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Get the ARIA accessibility tree snapshot
|
|
170
|
+
*/
|
|
171
|
+
async getSnapshot(sessionId: string): Promise<SnapshotResult> {
|
|
172
|
+
try {
|
|
173
|
+
const response = await this.client.get(`/sessions/${sessionId}/snapshot`);
|
|
174
|
+
return response.data as SnapshotResult;
|
|
175
|
+
} catch (error) {
|
|
176
|
+
throw this.handleError(error, 'get snapshot');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Get a screenshot of the current page
|
|
182
|
+
* @returns Buffer containing PNG image data
|
|
183
|
+
*/
|
|
184
|
+
async getScreenshot(sessionId: string): Promise<Buffer> {
|
|
185
|
+
try {
|
|
186
|
+
const response = await this.client.get(`/sessions/${sessionId}/screenshot`, {
|
|
187
|
+
responseType: 'arraybuffer',
|
|
188
|
+
});
|
|
189
|
+
return Buffer.from(response.data);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
throw this.handleError(error, 'get screenshot');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Get the current page URL
|
|
197
|
+
*/
|
|
198
|
+
async getUrl(sessionId: string): Promise<string> {
|
|
199
|
+
try {
|
|
200
|
+
const response = await this.client.get(`/sessions/${sessionId}/url`);
|
|
201
|
+
const result = response.data as UrlResult;
|
|
202
|
+
return result.url;
|
|
203
|
+
} catch (error) {
|
|
204
|
+
throw this.handleError(error, 'get URL');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Get recorded blocks (test steps) from the session
|
|
210
|
+
* @returns Array of ExtendedStep objects
|
|
211
|
+
*/
|
|
212
|
+
async getBlocks(sessionId: string): Promise<ExtendedStep[]> {
|
|
213
|
+
try {
|
|
214
|
+
const response = await this.client.get(`/sessions/${sessionId}/blocks`);
|
|
215
|
+
const result = response.data as BlocksResult;
|
|
216
|
+
return (result.blocks || []) as ExtendedStep[];
|
|
217
|
+
} catch (error) {
|
|
218
|
+
throw this.handleError(error, 'get blocks');
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ==========================================
|
|
223
|
+
// WebSocket Streaming
|
|
224
|
+
// ==========================================
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Get the WebSocket URL for streaming events
|
|
228
|
+
*/
|
|
229
|
+
getStreamUrl(sessionId: string): string {
|
|
230
|
+
// Convert HTTP URL to WebSocket URL
|
|
231
|
+
const baseUrl = this.getBaseUrl();
|
|
232
|
+
const wsUrl = baseUrl.replace(/^http/, 'ws');
|
|
233
|
+
return `${wsUrl}/sessions/${sessionId}/stream`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ==========================================
|
|
237
|
+
// Error Handling
|
|
238
|
+
// ==========================================
|
|
239
|
+
|
|
240
|
+
private handleError(error: unknown, operation: string): Error {
|
|
241
|
+
if (axios.isAxiosError(error)) {
|
|
242
|
+
const statusCode = error.response?.status;
|
|
243
|
+
const errorData = error.response?.data as { message?: string; detail?: string } | undefined;
|
|
244
|
+
|
|
245
|
+
if (statusCode === 404) {
|
|
246
|
+
return new Error(`Session not found`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (statusCode === 401) {
|
|
250
|
+
return new Error(`Unauthorized. Please check your API key.`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (statusCode === 403) {
|
|
254
|
+
return new Error(`Forbidden. You don't have permission for this operation.`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const message =
|
|
258
|
+
errorData?.message || errorData?.detail || `HTTP ${statusCode}: Failed to ${operation}`;
|
|
259
|
+
return new Error(message);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (error instanceof Error) {
|
|
263
|
+
return error;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return new Error(`Unknown error during ${operation}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Re-export types for convenience
|
|
271
|
+
export type {
|
|
272
|
+
BrowserSession,
|
|
273
|
+
BrowserAction,
|
|
274
|
+
ActionResult,
|
|
275
|
+
SnapshotResult,
|
|
276
|
+
UrlResult,
|
|
277
|
+
CreateBrowserSessionOptions,
|
|
278
|
+
BrowserSessionStatus,
|
|
279
|
+
} from './browser-types.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@desplega.ai/qa-use",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.2",
|
|
4
4
|
"packageManager": "bun@^1.3.4",
|
|
5
5
|
"description": "QA automation tool for browser testing with MCP server support",
|
|
6
6
|
"type": "module",
|
|
@@ -95,6 +95,7 @@
|
|
|
95
95
|
"express": "^4.21.2",
|
|
96
96
|
"glob": "^13.0.0",
|
|
97
97
|
"playwright": "1.55.0",
|
|
98
|
+
"ws": "^8.19.0",
|
|
98
99
|
"yaml": "^2.8.2"
|
|
99
100
|
}
|
|
100
101
|
}
|