@devicecloud.dev/dcd 0.0.1-alpha.1 → 0.0.1-alpha.10

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,19 +1,29 @@
1
1
  import { Command } from '@oclif/core';
2
2
  export default class Cloud extends Command {
3
3
  static args: {
4
- firstFile: import("@oclif/core/lib/interfaces").Arg<string, Record<string, unknown>>;
4
+ firstFile: import("@oclif/core/lib/interfaces").Arg<string | undefined, Record<string, unknown>>;
5
5
  secondFile: import("@oclif/core/lib/interfaces").Arg<string | undefined, Record<string, unknown>>;
6
6
  };
7
7
  static description: string;
8
8
  static examples: string[];
9
9
  static flags: {
10
- androidApiLevel: import("@oclif/core/lib/interfaces").OptionFlag<number | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
10
+ 'android-api-level': import("@oclif/core/lib/interfaces").OptionFlag<number | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
11
+ 'android-device': import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
11
12
  apiKey: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
12
13
  apiUrl: import("@oclif/core/lib/interfaces").OptionFlag<string, import("@oclif/core/lib/interfaces").CustomOptions>;
13
- appBinaryId: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
14
- appFile: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
14
+ 'app-binary-id': import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
15
+ 'app-file': import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
16
+ arm64: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
17
+ async: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
15
18
  env: import("@oclif/core/lib/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
16
- iOSVersion: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
19
+ 'exclude-tags': import("@oclif/core/lib/interfaces").OptionFlag<string[], import("@oclif/core/lib/interfaces").CustomOptions>;
20
+ flows: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
21
+ 'google-play': import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
22
+ 'include-tags': import("@oclif/core/lib/interfaces").OptionFlag<string[], import("@oclif/core/lib/interfaces").CustomOptions>;
23
+ 'ios-device': import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
24
+ 'ios-version': import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
25
+ name: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
26
+ orientation: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
17
27
  };
18
28
  run(): Promise<void>;
19
29
  }
@@ -1,32 +1,84 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ /* eslint-disable complexity */
3
4
  const core_1 = require("@oclif/core");
5
+ const cli_ux_1 = require("@oclif/core/lib/cli-ux");
6
+ const errors_1 = require("@oclif/core/lib/errors");
4
7
  const archiver = require("archiver");
5
- const node_fs_1 = require("node:fs");
6
8
  const promises_1 = require("node:fs/promises");
9
+ const node_stream_1 = require("node:stream");
10
+ const plan_1 = require("../plan");
7
11
  const mimeTypeLookupByExtension = {
8
12
  apk: 'application/vnd.android.package-archive',
9
13
  yaml: 'application/x-yaml',
10
14
  zip: 'application/zip',
11
15
  };
12
- const typeSafeFetch = async (baseUrl, path, init) => {
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) => {
13
34
  const res = await fetch(baseUrl + path, init);
14
35
  if (!res.ok) {
15
36
  throw new Error(await res.text());
16
37
  }
17
38
  return res.json();
18
39
  };
19
- const compress = async (zipTargetPath, sourceDir) => {
20
- const output = (0, node_fs_1.createWriteStream)(zipTargetPath);
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);
21
56
  const archive = archiver('zip', {
22
57
  zlib: { level: 9 },
23
58
  });
24
59
  archive.on('error', (err) => {
25
60
  throw err;
26
61
  });
27
- archive.pipe(output);
28
- archive.directory(sourceDir, false);
29
- await archive.finalize();
62
+ archive.directory(sourceDir, false, (data) => {
63
+ if (PERMITTED_EXTENSIONS.has(data.name.split('.').pop())) {
64
+ return data;
65
+ }
66
+ return false;
67
+ });
68
+ const buffer = await toBuffer(archive);
69
+ return buffer;
70
+ };
71
+ const compressFile = async (sourceFile) => {
72
+ const archive = archiver('zip', {
73
+ zlib: { level: 9 },
74
+ });
75
+ archive.on('error', (err) => {
76
+ throw err;
77
+ });
78
+ archive.file(sourceFile, { name: sourceFile });
79
+ const buffer = await toBuffer(archive);
80
+ // await writeFile('./my-zip.zip', buffer);
81
+ return buffer;
30
82
  };
