Skip to content

Commit 4ecfc70

Browse files
handle 429 status code
1 parent aea8f2b commit 4ecfc70

File tree

8 files changed

+241
-85
lines changed

8 files changed

+241
-85
lines changed

src/providers/espresso.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,10 +503,17 @@ export default class Espresso {
503503
if (cause && typeof cause === 'object') {
504504
const axiosError = cause as {
505505
response?: {
506+
status?: number;
506507
data?: { error?: string; errors?: string[]; message?: string };
507508
};
508509
message?: string;
509510
};
511+
512+
// Check for 429 status code (credits depleted)
513+
if (axiosError.response?.status === 429) {
514+
return 'Your TestingBot credits are depleted. Please upgrade your plan at https://testingbot.com/pricing';
515+
}
516+
510517
if (axiosError.response?.data?.errors) {
511518
return axiosError.response.data.errors.join('\n');
512519
}

src/providers/maestro.ts

Lines changed: 22 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export default class Maestro {
112112
throw new TestingBotError(`flows option is required`);
113113
}
114114

115-
// Check if all flows paths exist (can be files, directories, or glob patterns)
115+
// Check if all flows paths exist (can be files, directories or glob patterns)
116116
for (const flowsPath of this.options.flows) {
117117
const isGlobPattern =
118118
flowsPath.includes('*') ||
@@ -128,9 +128,6 @@ export default class Maestro {
128128
}
129129
}
130130

131-
// Device is optional - will be inferred from app file type if not provided
132-
133-
// Validate report options
134131
if (this.options.report && !this.options.reportOutputDir) {
135132
throw new TestingBotError(
136133
`--report-output-dir is required when --report is specified`,
@@ -141,7 +138,6 @@ export default class Maestro {
141138
await this.ensureOutputDirectory(this.options.reportOutputDir);
142139
}
143140

144-
// Validate artifact download options - output dir defaults to current directory
145141
if (this.options.downloadArtifacts && this.options.artifactsOutputDir) {
146142
await this.ensureOutputDirectory(this.options.artifactsOutputDir);
147143
}
@@ -159,7 +155,6 @@ export default class Maestro {
159155
}
160156
} catch (error) {
161157
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
162-
// Directory doesn't exist, try to create it
163158
try {
164159
await fs.promises.mkdir(dirPath, { recursive: true });
165160
} catch (mkdirError) {
@@ -221,7 +216,6 @@ export default class Maestro {
221216
return { success: true, runs: [] };
222217
}
223218

224-
// Set up signal handlers before waiting for completion
225219
this.setupSignalHandlers();
226220

227221
// Connect to real-time update server (unless --quiet is specified)
@@ -272,7 +266,6 @@ export default class Maestro {
272266
contentType = 'application/octet-stream';
273267
}
274268

275-
// Check if app already exists (unless checksum check is disabled)
276269
if (!this.options.ignoreChecksumCheck) {
277270
const checksum = await this.upload.calculateChecksum(appPath);
278271
const existingApp = await this.checkAppChecksum(checksum);
@@ -290,7 +283,6 @@ export default class Maestro {
290283
logger.info('Uploading Maestro App');
291284
}
292285

293-
// App doesn't exist (or checksum check skipped), upload it
294286
const result = await this.upload.upload({
295287
filePath: appPath,
296288
url: `${this.URL}/app`,
@@ -323,7 +315,6 @@ export default class Maestro {
323315
},
324316
);
325317

326-
// Check for version update notification
327318
const latestVersion = response.headers?.['x-testingbotctl-version'];
328319
utils.checkForUpdate(latestVersion);
329320

@@ -351,7 +342,6 @@ export default class Maestro {
351342
const stat = await fs.promises.stat(singlePath).catch(() => null);
352343
if (stat?.isFile() && path.extname(singlePath).toLowerCase() === '.zip') {
353344
zipPath = singlePath;
354-
// Upload the zip directly without cleanup
355345
await this.upload.upload({
356346
filePath: zipPath,
357347
url: `${this.URL}/${this.appId}/tests`,
@@ -419,7 +409,6 @@ export default class Maestro {
419409
// If we have a single directory, use it as base; otherwise use common ancestor or flatten
420410
const baseDir = baseDirs.length === 1 ? baseDirs[0] : undefined;
421411

422-
// Log files being included in the zip
423412
if (!this.options.quiet) {
424413
this.logIncludedFiles(allFlowFiles, baseDir);
425414
}
@@ -894,6 +883,7 @@ export default class Maestro {
894883
const startTime = Date.now();
895884
const previousStatus: Map<number, MaestroRunInfo['status']> = new Map();
896885
const previousFlowStatus: Map<number, MaestroFlowStatus> = new Map();
886+
const urlDisplayed: Set<number> = new Set();
897887
let flowsTableDisplayed = false;
898888
let displayedLineCount = 0;
899889

@@ -920,10 +910,22 @@ export default class Maestro {
920910
}
921911
}
922912

913+
// Show realtime URL once per run (before any in-place updates)
914+
for (const run of status.runs) {
915+
if (!urlDisplayed.has(run.id)) {
916+
console.log(
917+
` 🔗 Run ${run.id} (${run.capabilities.deviceName}): Watch in realtime:`,
918+
);
919+
console.log(
920+
` https://testingbot.com/members/maestro/${this.appId}/runs/${run.id}`,
921+
);
922+
urlDisplayed.add(run.id);
923+
}
924+
}
925+
923926
if (allFlows.length > 0) {
924927
if (!flowsTableDisplayed) {
925928
// First time showing flows - display header and initial state
926-
this.displayRunStatus(status.runs, startTime, previousStatus);
927929
console.log(); // Empty line before flows table
928930
this.displayFlowsTableHeader();
929931
displayedLineCount = this.displayFlowsWithLimit(allFlows, previousFlowStatus);
@@ -969,12 +971,10 @@ export default class Maestro {
969971
}
970972
}
971973

972-
// Fetch reports if requested
973974
if (this.options.report && this.options.reportOutputDir) {
974975
await this.fetchReports(status.runs);
975976
}
976977

977-
// Download artifacts if requested
978978
if (this.options.downloadArtifacts) {
979979
await this.downloadArtifacts(status.runs);
980980
}
@@ -1015,16 +1015,6 @@ export default class Maestro {
10151015
this.clearLine();
10161016
}
10171017

1018-
// Show URL when test starts running (transitions from WAITING to READY)
1019-
if (statusChanged && prevStatus === 'WAITING' && run.status === 'READY') {
1020-
console.log(
1021-
` 🚀 Run ${run.id} (${run.capabilities.deviceName}): Test started`,
1022-
);
1023-
console.log(
1024-
` Watch this test in realtime: https://testingbot.com/members/maestro/${this.appId}/runs/${run.id}`,
1025-
);
1026-
}
1027-
10281018
previousStatus.set(run.id, run.status);
10291019

10301020
const statusInfo = this.getStatusInfo(run.status);
@@ -1046,62 +1036,6 @@ export default class Maestro {
10461036
platformUtil.clearLine();
10471037
}
10481038

1049-
private displayFlowsProgress(
1050-
flows: MaestroFlowInfo[],
1051-
startTime: number,
1052-
isUpdate: boolean,
1053-
): void {
1054-
const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000);
1055-
const elapsedStr = this.formatElapsedTime(elapsedSeconds);
1056-
1057-
// Count flows by status
1058-
let waiting = 0;
1059-
let running = 0;
1060-
let passed = 0;
1061-
let failed = 0;
1062-
1063-
for (const flow of flows) {
1064-
switch (flow.status) {
1065-
case 'WAITING':
1066-
waiting++;
1067-
break;
1068-
case 'READY':
1069-
running++;
1070-
break;
1071-
case 'DONE':
1072-
if (flow.success === 1) {
1073-
passed++;
1074-
} else {
1075-
failed++;
1076-
}
1077-
break;
1078-
case 'FAILED':
1079-
failed++;
1080-
break;
1081-
}
1082-
}
1083-
1084-
const total = flows.length;
1085-
const completed = passed + failed;
1086-
1087-
// Build progress summary with colors
1088-
const parts: string[] = [];
1089-
if (waiting > 0) parts.push(colors.white(`${waiting} waiting`));
1090-
if (running > 0) parts.push(colors.blue(`${running} running`));
1091-
if (passed > 0) parts.push(colors.green(`${passed} passed`));
1092-
if (failed > 0) parts.push(colors.red(`${failed} failed`));
1093-
1094-
const progressBar = `[${completed}/${total}]`;
1095-
const message = ` 🔄 Flows ${progressBar}: ${parts.join(' | ')} (${elapsedStr})`;
1096-
1097-
if (isUpdate) {
1098-
// Clear current line and write new progress
1099-
process.stdout.write(`\r\x1b[K${message}`);
1100-
} else {
1101-
process.stdout.write(message);
1102-
}
1103-
}
1104-
11051039
private formatElapsedTime(seconds: number): string {
11061040
if (seconds < 60) {
11071041
return `${seconds}s`;
@@ -1410,7 +1344,6 @@ export default class Maestro {
14101344
},
14111345
});
14121346

1413-
// Check for version update notification
14141347
const latestVersion = response.headers?.['x-testingbotctl-version'];
14151348
utils.checkForUpdate(latestVersion);
14161349

@@ -1575,7 +1508,6 @@ export default class Maestro {
15751508
const runDir = path.join(tempDir, `run_${run.id}`);
15761509
await fs.promises.mkdir(runDir, { recursive: true });
15771510

1578-
// Download logs
15791511
if (
15801512
runDetails.assets.logs &&
15811513
Object.keys(runDetails.assets.logs).length > 0
@@ -1736,10 +1668,17 @@ export default class Maestro {
17361668
// Handle axios errors which have response.data
17371669
const axiosError = cause as {
17381670
response?: {
1671+
status?: number;
17391672
data?: { error?: string; errors?: string[]; message?: string };
17401673
};
17411674
message?: string;
17421675
};
1676+
1677+
// Check for 429 status code (credits depleted)
1678+
if (axiosError.response?.status === 429) {
1679+
return 'Your TestingBot credits are depleted. Please upgrade your plan at https://testingbot.com/pricing';
1680+
}
1681+
17431682
if (axiosError.response?.data?.errors) {
17441683
return axiosError.response.data.errors.join('\n');
17451684
}
@@ -1750,12 +1689,10 @@ export default class Maestro {
17501689
return axiosError.response.data.message;
17511690
}
17521691

1753-
// Handle standard Error objects
17541692
if (cause instanceof Error) {
17551693
return cause.message;
17561694
}
17571695

1758-
// Handle plain objects with errors array, error, or message property
17591696
const obj = cause as {
17601697
errors?: string[];
17611698
error?: string;

src/providers/xcuitest.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,10 +506,17 @@ export default class XCUITest {
506506
if (cause && typeof cause === 'object') {
507507
const axiosError = cause as {
508508
response?: {
509+
status?: number;
509510
data?: { error?: string; errors?: string[]; message?: string };
510511
};
511512
message?: string;
512513
};
514+
515+
// Check for 429 status code (credits depleted)
516+
if (axiosError.response?.status === 429) {
517+
return 'Your TestingBot credits are depleted. Please upgrade your plan at https://testingbot.com/pricing';
518+
}
519+
513520
if (axiosError.response?.data?.errors) {
514521
return axiosError.response.data.errors.join('\n');
515522
}

src/upload.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,11 @@ export default class Upload {
126126
' ~/.testingbot file with content: key:secret',
127127
);
128128
}
129+
if (error.response?.status === 429) {
130+
throw new TestingBotError(
131+
'Your TestingBot credits are depleted. Please upgrade your plan at https://testingbot.com/pricing',
132+
);
133+
}
129134
const message = error.response?.data?.error || error.message;
130135
throw new TestingBotError(`Upload failed: ${message}`);
131136
}

tests/providers/espresso.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,4 +1002,54 @@ describe('Espresso', () => {
10021002
stdoutSpy.mockRestore();
10031003
});
10041004
});
1005+
1006+
describe('extractErrorMessage', () => {
1007+
it('should return credits depleted message for 429 status code', () => {
1008+
const axiosError = {
1009+
response: {
1010+
status: 429,
1011+
data: {},
1012+
},
1013+
message: 'Request failed with status code 429',
1014+
};
1015+
1016+
const result = espresso['extractErrorMessage'](axiosError);
1017+
1018+
expect(result).toBe(
1019+
'Your TestingBot credits are depleted. Please upgrade your plan at https://testingbot.com/pricing',
1020+
);
1021+
});
1022+
1023+
it('should return error message from response data for non-429 errors', () => {
1024+
const axiosError = {
1025+
response: {
1026+
status: 400,
1027+
data: {
1028+
error: 'Invalid request',
1029+
},
1030+
},
1031+
message: 'Request failed',
1032+
};
1033+
1034+
const result = espresso['extractErrorMessage'](axiosError);
1035+
1036+
expect(result).toBe('Invalid request');
1037+
});
1038+
1039+
it('should return string cause directly', () => {
1040+
const result = espresso['extractErrorMessage']('Simple error message');
1041+
1042+
expect(result).toBe('Simple error message');
1043+
});
1044+
1045+
it('should join array of errors with newlines', () => {
1046+
const result = espresso['extractErrorMessage']([
1047+
'Error 1',
1048+
'Error 2',
1049+
'Error 3',
1050+
]);
1051+
1052+
expect(result).toBe('Error 1\nError 2\nError 3');
1053+
});
1054+
});
10051055
});

0 commit comments

Comments
 (0)