@testingbot/cli 1.0.0
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/LICENSE +21 -0
- package/README.md +375 -0
- package/dist/auth.d.ts +16 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +47 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +329 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/logger.d.ts +4 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +20 -0
- package/dist/models/credentials.d.ts +9 -0
- package/dist/models/credentials.d.ts.map +1 -0
- package/dist/models/credentials.js +20 -0
- package/dist/models/espresso_options.d.ts +116 -0
- package/dist/models/espresso_options.d.ts.map +1 -0
- package/dist/models/espresso_options.js +194 -0
- package/dist/models/maestro_options.d.ts +101 -0
- package/dist/models/maestro_options.d.ts.map +1 -0
- package/dist/models/maestro_options.js +176 -0
- package/dist/models/testingbot_error.d.ts +3 -0
- package/dist/models/testingbot_error.d.ts.map +1 -0
- package/dist/models/testingbot_error.js +5 -0
- package/dist/models/xcuitest_options.d.ts +88 -0
- package/dist/models/xcuitest_options.d.ts.map +1 -0
- package/dist/models/xcuitest_options.js +146 -0
- package/dist/providers/espresso.d.ts +67 -0
- package/dist/providers/espresso.d.ts.map +1 -0
- package/dist/providers/espresso.js +527 -0
- package/dist/providers/login.d.ts +18 -0
- package/dist/providers/login.d.ts.map +1 -0
- package/dist/providers/login.js +284 -0
- package/dist/providers/maestro.d.ts +92 -0
- package/dist/providers/maestro.d.ts.map +1 -0
- package/dist/providers/maestro.js +1010 -0
- package/dist/providers/xcuitest.d.ts +67 -0
- package/dist/providers/xcuitest.d.ts.map +1 -0
- package/dist/providers/xcuitest.js +529 -0
- package/dist/upload.d.ts +21 -0
- package/dist/upload.d.ts.map +1 -0
- package/dist/upload.js +94 -0
- package/dist/utils/file-type-detector.d.ts +15 -0
- package/dist/utils/file-type-detector.d.ts.map +1 -0
- package/dist/utils/file-type-detector.js +38 -0
- package/dist/utils/platform.d.ts +26 -0
- package/dist/utils/platform.d.ts.map +1 -0
- package/dist/utils/platform.js +58 -0
- package/dist/utils.d.ts +15 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +48 -0
- package/package.json +78 -0
|
@@ -0,0 +1,1010 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
+
};
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
const logger_1 = __importDefault(require("../logger"));
|
|
40
|
+
const axios_1 = __importDefault(require("axios"));
|
|
41
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
42
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
43
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
44
|
+
const glob_1 = require("glob");
|
|
45
|
+
const yaml = __importStar(require("js-yaml"));
|
|
46
|
+
const archiver_1 = __importDefault(require("archiver"));
|
|
47
|
+
const socket_io_client_1 = require("socket.io-client");
|
|
48
|
+
const testingbot_error_1 = __importDefault(require("../models/testingbot_error"));
|
|
49
|
+
const utils_1 = __importDefault(require("../utils"));
|
|
50
|
+
const upload_1 = __importDefault(require("../upload"));
|
|
51
|
+
const file_type_detector_1 = require("../utils/file-type-detector");
|
|
52
|
+
const platform_1 = __importDefault(require("../utils/platform"));
|
|
53
|
+
class Maestro {
|
|
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
|
+
detectedPlatform = undefined;
|
|
62
|
+
activeRunIds = [];
|
|
63
|
+
isShuttingDown = false;
|
|
64
|
+
signalHandler = null;
|
|
65
|
+
socket = null;
|
|
66
|
+
updateServer = null;
|
|
67
|
+
updateKey = null;
|
|
68
|
+
constructor(credentials, options) {
|
|
69
|
+
this.credentials = credentials;
|
|
70
|
+
this.options = options;
|
|
71
|
+
this.upload = new upload_1.default();
|
|
72
|
+
}
|
|
73
|
+
async validate() {
|
|
74
|
+
if (this.options.app === undefined) {
|
|
75
|
+
throw new testingbot_error_1.default(`app option is required`);
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
await node_fs_1.default.promises.access(this.options.app, node_fs_1.default.constants.R_OK);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
throw new testingbot_error_1.default(`Provided app path does not exist ${this.options.app}`);
|
|
82
|
+
}
|
|
83
|
+
if (this.options.flows === undefined || this.options.flows.length === 0) {
|
|
84
|
+
throw new testingbot_error_1.default(`flows option is required`);
|
|
85
|
+
}
|
|
86
|
+
// Check if all flows paths exist (can be files, directories, or glob patterns)
|
|
87
|
+
for (const flowsPath of this.options.flows) {
|
|
88
|
+
const isGlobPattern = flowsPath.includes('*') ||
|
|
89
|
+
flowsPath.includes('?') ||
|
|
90
|
+
flowsPath.includes('{');
|
|
91
|
+
if (!isGlobPattern) {
|
|
92
|
+
try {
|
|
93
|
+
await node_fs_1.default.promises.access(flowsPath, node_fs_1.default.constants.R_OK);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
throw new testingbot_error_1.default(`flows path does not exist ${flowsPath}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
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
|
+
if (this.options.reportOutputDir) {
|
|
106
|
+
await this.ensureOutputDirectory(this.options.reportOutputDir);
|
|
107
|
+
}
|
|
108
|
+
// Validate artifact download options - output dir defaults to current directory
|
|
109
|
+
if (this.options.downloadArtifacts && this.options.artifactsOutputDir) {
|
|
110
|
+
await this.ensureOutputDirectory(this.options.artifactsOutputDir);
|
|
111
|
+
}
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
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
|
+
/**
|
|
140
|
+
* Detect platform from app file content using magic bytes
|
|
141
|
+
*/
|
|
142
|
+
async detectPlatform() {
|
|
143
|
+
const appPath = this.options.app;
|
|
144
|
+
if (!appPath)
|
|
145
|
+
return undefined;
|
|
146
|
+
return (0, file_type_detector_1.detectPlatformFromFile)(appPath);
|
|
147
|
+
}
|
|
148
|
+
async run() {
|
|
149
|
+
if (!(await this.validate())) {
|
|
150
|
+
return { success: false, runs: [] };
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
// Detect platform from file content if not explicitly provided
|
|
154
|
+
if (!this.options.platformName) {
|
|
155
|
+
this.detectedPlatform = await this.detectPlatform();
|
|
156
|
+
}
|
|
157
|
+
if (!this.options.quiet) {
|
|
158
|
+
logger_1.default.info('Uploading Maestro App');
|
|
159
|
+
}
|
|
160
|
+
await this.uploadApp();
|
|
161
|
+
if (!this.options.quiet) {
|
|
162
|
+
logger_1.default.info('Uploading Maestro Flows');
|
|
163
|
+
}
|
|
164
|
+
await this.uploadFlows();
|
|
165
|
+
if (!this.options.quiet) {
|
|
166
|
+
logger_1.default.info('Running Maestro Tests');
|
|
167
|
+
}
|
|
168
|
+
await this.runTests();
|
|
169
|
+
if (this.options.async) {
|
|
170
|
+
if (!this.options.quiet) {
|
|
171
|
+
logger_1.default.info(`Tests started in async mode. Project ID: ${this.appId}`);
|
|
172
|
+
}
|
|
173
|
+
return { success: true, runs: [] };
|
|
174
|
+
}
|
|
175
|
+
// Set up signal handlers before waiting for completion
|
|
176
|
+
this.setupSignalHandlers();
|
|
177
|
+
// Connect to real-time update server (unless --quiet is specified)
|
|
178
|
+
this.connectToUpdateServer();
|
|
179
|
+
if (!this.options.quiet) {
|
|
180
|
+
logger_1.default.info('Waiting for test results...');
|
|
181
|
+
}
|
|
182
|
+
const result = await this.waitForCompletion();
|
|
183
|
+
// Clean up
|
|
184
|
+
this.disconnectFromUpdateServer();
|
|
185
|
+
this.removeSignalHandlers();
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
// Clean up on error
|
|
190
|
+
this.disconnectFromUpdateServer();
|
|
191
|
+
this.removeSignalHandlers();
|
|
192
|
+
logger_1.default.error(error instanceof Error ? error.message : error);
|
|
193
|
+
// Display the cause if available
|
|
194
|
+
if (error instanceof Error && error.cause) {
|
|
195
|
+
const causeMessage = this.extractErrorMessage(error.cause);
|
|
196
|
+
if (causeMessage) {
|
|
197
|
+
logger_1.default.error(` Reason: ${causeMessage}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return { success: false, runs: [] };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
async uploadApp() {
|
|
204
|
+
const appPath = this.options.app;
|
|
205
|
+
const ext = node_path_1.default.extname(appPath).toLowerCase();
|
|
206
|
+
let contentType;
|
|
207
|
+
if (ext === '.apk') {
|
|
208
|
+
contentType = 'application/vnd.android.package-archive';
|
|
209
|
+
}
|
|
210
|
+
else if (ext === '.ipa' || ext === '.app') {
|
|
211
|
+
contentType = 'application/octet-stream';
|
|
212
|
+
}
|
|
213
|
+
else if (ext === '.zip') {
|
|
214
|
+
contentType = 'application/zip';
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
contentType = 'application/octet-stream';
|
|
218
|
+
}
|
|
219
|
+
const result = await this.upload.upload({
|
|
220
|
+
filePath: appPath,
|
|
221
|
+
url: `${this.URL}/app`,
|
|
222
|
+
credentials: this.credentials,
|
|
223
|
+
contentType,
|
|
224
|
+
showProgress: !this.options.quiet,
|
|
225
|
+
});
|
|
226
|
+
this.appId = result.id;
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
async uploadFlows() {
|
|
230
|
+
const flowsPaths = this.options.flows;
|
|
231
|
+
let zipPath;
|
|
232
|
+
let shouldCleanup = false;
|
|
233
|
+
// Special case: single zip file - upload directly
|
|
234
|
+
if (flowsPaths.length === 1) {
|
|
235
|
+
const singlePath = flowsPaths[0];
|
|
236
|
+
const stat = await node_fs_1.default.promises.stat(singlePath).catch(() => null);
|
|
237
|
+
if (stat?.isFile() && node_path_1.default.extname(singlePath).toLowerCase() === '.zip') {
|
|
238
|
+
zipPath = singlePath;
|
|
239
|
+
// Upload the zip directly without cleanup
|
|
240
|
+
await this.upload.upload({
|
|
241
|
+
filePath: zipPath,
|
|
242
|
+
url: `${this.URL}/${this.appId}/tests`,
|
|
243
|
+
credentials: this.credentials,
|
|
244
|
+
contentType: 'application/zip',
|
|
245
|
+
showProgress: !this.options.quiet,
|
|
246
|
+
});
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// Collect all flow files from all paths
|
|
251
|
+
const allFlowFiles = [];
|
|
252
|
+
const baseDirs = [];
|
|
253
|
+
for (const flowsPath of flowsPaths) {
|
|
254
|
+
const stat = await node_fs_1.default.promises.stat(flowsPath).catch(() => null);
|
|
255
|
+
if (stat?.isFile()) {
|
|
256
|
+
const ext = node_path_1.default.extname(flowsPath).toLowerCase();
|
|
257
|
+
if (ext === '.yaml' || ext === '.yml') {
|
|
258
|
+
allFlowFiles.push(flowsPath);
|
|
259
|
+
}
|
|
260
|
+
else if (ext === '.zip') {
|
|
261
|
+
throw new testingbot_error_1.default(`Cannot combine .zip files with other flow paths. Use a single .zip file or provide directories/patterns.`);
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
throw new testingbot_error_1.default(`Invalid flow file format. Expected .yaml, .yml, or .zip, got ${ext}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
else if (stat?.isDirectory()) {
|
|
268
|
+
// Directory of flows
|
|
269
|
+
const flowFiles = await this.discoverFlows(flowsPath);
|
|
270
|
+
if (flowFiles.length === 0 && flowsPaths.length === 1) {
|
|
271
|
+
throw new testingbot_error_1.default(`No flow files (.yaml, .yml) found in directory ${flowsPath}`);
|
|
272
|
+
}
|
|
273
|
+
allFlowFiles.push(...flowFiles);
|
|
274
|
+
baseDirs.push(flowsPath);
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
// Treat as glob pattern
|
|
278
|
+
const flowFiles = await (0, glob_1.glob)(flowsPath);
|
|
279
|
+
const yamlFiles = flowFiles.filter((f) => {
|
|
280
|
+
const ext = node_path_1.default.extname(f).toLowerCase();
|
|
281
|
+
return ext === '.yaml' || ext === '.yml';
|
|
282
|
+
});
|
|
283
|
+
if (yamlFiles.length === 0 && flowsPaths.length === 1) {
|
|
284
|
+
throw new testingbot_error_1.default(`No flow files found matching pattern ${flowsPath}`);
|
|
285
|
+
}
|
|
286
|
+
allFlowFiles.push(...yamlFiles);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (allFlowFiles.length === 0) {
|
|
290
|
+
throw new testingbot_error_1.default(`No flow files (.yaml, .yml) found in the provided paths`);
|
|
291
|
+
}
|
|
292
|
+
// Determine base directory for zip structure
|
|
293
|
+
// If we have a single directory, use it as base; otherwise use common ancestor or flatten
|
|
294
|
+
const baseDir = baseDirs.length === 1 ? baseDirs[0] : undefined;
|
|
295
|
+
zipPath = await this.createFlowsZip(allFlowFiles, baseDir);
|
|
296
|
+
shouldCleanup = true;
|
|
297
|
+
try {
|
|
298
|
+
await this.upload.upload({
|
|
299
|
+
filePath: zipPath,
|
|
300
|
+
url: `${this.URL}/${this.appId}/tests`,
|
|
301
|
+
credentials: this.credentials,
|
|
302
|
+
contentType: 'application/zip',
|
|
303
|
+
showProgress: !this.options.quiet,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
if (shouldCleanup) {
|
|
308
|
+
await node_fs_1.default.promises.unlink(zipPath).catch(() => { });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
async discoverFlows(directory) {
|
|
314
|
+
const entries = await node_fs_1.default.promises.readdir(directory, {
|
|
315
|
+
withFileTypes: true,
|
|
316
|
+
});
|
|
317
|
+
const flowFiles = [];
|
|
318
|
+
const configPath = node_path_1.default.join(directory, 'config.yaml');
|
|
319
|
+
let config = null;
|
|
320
|
+
// Check for config.yaml
|
|
321
|
+
try {
|
|
322
|
+
const configContent = await node_fs_1.default.promises.readFile(configPath, 'utf-8');
|
|
323
|
+
config = yaml.load(configContent);
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
// No config.yaml, that's fine
|
|
327
|
+
}
|
|
328
|
+
// If config specifies flows, use those
|
|
329
|
+
if (config?.flows && config.flows.length > 0) {
|
|
330
|
+
for (const flowPattern of config.flows) {
|
|
331
|
+
const matches = await (0, glob_1.glob)(node_path_1.default.join(directory, flowPattern));
|
|
332
|
+
flowFiles.push(...matches);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
// Otherwise, get all yaml files in the directory (not subdirectories)
|
|
337
|
+
for (const entry of entries) {
|
|
338
|
+
if (entry.isFile()) {
|
|
339
|
+
const ext = node_path_1.default.extname(entry.name).toLowerCase();
|
|
340
|
+
if ((ext === '.yaml' || ext === '.yml') &&
|
|
341
|
+
entry.name !== 'config.yaml') {
|
|
342
|
+
flowFiles.push(node_path_1.default.join(directory, entry.name));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// Also discover dependencies (runFlow, runScript, addMedia)
|
|
348
|
+
const allFiles = new Set(flowFiles);
|
|
349
|
+
for (const flowFile of flowFiles) {
|
|
350
|
+
const dependencies = await this.discoverDependencies(flowFile, directory);
|
|
351
|
+
dependencies.forEach((dep) => allFiles.add(dep));
|
|
352
|
+
}
|
|
353
|
+
return Array.from(allFiles);
|
|
354
|
+
}
|
|
355
|
+
async discoverDependencies(flowFile, baseDir) {
|
|
356
|
+
const dependencies = [];
|
|
357
|
+
try {
|
|
358
|
+
const content = await node_fs_1.default.promises.readFile(flowFile, 'utf-8');
|
|
359
|
+
const flowData = yaml.load(content);
|
|
360
|
+
if (Array.isArray(flowData)) {
|
|
361
|
+
for (const step of flowData) {
|
|
362
|
+
if (typeof step === 'object' && step !== null) {
|
|
363
|
+
// Check for runFlow
|
|
364
|
+
if ('runFlow' in step) {
|
|
365
|
+
const runFlowValue = step.runFlow;
|
|
366
|
+
const refFile = typeof runFlowValue === 'string'
|
|
367
|
+
? runFlowValue
|
|
368
|
+
: runFlowValue?.file;
|
|
369
|
+
if (refFile) {
|
|
370
|
+
const depPath = node_path_1.default.resolve(node_path_1.default.dirname(flowFile), refFile);
|
|
371
|
+
if ((await node_fs_1.default.promises.access(depPath).catch(() => false)) ===
|
|
372
|
+
undefined) {
|
|
373
|
+
dependencies.push(depPath);
|
|
374
|
+
const nestedDeps = await this.discoverDependencies(depPath, baseDir);
|
|
375
|
+
dependencies.push(...nestedDeps);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
// Check for runScript
|
|
380
|
+
if ('runScript' in step) {
|
|
381
|
+
const scriptFile = step.runScript?.file;
|
|
382
|
+
if (scriptFile) {
|
|
383
|
+
const depPath = node_path_1.default.resolve(node_path_1.default.dirname(flowFile), scriptFile);
|
|
384
|
+
if ((await node_fs_1.default.promises.access(depPath).catch(() => false)) ===
|
|
385
|
+
undefined) {
|
|
386
|
+
dependencies.push(depPath);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
// Check for addMedia
|
|
391
|
+
if ('addMedia' in step) {
|
|
392
|
+
const mediaFiles = Array.isArray(step.addMedia)
|
|
393
|
+
? step.addMedia
|
|
394
|
+
: [step.addMedia];
|
|
395
|
+
for (const mediaFile of mediaFiles) {
|
|
396
|
+
if (typeof mediaFile === 'string') {
|
|
397
|
+
const depPath = node_path_1.default.resolve(node_path_1.default.dirname(flowFile), mediaFile);
|
|
398
|
+
if ((await node_fs_1.default.promises.access(depPath).catch(() => false)) ===
|
|
399
|
+
undefined) {
|
|
400
|
+
dependencies.push(depPath);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
// Ignore parsing errors
|
|
411
|
+
}
|
|
412
|
+
return dependencies;
|
|
413
|
+
}
|
|
414
|
+
async createFlowsZip(files, baseDir) {
|
|
415
|
+
const tmpDir = await node_fs_1.default.promises.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), 'maestro-'));
|
|
416
|
+
const zipPath = node_path_1.default.join(tmpDir, 'flows.zip');
|
|
417
|
+
return new Promise((resolve, reject) => {
|
|
418
|
+
const output = node_fs_1.default.createWriteStream(zipPath);
|
|
419
|
+
const archive = (0, archiver_1.default)('zip', { zlib: { level: 9 } });
|
|
420
|
+
output.on('close', () => resolve(zipPath));
|
|
421
|
+
archive.on('error', (err) => reject(err));
|
|
422
|
+
archive.pipe(output);
|
|
423
|
+
for (const file of files) {
|
|
424
|
+
// Determine the name in the archive
|
|
425
|
+
let archiveName;
|
|
426
|
+
if (baseDir) {
|
|
427
|
+
archiveName = node_path_1.default.relative(baseDir, file);
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
archiveName = node_path_1.default.basename(file);
|
|
431
|
+
}
|
|
432
|
+
archive.file(file, { name: archiveName });
|
|
433
|
+
}
|
|
434
|
+
archive.finalize();
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
async runTests() {
|
|
438
|
+
try {
|
|
439
|
+
const capabilities = this.options.getCapabilities(this.detectedPlatform);
|
|
440
|
+
const maestroOptions = this.options.getMaestroOptions();
|
|
441
|
+
const response = await axios_1.default.post(`${this.URL}/${this.appId}/run`, {
|
|
442
|
+
capabilities: [capabilities],
|
|
443
|
+
...(maestroOptions && { maestroOptions }),
|
|
444
|
+
}, {
|
|
445
|
+
headers: {
|
|
446
|
+
'Content-Type': 'application/json',
|
|
447
|
+
'User-Agent': utils_1.default.getUserAgent(),
|
|
448
|
+
},
|
|
449
|
+
auth: {
|
|
450
|
+
username: this.credentials.userName,
|
|
451
|
+
password: this.credentials.accessKey,
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
// Check for version update notification
|
|
455
|
+
const latestVersion = response.headers?.['x-testingbotctl-version'];
|
|
456
|
+
utils_1.default.checkForUpdate(latestVersion);
|
|
457
|
+
const result = response.data;
|
|
458
|
+
// Capture real-time update server info
|
|
459
|
+
if (result.update_server && result.update_key) {
|
|
460
|
+
this.updateServer = result.update_server;
|
|
461
|
+
this.updateKey = result.update_key;
|
|
462
|
+
}
|
|
463
|
+
if (result.success === false) {
|
|
464
|
+
// API returns errors as an array
|
|
465
|
+
const errorMessage = result.errors?.join('\n') || result.error || 'Unknown error';
|
|
466
|
+
throw new testingbot_error_1.default(`Running Maestro test failed`, {
|
|
467
|
+
cause: errorMessage,
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
if (error instanceof testingbot_error_1.default) {
|
|
474
|
+
throw error;
|
|
475
|
+
}
|
|
476
|
+
throw new testingbot_error_1.default(`Running Maestro test failed`, {
|
|
477
|
+
cause: error,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
async getStatus() {
|
|
482
|
+
try {
|
|
483
|
+
const response = await axios_1.default.get(`${this.URL}/${this.appId}`, {
|
|
484
|
+
headers: {
|
|
485
|
+
'User-Agent': utils_1.default.getUserAgent(),
|
|
486
|
+
},
|
|
487
|
+
auth: {
|
|
488
|
+
username: this.credentials.userName,
|
|
489
|
+
password: this.credentials.accessKey,
|
|
490
|
+
},
|
|
491
|
+
});
|
|
492
|
+
return response.data;
|
|
493
|
+
}
|
|
494
|
+
catch (error) {
|
|
495
|
+
throw new testingbot_error_1.default(`Failed to get Maestro test status`, {
|
|
496
|
+
cause: error,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
async waitForCompletion() {
|
|
501
|
+
let attempts = 0;
|
|
502
|
+
const startTime = Date.now();
|
|
503
|
+
const previousStatus = new Map();
|
|
504
|
+
while (attempts < this.MAX_POLL_ATTEMPTS) {
|
|
505
|
+
// Check if we're shutting down
|
|
506
|
+
if (this.isShuttingDown) {
|
|
507
|
+
throw new testingbot_error_1.default('Test run cancelled by user');
|
|
508
|
+
}
|
|
509
|
+
const status = await this.getStatus();
|
|
510
|
+
// Track active run IDs for graceful shutdown
|
|
511
|
+
this.activeRunIds = status.runs
|
|
512
|
+
.filter((run) => run.status !== 'DONE' && run.status !== 'FAILED')
|
|
513
|
+
.map((run) => run.id);
|
|
514
|
+
// Log current status of runs (unless quiet mode)
|
|
515
|
+
if (!this.options.quiet) {
|
|
516
|
+
this.displayRunStatus(status.runs, startTime, previousStatus);
|
|
517
|
+
}
|
|
518
|
+
if (status.completed) {
|
|
519
|
+
// Clear the updating line and print final status
|
|
520
|
+
if (!this.options.quiet) {
|
|
521
|
+
this.clearLine();
|
|
522
|
+
for (const run of status.runs) {
|
|
523
|
+
const statusEmoji = run.success === 1 ? '✅' : '❌';
|
|
524
|
+
const statusText = run.success === 1 ? 'Test completed successfully' : 'Test failed';
|
|
525
|
+
console.log(` ${statusEmoji} Run ${run.id} (${run.capabilities.deviceName}): ${statusText}`);
|
|
526
|
+
console.log(` View results: https://testingbot.com/members/maestro/${this.appId}/runs/${run.id}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const allSucceeded = status.runs.every((run) => run.success === 1);
|
|
530
|
+
if (allSucceeded) {
|
|
531
|
+
if (!this.options.quiet) {
|
|
532
|
+
logger_1.default.info('All tests completed successfully!');
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
else {
|
|
536
|
+
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
|
+
}
|
|
541
|
+
}
|
|
542
|
+
// Fetch reports if requested
|
|
543
|
+
if (this.options.report && this.options.reportOutputDir) {
|
|
544
|
+
await this.fetchReports(status.runs);
|
|
545
|
+
}
|
|
546
|
+
// Download artifacts if requested
|
|
547
|
+
if (this.options.downloadArtifacts && this.options.artifactsOutputDir) {
|
|
548
|
+
await this.downloadArtifacts(status.runs);
|
|
549
|
+
}
|
|
550
|
+
return {
|
|
551
|
+
success: status.success,
|
|
552
|
+
runs: status.runs,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
attempts++;
|
|
556
|
+
await this.sleep(this.POLL_INTERVAL_MS);
|
|
557
|
+
}
|
|
558
|
+
throw new testingbot_error_1.default(`Test timed out after ${(this.MAX_POLL_ATTEMPTS * this.POLL_INTERVAL_MS) / 1000 / 60} minutes`);
|
|
559
|
+
}
|
|
560
|
+
displayRunStatus(runs, startTime, previousStatus) {
|
|
561
|
+
const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000);
|
|
562
|
+
const elapsedStr = this.formatElapsedTime(elapsedSeconds);
|
|
563
|
+
for (const run of runs) {
|
|
564
|
+
const prevStatus = previousStatus.get(run.id);
|
|
565
|
+
const statusChanged = prevStatus !== run.status;
|
|
566
|
+
// If status changed from WAITING/READY to something else, clear the updating line
|
|
567
|
+
if (statusChanged &&
|
|
568
|
+
prevStatus &&
|
|
569
|
+
(prevStatus === 'WAITING' || prevStatus === 'READY')) {
|
|
570
|
+
this.clearLine();
|
|
571
|
+
}
|
|
572
|
+
previousStatus.set(run.id, run.status);
|
|
573
|
+
const statusInfo = this.getStatusInfo(run.status);
|
|
574
|
+
if (run.status === 'WAITING' || run.status === 'READY') {
|
|
575
|
+
// Update the same line for WAITING and READY states
|
|
576
|
+
const message = ` ${statusInfo.emoji} Run ${run.id} (${run.capabilities.deviceName}): ${statusInfo.text} (${elapsedStr})`;
|
|
577
|
+
process.stdout.write(`\r${message}`);
|
|
578
|
+
}
|
|
579
|
+
else if (statusChanged) {
|
|
580
|
+
// For other states (DONE, FAILED), print on a new line only when status changes
|
|
581
|
+
console.log(` ${statusInfo.emoji} Run ${run.id} (${run.capabilities.deviceName}): ${statusInfo.text}`);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
clearLine() {
|
|
586
|
+
platform_1.default.clearLine();
|
|
587
|
+
}
|
|
588
|
+
formatElapsedTime(seconds) {
|
|
589
|
+
if (seconds < 60) {
|
|
590
|
+
return `${seconds}s`;
|
|
591
|
+
}
|
|
592
|
+
const minutes = Math.floor(seconds / 60);
|
|
593
|
+
const remainingSeconds = seconds % 60;
|
|
594
|
+
return `${minutes}m ${remainingSeconds}s`;
|
|
595
|
+
}
|
|
596
|
+
getStatusInfo(status) {
|
|
597
|
+
switch (status) {
|
|
598
|
+
case 'WAITING':
|
|
599
|
+
return { emoji: '⏳', text: 'Waiting for test to start' };
|
|
600
|
+
case 'READY':
|
|
601
|
+
return { emoji: '🔄', text: 'Running test' };
|
|
602
|
+
case 'DONE':
|
|
603
|
+
return { emoji: '✅', text: 'Test has finished running' };
|
|
604
|
+
case 'FAILED':
|
|
605
|
+
return { emoji: '❌', text: 'Test failed' };
|
|
606
|
+
default:
|
|
607
|
+
return { emoji: '❓', text: status };
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
async fetchReports(runs) {
|
|
611
|
+
const reportFormat = this.options.report;
|
|
612
|
+
const outputDir = this.options.reportOutputDir;
|
|
613
|
+
if (!reportFormat || !outputDir) {
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (!this.options.quiet) {
|
|
617
|
+
logger_1.default.info(`Fetching ${reportFormat} reports...`);
|
|
618
|
+
}
|
|
619
|
+
for (const run of runs) {
|
|
620
|
+
try {
|
|
621
|
+
const reportEndpoint = reportFormat === 'junit' ? 'junit_report' : 'html_report';
|
|
622
|
+
const response = await axios_1.default.get(`${this.URL}/${this.appId}/${run.id}/${reportEndpoint}`, {
|
|
623
|
+
headers: {
|
|
624
|
+
'User-Agent': utils_1.default.getUserAgent(),
|
|
625
|
+
},
|
|
626
|
+
auth: {
|
|
627
|
+
username: this.credentials.userName,
|
|
628
|
+
password: this.credentials.accessKey,
|
|
629
|
+
},
|
|
630
|
+
});
|
|
631
|
+
// Extract the report content from the JSON response
|
|
632
|
+
const reportKey = reportFormat === 'junit' ? 'junit_report' : 'html_report';
|
|
633
|
+
const reportContent = response.data[reportKey];
|
|
634
|
+
if (!reportContent) {
|
|
635
|
+
logger_1.default.error(`No ${reportFormat} report found for run ${run.id}`);
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
const fileExtension = reportFormat === 'junit' ? 'xml' : 'html';
|
|
639
|
+
const fileName = `report_run_${run.id}.${fileExtension}`;
|
|
640
|
+
const filePath = node_path_1.default.join(outputDir, fileName);
|
|
641
|
+
await node_fs_1.default.promises.writeFile(filePath, reportContent, 'utf-8');
|
|
642
|
+
if (!this.options.quiet) {
|
|
643
|
+
logger_1.default.info(` Saved report for run ${run.id}: ${filePath}`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
catch (error) {
|
|
647
|
+
logger_1.default.error(`Failed to fetch report for run ${run.id}: ${error instanceof Error ? error.message : error}`);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
async getRunDetails(runId) {
|
|
652
|
+
try {
|
|
653
|
+
const response = await axios_1.default.get(`${this.URL}/${this.appId}/${runId}`, {
|
|
654
|
+
headers: {
|
|
655
|
+
'User-Agent': utils_1.default.getUserAgent(),
|
|
656
|
+
},
|
|
657
|
+
auth: {
|
|
658
|
+
username: this.credentials.userName,
|
|
659
|
+
password: this.credentials.accessKey,
|
|
660
|
+
},
|
|
661
|
+
});
|
|
662
|
+
return response.data;
|
|
663
|
+
}
|
|
664
|
+
catch (error) {
|
|
665
|
+
throw new testingbot_error_1.default(`Failed to get run details for run ${runId}`, {
|
|
666
|
+
cause: error,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
async waitForArtifactsSync(runId) {
|
|
671
|
+
const maxAttempts = 60; // 5 minutes max wait for artifacts
|
|
672
|
+
let attempts = 0;
|
|
673
|
+
while (attempts < maxAttempts) {
|
|
674
|
+
const details = await this.getRunDetails(runId);
|
|
675
|
+
if (details.assets_synced) {
|
|
676
|
+
return details;
|
|
677
|
+
}
|
|
678
|
+
attempts++;
|
|
679
|
+
await this.sleep(this.POLL_INTERVAL_MS);
|
|
680
|
+
}
|
|
681
|
+
throw new testingbot_error_1.default(`Timed out waiting for artifacts to sync for run ${runId}`);
|
|
682
|
+
}
|
|
683
|
+
async downloadFile(url, filePath) {
|
|
684
|
+
try {
|
|
685
|
+
const response = await axios_1.default.get(url, {
|
|
686
|
+
responseType: 'arraybuffer',
|
|
687
|
+
headers: {
|
|
688
|
+
'User-Agent': utils_1.default.getUserAgent(),
|
|
689
|
+
},
|
|
690
|
+
});
|
|
691
|
+
await node_fs_1.default.promises.writeFile(filePath, response.data);
|
|
692
|
+
}
|
|
693
|
+
catch (error) {
|
|
694
|
+
throw new testingbot_error_1.default(`Failed to download file from ${url}`, {
|
|
695
|
+
cause: error,
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
generateArtifactZipName() {
|
|
700
|
+
// Use --build option if provided, otherwise generate timestamp-based name
|
|
701
|
+
if (this.options.build) {
|
|
702
|
+
const sanitizedBuild = this.options.build.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
703
|
+
return `${sanitizedBuild}.zip`;
|
|
704
|
+
}
|
|
705
|
+
// Generate unique name with timestamp
|
|
706
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
707
|
+
return `maestro_artifacts_${timestamp}.zip`;
|
|
708
|
+
}
|
|
709
|
+
async downloadArtifacts(runs) {
|
|
710
|
+
if (!this.options.downloadArtifacts)
|
|
711
|
+
return;
|
|
712
|
+
if (!this.options.quiet) {
|
|
713
|
+
logger_1.default.info('Downloading artifacts...');
|
|
714
|
+
}
|
|
715
|
+
const outputDir = this.options.artifactsOutputDir || process.cwd();
|
|
716
|
+
const tempDir = await node_fs_1.default.promises.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), 'testingbot-maestro-artifacts-'));
|
|
717
|
+
try {
|
|
718
|
+
for (const run of runs) {
|
|
719
|
+
try {
|
|
720
|
+
if (!this.options.quiet) {
|
|
721
|
+
logger_1.default.info(` Waiting for artifacts sync for run ${run.id}...`);
|
|
722
|
+
}
|
|
723
|
+
const runDetails = await this.waitForArtifactsSync(run.id);
|
|
724
|
+
if (!runDetails.assets) {
|
|
725
|
+
if (!this.options.quiet) {
|
|
726
|
+
logger_1.default.info(` No artifacts available for run ${run.id}`);
|
|
727
|
+
}
|
|
728
|
+
continue;
|
|
729
|
+
}
|
|
730
|
+
const runDir = node_path_1.default.join(tempDir, `run_${run.id}`);
|
|
731
|
+
await node_fs_1.default.promises.mkdir(runDir, { recursive: true });
|
|
732
|
+
// Download logs
|
|
733
|
+
if (runDetails.assets.logs && runDetails.assets.logs.length > 0) {
|
|
734
|
+
const logsDir = node_path_1.default.join(runDir, 'logs');
|
|
735
|
+
await node_fs_1.default.promises.mkdir(logsDir, { recursive: true });
|
|
736
|
+
for (let i = 0; i < runDetails.assets.logs.length; i++) {
|
|
737
|
+
const logUrl = runDetails.assets.logs[i];
|
|
738
|
+
const logFileName = node_path_1.default.basename(logUrl) || `log_${i}.txt`;
|
|
739
|
+
const logPath = node_path_1.default.join(logsDir, logFileName);
|
|
740
|
+
try {
|
|
741
|
+
await this.downloadFile(logUrl, logPath);
|
|
742
|
+
if (!this.options.quiet) {
|
|
743
|
+
logger_1.default.info(` Downloaded log: ${logFileName}`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
catch (error) {
|
|
747
|
+
logger_1.default.error(` Failed to download log ${logFileName}: ${error instanceof Error ? error.message : error}`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
if (runDetails.assets.video &&
|
|
752
|
+
typeof runDetails.assets.video === 'string') {
|
|
753
|
+
const videoDir = node_path_1.default.join(runDir, 'video');
|
|
754
|
+
await node_fs_1.default.promises.mkdir(videoDir, { recursive: true });
|
|
755
|
+
const videoUrl = runDetails.assets.video;
|
|
756
|
+
const videoFileName = node_path_1.default.basename(videoUrl) || 'video.mp4';
|
|
757
|
+
const videoPath = node_path_1.default.join(videoDir, videoFileName);
|
|
758
|
+
try {
|
|
759
|
+
await this.downloadFile(videoUrl, videoPath);
|
|
760
|
+
if (!this.options.quiet) {
|
|
761
|
+
logger_1.default.info(` Downloaded video: ${videoFileName}`);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
catch (error) {
|
|
765
|
+
logger_1.default.error(` Failed to download video: ${error instanceof Error ? error.message : error}`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
if (runDetails.assets.screenshots &&
|
|
769
|
+
runDetails.assets.screenshots.length > 0) {
|
|
770
|
+
const screenshotsDir = node_path_1.default.join(runDir, 'screenshots');
|
|
771
|
+
await node_fs_1.default.promises.mkdir(screenshotsDir, { recursive: true });
|
|
772
|
+
for (let i = 0; i < runDetails.assets.screenshots.length; i++) {
|
|
773
|
+
const screenshotUrl = runDetails.assets.screenshots[i];
|
|
774
|
+
const screenshotFileName = node_path_1.default.basename(screenshotUrl) || `screenshot_${i}.png`;
|
|
775
|
+
const screenshotPath = node_path_1.default.join(screenshotsDir, screenshotFileName);
|
|
776
|
+
try {
|
|
777
|
+
await this.downloadFile(screenshotUrl, screenshotPath);
|
|
778
|
+
if (!this.options.quiet) {
|
|
779
|
+
logger_1.default.info(` Downloaded screenshot: ${screenshotFileName}`);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
catch (error) {
|
|
783
|
+
logger_1.default.error(` Failed to download screenshot ${screenshotFileName}: ${error instanceof Error ? error.message : error}`);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
if (runDetails.report) {
|
|
788
|
+
const reportPath = node_path_1.default.join(runDir, 'report.xml');
|
|
789
|
+
try {
|
|
790
|
+
await node_fs_1.default.promises.writeFile(reportPath, runDetails.report, 'utf-8');
|
|
791
|
+
if (!this.options.quiet) {
|
|
792
|
+
logger_1.default.info(` Saved report.xml`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
catch (error) {
|
|
796
|
+
logger_1.default.error(` Failed to save report.xml: ${error instanceof Error ? error.message : error}`);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
if (!this.options.quiet) {
|
|
800
|
+
logger_1.default.info(` Artifacts for run ${run.id} downloaded`);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
catch (error) {
|
|
804
|
+
logger_1.default.error(`Failed to download artifacts for run ${run.id}: ${error instanceof Error ? error.message : error}`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
const zipFileName = this.generateArtifactZipName();
|
|
808
|
+
const zipFilePath = node_path_1.default.join(outputDir, zipFileName);
|
|
809
|
+
if (!this.options.quiet) {
|
|
810
|
+
logger_1.default.info(`Creating artifacts zip: ${zipFileName}`);
|
|
811
|
+
}
|
|
812
|
+
await this.createZipFromDirectory(tempDir, zipFilePath);
|
|
813
|
+
if (!this.options.quiet) {
|
|
814
|
+
logger_1.default.info(`Artifacts saved to: ${zipFilePath}`);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
finally {
|
|
818
|
+
try {
|
|
819
|
+
await node_fs_1.default.promises.rm(tempDir, { recursive: true, force: true });
|
|
820
|
+
}
|
|
821
|
+
catch {
|
|
822
|
+
// Ignore cleanup errors
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
async createZipFromDirectory(sourceDir, zipPath) {
|
|
827
|
+
return new Promise((resolve, reject) => {
|
|
828
|
+
const output = node_fs_1.default.createWriteStream(zipPath);
|
|
829
|
+
const archive = (0, archiver_1.default)('zip', { zlib: { level: 9 } });
|
|
830
|
+
output.on('close', () => resolve());
|
|
831
|
+
archive.on('error', (err) => reject(err));
|
|
832
|
+
archive.pipe(output);
|
|
833
|
+
archive.directory(sourceDir, false);
|
|
834
|
+
archive.finalize();
|
|
835
|
+
});
|
|
836
|
+
}
|
|
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
|
+
connectToUpdateServer() {
|
|
944
|
+
if (!this.updateServer || !this.updateKey || this.options.quiet) {
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
try {
|
|
948
|
+
this.socket = (0, socket_io_client_1.io)(this.updateServer, {
|
|
949
|
+
transports: ['websocket'],
|
|
950
|
+
reconnection: true,
|
|
951
|
+
reconnectionAttempts: 3,
|
|
952
|
+
reconnectionDelay: 1000,
|
|
953
|
+
timeout: 10000,
|
|
954
|
+
});
|
|
955
|
+
this.socket.on('connect', () => {
|
|
956
|
+
// Join the room for this test run
|
|
957
|
+
this.socket?.emit('join', this.updateKey);
|
|
958
|
+
});
|
|
959
|
+
this.socket.on('maestro_data', (data) => {
|
|
960
|
+
this.handleMaestroData(data);
|
|
961
|
+
});
|
|
962
|
+
this.socket.on('maestro_error', (data) => {
|
|
963
|
+
this.handleMaestroError(data);
|
|
964
|
+
});
|
|
965
|
+
this.socket.on('connect_error', () => {
|
|
966
|
+
// Silently fail - real-time updates are optional
|
|
967
|
+
this.disconnectFromUpdateServer();
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
catch {
|
|
971
|
+
// Socket connection failed, continue without real-time updates
|
|
972
|
+
this.socket = null;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
disconnectFromUpdateServer() {
|
|
976
|
+
if (this.socket) {
|
|
977
|
+
this.socket.disconnect();
|
|
978
|
+
this.socket = null;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
handleMaestroData(data) {
|
|
982
|
+
try {
|
|
983
|
+
const message = JSON.parse(data);
|
|
984
|
+
if (message.payload) {
|
|
985
|
+
// Clear the status line before printing output
|
|
986
|
+
this.clearLine();
|
|
987
|
+
// Print the Maestro output, trimming trailing newlines
|
|
988
|
+
process.stdout.write(message.payload);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
catch {
|
|
992
|
+
// Invalid JSON, ignore
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
handleMaestroError(data) {
|
|
996
|
+
try {
|
|
997
|
+
const message = JSON.parse(data);
|
|
998
|
+
if (message.payload) {
|
|
999
|
+
// Clear the status line before printing error
|
|
1000
|
+
this.clearLine();
|
|
1001
|
+
// Print the error output
|
|
1002
|
+
process.stderr.write(message.payload);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
catch {
|
|
1006
|
+
// Invalid JSON, ignore
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
exports.default = Maestro;
|