@@ -14,6 +14,12 @@ import utils from '../utils';
1414import Upload from '../upload' ;
1515import { detectPlatformFromFile } from '../utils/file-type-detector' ;
1616
17+ export interface MaestroRunAssets {
18+ logs ?: string [ ] ;
19+ video ?: string | false ;
20+ screenshots ?: string [ ] ;
21+ }
22+
1723export 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
3042export 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 - z A - Z 0 - 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