@testrigor/testrigor-cli 2.0.6 → 2.0.7
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 +35 -4
- package/dist/commands/clone.d.ts +24 -0
- package/dist/commands/clone.js +149 -0
- package/dist/types.d.ts +21 -0
- package/dist/util/suite-archive.d.ts +25 -0
- package/dist/util/suite-archive.js +126 -0
- package/dist/util/trtc.d.ts +1 -0
- package/dist/util/trtc.js +1 -0
- package/dist/util/utils.d.ts +1 -0
- package/dist/util/utils.js +14 -0
- package/npm-shrinkwrap.json +2 -2
- package/oclif.manifest.json +66 -1
- package/package.json +4 -1
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.
|
|
171
|
+
_See code: [src/commands/authenticate.ts](https://github.com/TestRigor/testrigor-cli/blob/v2.0.7/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.7/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.
|
|
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)_
|
|
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.
|
|
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)_
|
|
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.
|
|
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)_
|
|
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;
|
|
@@ -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
|
+
}
|
package/dist/util/trtc.d.ts
CHANGED
|
@@ -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
package/dist/util/utils.d.ts
CHANGED
|
@@ -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;
|
package/dist/util/utils.js
CHANGED
|
@@ -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
|
+
}
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@testrigor/testrigor-cli",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.7",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@testrigor/testrigor-cli",
|
|
9
|
-
"version": "2.0.
|
|
9
|
+
"version": "2.0.7",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|
package/oclif.manifest.json
CHANGED
|
@@ -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.
|
|
524
|
+
"version": "2.0.7"
|
|
460
525
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@testrigor/testrigor-cli",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.7",
|
|
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
|
},
|