@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.
- package/README.md +266 -0
- package/dist/controller/automationLink.js +636 -0
- package/dist/core/controller.js +50 -0
- package/dist/index.js +202 -0
- package/dist/lib/errors.js +68 -0
- package/dist/lib/logger.js +117 -0
- package/dist/lib/retry.js +67 -0
- package/dist/linking/annotationScanner.js +745 -0
- package/dist/linking/patternMatcher.js +173 -0
- package/package.json +51 -0
|
@@ -0,0 +1,636 @@
|
|
|
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 __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
8
|
+
if (k2 === undefined) k2 = k;
|
|
9
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
10
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
11
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
12
|
+
}
|
|
13
|
+
Object.defineProperty(o, k2, desc);
|
|
14
|
+
}) : (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
o[k2] = m[k];
|
|
17
|
+
}));
|
|
18
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
19
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
20
|
+
}) : function(o, v) {
|
|
21
|
+
o["default"] = v;
|
|
22
|
+
});
|
|
23
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
24
|
+
var ownKeys = function(o) {
|
|
25
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
26
|
+
var ar = [];
|
|
27
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
28
|
+
return ar;
|
|
29
|
+
};
|
|
30
|
+
return ownKeys(o);
|
|
31
|
+
};
|
|
32
|
+
return function (mod) {
|
|
33
|
+
if (mod && mod.__esModule) return mod;
|
|
34
|
+
var result = {};
|
|
35
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
36
|
+
__setModuleDefault(result, mod);
|
|
37
|
+
return result;
|
|
38
|
+
};
|
|
39
|
+
})();
|
|
40
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
41
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
42
|
+
};
|
|
43
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
44
|
+
exports.AutomationLink = void 0;
|
|
45
|
+
const controller_1 = require("../core/controller");
|
|
46
|
+
const logger_1 = require("../lib/logger");
|
|
47
|
+
const errors_1 = require("../lib/errors");
|
|
48
|
+
const fs_1 = __importDefault(require("fs"));
|
|
49
|
+
const path_1 = __importDefault(require("path"));
|
|
50
|
+
const validator_1 = __importDefault(require("validator"));
|
|
51
|
+
// @ts-ignore
|
|
52
|
+
const testmo = __importStar(require("@testmo/testmo-api"));
|
|
53
|
+
// @ts-ignore
|
|
54
|
+
const packageInfo = __importStar(require("../../package.json"));
|
|
55
|
+
const retry_1 = require("../lib/retry");
|
|
56
|
+
const annotationScanner_1 = require("../linking/annotationScanner");
|
|
57
|
+
const patternMatcher_1 = require("../linking/patternMatcher");
|
|
58
|
+
const DEFAULT_PRECEDENCE = [
|
|
59
|
+
'annotation',
|
|
60
|
+
'config',
|
|
61
|
+
'pattern',
|
|
62
|
+
];
|
|
63
|
+
class AutomationLink extends controller_1.Controller {
|
|
64
|
+
validate(options) {
|
|
65
|
+
if (!validator_1.default.isInt(options.projectId, { min: 1 })) {
|
|
66
|
+
throw new errors_1.ValidationError('Project ID needs to be a positive number');
|
|
67
|
+
}
|
|
68
|
+
if (!validator_1.default.isInt(options.runId, { min: 1 })) {
|
|
69
|
+
throw new errors_1.ValidationError('Run ID needs to be a positive number');
|
|
70
|
+
}
|
|
71
|
+
if (options.configFile !== undefined && !fs_1.default.existsSync(options.configFile)) {
|
|
72
|
+
throw new errors_1.ConfigError(`Config file not found: ${options.configFile}\n` +
|
|
73
|
+
` Resolved path: ${path_1.default.resolve(options.configFile)}\n` +
|
|
74
|
+
` Working directory: ${process.cwd()}`, options.configFile);
|
|
75
|
+
}
|
|
76
|
+
if (options.conflictResolution !== undefined &&
|
|
77
|
+
!['error', 'warn', 'silent'].includes(options.conflictResolution)) {
|
|
78
|
+
throw new errors_1.ValidationError('conflict-resolution must be one of: error, warn, silent');
|
|
79
|
+
}
|
|
80
|
+
this.validateProxy(options.proxy);
|
|
81
|
+
}
|
|
82
|
+
loadConfig(configFile) {
|
|
83
|
+
var _a;
|
|
84
|
+
if (!configFile) {
|
|
85
|
+
return {};
|
|
86
|
+
}
|
|
87
|
+
// T111 — warn on large config files; T110 — inform about memory implications > 10MB
|
|
88
|
+
try {
|
|
89
|
+
const stat = fs_1.default.statSync(configFile);
|
|
90
|
+
const sizeKB = Math.round(stat.size / 1024);
|
|
91
|
+
if (stat.size > 10 * 1024 * 1024) {
|
|
92
|
+
logger_1.logger.warn(`Config file is very large (${sizeKB}KB, >10MB). ` +
|
|
93
|
+
`High memory usage expected. Consider using annotations or pattern matching instead.`);
|
|
94
|
+
}
|
|
95
|
+
else if (stat.size > 1024 * 1024) {
|
|
96
|
+
logger_1.logger.warn(`Config file is large (${sizeKB}KB, >1MB). ` +
|
|
97
|
+
`For this many mappings, consider using annotations or pattern matching instead.`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch (_b) {
|
|
101
|
+
// Let readFileSync below report the real I/O error
|
|
102
|
+
}
|
|
103
|
+
let content;
|
|
104
|
+
try {
|
|
105
|
+
content = fs_1.default.readFileSync(configFile, 'utf8');
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
throw new errors_1.ConfigError(`Cannot read config file: ${e.message}`, configFile);
|
|
109
|
+
}
|
|
110
|
+
const ext = path_1.default.extname(configFile).toLowerCase();
|
|
111
|
+
if (ext === '.json') {
|
|
112
|
+
try {
|
|
113
|
+
return JSON.parse(content);
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
throw new errors_1.ConfigError(`Config file contains invalid JSON: ${e.message}\n` +
|
|
117
|
+
` File: ${path_1.default.resolve(configFile)}`, configFile);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// YAML (.yml or .yaml)
|
|
121
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
122
|
+
const yaml = require('js-yaml');
|
|
123
|
+
try {
|
|
124
|
+
return ((_a = yaml.load(content)) !== null && _a !== void 0 ? _a : {});
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
throw new errors_1.ConfigError(`Config file contains invalid YAML: ${e.message}\n` +
|
|
128
|
+
` File: ${path_1.default.resolve(configFile)}`, configFile);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
buildConfigLookup(config) {
|
|
132
|
+
var _a;
|
|
133
|
+
const lookup = new Map();
|
|
134
|
+
if (config.config_mappings) {
|
|
135
|
+
for (const mapping of config.config_mappings) {
|
|
136
|
+
const existing = (_a = lookup.get(mapping.test_name)) !== null && _a !== void 0 ? _a : [];
|
|
137
|
+
existing.push(mapping);
|
|
138
|
+
lookup.set(mapping.test_name, existing);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return lookup;
|
|
142
|
+
}
|
|
143
|
+
resolveConfigMapping(lookup, test) {
|
|
144
|
+
const candidates = lookup.get(test.name);
|
|
145
|
+
if (!candidates || candidates.length === 0)
|
|
146
|
+
return undefined;
|
|
147
|
+
if (candidates.length === 1)
|
|
148
|
+
return candidates[0].case_id;
|
|
149
|
+
// Multiple candidates — try folder disambiguation first
|
|
150
|
+
let filtered = candidates;
|
|
151
|
+
if (test.folder !== null) {
|
|
152
|
+
// Test has a folder — prefer entries that match it
|
|
153
|
+
const byFolder = filtered.filter(c => c.folder === test.folder);
|
|
154
|
+
if (byFolder.length > 0) {
|
|
155
|
+
filtered = byFolder;
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
// No folder match — fall back to unscoped (no folder) entries
|
|
159
|
+
const unscoped = filtered.filter(c => !c.folder);
|
|
160
|
+
if (unscoped.length > 0) {
|
|
161
|
+
filtered = unscoped;
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
// All entries are folder-scoped and none match this test's folder
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
// Test has no folder — prefer entries that don't restrict by folder
|
|
171
|
+
const unscoped = filtered.filter(c => !c.folder);
|
|
172
|
+
if (unscoped.length > 0)
|
|
173
|
+
filtered = unscoped;
|
|
174
|
+
}
|
|
175
|
+
// Then try file disambiguation
|
|
176
|
+
if (filtered.length > 1 && test.file !== null) {
|
|
177
|
+
const normFile = test.file.replace(/\\/g, '/');
|
|
178
|
+
const byFile = filtered.filter(c => {
|
|
179
|
+
if (!c.file)
|
|
180
|
+
return false;
|
|
181
|
+
const normC = c.file.replace(/\\/g, '/');
|
|
182
|
+
return normC === normFile
|
|
183
|
+
|| normFile.endsWith('/' + normC)
|
|
184
|
+
|| normC.endsWith('/' + normFile);
|
|
185
|
+
});
|
|
186
|
+
if (byFile.length > 0) {
|
|
187
|
+
filtered = byFile;
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
// No file match — fall back to unscoped (no file) entries
|
|
191
|
+
const unscopedFile = filtered.filter(c => !c.file);
|
|
192
|
+
if (unscopedFile.length > 0) {
|
|
193
|
+
filtered = unscopedFile;
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
// All remaining entries specify a file but none match
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (filtered.length === 1)
|
|
202
|
+
return filtered[0].case_id;
|
|
203
|
+
// Still ambiguous — warn and skip
|
|
204
|
+
logger_1.logger.warn(`config_mappings ambiguous for "${test.name}": ` +
|
|
205
|
+
`${filtered.length} entries still match after applying folder/file disambiguation. ` +
|
|
206
|
+
`Add more specific "folder" or "file" values to distinguish them.`);
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
createRawClient(address, proxy, token) {
|
|
210
|
+
if (!token || validator_1.default.isEmpty(token)) {
|
|
211
|
+
throw new errors_1.ValidationError('Missing Testmo API token (TESTMO_TOKEN environment variable)');
|
|
212
|
+
}
|
|
213
|
+
const requestLogger = (loggingRequest) => {
|
|
214
|
+
logger_1.logger.debug(`Request: ${loggingRequest.method} ${loggingRequest.url}`);
|
|
215
|
+
};
|
|
216
|
+
const client = new testmo.ApiClientEx();
|
|
217
|
+
client.plugins.push(requestLogger);
|
|
218
|
+
client.basePath = address;
|
|
219
|
+
client.authentications.bearerAuth.accessToken = token;
|
|
220
|
+
client.defaultHeaders = {
|
|
221
|
+
'Accept': 'application/json',
|
|
222
|
+
'User-Agent': `Testmo Link/${packageInfo.version} (www.testmo.com)`,
|
|
223
|
+
};
|
|
224
|
+
if (proxy) {
|
|
225
|
+
client.proxy = proxy;
|
|
226
|
+
}
|
|
227
|
+
return client;
|
|
228
|
+
}
|
|
229
|
+
async getAllRunTests(client, runId) {
|
|
230
|
+
var _a;
|
|
231
|
+
const allTests = [];
|
|
232
|
+
let page = 1;
|
|
233
|
+
const perPage = 1000;
|
|
234
|
+
while (true) {
|
|
235
|
+
const result = await (0, retry_1.withRetry)(() => client.callApi('/api/v1/automation/runs/{run_id}/tests', 'GET', { run_id: runId }, { page, per_page: perPage }, {}, {}, null, ['bearerAuth'], ['application/json'], ['application/json'], Object, null));
|
|
236
|
+
const data = result.data;
|
|
237
|
+
const tests = (_a = data === null || data === void 0 ? void 0 : data.result) !== null && _a !== void 0 ? _a : [];
|
|
238
|
+
allTests.push(...tests);
|
|
239
|
+
if (!(data === null || data === void 0 ? void 0 : data.next_page)) {
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
page = data.next_page;
|
|
243
|
+
}
|
|
244
|
+
return allTests;
|
|
245
|
+
}
|
|
246
|
+
async createLinkBulk(client, projectId, links) {
|
|
247
|
+
await (0, retry_1.withRetry)(() => client.callApi('/api/v1/projects/{project_id}/automation-links/bulk', 'POST', { project_id: projectId }, {}, {}, {}, {
|
|
248
|
+
links: links.map(l => ({
|
|
249
|
+
case_id: l.repositoryCaseId,
|
|
250
|
+
automation_case_id: l.automationCaseId,
|
|
251
|
+
})),
|
|
252
|
+
}, ['bearerAuth'], ['application/json'], ['application/json'], Object, null));
|
|
253
|
+
}
|
|
254
|
+
async run(options) {
|
|
255
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0, _1, _2;
|
|
256
|
+
this.logControllerOptions(options);
|
|
257
|
+
options.testmoInstanceAddress = this.validateAndTruncateInstanceAddress(options.testmoInstanceAddress);
|
|
258
|
+
this.validate(options);
|
|
259
|
+
// --- Load config ---
|
|
260
|
+
const config = this.loadConfig(options.configFile);
|
|
261
|
+
// Warn on unrecognized top-level config keys (catches typos like "include", "output.verbose")
|
|
262
|
+
if (options.configFile) {
|
|
263
|
+
const knownKeys = new Set([
|
|
264
|
+
'config_mappings', 'annotations', 'pattern_matching', 'precedence', 'settings',
|
|
265
|
+
]);
|
|
266
|
+
for (const key of Object.keys(config)) {
|
|
267
|
+
if (!knownKeys.has(key)) {
|
|
268
|
+
logger_1.logger.warn(`Unknown config key "${key}" in ${options.configFile} — ` +
|
|
269
|
+
`not recognized and will be ignored. ` +
|
|
270
|
+
`Valid top-level keys: ${[...knownKeys].join(', ')}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// Apply config file settings (CLI flags take priority over config settings)
|
|
275
|
+
if (((_a = config.settings) === null || _a === void 0 ? void 0 : _a.verbose) && logger_1.logger.level > logger_1.LogLevel.Debug) {
|
|
276
|
+
logger_1.logger.level = logger_1.LogLevel.Debug;
|
|
277
|
+
}
|
|
278
|
+
if (((_b = config.settings) === null || _b === void 0 ? void 0 : _b.output_file) && !logger_1.logger.outputFile) {
|
|
279
|
+
logger_1.logger.outputFile = config.settings.output_file;
|
|
280
|
+
}
|
|
281
|
+
// CLI flags override config file settings
|
|
282
|
+
const conflictResolution = (_e = (_c = options.conflictResolution) !== null && _c !== void 0 ? _c : (_d = config.settings) === null || _d === void 0 ? void 0 : _d.conflict_resolution) !== null && _e !== void 0 ? _e : 'error';
|
|
283
|
+
const precedence = ((_f = config.precedence) === null || _f === void 0 ? void 0 : _f.length) ? config.precedence : DEFAULT_PRECEDENCE;
|
|
284
|
+
// --- Config mappings ---
|
|
285
|
+
const configLookup = this.buildConfigLookup(config);
|
|
286
|
+
const configMappingEnabled = precedence.includes('config') && configLookup.size > 0;
|
|
287
|
+
if (configMappingEnabled) {
|
|
288
|
+
const totalMappings = [...configLookup.values()].reduce((n, arr) => n + arr.length, 0);
|
|
289
|
+
logger_1.logger.info(`Loaded ${totalMappings} config mapping(s)`);
|
|
290
|
+
}
|
|
291
|
+
else if (!options.configFile) {
|
|
292
|
+
logger_1.logger.warn('No config file specified and no mappings loaded. ' +
|
|
293
|
+
'Use --config <file> to specify a YAML/JSON config with mappings.');
|
|
294
|
+
}
|
|
295
|
+
else if (configLookup.size === 0 &&
|
|
296
|
+
!((_g = config.annotations) === null || _g === void 0 ? void 0 : _g.enabled) &&
|
|
297
|
+
!((_h = config.pattern_matching) === null || _h === void 0 ? void 0 : _h.enabled)) {
|
|
298
|
+
logger_1.logger.warn('No config_mappings found in config file. No links will be created.');
|
|
299
|
+
}
|
|
300
|
+
// --- API client ---
|
|
301
|
+
const client = this.createRawClient(options.testmoInstanceAddress, options.proxy, options.testmoApiToken);
|
|
302
|
+
// T113 — phase performance timers (reported in debug/verbose mode)
|
|
303
|
+
const phaseMs = { scan: 0, caseLoad: 0, fetch: 0, match: 0, link: 0 };
|
|
304
|
+
// --- Annotation scanner setup ---
|
|
305
|
+
let annotationScanner;
|
|
306
|
+
if (((_j = config.annotations) === null || _j === void 0 ? void 0 : _j.enabled) === true &&
|
|
307
|
+
((_l = (_k = config.annotations.paths) === null || _k === void 0 ? void 0 : _k.length) !== null && _l !== void 0 ? _l : 0) === 0) {
|
|
308
|
+
logger_1.logger.warn('Annotation scanning is enabled but no paths are configured — skipping annotation scan.\n' +
|
|
309
|
+
'Add a "paths" key to your annotations configuration to specify which files to scan.');
|
|
310
|
+
}
|
|
311
|
+
const annotationEnabled = precedence.includes('annotation') &&
|
|
312
|
+
((_m = config.annotations) === null || _m === void 0 ? void 0 : _m.enabled) === true &&
|
|
313
|
+
((_p = (_o = config.annotations.paths) === null || _o === void 0 ? void 0 : _o.length) !== null && _p !== void 0 ? _p : 0) > 0;
|
|
314
|
+
if (annotationEnabled) {
|
|
315
|
+
annotationScanner = new annotationScanner_1.AnnotationScanner(config.annotations);
|
|
316
|
+
logger_1.logger.info(`Scanning files for annotations...`);
|
|
317
|
+
const t0 = Date.now();
|
|
318
|
+
await annotationScanner.scan(config.annotations.paths);
|
|
319
|
+
phaseMs.scan = Date.now() - t0;
|
|
320
|
+
}
|
|
321
|
+
// --- Pattern matcher setup ---
|
|
322
|
+
let patternMatcher;
|
|
323
|
+
const patternEnabled = precedence.includes('pattern') &&
|
|
324
|
+
((_q = config.pattern_matching) === null || _q === void 0 ? void 0 : _q.enabled) === true;
|
|
325
|
+
if (patternEnabled) {
|
|
326
|
+
const rules = (_r = config.pattern_matching.rules) !== null && _r !== void 0 ? _r : [];
|
|
327
|
+
patternMatcher = new patternMatcher_1.PatternMatcher(rules);
|
|
328
|
+
logger_1.logger.info(`Loading repository cases for pattern matching...`);
|
|
329
|
+
const projectId = parseInt(options.projectId, 10);
|
|
330
|
+
const t0 = Date.now();
|
|
331
|
+
await patternMatcher.loadCases(client, projectId);
|
|
332
|
+
phaseMs.caseLoad = Date.now() - t0;
|
|
333
|
+
logger_1.logger.info(`Loaded ${patternMatcher.getCaseCount()} case(s) for pattern matching`);
|
|
334
|
+
}
|
|
335
|
+
// --- Fetch run tests ---
|
|
336
|
+
logger_1.logger.info(`Fetching test results for run ${options.runId}...`);
|
|
337
|
+
const fetchStart = Date.now();
|
|
338
|
+
const tests = await this.getAllRunTests(client, parseInt(options.runId, 10));
|
|
339
|
+
phaseMs.fetch = Date.now() - fetchStart;
|
|
340
|
+
logger_1.logger.info(`Found ${tests.length} test result(s)`);
|
|
341
|
+
// Build a set of all case IDs already linked across the run so that
|
|
342
|
+
// pattern matching can filter them out before the ambiguity check.
|
|
343
|
+
const allLinkedCaseIds = new Set();
|
|
344
|
+
for (const t of tests) {
|
|
345
|
+
if (t.repository_case_id !== null)
|
|
346
|
+
allLinkedCaseIds.add(t.repository_case_id);
|
|
347
|
+
for (const id of ((_s = t.repository_case_ids) !== null && _s !== void 0 ? _s : []))
|
|
348
|
+
allLinkedCaseIds.add(id);
|
|
349
|
+
}
|
|
350
|
+
// --- Matching phase ---
|
|
351
|
+
let matched = 0;
|
|
352
|
+
let alreadyLinked = 0;
|
|
353
|
+
let unmatched = 0;
|
|
354
|
+
let errors = 0;
|
|
355
|
+
const methodCounts = {
|
|
356
|
+
annotation: 0,
|
|
357
|
+
config: 0,
|
|
358
|
+
pattern: 0,
|
|
359
|
+
};
|
|
360
|
+
const startTime = Date.now();
|
|
361
|
+
const matchStart = Date.now();
|
|
362
|
+
const projectId = parseInt(options.projectId, 10);
|
|
363
|
+
// T102 — error counts by category for aggregated reporting.
|
|
364
|
+
// The bulk endpoint returns 422 for validation failures (not per-case 404s),
|
|
365
|
+
// so only two categories are needed.
|
|
366
|
+
const errorCounts = { validation: 0, other: 0 };
|
|
367
|
+
const conflictRecords = [];
|
|
368
|
+
const pendingLinks = [];
|
|
369
|
+
for (const test of tests) {
|
|
370
|
+
// Skip if already linked (unless --force).
|
|
371
|
+
// When annotation scanning is enabled, defer the skip — the annotation path
|
|
372
|
+
// handles per-ID filtering via repository_case_ids, so a test with one existing
|
|
373
|
+
// link may still need additional IDs linked.
|
|
374
|
+
if (test.repository_case_id !== null && !options.force && !annotationEnabled) {
|
|
375
|
+
alreadyLinked++;
|
|
376
|
+
logger_1.logger.debug(`Skipping already-linked: "${test.name}" → case ${test.repository_case_id}`);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
// Try each method in precedence order, collect all results for conflict detection
|
|
380
|
+
const candidates = [];
|
|
381
|
+
let multiCaseDone = false;
|
|
382
|
+
// Tracks whether the annotation single-ID was suppressed because it's already linked
|
|
383
|
+
// (used to count the test as alreadyLinked rather than unmatched at the end)
|
|
384
|
+
let filteredByExistingLink = false;
|
|
385
|
+
for (const method of precedence) {
|
|
386
|
+
let caseId;
|
|
387
|
+
if (method === 'annotation' && annotationScanner) {
|
|
388
|
+
const ids = annotationScanner.findCaseIds(test.name, test.file, test.folder);
|
|
389
|
+
if (ids.length > 1 && candidates.length === 0) {
|
|
390
|
+
// Multiple case IDs and no higher-precedence method matched —
|
|
391
|
+
// filter out already-linked IDs, then create pending links for the rest.
|
|
392
|
+
const existingIds = new Set([
|
|
393
|
+
...((_t = test.repository_case_ids) !== null && _t !== void 0 ? _t : []),
|
|
394
|
+
...(test.repository_case_id !== null ? [test.repository_case_id] : []),
|
|
395
|
+
]);
|
|
396
|
+
const idsToLink = options.force
|
|
397
|
+
? ids
|
|
398
|
+
: ids.filter(id => !existingIds.has(id));
|
|
399
|
+
if (idsToLink.length === 0) {
|
|
400
|
+
// All annotation IDs are already linked
|
|
401
|
+
alreadyLinked++;
|
|
402
|
+
logger_1.logger.debug(`Skipping already-linked: "${test.name}" → all ${ids.length} annotation IDs already linked`);
|
|
403
|
+
multiCaseDone = true;
|
|
404
|
+
break;
|
|
405
|
+
}
|
|
406
|
+
if (options.dryRun) {
|
|
407
|
+
for (const id of idsToLink) {
|
|
408
|
+
logger_1.logger.info(`[DRY RUN] Would link: "${test.name}" ` +
|
|
409
|
+
`(automation_case_id=${test.automation_case_id}) → case ${id}` +
|
|
410
|
+
` [annotation]`);
|
|
411
|
+
matched++;
|
|
412
|
+
methodCounts.annotation++;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
for (const id of idsToLink) {
|
|
417
|
+
pendingLinks.push({ test, caseId: id, method: 'annotation' });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
multiCaseDone = true;
|
|
421
|
+
break;
|
|
422
|
+
}
|
|
423
|
+
// Single or no match — fall through to normal conflict-detection flow
|
|
424
|
+
caseId = ids[0];
|
|
425
|
+
// If this single ID is already linked, skip it to avoid a redundant POST
|
|
426
|
+
if (caseId !== undefined && !options.force) {
|
|
427
|
+
const existingIds = new Set([
|
|
428
|
+
...((_u = test.repository_case_ids) !== null && _u !== void 0 ? _u : []),
|
|
429
|
+
...(test.repository_case_id !== null ? [test.repository_case_id] : []),
|
|
430
|
+
]);
|
|
431
|
+
if (existingIds.has(caseId)) {
|
|
432
|
+
filteredByExistingLink = true;
|
|
433
|
+
caseId = undefined;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
else if (method === 'config' && configMappingEnabled) {
|
|
438
|
+
// Skip non-annotation methods for already-linked tests (unless --force).
|
|
439
|
+
// The early-exit above is deferred when annotationEnabled, but that deferral
|
|
440
|
+
// is only needed for the annotation path — config/pattern should still respect
|
|
441
|
+
// the already-linked state.
|
|
442
|
+
if (test.repository_case_id !== null && !options.force)
|
|
443
|
+
continue;
|
|
444
|
+
caseId = this.resolveConfigMapping(configLookup, test);
|
|
445
|
+
}
|
|
446
|
+
else if (method === 'pattern' && patternMatcher) {
|
|
447
|
+
if (test.repository_case_id !== null && !options.force)
|
|
448
|
+
continue;
|
|
449
|
+
caseId = patternMatcher.match(test.name, allLinkedCaseIds);
|
|
450
|
+
}
|
|
451
|
+
if (caseId !== undefined) {
|
|
452
|
+
candidates.push({ method, caseId });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (multiCaseDone)
|
|
456
|
+
continue;
|
|
457
|
+
if (candidates.length === 0) {
|
|
458
|
+
// Count as alreadyLinked if: the annotation single-ID was filtered because it's
|
|
459
|
+
// already linked, or the test has an existing link and annotations found nothing
|
|
460
|
+
// new to add (annotationEnabled bypassed the early-exit above).
|
|
461
|
+
if (filteredByExistingLink ||
|
|
462
|
+
(test.repository_case_id !== null && !options.force)) {
|
|
463
|
+
alreadyLinked++;
|
|
464
|
+
logger_1.logger.debug(`Skipping already-linked: "${test.name}"`);
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
unmatched++;
|
|
468
|
+
// T098 — per-test hint at debug level; summary-level suggestions follow
|
|
469
|
+
logger_1.logger.debug(`No match found for: "${test.name}"`);
|
|
470
|
+
}
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
// Check for conflicts (same test matched to different case IDs by different methods)
|
|
474
|
+
const uniqueCaseIds = new Set(candidates.map(c => c.caseId));
|
|
475
|
+
let resolvedCaseId;
|
|
476
|
+
let resolvedMethod;
|
|
477
|
+
if (uniqueCaseIds.size > 1) {
|
|
478
|
+
const detail = candidates
|
|
479
|
+
.map(c => `${c.method}→case ${c.caseId}`)
|
|
480
|
+
.join(', ');
|
|
481
|
+
if (conflictResolution === 'error') {
|
|
482
|
+
errors++;
|
|
483
|
+
logger_1.logger.error(`Conflict for "${test.name}": multiple methods returned different case IDs (${detail})`);
|
|
484
|
+
conflictRecords.push({
|
|
485
|
+
testName: test.name,
|
|
486
|
+
candidates,
|
|
487
|
+
resolvedCaseId: null,
|
|
488
|
+
resolvedMethod: null,
|
|
489
|
+
});
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
else if (conflictResolution === 'warn') {
|
|
493
|
+
logger_1.logger.warn(`Conflict for "${test.name}": using first match (${detail})`);
|
|
494
|
+
}
|
|
495
|
+
// Both warn and silent: use the first match (highest precedence)
|
|
496
|
+
resolvedCaseId = candidates[0].caseId;
|
|
497
|
+
resolvedMethod = candidates[0].method;
|
|
498
|
+
conflictRecords.push({
|
|
499
|
+
testName: test.name,
|
|
500
|
+
candidates,
|
|
501
|
+
resolvedCaseId,
|
|
502
|
+
resolvedMethod,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
// All candidates agree — use the first (highest precedence method)
|
|
507
|
+
resolvedCaseId = candidates[0].caseId;
|
|
508
|
+
resolvedMethod = candidates[0].method;
|
|
509
|
+
}
|
|
510
|
+
if (options.dryRun) {
|
|
511
|
+
logger_1.logger.info(`[DRY RUN] Would link: "${test.name}" ` +
|
|
512
|
+
`(automation_case_id=${test.automation_case_id}) → case ${resolvedCaseId}` +
|
|
513
|
+
` [${resolvedMethod}]`);
|
|
514
|
+
matched++;
|
|
515
|
+
methodCounts[resolvedMethod]++;
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
pendingLinks.push({ test, caseId: resolvedCaseId, method: resolvedMethod });
|
|
519
|
+
}
|
|
520
|
+
phaseMs.match = Date.now() - matchStart;
|
|
521
|
+
// --- Bulk link creation phase (up to 500 links per request) ---
|
|
522
|
+
// Each batch is a single POST to the bulk endpoint, reducing N individual
|
|
523
|
+
// requests to ceil(N/500) requests and eliminating the burst that triggers
|
|
524
|
+
// rate limiting.
|
|
525
|
+
const linkStart = Date.now();
|
|
526
|
+
const BULK_BATCH_SIZE = 500;
|
|
527
|
+
for (let i = 0; i < pendingLinks.length; i += BULK_BATCH_SIZE) {
|
|
528
|
+
const batch = pendingLinks.slice(i, i + BULK_BATCH_SIZE);
|
|
529
|
+
try {
|
|
530
|
+
await this.createLinkBulk(client, projectId, batch.map(({ test, caseId }) => ({
|
|
531
|
+
repositoryCaseId: caseId,
|
|
532
|
+
automationCaseId: test.automation_case_id,
|
|
533
|
+
})));
|
|
534
|
+
for (const { test, caseId, method } of batch) {
|
|
535
|
+
matched++;
|
|
536
|
+
methodCounts[method]++;
|
|
537
|
+
logger_1.logger.debug(`Linked: "${test.name}" ` +
|
|
538
|
+
`(automation_case_id=${test.automation_case_id}) → case ${caseId}` +
|
|
539
|
+
` [${method}]`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
catch (e) {
|
|
543
|
+
errors += batch.length;
|
|
544
|
+
const status = (_w = (_v = e === null || e === void 0 ? void 0 : e.response) === null || _v === void 0 ? void 0 : _v.status) !== null && _w !== void 0 ? _w : e === null || e === void 0 ? void 0 : e.status;
|
|
545
|
+
const msg = (_0 = (_z = (_y = (_x = e === null || e === void 0 ? void 0 : e.response) === null || _x === void 0 ? void 0 : _x.body) === null || _y === void 0 ? void 0 : _y.message) !== null && _z !== void 0 ? _z : e === null || e === void 0 ? void 0 : e.message) !== null && _0 !== void 0 ? _0 : (e != null && typeof e === 'object' ? JSON.stringify(e) : String(e));
|
|
546
|
+
if (status === 422 && ((_2 = (_1 = e === null || e === void 0 ? void 0 : e.response) === null || _1 === void 0 ? void 0 : _1.body) === null || _2 === void 0 ? void 0 : _2.errors)) {
|
|
547
|
+
errorCounts.validation += batch.length;
|
|
548
|
+
const fields = Object.entries(e.response.body.errors)
|
|
549
|
+
.map(([f, msgs]) => `${f}: ${msgs[0]}`)
|
|
550
|
+
.join(', ');
|
|
551
|
+
logger_1.logger.error(`Error linking batch of ${batch.length}: validation failed — ${fields}`);
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
errorCounts.other += batch.length;
|
|
555
|
+
logger_1.logger.error(`Error linking batch of ${batch.length}: ${msg}`);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
phaseMs.link = Date.now() - linkStart;
|
|
560
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
561
|
+
// T113 — performance metrics in debug mode
|
|
562
|
+
logger_1.logger.debug(`Performance: scan=${phaseMs.scan}ms, caseLoad=${phaseMs.caseLoad}ms, ` +
|
|
563
|
+
`fetch=${phaseMs.fetch}ms, match=${phaseMs.match}ms, link=${phaseMs.link}ms`);
|
|
564
|
+
// --- Summary ---
|
|
565
|
+
logger_1.logger.info('');
|
|
566
|
+
logger_1.logger.info(`Automation linking complete (${elapsed}s):`);
|
|
567
|
+
logger_1.logger.info(` Matched/linked: ${matched}`);
|
|
568
|
+
logger_1.logger.info(` Already linked: ${alreadyLinked} (skipped)`);
|
|
569
|
+
logger_1.logger.info(` Unmatched: ${unmatched}`);
|
|
570
|
+
if (errors > 0) {
|
|
571
|
+
logger_1.logger.info(` Errors: ${errors}`);
|
|
572
|
+
// T102 — aggregate error breakdown by category
|
|
573
|
+
if (errorCounts.validation > 0) {
|
|
574
|
+
logger_1.logger.info(` Validation failures: ${errorCounts.validation}`);
|
|
575
|
+
}
|
|
576
|
+
if (errorCounts.other > 0) {
|
|
577
|
+
logger_1.logger.info(` Network/API errors: ${errorCounts.other}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
// Show method breakdown if more than one method was active
|
|
581
|
+
const activeMethods = Object.keys(methodCounts).filter(m => methodCounts[m] > 0);
|
|
582
|
+
if (activeMethods.length > 1) {
|
|
583
|
+
logger_1.logger.info(` By method:`);
|
|
584
|
+
for (const m of activeMethods) {
|
|
585
|
+
logger_1.logger.info(` ${m}: ${methodCounts[m]}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
// Show conflict details if any conflicts occurred
|
|
589
|
+
if (conflictRecords.length > 0) {
|
|
590
|
+
logger_1.logger.info('');
|
|
591
|
+
logger_1.logger.info(`Conflict details (${conflictRecords.length}):`);
|
|
592
|
+
for (const conflict of conflictRecords) {
|
|
593
|
+
const candidateStr = conflict.candidates
|
|
594
|
+
.map(c => `${c.method}→case ${c.caseId}`)
|
|
595
|
+
.join(', ');
|
|
596
|
+
if (conflict.resolvedCaseId !== null) {
|
|
597
|
+
logger_1.logger.info(` "${conflict.testName}": ${candidateStr}` +
|
|
598
|
+
` → resolved: ${conflict.resolvedCaseId} [${conflict.resolvedMethod}]`);
|
|
599
|
+
}
|
|
600
|
+
else {
|
|
601
|
+
logger_1.logger.info(` "${conflict.testName}": ${candidateStr}` +
|
|
602
|
+
` → skipped (conflict error)`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
// T098 — resolution suggestions for unmatched tests
|
|
607
|
+
if (unmatched > 0) {
|
|
608
|
+
logger_1.logger.info('');
|
|
609
|
+
logger_1.logger.info(`Resolution suggestions for ${unmatched} unmatched test(s):`);
|
|
610
|
+
if (annotationEnabled) {
|
|
611
|
+
logger_1.logger.info(' - Annotation: ensure @TestmoId:<case_id> is on the line immediately above the test function/method');
|
|
612
|
+
}
|
|
613
|
+
if (configMappingEnabled) {
|
|
614
|
+
logger_1.logger.info(' - Config mapping: add an entry to config_mappings:');
|
|
615
|
+
logger_1.logger.info(' test_name: "<test name>"');
|
|
616
|
+
logger_1.logger.info(' case_id: <case_id>');
|
|
617
|
+
}
|
|
618
|
+
if (patternEnabled) {
|
|
619
|
+
logger_1.logger.info(' - Pattern matching: add or refine a pattern_matching rule');
|
|
620
|
+
}
|
|
621
|
+
if (!annotationEnabled && !configMappingEnabled && !patternEnabled) {
|
|
622
|
+
logger_1.logger.info(' - Enable at least one linking method in your config file:');
|
|
623
|
+
logger_1.logger.info(' config_mappings, annotations, or pattern_matching');
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (options.failOnUnmatched && unmatched > 0) {
|
|
627
|
+
logger_1.logger.error(`Failing: ${unmatched} unmatched test(s) (--fail-on-unmatched)`);
|
|
628
|
+
return 1;
|
|
629
|
+
}
|
|
630
|
+
if (errors > 0) {
|
|
631
|
+
return 1;
|
|
632
|
+
}
|
|
633
|
+
return 0;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
exports.AutomationLink = AutomationLink;
|