Skip to content

Commit db39420

Browse files
add --other-app cli
1 parent bac5fb2 commit db39420

8 files changed

Lines changed: 390 additions & 29 deletions

File tree

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@ testingbot maestro <app> <flows...> [options]
9292
- `app` - Path to your app file (.apk, .ipa, .app, or .zip)
9393
- `flows` - One or more paths to flow files (.yaml/.yml), directories, .zip files, or glob patterns
9494

95+
**App Options:**
96+
97+
| Option | Description |
98+
|--------|-------------|
99+
| `--app <path>` | Path to the application under test (alternative to the positional `app` argument) |
100+
| `--other-app <path>` | Additional companion app to install on the device alongside `--app`. Repeatable, **max 4** entries. |
101+
95102
**Device Options:**
96103

97104
| Option | Description |
@@ -179,6 +186,12 @@ testingbot maestro app.apk ./flows --groups "smoke,critical"
179186
# With environment variables
180187
testingbot maestro app.apk ./flows -e API_URL=https://staging.example.com -e API_KEY=secret
181188

189+
# With companion apps installed alongside the main app (up to 4)
190+
testingbot maestro --app main.apk \
191+
--other-app helper.apk \
192+
--other-app mock-server.apk \
193+
./flows
194+
182195
# Download JUnit report
183196
testingbot maestro app.apk ./flows --report junit --report-output-dir ./reports
184197

src/cli.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,15 @@ program
258258
'--app <path>',
259259
'Path to application under test (.apk, .ipa, .app, or .zip).',
260260
)
261+
.option(
262+
'--other-app <path>',
263+
'Additional app to install alongside --app (.apk, .ipa, .app, or .zip). Repeatable, max 4.',
264+
(val: string, acc: string[]) => {
265+
acc.push(val);
266+
return acc;
267+
},
268+
[] as string[],
269+
)
261270
// Device configuration
262271
.option(
263272
'--device <device>',
@@ -292,7 +301,11 @@ program
292301
.option(
293302
'--groups <names>',
294303
'Tag the test session with one or more groups (comma-separated).',
295-
(val) => val.split(',').map((g) => g.trim()).filter((g) => g.length > 0),
304+
(val) =>
305+
val
306+
.split(',')
307+
.map((g) => g.trim())
308+
.filter((g) => g.length > 0),
296309
)
297310
// Network and geo
298311
.option(
@@ -423,6 +436,18 @@ program
423436
);
424437
}
425438

439+
const otherApps: string[] = Array.isArray(args.otherApp)
440+
? args.otherApp.slice()
441+
: [];
442+
// Commander stores the accumulator on a shared default array; reset it so
443+
// values do not leak across repeated parses (e.g. in tests).
444+
if (Array.isArray(args.otherApp)) args.otherApp.length = 0;
445+
if (otherApps.length > 4) {
446+
throw new TestingBotError(
447+
`Too many --other-app entries (${otherApps.length}). Maximum is 4.`,
448+
);
449+
}
450+
426451
const credentials = await Auth.getCredentials({
427452
apiKey: args.apiKey,
428453
apiSecret: args.apiSecret,
@@ -491,6 +516,7 @@ program
491516
configFile: args.config,
492517
groups: args.groups,
493518
metadata,
519+
otherApps,
494520
});
495521
if (args.debug) {
496522
enableDebugLogging();

src/models/maestro_options.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,13 +42,16 @@ export interface MaestroRunOptions {
4242
version?: string;
4343
}
4444

45+
export const MAX_OTHER_APPS = 4;
46+
4547
export default class MaestroOptions {
4648
private static isIpaFile(app?: string): boolean {
4749
return app?.toLowerCase().endsWith('.ipa') ?? false;
4850
}
4951

5052
private _app: string;
5153
private _flows: string[];
54+
private _otherApps: string[];
5255
private _device?: string;
5356
private _includeTags?: string[];
5457
private _excludeTags?: string[];
@@ -116,10 +119,17 @@ export default class MaestroOptions {
116119
groups?: string[];
117120
googlePlayStore?: boolean;
118121
metadata?: RunMetadata;
122+
otherApps?: string[];
119123
},
120124
) {
121125
this._app = app;
122126
this._flows = flows ? (Array.isArray(flows) ? flows : [flows]) : [];
127+
this._otherApps = options?.otherApps ?? [];
128+
if (this._otherApps.length > MAX_OTHER_APPS) {
129+
throw new Error(
130+
`Too many other apps (${this._otherApps.length}). Maximum is ${MAX_OTHER_APPS}.`,
131+
);
132+
}
123133
this._device = device;
124134
this._includeTags = options?.includeTags;
125135
this._excludeTags = options?.excludeTags;
@@ -166,6 +176,10 @@ export default class MaestroOptions {
166176
return this._flows;
167177
}
168178

179+
public get otherApps(): string[] {
180+
return this._otherApps;
181+
}
182+
169183
public get device(): string | undefined {
170184
return this._device;
171185
}

src/providers/maestro.ts

Lines changed: 91 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
9999
private updateServer: string | null = null;
100100
private updateKey: string | null = null;
101101
private socketFallbackWarned = false;
102+
private otherAppUrls: string[] = [];
102103

103104
private flowAnimationFrame = 0;
104105
private flowAnimationTimer: NodeJS.Timeout | null = null;
@@ -141,6 +142,23 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
141142
);
142143
}
143144

