Skip to content

Commit 0b7ee28

Browse files
Merge branch 'main' into dependabot/npm_and_yarn/axios-1.18.0
2 parents 56b8902 + 296468f commit 0b7ee28

2 files changed

Lines changed: 92 additions & 10 deletions

File tree

src/providers/maestro.ts

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,24 @@ const FLOW_ANIMATION_MS = 120;
2222
// Single-column glyph so it does not disturb the row-width math used by the
2323
// in-place table redraw; marks flow rows that are retry attempts.
2424
const RETRY_ICON = '↻';
25+
const TERMINAL_RUN_STATUSES: ReadonlySet<MaestroRunInfo['status']> = new Set([
26+
'DONE',
27+
'FAILED',
28+
'CANCELLED',
29+
]);
2530

2631
export interface MaestroRunAssets {
2732
logs?: Record<string, string>;
2833
video?: string | false;
2934
screenshots?: string[];
3035
}
3136

32-
export type MaestroFlowStatus = 'WAITING' | 'READY' | 'DONE' | 'FAILED';
37+
export type MaestroFlowStatus =
38+
| 'WAITING'
39+
| 'READY'
40+
| 'DONE'
41+
| 'FAILED'
42+
| 'CANCELLED';
3343

3444
export interface MaestroFlowInfo {
3545
id: number;
@@ -53,7 +63,7 @@ export interface MaestroRunEnvironment {
5363

5464
export interface MaestroRunInfo {
5565
id: number;
56-
status: 'WAITING' | 'READY' | 'DONE' | 'FAILED';
66+
status: 'WAITING' | 'READY' | 'DONE' | 'FAILED' | 'CANCELLED';
5767
capabilities: {
5868
deviceName: string;
5969
platformName: string;
@@ -2062,8 +2072,8 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
20622072
const flows = run.flows ?? [];
20632073
if (flows.length === 0) {
20642074
// No flows yet: only settled once the run itself reaches a terminal
2065-
// state (e.g. it failed before producing any flow).
2066-
if (run.status !== 'DONE' && run.status !== 'FAILED') return false;
2075+
// state (e.g. it failed before producing any flow, or it was cancelled).
2076+
if (!TERMINAL_RUN_STATUSES.has(run.status)) return false;
20672077
continue;
20682078
}
20692079

@@ -2166,6 +2176,45 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
21662176
});
21672177
}
21682178

2179+
/**
2180+
* Stops a run, overriding the base provider's `/stop` with Maestro's `/cancel`.
2181+
*
2182+
* A 409 means the run reached a terminal state first, which is the normal
2183+
* outcome when Ctrl-C lands as the last flow finishes. That is a success for
2184+
* our purposes, so it is not reported as a failure to stop.
2185+
*/
2186+
protected override async stopRun(runId: number): Promise<void> {
2187+
if (!this.appId) {
2188+
return;
2189+
}
2190+
2191+
try {
2192+
await axios.post(
2193+
`${this.URL}/${this.appId}/${runId}/cancel`,
2194+
{},
2195+
{
2196+
headers: {
2197+
'Content-Type': 'application/json',
2198+
'User-Agent': utils.getUserAgent(),
2199+
},
2200+
auth: {
2201+
username: this.credentials.userName,
2202+
password: this.credentials.accessKey,
2203+
},
2204+
timeout: HTTP.TIMEOUT_MS,
2205+
},
2206+
);
2207+
2208+
if (!this.options.quiet) {
2209+
logger.info(`Cancelled run ${runId}`);
2210+
}
2211+
} catch (error) {
2212+
if (axios.isAxiosError(error) && error.response?.status === 409) {
2213+
return;
2214+
}
2215+
}
2216+
}
2217+
21692218
/** Stable key identifying the logical flow/shard a flow attempt belongs to. */
21702219
private flowGroupKey(flow: MaestroFlowInfo): string {
21712220
return flow.shard_index != null
@@ -2291,9 +2340,11 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
22912340

22922341
const status = await this.getStatus();
22932342

2294-
// Track active run IDs for graceful shutdown
2343+
// Track active run IDs for graceful shutdown. CANCELLED belongs in the terminal
2344+
// set: a run cancelled from the dashboard while we poll must not be listed as
2345+
// active, or Ctrl-C would fire another stop at a run that is already settled.
22952346
this.activeRunIds = status.runs
2296-
.filter((run) => run.status !== 'DONE' && run.status !== 'FAILED')
2347+
.filter((run) => !TERMINAL_RUN_STATUSES.has(run.status))
22972348
.map((run) => run.id);
22982349

22992350
const running = status.runs.find((r) => r.status === 'READY');
@@ -2556,6 +2607,8 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
25562607
return { symbol: pc.green('✔'), text: 'Test has finished running' };
25572608
case 'FAILED':
25582609
return { symbol: pc.red('✘'), text: 'Test failed' };
2610+
case 'CANCELLED':
2611+
return { symbol: pc.yellow('⊘'), text: 'Test was cancelled' };
25592612
default:
25602613
return { symbol: pc.dim('?'), text: status };
25612614
}
@@ -2585,6 +2638,8 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
25852638
}
25862639
case 'FAILED':
25872640
return { text: '✘ FAILED', colored: pc.red('✘ FAILED') };
2641+
case 'CANCELLED':
2642+
return { text: '⊘ CANCELLED', colored: pc.yellow('⊘ CANCELLED') };
25882643
default:
25892644
return { text: flow.status, colored: flow.status };
25902645
}
@@ -2669,6 +2724,7 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
26692724
let running = 0;
26702725
let passed = 0;
26712726
let failed = 0;
2727+
let cancelled = 0;
26722728

