@appium/base-driver 10.5.1 → 10.6.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 (130) hide show
  1. package/build/lib/basedriver/capabilities.d.ts.map +1 -1
  2. package/build/lib/basedriver/capabilities.js +45 -45
  3. package/build/lib/basedriver/capabilities.js.map +1 -1
  4. package/build/lib/basedriver/commands/bidi.d.ts.map +1 -1
  5. package/build/lib/basedriver/commands/bidi.js +9 -13
  6. package/build/lib/basedriver/commands/bidi.js.map +1 -1
  7. package/build/lib/basedriver/commands/event.d.ts.map +1 -1
  8. package/build/lib/basedriver/commands/event.js +4 -7
  9. package/build/lib/basedriver/commands/event.js.map +1 -1
  10. package/build/lib/basedriver/commands/execute.js +3 -6
  11. package/build/lib/basedriver/commands/execute.js.map +1 -1
  12. package/build/lib/basedriver/commands/log.d.ts.map +1 -1
  13. package/build/lib/basedriver/commands/log.js +1 -5
  14. package/build/lib/basedriver/commands/log.js.map +1 -1
  15. package/build/lib/basedriver/commands/timeout.d.ts.map +1 -1
  16. package/build/lib/basedriver/commands/timeout.js +5 -9
  17. package/build/lib/basedriver/commands/timeout.js.map +1 -1
  18. package/build/lib/basedriver/core.js +12 -12
  19. package/build/lib/basedriver/core.js.map +1 -1
  20. package/build/lib/basedriver/device-settings.d.ts.map +1 -1
  21. package/build/lib/basedriver/device-settings.js +3 -7
  22. package/build/lib/basedriver/device-settings.js.map +1 -1
  23. package/build/lib/basedriver/driver.d.ts.map +1 -1
  24. package/build/lib/basedriver/driver.js +13 -16
  25. package/build/lib/basedriver/driver.js.map +1 -1
  26. package/build/lib/basedriver/extension-core.d.ts +4 -1
  27. package/build/lib/basedriver/extension-core.d.ts.map +1 -1
  28. package/build/lib/basedriver/extension-core.js +27 -9
  29. package/build/lib/basedriver/extension-core.js.map +1 -1
  30. package/build/lib/basedriver/helpers.d.ts.map +1 -1
  31. package/build/lib/basedriver/helpers.js +28 -30
  32. package/build/lib/basedriver/helpers.js.map +1 -1
  33. package/build/lib/basedriver/ipc.d.ts +36 -0
  34. package/build/lib/basedriver/ipc.d.ts.map +1 -0
  35. package/build/lib/basedriver/ipc.js +155 -0
  36. package/build/lib/basedriver/ipc.js.map +1 -0
  37. package/build/lib/basedriver/validation.js +25 -28
  38. package/build/lib/basedriver/validation.js.map +1 -1
  39. package/build/lib/express/express-logging.d.ts.map +1 -1
  40. package/build/lib/express/express-logging.js +2 -3
  41. package/build/lib/express/express-logging.js.map +1 -1
  42. package/build/lib/express/idempotency.js +3 -6
  43. package/build/lib/express/idempotency.js.map +1 -1
  44. package/build/lib/express/middleware.d.ts.map +1 -1
  45. package/build/lib/express/middleware.js +6 -10
  46. package/build/lib/express/middleware.js.map +1 -1
  47. package/build/lib/express/server.d.ts.map +1 -1
  48. package/build/lib/express/server.js +64 -54
  49. package/build/lib/express/server.js.map +1 -1
  50. package/build/lib/express/static.d.ts.map +1 -1
  51. package/build/lib/express/static.js +14 -7
  52. package/build/lib/express/static.js.map +1 -1
  53. package/build/lib/express/websocket.d.ts.map +1 -1
  54. package/build/lib/express/websocket.js +6 -9
  55. package/build/lib/express/websocket.js.map +1 -1
  56. package/build/lib/helpers/capabilities.d.ts.map +1 -1
  57. package/build/lib/helpers/capabilities.js +14 -17
  58. package/build/lib/helpers/capabilities.js.map +1 -1
  59. package/build/lib/helpers/extension-command-name.js +2 -5
  60. package/build/lib/helpers/extension-command-name.js.map +1 -1
  61. package/build/lib/helpers/levenshtein-match.d.ts.map +1 -1
  62. package/build/lib/helpers/levenshtein-match.js +2 -6
  63. package/build/lib/helpers/levenshtein-match.js.map +1 -1
  64. package/build/lib/index.d.ts +1 -0
  65. package/build/lib/index.d.ts.map +1 -1
  66. package/build/lib/index.js +3 -14
  67. package/build/lib/index.js.map +1 -1
  68. package/build/lib/jsonwp-proxy/protocol-converter.d.ts.map +1 -1
  69. package/build/lib/jsonwp-proxy/protocol-converter.js +13 -17
  70. package/build/lib/jsonwp-proxy/protocol-converter.js.map +1 -1
  71. package/build/lib/jsonwp-proxy/proxy-request.d.ts +2 -2
  72. package/build/lib/jsonwp-proxy/proxy-request.d.ts.map +1 -1
  73. package/build/lib/jsonwp-proxy/proxy-request.js +25 -21
  74. package/build/lib/jsonwp-proxy/proxy-request.js.map +1 -1
  75. package/build/lib/jsonwp-proxy/proxy.d.ts.map +1 -1
  76. package/build/lib/jsonwp-proxy/proxy.js +29 -26
  77. package/build/lib/jsonwp-proxy/proxy.js.map +1 -1
  78. package/build/lib/protocol/errors.d.ts.map +1 -1
  79. package/build/lib/protocol/errors.js +25 -29
  80. package/build/lib/protocol/errors.js.map +1 -1
  81. package/build/lib/protocol/helpers.d.ts.map +1 -1
  82. package/build/lib/protocol/helpers.js +9 -8
  83. package/build/lib/protocol/helpers.js.map +1 -1
  84. package/build/lib/protocol/protocol.d.ts.map +1 -1
  85. package/build/lib/protocol/protocol.js +43 -48
  86. package/build/lib/protocol/protocol.js.map +1 -1
  87. package/build/lib/protocol/routes.d.ts +1 -1
  88. package/build/lib/protocol/routes.d.ts.map +1 -1
  89. package/build/lib/protocol/routes.js +9 -12
  90. package/build/lib/protocol/routes.js.map +1 -1
  91. package/build/lib/protocol/validators.d.ts.map +1 -1
  92. package/build/lib/protocol/validators.js +1 -5
  93. package/build/lib/protocol/validators.js.map +1 -1
  94. package/build/lib/utils.d.ts +16 -0
  95. package/build/lib/utils.d.ts.map +1 -0
  96. package/build/lib/utils.js +71 -0
  97. package/build/lib/utils.js.map +1 -0
  98. package/lib/basedriver/capabilities.ts +60 -55
  99. package/lib/basedriver/commands/bidi.ts +10 -10
  100. package/lib/basedriver/commands/event.ts +11 -10
  101. package/lib/basedriver/commands/execute.ts +3 -3
  102. package/lib/basedriver/commands/log.ts +3 -2
  103. package/lib/basedriver/commands/timeout.ts +5 -6
  104. package/lib/basedriver/core.ts +12 -12
  105. package/lib/basedriver/device-settings.ts +3 -4
  106. package/lib/basedriver/driver.ts +15 -13
  107. package/lib/basedriver/extension-core.ts +33 -7
  108. package/lib/basedriver/helpers.ts +28 -30
  109. package/lib/basedriver/ipc.ts +179 -0
  110. package/lib/basedriver/validation.ts +26 -26
  111. package/lib/express/express-logging.ts +3 -4
  112. package/lib/express/idempotency.ts +3 -3
  113. package/lib/express/middleware.ts +6 -8
  114. package/lib/express/server.ts +67 -61
  115. package/lib/express/static.ts +15 -7
  116. package/lib/express/websocket.ts +8 -10
  117. package/lib/helpers/capabilities.ts +18 -14
  118. package/lib/helpers/extension-command-name.ts +2 -2
  119. package/lib/helpers/levenshtein-match.ts +2 -5
  120. package/lib/index.js +1 -11
  121. package/lib/jsonwp-proxy/protocol-converter.ts +14 -15
  122. package/lib/jsonwp-proxy/proxy-request.ts +26 -26
  123. package/lib/jsonwp-proxy/proxy.ts +36 -37
  124. package/lib/protocol/errors.ts +29 -28
  125. package/lib/protocol/helpers.ts +9 -5
  126. package/lib/protocol/protocol.ts +44 -46
  127. package/lib/protocol/routes.ts +9 -9
  128. package/lib/protocol/validators.ts +1 -3
  129. package/lib/utils.ts +85 -0
  130. package/package.json +7 -9
