@testingbot/cli 1.0.6 → 1.0.8

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 +25 -0
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +79 -50
  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 +11 -4
  9. package/dist/models/espresso_options.d.ts.map +1 -1
  10. package/dist/models/espresso_options.js +24 -7
  11. package/dist/models/maestro_options.d.ts +13 -3
  12. package/dist/models/maestro_options.d.ts.map +1 -1
  13. package/dist/models/maestro_options.js +25 -2
  14. package/dist/models/xcuitest_options.d.ts +11 -4
  15. package/dist/models/xcuitest_options.d.ts.map +1 -1
  16. package/dist/models/xcuitest_options.js +24 -7
  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 +21 -0
  24. package/dist/providers/maestro.d.ts.map +1 -1
  25. package/dist/providers/maestro.js +320 -72
  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
  }
@@ -419,6 +452,18 @@ class Maestro extends base_provider_1.default {
419
452
  }
420
453
  }
421
454
  }
455
+ // Apply --include-tags / --exclude-tags filtering: drop flow files whose
456
+ // frontmatter tags don't match, and drop dependencies orphaned as a result.
457
+ const configTags = baseDir
458
+ ? await this.loadConfigTags(baseDir)
459
+ : { includeTags: undefined, excludeTags: undefined };
460
+ const effectiveIncludeTags = this.options.includeTags ?? configTags.includeTags;
461
+ const effectiveExcludeTags = this.options.excludeTags ?? configTags.excludeTags;
462
+ const filtered = await this.filterFlowsByTags(allFlowFiles, baseDir, effectiveIncludeTags, effectiveExcludeTags);
463
+ if (filtered !== allFlowFiles) {
464
+ allFlowFiles.length = 0;
465
+ allFlowFiles.push(...filtered);
466
+ }
422
467
  if (!this.options.quiet) {
423
468
  this.logIncludedFiles(allFlowFiles, baseDir);
424
469
  // Show info about potential slow execution on specific real devices
@@ -455,7 +500,7 @@ class Maestro extends base_provider_1.default {
455
500
  return true;
456
501
  }
457
502
  const { allFlowFiles, baseDir } = result;
458
- const zipPath = await this.createFlowsZip(allFlowFiles, baseDir);
503
+ const { zipPath, tmpDir } = await this.createFlowsZip(allFlowFiles, baseDir);
459
504
  try {
460
505
  await this.upload.upload({
461
506
  filePath: zipPath,
@@ -466,7 +511,11 @@ class Maestro extends base_provider_1.default {
466
511
  });
467
512
  }
468
513
  finally {
469
- 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
+ });
470
519
  }
471
520
  return true;
472
521
  }
