@testingbot/cli 1.0.7 → 1.0.9

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.
Files changed (45) hide show
  1. package/README.md +29 -0
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +79 -46
  4. package/dist/config/constants.d.ts +15 -0
  5. package/dist/config/constants.d.ts.map +1 -0
  6. package/dist/config/constants.js +17 -0
  7. package/dist/index.js +2 -0
  8. package/dist/models/espresso_options.d.ts +7 -0
  9. package/dist/models/espresso_options.d.ts.map +1 -1
  10. package/dist/models/espresso_options.js +18 -0
  11. package/dist/models/maestro_options.d.ts +20 -2
  12. package/dist/models/maestro_options.d.ts.map +1 -1
  13. package/dist/models/maestro_options.js +38 -1
  14. package/dist/models/xcuitest_options.d.ts +7 -0
  15. package/dist/models/xcuitest_options.d.ts.map +1 -1
  16. package/dist/models/xcuitest_options.js +18 -0
  17. package/dist/providers/base_provider.d.ts +28 -2
  18. package/dist/providers/base_provider.d.ts.map +1 -1
  19. package/dist/providers/base_provider.js +70 -2
  20. package/dist/providers/espresso.d.ts +1 -0
  21. package/dist/providers/espresso.d.ts.map +1 -1
  22. package/dist/providers/espresso.js +82 -35
  23. package/dist/providers/maestro.d.ts +31 -0
  24. package/dist/providers/maestro.d.ts.map +1 -1
  25. package/dist/providers/maestro.js +399 -149
  26. package/dist/providers/xcuitest.d.ts +1 -0
  27. package/dist/providers/xcuitest.d.ts.map +1 -1
  28. package/dist/providers/xcuitest.js +79 -35
  29. package/dist/ui/banner.d.ts +3 -0
  30. package/dist/ui/banner.d.ts.map +1 -0
  31. package/dist/ui/banner.js +82 -0
  32. package/dist/ui/spinner.d.ts +32 -0
  33. package/dist/ui/spinner.d.ts.map +1 -0
  34. package/dist/ui/spinner.js +92 -0
  35. package/dist/ui/terminal-title.d.ts +8 -0
  36. package/dist/ui/terminal-title.d.ts.map +1 -0
  37. package/dist/ui/terminal-title.js +57 -0
  38. package/dist/upload.d.ts +4 -0
  39. package/dist/upload.d.ts.map +1 -1
  40. package/dist/upload.js +70 -12
  41. package/dist/utils/connectivity.js +5 -3
  42. package/dist/utils.d.ts +6 -0
  43. package/dist/utils.d.ts.map +1 -1
  44. package/dist/utils.js +10 -0
  45. package/package.json +5 -3
@@ -50,12 +50,21 @@ const utils_1 = __importDefault(require("../utils"));
50
50
  const file_type_detector_1 = require("../utils/file-type-detector");
51
51
  const picocolors_1 = __importDefault(require("picocolors"));
52
52
  const base_provider_1 = __importDefault(require("./base_provider"));
53
+ const terminal_title_1 = require("../ui/terminal-title");
54
+ const constants_1 = require("../config/constants");
55
+ const FLOW_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
56
+ const FLOW_ANIMATION_MS = 120;
53
57
  class Maestro extends base_provider_1.default {
54
58
  URL = 'https://api.testingbot.com/v1/app-automate/maestro';
55
59
  detectedPlatform = undefined;
56
60
  socket = null;
57
61
  updateServer = null;
58
62
  updateKey = null;
63
+ socketFallbackWarned = false;
64
+ flowAnimationFrame = 0;
65
+ flowAnimationTimer = null;
66
+ latestFlows = [];
67
+ latestDisplayedLineCount = 0;
59
68
  constructor(credentials, options) {
60
69
  super(credentials, options);
61
70
  }
@@ -88,6 +97,13 @@ class Maestro extends base_provider_1.default {
88
97
  throw new testingbot_error_1.default(`Provided app path does not exist ${this.options.app}`);
89
98
  }),
90
99
  ];
100
+ if (this.options.configFile) {
101
+ fileChecks.push(node_fs_1.default.promises
102
+ .access(this.options.configFile, node_fs_1.default.constants.R_OK)
103
+ .catch(() => {
104
+ throw new testingbot_error_1.default(`Specified config file does not exist: ${this.options.configFile}`);
105
+ }));
106
+ }
91
107
  // Check if all flows paths exist (can be files, directories or glob patterns)