31
83
  class Cloud extends core_1.Command {
32
84
  static args = {
@@ -34,7 +86,6 @@ class Cloud extends core_1.Command {
34
86
  description: 'The binary file of the app to run your flow against, e.g. app.apk for android or app.zip for ios',
35
87
  hidden: true,
36
88
  name: 'App file',
37
- required: true,
38
89
  }),
39
90
  secondFile: core_1.Args.string({
40
91
  description: 'The flow file to run against the app, e.g. test.yaml',
@@ -45,100 +96,277 @@ class Cloud extends core_1.Command {
45
96
  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`;
46
97
  static examples = ['<%= config.bin %> <%= command.id %>'];
47
98
  static flags = {
48
- androidApiLevel: core_1.Flags.integer({
49
- aliases: ['android-api-level'],
50
- description: 'Android API level to run your flow against',
99
+ 'android-api-level': core_1.Flags.integer({
100
+ description: '[Android only] Android API level to run your flow against',
101
+ options: ['32', '33', '34'],
102
+ }),
103
+ 'android-device': core_1.Flags.string({
104
+ description: '[Android only] Android device to run your flow against',
105
+ options: [
106
+ 'pixel-6',
107
+ 'pixel-6a',
108
+ 'pixel-6-pro',
109
+ 'pixel-7',
110
+ 'pixel-7-pro',
111
+ 'generic-tablet',
112
+ ],
113
+ }),
114
+ apiKey: core_1.Flags.string({
115
+ aliases: ['api-key'],
116
+ description: 'API key',
51
117
  }),
52
- apiKey: core_1.Flags.string({ aliases: ['api-key'], description: 'API key' }),
53
118
  apiUrl: core_1.Flags.string({
54
- aliases: ['api-url'],
119
+ aliases: ['api-url', 'apiURL'],
55
120
  default: 'https://api.devicecloud.dev',
56
121
  description: 'API base URL',
122
+ hidden: true,
57
123
  }),
58
- appBinaryId: core_1.Flags.string({
124
+ 'app-binary-id': core_1.Flags.string({
59
125
  aliases: ['app-binary-id'],
60
126
  description: 'The ID of the app binary previously uploaded to Maestro Cloud',
61
127
  }),
62
- appFile: core_1.Flags.file({
128
+ 'app-file': core_1.Flags.file({
63
129
  aliases: ['app-file'],
64
- description: 'App binary to run your Flows against',
130
+ description: 'App binary to run your flows against',
131
+ }),
132
+ arm64: core_1.Flags.boolean({
133
+ default: false,
134
+ description: '[Android only] Run your flow against arm64 devices',
135
+ }),
136
+ async: core_1.Flags.boolean({
137
+ description: 'Wait for the results of the run',
65
138
  }),
66
139
  env: core_1.Flags.file({
67
- aliases: ['env'],
68
140
  char: 'e',
69
- description: 'One or more environment variables to inject into your Flows',
141
+ description: 'One or more environment variables to inject into your flows',
142
+ multiple: true,
143
+ }),
144
+ 'exclude-tags': core_1.Flags.string({
145
+ aliases: ['exclude-tags'],
146
+ default: [],
147
+ description: 'Flows which have these tags will be excluded from the run',
70
148
  multiple: true,
149
+ parse: (input) => input.split(','),
71
150
  }),
72
- iOSVersion: core_1.Flags.string({
73
- aliases: ['ios-version'],
74
- description: 'iOS version to run your flow against',
151
+ flows: core_1.Flags.string({
152
+ description: 'The path to the flow file or folder containing your flows',
153
+ }),
154
+ 'google-play': core_1.Flags.boolean({
155
+ aliases: ['google-play'],
156
+ default: false,
157
+ description: '[Android only] Run your flow against Google Play devices',
158
+ }),
159
+ 'include-tags': core_1.Flags.string({
160
+ aliases: ['include-tags'],
161
+ default: [],
162
+ description: 'Only flows which have these tags will be included in the run',
163
+ multiple: true,
164
+ parse: (input) => input.split(','),
165
+ }),
166
+ 'ios-device': core_1.Flags.string({
167
+ description: '[iOS only] iOS device to run your flow against',
168
+ options: [
169
+ 'iphone-12',
170
+ 'iphone-12-mini',
171
+ 'iphone-12-pro-max',
172
+ 'iphone-13',
173
+ 'iphone-13-mini',
174
+ 'iphone-13-pro-max',
175
+ 'iphone-14',
176
+ 'iphone-14-plus',
177
+ 'iphone-14-pro',
178
+ 'iphone-14-pro-max',
179
+ 'iphone-15',
180
+ 'iphone-15-plus',
181
+ 'iphone-15-pro',
182
+ 'iphone-15-pro-max',
183
+ 'ipad-pro-6th-gen',
184
+ ],
185
+ }),
186
+ 'ios-version': core_1.Flags.string({
187
+ description: '[iOS only] iOS version to run your flow against',
188
+ options: ['15', '16', '17'],
189
+ }),
190
+ name: core_1.Flags.string({
191
+ description: 'A custom name for your upload (useful for tagging commits etc)',
192
+ }),
193
+ orientation: core_1.Flags.string({
194
+ description: '[Android only] The orientation of the device to run your flow against in degrees',
195
+ options: ['0', '90', '180', '270'],
75
196
  }),
76
197
  };
77
198
  async run() {
78
- const { args, flags } = await this.parse(Cloud);
79
- const { apiKey, apiUrl, appBinaryId, ...rest } = flags;
80
- console.log({ args });
81
- const { firstFile, secondFile } = args;
82
- let finalBinaryId = appBinaryId;
83
- const appFile = firstFile;
84
- let flowFile = secondFile;
85
- if (appBinaryId) {
86
- if (secondFile) {
87
- throw new Error('You cannot provide both an appBinaryId and a binary file');
88
- }
89
- flowFile = firstFile;
90
- this.log(`you want to run the flow ${flowFile} against the binary with id ${appBinaryId} with the following flags: ${JSON.stringify(flags)}`);
91
- }
92
- else {
93
- if (!appFile.endsWith('.apk') && !appFile.endsWith('.zip')) {
94
- throw new Error('App file must be a .apk or .zip file');
199
+ try {
200
+ const { args, flags } = await this.parse(Cloud);
201
+ 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;
202
+ if (arm64) {
203
+ (0, cli_ux_1.info)('Contact hello@devicecloud.dev to enquire about arm64 devices');
204
+ (0, cli_ux_1.exit)();
95
205
  }
96
- if (!(flowFile && appFile)) {
97
- throw new Error('You must provide a flow file and an app binary id');
206
+ const { firstFile, secondFile } = args;
207
+ let finalBinaryId = appBinaryId;
208
+ const finalAppFile = appFile ?? firstFile;
209
+ let flowFile = flows ?? secondFile;
210
+ if (appBinaryId) {
211
+ if (secondFile) {
212
+ throw new Error('You cannot provide both an appBinaryId and a binary file');
213
+ }
214
+ flowFile = flows ?? firstFile;
98
215
  }
99
- this.log(`you want to run the flow ${flowFile} against the app ${appFile} with the following flags: ${JSON.stringify(flags)}`);
100
- }
101
- if (!finalBinaryId) {
102
- const binaryFormData = new FormData();
103
- const binaryBlob = new Blob([await (0, promises_1.readFile)(appFile)], {
104
- type: mimeTypeLookupByExtension[appFile.split('.').pop()],
216
+ if (!flowFile) {
217
+ throw new Error('You must provide a flow file');
218
+ }
219
+ let testFileNames = [];
220
+ let continueOnFailure = true;
221
+ let sequentialFlows = [];
222
+ if (!flowFile?.endsWith('.yaml') &&
223
+ !flowFile?.endsWith('.yml') &&
224
+ !flowFile?.endsWith('/')) {
225
+ flowFile += '/';
226
+ }
227
+ try {
228
+ const executionPlan = await (0, plan_1.plan)(flowFile, includeTags.flat(), excludeTags.flat());
229
+ testFileNames = executionPlan.flowsToRun;
230
+ continueOnFailure = executionPlan.sequence?.continueOnFailure ?? true;
231
+ sequentialFlows = executionPlan.sequence?.flows ?? [];
232
+ }
233
+ catch (error) {
234
+ console.error(error);
235
+ }
236
+ if (!appBinaryId) {
237
+ if (!(flowFile && finalAppFile)) {
238
+ throw new Error('You must provide a flow file and an app binary id');
239
+ }
240
+ if (!finalAppFile.endsWith('.apk') && !finalAppFile.endsWith('.zip')) {
241
+ throw new Error('App file must be a .apk or .zip file');
242
+ }
243
+ }
244
+ const flagLogs = [];
245
+ for (const [k, v] of Object.entries(flags)) {
246
+ if (v && v.toString().length > 0) {
247
+ flagLogs.push(`${k}: ${v}`);
248
+ }
249
+ }
250
+ this.log(`
251
+
252
+ Submitting new job
253
+ → Flow(s): ${flowFile}
254
+ → App: ${appBinaryId || finalAppFile}
255
+
256
+ With options
257
+ → ${flagLogs.join(`
258
+ → `)}
259
+
260
+ `);
261
+ if (!finalBinaryId) {
262
+ core_1.ux.action.start('Uploading binary', 'Initializing', { stdout: true });
263
+ const binaryFormData = new FormData();
264
+ const binaryBlob = new Blob([await (0, promises_1.readFile)(finalAppFile)], {
265
+ type: mimeTypeLookupByExtension[finalAppFile.split('.').pop()],
266
+ });
267
+ binaryFormData.set('file', binaryBlob, finalAppFile);
268
+ const options = {
269
+ body: binaryFormData,
270
+ headers: { 'x-app-api-key': apiKey },
271
+ };
272
+ core_1.ux.action.status = `Uploading`;
273
+ const { binaryId, message } = await typeSafePost(apiUrl, '/uploads/binary', options);
274
+ if (!binaryId)
275
+ throw new Error(message);
276
+ core_1.ux.action.stop(`\nBinary uploaded with id: ${binaryId}`);
277
+ finalBinaryId = binaryId;
278
+ }
279
+ const testFormData = new FormData();
280
+ // eslint-disable-next-line unicorn/no-array-reduce
281
+ const envObject = (env ?? []).reduce((acc, cur) => {
282
+ const [key, value] = cur.split('=');
283
+ acc[key] = value;
284
+ return acc;
285
+ }, {});
286
+ const buffer = [...sequentialFlows, ...testFileNames]?.length > 1
287
+ ? await compressDir(flowFile.split('/').slice(0, -1).join('/'))
288
+ : await compressFile(flowFile);
289
+ const blob = new Blob([buffer], {
290
+ type: mimeTypeLookupByExtension.zip,
105
291
  });
106
- console.log(mimeTypeLookupByExtension[appFile.split('.').pop()]);
107
- binaryFormData.set('file', binaryBlob, appFile);
292
+ testFormData.set('file', blob, 'flowFile.zip');
293
+ testFormData.set('appBinaryId', finalBinaryId);
294
+ testFormData.set('testFileNames', JSON.stringify(testFileNames));
295
+ testFormData.set('sequentialFlows', JSON.stringify(sequentialFlows));
296
+ testFormData.set('env', JSON.stringify(envObject));
297
+ testFormData.set('googlePlay', googlePlay ? 'true' : 'false');
298
+ testFormData.set('config', JSON.stringify({ continueOnFailure, orientation }));
299
+ if (androidApiLevel) {
300
+ testFormData.set('androidApiLevel', androidApiLevel.toString());
301
+ }
302
+ if (androidDevice) {
303
+ testFormData.set('androidDevice', androidDevice.toString());
304
+ }
305
+ if (iOSVersion) {
306
+ testFormData.set('iOSVersion', iOSVersion.toString());
307
+ }
308
+ if (iosDevice) {
309
+ testFormData.set('iosDevice', iosDevice.toString());
310
+ }
311
+ if (name) {
312
+ testFormData.set('name', name.toString());
313
+ }
314
+ for (const [key, value] of Object.entries(rest)) {
315
+ if (value) {
316
+ testFormData.set(key, value);
317
+ }
318
+ }
108
319
  const options = {
109
- body: binaryFormData,
320
+ body: testFormData,
110
321
  headers: { 'x-app-api-key': apiKey },
111
- method: 'POST',
112
322
  };
113
- const { binaryId, message } = await typeSafeFetch(apiUrl, '/uploads/binary', options);
114
- if (!binaryId)
115
- throw new Error(message);
116
- this.log(message);
117
- finalBinaryId = binaryId;
118
- }
119
- const testFormData = new FormData();
120
- const flowFileDirectory = flowFile.split('/').slice(0, -1).join('/');
121
- const yamlFileName = flowFile.split('/').pop();
122
- console.log({ flowFileDirectory, yamlFileName });
123
- await compress('flows.zip', flowFileDirectory);
124
- const blob = new Blob([await (0, promises_1.readFile)('flows.zip')], {
125
- type: mimeTypeLookupByExtension.zip,
126
- });
127
- testFormData.set('file', blob, 'flowFile.zip');
128
- testFormData.set('appBinaryId', finalBinaryId);
129
- testFormData.set('testFileName', yamlFileName);
130
- for (const [key, value] of Object.entries(rest)) {
131
- if (value) {
132
- testFormData.set(key, value);
323
+ const { message, results } = await typeSafePost(apiUrl, '/uploads/flow', options);
324
+ if (!results?.length)
325
+ (0, errors_1.error)('No tests created: ' + message);
326
+ (0, cli_ux_1.info)(`\nCreated ${results.length} tests: ${results
327
+ .map((r) => r.test_file_name)
328
+ .join(', ')}\n`);
329
+ (0, cli_ux_1.info)('Run triggered, you can access the results at:');
330
+ const url = `https://console.devicecloud.dev/results/${results[0].test_upload_id}/${results[0].id}`;
331
+ core_1.ux.url(url, url);
332
+ if (async) {
333
+ (0, cli_ux_1.info)('Not waiting for results as async flag is set to true');
334
+ (0, cli_ux_1.exit)(0);
133
335
  }
336
+ // poll for the run status every 5 seconds
337
+ core_1.ux.action.start('Waiting for results', 'Initializing', { stdout: true });
338
+ (0, cli_ux_1.info)('\nYou can safely close this terminal and the tests will continue\n');
339
+ const intervalId = setInterval(async () => {
340
+ const { results: updatedResults } = await typeSafeGet(apiUrl, `/results/${results[0].test_upload_id}`, {
341
+ headers: { 'x-app-api-key': apiKey },
342
+ });
343
+ if (!updatedResults) {
344
+ clearInterval(intervalId);
345
+ (0, errors_1.error)('No results found');
346
+ }
347
+ core_1.ux.action.status = '\nStatus | Test\n─────────── ───────────';
348
+ for (const { status, test_file_name: test } of updatedResults) {
349
+ core_1.ux.action.status += `\n${status.padEnd(10, ' ')} | ${test}`;
350
+ }
351
+ if (updatedResults.every((result) => !['PENDING', 'RUNNING'].includes(result.status))) {
352
+ core_1.ux.action.stop('completed');
353
+ (0, cli_ux_1.info)('\n');
354
+ (0, cli_ux_1.table)(updatedResults, {
355
+ status: { get: (row) => row.status },
356
+ test: { get: (row) => row.test_file_name },
357
+ }, { printLine: this.log.bind(this) });
358
+ (0, cli_ux_1.info)('\n');
359
+ clearInterval(intervalId);
360
+ if (updatedResults.some((result) => result.status === 'FAILED')) {
361
+ (0, cli_ux_1.exit)(1);
362
+ }
363
+ }
364
+ }, 5000);
365
+ }
366
+ catch (error) {
367
+ console.error(error);
368
+ (0, cli_ux_1.exit)(1);
134
369
  }
135
- const options = {
136
- body: testFormData,
137
- headers: { 'x-app-api-key': apiKey },
138
- method: 'POST',
139
- };
140
- const { message } = await typeSafeFetch(apiUrl, '/uploads/flow', options);
141
- console.log(message);
142
370
  }
143
371
  }
144
372
  exports.default = Cloud;
package/dist/plan.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ interface IExecutionPlan {
2
+ flowsToRun: string[];
3
+ sequence?: IFlowSequence | null;
4
+ }
5
+ interface IFlowSequence {
6
+ continueOnFailure?: boolean;
7
+ flows: string[];
8
+ }
9
+ export declare function plan(input: string, includeTags: string[], excludeTags: string[]): Promise<IExecutionPlan>;
10
+ export {};
package/dist/plan.js ADDED
@@ -0,0 +1,152 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.plan = void 0;
4
+ const globToRegExp = require("glob-to-regexp");
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
+ 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
+ }
66
+ async function plan(input, includeTags, excludeTags) {
67
+ if (!fs.existsSync(input)) {
68
+ throw new Error(`Flow path does not exist: ${path.resolve(input)}`);
69
+ }
70
+ if (fs.lstatSync(input).isFile()) {
71
+ return { flowsToRun: [input] };
72
+ }
73
+ let unfilteredFlowFiles = await walk(input, isFlowFile);
74
+ if (unfilteredFlowFiles.length === 0) {
75
+ throw new Error(`Flow directory does not contain any Flow files: ${path.resolve(input)}`);
76
+ }
77
+ const configFilePath = unfilteredFlowFiles.find((file) => file.endsWith('config.yaml') || file.endsWith('config.yml'));
78
+ if (configFilePath)
79
+ unfilteredFlowFiles = unfilteredFlowFiles.filter((file) => file !== configFilePath);
80
+ const cleanPath = path.normalize(input);
81
+ const relativeFilePaths = unfilteredFlowFiles.map((file) => file.replace(cleanPath, ''));
82
+ const topLevelFlowFiles = relativeFilePaths.filter((file) => !file.includes('/'));
83
+ const workspaceConfig = configFilePath
84
+ ? readYamlFileAsJson(configFilePath)
85
+ : {};
86
+ let unsortedFlowFiles = topLevelFlowFiles;
87
+ if (workspaceConfig.flows) {
88
+ const matchers = workspaceConfig.flows.map((glob) => glob === '*' ? /^((?!\/).)*$/ : globToRegExp(glob));
89
+ unsortedFlowFiles = relativeFilePaths.filter((filePath) => matchers.some((matcher) => matcher.test(filePath)));
90
+ }
91
+ if (unsortedFlowFiles.length === 0) {
92
+ const error = workspaceConfig.flows
93
+ ? new Error(`Flow inclusion pattern(s) did not match any Flow files:\n${workspaceConfig.flows.join('\n')}`)
94
+ : new Error(`Top-level directory does not contain any Flows: ${path.resolve(input)}`);
95
+ throw error;
96
+ }
97
+ // eslint-disable-next-line unicorn/no-array-reduce
98
+ const configPerFlowFile = unsortedFlowFiles.reduce((acc, filePath) => {
99
+ acc[filePath] = readConfigFromYamlFileAsJson(cleanPath + filePath);
100
+ return acc;
101
+ }, {});
102
+ const allIncludeTags = [
103
+ ...includeTags,
104
+ ...(workspaceConfig.includeTags || []),
105
+ ];
106
+ const allExcludeTags = [
107
+ ...excludeTags,
108
+ ...(workspaceConfig.excludeTags || []),
109
+ ];
110
+ const allFlows = unsortedFlowFiles.filter((filePath) => {
111
+ const config = configPerFlowFile[filePath];
112
+ const tags = config?.tags || [];
113
+ return ((allIncludeTags.length === 0 ||
114
+ tags.some((tag) => allIncludeTags.includes(tag))) &&
115
+ (allExcludeTags.length === 0 ||
116
+ !tags.some((tag) => allExcludeTags.includes(tag))));
117
+ });
118
+ if (allFlows.length === 0) {
119
+ throw new Error(`Include / Exclude tags did not match any Flows:\n\nInclude Tags:\n${allIncludeTags.join('\n')}\n\nExclude Tags:\n${allExcludeTags.join('\n')}`);
120
+ }
121
+ // eslint-disable-next-line unicorn/no-array-reduce
122
+ const pathsByName = allFlows.reduce((acc, filePath) => {
123
+ const config = configPerFlowFile[filePath];
124
+ acc[config?.name || path.parse(filePath).name] = filePath;
125
+ return acc;
126
+ }, {});
127
+ const flowsToRunInSequence = workspaceConfig.executionOrder?.flowsOrder
128
+ ?.map((flowOrder) => getFlowsToRunInSequence(pathsByName, [flowOrder]))
129
+ .flat() || [];
130
+ const normalFlows = allFlows.filter((flow) => !flowsToRunInSequence.includes(flow));
131
+ // for (const filePath of allFlows) {
132
+ // const commands = YamlCommandReader.readCommands(filePath).filter(
133
+ // (command) => command.addMediaCommand,
134
+ // );
135
+ // const mediaPaths = commands.flatMap(
136
+ // (command) => command.addMediaCommand.mediaPaths,
137
+ // );
138
+ // YamlCommandsPathValidator.validatePathsExistInWorkspace(
139
+ // input,
140
+ // filePath,
141
+ // mediaPaths,
142
+ // );
143
+ // }
144
+ return {
145
+ flowsToRun: normalFlows,
146
+ sequence: {
147
+ continueOnFailure: workspaceConfig.executionOrder?.continueOnFailure,
148
+ flows: flowsToRunInSequence,
149
+ },
150
+ };
151
+ }
152
+ exports.plan = plan;
@@ -6,8 +6,7 @@
6
6
  "firstFile": {
7
7
  "description": "The binary file of the app to run your flow against, e.g. app.apk for android or app.zip for ios",
8
8
  "hidden": true,
9
- "name": "firstFile",
10
- "required": true
9
+ "name": "firstFile"
11
10
  },
