@testingbot/cli 1.0.1 → 1.0.3

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 (46) hide show
  1. package/README.md +84 -7
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +55 -8
  4. package/dist/logger.js +4 -4
  5. package/dist/models/espresso_options.d.ts +9 -0
  6. package/dist/models/espresso_options.d.ts.map +1 -1
  7. package/dist/models/espresso_options.js +14 -0
  8. package/dist/models/maestro_options.d.ts +20 -7
  9. package/dist/models/maestro_options.d.ts.map +1 -1
  10. package/dist/models/maestro_options.js +22 -9
  11. package/dist/models/testingbot_error.d.ts +3 -0
  12. package/dist/models/testingbot_error.d.ts.map +1 -1
  13. package/dist/models/testingbot_error.js +5 -0
  14. package/dist/models/xcuitest_options.d.ts +9 -0
  15. package/dist/models/xcuitest_options.d.ts.map +1 -1
  16. package/dist/models/xcuitest_options.js +14 -0
  17. package/dist/providers/base_provider.d.ts +119 -0
  18. package/dist/providers/base_provider.d.ts.map +1 -0
  19. package/dist/providers/base_provider.js +296 -0
  20. package/dist/providers/espresso.d.ts +14 -21
  21. package/dist/providers/espresso.d.ts.map +1 -1
  22. package/dist/providers/espresso.js +50 -181
  23. package/dist/providers/login.d.ts +1 -0
  24. package/dist/providers/login.d.ts.map +1 -1
  25. package/dist/providers/login.js +16 -7
  26. package/dist/providers/maestro.d.ts +73 -21
  27. package/dist/providers/maestro.d.ts.map +1 -1
  28. package/dist/providers/maestro.js +842 -276
  29. package/dist/providers/xcuitest.d.ts +14 -21
  30. package/dist/providers/xcuitest.d.ts.map +1 -1
  31. package/dist/providers/xcuitest.js +50 -181
  32. package/dist/upload.d.ts +10 -0
  33. package/dist/upload.d.ts.map +1 -1
  34. package/dist/upload.js +46 -21
  35. package/dist/utils/connectivity.d.ts +26 -0
  36. package/dist/utils/connectivity.d.ts.map +1 -0
  37. package/dist/utils/connectivity.js +131 -0
  38. package/dist/utils/error-helpers.d.ts +26 -0
  39. package/dist/utils/error-helpers.d.ts.map +1 -0
  40. package/dist/utils/error-helpers.js +237 -0
  41. package/dist/utils/file-type-detector.d.ts +4 -1
  42. package/dist/utils/file-type-detector.d.ts.map +1 -1
  43. package/dist/utils/file-type-detector.js +30 -6
  44. package/dist/utils.d.ts.map +1 -1
  45. package/dist/utils.js +8 -3
  46. package/package.json +2 -2
@@ -47,95 +47,67 @@ const archiver_1 = __importDefault(require("archiver"));
47
47
  const socket_io_client_1 = require("socket.io-client");
48
48
  const testingbot_error_1 = __importDefault(require("../models/testingbot_error"));
49
49
  const utils_1 = __importDefault(require("../utils"));
50
- const upload_1 = __importDefault(require("../upload"));
51
50
  const file_type_detector_1 = require("../utils/file-type-detector");
