Skip to content

Commit 3387a6b

Browse files
Maestro changes
1 parent 4be462a commit 3387a6b

File tree

7 files changed

+288
-44
lines changed

7 files changed

+288
-44
lines changed

src/cli.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ const maestroCommand = program
9393
(val) => val as 'Android' | 'iOS',
9494
)
9595
.option('--deviceVersion <version>', 'OS version (e.g., "14", "17.2").')
96+
.option(
97+
'--real-device',
98+
'Use a real device instead of an emulator/simulator.',
99+
)
96100
.option(
97101
'--orientation <orientation>',
98102
'Screen orientation: PORTRAIT or LANDSCAPE.',
@@ -201,6 +205,7 @@ const maestroCommand = program
201205
async: args.async,
202206
report: args.report,
203207
reportOutputDir: args.reportOutputDir,
208+
realDevice: args.realDevice,
204209
});
205210
const credentials = await Auth.getCredentials({
206211
apiKey: args.apiKey,

src/logger.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import tracer from 'tracer';
33

44
const logger = tracer.colorConsole({
55
level: 'info',
6-
format: '{{timestamp}} {{title}}: {{message}}',
6+
format: '{{timestamp}} {{message}}',
77
dateformat: 'HH:MM:ss.L',
88
filters: [
99
{

src/models/maestro_options.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export interface MaestroCapabilities {
2020
timeZone?: string;
2121
throttleNetwork?: ThrottleNetwork;
2222
geoCountryCode?: string;
23+
realDevice?: string;
2324
}
2425

2526
export interface MaestroRunOptions {
@@ -50,6 +51,7 @@ export default class MaestroOptions {
5051
private _async: boolean;
5152
private _report?: ReportFormat;
5253
private _reportOutputDir?: string;
54+
private _realDevice: boolean;
5355

5456
public constructor(
5557
app: string,
@@ -73,6 +75,7 @@ export default class MaestroOptions {
7375
async?: boolean;
7476
report?: ReportFormat;
7577
reportOutputDir?: string;
78+
realDevice?: boolean;
7679
},
7780
) {
7881
this._app = app;
@@ -95,6 +98,7 @@ export default class MaestroOptions {
9598
this._async = options?.async ?? false;
9699
this._report = options?.report;
97100
this._reportOutputDir = options?.reportOutputDir;
101+
this._realDevice = options?.realDevice ?? false;
98102
}
99103

100104
public get app(): string {
@@ -177,6 +181,10 @@ export default class MaestroOptions {
177181
return this._reportOutputDir;
178182
}
179183

184+
public get realDevice(): boolean {
185+
return this._realDevice;
186+
}
187+
180188
public getMaestroOptions(): MaestroRunOptions | undefined {
181189
const opts: MaestroRunOptions = {};
182190

@@ -228,6 +236,7 @@ export default class MaestroOptions {
228236
if (this._timeZone) caps.timeZone = this._timeZone;
229237
if (this._throttleNetwork) caps.throttleNetwork = this._throttleNetwork;
230238
if (this._geoCountryCode) caps.geoCountryCode = this._geoCountryCode;
239+
if (this._realDevice) caps.realDevice = 'true';
231240

232241
return caps;
233242
}

src/providers/maestro.ts

Lines changed: 166 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,13 @@ export default class Maestro {
182182
return result;
183183
} catch (error) {
184184
logger.error(error instanceof Error ? error.message : error);
185+
// Display the cause if available
186+
if (error instanceof Error && error.cause) {
187+
const causeMessage = this.extractErrorMessage(error.cause);
188+
if (causeMessage) {
189+
logger.error(` Reason: ${causeMessage}`);
190+
}
191+
}
185192
return { success: false, runs: [] };
186193
}
187194
}
@@ -446,7 +453,6 @@ export default class Maestro {
446453
try {
447454
const capabilities = this.options.getCapabilities(this.detectedPlatform);
448455
const maestroOptions = this.options.getMaestroOptions();
449-
450456
const response = await axios.post(
451457
`${this.URL}/${this.appId}/run`,
452458
{
@@ -471,13 +477,19 @@ export default class Maestro {
471477

472478
const result = response.data;
473479
if (result.success === false) {
480+
// API returns errors as an array
481+
const errorMessage =
482+
result.errors?.join('\n') || result.error || 'Unknown error';
474483
throw new TestingBotError(`Running Maestro test failed`, {
475-
cause: result.error,
484+
cause: errorMessage,
476485
});
477486
}
478487

479488
return true;
480489
} catch (error) {
490+
if (error instanceof TestingBotError) {
491+
throw error;
492+
}
481493
throw new TestingBotError(`Running Maestro test failed`, {
482494
cause: error,
483495
});
@@ -506,21 +518,31 @@ export default class Maestro {
506518

507519
private async waitForCompletion(): Promise<MaestroResult> {
508520
let attempts = 0;
521+
const startTime = Date.now();
522+
const previousStatus: Map<number, MaestroRunInfo['status']> = new Map();
509523

510524
while (attempts < this.MAX_POLL_ATTEMPTS) {
511525
const status = await this.getStatus();
512526

513527
// Log current status of runs (unless quiet mode)
514528
if (!this.options.quiet) {
515-
for (const run of status.runs) {
516-
const statusEmoji = this.getStatusEmoji(run.status);
517-
logger.info(
518-
` ${statusEmoji} Run ${run.id} (${run.capabilities.deviceName}): ${run.status}`,
519-
);
520-
}
529+
this.displayRunStatus(status.runs, startTime, previousStatus);
521530
}
522531

523532
if (status.completed) {
533+
// Clear the updating line and print final status
534+
if (!this.options.quiet) {
535+
this.clearLine();
536+
for (const run of status.runs) {
537+
const statusEmoji = run.success === 1 ? '✅' : '❌';
538+
const statusText =
539+
run.success === 1 ? 'Test completed successfully' : 'Test failed';
540+
console.log(
541+
` ${statusEmoji} Run ${run.id} (${run.capabilities.deviceName}): ${statusText}`,
542+
);
543+
}
544+
}
545+
524546
const allSucceeded = status.runs.every((run) => run.success === 1);
525547

526548
if (allSucceeded) {
@@ -531,7 +553,9 @@ export default class Maestro {
531553
const failedRuns = status.runs.filter((run) => run.success !== 1);
532554
logger.error(`${failedRuns.length} test run(s) failed:`);
533555
for (const run of failedRuns) {
534-
logger.error(` - Run ${run.id} (${run.capabilities.deviceName})`);
556+
logger.error(
557+
` - Run ${run.id} (${run.capabilities.deviceName}): ${run.report}`,
558+
);
535559
}
536560
}
537561

@@ -555,6 +579,75 @@ export default class Maestro {
555579
);
556580
}
557581

582+
private displayRunStatus(
583+
runs: MaestroRunInfo[],
584+
startTime: number,
585+
previousStatus: Map<number, MaestroRunInfo['status']>,
586+
): void {
587+
const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000);
588+
const elapsedStr = this.formatElapsedTime(elapsedSeconds);
589+
590+
for (const run of runs) {
591+
const prevStatus = previousStatus.get(run.id);
592+
const statusChanged = prevStatus !== run.status;
593+
594+
// If status changed from WAITING/READY to something else, clear the updating line
595+
if (
596+
statusChanged &&
597+
prevStatus &&
598+
(prevStatus === 'WAITING' || prevStatus === 'READY')
599+
) {
600+
this.clearLine();
601+
}
602+
603+
previousStatus.set(run.id, run.status);
604+
605+
const statusInfo = this.getStatusInfo(run.status);
606+
607+
if (run.status === 'WAITING' || run.status === 'READY') {
608+
// Update the same line for WAITING and READY states
609+
const message = ` ${statusInfo.emoji} Run ${run.id} (${run.capabilities.deviceName}): ${statusInfo.text} (${elapsedStr})`;
610+
process.stdout.write(`\r${message}`);
611+
} else if (statusChanged) {
612+
// For other states (DONE, FAILED), print on a new line only when status changes
613+
console.log(
614+
` ${statusInfo.emoji} Run ${run.id} (${run.capabilities.deviceName}): ${statusInfo.text}`,
615+
);
616+
}
617+
}
618+
}
619+
620+
private clearLine(): void {
621+
process.stdout.write('\r\x1b[K');
622+
}
623+
624+
private formatElapsedTime(seconds: number): string {
625+
if (seconds < 60) {
626+
return `${seconds}s`;
627+
}
628+
const minutes = Math.floor(seconds / 60);
629+
const remainingSeconds = seconds % 60;
630+
return `${minutes}m ${remainingSeconds}s`;
631+
}
632+
633+
private getStatusInfo(status: MaestroRunInfo['status']): {
634+
emoji: string;
635+
text: string;
636+
} {
637+
switch (status) {
638+
case 'WAITING':
639+
return { emoji: '⏳', text: 'Waiting for test to start' };
640+
case 'READY':
641+
return { emoji: '🔄', text: 'Running test' };
642+
case 'DONE':
643+
return { emoji: '✅', text: 'Test has finished running' };
644+
case 'FAILED':
645+
return { emoji: '❌', text: 'Test failed' };
646+
default:
647+
return { emoji: '❓', text: status };
648+
}
649+
}
650+
558651
private async fetchReports(runs: MaestroRunInfo[]): Promise<void> {
559652
const reportFormat = this.options.report;
560653
const outputDir = this.options.reportOutputDir;
@@ -581,15 +674,24 @@ export default class Maestro {
581674
username: this.credentials.userName,
582675
password: this.credentials.accessKey,
583676
},
584-
responseType: 'arraybuffer',
585677
},
586678
);
587679

680+
// Extract the report content from the JSON response
681+
const reportKey =
682+
reportFormat === 'junit' ? 'junit_report' : 'html_report';
683+
const reportContent = response.data[reportKey];
684+
685+
if (!reportContent) {
686+
logger.error(`No ${reportFormat} report found for run ${run.id}`);
687+
continue;
688+
}
689+
588690
const fileExtension = reportFormat === 'junit' ? 'xml' : 'html';
589691
const fileName = `report_run_${run.id}.${fileExtension}`;
590692
const filePath = path.join(outputDir, fileName);
591693

592-
await fs.promises.writeFile(filePath, response.data);
694+
await fs.promises.writeFile(filePath, reportContent, 'utf-8');
593695

594696
if (!this.options.quiet) {
595697
logger.info(` Saved report for run ${run.id}: ${filePath}`);
@@ -602,22 +704,60 @@ export default class Maestro {
602704
}
603705
}
604706

605-
private getStatusEmoji(status: MaestroRunInfo['status']): string {
606-
switch (status) {
607-
case 'WAITING':
608-
return '⏳';
609-
case 'READY':
610-
return '🔄';
611-
case 'DONE':
612-
return '✅';
613-
case 'FAILED':
614-
return '❌';
615-
default:
616-
return '❓';
617-
}
618-
}
619-
620707
private sleep(ms: number): Promise<void> {
621708
return new Promise((resolve) => setTimeout(resolve, ms));
622709
}
710+
711+
private extractErrorMessage(cause: unknown): string | null {
712+
if (typeof cause === 'string') {
713+
return cause;
714+
}
715+
716+
// Handle arrays of errors
717+
if (Array.isArray(cause)) {
718+
return cause.join('\n');
719+
}
720+
721+
if (cause && typeof cause === 'object') {
722+
// Handle axios errors which have response.data
723+
const axiosError = cause as {
724+
response?: {
725+
data?: { error?: string; errors?: string[]; message?: string };
726+
};
727+
message?: string;
728+
};
729+
if (axiosError.response?.data?.errors) {
730+
return axiosError.response.data.errors.join('\n');
731+
}
732+
if (axiosError.response?.data?.error) {
733+
return axiosError.response.data.error;
734+
}
735+
if (axiosError.response?.data?.message) {
736+
return axiosError.response.data.message;
737+
}
738+
739+
// Handle standard Error objects
740+
if (cause instanceof Error) {
741+
return cause.message;
742+
}
743+
744+
// Handle plain objects with errors array, error, or message property
745+
const obj = cause as {
746+
errors?: string[];
747+
error?: string;
748+
message?: string;
749+
};
750+
if (obj.errors) {
751+
return obj.errors.join('\n');
752+
}
753+
if (obj.error) {
754+
return obj.error;
755+
}
756+
if (obj.message) {
757+
return obj.message;
758+
}
759+
}
760+
761+
return null;
762+
}
623763
}

tests/cli.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,23 @@ describe('TestingBotCTL CLI', () => {
122122
expect(mockMaestroRun).toHaveBeenCalledTimes(1);
123123
});
124124

125+
test('maestro command should accept --real-device flag', async () => {
126+
mockGetCredentials.mockResolvedValue({ apiKey: 'test-api-key' });
127+
128+
await program.parseAsync([
129+
'node',
130+
'cli',
131+
'maestro',
132+
'app.apk',
133+
'./flows',
134+
'--device',
135+
'Pixel 9',
136+
'--real-device',
137+
]);
138+
139+
expect(mockMaestroRun).toHaveBeenCalledTimes(1);
140+
});
141+
125142
test('xcuitest command should call xcuitest.run() with valid options', async () => {
126143
mockGetCredentials.mockResolvedValue({ apiKey: 'test-api-key' });
127144

0 commit comments

Comments
 (0)