@appium/test-support 1.3.20 → 1.4.0

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 (49) hide show
  1. package/README.md +8 -8
  2. package/build/lib/driver-e2e-suite.d.ts +77 -0
  3. package/build/lib/driver-e2e-suite.d.ts.map +1 -0
  4. package/build/lib/driver-e2e-suite.js +388 -0
  5. package/build/lib/driver-unit-suite.d.ts +12 -0
  6. package/build/lib/driver-unit-suite.d.ts.map +1 -0
  7. package/build/lib/driver-unit-suite.js +564 -0
  8. package/build/lib/env-utils.d.ts +5 -0
  9. package/build/lib/env-utils.d.ts.map +1 -0
  10. package/build/lib/env-utils.js +3 -6
  11. package/build/lib/helpers.d.ts +19 -0
  12. package/build/lib/helpers.d.ts.map +1 -0
  13. package/build/lib/helpers.js +49 -0
  14. package/build/lib/index.d.ts +12 -0
  15. package/build/lib/index.d.ts.map +1 -0
  16. package/build/lib/index.js +59 -2
  17. package/build/lib/log-utils.d.ts +34 -0
  18. package/build/lib/log-utils.d.ts.map +1 -0
  19. package/build/lib/log-utils.js +4 -8
  20. package/build/lib/logger.d.ts +3 -0
  21. package/build/lib/logger.d.ts.map +1 -0
  22. package/build/lib/logger.js +2 -2
  23. package/build/lib/mock-utils.d.ts +47 -0
  24. package/build/lib/mock-utils.d.ts.map +1 -0
  25. package/build/lib/mock-utils.js +57 -29
  26. package/build/lib/plugin-e2e-harness.d.ts +67 -0
  27. package/build/lib/plugin-e2e-harness.d.ts.map +1 -0
  28. package/build/lib/plugin-e2e-harness.js +144 -0
  29. package/build/lib/sandbox-utils.d.ts +41 -0
  30. package/build/lib/sandbox-utils.d.ts.map +1 -0
  31. package/build/lib/sandbox-utils.js +46 -29
  32. package/build/lib/time-utils.d.ts +9 -0
  33. package/build/lib/time-utils.d.ts.map +1 -0
  34. package/build/lib/time-utils.js +1 -1
  35. package/build/lib/unhandled-rejection.d.ts +2 -0
  36. package/build/lib/unhandled-rejection.d.ts.map +1 -0
  37. package/build/tsconfig.tsbuildinfo +1 -0
  38. package/lib/driver-e2e-suite.js +463 -0
  39. package/lib/driver-unit-suite.js +642 -0
  40. package/lib/env-utils.js +5 -3
  41. package/lib/helpers.js +68 -0
  42. package/lib/index.js +17 -7
  43. package/lib/log-utils.js +25 -8
  44. package/lib/logger.js +1 -1
  45. package/lib/mock-utils.js +89 -25
  46. package/lib/plugin-e2e-harness.js +163 -0
  47. package/lib/sandbox-utils.js +70 -26
  48. package/lib/time-utils.js +1 -0
  49. package/package.json +14 -5
