@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,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;