12
11
  "secondFile": {
13
12
  "description": "The flow file to run against the app, e.g. test.yaml",
@@ -20,14 +19,31 @@
20
19
  "<%= config.bin %> <%= command.id %>"
21
20
  ],
22
21
  "flags": {
23
- "androidApiLevel": {
24
- "aliases": [
25
- "android-api-level"
22
+ "android-api-level": {
23
+ "description": "[Android only] Android API level to run your flow against",
24
+ "name": "android-api-level",
25
+ "hasDynamicHelp": false,
26
+ "multiple": false,
27
+ "options": [
28
+ "32",
29
+ "33",
30
+ "34"
26
31
  ],
27
- "description": "Android API level to run your flow against",
28
- "name": "androidApiLevel",
32
+ "type": "option"
33
+ },
34
+ "android-device": {
35
+ "description": "[Android only] Android device to run your flow against",
36
+ "name": "android-device",
29
37
  "hasDynamicHelp": false,
30
38
  "multiple": false,
39
+ "options": [
40
+ "pixel-6",
41
+ "pixel-6a",
42
+ "pixel-6-pro",
43
+ "pixel-7",
44
+ "pixel-7-pro",
45
+ "generic-tablet"
46
+ ],
31
47
  "type": "option"
32
48
  },
33
49
  "apiKey": {
@@ -42,55 +58,150 @@
42
58
  },
43
59
  "apiUrl": {
44
60
  "aliases": [
45
- "api-url"
61
+ "api-url",
62
+ "apiURL"
46
63
  ],
47
64
  "description": "API base URL",
65
+ "hidden": true,
48
66
  "name": "apiUrl",
49
67
  "default": "https://api.devicecloud.dev",
50
68
  "hasDynamicHelp": false,
51
69
  "multiple": false,
52
70
  "type": "option"
53
71
  },
54
- "appBinaryId": {
72
+ "app-binary-id": {
55
73
  "aliases": [
56
74
  "app-binary-id"
57
75
  ],
58
76
  "description": "The ID of the app binary previously uploaded to Maestro Cloud",
59
- "name": "appBinaryId",
77
+ "name": "app-binary-id",
60
78
  "hasDynamicHelp": false,
61
79
  "multiple": false,
62
80
  "type": "option"
63
81
  },
64
- "appFile": {
82
+ "app-file": {
65
83
  "aliases": [
66
84
  "app-file"
67
85
  ],
68
- "description": "App binary to run your Flows against",
69
- "name": "appFile",
86
+ "description": "App binary to run your flows against",
87
+ "name": "app-file",
70
88
  "hasDynamicHelp": false,
71
89
  "multiple": false,
72
90
  "type": "option"
73
91
  },
92
+ "arm64": {
93
+ "description": "[Android only] Run your flow against arm64 devices",
94
+ "name": "arm64",
95
+ "allowNo": false,
96
+ "type": "boolean"
97
+ },
98
+ "async": {
99
+ "description": "Wait for the results of the run",
100
+ "name": "async",
101
+ "allowNo": false,
102
+ "type": "boolean"
103
+ },
74
104
  "env": {
75
- "aliases": [
76
- "env"
77
- ],
78
105
  "char": "e",
79
- "description": "One or more environment variables to inject into your Flows",
106
+ "description": "One or more environment variables to inject into your flows",
80
107
  "name": "env",
81
108
  "hasDynamicHelp": false,
82
109
  "multiple": true,
83
110
  "type": "option"
84
111
  },
85
- "iOSVersion": {
112
+ "exclude-tags": {
86
113
  "aliases": [
87
- "ios-version"
114
+ "exclude-tags"
88
115
  ],
89
- "description": "iOS version to run your flow against",
90
- "name": "iOSVersion",
116
+ "description": "Flows which have these tags will be excluded from the run",
117
+ "name": "exclude-tags",
118
+ "default": [],
119
+ "hasDynamicHelp": false,
120
+ "multiple": true,
121
+ "type": "option"
122
+ },
123
+ "flows": {
124
+ "description": "The path to the flow file or folder containing your flows",
125
+ "name": "flows",
91
126
  "hasDynamicHelp": false,
92
127
  "multiple": false,
93
128
  "type": "option"
129
+ },
130
+ "google-play": {
131
+ "aliases": [
132
+ "google-play"
133
+ ],
134
+ "description": "[Android only] Run your flow against Google Play devices",
135
+ "name": "google-play",
136
+ "allowNo": false,
137
+ "type": "boolean"
138
+ },
139
+ "include-tags": {
140
+ "aliases": [
141
+ "include-tags"
142
+ ],
143
+ "description": "Only flows which have these tags will be included in the run",
144
+ "name": "include-tags",
145
+ "default": [],
146
+ "hasDynamicHelp": false,
147
+ "multiple": true,
148
+ "type": "option"
149
+ },
150
+ "ios-device": {
151
+ "description": "[iOS only] iOS device to run your flow against",
152
+ "name": "ios-device",
153
+ "hasDynamicHelp": false,
154
+ "multiple": false,
155
+ "options": [
156
+ "iphone-12",
157
+ "iphone-12-mini",
158
+ "iphone-12-pro-max",
159
+ "iphone-13",
160
+ "iphone-13-mini",
161
+ "iphone-13-pro-max",
162
+ "iphone-14",
163
+ "iphone-14-plus",
164
+ "iphone-14-pro",
165
+ "iphone-14-pro-max",
166
+ "iphone-15",
167
+ "iphone-15-plus",
168
+ "iphone-15-pro",
169
+ "iphone-15-pro-max",
170
+ "ipad-pro-6th-gen"
171
+ ],
172
+ "type": "option"
173
+ },
174
+ "ios-version": {
175
+ "description": "[iOS only] iOS version to run your flow against",
176
+ "name": "ios-version",
177
+ "hasDynamicHelp": false,
178
+ "multiple": false,
179
+ "options": [
180
+ "15",
181
+ "16",
182
+ "17"
183
+ ],
184
+ "type": "option"
185
+ },
186
+ "name": {
187
+ "description": "A custom name for your upload (useful for tagging commits etc)",
188
+ "name": "name",
189
+ "hasDynamicHelp": false,
190
+ "multiple": false,
191
+ "type": "option"
192
+ },
193
+ "orientation": {
194
+ "description": "[Android only] The orientation of the device to run your flow against in degrees",
195
+ "name": "orientation",
196
+ "hasDynamicHelp": false,
197
+ "multiple": false,
198
+ "options": [
199
+ "0",
200
+ "90",
201
+ "180",
202
+ "270"
203
+ ],
204
+ "type": "option"
94
205
  }
95
206
  },
96
207
  "hasDynamicHelp": false,
@@ -109,5 +220,5 @@
109
220
  ]
110
221
  }
111
222
  },
112
- "version": "0.0.1-alpha.1"
223
+ "version": "0.0.1-alpha.10"
113
224
  }
package/package.json CHANGED
@@ -11,7 +11,9 @@
11
11
  "@oclif/plugin-plugins": "^4",
12
12
  "@oclif/plugin-update": "^4.1.11",
13
13
  "@oclif/plugin-warn-if-update-available": "^3.0.10",
14
- "archiver": "^6.0.1"
14
+ "archiver": "^6.0.1",
15
+ "glob-to-regexp": "^0.4.1",
16
+ "js-yaml": "^4.1.0"
15
17
  },
