Skip to content

Commit 6030085

Browse files
fixes for artifacts download
1 parent a692b98 commit 6030085

File tree

2 files changed

+119
-44
lines changed

2 files changed

+119
-44
lines changed

src/providers/maestro.ts

Lines changed: 85 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { detectPlatformFromFile } from '../utils/file-type-detector';
1616
import platform from '../utils/platform';
1717

1818
export interface MaestroRunAssets {
19-
logs?: string[];
19+
logs?: Record<string, string>;
2020
video?: string | false;
2121
screenshots?: string[];
2222
}
@@ -663,7 +663,7 @@ export default class Maestro {
663663
}
664664

665665
// Download artifacts if requested
666-
if (this.options.downloadArtifacts && this.options.artifactsOutputDir) {
666+
if (this.options.downloadArtifacts) {
667667
await this.downloadArtifacts(status.runs);
668668
}
669669

@@ -857,33 +857,83 @@ export default class Maestro {
857857
);
858858
}
859859

860-
private async downloadFile(url: string, filePath: string): Promise<void> {
861-
try {
862-
const response = await axios.get(url, {
863-
responseType: 'arraybuffer',
864-
headers: {
865-
'User-Agent': utils.getUserAgent(),
866-
},
867-
});
860+
private async downloadFile(
861+
url: string,
862+
filePath: string,
863+
retries = 3,
864+
): Promise<void> {
865+
let lastError: unknown;
868866

869-
await fs.promises.writeFile(filePath, response.data);
870-
} catch (error) {
871-
throw new TestingBotError(`Failed to download file from ${url}`, {
872-
cause: error,
873-
});
867+
for (let attempt = 1; attempt <= retries; attempt++) {
868+
try {
869+
const response = await axios.get(url, {
870+
responseType: 'arraybuffer',
871+
timeout: 60000, // 60 second timeout for large files
872+
});
873+
874+
await fs.promises.writeFile(filePath, response.data);
875+
return;
876+
} catch (error) {
877+
lastError = error;
878+
879+
// Don't retry on 4xx errors (client errors like 403, 404)
880+
if (axios.isAxiosError(error) && error.response?.status) {
881+
const status = error.response.status;
882+
if (status >= 400 && status < 500) {
883+
break;
884+
}
885+
}
886+
887+
// Wait before retrying (exponential backoff)
888+
if (attempt < retries) {
889+
await this.sleep(1000 * attempt);
890+
}
891+
}
892+
}
893+
894+
// Extract detailed error message
895+
let errorDetail = '';
896+
if (axios.isAxiosError(lastError)) {
897+
if (lastError.response) {
898+
errorDetail = `HTTP ${lastError.response.status}: ${lastError.response.statusText}`;
899+
} else if (lastError.code) {
900+
errorDetail = lastError.code;
901+
} else if (lastError.message) {
902+
errorDetail = lastError.message;
903+
}
904+
} else if (lastError instanceof Error) {
905+
errorDetail = lastError.message;
906+
} else if (lastError) {
907+
errorDetail = String(lastError);
874908
}
909+
910+
throw new TestingBotError(
911+
`Failed to download file${errorDetail ? `: ${errorDetail}` : ''}`,
912+
{
913+
cause: lastError,
914+
},
915+
);
875916
}
876917

877-
private generateArtifactZipName(): string {
878-
// Use --build option if provided, otherwise generate timestamp-based name
879-
if (this.options.build) {
880-
const sanitizedBuild = this.options.build.replace(/[^a-zA-Z0-9_-]/g, '_');
881-
return `${sanitizedBuild}.zip`;
918+
private async generateArtifactZipName(outputDir: string): Promise<string> {
919+
if (!this.options.build) {
920+
// Generate unique name with timestamp
921+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
922+
return `maestro_artifacts_${timestamp}.zip`;
882923
}
883924

884-
// Generate unique name with timestamp
885-
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
886-
return `maestro_artifacts_${timestamp}.zip`;
925+
const baseName = this.options.build.replace(/[^a-zA-Z0-9_-]/g, '_');
926+
const fileName = `${baseName}.zip`;
927+
const filePath = path.join(outputDir, fileName);
928+
929+
try {
930+
await fs.promises.access(filePath);
931+
// File exists, append timestamp
932+
return `${baseName}_${Date.now()}.zip`;
933+
} catch {
934+
// File doesn't exist, use base name
935+
return fileName;
936+
}
887937
}
888938

889939
private async downloadArtifacts(runs: MaestroRunInfo[]): Promise<void> {
@@ -919,13 +969,17 @@ export default class Maestro {
919969
await fs.promises.mkdir(runDir, { recursive: true });
920970

921971
// Download logs
922-
if (runDetails.assets.logs && runDetails.assets.logs.length > 0) {
972+
if (
973+
runDetails.assets.logs &&
974+
Object.keys(runDetails.assets.logs).length > 0
975+
) {
923976
const logsDir = path.join(runDir, 'logs');
924977
await fs.promises.mkdir(logsDir, { recursive: true });
925978

926-
for (let i = 0; i < runDetails.assets.logs.length; i++) {
927-
const logUrl = runDetails.assets.logs[i];
928-
const logFileName = path.basename(logUrl) || `log_${i}.txt`;
979+
for (const [logName, logUrl] of Object.entries(
980+
runDetails.assets.logs,
981+
)) {
982+
const logFileName = `${logName}.txt`;
929983
const logPath = path.join(logsDir, logFileName);
930984

931985
try {
@@ -949,7 +1003,7 @@ export default class Maestro {
9491003
await fs.promises.mkdir(videoDir, { recursive: true });
9501004

9511005
const videoUrl = runDetails.assets.video;
952-
const videoFileName = path.basename(videoUrl) || 'video.mp4';
1006+
const videoFileName = 'video.mp4';
9531007
const videoPath = path.join(videoDir, videoFileName);
9541008

9551009
try {
@@ -973,12 +1027,8 @@ export default class Maestro {
9731027

9741028
for (let i = 0; i < runDetails.assets.screenshots.length; i++) {
9751029
const screenshotUrl = runDetails.assets.screenshots[i];
976-
const screenshotFileName =
977-
path.basename(screenshotUrl) || `screenshot_${i}.png`;
978-
const screenshotPath = path.join(
979-
screenshotsDir,
980-
screenshotFileName,
981-
);
1030+
const screenshotFileName = `screenshot_${i}.png`;
1031+
const screenshotPath = path.join(screenshotsDir, screenshotFileName);
9821032

9831033
try {
9841034
await this.downloadFile(screenshotUrl, screenshotPath);
@@ -1023,7 +1073,7 @@ export default class Maestro {
10231073
}
10241074
}
10251075

1026-
const zipFileName = this.generateArtifactZipName();
1076+
const zipFileName = await this.generateArtifactZipName(outputDir);
10271077
const zipFilePath = path.join(outputDir, zipFileName);
10281078

10291079
if (!this.options.quiet) {

tests/providers/maestro.test.ts

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1577,7 +1577,7 @@ describe('Maestro', () => {
15771577
await expect(maestroWithArtifacts['validate']()).resolves.toBe(true);
15781578
});
15791579

1580-
it('should generate zip filename from --build option', () => {
1580+
it('should generate zip filename from --build option', async () => {
15811581
const optionsWithBuild = new MaestroOptions(
15821582
'path/to/app.apk',
15831583
'path/to/flows',
@@ -1589,11 +1589,14 @@ describe('Maestro', () => {
15891589
);
15901590
const maestroWithBuild = new Maestro(mockCredentials, optionsWithBuild);
15911591

1592-
const zipName = maestroWithBuild['generateArtifactZipName']();
1592+
// Mock access to throw (file doesn't exist)
1593+
(fs.promises.access as jest.Mock).mockRejectedValue(new Error('ENOENT'));
1594+
1595+
const zipName = await maestroWithBuild['generateArtifactZipName']('/tmp/test');
15931596
expect(zipName).toBe('my-build-123.zip');
15941597
});
15951598

1596-
it('should sanitize build name for zip filename', () => {
1599+
it('should sanitize build name for zip filename', async () => {
15971600
const optionsWithBuild = new MaestroOptions(
15981601
'path/to/app.apk',
15991602
'path/to/flows',
@@ -1605,11 +1608,14 @@ describe('Maestro', () => {
16051608
);
16061609
const maestroWithBuild = new Maestro(mockCredentials, optionsWithBuild);
16071610

1608-
const zipName = maestroWithBuild['generateArtifactZipName']();
1611+
// Mock access to throw (file doesn't exist)
1612+
(fs.promises.access as jest.Mock).mockRejectedValue(new Error('ENOENT'));
1613+
1614+
const zipName = await maestroWithBuild['generateArtifactZipName']('/tmp/test');
16091615
expect(zipName).toBe('my_build_test_v1_0.zip');
16101616
});
16111617

1612-
it('should generate timestamp-based zip filename when no --build option', () => {
1618+
it('should generate timestamp-based zip filename when no --build option', async () => {
16131619
const optionsWithoutBuild = new MaestroOptions(
16141620
'path/to/app.apk',
16151621
'path/to/flows',
@@ -1621,10 +1627,29 @@ describe('Maestro', () => {
16211627
optionsWithoutBuild,
16221628
);
16231629

1624-
const zipName = maestroWithoutBuild['generateArtifactZipName']();
1630+
const zipName = await maestroWithoutBuild['generateArtifactZipName']('/tmp/test');
16251631
expect(zipName).toMatch(/^maestro_artifacts_\d{4}-\d{2}-\d{2}T.*\.zip$/);
16261632
});
16271633

1634+
it('should add timestamp suffix when zip file already exists', async () => {
1635+
const optionsWithBuild = new MaestroOptions(
1636+
'path/to/app.apk',
1637+
'path/to/flows',
1638+
'Pixel 6',
1639+
{
1640+
downloadArtifacts: true,
1641+
build: 'existing-build',
1642+
},
1643+
);
1644+
const maestroWithBuild = new Maestro(mockCredentials, optionsWithBuild);
1645+
1646+
// Mock access: file exists
1647+
(fs.promises.access as jest.Mock).mockResolvedValueOnce(undefined);
1648+
1649+
const zipName = await maestroWithBuild['generateArtifactZipName']('/tmp/test');
1650+
expect(zipName).toMatch(/^existing-build_\d+\.zip$/);
1651+
});
1652+
16281653
it('should fetch run details with assets', async () => {
16291654
maestro['appId'] = 1234;
16301655

@@ -1636,7 +1661,7 @@ describe('Maestro', () => {
16361661
completed: true,
16371662
assets_synced: true,
16381663
assets: {
1639-
logs: ['https://example.com/log1.txt'],
1664+
logs: { vm: 'https://example.com/log1.txt' },
16401665
video: 'https://example.com/video.mp4',
16411666
screenshots: ['https://example.com/screenshot1.png'],
16421667
},
@@ -1653,7 +1678,7 @@ describe('Maestro', () => {
16531678
}),
16541679
);
16551680
expect(result.assets_synced).toBe(true);
1656-
expect(result.assets?.logs).toHaveLength(1);
1681+
expect(Object.keys(result.assets?.logs || {})).toHaveLength(1);
16571682
expect(result.assets?.video).toBe('https://example.com/video.mp4');
16581683
});
16591684

@@ -1673,7 +1698,7 @@ describe('Maestro', () => {
16731698
status: 'DONE',
16741699
assets_synced: true,
16751700
assets: {
1676-
logs: ['https://example.com/log.txt'],
1701+
logs: { vm: 'https://example.com/log.txt' },
16771702
},
16781703
},
16791704
};

0 commit comments

Comments
 (0)