145+
// Validate other-apps count and extensions
146+
const otherApps = this.options.otherApps;
147+
if (otherApps.length > 4) {
148+
throw new TestingBotError(
149+
`Too many other apps (${otherApps.length}). Maximum is 4.`,
150+
);
151+
}
152+
for (const otherAppPath of otherApps) {
153+
const otherExt = path.extname(otherAppPath).toLowerCase();
154+
if (!Maestro.SUPPORTED_APP_EXTENSIONS.includes(otherExt)) {
155+
throw new TestingBotError(
156+
`Unsupported other-app file format: ${otherExt || '(no extension)'} for ${otherAppPath}. ` +
157+
`Supported formats: ${Maestro.SUPPORTED_APP_EXTENSIONS.join(', ')}`,
158+
);
159+
}
160+
}
161+
144162
// Build list of all file checks to run in parallel
145163
const fileChecks: Promise<void>[] = [
146164
fs.promises.access(this.options.app, fs.constants.R_OK).catch(() => {
@@ -150,6 +168,16 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
150168
}),
151169
];
152170

171+
for (const otherAppPath of otherApps) {
172+
fileChecks.push(
173+
fs.promises.access(otherAppPath, fs.constants.R_OK).catch(() => {
174+
throw new TestingBotError(
175+
`Provided other-app path does not exist ${otherAppPath}`,
176+
);
177+
}),
178+
);
179+
}
180+
153181
if (this.options.configFile) {
154182
fileChecks.push(
155183
fs.promises
@@ -221,6 +249,8 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
221249
// Process flows to show actual zip structure
222250
const flowResult = await this.collectFlows();
223251

252+
const otherApps = this.options.otherApps;
253+
224254
this.printDryRunSummary({
225255
provider: 'Maestro',
226256
apiUrl: this.URL,
@@ -230,6 +260,11 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
230260
filePath: this.options.app,
231261
endpoint: `${this.URL}/app`,
232262
},
263+
...otherApps.map((p, i) => ({
264+
label: `Other App ${i + 1}`,
265+
filePath: p,
266+
endpoint: `${this.URL}/other-apps`,
267+
})),
233268
{
234269
label: 'Flows',
235270
filePath: this.options.flows.join(', '),
@@ -243,6 +278,11 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
243278
shardSplit: this.options.shardSplit,
244279
}),
245280
...(metadata && { metadata }),
281+
...(otherApps.length > 0 && {
282+
otherApps: otherApps.map(
283+
(_, i) => `<tb://appkey-other-app-${i + 1}>`,
284+
),
285+
}),
246286
},
247287
});
248288

@@ -279,6 +319,11 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
279319
setTitle('maestro · uploading app');
280320
await this.uploadApp();
281321

322+
if (this.options.otherApps.length > 0) {
323+
setTitle('maestro · uploading other apps');
324+
await this.uploadOtherApps();
325+
}
326+
282327
if (!this.options.quiet) {
283328
logger.info('Uploading Maestro Flows');
284329
}
@@ -422,6 +467,48 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
422467
}
423468
}
424469

