@testmo/testmo-link 1.0.0-beta.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.
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ /*
3
+ * Copyright (c) Testmo GmbH (Berlin, Germany)
4
+ * All rights reserved.
5
+ * contact@testmo.com - www.testmo.com
6
+ */
7
+ var __importDefault = (this && this.__importDefault) || function (mod) {
8
+ return (mod && mod.__esModule) ? mod : { "default": mod };
9
+ };
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.Controller = void 0;
12
+ const logger_1 = require("../lib/logger");
13
+ const errors_1 = require("../lib/errors");
14
+ const validator_1 = __importDefault(require("validator"));
15
+ const lodash_1 = __importDefault(require("lodash"));
16
+ class Controller {
17
+ logControllerOptions(options) {
18
+ let debugOptions = lodash_1.default.clone(options);
19
+ if (debugOptions.testmoApiToken !== undefined) {
20
+ debugOptions.testmoApiToken = debugOptions.testmoApiToken.replace(/./g, '*');
21
+ }
22
+ logger_1.logger.debug('Controller options', debugOptions);
23
+ }
24
+ validateAndTruncateInstanceAddress(address) {
25
+ if (validator_1.default.isEmpty(address)) {
26
+ throw new errors_1.ValidationError('The Testmo instance address cannot be empty');
27
+ }
28
+ if (!validator_1.default.isURL(address, { require_tld: false, require_protocol: true })) {
29
+ throw new errors_1.ValidationError('The Testmo instance address must be a valid URL');
30
+ }
31
+ const isLocalhost = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?(\/.*)?$/.test(address);
32
+ if (!isLocalhost && !validator_1.default.isURL(address, { protocols: ['https'] })) {
33
+ throw new errors_1.ValidationError('The Testmo instance address must use HTTPS');
34
+ }
35
+ return new URL(address).origin;
36
+ }
37
+ validateProxy(proxy) {
38
+ if (proxy === undefined) {
39
+ return;
40
+ }
41
+ if (!validator_1.default.isURL(proxy, {
42
+ protocols: ['http', 'https'],
43
+ require_tld: false,
44
+ require_protocol: true,
45
+ })) {
46
+ throw new errors_1.ValidationError('The proxy address must be a valid HTTP/HTTPS address');
47
+ }
48
+ }
49
+ }
50
+ exports.Controller = Controller;
package/dist/index.js ADDED
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /*
4
+ * Copyright (c) Testmo GmbH (Berlin, Germany)
5
+ * All rights reserved.
6
+ * contact@testmo.com - www.testmo.com
7
+ */
8
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
9
+ if (k2 === undefined) k2 = k;
10
+ var desc = Object.getOwnPropertyDescriptor(m, k);
11
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
12
+ desc = { enumerable: true, get: function() { return m[k]; } };
13
+ }
14
+ Object.defineProperty(o, k2, desc);
15
+ }) : (function(o, m, k, k2) {
16
+ if (k2 === undefined) k2 = k;
17
+ o[k2] = m[k];
18
+ }));
19
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
20
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
21
+ }) : function(o, v) {
22
+ o["default"] = v;
23
+ });
24
+ var __importStar = (this && this.__importStar) || (function () {
25
+ var ownKeys = function(o) {
26
+ ownKeys = Object.getOwnPropertyNames || function (o) {
27
+ var ar = [];
28
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
29
+ return ar;
30
+ };
31
+ return ownKeys(o);
32
+ };
33
+ return function (mod) {
34
+ if (mod && mod.__esModule) return mod;
35
+ var result = {};
36
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
37
+ __setModuleDefault(result, mod);
38
+ return result;
39
+ };
40
+ })();
41
+ var __importDefault = (this && this.__importDefault) || function (mod) {
42
+ return (mod && mod.__esModule) ? mod : { "default": mod };
43
+ };
44
+ Object.defineProperty(exports, "__esModule", { value: true });
45
+ const commander = __importStar(require("commander"));
46
+ const chalk_1 = __importDefault(require("chalk"));
47
+ const logger_1 = require("./lib/logger");
48
+ const errors_1 = require("./lib/errors");
49
+ const automationLink_1 = require("./controller/automationLink");
50
+ // @ts-ignore
51
+ const packageInfo = __importStar(require("../package.json"));
52
+ let ansi = !!process.stdout.isTTY && !(process.env.CI === 'true');
53
+ logger_1.logger.ansi = ansi;
54
+ function printError(err) {
55
+ if (err instanceof errors_1.ConfigError) {
56
+ logger_1.logger.error(err.message);
57
+ return;
58
+ }
59
+ if (err instanceof errors_1.PatternError) {
60
+ logger_1.logger.error(`Pattern configuration error: ${err.message}`);
61
+ return;
62
+ }
63
+ if (err instanceof errors_1.AnnotationError) {
64
+ const loc = err.line !== null ? `:${err.line}` : '';
65
+ logger_1.logger.error(`Annotation error in ${err.file}${loc}: ${err.message}`);
66
+ logger_1.logger.debug(` Raw text: ${err.rawText}`);
67
+ return;
68
+ }
69
+ if (err instanceof errors_1.ApiError) {
70
+ logger_1.logger.error(`API error (${err.statusCode}): ${err.message}`);
71
+ return;
72
+ }
73
+ const printApiError = (apiError) => {
74
+ if (apiError.body && apiError.status) {
75
+ const message = apiError.body.message || '[none]';
76
+ logger_1.logger.error(`Server response (${apiError.status}): ${message}`);
77
+ if (apiError.status === 401) {
78
+ logger_1.logger.error('Please verify your Testmo API token');
79
+ }
80
+ else if (apiError.status === 422 && 'errors' in apiError.body) {
81
+ for (const field in apiError.body.errors) {
82
+ const errs = apiError.body.errors[field];
83
+ if (Array.isArray(errs) && errs.length > 0) {
84
+ logger_1.logger.error(`Validation: ${errs[0]}`);
85
+ }
86
+ }
87
+ }
88
+ logger_1.logger.stack(apiError.error);
89
+ }
90
+ else {
91
+ logger_1.logger.exception(apiError.error);
92
+ }
93
+ };
94
+ if (err instanceof Error) {
95
+ logger_1.logger.exception(err);
96
+ }
97
+ else if (err instanceof Object && 'response' in err && 'error' in err) {
98
+ printApiError(err);
99
+ }
100
+ else if (err instanceof Object && 'error' in err &&
101
+ err.error instanceof Error) {
102
+ err.error.message = `Connection error: ${err.error.message}`;
103
+ logger_1.logger.exception(err.error);
104
+ }
105
+ else {
106
+ logger_1.logger.error(err);
107
+ }
108
+ }
109
+ const program = new commander.Command();
110
+ program.name('testmo-link');
111
+ program.version(packageInfo.version, '-V, --version', 'Output the version number');
112
+ program.description('Link automation test results to repository test cases in Testmo.\n\n' +
113
+ 'Set TESTMO_TOKEN environment variable to your API token.');
114
+ program.helpOption('-h, --help', 'Display help');
115
+ program.option('--ansi', 'Force ANSI console output (colors)');
116
+ program.option('--no-ansi', 'Do not use ANSI console output (colors)');
117
+ program.option('--verbose', 'Enable detailed output');
118
+ program.requiredOption('--instance <url>', 'Required: The full address of your Testmo instance (https://***.testmo.net)');
119
+ program.requiredOption('--project-id <id>', 'Required: The ID of the project');
120
+ program.requiredOption('--run-id <id>', 'Required: The ID of the automation run to link');
121
+ program.option('--config <file>', 'Path to a YAML or JSON configuration file with linking rules');
122
+ program.option('--dry-run', 'Preview what links would be created without making any changes');
123
+ program.option('--force', 'Re-link tests that are already linked to a repository case');
124
+ program.option('--fail-on-unmatched', 'Exit with code 1 if any tests could not be matched to a repository case');
125
+ program.option('--conflict-resolution <mode>', 'How to handle conflicts between linking methods: error, warn, silent (default: error)');
126
+ program.option('--proxy <address>', 'An optional HTTP(S) proxy server to use for all connections');
127
+ program.option('--output <file>', 'Write all log output to a file (always includes debug detail regardless of --verbose flag)');
128
+ program.addHelpText('after', '\nExamples:\n\n' +
129
+ ' $ TESTMO_TOKEN=**** testmo-link \\\n' +
130
+ ' --instance https://my-company.testmo.net \\\n' +
131
+ ' --project-id 1 \\\n' +
132
+ ' --run-id 123 \\\n' +
133
+ ' --config linking.yml\n\n' +
134
+ ' $ TESTMO_TOKEN=**** testmo-link \\\n' +
135
+ ' --instance https://my-company.testmo.net \\\n' +
136
+ ' --project-id 1 \\\n' +
137
+ ' --run-id 123 \\\n' +
138
+ ' --config linking.yml \\\n' +
139
+ ' --dry-run\n\n' +
140
+ 'Documentation:\n\n' +
141
+ ' https://docs.testmo.com/automation-linking');
142
+ program.on('option:verbose', () => {
143
+ logger_1.logger.level = logger_1.LogLevel.Debug;
144
+ logger_1.logger.debug(`Testmo Link/${packageInfo.version}`);
145
+ logger_1.logger.debug('ANSI output', ansi);
146
+ });
147
+ program.on('option:output', (file) => {
148
+ logger_1.logger.outputFile = file.trim();
149
+ });
150
+ program.on('option:ansi', () => {
151
+ ansi = true;
152
+ logger_1.logger.ansi = true;
153
+ });
154
+ program.on('option:no-ansi', () => {
155
+ ansi = false;
156
+ logger_1.logger.ansi = false;
157
+ });
158
+ program.configureOutput({
159
+ outputError: (str, write) => {
160
+ if (ansi) {
161
+ write(chalk_1.default.red(str));
162
+ }
163
+ else {
164
+ write(str);
165
+ }
166
+ }
167
+ });
168
+ program.action(async (options) => {
169
+ var _a, _b, _c, _d, _e, _f, _g;
170
+ try {
171
+ const controller = new automationLink_1.AutomationLink();
172
+ const exitCode = await controller.run({
173
+ testmoInstanceAddress: (_b = (_a = options.instance) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : '',
174
+ testmoApiToken: (_c = process.env.TESTMO_TOKEN) === null || _c === void 0 ? void 0 : _c.trim(),
175
+ projectId: (_e = (_d = options.projectId) === null || _d === void 0 ? void 0 : _d.trim()) !== null && _e !== void 0 ? _e : '',
176
+ runId: (_g = (_f = options.runId) === null || _f === void 0 ? void 0 : _f.trim()) !== null && _g !== void 0 ? _g : '',
177
+ configFile: options.config ? options.config.trim() : undefined,
178
+ dryRun: options.dryRun === true,
179
+ force: options.force === true,
180
+ failOnUnmatched: options.failOnUnmatched === true,
181
+ conflictResolution: options.conflictResolution
182
+ ? options.conflictResolution.trim()
183
+ : undefined,
184
+ proxy: options.proxy ? options.proxy.trim() : undefined,
185
+ });
186
+ if (exitCode !== 0) {
187
+ logger_1.logger.debug(`Exiting with exit code ${exitCode}`);
188
+ process.exit(exitCode);
189
+ }
190
+ }
191
+ catch (err) {
192
+ printError(err);
193
+ process.exit(1);
194
+ }
195
+ });
196
+ try {
197
+ program.parse(process.argv);
198
+ }
199
+ catch (error) {
200
+ logger_1.logger.exception(error);
201
+ throw error;
202
+ }
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ /*
3
+ * Copyright (c) Testmo GmbH (Berlin, Germany)
4
+ * All rights reserved.
5
+ * contact@testmo.com - www.testmo.com
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.ApiError = exports.AnnotationError = exports.PatternError = exports.ConfigError = exports.ValidationError = void 0;
9
+ class ValidationError extends Error {
10
+ constructor(m) {
11
+ super(m);
12
+ Object.setPrototypeOf(this, ValidationError.prototype);
13
+ }
14
+ }
15
+ exports.ValidationError = ValidationError;
16
+ /**
17
+ * Thrown when a config file cannot be found, read, or parsed.
18
+ * Extends ValidationError so existing `instanceof ValidationError` guards still match.
19
+ */
20
+ class ConfigError extends ValidationError {
21
+ constructor(message, filePath) {
22
+ super(message);
23
+ this.filePath = filePath;
24
+ Object.setPrototypeOf(this, ConfigError.prototype);
25
+ }
26
+ }
27
+ exports.ConfigError = ConfigError;
28
+ /**
29
+ * Thrown when a regex annotation pattern is unsafe or fails to compile.
30
+ * Extends ValidationError so it is treated as a startup validation failure.
31
+ */
32
+ class PatternError extends ValidationError {
33
+ constructor(pattern, compilationError) {
34
+ super(`Invalid pattern "${pattern}": ${compilationError}`);
35
+ this.pattern = pattern;
36
+ this.compilationError = compilationError;
37
+ Object.setPrototypeOf(this, PatternError.prototype);
38
+ }
39
+ }
40
+ exports.PatternError = PatternError;
41
+ /**
42
+ * Thrown when an annotation in a source file cannot be parsed.
43
+ * Carries the file path, line number, and raw annotation text for context.
44
+ */
45
+ class AnnotationError extends Error {
46
+ constructor(message, file, line, rawText) {
47
+ super(message);
48
+ this.file = file;
49
+ this.line = line;
50
+ this.rawText = rawText;
51
+ Object.setPrototypeOf(this, AnnotationError.prototype);
52
+ }
53
+ }
54
+ exports.AnnotationError = AnnotationError;
55
+ /**
56
+ * Thrown when an API call fails with a specific HTTP error that provides
57
+ * enough context to surface a targeted message to the user (e.g. 404 on a
58
+ * case ID that no longer exists).
59
+ */
60
+ class ApiError extends Error {
61
+ constructor(message, statusCode, context = {}) {
62
+ super(message);
63
+ this.statusCode = statusCode;
64
+ this.context = context;
65
+ Object.setPrototypeOf(this, ApiError.prototype);
66
+ }
67
+ }
68
+ exports.ApiError = ApiError;
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+ /*
3
+ * Copyright (c) Testmo GmbH (Berlin, Germany)
4
+ * All rights reserved.
5
+ * contact@testmo.com - www.testmo.com
6
+ */
7
+ var __importDefault = (this && this.__importDefault) || function (mod) {
8
+ return (mod && mod.__esModule) ? mod : { "default": mod };
9
+ };
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.logger = exports.Logger = exports.LogLevel = void 0;
12
+ /* eslint-disable no-unused-vars, no-shadow, no-console */
13
+ const chalk_1 = __importDefault(require("chalk"));
14
+ const fs_1 = __importDefault(require("fs"));
15
+ var LogLevel;
16
+ (function (LogLevel) {
17
+ LogLevel[LogLevel["Debug"] = 0] = "Debug";
18
+ LogLevel[LogLevel["Info"] = 1] = "Info";
19
+ LogLevel[LogLevel["Warning"] = 2] = "Warning";
20
+ LogLevel[LogLevel["Error"] = 3] = "Error";
21
+ LogLevel[LogLevel["Silent"] = 4] = "Silent";
22
+ })(LogLevel || (exports.LogLevel = LogLevel = {}));
23
+ // Strip ANSI escape codes for plain-text file output
24
+ function stripAnsi(s) {
25
+ // eslint-disable-next-line no-control-regex
26
+ return s.replace(/\x1B\[[0-9;]*m/g, '');
27
+ }
28
+ class Logger {
29
+ constructor(level = LogLevel.Info, ansi = false) {
30
+ this.outputFile = null;
31
+ this.level = level;
32
+ this.ansi = ansi;
33
+ }
34
+ writeToFile(message, ...args) {
35
+ if (!this.outputFile)
36
+ return;
37
+ try {
38
+ const parts = [message, ...args.map((a) => typeof a === 'object' ? JSON.stringify(a) : String(a))];
39
+ fs_1.default.appendFileSync(this.outputFile, stripAnsi(parts.join(' ')) + '\n', 'utf8');
40
+ }
41
+ catch (_a) {
42
+ // Silently ignore file write errors to avoid masking real output
43
+ }
44
+ }
45
+ debug(message, ...args) {
46
+ if (this.level <= LogLevel.Debug) {
47
+ if (this.ansi) {
48
+ console.log(chalk_1.default.yellow(message), ...args);
49
+ }
50
+ else {
51
+ console.log(message, ...args);
52
+ }
53
+ }
54
+ // Always write debug to file when output is set (even if not shown on terminal)
55
+ this.writeToFile(`[debug] ${message}`, ...args);
56
+ }
57
+ info(message, ...args) {
58
+ if (this.level <= LogLevel.Info) {
59
+ console.log(message, ...args);
60
+ }
61
+ if (this.outputFile)
62
+ this.writeToFile(message, ...args);
63
+ }
64
+ warn(message, ...args) {
65
+ if (this.level <= LogLevel.Warning) {
66
+ if (this.ansi) {
67
+ console.warn(chalk_1.default.yellow(message), ...args);
68
+ }
69
+ else {
70
+ console.warn(message, ...args);
71
+ }
72
+ }
73
+ if (this.outputFile)
74
+ this.writeToFile(`[warn] ${message}`, ...args);
75
+ }
76
+ error(message, ...args) {
77
+ if (this.level <= LogLevel.Error) {
78
+ if (this.ansi) {
79
+ console.error(chalk_1.default.red(message), ...args);
80
+ }
81
+ else {
82
+ console.error(message, ...args);
83
+ }
84
+ }
85
+ if (this.outputFile)
86
+ this.writeToFile(`[error] ${message}`, ...args);
87
+ }
88
+ stack(error) {
89
+ if (this.level <= LogLevel.Debug) {
90
+ if (this.ansi) {
91
+ console.error(chalk_1.default.gray(error instanceof Error ? error.stack : error));
92
+ }
93
+ else {
94
+ console.error(error instanceof Error ? error.stack : error);
95
+ }
96
+ }
97
+ if (this.outputFile) {
98
+ this.writeToFile(`[debug] ${error instanceof Error ? error.stack : String(error)}`);
99
+ }
100
+ }
101
+ exception(error) {
102
+ if (this.level <= LogLevel.Error) {
103
+ if (this.ansi) {
104
+ console.error(chalk_1.default.red(error instanceof Error ? error.message : error));
105
+ }
106
+ else {
107
+ console.error(error instanceof Error ? error.message : error);
108
+ }
109
+ }
110
+ if (this.outputFile) {
111
+ this.writeToFile(`[error] ${error instanceof Error ? error.message : String(error)}`);
112
+ }
113
+ this.stack(error);
114
+ }
115
+ }
116
+ exports.Logger = Logger;
117
+ exports.logger = new Logger();
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ /*
3
+ * Copyright (c) Testmo GmbH (Berlin, Germany)
4
+ * All rights reserved.
5
+ * contact@testmo.com - www.testmo.com
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.withRetry = withRetry;
9
+ const logger_1 = require("./logger");
10
+ const MAX_RETRIES = 3;
11
+ const BASE_DELAY_MS = 1000;
12
+ const MAX_RATE_LIMIT_RETRIES = 5;
13
+ function sleep(ms) {
14
+ return new Promise(resolve => setTimeout(resolve, ms));
15
+ }
16
+ function getStatus(err) {
17
+ var _a, _b;
18
+ return (_b = (_a = err === null || err === void 0 ? void 0 : err.response) === null || _a === void 0 ? void 0 : _a.status) !== null && _b !== void 0 ? _b : err === null || err === void 0 ? void 0 : err.status;
19
+ }
20
+ function isRetryable(status) {
21
+ // Retry on network errors (no status) or 5xx server errors
22
+ return status === undefined || (status >= 500 && status < 600);
23
+ }
24
+ /**
25
+ * Wraps an async API call with retry logic including exponential backoff
26
+ * and automatic 429 rate-limit detection.
27
+ *
28
+ * - 429 responses: respects the Retry-After header (defaults to 60s),
29
+ * retried up to MAX_RATE_LIMIT_RETRIES times without counting against
30
+ * the normal retry budget.
31
+ * - 5xx / network errors: retried up to maxRetries times with exponential
32
+ * backoff starting at 1s (1s, 2s, 4s, …).
33
+ * - 4xx (except 429): not retried — these indicate caller errors.
34
+ */
35
+ async function withRetry(fn, maxRetries = MAX_RETRIES) {
36
+ var _a, _b;
37
+ let attempt = 0;
38
+ let rateLimitAttempt = 0;
39
+ while (true) {
40
+ try {
41
+ return await fn();
42
+ }
43
+ catch (err) {
44
+ const status = getStatus(err);
45
+ if (status === 429) {
46
+ rateLimitAttempt++;
47
+ if (rateLimitAttempt > MAX_RATE_LIMIT_RETRIES) {
48
+ throw err;
49
+ }
50
+ const retryAfterHeader = (_b = (_a = err === null || err === void 0 ? void 0 : err.response) === null || _a === void 0 ? void 0 : _a.headers) === null || _b === void 0 ? void 0 : _b['retry-after'];
51
+ const parsed = retryAfterHeader ? parseInt(retryAfterHeader, 10) : NaN;
52
+ const waitMs = isNaN(parsed) ? 60000 : parsed * 1000;
53
+ logger_1.logger.warn(`Rate limited by API. Waiting ${Math.round(waitMs / 1000)}s before retrying ` +
54
+ `(rate-limit attempt ${rateLimitAttempt}/${MAX_RATE_LIMIT_RETRIES})...`);
55
+ await sleep(waitMs);
56
+ continue;
57
+ }
58
+ attempt++;
59
+ if (attempt > maxRetries || !isRetryable(status)) {
60
+ throw err;
61
+ }
62
+ const delay = BASE_DELAY_MS * Math.pow(2, attempt - 1); // 1s, 2s, 4s
63
+ logger_1.logger.debug(`API call failed (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms...`);
64
+ await sleep(delay);
65
+ }
66
+ }
67
+ }