@devicecloud.dev/dcd 0.0.1-alpha.1 → 0.0.1-alpha.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,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,14 +1,26 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ /* eslint-disable complexity */
3
4
  const core_1 = require("@oclif/core");
4
5
  const archiver = require("archiver");
5
- const node_fs_1 = require("node:fs");
6
6
  const promises_1 = require("node:fs/promises");
7
+ const node_stream_1 = require("node:stream");
8
+ const plan_1 = require("../plan");
7
9
  const mimeTypeLookupByExtension = {
8
10
  apk: 'application/vnd.android.package-archive',
9
11
  yaml: 'application/x-yaml',
10
12
  zip: 'application/zip',
11
13
  };
14
+ const PERMITTED_EXTENSIONS = new Set([
15
+ 'yml',
16
+ 'yaml',
17
+ 'png',
18
+ 'jpg',
19
+ 'jpeg',
20
+ 'gif',
21
+ 'mp4',
22
+ 'js',
23
+ ]);
12
24
  const typeSafeFetch = async (baseUrl, path, init) => {
13
25
  const res = await fetch(baseUrl + path, init);
14
26
  if (!res.ok) {
@@ -16,17 +28,48 @@ const typeSafeFetch = async (baseUrl, path, init) => {
16
28
  }
17
29
  return res.json();
18
30
  };
19
- const compress = async (zipTargetPath, sourceDir) => {
20
- const output = (0, node_fs_1.createWriteStream)(zipTargetPath);
31
+ const toBuffer = async (archive) => {
32
+ const chunks = [];
33
+ const writable = new node_stream_1.Writable();
34
+ writable._write = (chunk, _, callback) => {
35
+ // save to array to concatenate later
36
+ chunks.push(chunk);
37
+ callback();
38
+ };
39
+ // pipe to writable
40
+ archive.pipe(writable);
41
+ await archive.finalize();
42
+ // once done, concatenate chunks
43
+ return Buffer.concat(chunks);
44
+ };
45
+ const compressDir = async (sourceDir) => {
46
+ // const output = createWriteStream(zipTargetPath);
21
47
  const archive = archiver('zip', {
22
48
  zlib: { level: 9 },
23
49
  });
24
50
  archive.on('error', (err) => {
25
51
  throw err;
26
52
  });
27
- archive.pipe(output);
28
- archive.directory(sourceDir, false);
29
- await archive.finalize();
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
+ };
62
+ const compressFile = async (sourceFile) => {
63
+ const archive = archiver('zip', {
64
+ zlib: { level: 9 },
65
+ });
66
+ archive.on('error', (err) => {
67
+ throw err;
68
+ });
69
+ archive.file(sourceFile, { name: sourceFile });
70
+ const buffer = await toBuffer(archive);
71
+ // await writeFile('./my-zip.zip', buffer);
72
+ return buffer;
30
73
  };
31
74
  class Cloud extends core_1.Command {
32
75
  static args = {
@@ -34,7 +77,6 @@ class Cloud extends core_1.Command {
34
77
  description: 'The binary file of the app to run your flow against, e.g. app.apk for android or app.zip for ios',
35
78
  hidden: true,
36
79
  name: 'App file',
37
- required: true,
38
80
  }),
39
81
  secondFile: core_1.Args.string({
40
82
  description: 'The flow file to run against the app, e.g. test.yaml',
@@ -48,12 +90,14 @@ class Cloud extends core_1.Command {
48
90
  androidApiLevel: core_1.Flags.integer({
49
91
  aliases: ['android-api-level'],
50
92
  description: 'Android API level to run your flow against',
93
+ options: ['32', '33', '34'],
51
94
  }),
52
95
  apiKey: core_1.Flags.string({ aliases: ['api-key'], description: 'API key' }),
53
96
  apiUrl: core_1.Flags.string({
54
97
  aliases: ['api-url'],
55
98
  default: 'https://api.devicecloud.dev',
56
99
  description: 'API base URL',
100
+ hidden: true,
57
101
  }),
58
102
  appBinaryId: core_1.Flags.string({
59
103
  aliases: ['app-binary-id'],
@@ -69,42 +113,50 @@ class Cloud extends core_1.Command {
69
113
  description: 'One or more environment variables to inject into your Flows',
70
114
  multiple: true,
71
115
  }),
116
+ flows: core_1.Flags.string({
117
+ aliases: ['flows'],
118
+ description: 'The path to the flow file or folder containing your Flows',
119
+ }),
72
120
  iOSVersion: core_1.Flags.string({
73
121
  aliases: ['ios-version'],
74
122
  description: 'iOS version to run your flow against',
123
+ options: ['16.4', '17.2'],
75
124
  }),
76
125
  };
77
126
  async run() {
78
127
  const { args, flags } = await this.parse(Cloud);
79
- const { apiKey, apiUrl, appBinaryId, ...rest } = flags;
128
+ const { apiKey, apiUrl, appBinaryId, appFile, env, flows, ...rest } = flags;
80
129
  console.log({ args });
81
130
  const { firstFile, secondFile } = args;
82
131
  let finalBinaryId = appBinaryId;
83
- const appFile = firstFile;
84
- let flowFile = secondFile;
132
+ const finalAppFile = appFile ?? firstFile;
133
+ let flowFile = flows ?? secondFile;
85
134
  if (appBinaryId) {
86
135
  if (secondFile) {
87
136
  throw new Error('You cannot provide both an appBinaryId and a binary file');
88
137
  }
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)}`);
138
+ flowFile = flows ?? firstFile;
139
+ this.log(`you want to run the flow(s) ${flowFile} against the binary with id ${appBinaryId} with the following flags: ${JSON.stringify(flags)}`);
91
140
  }
92
141
  else {
93
- if (!appFile.endsWith('.apk') && !appFile.endsWith('.zip')) {
94
- throw new Error('App file must be a .apk or .zip file');
95
- }
96
- if (!(flowFile && appFile)) {
142
+ if (!(flowFile && finalAppFile)) {
97
143
  throw new Error('You must provide a flow file and an app binary id');
98
144
  }
99
- this.log(`you want to run the flow ${flowFile} against the app ${appFile} with the following flags: ${JSON.stringify(flags)}`);
145
+ if (!finalAppFile.endsWith('.apk') && !finalAppFile.endsWith('.zip')) {
146
+ throw new Error('App file must be a .apk or .zip file');
147
+ }
148
+ this.log(`you want to run the flow(s) ${flowFile} against the app ${finalAppFile} with the following flags: ${JSON.stringify(flags)}`);
149
+ }
150
+ if (!flowFile) {
151
+ throw new Error('You must provide a flow file');
100
152
  }
101
153
  if (!finalBinaryId) {
102
154
  const binaryFormData = new FormData();
103
- const binaryBlob = new Blob([await (0, promises_1.readFile)(appFile)], {
104
- type: mimeTypeLookupByExtension[appFile.split('.').pop()],
155
+ const binaryBlob = new Blob([await (0, promises_1.readFile)(finalAppFile)], {
156
+ type: mimeTypeLookupByExtension[finalAppFile.split('.').pop()],
105
157
  });
106
- console.log(mimeTypeLookupByExtension[appFile.split('.').pop()]);
107
- binaryFormData.set('file', binaryBlob, appFile);
158
+ console.log(mimeTypeLookupByExtension[finalAppFile.split('.').pop()]);
159
+ binaryFormData.set('file', binaryBlob, finalAppFile);
108
160
  const options = {
109
161
  body: binaryFormData,
110
162
  headers: { 'x-app-api-key': apiKey },
@@ -116,17 +168,46 @@ class Cloud extends core_1.Command {
116
168
  this.log(message);
117
169
  finalBinaryId = binaryId;
118
170
  }
171
+ const testFileNames = [];
172
+ let flowFileDirectory = flowFile;
173
+ if (!flowFile?.endsWith('.yaml') && !flowFile?.endsWith('.yml')) {
174
+ try {
175
+ const executionPlan = await (0, plan_1.plan)(flowFile, [], []);
176
+ for (const file of executionPlan.flowsToRun) {
177
+ testFileNames.push(file);
178
+ }
179
+ for (const file of executionPlan.sequence?.flows ?? []) {
180
+ // todo: handle continueOnFailure and other sequence properties
181
+ testFileNames.push(file);
182
+ }
183
+ console.log(executionPlan);
184
+ }
185
+ catch (error) {
186
+ console.error(error);
187
+ }
188
+ }
189
+ else {
190
+ // we are working with a single file
191
+ flowFileDirectory = flowFile.split('/').slice(0, -1).join('/');
192
+ testFileNames.push(flowFile.split('/').pop());
193
+ }
119
194
  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')], {
195
+ // eslint-disable-next-line unicorn/no-array-reduce
196
+ const envObject = (env ?? []).reduce((acc, cur) => {
197
+ const [key, value] = cur.split('=');
198
+ acc[key] = value;
199
+ return acc;
200
+ }, {});
201
+ const buffer = flowFileDirectory?.length
202
+ ? await compressDir(flowFileDirectory)
203
+ : await compressFile(flowFile);
204
+ const blob = new Blob([buffer], {
125
205
  type: mimeTypeLookupByExtension.zip,
126
206
  });
127
207
  testFormData.set('file', blob, 'flowFile.zip');
128
208
  testFormData.set('appBinaryId', finalBinaryId);
129
- testFormData.set('testFileName', yamlFileName);
209
+ testFormData.set('testFileNames', JSON.stringify(testFileNames));
210
+ testFormData.set('env', JSON.stringify(envObject));
130
211
  for (const [key, value] of Object.entries(rest)) {
131
212
  if (value) {
132
213
  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.1"
131
+ "version": "0.0.1-alpha.4"
113
132
  }
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",
@@ -70,7 +74,7 @@
70
74
  "test": "mocha --forbid-only \"test/**/*.test.ts\"",
71
75
  "version": "oclif readme && git add README.md"
72
76
  },
73
- "version": "0.0.1-alpha.1",
77
+ "version": "0.0.1-alpha.4",
74
78
  "bugs": {
75
79
  "url": "https://discord.gg/GzZBHcUJ"
76
80
  },