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