@appium/base-driver 8.4.0 → 8.5.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.
- package/build/lib/basedriver/commands/log.d.ts.map +1 -1
- package/build/lib/basedriver/commands/log.js +8 -6
- package/build/lib/basedriver/core.d.ts +3 -3
- package/build/lib/basedriver/core.js +1 -1
- package/build/lib/basedriver/device-settings.d.ts +7 -4
- package/build/lib/basedriver/device-settings.d.ts.map +1 -1
- package/build/lib/basedriver/device-settings.js +4 -9
- package/build/lib/basedriver/helpers.js +3 -3
- package/build/lib/express/idempotency.js +4 -4
- package/build/lib/express/websocket.d.ts.map +1 -1
- package/build/lib/express/websocket.js +39 -38
- package/build/lib/jsonwp-proxy/proxy.d.ts.map +1 -1
- package/build/lib/jsonwp-proxy/proxy.js +24 -6
- package/build/lib/protocol/routes.d.ts.map +1 -1
- package/build/lib/protocol/routes.js +9 -1
- package/build/test/basedriver/driver-e2e-tests.js +4 -4
- package/build/test/e2e/basedriver/websockets.e2e.spec.js +12 -7
- package/build/test/unit/basedriver/device-settings.spec.js +97 -0
- package/build/test/unit/protocol/routes.spec.js +2 -2
- package/build/tsconfig.tsbuildinfo +1 -1
- package/lib/basedriver/commands/log.js +8 -13
- package/lib/basedriver/core.js +1 -1
- package/lib/basedriver/device-settings.js +7 -12
- package/lib/basedriver/helpers.js +2 -2
- package/lib/express/idempotency.js +3 -3
- package/lib/express/websocket.js +35 -32
- package/lib/jsonwp-proxy/proxy.js +26 -6
- package/lib/protocol/routes.js +3 -0
- package/package.json +11 -9
- package/test/basedriver/driver-e2e-tests.js +3 -4
|
@@ -13,11 +13,12 @@ export function LogMixin (Base) {
|
|
|
13
13
|
* @implements {ILogCommands}
|
|
14
14
|
*/
|
|
15
15
|
class LogCommands extends Base {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
|
|
17
|
+
constructor (...args) {
|
|
18
|
+
super(...args);
|
|
19
|
+
/** @type {Record<string, LogType<Driver>>} */
|
|
20
|
+
this.supportedLogTypes = this.supportedLogTypes ?? {};
|
|
21
|
+
}
|
|
21
22
|
|
|
22
23
|
async getLogTypes () {
|
|
23
24
|
this.log.debug('Retrieving supported log types');
|
|
@@ -26,19 +27,13 @@ export function LogMixin (Base) {
|
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* @this {Driver}
|
|
30
|
+
* @param {string} logType
|
|
29
31
|
*/
|
|
30
32
|
async getLog (logType) {
|
|
31
33
|
this.log.debug(`Retrieving '${logType}' logs`);
|
|
32
34
|
|
|
33
35
|
if (!(await this.getLogTypes()).includes(logType)) {
|
|
34
|
-
const logsTypesWithDescriptions = _.
|
|
35
|
-
this.supportedLogTypes,
|
|
36
|
-
(acc, value, key) => {
|
|
37
|
-
acc[key] = value.description;
|
|
38
|
-
return acc;
|
|
39
|
-
},
|
|
40
|
-
{},
|
|
41
|
-
);
|
|
36
|
+
const logsTypesWithDescriptions = _.mapValues(this.supportedLogTypes, 'description');
|
|
42
37
|
throw new Error(
|
|
43
38
|
`Unsupported log type '${logType}'. ` +
|
|
44
39
|
`Supported types: ${JSON.stringify(logsTypesWithDescriptions)}`,
|
package/lib/basedriver/core.js
CHANGED
|
@@ -491,7 +491,7 @@ export {DriverCore};
|
|
|
491
491
|
* @typedef {import('@appium/types').W3CCapabilities} W3CCapabilities
|
|
492
492
|
* @typedef {import('@appium/types').Driver} Driver
|
|
493
493
|
* @typedef {import('@appium/types').Core} Core
|
|
494
|
-
* @typedef {import('@appium/types').
|
|
494
|
+
* @typedef {import('@appium/types').ServerArgs} DriverOpts
|
|
495
495
|
* @typedef {import('@appium/types').EventHistory} EventHistory
|
|
496
496
|
* @typedef {import('@appium/types').AppiumLogger} AppiumLogger
|
|
497
497
|
*/
|
|
@@ -5,7 +5,10 @@ import log from './logger';
|
|
|
5
5
|
import { node, util } from '@appium/support';
|
|
6
6
|
import { errors } from '../protocol/errors';
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Maximum size (in bytes) of a given driver's settings object (which is internal to {@linkcode DriverSettings}).
|
|
10
|
+
*/
|
|
11
|
+
export const MAX_SETTINGS_SIZE = 20 * 1024 * 1024; // 20 MB
|
|
9
12
|
|
|
10
13
|
/**
|
|
11
14
|
* @template {Record<string,unknown>} T
|
|
@@ -21,19 +24,18 @@ class DeviceSettings {
|
|
|
21
24
|
|
|
22
25
|
/**
|
|
23
26
|
* @protected
|
|
24
|
-
* @type {import('@appium/types').SettingsUpdateListener<T
|
|
27
|
+
* @type {import('@appium/types').SettingsUpdateListener<T>}
|
|
25
28
|
*/
|
|
26
29
|
_onSettingsUpdate;
|
|
27
30
|
|
|
28
31
|
/**
|
|
29
|
-
*
|
|
30
|
-
* an error will occur at runtime.
|
|
32
|
+
* Creates a _shallow copy_ of the `defaultSettings` parameter!
|
|
31
33
|
* @param {T} [defaultSettings]
|
|
32
34
|
* @param {import('@appium/types').SettingsUpdateListener<T>} [onSettingsUpdate]
|
|
33
35
|
*/
|
|
34
36
|
constructor (defaultSettings, onSettingsUpdate) {
|
|
35
37
|
this._settings = /** @type {T} */({...(defaultSettings ?? {})});
|
|
36
|
-
this._onSettingsUpdate = onSettingsUpdate;
|
|
38
|
+
this._onSettingsUpdate = onSettingsUpdate ?? (async () => {});
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
/**
|
|
@@ -51,12 +53,6 @@ class DeviceSettings {
|
|
|
51
53
|
`object size exceeds the allowed limit of ${util.toReadableSizeString(MAX_SETTINGS_SIZE)}`);
|
|
52
54
|
}
|
|
53
55
|
|
|
54
|
-
if (!_.isFunction(this._onSettingsUpdate)) {
|
|
55
|
-
log.errorAndThrow(`Unable to update settings; ` +
|
|
56
|
-
`onSettingsUpdate method not found on '${this.constructor.name}'`);
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
56
|
const props = /** @type {(keyof T & string)[]} */(_.keys(newSettings));
|
|
61
57
|
for (const prop of props) {
|
|
62
58
|
if (!_.isUndefined(this._settings[prop])) {
|
|
@@ -65,7 +61,6 @@ class DeviceSettings {
|
|
|
65
61
|
continue;
|
|
66
62
|
}
|
|
67
63
|
}
|
|
68
|
-
// update setting only when there is updateSettings defined.
|
|
69
64
|
await this._onSettingsUpdate(prop, newSettings[prop], this._settings[prop]);
|
|
70
65
|
this._settings[prop] = newSettings[prop];
|
|
71
66
|
}
|
|
@@ -262,7 +262,7 @@ async function configureApp (app, options = {}) {
|
|
|
262
262
|
}
|
|
263
263
|
logger.info(`The application at '${cachedPath}' does not exist anymore ` +
|
|
264
264
|
`or its integrity has been damaged. Deleting it from the internal cache`);
|
|
265
|
-
APPLICATIONS_CACHE.
|
|
265
|
+
APPLICATIONS_CACHE.delete(app);
|
|
266
266
|
}
|
|
267
267
|
|
|
268
268
|
let fileName = null;
|
|
@@ -347,7 +347,7 @@ async function configureApp (app, options = {}) {
|
|
|
347
347
|
}
|
|
348
348
|
logger.info(`The application at '${fullPath}' does not exist anymore ` +
|
|
349
349
|
`or its integrity has been damaged. Deleting it from the cache`);
|
|
350
|
-
APPLICATIONS_CACHE.
|
|
350
|
+
APPLICATIONS_CACHE.delete(app);
|
|
351
351
|
}
|
|
352
352
|
const tmpRoot = await tempDir.openDir();
|
|
353
353
|
try {
|
|
@@ -78,14 +78,14 @@ function cacheResponse (key, req, res) {
|
|
|
78
78
|
}
|
|
79
79
|
if (writeError) {
|
|
80
80
|
log.info(`Could not cache the response identified by '${key}': ${writeError.message}`);
|
|
81
|
-
IDEMPOTENT_RESPONSES.
|
|
81
|
+
IDEMPOTENT_RESPONSES.delete(key);
|
|
82
82
|
return responseStateListener.emit('ready', null);
|
|
83
83
|
}
|
|
84
84
|
if (!isResponseFullySent) {
|
|
85
85
|
log.info(`Could not cache the response identified by '${key}', ` +
|
|
86
86
|
`because it has not been completed`);
|
|
87
87
|
log.info('Does the client terminate connections too early?');
|
|
88
|
-
IDEMPOTENT_RESPONSES.
|
|
88
|
+
IDEMPOTENT_RESPONSES.delete(key);
|
|
89
89
|
return responseStateListener.emit('ready', null);
|
|
90
90
|
}
|
|
91
91
|
|
|
@@ -125,7 +125,7 @@ async function handleIdempotency (req, res, next) {
|
|
|
125
125
|
|
|
126
126
|
const rerouteCachedResponse = async (cachedResPath) => {
|
|
127
127
|
if (!await fs.exists(cachedResPath)) {
|
|
128
|
-
IDEMPOTENT_RESPONSES.
|
|
128
|
+
IDEMPOTENT_RESPONSES.delete(key);
|
|
129
129
|
log.warn(`Could not read the cached response identified by key '${key}'`);
|
|
130
130
|
log.warn('The temporary storage is not accessible anymore');
|
|
131
131
|
return next();
|
package/lib/express/websocket.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import _ from 'lodash';
|
|
2
|
-
import
|
|
2
|
+
import { URL } from 'url';
|
|
3
|
+
import B from 'bluebird';
|
|
3
4
|
|
|
4
5
|
const DEFAULT_WS_PATHNAME_PREFIX = '/ws';
|
|
5
6
|
|
|
@@ -18,29 +19,28 @@ const DEFAULT_WS_PATHNAME_PREFIX = '/ws';
|
|
|
18
19
|
* on how to configure the handler properly.
|
|
19
20
|
*/
|
|
20
21
|
async function addWebSocketHandler (handlerPathname, handlerServer) { // eslint-disable-line require-await
|
|
21
|
-
let isUpgradeListenerAssigned = true;
|
|
22
22
|
if (_.isUndefined(this.webSocketsMapping)) {
|
|
23
23
|
this.webSocketsMapping = {};
|
|
24
|
-
|
|
24
|
+
// https://github.com/websockets/ws/pull/885
|
|
25
|
+
this.on('upgrade', (request, socket, head) => {
|
|
26
|
+
let currentPathname;
|
|
27
|
+
try {
|
|
28
|
+
currentPathname = (new URL(request.url)).pathname;
|
|
29
|
+
} catch (ign) {
|
|
30
|
+
currentPathname = request.url;
|
|
31
|
+
}
|
|
32
|
+
for (const [pathname, wsServer] of _.toPairs(this.webSocketsMapping)) {
|
|
33
|
+
if (currentPathname === pathname) {
|
|
34
|
+
wsServer.handleUpgrade(request, socket, head, (ws) => {
|
|
35
|
+
wsServer.emit('connection', ws, request);
|
|
36
|
+
});
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
socket.destroy();
|
|
41
|
+
});
|
|
25
42
|
}
|
|
26
43
|
this.webSocketsMapping[handlerPathname] = handlerServer;
|
|
27
|
-
if (isUpgradeListenerAssigned) {
|
|
28
|
-
return;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
// https://github.com/websockets/ws/pull/885
|
|
32
|
-
this.on('upgrade', (request, socket, head) => {
|
|
33
|
-
const currentPathname = url.parse(request.url).pathname;
|
|
34
|
-
for (const [pathname, wsServer] of _.toPairs(this.webSocketsMapping)) {
|
|
35
|
-
if (currentPathname === pathname) {
|
|
36
|
-
wsServer.handleUpgrade(request, socket, head, (ws) => {
|
|
37
|
-
wsServer.emit('connection', ws, request);
|
|
38
|
-
});
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
socket.destroy();
|
|
43
|
-
});
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
@@ -59,13 +59,12 @@ async function getWebSocketHandlers (keysFilter = null) { // eslint-disable-line
|
|
|
59
59
|
return {};
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
for (const [pathname, wsServer] of _.toPairs(this.webSocketsMapping)) {
|
|
62
|
+
return _.toPairs(this.webSocketsMapping).reduce((acc, [pathname, wsServer]) => {
|
|
64
63
|
if (!_.isString(keysFilter) || pathname.includes(keysFilter)) {
|
|
65
|
-
|
|
64
|
+
acc[pathname] = wsServer;
|
|
66
65
|
}
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
return acc;
|
|
67
|
+
}, {});
|
|
69
68
|
}
|
|
70
69
|
|
|
71
70
|
/**
|
|
@@ -79,12 +78,16 @@ async function getWebSocketHandlers (keysFilter = null) { // eslint-disable-line
|
|
|
79
78
|
* @returns {boolean} true if the handlerPathname was found and deleted
|
|
80
79
|
*/
|
|
81
80
|
async function removeWebSocketHandler (handlerPathname) { // eslint-disable-line require-await
|
|
82
|
-
|
|
81
|
+
const wsServer = this.webSocketsMapping?.[handlerPathname];
|
|
82
|
+
if (!wsServer) {
|
|
83
83
|
return false;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
try {
|
|
87
|
-
|
|
87
|
+
wsServer.close();
|
|
88
|
+
for (const client of (wsServer.clients || [])) {
|
|
89
|
+
client.terminate();
|
|
90
|
+
}
|
|
88
91
|
return true;
|
|
89
92
|
} catch (ign) {
|
|
90
93
|
// ignore
|
|
@@ -106,11 +109,11 @@ async function removeAllWebSocketHandlers () {
|
|
|
106
109
|
return false;
|
|
107
110
|
}
|
|
108
111
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
return _.some(
|
|
113
|
+
await B.all(
|
|
114
|
+
_.keys(this.webSocketsMapping).map((pathname) => this.removeWebSocketHandler(pathname))
|
|
115
|
+
)
|
|
116
|
+
);
|
|
114
117
|
}
|
|
115
118
|
|
|
116
119
|
export {
|
|
@@ -3,7 +3,8 @@ import { logger, util } from '@appium/support';
|
|
|
3
3
|
import axios from 'axios';
|
|
4
4
|
import { getSummaryByCode } from '../jsonwp-status/status';
|
|
5
5
|
import {
|
|
6
|
-
errors, isErrorType, errorFromMJSONWPStatusCode, errorFromW3CJsonCode
|
|
6
|
+
errors, isErrorType, errorFromMJSONWPStatusCode, errorFromW3CJsonCode,
|
|
7
|
+
getResponseForW3CError,
|
|
7
8
|
} from '../protocol/errors';
|
|
8
9
|
import { routeToCommandName } from '../protocol';
|
|
9
10
|
import { MAX_LOG_BODY_LENGTH, DEFAULT_BASE_PATH, PROTOCOLS } from '../constants';
|
|
@@ -312,15 +313,34 @@ class JWProxy {
|
|
|
312
313
|
}
|
|
313
314
|
|
|
314
315
|
async proxyReqRes (req, res) {
|
|
315
|
-
|
|
316
|
+
// ! this method must not throw any exceptions
|
|
317
|
+
// ! make sure to call res.send before return
|
|
318
|
+
let statusCode;
|
|
319
|
+
let resBodyObj;
|
|
320
|
+
try {
|
|
321
|
+
let response;
|
|
322
|
+
[response, resBodyObj] = await this.proxyCommand(req.originalUrl, req.method, req.body);
|
|
323
|
+
res.headers = response.headers;
|
|
324
|
+
statusCode = response.statusCode;
|
|
325
|
+
} catch (err) {
|
|
326
|
+
[statusCode, resBodyObj] = getResponseForW3CError(
|
|
327
|
+
isErrorType(err, errors.ProxyRequestError) ? err.getActualError() : err
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
res.set('content-type', 'application/json; charset=utf-8');
|
|
331
|
+
if (!_.isPlainObject(resBodyObj)) {
|
|
332
|
+
const error = new errors.UnknownError(
|
|
333
|
+
`The downstream server response with the status code ${statusCode} is not a valid JSON object: ` +
|
|
334
|
+
_.truncate(`${resBodyObj}`, {length: 300})
|
|
335
|
+
);
|
|
336
|
+
[statusCode, resBodyObj] = getResponseForW3CError(error);
|
|
337
|
+
}
|
|
316
338
|
|
|
317
|
-
res.headers = response.headers;
|
|
318
|
-
res.set('content-type', response.headers['content-type']);
|
|
319
339
|
// if the proxied response contains a sessionId that the downstream
|
|
320
340
|
// driver has generated, we don't want to return that to the client.
|
|
321
341
|
// Instead, return the id from the request or from current session
|
|
322
|
-
const reqSessionId = this.getSessionIdFromUrl(req.originalUrl);
|
|
323
342
|
if (_.has(resBodyObj, 'sessionId')) {
|
|
343
|
+
const reqSessionId = this.getSessionIdFromUrl(req.originalUrl);
|
|
324
344
|
if (reqSessionId) {
|
|
325
345
|
this.log.info(`Replacing sessionId ${resBodyObj.sessionId} with ${reqSessionId}`);
|
|
326
346
|
resBodyObj.sessionId = reqSessionId;
|
|
@@ -330,7 +350,7 @@ class JWProxy {
|
|
|
330
350
|
}
|
|
331
351
|
}
|
|
332
352
|
resBodyObj.value = formatResponseValue(resBodyObj.value);
|
|
333
|
-
res.status(
|
|
353
|
+
res.status(statusCode).send(JSON.stringify(formatStatus(resBodyObj)));
|
|
334
354
|
}
|
|
335
355
|
}
|
|
336
356
|
|
package/lib/protocol/routes.js
CHANGED
|
@@ -690,6 +690,9 @@ const METHOD_MAP = {
|
|
|
690
690
|
'/session/:sessionId/window/fullscreen': {
|
|
691
691
|
POST: {command: 'fullScreenWindow'}
|
|
692
692
|
},
|
|
693
|
+
'/session/:sessionId/window/new': {
|
|
694
|
+
POST: {command: 'createNewWindow', payloadParams: {optional: ['type']}}
|
|
695
|
+
},
|
|
693
696
|
'/session/:sessionId/element/:elementId/property/:name': {
|
|
694
697
|
GET: {command: 'getProperty'}
|
|
695
698
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appium/base-driver",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.5.0",
|
|
4
4
|
"description": "Base driver class for Appium drivers",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"automation",
|
|
@@ -37,18 +37,18 @@
|
|
|
37
37
|
"!build/test/e2e/fixtures"
|
|
38
38
|
],
|
|
39
39
|
"scripts": {
|
|
40
|
-
"build": "
|
|
40
|
+
"build": "run-s build:*",
|
|
41
41
|
"build:distfiles": "babel lib --root-mode=upward --out-dir=build/lib",
|
|
42
42
|
"build:test": "babel test --root-mode=upward --out-dir=build/test --copy-files",
|
|
43
43
|
"dev": "run-p \"build:distfiles -- --watch\" \"build:test -- --watch\"",
|
|
44
44
|
"fix": "npm run lint -- --fix",
|
|
45
45
|
"lint": "eslint -c ../../.eslintrc --ignore-path ../../.eslintignore .",
|
|
46
46
|
"test": "npm run test:unit",
|
|
47
|
-
"test:e2e": "mocha --
|
|
48
|
-
"test:unit": "mocha
|
|
47
|
+
"test:e2e": "mocha --timeout 20s --slow 10s \"./test/e2e/**/*.spec.js\"",
|
|
48
|
+
"test:unit": "mocha \"./test/unit/**/*.spec.js\""
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@appium/support": "^2.57.
|
|
51
|
+
"@appium/support": "^2.57.2",
|
|
52
52
|
"@babel/runtime": "7.17.9",
|
|
53
53
|
"@colors/colors": "1.5.0",
|
|
54
54
|
"async-lock": "1.3.1",
|
|
@@ -60,13 +60,15 @@
|
|
|
60
60
|
"express": "4.17.3",
|
|
61
61
|
"http-status-codes": "2.2.0",
|
|
62
62
|
"lodash": "4.17.21",
|
|
63
|
-
"lru-cache": "7.
|
|
63
|
+
"lru-cache": "7.8.1",
|
|
64
64
|
"method-override": "3.0.0",
|
|
65
65
|
"morgan": "1.10.0",
|
|
66
66
|
"serve-favicon": "2.5.0",
|
|
67
67
|
"source-map-support": "0.5.21",
|
|
68
|
-
"validate.js": "0.13.1"
|
|
69
|
-
|
|
68
|
+
"validate.js": "0.13.1"
|
|
69
|
+
},
|
|
70
|
+
"devDependencies": {
|
|
71
|
+
"ws": "8.5.0"
|
|
70
72
|
},
|
|
71
73
|
"engines": {
|
|
72
74
|
"node": ">=12",
|
|
@@ -76,5 +78,5 @@
|
|
|
76
78
|
"access": "public"
|
|
77
79
|
},
|
|
78
80
|
"types": "./build/lib/index.d.ts",
|
|
79
|
-
"gitHead": "
|
|
81
|
+
"gitHead": "8b7906f757f23b7abc09a0acf41a3daa6671c2ec"
|
|
80
82
|
}
|
|
@@ -279,11 +279,10 @@ function baseDriverE2ETests (DriverClass, defaultCaps = {}) {
|
|
|
279
279
|
it('should be able to get settings object', function () {
|
|
280
280
|
d.settings.getSettings().ignoreUnimportantViews.should.be.false;
|
|
281
281
|
});
|
|
282
|
-
it('should
|
|
283
|
-
await d.settings.update({ignoreUnimportantViews: true}).should.
|
|
284
|
-
.be.rejectedWith('onSettingsUpdate');
|
|
282
|
+
it('should not reject when `updateSettings` method is not provided', async function () {
|
|
283
|
+
await d.settings.update({ignoreUnimportantViews: true}).should.not.be.rejected;
|
|
285
284
|
});
|
|
286
|
-
it('should
|
|
285
|
+
it('should reject for invalid update object', async function () {
|
|
287
286
|
await d.settings.update('invalid json').should.eventually
|
|
288
287
|
.be.rejectedWith('JSON');
|
|
289
288
|
});
|