@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.
@@ -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
- if (status.completed) {
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
- // Display final flows table with error messages if there are failures
1323
- if (!this.options.quiet && flowsTableDisplayed) {
1324
- const allFlows = [];
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
- displayFlowsTableHeader(hasFailures = false) {
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
- console.log(picocolors_1.default.dim(header));
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
- displayFlowRow(flow, isUpdate = false, hasFailures = false) {
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.name, maxName).padEnd(Math.min(30, maxName));
1615
- // Build the main row
1616
- let row = ` ${duration} ${statusPadded} ${name}`;
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
- // Move cursor up by the number of lines we PREVIOUSLY displayed
1686
- if (displayedLineCount > 0) {
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
- const maxName = this.getMaxNameLength();
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 duration = this.calculateFlowDuration(flow).padEnd(10);
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++;