470+
private async uploadOtherApps(): Promise<void> {
471+
const others = this.options.otherApps;
472+
if (!others || others.length === 0) return;
473+
474+
if (!this.options.quiet) {
475+
logger.info(`Uploading ${others.length} other app(s)`);
476+
}
477+
478+
for (let i = 0; i < others.length; i++) {
479+
const appPath = others[i];
480+
const ext = path.extname(appPath).toLowerCase();
481+
const contentType =
482+
ext === '.apk'
483+
? 'application/vnd.android.package-archive'
484+
: ext === '.ipa'
485+
? 'application/octet-stream'
486+
: ext === '.zip' || ext === '.app'
487+
? 'application/zip'
488+
: 'application/octet-stream';
489+
490+
if (!this.options.quiet) {
491+
logger.info(` [${i + 1}/${others.length}] ${path.basename(appPath)}`);
492+
}
493+
494+
const result = await this.upload.upload({
495+
filePath: appPath,
496+
url: `${this.URL}/other-apps`,
497+
credentials: this.credentials,
498+
contentType,
499+
showProgress: !this.options.quiet,
500+
validateZipFormat: ext === '.zip' || ext === '.app',
501+
});
502+
503+
if (!result.app_url) {
504+
throw new TestingBotError(
505+
`Other-app upload returned no app_url for ${appPath}`,
506+
);
507+
}
508+
this.otherAppUrls.push(result.app_url);
509+
}
510+
}
511+
425512
/**
426513
* Zip a .app bundle directory into a temporary zip file
427514
*/
@@ -1583,6 +1670,9 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
15831670
shardSplit: this.options.shardSplit,
15841671
}),
15851672
...(metadata && { metadata }),
1673+
...(this.otherAppUrls.length > 0 && {
1674+
otherApps: this.otherAppUrls,
1675+
}),
15861676
},
15871677
{
15881678
headers: {
@@ -2696,11 +2786,7 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
26962786
if (flow.report) {
26972787
const flowReportPath = path.join(flowDir, 'report.xml');
26982788
try {
2699-
await fs.promises.writeFile(
2700-
flowReportPath,
2701-
flow.report,
2702-
'utf-8',
2703-
);
2789+
await fs.promises.writeFile(flowReportPath, flow.report, 'utf-8');
27042790
if (!this.options.quiet) {
27052791
logger.info(` Saved ${flowDirName}/report.xml`);
27062792
}

src/upload.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface UploadOptions {
2727

2828
export interface UploadResult {
2929
id: number;
30+
app_url?: string;
3031
}
3132

3233
export default class Upload {
@@ -134,7 +135,12 @@ export default class Upload {
134135
);
135136
}
136137
}
137-
return { id: result.id };
138+
return {
139+
id: result.id,
140+
...(typeof result.app_url === 'string'
141+
? { app_url: result.app_url }
142+
: {}),
143+
};
138144
} else {
139145
if (showProgress) {
140146
if (interactive) {

tests/cli.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,68 @@ describe('TestingBotCTL CLI', () => {
401401
expect(opts.device).toBeUndefined();
402402
});
403403

404+
test('maestro command should accept repeated --other-app flags', async () => {
405+
mockGetCredentials.mockResolvedValue({ apiKey: 'test-api-key' });
406+
407+
await program.parseAsync([
408+
'node',
409+
'cli',
410+
'maestro',
411+
'--app',
412+
'app.apk',
413+
'--other-app',
414+
'helper.apk',
415+
'--other-app',
416+
'mock.apk',
417+
'./flows',
418+
]);
419+
420+
expect(mockMaestroRun).toHaveBeenCalledTimes(1);
421+
const opts = lastConstructorOptions<{ otherApps: string[] }>(Maestro);
422+
expect(opts.otherApps).toEqual(['helper.apk', 'mock.apk']);
423+
});
424+
425+
test('maestro command should default otherApps to an empty array', async () => {
426+
mockGetCredentials.mockResolvedValue({ apiKey: 'test-api-key' });
427+
428+
await program.parseAsync(['node', 'cli', 'maestro', 'app.apk', './flows']);
429+
430+
expect(mockMaestroRun).toHaveBeenCalledTimes(1);
431+
const opts = lastConstructorOptions<{ otherApps: string[] }>(Maestro);
432+
expect(opts.otherApps).toEqual([]);
433+
});
434+
435+
test('maestro command should reject more than 4 --other-app entries', async () => {
436+
mockGetCredentials.mockResolvedValue({ apiKey: 'test-api-key' });
437+
438+
await program.parseAsync([
439+
'node',
440+
'cli',
441+
'maestro',
442+
'app.apk',
443+
'./flows',
444+
'--other-app',
445+
'a.apk',
446+
'--other-app',
447+
'b.apk',
448+
'--other-app',
449+
'c.apk',
450+
'--other-app',
451+
'd.apk',
452+
'--other-app',
453+
'e.apk',
454+
]);
455+
456+
expect(mockMaestroRun).not.toHaveBeenCalled();
457+
expect(process.exitCode).toBe(1);
458+
expect(logger.error).toHaveBeenCalledWith(
459+
expect.stringContaining(
460+
'Too many --other-app entries (5). Maximum is 4.',
461+
),
462+
);
463+
process.exitCode = 0;
464+
});
465+
404466
test('maestro command should accept --real-device flag', async () => {
405467
mockGetCredentials.mockResolvedValue({ apiKey: 'test-api-key' });
406468

0 commit comments

Comments
 (0)