@testingbot/cli 1.0.0 → 1.0.2

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 (42) hide show
  1. package/README.md +3 -2
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +67 -8
  4. package/dist/models/espresso_options.d.ts +9 -0
  5. package/dist/models/espresso_options.d.ts.map +1 -1
  6. package/dist/models/espresso_options.js +14 -0
  7. package/dist/models/maestro_options.d.ts +19 -7
  8. package/dist/models/maestro_options.d.ts.map +1 -1
  9. package/dist/models/maestro_options.js +17 -8
  10. package/dist/models/testingbot_error.d.ts +3 -0
  11. package/dist/models/testingbot_error.d.ts.map +1 -1
  12. package/dist/models/testingbot_error.js +5 -0
  13. package/dist/models/xcuitest_options.d.ts +9 -0
  14. package/dist/models/xcuitest_options.d.ts.map +1 -1
  15. package/dist/models/xcuitest_options.js +14 -0
  16. package/dist/providers/base_provider.d.ts +119 -0
  17. package/dist/providers/base_provider.d.ts.map +1 -0
  18. package/dist/providers/base_provider.js +296 -0
  19. package/dist/providers/espresso.d.ts +14 -21
  20. package/dist/providers/espresso.d.ts.map +1 -1
  21. package/dist/providers/espresso.js +39 -168
  22. package/dist/providers/login.d.ts +1 -0
  23. package/dist/providers/login.d.ts.map +1 -1
  24. package/dist/providers/login.js +17 -8
  25. package/dist/providers/maestro.d.ts +54 -22
  26. package/dist/providers/maestro.d.ts.map +1 -1
  27. package/dist/providers/maestro.js +683 -286
  28. package/dist/providers/xcuitest.d.ts +14 -21
  29. package/dist/providers/xcuitest.d.ts.map +1 -1
  30. package/dist/providers/xcuitest.js +39 -168
  31. package/dist/upload.d.ts +11 -4
  32. package/dist/upload.d.ts.map +1 -1
  33. package/dist/upload.js +80 -35
  34. package/dist/utils/connectivity.d.ts +25 -0
  35. package/dist/utils/connectivity.d.ts.map +1 -0
  36. package/dist/utils/connectivity.js +118 -0
  37. package/dist/utils/error-helpers.d.ts +26 -0
  38. package/dist/utils/error-helpers.d.ts.map +1 -0
  39. package/dist/utils/error-helpers.js +237 -0
  40. package/dist/utils.d.ts.map +1 -1
  41. package/dist/utils.js +7 -2
  42. package/package.json +3 -1
@@ -47,28 +47,17 @@ 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 colors_1 = __importDefault(require("colors"));
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
  }