@@ -0,0 +1,463 @@
1
+ import _ from 'lodash';
2
+ import {server, routeConfiguringFunction, DeviceSettings} from 'appium/driver';
3
+ import axios from 'axios';
4
+ import B from 'bluebird';
5
+ import {TEST_HOST, getTestPort, createAppiumURL} from './helpers';
6
+ import chai from 'chai';
7
+ import sinon from 'sinon';
8
+
9
+ const should = chai.should();
10
+
11
+ /**
12
+ * Creates some helper functions for E2E tests to manage sessions.
13
+ * @template [CommandData=unknown]
14
+ * @template [ResponseData=any]
15
+ * @param {number} port - Port on which the server is running. Typically this will be retrieved via `get-port` beforehand
16
+ * @param {string} [address] - Address/host on which the server is running. Defaults to {@linkcode TEST_HOST}
17
+ * @returns {SessionHelpers<CommandData, ResponseData>}
18
+ */
19
+ export function createSessionHelpers(port, address = TEST_HOST) {
20
+ const createAppiumTestURL =
21
+ /** @type {import('lodash').CurriedFunction2<string,string,string>} */ (
22
+ createAppiumURL(address, port)
23
+ );
24
+
25
+ const createSessionURL = createAppiumTestURL(_, '');
26
+ const newSessionURL = createAppiumTestURL('', 'session');
27
+ return /** @type {SessionHelpers<CommandData, ResponseData>} */ ({
28
+ newSessionURL,
29
+ createAppiumTestURL,
30
+ /**
31
+ *
32
+ * @param {string} sessionId
33
+ * @param {string} cmdName
34
+ * @param {any} [data]
35
+ * @param {AxiosRequestConfig} [config]
36
+ * @returns {Promise<any>}
37
+ */
38
+ postCommand: async (sessionId, cmdName, data = {}, config = {}) => {
39
+ const url = createAppiumTestURL(sessionId, cmdName);
40
+ const response = await axios.post(url, data, config);
41
+ return response.data?.value;
42
+ },
43
+ /**
44
+ *
45
+ * @param {string} sessionIdOrCmdName
46
+ * @param {string|AxiosRequestConfig} cmdNameOrConfig
47
+ * @param {AxiosRequestConfig} [config]
48
+ * @returns {Promise<any>}
49
+ */
50
+ getCommand: async (sessionIdOrCmdName, cmdNameOrConfig, config = {}) => {
51
+ if (!_.isString(cmdNameOrConfig)) {
52
+ config = cmdNameOrConfig;
53
+ cmdNameOrConfig = sessionIdOrCmdName;
54
+ sessionIdOrCmdName = '';
55
+ }
56
+ const response = await axios({
57
+ url: createAppiumTestURL(sessionIdOrCmdName, cmdNameOrConfig),
58
+ validateStatus: null,
59
+ ...config,
60
+ });
61
+ return response.data?.value;
62
+ },
63
+ /**
64
+ *
65
+ * @param {NewSessionData} data
66
+ * @param {AxiosRequestConfig} [config]
67
+ */
68
+ startSession: async (data, config = {}) => {
69
+ data = _.defaultsDeep(data, {
70
+ capabilities: {
71
+ alwaysMatch: {},
72
+ firstMatch: [{}],
73
+ },
74
+ });
75
+ const response = await axios.post(newSessionURL, data, config);
76
+ return response.data?.value;
77
+ },
78
+ /**
79
+ *
80
+ * @param {string} sessionId
81
+ */
82
+ endSession: async (sessionId) =>
83
+ await axios.delete(createSessionURL(sessionId), {
84
+ validateStatus: null,
85
+ }),
86
+ /**
87
+ * @param {string} sessionId
88
+ * @returns {Promise<any>}
89
+ */
90
+ getSession: async (sessionId) => {
91
+ const response = await axios({
92
+ url: createSessionURL(sessionId),
93
+ validateStatus: null,
94
+ });
95
+ return response.data?.value;
96
+ },
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Creates E2E test suites for a driver.
102
+ * @template {Driver} P
103
+ * @param {DriverClass<P>} DriverClass
104
+ * @param {AppiumW3CCapabilities} [defaultCaps]
105
+ */
106
+ export function driverE2ETestSuite(DriverClass, defaultCaps = {}) {
107
+ let address = defaultCaps['appium:address'] ?? TEST_HOST;
108
+ let port = defaultCaps['appium:port'];
109
+ const className = DriverClass.name || '(unknown driver)';
110
+
111
+ describe(`BaseDriver E2E (as ${className})`, function () {
112
+ let baseServer;
113
+ /** @type {P} */
114
+ let d;
115
+ /**
116
+ * This URL creates a new session
117
+ * @type {string}
118
+ **/
119
+ let newSessionURL;
120
+
121
+ /** @type {SessionHelpers['startSession']} */
122
+ let startSession;
123
+ /** @type {SessionHelpers['getSession']} */
124
+ let getSession;
125
+ /** @type {SessionHelpers['endSession']} */
126
+ let endSession;
127
+ /** @type {SessionHelpers['getCommand']} */
128
+ let getCommand;
129
+ /** @type {SessionHelpers['postCommand']} */
130
+ let postCommand;
131
+ before(async function () {
132
+ port = port ?? (await getTestPort());
133
+ defaultCaps = {...defaultCaps, 'appium:port': port};
134
+ d = new DriverClass({port, address});
135
+ baseServer = await server({
136
+ routeConfiguringFunction: routeConfiguringFunction(d),
137
+ port,
138
+ hostname: address,
139
+ });
140
+ ({startSession, getSession, endSession, newSessionURL, getCommand, postCommand} =
141
+ createSessionHelpers(port, address));
142
+ });
143
+
144
+ after(async function () {
145
+ await baseServer.close();
146
+ });
147
+
148
+ describe('session handling', function () {
149
+ it('should handle idempotency while creating sessions', async function () {
150
+ const sessionIds = [];
151
+ let times = 0;
152
+ do {
153
+ const {sessionId} = await startSession(
154
+ {
155
+ capabilities: {alwaysMatch: defaultCaps},
156
+ },
157
+ {
158
+ headers: {
159
+ 'X-Idempotency-Key': '123456',
160
+ },
161
+ // XXX: I'm not sure what these are, as they are not documented axios options,
162
+ // nor are they mentioned in our source
163
+ // @ts-expect-error
164
+ simple: false,
165
+ resolveWithFullResponse: true,
166
+ }
167
+ );
168
+
169
+ sessionIds.push(sessionId);
170
+ times++;
171
+ } while (times < 2);
172
+ _.uniq(sessionIds).length.should.equal(1);
173
+
174
+ const {status, data} = await endSession(sessionIds[0]);
175
+ status.should.equal(200);
176
+ should.equal(data.value, null);
177
+ });
178
+
179
+ it('should handle idempotency while creating parallel sessions', async function () {
180
+ const reqs = [];
181
+ let times = 0;
182
+ do {
183
+ reqs.push(
184
+ startSession(
185
+ {
186
+ capabilities: {
187
+ alwaysMatch: defaultCaps,
188
+ },
189
+ },
190
+ {
191
+ headers: {
192
+ 'X-Idempotency-Key': '12345',
193
+ },
194
+ }
195
+ )
196
+ );
197
+ times++;
198
+ } while (times < 2);
199
+ const sessionIds = _.map(await B.all(reqs), 'sessionId');
200
+ _.uniq(sessionIds).length.should.equal(1);
201
+
202
+ const {status, data} = await endSession(sessionIds[0]);
203
+ status.should.equal(200);
204
+ should.equal(data.value, null);
205
+ });
206
+
207
+ it('should create session and retrieve a session id, then delete it', async function () {
208
+ let {status, data} = await axios.post(newSessionURL, {
209
+ capabilities: {
210
+ alwaysMatch: defaultCaps,
211
+ },
212
+ });
213
+
214
+ status.should.equal(200);
215
+ should.exist(data.value.sessionId);
216
+ data.value.capabilities.platformName.should.equal(defaultCaps.platformName);
217
+ data.value.capabilities.deviceName.should.equal(defaultCaps['appium:deviceName']);
218
+
219
+ ({status, data} = await endSession(/** @type {string} */ (d.sessionId)));
220
+
221
+ status.should.equal(200);
222
+ should.equal(data.value, null);
223
+ should.equal(d.sessionId, null);
224
+ });
225
+ });
226
+
227
+ it.skip('should throw NYI for commands not implemented', async function () {});
228
+
229
+ describe('command timeouts', function () {
230
+ let originalFindElement, originalFindElements;
231
+
232
+ /**
233
+ * @param {number} [timeout]
234
+ */
235
+ async function startTimeoutSession(timeout) {
236
+ const caps = _.cloneDeep(defaultCaps);
237
+ caps['appium:newCommandTimeout'] = timeout;
238
+ return await startSession({capabilities: {alwaysMatch: caps}});
239
+ }
240
+
241
+ before(function () {
242
+ originalFindElement = d.findElement;
243
+ d.findElement = function () {
244
+ return 'foo';
245
+ }.bind(d);
246
+
247
+ originalFindElements = d.findElements;
248
+ d.findElements = async function () {
249
+ await B.delay(200);
250
+ return ['foo'];
251
+ }.bind(d);
252
+ });
253
+
254
+ after(function () {
255
+ d.findElement = originalFindElement;
256
+ d.findElements = originalFindElements;
257
+ });
258
+
259
+ it('should set a default commandTimeout', async function () {
260
+ let newSession = await startTimeoutSession();
261
+ d.newCommandTimeoutMs.should.be.above(0);
262
+ await endSession(newSession.sessionId);
263
+ });
264
+
265
+ it('should timeout on commands using commandTimeout cap', async function () {
266
+ let newSession = await startTimeoutSession(0.25);
267
+ const sessionId = /** @type {string} */ (d.sessionId);
268
+ await postCommand(sessionId, 'element', {
269
+ using: 'name',
270
+ value: 'foo',
271
+ });
272
+ await B.delay(400);
273
+ const value = await getSession(sessionId);
274
+ should.equal(value.error, 'invalid session id');
275
+ should.equal(d.sessionId, null);
276
+ const resp = (await endSession(newSession.sessionId)).data.value;
277
+ should.equal(resp?.error, 'invalid session id');
278
+ });
279
+
280
+ it('should not timeout with commandTimeout of false', async function () {
281
+ let newSession = await startTimeoutSession(0.1);
282
+ let start = Date.now();
283
+ const value = await postCommand(/** @type {string} */ (d.sessionId), 'elements', {
284
+ using: 'name',
285
+ value: 'foo',
286
+ });
287
+ (Date.now() - start).should.be.above(150);
288
+ value.should.eql(['foo']);
289
+ await endSession(newSession.sessionId);
290
+ });
291
+
292
+ it('should not timeout with commandTimeout of 0', async function () {
293
+ d.newCommandTimeoutMs = 2;
294
+ let newSession = await startTimeoutSession(0);
295
+
296
+ await postCommand(/** @type {string} */ (d.sessionId), 'element', {
297
+ using: 'name',
298
+ value: 'foo',
299
+ });
300
+ await B.delay(400);
301
+ const value = await getSession(/** @type {string} */ (d.sessionId));
302
+ value.platformName?.should.equal(defaultCaps.platformName);
303
+ const resp = (await endSession(newSession.sessionId)).data.value;
304
+ should.equal(resp, null);
305
+
306
+ d.newCommandTimeoutMs = 60 * 1000;
307
+ });
308
+
309
+ it('should not timeout if its just the command taking awhile', async function () {
310
+ let newSession = await startTimeoutSession(0.25);
311
+ // XXX: race condition: we must build this URL before ...something happens...
312
+ // which causes `d.sessionId` to be missing
313
+ const {sessionId} = d;
314
+
315
+ await postCommand(/** @type {string} */ (d.sessionId), 'element', {
316
+ using: 'name',
317
+ value: 'foo',
318
+ });
319
+ await B.delay(400);
320
+ const value = await getSession(/** @type {string} */ (sessionId));
321
+ value.error.should.equal('invalid session id');
322
+ should.equal(d.sessionId, null);
323
+ const resp = (await endSession(newSession.sessionId)).data.value;
324
+ /** @type {string} */ (/** @type { {error: string} } */ (resp).error).should.equal(
325
+ 'invalid session id'
326
+ );
327
+ });
328
+
329
+ it('should not have a timer running before or after a session', async function () {
330
+ // @ts-expect-error
331
+ should.not.exist(d.noCommandTimer);
332
+ let newSession = await startTimeoutSession(0.25);
333
+ newSession.sessionId.should.equal(d.sessionId);
334
+ // @ts-expect-error
335
+ should.exist(d.noCommandTimer);
336
+ await endSession(newSession.sessionId);
337
+ // @ts-expect-error
338
+ should.not.exist(d.noCommandTimer);
339
+ });
340
+ });
341
+
342
+ describe('settings api', function () {
343
+ before(function () {
344
+ d.settings = new DeviceSettings({ignoreUnimportantViews: false});
345
+ });
346
+ it('should be able to get settings object', function () {
347
+ d.settings.getSettings().ignoreUnimportantViews.should.be.false;
348
+ });
349
+ it('should not reject when `updateSettings` method is not provided', async function () {
350
+ await d.settings.update({ignoreUnimportantViews: true}).should.not.be.rejected;
351
+ });
352
+ it('should reject for invalid update object', async function () {
353
+ await d.settings
354
+ // @ts-expect-error
355
+ .update('invalid json')
356
+ .should.be.rejectedWith('JSON');
357
+ });
358
+ });
359
+
360
+ describe('unexpected exits', function () {
361
+ /** @type {import('sinon').SinonSandbox} */
362
+ let sandbox;
363
+ beforeEach(function () {
364
+ sandbox = sinon.createSandbox();
365
+ });
366
+
367
+ afterEach(function () {
368
+ sandbox.restore();
369
+ });
370
+
371
+ it('should reject a current command when the driver crashes', async function () {
372
+ sandbox.stub(d, 'getStatus').callsFake(async function () {
373
+ await B.delay(5000);
374
+ });
375
+ const reqPromise = getCommand('status', {validateStatus: null});
376
+ // make sure that the request gets to the server before our shutdown
377
+ await B.delay(100);
378
+ const shutdownEventPromise = new B((resolve, reject) => {
379
+ setTimeout(
380
+ () =>
381
+ reject(
382
+ new Error(
383
+ 'onUnexpectedShutdown event is expected to be fired within 5 seconds timeout'
384
+ )
385
+ ),
386
+ 5000
387
+ );
388
+ d.onUnexpectedShutdown(resolve);
389
+ });
390
+ d.startUnexpectedShutdown(new Error('Crashytimes'));
391
+ const value = await reqPromise;
392
+ value.message.should.contain('Crashytimes');
393
+ await shutdownEventPromise;
394
+ });
395
+ });
396
+
397
+ describe('event timings', function () {
398
+ it('should not add timings if not using opt-in cap', async function () {
399
+ const session = await startSession({capabilities: {alwaysMatch: defaultCaps}});
400
+ const res = await getSession(session.sessionId);
401
+ should.not.exist(res.events);
402
+ await endSession(session.sessionId);
403
+ });
404
+ it('should add start session timings', async function () {
405
+ const caps = {...defaultCaps, 'appium:eventTimings': true};
406
+ const session = await startSession({capabilities: {alwaysMatch: caps}});
407
+ const res = await getSession(session.sessionId);
408
+ should.exist(res.events);
409
+ should.exist(res.events?.newSessionRequested);
410
+ should.exist(res.events?.newSessionStarted);
411
+ res.events?.newSessionRequested[0].should.be.a('number');
412
+ res.events?.newSessionStarted[0].should.be.a('number');
413
+ await endSession(session.sessionId);
414
+ });
415
+ });
416
+ });
417
+ }
418
+
419
+ /**
420
+ * A {@linkcode DriverClass}, except using the base {@linkcode Driver} type instead of `ExternalDriver`.
421
+ * This allows the suite to work for `BaseDriver`.
422
+ * @template {Driver} P
423
+ * @typedef {import('@appium/types').DriverClass<P>} DriverClass
424
+ */
425
+
426
+ /**
427
+ * @typedef {import('@appium/types').Capabilities} Capabilities
428
+ * @typedef {import('@appium/types').Driver} Driver
429
+ * @typedef {import('@appium/types').DriverStatic} DriverStatic
430
+ * @typedef {import('@appium/types').AppiumW3CCapabilities} AppiumW3CCapabilities
431
+ * @typedef {import('axios').AxiosRequestConfig} AxiosRequestConfig
432
+ * @typedef {import('@appium/types').SingularSessionData} SingularSessionData
433
+ */
434
+
435
+ /**
436
+ * @template T,D
437
+ * @typedef {import('axios').AxiosResponse<T, D>} AxiosResponse
438
+ */
439
+
440
+ /**
441
+ * @typedef NewSessionData
442
+ * @property {import('type-fest').RequireAtLeastOne<import('@appium/types').W3CCapabilities, 'firstMatch'|'alwaysMatch'>} capabilities
443
+ */
444
+
445
+ /**
446
+ * @typedef NewSessionResponse
447
+ * @property {string} sessionId,
448
+ * @property {import('@appium/types').Capabilities} capabilities
449
+ */
450
+
451
+ /**
452
+ * Some E2E helpers for making requests and managing sessions
453
+ * See {@linkcode createSessionHelpers}
454
+ * @template [CommandData=unknown]
455
+ * @template [ResponseData=any]
456
+ * @typedef SessionHelpers
457
+ * @property {string} newSessionURL - URL to create a new session. Can be used with raw `axios` requests to fully inspect raw response. Mostly, this will not be used.
458
+ * @property {(data: NewSessionData, config?: AxiosRequestConfig) => Promise<NewSessionResponse>} startSession - Begin a session
459
+ * @property {(sessionId: string) => Promise<AxiosResponse<{value: {error?: string}?}, {validateStatus: null}>>} endSession - End a session. _Note: resolves with raw response object_
460
+ * @property {(sessionId: string) => Promise<SingularSessionData>} getSession - Get info about a session
461
+ * @property {(sessionId: string, cmdName: string, data?: CommandData, config?: AxiosRequestConfig) => Promise<ResponseData>} postCommand - Send an arbitrary command via `POST`.
462
+ * @property {(sessionIdOrCmdName: string, cmdNameOrConfig: string|AxiosRequestConfig, config?: AxiosRequestConfig) => Promise<ResponseData>} getCommand - Send an arbitrary command via `GET`. Optional `sessionId`.
463
+ */