92
108
  for (const flowsPath of this.options.flows) {
93
109
  const isGlobPattern = flowsPath.includes('*') ||
@@ -172,20 +188,28 @@ class Maestro extends base_provider_1.default {
172
188
  return { success: true, runs: [] };
173
189
  }
174
190
  try {
191
+ (0, terminal_title_1.setTitle)('maestro');
175
192
  // Quick connectivity check before starting uploads
176
193
  await this.ensureConnectivity();
177
194
  // Detect platform from file content if not explicitly provided
178
195
  if (!this.options.platformName) {
179
196
  this.detectedPlatform = await this.detectPlatform();
180
197
  }
198
+ (0, terminal_title_1.setTitle)('maestro · uploading app');
181
199
  await this.uploadApp();
182
200
  if (!this.options.quiet) {
183
201
  logger_1.default.info('Uploading Maestro Flows');
184
202
  }
203
+ (0, terminal_title_1.setTitle)('maestro · uploading flows');
185
204
  await this.uploadFlows();
205
+ if (this.options.tunnel && this.options.async) {
206
+ 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
+ }
208
+ await this.startTunnel();
186
209
  if (!this.options.quiet) {
187
210
  logger_1.default.info('Running Maestro Tests');
188
211
  }
212
+ (0, terminal_title_1.setTitle)('maestro · queued');
189
213
  await this.runTests();
190
214
  if (this.options.async) {
191
215
  if (!this.options.quiet) {
@@ -204,12 +228,17 @@ class Maestro extends base_provider_1.default {
204
228
  // Clean up
205
229
  this.disconnectFromUpdateServer();
206
230
  this.removeSignalHandlers();
231
+ await this.stopTunnel();
207
232
  return result;
208
233
  }
209
234
  catch (error) {
210
235
  // Clean up on error
236
+ this.spinner.stop();
237
+ this.stopFlowAnimation();
211
238
  this.disconnectFromUpdateServer();
212
239
  this.removeSignalHandlers();
240
+ await this.stopTunnel();
241
+ (0, terminal_title_1.setTitle)('maestro · ✘ error');
213
242
  logger_1.default.error(error instanceof Error ? error.message : error);
214
243
  // Display the cause if available
215
244
  if (error instanceof Error && error.cause) {
@@ -224,7 +253,7 @@ class Maestro extends base_provider_1.default {
224
253
  async uploadApp() {
225
254
  let appPath = this.options.app;
226
255
  const ext = node_path_1.default.extname(appPath).toLowerCase();
227
- let tempZipPath = null;
256
+ let tempZipDir = null;
228
257
  // If .app bundle (directory), zip it first
229
258
  if (ext === '.app') {
230
259
  const stat = await node_fs_1.default.promises.stat(appPath);
@@ -232,8 +261,9 @@ class Maestro extends base_provider_1.default {
232
261
  if (!this.options.quiet) {
233
262
  logger_1.default.info('Zipping .app bundle for upload');
234
263
  }
235
- tempZipPath = await this.zipAppBundle(appPath);
236
- appPath = tempZipPath;
264
+ const zipped = await this.zipAppBundle(appPath);
265
+ tempZipDir = zipped.tmpDir;
266
+ appPath = zipped.zipPath;
237
267
  }
238
268
  }
239
269
  try {
@@ -277,9 +307,12 @@ class Maestro extends base_provider_1.default {
277
307
  return true;
278
308
  }
279
309
  finally {
280
- // Clean up temporary zip file
281
- if (tempZipPath) {
282
- await node_fs_1.default.promises.unlink(tempZipPath).catch(() => { });
310
+ if (tempZipDir) {
311
+ await node_fs_1.default.promises
312
+ .rm(tempZipDir, { recursive: true, force: true })
313
+ .catch((err) => {
314
+ logger_1.default.debug(`Failed to clean up temporary app zip dir ${tempZipDir}: ${err instanceof Error ? err.message : err}`);
315
+ });
283
316
  }
284
317
  }
285
318
  }
@@ -293,7 +326,8 @@ class Maestro extends base_provider_1.default {
293
326
  return new Promise((resolve, reject) => {
294
327
  const output = node_fs_1.default.createWriteStream(zipPath);
295
328
  const archive = (0, archiver_1.default)('zip', { zlib: { level: 9 } });
296
- output.on('close', () => resolve(zipPath));
329
+ output.on('close', () => resolve({ zipPath, tmpDir }));
330
+ output.on('error', (err) => reject(err));
297
331
  archive.on('error', (err) => reject(err));
298
332
  archive.pipe(output);
299
333
  // Add the .app directory with its name preserved
@@ -339,8 +373,7 @@ class Maestro extends base_provider_1.default {
339
373
  if (flowsPaths.length === 1) {
340
374
  const singlePath = flowsPaths[0];
341
375
  const stat = await node_fs_1.default.promises.stat(singlePath).catch(() => null);
342
- if (stat?.isFile() &&
343
- node_path_1.default.extname(singlePath).toLowerCase() === '.zip') {
376
+ if (stat?.isFile() && node_path_1.default.extname(singlePath).toLowerCase() === '.zip') {
344
377
  return null;
345
378
  }
346
379
  }
@@ -467,7 +500,7 @@ class Maestro extends base_provider_1.default {
467
500
  return true;
468
501
  }
469
502
  const { allFlowFiles, baseDir } = result;
470
- const zipPath = await this.createFlowsZip(allFlowFiles, baseDir);
503
+ const { zipPath, tmpDir } = await this.createFlowsZip(allFlowFiles, baseDir);
471
504
  try {
472
505
  await this.upload.upload({
473
506
  filePath: zipPath,
@@ -478,7 +511,11 @@ class Maestro extends base_provider_1.default {
478
511
  });
479
512
  }
480
513
  finally {
481
- await node_fs_1.default.promises.unlink(zipPath).catch(() => { });
514
+ await node_fs_1.default.promises
515
+ .rm(tmpDir, { recursive: true, force: true })
516
+ .catch((err) => {
517
+ logger_1.default.debug(`Failed to clean up temporary flows zip dir ${tmpDir}: ${err instanceof Error ? err.message : err}`);
518
+ });
482
519
  }
483
520
  return true;
484
521
  }
@@ -514,9 +551,14 @@ class Maestro extends base_provider_1.default {
514
551
  const flowFiles = [];
515
552
  let configPath = null;
516
553
  let config = null;
517
- // Check for config.yaml or config.yml
518
- for (const configName of ['config.yaml', 'config.yml']) {
519
- const candidatePath = node_path_1.default.join(directory, configName);
554
+ // If a custom config file is specified, use it; otherwise check for config.yaml or config.yml
555
+ const configCandidates = this.options.configFile
556
+ ? [node_path_1.default.resolve(this.options.configFile)]
557
+ : [
558
+ node_path_1.default.join(directory, 'config.yaml'),
559
+ node_path_1.default.join(directory, 'config.yml'),
560
+ ];
561
+ for (const candidatePath of configCandidates) {
520
562
  try {
521
563
  const configContent = await node_fs_1.default.promises.readFile(candidatePath, 'utf-8');
522
564
  config = yaml.load(configContent);
@@ -590,7 +632,13 @@ class Maestro extends base_provider_1.default {
590
632
  */
591
633
  isConfigFile(filePath) {
592
634
  const basename = node_path_1.default.basename(filePath);
593
- return basename === 'config.yaml' || basename === 'config.yml';
635
+ if (basename === 'config.yaml' || basename === 'config.yml') {
636
+ return true;
637
+ }
638
+ if (this.options.configFile) {
639
+ return basename === node_path_1.default.basename(this.options.configFile);
640
+ }
641
+ return false;
594
642
  }
595
643
  async readFlowTags(flowFile) {
596
644
  try {
@@ -618,8 +666,10 @@ class Maestro extends base_provider_1.default {
618
666
  * exists or the values are not arrays.
619
667
  */
620
668
  async loadConfigTags(baseDir) {
621
- for (const configName of ['config.yaml', 'config.yml']) {
622
- const candidate = node_path_1.default.join(baseDir, configName);
669
+ const candidates = this.options.configFile
670
+ ? [node_path_1.default.resolve(this.options.configFile)]
671
+ : [node_path_1.default.join(baseDir, 'config.yaml'), node_path_1.default.join(baseDir, 'config.yml')];
672
+ for (const candidate of candidates) {
623
673
  try {
624
674
  const content = await node_fs_1.default.promises.readFile(candidate, 'utf-8');
625
675
  const parsed = yaml.load(content);
@@ -688,9 +738,12 @@ class Maestro extends base_provider_1.default {
688
738
  * Check if a string looks like a file path (relative path with extension)
689
739
  */
690
740
  looksLikePath(value) {
691
- // Must be a relative path (starts with . or contains /)
692
- const isRelative = value.startsWith('./') || value.startsWith('../');
693
- const hasPathSeparator = value.includes('/');
741
+ // Must be a relative path (starts with . or contains a path separator)
742
+ const isRelative = value.startsWith('./') ||
743
+ value.startsWith('../') ||
744
+ value.startsWith('.\\') ||
745
+ value.startsWith('..\\');
746
+ const hasPathSeparator = value.includes('/') || value.includes('\\');
694
747
  // Must have a file extension
695
748
  const hasExtension = /\.[a-zA-Z0-9]+$/.test(value);
696
749
  // Exclude URLs
@@ -1085,7 +1138,8 @@ class Maestro extends base_provider_1.default {
1085
1138
  return new Promise((resolve, reject) => {
1086
1139
  const output = node_fs_1.default.createWriteStream(zipPath);
1087
1140
  const archive = (0, archiver_1.default)('zip', { zlib: { level: 9 } });
1088
- output.on('close', () => resolve(zipPath));
1141
+ output.on('close', () => resolve({ zipPath, tmpDir }));
1142
+ output.on('error', (err) => reject(err));
1089
1143
  archive.on('error', (err) => reject(err));
1090
1144
  archive.pipe(output);
1091
1145
  // Compute effective base directory for archive paths
@@ -1141,7 +1195,7 @@ class Maestro extends base_provider_1.default {
1141
1195
  username: this.credentials.userName,
1142
1196
  password: this.credentials.accessKey,
1143
1197
  },
1144
- timeout: 30000, // 30 second timeout
1198
+ timeout: constants_1.HTTP.TIMEOUT_MS,
1145
1199
  });
1146
1200
  // Check for version update notification
1147
1201
  const latestVersion = response.headers?.['x-testingbotctl-version'];
@@ -1179,7 +1233,7 @@ class Maestro extends base_provider_1.default {
1179
1233
  username: this.credentials.userName,
1180
1234
  password: this.credentials.accessKey,
1181
1235
  },
1182
- timeout: 30000, // 30 second timeout
1236
+ timeout: constants_1.HTTP.TIMEOUT_MS,
1183
1237
  });
1184
1238
  // Check for version update notification
1185
1239
  const latestVersion = response.headers?.['x-testingbotctl-version'];
@@ -1195,14 +1249,15 @@ class Maestro extends base_provider_1.default {
1195
1249
  }
1196
1250
  }
1197
1251
  async waitForCompletion() {
1198
- let attempts = 0;
1199
1252
  const startTime = Date.now();
1200
1253
  const previousStatus = new Map();
1201
1254
  const previousFlowStatus = new Map();
1202
1255
  const urlDisplayed = new Set();
1203
1256
  let flowsTableDisplayed = false;
1204
1257
  let displayedLineCount = 0;
1205
- while (attempts < this.MAX_POLL_ATTEMPTS) {
1258
+ let pollInterval = this.MIN_POLL_INTERVAL_MS;
1259
+ let previousSignature = null;
1260
+ while (true) {
1206
1261
  // Check if we're shutting down
1207
1262
  if (this.isShuttingDown) {
1208
1263
  throw new testingbot_error_1.default('Test run cancelled by user');
@@ -1212,6 +1267,11 @@ class Maestro extends base_provider_1.default {
1212
1267
  this.activeRunIds = status.runs
1213
1268
  .filter((run) => run.status !== 'DONE' && run.status !== 'FAILED')
1214
1269
  .map((run) => run.id);
1270
+ const running = status.runs.find((r) => r.status === 'READY');
1271
+ if (running) {
1272
+ const device = running.environment?.name || running.capabilities.deviceName;
1273
+ (0, terminal_title_1.setTitle)(`maestro · running · ${device}`);
1274
+ }
1215
1275
  // Log current status of runs (unless quiet mode)
1216
1276
  if (!this.options.quiet) {
1217
1277
  // Check if any run has flows and display them
@@ -1233,15 +1293,23 @@ class Maestro extends base_provider_1.default {
1233
1293
  // Check if any flow has failed (for showing error column)
1234
1294
  const hasFailures = this.hasAnyFlowFailed(allFlows);
1235
1295
  if (!flowsTableDisplayed) {
1296
+ // Flows have arrived — stop the run-level spinner so it doesn't
1297
+ // fight the flow table's cursor-based in-place updates.
1298
+ this.spinner.stop();
1236
1299
  // First time showing flows - display header and initial state
1237
1300
  console.log(); // Empty line before flows table
1238
1301
  this.displayFlowsTableHeader(hasFailures);
1239
1302
  displayedLineCount = this.displayFlowsWithLimit(allFlows, previousFlowStatus, hasFailures);
1240
1303
  flowsTableDisplayed = true;
1304
+ this.latestFlows = allFlows;
1305
+ this.latestDisplayedLineCount = displayedLineCount;
1306
+ this.startFlowAnimation(previousFlowStatus);
1241
1307
  }
1242
1308
  else {
1243
1309
  // Update flows in place
1244
1310
  displayedLineCount = this.updateFlowsInPlace(allFlows, previousFlowStatus, displayedLineCount);
1311
+ this.latestFlows = allFlows;
1312
+ this.latestDisplayedLineCount = displayedLineCount;
1245
1313
  }
1246
1314
  }
1247
1315
  else {
@@ -1250,6 +1318,7 @@ class Maestro extends base_provider_1.default {
1250
1318
  }
1251
1319
  }
1252
1320
  if (status.completed) {
1321
+ this.stopFlowAnimation();
1253
1322
  // Display final flows table with error messages if there are failures
1254
1323
  if (!this.options.quiet && flowsTableDisplayed) {
1255
1324
  const allFlows = [];
@@ -1266,9 +1335,9 @@ class Maestro extends base_provider_1.default {
1266
1335
  process.stdout.write(`\x1b[${linesToMove}A`);
1267
1336
  // Clear header line, write new header, then clear separator line
1268
1337
  process.stdout.write('\x1b[2K');
1269
- console.log(picocolors_1.default.dim(` ${'Duration'.padEnd(10)} ${'Status'.padEnd(8)} Flow Fail reason`));
1338
+ console.log(picocolors_1.default.dim(` ${'Duration'.padEnd(10)} ${'Status'.padEnd(10)} Flow Fail reason`));
1270
1339
  process.stdout.write('\x1b[2K');
1271
- console.log(picocolors_1.default.dim(` ${'─'.repeat(10)} ${'─'.repeat(8)} ${'─'.repeat(30)} ${'─'.repeat(80)}`));
1340
+ console.log(picocolors_1.default.dim(` ${'─'.repeat(10)} ${'─'.repeat(10)} ${'─'.repeat(30)} ${'─'.repeat(80)}`));
1272
1341
  // Redraw all flows with error messages
1273
1342
  for (const flow of allFlows) {
1274
1343
  // Clear the line before writing
@@ -1279,21 +1348,27 @@ class Maestro extends base_provider_1.default {
1279
1348
  }
1280
1349
  // Print final summary
1281
1350
  if (!this.options.quiet) {
1351
+ this.spinner.stop();
1282
1352
  console.log(); // Empty line before summary
1283
1353
  for (const run of status.runs) {
1284
- const statusEmoji = run.success === 1 ? '✅' : '❌';
1285
- const statusText = run.success === 1 ? 'Test completed successfully' : 'Test failed';
1286
- console.log(` ${statusEmoji} Run ${run.id} (${this.getRunDisplayName(run)}): ${statusText}`);
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}`);
1287
1360
  }
1288
1361
  }
1289
1362
  const allSucceeded = status.runs.every((run) => run.success === 1);
1290
1363
  if (allSucceeded) {
1364
+ (0, terminal_title_1.setTitle)('maestro · ✔ passed');
1291
1365
  if (!this.options.quiet) {
1292
1366
  logger_1.default.info('All tests completed successfully!');
1293
1367
  }
1294
1368
  }
1295
1369
  else {
1296
1370
  const failedRuns = status.runs.filter((run) => run.success !== 1);
1371
+ (0, terminal_title_1.setTitle)(`maestro · ✘ ${failedRuns.length} failed`);
1297
1372
  logger_1.default.error(`${failedRuns.length} test run(s) failed`);
1298
1373
  }
1299
1374
  if (this.options.report && this.options.reportOutputDir) {
@@ -1307,35 +1382,50 @@ class Maestro extends base_provider_1.default {
1307
1382
  runs: status.runs,
1308
1383
  };
1309
1384
  }
1310
- attempts++;
1311
- await this.sleep(this.POLL_INTERVAL_MS);
1385
+ // Checked after getStatus() so a run that completes during the final
1386
+ // sleep is returned as success on the next iteration instead of being
1387
+ // misreported as a timeout.
1388
+ if (Date.now() - startTime >= this.MAX_POLL_DURATION_MS) {
1389
+ throw new testingbot_error_1.default(`Test timed out after ${this.MAX_POLL_DURATION_MS / 1000 / 60} minutes`);
1390
+ }
1391
+ const signature = JSON.stringify(status.runs.map((r) => [
1392
+ r.id,
1393
+ r.status,
1394
+ r.success,
1395
+ r.flows?.map((f) => [f.id, f.status]) ?? [],
1396
+ ]));
1397
+ const changed = signature !== previousSignature;
1398
+ previousSignature = signature;
1399
+ pollInterval = this.computeNextPollInterval(pollInterval, changed);
1400
+ await this.sleep(pollInterval);
1312
1401
  }
1313
- throw new testingbot_error_1.default(`Test timed out after ${(this.MAX_POLL_ATTEMPTS * this.POLL_INTERVAL_MS) / 1000 / 60} minutes`);
1314
1402
  }
1315
1403
  displayRunStatus(runs, startTime, previousStatus) {
1316
1404
  const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000);
1317
1405
  const elapsedStr = this.formatElapsedTime(elapsedSeconds);
1406
+ const activeMessages = [];
1318
1407
  for (const run of runs) {
1319
1408
  const prevStatus = previousStatus.get(run.id);
1320
1409
  const statusChanged = prevStatus !== run.status;
1321
- // If status changed from WAITING/READY to something else, clear the updating line
1322
- if (statusChanged &&
1323
- prevStatus &&
1324
- (prevStatus === 'WAITING' || prevStatus === 'READY')) {
1325
- this.clearLine();
1326
- }
1327
1410
  previousStatus.set(run.id, run.status);
1328
1411
  const statusInfo = this.getStatusInfo(run.status);
1329
1412
  if (run.status === 'WAITING' || run.status === 'READY') {
1330
- // Update the same line for WAITING and READY states
1331
- const message = ` ${statusInfo.emoji} Run ${run.id} (${this.getRunDisplayName(run)}): ${statusInfo.text} (${elapsedStr})`;
1332
- process.stdout.write(`\r${message}`);
1413
+ const label = run.status === 'WAITING'
1414
+ ? picocolors_1.default.yellow(statusInfo.text)
1415
+ : picocolors_1.default.cyan(statusInfo.text);
1416
+ activeMessages.push(`${label} ${picocolors_1.default.dim(`• Run ${run.id} (${this.getRunDisplayName(run)}) • ${elapsedStr}`)}`);
1333
1417
  }
1334
1418
  else if (statusChanged) {
1335
- // For other states (DONE, FAILED), print on a new line only when status changes
1336
- console.log(` ${statusInfo.emoji} Run ${run.id} (${this.getRunDisplayName(run)}): ${statusInfo.text}`);
1419
+ this.spinner.clearLine();
1420
+ console.log(` ${statusInfo.symbol} Run ${run.id} ${picocolors_1.default.dim(`(${this.getRunDisplayName(run)})`)}: ${statusInfo.text}`);
1337
1421
  }
1338
1422
  }
1423
+ if (activeMessages.length > 0) {
1424
+ this.spinner.setMessage(activeMessages.join(picocolors_1.default.dim(' ┊ ')));
1425
+ }
1426
+ else {
1427
+ this.spinner.stop();
1428
+ }
1339
1429
  }
1340
1430
  /**
1341
1431
  * Get the display name for a run, preferring environment.name over capabilities.deviceName
@@ -1347,32 +1437,39 @@ class Maestro extends base_provider_1.default {
1347
1437
  getStatusInfo(status) {
1348
1438
  switch (status) {
1349
1439
  case 'WAITING':
1350
- return { emoji: '', text: 'Waiting for test to start' };
1440
+ return { symbol: picocolors_1.default.yellow(''), text: 'Waiting for test to start' };
1351
1441
  case 'READY':
1352
- return { emoji: '🔄', text: 'Running test' };
1442
+ return { symbol: picocolors_1.default.cyan(''), text: 'Running test' };
1353
1443
  case 'DONE':
1354
- return { emoji: '', text: 'Test has finished running' };
1444
+ return { symbol: picocolors_1.default.green(''), text: 'Test has finished running' };
1355
1445
  case 'FAILED':
1356
- return { emoji: '', text: 'Test failed' };
1446
+ return { symbol: picocolors_1.default.red(''), text: 'Test failed' };
1357
1447
  default:
1358
- return { emoji: '', text: status };
1448
+ return { symbol: picocolors_1.default.dim('?'), text: status };
1359
1449
  }
1360
1450
  }
1361
1451
  getFlowStatusDisplay(flow) {
1452
+ const frame = FLOW_SPINNER_FRAMES[this.flowAnimationFrame];
1362
1453
  switch (flow.status) {
1363
1454
  case 'WAITING':
1364
- return { text: 'WAITING', colored: picocolors_1.default.white('WAITING') };
1455
+ return {
1456
+ text: `${frame} WAITING`,
1457
+ colored: picocolors_1.default.yellow(`${frame} WAITING`),
1458
+ };
1365
1459
  case 'READY':
1366
- return { text: 'RUNNING', colored: picocolors_1.default.blue('RUNNING') };
1460
+ return {
1461
+ text: `${frame} RUNNING`,
1462
+ colored: picocolors_1.default.cyan(`${frame} RUNNING`),
1463
+ };
1367
1464
  case 'DONE':
1368
1465
  if (flow.success === 1) {
1369
- return { text: 'PASSED', colored: picocolors_1.default.green('PASSED') };
1466
+ return { text: 'PASSED', colored: picocolors_1.default.green('PASSED') };
1370
1467
  }
1371
1468
  else {
1372
- return { text: 'FAILED', colored: picocolors_1.default.red('FAILED') };
1469
+ return { text: 'FAILED', colored: picocolors_1.default.red('FAILED') };
1373
1470
  }
1374
1471
  case 'FAILED':
1375
- return { text: 'FAILED', colored: picocolors_1.default.red('FAILED') };
1472
+ return { text: 'FAILED', colored: picocolors_1.default.red('FAILED') };
1376
1473
  default:
1377
1474
  return { text: flow.status, colored: flow.status };
1378
1475
  }
@@ -1407,6 +1504,31 @@ class Maestro extends base_provider_1.default {
1407
1504
  const reservedLines = 6;
1408
1505
  return Math.max(5, terminalHeight - reservedLines);
1409
1506
  }
1507
+ getTerminalWidth() {
1508
+ return process.stdout.columns || 200;
1509
+ }
1510
+ /**
1511
+ * Returns the maximum length of `flow.name` that keeps the rendered row
1512
+ * within the current terminal width, so the row does not visually wrap.
1513
+ * Wrapped rows break the `\x1b[NA` cursor-up math used by in-place updates,
1514
+ * which is what causes the table to repeat instead of refresh in place
1515
+ * (e.g. with --shard-split where the API returns long comma-joined names).
1516
+ *
1517
+ * Row layout is: " {duration:10} {status:10} {name}[ {error}]" — overhead
1518
+ * is 23 plain-width chars before `name`. `extra` reserves room for trailing
1519
+ * content like a fail-reason suffix.
1520
+ */
1521
+ getMaxNameLength(extra = 0) {
1522
+ const overhead = 23 + extra + 1;
1523
+ return Math.max(10, this.getTerminalWidth() - overhead);
1524
+ }
1525
+ truncateForRow(name, max) {
1526
+ if (name.length <= max)
1527
+ return name;
1528
+ if (max <= 1)
1529
+ return name.slice(0, max);
1530
+ return name.slice(0, max - 1) + '…';
1531
+ }
1410
1532
  getRemainingSummary(flows, displayedCount) {
1411
1533
  const remaining = flows.slice(displayedCount);
1412
1534
  if (remaining.length === 0) {
@@ -1466,8 +1588,8 @@ class Maestro extends base_provider_1.default {
1466
1588
  return linesWritten;
1467
1589
  }
1468
1590
  displayFlowsTableHeader(hasFailures = false) {
1469
- let header = ` ${'Duration'.padEnd(10)} ${'Status'.padEnd(8)} Flow`;
1470
- let separator = ` ${'─'.repeat(10)} ${'─'.repeat(8)} ${'─'.repeat(30)}`;
1591
+ let header = ` ${'Duration'.padEnd(10)} ${'Status'.padEnd(10)} Flow`;
1592
+ let separator = ` ${'─'.repeat(10)} ${'─'.repeat(10)} ${'─'.repeat(30)}`;
1471
1593
  if (hasFailures) {
1472
1594
  header += ' Fail reason';
1473
1595
  separator += ` ${'─'.repeat(80)}`;
@@ -1480,16 +1602,21 @@ class Maestro extends base_provider_1.default {
1480
1602
  const statusDisplay = this.getFlowStatusDisplay(flow);
1481
1603
  // Pad based on display text length, add extra for color codes
1482
1604
  const statusPadded = statusDisplay.colored +
1483
- ' '.repeat(Math.max(0, 8 - statusDisplay.text.length));
1484
- const name = flow.name.padEnd(30);
1605
+ ' '.repeat(Math.max(0, 10 - statusDisplay.text.length));
1485
1606
  let linesWritten = 0;
1486
1607
  const isFailed = flow.status === 'DONE' && flow.success !== 1;
1487
1608
  const errorMessages = flow.error_messages || [];
1609
+ const firstError = hasFailures && isFailed && errorMessages.length > 0
1610
+ ? errorMessages[0]
1611
+ : '';
1612
+ const errorReserve = firstError ? firstError.length + 1 : 0;
1613
+ const maxName = this.getMaxNameLength(errorReserve);
1614
+ const name = this.truncateForRow(flow.name, maxName).padEnd(Math.min(30, maxName));
1488
1615
  // Build the main row
1489
1616
  let row = ` ${duration} ${statusPadded} ${name}`;
1490
1617
  // Add first error message on the same line if failed and has errors
1491
- if (hasFailures && isFailed && errorMessages.length > 0) {
1492
- row += ` ${picocolors_1.default.red(errorMessages[0])}`;
1618
+ if (firstError) {
1619
+ row += ` ${picocolors_1.default.red(firstError)}`;
1493
1620
  }
1494
1621
  if (isUpdate) {
1495
1622
  process.stdout.write(`\r${row}`);
@@ -1500,8 +1627,8 @@ class Maestro extends base_provider_1.default {
1500
1627
  linesWritten++;
1501
1628
  // Display remaining error messages on continuation lines
1502
1629
  if (!isUpdate && hasFailures && isFailed && errorMessages.length > 1) {
1503
- // Indent to align with the Fail reason column: Duration(11) + Status(9) + Test(31) = 51 chars
1504
- const indent = ' '.repeat(51);
1630
+ // Indent to align with the Fail reason column: Duration(11) + Status(11) + Test(31) = 53 chars
1631
+ const indent = ' '.repeat(53);
1505
1632
  for (let i = 1; i < errorMessages.length; i++) {
1506
1633
  console.log(`${indent} ${picocolors_1.default.red(errorMessages[i])}`);
1507
1634
  linesWritten++;
@@ -1524,6 +1651,33 @@ class Maestro extends base_provider_1.default {
1524
1651
  }
1525
1652
  return linesWritten;
1526
1653
  }
1654
+ /**
1655
+ * Starts the flow-table animation loop. Re-renders the cached flow rows at
1656
+ * `FLOW_ANIMATION_MS` so WAITING/RUNNING spinner frames advance between
1657
+ * (much slower) polls. Calling while already running is a no-op.
1658
+ */
1659
+ startFlowAnimation(previousFlowStatus) {
1660
+ if (this.flowAnimationTimer || !utils_1.default.isInteractive())
1661
+ return;
1662
+ this.flowAnimationTimer = setInterval(() => {
1663
+ const hasActive = this.latestFlows.some((f) => f.status === 'WAITING' || f.status === 'READY');
1664
+ if (!hasActive)
1665
+ return;
1666
+ this.flowAnimationFrame =
1667
+ (this.flowAnimationFrame + 1) % FLOW_SPINNER_FRAMES.length;
1668
+ this.latestDisplayedLineCount = this.updateFlowsInPlace(this.latestFlows, previousFlowStatus, this.latestDisplayedLineCount);
1669
+ }, FLOW_ANIMATION_MS);
1670
+ this.flowAnimationTimer.unref?.();
1671
+ }
1672
+ stopFlowAnimation() {
1673
+ if (this.flowAnimationTimer) {
1674
+ clearInterval(this.flowAnimationTimer);
1675
+ this.flowAnimationTimer = null;
1676
+ }
1677
+ }
1678
+ stopAnimations() {
1679
+ this.stopFlowAnimation();
1680
+ }
1527
1681
  updateFlowsInPlace(flows, previousFlowStatus, displayedLineCount) {
1528
1682
  const maxFlows = this.getMaxDisplayableFlows();
1529
1683
  const displayFlows = flows.slice(0, maxFlows);
@@ -1534,14 +1688,15 @@ class Maestro extends base_provider_1.default {
1534
1688
  }
1535
1689
  let linesWritten = 0;
1536
1690
  // Redraw displayed flows
1691
+ const maxName = this.getMaxNameLength();
1537
1692
  for (const flow of displayFlows) {
1538
1693
  const duration = this.calculateFlowDuration(flow).padEnd(10);
1539
1694
  const statusDisplay = this.getFlowStatusDisplay(flow);
1540
1695
  const statusPadded = statusDisplay.colored +
1541
- ' '.repeat(Math.max(0, 8 - statusDisplay.text.length));
1542
- const name = flow.name;
1696
+ ' '.repeat(Math.max(0, 10 - statusDisplay.text.length));
1697
+ const name = this.truncateForRow(flow.name, maxName);
1543
1698
  const row = ` ${duration} ${statusPadded} ${name}`;
1544
- process.stdout.write(`\r\x1b[K${row}\n`);
1699
+ process.stdout.write(`\r\x1b[2K${row}\n`);
1545
1700
  previousFlowStatus.set(flow.id, flow.status);
1546
1701
  linesWritten++;
1547
1702
  }
@@ -1565,7 +1720,23 @@ class Maestro extends base_provider_1.default {
1565
1720
  }
1566
1721
  for (const run of runs) {
1567
1722
  try {
1568
- const reportEndpoint = reportFormat === 'junit' ? 'junit_report' : 'html_report';
1723
+ let reportEndpoint;
1724
+ let reportKey;
1725
+ switch (reportFormat) {
1726
+ case 'junit':
1727
+ reportEndpoint = 'junit_report';
1728
+ reportKey = 'junit_report';
1729
+ break;
1730
+ case 'html-detailed':
1731
+ reportEndpoint = 'html_report_detailed';
1732
+ reportKey = 'html_report_detailed';
1733
+ break;
1734
+ case 'html':
1735
+ default:
1736
+ reportEndpoint = 'html_report';
1737
+ reportKey = 'html_report';
1738
+ break;
1739
+ }
1569
1740
  const response = await axios_1.default.get(`${this.URL}/${this.appId}/${run.id}/${reportEndpoint}`, {
1570
1741
  headers: {
1571
1742
  'User-Agent': utils_1.default.getUserAgent(),
@@ -1574,13 +1745,12 @@ class Maestro extends base_provider_1.default {
1574
1745
  username: this.credentials.userName,
1575
1746
  password: this.credentials.accessKey,
1576
1747
  },
1577
- timeout: 30000, // 30 second timeout
1748
+ timeout: constants_1.HTTP.TIMEOUT_MS,
1578
1749
  });
1579
1750
  // Check for version update notification
1580
1751
  const latestVersion = response.headers?.['x-testingbotctl-version'];
1581
1752
  utils_1.default.checkForUpdate(latestVersion);
1582
1753
  // Extract the report content from the JSON response
1583
- const reportKey = reportFormat === 'junit' ? 'junit_report' : 'html_report';
1584
1754
  const reportContent = response.data[reportKey];
1585
1755
  if (!reportContent) {
1586
1756
  logger_1.default.error(`No ${reportFormat} report found for run ${run.id}`);
@@ -1610,7 +1780,7 @@ class Maestro extends base_provider_1.default {
1610
1780
  username: this.credentials.userName,
1611
1781
  password: this.credentials.accessKey,
1612
1782
  },
1613
- timeout: 30000, // 30 second timeout
1783
+ timeout: constants_1.HTTP.TIMEOUT_MS,
1614
1784
  });
1615
1785
  const latestVersion = response.headers?.['x-testingbotctl-version'];
1616
1786
  utils_1.default.checkForUpdate(latestVersion);
@@ -1630,7 +1800,7 @@ class Maestro extends base_provider_1.default {
1630
1800
  return details;
1631
1801
  }
1632
1802
  attempts++;
1633
- await this.sleep(this.POLL_INTERVAL_MS);
1803
+ await this.sleep(this.MIN_POLL_INTERVAL_MS);
1634
1804
  }
1635
1805
  throw new testingbot_error_1.default(`Timed out waiting for artifacts to sync for run ${runId}`);
1636
1806
  }
@@ -1702,6 +1872,88 @@ class Maestro extends base_provider_1.default {
1702
1872
  return fileName;
1703
1873
  }
1704
1874
  }
1875
+ sanitizeFlowDirName(name) {
1876
+ if (!name)
1877
+ return '';
1878
+ let s = name.replace(/[^A-Za-z0-9._-]+/g, '_');
1879
+ s = s.replace(/_+/g, '_');
1880
+ s = s.replace(/^[_.-]+|[_.-]+$/g, '');
1881
+ if (s.length > 64)
1882
+ s = s.slice(0, 64).replace(/[_.-]+$/, '');
1883
+ return s;
1884
+ }
1885
+ buildFlowDirNames(entries, reserved = new Set()) {
1886
+ const baseNames = new Map();
1887
+ const counts = new Map();
1888
+ for (const r of reserved) {
1889
+ counts.set(r, 1);
1890
+ }
1891
+ for (const { runId, flow } of entries) {
1892
+ const sanitized = this.sanitizeFlowDirName(flow.name);
1893
+ const base = sanitized || `flow_${flow.id}`;
1894
+ baseNames.set(`${runId}:${flow.id}`, base);
1895
+ counts.set(base, (counts.get(base) || 0) + 1);
1896
+ }
1897
+ const result = new Map();
1898
+ for (const { runId, flow } of entries) {
1899
+ const key = `${runId}:${flow.id}`;
1900
+ const base = baseNames.get(key);
1901
+ const collides = (counts.get(base) || 0) > 1;
1902
+ result.set(key, collides ? `${base}_${runId}_${flow.id}` : base);
1903
+ }
1904
+ return result;
1905
+ }
1906
+ async downloadAssetBundle(assets, targetDir) {
1907
+ if (assets.logs && Object.keys(assets.logs).length > 0) {
1908
+ const logsDir = node_path_1.default.join(targetDir, 'logs');
1909
+ await node_fs_1.default.promises.mkdir(logsDir, { recursive: true });
1910
+ for (const [logName, logUrl] of Object.entries(assets.logs)) {
1911
+ const logFileName = `${logName}.txt`;
1912
+ const logPath = node_path_1.default.join(logsDir, logFileName);
1913
+ try {
1914
+ await this.downloadFile(logUrl, logPath);
1915
+ if (!this.options.quiet) {
1916
+ logger_1.default.info(` Downloaded log: ${logFileName}`);
1917
+ }
1918
+ }
1919
+ catch (error) {
1920
+ logger_1.default.error(` Failed to download log ${logFileName}: ${error instanceof Error ? error.message : error}`);
1921
+ }
1922
+ }
1923
+ }
1924
+ if (assets.video && typeof assets.video === 'string') {
1925
+ const videoDir = node_path_1.default.join(targetDir, 'video');
1926
+ await node_fs_1.default.promises.mkdir(videoDir, { recursive: true });
1927
+ const videoPath = node_path_1.default.join(videoDir, 'video.mp4');
1928
+ try {
1929
+ await this.downloadFile(assets.video, videoPath);
1930
+ if (!this.options.quiet) {
1931
+ logger_1.default.info(` Downloaded video: video.mp4`);
1932
+ }
1933
+ }
1934
+ catch (error) {
1935
+ logger_1.default.error(` Failed to download video: ${error instanceof Error ? error.message : error}`);
1936
+ }
1937
+ }
1938
+ if (assets.screenshots && assets.screenshots.length > 0) {
1939
+ const screenshotsDir = node_path_1.default.join(targetDir, 'screenshots');
1940
+ await node_fs_1.default.promises.mkdir(screenshotsDir, { recursive: true });
1941
+ for (let i = 0; i < assets.screenshots.length; i++) {
1942
+ const screenshotUrl = assets.screenshots[i];
1943
+ const screenshotFileName = `screenshot_${i}.png`;
1944
+ const screenshotPath = node_path_1.default.join(screenshotsDir, screenshotFileName);
1945
+ try {
1946
+ await this.downloadFile(screenshotUrl, screenshotPath);
1947
+ if (!this.options.quiet) {
1948
+ logger_1.default.info(` Downloaded screenshot: ${screenshotFileName}`);
1949
+ }
1950
+ }
1951
+ catch (error) {
1952
+ logger_1.default.error(` Failed to download screenshot ${screenshotFileName}: ${error instanceof Error ? error.message : error}`);
1953
+ }
1954
+ }
1955
+ }
1956
+ }
1705
1957
  async downloadArtifacts(runs) {
1706
1958
  if (!this.options.downloadArtifacts)
1707
1959
  return;
@@ -1732,92 +1984,85 @@ class Maestro extends base_provider_1.default {
1732
1984
  const outputDir = this.options.artifactsOutputDir || process.cwd();
1733
1985
  const tempDir = await node_fs_1.default.promises.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), 'testingbot-maestro-artifacts-'));
1734
1986
  try {
1987
+ const runDetailsList = [];
1735
1988
  for (const run of runsToDownload) {
1989
+ if (!this.options.quiet) {
1990
+ logger_1.default.info(` Waiting for artifacts sync for run ${run.id}...`);
1991
+ }
1736
1992
  try {
1993
+ const details = await this.waitForArtifactsSync(run.id);
1994
+ runDetailsList.push({ run, details });
1995
+ }
1996
+ catch (error) {
1997
+ logger_1.default.error(`Failed to download artifacts for run ${run.id}: ${error instanceof Error ? error.message : error}`);
1998
+ }
1999
+ }
2000
+ const multiRun = runDetailsList.length > 1;
2001
+ const runReportName = (runId) => multiRun ? `report_${runId}.xml` : 'report.xml';
2002
+ const runAssetsDirName = (runId) => multiRun ? `run_${runId}` : '';
2003
+ const reservedNames = new Set();
2004
+ for (const { run, details } of runDetailsList) {
2005
+ if (details.report)
2006
+ reservedNames.add(runReportName(run.id));
2007
+ if (details.assets) {
2008
+ const dir = runAssetsDirName(run.id);
2009
+ if (dir)
2010
+ reservedNames.add(dir);
2011
+ }
2012
+ }
2013
+ const flowEntries = runDetailsList.flatMap(({ run, details }) => (details.flows || [])
2014
+ .filter((flow) => flow.assets)
2015
+ .map((flow) => ({ runId: run.id, flow })));
2016
+ const flowDirNames = this.buildFlowDirNames(flowEntries, reservedNames);
2017
+ for (const { run, details } of runDetailsList) {
2018
+ const flowsWithAssets = (details.flows || []).filter((flow) => flow.assets);
2019
+ if (!details.assets && flowsWithAssets.length === 0) {
1737
2020
  if (!this.options.quiet) {
1738
- logger_1.default.info(` Waiting for artifacts sync for run ${run.id}...`);
2021
+ logger_1.default.info(` No artifacts available for run ${run.id}`);
1739
2022
  }
1740
- const runDetails = await this.waitForArtifactsSync(run.id);
1741
- if (!runDetails.assets) {
1742
- if (!this.options.quiet) {
1743
- logger_1.default.info(` No artifacts available for run ${run.id}`);
1744
- }
1745
- continue;
1746
- }
1747
- const runDir = node_path_1.default.join(tempDir, `run_${run.id}`);
1748
- await node_fs_1.default.promises.mkdir(runDir, { recursive: true });
1749
- if (runDetails.assets.logs &&
1750
- Object.keys(runDetails.assets.logs).length > 0) {
1751
- const logsDir = node_path_1.default.join(runDir, 'logs');
1752
- await node_fs_1.default.promises.mkdir(logsDir, { recursive: true });
1753
- for (const [logName, logUrl] of Object.entries(runDetails.assets.logs)) {
1754
- const logFileName = `${logName}.txt`;
1755
- const logPath = node_path_1.default.join(logsDir, logFileName);
1756
- try {
1757
- await this.downloadFile(logUrl, logPath);
1758
- if (!this.options.quiet) {
1759
- logger_1.default.info(` Downloaded log: ${logFileName}`);
1760
- }
1761
- }
1762
- catch (error) {
1763
- logger_1.default.error(` Failed to download log ${logFileName}: ${error instanceof Error ? error.message : error}`);
1764
- }
1765
- }
2023
+ continue;
2024
+ }
2025
+ if (details.assets) {
2026
+ const dirName = runAssetsDirName(run.id);
2027
+ const targetDir = dirName ? node_path_1.default.join(tempDir, dirName) : tempDir;
2028
+ if (dirName) {
2029
+ await node_fs_1.default.promises.mkdir(targetDir, { recursive: true });
1766
2030
  }
1767
- if (runDetails.assets.video &&
1768
- typeof runDetails.assets.video === 'string') {
1769
- const videoDir = node_path_1.default.join(runDir, 'video');
1770
- await node_fs_1.default.promises.mkdir(videoDir, { recursive: true });
1771
- const videoUrl = runDetails.assets.video;
1772
- const videoFileName = 'video.mp4';
1773
- const videoPath = node_path_1.default.join(videoDir, videoFileName);
2031
+ await this.downloadAssetBundle(details.assets, targetDir);
2032
+ }
2033
+ for (const flow of flowsWithAssets) {
2034
+ const flowDirName = flowDirNames.get(`${run.id}:${flow.id}`);
2035
+ const flowDir = node_path_1.default.join(tempDir, flowDirName);
2036
+ await node_fs_1.default.promises.mkdir(flowDir, { recursive: true });
2037
+ await this.downloadAssetBundle(flow.assets, flowDir);
2038
+ if (flow.report) {
2039
+ const flowReportPath = node_path_1.default.join(flowDir, 'report.xml');
1774
2040
  try {
1775
- await this.downloadFile(videoUrl, videoPath);
2041
+ await node_fs_1.default.promises.writeFile(flowReportPath, flow.report, 'utf-8');
1776
2042
  if (!this.options.quiet) {
1777
- logger_1.default.info(` Downloaded video: ${videoFileName}`);
2043
+ logger_1.default.info(` Saved ${flowDirName}/report.xml`);
1778
2044
  }
1779
2045
  }
1780
2046
  catch (error) {
1781
- logger_1.default.error(` Failed to download video: ${error instanceof Error ? error.message : error}`);
1782
- }
1783
- }
1784
- if (runDetails.assets.screenshots &&
1785
- runDetails.assets.screenshots.length > 0) {
1786
- const screenshotsDir = node_path_1.default.join(runDir, 'screenshots');
1787
- await node_fs_1.default.promises.mkdir(screenshotsDir, { recursive: true });
1788
- for (let i = 0; i < runDetails.assets.screenshots.length; i++) {
1789
- const screenshotUrl = runDetails.assets.screenshots[i];
1790
- const screenshotFileName = `screenshot_${i}.png`;
1791
- const screenshotPath = node_path_1.default.join(screenshotsDir, screenshotFileName);
1792
- try {
1793
- await this.downloadFile(screenshotUrl, screenshotPath);
1794
- if (!this.options.quiet) {
1795
- logger_1.default.info(` Downloaded screenshot: ${screenshotFileName}`);
1796
- }
1797
- }
1798
- catch (error) {
1799
- logger_1.default.error(` Failed to download screenshot ${screenshotFileName}: ${error instanceof Error ? error.message : error}`);
1800
- }
2047
+ logger_1.default.error(` Failed to save report.xml for ${flowDirName}: ${error instanceof Error ? error.message : error}`);
1801
2048
  }
1802
2049
  }
1803
- if (runDetails.report) {
1804
- const reportPath = node_path_1.default.join(runDir, 'report.xml');
1805
- try {
1806
- await node_fs_1.default.promises.writeFile(reportPath, runDetails.report, 'utf-8');
1807
- if (!this.options.quiet) {
1808
- logger_1.default.info(` Saved report.xml`);
1809
- }
1810
- }
1811
- catch (error) {
1812
- logger_1.default.error(` Failed to save report.xml: ${error instanceof Error ? error.message : error}`);
2050
+ }
2051
+ if (details.report) {
2052
+ const reportName = runReportName(run.id);
2053
+ const reportPath = node_path_1.default.join(tempDir, reportName);
2054
+ try {
2055
+ await node_fs_1.default.promises.writeFile(reportPath, details.report, 'utf-8');
2056
+ if (!this.options.quiet) {
2057
+ logger_1.default.info(` Saved ${reportName}`);
1813
2058
  }
1814
2059
  }
1815
- if (!this.options.quiet) {
1816
- logger_1.default.info(` Artifacts for run ${run.id} downloaded`);
2060
+ catch (error) {
2061
+ logger_1.default.error(` Failed to save ${reportName}: ${error instanceof Error ? error.message : error}`);
1817
2062
  }
1818
2063
  }
1819
- catch (error) {
1820
- logger_1.default.error(`Failed to download artifacts for run ${run.id}: ${error instanceof Error ? error.message : error}`);
2064
+ if (!this.options.quiet) {
2065
+ logger_1.default.info(` Artifacts for run ${run.id} downloaded`);
1821
2066
  }
1822
2067
  }
1823
2068
  const zipFileName = await this.generateArtifactZipName(outputDir);
@@ -1844,6 +2089,7 @@ class Maestro extends base_provider_1.default {
1844
2089
  const output = node_fs_1.default.createWriteStream(zipPath);
1845
2090
  const archive = (0, archiver_1.default)('zip', { zlib: { level: 9 } });
1846
2091
  output.on('close', () => resolve());
2092
+ output.on('error', (err) => reject(err));
1847
2093
  archive.on('error', (err) => reject(err));
1848
2094
  archive.pipe(output);
1849
2095
  archive.directory(sourceDir, false);
@@ -1858,9 +2104,9 @@ class Maestro extends base_provider_1.default {
1858
2104
  this.socket = (0, socket_io_client_1.io)(this.updateServer, {
1859
2105
  transports: ['websocket'],
1860
2106
  reconnection: true,
1861
- reconnectionAttempts: 3,
1862
- reconnectionDelay: 1000,
1863
- timeout: 10000,
2107
+ reconnectionAttempts: constants_1.SOCKET.RECONNECTION_ATTEMPTS,
2108
+ reconnectionDelay: constants_1.SOCKET.RECONNECTION_DELAY_MS,
2109
+ timeout: constants_1.SOCKET.TIMEOUT_MS,
1864
2110
  });
1865
2111
  this.socket.on('connect', () => {
1866
2112
  // Join the room for this test run
@@ -1872,8 +2118,12 @@ class Maestro extends base_provider_1.default {
1872
2118
  this.socket.on('maestro_error', (data) => {
1873
2119
  this.handleMaestroError(data);
1874
2120
  });
1875
- this.socket.on('connect_error', () => {
1876
- // Silently fail - real-time updates are optional
2121
+ this.socket.on('connect_error', (err) => {
2122
+ if (!this.socketFallbackWarned) {
2123
+ this.socketFallbackWarned = true;
2124
+ logger_1.default.warn('Real-time log stream unavailable, falling back to polling.');
2125
+ logger_1.default.debug(`Socket connect_error: ${err?.message ?? 'unknown error'}`);
2126
+ }
1877
2127
  this.disconnectFromUpdateServer();
1878
2128
  });
1879
2129
  }
@@ -1892,8 +2142,8 @@ class Maestro extends base_provider_1.default {
1892
2142
  try {
1893
2143
  const message = JSON.parse(data);
1894
2144
  if (message.payload) {
1895
- // Clear the status line before printing output
1896
- this.clearLine();
2145
+ // Clear the spinner line before printing output
2146
+ this.spinner.clearLine();
1897
2147
  // Print the Maestro output, trimming trailing newlines
1898
2148
  process.stdout.write(message.payload);
1899
2149
  }
@@ -1906,8 +2156,8 @@ class Maestro extends base_provider_1.default {
1906
2156
  try {
1907
2157
  const message = JSON.parse(data);
1908
2158
  if (message.payload) {
1909
- // Clear the status line before printing error
1910
- this.clearLine();
2159
+ // Clear the spinner line before printing error
2160
+ this.spinner.clearLine();
1911
2161
  // Print the error output
1912
2162
  process.stderr.write(message.payload);
1913
2163
  }