@@ -502,9 +551,14 @@ class Maestro extends base_provider_1.default {
502
551
  const flowFiles = [];
503
552
  let configPath = null;
504
553
  let config = null;
505
- // Check for config.yaml or config.yml
506
- for (const configName of ['config.yaml', 'config.yml']) {
507
- 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) {
508
562
  try {
509
563
  const configContent = await node_fs_1.default.promises.readFile(candidatePath, 'utf-8');
510
564
  config = yaml.load(configContent);
@@ -578,15 +632,118 @@ class Maestro extends base_provider_1.default {
578
632
  */
579
633
  isConfigFile(filePath) {
580
634
  const basename = node_path_1.default.basename(filePath);
581
- 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;
642
+ }
643
+ async readFlowTags(flowFile) {
644
+ try {
645
+ const content = await node_fs_1.default.promises.readFile(flowFile, 'utf-8');
646
+ const documents = [];
647
+ yaml.loadAll(content, (doc) => documents.push(doc));
648
+ for (const doc of documents) {
649
+ if (doc !== null && typeof doc === 'object' && !Array.isArray(doc)) {
650
+ const tags = doc.tags;
651
+ if (Array.isArray(tags)) {
652
+ return tags.filter((t) => typeof t === 'string');
653
+ }
654
+ return [];
655
+ }
656
+ }
657
+ }
658
+ catch {
659
+ // ignore
660
+ }
661
+ return [];
662
+ }
663
+ /**
664
+ * Load includeTags / excludeTags declared in the Maestro project's
665
+ * config.yaml (or config.yml). Returns undefined fields when no config
666
+ * exists or the values are not arrays.
667
+ */
668
+ async loadConfigTags(baseDir) {
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) {
673
+ try {
674
+ const content = await node_fs_1.default.promises.readFile(candidate, 'utf-8');
675
+ const parsed = yaml.load(content);
676
+ if (parsed && typeof parsed === 'object') {
677
+ const include = Array.isArray(parsed.includeTags)
678
+ ? parsed.includeTags.filter((t) => typeof t === 'string')
679
+ : undefined;
680
+ const exclude = Array.isArray(parsed.excludeTags)
681
+ ? parsed.excludeTags.filter((t) => typeof t === 'string')
682
+ : undefined;
683
+ return { includeTags: include, excludeTags: exclude };
684
+ }
685
+ return {};
686
+ }
687
+ catch {
688
+ // try next candidate
689
+ }
690
+ }
691
+ return {};
692
+ }
693
+ async filterFlowsByTags(allFlowFiles, baseDir, includeTagsArg, excludeTagsArg) {
694
+ const includeTags = includeTagsArg ?? [];
695
+ const excludeTags = excludeTagsArg ?? [];
696
+ const hasInclude = includeTags.length > 0;
697
+ const hasExclude = excludeTags.length > 0;
698
+ if (!hasInclude && !hasExclude) {
699
+ return allFlowFiles;
700
+ }
701
+ const yamlFlows = [];
702
+ const configFiles = [];
703
+ for (const f of allFlowFiles) {
704
+ const ext = node_path_1.default.extname(f).toLowerCase();
705
+ if (ext === '.yaml' || ext === '.yml') {
706
+ if (this.isConfigFile(f)) {
707
+ configFiles.push(f);
708
+ }
709
+ else {
710
+ yamlFlows.push(f);
711
+ }
712
+ }
713
+ }
714
+ const keptYamlFlows = [];
715
+ for (const flowFile of yamlFlows) {
716
+ const tags = await this.readFlowTags(flowFile);
717
+ if (hasInclude && !tags.some((t) => includeTags.includes(t))) {
718
+ continue;
719
+ }
720
+ if (hasExclude && tags.some((t) => excludeTags.includes(t))) {
721
+ continue;
722
+ }
723
+ keptYamlFlows.push(flowFile);
724
+ }
725
+ if (keptYamlFlows.length === 0) {
726
+ throw new testingbot_error_1.default(`No flow files match the provided tag filters (--include-tags / --exclude-tags)`);
727
+ }
728
+ const keptResolved = new Set([...keptYamlFlows, ...configFiles].map((f) => node_path_1.default.resolve(f)));
729
+ for (const flowFile of keptYamlFlows) {
730
+ const deps = await this.discoverDependencies(flowFile, baseDir || node_path_1.default.dirname(flowFile));
731
+ for (const dep of deps) {
732
+ keptResolved.add(node_path_1.default.resolve(dep));
733
+ }
734
+ }
735
+ return allFlowFiles.filter((f) => keptResolved.has(node_path_1.default.resolve(f)));
582
736
  }
583
737
  /**
584
738
  * Check if a string looks like a file path (relative path with extension)
585
739
  */
586
740
  looksLikePath(value) {
587
- // Must be a relative path (starts with . or contains /)
588
- const isRelative = value.startsWith('./') || value.startsWith('../');
589
- 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('\\');
590
747
  // Must have a file extension
591
748
  const hasExtension = /\.[a-zA-Z0-9]+$/.test(value);
592
749
  // Exclude URLs
@@ -981,7 +1138,8 @@ class Maestro extends base_provider_1.default {
981
1138
  return new Promise((resolve, reject) => {
982
1139
  const output = node_fs_1.default.createWriteStream(zipPath);
983
1140
  const archive = (0, archiver_1.default)('zip', { zlib: { level: 9 } });
984
- output.on('close', () => resolve(zipPath));
1141
+ output.on('close', () => resolve({ zipPath, tmpDir }));
1142
+ output.on('error', (err) => reject(err));
985
1143
  archive.on('error', (err) => reject(err));
986
1144
  archive.pipe(output);
987
1145
  // Compute effective base directory for archive paths
@@ -1037,7 +1195,7 @@ class Maestro extends base_provider_1.default {
1037
1195
  username: this.credentials.userName,
1038
1196
  password: this.credentials.accessKey,
1039
1197
  },
1040
- timeout: 30000, // 30 second timeout
1198
+ timeout: constants_1.HTTP.TIMEOUT_MS,
1041
1199
  });
1042
1200
  // Check for version update notification
1043
1201
  const latestVersion = response.headers?.['x-testingbotctl-version'];
@@ -1075,7 +1233,7 @@ class Maestro extends base_provider_1.default {
1075
1233
  username: this.credentials.userName,
1076
1234
  password: this.credentials.accessKey,
1077
1235
  },
1078
- timeout: 30000, // 30 second timeout
1236
+ timeout: constants_1.HTTP.TIMEOUT_MS,
1079
1237
  });
1080
1238
  // Check for version update notification
1081
1239
  const latestVersion = response.headers?.['x-testingbotctl-version'];
@@ -1091,14 +1249,15 @@ class Maestro extends base_provider_1.default {
1091
1249
  }
1092
1250
  }
1093
1251
  async waitForCompletion() {
1094
- let attempts = 0;
1095
1252
  const startTime = Date.now();
1096
1253
  const previousStatus = new Map();
1097
1254
  const previousFlowStatus = new Map();
1098
1255
  const urlDisplayed = new Set();
1099
1256
  let flowsTableDisplayed = false;
1100
1257
  let displayedLineCount = 0;
1101
- while (attempts < this.MAX_POLL_ATTEMPTS) {
1258
+ let pollInterval = this.MIN_POLL_INTERVAL_MS;
1259
+ let previousSignature = null;
1260
+ while (true) {
1102
1261
  // Check if we're shutting down
1103
1262
  if (this.isShuttingDown) {
1104
1263
  throw new testingbot_error_1.default('Test run cancelled by user');
@@ -1108,6 +1267,11 @@ class Maestro extends base_provider_1.default {
1108
1267
  this.activeRunIds = status.runs
1109
1268
  .filter((run) => run.status !== 'DONE' && run.status !== 'FAILED')
1110
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
+ }
1111
1275
  // Log current status of runs (unless quiet mode)
1112
1276
  if (!this.options.quiet) {
1113
1277
  // Check if any run has flows and display them
@@ -1129,15 +1293,23 @@ class Maestro extends base_provider_1.default {
1129
1293
  // Check if any flow has failed (for showing error column)
1130
1294
  const hasFailures = this.hasAnyFlowFailed(allFlows);
1131
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();
1132
1299
  // First time showing flows - display header and initial state
1133
1300
  console.log(); // Empty line before flows table
1134
1301
  this.displayFlowsTableHeader(hasFailures);
1135
1302
  displayedLineCount = this.displayFlowsWithLimit(allFlows, previousFlowStatus, hasFailures);
1136
1303
  flowsTableDisplayed = true;
1304
+ this.latestFlows = allFlows;
1305
+ this.latestDisplayedLineCount = displayedLineCount;
1306
+ this.startFlowAnimation(previousFlowStatus);
1137
1307
  }
1138
1308
  else {
1139
1309
  // Update flows in place
1140
1310
  displayedLineCount = this.updateFlowsInPlace(allFlows, previousFlowStatus, displayedLineCount);
1311
+ this.latestFlows = allFlows;
1312
+ this.latestDisplayedLineCount = displayedLineCount;
1141
1313
  }
1142
1314
  }
1143
1315
  else {
@@ -1146,6 +1318,7 @@ class Maestro extends base_provider_1.default {
1146
1318
  }
1147
1319
  }
1148
1320
  if (status.completed) {
1321
+ this.stopFlowAnimation();
1149
1322
  // Display final flows table with error messages if there are failures
1150
1323
  if (!this.options.quiet && flowsTableDisplayed) {
1151
1324
  const allFlows = [];
@@ -1162,9 +1335,9 @@ class Maestro extends base_provider_1.default {
1162
1335
  process.stdout.write(`\x1b[${linesToMove}A`);
1163
1336
  // Clear header line, write new header, then clear separator line
1164
1337
  process.stdout.write('\x1b[2K');
1165
- 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`));
1166
1339
  process.stdout.write('\x1b[2K');
1167
- 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)}`));
1168
1341
  // Redraw all flows with error messages
1169
1342
  for (const flow of allFlows) {
1170
1343
  // Clear the line before writing
@@ -1175,21 +1348,27 @@ class Maestro extends base_provider_1.default {
1175
1348
  }
1176
1349
  // Print final summary
1177
1350
  if (!this.options.quiet) {
1351
+ this.spinner.stop();
1178
1352
  console.log(); // Empty line before summary
1179
1353
  for (const run of status.runs) {
1180
- const statusEmoji = run.success === 1 ? '✅' : '❌';
1181
- const statusText = run.success === 1 ? 'Test completed successfully' : 'Test failed';
1182
- 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}`);
1183
1360
  }
1184
1361
  }
1185
1362
  const allSucceeded = status.runs.every((run) => run.success === 1);
1186
1363
  if (allSucceeded) {
1364
+ (0, terminal_title_1.setTitle)('maestro · ✔ passed');
1187
1365
  if (!this.options.quiet) {
1188
1366
  logger_1.default.info('All tests completed successfully!');
1189
1367
  }
1190
1368
  }
1191
1369
  else {
1192
1370
  const failedRuns = status.runs.filter((run) => run.success !== 1);
1371
+ (0, terminal_title_1.setTitle)(`maestro · ✘ ${failedRuns.length} failed`);
1193
1372
  logger_1.default.error(`${failedRuns.length} test run(s) failed`);
1194
1373
  }
1195
1374
  if (this.options.report && this.options.reportOutputDir) {
@@ -1203,35 +1382,50 @@ class Maestro extends base_provider_1.default {
1203
1382
  runs: status.runs,
1204
1383
  };
1205
1384
  }
1206
- attempts++;
1207
- 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);
1208
1401
  }
1209
- throw new testingbot_error_1.default(`Test timed out after ${(this.MAX_POLL_ATTEMPTS * this.POLL_INTERVAL_MS) / 1000 / 60} minutes`);
1210
1402
  }
1211
1403
  displayRunStatus(runs, startTime, previousStatus) {
1212
1404
  const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000);
1213
1405
  const elapsedStr = this.formatElapsedTime(elapsedSeconds);
1406
+ const activeMessages = [];
1214
1407
  for (const run of runs) {
1215
1408
  const prevStatus = previousStatus.get(run.id);
1216
1409
  const statusChanged = prevStatus !== run.status;
1217
- // If status changed from WAITING/READY to something else, clear the updating line
1218
- if (statusChanged &&
1219
- prevStatus &&
1220
- (prevStatus === 'WAITING' || prevStatus === 'READY')) {
1221
- this.clearLine();
1222
- }
1223
1410
  previousStatus.set(run.id, run.status);
1224
1411
  const statusInfo = this.getStatusInfo(run.status);
1225
1412
  if (run.status === 'WAITING' || run.status === 'READY') {
1226
- // Update the same line for WAITING and READY states
1227
- const message = ` ${statusInfo.emoji} Run ${run.id} (${this.getRunDisplayName(run)}): ${statusInfo.text} (${elapsedStr})`;
1228
- 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}`)}`);
1229
1417
  }
1230
1418
  else if (statusChanged) {
1231
- // For other states (DONE, FAILED), print on a new line only when status changes
1232
- 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}`);
1233
1421
  }
1234
1422
  }
1423
+ if (activeMessages.length > 0) {
1424
+ this.spinner.setMessage(activeMessages.join(picocolors_1.default.dim(' ┊ ')));
1425
+ }
1426
+ else {
1427
+ this.spinner.stop();
1428
+ }
1235
1429
  }
1236
1430
  /**
1237
1431
  * Get the display name for a run, preferring environment.name over capabilities.deviceName
@@ -1243,32 +1437,39 @@ class Maestro extends base_provider_1.default {
1243
1437
  getStatusInfo(status) {
1244
1438
  switch (status) {
1245
1439
  case 'WAITING':
1246
- return { emoji: '', text: 'Waiting for test to start' };
1440
+ return { symbol: picocolors_1.default.yellow(''), text: 'Waiting for test to start' };
1247
1441
  case 'READY':
1248
- return { emoji: '🔄', text: 'Running test' };
1442
+ return { symbol: picocolors_1.default.cyan(''), text: 'Running test' };
1249
1443
  case 'DONE':
1250
- return { emoji: '', text: 'Test has finished running' };
1444
+ return { symbol: picocolors_1.default.green(''), text: 'Test has finished running' };
1251
1445
  case 'FAILED':
1252
- return { emoji: '', text: 'Test failed' };
1446
+ return { symbol: picocolors_1.default.red(''), text: 'Test failed' };
1253
1447
  default:
1254
- return { emoji: '', text: status };
1448
+ return { symbol: picocolors_1.default.dim('?'), text: status };
1255
1449
  }
1256
1450
  }
1257
1451
  getFlowStatusDisplay(flow) {
1452
+ const frame = FLOW_SPINNER_FRAMES[this.flowAnimationFrame];
1258
1453
  switch (flow.status) {
1259
1454
  case 'WAITING':
1260
- return { text: 'WAITING', colored: picocolors_1.default.white('WAITING') };
1455
+ return {
1456
+ text: `${frame} WAITING`,
1457
+ colored: picocolors_1.default.yellow(`${frame} WAITING`),
1458
+ };
1261
1459
  case 'READY':
1262
- return { text: 'RUNNING', colored: picocolors_1.default.blue('RUNNING') };
1460
+ return {
1461
+ text: `${frame} RUNNING`,
1462
+ colored: picocolors_1.default.cyan(`${frame} RUNNING`),
1463
+ };
1263
1464
  case 'DONE':
1264
1465
  if (flow.success === 1) {
1265
- return { text: 'PASSED', colored: picocolors_1.default.green('PASSED') };
1466
+ return { text: 'PASSED', colored: picocolors_1.default.green('PASSED') };
1266
1467
  }
1267
1468
  else {
1268
- return { text: 'FAILED', colored: picocolors_1.default.red('FAILED') };
1469
+ return { text: 'FAILED', colored: picocolors_1.default.red('FAILED') };
1269
1470
  }
1270
1471
  case 'FAILED':
1271
- return { text: 'FAILED', colored: picocolors_1.default.red('FAILED') };
1472
+ return { text: 'FAILED', colored: picocolors_1.default.red('FAILED') };
1272
1473
  default:
1273
1474
  return { text: flow.status, colored: flow.status };
1274
1475
  }
@@ -1362,8 +1563,8 @@ class Maestro extends base_provider_1.default {
1362
1563
  return linesWritten;
1363
1564
  }
1364
1565
  displayFlowsTableHeader(hasFailures = false) {
1365
- let header = ` ${'Duration'.padEnd(10)} ${'Status'.padEnd(8)} Flow`;
1366
- let separator = ` ${'─'.repeat(10)} ${'─'.repeat(8)} ${'─'.repeat(30)}`;
1566
+ let header = ` ${'Duration'.padEnd(10)} ${'Status'.padEnd(10)} Flow`;
1567
+ let separator = ` ${'─'.repeat(10)} ${'─'.repeat(10)} ${'─'.repeat(30)}`;
1367
1568
  if (hasFailures) {
1368
1569
  header += ' Fail reason';
1369
1570
  separator += ` ${'─'.repeat(80)}`;
@@ -1376,7 +1577,7 @@ class Maestro extends base_provider_1.default {
1376
1577
  const statusDisplay = this.getFlowStatusDisplay(flow);
1377
1578
  // Pad based on display text length, add extra for color codes
1378
1579
  const statusPadded = statusDisplay.colored +
1379
- ' '.repeat(Math.max(0, 8 - statusDisplay.text.length));
1580
+ ' '.repeat(Math.max(0, 10 - statusDisplay.text.length));
1380
1581
  const name = flow.name.padEnd(30);
1381
1582
  let linesWritten = 0;
1382
1583
  const isFailed = flow.status === 'DONE' && flow.success !== 1;
@@ -1396,8 +1597,8 @@ class Maestro extends base_provider_1.default {
1396
1597
  linesWritten++;
1397
1598
  // Display remaining error messages on continuation lines
1398
1599
  if (!isUpdate && hasFailures && isFailed && errorMessages.length > 1) {
1399
- // Indent to align with the Fail reason column: Duration(11) + Status(9) + Test(31) = 51 chars
1400
- const indent = ' '.repeat(51);
1600
+ // Indent to align with the Fail reason column: Duration(11) + Status(11) + Test(31) = 53 chars
1601
+ const indent = ' '.repeat(53);
1401
1602
  for (let i = 1; i < errorMessages.length; i++) {
1402
1603
  console.log(`${indent} ${picocolors_1.default.red(errorMessages[i])}`);
1403
1604
  linesWritten++;
@@ -1420,6 +1621,33 @@ class Maestro extends base_provider_1.default {
1420
1621
  }
1421
1622
  return linesWritten;
1422
1623
  }
1624
+ /**
1625
+ * Starts the flow-table animation loop. Re-renders the cached flow rows at
1626
+ * `FLOW_ANIMATION_MS` so WAITING/RUNNING spinner frames advance between
1627
+ * (much slower) polls. Calling while already running is a no-op.
1628
+ */
1629
+ startFlowAnimation(previousFlowStatus) {
1630
+ if (this.flowAnimationTimer || !utils_1.default.isInteractive())
1631
+ return;
1632
+ this.flowAnimationTimer = setInterval(() => {
1633
+ const hasActive = this.latestFlows.some((f) => f.status === 'WAITING' || f.status === 'READY');
1634
+ if (!hasActive)
1635
+ return;
1636
+ this.flowAnimationFrame =
1637
+ (this.flowAnimationFrame + 1) % FLOW_SPINNER_FRAMES.length;
1638
+ this.latestDisplayedLineCount = this.updateFlowsInPlace(this.latestFlows, previousFlowStatus, this.latestDisplayedLineCount);
1639
+ }, FLOW_ANIMATION_MS);
1640
+ this.flowAnimationTimer.unref?.();
1641
+ }
1642
+ stopFlowAnimation() {
1643
+ if (this.flowAnimationTimer) {
1644
+ clearInterval(this.flowAnimationTimer);
1645
+ this.flowAnimationTimer = null;
1646
+ }
1647
+ }
1648
+ stopAnimations() {
1649
+ this.stopFlowAnimation();
1650
+ }
1423
1651
  updateFlowsInPlace(flows, previousFlowStatus, displayedLineCount) {
1424
1652
  const maxFlows = this.getMaxDisplayableFlows();
1425
1653
  const displayFlows = flows.slice(0, maxFlows);
@@ -1434,7 +1662,7 @@ class Maestro extends base_provider_1.default {
1434
1662
  const duration = this.calculateFlowDuration(flow).padEnd(10);
1435
1663
  const statusDisplay = this.getFlowStatusDisplay(flow);
1436
1664
  const statusPadded = statusDisplay.colored +
1437
- ' '.repeat(Math.max(0, 8 - statusDisplay.text.length));
1665
+ ' '.repeat(Math.max(0, 10 - statusDisplay.text.length));
1438
1666
  const name = flow.name;
1439
1667
  const row = ` ${duration} ${statusPadded} ${name}`;
1440
1668
  process.stdout.write(`\r\x1b[K${row}\n`);
@@ -1461,7 +1689,23 @@ class Maestro extends base_provider_1.default {
1461
1689
  }
1462
1690
  for (const run of runs) {
1463
1691
  try {
1464
- const reportEndpoint = reportFormat === 'junit' ? 'junit_report' : 'html_report';
1692
+ let reportEndpoint;
1693
+ let reportKey;
1694
+ switch (reportFormat) {
1695
+ case 'junit':
1696
+ reportEndpoint = 'junit_report';
1697
+ reportKey = 'junit_report';
1698
+ break;
1699
+ case 'html-detailed':
1700
+ reportEndpoint = 'html_report_detailed';
1701
+ reportKey = 'html_report_detailed';
1702
+ break;
1703
+ case 'html':
1704
+ default:
1705
+ reportEndpoint = 'html_report';
1706
+ reportKey = 'html_report';
1707
+ break;
1708
+ }
1465
1709
  const response = await axios_1.default.get(`${this.URL}/${this.appId}/${run.id}/${reportEndpoint}`, {
1466
1710
  headers: {
1467
1711
  'User-Agent': utils_1.default.getUserAgent(),
@@ -1470,13 +1714,12 @@ class Maestro extends base_provider_1.default {
1470
1714
  username: this.credentials.userName,
1471
1715
  password: this.credentials.accessKey,
1472
1716
  },
1473
- timeout: 30000, // 30 second timeout
1717
+ timeout: constants_1.HTTP.TIMEOUT_MS,
1474
1718
  });
1475
1719
  // Check for version update notification
1476
1720
  const latestVersion = response.headers?.['x-testingbotctl-version'];
1477
1721
  utils_1.default.checkForUpdate(latestVersion);
1478
1722
  // Extract the report content from the JSON response
1479
- const reportKey = reportFormat === 'junit' ? 'junit_report' : 'html_report';
1480
1723
  const reportContent = response.data[reportKey];
1481
1724
  if (!reportContent) {
1482
1725
  logger_1.default.error(`No ${reportFormat} report found for run ${run.id}`);
@@ -1506,7 +1749,7 @@ class Maestro extends base_provider_1.default {
1506
1749
  username: this.credentials.userName,
1507
1750
  password: this.credentials.accessKey,
1508
1751
  },
1509
- timeout: 30000, // 30 second timeout
1752
+ timeout: constants_1.HTTP.TIMEOUT_MS,
1510
1753
  });
1511
1754
  const latestVersion = response.headers?.['x-testingbotctl-version'];
1512
1755
  utils_1.default.checkForUpdate(latestVersion);
@@ -1526,7 +1769,7 @@ class Maestro extends base_provider_1.default {
1526
1769
  return details;
1527
1770
  }
1528
1771
  attempts++;
1529
- await this.sleep(this.POLL_INTERVAL_MS);
1772
+ await this.sleep(this.MIN_POLL_INTERVAL_MS);
1530
1773
  }
1531
1774
  throw new testingbot_error_1.default(`Timed out waiting for artifacts to sync for run ${runId}`);
1532
1775
  }
@@ -1740,6 +1983,7 @@ class Maestro extends base_provider_1.default {
1740
1983
  const output = node_fs_1.default.createWriteStream(zipPath);
1741
1984
  const archive = (0, archiver_1.default)('zip', { zlib: { level: 9 } });
1742
1985
  output.on('close', () => resolve());
1986
+ output.on('error', (err) => reject(err));
1743
1987
  archive.on('error', (err) => reject(err));
1744
1988
  archive.pipe(output);
1745
1989
  archive.directory(sourceDir, false);
@@ -1754,9 +1998,9 @@ class Maestro extends base_provider_1.default {
1754
1998
  this.socket = (0, socket_io_client_1.io)(this.updateServer, {
1755
1999
  transports: ['websocket'],
1756
2000
  reconnection: true,
1757
- reconnectionAttempts: 3,
1758
- reconnectionDelay: 1000,
1759
- timeout: 10000,
2001
+ reconnectionAttempts: constants_1.SOCKET.RECONNECTION_ATTEMPTS,
2002
+ reconnectionDelay: constants_1.SOCKET.RECONNECTION_DELAY_MS,
2003
+ timeout: constants_1.SOCKET.TIMEOUT_MS,
1760
2004
  });
1761
2005
  this.socket.on('connect', () => {
1762
2006
  // Join the room for this test run
@@ -1768,8 +2012,12 @@ class Maestro extends base_provider_1.default {
1768
2012
  this.socket.on('maestro_error', (data) => {
1769
2013
  this.handleMaestroError(data);
1770
2014
  });
1771
- this.socket.on('connect_error', () => {
1772
- // Silently fail - real-time updates are optional
2015
+ this.socket.on('connect_error', (err) => {
2016
+ if (!this.socketFallbackWarned) {
2017
+ this.socketFallbackWarned = true;
2018
+ logger_1.default.warn('Real-time log stream unavailable, falling back to polling.');
2019
+ logger_1.default.debug(`Socket connect_error: ${err?.message ?? 'unknown error'}`);
2020
+ }
1773
2021
  this.disconnectFromUpdateServer();
1774
2022
  });
1775
2023
  }
@@ -1788,8 +2036,8 @@ class Maestro extends base_provider_1.default {
1788
2036
  try {
1789
2037
  const message = JSON.parse(data);
1790
2038
  if (message.payload) {
1791
- // Clear the status line before printing output
1792
- this.clearLine();
2039
+ // Clear the spinner line before printing output
2040
+ this.spinner.clearLine();
1793
2041
  // Print the Maestro output, trimming trailing newlines
1794
2042
  process.stdout.write(message.payload);
1795
2043
  }
@@ -1802,8 +2050,8 @@ class Maestro extends base_provider_1.default {
1802
2050
  try {
1803
2051
  const message = JSON.parse(data);
1804
2052
  if (message.payload) {
1805
- // Clear the status line before printing error
1806
- this.clearLine();
2053
+ // Clear the spinner line before printing error
2054
+ this.spinner.clearLine();
1807
2055
  // Print the error output
1808
2056
  process.stderr.write(message.payload);
1809
2057
  }