@testrigor/testrigor-cli 0.0.42-beta → 0.0.44-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 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>>;
@@ -37,6 +39,8 @@ export default class Run extends CommandBase<typeof Run> {
37
39
  private fetchStatus;
38
40
  private getStatusApiEndpoint;
39
41
  private callRetest;
42
+ private fetchSuiteType;
43
+ private isMobileSuiteType;
40
44
  private uploadFile;
41
45
  private waitForTestSuiteToBeCreated;
42
46
  private waitForTestSuiteToFinish;
@@ -11,6 +11,7 @@ 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 appium_1 = require("../../util/appium");
14
15
  const utils_1 = require("../../util/utils");
15
16
  const sleep = async (milliseconds) => {
16
17
  await new Promise((resolve) => setTimeout(resolve, milliseconds));
@@ -70,6 +71,7 @@ class Run extends command_base_1.CommandBase {
70
71
  const maxPort = flags["max-port"];
71
72
  const url = flags["url"];
72
73
  const filePath = flags["file-path"];
74
+ const localTestNodesPath = flags["local-test-nodes-path"];
73
75
  const variablesPath = flags["variables-path"];
74
76
  const testCasesPath = flags["test-cases-path"];
75
77
  const rulesPath = flags["rules-path"];
@@ -79,6 +81,7 @@ class Run extends command_base_1.CommandBase {
79
81
  const explicitMutations = flags["explicit-mutations"];
80
82
  const labels = flags["labels"];
81
83
  const excludedLabels = flags["excluded-labels"];
84
+ const xrayExecutionKey = flags["xray-execution-key"];
82
85
  const chromeExtensions = flags["chrome-extension"];
83
86
  const context = {
84
87
  configuration: {
@@ -87,6 +90,8 @@ class Run extends command_base_1.CommandBase {
87
90
  settings: {},
88
91
  },
89
92
  tunnel: new tunnel_1.default(),
93
+ isMobile: false,
94
+ appiumStartedByUs: false,
90
95
  };
91
96
  if (labels) {
92
97
  context.configuration.labels = labels;
@@ -94,6 +99,9 @@ class Run extends command_base_1.CommandBase {
94
99
  if (excludedLabels) {
95
100
  context.configuration.excludedLabels = excludedLabels;
96
101
  }
102
+ if (xrayExecutionKey) {
103
+ context.configuration.xrayExecutionKey = xrayExecutionKey;
104
+ }
97
105
  if (minPort > maxPort) {
98
106
  core_1.ux.error("--minPort cannot be greater than --maxPort");
99
107
  }
@@ -149,6 +157,24 @@ class Run extends command_base_1.CommandBase {
149
157
  core_1.ux.error("--settings-path must be a valid JSON/YAML path");
150
158
  }
151
159
  }
160
+ if (localTestNodesPath) {
161
+ try {
162
+ const localTestNodes = await (0, parser_1.parseSettings)(localTestNodesPath);
163
+ if (localTestNodes) {
164
+ if (Array.isArray(localTestNodes.testNodes)) {
165
+ for (const node of localTestNodes.testNodes) {
166
+ if (!node.provider) {
167
+ node.provider = "LOCALHOST";
168
+ }
169
+ }
170
+ }
171
+ Object.assign(context.configuration, localTestNodes);
172
+ }
173
+ }
174
+ catch (e) {
175
+ core_1.ux.error("--local-test-nodes-path must be a valid JSON/YAML path");
176
+ }
177
+ }
152
178
  if (filePath) {
153
179
  if (url) {
154
180
  core_1.ux.info("WARNING: File path will be ignored because --url is used");
@@ -168,50 +194,85 @@ class Run extends command_base_1.CommandBase {
168
194
  context.chromeExtensions = chromeExtensions;
169
195
  }
170
196
  super.setTunnel(context.tunnel);
197
+ if (localTestNodesPath) {
198
+ context.isMobile = true;
199
+ }
200
+ else if (flags.localhost) {
201
+ const suiteType = await this.fetchSuiteType(testSuiteId, token);
202
+ context.isMobile = this.isMobileSuiteType(suiteType);
203
+ }
171
204
  const tasks = [
172
205
  {
173
- enabled: () => flags.localhost,
206
+ enabled: () => (flags.localhost || !!localTestNodesPath) && context.isMobile,
174
207
  task: (ctx, task) => {
175
- if (flags.verbose) {
176
- task.title = "Initializing tunnel";
177
- }
178
- else {
179
- task.title = "Initializing";
180
- }
208
+ task.title = "Starting mobile server";
181
209
  return task.newListr([
182
210
  {
183
- task: async (ctx, task) => {
184
- if (flags.verbose) {
185
- task.title = "Downloading binary";
211
+ task: async (_ctx, subtask) => {
212
+ await (0, appium_1.checkAppiumInstalled)();
213
+ subtask.title = "Mobile server binary found";
214
+ },
215
+ rendererOptions: { timer: listr2_1.PRESET_TIMER },
216
+ },
217
+ ...appium_1.REQUIRED_DRIVERS.map((driver) => ({
218
+ task: async (_ctx, subtask) => {
219
+ const status = await (0, appium_1.getDriverStatus)(driver.name, driver.version);
220
+ if (status.installed && status.upToDate) {
221
+ subtask.title = `Driver ${driver.name}@${driver.version} ready`;
222
+ return;
186
223
  }
187
- await ctx.tunnel.initialize();
224
+ subtask.title = status.installed
225
+ ? `Updating ${driver.name} (${status.version} → ${driver.version})`
226
+ : `Installing ${driver.name}@${driver.version}`;
227
+ const err = await (0, appium_1.ensureDriver)(driver.name, driver.version);
228
+ subtask.title = err
229
+ ? `Driver ${driver.name} skipped — ${err}`
230
+ : `Driver ${driver.name}@${driver.version} ready`;
231
+ },
232
+ rendererOptions: { timer: listr2_1.PRESET_TIMER },
233
+ })),
234
+ {
235
+ task: async (ctx, subtask) => {
236
+ const result = await (0, appium_1.startAppium)();
237
+ ctx.appiumStartedByUs = !result.alreadyRunning;
238
+ subtask.title = result.alreadyRunning
239
+ ? `Mobile server already running (PID: ${result.pid}), reusing`
240
+ : `Mobile server started (PID: ${result.pid})`;
188
241
  },
189
- rendererOptions: {
190
- timer: listr2_1.PRESET_TIMER,
242
+ rendererOptions: { timer: listr2_1.PRESET_TIMER },
243
+ },
244
+ ], { concurrent: false, rendererOptions: { collapseSubtasks: false } });
245
+ },
246
+ },
247
+ {
248
+ enabled: () => flags.localhost || !!localTestNodesPath,
249
+ task: (ctx, task) => {
250
+ task.title = flags.verbose ? "Initializing tunnel" : "Initializing";
251
+ return task.newListr([
252
+ {
253
+ task: async (ctx, subtask) => {
254
+ await ctx.tunnel.initialize();
255
+ subtask.title = "Binary ready";
191
256
  },
257
+ rendererOptions: { timer: listr2_1.PRESET_TIMER },
192
258
  },
193
259
  {
194
- task: async (ctx, task) => {
195
- if (flags.verbose) {
196
- task.title = "Starting tunnel";
197
- }
260
+ task: async (ctx, subtask) => {
198
261
  await ctx.tunnel.start(minPort, maxPort);
262
+ subtask.title = `Tunnel started (port ${ctx.tunnel.getPort()})`;
199
263
  },
200
264
  exitOnError: true,
201
- rendererOptions: {
202
- timer: listr2_1.PRESET_TIMER,
203
- },
265
+ rendererOptions: { timer: listr2_1.PRESET_TIMER },
204
266
  },
205
267
  {
206
268
  enabled: (ctx) => ctx.tunnel.isRunning(),
207
- task: (ctx, task) => {
208
- if (flags.verbose) {
209
- task.title = `Tunnel started at port ${ctx.tunnel.getPort()}`;
210
- }
269
+ task: (ctx, subtask) => {
270
+ const tunnelAddress = `tunnel.testrigor.com:${ctx.tunnel.getPort()}`;
211
271
  ctx.configuration.settings = {
212
272
  ...ctx.configuration.settings,
213
- httpProxyString: `tunnel.testrigor.com:${ctx.tunnel.getPort()}`,
273
+ httpProxyString: tunnelAddress,
214
274
  };
275
+ subtask.title = `Tunnel connected (${tunnelAddress})`;
215
276
  },
216
277
  },
217
278
  ], { concurrent: false, rendererOptions: { collapseSubtasks: false } });
@@ -223,7 +284,11 @@ class Run extends command_base_1.CommandBase {
223
284
  {
224
285
  // Checking last run id
225
286
  task: async (ctx) => {
226
- const statusResponse = await this.fetchStatus(testSuiteId, token, flags.branch, ctx.configuration.labels, ctx.configuration.excludedLabels);
287
+ let pollBranch = flags.branch;
288
+ if (testCaseUuid && !flags.branch) {
289
+ pollBranch = `single-test-execution-${testCaseUuid}`;
290
+ }
291
+ const statusResponse = await this.fetchStatus(testSuiteId, token, pollBranch, ctx.configuration.labels, ctx.configuration.excludedLabels);
227
292
  context.lastRunId = (0, utils_1.extractRunIdFromDetailsUrl)(statusResponse.detailsUrl);
228
293
  },
229
294
  exitOnError: false,
@@ -385,7 +450,11 @@ class Run extends command_base_1.CommandBase {
385
450
  title: "Waiting for test suite run creation",
386
451
  enabled: (ctx) => ctx.response?.queueId,
387
452
  task: async (ctx, currentTask) => {
388
- const detailsUrl = await this.waitForTestSuiteToBeCreated(testSuiteId, token, ctx.lastRunId, flags.branch, ctx.configuration.labels, ctx.configuration.excludedLabels);
453
+ let pollBranch = flags.branch;
454
+ if (testCaseUuid && !flags.branch) {
455
+ pollBranch = `single-test-execution-${testCaseUuid}`;
456
+ }
457
+ const detailsUrl = await this.waitForTestSuiteToBeCreated(testSuiteId, token, ctx.lastRunId, pollBranch, ctx.configuration.labels, ctx.configuration.excludedLabels);
389
458
  task.title = `Running test suite [ ${detailsUrl} ]`;
390
459
  currentTask.title = "Test suite run created successfully";
391
460
  },
@@ -394,13 +463,19 @@ class Run extends command_base_1.CommandBase {
394
463
  {
395
464
  title: "Checking status",
396
465
  enabled: (ctx) => ctx.response?.result === "ok" && !flags.async,
397
- task: async (ctx, task) => task.newListr([
398
- {
399
- task: async (ctx, statusTask) => {
400
- await this.waitForTestSuiteToFinish(testSuiteId, token, statusTask, flags.branch, ctx.configuration.labels, ctx.configuration.excludedLabels);
466
+ task: async (ctx, task) => {
467
+ let pollBranch = flags.branch;
468
+ if (testCaseUuid && !flags.branch) {
469
+ pollBranch = `single-test-execution-${testCaseUuid}`;
470
+ }
471
+ return task.newListr([
472
+ {
473
+ task: async (ctx, statusTask) => {
474
+ await this.waitForTestSuiteToFinish(testSuiteId, token, statusTask, pollBranch, ctx.configuration.labels, ctx.configuration.excludedLabels);
475
+ },
401
476
  },
402
- },
403
- ]),
477
+ ]);
478
+ },
404
479
  exitOnError: false,
405
480
  },
406
481
  {
@@ -438,6 +513,15 @@ class Run extends command_base_1.CommandBase {
438
513
  await ctx.tunnel.stop();
439
514
  },
440
515
  },
516
+ {
517
+ enabled: (ctx) => ctx.appiumStartedByUs,
518
+ task: async (_ctx, task) => {
519
+ if (flags.verbose) {
520
+ task.title = "Stopping mobile server";
521
+ }
522
+ await (0, appium_1.stopAppium)();
523
+ },
524
+ },
441
525
  ];
442
526
  const runner = new listr2_1.Listr(tasks, {
443
527
  ctx: context,
@@ -491,6 +575,24 @@ class Run extends command_base_1.CommandBase {
491
575
  },
492
576
  });
493
577
  }
578
+ async fetchSuiteType(testSuiteId, token) {
579
+ const response = await (0, node_fetch_commonjs_1.default)(`${this.TESTRIGOR_API_URL}/apps/${testSuiteId}/status`, {
580
+ method: "GET",
581
+ headers: {
582
+ Accept: "application/json",
583
+ "auth-token": token,
584
+ },
585
+ });
586
+ if (!response.ok) {
587
+ throw new Error(`Failed to fetch test suite info: ${response.statusText}`);
588
+ }
589
+ const data = (await response.json());
590
+ return data.type || "";
591
+ }
592
+ isMobileSuiteType(type) {
593
+ const mobilePrefixes = ["IOS_", "ANDROID_"];
594
+ return mobilePrefixes.some((prefix) => type.toUpperCase().startsWith(prefix));
595
+ }
494
596
  async uploadFile(task, testSuiteId, configuration, filePath, token, fileProperties) {
495
597
  const body = {
496
598
  fileName: fileProperties.fileName,
@@ -664,9 +766,8 @@ Run.flags = {
664
766
  dependsOn: ["branch"],
665
767
  }),
666
768
  localhost: core_1.Flags.boolean({
667
- description: "Localhost run",
769
+ description: "Localhost run (auto-detects web vs mobile)",
668
770
  aliases: ["tunnel"],
669
- dependsOn: ["url"],
670
771
  }),
671
772
  url: core_1.Flags.string({
672
773
  description: "The url",
@@ -689,9 +790,11 @@ Run.flags = {
689
790
  "settings-path": core_1.Flags.string({
690
791
  description: "Settings path",
691
792
  }),
793
+ "local-test-nodes-path": core_1.Flags.string({
794
+ description: "Path to a YAML or JSON file with local test nodes configuration",
795
+ }),
692
796
  "test-case-uuid": core_1.Flags.string({
693
797
  description: "Test case uuid",
694
- dependsOn: ["url"],
695
798
  }),
696
799
  "auto-create-ai-rules": core_1.Flags.boolean({
697
800
  description: "Enable auto create AI based rules",
@@ -718,6 +821,9 @@ Run.flags = {
718
821
  "chrome-extension": (0, chrome_extension_1.ChromeExtensionFlag)({
719
822
  exclusive: ["file-path"],
720
823
  }),
824
+ "xray-execution-key": core_1.Flags.string({
825
+ 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.",
826
+ }),
721
827
  };
722
828
  Run.args = {
723
829
  testSuiteId: core_1.Args.string({
@@ -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;
@@ -1 +1,2 @@
1
+ export declare const BINARY_NAME: string;
1
2
  export declare function download(dir: string): Promise<string | null>;
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();
@@ -4,6 +4,7 @@ export default class Tunnel {
4
4
  private process;
5
5
  private port;
6
6
  private binaryPath?;
7
+ private configPath?;
7
8
  constructor();
8
9
  initialize(): Promise<void>;
9
10
  start(minPort: number, maxPort: number): Promise<void>;
@@ -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 = `${parentDir}/config.toml`;
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
- return Fs.writeFile(configPath, newConfigContent);
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); // This is where config.toml is written
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 = os.platform() === "win32" ? "trtc.exe" : "trtc";
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) {
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.0.42-beta",
2
+ "version": "0.0.44-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.42-beta",
3
+ "version": "0.0.44-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": "TestRigor/testrigor-cli",
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"