@testrigor/testrigor-cli 2.0.6 → 2.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -134,6 +134,7 @@ Pull requests to `master` must pass two CircleCI checks before they can be merge
134
134
 
135
135
  This updates the version in `package.json` and in both root entries of `package-lock.json` (`version` and `packages[""].version`). `--no-git-tag-version` stops npm from creating its own commit and `v*` tag. npm also runs the `version` script (`oclif readme && git add README.md`). That script loads the commands from `dist`, which is why you build first. It only rewrites README sections between oclif markers such as `<!-- commands -->
136
136
  * [`testrigor authenticate`](#testrigor-authenticate)
137
+ * [`testrigor clone SUITE`](#testrigor-clone-suite)
137
138
  * [`testrigor help [COMMAND]`](#testrigor-help-command)
138
139
  * [`testrigor test-suite config`](#testrigor-test-suite-config)
139
140
  * [`testrigor test-suite run [TESTSUITEID]`](#testrigor-test-suite-run-testsuiteid)
@@ -167,7 +168,37 @@ EXAMPLES
167
168
  Config will be stored in ~/.testrigor/testrigor.yml
168
169
  ```
169
170
 
170
- _See code: [src/commands/authenticate.ts](https://github.com/TestRigor/testrigor-cli/blob/v2.0.6/src/commands/authenticate.ts)_
171
+ _See code: [src/commands/authenticate.ts](https://github.com/TestRigor/testrigor-cli/blob/v2.0.8/src/commands/authenticate.ts)_
172
+
173
+ ## `testrigor clone SUITE`
174
+
175
+ Download a test suite's test cases, rules and test data into a folder named after the suite
176
+
177
+ ```
178
+ USAGE
179
+ $ testrigor clone SUITE [--auth-token <value>] [-t <value>] [--verbose] [--base-url-api <value>]
180
+
181
+ ARGUMENTS
182
+ SUITE The test suite URL or id
183
+
184
+ FLAGS
185
+ --base-url-api=<value> Base URL for API2 (e.g., https://api2.testrigor.com)
186
+
187
+ GLOBAL FLAGS
188
+ -t, --token=<value> Suite authentication token
189
+ --auth-token=<value> Personal authentication token
190
+ --verbose Verbose output
191
+
192
+ DESCRIPTION
193
+ Download a test suite's test cases, rules and test data into a folder named after the suite
194
+
195
+ EXAMPLES
196
+ $ testrigor clone https://app.testrigor.com/test-suites/<test-suite-id>/test-cases
197
+
198
+ $ testrigor clone <test-suite-id>
199
+ ```
200
+
201
+ _See code: [src/commands/clone.ts](https://github.com/TestRigor/testrigor-cli/blob/v2.0.8/src/commands/clone.ts)_
171
202
 
172
203
  ## `testrigor help [COMMAND]`
173
204
 
@@ -213,7 +244,7 @@ EXAMPLES
213
244
  $ testrigor test-suite config --delete
214
245
  ```
215
246
 
216
- _See code: [src/commands/test-suite/config.ts](https://github.com/TestRigor/testrigor-cli/blob/v2.0.6/src/commands/test-suite/config.ts)_
247
+ _See code: [src/commands/test-suite/config.ts](https://github.com/TestRigor/testrigor-cli/blob/v2.0.8/src/commands/test-suite/config.ts)_
217
248
 
218
249
  ## `testrigor test-suite run [TESTSUITEID]`
219
250
 
@@ -273,7 +304,7 @@ DESCRIPTION
273
304
  Run all tests of a Test Suite
274
305
  ```
275
306
 
276
- _See code: [src/commands/test-suite/run.ts](https://github.com/TestRigor/testrigor-cli/blob/v2.0.6/src/commands/test-suite/run.ts)_
307
+ _See code: [src/commands/test-suite/run.ts](https://github.com/TestRigor/testrigor-cli/blob/v2.0.8/src/commands/test-suite/run.ts)_
277
308
 
278
309
  ## `testrigor test-suite validate-syntax [TESTSUITEID]`
279
310
 
@@ -313,7 +344,7 @@ EXAMPLES
313
344
  $ testrigor test-suite validate-syntax <test-suite-id> --test-cases-path "test_cases/**/*.{yaml,txt}"
314
345
  ```
315
346
 
316
- _See code: [src/commands/test-suite/validate-syntax.ts](https://github.com/TestRigor/testrigor-cli/blob/v2.0.6/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.8/src/commands/test-suite/validate-syntax.ts)_
317
348
  <!-- commandsstop -->`, so this README currently stays unchanged.
318
349
 
319
350
  3. Check that the diff only touches the version fields, then build and test from the lockfile:
@@ -0,0 +1,24 @@
1
+ import { CommandBase } from "../command-base";
2
+ export default class Clone extends CommandBase<typeof Clone> {
3
+ static description: string;
4
+ static examples: string[];
5
+ static baseFlags: (typeof CommandBase)["baseFlags"];
6
+ static flags: {
7
+ "base-url-api": import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
8
+ };
9
+ static args: {
10
+ suite: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
11
+ };
12
+ static pollIntervalMs: number;
13
+ private TESTRIGOR_API2_URL;
14
+ run(): Promise<void>;
15
+ private triggerExport;
16
+ private fetchExport;
17
+ /**
18
+ * Polls the export until it finishes and returns its download URL.
19
+ */
20
+ private waitForExport;
21
+ private downloadAndExtract;
22
+ private fetchArchive;
23
+ private callApi;
24
+ }
@@ -0,0 +1,149 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const tslib_1 = require("tslib");
4
+ const core_1 = require("@oclif/core");
5
+ const fs = tslib_1.__importStar(require("fs"));
6
+ const path = tslib_1.__importStar(require("path"));
7
+ const stream_1 = require("stream");
8
+ const command_base_1 = require("../command-base");
9
+ const environments_1 = require("../config/environments");
10
+ const suite_archive_1 = require("../util/suite-archive");
11
+ const utils_1 = require("../util/utils");
12
+ const EXPORT_PAYLOAD = {
13
+ testCases: "TXT",
14
+ testData: "YAML",
15
+ datasets: "YAML",
16
+ rules: "AUTO",
17
+ settings: "YAML",
18
+ };
19
+ const EXPORT_TIMEOUT_MS = 10 * 60 * 1000;
20
+ const DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1000;
21
+ class Clone extends command_base_1.CommandBase {
22
+ async run() {
23
+ const baseUrlApi = this.flags["base-url-api"] || environments_1.environments.prod.TESTRIGOR_API_URL;
24
+ this.TESTRIGOR_API2_URL = `${baseUrlApi.replace(/\/$/, "")}/api/v1`;
25
+ const testSuiteId = (0, utils_1.parseTestSuiteId)(this.args.suite);
26
+ if (!testSuiteId) {
27
+ this.error(`"${this.args.suite}" is not a test suite URL or id`, {
28
+ exit: 1,
29
+ });
30
+ }
31
+ const token = await this.resolveToken();
32
+ core_1.ux.stdout(`Exporting test suite ${testSuiteId}...`);
33
+ const exportId = await this.triggerExport(testSuiteId, token);
34
+ const downloadUrl = await this.waitForExport(testSuiteId, exportId, token);
35
+ // Named after the ZIP generated by the backend, which already ends with
36
+ // the export id, so repeated clones get their own folders.
37
+ const folderName = (0, suite_archive_1.sanitizeFolderName)((0, suite_archive_1.archiveNameFromDownloadUrl)(downloadUrl));
38
+ const targetDir = path.resolve(folderName);
39
+ const createdTargetDir = !fs.existsSync(targetDir);
40
+ try {
41
+ fs.mkdirSync(targetDir, { recursive: true });
42
+ core_1.ux.stdout("Downloading...");
43
+ await this.downloadAndExtract(testSuiteId, exportId, downloadUrl, token, targetDir);
44
+ }
45
+ catch (error) {
46
+ // Leave nothing half-extracted behind, but never delete a folder that
47
+ // was there before.
48
+ if (createdTargetDir) {
49
+ fs.rmSync(targetDir, { recursive: true, force: true });
50
+ }
51
+ throw error;
52
+ }
53
+ core_1.ux.stdout(`Cloned test suite ${testSuiteId} into ${path.join(folderName, suite_archive_1.TESTRIGOR_DIR)}`);
54
+ }
55
+ async triggerExport(testSuiteId, token) {
56
+ const response = await this.callApi(`/apps/${testSuiteId}/exports`, testSuiteId, token, "start the export", { method: "POST", body: JSON.stringify(EXPORT_PAYLOAD) });
57
+ return response.data;
58
+ }
59
+ async fetchExport(testSuiteId, exportId, token) {
60
+ const response = await this.callApi(`/apps/${testSuiteId}/exports/${exportId}`, testSuiteId, token, "get the export status", { method: "GET" });
61
+ return response.data;
62
+ }
63
+ /**
64
+ * Polls the export until it finishes and returns its download URL.
65
+ */
66
+ async waitForExport(testSuiteId, exportId, token) {
67
+ const deadline = Date.now() + EXPORT_TIMEOUT_MS;
68
+ for (;;) {
69
+ const exportInfo = await this.fetchExport(testSuiteId, exportId, token);
70
+ if (exportInfo.status === "FINISHED") {
71
+ if (!exportInfo.downloadUrl) {
72
+ throw new Error("The export finished without a download URL");
73
+ }
74
+ return exportInfo.downloadUrl;
75
+ }
76
+ if (exportInfo.status === "FAILED") {
77
+ this.error(`The export of test suite ${testSuiteId} failed. Please try again.`, { exit: 1 });
78
+ }
79
+ if (Date.now() >= deadline) {
80
+ this.error(`The export of test suite ${testSuiteId} did not finish within ${EXPORT_TIMEOUT_MS / 60000} minutes.`, { exit: 1 });
81
+ }
82
+ await new Promise((resolve) => setTimeout(resolve, Clone.pollIntervalMs));
83
+ }
84
+ }
85
+ async downloadAndExtract(testSuiteId, exportId, downloadUrl, token, targetDir) {
86
+ let response = await this.fetchArchive(downloadUrl);
87
+ if (response.status === 403) {
88
+ // The signed URL is only valid for a few minutes, so ask for a new one.
89
+ const exportInfo = await this.fetchExport(testSuiteId, exportId, token);
90
+ if (exportInfo.downloadUrl) {
91
+ response = await this.fetchArchive(exportInfo.downloadUrl);
92
+ }
93
+ }
94
+ if (!response.ok || response.body == null) {
95
+ throw new Error(`Failed to download the export: ${response.status} ${response.statusText}`);
96
+ }
97
+ await (0, suite_archive_1.extractSuiteArchive)(targetDir, stream_1.Readable.fromWeb(response.body));
98
+ }
99
+ // The download URL is a pre-signed S3 URL: it must not carry the token.
100
+ fetchArchive(url) {
101
+ return fetch(url, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
102
+ }
103
+ async callApi(endpoint, testSuiteId, token, action, init) {
104
+ const response = await fetch(`${this.TESTRIGOR_API2_URL}${endpoint}`, {
105
+ ...init,
106
+ headers: {
107
+ "Content-Type": "application/json",
108
+ Accept: "application/json",
109
+ "auth-token": token,
110
+ },
111
+ });
112
+ if (response.status === 401 || response.status === 403) {
113
+ this.error(`Access to test suite ${testSuiteId} was denied.\n` +
114
+ "Make sure you use a valid personal access token: run 'testrigor authenticate' to store one.", { exit: 1 });
115
+ }
116
+ if (!response.ok) {
117
+ const body = (await response.json().catch(() => null));
118
+ const details = body?.errors?.join("; ") || body?.message;
119
+ throw new Error(`Failed to ${action}: ${response.status} ${response.statusText}${details ? ` - ${details}` : ""}`);
120
+ }
121
+ return (await response.json());
122
+ }
123
+ }
124
+ Clone.description = "Download a test suite's test cases, rules and test data into a folder named after the suite";
125
+ Clone.examples = [
126
+ "testrigor clone https://app.testrigor.com/test-suites/<test-suite-id>/test-cases",
127
+ "testrigor clone <test-suite-id>",
128
+ ];
129
+ // Override base flags to remove tunnel-only flags that are not needed by clone.
130
+ Clone.baseFlags = {
131
+ "auth-token": command_base_1.CommandBase.baseFlags["auth-token"],
132
+ token: command_base_1.CommandBase.baseFlags.token,
133
+ verbose: command_base_1.CommandBase.baseFlags.verbose,
134
+ };
135
+ Clone.flags = {
136
+ "base-url-api": core_1.Flags.string({
137
+ description: "Base URL for API2 (e.g., https://api2.testrigor.com)",
138
+ }),
139
+ };
140
+ Clone.args = {
141
+ suite: core_1.Args.string({
142
+ name: "suite",
143
+ description: "The test suite URL or id",
144
+ required: true,
145
+ }),
146
+ };
147
+ // Overridable so tests do not have to wait between polls.
148
+ Clone.pollIntervalMs = 2000;
149
+ exports.default = Clone;
package/dist/types.d.ts CHANGED
@@ -56,6 +56,27 @@ export type ParserError = {
56
56
  export type ValidateSyntaxApiResponse = Api2Wrapper & {
57
57
  data?: ParserError[];
58
58
  };
59
+ export type ExportFileType = "TXT" | "YAML" | "AUTO" | "NONE";
60
+ export type ExportApplicationPayload = {
61
+ testCases: ExportFileType;
62
+ testData: ExportFileType;
63
+ datasets: ExportFileType;
64
+ rules: ExportFileType;
65
+ settings: ExportFileType;
66
+ };
67
+ export type ExportStatus = "IN_PROGRESS" | "FINISHED" | "FAILED";
68
+ export type ExportInfo = {
69
+ id: string;
70
+ status: ExportStatus;
71
+ createdAt: string;
72
+ downloadUrl?: string;
73
+ };
74
+ export type TriggerExportApiResponse = Api2Wrapper & {
75
+ data: string;
76
+ };
77
+ export type ExportApiResponse = Api2Wrapper & {
78
+ data: ExportInfo;
79
+ };
59
80
  export type ChromeExtensionInput = {
60
81
  name: string;
61
82
  file: string;
@@ -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
@@ -0,0 +1,25 @@
1
+ export declare const TESTRIGOR_DIR = "testrigor";
2
+ /**
3
+ * Makes a test suite name usable as a folder name on every OS: whitespace
4
+ * becomes "_", characters Windows rejects are dropped, and leading or trailing
5
+ * dots are trimmed. Returns "" when nothing usable is left.
6
+ */
7
+ export declare function sanitizeFolderName(name: string): string;
8
+ /**
9
+ * The ZIP file name without its extension. The backend names each export
10
+ * "<suite_name>_<exportId>.zip", so the name is unique per export.
11
+ */
12
+ export declare function archiveNameFromDownloadUrl(downloadUrl: string): string;
13
+ /**
14
+ * Maps a path inside the export ZIP to its place under the testrigor folder,
15
+ * or null for entries that are not kept. Throws for entries that would land
16
+ * outside the target folder (zip-slip). unzip-stream already rewrites ".."
17
+ * segments to ".", so this guards against whatever it lets through, such as
18
+ * drive-letter paths.
19
+ */
20
+ export declare function mapArchivePath(archivePath: string): string | null;
21
+ /**
22
+ * Extracts a suite export ZIP into <targetDir>/testrigor, rearranged into
23
+ * rules, testcases, testdata and testdata/datasets.
24
+ */
25
+ export declare function extractSuiteArchive(targetDir: string, stream: NodeJS.ReadableStream): Promise<void>;
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TESTRIGOR_DIR = void 0;
4
+ exports.sanitizeFolderName = sanitizeFolderName;
5
+ exports.archiveNameFromDownloadUrl = archiveNameFromDownloadUrl;
6
+ exports.mapArchivePath = mapArchivePath;
7
+ exports.extractSuiteArchive = extractSuiteArchive;
8
+ const tslib_1 = require("tslib");
9
+ const fs = tslib_1.__importStar(require("fs"));
10
+ const path = tslib_1.__importStar(require("path"));
11
+ const unzip = tslib_1.__importStar(require("unzip-stream"));
12
+ const trtc_1 = require("./trtc");
13
+ exports.TESTRIGOR_DIR = "testrigor";
14
+ // Created even when the export has nothing for them, so every clone has the
15
+ // same layout.
16
+ const SUITE_DIRS = [
17
+ "rules",
18
+ "testcases",
19
+ "testdata",
20
+ path.join("testdata", "datasets"),
21
+ ];
22
+ // Describes the export's own layout, which no longer holds once datasets are
23
+ // moved under testdata.
24
+ const STRUCTURE_FILE = "structure.yaml";
25
+ const WINDOWS_RESERVED_NAME = /^(con|prn|aux|nul|com\d|lpt\d)(\..*)?$/i;
26
+ /**
27
+ * Makes a test suite name usable as a folder name on every OS: whitespace
28
+ * becomes "_", characters Windows rejects are dropped, and leading or trailing
29
+ * dots are trimmed. Returns "" when nothing usable is left.
30
+ */
31
+ function sanitizeFolderName(name) {
32
+ const cleaned = name
33
+ .trim()
34
+ .replace(/\s+/g, "_")
35
+ // eslint-disable-next-line no-control-regex
36
+ .replace(/[<>:"/\\|?*\u0000-\u001f]/g, "")
37
+ .replace(/^\.+|\.+$/g, "");
38
+ return WINDOWS_RESERVED_NAME.test(cleaned) ? `${cleaned}_` : cleaned;
39
+ }
40
+ /**
41
+ * The ZIP file name without its extension. The backend names each export
42
+ * "<suite_name>_<exportId>.zip", so the name is unique per export.
43
+ */
44
+ function archiveNameFromDownloadUrl(downloadUrl) {
45
+ return decodeURIComponent(path.posix.basename(new URL(downloadUrl).pathname)).replace(/\.zip$/i, "");
46
+ }
47
+ /**
48
+ * Maps a path inside the export ZIP to its place under the testrigor folder,
49
+ * or null for entries that are not kept. Throws for entries that would land
50
+ * outside the target folder (zip-slip). unzip-stream already rewrites ".."
51
+ * segments to ".", so this guards against whatever it lets through, such as
52
+ * drive-letter paths.
53
+ */
54
+ function mapArchivePath(archivePath) {
55
+ const normalized = archivePath.replace(/\\/g, "/");
56
+ const segments = normalized
57
+ .split("/")
58
+ .filter((segment) => segment !== "" && segment !== ".");
59
+ if (normalized.startsWith("/") ||
60
+ segments.some((segment) => segment === ".." || segment.includes(":"))) {
61
+ throw new Error(`Refusing to extract "${archivePath}": it points outside the target folder`);
62
+ }
63
+ if (segments.length === 0 ||
64
+ (segments.length === 1 && segments[0] === STRUCTURE_FILE)) {
65
+ return null;
66
+ }
67
+ if (segments[0] === "datasets") {
68
+ segments.splice(0, 1, "testdata", "datasets");
69
+ }
70
+ return path.join(...segments);
71
+ }
72
+ /**
73
+ * Extracts a suite export ZIP into <targetDir>/testrigor, rearranged into
74
+ * rules, testcases, testdata and testdata/datasets.
75
+ */
76
+ function extractSuiteArchive(targetDir, stream) {
77
+ const root = path.join(targetDir, exports.TESTRIGOR_DIR);
78
+ const parser = unzip.Parse();
79
+ const writes = [];
80
+ // Reported once the files already being written are done, so the caller
81
+ // can clean up without racing them.
82
+ let rejectedEntryError;
83
+ return new Promise((resolve, reject) => {
84
+ stream.on("error", reject);
85
+ parser.on("error", reject);
86
+ parser.on("entry", (entry) => {
87
+ if (rejectedEntryError) {
88
+ entry.autodrain();
89
+ return;
90
+ }
91
+ let relativePath;
92
+ try {
93
+ relativePath = mapArchivePath(entry.path);
94
+ }
95
+ catch (error) {
96
+ entry.autodrain();
97
+ rejectedEntryError = error;
98
+ return;
99
+ }
100
+ if (relativePath === null) {
101
+ entry.autodrain();
102
+ return;
103
+ }
104
+ const entryPath = path.join(root, relativePath);
105
+ if (entry.type === "Directory") {
106
+ fs.mkdirSync(entryPath, { recursive: true });
107
+ entry.autodrain();
108
+ return;
109
+ }
110
+ fs.mkdirSync(path.dirname(entryPath), { recursive: true });
111
+ writes.push((0, trtc_1.writeEntry)(entryPath, entry));
112
+ });
113
+ parser.on("finish", () => Promise.allSettled(writes).then((results) => {
114
+ const failedWrite = results.find((result) => result.status === "rejected");
115
+ if (rejectedEntryError || failedWrite) {
116
+ reject(rejectedEntryError ?? failedWrite?.reason);
117
+ return;
118
+ }
119
+ for (const dir of SUITE_DIRS) {
120
+ fs.mkdirSync(path.join(root, dir), { recursive: true });
121
+ }
122
+ resolve();
123
+ }));
124
+ stream.pipe(parser);
125
+ });
126
+ }
@@ -1,3 +1,4 @@
1
+ export declare function writeEntry(entryPath: string, entry: NodeJS.ReadableStream, mode?: number): Promise<void>;
1
2
  export declare function extractZip(dir: string, stream: NodeJS.ReadableStream): Promise<string>;
2
3
  export declare function extractTar(dir: string, stream: NodeJS.ReadableStream): Promise<string>;
3
4
  export declare function download(dir: string): Promise<string>;
package/dist/util/trtc.js CHANGED
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.writeEntry = writeEntry;
3
4
  exports.extractZip = extractZip;
4
5
  exports.extractTar = extractTar;
5
6
  exports.download = download;
@@ -15,3 +15,4 @@ export declare function extractUrlWithoutQueryParams(urlString: string): string;
15
15
  export declare function extractRunIdFromDetailsUrl(detailsUrl: string | undefined): string;
16
16
  export declare function isValidUrl(url: string): boolean;
17
17
  export declare function splitLabels(values: string[] | undefined): string[];
18
+ export declare function parseTestSuiteId(input: string): string | null;
@@ -16,6 +16,7 @@ exports.extractUrlWithoutQueryParams = extractUrlWithoutQueryParams;
16
16
  exports.extractRunIdFromDetailsUrl = extractRunIdFromDetailsUrl;
17
17
  exports.isValidUrl = isValidUrl;
18
18
  exports.splitLabels = splitLabels;
19
+ exports.parseTestSuiteId = parseTestSuiteId;
19
20
  const tslib_1 = require("tslib");
20
21
  const fs_1 = tslib_1.__importDefault(require("@supercharge/fs"));
21
22
  const path = tslib_1.__importStar(require("path"));
@@ -107,3 +108,16 @@ function splitLabels(values) {
107
108
  .map((label) => label.trim())
108
109
  .filter((label) => label.length > 0);
109
110
  }
111
+ const TEST_SUITES_PATH_SEGMENT = "test-suites";
112
+ // Accepts a bare test suite id or a test suite URL such as
113
+ // https://app.testrigor.com/test-suites/<id>/test-cases, where the id is the
114
+ // path segment right after "test-suites". Returns null when there is no id.
115
+ function parseTestSuiteId(input) {
116
+ const value = input.trim();
117
+ if (!value.includes("/")) {
118
+ return value || null;
119
+ }
120
+ const segments = value.split("/");
121
+ const index = segments.indexOf(TEST_SUITES_PATH_SEGMENT);
122
+ return (index !== -1 && segments[index + 1]) || null;
123
+ }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@testrigor/testrigor-cli",
3
- "version": "2.0.6",
3
+ "version": "2.0.8",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@testrigor/testrigor-cli",
9
- "version": "2.0.6",
9
+ "version": "2.0.8",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT",
12
12
  "dependencies": {
@@ -36,6 +36,71 @@
36
36
  "authenticate.js"
37
37
  ]
38
38
  },
39
+ "clone": {
40
+ "aliases": [],
41
+ "args": {
42
+ "suite": {
43
+ "description": "The test suite URL or id",
44
+ "name": "suite",
45
+ "required": true
46
+ }
47
+ },
48
+ "description": "Download a test suite's test cases, rules and test data into a folder named after the suite",
49
+ "examples": [
50
+ "testrigor clone https://app.testrigor.com/test-suites/<test-suite-id>/test-cases",
51
+ "testrigor clone <test-suite-id>"
52
+ ],
53
+ "flags": {
54
+ "auth-token": {
55
+ "description": "Personal authentication token",
56
+ "helpGroup": "GLOBAL",
57
+ "name": "auth-token",
58
+ "required": false,
59
+ "hasDynamicHelp": false,
60
+ "multiple": false,
61
+ "type": "option"
62
+ },
63
+ "token": {
64
+ "char": "t",
65
+ "description": "Suite authentication token",
66
+ "helpGroup": "GLOBAL",
67
+ "name": "token",
68
+ "required": false,
69
+ "hasDynamicHelp": false,
70
+ "multiple": false,
71
+ "type": "option"
72
+ },
73
+ "verbose": {
74
+ "description": "Verbose output",
75
+ "helpGroup": "GLOBAL",
76
+ "name": "verbose",
77
+ "allowNo": false,
78
+ "type": "boolean"
79
+ },
80
+ "base-url-api": {
81
+ "description": "Base URL for API2 (e.g., https://api2.testrigor.com)",
82
+ "name": "base-url-api",
83
+ "hasDynamicHelp": false,
84
+ "multiple": false,
85
+ "type": "option"
86
+ }
87
+ },
88
+ "hasDynamicHelp": false,
89
+ "hiddenAliases": [],
90
+ "id": "clone",
91
+ "pluginAlias": "@testrigor/testrigor-cli",
92
+ "pluginName": "@testrigor/testrigor-cli",
93
+ "pluginType": "core",
94
+ "strict": true,
95
+ "enableJsonFlag": false,
96
+ "pollIntervalMs": 2000,
97
+ "isESM": false,
98
+ "relativePath": [
99
+ "dist",
100
+ "commands",
101
+ "clone.js"
102
+ ]
103
+ },
39
104
  "test-suite:config": {
40
105
  "aliases": [],
41
106
  "args": {},
@@ -456,5 +521,5 @@
456
521
  ]
457
522
  }
458
523
  },
459
- "version": "2.0.6"
524
+ "version": "2.0.8"
460
525
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testrigor/testrigor-cli",
3
- "version": "2.0.6",
3
+ "version": "2.0.8",
4
4
  "description": "TestRigor Command Line",
5
5
  "author": "Ivo Parun Rua @parunruaivo",
6
6
  "bin": {
@@ -87,6 +87,9 @@
87
87
  },
88
88
  "authenticate": {
89
89
  "description": "Set your testRigor personal authentication token"
90
+ },
91
+ "clone": {
92
+ "description": "Download a test suite's test cases, rules and test data into a folder named after the suite"
90
93
  }
91
94
  }
92
95
  },