@testingbot/cli 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +67 -8
- 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 +19 -7
- package/dist/models/maestro_options.d.ts.map +1 -1
- package/dist/models/maestro_options.js +17 -8
- 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 +39 -168
- package/dist/providers/login.d.ts +1 -0
- package/dist/providers/login.d.ts.map +1 -1
- package/dist/providers/login.js +17 -8
- package/dist/providers/maestro.d.ts +54 -22
- package/dist/providers/maestro.d.ts.map +1 -1
- package/dist/providers/maestro.js +683 -286
- package/dist/providers/xcuitest.d.ts +14 -21
- package/dist/providers/xcuitest.d.ts.map +1 -1
- package/dist/providers/xcuitest.js +39 -168
- package/dist/upload.d.ts +11 -4
- package/dist/upload.d.ts.map +1 -1
- package/dist/upload.js +80 -35
- package/dist/utils/connectivity.d.ts +25 -0
- package/dist/utils/connectivity.d.ts.map +1 -0
- package/dist/utils/connectivity.js +118 -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.d.ts.map +1 -1
- package/dist/utils.js +7 -2
- package/package.json +3 -1
|
@@ -47,28 +47,17 @@ const archiver_1 = __importDefault(require("archiver"));
|
|
|
47
47
|
const socket_io_client_1 = require("socket.io-client");
|
|
48
48
|
const testingbot_error_1 = __importDefault(require("../models/testingbot_error"));
|
|
49
49
|
const utils_1 = __importDefault(require("../utils"));
|
|
50
|
-
const upload_1 = __importDefault(require("../upload"));
|
|
51
50
|
const file_type_detector_1 = require("../utils/file-type-detector");
|
|
52
|
-
const
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
|
496
|
-
cause: error,
|
|
497
|
-
});
|
|
681
|
+
throw await this.handleErrorWithDiagnostics(error, 'Failed to get Maestro test status');
|
|
498
682
|
}
|
|
499
683
|
}
|
|
500
684
|
async waitForCompletion() {
|
|
501
685
|
let attempts = 0;
|
|
502
686
|
const startTime = Date.now();
|
|
503
687
|
const previousStatus = new Map();
|
|
688
|
+
const previousFlowStatus = new Map();
|
|
689
|
+
const urlDisplayed = new Set();
|
|
690
|
+
let flowsTableDisplayed = false;
|
|
691
|
+
let displayedLineCount = 0;
|
|
504
692
|
while (attempts < this.MAX_POLL_ATTEMPTS) {
|
|
505
693
|
// Check if we're shutting down
|
|
506
694
|
if (this.isShuttingDown) {
|
|
@@ -513,17 +701,76 @@ class Maestro {
|
|
|
513
701
|
.map((run) => run.id);
|
|
514
702
|
// Log current status of runs (unless quiet mode)
|
|
515
703
|
if (!this.options.quiet) {
|
|
516
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
526
|
-
console.log(` View results: https://testingbot.com/members/maestro/${this.appId}/runs/${run.id}`);
|
|
773
|
+
console.log(` ${statusEmoji} Run ${run.id} (${this.getRunDisplayName(run)}): ${statusText}`);
|
|
527
774
|
}
|
|
528
775
|
}
|
|
529
776
|
const allSucceeded = status.runs.every((run) => run.success === 1);
|
|
@@ -534,17 +781,12 @@ class Maestro {
|
|
|
534
781
|
}
|
|
535
782
|
else {
|
|
536
783
|
const failedRuns = status.runs.filter((run) => run.success !== 1);
|
|
537
|
-
logger_1.default.error(`${failedRuns.length} test run(s) failed
|
|
538
|
-
for (const run of failedRuns) {
|
|
539
|
-
logger_1.default.error(` - Run ${run.id} (${run.capabilities.deviceName}): ${run.report}`);
|
|
540
|
-
}
|
|
784
|
+
logger_1.default.error(`${failedRuns.length} test run(s) failed`);
|
|
541
785
|
}
|
|
542
|
-
// Fetch reports if requested
|
|
543
786
|
if (this.options.report && this.options.reportOutputDir) {
|
|
544
787
|
await this.fetchReports(status.runs);
|
|
545
788
|
}
|
|
546
|
-
|
|
547
|
-
if (this.options.downloadArtifacts && this.options.artifactsOutputDir) {
|
|
789
|
+
if (this.options.downloadArtifacts) {
|
|
548
790
|
await this.downloadArtifacts(status.runs);
|
|
549
791
|
}
|
|
550
792
|
return {
|
|
@@ -573,25 +815,21 @@ class Maestro {
|
|
|
573
815
|
const statusInfo = this.getStatusInfo(run.status);
|
|
574
816
|
if (run.status === 'WAITING' || run.status === 'READY') {
|
|
575
817
|
// Update the same line for WAITING and READY states
|
|
576
|
-
const message = ` ${statusInfo.emoji} Run ${run.id} (${run
|
|
818
|
+
const message = ` ${statusInfo.emoji} Run ${run.id} (${this.getRunDisplayName(run)}): ${statusInfo.text} (${elapsedStr})`;
|
|
577
819
|
process.stdout.write(`\r${message}`);
|
|
578
820
|
}
|
|
579
821
|
else if (statusChanged) {
|
|
580
822
|
// For other states (DONE, FAILED), print on a new line only when status changes
|
|
581
|
-
console.log(` ${statusInfo.emoji} Run ${run.id} (${run
|
|
823
|
+
console.log(` ${statusInfo.emoji} Run ${run.id} (${this.getRunDisplayName(run)}): ${statusInfo.text}`);
|
|
582
824
|
}
|
|
583
825
|
}
|
|
584
826
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
}
|
|
592
|
-
const minutes = Math.floor(seconds / 60);
|
|
593
|
-
const remainingSeconds = seconds % 60;
|
|
594
|
-
return `${minutes}m ${remainingSeconds}s`;
|
|
827
|
+
/**
|
|
828
|
+
* Get the display name for a run, preferring environment.name over capabilities.deviceName
|
|
829
|
+
* This shows the actual device used when a wildcard (*) was specified
|
|
830
|
+
*/
|
|
831
|
+
getRunDisplayName(run) {
|
|
832
|
+
return run.environment?.name || run.capabilities.deviceName;
|
|
595
833
|
}
|
|
596
834
|
getStatusInfo(status) {
|
|
597
835
|
switch (status) {
|
|
@@ -607,6 +845,202 @@ class Maestro {
|
|
|
607
845
|
return { emoji: '❓', text: status };
|
|
608
846
|
}
|
|
609
847
|
}
|
|
848
|
+
getFlowStatusDisplay(flow) {
|
|
849
|
+
switch (flow.status) {
|
|
850
|
+
case 'WAITING':
|
|
851
|
+
return { text: 'WAITING', colored: colors_1.default.white('WAITING') };
|
|
852
|
+
case 'READY':
|
|
853
|
+
return { text: 'RUNNING', colored: colors_1.default.blue('RUNNING') };
|
|
854
|
+
case 'DONE':
|
|
855
|
+
if (flow.success === 1) {
|
|
856
|
+
return { text: 'PASSED', colored: colors_1.default.green('PASSED') };
|
|
857
|
+
}
|
|
858
|
+
else {
|
|
859
|
+
return { text: 'FAILED', colored: colors_1.default.red('FAILED') };
|
|
860
|
+
}
|
|
861
|
+
case 'FAILED':
|
|
862
|
+
return { text: 'FAILED', colored: colors_1.default.red('FAILED') };
|
|
863
|
+
default:
|
|
864
|
+
return { text: flow.status, colored: flow.status };
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
hasAnyFlowFailed(flows) {
|
|
868
|
+
return flows.some((flow) => (flow.status === 'DONE' && flow.success !== 1) ||
|
|
869
|
+
flow.status === 'FAILED' ||
|
|
870
|
+
(flow.error_messages && flow.error_messages.length > 0));
|
|
871
|
+
}
|
|
872
|
+
calculateFlowDuration(flow) {
|
|
873
|
+
if (!flow.requested_at) {
|
|
874
|
+
return '-';
|
|
875
|
+
}
|
|
876
|
+
const startTime = new Date(flow.requested_at).getTime();
|
|
877
|
+
let endTime;
|
|
878
|
+
if (flow.completed_at) {
|
|
879
|
+
endTime = new Date(flow.completed_at).getTime();
|
|
880
|
+
}
|
|
881
|
+
else {
|
|
882
|
+
endTime = Date.now();
|
|
883
|
+
}
|
|
884
|
+
const durationSeconds = Math.floor((endTime - startTime) / 1000);
|
|
885
|
+
return this.formatElapsedTime(durationSeconds);
|
|
886
|
+
}
|
|
887
|
+
getTerminalHeight() {
|
|
888
|
+
// Default to 24 if terminal height is not available
|
|
889
|
+
return process.stdout.rows || 24;
|
|
890
|
+
}
|
|
891
|
+
getMaxDisplayableFlows() {
|
|
892
|
+
const terminalHeight = this.getTerminalHeight();
|
|
893
|
+
// Reserve lines for: header (2) + summary line (1) + some padding (3)
|
|
894
|
+
const reservedLines = 6;
|
|
895
|
+
return Math.max(5, terminalHeight - reservedLines);
|
|
896
|
+
}
|
|
897
|
+
getRemainingSummary(flows, displayedCount) {
|
|
898
|
+
const remaining = flows.slice(displayedCount);
|
|
899
|
+
if (remaining.length === 0) {
|
|
900
|
+
return '';
|
|
901
|
+
}
|
|
902
|
+
// Count statuses for remaining flows
|
|
903
|
+
let waiting = 0;
|
|
904
|
+
let running = 0;
|
|
905
|
+
let passed = 0;
|
|
906
|
+
let failed = 0;
|
|
907
|
+
for (const flow of remaining) {
|
|
908
|
+
switch (flow.status) {
|
|
909
|
+
case 'WAITING':
|
|
910
|
+
waiting++;
|
|
911
|
+
break;
|
|
912
|
+
case 'READY':
|
|
913
|
+
running++;
|
|
914
|
+
break;
|
|
915
|
+
case 'DONE':
|
|
916
|
+
if (flow.success === 1) {
|
|
917
|
+
passed++;
|
|
918
|
+
}
|
|
919
|
+
else {
|
|
920
|
+
failed++;
|
|
921
|
+
}
|
|
922
|
+
break;
|
|
923
|
+
case 'FAILED':
|
|
924
|
+
failed++;
|
|
925
|
+
break;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
const parts = [];
|
|
929
|
+
if (waiting > 0)
|
|
930
|
+
parts.push(colors_1.default.white(`${waiting} waiting`));
|
|
931
|
+
if (running > 0)
|
|
932
|
+
parts.push(colors_1.default.blue(`${running} running`));
|
|
933
|
+
if (passed > 0)
|
|
934
|
+
parts.push(colors_1.default.green(`${passed} passed`));
|
|
935
|
+
if (failed > 0)
|
|
936
|
+
parts.push(colors_1.default.red(`${failed} failed`));
|
|
937
|
+
return ` ... and ${remaining.length} more: ${parts.join(', ')}`;
|
|
938
|
+
}
|
|
939
|
+
displayFlowsWithLimit(flows, previousFlowStatus, hasFailures = false) {
|
|
940
|
+
const maxFlows = this.getMaxDisplayableFlows();
|
|
941
|
+
const displayFlows = flows.slice(0, maxFlows);
|
|
942
|
+
let linesWritten = 0;
|
|
943
|
+
for (const flow of displayFlows) {
|
|
944
|
+
linesWritten += this.displayFlowRow(flow, false, hasFailures);
|
|
945
|
+
previousFlowStatus.set(flow.id, flow.status);
|
|
946
|
+
}
|
|
947
|
+
// Show summary for remaining flows
|
|
948
|
+
if (flows.length > maxFlows) {
|
|
949
|
+
const summary = this.getRemainingSummary(flows, maxFlows);
|
|
950
|
+
console.log(colors_1.default.dim(summary));
|
|
951
|
+
linesWritten++;
|
|
952
|
+
}
|
|
953
|
+
return linesWritten;
|
|
954
|
+
}
|
|
955
|
+
displayFlowsTableHeader(hasFailures = false) {
|
|
956
|
+
let header = ` ${'Duration'.padEnd(10)} ${'Status'.padEnd(8)} Flow`;
|
|
957
|
+
let separator = ` ${'─'.repeat(10)} ${'─'.repeat(8)} ${'─'.repeat(30)}`;
|
|
958
|
+
if (hasFailures) {
|
|
959
|
+
header += ' Fail reason';
|
|
960
|
+
separator += ` ${'─'.repeat(80)}`;
|
|
961
|
+
}
|
|
962
|
+
console.log(colors_1.default.dim(header));
|
|
963
|
+
console.log(colors_1.default.dim(separator));
|
|
964
|
+
}
|
|
965
|
+
displayFlowRow(flow, isUpdate = false, hasFailures = false) {
|
|
966
|
+
const duration = this.calculateFlowDuration(flow).padEnd(10);
|
|
967
|
+
const statusDisplay = this.getFlowStatusDisplay(flow);
|
|
968
|
+
// Pad based on display text length, add extra for color codes
|
|
969
|
+
const statusPadded = statusDisplay.colored +
|
|
970
|
+
' '.repeat(Math.max(0, 8 - statusDisplay.text.length));
|
|
971
|
+
const name = flow.name.padEnd(30);
|
|
972
|
+
let linesWritten = 0;
|
|
973
|
+
const isFailed = flow.status === 'DONE' && flow.success !== 1;
|
|
974
|
+
const errorMessages = flow.error_messages || [];
|
|
975
|
+
// Build the main row
|
|
976
|
+
let row = ` ${duration} ${statusPadded} ${name}`;
|
|
977
|
+
// Add first error message on the same line if failed and has errors
|
|
978
|
+
if (hasFailures && isFailed && errorMessages.length > 0) {
|
|
979
|
+
row += ` ${colors_1.default.red(errorMessages[0])}`;
|
|
980
|
+
}
|
|
981
|
+
if (isUpdate) {
|
|
982
|
+
process.stdout.write(`\r${row}`);
|
|
983
|
+
}
|
|
984
|
+
else {
|
|
985
|
+
console.log(row);
|
|
986
|
+
}
|
|
987
|
+
linesWritten++;
|
|
988
|
+
// Display remaining error messages on continuation lines
|
|
989
|
+
if (!isUpdate && hasFailures && isFailed && errorMessages.length > 1) {
|
|
990
|
+
// Indent to align with the Fail reason column: Duration(11) + Status(9) + Test(31) = 51 chars
|
|
991
|
+
const indent = ' '.repeat(51);
|
|
992
|
+
for (let i = 1; i < errorMessages.length; i++) {
|
|
993
|
+
console.log(`${indent} ${colors_1.default.red(errorMessages[i])}`);
|
|
994
|
+
linesWritten++;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
return linesWritten;
|
|
998
|
+
}
|
|
999
|
+
displayFlowsTable(flows, previousFlowStatus, showHeader, hasFailures = false) {
|
|
1000
|
+
if (showHeader) {
|
|
1001
|
+
this.displayFlowsTableHeader(hasFailures);
|
|
1002
|
+
}
|
|
1003
|
+
let linesWritten = 0;
|
|
1004
|
+
for (const flow of flows) {
|
|
1005
|
+
const prevStatus = previousFlowStatus.get(flow.id);
|
|
1006
|
+
const isNewFlow = prevStatus === undefined;
|
|
1007
|
+
if (isNewFlow) {
|
|
1008
|
+
linesWritten += this.displayFlowRow(flow, false, hasFailures);
|
|
1009
|
+
}
|
|
1010
|
+
previousFlowStatus.set(flow.id, flow.status);
|
|
1011
|
+
}
|
|
1012
|
+
return linesWritten;
|
|
1013
|
+
}
|
|
1014
|
+
updateFlowsInPlace(flows, previousFlowStatus, displayedLineCount) {
|
|
1015
|
+
const maxFlows = this.getMaxDisplayableFlows();
|
|
1016
|
+
const displayFlows = flows.slice(0, maxFlows);
|
|
1017
|
+
const hasRemaining = flows.length > maxFlows;
|
|
1018
|
+
// Move cursor up by the number of lines we PREVIOUSLY displayed
|
|
1019
|
+
if (displayedLineCount > 0) {
|
|
1020
|
+
process.stdout.write(`\x1b[${displayedLineCount}A`);
|
|
1021
|
+
}
|
|
1022
|
+
let linesWritten = 0;
|
|
1023
|
+
// Redraw displayed flows
|
|
1024
|
+
for (const flow of displayFlows) {
|
|
1025
|
+
const duration = this.calculateFlowDuration(flow).padEnd(10);
|
|
1026
|
+
const statusDisplay = this.getFlowStatusDisplay(flow);
|
|
1027
|
+
const statusPadded = statusDisplay.colored +
|
|
1028
|
+
' '.repeat(Math.max(0, 8 - statusDisplay.text.length));
|
|
1029
|
+
const name = flow.name;
|
|
1030
|
+
const row = ` ${duration} ${statusPadded} ${name}`;
|
|
1031
|
+
process.stdout.write(`\r\x1b[K${row}\n`);
|
|
1032
|
+
previousFlowStatus.set(flow.id, flow.status);
|
|
1033
|
+
linesWritten++;
|
|
1034
|
+
}
|
|
1035
|
+
// Update or add summary line for remaining flows
|
|
1036
|
+
if (hasRemaining) {
|
|
1037
|
+
const summary = this.getRemainingSummary(flows, maxFlows);
|
|
1038
|
+
process.stdout.write(`\r\x1b[K${colors_1.default.dim(summary)}\n`);
|
|
1039
|
+
linesWritten++;
|
|
1040
|
+
}
|
|
1041
|
+
// Return the number of lines we wrote
|
|
1042
|
+
return linesWritten;
|
|
1043
|
+
}
|
|
610
1044
|
async fetchReports(runs) {
|
|
611
1045
|
const reportFormat = this.options.report;
|
|
612
1046
|
const outputDir = this.options.reportOutputDir;
|
|
@@ -627,7 +1061,11 @@ class Maestro {
|
|
|
627
1061
|
username: this.credentials.userName,
|
|
628
1062
|
password: this.credentials.accessKey,
|
|
629
1063
|
},
|
|
1064
|
+
timeout: 30000, // 30 second timeout
|
|
630
1065
|
});
|
|
1066
|
+
// Check for version update notification
|
|
1067
|
+
const latestVersion = response.headers?.['x-testingbotctl-version'];
|
|
1068
|
+
utils_1.default.checkForUpdate(latestVersion);
|
|
631
1069
|
// Extract the report content from the JSON response
|
|
632
1070
|
const reportKey = reportFormat === 'junit' ? 'junit_report' : 'html_report';
|
|
633
1071
|
const reportContent = response.data[reportKey];
|
|
@@ -650,21 +1088,24 @@ class Maestro {
|
|
|
650
1088
|
}
|
|
651
1089
|
async getRunDetails(runId) {
|
|
652
1090
|
try {
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
1091
|
+
return await this.withRetry(`Getting run details for run ${runId}`, async () => {
|
|
1092
|
+
const response = await axios_1.default.get(`${this.URL}/${this.appId}/${runId}`, {
|
|
1093
|
+
headers: {
|
|
1094
|
+
'User-Agent': utils_1.default.getUserAgent(),
|
|
1095
|
+
},
|
|
1096
|
+
auth: {
|
|
1097
|
+
username: this.credentials.userName,
|
|
1098
|
+
password: this.credentials.accessKey,
|
|
1099
|
+
},
|
|
1100
|
+
timeout: 30000, // 30 second timeout
|
|
1101
|
+
});
|
|
1102
|
+
const latestVersion = response.headers?.['x-testingbotctl-version'];
|
|
1103
|
+
utils_1.default.checkForUpdate(latestVersion);
|
|
1104
|
+
return response.data;
|
|
661
1105
|
});
|
|
662
|
-
return response.data;
|
|
663
1106
|
}
|
|
664
1107
|
catch (error) {
|
|
665
|
-
throw
|
|
666
|
-
cause: error,
|
|
667
|
-
});
|
|
1108
|
+
throw await this.handleErrorWithDiagnostics(error, `Failed to get run details for run ${runId}`);
|
|
668
1109
|
}
|
|
669
1110
|
}
|
|
670
1111
|
async waitForArtifactsSync(runId) {
|
|
@@ -680,42 +1121,105 @@ class Maestro {
|
|
|
680
1121
|
}
|
|
681
1122
|
throw new testingbot_error_1.default(`Timed out waiting for artifacts to sync for run ${runId}`);
|
|
682
1123
|
}
|
|
683
|
-
async downloadFile(url, filePath) {
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
'
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
1124
|
+
async downloadFile(url, filePath, retries = 3) {
|
|
1125
|
+
let lastError;
|
|
1126
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
1127
|
+
try {
|
|
1128
|
+
const response = await axios_1.default.get(url, {
|
|
1129
|
+
responseType: 'arraybuffer',
|
|
1130
|
+
timeout: 60000, // 60 second timeout for large files
|
|
1131
|
+
});
|
|
1132
|
+
await node_fs_1.default.promises.writeFile(filePath, response.data);
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
catch (error) {
|
|
1136
|
+
lastError = error;
|
|
1137
|
+
// Don't retry on 4xx errors (client errors like 403, 404)
|
|
1138
|
+
if (axios_1.default.isAxiosError(error) && error.response?.status) {
|
|
1139
|
+
const status = error.response.status;
|
|
1140
|
+
if (status >= 400 && status < 500) {
|
|
1141
|
+
break;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
// Wait before retrying (exponential backoff)
|
|
1145
|
+
if (attempt < retries) {
|
|
1146
|
+
await this.sleep(1000 * attempt);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
692
1149
|
}
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
1150
|
+
// Extract detailed error message
|
|
1151
|
+
let errorDetail = '';
|
|
1152
|
+
if (axios_1.default.isAxiosError(lastError)) {
|
|
1153
|
+
if (lastError.response) {
|
|
1154
|
+
errorDetail = `HTTP ${lastError.response.status}: ${lastError.response.statusText}`;
|
|
1155
|
+
}
|
|
1156
|
+
else if (lastError.code) {
|
|
1157
|
+
errorDetail = lastError.code;
|
|
1158
|
+
}
|
|
1159
|
+
else if (lastError.message) {
|
|
1160
|
+
errorDetail = lastError.message;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
else if (lastError instanceof Error) {
|
|
1164
|
+
errorDetail = lastError.message;
|
|
1165
|
+
}
|
|
1166
|
+
else if (lastError) {
|
|
1167
|
+
errorDetail = String(lastError);
|
|
697
1168
|
}
|
|
1169
|
+
throw new testingbot_error_1.default(`Failed to download file${errorDetail ? `: ${errorDetail}` : ''}`, {
|
|
1170
|
+
cause: lastError,
|
|
1171
|
+
});
|
|
698
1172
|
}
|
|
699
|
-
generateArtifactZipName() {
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
const
|
|
703
|
-
return
|
|
1173
|
+
async generateArtifactZipName(outputDir) {
|
|
1174
|
+
if (!this.options.name) {
|
|
1175
|
+
// Generate unique name with timestamp
|
|
1176
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1177
|
+
return `maestro_artifacts_${timestamp}.zip`;
|
|
1178
|
+
}
|
|
1179
|
+
const baseName = this.options.name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
1180
|
+
const fileName = `${baseName}.zip`;
|
|
1181
|
+
const filePath = node_path_1.default.join(outputDir, fileName);
|
|
1182
|
+
try {
|
|
1183
|
+
await node_fs_1.default.promises.access(filePath);
|
|
1184
|
+
// File exists, append timestamp
|
|
1185
|
+
return `${baseName}_${Date.now()}.zip`;
|
|
1186
|
+
}
|
|
1187
|
+
catch {
|
|
1188
|
+
// File doesn't exist, use base name
|
|
1189
|
+
return fileName;
|
|
704
1190
|
}
|
|
705
|
-
// Generate unique name with timestamp
|
|
706
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
707
|
-
return `maestro_artifacts_${timestamp}.zip`;
|
|
708
1191
|
}
|
|
709
1192
|
async downloadArtifacts(runs) {
|
|
710
1193
|
if (!this.options.downloadArtifacts)
|
|
711
1194
|
return;
|
|
1195
|
+
// Filter runs based on download mode
|
|
1196
|
+
const downloadMode = this.options.downloadArtifacts;
|
|
1197
|
+
const runsToDownload = downloadMode === 'failed'
|
|
1198
|
+
? runs.filter((run) => run.success !== 1)
|
|
1199
|
+
: runs;
|
|
1200
|
+
if (runsToDownload.length === 0) {
|
|
1201
|
+
if (!this.options.quiet) {
|
|
1202
|
+
if (downloadMode === 'failed') {
|
|
1203
|
+
logger_1.default.info('No failed runs to download artifacts for.');
|
|
1204
|
+
}
|
|
1205
|
+
else {
|
|
1206
|
+
logger_1.default.info('No runs to download artifacts for.');
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
712
1211
|
if (!this.options.quiet) {
|
|
713
|
-
|
|
1212
|
+
if (downloadMode === 'failed') {
|
|
1213
|
+
logger_1.default.info(`Downloading artifacts for ${runsToDownload.length} failed run(s)...`);
|
|
1214
|
+
}
|
|
1215
|
+
else {
|
|
1216
|
+
logger_1.default.info('Downloading artifacts...');
|
|
1217
|
+
}
|
|
714
1218
|
}
|
|
715
1219
|
const outputDir = this.options.artifactsOutputDir || process.cwd();
|
|
716
1220
|
const tempDir = await node_fs_1.default.promises.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), 'testingbot-maestro-artifacts-'));
|
|
717
1221
|
try {
|
|
718
|
-
for (const run of
|
|
1222
|
+
for (const run of runsToDownload) {
|
|
719
1223
|
try {
|
|
720
1224
|
if (!this.options.quiet) {
|
|
721
1225
|
logger_1.default.info(` Waiting for artifacts sync for run ${run.id}...`);
|
|
@@ -729,13 +1233,12 @@ class Maestro {
|
|
|
729
1233
|
}
|
|
730
1234
|
const runDir = node_path_1.default.join(tempDir, `run_${run.id}`);
|
|
731
1235
|
await node_fs_1.default.promises.mkdir(runDir, { recursive: true });
|
|
732
|
-
|
|
733
|
-
|
|
1236
|
+
if (runDetails.assets.logs &&
|
|
1237
|
+
Object.keys(runDetails.assets.logs).length > 0) {
|
|
734
1238
|
const logsDir = node_path_1.default.join(runDir, 'logs');
|
|
735
1239
|
await node_fs_1.default.promises.mkdir(logsDir, { recursive: true });
|
|
736
|
-
for (
|
|
737
|
-
const
|
|
738
|
-
const logFileName = node_path_1.default.basename(logUrl) || `log_${i}.txt`;
|
|
1240
|
+
for (const [logName, logUrl] of Object.entries(runDetails.assets.logs)) {
|
|
1241
|
+
const logFileName = `${logName}.txt`;
|
|
739
1242
|
const logPath = node_path_1.default.join(logsDir, logFileName);
|
|
740
1243
|
try {
|
|
741
1244
|
await this.downloadFile(logUrl, logPath);
|
|
@@ -753,7 +1256,7 @@ class Maestro {
|
|
|
753
1256
|
const videoDir = node_path_1.default.join(runDir, 'video');
|
|
754
1257
|
await node_fs_1.default.promises.mkdir(videoDir, { recursive: true });
|
|
755
1258
|
const videoUrl = runDetails.assets.video;
|
|
756
|
-
const videoFileName =
|
|
1259
|
+
const videoFileName = 'video.mp4';
|
|
757
1260
|
const videoPath = node_path_1.default.join(videoDir, videoFileName);
|
|
758
1261
|
try {
|
|
759
1262
|
await this.downloadFile(videoUrl, videoPath);
|
|
@@ -771,7 +1274,7 @@ class Maestro {
|
|
|
771
1274
|
await node_fs_1.default.promises.mkdir(screenshotsDir, { recursive: true });
|
|
772
1275
|
for (let i = 0; i < runDetails.assets.screenshots.length; i++) {
|
|
773
1276
|
const screenshotUrl = runDetails.assets.screenshots[i];
|
|
774
|
-
const screenshotFileName =
|
|
1277
|
+
const screenshotFileName = `screenshot_${i}.png`;
|
|
775
1278
|
const screenshotPath = node_path_1.default.join(screenshotsDir, screenshotFileName);
|
|
776
1279
|
try {
|
|
777
1280
|
await this.downloadFile(screenshotUrl, screenshotPath);
|
|
@@ -804,7 +1307,7 @@ class Maestro {
|
|
|
804
1307
|
logger_1.default.error(`Failed to download artifacts for run ${run.id}: ${error instanceof Error ? error.message : error}`);
|
|
805
1308
|
}
|
|
806
1309
|
}
|
|
807
|
-
const zipFileName = this.generateArtifactZipName();
|
|
1310
|
+
const zipFileName = await this.generateArtifactZipName(outputDir);
|
|
808
1311
|
const zipFilePath = node_path_1.default.join(outputDir, zipFileName);
|
|
809
1312
|
if (!this.options.quiet) {
|
|
810
1313
|
logger_1.default.info(`Creating artifacts zip: ${zipFileName}`);
|
|
@@ -834,112 +1337,6 @@ class Maestro {
|
|
|
834
1337
|
archive.finalize();
|
|
835
1338
|
});
|
|
836
1339
|
}
|
|
837
|
-
sleep(ms) {
|
|
838
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
839
|
-
}
|
|
840
|
-
extractErrorMessage(cause) {
|
|
841
|
-
if (typeof cause === 'string') {
|
|
842
|
-
return cause;
|
|
843
|
-
}
|
|
844
|
-
// Handle arrays of errors
|
|
845
|
-
if (Array.isArray(cause)) {
|
|
846
|
-
return cause.join('\n');
|
|
847
|
-
}
|
|
848
|
-
if (cause && typeof cause === 'object') {
|
|
849
|
-
// Handle axios errors which have response.data
|
|
850
|
-
const axiosError = cause;
|
|
851
|
-
if (axiosError.response?.data?.errors) {
|
|
852
|
-
return axiosError.response.data.errors.join('\n');
|
|
853
|
-
}
|
|
854
|
-
if (axiosError.response?.data?.error) {
|
|
855
|
-
return axiosError.response.data.error;
|
|
856
|
-
}
|
|
857
|
-
if (axiosError.response?.data?.message) {
|
|
858
|
-
return axiosError.response.data.message;
|
|
859
|
-
}
|
|
860
|
-
// Handle standard Error objects
|
|
861
|
-
if (cause instanceof Error) {
|
|
862
|
-
return cause.message;
|
|
863
|
-
}
|
|
864
|
-
// Handle plain objects with errors array, error, or message property
|
|
865
|
-
const obj = cause;
|
|
866
|
-
if (obj.errors) {
|
|
867
|
-
return obj.errors.join('\n');
|
|
868
|
-
}
|
|
869
|
-
if (obj.error) {
|
|
870
|
-
return obj.error;
|
|
871
|
-
}
|
|
872
|
-
if (obj.message) {
|
|
873
|
-
return obj.message;
|
|
874
|
-
}
|
|
875
|
-
}
|
|
876
|
-
return null;
|
|
877
|
-
}
|
|
878
|
-
setupSignalHandlers() {
|
|
879
|
-
this.signalHandler = () => {
|
|
880
|
-
this.handleShutdown();
|
|
881
|
-
};
|
|
882
|
-
platform_1.default.setupSignalHandlers(this.signalHandler);
|
|
883
|
-
}
|
|
884
|
-
removeSignalHandlers() {
|
|
885
|
-
if (this.signalHandler) {
|
|
886
|
-
platform_1.default.removeSignalHandlers(this.signalHandler);
|
|
887
|
-
this.signalHandler = null;
|
|
888
|
-
}
|
|
889
|
-
}
|
|
890
|
-
handleShutdown() {
|
|
891
|
-
if (this.isShuttingDown) {
|
|
892
|
-
// Already shutting down, force exit on second signal
|
|
893
|
-
logger_1.default.warn('Force exiting...');
|
|
894
|
-
process.exit(1);
|
|
895
|
-
}
|
|
896
|
-
this.isShuttingDown = true;
|
|
897
|
-
this.clearLine();
|
|
898
|
-
logger_1.default.warn('Received interrupt signal, stopping test runs...');
|
|
899
|
-
// Stop all active runs
|
|
900
|
-
this.stopActiveRuns()
|
|
901
|
-
.then(() => {
|
|
902
|
-
logger_1.default.info('All test runs have been stopped.');
|
|
903
|
-
process.exit(1);
|
|
904
|
-
})
|
|
905
|
-
.catch((error) => {
|
|
906
|
-
logger_1.default.error(`Failed to stop some test runs: ${error instanceof Error ? error.message : error}`);
|
|
907
|
-
process.exit(1);
|
|
908
|
-
});
|
|
909
|
-
}
|
|
910
|
-
async stopActiveRuns() {
|
|
911
|
-
if (!this.appId || this.activeRunIds.length === 0) {
|
|
912
|
-
return;
|
|
913
|
-
}
|
|
914
|
-
const stopPromises = this.activeRunIds.map((runId) => this.stopRun(runId).catch((error) => {
|
|
915
|
-
logger_1.default.error(`Failed to stop run ${runId}: ${error instanceof Error ? error.message : error}`);
|
|
916
|
-
}));
|
|
917
|
-
await Promise.all(stopPromises);
|
|
918
|
-
}
|
|
919
|
-
async stopRun(runId) {
|
|
920
|
-
if (!this.appId) {
|
|
921
|
-
return;
|
|
922
|
-
}
|
|
923
|
-
try {
|
|
924
|
-
await axios_1.default.post(`${this.URL}/${this.appId}/${runId}/stop`, {}, {
|
|
925
|
-
headers: {
|
|
926
|
-
'User-Agent': utils_1.default.getUserAgent(),
|
|
927
|
-
},
|
|
928
|
-
auth: {
|
|
929
|
-
username: this.credentials.userName,
|
|
930
|
-
password: this.credentials.accessKey,
|
|
931
|
-
},
|
|
932
|
-
});
|
|
933
|
-
if (!this.options.quiet) {
|
|
934
|
-
logger_1.default.info(` Stopped run ${runId}`);
|
|
935
|
-
}
|
|
936
|
-
}
|
|
937
|
-
catch (error) {
|
|
938
|
-
throw new testingbot_error_1.default(`Failed to stop run ${runId}`, {
|
|
939
|
-
cause: error,
|
|
940
|
-
});
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
1340
|
connectToUpdateServer() {
|
|
944
1341
|
if (!this.updateServer || !this.updateKey || this.options.quiet) {
|
|
945
1342
|
return;
|