@testingbot/cli 1.0.7 → 1.0.8
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 +25 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +75 -46
- package/dist/config/constants.d.ts +15 -0
- package/dist/config/constants.d.ts.map +1 -0
- package/dist/config/constants.js +17 -0
- package/dist/index.js +2 -0
- package/dist/models/espresso_options.d.ts +7 -0
- package/dist/models/espresso_options.d.ts.map +1 -1
- package/dist/models/espresso_options.js +18 -0
- package/dist/models/maestro_options.d.ts +12 -2
- package/dist/models/maestro_options.d.ts.map +1 -1
- package/dist/models/maestro_options.js +24 -1
- package/dist/models/xcuitest_options.d.ts +7 -0
- package/dist/models/xcuitest_options.d.ts.map +1 -1
- package/dist/models/xcuitest_options.js +18 -0
- package/dist/providers/base_provider.d.ts +28 -2
- package/dist/providers/base_provider.d.ts.map +1 -1
- package/dist/providers/base_provider.js +70 -2
- package/dist/providers/espresso.d.ts +1 -0
- package/dist/providers/espresso.d.ts.map +1 -1
- package/dist/providers/espresso.js +82 -35
- package/dist/providers/maestro.d.ts +13 -0
- package/dist/providers/maestro.d.ts.map +1 -1
- package/dist/providers/maestro.js +218 -74
- package/dist/providers/xcuitest.d.ts +1 -0
- package/dist/providers/xcuitest.d.ts.map +1 -1
- package/dist/providers/xcuitest.js +79 -35
- package/dist/ui/banner.d.ts +3 -0
- package/dist/ui/banner.d.ts.map +1 -0
- package/dist/ui/banner.js +82 -0
- package/dist/ui/spinner.d.ts +32 -0
- package/dist/ui/spinner.d.ts.map +1 -0
- package/dist/ui/spinner.js +92 -0
- package/dist/ui/terminal-title.d.ts +8 -0
- package/dist/ui/terminal-title.d.ts.map +1 -0
- package/dist/ui/terminal-title.js +57 -0
- package/dist/upload.d.ts +4 -0
- package/dist/upload.d.ts.map +1 -1
- package/dist/upload.js +70 -12
- package/dist/utils/connectivity.js +5 -3
- package/dist/utils.d.ts +6 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +10 -0
- package/package.json +5 -3
|
@@ -50,12 +50,21 @@ const utils_1 = __importDefault(require("../utils"));
|
|
|
50
50
|
const file_type_detector_1 = require("../utils/file-type-detector");
|
|
51
51
|
const picocolors_1 = __importDefault(require("picocolors"));
|
|
52
52
|
const base_provider_1 = __importDefault(require("./base_provider"));
|
|
53
|
+
const terminal_title_1 = require("../ui/terminal-title");
|
|
54
|
+
const constants_1 = require("../config/constants");
|
|
55
|
+
const FLOW_SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
56
|
+
const FLOW_ANIMATION_MS = 120;
|
|
53
57
|
class Maestro extends base_provider_1.default {
|
|
54
58
|
URL = 'https://api.testingbot.com/v1/app-automate/maestro';
|
|
55
59
|
detectedPlatform = undefined;
|
|
56
60
|
socket = null;
|
|
57
61
|
updateServer = null;
|
|
58
62
|
updateKey = null;
|
|
63
|
+
socketFallbackWarned = false;
|
|
64
|
+
flowAnimationFrame = 0;
|
|
65
|
+
flowAnimationTimer = null;
|
|
66
|
+
latestFlows = [];
|
|
67
|
+
latestDisplayedLineCount = 0;
|
|
59
68
|
constructor(credentials, options) {
|
|
60
69
|
super(credentials, options);
|
|
61
70
|
}
|
|
@@ -88,6 +97,13 @@ class Maestro extends base_provider_1.default {
|
|
|
88
97
|
throw new testingbot_error_1.default(`Provided app path does not exist ${this.options.app}`);
|
|
89
98
|
}),
|
|
90
99
|
];
|
|
100
|
+
if (this.options.configFile) {
|
|
101
|
+
fileChecks.push(node_fs_1.default.promises
|
|
102
|
+
.access(this.options.configFile, node_fs_1.default.constants.R_OK)
|
|
103
|
+
.catch(() => {
|
|
104
|
+
throw new testingbot_error_1.default(`Specified config file does not exist: ${this.options.configFile}`);
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
91
107
|
// Check if all flows paths exist (can be files, directories or glob patterns)
|
|
92
108
|
for (const flowsPath of this.options.flows) {
|
|
93
109
|
const isGlobPattern = flowsPath.includes('*') ||
|
|
@@ -172,20 +188,28 @@ class Maestro extends base_provider_1.default {
|
|
|
172
188
|
return { success: true, runs: [] };
|
|
173
189
|
}
|
|
174
190
|
try {
|
|
191
|
+
(0, terminal_title_1.setTitle)('maestro');
|
|
175
192
|
// Quick connectivity check before starting uploads
|
|
176
193
|
await this.ensureConnectivity();
|
|
177
194
|
// Detect platform from file content if not explicitly provided
|
|
178
195
|
if (!this.options.platformName) {
|
|
179
196
|
this.detectedPlatform = await this.detectPlatform();
|
|
180
197
|
}
|
|
198
|
+
(0, terminal_title_1.setTitle)('maestro · uploading app');
|
|
181
199
|
await this.uploadApp();
|
|
182
200
|
if (!this.options.quiet) {
|
|
183
201
|
logger_1.default.info('Uploading Maestro Flows');
|
|
184
202
|
}
|
|
203
|
+
(0, terminal_title_1.setTitle)('maestro · uploading flows');
|
|
185
204
|
await this.uploadFlows();
|
|
205
|
+
if (this.options.tunnel && this.options.async) {
|
|
206
|
+
throw new testingbot_error_1.default('Cannot use --tunnel with --async mode. The tunnel would close when the CLI exits. Use a standalone tunnel instead.');
|
|
207
|
+
}
|
|
208
|
+
await this.startTunnel();
|
|
186
209
|
if (!this.options.quiet) {
|
|
187
210
|
logger_1.default.info('Running Maestro Tests');
|
|
188
211
|
}
|
|
212
|
+
(0, terminal_title_1.setTitle)('maestro · queued');
|
|
189
213
|
await this.runTests();
|
|
190
214
|
if (this.options.async) {
|
|
191
215
|
if (!this.options.quiet) {
|
|
@@ -204,12 +228,17 @@ class Maestro extends base_provider_1.default {
|
|
|
204
228
|
// Clean up
|
|
205
229
|
this.disconnectFromUpdateServer();
|
|
206
230
|
this.removeSignalHandlers();
|
|
231
|
+
await this.stopTunnel();
|
|
207
232
|
return result;
|
|
208
233
|
}
|
|
209
234
|
catch (error) {
|
|
210
235
|
// Clean up on error
|
|
236
|
+
this.spinner.stop();
|
|
237
|
+
this.stopFlowAnimation();
|
|
211
238
|
this.disconnectFromUpdateServer();
|
|
212
239
|
this.removeSignalHandlers();
|
|
240
|
+
await this.stopTunnel();
|
|
241
|
+
(0, terminal_title_1.setTitle)('maestro · ✘ error');
|
|
213
242
|
logger_1.default.error(error instanceof Error ? error.message : error);
|
|
214
243
|
// Display the cause if available
|
|
215
244
|
if (error instanceof Error && error.cause) {
|
|
@@ -224,7 +253,7 @@ class Maestro extends base_provider_1.default {
|
|
|
224
253
|
async uploadApp() {
|
|
225
254
|
let appPath = this.options.app;
|
|
226
255
|
const ext = node_path_1.default.extname(appPath).toLowerCase();
|
|
227
|
-
let
|
|
256
|
+
let tempZipDir = null;
|
|
228
257
|
// If .app bundle (directory), zip it first
|
|
229
258
|
if (ext === '.app') {
|
|
230
259
|
const stat = await node_fs_1.default.promises.stat(appPath);
|
|
@@ -232,8 +261,9 @@ class Maestro extends base_provider_1.default {
|
|
|
232
261
|
if (!this.options.quiet) {
|
|
233
262
|
logger_1.default.info('Zipping .app bundle for upload');
|
|
234
263
|
}
|
|
235
|
-
|
|
236
|
-
|
|
264
|
+
const zipped = await this.zipAppBundle(appPath);
|
|
265
|
+
tempZipDir = zipped.tmpDir;
|
|
266
|
+
appPath = zipped.zipPath;
|
|
237
267
|
}
|
|
238
268
|
}
|
|
239
269
|
try {
|
|
@@ -277,9 +307,12 @@ class Maestro extends base_provider_1.default {
|
|
|
277
307
|
return true;
|
|
278
308
|
}
|
|
279
309
|
finally {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
310
|
+
if (tempZipDir) {
|
|
311
|
+
await node_fs_1.default.promises
|
|
312
|
+
.rm(tempZipDir, { recursive: true, force: true })
|
|
313
|
+
.catch((err) => {
|
|
314
|
+
logger_1.default.debug(`Failed to clean up temporary app zip dir ${tempZipDir}: ${err instanceof Error ? err.message : err}`);
|
|
315
|
+
});
|
|
283
316
|
}
|
|
284
317
|
}
|
|
285
318
|
}
|
|
@@ -293,7 +326,8 @@ class Maestro extends base_provider_1.default {
|
|
|
293
326
|
return new Promise((resolve, reject) => {
|
|
294
327
|
const output = node_fs_1.default.createWriteStream(zipPath);
|
|
295
328
|
const archive = (0, archiver_1.default)('zip', { zlib: { level: 9 } });
|
|
296
|
-
output.on('close', () => resolve(zipPath));
|
|
329
|
+
output.on('close', () => resolve({ zipPath, tmpDir }));
|
|
330
|
+
output.on('error', (err) => reject(err));
|
|
297
331
|
archive.on('error', (err) => reject(err));
|
|
298
332
|
archive.pipe(output);
|
|
299
333
|
// Add the .app directory with its name preserved
|
|
@@ -339,8 +373,7 @@ class Maestro extends base_provider_1.default {
|
|
|
339
373
|
if (flowsPaths.length === 1) {
|
|
340
374
|
const singlePath = flowsPaths[0];
|
|
341
375
|
const stat = await node_fs_1.default.promises.stat(singlePath).catch(() => null);
|
|
342
|
-
if (stat?.isFile() &&
|
|
343
|
-
node_path_1.default.extname(singlePath).toLowerCase() === '.zip') {
|
|
376
|
+
if (stat?.isFile() && node_path_1.default.extname(singlePath).toLowerCase() === '.zip') {
|
|
344
377
|
return null;
|
|
345
378
|
}
|
|
346
379
|
}
|
|
@@ -467,7 +500,7 @@ class Maestro extends base_provider_1.default {
|
|
|
467
500
|
return true;
|
|
468
501
|
}
|
|
469
502
|
const { allFlowFiles, baseDir } = result;
|
|
470
|
-
const zipPath = await this.createFlowsZip(allFlowFiles, baseDir);
|
|
503
|
+
const { zipPath, tmpDir } = await this.createFlowsZip(allFlowFiles, baseDir);
|
|
471
504
|
try {
|
|
472
505
|
await this.upload.upload({
|
|
473
506
|
filePath: zipPath,
|
|
@@ -478,7 +511,11 @@ class Maestro extends base_provider_1.default {
|
|
|
478
511
|
});
|
|
479
512
|
}
|
|
480
513
|
finally {
|
|
481
|
-
await node_fs_1.default.promises
|
|
514
|
+
await node_fs_1.default.promises
|
|
515
|
+
.rm(tmpDir, { recursive: true, force: true })
|
|
516
|
+
.catch((err) => {
|
|
517
|
+
logger_1.default.debug(`Failed to clean up temporary flows zip dir ${tmpDir}: ${err instanceof Error ? err.message : err}`);
|
|
518
|
+
});
|
|
482
519
|
}
|
|
483
520
|
return true;
|
|
484
521
|
}
|
|
@@ -514,9 +551,14 @@ class Maestro extends base_provider_1.default {
|
|
|
514
551
|
const flowFiles = [];
|
|
515
552
|
let configPath = null;
|
|
516
553
|
let config = null;
|
|
517
|
-
//
|
|
518
|
-
|
|
519
|
-
|
|
554
|
+
// If a custom config file is specified, use it; otherwise check for config.yaml or config.yml
|
|
555
|
+
const configCandidates = this.options.configFile
|
|
556
|
+
? [node_path_1.default.resolve(this.options.configFile)]
|
|
557
|
+
: [
|
|
558
|
+
node_path_1.default.join(directory, 'config.yaml'),
|
|
559
|
+
node_path_1.default.join(directory, 'config.yml'),
|
|
560
|
+
];
|
|
561
|
+
for (const candidatePath of configCandidates) {
|
|
520
562
|
try {
|
|
521
563
|
const configContent = await node_fs_1.default.promises.readFile(candidatePath, 'utf-8');
|
|
522
564
|
config = yaml.load(configContent);
|
|
@@ -590,7 +632,13 @@ class Maestro extends base_provider_1.default {
|
|
|
590
632
|
*/
|
|
591
633
|
isConfigFile(filePath) {
|
|
592
634
|
const basename = node_path_1.default.basename(filePath);
|
|
593
|
-
|
|
635
|
+
if (basename === 'config.yaml' || basename === 'config.yml') {
|
|
636
|
+
return true;
|
|
637
|
+
}
|
|
638
|
+
if (this.options.configFile) {
|
|
639
|
+
return basename === node_path_1.default.basename(this.options.configFile);
|
|
640
|
+
}
|
|
641
|
+
return false;
|
|
594
642
|
}
|
|
595
643
|
async readFlowTags(flowFile) {
|
|
596
644
|
try {
|
|
@@ -618,8 +666,10 @@ class Maestro extends base_provider_1.default {
|
|
|
618
666
|
* exists or the values are not arrays.
|
|
619
667
|
*/
|
|
620
668
|
async loadConfigTags(baseDir) {
|
|
621
|
-
|
|
622
|
-
|
|
669
|
+
const candidates = this.options.configFile
|
|
670
|
+
? [node_path_1.default.resolve(this.options.configFile)]
|
|
671
|
+
: [node_path_1.default.join(baseDir, 'config.yaml'), node_path_1.default.join(baseDir, 'config.yml')];
|
|
672
|
+
for (const candidate of candidates) {
|
|
623
673
|
try {
|
|
624
674
|
const content = await node_fs_1.default.promises.readFile(candidate, 'utf-8');
|
|
625
675
|
const parsed = yaml.load(content);
|
|
@@ -688,9 +738,12 @@ class Maestro extends base_provider_1.default {
|
|
|
688
738
|
* Check if a string looks like a file path (relative path with extension)
|
|
689
739
|
*/
|
|
690
740
|
looksLikePath(value) {
|
|
691
|
-
// Must be a relative path (starts with . or contains
|
|
692
|
-
const isRelative = value.startsWith('./') ||
|
|
693
|
-
|
|
741
|
+
// Must be a relative path (starts with . or contains a path separator)
|
|
742
|
+
const isRelative = value.startsWith('./') ||
|
|
743
|
+
value.startsWith('../') ||
|
|
744
|
+
value.startsWith('.\\') ||
|
|
745
|
+
value.startsWith('..\\');
|
|
746
|
+
const hasPathSeparator = value.includes('/') || value.includes('\\');
|
|
694
747
|
// Must have a file extension
|
|
695
748
|
const hasExtension = /\.[a-zA-Z0-9]+$/.test(value);
|
|
696
749
|
// Exclude URLs
|
|
@@ -1085,7 +1138,8 @@ class Maestro extends base_provider_1.default {
|
|
|
1085
1138
|
return new Promise((resolve, reject) => {
|
|
1086
1139
|
const output = node_fs_1.default.createWriteStream(zipPath);
|
|
1087
1140
|
const archive = (0, archiver_1.default)('zip', { zlib: { level: 9 } });
|
|
1088
|
-
output.on('close', () => resolve(zipPath));
|
|
1141
|
+
output.on('close', () => resolve({ zipPath, tmpDir }));
|
|
1142
|
+
output.on('error', (err) => reject(err));
|
|
1089
1143
|
archive.on('error', (err) => reject(err));
|
|
1090
1144
|
archive.pipe(output);
|
|
1091
1145
|
// Compute effective base directory for archive paths
|
|
@@ -1141,7 +1195,7 @@ class Maestro extends base_provider_1.default {
|
|
|
1141
1195
|
username: this.credentials.userName,
|
|
1142
1196
|
password: this.credentials.accessKey,
|
|
1143
1197
|
},
|
|
1144
|
-
timeout:
|
|
1198
|
+
timeout: constants_1.HTTP.TIMEOUT_MS,
|
|
1145
1199
|
});
|
|
1146
1200
|
// Check for version update notification
|
|
1147
1201
|
const latestVersion = response.headers?.['x-testingbotctl-version'];
|
|
@@ -1179,7 +1233,7 @@ class Maestro extends base_provider_1.default {
|
|
|
1179
1233
|
username: this.credentials.userName,
|
|
1180
1234
|
password: this.credentials.accessKey,
|
|
1181
1235
|
},
|
|
1182
|
-
timeout:
|
|
1236
|
+
timeout: constants_1.HTTP.TIMEOUT_MS,
|
|
1183
1237
|
});
|
|
1184
1238
|
// Check for version update notification
|
|
1185
1239
|
const latestVersion = response.headers?.['x-testingbotctl-version'];
|
|
@@ -1195,14 +1249,15 @@ class Maestro extends base_provider_1.default {
|
|
|
1195
1249
|
}
|
|
1196
1250
|
}
|
|
1197
1251
|
async waitForCompletion() {
|
|
1198
|
-
let attempts = 0;
|
|
1199
1252
|
const startTime = Date.now();
|
|
1200
1253
|
const previousStatus = new Map();
|
|
1201
1254
|
const previousFlowStatus = new Map();
|
|
1202
1255
|
const urlDisplayed = new Set();
|
|
1203
1256
|
let flowsTableDisplayed = false;
|
|
1204
1257
|
let displayedLineCount = 0;
|
|
1205
|
-
|
|
1258
|
+
let pollInterval = this.MIN_POLL_INTERVAL_MS;
|
|
1259
|
+
let previousSignature = null;
|
|
1260
|
+
while (true) {
|
|
1206
1261
|
// Check if we're shutting down
|
|
1207
1262
|
if (this.isShuttingDown) {
|
|
1208
1263
|
throw new testingbot_error_1.default('Test run cancelled by user');
|
|
@@ -1212,6 +1267,11 @@ class Maestro extends base_provider_1.default {
|
|
|
1212
1267
|
this.activeRunIds = status.runs
|
|
1213
1268
|
.filter((run) => run.status !== 'DONE' && run.status !== 'FAILED')
|
|
1214
1269
|
.map((run) => run.id);
|
|
1270
|
+
const running = status.runs.find((r) => r.status === 'READY');
|
|
1271
|
+
if (running) {
|
|
1272
|
+
const device = running.environment?.name || running.capabilities.deviceName;
|
|
1273
|
+
(0, terminal_title_1.setTitle)(`maestro · running · ${device}`);
|
|
1274
|
+
}
|
|
1215
1275
|
// Log current status of runs (unless quiet mode)
|
|
1216
1276
|
if (!this.options.quiet) {
|
|
1217
1277
|
// Check if any run has flows and display them
|
|
@@ -1233,15 +1293,23 @@ class Maestro extends base_provider_1.default {
|
|
|
1233
1293
|
// Check if any flow has failed (for showing error column)
|
|
1234
1294
|
const hasFailures = this.hasAnyFlowFailed(allFlows);
|
|
1235
1295
|
if (!flowsTableDisplayed) {
|
|
1296
|
+
// Flows have arrived — stop the run-level spinner so it doesn't
|
|
1297
|
+
// fight the flow table's cursor-based in-place updates.
|
|
1298
|
+
this.spinner.stop();
|
|
1236
1299
|
// First time showing flows - display header and initial state
|
|
1237
1300
|
console.log(); // Empty line before flows table
|
|
1238
1301
|
this.displayFlowsTableHeader(hasFailures);
|
|
1239
1302
|
displayedLineCount = this.displayFlowsWithLimit(allFlows, previousFlowStatus, hasFailures);
|
|
1240
1303
|
flowsTableDisplayed = true;
|
|
1304
|
+
this.latestFlows = allFlows;
|
|
1305
|
+
this.latestDisplayedLineCount = displayedLineCount;
|
|
1306
|
+
this.startFlowAnimation(previousFlowStatus);
|
|
1241
1307
|
}
|
|
1242
1308
|
else {
|
|
1243
1309
|
// Update flows in place
|
|
1244
1310
|
displayedLineCount = this.updateFlowsInPlace(allFlows, previousFlowStatus, displayedLineCount);
|
|
1311
|
+
this.latestFlows = allFlows;
|
|
1312
|
+
this.latestDisplayedLineCount = displayedLineCount;
|
|
1245
1313
|
}
|
|
1246
1314
|
}
|
|
1247
1315
|
else {
|
|
@@ -1250,6 +1318,7 @@ class Maestro extends base_provider_1.default {
|
|
|
1250
1318
|
}
|
|
1251
1319
|
}
|
|
1252
1320
|
if (status.completed) {
|
|
1321
|
+
this.stopFlowAnimation();
|
|
1253
1322
|
// Display final flows table with error messages if there are failures
|
|
1254
1323
|
if (!this.options.quiet && flowsTableDisplayed) {
|
|
1255
1324
|
const allFlows = [];
|
|
@@ -1266,9 +1335,9 @@ class Maestro extends base_provider_1.default {
|
|
|
1266
1335
|
process.stdout.write(`\x1b[${linesToMove}A`);
|
|
1267
1336
|
// Clear header line, write new header, then clear separator line
|
|
1268
1337
|
process.stdout.write('\x1b[2K');
|
|
1269
|
-
console.log(picocolors_1.default.dim(` ${'Duration'.padEnd(10)} ${'Status'.padEnd(
|
|
1338
|
+
console.log(picocolors_1.default.dim(` ${'Duration'.padEnd(10)} ${'Status'.padEnd(10)} Flow Fail reason`));
|
|
1270
1339
|
process.stdout.write('\x1b[2K');
|
|
1271
|
-
console.log(picocolors_1.default.dim(` ${'─'.repeat(10)} ${'─'.repeat(
|
|
1340
|
+
console.log(picocolors_1.default.dim(` ${'─'.repeat(10)} ${'─'.repeat(10)} ${'─'.repeat(30)} ${'─'.repeat(80)}`));
|
|
1272
1341
|
// Redraw all flows with error messages
|
|
1273
1342
|
for (const flow of allFlows) {
|
|
1274
1343
|
// Clear the line before writing
|
|
@@ -1279,21 +1348,27 @@ class Maestro extends base_provider_1.default {
|
|
|
1279
1348
|
}
|
|
1280
1349
|
// Print final summary
|
|
1281
1350
|
if (!this.options.quiet) {
|
|
1351
|
+
this.spinner.stop();
|
|
1282
1352
|
console.log(); // Empty line before summary
|
|
1283
1353
|
for (const run of status.runs) {
|
|
1284
|
-
const
|
|
1285
|
-
const
|
|
1286
|
-
|
|
1354
|
+
const passed = run.success === 1;
|
|
1355
|
+
const symbol = passed ? picocolors_1.default.green('✔') : picocolors_1.default.red('✘');
|
|
1356
|
+
const statusText = passed
|
|
1357
|
+
? picocolors_1.default.green('Test completed successfully')
|
|
1358
|
+
: picocolors_1.default.red('Test failed');
|
|
1359
|
+
console.log(` ${symbol} Run ${run.id} ${picocolors_1.default.dim(`(${this.getRunDisplayName(run)})`)}: ${statusText}`);
|
|
1287
1360
|
}
|
|
1288
1361
|
}
|
|
1289
1362
|
const allSucceeded = status.runs.every((run) => run.success === 1);
|
|
1290
1363
|
if (allSucceeded) {
|
|
1364
|
+
(0, terminal_title_1.setTitle)('maestro · ✔ passed');
|
|
1291
1365
|
if (!this.options.quiet) {
|
|
1292
1366
|
logger_1.default.info('All tests completed successfully!');
|
|
1293
1367
|
}
|
|
1294
1368
|
}
|
|
1295
1369
|
else {
|
|
1296
1370
|
const failedRuns = status.runs.filter((run) => run.success !== 1);
|
|
1371
|
+
(0, terminal_title_1.setTitle)(`maestro · ✘ ${failedRuns.length} failed`);
|
|
1297
1372
|
logger_1.default.error(`${failedRuns.length} test run(s) failed`);
|
|
1298
1373
|
}
|
|
1299
1374
|
if (this.options.report && this.options.reportOutputDir) {
|
|
@@ -1307,35 +1382,50 @@ class Maestro extends base_provider_1.default {
|
|
|
1307
1382
|
runs: status.runs,
|
|
1308
1383
|
};
|
|
1309
1384
|
}
|
|
1310
|
-
|
|
1311
|
-
|
|
1385
|
+
// Checked after getStatus() so a run that completes during the final
|
|
1386
|
+
// sleep is returned as success on the next iteration instead of being
|
|
1387
|
+
// misreported as a timeout.
|
|
1388
|
+
if (Date.now() - startTime >= this.MAX_POLL_DURATION_MS) {
|
|
1389
|
+
throw new testingbot_error_1.default(`Test timed out after ${this.MAX_POLL_DURATION_MS / 1000 / 60} minutes`);
|
|
1390
|
+
}
|
|
1391
|
+
const signature = JSON.stringify(status.runs.map((r) => [
|
|
1392
|
+
r.id,
|
|
1393
|
+
r.status,
|
|
1394
|
+
r.success,
|
|
1395
|
+
r.flows?.map((f) => [f.id, f.status]) ?? [],
|
|
1396
|
+
]));
|
|
1397
|
+
const changed = signature !== previousSignature;
|
|
1398
|
+
previousSignature = signature;
|
|
1399
|
+
pollInterval = this.computeNextPollInterval(pollInterval, changed);
|
|
1400
|
+
await this.sleep(pollInterval);
|
|
1312
1401
|
}
|
|
1313
|
-
throw new testingbot_error_1.default(`Test timed out after ${(this.MAX_POLL_ATTEMPTS * this.POLL_INTERVAL_MS) / 1000 / 60} minutes`);
|
|
1314
1402
|
}
|
|
1315
1403
|
displayRunStatus(runs, startTime, previousStatus) {
|
|
1316
1404
|
const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000);
|
|
1317
1405
|
const elapsedStr = this.formatElapsedTime(elapsedSeconds);
|
|
1406
|
+
const activeMessages = [];
|
|
1318
1407
|
for (const run of runs) {
|
|
1319
1408
|
const prevStatus = previousStatus.get(run.id);
|
|
1320
1409
|
const statusChanged = prevStatus !== run.status;
|
|
1321
|
-
// If status changed from WAITING/READY to something else, clear the updating line
|
|
1322
|
-
if (statusChanged &&
|
|
1323
|
-
prevStatus &&
|
|
1324
|
-
(prevStatus === 'WAITING' || prevStatus === 'READY')) {
|
|
1325
|
-
this.clearLine();
|
|
1326
|
-
}
|
|
1327
1410
|
previousStatus.set(run.id, run.status);
|
|
1328
1411
|
const statusInfo = this.getStatusInfo(run.status);
|
|
1329
1412
|
if (run.status === 'WAITING' || run.status === 'READY') {
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1413
|
+
const label = run.status === 'WAITING'
|
|
1414
|
+
? picocolors_1.default.yellow(statusInfo.text)
|
|
1415
|
+
: picocolors_1.default.cyan(statusInfo.text);
|
|
1416
|
+
activeMessages.push(`${label} ${picocolors_1.default.dim(`• Run ${run.id} (${this.getRunDisplayName(run)}) • ${elapsedStr}`)}`);
|
|
1333
1417
|
}
|
|
1334
1418
|
else if (statusChanged) {
|
|
1335
|
-
|
|
1336
|
-
console.log(` ${statusInfo.
|
|
1419
|
+
this.spinner.clearLine();
|
|
1420
|
+
console.log(` ${statusInfo.symbol} Run ${run.id} ${picocolors_1.default.dim(`(${this.getRunDisplayName(run)})`)}: ${statusInfo.text}`);
|
|
1337
1421
|
}
|
|
1338
1422
|
}
|
|
1423
|
+
if (activeMessages.length > 0) {
|
|
1424
|
+
this.spinner.setMessage(activeMessages.join(picocolors_1.default.dim(' ┊ ')));
|
|
1425
|
+
}
|
|
1426
|
+
else {
|
|
1427
|
+
this.spinner.stop();
|
|
1428
|
+
}
|
|
1339
1429
|
}
|
|
1340
1430
|
/**
|
|
1341
1431
|
* Get the display name for a run, preferring environment.name over capabilities.deviceName
|
|
@@ -1347,32 +1437,39 @@ class Maestro extends base_provider_1.default {
|
|
|
1347
1437
|
getStatusInfo(status) {
|
|
1348
1438
|
switch (status) {
|
|
1349
1439
|
case 'WAITING':
|
|
1350
|
-
return {
|
|
1440
|
+
return { symbol: picocolors_1.default.yellow('◐'), text: 'Waiting for test to start' };
|
|
1351
1441
|
case 'READY':
|
|
1352
|
-
return {
|
|
1442
|
+
return { symbol: picocolors_1.default.cyan('◑'), text: 'Running test' };
|
|
1353
1443
|
case 'DONE':
|
|
1354
|
-
return {
|
|
1444
|
+
return { symbol: picocolors_1.default.green('✔'), text: 'Test has finished running' };
|
|
1355
1445
|
case 'FAILED':
|
|
1356
|
-
return {
|
|
1446
|
+
return { symbol: picocolors_1.default.red('✘'), text: 'Test failed' };
|
|
1357
1447
|
default:
|
|
1358
|
-
return {
|
|
1448
|
+
return { symbol: picocolors_1.default.dim('?'), text: status };
|
|
1359
1449
|
}
|
|
1360
1450
|
}
|
|
1361
1451
|
getFlowStatusDisplay(flow) {
|
|
1452
|
+
const frame = FLOW_SPINNER_FRAMES[this.flowAnimationFrame];
|
|
1362
1453
|
switch (flow.status) {
|
|
1363
1454
|
case 'WAITING':
|
|
1364
|
-
return {
|
|
1455
|
+
return {
|
|
1456
|
+
text: `${frame} WAITING`,
|
|
1457
|
+
colored: picocolors_1.default.yellow(`${frame} WAITING`),
|
|
1458
|
+
};
|
|
1365
1459
|
case 'READY':
|
|
1366
|
-
return {
|
|
1460
|
+
return {
|
|
1461
|
+
text: `${frame} RUNNING`,
|
|
1462
|
+
colored: picocolors_1.default.cyan(`${frame} RUNNING`),
|
|
1463
|
+
};
|
|
1367
1464
|
case 'DONE':
|
|
1368
1465
|
if (flow.success === 1) {
|
|
1369
|
-
return { text: 'PASSED', colored: picocolors_1.default.green('PASSED') };
|
|
1466
|
+
return { text: '✔ PASSED', colored: picocolors_1.default.green('✔ PASSED') };
|
|
1370
1467
|
}
|
|
1371
1468
|
else {
|
|
1372
|
-
return { text: 'FAILED', colored: picocolors_1.default.red('FAILED') };
|
|
1469
|
+
return { text: '✘ FAILED', colored: picocolors_1.default.red('✘ FAILED') };
|
|
1373
1470
|
}
|
|
1374
1471
|
case 'FAILED':
|
|
1375
|
-
return { text: 'FAILED', colored: picocolors_1.default.red('FAILED') };
|
|
1472
|
+
return { text: '✘ FAILED', colored: picocolors_1.default.red('✘ FAILED') };
|
|
1376
1473
|
default:
|
|
1377
1474
|
return { text: flow.status, colored: flow.status };
|
|
1378
1475
|
}
|
|
@@ -1466,8 +1563,8 @@ class Maestro extends base_provider_1.default {
|
|
|
1466
1563
|
return linesWritten;
|
|
1467
1564
|
}
|
|
1468
1565
|
displayFlowsTableHeader(hasFailures = false) {
|
|
1469
|
-
let header = ` ${'Duration'.padEnd(10)} ${'Status'.padEnd(
|
|
1470
|
-
let separator = ` ${'─'.repeat(10)} ${'─'.repeat(
|
|
1566
|
+
let header = ` ${'Duration'.padEnd(10)} ${'Status'.padEnd(10)} Flow`;
|
|
1567
|
+
let separator = ` ${'─'.repeat(10)} ${'─'.repeat(10)} ${'─'.repeat(30)}`;
|
|
1471
1568
|
if (hasFailures) {
|
|
1472
1569
|
header += ' Fail reason';
|
|
1473
1570
|
separator += ` ${'─'.repeat(80)}`;
|
|
@@ -1480,7 +1577,7 @@ class Maestro extends base_provider_1.default {
|
|
|
1480
1577
|
const statusDisplay = this.getFlowStatusDisplay(flow);
|
|
1481
1578
|
// Pad based on display text length, add extra for color codes
|
|
1482
1579
|
const statusPadded = statusDisplay.colored +
|
|
1483
|
-
' '.repeat(Math.max(0,
|
|
1580
|
+
' '.repeat(Math.max(0, 10 - statusDisplay.text.length));
|
|
1484
1581
|
const name = flow.name.padEnd(30);
|
|
1485
1582
|
let linesWritten = 0;
|
|
1486
1583
|
const isFailed = flow.status === 'DONE' && flow.success !== 1;
|
|
@@ -1500,8 +1597,8 @@ class Maestro extends base_provider_1.default {
|
|
|
1500
1597
|
linesWritten++;
|
|
1501
1598
|
// Display remaining error messages on continuation lines
|
|
1502
1599
|
if (!isUpdate && hasFailures && isFailed && errorMessages.length > 1) {
|
|
1503
|
-
// Indent to align with the Fail reason column: Duration(11) + Status(
|
|
1504
|
-
const indent = ' '.repeat(
|
|
1600
|
+
// Indent to align with the Fail reason column: Duration(11) + Status(11) + Test(31) = 53 chars
|
|
1601
|
+
const indent = ' '.repeat(53);
|
|
1505
1602
|
for (let i = 1; i < errorMessages.length; i++) {
|
|
1506
1603
|
console.log(`${indent} ${picocolors_1.default.red(errorMessages[i])}`);
|
|
1507
1604
|
linesWritten++;
|
|
@@ -1524,6 +1621,33 @@ class Maestro extends base_provider_1.default {
|
|
|
1524
1621
|
}
|
|
1525
1622
|
return linesWritten;
|
|
1526
1623
|
}
|
|
1624
|
+
/**
|
|
1625
|
+
* Starts the flow-table animation loop. Re-renders the cached flow rows at
|
|
1626
|
+
* `FLOW_ANIMATION_MS` so WAITING/RUNNING spinner frames advance between
|
|
1627
|
+
* (much slower) polls. Calling while already running is a no-op.
|
|
1628
|
+
*/
|
|
1629
|
+
startFlowAnimation(previousFlowStatus) {
|
|
1630
|
+
if (this.flowAnimationTimer || !utils_1.default.isInteractive())
|
|
1631
|
+
return;
|
|
1632
|
+
this.flowAnimationTimer = setInterval(() => {
|
|
1633
|
+
const hasActive = this.latestFlows.some((f) => f.status === 'WAITING' || f.status === 'READY');
|
|
1634
|
+
if (!hasActive)
|
|
1635
|
+
return;
|
|
1636
|
+
this.flowAnimationFrame =
|
|
1637
|
+
(this.flowAnimationFrame + 1) % FLOW_SPINNER_FRAMES.length;
|
|
1638
|
+
this.latestDisplayedLineCount = this.updateFlowsInPlace(this.latestFlows, previousFlowStatus, this.latestDisplayedLineCount);
|
|
1639
|
+
}, FLOW_ANIMATION_MS);
|
|
1640
|
+
this.flowAnimationTimer.unref?.();
|
|
1641
|
+
}
|
|
1642
|
+
stopFlowAnimation() {
|
|
1643
|
+
if (this.flowAnimationTimer) {
|
|
1644
|
+
clearInterval(this.flowAnimationTimer);
|
|
1645
|
+
this.flowAnimationTimer = null;
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
stopAnimations() {
|
|
1649
|
+
this.stopFlowAnimation();
|
|
1650
|
+
}
|
|
1527
1651
|
updateFlowsInPlace(flows, previousFlowStatus, displayedLineCount) {
|
|
1528
1652
|
const maxFlows = this.getMaxDisplayableFlows();
|
|
1529
1653
|
const displayFlows = flows.slice(0, maxFlows);
|
|
@@ -1538,7 +1662,7 @@ class Maestro extends base_provider_1.default {
|
|
|
1538
1662
|
const duration = this.calculateFlowDuration(flow).padEnd(10);
|
|
1539
1663
|
const statusDisplay = this.getFlowStatusDisplay(flow);
|
|
1540
1664
|
const statusPadded = statusDisplay.colored +
|
|
1541
|
-
' '.repeat(Math.max(0,
|
|
1665
|
+
' '.repeat(Math.max(0, 10 - statusDisplay.text.length));
|
|
1542
1666
|
const name = flow.name;
|
|
1543
1667
|
const row = ` ${duration} ${statusPadded} ${name}`;
|
|
1544
1668
|
process.stdout.write(`\r\x1b[K${row}\n`);
|
|
@@ -1565,7 +1689,23 @@ class Maestro extends base_provider_1.default {
|
|
|
1565
1689
|
}
|
|
1566
1690
|
for (const run of runs) {
|
|
1567
1691
|
try {
|
|
1568
|
-
|
|
1692
|
+
let reportEndpoint;
|
|
1693
|
+
let reportKey;
|
|
1694
|
+
switch (reportFormat) {
|
|
1695
|
+
case 'junit':
|
|
1696
|
+
reportEndpoint = 'junit_report';
|
|
1697
|
+
reportKey = 'junit_report';
|
|
1698
|
+
break;
|
|
1699
|
+
case 'html-detailed':
|
|
1700
|
+
reportEndpoint = 'html_report_detailed';
|
|
1701
|
+
reportKey = 'html_report_detailed';
|
|
1702
|
+
break;
|
|
1703
|
+
case 'html':
|
|
1704
|
+
default:
|
|
1705
|
+
reportEndpoint = 'html_report';
|
|
1706
|
+
reportKey = 'html_report';
|
|
1707
|
+
break;
|
|
1708
|
+
}
|
|
1569
1709
|
const response = await axios_1.default.get(`${this.URL}/${this.appId}/${run.id}/${reportEndpoint}`, {
|
|
1570
1710
|
headers: {
|
|
1571
1711
|
'User-Agent': utils_1.default.getUserAgent(),
|
|
@@ -1574,13 +1714,12 @@ class Maestro extends base_provider_1.default {
|
|
|
1574
1714
|
username: this.credentials.userName,
|
|
1575
1715
|
password: this.credentials.accessKey,
|
|
1576
1716
|
},
|
|
1577
|
-
timeout:
|
|
1717
|
+
timeout: constants_1.HTTP.TIMEOUT_MS,
|
|
1578
1718
|
});
|
|
1579
1719
|
// Check for version update notification
|
|
1580
1720
|
const latestVersion = response.headers?.['x-testingbotctl-version'];
|
|
1581
1721
|
utils_1.default.checkForUpdate(latestVersion);
|
|
1582
1722
|
// Extract the report content from the JSON response
|
|
1583
|
-
const reportKey = reportFormat === 'junit' ? 'junit_report' : 'html_report';
|
|
1584
1723
|
const reportContent = response.data[reportKey];
|
|
1585
1724
|
if (!reportContent) {
|
|
1586
1725
|
logger_1.default.error(`No ${reportFormat} report found for run ${run.id}`);
|
|
@@ -1610,7 +1749,7 @@ class Maestro extends base_provider_1.default {
|
|
|
1610
1749
|
username: this.credentials.userName,
|
|
1611
1750
|
password: this.credentials.accessKey,
|
|
1612
1751
|
},
|
|
1613
|
-
timeout:
|
|
1752
|
+
timeout: constants_1.HTTP.TIMEOUT_MS,
|
|
1614
1753
|
});
|
|
1615
1754
|
const latestVersion = response.headers?.['x-testingbotctl-version'];
|
|
1616
1755
|
utils_1.default.checkForUpdate(latestVersion);
|
|
@@ -1630,7 +1769,7 @@ class Maestro extends base_provider_1.default {
|
|
|
1630
1769
|
return details;
|
|
1631
1770
|
}
|
|
1632
1771
|
attempts++;
|
|
1633
|
-
await this.sleep(this.
|
|
1772
|
+
await this.sleep(this.MIN_POLL_INTERVAL_MS);
|
|
1634
1773
|
}
|
|
1635
1774
|
throw new testingbot_error_1.default(`Timed out waiting for artifacts to sync for run ${runId}`);
|
|
1636
1775
|
}
|
|
@@ -1844,6 +1983,7 @@ class Maestro extends base_provider_1.default {
|
|
|
1844
1983
|
const output = node_fs_1.default.createWriteStream(zipPath);
|
|
1845
1984
|
const archive = (0, archiver_1.default)('zip', { zlib: { level: 9 } });
|
|
1846
1985
|
output.on('close', () => resolve());
|
|
1986
|
+
output.on('error', (err) => reject(err));
|
|
1847
1987
|
archive.on('error', (err) => reject(err));
|
|
1848
1988
|
archive.pipe(output);
|
|
1849
1989
|
archive.directory(sourceDir, false);
|
|
@@ -1858,9 +1998,9 @@ class Maestro extends base_provider_1.default {
|
|
|
1858
1998
|
this.socket = (0, socket_io_client_1.io)(this.updateServer, {
|
|
1859
1999
|
transports: ['websocket'],
|
|
1860
2000
|
reconnection: true,
|
|
1861
|
-
reconnectionAttempts:
|
|
1862
|
-
reconnectionDelay:
|
|
1863
|
-
timeout:
|
|
2001
|
+
reconnectionAttempts: constants_1.SOCKET.RECONNECTION_ATTEMPTS,
|
|
2002
|
+
reconnectionDelay: constants_1.SOCKET.RECONNECTION_DELAY_MS,
|
|
2003
|
+
timeout: constants_1.SOCKET.TIMEOUT_MS,
|
|
1864
2004
|
});
|
|
1865
2005
|
this.socket.on('connect', () => {
|
|
1866
2006
|
// Join the room for this test run
|
|
@@ -1872,8 +2012,12 @@ class Maestro extends base_provider_1.default {
|
|
|
1872
2012
|
this.socket.on('maestro_error', (data) => {
|
|
1873
2013
|
this.handleMaestroError(data);
|
|
1874
2014
|
});
|
|
1875
|
-
this.socket.on('connect_error', () => {
|
|
1876
|
-
|
|
2015
|
+
this.socket.on('connect_error', (err) => {
|
|
2016
|
+
if (!this.socketFallbackWarned) {
|
|
2017
|
+
this.socketFallbackWarned = true;
|
|
2018
|
+
logger_1.default.warn('Real-time log stream unavailable, falling back to polling.');
|
|
2019
|
+
logger_1.default.debug(`Socket connect_error: ${err?.message ?? 'unknown error'}`);
|
|
2020
|
+
}
|
|
1877
2021
|
this.disconnectFromUpdateServer();
|
|
1878
2022
|
});
|
|
1879
2023
|
}
|
|
@@ -1892,8 +2036,8 @@ class Maestro extends base_provider_1.default {
|
|
|
1892
2036
|
try {
|
|
1893
2037
|
const message = JSON.parse(data);
|
|
1894
2038
|
if (message.payload) {
|
|
1895
|
-
// Clear the
|
|
1896
|
-
this.clearLine();
|
|
2039
|
+
// Clear the spinner line before printing output
|
|
2040
|
+
this.spinner.clearLine();
|
|
1897
2041
|
// Print the Maestro output, trimming trailing newlines
|
|
1898
2042
|
process.stdout.write(message.payload);
|
|
1899
2043
|
}
|
|
@@ -1906,8 +2050,8 @@ class Maestro extends base_provider_1.default {
|
|
|
1906
2050
|
try {
|
|
1907
2051
|
const message = JSON.parse(data);
|
|
1908
2052
|
if (message.payload) {
|
|
1909
|
-
// Clear the
|
|
1910
|
-
this.clearLine();
|
|
2053
|
+
// Clear the spinner line before printing error
|
|
2054
|
+
this.spinner.clearLine();
|
|
1911
2055
|
// Print the error output
|
|
1912
2056
|
process.stderr.write(message.payload);
|
|
1913
2057
|
}
|
|
@@ -36,6 +36,7 @@ export default class XCUITest extends BaseProvider<XCUITestOptions> {
|
|
|
36
36
|
private socket;
|
|
37
37
|
private updateServer;
|
|
38
38
|
private updateKey;
|
|
39
|
+
private socketFallbackWarned;
|
|
39
40
|
constructor(credentials: Credentials, options: XCUITestOptions);
|
|
40
41
|
private validate;
|
|
41
42
|
run(): Promise<XCUITestResult>;
|