@testingbot/cli 1.0.9 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +26 -1
- package/dist/logger.d.ts.map +1 -1
- package/dist/logger.js +1 -1
- package/dist/models/maestro_options.d.ts +8 -0
- package/dist/models/maestro_options.d.ts.map +1 -1
- package/dist/models/maestro_options.js +22 -0
- package/dist/models/xcuitest_options.d.ts +3 -0
- package/dist/models/xcuitest_options.d.ts.map +1 -1
- package/dist/models/xcuitest_options.js +6 -0
- package/dist/providers/base_provider.d.ts +9 -0
- package/dist/providers/base_provider.d.ts.map +1 -1
- package/dist/providers/base_provider.js +11 -0
- package/dist/providers/espresso.d.ts +1 -1
- package/dist/providers/espresso.d.ts.map +1 -1
- package/dist/providers/espresso.js +3 -3
- package/dist/providers/maestro.d.ts +79 -0
- package/dist/providers/maestro.d.ts.map +1 -1
- package/dist/providers/maestro.js +463 -81
- package/dist/providers/xcuitest.d.ts +5 -1
- package/dist/providers/xcuitest.d.ts.map +1 -1
- package/dist/providers/xcuitest.js +23 -11
- package/dist/upload.d.ts +1 -0
- package/dist/upload.d.ts.map +1 -1
- package/dist/upload.js +6 -1
- package/package.json +4 -1
|
@@ -54,6 +54,9 @@ const terminal_title_1 = require("../ui/terminal-title");
|
|
|
54
54
|
const constants_1 = require("../config/constants");
|
|
55
55
|
const FLOW_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
56
56
|
const FLOW_ANIMATION_MS = 120;
|
|
57
|
+
// Single-column glyph so it does not disturb the row-width math used by the
|
|
58
|
+
// in-place table redraw; marks flow rows that are retry attempts.
|
|
59
|
+
const RETRY_ICON = '↻';
|
|
57
60
|
class Maestro extends base_provider_1.default {
|
|
58
61
|
URL = 'https://api.testingbot.com/v1/app-automate/maestro';
|
|
59
62
|
detectedPlatform = undefined;
|
|
@@ -61,10 +64,21 @@ class Maestro extends base_provider_1.default {
|
|
|
61
64
|
updateServer = null;
|
|
62
65
|
updateKey = null;
|
|
63
66
|
socketFallbackWarned = false;
|
|
67
|
+
otherAppUrls = [];
|
|
64
68
|
flowAnimationFrame = 0;
|
|
65
69
|
flowAnimationTimer = null;
|
|
66
70
|
latestFlows = [];
|
|
67
71
|
latestDisplayedLineCount = 0;
|
|
72
|
+
flowsTableDisplayed = false;
|
|
73
|
+
// Tracks whether the currently-drawn table header includes the "Fail reason"
|
|
74
|
+
// column. When failures appear after the header was first drawn (e.g. a flow
|
|
75
|
+
// fails while others still run, or during a retry), the header is redrawn so
|
|
76
|
+
// the live in-place rows can show fail reasons instead of waiting for the
|
|
77
|
+
// final summary.
|
|
78
|
+
flowTableHasFailuresColumn = false;
|
|
79
|
+
// Maps a flow's MaestroRunTest id to its 1-based attempt number within its
|
|
80
|
+
// logical group. Attempt > 1 means the row is a retry. Recomputed on render.
|
|
81
|
+
flowAttempts = new Map();
|
|
68
82
|
constructor(credentials, options) {
|
|
69
83
|
super(credentials, options);
|
|
70
84
|
}
|
|
@@ -91,12 +105,35 @@ class Maestro extends base_provider_1.default {
|
|
|
91
105
|
if (this.options.report && !this.options.reportOutputDir) {
|
|
92
106
|
throw new testingbot_error_1.default(`--report-output-dir is required when --report is specified`);
|
|
93
107
|
}
|
|
108
|
+
// Validate other-apps count and extensions
|
|
109
|
+
const otherApps = this.options.otherApps;
|
|
110
|
+
if (otherApps.length > 4) {
|
|
111
|
+
throw new testingbot_error_1.default(`Too many other apps (${otherApps.length}). Maximum is 4.`);
|
|
112
|
+
}
|
|
113
|
+
for (const otherAppEntry of otherApps) {
|
|
114
|
+
if (Maestro.isOtherAppUrl(otherAppEntry)) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const otherExt = node_path_1.default.extname(otherAppEntry).toLowerCase();
|
|
118
|
+
if (!Maestro.SUPPORTED_APP_EXTENSIONS.includes(otherExt)) {
|
|
119
|
+
throw new testingbot_error_1.default(`Unsupported other-app file format: ${otherExt || '(no extension)'} for ${otherAppEntry}. ` +
|
|
120
|
+
`Supported formats: ${Maestro.SUPPORTED_APP_EXTENSIONS.join(', ')}, or a tb:// / http(s):// URL`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
94
123
|
// Build list of all file checks to run in parallel
|
|
95
124
|
const fileChecks = [
|
|
96
125
|
node_fs_1.default.promises.access(this.options.app, node_fs_1.default.constants.R_OK).catch(() => {
|
|
97
126
|
throw new testingbot_error_1.default(`Provided app path does not exist ${this.options.app}`);
|
|
98
127
|
}),
|
|
99
128
|
];
|
|
129
|
+
for (const otherAppEntry of otherApps) {
|
|
130
|
+
if (Maestro.isOtherAppUrl(otherAppEntry)) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
fileChecks.push(node_fs_1.default.promises.access(otherAppEntry, node_fs_1.default.constants.R_OK).catch(() => {
|
|
134
|
+
throw new testingbot_error_1.default(`Provided other-app path does not exist ${otherAppEntry}`);
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
100
137
|
if (this.options.configFile) {
|
|
101
138
|
fileChecks.push(node_fs_1.default.promises
|
|
102
139
|
.access(this.options.configFile, node_fs_1.default.constants.R_OK)
|
|
@@ -147,6 +184,12 @@ class Maestro extends base_provider_1.default {
|
|
|
147
184
|
const metadata = this.options.metadata;
|
|
148
185
|
// Process flows to show actual zip structure
|
|
149
186
|
const flowResult = await this.collectFlows();
|
|
187
|
+
const otherApps = this.options.otherApps;
|
|
188
|
+
const otherAppPaths = otherApps.filter((v) => !Maestro.isOtherAppUrl(v));
|
|
189
|
+
let uploadCounter = 0;
|
|
190
|
+
const previewOtherAppUrls = otherApps.map((entry) => Maestro.isOtherAppUrl(entry)
|
|
191
|
+
? entry
|
|
192
|
+
: `<tb://appkey-other-app-${++uploadCounter}>`);
|
|
150
193
|
this.printDryRunSummary({
|
|
151
194
|
provider: 'Maestro',
|
|
152
195
|
apiUrl: this.URL,
|
|
@@ -156,6 +199,11 @@ class Maestro extends base_provider_1.default {
|
|
|
156
199
|
filePath: this.options.app,
|
|
157
200
|
endpoint: `${this.URL}/app`,
|
|
158
201
|
},
|
|
202
|
+
...otherAppPaths.map((p, i) => ({
|
|
203
|
+
label: `Other App ${i + 1}`,
|
|
204
|
+
filePath: p,
|
|
205
|
+
endpoint: `${this.URL}/other-apps`,
|
|
206
|
+
})),
|
|
159
207
|
{
|
|
160
208
|
label: 'Flows',
|
|
161
209
|
filePath: this.options.flows.join(', '),
|
|
@@ -169,6 +217,9 @@ class Maestro extends base_provider_1.default {
|
|
|
169
217
|
shardSplit: this.options.shardSplit,
|
|
170
218
|
}),
|
|
171
219
|
...(metadata && { metadata }),
|
|
220
|
+
...(otherApps.length > 0 && {
|
|
221
|
+
otherApps: previewOtherAppUrls,
|
|
222
|
+
}),
|
|
172
223
|
},
|
|
173
224
|
});
|
|
174
225
|
// Show zip structure details
|
|
@@ -197,6 +248,10 @@ class Maestro extends base_provider_1.default {
|
|
|
197
248
|
}
|
|
198
249
|
(0, terminal_title_1.setTitle)('maestro · uploading app');
|
|
199
250
|
await this.uploadApp();
|
|
251
|
+
if (this.options.otherApps.length > 0) {
|
|
252
|
+
(0, terminal_title_1.setTitle)('maestro · uploading other apps');
|
|
253
|
+
await this.uploadOtherApps();
|
|
254
|
+
}
|
|
200
255
|
if (!this.options.quiet) {
|
|
201
256
|
logger_1.default.info('Uploading Maestro Flows');
|
|
202
257
|
}
|
|
@@ -205,6 +260,9 @@ class Maestro extends base_provider_1.default {
|
|
|
205
260
|
if (this.options.tunnel && this.options.async) {
|
|
206
261
|
throw new testingbot_error_1.default('Cannot use --tunnel with --async mode. The tunnel would close when the CLI exits. Use a standalone tunnel instead.');
|
|
207
262
|
}
|
|
263
|
+
if (this.options.retry > 0 && this.options.async) {
|
|
264
|
+
throw new testingbot_error_1.default('Cannot use --retry with --async mode. Retrying failed flows requires waiting for results.');
|
|
265
|
+
}
|
|
208
266
|
await this.startTunnel();
|
|
209
267
|
if (!this.options.quiet) {
|
|
210
268
|
logger_1.default.info('Running Maestro Tests');
|
|
@@ -316,6 +374,58 @@ class Maestro extends base_provider_1.default {
|
|
|
316
374
|
}
|
|
317
375
|
}
|
|
318
376
|
}
|
|
377
|
+
static isOtherAppUrl(value) {
|
|
378
|
+
return (value.startsWith('tb://') ||
|
|
379
|
+
value.startsWith('http://') ||
|
|
380
|
+
value.startsWith('https://'));
|
|
381
|
+
}
|
|
382
|
+
async uploadOtherApps() {
|
|
383
|
+
const others = this.options.otherApps;
|
|
384
|
+
if (!others || others.length === 0)
|
|
385
|
+
return;
|
|
386
|
+
const uploadable = others.filter((v) => !Maestro.isOtherAppUrl(v));
|
|
387
|
+
if (!this.options.quiet) {
|
|
388
|
+
if (uploadable.length > 0) {
|
|
389
|
+
logger_1.default.info(`Uploading ${uploadable.length} other app(s)`);
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
logger_1.default.info(`Using ${others.length} other app URL(s)`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
for (let i = 0; i < others.length; i++) {
|
|
396
|
+
const entry = others[i];
|
|
397
|
+
if (Maestro.isOtherAppUrl(entry)) {
|
|
398
|
+
if (!this.options.quiet) {
|
|
399
|
+
logger_1.default.info(` [${i + 1}/${others.length}] ${entry} (URL)`);
|
|
400
|
+
}
|
|
401
|
+
this.otherAppUrls.push(entry);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
const ext = node_path_1.default.extname(entry).toLowerCase();
|
|
405
|
+
const contentType = ext === '.apk'
|
|
406
|
+
? 'application/vnd.android.package-archive'
|
|
407
|
+
: ext === '.ipa'
|
|
408
|
+
? 'application/octet-stream'
|
|
409
|
+
: ext === '.zip' || ext === '.app'
|
|
410
|
+
? 'application/zip'
|
|
411
|
+
: 'application/octet-stream';
|
|
412
|
+
if (!this.options.quiet) {
|
|
413
|
+
logger_1.default.info(` [${i + 1}/${others.length}] ${node_path_1.default.basename(entry)}`);
|
|
414
|
+
}
|
|
415
|
+
const result = await this.upload.upload({
|
|
416
|
+
filePath: entry,
|
|
417
|
+
url: `${this.URL}/other-apps`,
|
|
418
|
+
credentials: this.credentials,
|
|
419
|
+
contentType,
|
|
420
|
+
showProgress: !this.options.quiet,
|
|
421
|
+
validateZipFormat: ext === '.zip' || ext === '.app',
|
|
422
|
+
});
|
|
423
|
+
if (!result.app_url) {
|
|
424
|
+
throw new testingbot_error_1.default(`Other-app upload returned no app_url for ${entry}`);
|
|
425
|
+
}
|
|
426
|
+
this.otherAppUrls.push(result.app_url);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
319
429
|
/**
|
|
320
430
|
* Zip a .app bundle directory into a temporary zip file
|
|
321
431
|
*/
|
|
@@ -1186,6 +1296,9 @@ class Maestro extends base_provider_1.default {
|
|
|
1186
1296
|
shardSplit: this.options.shardSplit,
|
|
1187
1297
|
}),
|
|
1188
1298
|
...(metadata && { metadata }),
|
|
1299
|
+
...(this.otherAppUrls.length > 0 && {
|
|
1300
|
+
otherApps: this.otherAppUrls,
|
|
1301
|
+
}),
|
|
1189
1302
|
}, {
|
|
1190
1303
|
headers: {
|
|
1191
1304
|
'Content-Type': 'application/json',
|
|
@@ -1249,6 +1362,215 @@ class Maestro extends base_provider_1.default {
|
|
|
1249
1362
|
}
|
|
1250
1363
|
}
|
|
1251
1364
|
async waitForCompletion() {
|
|
1365
|
+
let status;
|
|
1366
|
+
if (this.options.retry > 0) {
|
|
1367
|
+
// Retry failed flows the moment they settle — while the rest of the run
|
|
1368
|
+
// is still executing — instead of waiting for the whole run to finish
|
|
1369
|
+
// first. Each retry creates a new attempt that surfaces in the live
|
|
1370
|
+
// table (marked with the ↻ icon) on the following poll.
|
|
1371
|
+
const retriedFrom = new Set();
|
|
1372
|
+
const abandoned = new Set();
|
|
1373
|
+
status = await this.pollOnce((s) => this.retriesComplete(s, retriedFrom, abandoned), (s) => this.issueEligibleRetries(s, retriedFrom, abandoned));
|
|
1374
|
+
}
|
|
1375
|
+
else {
|
|
1376
|
+
status = await this.pollOnce((s) => s.completed);
|
|
1377
|
+
}
|
|
1378
|
+
return this.finalize(status);
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* Poll completion condition used when --retry > 0. True once no flow is
|
|
1382
|
+
* still running and no failed flow is still eligible for — or awaiting the
|
|
1383
|
+
* result of — a retry. Replaces the run-level `completed` flag, which can
|
|
1384
|
+
* flip prematurely while a freshly-queued retry has not yet surfaced.
|
|
1385
|
+
*/
|
|
1386
|
+
retriesComplete(status, retriedFrom, abandoned) {
|
|
1387
|
+
for (const run of status.runs) {
|
|
1388
|
+
const flows = run.flows ?? [];
|
|
1389
|
+
if (flows.length === 0) {
|
|
1390
|
+
// No flows yet: only settled once the run itself reaches a terminal
|
|
1391
|
+
// state (e.g. it failed before producing any flow).
|
|
1392
|
+
if (run.status !== 'DONE' && run.status !== 'FAILED')
|
|
1393
|
+
return false;
|
|
1394
|
+
continue;
|
|
1395
|
+
}
|
|
1396
|
+
const attempts = this.computeFlowAttempts(flows);
|
|
1397
|
+
for (const latest of this.groupLatest(flows)) {
|
|
1398
|
+
if (latest.status === 'WAITING' || latest.status === 'READY') {
|
|
1399
|
+
return false; // still running
|
|
1400
|
+
}
|
|
1401
|
+
if (this.isFlowFailed(latest) && !abandoned.has(latest.id)) {
|
|
1402
|
+
// A retry was issued from this attempt but its replacement has not
|
|
1403
|
+
// surfaced yet — wait for the new attempt to appear and settle.
|
|
1404
|
+
if (retriedFrom.has(latest.id))
|
|
1405
|
+
return false;
|
|
1406
|
+
// Still within the retry budget: a retry is about to be issued.
|
|
1407
|
+
const attemptNumber = attempts.get(latest.id) ?? 1;
|
|
1408
|
+
if (attemptNumber <= this.options.retry)
|
|
1409
|
+
return false;
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
return true;
|
|
1414
|
+
}
|
|
1415
|
+
/**
|
|
1416
|
+
* Issues a retry for every failed flow/shard whose latest attempt has just
|
|
1417
|
+
* settled and still has retries left — immediately, without waiting for the
|
|
1418
|
+
* rest of the run. Each attempt id is retried at most once; a retry whose
|
|
1419
|
+
* request permanently fails is abandoned so the poll loop can still finish.
|
|
1420
|
+
*/
|
|
1421
|
+
async issueEligibleRetries(status, retriedFrom, abandoned) {
|
|
1422
|
+
if (this.isShuttingDown)
|
|
1423
|
+
return;
|
|
1424
|
+
for (const run of status.runs) {
|
|
1425
|
+
const flows = run.flows ?? [];
|
|
1426
|
+
if (flows.length === 0)
|
|
1427
|
+
continue;
|
|
1428
|
+
const attempts = this.computeFlowAttempts(flows);
|
|
1429
|
+
for (const latest of this.groupLatest(flows)) {
|
|
1430
|
+
if (!this.isFlowFailed(latest))
|
|
1431
|
+
continue;
|
|
1432
|
+
if (retriedFrom.has(latest.id) || abandoned.has(latest.id))
|
|
1433
|
+
continue;
|
|
1434
|
+
const attemptNumber = attempts.get(latest.id) ?? 1;
|
|
1435
|
+
if (attemptNumber > this.options.retry)
|
|
1436
|
+
continue; // budget exhausted
|
|
1437
|
+
try {
|
|
1438
|
+
await this.retryFlow(run.id, latest.id);
|
|
1439
|
+
retriedFrom.add(latest.id);
|
|
1440
|
+
(0, terminal_title_1.setTitle)(`maestro · retry ${attemptNumber}/${this.options.retry}`);
|
|
1441
|
+
}
|
|
1442
|
+
catch (err) {
|
|
1443
|
+
// Couldn't queue the retry — give up on this attempt so completion
|
|
1444
|
+
// isn't blocked forever waiting for a replacement that never comes.
|
|
1445
|
+
abandoned.add(latest.id);
|
|
1446
|
+
logger_1.default.warn(`Could not retry flow "${latest.name}" (run ${run.id}): ${err instanceof Error ? err.message : err}`);
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
/**
|
|
1452
|
+
* Triggers a retry of a single flow/shard and returns the new attempt's
|
|
1453
|
+
* MaestroRunTest id.
|
|
1454
|
+
*/
|
|
1455
|
+
async retryFlow(runId, flowId) {
|
|
1456
|
+
return await this.withRetry('Retrying Maestro flow', async () => {
|
|
1457
|
+
const response = await axios_1.default.post(`${this.URL}/${this.appId}/${runId}/${flowId}/retry`, {}, {
|
|
1458
|
+
headers: {
|
|
1459
|
+
'Content-Type': 'application/json',
|
|
1460
|
+
'User-Agent': utils_1.default.getUserAgent(),
|
|
1461
|
+
},
|
|
1462
|
+
auth: {
|
|
1463
|
+
username: this.credentials.userName,
|
|
1464
|
+
password: this.credentials.accessKey,
|
|
1465
|
+
},
|
|
1466
|
+
timeout: constants_1.HTTP.TIMEOUT_MS,
|
|
1467
|
+
});
|
|
1468
|
+
const data = response.data;
|
|
1469
|
+
if (data?.success === false) {
|
|
1470
|
+
const errorMessage = data.errors?.join('\n') || data.error || 'Unknown error';
|
|
1471
|
+
throw new testingbot_error_1.default('Retrying Maestro flow failed', {
|
|
1472
|
+
cause: errorMessage,
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
return data?.flow?.id;
|
|
1476
|
+
});
|
|
1477
|
+
}
|
|
1478
|
+
/** Stable key identifying the logical flow/shard a flow attempt belongs to. */
|
|
1479
|
+
flowGroupKey(flow) {
|
|
1480
|
+
return flow.shard_index != null
|
|
1481
|
+
? `shard:${flow.shard_index}`
|
|
1482
|
+
: `name:${flow.name}`;
|
|
1483
|
+
}
|
|
1484
|
+
/** Latest attempt per logical flow/shard (highest id wins). */
|
|
1485
|
+
groupLatest(flows) {
|
|
1486
|
+
const latest = new Map();
|
|
1487
|
+
for (const flow of flows) {
|
|
1488
|
+
const key = this.flowGroupKey(flow);
|
|
1489
|
+
const current = latest.get(key);
|
|
1490
|
+
if (!current || flow.id > current.id)
|
|
1491
|
+
latest.set(key, flow);
|
|
1492
|
+
}
|
|
1493
|
+
return [...latest.values()];
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* Maps each flow's id to its 1-based attempt number within its logical
|
|
1497
|
+
* group (ordered by id). The first attempt is 1; anything higher is a retry.
|
|
1498
|
+
*/
|
|
1499
|
+
computeFlowAttempts(flows) {
|
|
1500
|
+
const groups = new Map();
|
|
1501
|
+
for (const flow of flows) {
|
|
1502
|
+
const key = this.flowGroupKey(flow);
|
|
1503
|
+
const arr = groups.get(key);
|
|
1504
|
+
if (arr)
|
|
1505
|
+
arr.push(flow);
|
|
1506
|
+
else
|
|
1507
|
+
groups.set(key, [flow]);
|
|
1508
|
+
}
|
|
1509
|
+
const attempts = new Map();
|
|
1510
|
+
for (const arr of groups.values()) {
|
|
1511
|
+
arr
|
|
1512
|
+
.slice()
|
|
1513
|
+
.sort((a, b) => a.id - b.id)
|
|
1514
|
+
.forEach((flow, index) => attempts.set(flow.id, index + 1));
|
|
1515
|
+
}
|
|
1516
|
+
return attempts;
|
|
1517
|
+
}
|
|
1518
|
+
/**
|
|
1519
|
+
* The flow name as shown in the table, prefixed with a retry marker when the
|
|
1520
|
+
* row is a retry attempt. Reads the attempt map populated at render time.
|
|
1521
|
+
*/
|
|
1522
|
+
flowRowName(flow) {
|
|
1523
|
+
const attempt = this.flowAttempts.get(flow.id) ?? 1;
|
|
1524
|
+
if (attempt > 1) {
|
|
1525
|
+
return `${RETRY_ICON} ${flow.name} (retry ${attempt - 1})`;
|
|
1526
|
+
}
|
|
1527
|
+
return flow.name;
|
|
1528
|
+
}
|
|
1529
|
+
/**
|
|
1530
|
+
* Colorizes the retry marker without affecting column width: the padding is
|
|
1531
|
+
* computed on the plain string first, then the glyph is wrapped in color.
|
|
1532
|
+
*/
|
|
1533
|
+
colorizeRetryIcon(text) {
|
|
1534
|
+
return text.includes(RETRY_ICON)
|
|
1535
|
+
? text.replace(RETRY_ICON, picocolors_1.default.cyan(RETRY_ICON))
|
|
1536
|
+
: text;
|
|
1537
|
+
}
|
|
1538
|
+
isFlowFailed(flow) {
|
|
1539
|
+
return ((flow.status === 'DONE' && flow.success !== 1) ||
|
|
1540
|
+
flow.status === 'FAILED' ||
|
|
1541
|
+
(flow.error_messages != null && flow.error_messages.length > 0));
|
|
1542
|
+
}
|
|
1543
|
+
/** Failed flows/shards, considering only the latest attempt per group. */
|
|
1544
|
+
failedLatestAttempts(runs) {
|
|
1545
|
+
const failed = [];
|
|
1546
|
+
for (const run of runs) {
|
|
1547
|
+
for (const flow of this.groupLatest(run.flows ?? [])) {
|
|
1548
|
+
if (this.isFlowFailed(flow))
|
|
1549
|
+
failed.push({ runId: run.id, flow });
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
return failed;
|
|
1553
|
+
}
|
|
1554
|
+
/** A run passes if every logical flow's latest attempt passed. */
|
|
1555
|
+
runPassed(run) {
|
|
1556
|
+
const groups = this.groupLatest(run.flows ?? []);
|
|
1557
|
+
if (groups.length === 0)
|
|
1558
|
+
return run.success === 1;
|
|
1559
|
+
return groups.every((flow) => !this.isFlowFailed(flow));
|
|
1560
|
+
}
|
|
1561
|
+
computeOverallSuccess(runs) {
|
|
1562
|
+
return runs.every((run) => this.runPassed(run));
|
|
1563
|
+
}
|
|
1564
|
+
/**
|
|
1565
|
+
* Polls the run status, rendering the live flow table, until `isComplete`
|
|
1566
|
+
* returns true. Returns the raw status without printing the final summary or
|
|
1567
|
+
* fetching reports/artifacts — that is finalize()'s job.
|
|
1568
|
+
*
|
|
1569
|
+
* `onPoll`, when provided, runs after each poll is rendered and before the
|
|
1570
|
+
* completion check — used by --retry to queue retries for flows that have
|
|
1571
|
+
* just failed without waiting for the rest of the run to finish.
|
|
1572
|
+
*/
|
|
1573
|
+
async pollOnce(isComplete, onPoll) {
|
|
1252
1574
|
const startTime = Date.now();
|
|
1253
1575
|
const previousStatus = new Map();
|
|
1254
1576
|
const previousFlowStatus = new Map();
|
|
@@ -1317,70 +1639,17 @@ class Maestro extends base_provider_1.default {
|
|
|
1317
1639
|
this.displayRunStatus(status.runs, startTime, previousStatus);
|
|
1318
1640
|
}
|
|
1319
1641
|
}
|
|
1320
|
-
|
|
1642
|
+
// Queue any eligible retries from this poll's results before deciding
|
|
1643
|
+
// whether we're done, so a flow that just failed is retried immediately
|
|
1644
|
+
// rather than after the whole run completes.
|
|
1645
|
+
if (onPoll) {
|
|
1646
|
+
await onPoll(status);
|
|
1647
|
+
}
|
|
1648
|
+
if (isComplete(status)) {
|
|
1321
1649
|
this.stopFlowAnimation();
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
for (const run of status.runs) {
|
|
1326
|
-
if (run.flows && run.flows.length > 0) {
|
|
1327
|
-
allFlows.push(...run.flows);
|
|
1328
|
-
}
|
|
1329
|
-
}
|
|
1330
|
-
const hasFailures = this.hasAnyFlowFailed(allFlows);
|
|
1331
|
-
if (hasFailures) {
|
|
1332
|
-
// Move cursor up to overwrite the existing table
|
|
1333
|
-
// +2 for header and separator lines
|
|
1334
|
-
const linesToMove = displayedLineCount + 2;
|
|
1335
|
-
process.stdout.write(`\x1b[${linesToMove}A`);
|
|
1336
|
-
// Clear header line, write new header, then clear separator line
|
|
1337
|
-
process.stdout.write('\x1b[2K');
|
|
1338
|
-
console.log(picocolors_1.default.dim(` ${'Duration'.padEnd(10)} ${'Status'.padEnd(10)} Flow Fail reason`));
|
|
1339
|
-
process.stdout.write('\x1b[2K');
|
|
1340
|
-
console.log(picocolors_1.default.dim(` ${'─'.repeat(10)} ${'─'.repeat(10)} ${'─'.repeat(30)} ${'─'.repeat(80)}`));
|
|
1341
|
-
// Redraw all flows with error messages
|
|
1342
|
-
for (const flow of allFlows) {
|
|
1343
|
-
// Clear the line before writing
|
|
1344
|
-
process.stdout.write('\x1b[2K');
|
|
1345
|
-
this.displayFlowRow(flow, false, true);
|
|
1346
|
-
}
|
|
1347
|
-
}
|
|
1348
|
-
}
|
|
1349
|
-
// Print final summary
|
|
1350
|
-
if (!this.options.quiet) {
|
|
1351
|
-
this.spinner.stop();
|
|
1352
|
-
console.log(); // Empty line before summary
|
|
1353
|
-
for (const run of status.runs) {
|
|
1354
|
-
const passed = run.success === 1;
|
|
1355
|
-
const symbol = passed ? picocolors_1.default.green('✔') : picocolors_1.default.red('✘');
|
|
1356
|
-
const statusText = passed
|
|
1357
|
-
? picocolors_1.default.green('Test completed successfully')
|
|
1358
|
-
: picocolors_1.default.red('Test failed');
|
|
1359
|
-
console.log(` ${symbol} Run ${run.id} ${picocolors_1.default.dim(`(${this.getRunDisplayName(run)})`)}: ${statusText}`);
|
|
1360
|
-
}
|
|
1361
|
-
}
|
|
1362
|
-
const allSucceeded = status.runs.every((run) => run.success === 1);
|
|
1363
|
-
if (allSucceeded) {
|
|
1364
|
-
(0, terminal_title_1.setTitle)('maestro · ✔ passed');
|
|
1365
|
-
if (!this.options.quiet) {
|
|
1366
|
-
logger_1.default.info('All tests completed successfully!');
|
|
1367
|
-
}
|
|
1368
|
-
}
|
|
1369
|
-
else {
|
|
1370
|
-
const failedRuns = status.runs.filter((run) => run.success !== 1);
|
|
1371
|
-
(0, terminal_title_1.setTitle)(`maestro · ✘ ${failedRuns.length} failed`);
|
|
1372
|
-
logger_1.default.error(`${failedRuns.length} test run(s) failed`);
|
|
1373
|
-
}
|
|
1374
|
-
if (this.options.report && this.options.reportOutputDir) {
|
|
1375
|
-
await this.fetchReports(status.runs);
|
|
1376
|
-
}
|
|
1377
|
-
if (this.options.downloadArtifacts) {
|
|
1378
|
-
await this.downloadArtifacts(status.runs);
|
|
1379
|
-
}
|
|
1380
|
-
return {
|
|
1381
|
-
success: status.success,
|
|
1382
|
-
runs: status.runs,
|
|
1383
|
-
};
|
|
1650
|
+
this.flowsTableDisplayed = flowsTableDisplayed;
|
|
1651
|
+
this.latestDisplayedLineCount = displayedLineCount;
|
|
1652
|
+
return status;
|
|
1384
1653
|
}
|
|
1385
1654
|
// Checked after getStatus() so a run that completes during the final
|
|
1386
1655
|
// sleep is returned as success on the next iteration instead of being
|
|
@@ -1400,6 +1669,85 @@ class Maestro extends base_provider_1.default {
|
|
|
1400
1669
|
await this.sleep(pollInterval);
|
|
1401
1670
|
}
|
|
1402
1671
|
}
|
|
1672
|
+
/**
|
|
1673
|
+
* Renders the final table/summary, reports the last-attempt-wins result, and
|
|
1674
|
+
* fetches reports/artifacts. Called once after the (optional) retry loop.
|
|
1675
|
+
*/
|
|
1676
|
+
async finalize(status) {
|
|
1677
|
+
this.stopFlowAnimation();
|
|
1678
|
+
// Display final flows table with error messages if there are failures
|
|
1679
|
+
if (!this.options.quiet && this.flowsTableDisplayed) {
|
|
1680
|
+
const allFlows = [];
|
|
1681
|
+
for (const run of status.runs) {
|
|
1682
|
+
if (run.flows && run.flows.length > 0) {
|
|
1683
|
+
allFlows.push(...run.flows);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
this.flowAttempts = this.computeFlowAttempts(allFlows);
|
|
1687
|
+
const hasFailures = this.hasAnyFlowFailed(allFlows);
|
|
1688
|
+
if (hasFailures) {
|
|
1689
|
+
// Move cursor up to overwrite the existing table
|
|
1690
|
+
// +2 for header and separator lines
|
|
1691
|
+
const linesToMove = this.latestDisplayedLineCount + 2;
|
|
1692
|
+
process.stdout.write(`\x1b[${linesToMove}A`);
|
|
1693
|
+
// Clear header line, write new header, then clear separator line
|
|
1694
|
+
const { header, separator } = this.buildFlowsTableHeader(true);
|
|
1695
|
+
process.stdout.write('\x1b[2K');
|
|
1696
|
+
console.log(header);
|
|
1697
|
+
process.stdout.write('\x1b[2K');
|
|
1698
|
+
console.log(separator);
|
|
1699
|
+
this.flowTableHasFailuresColumn = true;
|
|
1700
|
+
// Redraw all flows with error messages
|
|
1701
|
+
for (const flow of allFlows) {
|
|
1702
|
+
// Clear the line before writing
|
|
1703
|
+
process.stdout.write('\x1b[2K');
|
|
1704
|
+
this.displayFlowRow(flow, false, true);
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
// Print final summary (last-attempt-wins per run)
|
|
1709
|
+
if (!this.options.quiet) {
|
|
1710
|
+
this.spinner.stop();
|
|
1711
|
+
console.log(); // Empty line before summary
|
|
1712
|
+
for (const run of status.runs) {
|
|
1713
|
+
const passed = this.runPassed(run);
|
|
1714
|
+
const symbol = passed ? picocolors_1.default.green('✔') : picocolors_1.default.red('✘');
|
|
1715
|
+
const statusText = passed
|
|
1716
|
+
? picocolors_1.default.green('Test completed successfully')
|
|
1717
|
+
: picocolors_1.default.red('Test failed');
|
|
1718
|
+
console.log(` ${symbol} Run ${run.id} ${picocolors_1.default.dim(`(${this.getRunDisplayName(run)})`)}: ${statusText}`);
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
const allSucceeded = this.computeOverallSuccess(status.runs);
|
|
1722
|
+
if (allSucceeded) {
|
|
1723
|
+
(0, terminal_title_1.setTitle)('maestro · ✔ passed');
|
|
1724
|
+
if (!this.options.quiet) {
|
|
1725
|
+
logger_1.default.info('All tests completed successfully!');
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
else {
|
|
1729
|
+
const failedRuns = status.runs.filter((run) => !this.runPassed(run));
|
|
1730
|
+
const failedFlows = this.failedLatestAttempts(status.runs);
|
|
1731
|
+
if (failedFlows.length > 0) {
|
|
1732
|
+
(0, terminal_title_1.setTitle)(`maestro · ✘ ${failedFlows.length} failed`);
|
|
1733
|
+
logger_1.default.error(`${failedFlows.length} flow(s) failed across ${failedRuns.length} run(s)`);
|
|
1734
|
+
}
|
|
1735
|
+
else {
|
|
1736
|
+
(0, terminal_title_1.setTitle)(`maestro · ✘ ${failedRuns.length} failed`);
|
|
1737
|
+
logger_1.default.error(`${failedRuns.length} test run(s) failed`);
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
if (this.options.report && this.options.reportOutputDir) {
|
|
1741
|
+
await this.fetchReports(status.runs);
|
|
1742
|
+
}
|
|
1743
|
+
if (this.options.downloadArtifacts) {
|
|
1744
|
+
await this.downloadArtifacts(status.runs);
|
|
1745
|
+
}
|
|
1746
|
+
return {
|
|
1747
|
+
success: allSucceeded,
|
|
1748
|
+
runs: status.runs,
|
|
1749
|
+
};
|
|
1750
|
+
}
|
|
1403
1751
|
displayRunStatus(runs, startTime, previousStatus) {
|
|
1404
1752
|
const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000);
|
|
1405
1753
|
const elapsedStr = this.formatElapsedTime(elapsedSeconds);
|
|
@@ -1572,6 +1920,7 @@ class Maestro extends base_provider_1.default {
|
|
|
1572
1920
|
return ` ... and ${remaining.length} more: ${parts.join(', ')}`;
|
|
1573
1921
|
}
|
|
1574
1922
|
displayFlowsWithLimit(flows, previousFlowStatus, hasFailures = false) {
|
|
1923
|
+
this.flowAttempts = this.computeFlowAttempts(flows);
|
|
1575
1924
|
const maxFlows = this.getMaxDisplayableFlows();
|
|
1576
1925
|
const displayFlows = flows.slice(0, maxFlows);
|
|
1577
1926
|
let linesWritten = 0;
|
|
@@ -1587,23 +1936,38 @@ class Maestro extends base_provider_1.default {
|
|
|
1587
1936
|
}
|
|
1588
1937
|
return linesWritten;
|
|
1589
1938
|
}
|
|
1590
|
-
|
|
1939
|
+
/**
|
|
1940
|
+
* Builds the (dimmed) header and separator lines for the flows table. When
|
|
1941
|
+
* `hasFailures` is set, the "Fail reason" column is appended.
|
|
1942
|
+
*/
|
|
1943
|
+
buildFlowsTableHeader(hasFailures) {
|
|
1591
1944
|
let header = ` ${'Duration'.padEnd(10)} ${'Status'.padEnd(10)} Flow`;
|
|
1592
1945
|
let separator = ` ${'─'.repeat(10)} ${'─'.repeat(10)} ${'─'.repeat(30)}`;
|
|
1593
1946
|
if (hasFailures) {
|
|
1594
1947
|
header += ' Fail reason';
|
|
1595
1948
|
separator += ` ${'─'.repeat(80)}`;
|
|
1596
1949
|
}
|
|
1597
|
-
|
|
1598
|
-
console.log(picocolors_1.default.dim(separator));
|
|
1950
|
+
return { header: picocolors_1.default.dim(header), separator: picocolors_1.default.dim(separator) };
|
|
1599
1951
|
}
|
|
1600
|
-
|
|
1952
|
+
displayFlowsTableHeader(hasFailures = false) {
|
|
1953
|
+
const { header, separator } = this.buildFlowsTableHeader(hasFailures);
|
|
1954
|
+
console.log(header);
|
|
1955
|
+
console.log(separator);
|
|
1956
|
+
this.flowTableHasFailuresColumn = hasFailures;
|
|
1957
|
+
}
|
|
1958
|
+
/**
|
|
1959
|
+
* Builds the single-line representation of a flow row: duration, status, the
|
|
1960
|
+
* (padded) flow name, and — when the flow has failed — its first error
|
|
1961
|
+
* message inline. Shared by the static render (displayFlowRow) and the live
|
|
1962
|
+
* in-place updates (updateFlowsInPlace) so the fail reason shows
|
|
1963
|
+
* consistently, including while a retry is still running.
|
|
1964
|
+
*/
|
|
1965
|
+
buildFlowRowLine(flow, hasFailures) {
|
|
1601
1966
|
const duration = this.calculateFlowDuration(flow).padEnd(10);
|
|
1602
1967
|
const statusDisplay = this.getFlowStatusDisplay(flow);
|
|
1603
1968
|
// Pad based on display text length, add extra for color codes
|
|
1604
1969
|
const statusPadded = statusDisplay.colored +
|
|
1605
1970
|
' '.repeat(Math.max(0, 10 - statusDisplay.text.length));
|
|
1606
|
-
let linesWritten = 0;
|
|
1607
1971
|
const isFailed = flow.status === 'DONE' && flow.success !== 1;
|
|
1608
1972
|
const errorMessages = flow.error_messages || [];
|
|
1609
1973
|
const firstError = hasFailures && isFailed && errorMessages.length > 0
|
|
@@ -1611,13 +1975,21 @@ class Maestro extends base_provider_1.default {
|
|
|
1611
1975
|
: '';
|
|
1612
1976
|
const errorReserve = firstError ? firstError.length + 1 : 0;
|
|
1613
1977
|
const maxName = this.getMaxNameLength(errorReserve);
|
|
1614
|
-
const name = this.truncateForRow(flow
|
|
1615
|
-
// Build the main row
|
|
1616
|
-
|
|
1978
|
+
const name = this.truncateForRow(this.flowRowName(flow), maxName).padEnd(Math.min(30, maxName));
|
|
1979
|
+
// Build the main row (colorize the retry marker after padding so the
|
|
1980
|
+
// column-width math stays based on the plain glyph).
|
|
1981
|
+
let row = ` ${duration} ${statusPadded} ${this.colorizeRetryIcon(name)}`;
|
|
1617
1982
|
// Add first error message on the same line if failed and has errors
|
|
1618
1983
|
if (firstError) {
|
|
1619
1984
|
row += ` ${picocolors_1.default.red(firstError)}`;
|
|
1620
1985
|
}
|
|
1986
|
+
return row;
|
|
1987
|
+
}
|
|
1988
|
+
displayFlowRow(flow, isUpdate = false, hasFailures = false) {
|
|
1989
|
+
const row = this.buildFlowRowLine(flow, hasFailures);
|
|
1990
|
+
let linesWritten = 0;
|
|
1991
|
+
const isFailed = flow.status === 'DONE' && flow.success !== 1;
|
|
1992
|
+
const errorMessages = flow.error_messages || [];
|
|
1621
1993
|
if (isUpdate) {
|
|
1622
1994
|
process.stdout.write(`\r${row}`);
|
|
1623
1995
|
}
|
|
@@ -1679,23 +2051,33 @@ class Maestro extends base_provider_1.default {
|
|
|
1679
2051
|
this.stopFlowAnimation();
|
|
1680
2052
|
}
|
|
1681
2053
|
updateFlowsInPlace(flows, previousFlowStatus, displayedLineCount) {
|
|
2054
|
+
this.flowAttempts = this.computeFlowAttempts(flows);
|
|
1682
2055
|
const maxFlows = this.getMaxDisplayableFlows();
|
|
1683
2056
|
const displayFlows = flows.slice(0, maxFlows);
|
|
1684
2057
|
const hasRemaining = flows.length > maxFlows;
|
|
1685
|
-
|
|
1686
|
-
|
|
2058
|
+
const hasFailures = this.hasAnyFlowFailed(flows);
|
|
2059
|
+
// If failures appeared after the header was first drawn (e.g. mid-run, or
|
|
2060
|
+
// when re-rendering during a retry), redraw the header to add the "Fail
|
|
2061
|
+
// reason" column. This also moves the cursor back up to the first flow row.
|
|
2062
|
+
// Otherwise just move up by the number of lines we PREVIOUSLY displayed.
|
|
2063
|
+
if (hasFailures && !this.flowTableHasFailuresColumn) {
|
|
2064
|
+
const { header, separator } = this.buildFlowsTableHeader(true);
|
|
2065
|
+
process.stdout.write(`\x1b[${displayedLineCount + 2}A`);
|
|
2066
|
+
process.stdout.write('\x1b[2K');
|
|
2067
|
+
console.log(header);
|
|
2068
|
+
process.stdout.write('\x1b[2K');
|
|
2069
|
+
console.log(separator);
|
|
2070
|
+
this.flowTableHasFailuresColumn = true;
|
|
2071
|
+
}
|
|
2072
|
+
else if (displayedLineCount > 0) {
|
|
1687
2073
|
process.stdout.write(`\x1b[${displayedLineCount}A`);
|
|
1688
2074
|
}
|
|
1689
2075
|
let linesWritten = 0;
|
|
1690
|
-
// Redraw displayed flows
|
|
1691
|
-
|
|
2076
|
+
// Redraw displayed flows. buildFlowRowLine appends the first error message
|
|
2077
|
+
// inline for failed flows so fail reasons stay visible during live updates
|
|
2078
|
+
// (including while a retry re-runs the failed flows).
|
|
1692
2079
|
for (const flow of displayFlows) {
|
|
1693
|
-
const
|
|
1694
|
-
const statusDisplay = this.getFlowStatusDisplay(flow);
|
|
1695
|
-
const statusPadded = statusDisplay.colored +
|
|
1696
|
-
' '.repeat(Math.max(0, 10 - statusDisplay.text.length));
|
|
1697
|
-
const name = this.truncateForRow(flow.name, maxName);
|
|
1698
|
-
const row = ` ${duration} ${statusPadded} ${name}`;
|
|
2080
|
+
const row = this.buildFlowRowLine(flow, hasFailures);
|
|
1699
2081
|
process.stdout.write(`\r\x1b[2K${row}\n`);
|
|
1700
2082
|
previousFlowStatus.set(flow.id, flow.status);
|
|
1701
2083
|
linesWritten++;
|