@testrigor/testrigor-cli 0.0.42-beta → 0.0.45-beta
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 +40 -0
- package/dist/commands/test-suite/run.d.ts +6 -0
- package/dist/commands/test-suite/run.js +235 -38
- package/dist/types.d.ts +51 -0
- package/dist/util/appium.d.ts +25 -0
- package/dist/util/appium.js +189 -0
- package/dist/util/certificate-uploader.d.ts +2 -0
- package/dist/util/certificate-uploader.js +32 -0
- package/dist/util/trtc.d.ts +1 -0
- package/dist/util/trtc.js +5 -1
- package/dist/util/tunnel.d.ts +1 -0
- package/dist/util/tunnel.js +9 -9
- package/dist/util/type-guards.d.ts +4 -0
- package/dist/util/type-guards.js +15 -0
- package/dist/util/variable-file-uploader.d.ts +1 -0
- package/dist/util/variable-file-uploader.js +43 -0
- package/dist/util/variables-path-parser.d.ts +2 -0
- package/dist/util/variables-path-parser.js +126 -0
- package/oclif.manifest.json +15 -9
- package/package.json +8 -3
package/README.md
CHANGED
|
@@ -31,3 +31,43 @@ This is a command line tool for TestRigor. It allows you to run TestRigor tests
|
|
|
31
31
|
20. Show current configuration: `testrigor test-suite config --show`
|
|
32
32
|
21. Authenticate with TestRigor and store credentials (interactive prompt): `testrigor authenticate`
|
|
33
33
|
22. Run with override chrome extensions at runtime (one or more chrome extensions): `test-suite run <test-suite-id> --token <token> --chrome-extension "name=<extension-name> file=<extension-file>" --chrome-extension "name=<extension-name> file=<extension-file>"`
|
|
34
|
+
|
|
35
|
+
### Mobile Testing
|
|
36
|
+
|
|
37
|
+
Run mobile tests on your own local devices (emulators/simulators/real devices) using the same `--localhost` flag. The CLI auto-detects mobile suites and handles everything behind the scenes (mobile server, tunnel, configuration).
|
|
38
|
+
|
|
39
|
+
#### Setup
|
|
40
|
+
|
|
41
|
+
Run your test suite with `--localhost` and `--local-test-nodes-path`:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
testrigor test-suite run <test-suite-id> --token <token> --localhost --local-test-nodes-path ./test-nodes.yaml
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The CLI will:
|
|
48
|
+
- Detect it's a mobile suite
|
|
49
|
+
- Start the mobile server (Appium) automatically
|
|
50
|
+
- Open a tunnel to TestRigor cloud
|
|
51
|
+
- Run the test on your local device
|
|
52
|
+
- Stop everything when done
|
|
53
|
+
|
|
54
|
+
#### Override device config with a YAML file
|
|
55
|
+
|
|
56
|
+
Instead of configuring devices in the UI, you can provide a local YAML file with `--local-test-nodes-path`:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
testrigor test-suite run <test-suite-id> --token <token> --local-test-nodes-path ./test-nodes.yaml
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Example `test-nodes.yaml`:
|
|
63
|
+
|
|
64
|
+
```yaml
|
|
65
|
+
testNodes:
|
|
66
|
+
- os: iOS
|
|
67
|
+
osVersion: "18.4"
|
|
68
|
+
abi: x86_64
|
|
69
|
+
x86_64: "iPhone 16 Pro"
|
|
70
|
+
overrideTestNodes: true
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
This overrides the device config stored in the test suite and forces mobile mode (no need for `--localhost`).
|
|
@@ -17,6 +17,7 @@ export default class Run extends CommandBase<typeof Run> {
|
|
|
17
17
|
"test-cases-path": import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
|
|
18
18
|
"rules-path": import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
|
|
19
19
|
"settings-path": import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
|
|
20
|
+
"local-test-nodes-path": import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
|
|
20
21
|
"test-case-uuid": import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
|
|
21
22
|
"auto-create-ai-rules": import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
|
|
22
23
|
"explicit-mutations": import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
|
|
@@ -28,6 +29,7 @@ export default class Run extends CommandBase<typeof Run> {
|
|
|
28
29
|
name: string;
|
|
29
30
|
file: string;
|
|
30
31
|
} | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
|
|
32
|
+
"xray-execution-key": import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces/parser").CustomOptions>;
|
|
31
33
|
};
|
|
32
34
|
static args: {
|
|
33
35
|
testSuiteId: import("@oclif/core/lib/interfaces/parser").Arg<string | undefined, Record<string, unknown>>;
|
|
@@ -36,7 +38,11 @@ export default class Run extends CommandBase<typeof Run> {
|
|
|
36
38
|
run(): Promise<void>;
|
|
37
39
|
private fetchStatus;
|
|
38
40
|
private getStatusApiEndpoint;
|
|
41
|
+
private uploadVariableFiles;
|
|
42
|
+
private uploadClientCertificates;
|
|
39
43
|
private callRetest;
|
|
44
|
+
private fetchSuiteType;
|
|
45
|
+
private isMobileSuiteType;
|
|
40
46
|
private uploadFile;
|
|
41
47
|
private waitForTestSuiteToBeCreated;
|
|
42
48
|
private waitForTestSuiteToFinish;
|
|
@@ -11,6 +11,10 @@ const chrome_extension_1 = require("../../flags/chrome-extension");
|
|
|
11
11
|
const parser_1 = require("../../util/parser");
|
|
12
12
|
const request_builder_1 = require("../../util/request-builder");
|
|
13
13
|
const tunnel_1 = require("../../util/tunnel");
|
|
14
|
+
const certificate_uploader_1 = require("../../util/certificate-uploader");
|
|
15
|
+
const variable_file_uploader_1 = require("../../util/variable-file-uploader");
|
|
16
|
+
const variables_path_parser_1 = require("../../util/variables-path-parser");
|
|
17
|
+
const appium_1 = require("../../util/appium");
|
|
14
18
|
const utils_1 = require("../../util/utils");
|
|
15
19
|
const sleep = async (milliseconds) => {
|
|
16
20
|
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
@@ -70,6 +74,7 @@ class Run extends command_base_1.CommandBase {
|
|
|
70
74
|
const maxPort = flags["max-port"];
|
|
71
75
|
const url = flags["url"];
|
|
72
76
|
const filePath = flags["file-path"];
|
|
77
|
+
const localTestNodesPath = flags["local-test-nodes-path"];
|
|
73
78
|
const variablesPath = flags["variables-path"];
|
|
74
79
|
const testCasesPath = flags["test-cases-path"];
|
|
75
80
|
const rulesPath = flags["rules-path"];
|
|
@@ -79,6 +84,7 @@ class Run extends command_base_1.CommandBase {
|
|
|
79
84
|
const explicitMutations = flags["explicit-mutations"];
|
|
80
85
|
const labels = flags["labels"];
|
|
81
86
|
const excludedLabels = flags["excluded-labels"];
|
|
87
|
+
const xrayExecutionKey = flags["xray-execution-key"];
|
|
82
88
|
const chromeExtensions = flags["chrome-extension"];
|
|
83
89
|
const context = {
|
|
84
90
|
configuration: {
|
|
@@ -87,6 +93,8 @@ class Run extends command_base_1.CommandBase {
|
|
|
87
93
|
settings: {},
|
|
88
94
|
},
|
|
89
95
|
tunnel: new tunnel_1.default(),
|
|
96
|
+
isMobile: false,
|
|
97
|
+
appiumStartedByUs: false,
|
|
90
98
|
};
|
|
91
99
|
if (labels) {
|
|
92
100
|
context.configuration.labels = labels;
|
|
@@ -94,6 +102,9 @@ class Run extends command_base_1.CommandBase {
|
|
|
94
102
|
if (excludedLabels) {
|
|
95
103
|
context.configuration.excludedLabels = excludedLabels;
|
|
96
104
|
}
|
|
105
|
+
if (xrayExecutionKey) {
|
|
106
|
+
context.configuration.xrayExecutionKey = xrayExecutionKey;
|
|
107
|
+
}
|
|
97
108
|
if (minPort > maxPort) {
|
|
98
109
|
core_1.ux.error("--minPort cannot be greater than --maxPort");
|
|
99
110
|
}
|
|
@@ -110,10 +121,23 @@ class Run extends command_base_1.CommandBase {
|
|
|
110
121
|
if (variablesPath) {
|
|
111
122
|
try {
|
|
112
123
|
const file = await Fs.content(variablesPath);
|
|
113
|
-
|
|
124
|
+
const parsed = JSON.parse(file.toString());
|
|
125
|
+
const { textValues, fileInputs, certInputs } = (0, variables_path_parser_1.parseVariablesJson)(parsed);
|
|
126
|
+
context.configuration.storedValues = textValues;
|
|
127
|
+
if (fileInputs.length > 0) {
|
|
128
|
+
context.storedValueFileInputs = fileInputs;
|
|
129
|
+
}
|
|
130
|
+
if (certInputs.length > 0) {
|
|
131
|
+
context.storedValueCertInputs = certInputs;
|
|
132
|
+
}
|
|
114
133
|
}
|
|
115
134
|
catch (e) {
|
|
116
|
-
|
|
135
|
+
if (e instanceof SyntaxError) {
|
|
136
|
+
core_1.ux.error("--variables-path must be a valid JSON path");
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
core_1.ux.error(e instanceof Error ? e.message : String(e));
|
|
140
|
+
}
|
|
117
141
|
}
|
|
118
142
|
}
|
|
119
143
|
if (testCasesPath) {
|
|
@@ -149,6 +173,24 @@ class Run extends command_base_1.CommandBase {
|
|
|
149
173
|
core_1.ux.error("--settings-path must be a valid JSON/YAML path");
|
|
150
174
|
}
|
|
151
175
|
}
|
|
176
|
+
if (localTestNodesPath) {
|
|
177
|
+
try {
|
|
178
|
+
const localTestNodes = await (0, parser_1.parseSettings)(localTestNodesPath);
|
|
179
|
+
if (localTestNodes) {
|
|
180
|
+
if (Array.isArray(localTestNodes.testNodes)) {
|
|
181
|
+
for (const node of localTestNodes.testNodes) {
|
|
182
|
+
if (!node.provider) {
|
|
183
|
+
node.provider = "LOCALHOST";
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
Object.assign(context.configuration, localTestNodes);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
core_1.ux.error("--local-test-nodes-path must be a valid JSON/YAML path");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
152
194
|
if (filePath) {
|
|
153
195
|
if (url) {
|
|
154
196
|
core_1.ux.info("WARNING: File path will be ignored because --url is used");
|
|
@@ -168,50 +210,85 @@ class Run extends command_base_1.CommandBase {
|
|
|
168
210
|
context.chromeExtensions = chromeExtensions;
|
|
169
211
|
}
|
|
170
212
|
super.setTunnel(context.tunnel);
|
|
213
|
+
if (localTestNodesPath) {
|
|
214
|
+
context.isMobile = true;
|
|
215
|
+
}
|
|
216
|
+
else if (flags.localhost) {
|
|
217
|
+
const suiteType = await this.fetchSuiteType(testSuiteId, token);
|
|
218
|
+
context.isMobile = this.isMobileSuiteType(suiteType);
|
|
219
|
+
}
|
|
171
220
|
const tasks = [
|
|
172
221
|
{
|
|
173
|
-
enabled: () => flags.localhost,
|
|
222
|
+
enabled: () => (flags.localhost || !!localTestNodesPath) && context.isMobile,
|
|
174
223
|
task: (ctx, task) => {
|
|
175
|
-
|
|
176
|
-
task.title = "Initializing tunnel";
|
|
177
|
-
}
|
|
178
|
-
else {
|
|
179
|
-
task.title = "Initializing";
|
|
180
|
-
}
|
|
224
|
+
task.title = "Starting mobile server";
|
|
181
225
|
return task.newListr([
|
|
182
226
|
{
|
|
183
|
-
task: async (
|
|
184
|
-
|
|
185
|
-
|
|
227
|
+
task: async (_ctx, subtask) => {
|
|
228
|
+
await (0, appium_1.checkAppiumInstalled)();
|
|
229
|
+
subtask.title = "Mobile server binary found";
|
|
230
|
+
},
|
|
231
|
+
rendererOptions: { timer: listr2_1.PRESET_TIMER },
|
|
232
|
+
},
|
|
233
|
+
...appium_1.REQUIRED_DRIVERS.map((driver) => ({
|
|
234
|
+
task: async (_ctx, subtask) => {
|
|
235
|
+
const status = await (0, appium_1.getDriverStatus)(driver.name, driver.version);
|
|
236
|
+
if (status.installed && status.upToDate) {
|
|
237
|
+
subtask.title = `Driver ${driver.name}@${driver.version} ready`;
|
|
238
|
+
return;
|
|
186
239
|
}
|
|
187
|
-
|
|
240
|
+
subtask.title = status.installed
|
|
241
|
+
? `Updating ${driver.name} (${status.version} → ${driver.version})`
|
|
242
|
+
: `Installing ${driver.name}@${driver.version}`;
|
|
243
|
+
const err = await (0, appium_1.ensureDriver)(driver.name, driver.version);
|
|
244
|
+
subtask.title = err
|
|
245
|
+
? `Driver ${driver.name} skipped — ${err}`
|
|
246
|
+
: `Driver ${driver.name}@${driver.version} ready`;
|
|
188
247
|
},
|
|
189
|
-
rendererOptions: {
|
|
190
|
-
|
|
248
|
+
rendererOptions: { timer: listr2_1.PRESET_TIMER },
|
|
249
|
+
})),
|
|
250
|
+
{
|
|
251
|
+
task: async (ctx, subtask) => {
|
|
252
|
+
const result = await (0, appium_1.startAppium)();
|
|
253
|
+
ctx.appiumStartedByUs = !result.alreadyRunning;
|
|
254
|
+
subtask.title = result.alreadyRunning
|
|
255
|
+
? `Mobile server already running (PID: ${result.pid}), reusing`
|
|
256
|
+
: `Mobile server started (PID: ${result.pid})`;
|
|
191
257
|
},
|
|
258
|
+
rendererOptions: { timer: listr2_1.PRESET_TIMER },
|
|
192
259
|
},
|
|
260
|
+
], { concurrent: false, rendererOptions: { collapseSubtasks: false } });
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
enabled: () => flags.localhost || !!localTestNodesPath,
|
|
265
|
+
task: (ctx, task) => {
|
|
266
|
+
task.title = flags.verbose ? "Initializing tunnel" : "Initializing";
|
|
267
|
+
return task.newListr([
|
|
193
268
|
{
|
|
194
|
-
task: async (ctx,
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
269
|
+
task: async (ctx, subtask) => {
|
|
270
|
+
await ctx.tunnel.initialize();
|
|
271
|
+
subtask.title = "Binary ready";
|
|
272
|
+
},
|
|
273
|
+
rendererOptions: { timer: listr2_1.PRESET_TIMER },
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
task: async (ctx, subtask) => {
|
|
198
277
|
await ctx.tunnel.start(minPort, maxPort);
|
|
278
|
+
subtask.title = `Tunnel started (port ${ctx.tunnel.getPort()})`;
|
|
199
279
|
},
|
|
200
280
|
exitOnError: true,
|
|
201
|
-
rendererOptions: {
|
|
202
|
-
timer: listr2_1.PRESET_TIMER,
|
|
203
|
-
},
|
|
281
|
+
rendererOptions: { timer: listr2_1.PRESET_TIMER },
|
|
204
282
|
},
|
|
205
283
|
{
|
|
206
284
|
enabled: (ctx) => ctx.tunnel.isRunning(),
|
|
207
|
-
task: (ctx,
|
|
208
|
-
|
|
209
|
-
task.title = `Tunnel started at port ${ctx.tunnel.getPort()}`;
|
|
210
|
-
}
|
|
285
|
+
task: (ctx, subtask) => {
|
|
286
|
+
const tunnelAddress = `tunnel.testrigor.com:${ctx.tunnel.getPort()}`;
|
|
211
287
|
ctx.configuration.settings = {
|
|
212
288
|
...ctx.configuration.settings,
|
|
213
|
-
httpProxyString:
|
|
289
|
+
httpProxyString: tunnelAddress,
|
|
214
290
|
};
|
|
291
|
+
subtask.title = `Tunnel connected (${tunnelAddress})`;
|
|
215
292
|
},
|
|
216
293
|
},
|
|
217
294
|
], { concurrent: false, rendererOptions: { collapseSubtasks: false } });
|
|
@@ -223,11 +300,27 @@ class Run extends command_base_1.CommandBase {
|
|
|
223
300
|
{
|
|
224
301
|
// Checking last run id
|
|
225
302
|
task: async (ctx) => {
|
|
226
|
-
|
|
303
|
+
let pollBranch = flags.branch;
|
|
304
|
+
if (testCaseUuid && !flags.branch) {
|
|
305
|
+
pollBranch = `single-test-execution-${testCaseUuid}`;
|
|
306
|
+
}
|
|
307
|
+
const statusResponse = await this.fetchStatus(testSuiteId, token, pollBranch, ctx.configuration.labels, ctx.configuration.excludedLabels);
|
|
227
308
|
context.lastRunId = (0, utils_1.extractRunIdFromDetailsUrl)(statusResponse.detailsUrl);
|
|
228
309
|
},
|
|
229
310
|
exitOnError: false,
|
|
230
311
|
},
|
|
312
|
+
{
|
|
313
|
+
title: "Uploading variable files",
|
|
314
|
+
enabled: () => !!context.storedValueFileInputs?.length,
|
|
315
|
+
task: (ctx) => this.uploadVariableFiles(ctx, testSuiteId, token),
|
|
316
|
+
exitOnError: false,
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
title: "Uploading client certificates",
|
|
320
|
+
enabled: () => !!context.storedValueCertInputs?.length,
|
|
321
|
+
task: (ctx, task) => this.uploadClientCertificates(ctx, testSuiteId, token, task),
|
|
322
|
+
exitOnError: false,
|
|
323
|
+
},
|
|
231
324
|
{
|
|
232
325
|
enabled: () => !flags["file-path"] && !flags["chrome-extension"],
|
|
233
326
|
task: async (ctx) => {
|
|
@@ -385,7 +478,11 @@ class Run extends command_base_1.CommandBase {
|
|
|
385
478
|
title: "Waiting for test suite run creation",
|
|
386
479
|
enabled: (ctx) => ctx.response?.queueId,
|
|
387
480
|
task: async (ctx, currentTask) => {
|
|
388
|
-
|
|
481
|
+
let pollBranch = flags.branch;
|
|
482
|
+
if (testCaseUuid && !flags.branch) {
|
|
483
|
+
pollBranch = `single-test-execution-${testCaseUuid}`;
|
|
484
|
+
}
|
|
485
|
+
const detailsUrl = await this.waitForTestSuiteToBeCreated(testSuiteId, token, ctx.lastRunId, pollBranch, ctx.configuration.labels, ctx.configuration.excludedLabels);
|
|
389
486
|
task.title = `Running test suite [ ${detailsUrl} ]`;
|
|
390
487
|
currentTask.title = "Test suite run created successfully";
|
|
391
488
|
},
|
|
@@ -394,13 +491,19 @@ class Run extends command_base_1.CommandBase {
|
|
|
394
491
|
{
|
|
395
492
|
title: "Checking status",
|
|
396
493
|
enabled: (ctx) => ctx.response?.result === "ok" && !flags.async,
|
|
397
|
-
task: async (ctx, task) =>
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
494
|
+
task: async (ctx, task) => {
|
|
495
|
+
let pollBranch = flags.branch;
|
|
496
|
+
if (testCaseUuid && !flags.branch) {
|
|
497
|
+
pollBranch = `single-test-execution-${testCaseUuid}`;
|
|
498
|
+
}
|
|
499
|
+
return task.newListr([
|
|
500
|
+
{
|
|
501
|
+
task: async (ctx, statusTask) => {
|
|
502
|
+
await this.waitForTestSuiteToFinish(testSuiteId, token, statusTask, pollBranch, ctx.configuration.labels, ctx.configuration.excludedLabels);
|
|
503
|
+
},
|
|
401
504
|
},
|
|
402
|
-
|
|
403
|
-
|
|
505
|
+
]);
|
|
506
|
+
},
|
|
404
507
|
exitOnError: false,
|
|
405
508
|
},
|
|
406
509
|
{
|
|
@@ -438,6 +541,15 @@ class Run extends command_base_1.CommandBase {
|
|
|
438
541
|
await ctx.tunnel.stop();
|
|
439
542
|
},
|
|
440
543
|
},
|
|
544
|
+
{
|
|
545
|
+
enabled: (ctx) => ctx.appiumStartedByUs,
|
|
546
|
+
task: async (_ctx, task) => {
|
|
547
|
+
if (flags.verbose) {
|
|
548
|
+
task.title = "Stopping mobile server";
|
|
549
|
+
}
|
|
550
|
+
await (0, appium_1.stopAppium)();
|
|
551
|
+
},
|
|
552
|
+
},
|
|
441
553
|
];
|
|
442
554
|
const runner = new listr2_1.Listr(tasks, {
|
|
443
555
|
ctx: context,
|
|
@@ -480,6 +592,69 @@ class Run extends command_base_1.CommandBase {
|
|
|
480
592
|
const queryString = queryParams.toString();
|
|
481
593
|
return queryString ? `${apiEndpoint}?${queryString}` : apiEndpoint;
|
|
482
594
|
}
|
|
595
|
+
async uploadVariableFiles(ctx, testSuiteId, token) {
|
|
596
|
+
const controller = new AbortController();
|
|
597
|
+
const timeoutId = setTimeout(() => controller.abort(), utils_1.UPLOAD_TIMEOUT_MS);
|
|
598
|
+
try {
|
|
599
|
+
ctx.configuration.storedValues = ctx.configuration.storedValues || {};
|
|
600
|
+
const uploads = await Promise.all(ctx.storedValueFileInputs.map(async (input) => ({
|
|
601
|
+
name: input.name,
|
|
602
|
+
url: await (0, variable_file_uploader_1.uploadVariableFile)(this.TESTRIGOR_API2_URL, testSuiteId, token, input.file),
|
|
603
|
+
})));
|
|
604
|
+
for (const { name, url } of uploads) {
|
|
605
|
+
ctx.configuration.storedValues[name] = url;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
catch (error) {
|
|
609
|
+
if (error.name === "AbortError") {
|
|
610
|
+
throw new Error("Variable files upload timed out. Please check your network connection.");
|
|
611
|
+
}
|
|
612
|
+
throw error;
|
|
613
|
+
}
|
|
614
|
+
finally {
|
|
615
|
+
clearTimeout(timeoutId);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
async uploadClientCertificates(ctx, testSuiteId, token, task) {
|
|
619
|
+
const controller = new AbortController();
|
|
620
|
+
const timeoutId = setTimeout(() => controller.abort(), utils_1.UPLOAD_TIMEOUT_MS);
|
|
621
|
+
try {
|
|
622
|
+
ctx.configuration.clientCertificateOverrides =
|
|
623
|
+
ctx.configuration.clientCertificateOverrides || {};
|
|
624
|
+
const uploads = await Promise.all(ctx.storedValueCertInputs.map(async (input) => {
|
|
625
|
+
const uploaded = await (0, certificate_uploader_1.uploadClientCertificate)(this.TESTRIGOR_API2_URL, testSuiteId, token, input.file, input.password);
|
|
626
|
+
return {
|
|
627
|
+
name: input.name,
|
|
628
|
+
certificateId: uploaded.certificateId,
|
|
629
|
+
info: uploaded.info,
|
|
630
|
+
};
|
|
631
|
+
}));
|
|
632
|
+
for (const { name, certificateId } of uploads) {
|
|
633
|
+
ctx.configuration.clientCertificateOverrides[name] = certificateId;
|
|
634
|
+
}
|
|
635
|
+
const summary = uploads
|
|
636
|
+
.map(({ name, info }) => {
|
|
637
|
+
const subject = info?.subject?.commonName || info?.subject?.distinguishedName;
|
|
638
|
+
const notAfter = info?.notAfter
|
|
639
|
+
? ` (expires ${info.notAfter.slice(0, 10)})`
|
|
640
|
+
: "";
|
|
641
|
+
return subject ? `${name}: ${subject}${notAfter}` : name;
|
|
642
|
+
})
|
|
643
|
+
.join("; ");
|
|
644
|
+
if (summary) {
|
|
645
|
+
task.title = `Uploaded client certificate(s): ${summary}`;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
catch (error) {
|
|
649
|
+
if (error.name === "AbortError") {
|
|
650
|
+
throw new Error("Client certificates upload timed out. Please check your network connection.");
|
|
651
|
+
}
|
|
652
|
+
throw error;
|
|
653
|
+
}
|
|
654
|
+
finally {
|
|
655
|
+
clearTimeout(timeoutId);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
483
658
|
async callRetest(testSuiteId, configuration, token) {
|
|
484
659
|
return (0, node_fetch_commonjs_1.default)(`${this.TESTRIGOR_API_URL}/apps/${testSuiteId}/retest`, {
|
|
485
660
|
method: "POST",
|
|
@@ -491,6 +666,24 @@ class Run extends command_base_1.CommandBase {
|
|
|
491
666
|
},
|
|
492
667
|
});
|
|
493
668
|
}
|
|
669
|
+
async fetchSuiteType(testSuiteId, token) {
|
|
670
|
+
const response = await (0, node_fetch_commonjs_1.default)(`${this.TESTRIGOR_API_URL}/apps/${testSuiteId}/status`, {
|
|
671
|
+
method: "GET",
|
|
672
|
+
headers: {
|
|
673
|
+
Accept: "application/json",
|
|
674
|
+
"auth-token": token,
|
|
675
|
+
},
|
|
676
|
+
});
|
|
677
|
+
if (!response.ok) {
|
|
678
|
+
throw new Error(`Failed to fetch test suite info: ${response.statusText}`);
|
|
679
|
+
}
|
|
680
|
+
const data = (await response.json());
|
|
681
|
+
return data.type || "";
|
|
682
|
+
}
|
|
683
|
+
isMobileSuiteType(type) {
|
|
684
|
+
const mobilePrefixes = ["IOS_", "ANDROID_"];
|
|
685
|
+
return mobilePrefixes.some((prefix) => type.toUpperCase().startsWith(prefix));
|
|
686
|
+
}
|
|
494
687
|
async uploadFile(task, testSuiteId, configuration, filePath, token, fileProperties) {
|
|
495
688
|
const body = {
|
|
496
689
|
fileName: fileProperties.fileName,
|
|
@@ -664,9 +857,8 @@ Run.flags = {
|
|
|
664
857
|
dependsOn: ["branch"],
|
|
665
858
|
}),
|
|
666
859
|
localhost: core_1.Flags.boolean({
|
|
667
|
-
description: "Localhost run",
|
|
860
|
+
description: "Localhost run (auto-detects web vs mobile)",
|
|
668
861
|
aliases: ["tunnel"],
|
|
669
|
-
dependsOn: ["url"],
|
|
670
862
|
}),
|
|
671
863
|
url: core_1.Flags.string({
|
|
672
864
|
description: "The url",
|
|
@@ -689,9 +881,11 @@ Run.flags = {
|
|
|
689
881
|
"settings-path": core_1.Flags.string({
|
|
690
882
|
description: "Settings path",
|
|
691
883
|
}),
|
|
884
|
+
"local-test-nodes-path": core_1.Flags.string({
|
|
885
|
+
description: "Path to a YAML or JSON file with local test nodes configuration",
|
|
886
|
+
}),
|
|
692
887
|
"test-case-uuid": core_1.Flags.string({
|
|
693
888
|
description: "Test case uuid",
|
|
694
|
-
dependsOn: ["url"],
|
|
695
889
|
}),
|
|
696
890
|
"auto-create-ai-rules": core_1.Flags.boolean({
|
|
697
891
|
description: "Enable auto create AI based rules",
|
|
@@ -718,6 +912,9 @@ Run.flags = {
|
|
|
718
912
|
"chrome-extension": (0, chrome_extension_1.ChromeExtensionFlag)({
|
|
719
913
|
exclusive: ["file-path"],
|
|
720
914
|
}),
|
|
915
|
+
"xray-execution-key": core_1.Flags.string({
|
|
916
|
+
description: "Reuse an existing Xray Cloud Test Execution by key (e.g. XR-123) instead of creating a new one. Optional; leave blank to create a new execution.",
|
|
917
|
+
}),
|
|
721
918
|
};
|
|
722
919
|
Run.args = {
|
|
723
920
|
testSuiteId: core_1.Args.string({
|
package/dist/types.d.ts
CHANGED
|
@@ -65,3 +65,54 @@ export type ChromeExtensionOverrideItem = {
|
|
|
65
65
|
export type ChromeExtensionOverridesRequest = {
|
|
66
66
|
chromeExtensions: ChromeExtensionOverrideItem[];
|
|
67
67
|
};
|
|
68
|
+
export type StoredValueFileInput = {
|
|
69
|
+
name: string;
|
|
70
|
+
file: string;
|
|
71
|
+
};
|
|
72
|
+
export type StoredValueCertInput = {
|
|
73
|
+
name: string;
|
|
74
|
+
file: string;
|
|
75
|
+
password: string;
|
|
76
|
+
};
|
|
77
|
+
export type ParsedVariables = {
|
|
78
|
+
textValues: Record<string, string>;
|
|
79
|
+
fileInputs: StoredValueFileInput[];
|
|
80
|
+
certInputs: StoredValueCertInput[];
|
|
81
|
+
};
|
|
82
|
+
export type ClientCertificateDistinguishedName = {
|
|
83
|
+
distinguishedName?: string;
|
|
84
|
+
commonName?: string;
|
|
85
|
+
};
|
|
86
|
+
export type ClientCertificateInfo = {
|
|
87
|
+
serialNumber?: string;
|
|
88
|
+
notBefore?: string;
|
|
89
|
+
notAfter?: string;
|
|
90
|
+
issuer?: ClientCertificateDistinguishedName;
|
|
91
|
+
subject?: ClientCertificateDistinguishedName;
|
|
92
|
+
};
|
|
93
|
+
export type CertificateUploadDataApi2Response = {
|
|
94
|
+
certificateId: string;
|
|
95
|
+
info?: ClientCertificateInfo;
|
|
96
|
+
};
|
|
97
|
+
export type UploadedClientCertificate = {
|
|
98
|
+
certificateId: string;
|
|
99
|
+
info?: ClientCertificateInfo;
|
|
100
|
+
};
|
|
101
|
+
export type CertificateUploadApi2Response = Api2Wrapper & {
|
|
102
|
+
data: CertificateUploadDataApi2Response;
|
|
103
|
+
};
|
|
104
|
+
export type JsonValue = string | number | boolean | null | JsonValue[] | {
|
|
105
|
+
[key: string]: JsonValue;
|
|
106
|
+
};
|
|
107
|
+
export type JsonObject = {
|
|
108
|
+
[key: string]: JsonValue;
|
|
109
|
+
};
|
|
110
|
+
export type FileVariableObject = {
|
|
111
|
+
type: "FILE";
|
|
112
|
+
value: string;
|
|
113
|
+
};
|
|
114
|
+
export type CertVariableObject = {
|
|
115
|
+
type: "CLIENT_CERTIFICATE";
|
|
116
|
+
value: string;
|
|
117
|
+
password: string;
|
|
118
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
declare const DEFAULT_PORT = 4723;
|
|
2
|
+
declare const DEFAULT_BASE_PATH = "/wd/hub";
|
|
3
|
+
declare const PID_DIR: string;
|
|
4
|
+
declare const PID_FILE: string;
|
|
5
|
+
declare const REQUIRED_DRIVERS: Array<{
|
|
6
|
+
name: string;
|
|
7
|
+
version: string;
|
|
8
|
+
}>;
|
|
9
|
+
export { DEFAULT_PORT, DEFAULT_BASE_PATH, REQUIRED_DRIVERS, PID_DIR, PID_FILE };
|
|
10
|
+
export declare function checkAppiumInstalled(): Promise<void>;
|
|
11
|
+
export type DriverStatus = {
|
|
12
|
+
installed: boolean;
|
|
13
|
+
version?: string;
|
|
14
|
+
upToDate: boolean;
|
|
15
|
+
};
|
|
16
|
+
export declare function getDriverStatus(driverName: string, requiredVersion: string): Promise<DriverStatus>;
|
|
17
|
+
export declare function ensureDriver(driverName: string, requiredVersion: string): Promise<string | null>;
|
|
18
|
+
export declare function isProcessRunning(pid: number): boolean;
|
|
19
|
+
export type StartResult = {
|
|
20
|
+
pid: number;
|
|
21
|
+
alreadyRunning: boolean;
|
|
22
|
+
};
|
|
23
|
+
export declare function getRunningAppiumPid(): Promise<number | null>;
|
|
24
|
+
export declare function startAppium(port?: number, logLevel?: string): Promise<StartResult>;
|
|
25
|
+
export declare function stopAppium(): Promise<number | null>;
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.stopAppium = exports.startAppium = exports.getRunningAppiumPid = exports.isProcessRunning = exports.ensureDriver = exports.getDriverStatus = exports.checkAppiumInstalled = exports.PID_FILE = exports.PID_DIR = exports.REQUIRED_DRIVERS = exports.DEFAULT_BASE_PATH = exports.DEFAULT_PORT = void 0;
|
|
4
|
+
const childProcess = require("child_process");
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const util = require("util");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
const os = require("os");
|
|
9
|
+
const Fs = require("@supercharge/fs");
|
|
10
|
+
const execFile = util.promisify(childProcess.execFile);
|
|
11
|
+
const PROJECT_ROOT = path.resolve(__dirname, "..", "..");
|
|
12
|
+
const APPIUM_HOME = path.join(PROJECT_ROOT, ".appium");
|
|
13
|
+
const DEFAULT_PORT = 4723;
|
|
14
|
+
exports.DEFAULT_PORT = DEFAULT_PORT;
|
|
15
|
+
const DEFAULT_BASE_PATH = "/wd/hub";
|
|
16
|
+
exports.DEFAULT_BASE_PATH = DEFAULT_BASE_PATH;
|
|
17
|
+
const PID_DIR = path.join(os.homedir(), ".testrigor");
|
|
18
|
+
exports.PID_DIR = PID_DIR;
|
|
19
|
+
const PID_FILE = path.join(PID_DIR, "appium.pid");
|
|
20
|
+
exports.PID_FILE = PID_FILE;
|
|
21
|
+
const LOG_FILE = path.join(os.tmpdir(), "testrigor-appium.log");
|
|
22
|
+
const REQUIRED_DRIVERS = [
|
|
23
|
+
{ name: "uiautomator2", version: "4.2.3" },
|
|
24
|
+
{ name: "xcuitest", version: "9.2.4" },
|
|
25
|
+
];
|
|
26
|
+
exports.REQUIRED_DRIVERS = REQUIRED_DRIVERS;
|
|
27
|
+
function getAppiumBin() {
|
|
28
|
+
return path.resolve(PROJECT_ROOT, "node_modules", ".bin", "appium");
|
|
29
|
+
}
|
|
30
|
+
function getAppiumEnv() {
|
|
31
|
+
return {
|
|
32
|
+
...process.env,
|
|
33
|
+
APPIUM_HOME,
|
|
34
|
+
CHROMELABS_URL: "https://googlechromelabs.github.io",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
async function checkAppiumInstalled() {
|
|
38
|
+
const appiumBin = getAppiumBin();
|
|
39
|
+
if (!(await Fs.exists(appiumBin))) {
|
|
40
|
+
throw new Error(`Appium binary not found at ${appiumBin}. Try reinstalling testrigor-cli.`);
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
await execFile(appiumBin, ["--version"], { env: getAppiumEnv() });
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
throw new Error(`Appium binary at ${appiumBin} is not working. Try reinstalling testrigor-cli.`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
exports.checkAppiumInstalled = checkAppiumInstalled;
|
|
50
|
+
async function getDriverStatus(driverName, requiredVersion) {
|
|
51
|
+
const appiumBin = getAppiumBin();
|
|
52
|
+
try {
|
|
53
|
+
const { stdout } = await execFile(appiumBin, ["driver", "list", "--installed", "--json"], { encoding: "utf8", env: getAppiumEnv() });
|
|
54
|
+
const drivers = JSON.parse(stdout);
|
|
55
|
+
const info = drivers[driverName];
|
|
56
|
+
if (!info || !info.installed) {
|
|
57
|
+
return { installed: false, upToDate: false };
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
installed: true,
|
|
61
|
+
version: info.version,
|
|
62
|
+
upToDate: info.version === requiredVersion,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return { installed: false, upToDate: false };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
exports.getDriverStatus = getDriverStatus;
|
|
70
|
+
async function ensureDriver(driverName, requiredVersion) {
|
|
71
|
+
const appiumBin = getAppiumBin();
|
|
72
|
+
const env = getAppiumEnv();
|
|
73
|
+
const status = await getDriverStatus(driverName, requiredVersion);
|
|
74
|
+
if (status.installed && status.upToDate) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
if (status.installed) {
|
|
79
|
+
await execFile(appiumBin, ["driver", "uninstall", driverName], {
|
|
80
|
+
encoding: "utf8",
|
|
81
|
+
env,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
await execFile(appiumBin, ["driver", "install", `${driverName}@${requiredVersion}`], { encoding: "utf8", env });
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
89
|
+
return raw.split("\n")[0].replace(/^Command failed:.*?Error:\s*/, "").trim();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
exports.ensureDriver = ensureDriver;
|
|
93
|
+
function isProcessRunning(pid) {
|
|
94
|
+
try {
|
|
95
|
+
process.kill(pid, 0);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
exports.isProcessRunning = isProcessRunning;
|
|
103
|
+
function killSafe(pid) {
|
|
104
|
+
if (isProcessRunning(pid)) {
|
|
105
|
+
process.kill(pid);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async function getRunningAppiumPid() {
|
|
109
|
+
if (!(await Fs.exists(PID_FILE))) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const existingPid = Number((await Fs.content(PID_FILE)).trim());
|
|
113
|
+
if (isProcessRunning(existingPid)) {
|
|
114
|
+
return existingPid;
|
|
115
|
+
}
|
|
116
|
+
await Fs.removeFile(PID_FILE);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
exports.getRunningAppiumPid = getRunningAppiumPid;
|
|
120
|
+
async function healthCheck(port, retries = 15, interval = 2000) {
|
|
121
|
+
const url = `http://127.0.0.1:${port}/wd/hub/status`;
|
|
122
|
+
let lastError = "";
|
|
123
|
+
for (let i = 0; i < retries; i++) {
|
|
124
|
+
try {
|
|
125
|
+
const res = await fetch(url);
|
|
126
|
+
const body = await res.json();
|
|
127
|
+
if (body.value?.ready)
|
|
128
|
+
return;
|
|
129
|
+
lastError = "server responded but not ready";
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
lastError = err.message || String(err);
|
|
133
|
+
}
|
|
134
|
+
await new Promise((r) => setTimeout(r, interval));
|
|
135
|
+
}
|
|
136
|
+
throw new Error(`Mobile server not ready after ${retries} attempts: ${lastError}`);
|
|
137
|
+
}
|
|
138
|
+
async function startAppium(port = DEFAULT_PORT, logLevel = "debug") {
|
|
139
|
+
const existingPid = await getRunningAppiumPid();
|
|
140
|
+
if (existingPid) {
|
|
141
|
+
return { pid: existingPid, alreadyRunning: true };
|
|
142
|
+
}
|
|
143
|
+
const appiumBin = getAppiumBin();
|
|
144
|
+
const logStream = fs.openSync(LOG_FILE, "a");
|
|
145
|
+
const appiumProcess = childProcess.spawn(appiumBin, [
|
|
146
|
+
"--port",
|
|
147
|
+
String(port),
|
|
148
|
+
"--base-path",
|
|
149
|
+
DEFAULT_BASE_PATH,
|
|
150
|
+
"--allow-insecure",
|
|
151
|
+
"chromedriver_autodownload",
|
|
152
|
+
"--log-level",
|
|
153
|
+
logLevel,
|
|
154
|
+
], {
|
|
155
|
+
detached: true,
|
|
156
|
+
stdio: ["ignore", logStream, logStream],
|
|
157
|
+
env: getAppiumEnv(),
|
|
158
|
+
});
|
|
159
|
+
const pid = appiumProcess.pid;
|
|
160
|
+
if (!pid) {
|
|
161
|
+
throw new Error("Failed to start Appium server: no PID returned");
|
|
162
|
+
}
|
|
163
|
+
appiumProcess.unref();
|
|
164
|
+
await Fs.ensureDir(PID_DIR);
|
|
165
|
+
await Fs.writeFile(PID_FILE, String(pid));
|
|
166
|
+
try {
|
|
167
|
+
await healthCheck(port);
|
|
168
|
+
}
|
|
169
|
+
catch (err) {
|
|
170
|
+
killSafe(pid);
|
|
171
|
+
await Fs.removeFile(PID_FILE);
|
|
172
|
+
throw err;
|
|
173
|
+
}
|
|
174
|
+
return { pid, alreadyRunning: false };
|
|
175
|
+
}
|
|
176
|
+
exports.startAppium = startAppium;
|
|
177
|
+
async function stopAppium() {
|
|
178
|
+
if (!(await Fs.exists(PID_FILE))) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
const pidContent = (await Fs.content(PID_FILE)).trim();
|
|
182
|
+
const pid = Number(pidContent);
|
|
183
|
+
if (!isNaN(pid) && isProcessRunning(pid)) {
|
|
184
|
+
process.kill(pid, "SIGTERM");
|
|
185
|
+
}
|
|
186
|
+
await Fs.removeFile(PID_FILE);
|
|
187
|
+
return pid;
|
|
188
|
+
}
|
|
189
|
+
exports.stopAppium = stopAppium;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.uploadClientCertificate = void 0;
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const node_fetch_commonjs_1 = require("node-fetch-commonjs");
|
|
7
|
+
const type_guards_1 = require("./type-guards");
|
|
8
|
+
async function uploadClientCertificate(api2BaseUrl, testSuiteId, token, localFilePath, password) {
|
|
9
|
+
const fileName = path.basename(localFilePath);
|
|
10
|
+
const certBase64 = fs.readFileSync(localFilePath).toString("base64");
|
|
11
|
+
const response = await (0, node_fetch_commonjs_1.default)(`${api2BaseUrl}/apps/${testSuiteId}/test_data/global_variables/client_certificate`, {
|
|
12
|
+
method: "POST",
|
|
13
|
+
body: JSON.stringify({ fileName, certBase64, password }),
|
|
14
|
+
headers: {
|
|
15
|
+
"Content-Type": "application/json",
|
|
16
|
+
Accept: "application/json",
|
|
17
|
+
"auth-token": token,
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
const responseJson = await response.json();
|
|
21
|
+
if (!(0, type_guards_1.isCertificateUploadApi2Response)(responseJson)) {
|
|
22
|
+
throw new Error(`Unexpected response shape from client certificate upload endpoint for '${fileName}'`);
|
|
23
|
+
}
|
|
24
|
+
if (!response.ok || responseJson.status !== 200 || !responseJson.data) {
|
|
25
|
+
throw new Error(`Failed to upload client certificate '${fileName}': ${responseJson.message}`);
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
certificateId: responseJson.data.certificateId,
|
|
29
|
+
info: responseJson.data.info,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
exports.uploadClientCertificate = uploadClientCertificate;
|
package/dist/util/trtc.d.ts
CHANGED
package/dist/util/trtc.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.download = void 0;
|
|
3
|
+
exports.download = exports.BINARY_NAME = void 0;
|
|
4
4
|
const Fs = require("@supercharge/fs");
|
|
5
5
|
const fs = require("fs");
|
|
6
6
|
const node_fetch_commonjs_1 = require("node-fetch-commonjs");
|
|
@@ -25,6 +25,10 @@ const ARCHITECTURE_MAP = {
|
|
|
25
25
|
arm64: "arm64",
|
|
26
26
|
};
|
|
27
27
|
const VERSION = "2.3.15";
|
|
28
|
+
const WIN32_PLATFORM = "win32";
|
|
29
|
+
const WIN32_BINARY = "trtc.exe";
|
|
30
|
+
const UNIX_BINARY = "trtc";
|
|
31
|
+
exports.BINARY_NAME = os.platform() === WIN32_PLATFORM ? WIN32_BINARY : UNIX_BINARY;
|
|
28
32
|
function getFolderName() {
|
|
29
33
|
const platform = os.platform();
|
|
30
34
|
const architecture = os.arch();
|
package/dist/util/tunnel.d.ts
CHANGED
package/dist/util/tunnel.js
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
const Fs = require("@supercharge/fs");
|
|
4
4
|
const childProcess = require("child_process");
|
|
5
|
-
const os = require("os");
|
|
6
5
|
const path = require("path");
|
|
7
6
|
const trtc_1 = require("./trtc");
|
|
8
7
|
const utils_1 = require("./utils");
|
|
@@ -11,24 +10,25 @@ function randomPort(min, max) {
|
|
|
11
10
|
}
|
|
12
11
|
async function updateConfig(dir, port) {
|
|
13
12
|
const parentDir = path.dirname(dir);
|
|
14
|
-
const configPath =
|
|
13
|
+
const configPath = path.join(parentDir, "config.toml");
|
|
15
14
|
const newConfigContent = `[[proxies]]
|
|
16
15
|
name = "http_proxy_${port}"
|
|
17
16
|
type = "tcp"
|
|
18
17
|
remotePort = ${port}
|
|
19
18
|
[proxies.plugin]
|
|
20
19
|
type = "http_proxy"`;
|
|
21
|
-
|
|
20
|
+
await Fs.writeFile(configPath, newConfigContent);
|
|
21
|
+
return configPath;
|
|
22
22
|
}
|
|
23
|
-
async function startTrtc(trtc, command) {
|
|
23
|
+
async function startTrtc(trtc, command, configPath) {
|
|
24
24
|
return new Promise((resolve, reject) => {
|
|
25
25
|
const binaryPath = path.join(path.dirname(trtc), command);
|
|
26
|
-
const workingDir = path.dirname(trtc);
|
|
26
|
+
const workingDir = path.dirname(trtc);
|
|
27
27
|
if (!Fs.pathExistsSync(binaryPath)) {
|
|
28
28
|
reject(new Error(`Binary not found at ${binaryPath}`));
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
31
|
-
const tunnelProcess = childProcess.spawn(binaryPath, [], {
|
|
31
|
+
const tunnelProcess = childProcess.spawn(binaryPath, ["-c", configPath], {
|
|
32
32
|
cwd: workingDir,
|
|
33
33
|
});
|
|
34
34
|
tunnelProcess.stdout.on("data", (data) => {
|
|
@@ -68,13 +68,13 @@ class Tunnel {
|
|
|
68
68
|
if (this.binaryPath == null) {
|
|
69
69
|
throw new Error("Could not download the trtc binary");
|
|
70
70
|
}
|
|
71
|
-
const command =
|
|
71
|
+
const command = trtc_1.BINARY_NAME;
|
|
72
72
|
let maxTries = 40;
|
|
73
73
|
while (maxTries > 0) {
|
|
74
74
|
try {
|
|
75
75
|
this.port = randomPort(minPort, maxPort);
|
|
76
|
-
await updateConfig(this.binaryPath, this.port);
|
|
77
|
-
this.process = await startTrtc(this.binaryPath, command);
|
|
76
|
+
this.configPath = await updateConfig(this.binaryPath, this.port);
|
|
77
|
+
this.process = await startTrtc(this.binaryPath, command, this.configPath);
|
|
78
78
|
break;
|
|
79
79
|
}
|
|
80
80
|
catch (e) {
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { CertificateUploadApi2Response, SignedUrlApi2Response } from "../types";
|
|
2
|
+
export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
|
|
3
|
+
export declare function isSignedUrlApi2Response(value: unknown): value is SignedUrlApi2Response;
|
|
4
|
+
export declare function isCertificateUploadApi2Response(value: unknown): value is CertificateUploadApi2Response;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isCertificateUploadApi2Response = exports.isSignedUrlApi2Response = exports.isPlainObject = void 0;
|
|
4
|
+
function isPlainObject(value) {
|
|
5
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
exports.isPlainObject = isPlainObject;
|
|
8
|
+
function isSignedUrlApi2Response(value) {
|
|
9
|
+
return isPlainObject(value) && "status" in value;
|
|
10
|
+
}
|
|
11
|
+
exports.isSignedUrlApi2Response = isSignedUrlApi2Response;
|
|
12
|
+
function isCertificateUploadApi2Response(value) {
|
|
13
|
+
return isPlainObject(value) && "status" in value;
|
|
14
|
+
}
|
|
15
|
+
exports.isCertificateUploadApi2Response = isCertificateUploadApi2Response;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function uploadVariableFile(api2BaseUrl: string, testSuiteId: string, token: string, localFilePath: string): Promise<string>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.uploadVariableFile = void 0;
|
|
4
|
+
const Fs = require("@supercharge/fs");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const node_fetch_commonjs_1 = require("node-fetch-commonjs");
|
|
7
|
+
const utils_1 = require("./utils");
|
|
8
|
+
const type_guards_1 = require("./type-guards");
|
|
9
|
+
async function uploadVariableFile(api2BaseUrl, testSuiteId, token, localFilePath) {
|
|
10
|
+
const fileName = path.basename(localFilePath);
|
|
11
|
+
const fileSize = Fs.statSync(localFilePath).size;
|
|
12
|
+
const presignedResp = await (0, node_fetch_commonjs_1.default)(`${api2BaseUrl}/apps/${testSuiteId}/test_data/global_variables/file/pre_signed_url`, {
|
|
13
|
+
method: "POST",
|
|
14
|
+
body: JSON.stringify({ fileName }),
|
|
15
|
+
headers: {
|
|
16
|
+
"Content-Type": "application/json",
|
|
17
|
+
Accept: "application/json",
|
|
18
|
+
"auth-token": token,
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
const presignedJson = await presignedResp.json();
|
|
22
|
+
if (!(0, type_guards_1.isSignedUrlApi2Response)(presignedJson)) {
|
|
23
|
+
throw new Error(`Unexpected response shape from presigned URL endpoint for '${fileName}'`);
|
|
24
|
+
}
|
|
25
|
+
if (!presignedResp.ok ||
|
|
26
|
+
presignedJson.status !== 200 ||
|
|
27
|
+
!presignedJson.data) {
|
|
28
|
+
throw new Error(`Failed to request presigned URL for variable file '${fileName}': ${presignedJson.message}`);
|
|
29
|
+
}
|
|
30
|
+
const fileStream = Fs.createReadStream(localFilePath);
|
|
31
|
+
const putResp = await (0, node_fetch_commonjs_1.default)(presignedJson.data.url, {
|
|
32
|
+
method: "PUT",
|
|
33
|
+
body: fileStream,
|
|
34
|
+
headers: {
|
|
35
|
+
"Content-length": `${fileSize}`,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
if (!putResp.ok) {
|
|
39
|
+
throw new Error(`Failed to upload variable file '${fileName}' to S3: ${putResp.statusText}`);
|
|
40
|
+
}
|
|
41
|
+
return (0, utils_1.extractUrlWithoutQueryParams)(presignedJson.data.url);
|
|
42
|
+
}
|
|
43
|
+
exports.uploadVariableFile = uploadVariableFile;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseVariablesJson = void 0;
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const type_guards_1 = require("./type-guards");
|
|
7
|
+
const MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024;
|
|
8
|
+
const MAX_COMBINED_SIZE_BYTES = 100 * 1024 * 1024;
|
|
9
|
+
const MAX_SIZE_LABEL = "100 MB";
|
|
10
|
+
const MAX_CERT_FILE_SIZE_BYTES = 20 * 1024; // 20 KB
|
|
11
|
+
const MAX_CERT_PASSWORD_LENGTH = 100;
|
|
12
|
+
const MAX_CERT_FILENAME_LENGTH = 100;
|
|
13
|
+
const ERROR_ROOT_NOT_OBJECT = "--variables-path must contain a JSON object at the top level";
|
|
14
|
+
const ERROR_COMBINED_SIZE_EXCEEDED = `Variable files combined size exceeds the maximum allowed (${MAX_SIZE_LABEL})`;
|
|
15
|
+
function isUrl(value) {
|
|
16
|
+
return /^https?:\/\//i.test(value);
|
|
17
|
+
}
|
|
18
|
+
function isFileVariableObject(value) {
|
|
19
|
+
return ((0, type_guards_1.isPlainObject)(value) &&
|
|
20
|
+
value.type === "FILE" &&
|
|
21
|
+
typeof value.value === "string");
|
|
22
|
+
}
|
|
23
|
+
function isCertVariableObject(value) {
|
|
24
|
+
return ((0, type_guards_1.isPlainObject)(value) &&
|
|
25
|
+
value.type === "CLIENT_CERTIFICATE" &&
|
|
26
|
+
typeof value.value === "string" &&
|
|
27
|
+
typeof value.password === "string");
|
|
28
|
+
}
|
|
29
|
+
function readLocalFileSize(key, filePath) {
|
|
30
|
+
if (!fs.existsSync(filePath)) {
|
|
31
|
+
throw new Error(`Variable "${key}": file not found at path "${filePath}"`);
|
|
32
|
+
}
|
|
33
|
+
const size = fs.statSync(filePath).size;
|
|
34
|
+
if (size > MAX_FILE_SIZE_BYTES) {
|
|
35
|
+
throw new Error(`Variable "${key}": file size exceeds the maximum allowed (${MAX_SIZE_LABEL})`);
|
|
36
|
+
}
|
|
37
|
+
return size;
|
|
38
|
+
}
|
|
39
|
+
function validateLocalCertFile(key, value) {
|
|
40
|
+
if (isUrl(value)) {
|
|
41
|
+
throw new Error(`Variable "${key}": CLIENT_CERTIFICATE does not support URL values; provide a local .pfx file path`);
|
|
42
|
+
}
|
|
43
|
+
if (!fs.existsSync(value)) {
|
|
44
|
+
throw new Error(`Variable "${key}": file not found at path "${value}"`);
|
|
45
|
+
}
|
|
46
|
+
if (!/\.pfx$/i.test(path.basename(value))) {
|
|
47
|
+
throw new Error(`Variable "${key}": CLIENT_CERTIFICATE requires a .pfx file`);
|
|
48
|
+
}
|
|
49
|
+
if (path.basename(value).length > MAX_CERT_FILENAME_LENGTH) {
|
|
50
|
+
throw new Error(`Variable "${key}": filename is too long (max ${MAX_CERT_FILENAME_LENGTH} chars)`);
|
|
51
|
+
}
|
|
52
|
+
if (fs.statSync(value).size > MAX_CERT_FILE_SIZE_BYTES) {
|
|
53
|
+
throw new Error(`Variable "${key}": certificate file exceeds the maximum allowed (20 KB)`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function parseVariablesJson(parsed) {
|
|
57
|
+
if (!(0, type_guards_1.isPlainObject)(parsed)) {
|
|
58
|
+
throw new Error(ERROR_ROOT_NOT_OBJECT);
|
|
59
|
+
}
|
|
60
|
+
const textValues = {};
|
|
61
|
+
const fileInputs = [];
|
|
62
|
+
const certInputs = [];
|
|
63
|
+
let combinedSize = 0;
|
|
64
|
+
for (const [key, raw] of Object.entries(parsed)) {
|
|
65
|
+
if (typeof raw === "string") {
|
|
66
|
+
textValues[key] = raw;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (!(0, type_guards_1.isPlainObject)(raw)) {
|
|
70
|
+
throw new Error(`Variable "${key}": expected a string or { type: "FILE", value: "..." }`);
|
|
71
|
+
}
|
|
72
|
+
if (raw.type === "FILE" && raw.password !== undefined) {
|
|
73
|
+
throw new Error(`Variable "${key}": "password" field is only valid for CLIENT_CERTIFICATE. Did you mean type:"CLIENT_CERTIFICATE"?`);
|
|
74
|
+
}
|
|
75
|
+
if (raw.type === "FILE") {
|
|
76
|
+
if (!isFileVariableObject(raw)) {
|
|
77
|
+
throw new Error(`Variable "${key}": missing or empty "value" for type:FILE`);
|
|
78
|
+
}
|
|
79
|
+
const value = raw.value.trim();
|
|
80
|
+
if (value.length === 0) {
|
|
81
|
+
throw new Error(`Variable "${key}": missing or empty "value" for type:FILE`);
|
|
82
|
+
}
|
|
83
|
+
if (isUrl(value)) {
|
|
84
|
+
textValues[key] = value;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
combinedSize += readLocalFileSize(key, value);
|
|
88
|
+
if (combinedSize > MAX_COMBINED_SIZE_BYTES) {
|
|
89
|
+
throw new Error(ERROR_COMBINED_SIZE_EXCEEDED);
|
|
90
|
+
}
|
|
91
|
+
fileInputs.push({ name: key, file: value });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (raw.type === "CLIENT_CERTIFICATE") {
|
|
95
|
+
if (!isCertVariableObject(raw)) {
|
|
96
|
+
if (typeof raw.value !== "string" || raw.value.trim().length === 0) {
|
|
97
|
+
throw new Error(`Variable "${key}": missing or empty "value" for type:CLIENT_CERTIFICATE`);
|
|
98
|
+
}
|
|
99
|
+
throw new Error(`Variable "${key}": missing or empty "password" for type:CLIENT_CERTIFICATE`);
|
|
100
|
+
}
|
|
101
|
+
const value = raw.value.trim();
|
|
102
|
+
if (value.length === 0) {
|
|
103
|
+
throw new Error(`Variable "${key}": missing or empty "value" for type:CLIENT_CERTIFICATE`);
|
|
104
|
+
}
|
|
105
|
+
if (raw.password.length === 0) {
|
|
106
|
+
throw new Error(`Variable "${key}": missing or empty "password" for type:CLIENT_CERTIFICATE`);
|
|
107
|
+
}
|
|
108
|
+
if (raw.password.length > MAX_CERT_PASSWORD_LENGTH) {
|
|
109
|
+
throw new Error(`Variable "${key}": "password" exceeds the maximum length of ${MAX_CERT_PASSWORD_LENGTH} characters`);
|
|
110
|
+
}
|
|
111
|
+
validateLocalCertFile(key, value);
|
|
112
|
+
certInputs.push({
|
|
113
|
+
name: key,
|
|
114
|
+
file: value,
|
|
115
|
+
password: raw.password,
|
|
116
|
+
});
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (typeof raw.type === "string" && raw.type.toUpperCase() === "FILE") {
|
|
120
|
+
throw new Error(`Variable "${key}": unsupported type "${String(raw.type)}". Only "FILE" (uppercase) and "CLIENT_CERTIFICATE" are supported.`);
|
|
121
|
+
}
|
|
122
|
+
throw new Error(`Variable "${key}": unsupported type "${String(raw.type)}". Only "FILE" and "CLIENT_CERTIFICATE" are supported.`);
|
|
123
|
+
}
|
|
124
|
+
return { textValues, fileInputs, certInputs };
|
|
125
|
+
}
|
|
126
|
+
exports.parseVariablesJson = parseVariablesJson;
|
package/oclif.manifest.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.0.
|
|
2
|
+
"version": "0.0.45-beta",
|
|
3
3
|
"commands": {
|
|
4
4
|
"authenticate": {
|
|
5
5
|
"id": "authenticate",
|
|
@@ -147,11 +147,8 @@
|
|
|
147
147
|
"localhost": {
|
|
148
148
|
"name": "localhost",
|
|
149
149
|
"type": "boolean",
|
|
150
|
-
"description": "Localhost run",
|
|
150
|
+
"description": "Localhost run (auto-detects web vs mobile)",
|
|
151
151
|
"allowNo": false,
|
|
152
|
-
"dependsOn": [
|
|
153
|
-
"url"
|
|
154
|
-
],
|
|
155
152
|
"aliases": [
|
|
156
153
|
"tunnel"
|
|
157
154
|
]
|
|
@@ -198,14 +195,17 @@
|
|
|
198
195
|
"description": "Settings path",
|
|
199
196
|
"multiple": false
|
|
200
197
|
},
|
|
198
|
+
"local-test-nodes-path": {
|
|
199
|
+
"name": "local-test-nodes-path",
|
|
200
|
+
"type": "option",
|
|
201
|
+
"description": "Path to a YAML or JSON file with local test nodes configuration",
|
|
202
|
+
"multiple": false
|
|
203
|
+
},
|
|
201
204
|
"test-case-uuid": {
|
|
202
205
|
"name": "test-case-uuid",
|
|
203
206
|
"type": "option",
|
|
204
207
|
"description": "Test case uuid",
|
|
205
|
-
"multiple": false
|
|
206
|
-
"dependsOn": [
|
|
207
|
-
"url"
|
|
208
|
-
]
|
|
208
|
+
"multiple": false
|
|
209
209
|
},
|
|
210
210
|
"auto-create-ai-rules": {
|
|
211
211
|
"name": "auto-create-ai-rules",
|
|
@@ -251,6 +251,12 @@
|
|
|
251
251
|
"exclusive": [
|
|
252
252
|
"file-path"
|
|
253
253
|
]
|
|
254
|
+
},
|
|
255
|
+
"xray-execution-key": {
|
|
256
|
+
"name": "xray-execution-key",
|
|
257
|
+
"type": "option",
|
|
258
|
+
"description": "Reuse an existing Xray Cloud Test Execution by key (e.g. XR-123) instead of creating a new one. Optional; leave blank to create a new execution.",
|
|
259
|
+
"multiple": false
|
|
254
260
|
}
|
|
255
261
|
},
|
|
256
262
|
"args": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@testrigor/testrigor-cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.45-beta",
|
|
4
4
|
"description": "TestRigor Command Line",
|
|
5
5
|
"author": "Ivo Parun Rua @parunruaivo",
|
|
6
6
|
"bin": {
|
|
@@ -9,10 +9,14 @@
|
|
|
9
9
|
"homepage": "https://github.com/TestRigor/testrigor-cli",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"main": "dist/index.ts",
|
|
12
|
-
"repository":
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/TestRigor/testrigor-cli.git"
|
|
15
|
+
},
|
|
13
16
|
"files": [
|
|
14
17
|
"/bin",
|
|
15
18
|
"/dist",
|
|
19
|
+
"/.appium",
|
|
16
20
|
"/npm-shrinkwrap.json",
|
|
17
21
|
"oclif.manifest.json"
|
|
18
22
|
],
|
|
@@ -21,6 +25,7 @@
|
|
|
21
25
|
"@oclif/plugin-help": "^5",
|
|
22
26
|
"@oclif/plugin-plugins": "^3.2.0",
|
|
23
27
|
"@supercharge/fs": "^3.4.0",
|
|
28
|
+
"appium": "^2.18",
|
|
24
29
|
"form-data": "^4.0.1",
|
|
25
30
|
"glob": "^10.3.10",
|
|
26
31
|
"gunzip-maybe": "^1.4.2",
|
|
@@ -85,7 +90,7 @@
|
|
|
85
90
|
"prepack": "npm run build && oclif manifest && oclif readme",
|
|
86
91
|
"test": "mocha --forbid-only \"test/**/*.ts\"",
|
|
87
92
|
"version": "oclif readme && git add README.md",
|
|
88
|
-
"tarballs": "npm run prepack && oclif pack tarballs"
|
|
93
|
+
"tarballs": "npm run prepack && oclif pack tarballs --targets linux-x64,linux-arm64,darwin-x64,darwin-arm64,win32-x64"
|
|
89
94
|
},
|
|
90
95
|
"engines": {
|
|
91
96
|
"node": ">=18.0.0"
|