Skip to content

Commit aae02fb

Browse files
maestro: accept multiple flow paths
1 parent 27e8c46 commit aae02fb

File tree

5 files changed

+145
-68
lines changed

5 files changed

+145
-68
lines changed

src/cli.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -204,18 +204,14 @@ const maestroCommand = program
204204
'Path to application under test (.apk, .ipa, .app or .zip)',
205205
)
206206
.argument(
207-
'[flows]',
208-
'Path to flow file (.yaml/.yml), directory, .zip or glob pattern',
207+
'[flows...]',
208+
'Paths to flow files, directories, or glob patterns (can specify multiple)',
209209
)
210210
// App and flows options
211211
.option(
212212
'--app <path>',
213213
'Path to application under test (.apk, .ipa, .app, or .zip).',
214214
)
215-
.option(
216-
'--flows <path>',
217-
'Path to flow file (.yaml/.yml), directory of flows, .zip file or glob pattern.',
218-
)
219215
// Device configuration
220216
.option(
221217
'--device <device>',
@@ -299,13 +295,24 @@ const maestroCommand = program
299295
// Authentication
300296
.option('--api-key <key>', 'TestingBot API key.')
301297
.option('--api-secret <secret>', 'TestingBot API secret.')
302-
.action(async (appFileArg, flowsArg, args) => {
298+
.action(async (appFileArg, flowsArgs, args) => {
303299
try {
304-
// Positional arguments take precedence, fall back to options
305-
const app = appFileArg || args.app;
306-
const flows = flowsArg || args.flows;
300+
let app: string;
301+
let flows: string[];
302+
303+
if (args.app) {
304+
// If --app is specified, treat all positional arguments as flows
305+
app = args.app;
306+
flows = appFileArg
307+
? [appFileArg, ...(flowsArgs || [])]
308+
: flowsArgs || [];
309+
} else {
310+
// Otherwise, first positional is app, rest are flows
311+
app = appFileArg;
312+
flows = flowsArgs || [];
313+
}
307314

308-
if (!app || !flows) {
315+
if (!app || flows.length === 0) {
309316
maestroCommand.help();
310317
return;
311318
}

src/models/maestro_options.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export interface MaestroRunOptions {
3232

3333
export default class MaestroOptions {
3434
private _app: string;
35-
private _flows: string;
35+
private _flows: string[];
3636
private _device?: string;
3737
private _includeTags?: string[];
3838
private _excludeTags?: string[];
@@ -55,7 +55,7 @@ export default class MaestroOptions {
5555

5656
public constructor(
5757
app: string,
58-
flows: string,
58+
flows: string | string[],
5959
device?: string,
6060
options?: {
6161
includeTags?: string[];
@@ -79,7 +79,7 @@ export default class MaestroOptions {
7979
},
8080
) {
8181
this._app = app;
82-
this._flows = flows;
82+
this._flows = flows ? (Array.isArray(flows) ? flows : [flows]) : [];
8383
this._device = device;
8484
this._includeTags = options?.includeTags;
8585
this._excludeTags = options?.excludeTags;
@@ -105,7 +105,7 @@ export default class MaestroOptions {
105105
return this._app;
106106
}
107107

108-
public get flows(): string {
108+
public get flows(): string[] {
109109
return this._flows;
110110
}
111111

src/providers/maestro.ts

Lines changed: 86 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -80,22 +80,23 @@ export default class Maestro {
8080
);
8181
}
8282

83-
if (this.options.flows === undefined) {
83+
if (this.options.flows === undefined || this.options.flows.length === 0) {
8484
throw new TestingBotError(`flows option is required`);
8585
}
8686

87-
// Check if flows path exists (can be a file, directory, or glob pattern)
88-
const flowsPath = this.options.flows;
89-
const isGlobPattern =
90-
flowsPath.includes('*') ||
91-
flowsPath.includes('?') ||
92-
flowsPath.includes('{');
87+
// Check if all flows paths exist (can be files, directories, or glob patterns)
88+
for (const flowsPath of this.options.flows) {
89+
const isGlobPattern =
90+
flowsPath.includes('*') ||
91+
flowsPath.includes('?') ||
92+
flowsPath.includes('{');
9393

94-
if (!isGlobPattern) {
95-
try {
96-
await fs.promises.access(flowsPath, fs.constants.R_OK);
97-
} catch {
98-
throw new TestingBotError(`flows path does not exist ${flowsPath}`);
94+
if (!isGlobPattern) {
95+
try {
96+
await fs.promises.access(flowsPath, fs.constants.R_OK);
97+
} catch {
98+
throw new TestingBotError(`flows path does not exist ${flowsPath}`);
99+
}
99100
}
100101
}
101102

@@ -251,52 +252,87 @@ export default class Maestro {
251252
}
252253

253254
private async uploadFlows() {
254-
const flowsPath = this.options.flows;
255-
const stat = await fs.promises.stat(flowsPath).catch(() => null);
255+
const flowsPaths = this.options.flows;
256256

257257
let zipPath: string;
258258
let shouldCleanup = false;
259259

260-
if (stat?.isFile()) {
261-
const ext = path.extname(flowsPath).toLowerCase();
262-
if (ext === '.zip') {
263-
// Already a zip file, upload directly
264-
zipPath = flowsPath;
265-
} else if (ext === '.yaml' || ext === '.yml') {
266-
// Single flow file, create a zip
267-
zipPath = await this.createFlowsZip([flowsPath]);
268-
shouldCleanup = true;
269-
} else {
270-
throw new TestingBotError(
271-
`Invalid flow file format. Expected .yaml, .yml, or .zip, got ${ext}`,
272-
);
273-
}
274-
} else if (stat?.isDirectory()) {
275-
// Directory of flows
276-
const flowFiles = await this.discoverFlows(flowsPath);
277-
if (flowFiles.length === 0) {
278-
throw new TestingBotError(
279-
`No flow files (.yaml, .yml) found in directory ${flowsPath}`,
280-
);
260+
// Special case: single zip file - upload directly
261+
if (flowsPaths.length === 1) {
262+
const singlePath = flowsPaths[0];
263+
const stat = await fs.promises.stat(singlePath).catch(() => null);
264+
if (stat?.isFile() && path.extname(singlePath).toLowerCase() === '.zip') {
265+
zipPath = singlePath;
266+
// Upload the zip directly without cleanup
267+
await this.upload.upload({
268+
filePath: zipPath,
269+
url: `${this.URL}/${this.appId}/tests`,
270+
credentials: this.credentials,
271+
contentType: 'application/zip',
272+
showProgress: !this.options.quiet,
273+
});
274+
return true;
281275
}
282-
zipPath = await this.createFlowsZip(flowFiles, flowsPath);
283-
shouldCleanup = true;
284-
} else {
285-
// Treat as glob pattern
286-
const flowFiles = await glob(flowsPath);
287-
const yamlFiles = flowFiles.filter((f) => {
288-
const ext = path.extname(f).toLowerCase();
289-
return ext === '.yaml' || ext === '.yml';
290-
});
291-
if (yamlFiles.length === 0) {
292-
throw new TestingBotError(
293-
`No flow files found matching pattern ${flowsPath}`,
294-
);
276+
}
277+
278+
// Collect all flow files from all paths
279+
const allFlowFiles: string[] = [];
280+
const baseDirs: string[] = [];
281+
282+
for (const flowsPath of flowsPaths) {
283+
const stat = await fs.promises.stat(flowsPath).catch(() => null);
284+
285+
if (stat?.isFile()) {
286+
const ext = path.extname(flowsPath).toLowerCase();
287+
if (ext === '.yaml' || ext === '.yml') {
288+
allFlowFiles.push(flowsPath);
289+
} else if (ext === '.zip') {
290+
throw new TestingBotError(
291+
`Cannot combine .zip files with other flow paths. Use a single .zip file or provide directories/patterns.`,
292+
);
293+
} else {
294+
throw new TestingBotError(
295+
`Invalid flow file format. Expected .yaml, .yml, or .zip, got ${ext}`,
296+
);
297+
}
298+
} else if (stat?.isDirectory()) {
299+
// Directory of flows
300+
const flowFiles = await this.discoverFlows(flowsPath);
301+
if (flowFiles.length === 0 && flowsPaths.length === 1) {
302+
throw new TestingBotError(
303+
`No flow files (.yaml, .yml) found in directory ${flowsPath}`,
304+
);
305+
}
306+
allFlowFiles.push(...flowFiles);
307+
baseDirs.push(flowsPath);
308+
} else {
309+
// Treat as glob pattern
310+
const flowFiles = await glob(flowsPath);
311+
const yamlFiles = flowFiles.filter((f) => {
312+
const ext = path.extname(f).toLowerCase();
313+
return ext === '.yaml' || ext === '.yml';
314+
});
315+
if (yamlFiles.length === 0 && flowsPaths.length === 1) {
316+
throw new TestingBotError(
317+
`No flow files found matching pattern ${flowsPath}`,
318+
);
319+
}
320+
allFlowFiles.push(...yamlFiles);
295321
}
296-
zipPath = await this.createFlowsZip(yamlFiles);
297-
shouldCleanup = true;
298322
}
299323

324+
if (allFlowFiles.length === 0) {
325+
throw new TestingBotError(
326+
`No flow files (.yaml, .yml) found in the provided paths`,
327+
);
328+
}
329+
330+
// Determine base directory for zip structure
331+
// If we have a single directory, use it as base; otherwise use common ancestor or flatten
332+
const baseDir = baseDirs.length === 1 ? baseDirs[0] : undefined;
333+
zipPath = await this.createFlowsZip(allFlowFiles, baseDir);
334+
shouldCleanup = true;
335+
300336
try {
301337
await this.upload.upload({
302338
filePath: zipPath,

tests/cli.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,13 +200,30 @@ describe('TestingBotCTL CLI', () => {
200200
'app.apk',
201201
'--device',
202202
'device-1',
203-
'--flows',
204203
'./flows',
205204
]);
206205

207206
expect(mockMaestroRun).toHaveBeenCalledTimes(1);
208207
});
209208

209+
test('maestro command should accept multiple flow paths', async () => {
210+
mockGetCredentials.mockResolvedValue({ apiKey: 'test-api-key' });
211+
212+
await program.parseAsync([
213+
'node',
214+
'cli',
215+
'maestro',
216+
'app.apk',
217+
'./flows1',
218+
'./flows2',
219+
'./flows3',
220+
'--device',
221+
'device-1',
222+
]);
223+
224+
expect(mockMaestroRun).toHaveBeenCalledTimes(1);
225+
});
226+
210227
test('maestro command should accept include-tags and exclude-tags', async () => {
211228
mockGetCredentials.mockResolvedValue({ apiKey: 'test-api-key' });
212229

tests/models/maestro_options.test.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ describe('MaestroOptions', () => {
66
const options = new MaestroOptions('app.apk', './flows', 'Pixel 8');
77

88
expect(options.app).toBe('app.apk');
9-
expect(options.flows).toBe('./flows');
9+
expect(options.flows).toEqual(['./flows']);
1010
expect(options.device).toBe('Pixel 8');
1111
expect(options.platformName).toBeUndefined();
1212
expect(options.version).toBeUndefined();
@@ -43,7 +43,7 @@ describe('MaestroOptions', () => {
4343
});
4444

4545
expect(options.app).toBe('app.apk');
46-
expect(options.flows).toBe('./flows');
46+
expect(options.flows).toEqual(['./flows']);
4747
expect(options.device).toBe('Pixel 8');
4848
expect(options.includeTags).toEqual(['smoke']);
4949
expect(options.excludeTags).toEqual(['flaky']);
@@ -72,6 +72,23 @@ describe('MaestroOptions', () => {
7272

7373
expect(options.async).toBe(false);
7474
});
75+
76+
it('should accept multiple flow paths as an array', () => {
77+
const options = new MaestroOptions(
78+
'app.apk',
79+
['./flows1', './flows2', './flows3'],
80+
'Pixel 8',
81+
);
82+
83+
expect(options.flows).toEqual(['./flows1', './flows2', './flows3']);
84+
});
85+
86+
it('should normalize single flow path to an array', () => {
87+
const options = new MaestroOptions('app.apk', './flows', 'Pixel 8');
88+
89+
expect(options.flows).toEqual(['./flows']);
90+
expect(Array.isArray(options.flows)).toBe(true);
91+
});
7592
});
7693

7794
describe('getCapabilities', () => {

0 commit comments

Comments
 (0)