@devicecloud.dev/dcd 0.0.1-alpha.0 → 0.0.1-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,14 +5,14 @@ One line swap out for Maestro Cloud
5
5
 
6
6
  Install:
7
7
  ```sh-session
8
- $ npm install -g @devicecloud/dcd
8
+ $ npm install -g @devicecloud.dev/dcd
9
9
  ```
10
10
 
11
11
  Use:
12
- ```sh
12
+ ```sh-session
13
13
  # maestro cloud --apiKey <apiKey> <appFile> .myFlows/
14
14
 
15
- dcd cloud --apiKey <apiKey> <appFile> .myFlows/
15
+ $ dcd cloud --apiKey <apiKey> <appFile> .myFlows/
16
16
  ```
17
17
 
18
18
  See full documentation: [Docs](https://docs.devicecloud.dev)
@@ -1,7 +1,7 @@
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;
@@ -13,6 +13,7 @@ export default class Cloud extends Command {
13
13
  appBinaryId: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
14
14
  appFile: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
15
15
  env: import("@oclif/core/lib/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
16
+ flows: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
16
17
  iOSVersion: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
17
18
  };
18
19
  run(): Promise<void>;
@@ -1,12 +1,25 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const core_1 = require("@oclif/core");
4
+ const archiver = require("archiver");
4
5
  const promises_1 = require("node:fs/promises");
6
+ const node_stream_1 = require("node:stream");
7
+ const plan_1 = require("../plan");
5
8
  const mimeTypeLookupByExtension = {
6
9
  apk: 'application/vnd.android.package-archive',
7
10
  yaml: 'application/x-yaml',
8
11
  zip: 'application/zip',
9
12
  };
13
+ const PERMITTED_EXTENSIONS = new Set([
14
+ 'yml',
15
+ 'yaml',
16
+ 'png',
17
+ 'jpg',
18
+ 'jpeg',
19
+ 'gif',
20
+ 'mp4',
21
+ 'js',
22
+ ]);
10
23
  const typeSafeFetch = async (baseUrl, path, init) => {
11
24
  const res = await fetch(baseUrl + path, init);
12
25
  if (!res.ok) {
@@ -14,13 +27,44 @@ const typeSafeFetch = async (baseUrl, path, init) => {
14
27
  }
15
28
  return res.json();
16
29
  };
30
+ const toBuffer = async (archive) => {
31
+ const chunks = [];
32
+ const writable = new node_stream_1.Writable();
33
+ writable._write = (chunk, _, callback) => {
34
+ // save to array to concatenate later
35
+ chunks.push(chunk);
36
+ callback();
37
+ };
38
+ // pipe to writable
39
+ archive.pipe(writable);
40
+ await archive.finalize();
41
+ // once done, concatenate chunks
42
+ return Buffer.concat(chunks);
43
+ };
44
+ const compress = async (sourceDir) => {
45
+ // const output = createWriteStream(zipTargetPath);
46
+ const archive = archiver('zip', {
47
+ zlib: { level: 9 },
48
+ });
49
+ archive.on('error', (err) => {
50
+ throw err;
51
+ });
52
+ // archive.pipe(output);
53
+ archive.directory(sourceDir, false, (data) => {
54
+ if (PERMITTED_EXTENSIONS.has(data.name.split('.').pop())) {
55
+ return data;
56
+ }
57
+ return false;
58
+ });
59
+ const buffer = await toBuffer(archive);
60
+ return buffer;
61
+ };
17
62
  class Cloud extends core_1.Command {
18
63
  static args = {
19
64
  firstFile: core_1.Args.string({
20
65
  description: 'The binary file of the app to run your flow against, e.g. app.apk for android or app.zip for ios',
21
66
  hidden: true,
22
67
  name: 'App file',
23
- required: true,
24
68
  }),
25
69
  secondFile: core_1.Args.string({
26
70
  description: 'The flow file to run against the app, e.g. test.yaml',
@@ -34,12 +78,14 @@ class Cloud extends core_1.Command {
34
78
  androidApiLevel: core_1.Flags.integer({
35
79
  aliases: ['android-api-level'],
36
80
  description: 'Android API level to run your flow against',
81
+ options: ['32', '33', '34'],
37
82
  }),
38
83
  apiKey: core_1.Flags.string({ aliases: ['api-key'], description: 'API key' }),
39
84
  apiUrl: core_1.Flags.string({
40
85
  aliases: ['api-url'],
41
86
  default: 'https://api.devicecloud.dev',
42
87
  description: 'API base URL',
88
+ hidden: true,
43
89
  }),
44
90
  appBinaryId: core_1.Flags.string({
45
91
  aliases: ['app-binary-id'],
@@ -55,42 +101,47 @@ class Cloud extends core_1.Command {
55
101
  description: 'One or more environment variables to inject into your Flows',
56
102
  multiple: true,
57
103
  }),
104
+ flows: core_1.Flags.string({
105
+ aliases: ['flows'],
106
+ description: 'The path to the flow file or folder containing your Flows',
107
+ }),
58
108
  iOSVersion: core_1.Flags.string({
59
109
  aliases: ['ios-version'],
60
110
  description: 'iOS version to run your flow against',
111
+ options: ['16.4', '17.2'],
61
112
  }),
62
113
  };
63
114
  async run() {
64
115
  const { args, flags } = await this.parse(Cloud);
65
- const { apiKey, apiUrl, appBinaryId, ...rest } = flags;
116
+ const { apiKey, apiUrl, appBinaryId, appFile, env, flows, ...rest } = flags;
66
117
  console.log({ args });
67
118
  const { firstFile, secondFile } = args;
68
119
  let finalBinaryId = appBinaryId;
69
- const appFile = firstFile;
70
- let flowFile = secondFile;
120
+ const finalAppFile = appFile ?? firstFile;
121
+ let flowFile = flows ?? secondFile;
71
122
  if (appBinaryId) {
72
123
  if (secondFile) {
73
124
  throw new Error('You cannot provide both an appBinaryId and a binary file');
74
125
  }
75
- flowFile = firstFile;
76
- this.log(`you want to run the flow ${flowFile} against the binary with id ${appBinaryId} with the following flags: ${JSON.stringify(flags)}`);
126
+ flowFile = flows ?? firstFile;
127
+ this.log(`you want to run the flow(s) ${flowFile} against the binary with id ${appBinaryId} with the following flags: ${JSON.stringify(flags)}`);
77
128
  }
78
129
  else {
79
- if (!appFile.endsWith('.apk') && !appFile.endsWith('.zip')) {
80
- throw new Error('App file must be a .apk or .zip file');
81
- }
82
- if (!(flowFile && appFile)) {
130
+ if (!(flowFile && finalAppFile)) {
83
131
  throw new Error('You must provide a flow file and an app binary id');
84
132
  }
85
- this.log(`you want to run the flow ${flowFile} against the app ${appFile} with the following flags: ${JSON.stringify(flags)}`);
133
+ if (!finalAppFile.endsWith('.apk') && !finalAppFile.endsWith('.zip')) {
134
+ throw new Error('App file must be a .apk or .zip file');
135
+ }
136
+ this.log(`you want to run the flow(s) ${flowFile} against the app ${finalAppFile} with the following flags: ${JSON.stringify(flags)}`);
86
137
  }
87
138
  if (!finalBinaryId) {
88
139
  const binaryFormData = new FormData();
89
- const binaryBlob = new Blob([await (0, promises_1.readFile)(appFile)], {
90
- type: mimeTypeLookupByExtension[appFile.split('.').pop()],
140
+ const binaryBlob = new Blob([await (0, promises_1.readFile)(finalAppFile)], {
141
+ type: mimeTypeLookupByExtension[finalAppFile.split('.').pop()],
91
142
  });
92
- console.log(mimeTypeLookupByExtension[appFile.split('.').pop()]);
93
- binaryFormData.set('file', binaryBlob, appFile);
143
+ console.log(mimeTypeLookupByExtension[finalAppFile.split('.').pop()]);
144
+ binaryFormData.set('file', binaryBlob, finalAppFile);
94
145
  const options = {
95
146
  body: binaryFormData,
96
147
  headers: { 'x-app-api-key': apiKey },
@@ -102,13 +153,44 @@ class Cloud extends core_1.Command {
102
153
  this.log(message);
103
154
  finalBinaryId = binaryId;
104
155
  }
156
+ const testFileNames = [];
157
+ let flowFileDirectory = flowFile;
158
+ if (flowFile?.endsWith('/')) {
159
+ try {
160
+ const executionPlan = await (0, plan_1.plan)(flowFile, [], []);
161
+ for (const file of executionPlan.flowsToRun) {
162
+ testFileNames.push(file);
163
+ }
164
+ for (const file of executionPlan.sequence?.flows ?? []) {
165
+ // todo: handle continueOnFailure and other sequence properties
166
+ testFileNames.push(file);
167
+ }
168
+ console.log(executionPlan);
169
+ }
170
+ catch (error) {
171
+ console.error(error);
172
+ }
173
+ }
174
+ else {
175
+ // we are working with a single file
176
+ flowFileDirectory = flowFile.split('/').slice(0, -1).join('/');
177
+ testFileNames.push(flowFile.split('/').pop());
178
+ }
105
179
  const testFormData = new FormData();
106
- const blob = new Blob([await (0, promises_1.readFile)(flowFile)], {
107
- type: mimeTypeLookupByExtension[flowFile.split('.').pop()],
180
+ // eslint-disable-next-line unicorn/no-array-reduce
181
+ const envObject = (env ?? []).reduce((acc, cur) => {
182
+ const [key, value] = cur.split('=');
183
+ acc[key] = value;
184
+ return acc;
185
+ }, {});
186
+ const buffer = await compress(flowFileDirectory);
187
+ const blob = new Blob([buffer], {
188
+ type: mimeTypeLookupByExtension.zip,
108
189
  });
109
- testFormData.set('file', blob, flowFile);
190
+ testFormData.set('file', blob, 'flowFile.zip');
110
191
  testFormData.set('appBinaryId', finalBinaryId);
111
- testFormData.set('testFileName', flowFile);
192
+ testFormData.set('testFileNames', JSON.stringify(testFileNames));
193
+ testFormData.set('env', JSON.stringify(envObject));
112
194
  for (const [key, value] of Object.entries(rest)) {
113
195
  if (value) {
114
196
  testFormData.set(key, value);
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).filter((key) => key === 'appId').length > 1) {
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",
@@ -28,6 +27,11 @@
28
27
  "name": "androidApiLevel",
29
28
  "hasDynamicHelp": false,
30
29
  "multiple": false,
30
+ "options": [
31
+ "32",
32
+ "33",
33
+ "34"
34
+ ],
31
35
  "type": "option"
32
36
  },
33
37
  "apiKey": {
@@ -45,6 +49,7 @@
45
49
  "api-url"
46
50
  ],
47
51
  "description": "API base URL",
52
+ "hidden": true,
48
53
  "name": "apiUrl",
49
54
  "default": "https://api.devicecloud.dev",
50
55
  "hasDynamicHelp": false,
@@ -82,6 +87,16 @@
82
87
  "multiple": true,
83
88
  "type": "option"
84
89
  },
90
+ "flows": {
91
+ "aliases": [
92
+ "flows"
93
+ ],
94
+ "description": "The path to the flow file or folder containing your Flows",
95
+ "name": "flows",
96
+ "hasDynamicHelp": false,
97
+ "multiple": false,
98
+ "type": "option"
99
+ },
85
100
  "iOSVersion": {
86
101
  "aliases": [
87
102
  "ios-version"
@@ -90,6 +105,10 @@
90
105
  "name": "iOSVersion",
91
106
  "hasDynamicHelp": false,
92
107
  "multiple": false,
108
+ "options": [
109
+ "16.4",
110
+ "17.2"
111
+ ],
93
112
  "type": "option"
94
113
  }
95
114
  },
@@ -109,5 +128,5 @@
109
128
  ]
110
129
  }
111
130
  },
112
- "version": "0.0.1-alpha.0"
131
+ "version": "0.0.1-alpha.3"
113
132
  }
package/package.json CHANGED
@@ -10,13 +10,19 @@
10
10
  "@oclif/plugin-not-found": "^3.0.10",
11
11
  "@oclif/plugin-plugins": "^4",
12
12
  "@oclif/plugin-update": "^4.1.11",
13
- "@oclif/plugin-warn-if-update-available": "^3.0.10"
13
+ "@oclif/plugin-warn-if-update-available": "^3.0.10",
14
+ "archiver": "^6.0.1",
15
+ "glob-to-regexp": "^0.4.1",
16
+ "js-yaml": "^4.1.0"
14
17
  },
15
18
  "description": "Better cloud maestro testing",
16
19
  "devDependencies": {
17
20
  "@oclif/prettier-config": "^0.2.1",
18
21
  "@oclif/test": "^3",
22
+ "@types/archiver": "^6.0.2",
19
23
  "@types/chai": "^4",
24
+ "@types/glob-to-regexp": "^0.4.4",
25
+ "@types/js-yaml": "^4.0.9",
20
26
  "@types/mocha": "^9.0.0",
21
27
  "chai": "^4",
22
28
  "eslint": "^8.56.0",
@@ -68,15 +74,17 @@
68
74
  "test": "mocha --forbid-only \"test/**/*.test.ts\"",
69
75
  "version": "oclif readme && git add README.md"
70
76
  },
71
- "version": "0.0.1-alpha.0",
77
+ "version": "0.0.1-alpha.3",
72
78
  "bugs": {
73
79
  "url": "https://discord.gg/GzZBHcUJ"
74
80
  },
75
81
  "keywords": [
76
- "oclif"
82
+ "devicecloud",
83
+ "devicecloud.dev",
84
+ "dcd",
85
+ "cloud",
86
+ "maestro",
87
+ "testing"
77
88
  ],
78
- "types": "dist/index.d.ts",
79
- "directories": {
80
- "test": "test"
81
- }
82
- }
89
+ "types": "dist/index.d.ts"
90
+ }