@dichovsky/testrail-api-client 1.0.0

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/dist/cli.js ADDED
@@ -0,0 +1,268 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { createRequire } from 'node:module';
4
+ import { TestRailClient } from './client.js';
5
+ // ── Version ───────────────────────────────────────────────────────────────────
6
+ const require = createRequire(import.meta.url);
7
+ const VERSION = require('../package.json').version;
8
+ // ── Arg Parsing ───────────────────────────────────────────────────────────────
9
+ const { values, positionals } = parseArgs({
10
+ args: process.argv.slice(2),
11
+ options: {
12
+ 'base-url': { type: 'string' },
13
+ email: { type: 'string' },
14
+ 'api-key': { type: 'string' },
15
+ format: { type: 'string', default: 'json' },
16
+ quiet: { type: 'boolean', default: false },
17
+ help: { type: 'boolean', default: false },
18
+ version: { type: 'boolean', default: false },
19
+ 'project-id': { type: 'string' },
20
+ 'suite-id': { type: 'string' },
21
+ 'run-id': { type: 'string' },
22
+ 'case-id': { type: 'string' },
23
+ limit: { type: 'string' },
24
+ offset: { type: 'string' },
25
+ },
26
+ allowPositionals: true,
27
+ strict: false,
28
+ });
29
+ // ── Output Helpers ────────────────────────────────────────────────────────────
30
+ const quiet = values.quiet === true;
31
+ const format = values.format ?? 'json';
32
+ function out(data) {
33
+ if (quiet)
34
+ return;
35
+ if (format === 'table') {
36
+ process.stdout.write(`${renderTable(data)}\n`);
37
+ }
38
+ else {
39
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
40
+ }
41
+ }
42
+ function err(message) {
43
+ if (!quiet)
44
+ process.stderr.write(`Error: ${message}\n`);
45
+ }
46
+ function valueToString(v) {
47
+ if (v === null || v === undefined)
48
+ return '';
49
+ if (typeof v === 'object')
50
+ return JSON.stringify(v);
51
+ if (typeof v === 'string')
52
+ return v;
53
+ if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'bigint')
54
+ return String(v);
55
+ if (typeof v === 'symbol')
56
+ return v.toString();
57
+ return '[Function]';
58
+ }
59
+ function renderTable(data) {
60
+ const rows = Array.isArray(data) ? data : [data];
61
+ if (rows.length === 0)
62
+ return '(empty)';
63
+ const first = rows[0];
64
+ if (typeof first !== 'object' || first === null) {
65
+ return rows.map(String).join('\n');
66
+ }
67
+ const keys = Object.keys(first);
68
+ const widths = keys.map((k) => Math.max(k.length, ...rows.map((r) => valueToString(r[k]).length)));
69
+ const line = widths.map((w) => '-'.repeat(w)).join('-+-');
70
+ const header = keys.map((k, i) => k.padEnd(widths[i] ?? k.length)).join(' | ');
71
+ const body = rows.map((r) => keys.map((k, i) => valueToString(r[k]).padEnd(widths[i] ?? k.length)).join(' | '));
72
+ return [header, line, ...body].join('\n');
73
+ }
74
+ // ── Help ──────────────────────────────────────────────────────────────────────
75
+ const HELP = `
76
+ testrail <resource> <action> [id] [options]
77
+
78
+ Resources & actions:
79
+ project get <id> | list [--limit N] [--offset N]
80
+ suite get <id> | list --project-id <id>
81
+ case get <id> | list --project-id <id> [--suite-id <id>]
82
+ run get <id> | list --project-id <id> [--limit N] [--offset N]
83
+ result list --run-id <id> [--limit N] [--offset N]
84
+ milestone get <id> | list --project-id <id> [--limit N] [--offset N]
85
+ user get <id> | list [--limit N] [--offset N]
86
+
87
+ Auth (env var or flag):
88
+ TESTRAIL_BASE_URL / --base-url <url>
89
+ TESTRAIL_EMAIL / --email <email>
90
+ TESTRAIL_API_KEY / --api-key <key>
91
+
92
+ Options:
93
+ --format json|table Output format (default: json)
94
+ --quiet Suppress output; use exit code 0/1
95
+ --help Show this help
96
+ --version Print version
97
+ `.trim();
98
+ // ── Entry Point ───────────────────────────────────────────────────────────────
99
+ if (values.version === true) {
100
+ process.stdout.write(`testrail-cli v${VERSION}\n`);
101
+ process.exit(0);
102
+ }
103
+ if (values.help === true || positionals.length === 0) {
104
+ process.stdout.write(`${HELP}\n`);
105
+ process.exit(0);
106
+ }
107
+ const [resource, action, idArg] = positionals;
108
+ if (resource === undefined || resource === '' || action === undefined || action === '') {
109
+ process.stderr.write('Usage: testrail <resource> <action> [id] [options]\nRun with --help for details.\n');
110
+ process.exit(1);
111
+ }
112
+ // ── Auth Resolution ───────────────────────────────────────────────────────────
113
+ const baseUrl = values['base-url'] ?? process.env['TESTRAIL_BASE_URL'];
114
+ const email = values['email'] ?? process.env['TESTRAIL_EMAIL'];
115
+ const apiKey = values['api-key'] ?? process.env['TESTRAIL_API_KEY'];
116
+ if (baseUrl === undefined ||
117
+ baseUrl === '' ||
118
+ email === undefined ||
119
+ email === '' ||
120
+ apiKey === undefined ||
121
+ apiKey === '') {
122
+ err('Missing auth. Set TESTRAIL_BASE_URL, TESTRAIL_EMAIL, TESTRAIL_API_KEY or use --base-url, --email, --api-key flags.');
123
+ process.exit(1);
124
+ }
125
+ const config = { baseUrl, email, apiKey };
126
+ const client = new TestRailClient(config);
127
+ // ── Numeric Helpers ───────────────────────────────────────────────────────────
128
+ function parseId(raw, name) {
129
+ const n = Number(raw);
130
+ if (raw === undefined || raw === '' || !Number.isInteger(n) || n <= 0) {
131
+ err(`${name} must be a positive integer (got: ${raw ?? '(none)'})`);
132
+ process.exit(1);
133
+ }
134
+ return n;
135
+ }
136
+ function optInt(raw) {
137
+ if (raw === undefined)
138
+ return undefined;
139
+ const n = Number(raw);
140
+ return Number.isInteger(n) && n >= 0 ? n : undefined;
141
+ }
142
+ const suiteId = optInt(values['suite-id']);
143
+ const limit = optInt(values.limit);
144
+ const offset = optInt(values.offset);
145
+ // ── Command Dispatch ──────────────────────────────────────────────────────────
146
+ async function run(res, act) {
147
+ switch (res) {
148
+ case 'project': {
149
+ if (act === 'get') {
150
+ const id = parseId(idArg, 'project id');
151
+ out(await client.getProject(id));
152
+ }
153
+ else if (act === 'list') {
154
+ out(await client.getProjects(limit, offset));
155
+ }
156
+ else {
157
+ err(`Unknown action '${act}' for project. Use: get, list`);
158
+ process.exit(1);
159
+ }
160
+ break;
161
+ }
162
+ case 'suite': {
163
+ if (act === 'get') {
164
+ const id = parseId(idArg, 'suite id');
165
+ out(await client.getSuite(id));
166
+ }
167
+ else if (act === 'list') {
168
+ const pid = parseId(values['project-id'], '--project-id');
169
+ out(await client.getSuites(pid));
170
+ }
171
+ else {
172
+ err(`Unknown action '${act}' for suite. Use: get, list`);
173
+ process.exit(1);
174
+ }
175
+ break;
176
+ }
177
+ case 'case': {
178
+ if (act === 'get') {
179
+ const id = parseId(idArg, 'case id');
180
+ out(await client.getCase(id));
181
+ }
182
+ else if (act === 'list') {
183
+ const pid = parseId(values['project-id'], '--project-id');
184
+ out(await client.getCases(pid, suiteId !== undefined ? { suiteId } : undefined));
185
+ }
186
+ else {
187
+ err(`Unknown action '${act}' for case. Use: get, list`);
188
+ process.exit(1);
189
+ }
190
+ break;
191
+ }
192
+ case 'run': {
193
+ if (act === 'get') {
194
+ const id = parseId(idArg, 'run id');
195
+ out(await client.getRun(id));
196
+ }
197
+ else if (act === 'list') {
198
+ const pid = parseId(values['project-id'], '--project-id');
199
+ out(await client.getRuns(pid, {
200
+ ...(limit !== undefined && { limit }),
201
+ ...(offset !== undefined && { offset }),
202
+ }));
203
+ }
204
+ else {
205
+ err(`Unknown action '${act}' for run. Use: get, list`);
206
+ process.exit(1);
207
+ }
208
+ break;
209
+ }
210
+ case 'result': {
211
+ if (act === 'list') {
212
+ const rid = parseId(values['run-id'], '--run-id');
213
+ const resultOpts = { ...(limit !== undefined && { limit }), ...(offset !== undefined && { offset }) };
214
+ out(await client.getResultsForRun(rid, resultOpts));
215
+ }
216
+ else {
217
+ err(`Unknown action '${act}' for result. Use: list`);
218
+ process.exit(1);
219
+ }
220
+ break;
221
+ }
222
+ case 'milestone': {
223
+ if (act === 'get') {
224
+ const id = parseId(idArg, 'milestone id');
225
+ out(await client.getMilestone(id));
226
+ }
227
+ else if (act === 'list') {
228
+ const pid = parseId(values['project-id'], '--project-id');
229
+ const msOpts = { ...(limit !== undefined && { limit }), ...(offset !== undefined && { offset }) };
230
+ out(await client.getMilestones(pid, msOpts));
231
+ }
232
+ else {
233
+ err(`Unknown action '${act}' for milestone. Use: get, list`);
234
+ process.exit(1);
235
+ }
236
+ break;
237
+ }
238
+ case 'user': {
239
+ if (act === 'get') {
240
+ const id = parseId(idArg, 'user id');
241
+ out(await client.getUser(id));
242
+ }
243
+ else if (act === 'list') {
244
+ out(await client.getUsers(limit, offset));
245
+ }
246
+ else {
247
+ err(`Unknown action '${act}' for user. Use: get, list`);
248
+ process.exit(1);
249
+ }
250
+ break;
251
+ }
252
+ default: {
253
+ err(`Unknown resource '${res}'. Use: project, suite, case, run, result, milestone, user`);
254
+ process.exit(1);
255
+ }
256
+ }
257
+ }
258
+ run(resource, action)
259
+ .then(() => {
260
+ client.destroy();
261
+ process.exit(0);
262
+ })
263
+ .catch((e) => {
264
+ err(e instanceof Error ? e.message : String(e));
265
+ client.destroy();
266
+ process.exit(1);
267
+ });
268
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1,103 @@
1
+ import type { TestRailConfig } from './types.js';
2
+ /**
3
+ * HTTP pipeline, caching, rate limiting, retry logic, and lifecycle management.
4
+ * Extended by {@link TestRailClient} which adds all API endpoint methods.
5
+ */
6
+ export declare class TestRailClientCore {
7
+ private readonly baseUrl;
8
+ private auth;
9
+ private readonly timeout;
10
+ private readonly maxRetries;
11
+ private readonly enableCache;
12
+ private readonly cacheTtl;
13
+ private readonly cacheCleanupInterval;
14
+ private readonly maxCacheSize;
15
+ private readonly cache;
16
+ private cacheCleanupTimer;
17
+ private readonly rateLimiter;
18
+ private isDestroyed;
19
+ constructor(config: TestRailConfig);
20
+ private validateConfig;
21
+ private getRetryDelay;
22
+ /**
23
+ * Parses the Retry-After header value to milliseconds
24
+ *
25
+ * @param response - The HTTP response containing the Retry-After header
26
+ * @returns The delay in milliseconds, or null if header is absent or invalid
27
+ */
28
+ private parseRetryAfterMs;
29
+ /** Sliding window rate limiter. @throws {TestRailApiError} when limit exceeded */
30
+ private checkRateLimit;
31
+ /**
32
+ * Validates that an ID is a positive integer.
33
+ * @throws {TestRailValidationError} When ID is invalid
34
+ */
35
+ protected validateId(id: number, name: string): void;
36
+ /**
37
+ * Validates that a string entry ID is non-empty.
38
+ * @throws {TestRailValidationError} When entryId is not a non-empty string
39
+ */
40
+ protected validateEntryId(entryId: string): void;
41
+ /**
42
+ * Validates optional pagination parameters.
43
+ * @throws {TestRailValidationError} When limit is not a positive integer or offset is not a non-negative integer
44
+ */
45
+ protected validatePaginationParams(limit?: number, offset?: number): void;
46
+ /**
47
+ * Builds a TestRail endpoint URL with optional query parameters.
48
+ * Appends params using `&key=value` (TestRail URL quirk — uses `&`, not `?`).
49
+ * Keys and values are automatically percent-encoded via `encodeURIComponent`.
50
+ * Do NOT pre-encode values before passing them; doing so will cause double-encoding.
51
+ */
52
+ protected buildEndpoint(base: string, params?: Record<string, string | number | undefined>): string;
53
+ private getCachedData;
54
+ private setCachedData;
55
+ /**
56
+ * Clears the entire cache.
57
+ */
58
+ clearCache(): void;
59
+ private startCacheCleanup;
60
+ private stopCacheCleanup;
61
+ private cleanupExpiredCache;
62
+ /**
63
+ * Releases all resources held by this client instance.
64
+ * Stops the cache cleanup timer, clears the cache, and removes this instance
65
+ * from the active-clients registry. Safe to call multiple times (idempotent).
66
+ * Also called automatically on `exit`, `SIGINT`, and `SIGTERM`.
67
+ */
68
+ destroy(): void;
69
+ /**
70
+ * Makes an HTTP request to the TestRail API with caching, rate limiting, and retry logic.
71
+ *
72
+ * @param method - HTTP method (GET, POST)
73
+ * @param endpoint - API endpoint path (without base URL prefix)
74
+ * @param data - Optional request body
75
+ * @param retryCount - Current retry attempt (internal — do not pass)
76
+ * @param skipCache - Skip cache lookup and storage for this request
77
+ * @throws {TestRailApiError} When the API request fails or network error occurs
78
+ * @throws {Error} When called after `destroy()`
79
+ */
80
+ protected request<T>(method: string, endpoint: string, data?: unknown, retryCount?: number, skipCache?: boolean): Promise<T>;
81
+ /**
82
+ * Makes a multipart/form-data POST request to the TestRail API.
83
+ * Used exclusively for file attachment uploads. Applies rate limiting
84
+ * and throws on failure, but does NOT retry (uploads are not idempotent).
85
+ *
86
+ * @param endpoint - API endpoint path (without base URL prefix)
87
+ * @param file - File content as Blob, Uint8Array, or File
88
+ * @param filename - Filename to send in the multipart disposition
89
+ * @throws {TestRailApiError} When the API request fails or network error occurs
90
+ * @throws {Error} When called after `destroy()`
91
+ */
92
+ protected requestMultipart<T>(endpoint: string, file: globalThis.Blob | Uint8Array | globalThis.File, filename: string): Promise<T>;
93
+ /**
94
+ * Makes a GET request to the TestRail API and returns the raw binary response.
95
+ * Used for downloading attachment contents.
96
+ *
97
+ * @param endpoint - API endpoint path (without base URL prefix)
98
+ * @throws {TestRailApiError} When the API request fails or network error occurs
99
+ * @throws {Error} When called after `destroy()`
100
+ */
101
+ protected requestBinary(endpoint: string, retryCount?: number): Promise<ArrayBuffer>;
102
+ }
103
+ //# sourceMappingURL=client-core.d.ts.map