Skip to content

Commit 51eb617

Browse files
maestro: add signal handlers
1 parent 3387a6b commit 51eb617

File tree

2 files changed

+222
-0
lines changed

2 files changed

+222
-0
lines changed

src/providers/maestro.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ export default class Maestro {
4848

4949
private appId: number | undefined = undefined;
5050
private detectedPlatform: 'Android' | 'iOS' | undefined = undefined;
51+
private activeRunIds: number[] = [];
52+
private isShuttingDown = false;
53+
private signalHandler: (() => void) | null = null;
5154

5255
public constructor(credentials: Credentials, options: MaestroOptions) {
5356
this.credentials = credentials;
@@ -175,12 +178,22 @@ export default class Maestro {
175178
return { success: true, runs: [] };
176179
}
177180

181+
// Set up signal handlers before waiting for completion
182+
this.setupSignalHandlers();
183+
178184
if (!this.options.quiet) {
179185
logger.info('Waiting for test results...');
180186
}
181187
const result = await this.waitForCompletion();
188+
189+
// Clean up signal handlers
190+
this.removeSignalHandlers();
191+
182192
return result;
183193
} catch (error) {
194+
// Clean up signal handlers on error
195+
this.removeSignalHandlers();
196+
184197
logger.error(error instanceof Error ? error.message : error);
185198
// Display the cause if available
186199
if (error instanceof Error && error.cause) {
@@ -522,8 +535,18 @@ export default class Maestro {
522535
const previousStatus: Map<number, MaestroRunInfo['status']> = new Map();
523536

524537
while (attempts < this.MAX_POLL_ATTEMPTS) {
538+
// Check if we're shutting down
539+
if (this.isShuttingDown) {
540+
throw new TestingBotError('Test run cancelled by user');
541+
}
542+
525543
const status = await this.getStatus();
526544

545+
// Track active run IDs for graceful shutdown
546+
this.activeRunIds = status.runs
547+
.filter((run) => run.status !== 'DONE' && run.status !== 'FAILED')
548+
.map((run) => run.id);
549+
527550
// Log current status of runs (unless quiet mode)
528551
if (!this.options.quiet) {
529552
this.displayRunStatus(status.runs, startTime, previousStatus);
@@ -760,4 +783,92 @@ export default class Maestro {
760783

761784
return null;
762785
}
786+
787+
private setupSignalHandlers(): void {
788+
this.signalHandler = () => {
789+
this.handleShutdown();
790+
};
791+
792+
process.on('SIGINT', this.signalHandler);
793+
process.on('SIGTERM', this.signalHandler);
794+
}
795+
796+
private removeSignalHandlers(): void {
797+
if (this.signalHandler) {
798+
process.removeListener('SIGINT', this.signalHandler);
799+
process.removeListener('SIGTERM', this.signalHandler);
800+
this.signalHandler = null;
801+
}
802+
}
803+
804+
private handleShutdown(): void {
805+
if (this.isShuttingDown) {
806+
// Already shutting down, force exit on second signal
807+
logger.warn('Force exiting...');
808+
process.exit(1);
809+
}
810+
811+
this.isShuttingDown = true;
812+
this.clearLine();
813+
logger.warn('Received interrupt signal, stopping test runs...');
814+
815+
// Stop all active runs
816+
this.stopActiveRuns()
817+
.then(() => {
818+
logger.info('All test runs have been stopped.');
819+
process.exit(1);
820+
})
821+
.catch((error) => {
822+
logger.error(
823+
`Failed to stop some test runs: ${error instanceof Error ? error.message : error}`,
824+
);
825+
process.exit(1);
826+
});
827+
}
828+
829+
private async stopActiveRuns(): Promise<void> {
830+
if (!this.appId || this.activeRunIds.length === 0) {
831+
return;
832+
}
833+
834+
const stopPromises = this.activeRunIds.map((runId) =>
835+
this.stopRun(runId).catch((error) => {
836+
logger.error(
837+
`Failed to stop run ${runId}: ${error instanceof Error ? error.message : error}`,
838+
);
839+
}),
840+
);
841+
842+
await Promise.all(stopPromises);
843+
}
844+
845+
private async stopRun(runId: number): Promise<void> {
846+
if (!this.appId) {
847+
return;
848+
}
849+
850+
try {
851+
await axios.post(
852+
`${this.URL}/${this.appId}/${runId}/stop`,
853+
{},
854+
{
855+
headers: {
856+
'User-Agent': utils.getUserAgent(),
857+
},
858+
auth: {
859+
username: this.credentials.userName,
860+
password: this.credentials.accessKey,
861+
},
862+
},
863+
);
864+
865+
if (!this.options.quiet) {
866+
logger.info(` Stopped run ${runId}`);
867+
}
868+
} catch (error) {
869+
throw new TestingBotError(`Failed to stop run ${runId}`, {
870+
cause: error,
871+
});
872+
}
873+
}
763874
}

tests/providers/maestro.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1214,4 +1214,115 @@ describe('Maestro', () => {
12141214
});
12151215
});
12161216
});
1217+
1218+
describe('Stop Run', () => {
1219+
beforeEach(() => {
1220+
maestro['appId'] = 1234;
1221+
});
1222+
1223+
it('should call stop API for a specific run', async () => {
1224+
axios.post = jest.fn().mockResolvedValue({ data: { success: true } });
1225+
1226+
await maestro['stopRun'](5678);
1227+
1228+
expect(axios.post).toHaveBeenCalledWith(
1229+
'https://api.testingbot.com/v1/app-automate/maestro/1234/5678/stop',
1230+
{},
1231+
expect.objectContaining({
1232+
auth: {
1233+
username: 'testUser',
1234+
password: 'testKey',
1235+
},
1236+
}),
1237+
);
1238+
});
1239+
1240+
it('should stop multiple active runs', async () => {
1241+
axios.post = jest.fn().mockResolvedValue({ data: { success: true } });
1242+
maestro['activeRunIds'] = [5678, 9012];
1243+
1244+
await maestro['stopActiveRuns']();
1245+
1246+
expect(axios.post).toHaveBeenCalledTimes(2);
1247+
expect(axios.post).toHaveBeenCalledWith(
1248+
'https://api.testingbot.com/v1/app-automate/maestro/1234/5678/stop',
1249+
{},
1250+
expect.any(Object),
1251+
);
1252+
expect(axios.post).toHaveBeenCalledWith(
1253+
'https://api.testingbot.com/v1/app-automate/maestro/1234/9012/stop',
1254+
{},
1255+
expect.any(Object),
1256+
);
1257+
});
1258+
1259+
it('should not call stop API when no active runs', async () => {
1260+
axios.post = jest.fn();
1261+
maestro['activeRunIds'] = [];
1262+
1263+
await maestro['stopActiveRuns']();
1264+
1265+
expect(axios.post).not.toHaveBeenCalled();
1266+
});
1267+
1268+
it('should not call stop API when appId is not set', async () => {
1269+
axios.post = jest.fn();
1270+
maestro['appId'] = undefined;
1271+
maestro['activeRunIds'] = [5678];
1272+
1273+
await maestro['stopActiveRuns']();
1274+
1275+
expect(axios.post).not.toHaveBeenCalled();
1276+
});
1277+
1278+
it('should continue stopping other runs when one fails', async () => {
1279+
axios.post = jest
1280+
.fn()
1281+
.mockRejectedValueOnce(new Error('Network error'))
1282+
.mockResolvedValueOnce({ data: { success: true } });
1283+
maestro['activeRunIds'] = [5678, 9012];
1284+
1285+
// Should not throw
1286+
await maestro['stopActiveRuns']();
1287+
1288+
expect(axios.post).toHaveBeenCalledTimes(2);
1289+
});
1290+
1291+
it('should filter active run IDs correctly', () => {
1292+
const runs = [
1293+
{
1294+
id: 5678,
1295+
status: 'WAITING' as const,
1296+
capabilities: { deviceName: 'Pixel 6', platformName: 'Android' },
1297+
success: 0,
1298+
},
1299+
{
1300+
id: 9012,
1301+
status: 'READY' as const,
1302+
capabilities: { deviceName: 'Pixel 8', platformName: 'Android' },
1303+
success: 0,
1304+
},
1305+
{
1306+
id: 1111,
1307+
status: 'DONE' as const,
1308+
capabilities: { deviceName: 'Pixel 7', platformName: 'Android' },
1309+
success: 1,
1310+
},
1311+
{
1312+
id: 2222,
1313+
status: 'FAILED' as const,
1314+
capabilities: { deviceName: 'Pixel 5', platformName: 'Android' },
1315+
success: 0,
1316+
},
1317+
];
1318+
1319+
// Simulate what waitForCompletion does to track active runs
1320+
const activeRunIds = runs
1321+
.filter((run) => run.status !== 'DONE' && run.status !== 'FAILED')
1322+
.map((run) => run.id);
1323+
1324+
// Only WAITING and READY runs should be tracked
1325+
expect(activeRunIds).toEqual([5678, 9012]);
1326+
});
1327+
});
12171328
});

0 commit comments

Comments
 (0)