@testrigor/testrigor-cli 2.0.7 → 2.0.9

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
@@ -168,7 +168,7 @@ EXAMPLES
168
168
  Config will be stored in ~/.testrigor/testrigor.yml
169
169
  ```
170
170
 
171
- _See code: [src/commands/authenticate.ts](https://github.com/TestRigor/testrigor-cli/blob/v2.0.7/src/commands/authenticate.ts)_
171
+ _See code: [src/commands/authenticate.ts](https://github.com/TestRigor/testrigor-cli/blob/v2.0.9/src/commands/authenticate.ts)_
172
172
 
173
173
  ## `testrigor clone SUITE`
174
174
 
@@ -198,7 +198,7 @@ EXAMPLES
198
198
  $ testrigor clone <test-suite-id>
199
199
  ```
200
200
 
201
- _See code: [src/commands/clone.ts](https://github.com/TestRigor/testrigor-cli/blob/v2.0.7/src/commands/clone.ts)_
201
+ _See code: [src/commands/clone.ts](https://github.com/TestRigor/testrigor-cli/blob/v2.0.9/src/commands/clone.ts)_
202
202
 
203
203
  ## `testrigor help [COMMAND]`
204
204
 
@@ -244,7 +244,7 @@ EXAMPLES
244
244
  $ testrigor test-suite config --delete
245
245
  ```
246
246
 
247
- _See code: [src/commands/test-suite/config.ts](https://github.com/TestRigor/testrigor-cli/blob/v2.0.7/src/commands/test-suite/config.ts)_
247
+ _See code: [src/commands/test-suite/config.ts](https://github.com/TestRigor/testrigor-cli/blob/v2.0.9/src/commands/test-suite/config.ts)_
248
248
 
249
249
  ## `testrigor test-suite run [TESTSUITEID]`
250
250
 
@@ -304,7 +304,7 @@ DESCRIPTION
304
304
  Run all tests of a Test Suite
305
305
  ```
306
306
 
307
- _See code: [src/commands/test-suite/run.ts](https://github.com/TestRigor/testrigor-cli/blob/v2.0.7/src/commands/test-suite/run.ts)_
307
+ _See code: [src/commands/test-suite/run.ts](https://github.com/TestRigor/testrigor-cli/blob/v2.0.9/src/commands/test-suite/run.ts)_
308
308
 
309
309
  ## `testrigor test-suite validate-syntax [TESTSUITEID]`
310
310
 
@@ -344,7 +344,7 @@ EXAMPLES
344
344
  $ testrigor test-suite validate-syntax <test-suite-id> --test-cases-path "test_cases/**/*.{yaml,txt}"
345
345
  ```
346
346
 
347
- _See code: [src/commands/test-suite/validate-syntax.ts](https://github.com/TestRigor/testrigor-cli/blob/v2.0.7/src/commands/test-suite/validate-syntax.ts)_
347
+ _See code: [src/commands/test-suite/validate-syntax.ts](https://github.com/TestRigor/testrigor-cli/blob/v2.0.9/src/commands/test-suite/validate-syntax.ts)_
348
348
  <!-- commandsstop -->`, so this README currently stays unchanged.
349
349
 
350
350
  3. Check that the diff only touches the version fields, then build and test from the lockfile:
@@ -36,7 +36,7 @@ const LOG_FILE = path.join(os.tmpdir(), "testrigor-appium.log");
36
36
  const APPIUM_HOME = process.env.APPIUM_HOME || path.join((0, utils_1.getHomeDir)(), "appium-home");
37
37
  const REQUIRED_DRIVERS = [
38
38
  { name: "uiautomator2", version: "4.2.3" },
39
- { name: "xcuitest", version: "10.43.1" },
39
+ { name: "xcuitest", version: "12.22.0" },
40
40
  ];
41
41
  exports.REQUIRED_DRIVERS = REQUIRED_DRIVERS;
42
42
  // Appium is not a dependency of the CLI: its tree pins a vulnerable
@@ -1,4 +1,13 @@
1
- export declare function writeEntry(entryPath: string, entry: NodeJS.ReadableStream, mode?: number): Promise<void>;
2
- export declare function extractZip(dir: string, stream: NodeJS.ReadableStream): Promise<string>;
3
- export declare function extractTar(dir: string, stream: NodeJS.ReadableStream): Promise<string>;
4
- export declare function download(dir: string): Promise<string>;
1
+ export declare const BINARY_SHA256: {
2
+ [key: string]: string;
3
+ };
4
+ export declare function findCachedBinary(dir: string, expectedSha256?: string): Promise<string | undefined>;
5
+ export declare function writeEntry(entryPath: string, entry: NodeJS.ReadableStream, mode?: number, expectedSha256?: string): Promise<void>;
6
+ export interface ExtractOptions {
7
+ expectedSha256?: string;
8
+ binaryName?: string;
9
+ }
10
+ export declare function extractZip(dir: string, stream: NodeJS.ReadableStream, options?: ExtractOptions): Promise<string>;
11
+ export declare function extractTar(dir: string, stream: NodeJS.ReadableStream, options?: ExtractOptions): Promise<string>;
12
+ export declare function download(dir: string, expectedSha256?: string): Promise<string>;
13
+ export declare function ensureBinary(dir: string, expectedSha256?: string): Promise<string>;
package/dist/util/trtc.js CHANGED
@@ -1,11 +1,15 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BINARY_SHA256 = void 0;
4
+ exports.findCachedBinary = findCachedBinary;
3
5
  exports.writeEntry = writeEntry;
4
6
  exports.extractZip = extractZip;
5
7
  exports.extractTar = extractTar;
6
8
  exports.download = download;
9
+ exports.ensureBinary = ensureBinary;
7
10
  const tslib_1 = require("tslib");
8
11
  const fs_1 = tslib_1.__importDefault(require("@supercharge/fs"));
12
+ const crypto = tslib_1.__importStar(require("crypto"));
9
13
  const fs = tslib_1.__importStar(require("fs"));
10
14
  const os = tslib_1.__importStar(require("os"));
11
15
  const path = tslib_1.__importStar(require("path"));
@@ -60,6 +64,47 @@ function getUrl() {
60
64
  }
61
65
  throw new Error(`Unsupported platform ${platform} or architecture ${architecture}`);
62
66
  }
67
+ // SHA-256 of the trtc binary inside each VERSION archive, keyed by `${FILE_NAME}-${ARCHITECTURE_MAP}`.
68
+ // Bump together with VERSION. A binary that does not match is never spawned.
69
+ exports.BINARY_SHA256 = {
70
+ "darwin-amd64": "44334bfc402e290473cb0cde2b77dc6ad92ab87e20cb60bbd99eda8ebd4341d8",
71
+ "darwin-arm64": "bd09654755f78f8974cb8553c8ac0f1f83afb6ef101599d2ba6c652a82a506bc",
72
+ "linux-amd64": "e96dbe13969340048254f4126d0c229a0022b1dfa471adbd5023e6f01f1fd8b4",
73
+ "linux-arm64": "e70d995325adb783da70e498a17014ea1ace746cd2399ad26a0a4273ca7770ef",
74
+ "windows-amd64": "40ac2237574b5de57cccb43dc62135b7dc3ffe0e5be6c8c214d43492ffc8c26d",
75
+ };
76
+ function getExpectedSha256() {
77
+ const platform = os.platform();
78
+ const architecture = os.arch();
79
+ const expected = exports.BINARY_SHA256[`${FILE_NAME[platform]}-${ARCHITECTURE_MAP[architecture]}`];
80
+ if (expected) {
81
+ return expected;
82
+ }
83
+ throw new Error(`Unsupported platform ${platform} or architecture ${architecture}`);
84
+ }
85
+ function hashFile(file) {
86
+ return new Promise((resolve, reject) => {
87
+ const hash = crypto.createHash("sha256");
88
+ fs.createReadStream(file)
89
+ .on("error", reject)
90
+ .on("data", (chunk) => hash.update(chunk))
91
+ .on("end", () => resolve(hash.digest("hex")));
92
+ });
93
+ }
94
+ // Returns the binary already extracted in dir when it is executable (not checked on Windows) and
95
+ // matches the pinned hash. A matching hash also proves the version, so no marker file is needed.
96
+ async function findCachedBinary(dir, expectedSha256 = getExpectedSha256()) {
97
+ try {
98
+ const binary = getBinaryPath(dir);
99
+ if (os.platform() !== WIN32_PLATFORM) {
100
+ fs.accessSync(binary, fs.constants.X_OK);
101
+ }
102
+ return (await hashFile(binary)) === expectedSha256 ? binary : undefined;
103
+ }
104
+ catch {
105
+ return undefined;
106
+ }
107
+ }
63
108
  const DOWNLOAD_ATTEMPTS = 3;
64
109
  const DOWNLOAD_STALL_TIMEOUT_MS = 20000;
65
110
  function errorMessage(error) {
@@ -69,12 +114,22 @@ function errorMessage(error) {
69
114
  // destination once fully flushed, so on any OS the binary is never spawned half-written. It also
70
115
  // replaces an existing binary with a new file instead of truncating and rewriting it in place,
71
116
  // which an OS may refuse or penalise while that binary is running or cached from a previous run.
72
- function writeEntry(entryPath, entry, mode) {
117
+ // When expectedSha256 is set, the entry is hashed as it is written and discarded on a mismatch.
118
+ function writeEntry(entryPath, entry, mode, expectedSha256) {
73
119
  return new Promise((resolve, reject) => {
74
120
  const tmpPath = `${entryPath}.${process.pid}.tmp`;
75
121
  const writeStream = fs.createWriteStream(tmpPath);
122
+ const hash = crypto.createHash("sha256");
76
123
  writeStream.on("error", reject);
77
124
  writeStream.on("close", () => {
125
+ if (expectedSha256 !== undefined) {
126
+ const actualSha256 = hash.digest("hex");
127
+ if (actualSha256 !== expectedSha256) {
128
+ fs.rmSync(tmpPath, { force: true });
129
+ reject(new Error(`Checksum mismatch for ${path.basename(entryPath)}: expected ${expectedSha256}, got ${actualSha256}`));
130
+ return;
131
+ }
132
+ }
78
133
  if (mode !== undefined) {
79
134
  try {
80
135
  fs.chmodSync(tmpPath, mode);
@@ -92,10 +147,19 @@ function writeEntry(entryPath, entry, mode) {
92
147
  }
93
148
  resolve();
94
149
  });
150
+ if (expectedSha256 !== undefined) {
151
+ entry.on("data", (chunk) => hash.update(chunk));
152
+ }
95
153
  entry.pipe(writeStream);
96
154
  });
97
155
  }
98
- async function extractZip(dir, stream) {
156
+ function expectedHashFor(entryName, options) {
157
+ const binaryName = options.binaryName ?? BINARY_NAME;
158
+ return path.basename(entryName) === binaryName
159
+ ? options.expectedSha256
160
+ : undefined;
161
+ }
162
+ async function extractZip(dir, stream, options = {}) {
99
163
  const parser = unzip.Parse();
100
164
  const writes = [];
101
165
  return new Promise((resolve, reject) => {
@@ -109,13 +173,13 @@ async function extractZip(dir, stream) {
109
173
  return;
110
174
  }
111
175
  fs.mkdirSync(path.dirname(entryPath), { recursive: true });
112
- writes.push(writeEntry(entryPath, entry));
176
+ writes.push(writeEntry(entryPath, entry, undefined, expectedHashFor(entry.path, options)));
113
177
  });
114
178
  parser.on("finish", () => Promise.all(writes).then(() => resolve(getBinaryPath(dir)), reject));
115
179
  stream.pipe(parser);
116
180
  });
117
181
  }
118
- async function extractTar(dir, stream) {
182
+ async function extractTar(dir, stream, options = {}) {
119
183
  const extract = tar.extract();
120
184
  return new Promise((resolve, reject) => {
121
185
  stream.on("error", reject);
@@ -129,7 +193,7 @@ async function extractTar(dir, stream) {
129
193
  next();
130
194
  break;
131
195
  case "file":
132
- writeEntry(entryPath, stream, header.mode || 0o644).then(() => next(), reject);
196
+ writeEntry(entryPath, stream, header.mode || 0o644, expectedHashFor(header.name, options)).then(() => next(), reject);
133
197
  return;
134
198
  case "symlink":
135
199
  // ignore symlinks since they will not work on windows
@@ -147,7 +211,7 @@ async function extractTar(dir, stream) {
147
211
  }
148
212
  // Aborts the download when no data arrives for DOWNLOAD_STALL_TIMEOUT_MS, since a stalled
149
213
  // connection would otherwise never settle.
150
- async function downloadOnce(dir) {
214
+ async function downloadOnce(dir, expectedSha256) {
151
215
  const controller = new AbortController();
152
216
  let stallTimer;
153
217
  const armStallTimer = () => {
@@ -164,23 +228,31 @@ async function downloadOnce(dir) {
164
228
  throw new Error("unexpected null response body");
165
229
  }
166
230
  const stream = stream_1.Readable.fromWeb(response.body);
231
+ const options = { expectedSha256 };
167
232
  const extraction = os.platform() === WIN32_PLATFORM
168
- ? extractZip(dir, stream)
169
- : extractTar(dir, stream);
233
+ ? extractZip(dir, stream, options)
234
+ : extractTar(dir, stream, options);
170
235
  // Attached after the extractor has piped the body, so no chunk is consumed before it.
171
236
  stream.on("data", armStallTimer);
172
- return await extraction;
237
+ try {
238
+ return await extraction;
239
+ }
240
+ catch (error) {
241
+ // A rejected entry (e.g. a checksum mismatch) leaves the body piped but unread, so close it.
242
+ stream.destroy();
243
+ throw error;
244
+ }
173
245
  }
174
246
  finally {
175
247
  clearTimeout(stallTimer);
176
248
  }
177
249
  }
178
- async function download(dir) {
250
+ async function download(dir, expectedSha256 = getExpectedSha256()) {
179
251
  let lastError;
180
252
  for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt++) {
181
253
  try {
182
254
  // eslint-disable-next-line no-await-in-loop
183
- return await downloadOnce(dir);
255
+ return await downloadOnce(dir, expectedSha256);
184
256
  }
185
257
  catch (error) {
186
258
  lastError = error;
@@ -188,3 +260,8 @@ async function download(dir) {
188
260
  }
189
261
  throw new Error(`Could not download the trtc binary after ${DOWNLOAD_ATTEMPTS} attempts: ${errorMessage(lastError)}`);
190
262
  }
263
+ // Reuses the cached binary when it is valid and downloads it otherwise.
264
+ async function ensureBinary(dir, expectedSha256 = getExpectedSha256()) {
265
+ return ((await findCachedBinary(dir, expectedSha256)) ??
266
+ download(dir, expectedSha256));
267
+ }
@@ -1,3 +1,8 @@
1
+ import * as childProcess from "child_process";
2
+ export declare class PortInUseError extends Error {
3
+ constructor();
4
+ }
5
+ export declare function describeSpawnError(error: NodeJS.ErrnoException, binaryPath: string): string;
1
6
  export default class Tunnel {
2
7
  private static readonly BINARY_DIRECTORY;
3
8
  private running;
@@ -11,4 +16,5 @@ export default class Tunnel {
11
16
  stop(): Promise<void>;
12
17
  isRunning(): boolean;
13
18
  getPort(): number;
19
+ protected launch(binaryPath: string, configPath: string): Promise<childProcess.ChildProcessWithoutNullStreams>;
14
20
  }
@@ -1,11 +1,22 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PortInUseError = void 0;
4
+ exports.describeSpawnError = describeSpawnError;
3
5
  const tslib_1 = require("tslib");
4
6
  const fs_1 = tslib_1.__importDefault(require("@supercharge/fs"));
5
7
  const childProcess = tslib_1.__importStar(require("child_process"));
6
8
  const path = tslib_1.__importStar(require("path"));
7
9
  const trtc_1 = require("./trtc");
8
10
  const utils_1 = require("./utils");
11
+ // Only a port conflict is worth retrying with another port, so this bounds that retry alone.
12
+ const MAX_PORT_ATTEMPTS = 40;
13
+ class PortInUseError extends Error {
14
+ constructor() {
15
+ super("Port already used");
16
+ this.name = "PortInUseError";
17
+ }
18
+ }
19
+ exports.PortInUseError = PortInUseError;
9
20
  function randomPort(min, max) {
10
21
  return Math.floor(Math.random() * (max - min + 1) + min);
11
22
  }
@@ -21,6 +32,18 @@ type = "http_proxy"`;
21
32
  await fs_1.default.writeFile(configPath, newConfigContent);
22
33
  return configPath;
23
34
  }
35
+ // Endpoint protection often refuses to run an unknown binary with EPERM or EACCES, so those
36
+ // messages say what to allow-list instead of repeating a bare system error.
37
+ function describeSpawnError(error, binaryPath) {
38
+ const code = error.code ?? "unknown error";
39
+ if (error.code === "EPERM" || error.code === "EACCES") {
40
+ return `Could not run trtc at ${binaryPath} (${code}). Antivirus or endpoint protection may be blocking it; allow-list this path and try again.`;
41
+ }
42
+ if (error.code === "ENOENT") {
43
+ return `trtc is missing at ${binaryPath} (${code}). Delete the trtc folder in the testRigor CLI home (~/.testrigor by default) so it is downloaded again.`;
44
+ }
45
+ return `Could not run trtc at ${binaryPath} (${code}): ${error.message}`;
46
+ }
24
47
  async function startTrtc(trtc, configPath) {
25
48
  return new Promise((resolve, reject) => {
26
49
  if (!fs_1.default.pathExistsSync(trtc)) {
@@ -30,25 +53,34 @@ async function startTrtc(trtc, configPath) {
30
53
  const tunnelProcess = childProcess.spawn(trtc, ["-c", configPath], {
31
54
  cwd: path.dirname(trtc),
32
55
  });
56
+ const rejectPortInUse = () => {
57
+ tunnelProcess.kill("SIGINT");
58
+ reject(new PortInUseError());
59
+ };
33
60
  tunnelProcess.stdout.on("data", (data) => {
34
61
  const dataString = data.toString();
35
62
  if (dataString.includes("start proxy success")) {
36
63
  resolve(tunnelProcess);
37
64
  }
38
65
  else if (dataString.includes("port already used")) {
39
- tunnelProcess.kill("SIGINT");
40
- reject(new Error("Port already used"));
66
+ rejectPortInUse();
41
67
  }
42
68
  });
43
69
  tunnelProcess.stderr.on("data", (data) => {
44
- reject(new Error(`TRTC error: ${data.toString()}`));
70
+ const dataString = data.toString();
71
+ if (dataString.includes("port already used")) {
72
+ rejectPortInUse();
73
+ return;
74
+ }
75
+ reject(new Error(`TRTC error: ${dataString}`));
45
76
  });
46
77
  tunnelProcess.on("error", (error) => {
47
- reject(error);
78
+ reject(new Error(describeSpawnError(error, trtc)));
48
79
  });
49
80
  tunnelProcess.on("exit", (code, signal) => {
50
81
  if (code !== 0) {
51
- reject(new Error(`TRTC exited with code ${code}`));
82
+ const how = code !== null ? `code ${code}` : `signal ${signal}`;
83
+ reject(new Error(`TRTC exited with ${how}`));
52
84
  }
53
85
  });
54
86
  });
@@ -61,30 +93,28 @@ class Tunnel {
61
93
  }
62
94
  async initialize() {
63
95
  const directory = await (0, utils_1.createDirectory)(Tunnel.BINARY_DIRECTORY);
64
- this.binaryPath = await (0, trtc_1.download)(directory);
96
+ this.binaryPath = await (0, trtc_1.ensureBinary)(directory);
65
97
  }
66
98
  async start(minPort, maxPort) {
67
- if (this.binaryPath == null) {
99
+ const binaryPath = this.binaryPath;
100
+ if (binaryPath == null) {
68
101
  throw new Error("Could not download the trtc binary");
69
102
  }
70
- let lastError;
71
- let maxTries = 40;
72
- while (maxTries > 0) {
103
+ for (let attempt = 1; attempt <= MAX_PORT_ATTEMPTS; attempt++) {
104
+ this.port = randomPort(minPort, maxPort);
105
+ this.configPath = await updateConfig(binaryPath, this.port);
73
106
  try {
74
- this.port = randomPort(minPort, maxPort);
75
- this.configPath = await updateConfig(this.binaryPath, this.port);
76
- this.process = await startTrtc(this.binaryPath, this.configPath);
77
- break;
107
+ this.process = await this.launch(binaryPath, this.configPath);
108
+ this.running = true;
109
+ return;
78
110
  }
79
- catch (e) {
80
- lastError = e;
81
- maxTries--;
111
+ catch (error) {
112
+ if (!(error instanceof PortInUseError)) {
113
+ throw error;
114
+ }
82
115
  }
83
116
  }
84
- if (maxTries === 0) {
85
- throw new Error(`Could not start trtc process: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
86
- }
87
- this.running = true;
117
+ throw new Error(`No free tunnel port found in ${minPort}-${maxPort} after ${MAX_PORT_ATTEMPTS} attempts`);
88
118
  }
89
119
  async stop() {
90
120
  if (this.isRunning()) {
@@ -97,6 +127,9 @@ class Tunnel {
97
127
  getPort() {
98
128
  return this.port;
99
129
  }
130
+ launch(binaryPath, configPath) {
131
+ return startTrtc(binaryPath, configPath);
132
+ }
100
133
  }
101
134
  Tunnel.BINARY_DIRECTORY = "trtc";
102
135
  exports.default = Tunnel;
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@testrigor/testrigor-cli",
3
- "version": "2.0.7",
3
+ "version": "2.0.9",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@testrigor/testrigor-cli",
9
- "version": "2.0.7",
9
+ "version": "2.0.9",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {
@@ -521,5 +521,5 @@
521
521
  ]
522
522
  }
523
523
  },
524
- "version": "2.0.7"
524
+ "version": "2.0.9"
525
525
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testrigor/testrigor-cli",
3
- "version": "2.0.7",
3
+ "version": "2.0.9",
4
4
  "description": "TestRigor Command Line",
5
5
  "author": "Ivo Parun Rua @parunruaivo",
6
6
  "bin": {