@testingbot/cli 1.0.1 → 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 +55 -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 +16 -7
  25. package/dist/providers/maestro.d.ts +53 -21
  26. package/dist/providers/maestro.d.ts.map +1 -1
  27. package/dist/providers/maestro.js +614 -263
  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 +10 -0
  32. package/dist/upload.d.ts.map +1 -1
  33. package/dist/upload.js +46 -21
  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 +1 -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,16 +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}`);
773
+ console.log(` ${statusEmoji} Run ${run.id} (${this.getRunDisplayName(run)}): ${statusText}`);
526
774
  }
527
775
  }
528
776
  const allSucceeded = status.runs.every((run) => run.success === 1);
@@ -533,16 +781,11 @@ class Maestro {
533
781
  }
534
782
  else {
535
783
  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
- }
784
+ logger_1.default.error(`${failedRuns.length} test run(s) failed`);
540
785
  }
541
- // Fetch reports if requested
542
786
  if (this.options.report && this.options.reportOutputDir) {
543
787
  await this.fetchReports(status.runs);
544
788
  }
545
- // Download artifacts if requested
546
789
  if (this.options.downloadArtifacts) {
547
790
  await this.downloadArtifacts(status.runs);
548
791
  }
@@ -568,34 +811,25 @@ class Maestro {
568
811
  (prevStatus === 'WAITING' || prevStatus === 'READY')) {
569
812
  this.clearLine();
570
813
  }
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
814
  previousStatus.set(run.id, run.status);
577
815
  const statusInfo = this.getStatusInfo(run.status);
578
816
  if (run.status === 'WAITING' || run.status === 'READY') {
579
817
  // Update the same line for WAITING and READY states
580
- 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})`;
581
819
  process.stdout.write(`\r${message}`);
582
820
  }
583
821
  else if (statusChanged) {
584
822
  // 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}`);
823
+ console.log(` ${statusInfo.emoji} Run ${run.id} (${this.getRunDisplayName(run)}): ${statusInfo.text}`);
586
824
  }
587
825
  }
588
826
  }
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`;
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;
599
833
  }
600
834
  getStatusInfo(status) {
601
835
  switch (status) {
@@ -611,6 +845,202 @@ class Maestro {
611
845
  return { emoji: '❓', text: status };
612
846
  }
613
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
+ }
614
1044
  async fetchReports(runs) {
615
1045
  const reportFormat = this.options.report;
616
1046
  const outputDir = this.options.reportOutputDir;
@@ -631,7 +1061,11 @@ class Maestro {
631
1061
  username: this.credentials.userName,
632
1062
  password: this.credentials.accessKey,
633
1063
  },
1064
+ timeout: 30000, // 30 second timeout
634
1065
  });
1066
+ // Check for version update notification
1067
+ const latestVersion = response.headers?.['x-testingbotctl-version'];
1068
+ utils_1.default.checkForUpdate(latestVersion);
635
1069
  // Extract the report content from the JSON response
636
1070
  const reportKey = reportFormat === 'junit' ? 'junit_report' : 'html_report';
637
1071
  const reportContent = response.data[reportKey];
@@ -654,21 +1088,24 @@ class Maestro {
654
1088
  }
655
1089
  async getRunDetails(runId) {
656
1090
  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
- },
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;
665
1105
  });
666
- return response.data;
667
1106
  }
668
1107
  catch (error) {
669
- throw new testingbot_error_1.default(`Failed to get run details for run ${runId}`, {
670
- cause: error,
671
- });
1108
+ throw await this.handleErrorWithDiagnostics(error, `Failed to get run details for run ${runId}`);
672
1109
  }
673
1110
  }
674
1111
  async waitForArtifactsSync(runId) {
@@ -734,12 +1171,12 @@ class Maestro {
734
1171
  });
735
1172
  }
736
1173
  async generateArtifactZipName(outputDir) {
737
- if (!this.options.build) {
1174
+ if (!this.options.name) {
738
1175
  // Generate unique name with timestamp
739
1176
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
740
1177
  return `maestro_artifacts_${timestamp}.zip`;
741
1178
  }
742
- const baseName = this.options.build.replace(/[^a-zA-Z0-9_-]/g, '_');
1179
+ const baseName = this.options.name.replace(/[^a-zA-Z0-9_-]/g, '_');
743
1180
  const fileName = `${baseName}.zip`;
744
1181
  const filePath = node_path_1.default.join(outputDir, fileName);
745
1182
  try {
@@ -755,13 +1192,34 @@ class Maestro {
755
1192
  async downloadArtifacts(runs) {
756
1193
  if (!this.options.downloadArtifacts)
757
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
+ }
758
1211
  if (!this.options.quiet) {
759
- 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
+ }
760
1218
  }
761
1219
  const outputDir = this.options.artifactsOutputDir || process.cwd();
762
1220
  const tempDir = await node_fs_1.default.promises.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), 'testingbot-maestro-artifacts-'));
763
1221
  try {
764
- for (const run of runs) {
1222
+ for (const run of runsToDownload) {
765
1223
  try {
766
1224
  if (!this.options.quiet) {
767
1225
  logger_1.default.info(` Waiting for artifacts sync for run ${run.id}...`);
@@ -775,7 +1233,6 @@ class Maestro {
775
1233
  }
776
1234
  const runDir = node_path_1.default.join(tempDir, `run_${run.id}`);
777
1235
  await node_fs_1.default.promises.mkdir(runDir, { recursive: true });
778
- // Download logs
779
1236
  if (runDetails.assets.logs &&
780
1237
  Object.keys(runDetails.assets.logs).length > 0) {
781
1238
  const logsDir = node_path_1.default.join(runDir, 'logs');
@@ -880,112 +1337,6 @@ class Maestro {
880
1337
  archive.finalize();
881
1338
  });
882
1339
  }
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
1340
  connectToUpdateServer() {
990
1341
  if (!this.updateServer || !this.updateKey || this.options.quiet) {
991
1342
  return;