Skip to content

Commit fe69502

Browse files
maestro download artifacts
1 parent aae02fb commit fe69502

File tree

4 files changed

+477
-0
lines changed

4 files changed

+477
-0
lines changed

src/cli.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,15 @@ const maestroCommand = program
292292
'--report-output-dir <path>',
293293
'Directory to save test reports (required when --report is used).',
294294
)
295+
// Artifact download
296+
.option(
297+
'--download-artifacts',
298+
'Download test artifacts (logs, screenshots, video) after completion.',
299+
)
300+
.option(
301+
'--artifacts-output-dir <path>',
302+
'Directory to save artifacts zip (defaults to current directory).',
303+
)
295304
// Authentication
296305
.option('--api-key <key>', 'TestingBot API key.')
297306
.option('--api-secret <secret>', 'TestingBot API secret.')
@@ -347,6 +356,8 @@ const maestroCommand = program
347356
report: args.report,
348357
reportOutputDir: args.reportOutputDir,
349358
realDevice: args.realDevice,
359+
downloadArtifacts: args.downloadArtifacts,
360+
artifactsOutputDir: args.artifactsOutputDir,
350361
});
351362
const credentials = await Auth.getCredentials({
352363
apiKey: args.apiKey,

src/models/maestro_options.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export default class MaestroOptions {
5252
private _report?: ReportFormat;
5353
private _reportOutputDir?: string;
5454
private _realDevice: boolean;
55+
private _downloadArtifacts: boolean;
56+
private _artifactsOutputDir?: string;
5557

5658
public constructor(
5759
app: string,
@@ -76,6 +78,8 @@ export default class MaestroOptions {
7678
report?: ReportFormat;
7779
reportOutputDir?: string;
7880
realDevice?: boolean;
81+
downloadArtifacts?: boolean;
82+
artifactsOutputDir?: string;
7983
},
8084
) {
8185
this._app = app;
@@ -99,6 +103,8 @@ export default class MaestroOptions {
99103
this._report = options?.report;
100104
this._reportOutputDir = options?.reportOutputDir;
101105
this._realDevice = options?.realDevice ?? false;
106+
this._downloadArtifacts = options?.downloadArtifacts ?? false;
107+
this._artifactsOutputDir = options?.artifactsOutputDir;
102108
}
103109

104110
public get app(): string {
@@ -185,6 +191,14 @@ export default class MaestroOptions {
185191
return this._realDevice;
186192
}
187193

194+
public get downloadArtifacts(): boolean {
195+
return this._downloadArtifacts;
196+
}
197+
198+
public get artifactsOutputDir(): string | undefined {
199+
return this._artifactsOutputDir;
200+
}
201+
188202
public getMaestroOptions(): MaestroRunOptions | undefined {
189203
const opts: MaestroRunOptions = {};
190204

src/providers/maestro.ts

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ import utils from '../utils';
1414
import Upload from '../upload';
1515
import { detectPlatformFromFile } from '../utils/file-type-detector';
1616

17+
export interface MaestroRunAssets {
18+
logs?: string[];
19+
video?: string | false;
20+
screenshots?: string[];
21+
}
22+
1723
export interface MaestroRunInfo {
1824
id: number;
1925
status: 'WAITING' | 'READY' | 'DONE' | 'FAILED';
@@ -25,6 +31,12 @@ export interface MaestroRunInfo {
2531
success: number;
2632
report?: string;
2733
options?: Record<string, unknown>;
34+
assets?: MaestroRunAssets;
35+
}
36+
37+
export interface MaestroRunDetails extends MaestroRunInfo {
38+
completed: boolean;
39+
assets_synced: boolean;
2840
}
2941

3042
export interface MaestroStatusResponse {
@@ -113,6 +125,11 @@ export default class Maestro {
113125
await this.ensureOutputDirectory(this.options.reportOutputDir);
114126
}
115127

128+
// Validate artifact download options - output dir defaults to current directory
129+
if (this.options.downloadArtifacts && this.options.artifactsOutputDir) {
130+
await this.ensureOutputDirectory(this.options.artifactsOutputDir);
131+
}
132+
116133
return true;
117134
}
118135

@@ -644,6 +661,11 @@ export default class Maestro {
644661
await this.fetchReports(status.runs);
645662
}
646663

664+
// Download artifacts if requested
665+
if (this.options.downloadArtifacts && this.options.artifactsOutputDir) {
666+
await this.downloadArtifacts(status.runs);
667+
}
668+
647669
return {
648670
success: status.success,
649671
runs: status.runs,
@@ -784,6 +806,250 @@ export default class Maestro {
784806
}
785807
}
786808

809+
private async getRunDetails(runId: number): Promise<MaestroRunDetails> {
810+
try {
811+
const response = await axios.get(`${this.URL}/${this.appId}/${runId}`, {
812+
headers: {
813+
'User-Agent': utils.getUserAgent(),
814+
},
815+
auth: {
816+
username: this.credentials.userName,
817+
password: this.credentials.accessKey,
818+
},
819+
});
820+
821+
return response.data;
822+
} catch (error) {
823+
throw new TestingBotError(`Failed to get run details for run ${runId}`, {
824+
cause: error,
825+
});
826+
}
827+
}
828+
829+
private async waitForArtifactsSync(
830+
runId: number,
831+
): Promise<MaestroRunDetails> {
832+
const maxAttempts = 60; // 5 minutes max wait for artifacts
833+
let attempts = 0;
834+
835+
while (attempts < maxAttempts) {
836+
const details = await this.getRunDetails(runId);
837+
if (details.assets_synced) {
838+
return details;
839+
}
840+
attempts++;
841+
await this.sleep(this.POLL_INTERVAL_MS);
842+
}
843+
844+
throw new TestingBotError(
845+
`Timed out waiting for artifacts to sync for run ${runId}`,
846+
);
847+
}
848+
849+
private async downloadFile(url: string, filePath: string): Promise<void> {
850+
try {
851+
const response = await axios.get(url, {
852+
responseType: 'arraybuffer',
853+
headers: {
854+
'User-Agent': utils.getUserAgent(),
855+
}
856+
});
857+
858+
await fs.promises.writeFile(filePath, response.data);
859+
} catch (error) {
860+
throw new TestingBotError(`Failed to download file from ${url}`, {
861+
cause: error,
862+
});
863+
}
864+
}
865+
866+
private generateArtifactZipName(): string {
867+
// Use --build option if provided, otherwise generate timestamp-based name
868+
if (this.options.build) {
869+
const sanitizedBuild = this.options.build.replace(/[^a-zA-Z0-9_-]/g, '_');
870+
return `${sanitizedBuild}.zip`;
871+
}
872+
873+
// Generate unique name with timestamp
874+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
875+
return `maestro_artifacts_${timestamp}.zip`;
876+
}
877+
878+
private async downloadArtifacts(runs: MaestroRunInfo[]): Promise<void> {
879+
if (!this.options.downloadArtifacts) return;
880+
881+
if (!this.options.quiet) {
882+
logger.info('Downloading artifacts...');
883+
}
884+
885+
const outputDir = this.options.artifactsOutputDir || process.cwd();
886+
887+
const tempDir = await fs.promises.mkdtemp(
888+
path.join(os.tmpdir(), 'testingbot-maestro-artifacts-'),
889+
);
890+
891+
try {
892+
for (const run of runs) {
893+
try {
894+
if (!this.options.quiet) {
895+
logger.info(` Waiting for artifacts sync for run ${run.id}...`);
896+
}
897+
898+
const runDetails = await this.waitForArtifactsSync(run.id);
899+
900+
if (!runDetails.assets) {
901+
if (!this.options.quiet) {
902+
logger.info(` No artifacts available for run ${run.id}`);
903+
}
904+
continue;
905+
}
906+
907+
const runDir = path.join(tempDir, `run_${run.id}`);
908+
await fs.promises.mkdir(runDir, { recursive: true });
909+
910+
// Download logs
911+
if (runDetails.assets.logs && runDetails.assets.logs.length > 0) {
912+
const logsDir = path.join(runDir, 'logs');
913+
await fs.promises.mkdir(logsDir, { recursive: true });
914+
915+
for (let i = 0; i < runDetails.assets.logs.length; i++) {
916+
const logUrl = runDetails.assets.logs[i];
917+
const logFileName = path.basename(logUrl) || `log_${i}.txt`;
918+
const logPath = path.join(logsDir, logFileName);
919+
920+
try {
921+
await this.downloadFile(logUrl, logPath);
922+
if (!this.options.quiet) {
923+
logger.info(` Downloaded log: ${logFileName}`);
924+
}
925+
} catch (error) {
926+
logger.error(
927+
` Failed to download log ${logFileName}: ${error instanceof Error ? error.message : error}`,
928+
);
929+
}
930+
}
931+
}
932+
933+
if (
934+
runDetails.assets.video &&
935+
typeof runDetails.assets.video === 'string'
936+
) {
937+
const videoDir = path.join(runDir, 'video');
938+
await fs.promises.mkdir(videoDir, { recursive: true });
939+
940+
const videoUrl = runDetails.assets.video;
941+
const videoFileName = path.basename(videoUrl) || 'video.mp4';
942+
const videoPath = path.join(videoDir, videoFileName);
943+
944+
try {
945+
await this.downloadFile(videoUrl, videoPath);
946+
if (!this.options.quiet) {
947+
logger.info(` Downloaded video: ${videoFileName}`);
948+
}
949+
} catch (error) {
950+
logger.error(
951+
` Failed to download video: ${error instanceof Error ? error.message : error}`,
952+
);
953+
}
954+
}
955+
956+
if (
957+
runDetails.assets.screenshots &&
958+
runDetails.assets.screenshots.length > 0
959+
) {
960+
const screenshotsDir = path.join(runDir, 'screenshots');
961+
await fs.promises.mkdir(screenshotsDir, { recursive: true });
962+
963+
for (let i = 0; i < runDetails.assets.screenshots.length; i++) {
964+
const screenshotUrl = runDetails.assets.screenshots[i];
965+
const screenshotFileName =
966+
path.basename(screenshotUrl) || `screenshot_${i}.png`;
967+
const screenshotPath = path.join(
968+
screenshotsDir,
969+
screenshotFileName,
970+
);
971+
972+
try {
973+
await this.downloadFile(screenshotUrl, screenshotPath);
974+
if (!this.options.quiet) {
975+
logger.info(
976+
` Downloaded screenshot: ${screenshotFileName}`,
977+
);
978+
}
979+
} catch (error) {
980+
logger.error(
981+
` Failed to download screenshot ${screenshotFileName}: ${error instanceof Error ? error.message : error}`,
982+
);
983+
}
984+
}
985+
}
986+
987+
if (runDetails.report) {
988+
const reportPath = path.join(runDir, 'report.xml');
989+
try {
990+
await fs.promises.writeFile(
991+
reportPath,
992+
runDetails.report,
993+
'utf-8',
994+
);
995+
if (!this.options.quiet) {
996+
logger.info(` Saved report.xml`);
997+
}
998+
} catch (error) {
999+
logger.error(
1000+
` Failed to save report.xml: ${error instanceof Error ? error.message : error}`,
1001+
);
1002+
}
1003+
}
1004+
1005+
if (!this.options.quiet) {
1006+
logger.info(` Artifacts for run ${run.id} downloaded`);
1007+
}
1008+
} catch (error) {
1009+
logger.error(
1010+
`Failed to download artifacts for run ${run.id}: ${error instanceof Error ? error.message : error}`,
1011+
);
1012+
}
1013+
}
1014+
1015+
const zipFileName = this.generateArtifactZipName();
1016+
const zipFilePath = path.join(outputDir, zipFileName);
1017+
1018+
if (!this.options.quiet) {
1019+
logger.info(`Creating artifacts zip: ${zipFileName}`);
1020+
}
1021+
1022+
await this.createZipFromDirectory(tempDir, zipFilePath);
1023+
1024+
if (!this.options.quiet) {
1025+
logger.info(`Artifacts saved to: ${zipFilePath}`);
1026+
}
1027+
} finally {
1028+
try {
1029+
await fs.promises.rm(tempDir, { recursive: true, force: true });
1030+
} catch {
1031+
// Ignore cleanup errors
1032+
}
1033+
}
1034+
}
1035+
1036+
private async createZipFromDirectory(
1037+
sourceDir: string,
1038+
zipPath: string,
1039+
): Promise<void> {
1040+
return new Promise((resolve, reject) => {
1041+
const output = fs.createWriteStream(zipPath);
1042+
const archive = archiver('zip', { zlib: { level: 9 } });
1043+
1044+
output.on('close', () => resolve());
1045+
archive.on('error', (err) => reject(err));
1046+
1047+
archive.pipe(output);
1048+
archive.directory(sourceDir, false);
1049+
archive.finalize();
1050+
});
1051+
}
1052+
7871053
private sleep(ms: number): Promise<void> {
7881054
return new Promise((resolve) => setTimeout(resolve, ms));
7891055
}

0 commit comments

Comments
 (0)