@testingbot/cli 1.0.8 → 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);
@@ -1504,6 +1852,31 @@ class Maestro extends base_provider_1.default {
1504
1852
  const reservedLines = 6;
1505
1853
  return Math.max(5, terminalHeight - reservedLines);
1506
1854
  }
1855
+ getTerminalWidth() {
1856
+ return process.stdout.columns || 200;
1857
+ }
1858
+ /**
1859
+ * Returns the maximum length of `flow.name` that keeps the rendered row
1860
+ * within the current terminal width, so the row does not visually wrap.
1861
+ * Wrapped rows break the `\x1b[NA` cursor-up math used by in-place updates,
1862
+ * which is what causes the table to repeat instead of refresh in place
1863
+ * (e.g. with --shard-split where the API returns long comma-joined names).
1864
+ *
1865
+ * Row layout is: " {duration:10} {status:10} {name}[ {error}]" — overhead
1866
+ * is 23 plain-width chars before `name`. `extra` reserves room for trailing
1867
+ * content like a fail-reason suffix.
1868
+ */
1869
+ getMaxNameLength(extra = 0) {
1870
+ const overhead = 23 + extra + 1;
1871
+ return Math.max(10, this.getTerminalWidth() - overhead);
1872
+ }
1873
+ truncateForRow(name, max) {
1874
+ if (name.length <= max)
1875
+ return name;
1876
+ if (max <= 1)
1877
+ return name.slice(0, max);
1878
+ return name.slice(0, max - 1) + '…';
1879
+ }
1507
1880
  getRemainingSummary(flows, displayedCount) {
1508
1881
  const remaining = flows.slice(displayedCount);
1509
1882
  if (remaining.length === 0) {
@@ -1547,6 +1920,7 @@ class Maestro extends base_provider_1.default {
1547
1920
  return ` ... and ${remaining.length} more: ${parts.join(', ')}`;
1548
1921
  }
1549
1922
  displayFlowsWithLimit(flows, previousFlowStatus, hasFailures = false) {
1923
+ this.flowAttempts = this.computeFlowAttempts(flows);
1550
1924
  const maxFlows = this.getMaxDisplayableFlows();
1551
1925
  const displayFlows = flows.slice(0, maxFlows);
1552
1926
  let linesWritten = 0;
@@ -1562,32 +1936,60 @@ class Maestro extends base_provider_1.default {
1562
1936
  }
1563
1937
  return linesWritten;
1564
1938
  }
1565
- 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) {
1566
1944
  let header = ` ${'Duration'.padEnd(10)} ${'Status'.padEnd(10)} Flow`;
1567
1945
  let separator = ` ${'─'.repeat(10)} ${'─'.repeat(10)} ${'─'.repeat(30)}`;
1568
1946
  if (hasFailures) {
1569
1947
  header += ' Fail reason';
1570
1948
  separator += ` ${'─'.repeat(80)}`;
1571
1949
  }
1572
- console.log(picocolors_1.default.dim(header));
1573
- console.log(picocolors_1.default.dim(separator));
1950
+ return { header: picocolors_1.default.dim(header), separator: picocolors_1.default.dim(separator) };
1574
1951
  }
1575
- 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) {
1576
1966
  const duration = this.calculateFlowDuration(flow).padEnd(10);
1577
1967
  const statusDisplay = this.getFlowStatusDisplay(flow);
1578
1968
  // Pad based on display text length, add extra for color codes
1579
1969
  const statusPadded = statusDisplay.colored +
1580
1970
  ' '.repeat(Math.max(0, 10 - statusDisplay.text.length));
1581
- const name = flow.name.padEnd(30);
1582
- let linesWritten = 0;
1583
1971
  const isFailed = flow.status === 'DONE' && flow.success !== 1;
1584
1972
  const errorMessages = flow.error_messages || [];
1585
- // Build the main row
1586
- let row = ` ${duration} ${statusPadded} ${name}`;
1973
+ const firstError = hasFailures && isFailed && errorMessages.length > 0
1974
+ ? errorMessages[0]
1975
+ : '';
1976
+ const errorReserve = firstError ? firstError.length + 1 : 0;
1977
+ const maxName = this.getMaxNameLength(errorReserve);
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)}`;
1587
1982
  // Add first error message on the same line if failed and has errors
1588
- if (hasFailures && isFailed && errorMessages.length > 0) {
1589
- row += ` ${picocolors_1.default.red(errorMessages[0])}`;
1983
+ if (firstError) {
1984
+ row += ` ${picocolors_1.default.red(firstError)}`;
1590
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 || [];
1591
1993
  if (isUpdate) {
1592
1994
  process.stdout.write(`\r${row}`);
1593
1995
  }
@@ -1649,23 +2051,34 @@ class Maestro extends base_provider_1.default {
1649
2051
  this.stopFlowAnimation();
1650
2052
  }
1651
2053
  updateFlowsInPlace(flows, previousFlowStatus, displayedLineCount) {
2054
+ this.flowAttempts = this.computeFlowAttempts(flows);
1652
2055
  const maxFlows = this.getMaxDisplayableFlows();
1653
2056
  const displayFlows = flows.slice(0, maxFlows);
1654
2057
  const hasRemaining = flows.length > maxFlows;
1655
- // Move cursor up by the number of lines we PREVIOUSLY displayed
1656
- 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) {
1657
2073
  process.stdout.write(`\x1b[${displayedLineCount}A`);
1658
2074
  }
1659
2075
  let linesWritten = 0;
1660
- // Redraw displayed flows
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).
1661
2079
  for (const flow of displayFlows) {
1662
- const duration = this.calculateFlowDuration(flow).padEnd(10);
1663
- const statusDisplay = this.getFlowStatusDisplay(flow);
1664
- const statusPadded = statusDisplay.colored +
1665
- ' '.repeat(Math.max(0, 10 - statusDisplay.text.length));
1666
- const name = flow.name;
1667
- const row = ` ${duration} ${statusPadded} ${name}`;
1668
- process.stdout.write(`\r\x1b[K${row}\n`);
2080
+ const row = this.buildFlowRowLine(flow, hasFailures);
2081
+ process.stdout.write(`\r\x1b[2K${row}\n`);
1669
2082
  previousFlowStatus.set(flow.id, flow.status);
1670
2083
  linesWritten++;
1671
2084
  }
@@ -1841,6 +2254,88 @@ class Maestro extends base_provider_1.default {
1841
2254
  return fileName;
1842
2255
  }
1843
2256
  }
2257
+ sanitizeFlowDirName(name) {
2258
+ if (!name)
2259
+ return '';
2260
+ let s = name.replace(/[^A-Za-z0-9._-]+/g, '_');
2261
+ s = s.replace(/_+/g, '_');
2262
+ s = s.replace(/^[_.-]+|[_.-]+$/g, '');
2263
+ if (s.length > 64)
2264
+ s = s.slice(0, 64).replace(/[_.-]+$/, '');
2265
+ return s;
2266
+ }
2267
+ buildFlowDirNames(entries, reserved = new Set()) {
2268
+ const baseNames = new Map();
2269
+ const counts = new Map();
2270
+ for (const r of reserved) {
2271
+ counts.set(r, 1);
2272
+ }
2273
+ for (const { runId, flow } of entries) {
2274
+ const sanitized = this.sanitizeFlowDirName(flow.name);
2275
+ const base = sanitized || `flow_${flow.id}`;
2276
+ baseNames.set(`${runId}:${flow.id}`, base);
2277
+ counts.set(base, (counts.get(base) || 0) + 1);
2278
+ }
2279
+ const result = new Map();
2280
+ for (const { runId, flow } of entries) {
2281
+ const key = `${runId}:${flow.id}`;
2282
+ const base = baseNames.get(key);
2283
+ const collides = (counts.get(base) || 0) > 1;
2284
+ result.set(key, collides ? `${base}_${runId}_${flow.id}` : base);
2285
+ }
2286
+ return result;
2287
+ }
2288
+ async downloadAssetBundle(assets, targetDir) {
2289
+ if (assets.logs && Object.keys(assets.logs).length > 0) {
2290
+ const logsDir = node_path_1.default.join(targetDir, 'logs');
2291
+ await node_fs_1.default.promises.mkdir(logsDir, { recursive: true });
2292
+ for (const [logName, logUrl] of Object.entries(assets.logs)) {
2293
+ const logFileName = `${logName}.txt`;
2294
+ const logPath = node_path_1.default.join(logsDir, logFileName);
2295
+ try {
2296
+ await this.downloadFile(logUrl, logPath);
2297
+ if (!this.options.quiet) {
2298
+ logger_1.default.info(` Downloaded log: ${logFileName}`);
2299
+ }
2300
+ }
2301
+ catch (error) {
2302
+ logger_1.default.error(` Failed to download log ${logFileName}: ${error instanceof Error ? error.message : error}`);
2303
+ }
2304
+ }
2305
+ }
2306
+ if (assets.video && typeof assets.video === 'string') {
2307
+ const videoDir = node_path_1.default.join(targetDir, 'video');
2308
+ await node_fs_1.default.promises.mkdir(videoDir, { recursive: true });
2309
+ const videoPath = node_path_1.default.join(videoDir, 'video.mp4');
2310
+ try {
2311
+ await this.downloadFile(assets.video, videoPath);
2312
+ if (!this.options.quiet) {
2313
+ logger_1.default.info(` Downloaded video: video.mp4`);
2314
+ }
2315
+ }
2316
+ catch (error) {
2317
+ logger_1.default.error(` Failed to download video: ${error instanceof Error ? error.message : error}`);
2318
+ }
2319
+ }
2320
+ if (assets.screenshots && assets.screenshots.length > 0) {
2321
+ const screenshotsDir = node_path_1.default.join(targetDir, 'screenshots');
2322
+ await node_fs_1.default.promises.mkdir(screenshotsDir, { recursive: true });
2323
+ for (let i = 0; i < assets.screenshots.length; i++) {
2324
+ const screenshotUrl = assets.screenshots[i];
2325
+ const screenshotFileName = `screenshot_${i}.png`;
2326
+ const screenshotPath = node_path_1.default.join(screenshotsDir, screenshotFileName);
2327
+ try {
2328
+ await this.downloadFile(screenshotUrl, screenshotPath);
2329
+ if (!this.options.quiet) {
2330
+ logger_1.default.info(` Downloaded screenshot: ${screenshotFileName}`);
2331
+ }
2332
+ }
2333
+ catch (error) {
2334
+ logger_1.default.error(` Failed to download screenshot ${screenshotFileName}: ${error instanceof Error ? error.message : error}`);
2335
+ }
2336
+ }
2337
+ }
2338
+ }
1844
2339
  async downloadArtifacts(runs) {
1845
2340
  if (!this.options.downloadArtifacts)
1846
2341
  return;
@@ -1871,92 +2366,85 @@ class Maestro extends base_provider_1.default {
1871
2366
  const outputDir = this.options.artifactsOutputDir || process.cwd();
1872
2367
  const tempDir = await node_fs_1.default.promises.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), 'testingbot-maestro-artifacts-'));
1873
2368
  try {
2369
+ const runDetailsList = [];
1874
2370
  for (const run of runsToDownload) {
2371
+ if (!this.options.quiet) {
2372
+ logger_1.default.info(` Waiting for artifacts sync for run ${run.id}...`);
2373
+ }
1875
2374
  try {
2375
+ const details = await this.waitForArtifactsSync(run.id);
2376
+ runDetailsList.push({ run, details });
2377
+ }
2378
+ catch (error) {
2379
+ logger_1.default.error(`Failed to download artifacts for run ${run.id}: ${error instanceof Error ? error.message : error}`);
2380
+ }
2381
+ }
2382
+ const multiRun = runDetailsList.length > 1;
2383
+ const runReportName = (runId) => multiRun ? `report_${runId}.xml` : 'report.xml';
2384
+ const runAssetsDirName = (runId) => multiRun ? `run_${runId}` : '';
2385
+ const reservedNames = new Set();
2386
+ for (const { run, details } of runDetailsList) {
2387
+ if (details.report)
2388
+ reservedNames.add(runReportName(run.id));
2389
+ if (details.assets) {
2390
+ const dir = runAssetsDirName(run.id);
2391
+ if (dir)
2392
+ reservedNames.add(dir);
2393
+ }
2394
+ }
2395
+ const flowEntries = runDetailsList.flatMap(({ run, details }) => (details.flows || [])
2396
+ .filter((flow) => flow.assets)
2397
+ .map((flow) => ({ runId: run.id, flow })));
2398
+ const flowDirNames = this.buildFlowDirNames(flowEntries, reservedNames);
2399
+ for (const { run, details } of runDetailsList) {
2400
+ const flowsWithAssets = (details.flows || []).filter((flow) => flow.assets);
2401
+ if (!details.assets && flowsWithAssets.length === 0) {
1876
2402
  if (!this.options.quiet) {
1877
- logger_1.default.info(` Waiting for artifacts sync for run ${run.id}...`);
1878
- }
1879
- const runDetails = await this.waitForArtifactsSync(run.id);
1880
- if (!runDetails.assets) {
1881
- if (!this.options.quiet) {
1882
- logger_1.default.info(` No artifacts available for run ${run.id}`);
1883
- }
1884
- continue;
2403
+ logger_1.default.info(` No artifacts available for run ${run.id}`);
1885
2404
  }
1886
- const runDir = node_path_1.default.join(tempDir, `run_${run.id}`);
1887
- await node_fs_1.default.promises.mkdir(runDir, { recursive: true });
1888
- if (runDetails.assets.logs &&
1889
- Object.keys(runDetails.assets.logs).length > 0) {
1890
- const logsDir = node_path_1.default.join(runDir, 'logs');
1891
- await node_fs_1.default.promises.mkdir(logsDir, { recursive: true });
1892
- for (const [logName, logUrl] of Object.entries(runDetails.assets.logs)) {
1893
- const logFileName = `${logName}.txt`;
1894
- const logPath = node_path_1.default.join(logsDir, logFileName);
1895
- try {
1896
- await this.downloadFile(logUrl, logPath);
1897
- if (!this.options.quiet) {
1898
- logger_1.default.info(` Downloaded log: ${logFileName}`);
1899
- }
1900
- }
1901
- catch (error) {
1902
- logger_1.default.error(` Failed to download log ${logFileName}: ${error instanceof Error ? error.message : error}`);
1903
- }
1904
- }
2405
+ continue;
2406
+ }
2407
+ if (details.assets) {
2408
+ const dirName = runAssetsDirName(run.id);
2409
+ const targetDir = dirName ? node_path_1.default.join(tempDir, dirName) : tempDir;
2410
+ if (dirName) {
2411
+ await node_fs_1.default.promises.mkdir(targetDir, { recursive: true });
1905
2412
  }
1906
- if (runDetails.assets.video &&
1907
- typeof runDetails.assets.video === 'string') {
1908
- const videoDir = node_path_1.default.join(runDir, 'video');
1909
- await node_fs_1.default.promises.mkdir(videoDir, { recursive: true });
1910
- const videoUrl = runDetails.assets.video;
1911
- const videoFileName = 'video.mp4';
1912
- const videoPath = node_path_1.default.join(videoDir, videoFileName);
2413
+ await this.downloadAssetBundle(details.assets, targetDir);
2414
+ }
2415
+ for (const flow of flowsWithAssets) {
2416
+ const flowDirName = flowDirNames.get(`${run.id}:${flow.id}`);
2417
+ const flowDir = node_path_1.default.join(tempDir, flowDirName);
2418
+ await node_fs_1.default.promises.mkdir(flowDir, { recursive: true });
2419
+ await this.downloadAssetBundle(flow.assets, flowDir);
2420
+ if (flow.report) {
2421
+ const flowReportPath = node_path_1.default.join(flowDir, 'report.xml');
1913
2422
  try {
1914
- await this.downloadFile(videoUrl, videoPath);
2423
+ await node_fs_1.default.promises.writeFile(flowReportPath, flow.report, 'utf-8');
1915
2424
  if (!this.options.quiet) {
1916
- logger_1.default.info(` Downloaded video: ${videoFileName}`);
2425
+ logger_1.default.info(` Saved ${flowDirName}/report.xml`);
1917
2426
  }
1918
2427
  }
1919
2428
  catch (error) {
1920
- logger_1.default.error(` Failed to download video: ${error instanceof Error ? error.message : error}`);
2429
+ logger_1.default.error(` Failed to save report.xml for ${flowDirName}: ${error instanceof Error ? error.message : error}`);
1921
2430
  }
1922
2431
  }
1923
- if (runDetails.assets.screenshots &&
1924
- runDetails.assets.screenshots.length > 0) {
1925
- const screenshotsDir = node_path_1.default.join(runDir, 'screenshots');
1926
- await node_fs_1.default.promises.mkdir(screenshotsDir, { recursive: true });
1927
- for (let i = 0; i < runDetails.assets.screenshots.length; i++) {
1928
- const screenshotUrl = runDetails.assets.screenshots[i];
1929
- const screenshotFileName = `screenshot_${i}.png`;
1930
- const screenshotPath = node_path_1.default.join(screenshotsDir, screenshotFileName);
1931
- try {
1932
- await this.downloadFile(screenshotUrl, screenshotPath);
1933
- if (!this.options.quiet) {
1934
- logger_1.default.info(` Downloaded screenshot: ${screenshotFileName}`);
1935
- }
1936
- }
1937
- catch (error) {
1938
- logger_1.default.error(` Failed to download screenshot ${screenshotFileName}: ${error instanceof Error ? error.message : error}`);
1939
- }
1940
- }
1941
- }
1942
- if (runDetails.report) {
1943
- const reportPath = node_path_1.default.join(runDir, 'report.xml');
1944
- try {
1945
- await node_fs_1.default.promises.writeFile(reportPath, runDetails.report, 'utf-8');
1946
- if (!this.options.quiet) {
1947
- logger_1.default.info(` Saved report.xml`);
1948
- }
1949
- }
1950
- catch (error) {
1951
- logger_1.default.error(` Failed to save report.xml: ${error instanceof Error ? error.message : error}`);
2432
+ }
2433
+ if (details.report) {
2434
+ const reportName = runReportName(run.id);
2435
+ const reportPath = node_path_1.default.join(tempDir, reportName);
2436
+ try {
2437
+ await node_fs_1.default.promises.writeFile(reportPath, details.report, 'utf-8');
2438
+ if (!this.options.quiet) {
2439
+ logger_1.default.info(` Saved ${reportName}`);
1952
2440
  }
1953
2441
  }
1954
- if (!this.options.quiet) {
1955
- logger_1.default.info(` Artifacts for run ${run.id} downloaded`);
2442
+ catch (error) {
2443
+ logger_1.default.error(` Failed to save ${reportName}: ${error instanceof Error ? error.message : error}`);
1956
2444
  }
1957
2445
  }
1958
- catch (error) {
1959
- logger_1.default.error(`Failed to download artifacts for run ${run.id}: ${error instanceof Error ? error.message : error}`);
2446
+ if (!this.options.quiet) {
2447
+ logger_1.default.info(` Artifacts for run ${run.id} downloaded`);
1960
2448
  }
1961
2449
  }
1962
2450
  const zipFileName = await this.generateArtifactZipName(outputDir);