26732729
for (const flow of remaining) {
26742730
switch (flow.status) {
@@ -2688,6 +2744,9 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
26882744
case 'FAILED':
26892745
failed++;
26902746
break;
2747+
case 'CANCELLED':
2748+
cancelled++;
2749+
break;
26912750
}
26922751
}
26932752

@@ -2696,6 +2755,7 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
26962755
if (running > 0) parts.push(pc.blue(`${running} running`));
26972756
if (passed > 0) parts.push(pc.green(`${passed} passed`));
26982757
if (failed > 0) parts.push(pc.red(`${failed} failed`));
2758+
if (cancelled > 0) parts.push(pc.yellow(`${cancelled} cancelled`));
26992759

27002760
return ` ... and ${remaining.length} more: ${parts.join(', ')}`;
27012761
}

tests/providers/maestro.test.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1885,13 +1885,15 @@ describe('Maestro', () => {
18851885
maestro['appId'] = 1234;
18861886
});
18871887

1888-
it('should call stop API for a specific run', async () => {
1888+
// Maestro overrides the base provider's /stop with /cancel, which also releases
1889+
// the run's device session and drops its still-queued grid requests.
1890+
it('should call cancel API for a specific run', async () => {
18891891
axios.post = jest.fn().mockResolvedValue({ data: { success: true } });
18901892

18911893
await maestro['stopRun'](5678);
18921894

18931895
expect(axios.post).toHaveBeenCalledWith(
1894-
'https://api.testingbot.com/v1/app-automate/maestro/1234/5678/stop',
1896+
'https://api.testingbot.com/v1/app-automate/maestro/1234/5678/cancel',
18951897
{},
18961898
expect.objectContaining({
18971899
auth: {
@@ -1910,17 +1912,37 @@ describe('Maestro', () => {
19101912

19111913
expect(axios.post).toHaveBeenCalledTimes(2);
19121914
expect(axios.post).toHaveBeenCalledWith(
1913-
'https://api.testingbot.com/v1/app-automate/maestro/1234/5678/stop',
1915+
'https://api.testingbot.com/v1/app-automate/maestro/1234/5678/cancel',
19141916
{},
19151917
expect.any(Object),
19161918
);
19171919
expect(axios.post).toHaveBeenCalledWith(
1918-
'https://api.testingbot.com/v1/app-automate/maestro/1234/9012/stop',
1920+
'https://api.testingbot.com/v1/app-automate/maestro/1234/9012/cancel',
19191921
{},
19201922
expect.any(Object),
19211923
);
19221924
});
19231925

1926+
// 409 is the API saying the run already reached a terminal state, which is the
1927+
// normal race when Ctrl-C lands as the last flow finishes. Nothing to report.
1928+
it('should treat a 409 as an already-finished run rather than a failure', async () => {
1929+
const conflict = Object.assign(new Error('Request failed'), {
1930+
isAxiosError: true,
1931+
response: { status: 409, data: { error: 'already finished' } },
1932+
});
1933+
axios.post = jest.fn().mockRejectedValue(conflict);
1934+
(axios.isAxiosError as unknown as jest.Mock).mockReturnValue(true);
1935+
const infoSpy = jest.spyOn(logger, 'info').mockImplementation();
1936+
1937+
await expect(maestro['stopRun'](5678)).resolves.toBeUndefined();
1938+
expect(infoSpy).not.toHaveBeenCalledWith(
1939+
expect.stringContaining('Cancelled run 5678'),
1940+
);
1941+
1942+
infoSpy.mockRestore();
1943+
(axios.isAxiosError as unknown as jest.Mock).mockReset();
1944+
});
1945+
19241946
it('should not call stop API when no active runs', async () => {
19251947
axios.post = jest.fn();
19261948
maestro['activeRunIds'] = [];

0 commit comments

Comments
 (0)