Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
ulixee
GitHub Repository: ulixee/secret-agent
Path: blob/main/full-client/test/handler.test.ts
1028 views
1
import { Helpers } from '@secret-agent/testing';
2
import { ITestKoaServer } from '@secret-agent/testing/helpers';
3
import Core, { CoreProcess, Session } from '@secret-agent/core/index';
4
import DisconnectedFromCoreError from '@secret-agent/client/connections/DisconnectedFromCoreError';
5
import { Agent, RemoteConnectionToCore } from '@secret-agent/client/index';
6
import { createPromise } from '@secret-agent/commons/utils';
7
import { Handler } from '../index';
8
9
let koaServer: ITestKoaServer;
10
beforeAll(async () => {
11
await Core.start();
12
koaServer = await Helpers.runKoaServer(true);
13
});
14
afterEach(Helpers.afterEach);
15
afterAll(Helpers.afterAll);
16
17
describe('Full client Handler', () => {
18
it('allows you to run concurrent tasks', async () => {
19
koaServer.get('/handler', ctx => {
20
ctx.body = `<html><head><title>Handler page</title></head><body><h1>Here</h1></body></html>`;
21
});
22
const concurrency = 5;
23
const handler = new Handler({
24
maxConcurrency: concurrency,
25
host: await Core.server.address,
26
});
27
Helpers.needsClosing.push(handler);
28
const sessionsRunning = new Map<string, boolean>();
29
let hasReachedMax = false;
30
const runningAtSameTime: string[][] = [];
31
32
for (let i = 0; i < 6; i += 1) {
33
// eslint-disable-next-line @typescript-eslint/no-loop-func
34
handler.dispatchAgent(async agent => {
35
const sessionId = await agent.sessionId;
36
sessionsRunning.set(sessionId, true);
37
const concurrent: string[] = [];
38
for (const [session, isRunning] of sessionsRunning) {
39
if (isRunning) concurrent.push(session);
40
}
41
runningAtSameTime.push(concurrent);
42
await agent.goto(`${koaServer.baseUrl}/handler`);
43
await agent.document.title;
44
45
while (!hasReachedMax && runningAtSameTime.length < concurrency) {
46
await new Promise(setImmediate);
47
}
48
49
hasReachedMax = true;
50
sessionsRunning.set(sessionId, false);
51
});
52
}
53
54
await handler.waitForAllDispatches();
55
56
expect(runningAtSameTime.filter(x => x.length > concurrency)).toHaveLength(0);
57
});
58
59
it('waits for an agent to close that is checked out', async () => {
60
const handler = new Handler({
61
maxConcurrency: 2,
62
host: await Core.server.address,
63
});
64
Helpers.needsClosing.push(handler);
65
66
const agent1 = await handler.createAgent();
67
const agent2 = await handler.createAgent();
68
await expect(agent1.sessionId).resolves.toBeTruthy();
69
await expect(agent2.sessionId).resolves.toBeTruthy();
70
const agent3 = handler.createAgent();
71
72
Helpers.needsClosing.push(agent2);
73
74
async function isAgent3Available(millis = 100): Promise<boolean> {
75
const result = await Promise.race([
76
agent3,
77
new Promise(resolve => setTimeout(() => resolve('not avail'), millis)),
78
]);
79
return result !== 'not avail';
80
}
81
82
await expect(isAgent3Available(0)).resolves.toBe(false);
83
84
await agent1.close();
85
86
await expect(isAgent3Available(5e3)).resolves.toBe(true);
87
await (await agent3).close();
88
});
89
});
90
91
describe('waitForAllDispatches', () => {
92
it('should not wait for an agent created through createAgent', async () => {
93
const handler = new Handler({
94
maxConcurrency: 2,
95
host: await Core.server.address,
96
});
97
Helpers.needsClosing.push(handler);
98
99
const agent1 = await handler.createAgent();
100
let counter = 0;
101
for (let i = 0; i < 5; i += 1) {
102
// eslint-disable-next-line @typescript-eslint/no-loop-func
103
handler.dispatchAgent(async () => {
104
counter += 1;
105
handler.dispatchAgent(async () => {
106
counter += 1;
107
await new Promise(resolve => setTimeout(resolve, 25 * Math.random()));
108
});
109
await new Promise(resolve => setTimeout(resolve, 25 * Math.random()));
110
});
111
}
112
113
const results = await handler.waitForAllDispatches();
114
expect(results).toHaveLength(10);
115
expect(counter).toBe(10);
116
expect(await agent1.sessionId).toBeTruthy();
117
});
118
119
it('should bubble up errors that occur when waiting for all', async () => {
120
const handler = new Handler({
121
maxConcurrency: 2,
122
host: await Core.server.address,
123
});
124
Helpers.needsClosing.push(handler);
125
126
const agent1 = await handler.createAgent();
127
128
const tab = Session.getTab({
129
sessionId: await agent1.sessionId,
130
tabId: await agent1.activeTab.tabId,
131
});
132
jest.spyOn(tab, 'goto').mockImplementation(async url => {
133
throw new Error(`invalid url "${url}"`);
134
});
135
136
await expect(agent1.goto('any url')).rejects.toThrow('invalid url "any url"');
137
138
handler.dispatchAgent(async agent => {
139
const tab2 = Session.getTab({
140
sessionId: await agent.sessionId,
141
tabId: await agent.activeTab.tabId,
142
});
143
jest.spyOn(tab2, 'goto').mockImplementation(async url => {
144
throw new Error(`invalid url "${url}"`);
145
});
146
147
await agent.goto('any url 2');
148
});
149
150
await expect(handler.waitForAllDispatches()).rejects.toThrow('invalid url "any url 2"');
151
});
152
});
153
154
describe('waitForAllDispatchesSettled', () => {
155
it('should return all successful and error dispatches', async () => {
156
const handler = new Handler({
157
maxConcurrency: 2,
158
host: await Core.server.address,
159
});
160
Helpers.needsClosing.push(handler);
161
162
let failedAgentSessionId: string;
163
handler.dispatchAgent(
164
async agent => {
165
failedAgentSessionId = await agent.sessionId;
166
const tab = Session.getTab({
167
sessionId: failedAgentSessionId,
168
tabId: await agent.activeTab.tabId,
169
});
170
jest.spyOn(tab, 'goto').mockImplementation(async url => {
171
throw new Error(`invalid url "${url}"`);
172
});
173
174
await agent.goto('any url 2');
175
},
176
{ input: { test: 1 } },
177
);
178
179
handler.dispatchAgent(
180
async agent => {
181
await agent.goto(koaServer.baseUrl);
182
agent.output = { result: 1 };
183
},
184
{ input: { test: 1 } },
185
);
186
187
const dispatchResult = await handler.waitForAllDispatchesSettled();
188
expect(dispatchResult).toHaveLength(2);
189
expect(dispatchResult[0].error).toBeTruthy();
190
expect(dispatchResult[0].error.message).toMatch('invalid url');
191
expect(dispatchResult[0].options.input).toStrictEqual({
192
test: 1,
193
});
194
195
expect(dispatchResult[1].error).not.toBeTruthy();
196
expect(dispatchResult[1].output).toStrictEqual({ result: 1 });
197
});
198
});
199
200
describe('connectionToCore', () => {
201
it('handles disconnects from killed core server', async () => {
202
const coreHost = await CoreProcess.spawn({});
203
Helpers.onClose(() => CoreProcess.kill('SIGINT'));
204
const connection = new RemoteConnectionToCore({
205
maxConcurrency: 2,
206
host: coreHost,
207
});
208
await connection.connect();
209
210
const handler = new Handler(connection);
211
Helpers.needsClosing.push(handler);
212
213
const waitForGoto = createPromise();
214
const dispatchErrorPromise = createPromise<Error>();
215
handler.dispatchAgent(async agent => {
216
try {
217
await agent.goto(koaServer.baseUrl);
218
const promise = agent.waitForMillis(10e3);
219
await new Promise(resolve => setTimeout(resolve, 50));
220
waitForGoto.resolve();
221
await promise;
222
} catch (error) {
223
dispatchErrorPromise.resolve(error);
224
throw error;
225
}
226
});
227
await waitForGoto.promise;
228
await CoreProcess.kill('SIGINT');
229
await new Promise(setImmediate);
230
await expect(dispatchErrorPromise).resolves.toBeTruthy();
231
const dispatchError = await dispatchErrorPromise;
232
expect(dispatchError).toBeInstanceOf(DisconnectedFromCoreError);
233
expect((dispatchError as DisconnectedFromCoreError).coreHost).toBe(coreHost);
234
await expect(handler.waitForAllDispatches()).rejects.toThrowError(DisconnectedFromCoreError);
235
});
236
237
it('handles disconnects from client', async () => {
238
const coreHost = await CoreProcess.spawn({});
239
Helpers.onClose(() => CoreProcess.kill('SIGINT'));
240
const connection = new RemoteConnectionToCore({
241
maxConcurrency: 2,
242
host: coreHost,
243
});
244
await connection.connect();
245
246
const handler = new Handler(connection);
247
Helpers.needsClosing.push(handler);
248
249
const waitForGoto = createPromise();
250
const dispatchErrorPromise = createPromise<Error>();
251
handler.dispatchAgent(async agent => {
252
try {
253
await agent.goto(koaServer.baseUrl);
254
const promise = agent.waitForMillis(10e3);
255
await new Promise(resolve => setTimeout(resolve, 50));
256
waitForGoto.resolve();
257
await promise;
258
} catch (error) {
259
dispatchErrorPromise.resolve(error);
260
throw error;
261
}
262
});
263
await waitForGoto.promise;
264
await connection.disconnect();
265
await new Promise(setImmediate);
266
await expect(dispatchErrorPromise).resolves.toBeTruthy();
267
const dispatchError = await dispatchErrorPromise;
268
expect(dispatchError).toBeInstanceOf(DisconnectedFromCoreError);
269
expect((dispatchError as DisconnectedFromCoreError).coreHost).toBe(coreHost);
270
await expect(handler.waitForAllDispatches()).rejects.toThrowError(DisconnectedFromCoreError);
271
});
272
273
it('handles core server ending websocket (econnreset)', async () => {
274
const coreHost = await Core.server.address;
275
// @ts-ignore
276
const sockets = new Set(Core.server.sockets);
277
278
const connection = new RemoteConnectionToCore({
279
maxConcurrency: 2,
280
host: coreHost,
281
});
282
await connection.connect();
283
// @ts-ignore
284
const newSockets = [...Core.server.sockets];
285
286
const socket = newSockets.find(x => !sockets.has(x));
287
288
const handler = new Handler(connection);
289
Helpers.needsClosing.push(handler);
290
291
const waitForGoto = createPromise();
292
let dispatchError: Error = null;
293
handler.dispatchAgent(async agent => {
294
try {
295
await agent.goto(koaServer.baseUrl);
296
const promise = agent.waitForMillis(10e3);
297
waitForGoto.resolve();
298
await promise;
299
} catch (error) {
300
dispatchError = error;
301
throw error;
302
}
303
});
304
305
await waitForGoto.promise;
306
socket.destroy();
307
await expect(handler.waitForAllDispatches()).rejects.toThrowError(DisconnectedFromCoreError);
308
await new Promise(setImmediate);
309
expect(dispatchError).toBeTruthy();
310
expect(dispatchError).toBeInstanceOf(DisconnectedFromCoreError);
311
expect((dispatchError as DisconnectedFromCoreError).coreHost).toBe(coreHost);
312
});
313
314
it('can close without waiting for dispatches', async () => {
315
const spawnedCoreHost = await CoreProcess.spawn({});
316
Helpers.onClose(() => CoreProcess.kill('SIGINT'));
317
const coreHost = await Core.server.address;
318
319
const localConn = new RemoteConnectionToCore({ host: coreHost, maxConcurrency: 2 });
320
const spawnedConn = new RemoteConnectionToCore({ host: spawnedCoreHost, maxConcurrency: 2 });
321
await localConn.connect();
322
await spawnedConn.connect();
323
const handler = new Handler(localConn, spawnedConn);
324
Helpers.needsClosing.push(handler);
325
326
let spawnedConnections = 0;
327
let localConnections = 0;
328
329
const waits: Promise<any>[] = [];
330
const waitForAgent = async (agent: Agent) => {
331
await agent.goto(koaServer.baseUrl);
332
const host = await agent.coreHost;
333
if (host === spawnedCoreHost) spawnedConnections += 1;
334
else localConnections += 1;
335
336
// don't wait
337
const promise = agent.waitForMillis(10e3);
338
agent.input.resolve();
339
await expect(promise).rejects.toThrowError('Disconnected');
340
};
341
for (let i = 0; i < 4; i += 1) {
342
const waitForGoto = createPromise();
343
waits.push(waitForGoto.promise);
344
handler.dispatchAgent(waitForAgent, { input: waitForGoto });
345
}
346
347
await Promise.all(waits);
348
expect(spawnedConnections).toBe(2);
349
expect(localConnections).toBe(2);
350
351
// kill off one of the cores
352
await CoreProcess.kill('SIGINT');
353
// should still be able to close
354
await expect(handler.close()).resolves.toBeUndefined();
355
});
356
357
it('can add and remove connections', async () => {
358
const coreHost = await Core.server.address;
359
360
const connection = new RemoteConnectionToCore({
361
maxConcurrency: 2,
362
host: coreHost,
363
});
364
await connection.connect();
365
366
const handler = new Handler(connection);
367
Helpers.needsClosing.push(handler);
368
369
expect(await handler.coreHosts).toHaveLength(1);
370
371
const spawnedCoreHost = await CoreProcess.spawn({});
372
Helpers.onClose(() => CoreProcess.kill('SIGINT'));
373
await expect(handler.addConnectionToCore({ host: spawnedCoreHost })).resolves.toBeUndefined();
374
375
expect(await handler.coreHosts).toHaveLength(2);
376
377
const disconnectSpy = jest.spyOn(connection, 'disconnect');
378
379
await handler.removeConnectionToCore(String(await connection.hostOrError));
380
381
expect(disconnectSpy).toHaveBeenCalledTimes(1);
382
383
expect(await handler.coreHosts).toHaveLength(1);
384
385
await handler.close();
386
});
387
388
it('can re-queue dispatched agents that never started', async () => {
389
const coreHost = await CoreProcess.spawn({});
390
Helpers.onClose(() => CoreProcess.kill('SIGINT'));
391
const connection1 = new RemoteConnectionToCore({
392
maxConcurrency: 1,
393
host: coreHost,
394
});
395
await connection1.connect();
396
397
const handler = new Handler(connection1);
398
Helpers.needsClosing.push(handler);
399
400
const waitForGoto = createPromise();
401
const dispatchErrorPromise = createPromise<Error>();
402
handler.dispatchAgent(async agent => {
403
try {
404
await agent.goto(koaServer.baseUrl);
405
// create a command we can disconnect from (don't await yet)
406
const promise = agent.waitForMillis(5e3);
407
await new Promise(resolve => setTimeout(resolve, 50));
408
waitForGoto.resolve();
409
await promise;
410
} catch (error) {
411
dispatchErrorPromise.resolve(error);
412
throw error;
413
}
414
});
415
416
let counter = 0;
417
const incr = async agent => {
418
await agent.goto(koaServer.baseUrl);
419
counter += 1;
420
};
421
handler.dispatchAgent(incr);
422
handler.dispatchAgent(incr);
423
424
// first 2 will be queued against the first connection
425
const coreHost2 = await Core.server.address;
426
await handler.addConnectionToCore({ maxConcurrency: 2, host: coreHost2 });
427
handler.dispatchAgent(incr);
428
handler.dispatchAgent(incr);
429
await waitForGoto.promise;
430
431
// disconnect the first connection. the first two handlers should get re-queued
432
await connection1.disconnect();
433
await new Promise(setImmediate);
434
435
// should have an error thrown if it actually the process. this one should NOT get re-queued
436
await expect(dispatchErrorPromise).resolves.toBeTruthy();
437
const dispatchError = await dispatchErrorPromise;
438
expect(dispatchError).toBeInstanceOf(DisconnectedFromCoreError);
439
440
const allDispatches = await handler.waitForAllDispatchesSettled();
441
442
expect(counter).toBe(4);
443
expect(Object.keys(allDispatches)).toHaveLength(5);
444
expect(Object.values(allDispatches).filter(x => !!x.error)).toHaveLength(1);
445
});
446
});
447
448