@@ -17,8 +17,6 @@ import {
17
17
  type SingularSessionData,
18
18
  type SessionCapabilities,
19
19
  } from '@appium/types';
20
- import B from 'bluebird';
21
- import _ from 'lodash';
22
20
  import {fixCaps, isW3cCaps} from '../helpers/capabilities';
23
21
  import {getLevenshteinSuggestion} from '../helpers/levenshtein-match';
24
22
  import {calcSignature} from '../helpers/session';
@@ -27,6 +25,7 @@ import {processCapabilities, validateCaps} from './capabilities';
27
25
  import {DriverCore} from './core';
28
26
  import * as helpers from './helpers';
29
27
  import {resolveExecuteExtensionName} from '../helpers/extension-command-name';
28
+ import {mergePlainObjects} from '../utils';
30
29
 
31
30
  const EVENT_SESSION_INIT = 'newSessionRequested';
32
31
  const EVENT_SESSION_START = 'newSessionStarted';
@@ -69,7 +68,9 @@ export class BaseDriver<
69
68
  * @see {@link https://github.com/appium/appium/issues/new}
70
69
  */
71
70
  protected get _desiredCapConstraints(): Readonly<BaseDriverCapConstraints & C> {
72
- return Object.freeze(_.merge({}, BASE_DESIRED_CAP_CONSTRAINTS, this.desiredCapConstraints));
71
+ return Object.freeze(
72
+ mergePlainObjects({}, BASE_DESIRED_CAP_CONSTRAINTS, this.desiredCapConstraints) as BaseDriverCapConstraints & C
73
+ );
73
74
  }
74
75
 
75
76
  /**
@@ -113,11 +114,11 @@ export class BaseDriver<
113
114
  unexpectedShutdownRejecter?.(e);
114
115
  };
115
116
  try {
116
- return await B.race([
117
+ return await Promise.race([
117
118
  this[cmd](...args),
118
119
  // This promise is needed to monitor if the session has been
119
120
  // shut down unexpectedly while the command was running
120
- new B((resolve, reject) => {
121
+ new Promise((resolve, reject) => {
121
122
  unexpectedShutdownResolver = resolve;
122
123
  unexpectedShutdownRejecter = reject;
123
124
  this.eventEmitter.once(ON_UNEXPECTED_SHUTDOWN_EVENT, onUnexpectedShutdown);
@@ -180,7 +181,7 @@ export class BaseDriver<
180
181
  clarifyCommandName(cmd: string, args: string[]): string {
181
182
  if (cmd === 'execute') {
182
183
  const firstArg = args?.[0];
183
- if (_.isString(firstArg) && firstArg.trim().length > 0) {
184
+ if (typeof firstArg === 'string' && firstArg.trim().length > 0) {
184
185
  return resolveExecuteExtensionName.call(this, firstArg);
185
186
  }
186
187
  }
@@ -257,7 +258,7 @@ export class BaseDriver<
257
258
  await this.createSession(this.originalCaps);
258
259
  } finally {
259
260
  // always restore state.
260
- for (const [key, value] of _.toPairs(currentConfig)) {
261
+ for (const [key, value] of Object.entries(currentConfig)) {
261
262
  this[key] = value;
262
263
  }
263
264
  }
@@ -286,7 +287,7 @@ export class BaseDriver<
286
287
 
287
288
  this.log.debug();
288
289
 
289
- const originalCaps = _.cloneDeep(
290
+ const originalCaps = structuredClone(
290
291
  [w3cCapabilities, w3cCapabilities1, w3cCapabilities2].find(isW3cCaps),
291
292
  );
292
293
  if (!originalCaps) {
@@ -321,7 +322,7 @@ export class BaseDriver<
321
322
  this.sessionCreationTimestampMs = Date.now();
322
323
  this.caps = caps;
323
324
  // merge caps onto opts so we don't need to worry about what's where
324
- this.opts = {..._.cloneDeep(this.initialOpts), ...this.caps};
325
+ this.opts = {...structuredClone(this.initialOpts), ...this.caps};
325
326
 
326
327
  // deal with resets
327
328
  // some people like to do weird things by setting noReset and fullReset
@@ -347,7 +348,7 @@ export class BaseDriver<
347
348
  delete this.opts.app;
348
349
  }
349
350
 
350
- if (!_.isUndefined(this.caps.newCommandTimeout)) {
351
+ if (this.caps.newCommandTimeout !== undefined) {
351
352
  this.newCommandTimeoutMs = (this.caps.newCommandTimeout as number) * 1000;
352
353
  }
353
354
 
@@ -388,7 +389,7 @@ export class BaseDriver<
388
389
  // simple hack to release pending commands if they exist
389
390
  // @ts-expect-error private API
390
391
  const queues = this.commandsQueueGuard.queues;
391
- for (const key of _.keys(queues)) {
392
+ for (const key of Object.keys(queues)) {
392
393
  queues[key] = [];
393
394
  }
394
395
  }
@@ -396,8 +397,9 @@ export class BaseDriver<
396
397
  }
397
398
 
398
399
  logExtraCaps(caps: Capabilities<C>) {
399
- const knownCaps = _.keys(this._desiredCapConstraints);
400
- const extraCaps = _.difference(_.keys(caps), knownCaps);
400
+ const knownCaps = Object.keys(this._desiredCapConstraints);
401
+ const knownCapsSet = new Set(knownCaps);
402
+ const extraCaps = Object.keys(caps).filter((cap) => !knownCapsSet.has(cap));
401
403
  if (extraCaps.length) {
402
404
  this.log.warn(`The following provided capabilities were not recognized by this driver:`);
403
405
  for (const cap of extraCaps) {
@@ -1,9 +1,12 @@
1
- import {logger} from '@appium/support';
1
+ import {logger, util} from '@appium/support';
2
2
  import {EventEmitter} from 'node:events';
3
3
  import type {
4
4
  AppiumLogger,
5
5
  BidiModuleMap,
6
6
  BiDiResultData,
7
+ IAppiumIpc,
8
+ IIpcSubscription,
9
+ IpcData,
7
10
  StringRecord,
8
11
  } from '@appium/types';
9
12
  import {
@@ -11,7 +14,6 @@ import {
11
14
  } from '../constants';
12
15
  import {errors} from '../protocol';
13
16
  import {BIDI_COMMANDS} from '../protocol/bidi-commands';
14
- import _ from 'lodash';
15
17
  import {generateDriverLogPrefix} from './helpers';
16
18
 
17
19
  export class ExtensionCore {
@@ -21,6 +23,7 @@ export class ExtensionCore {
21
23
  // used to handle driver events
22
24
  readonly eventEmitter: NodeJS.EventEmitter;
23
25
  protected _log: AppiumLogger;
26
+ private ipc?: IAppiumIpc;
24
27
 
25
28
 
26
29
  constructor(logPrefix?: string) {
@@ -41,7 +44,7 @@ export class ExtensionCore {
41
44
  }
42
45
 
43
46
  updateBidiCommands(cmds: BidiModuleMap): void {
44
- const overlappingKeys = _.intersection(Object.keys(cmds), Object.keys(this.bidiCommands));
47
+ const overlappingKeys = Object.keys(cmds).filter((key) => key in this.bidiCommands);
45
48
  if (overlappingKeys.length) {
46
49
  this.log.warn(`Overwriting existing bidi modules: ${JSON.stringify(overlappingKeys)}. This may not be intended!`);
47
50
  }
@@ -98,7 +101,7 @@ export class ExtensionCore {
98
101
  const args: any[] = [];
99
102
  if (params?.required?.length) {
100
103
  for (const requiredParam of params.required) {
101
- if (_.isUndefined(bidiParams[requiredParam])) {
104
+ if (bidiParams[requiredParam] === undefined) {
102
105
  throw new errors.InvalidArgumentError(
103
106
  `The ${requiredParam} parameter was required but you omitted it`,
104
107
  );
@@ -111,18 +114,41 @@ export class ExtensionCore {
111
114
  args.push(bidiParams[optionalParam]);
112
115
  }
113
116
  }
114
- const logParams = _.truncate(JSON.stringify(bidiParams), {length: MAX_LOG_BODY_LENGTH});
117
+ const logParams = util.truncateString(JSON.stringify(bidiParams), {length: MAX_LOG_BODY_LENGTH});
115
118
  this.log.debug(
116
119
  `Executing bidi command '${bidiCmd}' with params ${logParams} by passing to ${handlerType} ` +
117
120
  `method '${command}'`,
118
121
  );
119
122
  // call the handler with the signature appropriate to extension type (plugin or driver)
120
123
  const response = (next && driver) ? await this[command](next, driver, ...args) : await this[command](...args);
121
- const finalResponse = _.isUndefined(response) ? {} : response;
124
+ const finalResponse = response === undefined ? {} : response;
122
125
  this.log.debug(
123
126
  `Responding to bidi command '${bidiCmd}' with ` +
124
- `${_.truncate(JSON.stringify(finalResponse), {length: MAX_LOG_BODY_LENGTH})}`
127
+ `${util.truncateString(JSON.stringify(finalResponse), {length: MAX_LOG_BODY_LENGTH})}`
125
128
  );
126
129
  return finalResponse;
127
130
  }
131
+
132
+ /**
133
+ * @internal Used by AppiumDriver to wire session IPC; extension authors should use {@link onIpcInit} instead.
134
+ */
135
+ async assignIpc(ipc: IAppiumIpc): Promise<void> {
136
+ this.ipc = ipc;
137
+ try {
138
+ await this.onIpcInit();
139
+ } catch (e) {
140
+ this.log.error(`Error running onIpcInit: `, e);
141
+ }
142
+ }
143
+
144
+ async onIpcInit(): Promise<void> {}
145
+
146
+ ipcSubscribe<T extends IpcData>(topic: string): IIpcSubscription<T> {
147
+ if (!this.ipc) {
148
+ throw new Error(`Cannot subscribe to an IPC topic without an IPC object assigned. ` +
149
+ `This is likely a programming error. ipcSubscribe should be called in the ` +
150
+ `onIpcInit handler or after you are certain that createSession has completed successfully.`);
151
+ }
152
+ return this.ipc.subscribe<T>(topic, generateDriverLogPrefix(this));
153
+ }
128
154
  }