52
- const platform_1 = __importDefault(require("../utils/platform"));
53
- class Maestro {
51
+ const picocolors_1 = __importDefault(require("picocolors"));
52
+ const base_provider_1 = __importDefault(require("./base_provider"));
53
+ class Maestro extends base_provider_1.default {
54
54
  URL = 'https://api.testingbot.com/v1/app-automate/maestro';
55
- POLL_INTERVAL_MS = 5000;
56
- MAX_POLL_ATTEMPTS = 720; // 1 hour max with 5s interval
57
- credentials;
58
- options;
59
- upload;
60
- appId = undefined;
61
55
  detectedPlatform = undefined;
62
- activeRunIds = [];
63
- isShuttingDown = false;
64
- signalHandler = null;
65
56
  socket = null;
66
57
  updateServer = null;
67
58
  updateKey = null;
68
59
  constructor(credentials, options) {
69
- this.credentials = credentials;
70
- this.options = options;
71
- this.upload = new upload_1.default();
60
+ super(credentials, options);
72
61
  }
62
+ static SUPPORTED_APP_EXTENSIONS = [
63
+ '.apk',
64
+ '.apks',
65
+ '.ipa',
66
+ '.app',
67
+ '.zip',
68
+ ];
73
69
  async validate() {
74
70
  if (this.options.app === undefined) {
75
71
  throw new testingbot_error_1.default(`app option is required`);
76
72
  }
77
- try {
78
- await node_fs_1.default.promises.access(this.options.app, node_fs_1.default.constants.R_OK);
79
- }
80
- catch {
81
- throw new testingbot_error_1.default(`Provided app path does not exist ${this.options.app}`);
73
+ // Validate app file extension
74
+ const appExt = node_path_1.default.extname(this.options.app).toLowerCase();
75
+ if (!Maestro.SUPPORTED_APP_EXTENSIONS.includes(appExt)) {
76
+ throw new testingbot_error_1.default(`Unsupported app file format: ${appExt || '(no extension)'}. ` +
77
+ `Supported formats: ${Maestro.SUPPORTED_APP_EXTENSIONS.join(', ')}`);
82
78
  }
83
79
  if (this.options.flows === undefined || this.options.flows.length === 0) {
84
80
  throw new testingbot_error_1.default(`flows option is required`);
85
81
  }
86
- // Check if all flows paths exist (can be files, directories, or glob patterns)
82
+ if (this.options.report && !this.options.reportOutputDir) {
83
+ throw new testingbot_error_1.default(`--report-output-dir is required when --report is specified`);
84
+ }
85
+ // Build list of all file checks to run in parallel
86
+ const fileChecks = [
87
+ node_fs_1.default.promises.access(this.options.app, node_fs_1.default.constants.R_OK).catch(() => {
88
+ throw new testingbot_error_1.default(`Provided app path does not exist ${this.options.app}`);
89
+ }),
90
+ ];
91
+ // Check if all flows paths exist (can be files, directories or glob patterns)
87
92
  for (const flowsPath of this.options.flows) {
88
93
  const isGlobPattern = flowsPath.includes('*') ||
89
94
  flowsPath.includes('?') ||
90
95
  flowsPath.includes('{');
91
96
  if (!isGlobPattern) {
92
- try {
93
- await node_fs_1.default.promises.access(flowsPath, node_fs_1.default.constants.R_OK);
94
- }
95
- catch {
97
+ fileChecks.push(node_fs_1.default.promises.access(flowsPath, node_fs_1.default.constants.R_OK).catch(() => {
96
98
  throw new testingbot_error_1.default(`flows path does not exist ${flowsPath}`);
97
- }
99
+ }));
98
100
  }
99
101
  }
100
- // Device is optional - will be inferred from app file type if not provided
101
- // Validate report options
102
- if (this.options.report && !this.options.reportOutputDir) {
103
- throw new testingbot_error_1.default(`--report-output-dir is required when --report is specified`);
104
- }
105
102
  if (this.options.reportOutputDir) {
106
- await this.ensureOutputDirectory(this.options.reportOutputDir);
103
+ fileChecks.push(this.ensureOutputDirectory(this.options.reportOutputDir));
107
104
  }
108
- // Validate artifact download options - output dir defaults to current directory
109
105
  if (this.options.downloadArtifacts && this.options.artifactsOutputDir) {
110
- await this.ensureOutputDirectory(this.options.artifactsOutputDir);
106
+ fileChecks.push(this.ensureOutputDirectory(this.options.artifactsOutputDir));
111
107
  }
108
+ await Promise.all(fileChecks);
112
109
  return true;
113
110
  }
114
- async ensureOutputDirectory(dirPath) {
115
- try {
116
- const stat = await node_fs_1.default.promises.stat(dirPath);
117
- if (!stat.isDirectory()) {
118
- throw new testingbot_error_1.default(`Report output path exists but is not a directory: ${dirPath}`);
119
- }
120
- }
121
- catch (error) {
122
- if (error.code === 'ENOENT') {
123
- // Directory doesn't exist, try to create it
124
- try {
125
- await node_fs_1.default.promises.mkdir(dirPath, { recursive: true });
126
- }
127
- catch (mkdirError) {
128
- throw new testingbot_error_1.default(`Failed to create report output directory: ${dirPath}`, { cause: mkdirError });
129
- }
130
- }
131
- else if (error instanceof testingbot_error_1.default) {
132
- throw error;
133
- }
134
- else {
135
- throw new testingbot_error_1.default(`Failed to access report output directory: ${dirPath}`, { cause: error });
136
- }
137
- }
138
- }
139
111
  /**
140
112
  * Detect platform from app file content using magic bytes
141
113
  */
@@ -150,13 +122,12 @@ class Maestro {
150
122
  return { success: false, runs: [] };
151
123
  }
152
124
  try {
125
+ // Quick connectivity check before starting uploads
126
+ await this.ensureConnectivity();
153
127
  // Detect platform from file content if not explicitly provided
154
128
  if (!this.options.platformName) {
155
129
  this.detectedPlatform = await this.detectPlatform();
156
130
  }
157
- if (!this.options.quiet) {
158
- logger_1.default.info('Uploading Maestro App');
159
- }
160
131
  await this.uploadApp();
161
132
  if (!this.options.quiet) {
162
133
  logger_1.default.info('Uploading Maestro Flows');
@@ -169,13 +140,13 @@ class Maestro {
169
140
  if (this.options.async) {
170
141
  if (!this.options.quiet) {
171
142
  logger_1.default.info(`Tests started in async mode. Project ID: ${this.appId}`);
143
+ logger_1.default.info(`View realtime results: https://testingbot.com/members/maestro/${this.appId}`);
172
144
  }
173
145
  return { success: true, runs: [] };
174
146
  }
175
- // Set up signal handlers before waiting for completion
176
147
  this.setupSignalHandlers();
177
148
  // Connect to real-time update server (unless --quiet is specified)
178
- this.connectToUpdateServer();
149
+ // this.connectToUpdateServer();
179
150
  if (!this.options.quiet) {
180
151
  logger_1.default.info('Waiting for test results...');
181
152
  }
@@ -216,6 +187,20 @@ class Maestro {
216
187
  else {
217
188
  contentType = 'application/octet-stream';
218
189
  }
190
+ if (!this.options.ignoreChecksumCheck) {
191
+ const checksum = await this.upload.calculateChecksum(appPath);
192
+ const existingApp = await this.checkAppChecksum(checksum);
193
+ if (existingApp) {
194
+ this.appId = existingApp.id;
195
+ if (!this.options.quiet) {
196
+ logger_1.default.info(' App already uploaded, skipping upload');
197
+ }
198
+ return true;
199
+ }
200
+ }
201
+ if (!this.options.quiet) {
202
+ logger_1.default.info('Uploading Maestro App');
203
+ }
219
204
  const result = await this.upload.upload({
220
205
  filePath: appPath,
221
206
  url: `${this.URL}/app`,
@@ -226,6 +211,31 @@ class Maestro {
226
211
  this.appId = result.id;
227
212
  return true;
228
213
  }
214
+ async checkAppChecksum(checksum) {
215
+ try {
216
+ const response = await axios_1.default.post(`${this.URL}/app/checksum`, { checksum }, {
217
+ headers: {
218
+ 'Content-Type': 'application/json',
219
+ 'User-Agent': utils_1.default.getUserAgent(),
220
+ },
221
+ auth: {
222
+ username: this.credentials.userName,
223
+ password: this.credentials.accessKey,
224
+ },
225
+ });
226
+ const latestVersion = response.headers?.['x-testingbotctl-version'];
227
+ utils_1.default.checkForUpdate(latestVersion);
228
+ const result = response.data;
229
+ if (result.app_exists && result.id) {
230
+ return { id: result.id };
231
+ }
232
+ return null;
233
+ }
234
+ catch {
235
+ // If checksum check fails, proceed with upload
236
+ return null;
237
+ }
238
+ }
229
239
  async uploadFlows() {
230
240
  const flowsPaths = this.options.flows;
231
241
  let zipPath;
@@ -236,7 +246,6 @@ class Maestro {
236
246
  const stat = await node_fs_1.default.promises.stat(singlePath).catch(() => null);
237
247
  if (stat?.isFile() && node_path_1.default.extname(singlePath).toLowerCase() === '.zip') {
238
248
  zipPath = singlePath;
239
- // Upload the zip directly without cleanup
240
249
  await this.upload.upload({
241
250
  filePath: zipPath,
242
251
  url: `${this.URL}/${this.appId}/tests`,
@@ -292,6 +301,14 @@ class Maestro {
292
301
  // Determine base directory for zip structure
293
302
  // If we have a single directory, use it as base; otherwise use common ancestor or flatten
294
303
  const baseDir = baseDirs.length === 1 ? baseDirs[0] : undefined;
304
+ if (!this.options.quiet) {
305
+ this.logIncludedFiles(allFlowFiles, baseDir);
306
+ }
307
+ // Check for missing file references and warn the user
308
+ const missingReferences = await this.findMissingReferences(allFlowFiles, allFlowFiles, baseDir);
309
+ if (!this.options.quiet && missingReferences.length > 0) {
310
+ this.logMissingReferences(missingReferences, baseDir);
311
+ }
295
312
  zipPath = await this.createFlowsZip(allFlowFiles, baseDir);
296
313
  shouldCleanup = true;
297
314
  try {
@@ -350,66 +367,440 @@ class Maestro {
350
367
  const dependencies = await this.discoverDependencies(flowFile, directory);
351
368
  dependencies.forEach((dep) => allFiles.add(dep));
352
369
  }
370
+ // Include config.yaml if it exists
371
+ if (config) {
372
+ allFiles.add(configPath);
373
+ }
353
374
  return Array.from(allFiles);
354
375
  }
355
- async discoverDependencies(flowFile, baseDir) {
376
+ async discoverDependencies(flowFile, baseDir, visited = new Set()) {
377
+ // Normalize path to handle different relative path references to same file
378
+ const normalizedFlowFile = node_path_1.default.resolve(flowFile);
379
+ // Prevent circular dependencies
380
+ if (visited.has(normalizedFlowFile)) {
381
+ return [];
382
+ }
383
+ visited.add(normalizedFlowFile);
356
384
  const dependencies = [];
357
385
  try {
358
386
  const content = await node_fs_1.default.promises.readFile(flowFile, 'utf-8');
359
- const flowData = yaml.load(content);
360
- if (Array.isArray(flowData)) {
361
- for (const step of flowData) {
362
- if (typeof step === 'object' && step !== null) {
363
- // Check for runFlow
364
- if ('runFlow' in step) {
365
- const runFlowValue = step.runFlow;
366
- const refFile = typeof runFlowValue === 'string'
367
- ? runFlowValue
368
- : runFlowValue?.file;
369
- if (refFile) {
370
- const depPath = node_path_1.default.resolve(node_path_1.default.dirname(flowFile), refFile);
371
- if ((await node_fs_1.default.promises.access(depPath).catch(() => false)) ===
372
- undefined) {
373
- dependencies.push(depPath);
374
- const nestedDeps = await this.discoverDependencies(depPath, baseDir);
375
- dependencies.push(...nestedDeps);
376
- }
377
- }
387
+ // Maestro YAML files can have front matter (metadata) followed by ---
388
+ // and then the actual flow steps. Use loadAll to handle both cases.
389
+ const documents = [];
390
+ yaml.loadAll(content, (doc) => documents.push(doc));
391
+ for (const flowData of documents) {
392
+ if (flowData !== null && typeof flowData === 'object') {
393
+ const deps = await this.extractPathsFromValue(flowData, flowFile, baseDir, visited);
394
+ dependencies.push(...deps);
395
+ }
396
+ }
397
+ }
398
+ catch {
399
+ // Ignore parsing errors
400
+ }
401
+ return dependencies;
402
+ }
403
+ /**
404
+ * Check if a string looks like a file path (relative path with extension)
405
+ */
406
+ looksLikePath(value) {
407
+ // Must be a relative path (starts with . or contains /)
408
+ const isRelative = value.startsWith('./') || value.startsWith('../');
409
+ const hasPathSeparator = value.includes('/');
410
+ // Must have a file extension
411
+ const hasExtension = /\.[a-zA-Z0-9]+$/.test(value);
412
+ // Exclude URLs
413
+ const isUrl = value.startsWith('http://') ||
414
+ value.startsWith('https://') ||
415
+ value.startsWith('file://');
416
+ // Exclude template variables that are just ${...}
417
+ const isOnlyVariable = /^\$\{[^}]+\}$/.test(value);
418
+ return ((isRelative || hasPathSeparator) &&
419
+ hasExtension &&
420
+ !isUrl &&
421
+ !isOnlyVariable);
422
+ }
423
+ /**
424
+ * Try to add a file path as a dependency if it exists
425
+ */
426
+ async tryAddDependency(filePath, flowFile, baseDir, dependencies, visited) {
427
+ const depPath = node_path_1.default.resolve(node_path_1.default.dirname(flowFile), filePath);
428
+ // Check if already added (handles deduplication for non-YAML files)
429
+ // YAML files are tracked by discoverDependencies to handle circular refs
430
+ if (visited.has(depPath)) {
431
+ return;
432
+ }
433
+ try {
434
+ await node_fs_1.default.promises.access(depPath);
435
+ dependencies.push(depPath);
436
+ // If it's a YAML file, recursively discover its dependencies
437
+ // discoverDependencies will add it to visited to prevent circular refs
438
+ const ext = node_path_1.default.extname(depPath).toLowerCase();
439
+ if (ext === '.yaml' || ext === '.yml') {
440
+ const nestedDeps = await this.discoverDependencies(depPath, baseDir, visited);
441
+ dependencies.push(...nestedDeps);
442
+ }
443
+ else {
444
+ // For non-YAML files, add to visited here to prevent duplicates
445
+ visited.add(depPath);
446
+ }
447
+ }
448
+ catch {
449
+ // File doesn't exist, skip it
450
+ }
451
+ }
452
+ /**
453
+ * Recursively extract file paths from any value in the YAML structure
454
+ */
455
+ async extractPathsFromValue(value, flowFile, baseDir, visited) {
456
+ const dependencies = [];
457
+ if (typeof value === 'string') {
458
+ // Check if this string looks like a file path
459
+ if (this.looksLikePath(value)) {
460
+ await this.tryAddDependency(value, flowFile, baseDir, dependencies, visited);
461
+ }
462
+ }
463
+ else if (Array.isArray(value)) {
464
+ // Recursively check array elements
465
+ for (const item of value) {
466
+ const deps = await this.extractPathsFromValue(item, flowFile, baseDir, visited);
467
+ dependencies.push(...deps);
468
+ }
469
+ }
470
+ else if (value !== null && typeof value === 'object') {
471
+ const obj = value;
472
+ // Track which keys we've handled specially to avoid double-processing
473
+ const handledKeys = new Set();
474
+ // Handle known Maestro commands that reference files
475
+ // These should always be treated as file paths, even without path separators
476
+ // runScript: can be string or { file: "..." }
477
+ if ('runScript' in obj) {
478
+ handledKeys.add('runScript');
479
+ const runScript = obj.runScript;
480
+ const scriptFile = typeof runScript === 'string'
481
+ ? runScript
482
+ : runScript?.file;
483
+ if (typeof scriptFile === 'string') {
484
+ await this.tryAddDependency(scriptFile, flowFile, baseDir, dependencies, visited);
485
+ }
486
+ }
487
+ // runFlow: can be string or { file: "...", commands: [...] }
488
+ if ('runFlow' in obj) {
489
+ handledKeys.add('runFlow');
490
+ const runFlow = obj.runFlow;
491
+ const flowRef = typeof runFlow === 'string'
492
+ ? runFlow
493
+ : runFlow?.file;
494
+ if (typeof flowRef === 'string') {
495
+ await this.tryAddDependency(flowRef, flowFile, baseDir, dependencies, visited);
496
+ }
497
+ // Recurse into runFlow for inline commands
498
+ if (typeof runFlow === 'object' && runFlow !== null) {
499
+ const deps = await this.extractPathsFromValue(runFlow, flowFile, baseDir, visited);
500
+ dependencies.push(...deps);
501
+ }
502
+ }
503
+ // addMedia: can be string or array of strings
504
+ if ('addMedia' in obj) {
505
+ handledKeys.add('addMedia');
506
+ const addMedia = obj.addMedia;
507
+ const mediaFiles = Array.isArray(addMedia) ? addMedia : [addMedia];
508
+ for (const mediaFile of mediaFiles) {
509
+ if (typeof mediaFile === 'string') {
510
+ await this.tryAddDependency(mediaFile, flowFile, baseDir, dependencies, visited);
511
+ }
512
+ }
513
+ }
514
+ // onFlowStart: array of commands in frontmatter
515
+ if ('onFlowStart' in obj) {
516
+ handledKeys.add('onFlowStart');
517
+ const onFlowStart = obj.onFlowStart;
518
+ if (Array.isArray(onFlowStart)) {
519
+ const deps = await this.extractPathsFromValue(onFlowStart, flowFile, baseDir, visited);
520
+ dependencies.push(...deps);
521
+ }
522
+ }
523
+ // onFlowComplete: array of commands in frontmatter
524
+ if ('onFlowComplete' in obj) {
525
+ handledKeys.add('onFlowComplete');
526
+ const onFlowComplete = obj.onFlowComplete;
527
+ if (Array.isArray(onFlowComplete)) {
528
+ const deps = await this.extractPathsFromValue(onFlowComplete, flowFile, baseDir, visited);
529
+ dependencies.push(...deps);
530
+ }
531
+ }
532
+ // Generic handling for any command with nested 'commands' array
533
+ // This covers repeat, retry, doubleTapOn, longPressOn, and any future commands
534
+ // that use the commands pattern
535
+ if ('commands' in obj) {
536
+ handledKeys.add('commands');
537
+ const commands = obj.commands;
538
+ if (Array.isArray(commands)) {
539
+ const deps = await this.extractPathsFromValue(commands, flowFile, baseDir, visited);
540
+ dependencies.push(...deps);
541
+ }
542
+ }
543
+ // Generic handling for 'file' property in any command (e.g., retry: { file: ... })
544
+ if ('file' in obj && typeof obj.file === 'string') {
545
+ handledKeys.add('file');
546
+ await this.tryAddDependency(obj.file, flowFile, baseDir, dependencies, visited);
547
+ }
548
+ // Recursively check remaining object properties for nested structures
549
+ for (const [key, propValue] of Object.entries(obj)) {
550
+ if (!handledKeys.has(key)) {
551
+ const deps = await this.extractPathsFromValue(propValue, flowFile, baseDir, visited);
552
+ dependencies.push(...deps);
553
+ }
554
+ }
555
+ }
556
+ return dependencies;
557
+ }
558
+ /**
559
+ * Find all file references in flow files that don't exist on disk.
560
+ * This validates that all referenced files (runScript, runFlow, addMedia, etc.)
561
+ * will be included in the zip.
562
+ */
563
+ async findMissingReferences(flowFiles, allIncludedFiles, baseDir) {
564
+ const missingReferences = [];
565
+ const includedFilesSet = new Set(allIncludedFiles.map((f) => node_path_1.default.resolve(f)));
566
+ for (const flowFile of flowFiles) {
567
+ const ext = node_path_1.default.extname(flowFile).toLowerCase();
568
+ if (ext !== '.yaml' && ext !== '.yml') {
569
+ continue;
570
+ }
571
+ try {
572
+ const content = await node_fs_1.default.promises.readFile(flowFile, 'utf-8');
573
+ const documents = [];
574
+ yaml.loadAll(content, (doc) => documents.push(doc));
575
+ for (const flowData of documents) {
576
+ if (flowData !== null && typeof flowData === 'object') {
577
+ const missing = await this.findMissingInValue(flowData, flowFile, includedFilesSet);
578
+ missingReferences.push(...missing);
579
+ }
580
+ }
581
+ }
582
+ catch {
583
+ // Ignore parsing errors
584
+ }
585
+ }
586
+ return missingReferences;
587
+ }
588
+ /**
589
+ * Recursively find missing file references in a YAML value
590
+ */
591
+ async findMissingInValue(value, flowFile, includedFiles) {
592
+ const missingReferences = [];
593
+ if (typeof value === 'string') {
594
+ if (this.looksLikePath(value)) {
595
+ const resolvedPath = node_path_1.default.resolve(node_path_1.default.dirname(flowFile), value);
596
+ // Check if the file is in included files OR exists on disk
597
+ if (!includedFiles.has(resolvedPath)) {
598
+ try {
599
+ await node_fs_1.default.promises.access(resolvedPath);
600
+ // File exists on disk but won't be included - also warn
601
+ }
602
+ catch {
603
+ // File doesn't exist
604
+ missingReferences.push({
605
+ flowFile,
606
+ referencedFile: value,
607
+ resolvedPath,
608
+ });
609
+ }
610
+ }
611
+ }
612
+ }
613
+ else if (Array.isArray(value)) {
614
+ for (const item of value) {
615
+ const missing = await this.findMissingInValue(item, flowFile, includedFiles);
616
+ missingReferences.push(...missing);
617
+ }
618
+ }
619
+ else if (value !== null && typeof value === 'object') {
620
+ const obj = value;
621
+ const handledKeys = new Set();
622
+ // Handle runScript - extract file reference but don't recurse
623
+ // (runScript objects only contain file, env, when - no nested file refs)
624
+ if ('runScript' in obj) {
625
+ handledKeys.add('runScript');
626
+ const runScript = obj.runScript;
627
+ const scriptFile = typeof runScript === 'string'
628
+ ? runScript
629
+ : runScript?.file;
630
+ if (typeof scriptFile === 'string') {
631
+ const resolved = node_path_1.default.resolve(node_path_1.default.dirname(flowFile), scriptFile);
632
+ if (!includedFiles.has(resolved)) {
633
+ try {
634
+ await node_fs_1.default.promises.access(resolved);
378
635
  }
379
- // Check for runScript
380
- if ('runScript' in step) {
381
- const scriptFile = step.runScript?.file;
382
- if (scriptFile) {
383
- const depPath = node_path_1.default.resolve(node_path_1.default.dirname(flowFile), scriptFile);
384
- if ((await node_fs_1.default.promises.access(depPath).catch(() => false)) ===
385
- undefined) {
386
- dependencies.push(depPath);
387
- }
388
- }
636
+ catch {
637
+ missingReferences.push({
638
+ flowFile,
639
+ referencedFile: scriptFile,
640
+ resolvedPath: resolved,
641
+ });
389
642
  }
390
- // Check for addMedia
391
- if ('addMedia' in step) {
392
- const mediaFiles = Array.isArray(step.addMedia)
393
- ? step.addMedia
394
- : [step.addMedia];
395
- for (const mediaFile of mediaFiles) {
396
- if (typeof mediaFile === 'string') {
397
- const depPath = node_path_1.default.resolve(node_path_1.default.dirname(flowFile), mediaFile);
398
- if ((await node_fs_1.default.promises.access(depPath).catch(() => false)) ===
399
- undefined) {
400
- dependencies.push(depPath);
401
- }
402
- }
643
+ }
644
+ }
645
+ // Don't recurse into runScript - it only has file, env, when (no nested file refs)
646
+ }
647
+ // Handle runFlow - extract file reference and recurse only into commands
648
+ if ('runFlow' in obj) {
649
+ handledKeys.add('runFlow');
650
+ const runFlow = obj.runFlow;
651
+ const flowRef = typeof runFlow === 'string'
652
+ ? runFlow
653
+ : runFlow?.file;
654
+ if (typeof flowRef === 'string') {
655
+ const resolved = node_path_1.default.resolve(node_path_1.default.dirname(flowFile), flowRef);
656
+ if (!includedFiles.has(resolved)) {
657
+ try {
658
+ await node_fs_1.default.promises.access(resolved);
659
+ }
660
+ catch {
661
+ missingReferences.push({
662
+ flowFile,
663
+ referencedFile: flowRef,
664
+ resolvedPath: resolved,
665
+ });
666
+ }
667
+ }
668
+ }
669
+ // Only recurse into 'commands' if present (for inline commands)
670
+ if (typeof runFlow === 'object' &&
671
+ runFlow !== null &&
672
+ 'commands' in runFlow) {
673
+ const commands = runFlow.commands;
674
+ if (Array.isArray(commands)) {
675
+ const nestedMissing = await this.findMissingInValue(commands, flowFile, includedFiles);
676
+ missingReferences.push(...nestedMissing);
677
+ }
678
+ }
679
+ }
680
+ // Handle addMedia
681
+ if ('addMedia' in obj) {
682
+ handledKeys.add('addMedia');
683
+ const addMedia = obj.addMedia;
684
+ const mediaFiles = Array.isArray(addMedia) ? addMedia : [addMedia];
685
+ for (const mediaFile of mediaFiles) {
686
+ if (typeof mediaFile === 'string') {
687
+ const resolved = node_path_1.default.resolve(node_path_1.default.dirname(flowFile), mediaFile);
688
+ if (!includedFiles.has(resolved)) {
689
+ try {
690
+ await node_fs_1.default.promises.access(resolved);
691
+ }
692
+ catch {
693
+ missingReferences.push({
694
+ flowFile,
695
+ referencedFile: mediaFile,
696
+ resolvedPath: resolved,
697
+ });
403
698
  }
404
699
  }
405
700
  }
406
701
  }
407
702
  }
703
+ // Handle file property
704
+ if ('file' in obj && typeof obj.file === 'string') {
705
+ handledKeys.add('file');
706
+ const resolved = node_path_1.default.resolve(node_path_1.default.dirname(flowFile), obj.file);
707
+ if (!includedFiles.has(resolved)) {
708
+ try {
709
+ await node_fs_1.default.promises.access(resolved);
710
+ }
711
+ catch {
712
+ missingReferences.push({
713
+ flowFile,
714
+ referencedFile: obj.file,
715
+ resolvedPath: resolved,
716
+ });
717
+ }
718
+ }
719
+ }
720
+ // Handle onFlowStart, onFlowComplete, commands
721
+ for (const key of ['onFlowStart', 'onFlowComplete', 'commands']) {
722
+ if (key in obj) {
723
+ handledKeys.add(key);
724
+ const nested = obj[key];
725
+ if (Array.isArray(nested)) {
726
+ const nestedMissing = await this.findMissingInValue(nested, flowFile, includedFiles);
727
+ missingReferences.push(...nestedMissing);
728
+ }
729
+ }
730
+ }
731
+ // Recursively check remaining properties
732
+ for (const [key, propValue] of Object.entries(obj)) {
733
+ if (!handledKeys.has(key)) {
734
+ const nestedMissing = await this.findMissingInValue(propValue, flowFile, includedFiles);
735
+ missingReferences.push(...nestedMissing);
736
+ }
737
+ }
408
738
  }
409
- catch {
410
- // Ignore parsing errors
739
+ return missingReferences;
740
+ }
741
+ /**
742
+ * Log warnings for missing file references
743
+ */
744
+ logMissingReferences(missingReferences, baseDir) {
745
+ if (missingReferences.length === 0) {
746
+ return;
747
+ }
748
+ logger_1.default.warn(`Warning: ${missingReferences.length} referenced file(s) not found:`);
749
+ for (const ref of missingReferences) {
750
+ const flowRelative = baseDir
751
+ ? node_path_1.default.relative(baseDir, ref.flowFile)
752
+ : node_path_1.default.basename(ref.flowFile);
753
+ logger_1.default.warn(` In ${flowRelative}: ${ref.referencedFile}`);
754
+ }
755
+ logger_1.default.warn('These files will not be included in the upload and may cause test failures.');
756
+ }
757
+ logIncludedFiles(files, baseDir) {
758
+ // Get relative paths for display
759
+ const relativePaths = files
760
+ .map((f) => (baseDir ? node_path_1.default.relative(baseDir, f) : node_path_1.default.basename(f)))
761
+ .sort();
762
+ // Group by file type
763
+ const groups = {
764
+ 'Flow files': [],
765
+ Scripts: [],
766
+ 'Media files': [],
767
+ 'Config files': [],
768
+ Other: [],
769
+ };
770
+ for (const filePath of relativePaths) {
771
+ const ext = node_path_1.default.extname(filePath).toLowerCase();
772
+ if (ext === '.yaml' || ext === '.yml') {
773
+ if (filePath === 'config.yaml' || filePath.endsWith('/config.yaml')) {
774
+ groups['Config files'].push(filePath);
775
+ }
776
+ else {
777
+ groups['Flow files'].push(filePath);
778
+ }
779
+ }
780
+ else if (ext === '.js' || ext === '.ts') {
781
+ groups['Scripts'].push(filePath);
782
+ }
783
+ else if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.mp4', '.mov'].includes(ext)) {
784
+ groups['Media files'].push(filePath);
785
+ }
786
+ else {
787
+ groups['Other'].push(filePath);
788
+ }
789
+ }
790
+ logger_1.default.info(`Bundling ${files.length} files into flows.zip:`);
791
+ for (const [groupName, groupFiles] of Object.entries(groups)) {
792
+ if (groupFiles.length > 0) {
793
+ logger_1.default.info(` ${groupName} (${groupFiles.length}):`);
794
+ // Show first 10 files, then summarize if more
795
+ const displayFiles = groupFiles.slice(0, 10);
796
+ for (const file of displayFiles) {
797
+ logger_1.default.info(` - ${file}`);
798
+ }
799
+ if (groupFiles.length > 10) {
800
+ logger_1.default.info(` ... and ${groupFiles.length - 10} more`);
801
+ }
802
+ }
411
803
  }
412
- return dependencies;
413
804
  }
414
805
  async createFlowsZip(files, baseDir) {
415
806
  const tmpDir = await node_fs_1.default.promises.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), 'maestro-'));
@@ -438,9 +829,14 @@ class Maestro {
438
829
  try {
439
830
  const capabilities = this.options.getCapabilities(this.detectedPlatform);
440
831
  const maestroOptions = this.options.getMaestroOptions();
832
+ const metadata = this.options.metadata;
441
833
  const response = await axios_1.default.post(`${this.URL}/${this.appId}/run`, {
442
834
  capabilities: [capabilities],
443
835
  ...(maestroOptions && { maestroOptions }),
836
+ ...(this.options.shardSplit && {
837
+ shardSplit: this.options.shardSplit,
838
+ }),
839
+ ...(metadata && { metadata }),
444
840
  }, {
445
841
  headers: {
446
842
  'Content-Type': 'application/json',
@@ -450,6 +846,7 @@ class Maestro {
450
846
  username: this.credentials.userName,
451
847
  password: this.credentials.accessKey,
452
848
  },
849
+ timeout: 30000, // 30 second timeout
453
850
  });
454
851
  // Check for version update notification
455
852
  const latestVersion = response.headers?.['x-testingbotctl-version'];
@@ -473,34 +870,40 @@ class Maestro {
473
870
  if (error instanceof testingbot_error_1.default) {
474
871
  throw error;
475
872
  }
476
- throw new testingbot_error_1.default(`Running Maestro test failed`, {
477
- cause: error,
478
- });
873
+ throw await this.handleErrorWithDiagnostics(error, 'Running Maestro test failed');
479
874
  }
480
875
  }
481
876
  async getStatus() {
482
877
  try {
483
- const response = await axios_1.default.get(`${this.URL}/${this.appId}`, {
484
- headers: {
485
- 'User-Agent': utils_1.default.getUserAgent(),
486
- },
487
- auth: {
488
- username: this.credentials.userName,
489
- password: this.credentials.accessKey,
490
- },
878
+ return await this.withRetry('Getting Maestro test status', async () => {
879
+ const response = await axios_1.default.get(`${this.URL}/${this.appId}`, {
880
+ headers: {
881
+ 'User-Agent': utils_1.default.getUserAgent(),
882
+ },
883
+ auth: {
884
+ username: this.credentials.userName,
885
+ password: this.credentials.accessKey,
886
+ },
887
+ timeout: 30000, // 30 second timeout
888
+ });
889
+ // Check for version update notification
890
+ const latestVersion = response.headers?.['x-testingbotctl-version'];
891
+ utils_1.default.checkForUpdate(latestVersion);
892
+ return response.data;
491
893
  });
492
- return response.data;
493
894
  }
494
895
  catch (error) {
495
- throw new testingbot_error_1.default(`Failed to get Maestro test status`, {
496
- cause: error,
497
- });
896
+ throw await this.handleErrorWithDiagnostics(error, 'Failed to get Maestro test status');
498
897
  }
499
898
  }
500
899
  async waitForCompletion() {
501
900
  let attempts = 0;
502
901
  const startTime = Date.now();
503
902
  const previousStatus = new Map();
903
+ const previousFlowStatus = new Map();
904
+ const urlDisplayed = new Set();
905
+ let flowsTableDisplayed = false;
906
+ let displayedLineCount = 0;
504
907
  while (attempts < this.MAX_POLL_ATTEMPTS) {
505
908
  // Check if we're shutting down
506
909
  if (this.isShuttingDown) {
@@ -513,16 +916,76 @@ class Maestro {
513
916
  .map((run) => run.id);
514
917
  // Log current status of runs (unless quiet mode)
515
918
  if (!this.options.quiet) {
516
- this.displayRunStatus(status.runs, startTime, previousStatus);
919
+ // Check if any run has flows and display them
920
+ const allFlows = [];
921
+ for (const run of status.runs) {
922
+ if (run.flows && run.flows.length > 0) {
923
+ allFlows.push(...run.flows);
924
+ }
925
+ }
926
+ // Show realtime URL once per run (before any in-place updates)
927
+ for (const run of status.runs) {
928
+ if (!urlDisplayed.has(run.id)) {
929
+ console.log(` 🔗 Run ${run.id} (${this.getRunDisplayName(run)}): Watch in realtime:`);
930
+ console.log(` https://testingbot.com/members/maestro/${this.appId}/runs/${run.id}`);
931
+ urlDisplayed.add(run.id);
932
+ }
933
+ }
934
+ if (allFlows.length > 0) {
935
+ // Check if any flow has failed (for showing error column)
936
+ const hasFailures = this.hasAnyFlowFailed(allFlows);
937
+ if (!flowsTableDisplayed) {
938
+ // First time showing flows - display header and initial state
939
+ console.log(); // Empty line before flows table
940
+ this.displayFlowsTableHeader(hasFailures);
941
+ displayedLineCount = this.displayFlowsWithLimit(allFlows, previousFlowStatus, hasFailures);
942
+ flowsTableDisplayed = true;
943
+ }
944
+ else {
945
+ // Update flows in place
946
+ displayedLineCount = this.updateFlowsInPlace(allFlows, previousFlowStatus, displayedLineCount);
947
+ }
948
+ }
949
+ else {
950
+ // No flows yet, show run status
951
+ this.displayRunStatus(status.runs, startTime, previousStatus);
952
+ }
517
953
  }
518
954
  if (status.completed) {
519
- // Clear the updating line and print final status
955
+ // Display final flows table with error messages if there are failures
956
+ if (!this.options.quiet && flowsTableDisplayed) {
957
+ const allFlows = [];
958
+ for (const run of status.runs) {
959
+ if (run.flows && run.flows.length > 0) {
960
+ allFlows.push(...run.flows);
961
+ }
962
+ }
963
+ const hasFailures = this.hasAnyFlowFailed(allFlows);
964
+ if (hasFailures) {
965
+ // Move cursor up to overwrite the existing table
966
+ // +2 for header and separator lines
967
+ const linesToMove = displayedLineCount + 2;
968
+ process.stdout.write(`\x1b[${linesToMove}A`);
969
+ // Clear header line, write new header, then clear separator line
970
+ process.stdout.write('\x1b[2K');
971
+ console.log(picocolors_1.default.dim(` ${'Duration'.padEnd(10)} ${'Status'.padEnd(8)} Flow Fail reason`));
972
+ process.stdout.write('\x1b[2K');
973
+ console.log(picocolors_1.default.dim(` ${'─'.repeat(10)} ${'─'.repeat(8)} ${'─'.repeat(30)} ${'─'.repeat(80)}`));
974
+ // Redraw all flows with error messages
975
+ for (const flow of allFlows) {
976
+ // Clear the line before writing
977
+ process.stdout.write('\x1b[2K');
978
+ this.displayFlowRow(flow, false, true);
979
+ }
980
+ }
981
+ }
982
+ // Print final summary
520
983
  if (!this.options.quiet) {
521
- this.clearLine();
984
+ console.log(); // Empty line before summary
522
985
  for (const run of status.runs) {
523
986
  const statusEmoji = run.success === 1 ? '✅' : '❌';
524
987
  const statusText = run.success === 1 ? 'Test completed successfully' : 'Test failed';
525
- console.log(` ${statusEmoji} Run ${run.id} (${run.capabilities.deviceName}): ${statusText}`);
988
+ console.log(` ${statusEmoji} Run ${run.id} (${this.getRunDisplayName(run)}): ${statusText}`);
526
989
  }
527
990
  }
528
991
  const allSucceeded = status.runs.every((run) => run.success === 1);
@@ -533,16 +996,11 @@ class Maestro {
533
996
  }
534
997
  else {
535
998
  const failedRuns = status.runs.filter((run) => run.success !== 1);
536
- logger_1.default.error(`${failedRuns.length} test run(s) failed:`);
537
- for (const run of failedRuns) {
538
- logger_1.default.error(` - Run ${run.id} (${run.capabilities.deviceName}): ${run.report}`);
539
- }
999
+ logger_1.default.error(`${failedRuns.length} test run(s) failed`);
540
1000
  }
541
- // Fetch reports if requested
542
1001
  if (this.options.report && this.options.reportOutputDir) {
543
1002
  await this.fetchReports(status.runs);
544
1003
  }
545
- // Download artifacts if requested
546
1004
  if (this.options.downloadArtifacts) {
547
1005
  await this.downloadArtifacts(status.runs);
548
1006
  }
@@ -568,34 +1026,25 @@ class Maestro {
568
1026
  (prevStatus === 'WAITING' || prevStatus === 'READY')) {
569
1027
  this.clearLine();
570
1028
  }
571
- // Show URL when test starts running (transitions from WAITING to READY)
572
- if (statusChanged && prevStatus === 'WAITING' && run.status === 'READY') {
573
- console.log(` 🚀 Run ${run.id} (${run.capabilities.deviceName}): Test started`);
574
- console.log(` Watch this test in realtime: https://testingbot.com/members/maestro/${this.appId}/runs/${run.id}`);
575
- }
576
1029
  previousStatus.set(run.id, run.status);
577
1030
  const statusInfo = this.getStatusInfo(run.status);
578
1031
  if (run.status === 'WAITING' || run.status === 'READY') {
579
1032
  // Update the same line for WAITING and READY states
580
- const message = ` ${statusInfo.emoji} Run ${run.id} (${run.capabilities.deviceName}): ${statusInfo.text} (${elapsedStr})`;
1033
+ const message = ` ${statusInfo.emoji} Run ${run.id} (${this.getRunDisplayName(run)}): ${statusInfo.text} (${elapsedStr})`;
581
1034
  process.stdout.write(`\r${message}`);
582
1035
  }
583
1036
  else if (statusChanged) {
584
1037
  // For other states (DONE, FAILED), print on a new line only when status changes
585
- console.log(` ${statusInfo.emoji} Run ${run.id} (${run.capabilities.deviceName}): ${statusInfo.text}`);
1038
+ console.log(` ${statusInfo.emoji} Run ${run.id} (${this.getRunDisplayName(run)}): ${statusInfo.text}`);
586
1039
  }
587
1040
  }
588
1041
  }
589
- clearLine() {
590
- platform_1.default.clearLine();
591
- }
592
- formatElapsedTime(seconds) {
593
- if (seconds < 60) {
594
- return `${seconds}s`;
595
- }
596
- const minutes = Math.floor(seconds / 60);
597
- const remainingSeconds = seconds % 60;
598
- return `${minutes}m ${remainingSeconds}s`;
1042
+ /**
1043
+ * Get the display name for a run, preferring environment.name over capabilities.deviceName
1044
+ * This shows the actual device used when a wildcard (*) was specified
1045
+ */
1046
+ getRunDisplayName(run) {
1047
+ return run.environment?.name || run.capabilities.deviceName;
599
1048
  }
600
1049
  getStatusInfo(status) {
601
1050
  switch (status) {
@@ -611,6 +1060,202 @@ class Maestro {
611
1060
  return { emoji: '❓', text: status };
612
1061
  }
613
1062
  }
1063
+ getFlowStatusDisplay(flow) {
1064
+ switch (flow.status) {
1065
+ case 'WAITING':
1066
+ return { text: 'WAITING', colored: picocolors_1.default.white('WAITING') };
1067
+ case 'READY':
1068
+ return { text: 'RUNNING', colored: picocolors_1.default.blue('RUNNING') };
1069
+ case 'DONE':
1070
+ if (flow.success === 1) {
1071
+ return { text: 'PASSED', colored: picocolors_1.default.green('PASSED') };
1072
+ }
1073
+ else {
1074
+ return { text: 'FAILED', colored: picocolors_1.default.red('FAILED') };
1075
+ }
1076
+ case 'FAILED':
1077
+ return { text: 'FAILED', colored: picocolors_1.default.red('FAILED') };
1078
+ default:
1079
+ return { text: flow.status, colored: flow.status };
1080
+ }
1081
+ }
1082
+ hasAnyFlowFailed(flows) {
1083
+ return flows.some((flow) => (flow.status === 'DONE' && flow.success !== 1) ||
1084
+ flow.status === 'FAILED' ||
1085
+ (flow.error_messages && flow.error_messages.length > 0));
1086
+ }
1087
+ calculateFlowDuration(flow) {
1088
+ if (!flow.requested_at) {
1089
+ return '-';
1090
+ }
1091
+ const startTime = new Date(flow.requested_at).getTime();
1092
+ let endTime;
1093
+ if (flow.completed_at) {
1094
+ endTime = new Date(flow.completed_at).getTime();
1095
+ }
1096
+ else {
1097
+ endTime = Date.now();
1098
+ }
1099
+ const durationSeconds = Math.floor((endTime - startTime) / 1000);
1100
+ return this.formatElapsedTime(durationSeconds);
1101
+ }
1102
+ getTerminalHeight() {
1103
+ // Default to 24 if terminal height is not available
1104
+ return process.stdout.rows || 24;
1105
+ }
1106
+ getMaxDisplayableFlows() {
1107
+ const terminalHeight = this.getTerminalHeight();
1108
+ // Reserve lines for: header (2) + summary line (1) + some padding (3)
1109
+ const reservedLines = 6;
1110
+ return Math.max(5, terminalHeight - reservedLines);
1111
+ }
1112
+ getRemainingSummary(flows, displayedCount) {
1113
+ const remaining = flows.slice(displayedCount);
1114
+ if (remaining.length === 0) {
1115
+ return '';
1116
+ }
1117
+ // Count statuses for remaining flows
1118
+ let waiting = 0;
1119
+ let running = 0;
1120
+ let passed = 0;
1121
+ let failed = 0;
1122
+ for (const flow of remaining) {
1123
+ switch (flow.status) {
1124
+ case 'WAITING':
1125
+ waiting++;
1126
+ break;
1127
+ case 'READY':
1128
+ running++;
1129
+ break;
1130
+ case 'DONE':
1131
+ if (flow.success === 1) {
1132
+ passed++;
1133
+ }
1134
+ else {
1135
+ failed++;
1136
+ }
1137
+ break;
1138
+ case 'FAILED':
1139
+ failed++;
1140
+ break;
1141
+ }
1142
+ }
1143
+ const parts = [];
1144
+ if (waiting > 0)
1145
+ parts.push(picocolors_1.default.white(`${waiting} waiting`));
1146
+ if (running > 0)
1147
+ parts.push(picocolors_1.default.blue(`${running} running`));
1148
+ if (passed > 0)
1149
+ parts.push(picocolors_1.default.green(`${passed} passed`));
1150
+ if (failed > 0)
1151
+ parts.push(picocolors_1.default.red(`${failed} failed`));
1152
+ return ` ... and ${remaining.length} more: ${parts.join(', ')}`;
1153
+ }
1154
+ displayFlowsWithLimit(flows, previousFlowStatus, hasFailures = false) {
1155
+ const maxFlows = this.getMaxDisplayableFlows();
1156
+ const displayFlows = flows.slice(0, maxFlows);
1157
+ let linesWritten = 0;
1158
+ for (const flow of displayFlows) {
1159
+ linesWritten += this.displayFlowRow(flow, false, hasFailures);
1160
+ previousFlowStatus.set(flow.id, flow.status);
1161
+ }
1162
+ // Show summary for remaining flows
1163
+ if (flows.length > maxFlows) {
1164
+ const summary = this.getRemainingSummary(flows, maxFlows);
1165
+ console.log(picocolors_1.default.dim(summary));
1166
+ linesWritten++;
1167
+ }
1168
+ return linesWritten;
1169
+ }
1170
+ displayFlowsTableHeader(hasFailures = false) {
1171
+ let header = ` ${'Duration'.padEnd(10)} ${'Status'.padEnd(8)} Flow`;
1172
+ let separator = ` ${'─'.repeat(10)} ${'─'.repeat(8)} ${'─'.repeat(30)}`;
1173
+ if (hasFailures) {
1174
+ header += ' Fail reason';
1175
+ separator += ` ${'─'.repeat(80)}`;
1176
+ }
1177
+ console.log(picocolors_1.default.dim(header));
1178
+ console.log(picocolors_1.default.dim(separator));
1179
+ }
1180
+ displayFlowRow(flow, isUpdate = false, hasFailures = false) {
1181
+ const duration = this.calculateFlowDuration(flow).padEnd(10);
1182
+ const statusDisplay = this.getFlowStatusDisplay(flow);
1183
+ // Pad based on display text length, add extra for color codes
1184
+ const statusPadded = statusDisplay.colored +
1185
+ ' '.repeat(Math.max(0, 8 - statusDisplay.text.length));
1186
+ const name = flow.name.padEnd(30);
1187
+ let linesWritten = 0;
1188
+ const isFailed = flow.status === 'DONE' && flow.success !== 1;
1189
+ const errorMessages = flow.error_messages || [];
1190
+ // Build the main row
1191
+ let row = ` ${duration} ${statusPadded} ${name}`;
1192
+ // Add first error message on the same line if failed and has errors
1193
+ if (hasFailures && isFailed && errorMessages.length > 0) {
1194
+ row += ` ${picocolors_1.default.red(errorMessages[0])}`;
1195
+ }
1196
+ if (isUpdate) {
1197
+ process.stdout.write(`\r${row}`);
1198
+ }
1199
+ else {
1200
+ console.log(row);
1201
+ }
1202
+ linesWritten++;
1203
+ // Display remaining error messages on continuation lines
1204
+ if (!isUpdate && hasFailures && isFailed && errorMessages.length > 1) {
1205
+ // Indent to align with the Fail reason column: Duration(11) + Status(9) + Test(31) = 51 chars
1206
+ const indent = ' '.repeat(51);
1207
+ for (let i = 1; i < errorMessages.length; i++) {
1208
+ console.log(`${indent} ${picocolors_1.default.red(errorMessages[i])}`);
1209
+ linesWritten++;
1210
+ }
1211
+ }
1212
+ return linesWritten;
1213
+ }
1214
+ displayFlowsTable(flows, previousFlowStatus, showHeader, hasFailures = false) {
1215
+ if (showHeader) {
1216
+ this.displayFlowsTableHeader(hasFailures);
1217
+ }
1218
+ let linesWritten = 0;
1219
+ for (const flow of flows) {
1220
+ const prevStatus = previousFlowStatus.get(flow.id);
1221
+ const isNewFlow = prevStatus === undefined;
1222
+ if (isNewFlow) {
1223
+ linesWritten += this.displayFlowRow(flow, false, hasFailures);
1224
+ }
1225
+ previousFlowStatus.set(flow.id, flow.status);
1226
+ }
1227
+ return linesWritten;
1228
+ }
1229
+ updateFlowsInPlace(flows, previousFlowStatus, displayedLineCount) {
1230
+ const maxFlows = this.getMaxDisplayableFlows();
1231
+ const displayFlows = flows.slice(0, maxFlows);
1232
+ const hasRemaining = flows.length > maxFlows;
1233
+ // Move cursor up by the number of lines we PREVIOUSLY displayed
1234
+ if (displayedLineCount > 0) {
1235
+ process.stdout.write(`\x1b[${displayedLineCount}A`);
1236
+ }
1237
+ let linesWritten = 0;
1238
+ // Redraw displayed flows
1239
+ for (const flow of displayFlows) {
1240
+ const duration = this.calculateFlowDuration(flow).padEnd(10);
1241
+ const statusDisplay = this.getFlowStatusDisplay(flow);
1242
+ const statusPadded = statusDisplay.colored +
1243
+ ' '.repeat(Math.max(0, 8 - statusDisplay.text.length));
1244
+ const name = flow.name;
1245
+ const row = ` ${duration} ${statusPadded} ${name}`;
1246
+ process.stdout.write(`\r\x1b[K${row}\n`);
1247
+ previousFlowStatus.set(flow.id, flow.status);
1248
+ linesWritten++;
1249
+ }
1250
+ // Update or add summary line for remaining flows
1251
+ if (hasRemaining) {
1252
+ const summary = this.getRemainingSummary(flows, maxFlows);
1253
+ process.stdout.write(`\r\x1b[K${picocolors_1.default.dim(summary)}\n`);
1254
+ linesWritten++;
1255
+ }
1256
+ // Return the number of lines we wrote
1257
+ return linesWritten;
1258
+ }
614
1259
  async fetchReports(runs) {
615
1260
  const reportFormat = this.options.report;
616
1261
  const outputDir = this.options.reportOutputDir;
@@ -631,7 +1276,11 @@ class Maestro {
631
1276
  username: this.credentials.userName,
632
1277
  password: this.credentials.accessKey,
633
1278
  },
1279
+ timeout: 30000, // 30 second timeout
634
1280
  });
1281
+ // Check for version update notification
1282
+ const latestVersion = response.headers?.['x-testingbotctl-version'];
1283
+ utils_1.default.checkForUpdate(latestVersion);
635
1284
  // Extract the report content from the JSON response
636
1285
  const reportKey = reportFormat === 'junit' ? 'junit_report' : 'html_report';
637
1286
  const reportContent = response.data[reportKey];
@@ -654,21 +1303,24 @@ class Maestro {
654
1303
  }
655
1304
  async getRunDetails(runId) {
656
1305
  try {
657
- const response = await axios_1.default.get(`${this.URL}/${this.appId}/${runId}`, {
658
- headers: {
659
- 'User-Agent': utils_1.default.getUserAgent(),
660
- },
661
- auth: {
662
- username: this.credentials.userName,
663
- password: this.credentials.accessKey,
664
- },
1306
+ return await this.withRetry(`Getting run details for run ${runId}`, async () => {
1307
+ const response = await axios_1.default.get(`${this.URL}/${this.appId}/${runId}`, {
1308
+ headers: {
1309
+ 'User-Agent': utils_1.default.getUserAgent(),
1310
+ },
1311
+ auth: {
1312
+ username: this.credentials.userName,
1313
+ password: this.credentials.accessKey,
1314
+ },
1315
+ timeout: 30000, // 30 second timeout
1316
+ });
1317
+ const latestVersion = response.headers?.['x-testingbotctl-version'];
1318
+ utils_1.default.checkForUpdate(latestVersion);
1319
+ return response.data;
665
1320
  });
666
- return response.data;
667
1321
  }
668
1322
  catch (error) {
669
- throw new testingbot_error_1.default(`Failed to get run details for run ${runId}`, {
670
- cause: error,
671
- });
1323
+ throw await this.handleErrorWithDiagnostics(error, `Failed to get run details for run ${runId}`);
672
1324
  }
673
1325
  }
674
1326
  async waitForArtifactsSync(runId) {
@@ -734,12 +1386,12 @@ class Maestro {
734
1386
  });
735
1387
  }
736
1388
  async generateArtifactZipName(outputDir) {
737
- if (!this.options.build) {
1389
+ if (!this.options.name) {
738
1390
  // Generate unique name with timestamp
739
1391
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
740
1392
  return `maestro_artifacts_${timestamp}.zip`;
741
1393
  }
742
- const baseName = this.options.build.replace(/[^a-zA-Z0-9_-]/g, '_');
1394
+ const baseName = this.options.name.replace(/[^a-zA-Z0-9_-]/g, '_');
743
1395
  const fileName = `${baseName}.zip`;
744
1396
  const filePath = node_path_1.default.join(outputDir, fileName);
745
1397
  try {
@@ -755,13 +1407,34 @@ class Maestro {
755
1407
  async downloadArtifacts(runs) {
756
1408
  if (!this.options.downloadArtifacts)
757
1409
  return;
1410
+ // Filter runs based on download mode
1411
+ const downloadMode = this.options.downloadArtifacts;
1412
+ const runsToDownload = downloadMode === 'failed'
1413
+ ? runs.filter((run) => run.success !== 1)
1414
+ : runs;
1415
+ if (runsToDownload.length === 0) {
1416
+ if (!this.options.quiet) {
1417
+ if (downloadMode === 'failed') {
1418
+ logger_1.default.info('No failed runs to download artifacts for.');
1419
+ }
1420
+ else {
1421
+ logger_1.default.info('No runs to download artifacts for.');
1422
+ }
1423
+ }
1424
+ return;
1425
+ }
758
1426
  if (!this.options.quiet) {
759
- logger_1.default.info('Downloading artifacts...');
1427
+ if (downloadMode === 'failed') {
1428
+ logger_1.default.info(`Downloading artifacts for ${runsToDownload.length} failed run(s)...`);
1429
+ }
1430
+ else {
1431
+ logger_1.default.info('Downloading artifacts...');
1432
+ }
760
1433
  }
761
1434
  const outputDir = this.options.artifactsOutputDir || process.cwd();
762
1435
  const tempDir = await node_fs_1.default.promises.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), 'testingbot-maestro-artifacts-'));
763
1436
  try {
764
- for (const run of runs) {
1437
+ for (const run of runsToDownload) {
765
1438
  try {
766
1439
  if (!this.options.quiet) {
767
1440
  logger_1.default.info(` Waiting for artifacts sync for run ${run.id}...`);
@@ -775,7 +1448,6 @@ class Maestro {
775
1448
  }
776
1449
  const runDir = node_path_1.default.join(tempDir, `run_${run.id}`);
777
1450
  await node_fs_1.default.promises.mkdir(runDir, { recursive: true });
778
- // Download logs
779
1451
  if (runDetails.assets.logs &&
780
1452
  Object.keys(runDetails.assets.logs).length > 0) {
781
1453
  const logsDir = node_path_1.default.join(runDir, 'logs');
@@ -880,112 +1552,6 @@ class Maestro {
880
1552
  archive.finalize();
881
1553
  });
882
1554
  }
883
- sleep(ms) {
884
- return new Promise((resolve) => setTimeout(resolve, ms));
885
- }
886
- extractErrorMessage(cause) {
887
- if (typeof cause === 'string') {
888
- return cause;
889
- }
890
- // Handle arrays of errors
891
- if (Array.isArray(cause)) {
892
- return cause.join('\n');
893
- }
894
- if (cause && typeof cause === 'object') {
895
- // Handle axios errors which have response.data
896
- const axiosError = cause;
897
- if (axiosError.response?.data?.errors) {
898
- return axiosError.response.data.errors.join('\n');
899
- }
900
- if (axiosError.response?.data?.error) {
901
- return axiosError.response.data.error;
902
- }
903
- if (axiosError.response?.data?.message) {
904
- return axiosError.response.data.message;
905
- }
906
- // Handle standard Error objects
907
- if (cause instanceof Error) {
908
- return cause.message;
909
- }
910
- // Handle plain objects with errors array, error, or message property
911
- const obj = cause;
912
- if (obj.errors) {
913
- return obj.errors.join('\n');
914
- }
915
- if (obj.error) {
916
- return obj.error;
917
- }
918
- if (obj.message) {
919
- return obj.message;
920
- }
921
- }
922
- return null;
923
- }
924
- setupSignalHandlers() {
925
- this.signalHandler = () => {
926
- this.handleShutdown();
927
- };
928
- platform_1.default.setupSignalHandlers(this.signalHandler);
929
- }
930
- removeSignalHandlers() {
931
- if (this.signalHandler) {
932
- platform_1.default.removeSignalHandlers(this.signalHandler);
933
- this.signalHandler = null;
934
- }
935
- }
936
- handleShutdown() {
937
- if (this.isShuttingDown) {
938
- // Already shutting down, force exit on second signal
939
- logger_1.default.warn('Force exiting...');
940
- process.exit(1);
941
- }
942
- this.isShuttingDown = true;
943
- this.clearLine();
944
- logger_1.default.warn('Received interrupt signal, stopping test runs...');
945
- // Stop all active runs
946
- this.stopActiveRuns()
947
- .then(() => {
948
- logger_1.default.info('All test runs have been stopped.');
949
- process.exit(1);
950
- })
951
- .catch((error) => {
952
- logger_1.default.error(`Failed to stop some test runs: ${error instanceof Error ? error.message : error}`);
953
- process.exit(1);
954
- });
955
- }
956
- async stopActiveRuns() {
957
- if (!this.appId || this.activeRunIds.length === 0) {
958
- return;
959
- }
960
- const stopPromises = this.activeRunIds.map((runId) => this.stopRun(runId).catch((error) => {
961
- logger_1.default.error(`Failed to stop run ${runId}: ${error instanceof Error ? error.message : error}`);
962
- }));
963
- await Promise.all(stopPromises);
964
- }
965
- async stopRun(runId) {
966
- if (!this.appId) {
967
- return;
968
- }
969
- try {
970
- await axios_1.default.post(`${this.URL}/${this.appId}/${runId}/stop`, {}, {
971
- headers: {
972
- 'User-Agent': utils_1.default.getUserAgent(),
973
- },
974
- auth: {
975
- username: this.credentials.userName,
976
- password: this.credentials.accessKey,
977
- },
978
- });
979
- if (!this.options.quiet) {
980
- logger_1.default.info(` Stopped run ${runId}`);
981
- }
982
- }
983
- catch (error) {
984
- throw new testingbot_error_1.default(`Failed to stop run ${runId}`, {
985
- cause: error,
986
- });
987
- }
988
- }
989
1555
  connectToUpdateServer() {
990
1556
  if (!this.updateServer || !this.updateKey || this.options.quiet) {
991
1557
  return;