73
62
  async validate() {
74
63
  if (this.options.app === undefined) {
@@ -83,7 +72,7 @@ class Maestro {
83
72
  if (this.options.flows === undefined || this.options.flows.length === 0) {
84
73
  throw new testingbot_error_1.default(`flows option is required`);
85
74
  }
86
- // Check if all flows paths exist (can be files, directories, or glob patterns)
75
+ // Check if all flows paths exist (can be files, directories or glob patterns)
87
76
  for (const flowsPath of this.options.flows) {
88
77
  const isGlobPattern = flowsPath.includes('*') ||
89
78
  flowsPath.includes('?') ||
@@ -97,45 +86,17 @@ class Maestro {
97
86
  }
98
87
  }
99
88
  }
100
- // Device is optional - will be inferred from app file type if not provided
101
- // Validate report options
102
89
  if (this.options.report && !this.options.reportOutputDir) {
103
90
  throw new testingbot_error_1.default(`--report-output-dir is required when --report is specified`);
104
91
  }
105
92
  if (this.options.reportOutputDir) {
106
93
  await this.ensureOutputDirectory(this.options.reportOutputDir);
107
94
  }
108
- // Validate artifact download options - output dir defaults to current directory
109
95
  if (this.options.downloadArtifacts && this.options.artifactsOutputDir) {
110
96
  await this.ensureOutputDirectory(this.options.artifactsOutputDir);
111
97
  }
112
98
  return true;
113
99
  }
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
100
  /**
140
101
  * Detect platform from app file content using magic bytes
141
102
  */
@@ -150,13 +111,12 @@ class Maestro {
150
111
  return { success: false, runs: [] };
151
112
  }
152
113
  try {
114
+ // Quick connectivity check before starting uploads
115
+ await this.ensureConnectivity();
153
116
  // Detect platform from file content if not explicitly provided
154
117
  if (!this.options.platformName) {
155
118
  this.detectedPlatform = await this.detectPlatform();
156
119
  }
157
- if (!this.options.quiet) {
158
- logger_1.default.info('Uploading Maestro App');
159
- }
160
120
  await this.uploadApp();
161
121
  if (!this.options.quiet) {
162
122
  logger_1.default.info('Uploading Maestro Flows');
@@ -169,13 +129,13 @@ class Maestro {
169
129
  if (this.options.async) {
170
130
  if (!this.options.quiet) {
171
131
  logger_1.default.info(`Tests started in async mode. Project ID: ${this.appId}`);
132
+ logger_1.default.info(`View realtime results: https://testingbot.com/members/maestro/${this.appId}`);
172
133
  }
173
134
  return { success: true, runs: [] };
174
135
  }
175
- // Set up signal handlers before waiting for completion
176
136
  this.setupSignalHandlers();
177
137
  // Connect to real-time update server (unless --quiet is specified)
178
- this.connectToUpdateServer();
138
+ // this.connectToUpdateServer();
179
139
  if (!this.options.quiet) {
180
140
  logger_1.default.info('Waiting for test results...');
181
141
  }
@@ -216,6 +176,20 @@ class Maestro {
216
176
  else {
217
177
  contentType = 'application/octet-stream';
218
178
  }
179
+ if (!this.options.ignoreChecksumCheck) {
180
+ const checksum = await this.upload.calculateChecksum(appPath);
181
+ const existingApp = await this.checkAppChecksum(checksum);
182
+ if (existingApp) {
183
+ this.appId = existingApp.id;
184
+ if (!this.options.quiet) {
185
+ logger_1.default.info(' App already uploaded, skipping upload');
186
+ }
187
+ return true;
188
+ }
189
+ }
190
+ if (!this.options.quiet) {
191
+ logger_1.default.info('Uploading Maestro App');
192
+ }
219
193
  const result = await this.upload.upload({
220
194
  filePath: appPath,
221
195
  url: `${this.URL}/app`,
@@ -226,6 +200,31 @@ class Maestro {
226
200
  this.appId = result.id;
227
201
  return true;
228
202
  }
203
+ async checkAppChecksum(checksum) {
204
+ try {
205
+ const response = await axios_1.default.post(`${this.URL}/app/checksum`, { checksum }, {
206
+ headers: {
207
+ 'Content-Type': 'application/json',
208
+ 'User-Agent': utils_1.default.getUserAgent(),
209
+ },
210
+ auth: {
211
+ username: this.credentials.userName,
212
+ password: this.credentials.accessKey,
213
+ },
214
+ });
215
+ const latestVersion = response.headers?.['x-testingbotctl-version'];
216
+ utils_1.default.checkForUpdate(latestVersion);
217
+ const result = response.data;
218
+ if (result.app_exists && result.id) {
219
+ return { id: result.id };
220
+ }
221
+ return null;
222
+ }
223
+ catch {
224
+ // If checksum check fails, proceed with upload
225
+ return null;
226
+ }
227
+ }
229
228
  async uploadFlows() {
230
229
  const flowsPaths = this.options.flows;
231
230
  let zipPath;
@@ -236,7 +235,6 @@ class Maestro {
236
235
  const stat = await node_fs_1.default.promises.stat(singlePath).catch(() => null);
237
236
  if (stat?.isFile() && node_path_1.default.extname(singlePath).toLowerCase() === '.zip') {
238
237
  zipPath = singlePath;
239
- // Upload the zip directly without cleanup
240
238
  await this.upload.upload({
241
239
  filePath: zipPath,
242
240
  url: `${this.URL}/${this.appId}/tests`,
@@ -292,6 +290,9 @@ class Maestro {
292
290
  // Determine base directory for zip structure
293
291
  // If we have a single directory, use it as base; otherwise use common ancestor or flatten
294
292
  const baseDir = baseDirs.length === 1 ? baseDirs[0] : undefined;
293
+ if (!this.options.quiet) {
294
+ this.logIncludedFiles(allFlowFiles, baseDir);
295
+ }
295
296
  zipPath = await this.createFlowsZip(allFlowFiles, baseDir);
296
297
  shouldCleanup = true;
297
298
  try {
@@ -350,59 +351,31 @@ class Maestro {
350
351
  const dependencies = await this.discoverDependencies(flowFile, directory);
351
352
  dependencies.forEach((dep) => allFiles.add(dep));
352
353
  }
354
+ // Include config.yaml if it exists
355
+ if (config) {
356
+ allFiles.add(configPath);
357
+ }
353
358
  return Array.from(allFiles);
354
359
  }
355
- async discoverDependencies(flowFile, baseDir) {
360
+ async discoverDependencies(flowFile, baseDir, visited = new Set()) {
361
+ // Normalize path to handle different relative path references to same file
362
+ const normalizedFlowFile = node_path_1.default.resolve(flowFile);
363
+ // Prevent circular dependencies
364
+ if (visited.has(normalizedFlowFile)) {
365
+ return [];
366
+ }
367
+ visited.add(normalizedFlowFile);
356
368
  const dependencies = [];
357
369
  try {
358
370
  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
- }
378
- }
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
- }
389
- }
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
- }
403
- }
404
- }
405
- }
371
+ // Maestro YAML files can have front matter (metadata) followed by ---
372
+ // and then the actual flow steps. Use loadAll to handle both cases.
373
+ const documents = [];
374
+ yaml.loadAll(content, (doc) => documents.push(doc));
375
+ for (const flowData of documents) {
376
+ if (flowData !== null && typeof flowData === 'object') {
377
+ const deps = await this.extractPathsFromValue(flowData, flowFile, baseDir, visited);
378
+ dependencies.push(...deps);
406
379
  }
407
380
  }
408
381
  }
@@ -411,6 +384,209 @@ class Maestro {
411
384
  }
412
385
  return dependencies;
413
386
  }
387
+ /**
388
+ * Check if a string looks like a file path (relative path with extension)
389
+ */
390
+ looksLikePath(value) {
391
+ // Must be a relative path (starts with . or contains /)
392
+ const isRelative = value.startsWith('./') || value.startsWith('../');
393
+ const hasPathSeparator = value.includes('/');
394
+ // Must have a file extension
395
+ const hasExtension = /\.[a-zA-Z0-9]+$/.test(value);
396
+ // Exclude URLs
397
+ const isUrl = value.startsWith('http://') ||
398
+ value.startsWith('https://') ||
399
+ value.startsWith('file://');
400
+ // Exclude template variables that are just ${...}
401
+ const isOnlyVariable = /^\$\{[^}]+\}$/.test(value);
402
+ return ((isRelative || hasPathSeparator) &&
403
+ hasExtension &&
404
+ !isUrl &&
405
+ !isOnlyVariable);
406
+ }
407
+ /**
408
+ * Try to add a file path as a dependency if it exists
409
+ */
410
+ async tryAddDependency(filePath, flowFile, baseDir, dependencies, visited) {
411
+ const depPath = node_path_1.default.resolve(node_path_1.default.dirname(flowFile), filePath);
412
+ // Check if already added (handles deduplication for non-YAML files)
413
+ // YAML files are tracked by discoverDependencies to handle circular refs
414
+ if (visited.has(depPath)) {
415
+ return;
416
+ }
417
+ try {
418
+ await node_fs_1.default.promises.access(depPath);
419
+ dependencies.push(depPath);
420
+ // If it's a YAML file, recursively discover its dependencies
421
+ // discoverDependencies will add it to visited to prevent circular refs
422
+ const ext = node_path_1.default.extname(depPath).toLowerCase();
423
+ if (ext === '.yaml' || ext === '.yml') {
424
+ const nestedDeps = await this.discoverDependencies(depPath, baseDir, visited);
425
+ dependencies.push(...nestedDeps);
426
+ }
427
+ else {
428
+ // For non-YAML files, add to visited here to prevent duplicates
429
+ visited.add(depPath);
430
+ }
431
+ }
432
+ catch {
433
+ // File doesn't exist, skip it
434
+ }
435
+ }
436
+ /**
437
+ * Recursively extract file paths from any value in the YAML structure
438
+ */
439
+ async extractPathsFromValue(value, flowFile, baseDir, visited) {
440
+ const dependencies = [];
441
+ if (typeof value === 'string') {
442
+ // Check if this string looks like a file path
443
+ if (this.looksLikePath(value)) {
444
+ await this.tryAddDependency(value, flowFile, baseDir, dependencies, visited);
445
+ }
446
+ }
447
+ else if (Array.isArray(value)) {
448
+ // Recursively check array elements
449
+ for (const item of value) {
450
+ const deps = await this.extractPathsFromValue(item, flowFile, baseDir, visited);
451
+ dependencies.push(...deps);
452
+ }
453
+ }
454
+ else if (value !== null && typeof value === 'object') {
455
+ const obj = value;
456
+ // Track which keys we've handled specially to avoid double-processing
457
+ const handledKeys = new Set();
458
+ // Handle known Maestro commands that reference files
459
+ // These should always be treated as file paths, even without path separators
460
+ // runScript: can be string or { file: "..." }
461
+ if ('runScript' in obj) {
462
+ handledKeys.add('runScript');
463
+ const runScript = obj.runScript;
464
+ const scriptFile = typeof runScript === 'string'
465
+ ? runScript
466
+ : runScript?.file;
467
+ if (typeof scriptFile === 'string') {
468
+ await this.tryAddDependency(scriptFile, flowFile, baseDir, dependencies, visited);
469
+ }
470
+ }
471
+ // runFlow: can be string or { file: "...", commands: [...] }
472
+ if ('runFlow' in obj) {
473
+ handledKeys.add('runFlow');
474
+ const runFlow = obj.runFlow;
475
+ const flowRef = typeof runFlow === 'string'
476
+ ? runFlow
477
+ : runFlow?.file;
478
+ if (typeof flowRef === 'string') {
479
+ await this.tryAddDependency(flowRef, flowFile, baseDir, dependencies, visited);
480
+ }
481
+ // Recurse into runFlow for inline commands
482
+ if (typeof runFlow === 'object' && runFlow !== null) {
483
+ const deps = await this.extractPathsFromValue(runFlow, flowFile, baseDir, visited);
484
+ dependencies.push(...deps);
485
+ }
486
+ }
487
+ // addMedia: can be string or array of strings
488
+ if ('addMedia' in obj) {
489
+ handledKeys.add('addMedia');
490
+ const addMedia = obj.addMedia;
491
+ const mediaFiles = Array.isArray(addMedia) ? addMedia : [addMedia];
492
+ for (const mediaFile of mediaFiles) {
493
+ if (typeof mediaFile === 'string') {
494
+ await this.tryAddDependency(mediaFile, flowFile, baseDir, dependencies, visited);
495
+ }
496
+ }
497
+ }
498
+ // onFlowStart: array of commands in frontmatter
499
+ if ('onFlowStart' in obj) {
500
+ handledKeys.add('onFlowStart');
501
+ const onFlowStart = obj.onFlowStart;
502
+ if (Array.isArray(onFlowStart)) {
503
+ const deps = await this.extractPathsFromValue(onFlowStart, flowFile, baseDir, visited);
504
+ dependencies.push(...deps);
505
+ }
506
+ }
507
+ // onFlowComplete: array of commands in frontmatter
508
+ if ('onFlowComplete' in obj) {
509
+ handledKeys.add('onFlowComplete');
510
+ const onFlowComplete = obj.onFlowComplete;
511
+ if (Array.isArray(onFlowComplete)) {
512
+ const deps = await this.extractPathsFromValue(onFlowComplete, flowFile, baseDir, visited);
513
+ dependencies.push(...deps);
514
+ }
515
+ }
516
+ // Generic handling for any command with nested 'commands' array
517
+ // This covers repeat, retry, doubleTapOn, longPressOn, and any future commands
518
+ // that use the commands pattern
519
+ if ('commands' in obj) {
520
+ handledKeys.add('commands');
521
+ const commands = obj.commands;
522
+ if (Array.isArray(commands)) {
523
+ const deps = await this.extractPathsFromValue(commands, flowFile, baseDir, visited);
524
+ dependencies.push(...deps);
525
+ }
526
+ }
527
+ // Generic handling for 'file' property in any command (e.g., retry: { file: ... })
528
+ if ('file' in obj && typeof obj.file === 'string') {
529
+ handledKeys.add('file');
530
+ await this.tryAddDependency(obj.file, flowFile, baseDir, dependencies, visited);
531
+ }
532
+ // Recursively check remaining object properties for nested structures
533
+ for (const [key, propValue] of Object.entries(obj)) {
534
+ if (!handledKeys.has(key)) {
535
+ const deps = await this.extractPathsFromValue(propValue, flowFile, baseDir, visited);
536
+ dependencies.push(...deps);
537
+ }
538
+ }
539
+ }
540
+ return dependencies;
541
+ }
542
+ logIncludedFiles(files, baseDir) {
543
+ // Get relative paths for display
544
+ const relativePaths = files
545
+ .map((f) => (baseDir ? node_path_1.default.relative(baseDir, f) : node_path_1.default.basename(f)))
546
+ .sort();
547
+ // Group by file type
548
+ const groups = {
549
+ 'Flow files': [],
550
+ Scripts: [],
551
+ 'Media files': [],
552
+ 'Config files': [],
553
+ Other: [],
554
+ };
555
+ for (const filePath of relativePaths) {
556
+ const ext = node_path_1.default.extname(filePath).toLowerCase();
557
+ if (ext === '.yaml' || ext === '.yml') {
558
+ if (filePath === 'config.yaml' || filePath.endsWith('/config.yaml')) {
559
+ groups['Config files'].push(filePath);
560
+ }
561
+ else {
562
+ groups['Flow files'].push(filePath);
563
+ }
564
+ }
565
+ else if (ext === '.js' || ext === '.ts') {
566
+ groups['Scripts'].push(filePath);
567
+ }
568
+ else if (['.jpg', '.jpeg', '.png', '.gif', '.webp', '.mp4', '.mov'].includes(ext)) {
569
+ groups['Media files'].push(filePath);
570
+ }
571
+ else {
572
+ groups['Other'].push(filePath);
573
+ }
574
+ }
575
+ logger_1.default.info(`Bundling ${files.length} files into flows.zip:`);
576
+ for (const [groupName, groupFiles] of Object.entries(groups)) {
577
+ if (groupFiles.length > 0) {
578
+ logger_1.default.info(` ${groupName} (${groupFiles.length}):`);
579
+ // Show first 10 files, then summarize if more
580
+ const displayFiles = groupFiles.slice(0, 10);
581
+ for (const file of displayFiles) {
582
+ logger_1.default.info(` - ${file}`);
583
+ }
584
+ if (groupFiles.length > 10) {
585
+ logger_1.default.info(` ... and ${groupFiles.length - 10} more`);
586
+ }
587
+ }
588
+ }
589
+ }
414
590
  async createFlowsZip(files, baseDir) {
415
591
  const tmpDir = await node_fs_1.default.promises.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), 'maestro-'));
416
592
  const zipPath = node_path_1.default.join(tmpDir, 'flows.zip');
@@ -438,9 +614,14 @@ class Maestro {
438
614
  try {
439
615
  const capabilities = this.options.getCapabilities(this.detectedPlatform);
440
616
  const maestroOptions = this.options.getMaestroOptions();
617
+ const metadata = this.options.metadata;
441
618
  const response = await axios_1.default.post(`${this.URL}/${this.appId}/run`, {
442
619
  capabilities: [capabilities],
443
620
  ...(maestroOptions && { maestroOptions }),
621
+ ...(this.options.shardSplit && {
622
+ shardSplit: this.options.shardSplit,
623
+ }),
624
+ ...(metadata && { metadata }),
444
625
  }, {
445
626
  headers: {
446
627
  'Content-Type': 'application/json',
@@ -450,6 +631,7 @@ class Maestro {
450
631
  username: this.credentials.userName,
451
632
  password: this.credentials.accessKey,
452
633
  },
634
+ timeout: 30000, // 30 second timeout
453
635
  });
454
636
  // Check for version update notification
455
637
  const latestVersion = response.headers?.['x-testingbotctl-version'];
@@ -473,34 +655,40 @@ class Maestro {
473
655
  if (error instanceof testingbot_error_1.default) {
474
656
  throw error;
475
657
  }
476
- throw new testingbot_error_1.default(`Running Maestro test failed`, {
477
- cause: error,
478
- });
658
+ throw await this.handleErrorWithDiagnostics(error, 'Running Maestro test failed');
479
659
  }
480
660
  }
481
661
  async getStatus() {
482
662
  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
- },
663
+ return await this.withRetry('Getting Maestro test status', async () => {
664
+ const response = await axios_1.default.get(`${this.URL}/${this.appId}`, {
665
+ headers: {
666
+ 'User-Agent': utils_1.default.getUserAgent(),
667
+ },
668
+ auth: {
669
+ username: this.credentials.userName,
670
+ password: this.credentials.accessKey,
671
+ },
672
+ timeout: 30000, // 30 second timeout
673
+ });
674
+ // Check for version update notification
675
+ const latestVersion = response.headers?.['x-testingbotctl-version'];
676
+ utils_1.default.checkForUpdate(latestVersion);
677
+ return response.data;
491
678
  });
492
- return response.data;
493
679
  }
494
680
  catch (error) {
495
- throw new testingbot_error_1.default(`Failed to get Maestro test status`, {
496
- cause: error,
497
- });
681
+ throw await this.handleErrorWithDiagnostics(error, 'Failed to get Maestro test status');
498
682
  }
499
683
  }
500
684
  async waitForCompletion() {
501
685
  let attempts = 0;
502
686
  const startTime = Date.now();
503
687
  const previousStatus = new Map();
688
+ const previousFlowStatus = new Map();
689
+ const urlDisplayed = new Set();
690
+ let flowsTableDisplayed = false;
691
+ let displayedLineCount = 0;
504
692
  while (attempts < this.MAX_POLL_ATTEMPTS) {
505
693
  // Check if we're shutting down
506
694
  if (this.isShuttingDown) {
@@ -513,17 +701,76 @@ class Maestro {
513
701
  .map((run) => run.id);
514
702
  // Log current status of runs (unless quiet mode)
515
703
  if (!this.options.quiet) {
516
- this.displayRunStatus(status.runs, startTime, previousStatus);
704
+ // Check if any run has flows and display them
705
+ const allFlows = [];
706
+ for (const run of status.runs) {
707
+ if (run.flows && run.flows.length > 0) {
708
+ allFlows.push(...run.flows);
709
+ }
710
+ }
711
+ // Show realtime URL once per run (before any in-place updates)
712
+ for (const run of status.runs) {
713
+ if (!urlDisplayed.has(run.id)) {
714
+ console.log(` 🔗 Run ${run.id} (${this.getRunDisplayName(run)}): Watch in realtime:`);
715
+ console.log(` https://testingbot.com/members/maestro/${this.appId}/runs/${run.id}`);
716
+ urlDisplayed.add(run.id);
717
+ }
718
+ }
719
+ if (allFlows.length > 0) {
720
+ // Check if any flow has failed (for showing error column)
721
+ const hasFailures = this.hasAnyFlowFailed(allFlows);
722
+ if (!flowsTableDisplayed) {
723
+ // First time showing flows - display header and initial state
724
+ console.log(); // Empty line before flows table
725
+ this.displayFlowsTableHeader(hasFailures);
726
+ displayedLineCount = this.displayFlowsWithLimit(allFlows, previousFlowStatus, hasFailures);
727
+ flowsTableDisplayed = true;
728
+ }
729
+ else {
730
+ // Update flows in place
731
+ displayedLineCount = this.updateFlowsInPlace(allFlows, previousFlowStatus, displayedLineCount);
732
+ }
733
+ }
734
+ else {
735
+ // No flows yet, show run status
736
+ this.displayRunStatus(status.runs, startTime, previousStatus);
737
+ }
517
738
  }
518
739
  if (status.completed) {
519
- // Clear the updating line and print final status
740
+ // Display final flows table with error messages if there are failures
741
+ if (!this.options.quiet && flowsTableDisplayed) {
742
+ const allFlows = [];
743
+ for (const run of status.runs) {
744
+ if (run.flows && run.flows.length > 0) {
745
+ allFlows.push(...run.flows);
746
+ }
747
+ }
748
+ const hasFailures = this.hasAnyFlowFailed(allFlows);
749
+ if (hasFailures) {
750
+ // Move cursor up to overwrite the existing table
751
+ // +2 for header and separator lines
752
+ const linesToMove = displayedLineCount + 2;
753
+ process.stdout.write(`\x1b[${linesToMove}A`);
754
+ // Clear header line, write new header, then clear separator line
755
+ process.stdout.write('\x1b[2K');
756
+ console.log(colors_1.default.dim(` ${'Duration'.padEnd(10)} ${'Status'.padEnd(8)} Flow Fail reason`));
757
+ process.stdout.write('\x1b[2K');
758
+ console.log(colors_1.default.dim(` ${'─'.repeat(10)} ${'─'.repeat(8)} ${'─'.repeat(30)} ${'─'.repeat(80)}`));
759
+ // Redraw all flows with error messages
760
+ for (const flow of allFlows) {
761
+ // Clear the line before writing
762
+ process.stdout.write('\x1b[2K');
763
+ this.displayFlowRow(flow, false, true);
764
+ }
765
+ }
766
+ }
767
+ // Print final summary
520
768
  if (!this.options.quiet) {
521
- this.clearLine();
769
+ console.log(); // Empty line before summary
522
770
  for (const run of status.runs) {
523
771
  const statusEmoji = run.success === 1 ? '✅' : '❌';
524
772
  const statusText = run.success === 1 ? 'Test completed successfully' : 'Test failed';
525
- console.log(` ${statusEmoji} Run ${run.id} (${run.capabilities.deviceName}): ${statusText}`);
526
- console.log(` View results: https://testingbot.com/members/maestro/${this.appId}/runs/${run.id}`);
773
+ console.log(` ${statusEmoji} Run ${run.id} (${this.getRunDisplayName(run)}): ${statusText}`);
527
774
  }
528
775
  }
529
776
  const allSucceeded = status.runs.every((run) => run.success === 1);
@@ -534,17 +781,12 @@ class Maestro {
534
781
  }
535
782
  else {
536
783
  const failedRuns = status.runs.filter((run) => run.success !== 1);
537
- logger_1.default.error(`${failedRuns.length} test run(s) failed:`);
538
- for (const run of failedRuns) {
539
- logger_1.default.error(` - Run ${run.id} (${run.capabilities.deviceName}): ${run.report}`);
540
- }
784
+ logger_1.default.error(`${failedRuns.length} test run(s) failed`);
541
785
  }
542
- // Fetch reports if requested
543
786
  if (this.options.report && this.options.reportOutputDir) {
544
787
  await this.fetchReports(status.runs);
545
788
  }
546
- // Download artifacts if requested
547
- if (this.options.downloadArtifacts && this.options.artifactsOutputDir) {
789
+ if (this.options.downloadArtifacts) {
548
790
  await this.downloadArtifacts(status.runs);
549
791
  }
550
792
  return {
@@ -573,25 +815,21 @@ class Maestro {
573
815
  const statusInfo = this.getStatusInfo(run.status);
574
816
  if (run.status === 'WAITING' || run.status === 'READY') {
575
817
  // Update the same line for WAITING and READY states
576
- const message = ` ${statusInfo.emoji} Run ${run.id} (${run.capabilities.deviceName}): ${statusInfo.text} (${elapsedStr})`;
818
+ const message = ` ${statusInfo.emoji} Run ${run.id} (${this.getRunDisplayName(run)}): ${statusInfo.text} (${elapsedStr})`;
577
819
  process.stdout.write(`\r${message}`);
578
820
  }
579
821
  else if (statusChanged) {
580
822
  // For other states (DONE, FAILED), print on a new line only when status changes
581
- console.log(` ${statusInfo.emoji} Run ${run.id} (${run.capabilities.deviceName}): ${statusInfo.text}`);
823
+ console.log(` ${statusInfo.emoji} Run ${run.id} (${this.getRunDisplayName(run)}): ${statusInfo.text}`);
582
824
  }
583
825
  }
584
826
  }
585
- clearLine() {
586
- platform_1.default.clearLine();
587
- }
588
- formatElapsedTime(seconds) {
589
- if (seconds < 60) {
590
- return `${seconds}s`;
591
- }
592
- const minutes = Math.floor(seconds / 60);
593
- const remainingSeconds = seconds % 60;
594
- return `${minutes}m ${remainingSeconds}s`;
827
+ /**
828
+ * Get the display name for a run, preferring environment.name over capabilities.deviceName
829
+ * This shows the actual device used when a wildcard (*) was specified
830
+ */
831
+ getRunDisplayName(run) {
832
+ return run.environment?.name || run.capabilities.deviceName;
595
833
  }
596
834
  getStatusInfo(status) {
597
835
  switch (status) {
@@ -607,6 +845,202 @@ class Maestro {
607
845
  return { emoji: '❓', text: status };
608
846
  }
609
847
  }
848
+ getFlowStatusDisplay(flow) {
849
+ switch (flow.status) {
850
+ case 'WAITING':
851
+ return { text: 'WAITING', colored: colors_1.default.white('WAITING') };
852
+ case 'READY':
853
+ return { text: 'RUNNING', colored: colors_1.default.blue('RUNNING') };
854
+ case 'DONE':
855
+ if (flow.success === 1) {
856
+ return { text: 'PASSED', colored: colors_1.default.green('PASSED') };
857
+ }
858
+ else {
859
+ return { text: 'FAILED', colored: colors_1.default.red('FAILED') };
860
+ }
861
+ case 'FAILED':
862
+ return { text: 'FAILED', colored: colors_1.default.red('FAILED') };
863
+ default:
864
+ return { text: flow.status, colored: flow.status };
865
+ }
866
+ }
867
+ hasAnyFlowFailed(flows) {
868
+ return flows.some((flow) => (flow.status === 'DONE' && flow.success !== 1) ||
869
+ flow.status === 'FAILED' ||
870
+ (flow.error_messages && flow.error_messages.length > 0));
871
+ }
872
+ calculateFlowDuration(flow) {
873
+ if (!flow.requested_at) {
874
+ return '-';
875
+ }
876
+ const startTime = new Date(flow.requested_at).getTime();
877
+ let endTime;
878
+ if (flow.completed_at) {
879
+ endTime = new Date(flow.completed_at).getTime();
880
+ }
881
+ else {
882
+ endTime = Date.now();
883
+ }
884
+ const durationSeconds = Math.floor((endTime - startTime) / 1000);
885
+ return this.formatElapsedTime(durationSeconds);
886
+ }
887
+ getTerminalHeight() {
888
+ // Default to 24 if terminal height is not available
889
+ return process.stdout.rows || 24;
890
+ }
891
+ getMaxDisplayableFlows() {
892
+ const terminalHeight = this.getTerminalHeight();
893
+ // Reserve lines for: header (2) + summary line (1) + some padding (3)
894
+ const reservedLines = 6;
895
+ return Math.max(5, terminalHeight - reservedLines);
896
+ }
897
+ getRemainingSummary(flows, displayedCount) {
898
+ const remaining = flows.slice(displayedCount);
899
+ if (remaining.length === 0) {
900
+ return '';
901
+ }
902
+ // Count statuses for remaining flows
903
+ let waiting = 0;
904
+ let running = 0;
905
+ let passed = 0;
906
+ let failed = 0;
907
+ for (const flow of remaining) {
908
+ switch (flow.status) {
909
+ case 'WAITING':
910
+ waiting++;
911
+ break;
912
+ case 'READY':
913
+ running++;
914
+ break;
915
+ case 'DONE':
916
+ if (flow.success === 1) {
917
+ passed++;
918
+ }
919
+ else {
920
+ failed++;
921
+ }
922
+ break;
923
+ case 'FAILED':
924
+ failed++;
925
+ break;
926
+ }
927
+ }
928
+ const parts = [];
929
+ if (waiting > 0)
930
+ parts.push(colors_1.default.white(`${waiting} waiting`));
931
+ if (running > 0)
932
+ parts.push(colors_1.default.blue(`${running} running`));
933
+ if (passed > 0)
934
+ parts.push(colors_1.default.green(`${passed} passed`));
935
+ if (failed > 0)
936
+ parts.push(colors_1.default.red(`${failed} failed`));
937
+ return ` ... and ${remaining.length} more: ${parts.join(', ')}`;
938
+ }
939
+ displayFlowsWithLimit(flows, previousFlowStatus, hasFailures = false) {
940
+ const maxFlows = this.getMaxDisplayableFlows();
941
+ const displayFlows = flows.slice(0, maxFlows);
942
+ let linesWritten = 0;
943
+ for (const flow of displayFlows) {
944
+ linesWritten += this.displayFlowRow(flow, false, hasFailures);
945
+ previousFlowStatus.set(flow.id, flow.status);
946
+ }
947
+ // Show summary for remaining flows
948
+ if (flows.length > maxFlows) {
949
+ const summary = this.getRemainingSummary(flows, maxFlows);
950
+ console.log(colors_1.default.dim(summary));
951
+ linesWritten++;
952
+ }
953
+ return linesWritten;
954
+ }
955
+ displayFlowsTableHeader(hasFailures = false) {
956
+ let header = ` ${'Duration'.padEnd(10)} ${'Status'.padEnd(8)} Flow`;
957
+ let separator = ` ${'─'.repeat(10)} ${'─'.repeat(8)} ${'─'.repeat(30)}`;
958
+ if (hasFailures) {
959
+ header += ' Fail reason';
960
+ separator += ` ${'─'.repeat(80)}`;
961
+ }
962
+ console.log(colors_1.default.dim(header));
963
+ console.log(colors_1.default.dim(separator));
964
+ }
965
+ displayFlowRow(flow, isUpdate = false, hasFailures = false) {
966
+ const duration = this.calculateFlowDuration(flow).padEnd(10);
967
+ const statusDisplay = this.getFlowStatusDisplay(flow);
968
+ // Pad based on display text length, add extra for color codes
969
+ const statusPadded = statusDisplay.colored +
970
+ ' '.repeat(Math.max(0, 8 - statusDisplay.text.length));
971
+ const name = flow.name.padEnd(30);
972
+ let linesWritten = 0;
973
+ const isFailed = flow.status === 'DONE' && flow.success !== 1;
974
+ const errorMessages = flow.error_messages || [];
975
+ // Build the main row
976
+ let row = ` ${duration} ${statusPadded} ${name}`;
977
+ // Add first error message on the same line if failed and has errors
978
+ if (hasFailures && isFailed && errorMessages.length > 0) {
979
+ row += ` ${colors_1.default.red(errorMessages[0])}`;
980
+ }
981
+ if (isUpdate) {
982
+ process.stdout.write(`\r${row}`);
983
+ }
984
+ else {
985
+ console.log(row);
986
+ }
987
+ linesWritten++;
988
+ // Display remaining error messages on continuation lines
989
+ if (!isUpdate && hasFailures && isFailed && errorMessages.length > 1) {
990
+ // Indent to align with the Fail reason column: Duration(11) + Status(9) + Test(31) = 51 chars
991
+ const indent = ' '.repeat(51);
992
+ for (let i = 1; i < errorMessages.length; i++) {
993
+ console.log(`${indent} ${colors_1.default.red(errorMessages[i])}`);
994
+ linesWritten++;
995
+ }
996
+ }
997
+ return linesWritten;
998
+ }
999
+ displayFlowsTable(flows, previousFlowStatus, showHeader, hasFailures = false) {
1000
+ if (showHeader) {
1001
+ this.displayFlowsTableHeader(hasFailures);
1002
+ }
1003
+ let linesWritten = 0;
1004
+ for (const flow of flows) {
1005
+ const prevStatus = previousFlowStatus.get(flow.id);
1006
+ const isNewFlow = prevStatus === undefined;
1007
+ if (isNewFlow) {
1008
+ linesWritten += this.displayFlowRow(flow, false, hasFailures);
1009
+ }
1010
+ previousFlowStatus.set(flow.id, flow.status);
1011
+ }
1012
+ return linesWritten;
1013
+ }
1014
+ updateFlowsInPlace(flows, previousFlowStatus, displayedLineCount) {
1015
+ const maxFlows = this.getMaxDisplayableFlows();
1016
+ const displayFlows = flows.slice(0, maxFlows);
1017
+ const hasRemaining = flows.length > maxFlows;
1018
+ // Move cursor up by the number of lines we PREVIOUSLY displayed
1019
+ if (displayedLineCount > 0) {
1020
+ process.stdout.write(`\x1b[${displayedLineCount}A`);
1021
+ }
1022
+ let linesWritten = 0;
1023
+ // Redraw displayed flows
1024
+ for (const flow of displayFlows) {
1025
+ const duration = this.calculateFlowDuration(flow).padEnd(10);
1026
+ const statusDisplay = this.getFlowStatusDisplay(flow);
1027
+ const statusPadded = statusDisplay.colored +
1028
+ ' '.repeat(Math.max(0, 8 - statusDisplay.text.length));
1029
+ const name = flow.name;
1030
+ const row = ` ${duration} ${statusPadded} ${name}`;
1031
+ process.stdout.write(`\r\x1b[K${row}\n`);
1032
+ previousFlowStatus.set(flow.id, flow.status);
1033
+ linesWritten++;
1034
+ }
1035
+ // Update or add summary line for remaining flows
1036
+ if (hasRemaining) {
1037
+ const summary = this.getRemainingSummary(flows, maxFlows);
1038
+ process.stdout.write(`\r\x1b[K${colors_1.default.dim(summary)}\n`);
1039
+ linesWritten++;
1040
+ }
1041
+ // Return the number of lines we wrote
1042
+ return linesWritten;
1043
+ }
610
1044
  async fetchReports(runs) {
611
1045
  const reportFormat = this.options.report;
612
1046
  const outputDir = this.options.reportOutputDir;
@@ -627,7 +1061,11 @@ class Maestro {
627
1061
  username: this.credentials.userName,
628
1062
  password: this.credentials.accessKey,
629
1063
  },
1064
+ timeout: 30000, // 30 second timeout
630
1065
  });
1066
+ // Check for version update notification
1067
+ const latestVersion = response.headers?.['x-testingbotctl-version'];
1068
+ utils_1.default.checkForUpdate(latestVersion);
631
1069
  // Extract the report content from the JSON response
632
1070
  const reportKey = reportFormat === 'junit' ? 'junit_report' : 'html_report';
633
1071
  const reportContent = response.data[reportKey];
@@ -650,21 +1088,24 @@ class Maestro {
650
1088
  }
651
1089
  async getRunDetails(runId) {
652
1090
  try {
653
- const response = await axios_1.default.get(`${this.URL}/${this.appId}/${runId}`, {
654
- headers: {
655
- 'User-Agent': utils_1.default.getUserAgent(),
656
- },
657
- auth: {
658
- username: this.credentials.userName,
659
- password: this.credentials.accessKey,
660
- },
1091
+ return await this.withRetry(`Getting run details for run ${runId}`, async () => {
1092
+ const response = await axios_1.default.get(`${this.URL}/${this.appId}/${runId}`, {
1093
+ headers: {
1094
+ 'User-Agent': utils_1.default.getUserAgent(),
1095
+ },
1096
+ auth: {
1097
+ username: this.credentials.userName,
1098
+ password: this.credentials.accessKey,
1099
+ },
1100
+ timeout: 30000, // 30 second timeout
1101
+ });
1102
+ const latestVersion = response.headers?.['x-testingbotctl-version'];
1103
+ utils_1.default.checkForUpdate(latestVersion);
1104
+ return response.data;
661
1105
  });
662
- return response.data;
663
1106
  }
664
1107
  catch (error) {
665
- throw new testingbot_error_1.default(`Failed to get run details for run ${runId}`, {
666
- cause: error,
667
- });
1108
+ throw await this.handleErrorWithDiagnostics(error, `Failed to get run details for run ${runId}`);
668
1109
  }
669
1110
  }
670
1111
  async waitForArtifactsSync(runId) {
@@ -680,42 +1121,105 @@ class Maestro {
680
1121
  }
681
1122
  throw new testingbot_error_1.default(`Timed out waiting for artifacts to sync for run ${runId}`);
682
1123
  }
683
- async downloadFile(url, filePath) {
684
- try {
685
- const response = await axios_1.default.get(url, {
686
- responseType: 'arraybuffer',
687
- headers: {
688
- 'User-Agent': utils_1.default.getUserAgent(),
689
- },
690
- });
691
- await node_fs_1.default.promises.writeFile(filePath, response.data);
1124
+ async downloadFile(url, filePath, retries = 3) {
1125
+ let lastError;
1126
+ for (let attempt = 1; attempt <= retries; attempt++) {
1127
+ try {
1128
+ const response = await axios_1.default.get(url, {
1129
+ responseType: 'arraybuffer',
1130
+ timeout: 60000, // 60 second timeout for large files
1131
+ });
1132
+ await node_fs_1.default.promises.writeFile(filePath, response.data);
1133
+ return;
1134
+ }
1135
+ catch (error) {
1136
+ lastError = error;
1137
+ // Don't retry on 4xx errors (client errors like 403, 404)
1138
+ if (axios_1.default.isAxiosError(error) && error.response?.status) {
1139
+ const status = error.response.status;
1140
+ if (status >= 400 && status < 500) {
1141
+ break;
1142
+ }
1143
+ }
1144
+ // Wait before retrying (exponential backoff)
1145
+ if (attempt < retries) {
1146
+ await this.sleep(1000 * attempt);
1147
+ }
1148
+ }
692
1149
  }
693
- catch (error) {
694
- throw new testingbot_error_1.default(`Failed to download file from ${url}`, {
695
- cause: error,
696
- });
1150
+ // Extract detailed error message
1151
+ let errorDetail = '';
1152
+ if (axios_1.default.isAxiosError(lastError)) {
1153
+ if (lastError.response) {
1154
+ errorDetail = `HTTP ${lastError.response.status}: ${lastError.response.statusText}`;
1155
+ }
1156
+ else if (lastError.code) {
1157
+ errorDetail = lastError.code;
1158
+ }
1159
+ else if (lastError.message) {
1160
+ errorDetail = lastError.message;
1161
+ }
1162
+ }
1163
+ else if (lastError instanceof Error) {
1164
+ errorDetail = lastError.message;
1165
+ }
1166
+ else if (lastError) {
1167
+ errorDetail = String(lastError);
697
1168
  }
1169
+ throw new testingbot_error_1.default(`Failed to download file${errorDetail ? `: ${errorDetail}` : ''}`, {
1170
+ cause: lastError,
1171
+ });
698
1172
  }
699
- generateArtifactZipName() {
700
- // Use --build option if provided, otherwise generate timestamp-based name
701
- if (this.options.build) {
702
- const sanitizedBuild = this.options.build.replace(/[^a-zA-Z0-9_-]/g, '_');
703
- return `${sanitizedBuild}.zip`;
1173
+ async generateArtifactZipName(outputDir) {
1174
+ if (!this.options.name) {
1175
+ // Generate unique name with timestamp
1176
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
1177
+ return `maestro_artifacts_${timestamp}.zip`;
1178
+ }
1179
+ const baseName = this.options.name.replace(/[^a-zA-Z0-9_-]/g, '_');
1180
+ const fileName = `${baseName}.zip`;
1181
+ const filePath = node_path_1.default.join(outputDir, fileName);
1182
+ try {
1183
+ await node_fs_1.default.promises.access(filePath);
1184
+ // File exists, append timestamp
1185
+ return `${baseName}_${Date.now()}.zip`;
1186
+ }
1187
+ catch {
1188
+ // File doesn't exist, use base name
1189
+ return fileName;
704
1190
  }
705
- // Generate unique name with timestamp
706
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
707
- return `maestro_artifacts_${timestamp}.zip`;
708
1191
  }
709
1192
  async downloadArtifacts(runs) {
710
1193
  if (!this.options.downloadArtifacts)
711
1194
  return;
1195
+ // Filter runs based on download mode
1196
+ const downloadMode = this.options.downloadArtifacts;
1197
+ const runsToDownload = downloadMode === 'failed'
1198
+ ? runs.filter((run) => run.success !== 1)
1199
+ : runs;
1200
+ if (runsToDownload.length === 0) {
1201
+ if (!this.options.quiet) {
1202
+ if (downloadMode === 'failed') {
1203
+ logger_1.default.info('No failed runs to download artifacts for.');
1204
+ }
1205
+ else {
1206
+ logger_1.default.info('No runs to download artifacts for.');
1207
+ }
1208
+ }
1209
+ return;
1210
+ }
712
1211
  if (!this.options.quiet) {
713
- logger_1.default.info('Downloading artifacts...');
1212
+ if (downloadMode === 'failed') {
1213
+ logger_1.default.info(`Downloading artifacts for ${runsToDownload.length} failed run(s)...`);
1214
+ }
1215
+ else {
1216
+ logger_1.default.info('Downloading artifacts...');
1217
+ }
714
1218
  }
715
1219
  const outputDir = this.options.artifactsOutputDir || process.cwd();
716
1220
  const tempDir = await node_fs_1.default.promises.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), 'testingbot-maestro-artifacts-'));
717
1221
  try {
718
- for (const run of runs) {
1222
+ for (const run of runsToDownload) {
719
1223
  try {
720
1224
  if (!this.options.quiet) {
721
1225
  logger_1.default.info(` Waiting for artifacts sync for run ${run.id}...`);
@@ -729,13 +1233,12 @@ class Maestro {
729
1233
  }
730
1234
  const runDir = node_path_1.default.join(tempDir, `run_${run.id}`);
731
1235
  await node_fs_1.default.promises.mkdir(runDir, { recursive: true });
732
- // Download logs
733
- if (runDetails.assets.logs && runDetails.assets.logs.length > 0) {
1236
+ if (runDetails.assets.logs &&
1237
+ Object.keys(runDetails.assets.logs).length > 0) {
734
1238
  const logsDir = node_path_1.default.join(runDir, 'logs');
735
1239
  await node_fs_1.default.promises.mkdir(logsDir, { recursive: true });
736
- for (let i = 0; i < runDetails.assets.logs.length; i++) {
737
- const logUrl = runDetails.assets.logs[i];
738
- const logFileName = node_path_1.default.basename(logUrl) || `log_${i}.txt`;
1240
+ for (const [logName, logUrl] of Object.entries(runDetails.assets.logs)) {
1241
+ const logFileName = `${logName}.txt`;
739
1242
  const logPath = node_path_1.default.join(logsDir, logFileName);
740
1243
  try {
741
1244
  await this.downloadFile(logUrl, logPath);
@@ -753,7 +1256,7 @@ class Maestro {
753
1256
  const videoDir = node_path_1.default.join(runDir, 'video');
754
1257
  await node_fs_1.default.promises.mkdir(videoDir, { recursive: true });
755
1258
  const videoUrl = runDetails.assets.video;
756
- const videoFileName = node_path_1.default.basename(videoUrl) || 'video.mp4';
1259
+ const videoFileName = 'video.mp4';
757
1260
  const videoPath = node_path_1.default.join(videoDir, videoFileName);
758
1261
  try {
759
1262
  await this.downloadFile(videoUrl, videoPath);
@@ -771,7 +1274,7 @@ class Maestro {
771
1274
  await node_fs_1.default.promises.mkdir(screenshotsDir, { recursive: true });
772
1275
  for (let i = 0; i < runDetails.assets.screenshots.length; i++) {
773
1276
  const screenshotUrl = runDetails.assets.screenshots[i];
774
- const screenshotFileName = node_path_1.default.basename(screenshotUrl) || `screenshot_${i}.png`;
1277
+ const screenshotFileName = `screenshot_${i}.png`;
775
1278
  const screenshotPath = node_path_1.default.join(screenshotsDir, screenshotFileName);
776
1279
  try {
777
1280
  await this.downloadFile(screenshotUrl, screenshotPath);
@@ -804,7 +1307,7 @@ class Maestro {
804
1307
  logger_1.default.error(`Failed to download artifacts for run ${run.id}: ${error instanceof Error ? error.message : error}`);
805
1308
  }
806
1309
  }
807
- const zipFileName = this.generateArtifactZipName();
1310
+ const zipFileName = await this.generateArtifactZipName(outputDir);
808
1311
  const zipFilePath = node_path_1.default.join(outputDir, zipFileName);
809
1312
  if (!this.options.quiet) {
810
1313
  logger_1.default.info(`Creating artifacts zip: ${zipFileName}`);
@@ -834,112 +1337,6 @@ class Maestro {
834
1337
  archive.finalize();
835
1338
  });
836
1339
  }
837
- sleep(ms) {
838
- return new Promise((resolve) => setTimeout(resolve, ms));
839
- }
840
- extractErrorMessage(cause) {
841
- if (typeof cause === 'string') {
842
- return cause;
843
- }
844
- // Handle arrays of errors
845
- if (Array.isArray(cause)) {
846
- return cause.join('\n');
847
- }
848
- if (cause && typeof cause === 'object') {
849
- // Handle axios errors which have response.data
850
- const axiosError = cause;
851
- if (axiosError.response?.data?.errors) {
852
- return axiosError.response.data.errors.join('\n');
853
- }
854
- if (axiosError.response?.data?.error) {
855
- return axiosError.response.data.error;
856
- }
857
- if (axiosError.response?.data?.message) {
858
- return axiosError.response.data.message;
859
- }
860
- // Handle standard Error objects
861
- if (cause instanceof Error) {
862
- return cause.message;
863
- }
864
- // Handle plain objects with errors array, error, or message property
865
- const obj = cause;
866
- if (obj.errors) {
867
- return obj.errors.join('\n');
868
- }
869
- if (obj.error) {
870
- return obj.error;
871
- }
872
- if (obj.message) {
873
- return obj.message;
874
- }
875
- }
876
- return null;
877
- }
878
- setupSignalHandlers() {
879
- this.signalHandler = () => {
880
- this.handleShutdown();
881
- };
882
- platform_1.default.setupSignalHandlers(this.signalHandler);
883
- }
884
- removeSignalHandlers() {
885
- if (this.signalHandler) {
886
- platform_1.default.removeSignalHandlers(this.signalHandler);
887
- this.signalHandler = null;
888
- }
889
- }
890
- handleShutdown() {
891
- if (this.isShuttingDown) {
892
- // Already shutting down, force exit on second signal
893
- logger_1.default.warn('Force exiting...');
894
- process.exit(1);
895
- }
896
- this.isShuttingDown = true;
897
- this.clearLine();
898
- logger_1.default.warn('Received interrupt signal, stopping test runs...');
899
- // Stop all active runs
900
- this.stopActiveRuns()
901
- .then(() => {
902
- logger_1.default.info('All test runs have been stopped.');
903
- process.exit(1);
904
- })
905
- .catch((error) => {
906
- logger_1.default.error(`Failed to stop some test runs: ${error instanceof Error ? error.message : error}`);
907
- process.exit(1);
908
- });
909
- }
910
- async stopActiveRuns() {
911
- if (!this.appId || this.activeRunIds.length === 0) {
912
- return;
913
- }
914
- const stopPromises = this.activeRunIds.map((runId) => this.stopRun(runId).catch((error) => {
915
- logger_1.default.error(`Failed to stop run ${runId}: ${error instanceof Error ? error.message : error}`);
916
- }));
917
- await Promise.all(stopPromises);
918
- }
919
- async stopRun(runId) {
920
- if (!this.appId) {
921
- return;
922
- }
923
- try {
924
- await axios_1.default.post(`${this.URL}/${this.appId}/${runId}/stop`, {}, {
925
- headers: {
926
- 'User-Agent': utils_1.default.getUserAgent(),
927
- },
928
- auth: {
929
- username: this.credentials.userName,
930
- password: this.credentials.accessKey,
931
- },
932
- });
933
- if (!this.options.quiet) {
934
- logger_1.default.info(` Stopped run ${runId}`);
935
- }
936
- }
937
- catch (error) {
938
- throw new testingbot_error_1.default(`Failed to stop run ${runId}`, {
939
- cause: error,
940
- });
941
- }
942
- }
943
1340
  connectToUpdateServer() {
944
1341
  if (!this.updateServer || !this.updateKey || this.options.quiet) {
945
1342
  return;