@devicecloud.dev/dcd 0.0.2 → 0.0.4

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.
@@ -1,4 +1,21 @@
1
1
  import { Command } from '@oclif/core';
2
+ export declare enum EiOSDevices {
3
+ 'ipad-pro-6th-gen' = "ipad-pro-6th-gen",
4
+ 'iphone-12' = "iphone-12",
5
+ 'iphone-12-mini' = "iphone-12-mini",
6
+ 'iphone-12-pro-max' = "iphone-12-pro-max",
7
+ 'iphone-13' = "iphone-13",
8
+ 'iphone-13-mini' = "iphone-13-mini",
9
+ 'iphone-13-pro-max' = "iphone-13-pro-max",
10
+ 'iphone-14' = "iphone-14",
11
+ 'iphone-14-plus' = "iphone-14-plus",
12
+ 'iphone-14-pro' = "iphone-14-pro",
13
+ 'iphone-14-pro-max' = "iphone-14-pro-max",
14
+ 'iphone-15' = "iphone-15",
15
+ 'iphone-15-plus' = "iphone-15-plus",
16
+ 'iphone-15-pro' = "iphone-15-pro",
17
+ 'iphone-15-pro-max' = "iphone-15-pro-max"
18
+ }
2
19
  export default class Cloud extends Command {
3
20
  static args: {
4
21
  firstFile: import("@oclif/core/lib/interfaces").Arg<string | undefined, Record<string, unknown>>;
@@ -1,91 +1,20 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EiOSDevices = void 0;
3
4
  /* eslint-disable complexity */
4
5
  const core_1 = require("@oclif/core");
5
6
  const cli_ux_1 = require("@oclif/core/lib/cli-ux");
6
7
  const errors_1 = require("@oclif/core/lib/errors");
7
- const archiver = require("archiver");
8
8
  const promises_1 = require("node:fs/promises");
9
- const node_stream_1 = require("node:stream");
9
+ const path = require("node:path");
10
+ const constants_1 = require("../constants");
11
+ const methods_1 = require("../methods");
10
12
  const plan_1 = require("../plan");
11
13
  const mimeTypeLookupByExtension = {
12
14
  apk: 'application/vnd.android.package-archive',
13
15
  yaml: 'application/x-yaml',
14
16
  zip: 'application/zip',
15
17
  };
16
- const PERMITTED_EXTENSIONS = new Set([
17
- 'yml',
18
- 'yaml',
19
- 'png',
20
- 'jpg',
21
- 'jpeg',
22
- 'gif',
23
- 'mp4',
24
- 'js',
25
- ]);
26
- const typeSafePost = async (baseUrl, path, init) => {
27
- const res = await fetch(baseUrl + path, { ...init, method: 'POST' });
28
- if (!res.ok) {
29
- throw new Error(await res.text());
30
- }
31
- return res.json();
32
- };
33
- const typeSafeGet = async (baseUrl, path, init) => {
34
- const res = await fetch(baseUrl + path, init);
35
- if (!res.ok) {
36
- throw new Error(await res.text());
37
- }
38
- return res.json();
39
- };
40
- const toBuffer = async (archive) => {
41
- const chunks = [];
42
- const writable = new node_stream_1.Writable();
43
- writable._write = (chunk, _, callback) => {
44
- // save to array to concatenate later
45
- chunks.push(chunk);
46
- callback();
47
- };
48
- // pipe to writable
49
- archive.pipe(writable);
50
- await archive.finalize();
51
- // once done, concatenate chunks
52
- return Buffer.concat(chunks);
53
- };
54
- const compressDir = async (sourceDir) => {
55
- // const output = createWriteStream(zipTargetPath);
56
- const archive = archiver('zip', {
57
- zlib: { level: 9 },
58
- });
59
- archive.on('error', (err) => {
60
- throw err;
61
- });
62
- archive.directory(sourceDir, '.', (data) => {
63
- if (data.name.split('/')[0] === 'node_modules')
64
- return false;
65
- if (PERMITTED_EXTENSIONS.has(data.name.split('.').pop())) {
66
- return data;
67
- }
68
- return false;
69
- });
70
- const buffer = await toBuffer(archive);
71
- // await writeFile('./my-zip.zip', buffer);
72
- return buffer;
73
- };
74
- // const compressFile = async (sourceFile: string) => {
75
- // const archive = archiver('zip', {
76
- // zlib: { level: 9 },
77
- // });
78
- // archive.on('error', (err) => {
79
- // throw err;
80
- // });
81
- // console.log(sourceFile, sourceFile.split('/').pop());
82
- // archive.file(sourceFile.includes('/') ? sourceFile : './' + sourceFile, {
83
- // name: sourceFile.split('/').pop()!,
84
- // });
85
- // const buffer = await toBuffer(archive);
86
- // await writeFile('./my-file-zip.zip', buffer);
87
- // return buffer;
88
- // };
89
18
  var EiOSDevices;
90
19
  (function (EiOSDevices) {
91
20
  EiOSDevices["ipad-pro-6th-gen"] = "ipad-pro-6th-gen";
@@ -103,7 +32,7 @@ var EiOSDevices;
103
32
  EiOSDevices["iphone-15-plus"] = "iphone-15-plus";
104
33
  EiOSDevices["iphone-15-pro"] = "iphone-15-pro";
105
34
  EiOSDevices["iphone-15-pro-max"] = "iphone-15-pro-max";
106
- })(EiOSDevices || (EiOSDevices = {}));
35
+ })(EiOSDevices || (exports.EiOSDevices = EiOSDevices = {}));
107
36
  class Cloud extends core_1.Command {
108
37
  static args = {
109
38
  firstFile: core_1.Args.string({
@@ -119,109 +48,10 @@ class Cloud extends core_1.Command {
119
48
  };
120
49
  static description = `Test a Flow or set of Flows on devicecloud.dev (https://devicecloud.dev)\nProvide your application file and a folder with Maestro flows to run them in parallel on multiple devices in devicecloud.dev\nThe command will block until all analyses have completed`;
121
50
  static examples = ['<%= config.bin %> <%= command.id %>'];
122
- static flags = {
123
- 'android-api-level': core_1.Flags.integer({
124
- description: '[Android only] Android API level to run your flow against',
125
- options: ['32', '33', '34'],
126
- }),
127
- 'android-device': core_1.Flags.string({
128
- description: '[Android only] Android device to run your flow against',
129
- options: [
130
- 'pixel-6',
131
- 'pixel-6a',
132
- 'pixel-6-pro',
133
- 'pixel-7',
134
- 'pixel-7-pro',
135
- 'generic-tablet',
136
- ],
137
- }),
138
- apiKey: core_1.Flags.string({
139
- aliases: ['api-key'],
140
- description: 'API key',
141
- }),
142
- apiUrl: core_1.Flags.string({
143
- aliases: ['api-url', 'apiURL'],
144
- default: 'https://api.devicecloud.dev',
145
- description: 'API base URL',
146
- hidden: true,
147
- }),
148
- 'app-binary-id': core_1.Flags.string({
149
- aliases: ['app-binary-id'],
150
- description: 'The ID of the app binary previously uploaded to Maestro Cloud',
151
- }),
152
- 'app-file': core_1.Flags.file({
153
- aliases: ['app-file'],
154
- description: 'App binary to run your flows against',
155
- }),
156
- arm64: core_1.Flags.boolean({
157
- default: false,
158
- description: '[Android only] Run your flow against arm64 devices',
159
- }),
160
- async: core_1.Flags.boolean({
161
- description: 'Wait for the results of the run',
162
- }),
163
- env: core_1.Flags.file({
164
- char: 'e',
165
- description: 'One or more environment variables to inject into your flows',
166
- multiple: true,
167
- }),
168
- 'exclude-tags': core_1.Flags.string({
169
- aliases: ['exclude-tags'],
170
- default: [],
171
- description: 'Flows which have these tags will be excluded from the run',
172
- multiple: true,
173
- parse: (input) => input.split(','),
174
- }),
175
- flows: core_1.Flags.string({
176
- description: 'The path to the flow file or folder containing your flows',
177
- }),
178
- 'google-play': core_1.Flags.boolean({
179
- aliases: ['google-play'],
180
- default: false,
181
- description: '[Android only] Run your flow against Google Play devices',
182
- }),
183
- 'include-tags': core_1.Flags.string({
184
- aliases: ['include-tags'],
185
- default: [],
186
- description: 'Only flows which have these tags will be included in the run',
187
- multiple: true,
188
- parse: (input) => input.split(','),
189
- }),
190
- 'ios-device': core_1.Flags.string({
191
- description: '[iOS only] iOS device to run your flow against',
192
- options: [
193
- 'iphone-12',
194
- 'iphone-12-mini',
195
- 'iphone-12-pro-max',
196
- 'iphone-13',
197
- 'iphone-13-mini',
198
- 'iphone-13-pro-max',
199
- 'iphone-14',
200
- 'iphone-14-plus',
201
- 'iphone-14-pro',
202
- 'iphone-14-pro-max',
203
- 'iphone-15',
204
- 'iphone-15-plus',
205
- 'iphone-15-pro',
206
- 'iphone-15-pro-max',
207
- 'ipad-pro-6th-gen',
208
- ],
209
- }),
210
- 'ios-version': core_1.Flags.string({
211
- description: '[iOS only] iOS version to run your flow against',
212
- options: ['15', '16', '17'],
213
- }),
214
- name: core_1.Flags.string({
215
- description: 'A custom name for your upload (useful for tagging commits etc)',
216
- }),
217
- orientation: core_1.Flags.string({
218
- description: '[Android only] The orientation of the device to run your flow against in degrees',
219
- options: ['0', '90', '180', '270'],
220
- }),
221
- };
51
+ static flags = constants_1.flags;
222
52
  async run() {
223
53
  try {
224
- const { args, flags } = await this.parse(Cloud);
54
+ const { args, flags, raw } = await this.parse(Cloud);
225
55
  const { 'android-api-level': androidApiLevel, 'android-device': androidDevice, apiKey, apiUrl, 'app-binary-id': appBinaryId, 'app-file': appFile, arm64, async, env, 'exclude-tags': excludeTags, flows, 'google-play': googlePlay, 'include-tags': includeTags, 'ios-device': iOSDevice, 'ios-version': iOSVersion, name, orientation, ...rest } = flags;
226
56
  if (arm64) {
227
57
  (0, cli_ux_1.info)('Contact hello@devicecloud.dev to enquire about arm64 devices');
@@ -241,46 +71,29 @@ class Cloud extends core_1.Command {
241
71
  throw new Error('You must provide a flow file');
242
72
  }
243
73
  if (iOSVersion) {
244
- const iOSCompatibilityLookup = {
245
- 'ipad-pro-6th-gen': ['16', '17'],
246
- 'iphone-12': ['15', '16', '17'],
247
- 'iphone-12-mini': ['15', '16', '17'],
248
- 'iphone-12-pro-max': ['15', '16', '17'],
249
- 'iphone-13': ['15', '16', '17'],
250
- 'iphone-13-mini': ['15', '16', '17'],
251
- 'iphone-13-pro-max': ['15', '16', '17'],
252
- 'iphone-14': ['16', '17'],
253
- 'iphone-14-plus': ['16', '17'],
254
- 'iphone-14-pro': ['16', '17'],
255
- 'iphone-14-pro-max': ['16', '17'],
256
- 'iphone-15': ['17'],
257
- 'iphone-15-plus': ['17'],
258
- 'iphone-15-pro': ['17'],
259
- 'iphone-15-pro-max': ['17'],
260
- };
261
74
  const iOSDeviceID = iOSDevice || 'iphone-14';
262
- const supportediOSVersions = iOSCompatibilityLookup[iOSDeviceID];
75
+ const supportediOSVersions = constants_1.iOSCompatibilityLookup[iOSDeviceID];
263
76
  if (!supportediOSVersions.includes(iOSVersion)) {
264
77
  throw new Error(`${iOSDeviceID} only supports these iOS versions: ${supportediOSVersions.join(',')}`);
265
78
  }
266
79
  }
267
- let testFileNames = [];
268
- let continueOnFailure = true;
269
- let sequentialFlows = [];
80
+ flowFile = path.resolve(flowFile);
270
81
  if (!flowFile?.endsWith('.yaml') &&
271
82
  !flowFile?.endsWith('.yml') &&
272
83
  !flowFile?.endsWith('/')) {
273
84
  flowFile += '/';
274
85
  }
86
+ let executionPlan;
275
87
  try {
276
- const executionPlan = await (0, plan_1.plan)(flowFile, includeTags.flat(), excludeTags.flat());
277
- testFileNames = executionPlan.flowsToRun;
278
- continueOnFailure = executionPlan.sequence?.continueOnFailure ?? true;
279
- sequentialFlows = executionPlan.sequence?.flows ?? [];
88
+ executionPlan = await (0, plan_1.plan)(flowFile, includeTags.flat(), excludeTags.flat());
280
89
  }
281
90
  catch (error) {
282
91
  console.error(error);
92
+ // eslint-disable-next-line no-process-exit, unicorn/no-process-exit
93
+ process.exit(2);
283
94
  }
95
+ const { allExcludeTags, allIncludeTags, flowsToRun: testFileNames, referencedFiles, sequence, } = executionPlan;
96
+ const { continueOnFailure = true, flows: sequentialFlows = [] } = sequence ?? {};
284
97
  if (!appBinaryId) {
285
98
  if (!(flowFile && finalAppFile)) {
286
99
  throw new Error('You must provide a flow file and an app binary id');
@@ -288,6 +101,9 @@ class Cloud extends core_1.Command {
288
101
  if (!finalAppFile.endsWith('.apk') && !finalAppFile.endsWith('.zip')) {
289
102
  throw new Error('App file must be a .apk or .zip file');
290
103
  }
104
+ if (finalAppFile.endsWith('.zip')) {
105
+ await (0, methods_1.verifyAppZip)(finalAppFile);
106
+ }
291
107
  }
292
108
  const flagLogs = [];
293
109
  for (const [k, v] of Object.entries(flags)) {
@@ -297,15 +113,15 @@ class Cloud extends core_1.Command {
297
113
  }
298
114
  this.log(`
299
115
 
300
- Submitting new job
301
- → Flow(s): ${flowFile}
302
- → App: ${appBinaryId || finalAppFile}
116
+ Submitting new job
117
+ → Flow(s): ${flowFile}
118
+ → App: ${appBinaryId || finalAppFile}
303
119
 
304
- With options
305
- → ${flagLogs.join(`
306
- → `)}
120
+ With options
121
+ → ${flagLogs.join(`
122
+ → `)}
307
123
 
308
- `);
124
+ `);
309
125
  if (!finalBinaryId) {
310
126
  core_1.ux.action.start('Uploading binary', 'Initializing', { stdout: true });
311
127
  const binaryFormData = new FormData();
@@ -318,7 +134,7 @@ class Cloud extends core_1.Command {
318
134
  headers: { 'x-app-api-key': apiKey },
319
135
  };
320
136
  core_1.ux.action.status = `Uploading`;
321
- const { binaryId, message } = await typeSafePost(apiUrl, '/uploads/binary', options);
137
+ const { binaryId, message } = await (0, methods_1.typeSafePost)(apiUrl, '/uploads/binary', options);
322
138
  if (!binaryId)
323
139
  throw new Error(message);
324
140
  core_1.ux.action.stop(`\nBinary uploaded with id: ${binaryId}`);
@@ -331,14 +147,15 @@ class Cloud extends core_1.Command {
331
147
  acc[key] = value;
332
148
  return acc;
333
149
  }, {});
334
- const path = flowFile.split('/').slice(0, -1).join('/');
335
- const buffer = await compressDir(path?.length ? path : './');
336
- // const buffer =
337
- // totalFlowFiles > 1 || flowFile?.endsWith('/')
338
- // ? await compressDir(
339
- // flowFile!.split('/').slice(0, -1).join('/') ?? '.',
340
- // )
341
- // : await compressFile(flowFile!);
150
+ const buffer = await (0, methods_1.compressFilesFromRelativePath)(flowFile?.endsWith('.yaml') || flowFile?.endsWith('.yml')
151
+ ? path.dirname(flowFile)
152
+ : flowFile, [
153
+ ...new Set([
154
+ ...referencedFiles,
155
+ ...testFileNames,
156
+ ...sequentialFlows,
157
+ ]),
158
+ ]);
342
159
  const blob = new Blob([buffer], {
343
160
  type: mimeTypeLookupByExtension.zip,
344
161
  });
@@ -348,22 +165,23 @@ class Cloud extends core_1.Command {
348
165
  testFormData.set('sequentialFlows', JSON.stringify(sequentialFlows));
349
166
  testFormData.set('env', JSON.stringify(envObject));
350
167
  testFormData.set('googlePlay', googlePlay ? 'true' : 'false');
351
- testFormData.set('config', JSON.stringify({ continueOnFailure, orientation }));
352
- if (androidApiLevel) {
168
+ testFormData.set('config', JSON.stringify({
169
+ allExcludeTags,
170
+ allIncludeTags,
171
+ continueOnFailure,
172
+ orientation,
173
+ raw,
174
+ }));
175
+ if (androidApiLevel)
353
176
  testFormData.set('androidApiLevel', androidApiLevel.toString());
354
- }
355
- if (androidDevice) {
177
+ if (androidDevice)
356
178
  testFormData.set('androidDevice', androidDevice.toString());
357
- }
358
- if (iOSVersion) {
179
+ if (iOSVersion)
359
180
  testFormData.set('iOSVersion', iOSVersion.toString());
360
- }
361
- if (iOSDevice) {
181
+ if (iOSDevice)
362
182
  testFormData.set('iOSDevice', iOSDevice.toString());
363
- }
364
- if (name) {
183
+ if (name)
365
184
  testFormData.set('name', name.toString());
366
- }
367
185
  for (const [key, value] of Object.entries(rest)) {
368
186
  if (value) {
369
187
  testFormData.set(key, value);
@@ -373,7 +191,7 @@ class Cloud extends core_1.Command {
373
191
  body: testFormData,
374
192
  headers: { 'x-app-api-key': apiKey },
375
193
  };
376
- const { message, results } = await typeSafePost(apiUrl, '/uploads/flow', options);
194
+ const { message, results } = await (0, methods_1.typeSafePost)(apiUrl, '/uploads/flow', options);
377
195
  if (!results?.length)
378
196
  (0, errors_1.error)('No tests created: ' + message);
379
197
  (0, cli_ux_1.info)(`\nCreated ${results.length} tests: ${results
@@ -390,7 +208,7 @@ class Cloud extends core_1.Command {
390
208
  core_1.ux.action.start('Waiting for results', 'Initializing', { stdout: true });
391
209
  (0, cli_ux_1.info)('\nYou can safely close this terminal and the tests will continue\n');
392
210
  const intervalId = setInterval(async () => {
393
- const { results: updatedResults } = await typeSafeGet(apiUrl, `/results/${results[0].test_upload_id}`, {
211
+ const { results: updatedResults } = await (0, methods_1.typeSafeGet)(apiUrl, `/results/${results[0].test_upload_id}`, {
394
212
  headers: { 'x-app-api-key': apiKey },
395
213
  });
396
214
  if (!updatedResults) {
@@ -411,7 +229,8 @@ class Cloud extends core_1.Command {
411
229
  (0, cli_ux_1.info)('\n');
412
230
  clearInterval(intervalId);
413
231
  if (updatedResults.some((result) => result.status === 'FAILED')) {
414
- (0, cli_ux_1.exit)(1);
232
+ // eslint-disable-next-line no-process-exit, unicorn/no-process-exit
233
+ process.exit(2);
415
234
  }
416
235
  }
417
236
  }, 5000);
@@ -0,0 +1,23 @@
1
+ import { EiOSDevices } from './commands/cloud';
2
+ export declare const flags: {
3
+ 'android-api-level': import("@oclif/core/lib/interfaces").OptionFlag<number | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
4
+ 'android-device': import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
5
+ apiKey: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
6
+ apiUrl: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
7
+ 'app-binary-id': import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
8
+ 'app-file': import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
9
+ arm64: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
10
+ async: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
11
+ env: import("@oclif/core/lib/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
12
+ 'exclude-tags': import("@oclif/core/lib/interfaces").OptionFlag<string[], import("@oclif/core/lib/interfaces").CustomOptions>;
13
+ flows: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
14
+ 'google-play': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
15
+ 'include-tags': import("@oclif/core/lib/interfaces").OptionFlag<string[], import("@oclif/core/lib/interfaces").CustomOptions>;
16
+ 'ios-device': import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
17
+ 'ios-version': import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
18
+ name: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
19
+ orientation: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
20
+ };
21
+ export declare const iOSCompatibilityLookup: {
22
+ [k in EiOSDevices]: string[];
23
+ };
@@ -0,0 +1,121 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.iOSCompatibilityLookup = exports.flags = void 0;
4
+ const core_1 = require("@oclif/core");
5
+ exports.flags = {
6
+ 'android-api-level': core_1.Flags.integer({
7
+ description: '[Android only] Android API level to run your flow against',
8
+ options: ['32', '33', '34'],
9
+ }),
10
+ 'android-device': core_1.Flags.string({
11
+ description: '[Android only] Android device to run your flow against',
12
+ options: [
13
+ 'pixel-6',
14
+ 'pixel-6a',
15
+ 'pixel-6-pro',
16
+ 'pixel-7',
17
+ 'pixel-7-pro',
18
+ 'generic-tablet',
19
+ ],
20
+ }),
21
+ apiKey: core_1.Flags.string({
22
+ aliases: ['api-key'],
23
+ description: 'API key',
24
+ }),
25
+ apiUrl: core_1.Flags.string({
26
+ aliases: ['api-url', 'apiURL'],
27
+ default: 'https://api.devicecloud.dev',
28
+ description: 'API base URL',
29
+ hidden: true,
30
+ }),
31
+ 'app-binary-id': core_1.Flags.string({
32
+ aliases: ['app-binary-id'],
33
+ description: 'The ID of the app binary previously uploaded to Maestro Cloud',
34
+ }),
35
+ 'app-file': core_1.Flags.file({
36
+ aliases: ['app-file'],
37
+ description: 'App binary to run your flows against',
38
+ }),
39
+ arm64: core_1.Flags.boolean({
40
+ default: false,
41
+ description: '[Android only] Run your flow against arm64 devices',
42
+ }),
43
+ async: core_1.Flags.boolean({
44
+ description: 'Wait for the results of the run',
45
+ }),
46
+ env: core_1.Flags.file({
47
+ char: 'e',
48
+ description: 'One or more environment variables to inject into your flows',
49
+ multiple: true,
50
+ }),
51
+ 'exclude-tags': core_1.Flags.string({
52
+ aliases: ['exclude-tags'],
53
+ default: [],
54
+ description: 'Flows which have these tags will be excluded from the run',
55
+ multiple: true,
56
+ parse: (input) => input.split(','),
57
+ }),
58
+ flows: core_1.Flags.string({
59
+ description: 'The path to the flow file or folder containing your flows',
60
+ }),
61
+ 'google-play': core_1.Flags.boolean({
62
+ aliases: ['google-play'],
63
+ default: false,
64
+ description: '[Android only] Run your flow against Google Play devices',
65
+ }),
66
+ 'include-tags': core_1.Flags.string({
67
+ aliases: ['include-tags'],
68
+ default: [],
69
+ description: 'Only flows which have these tags will be included in the run',
70
+ multiple: true,
71
+ parse: (input) => input.split(','),
72
+ }),
73
+ 'ios-device': core_1.Flags.string({
74
+ description: '[iOS only] iOS device to run your flow against',
75
+ options: [
76
+ 'iphone-12',
77
+ 'iphone-12-mini',
78
+ 'iphone-12-pro-max',
79
+ 'iphone-13',
80
+ 'iphone-13-mini',
81
+ 'iphone-13-pro-max',
82
+ 'iphone-14',
83
+ 'iphone-14-plus',
84
+ 'iphone-14-pro',
85
+ 'iphone-14-pro-max',
86
+ 'iphone-15',
87
+ 'iphone-15-plus',
88
+ 'iphone-15-pro',
89
+ 'iphone-15-pro-max',
90
+ 'ipad-pro-6th-gen',
91
+ ],
92
+ }),
93
+ 'ios-version': core_1.Flags.string({
94
+ description: '[iOS only] iOS version to run your flow against',
95
+ options: ['15', '16', '17'],
96
+ }),
97
+ name: core_1.Flags.string({
98
+ description: 'A custom name for your upload (useful for tagging commits etc)',
99
+ }),
100
+ orientation: core_1.Flags.string({
101
+ description: '[Android only] The orientation of the device to run your flow against in degrees',
102
+ options: ['0', '90', '180', '270'],
103
+ }),
104
+ };
105
+ exports.iOSCompatibilityLookup = {
106
+ 'ipad-pro-6th-gen': ['16', '17'],
107
+ 'iphone-12': ['15', '16', '17'],
108
+ 'iphone-12-mini': ['15', '16', '17'],
109
+ 'iphone-12-pro-max': ['15', '16', '17'],
110
+ 'iphone-13': ['15', '16', '17'],
111
+ 'iphone-13-mini': ['15', '16', '17'],
112
+ 'iphone-13-pro-max': ['15', '16', '17'],
113
+ 'iphone-14': ['16', '17'],
114
+ 'iphone-14-plus': ['16', '17'],
115
+ 'iphone-14-pro': ['16', '17'],
116
+ 'iphone-14-pro-max': ['16', '17'],
117
+ 'iphone-15': ['17'],
118
+ 'iphone-15-plus': ['17'],
119
+ 'iphone-15-pro': ['17'],
120
+ 'iphone-15-pro-max': ['17'],
121
+ };
@@ -0,0 +1,15 @@
1
+ /// <reference types="node" />
2
+ import * as archiver from 'archiver';
3
+ import { paths } from '../../api/schema.types';
4
+ export declare const typeSafePost: <T extends keyof paths>(baseUrl: string, path: string, init?: {
5
+ body?: FormData;
6
+ headers?: HeadersInit;
7
+ }) => Promise<paths[T]["post"]["responses"]["201"]["content"]["application/json"]>;
8
+ export declare const typeSafeGet: <T extends keyof paths>(baseUrl: string, path: string, init?: {
9
+ body?: FormData;
10
+ headers?: HeadersInit;
11
+ }) => Promise<paths[T]["get"]["responses"]["200"]["content"]["application/json"]>;
12
+ export declare const toBuffer: (archive: archiver.Archiver) => Promise<Buffer>;
13
+ export declare const compressDir: (sourceDir: string) => Promise<Buffer>;
14
+ export declare const compressFilesFromRelativePath: (path: string, files: string[]) => Promise<Buffer>;
15
+ export declare const verifyAppZip: (zipPath: string) => Promise<void>;
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.verifyAppZip = exports.compressFilesFromRelativePath = exports.compressDir = exports.toBuffer = exports.typeSafeGet = exports.typeSafePost = void 0;
4
+ const archiver = require("archiver");
5
+ // import { writeFile } from 'node:fs/promises';
6
+ const nodePath = require("node:path");
7
+ const node_stream_1 = require("node:stream");
8
+ const StreamZip = require("node-stream-zip");
9
+ const PERMITTED_EXTENSIONS = new Set([
10
+ 'yml',
11
+ 'yaml',
12
+ 'png',
13
+ 'jpg',
14
+ 'jpeg',
15
+ 'gif',
16
+ 'mp4',
17
+ 'js',
18
+ ]);
19
+ const typeSafePost = async (baseUrl, path, init) => {
20
+ const res = await fetch(baseUrl + path, { ...init, method: 'POST' });
21
+ if (!res.ok) {
22
+ throw new Error(await res.text());
23
+ }
24
+ return res.json();
25
+ };
26
+ exports.typeSafePost = typeSafePost;
27
+ const typeSafeGet = async (baseUrl, path, init) => {
28
+ const res = await fetch(baseUrl + path, init);
29
+ if (!res.ok) {
30
+ throw new Error(await res.text());
31
+ }
32
+ return res.json();
33
+ };
34
+ exports.typeSafeGet = typeSafeGet;
35
+ const toBuffer = async (archive) => {
36
+ const chunks = [];
37
+ const writable = new node_stream_1.Writable();
38
+ writable._write = (chunk, _, callback) => {
39
+ // save to array to concatenate later
40
+ chunks.push(chunk);
41
+ callback();
42
+ };
43
+ // pipe to writable
44
+ archive.pipe(writable);
45
+ await archive.finalize();
46
+ // once done, concatenate chunks
47
+ return Buffer.concat(chunks);
48
+ };
49
+ exports.toBuffer = toBuffer;
50
+ const compressDir = async (sourceDir) => {
51
+ // const output = createWriteStream(zipTargetPath);
52
+ const archive = archiver('zip', {
53
+ zlib: { level: 9 },
54
+ });
55
+ archive.on('error', (err) => {
56
+ throw err;
57
+ });
58
+ archive.directory(sourceDir, '.', (data) => {
59
+ if (data.name.split('/')[0] === 'node_modules')
60
+ return false;
61
+ if (PERMITTED_EXTENSIONS.has(data.name.split('.').pop())) {
62
+ return data;
63
+ }
64
+ return false;
65
+ });
66
+ const buffer = await (0, exports.toBuffer)(archive);
67
+ // await writeFile('./my-zip.zip', buffer);
68
+ return buffer;
69
+ };
70
+ exports.compressDir = compressDir;
71
+ const compressFilesFromRelativePath = async (path, files) => {
72
+ const archive = archiver('zip', {
73
+ zlib: { level: 9 },
74
+ });
75
+ archive.on('error', (err) => {
76
+ throw err;
77
+ });
78
+ for (const file of files) {
79
+ archive.file(nodePath.resolve(path, file), { name: file });
80
+ }
81
+ const buffer = await (0, exports.toBuffer)(archive);
82
+ // await writeFile('./my-zip.zip', buffer);
83
+ return buffer;
84
+ };
85
+ exports.compressFilesFromRelativePath = compressFilesFromRelativePath;
86
+ const verifyAppZip = async (zipPath) => {
87
+ // eslint-disable-next-line import/namespace, new-cap
88
+ const zip = await new StreamZip.async({
89
+ file: zipPath,
90
+ storeEntries: true,
91
+ });
92
+ const entries = await zip.entries();
93
+ const topLevelEntries = Object.values(entries).filter((entry) => !entry.name.split('/')[1]);
94
+ if (topLevelEntries.length !== 1 ||
95
+ !topLevelEntries[0].name.endsWith('.app/')) {
96
+ throw new Error('Zip file must contain exactly one entry which is a .app, check the contents of the zip file');
97
+ }
98
+ zip.close();
99
+ };
100
+ exports.verifyAppZip = verifyAppZip;
package/dist/plan.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  interface IExecutionPlan {
2
+ allExcludeTags?: null | string[];
3
+ allIncludeTags?: null | string[];
2
4
  flowsToRun: string[];
5
+ referencedFiles: string[];
3
6
  sequence?: IFlowSequence | null;
4
7
  totalFlowFiles: number;
5
8
  }
package/dist/plan.js CHANGED
@@ -1,76 +1,43 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.plan = void 0;
4
- const globToRegExp = require("glob-to-regexp");
5
- const yaml = require("js-yaml");
4
+ /* eslint-disable complexity */
5
+ const glob_1 = require("glob");
6
6
  const fs = require("node:fs");
7
7
  const path = require("node:path");
8
- function getFlowsToRunInSequence(paths, flowOrder) {
9
- if (flowOrder.length === 0)
10
- return [];
11
- const orderSet = new Set(flowOrder);
12
- const namesInOrder = Object.keys(paths).filter((key) => orderSet.has(key));
13
- if (namesInOrder.length === 0)
14
- return [];
15
- const result = [...orderSet].filter((item) => namesInOrder.includes(item));
16
- if (result.length === 0) {
17
- throw new Error(`Could not find flows needed for execution in order: ${[...orderSet]
18
- .filter((item) => !namesInOrder.includes(item))
19
- .join(', ')}`);
20
- }
21
- else if (flowOrder
22
- .slice(0, result.length)
23
- .every((value, index) => value === result[index])) {
24
- return result.map((item) => paths[item]);
25
- }
26
- else {
27
- return [];
28
- }
29
- }
30
- function isFlowFile(filePath) {
31
- return filePath.endsWith('.yaml') || filePath.endsWith('.yml');
32
- }
33
- const readYamlFileAsJson = (filePath) => {
34
- const yamlText = fs.readFileSync(filePath, 'utf8');
35
- return yaml.load(yamlText);
36
- };
37
- const readConfigFromYamlFileAsJson = (filePath) => {
38
- const yamlText = fs.readFileSync(filePath, 'utf8');
39
- if (yamlText.includes('\n---\n')) {
40
- const yamlTexts = yamlText.split('\n---\n');
41
- const config = yaml.load(yamlTexts[0]);
42
- if (Object.keys(config).length > 0) {
43
- return config;
44
- }
45
- }
46
- return null;
47
- };
48
- async function walk(dir, filterFunction) {
49
- const readDirResult = await fs.promises.readdir(dir);
50
- const files = await Promise.all(readDirResult.map(async (file) => {
51
- const filePath = path.join(dir, file);
52
- const stats = await fs.promises.stat(filePath);
53
- if (stats.isDirectory())
54
- return walk(filePath);
55
- if (stats.isFile())
56
- if (filterFunction) {
57
- if (filterFunction(filePath))
58
- return filePath;
59
- }
60
- else {
61
- return filePath;
62
- }
63
- }));
64
- return files.flat().filter(Boolean);
65
- }
8
+ const planMethods_1 = require("./planMethods");
9
+ const commandsThatRequireFiles = new Set(['addMedia', 'runFlow', 'runScript']);
66
10
  async function plan(input, includeTags, excludeTags) {
67
11
  if (!fs.existsSync(input)) {
68
12
  throw new Error(`Flow path does not exist: ${path.resolve(input)}`);
69
13
  }
70
14
  if (fs.lstatSync(input).isFile()) {
71
- return { flowsToRun: [input.split('/').pop() ?? input], totalFlowFiles: 1 };
15
+ const directory = path.dirname(input);
16
+ const { testSteps } = (0, planMethods_1.readTestYamlFileAsJson)(input);
17
+ let errors = [];
18
+ let allFiles = [];
19
+ for (const command of testSteps) {
20
+ if (typeof command === 'string')
21
+ continue;
22
+ for (const [commandName, commandValue] of Object.entries(command)) {
23
+ if (commandsThatRequireFiles.has(commandName)) {
24
+ const { errors: newErrors, files } = (0, planMethods_1.checkIfFilesExistInWorkspace)(commandName, commandValue, path.normalize(input), directory + '/');
25
+ errors = [...errors, ...newErrors];
26
+ allFiles = [...allFiles, ...files];
27
+ }
28
+ }
29
+ }
30
+ if (errors.length > 0) {
31
+ throw new Error('The following flow files are not present in the provided directory: \n' +
32
+ errors.join('\n'));
33
+ }
34
+ return {
35
+ flowsToRun: [input.split('/').pop() ?? input],
36
+ referencedFiles: [...new Set(allFiles)],
37
+ totalFlowFiles: 1,
38
+ };
72
39
  }
73
- let unfilteredFlowFiles = await walk(input, isFlowFile);
40
+ let unfilteredFlowFiles = await (0, planMethods_1.walk)(input, planMethods_1.isFlowFile);
74
41
  if (unfilteredFlowFiles.length === 0) {
75
42
  throw new Error(`Flow directory does not contain any Flow files: ${path.resolve(input)}`);
76
43
  }
@@ -81,12 +48,17 @@ async function plan(input, includeTags, excludeTags) {
81
48
  const relativeFilePaths = unfilteredFlowFiles.map((file) => file.replace(cleanPath, ''));
82
49
  const topLevelFlowFiles = relativeFilePaths.filter((file) => !file.includes('/'));
83
50
  const workspaceConfig = configFilePath
84
- ? readYamlFileAsJson(configFilePath)
51
+ ? (0, planMethods_1.readYamlFileAsJson)(configFilePath)
85
52
  : {};
86
53
  let unsortedFlowFiles = topLevelFlowFiles;
87
54
  if (workspaceConfig.flows) {
88
- const matchers = workspaceConfig.flows.map((glob) => glob === '*' ? /^((?!\/).)*$/ : globToRegExp(glob));
89
- unsortedFlowFiles = relativeFilePaths.filter((filePath) => matchers.some((matcher) => matcher.test(filePath)));
55
+ const globs = workspaceConfig.flows.map((glob) => glob);
56
+ const matchedFiles = await (0, glob_1.glob)(globs, {
57
+ cwd: input,
58
+ nodir: true,
59
+ });
60
+ unsortedFlowFiles = matchedFiles.filter((file) => file !== 'config.yaml' &&
61
+ (file.endsWith('.yaml') || file.endsWith('.yml')));
90
62
  }
91
63
  if (unsortedFlowFiles.length === 0) {
92
64
  const error = workspaceConfig.flows
@@ -95,10 +67,42 @@ async function plan(input, includeTags, excludeTags) {
95
67
  throw error;
96
68
  }
97
69
  // eslint-disable-next-line unicorn/no-array-reduce
98
- const configPerFlowFile = unsortedFlowFiles.reduce((acc, filePath) => {
99
- acc[filePath] = readConfigFromYamlFileAsJson(cleanPath + filePath);
70
+ const { configPerFlowFile } = unsortedFlowFiles.reduce((acc, filePath) => {
71
+ const { config } = (0, planMethods_1.readTestYamlFileAsJson)(cleanPath + filePath);
72
+ acc.configPerFlowFile[filePath] = config;
100
73
  return acc;
101
- }, {});
74
+ }, {
75
+ configPerFlowFile: {},
76
+ });
77
+ // eslint-disable-next-line unicorn/no-array-reduce
78
+ const { testStepsPerFlowFile } = unfilteredFlowFiles.reduce((acc, filePath) => {
79
+ const { testSteps } = (0, planMethods_1.readTestYamlFileAsJson)(filePath);
80
+ acc.testStepsPerFlowFile[filePath] = testSteps;
81
+ return acc;
82
+ }, {
83
+ testStepsPerFlowFile: {},
84
+ });
85
+ let errors = [];
86
+ let allFiles = [];
87
+ for (const [filePath, commands] of Object.entries(testStepsPerFlowFile)) {
88
+ if (!commands)
89
+ break;
90
+ for (const command of commands) {
91
+ if (typeof command === 'string')
92
+ continue;
93
+ for (const [commandName, commandValue] of Object.entries(command)) {
94
+ if (commandsThatRequireFiles.has(commandName)) {
95
+ const { errors: newErrors, files } = (0, planMethods_1.checkIfFilesExistInWorkspace)(commandName, commandValue, filePath, cleanPath);
96
+ errors = [...errors, ...newErrors];
97
+ allFiles = [...allFiles, ...files];
98
+ }
99
+ }
100
+ }
101
+ }
102
+ if (errors.length > 0) {
103
+ throw new Error('The following flow files are not present in the provided directory: \n' +
104
+ errors.join('\n'));
105
+ }
102
106
  const allIncludeTags = [
103
107
  ...includeTags,
104
108
  ...(workspaceConfig.includeTags || []),
@@ -125,7 +129,7 @@ async function plan(input, includeTags, excludeTags) {
125
129
  return acc;
126
130
  }, {});
127
131
  const flowsToRunInSequence = workspaceConfig.executionOrder?.flowsOrder
128
- ?.map((flowOrder) => getFlowsToRunInSequence(pathsByName, [flowOrder]))
132
+ ?.map((flowOrder) => (0, planMethods_1.getFlowsToRunInSequence)(pathsByName, [flowOrder]))
129
133
  .flat() || [];
130
134
  const normalFlows = allFlows.filter((flow) => !flowsToRunInSequence.includes(flow));
131
135
  // for (const filePath of allFlows) {
@@ -142,7 +146,10 @@ async function plan(input, includeTags, excludeTags) {
142
146
  // );
143
147
  // }
144
148
  return {
149
+ allExcludeTags,
150
+ allIncludeTags,
145
151
  flowsToRun: normalFlows,
152
+ referencedFiles: [...new Set(allFiles)],
146
153
  sequence: {
147
154
  continueOnFailure: workspaceConfig.executionOrder?.continueOnFailure,
148
155
  flows: flowsToRunInSequence,
@@ -0,0 +1,17 @@
1
+ export declare function getFlowsToRunInSequence(paths: {
2
+ [key: string]: string;
3
+ }, flowOrder: string[]): string[];
4
+ export declare function isFlowFile(filePath: string): boolean;
5
+ export declare const readYamlFileAsJson: (filePath: string) => unknown;
6
+ export declare const readTestYamlFileAsJson: (filePath: string) => {
7
+ config: Record<string, unknown>;
8
+ testSteps: Record<string, unknown>[];
9
+ } | {
10
+ config: null;
11
+ testSteps: Record<string, unknown>[];
12
+ };
13
+ export declare function walk(dir: string, filterFunction?: (filePath: string) => boolean): Promise<string[]>;
14
+ export declare const checkIfFilesExistInWorkspace: (commandName: string, command: Record<string, string> | string | string[], filePath: string, cleanPath: string) => {
15
+ errors: string[];
16
+ files: string[];
17
+ };
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.checkIfFilesExistInWorkspace = exports.walk = exports.readTestYamlFileAsJson = exports.readYamlFileAsJson = exports.isFlowFile = exports.getFlowsToRunInSequence = void 0;
4
+ /* eslint-disable unicorn/filename-case */
5
+ const yaml = require("js-yaml");
6
+ const fs = require("node:fs");
7
+ const path = require("node:path");
8
+ function getFlowsToRunInSequence(paths, flowOrder) {
9
+ if (flowOrder.length === 0)
10
+ return [];
11
+ const orderSet = new Set(flowOrder);
12
+ const namesInOrder = Object.keys(paths).filter((key) => orderSet.has(key));
13
+ if (namesInOrder.length === 0)
14
+ return [];
15
+ const result = [...orderSet].filter((item) => namesInOrder.includes(item));
16
+ if (result.length === 0) {
17
+ throw new Error(`Could not find flows needed for execution in order: ${[...orderSet]
18
+ .filter((item) => !namesInOrder.includes(item))
19
+ .join(', ')}`);
20
+ }
21
+ else if (flowOrder
22
+ .slice(0, result.length)
23
+ .every((value, index) => value === result[index])) {
24
+ return result.map((item) => paths[item]);
25
+ }
26
+ else {
27
+ return [];
28
+ }
29
+ }
30
+ exports.getFlowsToRunInSequence = getFlowsToRunInSequence;
31
+ function isFlowFile(filePath) {
32
+ return filePath.endsWith('.yaml') || filePath.endsWith('.yml');
33
+ }
34
+ exports.isFlowFile = isFlowFile;
35
+ const readYamlFileAsJson = (filePath) => {
36
+ const yamlText = fs.readFileSync(filePath, 'utf8');
37
+ return yaml.load(yamlText);
38
+ };
39
+ exports.readYamlFileAsJson = readYamlFileAsJson;
40
+ const readTestYamlFileAsJson = (filePath) => {
41
+ const yamlText = fs.readFileSync(filePath, 'utf8');
42
+ if (yamlText.includes('\n---\n')) {
43
+ const yamlTexts = yamlText.split('\n---\n');
44
+ const config = yaml.load(yamlTexts[0]);
45
+ const testSteps = yaml.load(yamlTexts[1]);
46
+ if (Object.keys(config ?? {}).length > 0) {
47
+ return { config, testSteps };
48
+ }
49
+ }
50
+ const testSteps = yaml.load(yamlText);
51
+ if (Object.keys(testSteps).length > 0) {
52
+ return { config: null, testSteps };
53
+ }
54
+ return { config: null, testSteps };
55
+ };
56
+ exports.readTestYamlFileAsJson = readTestYamlFileAsJson;
57
+ async function walk(dir, filterFunction) {
58
+ const readDirResult = await fs.promises.readdir(dir);
59
+ const files = await Promise.all(readDirResult.map(async (file) => {
60
+ const filePath = path.join(dir, file);
61
+ const stats = await fs.promises.stat(filePath);
62
+ if (stats.isDirectory())
63
+ return walk(filePath, filterFunction);
64
+ if (stats.isFile())
65
+ if (filterFunction) {
66
+ if (filterFunction(filePath))
67
+ return filePath;
68
+ }
69
+ else {
70
+ return filePath;
71
+ }
72
+ }));
73
+ return files.flat().filter(Boolean);
74
+ }
75
+ exports.walk = walk;
76
+ const checkIfFilesExistInWorkspace = (commandName, command, filePath, cleanPath) => {
77
+ const errors = [];
78
+ const files = [];
79
+ const directory = path.dirname(filePath);
80
+ const buildError = (error) => `Flow file "${filePath}" has a command "${commandName}" that references a ${error} "${command}"`;
81
+ const processFilePath = (relativePath) => {
82
+ const absoluteFilePath = path.resolve(directory, relativePath);
83
+ const error = checkFile(absoluteFilePath, cleanPath);
84
+ if (error)
85
+ errors.push(buildError(error));
86
+ files.push(absoluteFilePath.replace(cleanPath, './'));
87
+ };
88
+ // simple command
89
+ if (typeof command === 'string')
90
+ processFilePath(command);
91
+ // array command
92
+ if (Array.isArray(command)) {
93
+ for (const file of command) {
94
+ processFilePath(file);
95
+ }
96
+ }
97
+ // object command
98
+ const x = command; // prevent annoying ts error
99
+ if (typeof command === 'object' && x?.file)
100
+ processFilePath(x.file);
101
+ return { errors, files };
102
+ };
103
+ exports.checkIfFilesExistInWorkspace = checkIfFilesExistInWorkspace;
104
+ const checkFile = (filePath, cleanPath) => {
105
+ if (!fs.existsSync(filePath))
106
+ return `non-existent file`;
107
+ if (!filePath.startsWith(cleanPath))
108
+ return `file outside the workspace`;
109
+ };
@@ -220,5 +220,5 @@
220
220
  ]
221
221
  }
222
222
  },
223
- "version": "0.0.2"
223
+ "version": "0.0.4"
224
224
  }
package/package.json CHANGED
@@ -12,8 +12,9 @@
12
12
  "@oclif/plugin-update": "^4.1.11",
13
13
  "@oclif/plugin-warn-if-update-available": "^3.0.10",
14
14
  "archiver": "^6.0.1",
15
- "glob-to-regexp": "^0.4.1",
16
- "js-yaml": "^4.1.0"
15
+ "glob": "^10.3.10",
16
+ "js-yaml": "^4.1.0",
17
+ "node-stream-zip": "^1.15.0"
17
18
  },
18
19
  "description": "Better cloud maestro testing",
19
20
  "devDependencies": {
@@ -72,9 +73,9 @@
72
73
  "test": "mocha --forbid-only \"test/**/*.test.ts\"",
73
74
  "version": "oclif readme && git add README.md"
74
75
  },
75
- "version": "0.0.2",
76
+ "version": "0.0.4",
76
77
  "bugs": {
77
- "url": "https://discord.gg/GzZBHcUJ"
78
+ "url": "https://discord.gg/gm3mJwcNw8"
78
79
  },
79
80
  "keywords": [
80
81
  "devicecloud",