@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,745 @@
|
|
|
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
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
41
|
+
exports.AnnotationScanner = void 0;
|
|
42
|
+
exports.isSafePattern = isSafePattern;
|
|
43
|
+
exports.toJestParamPattern = toJestParamPattern;
|
|
44
|
+
exports.pendingBracketDepth = pendingBracketDepth;
|
|
45
|
+
exports.extractTestName = extractTestName;
|
|
46
|
+
exports.normalizeTestName = normalizeTestName;
|
|
47
|
+
const fs = __importStar(require("fs"));
|
|
48
|
+
const path = __importStar(require("path"));
|
|
49
|
+
const readline = __importStar(require("readline"));
|
|
50
|
+
const glob_1 = require("glob");
|
|
51
|
+
const logger_1 = require("../lib/logger");
|
|
52
|
+
const errors_1 = require("../lib/errors");
|
|
53
|
+
// Default annotation pattern
|
|
54
|
+
const DEFAULT_PATTERNS = ['@TestmoId:(\\d+)'];
|
|
55
|
+
// Warn when a file exceeds this size during streaming scan
|
|
56
|
+
const LARGE_FILE_WARN_BYTES = 10 * 1024 * 1024; // 10MB
|
|
57
|
+
// Lines to scan below an annotation looking for the test name (normal case)
|
|
58
|
+
const NAME_SCAN_LINES = 8;
|
|
59
|
+
// Gherkin structural keywords that do NOT represent individual test scenarios.
|
|
60
|
+
// Scenario / Scenario Outline / Example / i18n equivalents are intentionally absent.
|
|
61
|
+
const GHERKIN_NON_SCENARIO_KEYWORDS = new Set([
|
|
62
|
+
'feature', 'background', 'examples', 'rule',
|
|
63
|
+
'given', 'when', 'then', 'and', 'but',
|
|
64
|
+
]);
|
|
65
|
+
// Absolute upper bound — prevents runaway accumulation when inside a very large
|
|
66
|
+
// multi-line decorator argument list or a malformed file
|
|
67
|
+
const MAX_SCAN_LINES = 100;
|
|
68
|
+
/**
|
|
69
|
+
* Validates that a regex pattern does not contain constructs known to cause
|
|
70
|
+
* catastrophic backtracking (nested quantifiers).
|
|
71
|
+
*/
|
|
72
|
+
function isSafePattern(pattern) {
|
|
73
|
+
// Reject patterns with nested quantifiers like (a+)+ or (\w*)*
|
|
74
|
+
if (/\([^)]*[\+\*\?][^)]*\)[\+\*\{]/.test(pattern)) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
// Reject alternation inside repeated groups like (a|b)+
|
|
78
|
+
if (/\([^)]*\|[^)]*\)[\+\*\{]/.test(pattern)) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Returns true if the line is a decorator, attribute, blank, or comment line
|
|
85
|
+
* that should be skipped when extracting the test name after an annotation.
|
|
86
|
+
*/
|
|
87
|
+
function isDecoratorLine(line) {
|
|
88
|
+
const t = line.trim();
|
|
89
|
+
return (!t ||
|
|
90
|
+
t.startsWith('@') || // Python/Java/Kotlin/Go decorators
|
|
91
|
+
t.startsWith('[') || // C# attributes
|
|
92
|
+
t.startsWith('//') || // single-line comments (already consumed annotation)
|
|
93
|
+
t.startsWith('#') || // Python/Ruby comments
|
|
94
|
+
t.startsWith('*') || // Javadoc continuation
|
|
95
|
+
t.startsWith('/*') || // block comment open
|
|
96
|
+
t.startsWith('--') // SQL / Lua comments
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Convert a Ruby test name containing string interpolation (#{...}) to a RegExp
|
|
101
|
+
* for loose matching against resolved test names from the runner.
|
|
102
|
+
* "grants access to #{role}" → /^grants access to .+$/
|
|
103
|
+
* Returns undefined if the name contains no interpolation.
|
|
104
|
+
*/
|
|
105
|
+
function toInterpolationPattern(name) {
|
|
106
|
+
if (!name.includes('#{'))
|
|
107
|
+
return undefined;
|
|
108
|
+
const parts = name.split(/#\{[^}]*\}/);
|
|
109
|
+
// Require at least 3 characters of literal text combined to avoid catch-all patterns
|
|
110
|
+
// e.g. "#{name}" → literal="" → skip; "grants access to #{role}" → literal="grants access to " → ok
|
|
111
|
+
if (parts.join('').trim().length < 3)
|
|
112
|
+
return undefined;
|
|
113
|
+
const escaped = parts.map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
114
|
+
return new RegExp(`^${escaped.join('.+')}$`);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Convert a Jest test.each name containing placeholders to a RegExp for loose
|
|
118
|
+
* matching against the generated test names that Jest produces at runtime.
|
|
119
|
+
*
|
|
120
|
+
* Handles two placeholder styles:
|
|
121
|
+
* - printf-style: %s, %d, %i, %f, %o, %#, %p (inline array form)
|
|
122
|
+
* "should grant access to %s role" → /^should grant access to .+ role$/i
|
|
123
|
+
* - template-literal $variable style (tagged template form)
|
|
124
|
+
* "should add $a + $b = $expected" → /^should add .+ \+ .+ = .+$/i
|
|
125
|
+
*
|
|
126
|
+
* Returns undefined if the name contains no placeholders, or if the combined
|
|
127
|
+
* literal text is too short (< 3 chars) to produce a safe, non-catch-all pattern.
|
|
128
|
+
*/
|
|
129
|
+
function toJestParamPattern(name) {
|
|
130
|
+
const hasPrintf = /%[sdifo#p]/i.test(name);
|
|
131
|
+
// Match both $var (tagged template) and ${expression} (inline template literal) styles.
|
|
132
|
+
// ${expression} covers patterns like `should login as ${user.role} user` produced by
|
|
133
|
+
// Cypress forEach loops (TMD-2124). The split regex below includes both placeholder
|
|
134
|
+
// forms so we can build a matching pattern for the resolved runtime test names.
|
|
135
|
+
const hasTemplate = /\$[A-Za-z_][A-Za-z0-9_]*/.test(name) || /\$\{[^}]+\}/.test(name);
|
|
136
|
+
if (!hasPrintf && !hasTemplate)
|
|
137
|
+
return undefined;
|
|
138
|
+
const parts = name.split(/(?:%[sdifo#p]|\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*)/gi);
|
|
139
|
+
// Require at least 3 chars of literal text to avoid overly broad patterns
|
|
140
|
+
if (parts.join('').trim().length < 3)
|
|
141
|
+
return undefined;
|
|
142
|
+
const escaped = parts.map(p => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
143
|
+
return new RegExp(`^${escaped.join('.+')}$`, 'i');
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Returns the net open bracket depth of the accumulated lookahead lines,
|
|
147
|
+
* considering only decorator-initiated lines as bracket-tracked context.
|
|
148
|
+
* A value > 0 means we are still inside a multi-line decorator argument list
|
|
149
|
+
* and should keep accumulating lines before attempting name extraction.
|
|
150
|
+
*/
|
|
151
|
+
function pendingBracketDepth(lines) {
|
|
152
|
+
let depth = 0;
|
|
153
|
+
for (const line of lines) {
|
|
154
|
+
const t = line.trim();
|
|
155
|
+
if (depth > 0 || t.startsWith('@') || t.startsWith('[')) {
|
|
156
|
+
for (const ch of t) {
|
|
157
|
+
if (ch === '(' || ch === '[')
|
|
158
|
+
depth++;
|
|
159
|
+
else if (ch === ')' || ch === ']')
|
|
160
|
+
depth--;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return Math.max(0, depth);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Extract the test name from the lines immediately following an annotation.
|
|
168
|
+
* Tries language-specific patterns in priority order, then a generic fallback.
|
|
169
|
+
* Returns the extracted name, or undefined if no pattern matched.
|
|
170
|
+
*/
|
|
171
|
+
function extractTestName(lines) {
|
|
172
|
+
var _a, _b;
|
|
173
|
+
let inBlockComment = false;
|
|
174
|
+
let inRubyBlockComment = false;
|
|
175
|
+
// Tracks unmatched open brackets inside a decorator's argument list so that
|
|
176
|
+
// multi-line decorators like @pytest.mark.parametrize("a", [\n (1,2),\n])
|
|
177
|
+
// are fully skipped before we attempt to extract the function name (TMD-2044).
|
|
178
|
+
let decoratorDepth = 0;
|
|
179
|
+
for (let i = 0; i < lines.length; i++) {
|
|
180
|
+
const line = lines[i];
|
|
181
|
+
const t = line.trim();
|
|
182
|
+
// Track C#/Java/JS /* ... */ multi-line block comments
|
|
183
|
+
if (inBlockComment) {
|
|
184
|
+
if (t.includes('*/'))
|
|
185
|
+
inBlockComment = false;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (t.startsWith('/*') && !t.includes('*/')) {
|
|
189
|
+
inBlockComment = true;
|
|
190
|
+
// fall through to isDecoratorLine which also skips the opening line
|
|
191
|
+
}
|
|
192
|
+
// Track Ruby =begin...=end block comments
|
|
193
|
+
if (t === '=begin') {
|
|
194
|
+
inRubyBlockComment = true;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (inRubyBlockComment) {
|
|
198
|
+
if (t === '=end')
|
|
199
|
+
inRubyBlockComment = false;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
// If inside a multi-line decorator argument list, keep skipping until brackets balance
|
|
203
|
+
if (decoratorDepth > 0) {
|
|
204
|
+
for (const ch of t) {
|
|
205
|
+
if (ch === '(' || ch === '[')
|
|
206
|
+
decoratorDepth++;
|
|
207
|
+
else if (ch === ')' || ch === ']')
|
|
208
|
+
decoratorDepth--;
|
|
209
|
+
}
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (isDecoratorLine(line)) {
|
|
213
|
+
// Only count brackets on actual decorator/attribute lines — not on
|
|
214
|
+
// comments, blank lines, or Javadoc/block-comment lines, which may
|
|
215
|
+
// contain unmatched brackets (e.g. `# Returns list of (valid, invalid)`)
|
|
216
|
+
// that would corrupt the depth count and cause real code lines to be skipped.
|
|
217
|
+
if (t.startsWith('@') || t.startsWith('[')) {
|
|
218
|
+
for (const ch of t) {
|
|
219
|
+
if (ch === '(' || ch === '[')
|
|
220
|
+
decoratorDepth++;
|
|
221
|
+
else if (ch === ')' || ch === ']')
|
|
222
|
+
decoratorDepth--;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
// JS/TS: it/test/describe with a restricted set of known dot-modifiers (skip, only,
|
|
228
|
+
// fixme, fail, parallel, serial). Using a closed list instead of (?:\.[a-z]+)? prevents
|
|
229
|
+
// false positives on non-test helpers like test.step('label', fn) or test.use({...}).
|
|
230
|
+
// All patterns also require a comma after the name to avoid matching bare fn('string')
|
|
231
|
+
// calls that happen to follow an annotation.
|
|
232
|
+
// context() and specify() are Cypress aliases for describe() and it() respectively
|
|
233
|
+
// (TMD-2124) and are treated identically for name extraction purposes.
|
|
234
|
+
// Shape A of test.each: test.each(data)('name', fn) — greedy .* skips all data,
|
|
235
|
+
// then backtracking finds the )( boundary before the test name string.
|
|
236
|
+
// The fallback's ^ anchor prevents method-chain calls like todoPage.fill('selector')
|
|
237
|
+
// from matching; only the known test modifiers skip/only/fixme/fail/parallel/serial/concurrent are allowed after the dot.
|
|
238
|
+
const jsMatch = (_b = (_a = t.match(/\b(?:it|test|describe|context|specify)(?:\.(?:skip|only|fixme|fail|parallel|serial|concurrent))?\s*\(\s*['"`]([^'"`]+)['"`]\s*,/)) !== null && _a !== void 0 ? _a : t.match(/\b(?:it|test)\.each.*\)\s*\(\s*['"`]([^'"`]+)['"`]\s*,/)) !== null && _b !== void 0 ? _b : t.match(/^[A-Za-z_$][A-Za-z0-9_$]*(?:\.(?:skip|only|fixme|fail|concurrent))?\s*\(\s*['"`]([^'"`]+)['"`]\s*,/);
|
|
239
|
+
if (jsMatch)
|
|
240
|
+
return jsMatch[1];
|
|
241
|
+
// JS/TS: test.each / it.each tagged template literal form (Shape B).
|
|
242
|
+
// The test name is on the closing backtick line several lines below the annotation:
|
|
243
|
+
// test.each` ← first real line after annotation
|
|
244
|
+
// a | b
|
|
245
|
+
// 1 | 2
|
|
246
|
+
// `('name', fn) ← name extracted from here
|
|
247
|
+
// Comma is required after the name — a bare `('name')` without a callback is not a test.
|
|
248
|
+
if (/\b(?:it|test)\.each\s*`/.test(t)) {
|
|
249
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
250
|
+
const m = lines[j].trim().match(/^`\s*\(\s*['"`]([^'"`]+)['"`]\s*,/);
|
|
251
|
+
if (m)
|
|
252
|
+
return m[1];
|
|
253
|
+
}
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
// JS/TS: test.each / it.each Shape A multi-line form — when the data argument
|
|
257
|
+
// and the test name call are split across lines:
|
|
258
|
+
// test.each(['a', 'b'])( ← first line after annotation (no name yet)
|
|
259
|
+
// 'test name %s', ← name is here
|
|
260
|
+
// (v) => { ... }
|
|
261
|
+
// or with multi-line data:
|
|
262
|
+
// test.each([ ← data spans several lines
|
|
263
|
+
// ['a', true], ['b', false]
|
|
264
|
+
// ])('test name %s', ...) ← name on the closing-bracket line
|
|
265
|
+
if (/\b(?:it|test)\.each\b/.test(t) && !t.includes('`')) {
|
|
266
|
+
let depth = 0;
|
|
267
|
+
for (const ch of t) {
|
|
268
|
+
if (ch === '(' || ch === '[')
|
|
269
|
+
depth++;
|
|
270
|
+
else if (ch === ')' || ch === ']')
|
|
271
|
+
depth--;
|
|
272
|
+
}
|
|
273
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
274
|
+
const next = lines[j].trim();
|
|
275
|
+
if (!next)
|
|
276
|
+
continue;
|
|
277
|
+
// ')(' pattern on a single line: data closes and name call opens
|
|
278
|
+
const m1 = next.match(/\)\s*\(\s*['"`]([^'"`]+)['"`]\s*,/);
|
|
279
|
+
if (m1)
|
|
280
|
+
return m1[1];
|
|
281
|
+
// depth 1 means we're inside the name call parens: look for string arg
|
|
282
|
+
if (depth <= 1) {
|
|
283
|
+
const m2 = next.match(/^\(?['"`]([^'"`]+)['"`]\s*,/);
|
|
284
|
+
if (m2)
|
|
285
|
+
return m2[1];
|
|
286
|
+
}
|
|
287
|
+
for (const ch of next) {
|
|
288
|
+
if (ch === '(' || ch === '[')
|
|
289
|
+
depth++;
|
|
290
|
+
else if (ch === ')' || ch === ']')
|
|
291
|
+
depth--;
|
|
292
|
+
}
|
|
293
|
+
if (depth < 0)
|
|
294
|
+
break;
|
|
295
|
+
}
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
// Ruby: it/fit/xit/pending/example 'name' / describe/fdescribe 'name' / context/specify 'name'
|
|
299
|
+
const rubyMatch = t.match(/\b(?:it|fit|xit|pending|describe|fdescribe|context|specify|example)\s+['"]([^'"]+)['"]/);
|
|
300
|
+
if (rubyMatch)
|
|
301
|
+
return rubyMatch[1];
|
|
302
|
+
// Python: def name( / async def name(
|
|
303
|
+
const pythonMatch = t.match(/\bdef\s+(\w+)\s*\(/);
|
|
304
|
+
if (pythonMatch)
|
|
305
|
+
return pythonMatch[1];
|
|
306
|
+
// Kotlin: fun `test description with spaces`()
|
|
307
|
+
const kotlinBacktick = t.match(/\bfun\s+`([^`]+)`/);
|
|
308
|
+
if (kotlinBacktick)
|
|
309
|
+
return kotlinBacktick[1];
|
|
310
|
+
// Cucumber/Gherkin: Scenario: / Scenario Outline: / Scenario Template: /
|
|
311
|
+
// Example: (Gherkin 6+) / and i18n equivalents (Scénario:, シナリオ:, etc.)
|
|
312
|
+
// Matches any "Keyword: name" line; strips trailing inline comment.
|
|
313
|
+
// Non-scenario structural keywords (Feature, Background, Examples, Rule) are skipped.
|
|
314
|
+
const gherkinMatch = t.match(/^([\w\s\u00C0-\uFFFF]+?):\s+(.+)$/u);
|
|
315
|
+
if (gherkinMatch) {
|
|
316
|
+
const keyword = gherkinMatch[1].trim().toLowerCase();
|
|
317
|
+
if (!GHERKIN_NON_SCENARIO_KEYWORDS.has(keyword)) {
|
|
318
|
+
const name = gherkinMatch[2].trim().replace(/\s+#.*$/, '').trim();
|
|
319
|
+
if (name)
|
|
320
|
+
return name;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
// Generic: last identifier before ( — covers Java, C#, Go, PHP, Kotlin, Rust, Swift, etc.
|
|
324
|
+
// matchAll finds every word( occurrence; we take the last non-keyword one.
|
|
325
|
+
const allMatches = [...t.matchAll(/\b(\w+)\s*\(/g)];
|
|
326
|
+
if (allMatches.length > 0) {
|
|
327
|
+
const skip = new Set([
|
|
328
|
+
'if', 'for', 'while', 'switch', 'catch', 'return',
|
|
329
|
+
'new', 'throw', 'import', 'export', 'class', 'interface',
|
|
330
|
+
'enum', 'struct', 'impl', 'super', 'this', 'typeof', 'instanceof',
|
|
331
|
+
'function',
|
|
332
|
+
]);
|
|
333
|
+
for (let i = allMatches.length - 1; i >= 0; i--) {
|
|
334
|
+
const m = allMatches[i];
|
|
335
|
+
const name = m[1];
|
|
336
|
+
// Skip names that are attribute/subscript calls, e.g. [Values(1, 2, 3)]
|
|
337
|
+
const precededByBracket = m.index > 0 && t[m.index - 1] === '[';
|
|
338
|
+
if (name.length >= 3 && !skip.has(name) && !precededByBracket)
|
|
339
|
+
return name;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
break; // stop after the first non-decorator line
|
|
343
|
+
}
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Normalize a test name for matching:
|
|
348
|
+
* - strips parameterized suffixes: test_login[admin] → test_login
|
|
349
|
+
* - strips JUnit 5 typed suffixes: testLogin(String)[1] → testLogin
|
|
350
|
+
* - strips TestNG / NUnit row suffixes: login (row 1) → login
|
|
351
|
+
* - strips JUnit 5 / Kotlin Surefire empty parens: testLogin() → testLogin
|
|
352
|
+
* - strips PHPUnit data provider suffixes: test with data set "x" → test
|
|
353
|
+
* - strips Go subtest paths: TestLogin/admin → TestLogin
|
|
354
|
+
* - lowercases and trims
|
|
355
|
+
*/
|
|
356
|
+
function normalizeTestName(name) {
|
|
357
|
+
return name
|
|
358
|
+
.replace(/\(.*?\)\[\d+\]$/, '') // (Type)[N] — JUnit 5 parameterized
|
|
359
|
+
.replace(/\[.*?\]$/, '') // [param] — pytest / JUnit 5
|
|
360
|
+
.replace(/\s*\(\s*row\s*\d+\s*\)$/i, '') // (row N) — NUnit / TestNG
|
|
361
|
+
.replace(/\s*\([^)]*,[^)]*\)$/, '') // (a, b, ...) — Cucumber Scenario Outline
|
|
362
|
+
.replace(/([A-Za-z0-9_])\(\s*\)$/, '$1') // () — JUnit 5 / Kotlin Surefire (only after word char, not special chars like &*)
|
|
363
|
+
.replace(/\s+with data set\s+.*$/i, '') // with data set ... — PHPUnit data providers
|
|
364
|
+
.replace(/^(Test\w+)\/.+$/, '$1') // TestFoo/subtest — Go subtests
|
|
365
|
+
.trim()
|
|
366
|
+
.toLowerCase();
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Return the last segment of a describe-prefixed test name.
|
|
370
|
+
* "Authentication > should login" → "should login"
|
|
371
|
+
* Returns undefined if no separator is found.
|
|
372
|
+
*/
|
|
373
|
+
function getLastSegment(name) {
|
|
374
|
+
const separators = [' > ', ' / ', ' :: ', ' — ', ' - '];
|
|
375
|
+
for (const sep of separators) {
|
|
376
|
+
const idx = name.lastIndexOf(sep);
|
|
377
|
+
if (idx >= 0)
|
|
378
|
+
return name.slice(idx + sep.length).trim();
|
|
379
|
+
}
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Scans source files for @TestmoId annotations, extracts the name of the
|
|
384
|
+
* test immediately following each annotation, and builds a name-based index
|
|
385
|
+
* for matching against test results.
|
|
386
|
+
*
|
|
387
|
+
* Matching requires only test.name (always present in results). The optional
|
|
388
|
+
* test.file attribute is used to disambiguate when the same test name appears
|
|
389
|
+
* in multiple files.
|
|
390
|
+
*/
|
|
391
|
+
class AnnotationScanner {
|
|
392
|
+
constructor(config) {
|
|
393
|
+
var _a, _b;
|
|
394
|
+
this.annotationsByName = new Map();
|
|
395
|
+
this.annotationsByPattern = [];
|
|
396
|
+
this.warnedAmbiguous = new Set();
|
|
397
|
+
this.basePath = path.resolve((_a = config.base_path) !== null && _a !== void 0 ? _a : process.cwd());
|
|
398
|
+
const rawPatterns = ((_b = config.patterns) === null || _b === void 0 ? void 0 : _b.length) ? config.patterns : DEFAULT_PATTERNS;
|
|
399
|
+
this.compiledPatterns = [];
|
|
400
|
+
for (const raw of rawPatterns) {
|
|
401
|
+
if (!isSafePattern(raw)) {
|
|
402
|
+
logger_1.logger.warn(`Skipping unsafe annotation pattern (potential ReDoS): ${raw}`);
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
this.compiledPatterns.push(new RegExp(raw, 'g'));
|
|
407
|
+
}
|
|
408
|
+
catch (e) {
|
|
409
|
+
throw new errors_1.PatternError(raw, e.message);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Scan all files matching the given glob patterns and extract annotations.
|
|
415
|
+
*/
|
|
416
|
+
async scan(globPatterns) {
|
|
417
|
+
if (!globPatterns.length)
|
|
418
|
+
return;
|
|
419
|
+
const files = await (0, glob_1.glob)(globPatterns, {
|
|
420
|
+
cwd: this.basePath,
|
|
421
|
+
nodir: true,
|
|
422
|
+
absolute: false,
|
|
423
|
+
});
|
|
424
|
+
if (files.length === 0) {
|
|
425
|
+
logger_1.logger.warn(`No files matched annotation scanning paths: ${globPatterns.join(', ')}`);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
logger_1.logger.debug(`Scanning ${files.length} file(s) for annotations`);
|
|
429
|
+
for (const relFile of files) {
|
|
430
|
+
const absPath = path.join(this.basePath, relFile);
|
|
431
|
+
await this.parseFile(relFile, absPath);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Stream a single file line-by-line, extracting annotations and the test
|
|
436
|
+
* name immediately following each one using a small lookahead window.
|
|
437
|
+
*/
|
|
438
|
+
async parseFile(relFile, absPath) {
|
|
439
|
+
var _a;
|
|
440
|
+
try {
|
|
441
|
+
const stat = fs.statSync(absPath);
|
|
442
|
+
if (stat.size > LARGE_FILE_WARN_BYTES) {
|
|
443
|
+
logger_1.logger.warn(`Large file (${Math.round(stat.size / 1024 / 1024)}MB) during annotation scan: ${relFile}`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
catch (_b) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const rawAnnotations = [];
|
|
450
|
+
let lineNum = 0;
|
|
451
|
+
let pendingAnnotation;
|
|
452
|
+
let pendingLines = [];
|
|
453
|
+
// Running bracket depth for the current lookahead window.
|
|
454
|
+
// Tracks unclosed ( [ from decorator lines so we know whether we are still
|
|
455
|
+
// inside a multi-line decorator argument list when deciding to flush.
|
|
456
|
+
let pendingDecoratorDepth = 0;
|
|
457
|
+
const resolvePending = () => {
|
|
458
|
+
var _a, _b;
|
|
459
|
+
if (!pendingAnnotation)
|
|
460
|
+
return;
|
|
461
|
+
const extractedName = (_b = (_a = extractTestName(pendingLines)) === null || _a === void 0 ? void 0 : _a.trim().toLowerCase()) !== null && _b !== void 0 ? _b : '';
|
|
462
|
+
for (const caseId of pendingAnnotation.caseIds) {
|
|
463
|
+
rawAnnotations.push({ line: pendingAnnotation.line, caseId, extractedName });
|
|
464
|
+
}
|
|
465
|
+
pendingAnnotation = undefined;
|
|
466
|
+
pendingLines = [];
|
|
467
|
+
pendingDecoratorDepth = 0;
|
|
468
|
+
};
|
|
469
|
+
await new Promise((resolve) => {
|
|
470
|
+
let stream;
|
|
471
|
+
try {
|
|
472
|
+
stream = fs.createReadStream(absPath, { encoding: 'utf8' });
|
|
473
|
+
}
|
|
474
|
+
catch (_a) {
|
|
475
|
+
resolve();
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
479
|
+
stream.on('error', () => { rl.close(); resolve(); });
|
|
480
|
+
rl.on('line', (line) => {
|
|
481
|
+
lineNum++;
|
|
482
|
+
// Check for a new annotation on this line first
|
|
483
|
+
const newCaseIds = [];
|
|
484
|
+
for (const pattern of this.compiledPatterns) {
|
|
485
|
+
pattern.lastIndex = 0;
|
|
486
|
+
let match;
|
|
487
|
+
while ((match = pattern.exec(line)) !== null) {
|
|
488
|
+
const caseId = parseInt(match[1], 10);
|
|
489
|
+
if (!isNaN(caseId) && caseId >= 1) {
|
|
490
|
+
newCaseIds.push(caseId);
|
|
491
|
+
// Also extract comma-separated IDs that follow this match
|
|
492
|
+
// e.g. @TestmoId:100,200,300
|
|
493
|
+
let pos = match.index + match[0].length;
|
|
494
|
+
while (pos < line.length) {
|
|
495
|
+
const extra = /^,\s*(\d+)/.exec(line.slice(pos));
|
|
496
|
+
if (!extra)
|
|
497
|
+
break;
|
|
498
|
+
const extraId = parseInt(extra[1], 10);
|
|
499
|
+
if (!isNaN(extraId) && extraId >= 1)
|
|
500
|
+
newCaseIds.push(extraId);
|
|
501
|
+
pos += extra[0].length;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (newCaseIds.length > 0) {
|
|
507
|
+
if (pendingAnnotation && pendingLines.length === 0) {
|
|
508
|
+
// Consecutive annotation lines before a test — accumulate all case IDs
|
|
509
|
+
for (const id of newCaseIds)
|
|
510
|
+
pendingAnnotation.caseIds.push(id);
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
// New annotation group: commit any pending and start fresh
|
|
514
|
+
resolvePending();
|
|
515
|
+
pendingAnnotation = { line: lineNum, caseIds: newCaseIds };
|
|
516
|
+
pendingLines = [];
|
|
517
|
+
pendingDecoratorDepth = 0;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
else if (pendingAnnotation) {
|
|
521
|
+
// Collect lookahead lines for name extraction.
|
|
522
|
+
// Suppress the NAME_SCAN_LINES cap while inside a multi-line
|
|
523
|
+
// decorator argument list (e.g. @pytest.mark.parametrize with
|
|
524
|
+
// many rows); fall back to MAX_SCAN_LINES as an absolute ceiling.
|
|
525
|
+
pendingLines.push(line);
|
|
526
|
+
// Maintain running bracket depth for decorator lines so we can
|
|
527
|
+
// detect multi-line argument lists without rescanning every time.
|
|
528
|
+
const prevDepth = pendingDecoratorDepth;
|
|
529
|
+
const t = line.trim();
|
|
530
|
+
if (pendingDecoratorDepth > 0 || t.startsWith('@') || t.startsWith('[')) {
|
|
531
|
+
for (const ch of t) {
|
|
532
|
+
if (ch === '(' || ch === '[')
|
|
533
|
+
pendingDecoratorDepth++;
|
|
534
|
+
else if (ch === ')' || ch === ']')
|
|
535
|
+
pendingDecoratorDepth--;
|
|
536
|
+
}
|
|
537
|
+
if (pendingDecoratorDepth < 0)
|
|
538
|
+
pendingDecoratorDepth = 0;
|
|
539
|
+
}
|
|
540
|
+
// justClosedDecorator: depth just reached 0 on this line (e.g. the
|
|
541
|
+
// "])" closing line). The def is on the NEXT line — defer the flush.
|
|
542
|
+
const justClosedDecorator = prevDepth > 0 && pendingDecoratorDepth === 0;
|
|
543
|
+
if (pendingLines.length >= MAX_SCAN_LINES ||
|
|
544
|
+
(pendingLines.length >= NAME_SCAN_LINES &&
|
|
545
|
+
pendingDecoratorDepth === 0 &&
|
|
546
|
+
!justClosedDecorator)) {
|
|
547
|
+
resolvePending();
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
rl.on('close', () => {
|
|
552
|
+
resolvePending();
|
|
553
|
+
resolve();
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
// Index by extracted name
|
|
557
|
+
const normalized = relFile.replace(/\\/g, '/');
|
|
558
|
+
for (const ann of rawAnnotations) {
|
|
559
|
+
if (!ann.extractedName) {
|
|
560
|
+
logger_1.logger.debug(`No test name found after annotation at ${normalized}:${ann.line} ` +
|
|
561
|
+
`(case ${ann.caseId}) — annotation will not be matched`);
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
const entry = {
|
|
565
|
+
extractedName: ann.extractedName,
|
|
566
|
+
file: normalized,
|
|
567
|
+
caseId: ann.caseId,
|
|
568
|
+
line: ann.line,
|
|
569
|
+
};
|
|
570
|
+
const existing = (_a = this.annotationsByName.get(ann.extractedName)) !== null && _a !== void 0 ? _a : [];
|
|
571
|
+
existing.push(entry);
|
|
572
|
+
this.annotationsByName.set(ann.extractedName, existing);
|
|
573
|
+
// Also index by interpolation pattern for Ruby names like "grants access to #{role}"
|
|
574
|
+
const interpolationPattern = toInterpolationPattern(ann.extractedName);
|
|
575
|
+
if (interpolationPattern) {
|
|
576
|
+
this.annotationsByPattern.push({ pattern: interpolationPattern, annotation: entry });
|
|
577
|
+
}
|
|
578
|
+
// Index by Jest printf pattern for names like "should grant access to %s role"
|
|
579
|
+
const jestParamPattern = toJestParamPattern(ann.extractedName);
|
|
580
|
+
if (jestParamPattern) {
|
|
581
|
+
this.annotationsByPattern.push({ pattern: jestParamPattern, annotation: entry });
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Returns true if the test result's folder string corresponds to the
|
|
587
|
+
* annotation's source file. Used as a fallback when testFile is absent
|
|
588
|
+
* or insufficient for disambiguation.
|
|
589
|
+
*
|
|
590
|
+
* Strategy: use only the last segment of the folder (e.g. "UserManagementTests"
|
|
591
|
+
* from "com.example.tests.user.UserManagementTests") and check whether it is
|
|
592
|
+
* contained in the file's basename (without extension). Using intermediate
|
|
593
|
+
* segments would cause generic ones like "tests" to match every file.
|
|
594
|
+
*/
|
|
595
|
+
static folderMatchesFile(folder, filePath) {
|
|
596
|
+
var _a;
|
|
597
|
+
if (!folder)
|
|
598
|
+
return false;
|
|
599
|
+
const baseName = path.basename(filePath).replace(/\.[^.]+$/, '').toLowerCase();
|
|
600
|
+
if (baseName.length < 3)
|
|
601
|
+
return false;
|
|
602
|
+
// Use only the last segment of the folder (handles FQCN like
|
|
603
|
+
// "com.example.tests.user.UserManagementTests" → "UserManagementTests").
|
|
604
|
+
// Using all segments would cause intermediate ones like "tests" to
|
|
605
|
+
// match every file whose basename ends in "Tests".
|
|
606
|
+
const parts = folder.split(/[./\\]+/).filter(Boolean);
|
|
607
|
+
const lastSegment = ((_a = parts[parts.length - 1]) !== null && _a !== void 0 ? _a : '').toLowerCase();
|
|
608
|
+
if (lastSegment.length < 5)
|
|
609
|
+
return false;
|
|
610
|
+
return baseName.includes(lastSegment) || lastSegment.includes(baseName);
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Find the repository case ID for a test given its name.
|
|
614
|
+
*
|
|
615
|
+
* Matching is performed against the name extracted from the source file
|
|
616
|
+
* immediately below each annotation. The optional testFile is used to
|
|
617
|
+
* disambiguate when two files contain annotations with the same name.
|
|
618
|
+
* When testFile is absent, testFolder is tried as a fallback by matching
|
|
619
|
+
* folder segments against source file basenames.
|
|
620
|
+
*
|
|
621
|
+
* @param testName Test name from the run result (always present)
|
|
622
|
+
* @param testFile Optional source file path (used for disambiguation)
|
|
623
|
+
* @param testFolder Optional test suite/class folder (fallback disambiguation)
|
|
624
|
+
* @returns The matched case ID, or undefined if no unambiguous match found
|
|
625
|
+
*/
|
|
626
|
+
findCaseId(testName, testFile, testFolder) {
|
|
627
|
+
const ids = this.findCaseIds(testName, testFile, testFolder);
|
|
628
|
+
return ids.length > 0 ? ids[0] : undefined;
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Returns all repository case IDs linked to a test via annotations.
|
|
632
|
+
* For multi-case annotations (multiple @TestmoId on one test), returns all IDs.
|
|
633
|
+
* Returns an empty array when no match is found or when the same test name
|
|
634
|
+
* appears in multiple files (true ambiguity).
|
|
635
|
+
*/
|
|
636
|
+
findCaseIds(testName, testFile, testFolder) {
|
|
637
|
+
const normalized = normalizeTestName(testName);
|
|
638
|
+
// Try exact name match
|
|
639
|
+
let results = this.resolveAllByName(normalized, testFile, testFolder);
|
|
640
|
+
if (results.length > 0)
|
|
641
|
+
return results;
|
|
642
|
+
// Try suffix match for describe-prefixed names ("Suite > test name")
|
|
643
|
+
const segment = getLastSegment(normalized);
|
|
644
|
+
if (segment) {
|
|
645
|
+
results = this.resolveAllByName(segment, testFile, testFolder);
|
|
646
|
+
if (results.length > 0)
|
|
647
|
+
return results;
|
|
648
|
+
}
|
|
649
|
+
// Try interpolation-pattern match for Ruby names like "grants access to #{role}"
|
|
650
|
+
const patResults = this.resolveAllByPattern(normalized, testFile, testFolder);
|
|
651
|
+
if (patResults.length > 0)
|
|
652
|
+
return patResults;
|
|
653
|
+
if (segment) {
|
|
654
|
+
const segResults = this.resolveAllByPattern(segment, testFile, testFolder);
|
|
655
|
+
if (segResults.length > 0)
|
|
656
|
+
return segResults;
|
|
657
|
+
}
|
|
658
|
+
return [];
|
|
659
|
+
}
|
|
660
|
+
resolveByPattern(name, testFile, testFolder) {
|
|
661
|
+
const ids = this.resolveAllByPattern(name, testFile, testFolder);
|
|
662
|
+
return ids.length > 0 ? ids[0] : undefined;
|
|
663
|
+
}
|
|
664
|
+
resolveAllByPattern(name, testFile, testFolder) {
|
|
665
|
+
const candidates = this.annotationsByPattern
|
|
666
|
+
.filter(({ pattern }) => pattern.test(name))
|
|
667
|
+
.map(({ annotation }) => annotation);
|
|
668
|
+
if (candidates.length === 0)
|
|
669
|
+
return [];
|
|
670
|
+
// Narrow by file/folder using the same fallback logic as resolveAllByName
|
|
671
|
+
let pool = candidates;
|
|
672
|
+
if (testFile) {
|
|
673
|
+
const normFile = testFile.replace(/\\/g, '/');
|
|
674
|
+
const fileMatches = candidates.filter(c => c.file === normFile ||
|
|
675
|
+
c.file.endsWith('/' + normFile) ||
|
|
676
|
+
normFile.endsWith('/' + c.file));
|
|
677
|
+
if (fileMatches.length > 0)
|
|
678
|
+
pool = fileMatches;
|
|
679
|
+
}
|
|
680
|
+
if (pool === candidates && testFolder) {
|
|
681
|
+
const folderMatches = candidates.filter(c => AnnotationScanner.folderMatchesFile(testFolder, c.file));
|
|
682
|
+
if (folderMatches.length > 0)
|
|
683
|
+
pool = folderMatches;
|
|
684
|
+
}
|
|
685
|
+
// All from same file → return all case IDs (de-duplicated)
|
|
686
|
+
const files = new Set(pool.map(c => c.file));
|
|
687
|
+
if (files.size === 1) {
|
|
688
|
+
return [...new Set(pool.map(c => c.caseId))];
|
|
689
|
+
}
|
|
690
|
+
// Still ambiguous — warn once per name
|
|
691
|
+
if (!this.warnedAmbiguous.has(name)) {
|
|
692
|
+
this.warnedAmbiguous.add(name);
|
|
693
|
+
const detail = pool.map(c => `${c.file}→case ${c.caseId}`).join(', ');
|
|
694
|
+
logger_1.logger.warn(`Annotation ambiguous for "${name}": multiple interpolation patterns matched ` +
|
|
695
|
+
`(${detail}). Include the file attribute in test results, or use ` +
|
|
696
|
+
`config_mappings to resolve.`);
|
|
697
|
+
}
|
|
698
|
+
return [];
|
|
699
|
+
}
|
|
700
|
+
resolveAllByName(name, testFile, testFolder) {
|
|
701
|
+
const candidates = this.annotationsByName.get(name);
|
|
702
|
+
if (!candidates || candidates.length === 0)
|
|
703
|
+
return [];
|
|
704
|
+
// Narrow to best-matching file if disambiguation hints are available.
|
|
705
|
+
// Try testFile first; fall through to testFolder if testFile matched nothing
|
|
706
|
+
// (preserves the disambiguation behaviour of the prior resolveByName).
|
|
707
|
+
let pool = candidates;
|
|
708
|
+
if (testFile) {
|
|
709
|
+
const normFile = testFile.replace(/\\/g, '/');
|
|
710
|
+
const fileMatches = candidates.filter(c => c.file === normFile ||
|
|
711
|
+
c.file.endsWith('/' + normFile) ||
|
|
712
|
+
normFile.endsWith('/' + c.file));
|
|
713
|
+
if (fileMatches.length > 0)
|
|
714
|
+
pool = fileMatches;
|
|
715
|
+
}
|
|
716
|
+
if (pool === candidates && testFolder) {
|
|
717
|
+
const folderMatches = candidates.filter(c => AnnotationScanner.folderMatchesFile(testFolder, c.file));
|
|
718
|
+
if (folderMatches.length > 0)
|
|
719
|
+
pool = folderMatches;
|
|
720
|
+
}
|
|
721
|
+
// All from same file → unambiguous single or multi-case annotation
|
|
722
|
+
const files = new Set(pool.map(c => c.file));
|
|
723
|
+
if (files.size === 1) {
|
|
724
|
+
// De-duplicate in case the same ID was annotated more than once
|
|
725
|
+
return [...new Set(pool.map(c => c.caseId))];
|
|
726
|
+
}
|
|
727
|
+
// Multiple files — true ambiguity; warn once and return nothing
|
|
728
|
+
if (!this.warnedAmbiguous.has(name)) {
|
|
729
|
+
this.warnedAmbiguous.add(name);
|
|
730
|
+
const detail = pool.map(c => `${c.file}→case ${c.caseId}`).join(', ');
|
|
731
|
+
logger_1.logger.warn(`Annotation ambiguous for "${name}": same name found in multiple files ` +
|
|
732
|
+
`(${detail}). Include the file attribute in test results, or use ` +
|
|
733
|
+
`config_mappings to resolve.`);
|
|
734
|
+
}
|
|
735
|
+
return [];
|
|
736
|
+
}
|
|
737
|
+
/** Total number of successfully named annotations indexed. */
|
|
738
|
+
getNamedAnnotationCount() {
|
|
739
|
+
let count = 0;
|
|
740
|
+
for (const anns of this.annotationsByName.values())
|
|
741
|
+
count += anns.length;
|
|
742
|
+
return count;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
exports.AnnotationScanner = AnnotationScanner;
|