16
18
  "description": "Better cloud maestro testing",
17
19
  "devDependencies": {
@@ -19,6 +21,8 @@
19
21
  "@oclif/test": "^3",
20
22
  "@types/archiver": "^6.0.2",
21
23
  "@types/chai": "^4",
24
+ "@types/glob-to-regexp": "^0.4.4",
25
+ "@types/js-yaml": "^4.0.9",
22
26
  "@types/mocha": "^9.0.0",
23
27
  "chai": "^4",
24
28
  "eslint": "^8.56.0",
@@ -49,10 +53,7 @@
49
53
  "commands": "./dist/commands",
50
54
  "plugins": [
51
55
  "@oclif/plugin-help",
52
- "@oclif/plugin-not-found",
53
- "@oclif/plugin-update",
54
- "@oclif/plugin-warn-if-update-available",
55
- "@oclif/plugin-autocomplete"
56
+ "@oclif/plugin-not-found"
56
57
  ]
57
58
  },
58
59
  "private": false,
@@ -61,6 +62,7 @@
61
62
  "url": "@devicecloud.dev/dcd"
62
63
  },
63
64
  "scripts": {
65
+ "dcd": "./bin/dev.js",
64
66
  "build": "shx rm -rf dist && tsc -b",
65
67
  "lint": "eslint . --ext .ts",
66
68
  "postpack": "shx rm -f oclif.manifest.json",
@@ -70,7 +72,7 @@
70
72
  "test": "mocha --forbid-only \"test/**/*.test.ts\"",
71
73
  "version": "oclif readme && git add README.md"
72
74
  },
73
- "version": "0.0.1-alpha.1",
75
+ "version": "0.0.1-alpha.10",
74
76
  "bugs": {
75
77
  "url": "https://discord.gg/GzZBHcUJ"
76
78
  },