@@ -1,11 +1,9 @@
1
- import _ from 'lodash';
2
1
  import path from 'node:path';
3
2
  import {log as logger} from './logger';
4
3
  import {tempDir, fs, util, timing, node} from '@appium/support';
5
4
  import {LRUCache} from 'lru-cache';
6
5
  import AsyncLock from 'async-lock';
7
6
  import axios from 'axios';
8
- import B from 'bluebird';
9
7
  import type {
10
8
  ConfigureAppOptions,
11
9
  CachedAppInfo,
@@ -102,27 +100,27 @@ export async function configureApp(
102
100
  app: string,
103
101
  options: string | string[] | ConfigureAppOptions = {} as ConfigureAppOptions
104
102
  ): Promise<string> {
105
- if (!_.isString(app)) {
103
+ if (typeof app !== 'string') {
106
104
  // immediately shortcircuit if not given an app
107
105
  return '';
108
106
  }
109
107
 
110
108
  let supportedAppExtensions: string[];
111
- const opts = !_.isString(options) && !_.isArray(options) ? options : undefined;
109
+ const opts = typeof options !== 'string' && !Array.isArray(options) ? options : undefined;
112
110
  const onPostProcess = opts?.onPostProcess;
113
111
  const onDownload = opts?.onDownload;
114
112
 
115
- if (_.isString(options)) {
113
+ if (typeof options === 'string') {
116
114
  supportedAppExtensions = [options];
117
- } else if (_.isArray(options)) {
115
+ } else if (Array.isArray(options)) {
118
116
  supportedAppExtensions = options;
119
- } else if (_.isPlainObject(options)) {
117
+ } else if (util.isPlainObject(options)) {
120
118
  supportedAppExtensions = options.supportedExtensions ?? [];
121
119
  } else {
122
120
  supportedAppExtensions = [];
123
121
  }
124
122
 
125
- if (_.isEmpty(supportedAppExtensions)) {
123
+ if (util.isEmpty(supportedAppExtensions)) {
126
124
  throw new Error(`One or more supported app extensions must be provided`);
127
125
  }
128
126
 
@@ -170,7 +168,7 @@ export async function configureApp(
170
168
  let {stream, status} = result;
171
169
  logger.debug(`Response status: ${status}`);
172
170
  try {
173
- if (!_.isEmpty(headers)) {
171
+ if (!util.isEmpty(headers)) {
174
172
  if (headers.etag) {
175
173
  logger.debug(`Etag: ${headers.etag}`);
176
174
  remoteAppProps.etag = headers.etag;
@@ -212,7 +210,7 @@ export async function configureApp(
212
210
  if (onDownload) {
213
211
  newApp = await onDownload({
214
212
  url: originalAppLink,
215
- headers: _.clone(headers) as HTTPHeaders,
213
+ headers: structuredClone(headers) as HTTPHeaders,
216
214
  stream,
217
215
  });
218
216
  } else {
@@ -233,7 +231,7 @@ export async function configureApp(
233
231
  } else {
234
232
  let errorMessage = `The application at '${newApp}' does not exist or is not accessible`;
235
233
  // protocol value for 'C:\\temp' is 'c:', so we check the length as well
236
- if (_.isString(protocol) && protocol.length > 2) {
234
+ if (typeof protocol === 'string' && protocol.length > 2) {
237
235
  errorMessage =
238
236
  `The protocol '${protocol}' used in '${newApp}' is not supported. ` +
239
237
  `Only http: and https: protocols are supported`;
@@ -267,12 +265,12 @@ export async function configureApp(
267
265
  return appPathToCache;
268
266
  };
269
267
 
270
- if (_.isFunction(onPostProcess)) {
268
+ if (typeof onPostProcess === 'function') {
271
269
  const postProcessArg: PostProcessOptions = {
272
- cachedAppInfo: _.clone(cachedAppInfo) as CachedAppInfo | undefined,
270
+ cachedAppInfo: structuredClone(cachedAppInfo) as CachedAppInfo | undefined,
273
271
  isUrl,
274
272
  originalAppLink,
275
- headers: _.clone(headers) as HTTPHeaders,
273
+ headers: structuredClone(headers) as HTTPHeaders,
276
274
  appPath: newApp,
277
275
  };
278
276
  const result = await onPostProcess(postProcessArg);
@@ -282,7 +280,7 @@ export async function configureApp(
282
280
  }
283
281
 
284
282
  verifyAppExtension(newApp, supportedAppExtensions);
285
- return appCacheKey !== toCacheKey(newApp) && (packageHash || _.values(remoteAppProps).some(Boolean))
283
+ return appCacheKey !== toCacheKey(newApp) && (packageHash || Object.values(remoteAppProps).some(Boolean))
286
284
  ? await storeAppInCache(newApp)
287
285
  : newApp;
288
286
  });
@@ -310,14 +308,14 @@ export function isPackageOrBundle(app: string): boolean {
310
308
  */
311
309
  export function duplicateKeys<T>(input: T, firstKey: string, secondKey: string): T {
312
310
  // If array provided, recursively call on all elements
313
- if (_.isArray(input)) {
311
+ if (Array.isArray(input)) {
314
312
  return input.map((item) => duplicateKeys(item, firstKey, secondKey)) as T;
315
313
  }
316
314
 
317
315
  // If object, create duplicates for keys and then recursively call on values
318
- if (_.isPlainObject(input)) {
316
+ if (util.isPlainObject(input)) {
319
317
  const resultObj: Record<string, unknown> = {};
320
- for (const [key, value] of _.toPairs(input as Record<string, unknown>)) {
318
+ for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
321
319
  const recursivelyCalledValue = duplicateKeys(value, firstKey, secondKey);
322
320
  if (key === firstKey) {
323
321
  resultObj[secondKey] = recursivelyCalledValue;
@@ -342,23 +340,23 @@ export function duplicateKeys<T>(input: T, firstKey: string, secondKey: string):
342
340
  * @throws {TypeError} If value is not a string/array or JSON parsing fails for array-like input.
343
341
  */
344
342
  export function parseCapsArray(capValue: string | string[]): string[] {
345
- if (_.isArray(capValue)) {
343
+ if (Array.isArray(capValue)) {
346
344
  return capValue;
347
345
  }
348
346
 
349
347
  try {
350
348
  const parsed = JSON.parse(capValue);
351
- if (_.isArray(parsed)) {
349
+ if (Array.isArray(parsed)) {
352
350
  return parsed;
353
351
  }
354
352
  } catch (e) {
355
353
  const message = `Failed to parse capability as JSON array: ${(e as Error).message}`;
356
- if (_.isString(capValue) && _.startsWith(_.trimStart(capValue), '[')) {
354
+ if (typeof capValue === 'string' && capValue.trimStart().startsWith('[')) {
357
355
  throw new TypeError(message, {cause: e});
358
356
  }
359
357
  logger.warn(message);
360
358
  }
361
- if (_.isString(capValue)) {
359
+ if (typeof capValue === 'string') {
362
360
  return [capValue];
363
361
  }
364
362
  throw new TypeError(`Expected a string or a valid JSON array; received '${capValue}'`);
@@ -392,10 +390,10 @@ function parseAppLink(appLink: string): URL | {protocol?: string; pathname?: str
392
390
 
393
391
  function isEnvOptionEnabled(optionName: string, defaultValue: boolean | null = null): boolean {
394
392
  const value = process.env[optionName];
395
- if (!_.isNull(defaultValue) && _.isEmpty(value)) {
393
+ if (defaultValue !== null && util.isEmpty(value)) {
396
394
  return defaultValue;
397
395
  }
398
- return !_.isEmpty(value) && !['0', 'false', 'no'].includes(_.toLower(value));
396
+ return !util.isEmpty(value) && !['0', 'false', 'no'].includes(String(value).toLowerCase());
399
397
  }
400
398
 
401
399
  function isSupportedUrl(app: string): boolean {
@@ -466,7 +464,7 @@ async function fetchApp(srcStream: Readable, dstPath: string): Promise<string> {
466
464
  const writer = fs.createWriteStream(dstPath);
467
465
  srcStream.pipe(writer);
468
466
 
469
- await new B<void>((resolve, reject) => {
467
+ await new Promise<void>((resolve, reject) => {
470
468
  srcStream.once('error', reject);
471
469
  writer.once('finish', () => resolve());
472
470
  writer.once('error', (e: Error) => {
@@ -515,18 +513,18 @@ function determineFilename(
515
513
  ? basename.substring(0, basename.length - extname.length)
516
514
  : DEFAULT_BASENAME;
517
515
  let resultingExt = extname;
518
- if (!supportedAppExtensions.map(_.toLower).includes(_.toLower(resultingExt))) {
516
+ if (!supportedAppExtensions.map((ext) => ext.toLowerCase()).includes(resultingExt.toLowerCase())) {
519
517
  logger.info(
520
518
  `The current file extension '${resultingExt}' is not supported. ` +
521
- `Defaulting to '${_.first(supportedAppExtensions)}'`
519
+ `Defaulting to '${supportedAppExtensions[0]}'`
522
520
  );
523
- resultingExt = _.first(supportedAppExtensions) as string;
521
+ resultingExt = supportedAppExtensions[0] as string;
524
522
  }
525
523
  return `${resultingName}${resultingExt}`;
526
524
  }
527
525
 
528
526
  function verifyAppExtension(app: string, supportedAppExtensions: string[]): string {
529
- if (supportedAppExtensions.map(_.toLower).includes(_.toLower(path.extname(app)))) {
527
+ if (supportedAppExtensions.map((ext) => ext.toLowerCase()).includes(path.extname(app).toLowerCase())) {
530
528
  return app;
531
529
  }
532
530
  throw new Error(
@@ -565,7 +563,7 @@ async function isAppIntegrityOk(
565
563
  }
566
564
 
567
565
  function toNaturalNumber(defaultValue: number, envVarName?: string): number {
568
- if (!envVarName || _.isUndefined(process.env[envVarName])) {
566
+ if (!envVarName || process.env[envVarName] === undefined) {
569
567
  return defaultValue;
570
568
  }
571
569
  const num = parseInt(`${process.env[envVarName]}`, 10);
@@ -0,0 +1,179 @@
1
+ import {log} from './logger';
2
+ import type {StringRecord, IIpcSubscription, IAppiumIpc, IpcMessage, IpcEvent, AppiumLogger, IpcData} from '@appium/types';
3
+ import EventEmitter from 'node:events';
4
+ import {sleep} from 'asyncbox';
5
+ import {node} from '@appium/support';
6
+
7
+ const DEF_MAX_OBJ_SIZE_BYTES = 1024 * 1024; // 1mb seems like plenty for any plugin to pass a message
8
+ const DEF_MAX_TOPICS = 1000;
9
+
10
+ export const EVT_MESSAGE = 'message';
11
+ export const EVT_UNSUBSCRIBED = 'unsubscribed';
12
+
13
+ export type AppiumIpcOpts = {
14
+ maxObjSize?: number;
15
+ maxTopics?: number;
16
+ log?: AppiumLogger;
17
+ };
18
+
19
+ const ASYNC_ITERATOR_STOP = Symbol('asyncIteratorStop');
20
+
21
+ export class IpcSubscription<T extends IpcData> extends EventEmitter<IpcEvent<T>> implements IIpcSubscription<T> {
22
+
23
+ constructor(
24
+ public readonly subscriber: string,
25
+ public readonly topic: string,
26
+ private readonly ipc: AppiumIpc
27
+ ) {
28
+ super();
29
+ }
30
+
31
+ get isActive() {
32
+ return this.ipc.subscriptionExists(this.topic, this.subscriber);
33
+ }
34
+
35
+ getMessage(): IpcMessage<T> | undefined {
36
+ if (!this.isActive) {
37
+ throw new Error('Cannot get message from subscription after unsubscribing');
38
+ }
39
+ return this.ipc.getMessage<T>(this.topic);
40
+ }
41
+
42
+ async publish(data: T): Promise<void> {
43
+ if (!this.isActive) {
44
+ throw new Error('Cannot publish data to topic from subscription after unsubscribing');
45
+ }
46
+ return await this.ipc.publish<T>(this.topic, this.subscriber, data);
47
+ }
48
+
49
+ unsubscribe(): boolean {
50
+ if (!this.isActive) {
51
+ return false;
52
+ }
53
+ const unsubscribeRes = this.ipc.unsubscribe(this.topic, this.subscriber);
54
+ this.emit('unsubscribed');
55
+ this.removeAllListeners(EVT_MESSAGE);
56
+ return unsubscribeRes;
57
+ }
58
+
59
+ async *[Symbol.asyncIterator](): AsyncGenerator<IpcMessage<T>> {
60
+ // yield any messages that are emitted, but keep an eye out for an unsubscribed that happens
61
+ // while a caller is waiting on the loop, because we want to exit the loop in case of
62
+ // unsubscription, even if we were already waiting on the next message.
63
+ while (this.isActive) {
64
+ const val = await new Promise<IpcMessage<T> | typeof ASYNC_ITERATOR_STOP>((resolve) => {
65
+ this.once(EVT_MESSAGE, (message: IpcMessage<T>) => {
66
+ this.removeAllListeners(EVT_UNSUBSCRIBED);
67
+ resolve(message);
68
+ });
69
+ this.once(EVT_UNSUBSCRIBED, () => {
70
+ // EVT_MESSAGE listeners are already removed in unsubscribe()
71
+ resolve(ASYNC_ITERATOR_STOP);
72
+ });
73
+ });
74
+ if (val === ASYNC_ITERATOR_STOP) {
75
+ break;
76
+ }
77
+ yield val;
78
+ }
79
+ }
80
+ }
81
+
82
+ export class AppiumIpc implements IAppiumIpc {
83
+ protected readonly messageByTopic: StringRecord<IpcMessage<any>> = {};
84
+ protected readonly subs: StringRecord<Array<IpcSubscription<any>>> = {};
85
+ protected readonly topics = new Set<string>();
86
+ protected readonly maxObjSize: number;
87
+ protected readonly maxTopics: number;
88
+ protected readonly log: AppiumLogger;
89
+
90
+ constructor (opts: AppiumIpcOpts = {}) {
91
+ this.maxObjSize = opts.maxObjSize ?? DEF_MAX_OBJ_SIZE_BYTES;
92
+ this.maxTopics = opts.maxTopics ?? DEF_MAX_TOPICS;
93
+ this.log = opts.log ?? log;
94
+ this.log.debug(`Initialized new IPC object with max object size of ${this.maxObjSize} bytes ` +
95
+ `and max topics of ${this.maxTopics}`);
96
+ }
97
+
98
+ subscribe<T extends IpcData>(topic: string, subscriber: string): IpcSubscription<T> {
99
+ this.log.info(`Subscribing ${subscriber} to topic '${topic}'`);
100
+ if (this.subscriptionExists(topic, subscriber)) {
101
+ throw new Error(`Subscription already exists for topic "${topic}" and subscriber "${subscriber}"`);
102
+ }
103
+
104
+ this.ensureTopic(topic);
105
+ this.subs[topic] ??= [];
106
+ const sub = new IpcSubscription<T>(subscriber, topic, this);
107
+ this.subs[topic].push(sub);
108
+ return sub;
109
+ }
110
+
111
+ unsubscribe(topic: string, subscriber: string): boolean {
112
+ this.log.info(`Unsubscribing ${subscriber} from topic '${topic}'`);
113
+ if (this.subscriptionExists(topic, subscriber)) {
114
+ this.subs[topic] = this.subs[topic].filter((sub) => sub.subscriber !== subscriber);
115
+ return true;
116
+ }
117
+ return false;
118
+ }
119
+
120
+ async publish<T extends IpcData>(topic: string, publisher: string, data: T): Promise<void> {
121
+ this.log.debug(`${publisher} is publishing a message to topic ${topic}`);
122
+
123
+ this.ensureTopic(topic);
124
+
125
+ const messageSize = node.getObjectSize(data);
126
+ if (messageSize > this.maxObjSize) {
127
+ throw new Error(`Error when ${publisher} is publishing to topic '${topic}': ` +
128
+ `Message with size ${messageSize} bytes is bigger than max size of ${this.maxObjSize} bytes`);
129
+ }
130
+
131
+ let clonedData: T;
132
+ try {
133
+ clonedData = structuredClone(data);
134
+ } catch (e) {
135
+ throw new Error(`Could not clone data for IPC publish from ${publisher} on topic ${topic}`, {cause: e});
136
+ }
137
+
138
+ const message: IpcMessage<T> = {publisher, data: clonedData, topic, timestampMs: Date.now()};
139
+
140
+ this.messageByTopic[topic] = message;
141
+
142
+ const subs = this.subs[topic] ?
143
+ this.subs[topic].filter((sub) => sub.subscriber !== publisher) :
144
+ [];
145
+
146
+ for (const sub of subs) {
147
+ sub.emit(EVT_MESSAGE, structuredClone(message));
148
+ }
149
+
150
+ // we don't want to return from publish until the async iterators on subscriptions have had
151
+ // a chance to observe the emitted value, otherwise some might get lost
152
+ await sleep(0);
153
+
154
+ }
155
+
156
+ getMessage<T extends IpcData>(topic: string): IpcMessage<T> | undefined {
157
+ if (!this.messageByTopic[topic]) {
158
+ return;
159
+ }
160
+
161
+ return structuredClone(this.messageByTopic[topic] as IpcMessage<T>);
162
+ }
163
+
164
+ subscriptionExists(topic: string, subscriber: string): boolean {
165
+ return !!this.subs[topic]?.some((sub) => sub.subscriber === subscriber);
166
+ }
167
+
168
+ protected ensureTopic(topic: string): void {
169
+ if (this.topics.has(topic)) {
170
+ return;
171
+ }
172
+ if (this.topics.size >= this.maxTopics) {
173
+ throw new Error(`Cannot create new IPC topic '${topic}': ` +
174
+ `maximum of ${this.maxTopics} topics per session reached. ` +
175
+ `Adjust with the --max-ipc-topics server arg.`);
176
+ }
177
+ this.topics.add(topic);
178
+ }
179
+ }