@taodigital/agents 0.0.0-stage → 1.2.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.
Files changed (76) hide show
  1. package/README.md +99 -2
  2. package/dist/api-test-agent.d.ts +126 -0
  3. package/dist/api-test-agent.js +267 -0
  4. package/dist/artifact-intel-agent.d.ts +11 -0
  5. package/dist/artifact-intel-agent.js +152 -0
  6. package/dist/coverage-gap-agent.d.ts +25 -0
  7. package/dist/coverage-gap-agent.js +269 -0
  8. package/dist/coverage-map-agent.d.ts +15 -0
  9. package/dist/coverage-map-agent.js +255 -0
  10. package/dist/crawl-diff-agent.d.ts +27 -0
  11. package/dist/crawl-diff-agent.js +412 -0
  12. package/dist/data-agent.d.ts +101 -0
  13. package/dist/data-agent.js +2284 -0
  14. package/dist/document-agent.d.ts +10 -0
  15. package/dist/document-agent.js +32 -0
  16. package/dist/enhancement-agent.d.ts +48 -0
  17. package/dist/enhancement-agent.js +342 -0
  18. package/dist/field-detector-agent.d.ts +35 -0
  19. package/dist/field-detector-agent.js +220 -0
  20. package/dist/flow-agent.d.ts +130 -0
  21. package/dist/flow-agent.js +169 -0
  22. package/dist/github-agent.d.ts +18 -0
  23. package/dist/github-agent.js +39 -0
  24. package/dist/github-write-agent.d.ts +67 -0
  25. package/dist/github-write-agent.js +249 -0
  26. package/dist/grouping-agent.d.ts +18 -0
  27. package/dist/grouping-agent.js +338 -0
  28. package/dist/healing-agent.d.ts +73 -0
  29. package/dist/healing-agent.js +137 -0
  30. package/dist/index.d.ts +36 -0
  31. package/dist/index.js +67 -0
  32. package/dist/integration-test-agent.d.ts +118 -0
  33. package/dist/integration-test-agent.js +165 -0
  34. package/dist/label-assignment-agent.d.ts +15 -0
  35. package/dist/label-assignment-agent.js +97 -0
  36. package/dist/llm-config.d.ts +38 -0
  37. package/dist/llm-config.js +287 -0
  38. package/dist/llm-mock.d.ts +77 -0
  39. package/dist/llm-mock.js +192 -0
  40. package/dist/llm.d.ts +58 -0
  41. package/dist/llm.js +438 -0
  42. package/dist/nlp-agent.d.ts +208 -0
  43. package/dist/nlp-agent.js +340 -0
  44. package/dist/orchestrator.d.ts +285 -0
  45. package/dist/orchestrator.js +141 -0
  46. package/dist/plan-agent.d.ts +4 -0
  47. package/dist/plan-agent.js +200 -0
  48. package/dist/repo-scan-agent.d.ts +189 -0
  49. package/dist/repo-scan-agent.js +2032 -0
  50. package/dist/requirements-agent.d.ts +59 -0
  51. package/dist/requirements-agent.js +641 -0
  52. package/dist/scenario-agent.d.ts +20 -0
  53. package/dist/scenario-agent.js +123 -0
  54. package/dist/script-agent.d.ts +447 -0
  55. package/dist/script-agent.js +11813 -0
  56. package/dist/security-test-agent.d.ts +111 -0
  57. package/dist/security-test-agent.js +141 -0
  58. package/dist/skill-file-agent.d.ts +264 -0
  59. package/dist/skill-file-agent.js +3470 -0
  60. package/dist/step-coverage.d.ts +46 -0
  61. package/dist/step-coverage.js +288 -0
  62. package/dist/step-utils.d.ts +2 -0
  63. package/dist/step-utils.js +62 -0
  64. package/dist/test-case-agent.d.ts +206 -0
  65. package/dist/test-case-agent.js +2141 -0
  66. package/dist/test-import-agent.d.ts +148 -0
  67. package/dist/test-import-agent.js +3235 -0
  68. package/dist/test-script-agent.d.ts +16 -0
  69. package/dist/test-script-agent.js +48 -0
  70. package/dist/token-pricing.d.ts +14 -0
  71. package/dist/token-pricing.js +62 -0
  72. package/dist/types.d.ts +232 -0
  73. package/dist/types.js +7 -0
  74. package/dist/visual-agent.d.ts +114 -0
  75. package/dist/visual-agent.js +261 -0
  76. package/package.json +26 -4
@@ -0,0 +1,2032 @@
1
+ "use strict";
2
+ /**
3
+ * GitHub repo scanning agent for Framework Modernization.
4
+ *
5
+ * Handles: GitHub API calls (raw fetch), framework detection from config file paths,
6
+ * test ID extraction from test file content, and style extraction from sample files.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.validateGitHubRepo = validateGitHubRepo;
10
+ exports.fetchRepoTree = fetchRepoTree;
11
+ exports.fetchFileContent = fetchFileContent;
12
+ exports.detectFramework = detectFramework;
13
+ exports.resolveFixtureAliasesFromImports = resolveFixtureAliasesFromImports;
14
+ exports.extractTestIds = extractTestIds;
15
+ exports.extractStyleProfile = extractStyleProfile;
16
+ exports.deriveJavaBasePackage = deriveJavaBasePackage;
17
+ exports.buildPageObjectInventory = buildPageObjectInventory;
18
+ exports.formatPageObjectInventoryForPrompt = formatPageObjectInventoryForPrompt;
19
+ exports.deriveRepoTargetPath = deriveRepoTargetPath;
20
+ exports.parseFrameworkConfig = parseFrameworkConfig;
21
+ exports.detectConfigFilesToFetch = detectConfigFilesToFetch;
22
+ const core_1 = require("@taodigital/core");
23
+ const llm_js_1 = require("./llm.js");
24
+ const llm_config_js_1 = require("./llm-config.js");
25
+ const app_brain_1 = require("@taodigital/app-brain");
26
+ const GITHUB_API_BASE = process.env.GITHUB_API_BASE_URL ?? 'https://api.github.com';
27
+ // ---------------------------------------------------------------------------
28
+ // Test data / fixture / locators directory detection
29
+ // ---------------------------------------------------------------------------
30
+ // Ordered from most-specific to least-specific; detectDataDirs scores by index position.
31
+ const TEST_DATA_PATTERNS = [
32
+ /(?:^|\/)testdata\//i,
33
+ /(?:^|\/)test-data\//i,
34
+ /(?:^|\/)test_data\//i,
35
+ /(?:^|\/)src\/test\/resources\//i,
36
+ /(?:^|\/)test[-_]?resources\//i,
37
+ /(?:^|\/)mocks?\//i,
38
+ /(?:^|\/)stubs?\//i,
39
+ /(?:^|\/)seeds?\//i,
40
+ /(?:^|\/)factor(?:y|ies)\//i,
41
+ // src/data/ is a common Playwright repo convention — must score above the generic data/ catch-all
42
+ /(?:^|\/)src\/data\//i,
43
+ // __snapshots__/ is Jest auto-generated assertion artifacts, NOT test data
44
+ // data/ is last — too broad; scored low so testdata/ wins when both present
45
+ /(?:^|\/)data\//i,
46
+ ];
47
+ const FIXTURE_PATTERNS = [
48
+ // src/fixtures/ is a common Playwright repo convention where all test support lives under src/
49
+ // Must be index 0 to beat tests/fixtures/ which receives +5 bonus from the tests/ path scoring
50
+ /(?:^|\/)src\/fixtures?\//i,
51
+ /(?:^|\/)fixtures?\//i,
52
+ /(?:^|\/)fixture\//i,
53
+ /(?:^|\/)__fixtures__\//i,
54
+ /(?:^|\/)__mocks__\//i,
55
+ /(?:^|\/)spec\/fixtures?\//i,
56
+ /(?:^|\/)spec\/support\//i,
57
+ /(?:^|\/)src\/test\/resources\//i,
58
+ ];
59
+ // Only genuine locator/selector dirs — not generic `constants/`, `objects/` (DTOs), or `step_definitions/` (test code)
60
+ const LOCATOR_PATTERNS = [
61
+ /(?:^|\/)locators?\//i,
62
+ /(?:^|\/)selectors?\//i,
63
+ /(?:^|\/)elements?\//i,
64
+ /(?:^|\/)mapping\//i,
65
+ /(?:^|\/)objectmap\//i,
66
+ /(?:^|\/)po\//i,
67
+ ];
68
+ const UTILS_PATTERNS = [
69
+ /(?:^|\/)support\/utils?\//i,
70
+ /(?:^|\/)lib\/utils?\//i,
71
+ /(?:^|\/)utils?\//i,
72
+ /(?:^|\/)helpers?\//i,
73
+ /(?:^|\/)shared\//i,
74
+ /(?:^|\/)common\//i,
75
+ ];
76
+ const CONFIG_PATTERNS = [
77
+ /(?:^|\/)src\/config\//i,
78
+ /(?:^|\/)config\/(?!playwright|jest|cypress|webpack|babel|eslint|prettier|rollup|vite|tsconfig)\w/i,
79
+ /(?:^|\/)conf\//i,
80
+ /(?:^|\/)settings\//i,
81
+ ];
82
+ /**
83
+ * Scan actual repo file paths to detect test data, fixture, and locators directory names.
84
+ * Returns first matching directory prefix for each, or null if not found.
85
+ * Heuristic runs BEFORE LLM so detected values can be passed as authoritative context.
86
+ */
87
+ function detectDataDirs(paths) {
88
+ // Exclude node_modules and package manager caches — they produce false positives (e.g. lodash/data/)
89
+ const filtered = paths.filter((p) => !p.includes('node_modules/') && !p.startsWith('.yarn/') && !p.startsWith('.pnp'));
90
+ // Score-based: lower patternIndex = more specific = higher score; test-path bonus.
91
+ function extractDir(p, m) {
92
+ const seg = m[0].replace(/^\//, '');
93
+ const idx = p.indexOf(seg);
94
+ const dir = p.slice(0, idx + seg.length);
95
+ return dir.startsWith('/') ? dir.slice(1) : dir;
96
+ }
97
+ function bestMatch(patterns) {
98
+ let best = null;
99
+ for (const p of filtered) {
100
+ for (let i = 0; i < patterns.length; i++) {
101
+ const m = p.match(patterns[i]);
102
+ if (!m)
103
+ continue;
104
+ // Higher score = more specific pattern (lower index) + bonus if inside a test dir
105
+ const score = (patterns.length - i) * 10 + (/(?:^|\/)tests?\//i.test(p) ? 5 : 0);
106
+ if (!best || score > best.score) {
107
+ best = { dir: extractDir(p, m), score };
108
+ }
109
+ break; // one pattern match per path is enough
110
+ }
111
+ }
112
+ return best ? best.dir : null;
113
+ }
114
+ return {
115
+ testDataDir: bestMatch(TEST_DATA_PATTERNS),
116
+ fixtureDir: bestMatch(FIXTURE_PATTERNS),
117
+ locatorsDir: bestMatch(LOCATOR_PATTERNS),
118
+ utilsDir: bestMatch(UTILS_PATTERNS),
119
+ configDir: bestMatch(CONFIG_PATTERNS),
120
+ };
121
+ }
122
+ // ---------------------------------------------------------------------------
123
+ // GitHub API helpers
124
+ // ---------------------------------------------------------------------------
125
+ function githubHeaders(token) {
126
+ return {
127
+ Authorization: `Bearer ${token}`,
128
+ Accept: 'application/vnd.github+json',
129
+ 'X-GitHub-Api-Version': '2022-11-28',
130
+ };
131
+ }
132
+ async function validateGitHubRepo(token, repoFullName) {
133
+ try {
134
+ const res = await fetch(`${GITHUB_API_BASE}/repos/${repoFullName}`, {
135
+ headers: githubHeaders(token),
136
+ });
137
+ if (!res.ok) {
138
+ const body = await res.json().catch(() => ({}));
139
+ return {
140
+ ok: false,
141
+ defaultBranch: '',
142
+ private: false,
143
+ error: body?.message ?? `HTTP ${res.status}`,
144
+ };
145
+ }
146
+ const data = await res.json();
147
+ // For public repos, GET /repos/:name returns 200 even with an invalid/expired token.
148
+ // Validate the token itself by calling GET /user (auth-required endpoint).
149
+ if (!data.private) {
150
+ const userRes = await fetch(`${GITHUB_API_BASE}/user`, { headers: githubHeaders(token) });
151
+ if (!userRes.ok) {
152
+ const body = await userRes.json().catch(() => ({}));
153
+ return {
154
+ ok: false,
155
+ defaultBranch: '',
156
+ private: false,
157
+ error: body?.message ?? 'Token is invalid or expired',
158
+ };
159
+ }
160
+ }
161
+ return {
162
+ ok: true,
163
+ defaultBranch: data.default_branch ?? 'main',
164
+ private: data.private ?? false,
165
+ };
166
+ }
167
+ catch (e) {
168
+ return { ok: false, defaultBranch: '', private: false, error: e?.message ?? 'Network error' };
169
+ }
170
+ }
171
+ async function fetchRepoTree(token, repoFullName, branch = 'HEAD') {
172
+ let res;
173
+ try {
174
+ res = await fetch(`${GITHUB_API_BASE}/repos/${repoFullName}/git/trees/${branch}?recursive=1`, { headers: githubHeaders(token) });
175
+ }
176
+ catch (e) {
177
+ const cause = e?.cause?.message ?? e?.cause ?? '';
178
+ throw new Error(`GitHub API unreachable: ${e?.message ?? 'fetch failed'}${cause ? ` (${cause})` : ''}`);
179
+ }
180
+ if (!res.ok) {
181
+ const body = await res.json().catch(() => ({}));
182
+ const detail = body?.message ? `: ${body.message}` : '';
183
+ throw new Error(`GitHub API error: HTTP ${res.status}${detail}`);
184
+ }
185
+ const data = await res.json();
186
+ const paths = (data.tree ?? [])
187
+ .filter((item) => item.type === 'blob')
188
+ .map((item) => item.path);
189
+ return { paths, sha: data.sha ?? '', truncated: data.truncated ?? false };
190
+ }
191
+ async function fetchFileContent(token, repoFullName, filePath) {
192
+ const maxAttempts = 3;
193
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
194
+ try {
195
+ await new Promise((r) => setTimeout(r, 100 * attempt));
196
+ const res = await fetch(`${GITHUB_API_BASE}/repos/${repoFullName}/contents/${filePath}`, { headers: githubHeaders(token) });
197
+ if (res.status === 403 || res.status === 429) {
198
+ // Rate limited — check Retry-After or back off exponentially
199
+ const retryAfter = res.headers.get('Retry-After');
200
+ const delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : 1000 * attempt;
201
+ if (attempt < maxAttempts) {
202
+ await new Promise((r) => setTimeout(r, delay));
203
+ continue;
204
+ }
205
+ return null;
206
+ }
207
+ if (!res.ok)
208
+ return null;
209
+ const data = await res.json();
210
+ if (data.encoding === 'base64' && typeof data.content === 'string') {
211
+ return Buffer.from(data.content.replace(/\n/g, ''), 'base64').toString('utf-8');
212
+ }
213
+ return null;
214
+ }
215
+ catch {
216
+ if (attempt === maxAttempts)
217
+ return null;
218
+ }
219
+ }
220
+ return null;
221
+ }
222
+ const FRAMEWORK_SIGNATURES = [
223
+ {
224
+ configPatterns: [/playwright\.config\.ts$/i],
225
+ framework: 'playwright-ts',
226
+ language: 'typescript',
227
+ testGlob: '**/*.spec.ts',
228
+ confidence: 'HIGH',
229
+ },
230
+ {
231
+ configPatterns: [/playwright\.config\.js$/i],
232
+ framework: 'playwright-js',
233
+ language: 'javascript',
234
+ testGlob: '**/*.spec.js',
235
+ confidence: 'HIGH',
236
+ },
237
+ {
238
+ configPatterns: [/cypress\.config\.ts$/i],
239
+ framework: 'cypress-ts',
240
+ language: 'typescript',
241
+ testGlob: 'cypress/e2e/**/*.cy.ts',
242
+ confidence: 'HIGH',
243
+ },
244
+ {
245
+ configPatterns: [/cypress\.config\.js$/i],
246
+ framework: 'cypress-js',
247
+ language: 'javascript',
248
+ testGlob: 'cypress/e2e/**/*.cy.js',
249
+ confidence: 'HIGH',
250
+ },
251
+ {
252
+ configPatterns: [/jest\.config\.ts$/i],
253
+ framework: 'jest-ts',
254
+ language: 'typescript',
255
+ testGlob: '**/*.test.ts',
256
+ confidence: 'HIGH',
257
+ },
258
+ {
259
+ configPatterns: [/jest\.config\.js$/i],
260
+ framework: 'jest-js',
261
+ language: 'javascript',
262
+ testGlob: '**/*.test.js',
263
+ confidence: 'HIGH',
264
+ },
265
+ {
266
+ configPatterns: [/pytest\.ini$/i, /pyproject\.toml$/i, /setup\.cfg$/i, /conftest\.py$/i],
267
+ framework: 'pytest',
268
+ language: 'python',
269
+ testGlob: '**/*.py',
270
+ confidence: 'HIGH',
271
+ },
272
+ {
273
+ configPatterns: [/pom\.xml$/i],
274
+ framework: 'junit',
275
+ language: 'java',
276
+ testGlob: 'src/test/**/*.java',
277
+ confidence: 'HIGH',
278
+ },
279
+ {
280
+ configPatterns: [/build\.gradle(\.kts)?$/i],
281
+ framework: 'junit',
282
+ language: 'java',
283
+ testGlob: 'src/test/**/*.java',
284
+ confidence: 'HIGH',
285
+ },
286
+ {
287
+ configPatterns: [/testng\.xml$/i],
288
+ framework: 'junit', // TestNG uses JUnit-compatible runner; will upgrade to selenium-java
289
+ language: 'java',
290
+ testGlob: 'src/test/**/*.java',
291
+ confidence: 'HIGH',
292
+ },
293
+ {
294
+ configPatterns: [/wdio\.conf\.(js|ts)$/i],
295
+ framework: 'webdriverio',
296
+ language: 'javascript',
297
+ testGlob: 'test/specs/**/*.js',
298
+ confidence: 'HIGH',
299
+ },
300
+ {
301
+ configPatterns: [/vitest\.config\.(ts|js|mts|mjs)$/i],
302
+ framework: 'jest-ts',
303
+ language: 'typescript',
304
+ testGlob: '**/*.test.ts',
305
+ confidence: 'HIGH',
306
+ },
307
+ {
308
+ configPatterns: [/\.mocharc\.(js|json|yml|yaml|cjs|mjs)$/i],
309
+ framework: 'jest-js',
310
+ language: 'javascript',
311
+ testGlob: 'test/**/*.js',
312
+ confidence: 'MEDIUM',
313
+ },
314
+ {
315
+ // Nightwatch ≠ WebdriverIO but no dedicated support; map to closest equivalent
316
+ configPatterns: [/nightwatch\.conf\.(js|ts)$/i],
317
+ framework: 'webdriverio',
318
+ language: 'javascript',
319
+ testGlob: 'test/**/*.js',
320
+ confidence: 'LOW',
321
+ },
322
+ {
323
+ configPatterns: [/playwright\.config\.(py|cfg)$/i, /conftest\.py$/i],
324
+ framework: 'pytest',
325
+ language: 'python',
326
+ testGlob: '**/*.py',
327
+ confidence: 'MEDIUM',
328
+ },
329
+ {
330
+ configPatterns: [/behave\.ini$/i, /features\/.*\.feature$/i],
331
+ framework: 'cucumber',
332
+ language: 'python',
333
+ testGlob: 'features/**/*.feature',
334
+ confidence: 'HIGH',
335
+ },
336
+ {
337
+ configPatterns: [/\.feature$/i],
338
+ framework: 'cucumber',
339
+ language: 'javascript',
340
+ testGlob: '**/*.feature',
341
+ confidence: 'MEDIUM',
342
+ },
343
+ ];
344
+ function detectFramework(paths) {
345
+ const results = [];
346
+ const seen = new Set();
347
+ for (const sig of FRAMEWORK_SIGNATURES) {
348
+ for (const path of paths) {
349
+ for (const pattern of sig.configPatterns) {
350
+ if (pattern.test(path)) {
351
+ if (!seen.has(sig.framework)) {
352
+ seen.add(sig.framework);
353
+ results.push({
354
+ framework: sig.framework,
355
+ language: sig.language,
356
+ testGlob: sig.testGlob,
357
+ configPath: path,
358
+ confidence: sig.confidence,
359
+ });
360
+ }
361
+ break;
362
+ }
363
+ }
364
+ }
365
+ }
366
+ if (results.length === 0) {
367
+ results.push({
368
+ framework: 'unknown',
369
+ language: 'javascript',
370
+ testGlob: '**/*.test.*',
371
+ configPath: '',
372
+ confidence: 'LOW',
373
+ });
374
+ }
375
+ // Upgrade pytest → playwright-py when playwright appears in any repo path
376
+ // (playwright-python projects use pytest-playwright as the runner)
377
+ const hasPlaywrightPaths = paths.some(p => /playwright/i.test(p));
378
+ if (hasPlaywrightPaths) {
379
+ for (const r of results) {
380
+ if (r.framework === 'pytest') {
381
+ r.framework = 'playwright-py';
382
+ }
383
+ }
384
+ }
385
+ // Upgrade junit → playwright-java or selenium-java based on repo paths.
386
+ // pom.xml alone can't distinguish Selenium from Playwright Java;
387
+ // use presence of playwright-specific paths to decide.
388
+ for (const r of results) {
389
+ if (r.framework === 'junit') {
390
+ r.framework = hasPlaywrightPaths ? 'playwright-java' : 'selenium-java';
391
+ }
392
+ }
393
+ // Deterministic primary-framework ordering when multiple frameworks detected.
394
+ // FRAMEWORK_SIGNATURES order already encodes priority (playwright > cypress > jest > ...),
395
+ // so results array is already in priority order — but sort explicitly to be safe.
396
+ const FRAMEWORK_PRIORITY = {
397
+ 'playwright-ts': 0, 'playwright-js': 1, 'playwright-py': 2, 'playwright-java': 3,
398
+ 'cypress-ts': 4, 'cypress-js': 5,
399
+ 'jest-ts': 6, 'jest-js': 7,
400
+ 'webdriverio': 8,
401
+ 'pytest': 9, 'selenium-py': 10,
402
+ 'selenium-java': 11,
403
+ 'cucumber': 12, 'gherkin': 13,
404
+ 'unknown': 99,
405
+ };
406
+ results.sort((a, b) => (FRAMEWORK_PRIORITY[a.framework] ?? 50) - (FRAMEWORK_PRIORITY[b.framework] ?? 50));
407
+ return results;
408
+ }
409
+ // ---------------------------------------------------------------------------
410
+ // Test ID extraction
411
+ // ---------------------------------------------------------------------------
412
+ function normalizeName(name) {
413
+ return name
414
+ .toLowerCase()
415
+ .replace(/[^\w\s]/g, '')
416
+ .replace(/\s+/g, ' ')
417
+ .trim();
418
+ }
419
+ function isDynamic(name) {
420
+ return (name.includes('${') || // JS template literal: ${var}
421
+ name.includes('\\${') || // escaped: \${var}
422
+ name.includes('%') || // printf-style: %s %d %i %f %p
423
+ /\{[^}]+\}/.test(name) || // bare braces: {name} {index} {param} (JUnit/pytest)
424
+ /<[^>]+>/.test(name) // Gherkin Scenario Outline: <variable>
425
+ );
426
+ }
427
+ /** Strip string literals and line comments so brace-counting isn't corrupted by `{` inside strings. */
428
+ function stripStringLiterals(line) {
429
+ // Remove line comment first
430
+ let s = line.replace(/\/\/.*$/, '');
431
+ // Remove single-quoted, double-quoted, and template-literal string contents (handles simple escapes)
432
+ s = s.replace(/'(?:[^'\\]|\\.)*'/g, "''");
433
+ s = s.replace(/"(?:[^"\\]|\\.)*"/g, '""');
434
+ s = s.replace(/`(?:[^`\\]|\\.)*`/g, '``');
435
+ return s;
436
+ }
437
+ /** Name-based heuristic: is this specifier name likely a test object? */
438
+ function isTestObjectName(name) {
439
+ const lower = name.toLowerCase();
440
+ return (lower === 'test' ||
441
+ lower === 'it' ||
442
+ lower.endsWith('test') || // authTest, mobalyticsTest, adminTest
443
+ lower.endsWith('fixture') || // testFixture, authFixture
444
+ lower === 'base' ||
445
+ lower === 'expect' // sometimes exported alongside test
446
+ );
447
+ }
448
+ /**
449
+ * Detect fixture-aliased test identifiers from import statements.
450
+ *
451
+ * Strategy:
452
+ * - Relative imports (../ ./): accept ANY specifier whose name looks like a test object
453
+ * — path doesn't matter, name heuristic is enough.
454
+ * - Package imports (@scope/pkg, bare names): use broadened keyword list covering
455
+ * common enterprise directory names (core, auth, support, helpers, utils, testing, lib).
456
+ * - `test as alias` renames: always captured regardless of path.
457
+ * - require() destructuring: same rules as named imports.
458
+ * - extraAliases: pre-resolved aliases from multi-file tracing (G-07), merged in directly.
459
+ */
460
+ function detectTestAliases(content, extraAliases) {
461
+ const aliases = new Set(extraAliases);
462
+ const namedImportRe = /^import\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/gm;
463
+ let m;
464
+ while ((m = namedImportRe.exec(content)) !== null) {
465
+ const importPath = m[2];
466
+ const isRelative = importPath.startsWith('./') || importPath.startsWith('../');
467
+ // For package imports, require at least one fixture-related keyword in the path
468
+ const isFixturePath = isRelative ||
469
+ /\bfixture[s]?\b/i.test(importPath) ||
470
+ /\/(?:setup|base|support|helpers?|utils?|core|auth|testing|lib)\b/.test(importPath) ||
471
+ /\btest(?:ing|s)?\b/i.test(importPath);
472
+ if (!isFixturePath)
473
+ continue;
474
+ const specifiers = m[1].split(',').map((s) => s.trim());
475
+ for (const spec of specifiers) {
476
+ const asMatch = /^(\w+)\s+as\s+(\w+)$/.exec(spec);
477
+ if (asMatch) {
478
+ // `test as authTest` — the rename target IS the test function regardless of name
479
+ aliases.add(asMatch[2]);
480
+ }
481
+ else if (/^\w+$/.test(spec) && isTestObjectName(spec)) {
482
+ aliases.add(spec);
483
+ }
484
+ }
485
+ }
486
+ // require() destructuring: const { authTest } = require('../auth')
487
+ const requireRe = /const\s*\{([^}]+)\}\s*=\s*require\s*\(\s*['"]([^'"]+)['"]/gm;
488
+ while ((m = requireRe.exec(content)) !== null) {
489
+ const importPath = m[2];
490
+ const isRelative = importPath.startsWith('./') || importPath.startsWith('../');
491
+ const isFixturePath = isRelative ||
492
+ /\bfixture[s]?\b/i.test(importPath) ||
493
+ /\/(?:setup|base|support|helpers?|utils?|core|auth|testing|lib)\b/.test(importPath);
494
+ if (!isFixturePath)
495
+ continue;
496
+ const specifiers = m[1].split(',').map((s) => s.trim());
497
+ for (const spec of specifiers) {
498
+ if (/^\w+$/.test(spec) && isTestObjectName(spec)) {
499
+ aliases.add(spec);
500
+ }
501
+ }
502
+ }
503
+ return aliases;
504
+ }
505
+ /**
506
+ * G-07: Multi-file fixture tracing.
507
+ * For relative imports where specifier name does NOT match the name heuristic,
508
+ * fetch the imported file and check if it exports a test.extend() result.
509
+ * Returns a set of confirmed alias names to merge into detectTestAliases.
510
+ * Max 3 fixture files fetched per spec file to stay within rate limits.
511
+ */
512
+ /** Resolve a relative import path against a base directory string (no Node.js path module needed). */
513
+ function resolveRelativeImport(specDir, importPath) {
514
+ const parts = specDir ? specDir.split('/') : [];
515
+ for (const seg of importPath.split('/')) {
516
+ if (seg === '..')
517
+ parts.pop();
518
+ else if (seg !== '.')
519
+ parts.push(seg);
520
+ }
521
+ return parts.join('/');
522
+ }
523
+ /**
524
+ * G-07: Multi-file fixture tracing.
525
+ * For relative imports where specifier name does NOT match the name heuristic,
526
+ * fetch the imported file and check if it exports a test.extend() result.
527
+ * Returns confirmed alias names to merge into detectTestAliases.
528
+ * Max 3 fixture files fetched per spec file to stay within rate limits.
529
+ */
530
+ async function resolveFixtureAliasesFromImports(content, token, repoFullName, specFilePath) {
531
+ const confirmed = new Set();
532
+ const namedImportRe = /^import\s*\{([^}]+)\}\s*from\s*['"]([^'"]+)['"]/gm;
533
+ let m;
534
+ let fetched = 0;
535
+ const specDir = specFilePath.includes('/')
536
+ ? specFilePath.substring(0, specFilePath.lastIndexOf('/'))
537
+ : '';
538
+ while ((m = namedImportRe.exec(content)) !== null && fetched < 3) {
539
+ const importPath = m[2];
540
+ if (!importPath.startsWith('./') && !importPath.startsWith('../'))
541
+ continue;
542
+ const specifiers = m[1].split(',').map((s) => s.trim());
543
+ // Only trace imports where at least one specifier name looks non-obvious
544
+ const unknownSpecs = specifiers.filter((sp) => {
545
+ const name = (/^(\w+)\s+as\s+(\w+)$/.exec(sp) ?? [])[2] ?? sp;
546
+ return /^\w+$/.test(name) && !isTestObjectName(name);
547
+ });
548
+ if (unknownSpecs.length === 0)
549
+ continue;
550
+ const resolved = resolveRelativeImport(specDir, importPath);
551
+ const candidates = [`${resolved}.ts`, `${resolved}/index.ts`, `${resolved}.js`, `${resolved}/index.js`];
552
+ for (const candidate of candidates) {
553
+ const fixContent = await fetchFileContent(token, repoFullName, candidate);
554
+ if (!fixContent)
555
+ continue;
556
+ fetched++;
557
+ // Check if file exports a test.extend() result
558
+ const hasExtend = /(?:test|it|base)\s*(?:\.\w+)?\s*\.extend\s*\(/.test(fixContent) ||
559
+ /=\s*(?:test|it|base)\s*\.extend\s*\(/.test(fixContent);
560
+ if (!hasExtend)
561
+ break;
562
+ // Confirmed fixture module — add all unknown specifier names
563
+ for (const sp of unknownSpecs) {
564
+ const asMatch = /^(\w+)\s+as\s+(\w+)$/.exec(sp);
565
+ const aliasName = asMatch ? asMatch[2] : sp;
566
+ if (/^\w+$/.test(aliasName))
567
+ confirmed.add(aliasName);
568
+ }
569
+ break;
570
+ }
571
+ }
572
+ return confirmed;
573
+ }
574
+ function buildAliasedIdentifierPattern(aliases) {
575
+ // Returns a regex alternation group for all known test identifiers
576
+ // Base identifiers always included; fixture aliases appended
577
+ const base = ['test', 'it'];
578
+ const extra = [...aliases].filter((a) => !base.includes(a));
579
+ return [...base, ...extra].map((a) => a.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
580
+ }
581
+ function extractJestLike(content, filePath, framework, extraAliases) {
582
+ const tests = [];
583
+ const lines = content.split('\n');
584
+ const describeStack = [];
585
+ // Detect fixture-aliased test identifiers (e.g. authTest from test.extend())
586
+ const aliases = detectTestAliases(content, extraAliases);
587
+ const idPat = buildAliasedIdentifierPattern(aliases);
588
+ // describe/suite/<alias>.describe — including .only/.skip/.each/.parallel/.configure variants
589
+ const describeRe = new RegExp(String.raw `^\s*(?:(?:${idPat})\.describe(?:\.\w+)?|describe(?:\.\w+)?|suite)\s*(?:\.each\b[^(]*)?\s*\(\s*(['"\`])(.*?)\1`);
590
+ // test/it/<alias> with all modifiers; also x-prefixed variants
591
+ const testRe = new RegExp(String.raw `^\s*(?:x(?:test|it)|(?:${idPat})(?:\.(?:skip|only|fixme|fail|slow|concurrent|todo|each\b[^(]*))?)\s*\(\s*(['"\`])(.*?)\1`);
592
+ // test.each tagged template: test.each`data`('title %s', fn)
593
+ const eachTaggedRe = new RegExp(String.raw `^\s*(?:${idPat})(?:\.(?:skip|only|fixme))?\s*\.each\s*` + '`');
594
+ // test.each([...])('title', fn) — function call form start detection
595
+ const eachFnStartRe = new RegExp(String.raw `^\s*(?:${idPat})(?:\.(?:skip|only|fixme))?\s*\.each\s*\(`);
596
+ // Closing of test.each data followed by title: ])('title') or ])( or })('title')
597
+ const eachFnClosingRe = /^[\s\]})]*\)\s*\(\s*(['"`])(.*?)\1/;
598
+ let braceDepthAtDescribe = [];
599
+ let braceDepth = 0;
600
+ for (let i = 0; i < lines.length; i++) {
601
+ const line = lines[i];
602
+ const lineNum = i + 1;
603
+ // Use stripped line for brace counting to avoid corruption from string literals
604
+ const stripped = stripStringLiterals(line);
605
+ const openBraces = (stripped.match(/\{/g) ?? []).length;
606
+ const closeBraces = (stripped.match(/\}/g) ?? []).length;
607
+ const describeMatch = describeRe.exec(line);
608
+ if (describeMatch) {
609
+ describeStack.push(describeMatch[2]);
610
+ braceDepthAtDescribe.push(braceDepth + openBraces - closeBraces);
611
+ }
612
+ const testMatch = testRe.exec(line);
613
+ if (testMatch) {
614
+ const name = testMatch[2];
615
+ tests.push({
616
+ name,
617
+ normalizedName: normalizeName(name),
618
+ filePath,
619
+ lineNumber: lineNum,
620
+ describeBlock: describeStack.length > 0 ? describeStack.join(' > ') : undefined,
621
+ framework,
622
+ dynamic: isDynamic(name),
623
+ });
624
+ }
625
+ // test.each tagged template literal: test.each`...`('title %s', fn)
626
+ if (eachTaggedRe.test(line)) {
627
+ // Scan forward to find the closing backtick then the title call
628
+ for (let j = i + 1; j < Math.min(i + 20, lines.length); j++) {
629
+ const nextLine = lines[j];
630
+ if (/^\s*`/.test(nextLine)) {
631
+ // Next line after backtick-close may have (' title', fn) or be on same line
632
+ const afterClose = nextLine.split('`').pop() ?? '';
633
+ const titleMatch = /\(\s*(['"`])(.*?)\1/.exec(afterClose);
634
+ if (titleMatch) {
635
+ tests.push({
636
+ name: titleMatch[2],
637
+ normalizedName: normalizeName(titleMatch[2]),
638
+ filePath,
639
+ lineNumber: j + 1,
640
+ describeBlock: describeStack.length > 0 ? describeStack.join(' > ') : undefined,
641
+ framework,
642
+ dynamic: isDynamic(titleMatch[2]),
643
+ });
644
+ }
645
+ else {
646
+ // Title on the line after the backtick-close
647
+ const nextNext = lines[j + 1] ?? '';
648
+ const titleMatch2 = /^\s*\(\s*(['"`])(.*?)\1/.exec(nextNext);
649
+ if (titleMatch2) {
650
+ tests.push({
651
+ name: titleMatch2[2],
652
+ normalizedName: normalizeName(titleMatch2[2]),
653
+ filePath,
654
+ lineNumber: j + 2,
655
+ describeBlock: describeStack.length > 0 ? describeStack.join(' > ') : undefined,
656
+ framework,
657
+ dynamic: isDynamic(titleMatch2[2]),
658
+ });
659
+ }
660
+ }
661
+ break;
662
+ }
663
+ }
664
+ }
665
+ // test.each([...])('title', fn) — function call form, data spans multiple lines
666
+ // Detect the opening `test.each(` and scan forward for the closing `])('title'`
667
+ if (eachFnStartRe.test(line)) {
668
+ // Check same line first: test.each([data])('title', fn)
669
+ const sameLineMatch = /\)\s*\(\s*(['"`])(.*?)\1/.exec(line.replace(/^[^(]*\(/, ''));
670
+ if (sameLineMatch) {
671
+ tests.push({
672
+ name: sameLineMatch[2],
673
+ normalizedName: normalizeName(sameLineMatch[2]),
674
+ filePath,
675
+ lineNumber: lineNum,
676
+ describeBlock: describeStack.length > 0 ? describeStack.join(' > ') : undefined,
677
+ framework,
678
+ dynamic: isDynamic(sameLineMatch[2]),
679
+ });
680
+ }
681
+ else {
682
+ // Multi-line: scan forward for the closing title call
683
+ for (let j = i + 1; j < Math.min(i + 30, lines.length); j++) {
684
+ const closingMatch = eachFnClosingRe.exec(lines[j]);
685
+ if (closingMatch) {
686
+ tests.push({
687
+ name: closingMatch[2],
688
+ normalizedName: normalizeName(closingMatch[2]),
689
+ filePath,
690
+ lineNumber: j + 1,
691
+ describeBlock: describeStack.length > 0 ? describeStack.join(' > ') : undefined,
692
+ framework,
693
+ dynamic: isDynamic(closingMatch[2]),
694
+ });
695
+ break;
696
+ }
697
+ // Stop scanning if we hit another test definition
698
+ if (testRe.test(lines[j]) || describeRe.test(lines[j]))
699
+ break;
700
+ }
701
+ }
702
+ }
703
+ braceDepth += openBraces - closeBraces;
704
+ while (braceDepthAtDescribe.length > 0 &&
705
+ braceDepth <= braceDepthAtDescribe[braceDepthAtDescribe.length - 1]) {
706
+ braceDepthAtDescribe.pop();
707
+ describeStack.pop();
708
+ }
709
+ }
710
+ return tests;
711
+ }
712
+ function extractPytest(content, filePath) {
713
+ const tests = [];
714
+ const lines = content.split('\n');
715
+ // async def test_* also valid in pytest-asyncio
716
+ const testFnRe = /^(?:async\s+)?def\s+(test_\w+)\s*\(/;
717
+ const classRe = /^class\s+(\w+)\s*[:(]/;
718
+ const parametrizeRe = /@pytest\.mark\.parametrize\b/;
719
+ let currentClass;
720
+ let hasParametrize = false;
721
+ for (let i = 0; i < lines.length; i++) {
722
+ const line = lines[i];
723
+ const lineNum = i + 1;
724
+ const classMatch = classRe.exec(line);
725
+ if (classMatch) {
726
+ currentClass = classMatch[1];
727
+ hasParametrize = false;
728
+ continue;
729
+ }
730
+ // Track @pytest.mark.parametrize decorator before function
731
+ if (parametrizeRe.test(line)) {
732
+ hasParametrize = true;
733
+ continue;
734
+ }
735
+ const testMatch = testFnRe.exec(line);
736
+ if (testMatch) {
737
+ const name = testMatch[1];
738
+ tests.push({
739
+ name,
740
+ normalizedName: normalizeName(name),
741
+ filePath,
742
+ lineNumber: lineNum,
743
+ describeBlock: currentClass,
744
+ framework: 'pytest',
745
+ dynamic: hasParametrize || isDynamic(name),
746
+ });
747
+ hasParametrize = false;
748
+ }
749
+ else if (!/^\s*@/.test(line) && !/^\s*$/.test(line) && !/^\s*#/.test(line)) {
750
+ // Non-decorator, non-blank, non-comment line resets parametrize flag
751
+ hasParametrize = false;
752
+ }
753
+ }
754
+ return tests;
755
+ }
756
+ function extractJUnit(content, filePath) {
757
+ const tests = [];
758
+ const lines = content.split('\n');
759
+ // JUnit 5: @Test, @ParameterizedTest, @RepeatedTest, @TestFactory; JUnit 4: @Test
760
+ const testAnnotRe = /@(?:Test|ParameterizedTest|RepeatedTest|TestFactory)\b/;
761
+ let nextIsTest = false;
762
+ // Track @Nested inner classes as describe blocks
763
+ const nestedStack = [];
764
+ let braceDepth = 0;
765
+ let braceDepthAtNested = [];
766
+ for (let i = 0; i < lines.length; i++) {
767
+ const line = lines[i];
768
+ const lineNum = i + 1;
769
+ const stripped = stripStringLiterals(line);
770
+ braceDepth += (stripped.match(/\{/g) ?? []).length - (stripped.match(/\}/g) ?? []).length;
771
+ if (/@Nested\b/.test(line)) {
772
+ // The class name follows on next non-annotation line
773
+ for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) {
774
+ const classMatch = /class\s+(\w+)/.exec(lines[j]);
775
+ if (classMatch) {
776
+ nestedStack.push(classMatch[1]);
777
+ braceDepthAtNested.push(braceDepth);
778
+ break;
779
+ }
780
+ }
781
+ }
782
+ // Pop nested class stack when brace depth falls to pre-nested level
783
+ while (braceDepthAtNested.length > 0 && braceDepth < braceDepthAtNested[braceDepthAtNested.length - 1]) {
784
+ braceDepthAtNested.pop();
785
+ nestedStack.pop();
786
+ }
787
+ if (testAnnotRe.test(line)) {
788
+ nextIsTest = true;
789
+ continue;
790
+ }
791
+ if (nextIsTest) {
792
+ // Prefer @DisplayName("text") if it appeared on the previous 3 lines
793
+ let displayName;
794
+ for (let j = Math.max(0, i - 3); j < i; j++) {
795
+ const dm = /@DisplayName\s*\(\s*["']([^"']+)["']/.exec(lines[j]);
796
+ if (dm) {
797
+ displayName = dm[1];
798
+ break;
799
+ }
800
+ }
801
+ const methodMatch = /(?:public|protected|private|void|\s)+\s+(\w+)\s*\(/.exec(line);
802
+ if (methodMatch) {
803
+ const rawName = methodMatch[1];
804
+ const name = displayName ?? rawName;
805
+ tests.push({
806
+ name,
807
+ normalizedName: normalizeName(rawName),
808
+ filePath,
809
+ lineNumber: lineNum,
810
+ describeBlock: nestedStack.length > 0 ? nestedStack.join(' > ') : undefined,
811
+ framework: 'junit',
812
+ dynamic: isDynamic(name),
813
+ });
814
+ }
815
+ nextIsTest = false;
816
+ }
817
+ }
818
+ return tests;
819
+ }
820
+ function extractGherkin(content, filePath) {
821
+ const tests = [];
822
+ const lines = content.split('\n');
823
+ const scenarioRe = /^\s*Scenario(?:\s+Outline)?:\s*(.+)$/;
824
+ const featureRe = /^\s*Feature:\s*(.+)$/;
825
+ // Gherkin v6+: Rule: groups scenarios within a feature
826
+ const ruleRe = /^\s*Rule:\s*(.+)$/;
827
+ const tagRe = /^\s*(@\S+(?:\s+@\S+)*)\s*$/;
828
+ let featureName;
829
+ let ruleName;
830
+ let pendingTags;
831
+ for (let i = 0; i < lines.length; i++) {
832
+ const line = lines[i];
833
+ const lineNum = i + 1;
834
+ const featureMatch = featureRe.exec(line);
835
+ if (featureMatch) {
836
+ featureName = featureMatch[1].trim();
837
+ ruleName = undefined;
838
+ continue;
839
+ }
840
+ const ruleMatch = ruleRe.exec(line);
841
+ if (ruleMatch) {
842
+ ruleName = ruleMatch[1].trim();
843
+ continue;
844
+ }
845
+ const tagMatch = tagRe.exec(line);
846
+ if (tagMatch) {
847
+ pendingTags = tagMatch[1].trim();
848
+ continue;
849
+ }
850
+ const scenarioMatch = scenarioRe.exec(line);
851
+ if (scenarioMatch) {
852
+ const name = scenarioMatch[1].trim();
853
+ const describeBlock = ruleName
854
+ ? `${featureName ?? ''} > ${ruleName}`.trim()
855
+ : featureName;
856
+ tests.push({
857
+ name,
858
+ normalizedName: normalizeName(name),
859
+ filePath,
860
+ lineNumber: lineNum,
861
+ describeBlock,
862
+ framework: 'cucumber',
863
+ dynamic: isDynamic(name),
864
+ });
865
+ pendingTags = undefined;
866
+ continue;
867
+ }
868
+ // Non-tag, non-scenario line clears pending tags
869
+ if (!/^\s*$/.test(line) && !tagMatch) {
870
+ pendingTags = undefined;
871
+ }
872
+ }
873
+ return tests;
874
+ }
875
+ function extractTestIds(content, filePath, framework, extraAliases) {
876
+ if (framework === 'pytest') {
877
+ return extractPytest(content, filePath);
878
+ }
879
+ if (framework === 'junit') {
880
+ return extractJUnit(content, filePath);
881
+ }
882
+ if (framework === 'cucumber') {
883
+ return extractGherkin(content, filePath);
884
+ }
885
+ // playwright-ts, playwright-js, cypress-ts, cypress-js, jest-ts, jest-js, webdriverio
886
+ return extractJestLike(content, filePath, framework, extraAliases);
887
+ }
888
+ // ---------------------------------------------------------------------------
889
+ // Style profile extraction
890
+ // ---------------------------------------------------------------------------
891
+ function extractImportLines(content, max = 10) {
892
+ const importRe = /^(?:import|from)\s+.+$/gm;
893
+ const matches = content.match(importRe) ?? [];
894
+ return matches.slice(0, max);
895
+ }
896
+ function extractPatternExample(content, re) {
897
+ const match = re.exec(content);
898
+ if (!match)
899
+ return '';
900
+ // Return the matched block trimmed to a reasonable length
901
+ return match[0].trim().slice(0, 200);
902
+ }
903
+ function detectDirFileStem(allPaths, dir) {
904
+ if (!dir)
905
+ return '';
906
+ const prefix = dir.replace(/\/?$/, '/');
907
+ const files = allPaths
908
+ .filter(p => p.startsWith(prefix) && !p.slice(prefix.length).includes('/'))
909
+ .map(p => p.split('/').pop())
910
+ .filter(f => /\.(ts|js|py|java|kt|json)$/.test(f) && !f.startsWith('index.') && !f.startsWith('_'))
911
+ .slice(0, 8);
912
+ if (files.length === 0)
913
+ return '';
914
+ const isPascal = /^[A-Z]/.test(files[0]);
915
+ const caseToken = isPascal ? '{PascalCase}' : '{camelCase}';
916
+ // Multi-part extension like .fixture.ts or .spec.ts
917
+ const fullExt = files[0].match(/(?:\.[a-z]+){1,2}$/)?.[0] ?? '';
918
+ const withoutExt = files.map(f => f.replace(/(?:\.[a-z]+){1,2}$/, ''));
919
+ const KNOWN_SUFFIXES = ['Page', 'Locators', 'Locator', 'TestData', 'Data', 'Actions', 'Assertions', 'Helper', 'Utils', 'Constants', 'Steps', 'Helpers'];
920
+ let suffix = '';
921
+ for (const s of KNOWN_SUFFIXES) {
922
+ if (withoutExt.filter(f => f.endsWith(s)).length > 0) {
923
+ suffix = s;
924
+ break;
925
+ }
926
+ }
927
+ return `${caseToken}${suffix}${fullExt}`;
928
+ }
929
+ function detectFolderPattern(paths) {
930
+ if (paths.length === 0)
931
+ return '';
932
+ // For Java repos: prefer actual test class files (*Test.java/*Tests.java),
933
+ // deprioritize utility/helper dirs which appear first alphabetically.
934
+ const helperDirRe = /\/(helper|util|utils|base|common|shared)\//;
935
+ const javaTestRe = /[A-Z]\w*Tests?\.java$/;
936
+ const javaTestFiles = paths.filter((p) => javaTestRe.test(p));
937
+ const preferred = javaTestFiles.filter((p) => !helperDirRe.test(p));
938
+ const candidate = preferred[0] ?? javaTestFiles[0] ?? paths[0];
939
+ const parts = candidate.split('/');
940
+ if (parts.length >= 2) {
941
+ const prefix = parts.slice(0, -1).join('/');
942
+ const filename = parts[parts.length - 1];
943
+ const ext = filename.includes('.') ? filename.slice(filename.lastIndexOf('.')) : '';
944
+ return `${prefix}/{feature}${ext}`;
945
+ }
946
+ return candidate;
947
+ }
948
+ function detectNamingPattern(paths) {
949
+ if (paths.length === 0)
950
+ return '';
951
+ const filenames = paths.map((p) => p.split('/').pop() ?? p);
952
+ const javaCount = filenames.filter((f) => f.endsWith('.java')).length;
953
+ const kotlinCount = filenames.filter((f) => f.endsWith('.kt')).length;
954
+ if (kotlinCount > javaCount && kotlinCount > 0)
955
+ return '{PascalCase}Test.kt';
956
+ if (javaCount > 0)
957
+ return '{PascalCase}Test.java';
958
+ const specCount = filenames.filter((f) => f.includes('.spec.')).length;
959
+ const cyCount = filenames.filter((f) => f.includes('.cy.')).length;
960
+ const testCount = filenames.filter((f) => f.includes('.test.')).length;
961
+ const e2eCount = filenames.filter((f) => f.includes('.e2e.')).length;
962
+ const intCount = filenames.filter((f) => f.includes('.integration.')).length;
963
+ const smokeCount = filenames.filter((f) => f.includes('.smoke.')).length;
964
+ const featureCount = filenames.filter((f) => f.endsWith('.feature')).length;
965
+ const pyTestSuffix = filenames.filter((f) => f.endsWith('_test.py')).length;
966
+ const pyTestPrefix = filenames.filter((f) => /^test_/.test(f)).length;
967
+ const rbSpecCount = filenames.filter((f) => f.endsWith('_spec.rb')).length;
968
+ const stepsCount = filenames.filter((f) => f.includes('.steps.')).length;
969
+ if (featureCount > 0)
970
+ return '{kebab-feature}.feature';
971
+ if (pyTestSuffix > 0)
972
+ return '{snake_feature}_test.py';
973
+ if (pyTestPrefix > 0)
974
+ return 'test_{snake_feature}.py';
975
+ if (rbSpecCount > 0)
976
+ return '{snake_feature}_spec.rb';
977
+ // Prefer most-used TS/JS suffix; spec > cy > test > e2e > integration > smoke
978
+ const tsxCount = filenames.filter((f) => f.includes('.test.tsx') || f.includes('.spec.tsx')).length;
979
+ const isTsx = tsxCount > Math.max(specCount, testCount) / 2;
980
+ const ext = isTsx ? 'tsx' : 'ts';
981
+ if (cyCount > 0 && cyCount >= specCount)
982
+ return `{kebab-feature}.cy.${ext}`;
983
+ if (stepsCount > 0)
984
+ return `{kebab-feature}.steps.${ext}`;
985
+ if (e2eCount > 0 && e2eCount >= testCount)
986
+ return `{kebab-feature}.e2e.${ext}`;
987
+ if (intCount > 0 && intCount >= testCount)
988
+ return `{kebab-feature}.integration.${ext}`;
989
+ if (smokeCount > 0 && smokeCount >= testCount)
990
+ return `{kebab-feature}.smoke.${ext}`;
991
+ if (specCount >= testCount)
992
+ return `{kebab-feature}.spec.${ext}`;
993
+ return `{kebab-feature}.test.${ext}`;
994
+ }
995
+ async function extractStyleProfile(sampleFiles, framework, language, tenantId, projectId, jobId,
996
+ /** Filtered code paths (test + source files) — used by LLM for convention/structure analysis */
997
+ allPaths,
998
+ /**
999
+ * Full repo tree paths (unfiltered) — used ONLY by heuristic detectDataDirs() so that
1000
+ * testdata/*.json, fixtures/*.yaml, locators/ etc. are visible even when they contain no .ts files.
1001
+ * When omitted, falls back to allPaths (same behaviour as before).
1002
+ */
1003
+ fullTreePaths) {
1004
+ if (sampleFiles.length === 0) {
1005
+ return {
1006
+ framework,
1007
+ language,
1008
+ importLines: [],
1009
+ describePattern: '',
1010
+ beforeEachPattern: '',
1011
+ pageObjectPattern: '',
1012
+ conventions: '',
1013
+ folderPattern: '',
1014
+ namingPattern: '',
1015
+ };
1016
+ }
1017
+ // Pick up to 5 samples from different directories for pattern diversity
1018
+ function pickDiverseSamples(files, n) {
1019
+ if (files.length <= n)
1020
+ return files;
1021
+ const seenDir = new Set();
1022
+ const picked = [];
1023
+ for (const f of files) {
1024
+ const dir = f.path.includes('/') ? f.path.split('/').slice(0, -2).join('/') : '';
1025
+ if (!seenDir.has(dir) && picked.length < n) {
1026
+ seenDir.add(dir);
1027
+ picked.push(f);
1028
+ }
1029
+ }
1030
+ for (const f of files) {
1031
+ if (picked.length >= n)
1032
+ break;
1033
+ if (!picked.includes(f))
1034
+ picked.push(f);
1035
+ }
1036
+ return picked;
1037
+ }
1038
+ const primary = sampleFiles[0];
1039
+ const diverseSamples = pickDiverseSamples(sampleFiles, 5);
1040
+ const allContent = diverseSamples
1041
+ .map((f) => `// File: ${f.path}\n${f.content.slice(0, 1500)}`)
1042
+ .join('\n\n---\n\n');
1043
+ const importLines = extractImportLines(primary.content);
1044
+ const describePattern = extractPatternExample(primary.content, /describe\s*\([^)]*\)\s*\{[^}]{0,300}/s);
1045
+ const beforeEachPattern = extractPatternExample(primary.content, /beforeEach\s*\([^)]*\)\s*\{[^}]{0,300}/s);
1046
+ // Detect page object usage pattern — try constructor first, then functional factory, then static methods
1047
+ const pageObjectPattern = extractPatternExample(primary.content, /new\s+\w+(?:Page|Component|Screen|Helper)\s*\([^\n]{0,150}/) ||
1048
+ extractPatternExample(primary.content, /(?:const|let)\s+\w+\s*=\s*(?:await\s+)?\w+(?:Page|Component|Screen|Helper)\s*\([^\n]{0,150}/) ||
1049
+ extractPatternExample(primary.content, /\w+(?:Page|Component|Screen|Helper)\.\w+\s*\(page[^\n]{0,120}/) ||
1050
+ extractPatternExample(primary.content, /export\s+(?:const|function|async\s+function)\s+\w+(?:Page|Component|Screen|Helper)\s*\([^\n]{0,120}/) ||
1051
+ '';
1052
+ // Heuristic fallbacks (used when LLM call fails)
1053
+ const paths = allPaths ?? sampleFiles.map((f) => f.path);
1054
+ let folderPattern = detectFolderPattern(paths);
1055
+ let namingPattern = detectNamingPattern(paths);
1056
+ let conventions = '';
1057
+ let llmBasePackage = null;
1058
+ let llmJavaRoot = null;
1059
+ let llmTestStructurePattern = null;
1060
+ let llmTestLayers = null;
1061
+ let llmPageObjectLocation = null;
1062
+ let llmTestLocation = null;
1063
+ let llmBasePageClass = null;
1064
+ let llmDriverClass = null;
1065
+ let llmTestDataDir = undefined;
1066
+ let llmFixtureDir = undefined;
1067
+ let llmLocatorsDir = undefined;
1068
+ let llmComponentDir = null;
1069
+ let llmTestSubDirs = null;
1070
+ let llmIsTestFixtureModule = null;
1071
+ // Heuristic detection before LLM — runs on FULL tree so testdata/*.json, fixtures/*.yaml are visible.
1072
+ // fullTreePaths may contain all file extensions; detectDataDirs uses pattern matching on path strings only.
1073
+ const { testDataDir: detectedTestDataDir, fixtureDir: detectedFixtureDir, locatorsDir: detectedLocatorsDir, utilsDir: detectedUtilsDir, configDir: detectedConfigDir } = detectDataDirs(fullTreePaths ?? paths);
1074
+ try {
1075
+ const llmCfg = await (0, llm_config_js_1.getTaskAgentLLMConfig)(tenantId, 'testGeneration');
1076
+ // Send up to 300 code paths — test files first, then source files
1077
+ const isTestP = (p) => /\.(spec|test|e2e|cy|steps|feature)\./i.test(p) || /(?:_test|Test)\.(py|java|kt)$/.test(p) || /^test_/.test(p.split('/').pop() ?? '');
1078
+ const testPathsSorted = paths.filter(isTestP);
1079
+ const nonTestPaths = paths.filter(p => !isTestP(p));
1080
+ // Also include unique top-level directories from full tree so LLM can infer data/fixture/locator dirs
1081
+ // even when those dirs contain only JSON/YAML/CSV (non-code) files
1082
+ const fullTree = fullTreePaths ?? paths;
1083
+ const extraDirs = [...new Set(fullTree
1084
+ .filter(p => !p.includes('node_modules/') && !p.startsWith('.') && p.includes('/'))
1085
+ .map(p => {
1086
+ const parts = p.split('/');
1087
+ return parts.length >= 3 ? parts.slice(0, 2).join('/') : parts[0];
1088
+ }))].slice(0, 120).map(d => `${d}/`); // format as dir hint
1089
+ // Include heuristic-detected dirs (config/utils/locators) so they always reach the LLM
1090
+ // even when those dirs contain only non-code files (JSON/YAML) excluded from the paths array.
1091
+ const detectedDirHints = [
1092
+ detectedConfigDir, detectedUtilsDir, detectedLocatorsDir,
1093
+ detectedTestDataDir, detectedFixtureDir,
1094
+ ].filter((d) => d !== null && d !== undefined)
1095
+ .map(d => (d.endsWith('/') ? d : `${d}/`));
1096
+ const pathList = [
1097
+ ...testPathsSorted.slice(0, 200),
1098
+ ...nonTestPaths.slice(0, 100),
1099
+ '', '# All top-level dirs:', ...extraDirs,
1100
+ ...(detectedDirHints.length > 0 ? ['', '# Framework dirs detected by heuristic (config/utils/locators):', ...detectedDirHints] : []),
1101
+ ].join('\n');
1102
+ const systemPrompt = `You are a test repository structure analyst. Given ${framework} (${language}) test file paths and sample content, return ONLY a JSON object — no markdown, no explanation.
1103
+
1104
+ Fields to return:
1105
+
1106
+ "javaRoot": Java only — the source root directory for test files (e.g. "src/test/java", "src/main/java"). Leave empty string for non-Java.
1107
+
1108
+ "basePackage": package/module root under javaRoot:
1109
+ - Java: dot-separated (e.g. "tests", "com.company.qa")
1110
+ - TypeScript/JavaScript: root test directory (e.g. "tests", "e2e")
1111
+ - Python: dot-separated module (e.g. "tests")
1112
+ - Ruby: spec root (e.g. "spec")
1113
+ - C#: namespace root (e.g. "Company.Tests")
1114
+
1115
+ "testStructurePattern": how the repo organises tests (choose one):
1116
+ - "flat": single test class per feature, no separation of page objects
1117
+ - "two-tier": page objects in one location, test classes in another
1118
+ - "zeta-v2": already uses Zeta v2 layers (locators + pages + actions + assertions + testdata + smoke subfolders)
1119
+ - "custom": multi-layer but different names
1120
+
1121
+ "testLayers": array of subdirectory layer names actually found in this repo (e.g. [] for flat, ["pages"] for two-tier, ["locators","pages","actions","assertions","testdata"] for zeta-v2)
1122
+
1123
+ "pageObjectLocation": where TEST AUTOMATION page object classes ACTUALLY EXIST relative to repo root. IMPORTANT: if both a 'pages/' or 'src/pages/' directory AND a 'src/components/' or 'components/' directory exist, use 'pages/'/'src/pages/' for pageObjectLocation — 'src/components/' holds UI framework components (React/Vue/Angular), NOT test page objects. For Java test repos, page objects almost always live in src/test/java (e.g. "src/test/java/pages") NOT src/main/java. Empty if flat.
1124
+ "componentDir": if the repo has a SEPARATE UI components directory distinct from pageObjectLocation (e.g. "src/components" for React components). Empty string if none or same as pageObjectLocation. This should be the React/Vue/Angular component folder, NOT the test page objects folder.
1125
+ "testLocation": where test runner classes ACTUALLY EXIST relative to repo root (e.g. "tests", "tests/ui", "src/test/java/tests").
1126
+ "testSubDirs": if tests are split into multiple subdirectories by type, list them (e.g. ["tests/ui","tests/api"]). Empty array if single directory.
1127
+ "basePageClass": repo's base class name for page objects (e.g. "Page", "BasePage"). Empty if none.
1128
+ "driverClass": repo's driver/browser factory class name (e.g. "Browser", "DriverFactory"). Empty if none.
1129
+
1130
+ "testDataDir": directory where test data files live (e.g. "src/data/", "testdata/", "data/", "src/test/resources/"). Set to null if no such directory.
1131
+ "fixtureDir": directory where fixture/data files live (e.g. "src/fixtures/", "fixtures/", "cypress/fixtures/"). May be same as testDataDir. Set to null if none.
1132
+ "isTestFixtureModule": true ONLY if fixtureDir contains TypeScript/JavaScript files that export an extended test object (Playwright fixtures, not static JSON/CSV data). false otherwise.
1133
+ "locatorsDir": directory where locator/selector constant files live (e.g. "locators/", "selectors/", "src/locators/"). Set to null if none.
1134
+
1135
+ "folderPattern": full template path for a new test file using {feature} placeholder (e.g. "src/test/java/tests/{feature}/{Feature}Tests.java")
1136
+ "namingPattern": file naming template (e.g. "{PascalCase}Tests.java", "{kebab}.spec.ts")
1137
+ "conventions": exactly 5 bullet points about coding style, naming, patterns`;
1138
+ const shownCount = Math.min(300, testPathsSorted.length + nonTestPaths.length);
1139
+ const userPrompt = `Test file paths (${paths.length} total, showing ${shownCount} — test files first):
1140
+ ${pathList}
1141
+
1142
+ Sample file content:
1143
+ ${allContent}
1144
+
1145
+ Return JSON: {"javaRoot":"...","basePackage":"...","testStructurePattern":"...","testLayers":[...],"pageObjectLocation":"...","componentDir":"...","testLocation":"...","testSubDirs":[...],"basePageClass":"...","driverClass":"...","testDataDir":"...or null","fixtureDir":"...or null","isTestFixtureModule":false,"locatorsDir":"...or null","folderPattern":"...","namingPattern":"...","conventions":"..."}`;
1146
+ const t0 = Date.now();
1147
+ const seed = (0, llm_js_1.deterministicSeed)(`${tenantId}:${projectId ?? ''}:repo-scan:${framework}:${paths.slice(0, 20).join('|').slice(0, 400)}`);
1148
+ const effectiveSystemPrompt = await (0, core_1.getAgentPrompt)('repo-scan-style', undefined, tenantId) ?? systemPrompt;
1149
+ const result = await (0, llm_js_1.askTracked)(effectiveSystemPrompt, userPrompt, llmCfg, undefined, undefined, { temperature: 0, seed });
1150
+ if (projectId) {
1151
+ (0, app_brain_1.recordLLMCall)({
1152
+ tenantId,
1153
+ projectId,
1154
+ agentType: 'repo_scan',
1155
+ label: `style_profile_${framework}`,
1156
+ jobId,
1157
+ usage: result.usage,
1158
+ latencyMs: Date.now() - t0,
1159
+ }).catch(() => { });
1160
+ }
1161
+ // Strip markdown code fences before attempting JSON extraction
1162
+ const cleanText = result.text.replace(/^```[\w-]*\s*/m, '').replace(/\s*```\s*$/m, '');
1163
+ const jsonMatch = cleanText.match(/\{[\s\S]*\}/);
1164
+ function applyLLMProfile(parsed) {
1165
+ if (parsed.conventions)
1166
+ conventions = parsed.conventions;
1167
+ if (parsed.folderPattern)
1168
+ folderPattern = parsed.folderPattern;
1169
+ if (parsed.namingPattern)
1170
+ namingPattern = parsed.namingPattern;
1171
+ if (parsed.basePackage)
1172
+ llmBasePackage = parsed.basePackage;
1173
+ if (parsed.javaRoot)
1174
+ llmJavaRoot = parsed.javaRoot;
1175
+ if (parsed.testStructurePattern && ['flat', 'two-tier', 'zeta-v2', 'custom'].includes(parsed.testStructurePattern)) {
1176
+ llmTestStructurePattern = parsed.testStructurePattern;
1177
+ }
1178
+ if (Array.isArray(parsed.testLayers))
1179
+ llmTestLayers = parsed.testLayers;
1180
+ if (parsed.componentDir)
1181
+ llmComponentDir = parsed.componentDir;
1182
+ // Reject pageObjectLocation if it resolves to the same path as componentDir —
1183
+ // LLM sometimes picks src/components/ (UI framework dir) instead of src/pages/ (test PO dir)
1184
+ if (parsed.pageObjectLocation) {
1185
+ const poNorm = parsed.pageObjectLocation.replace(/\/$/, '');
1186
+ const compNorm = (parsed.componentDir ?? '').replace(/\/$/, '');
1187
+ if (!compNorm || poNorm !== compNorm) {
1188
+ llmPageObjectLocation = parsed.pageObjectLocation;
1189
+ }
1190
+ }
1191
+ if (parsed.testLocation)
1192
+ llmTestLocation = parsed.testLocation;
1193
+ if (Array.isArray(parsed.testSubDirs) && parsed.testSubDirs.length > 0)
1194
+ llmTestSubDirs = parsed.testSubDirs;
1195
+ if (parsed.basePageClass)
1196
+ llmBasePageClass = parsed.basePageClass;
1197
+ if (parsed.driverClass)
1198
+ llmDriverClass = parsed.driverClass;
1199
+ if ('testDataDir' in parsed)
1200
+ llmTestDataDir = parsed.testDataDir ?? null;
1201
+ if ('fixtureDir' in parsed)
1202
+ llmFixtureDir = parsed.fixtureDir ?? null;
1203
+ if (typeof parsed.isTestFixtureModule === 'boolean')
1204
+ llmIsTestFixtureModule = parsed.isTestFixtureModule;
1205
+ if ('locatorsDir' in parsed)
1206
+ llmLocatorsDir = parsed.locatorsDir ?? null;
1207
+ }
1208
+ let parsed = null;
1209
+ if (jsonMatch) {
1210
+ try {
1211
+ parsed = JSON.parse(jsonMatch[0]);
1212
+ }
1213
+ catch { /* fall through to retry */ }
1214
+ }
1215
+ if (!parsed) {
1216
+ // Retry once with a stripped prompt asking only for JSON
1217
+ try {
1218
+ const retryResult = await (0, llm_js_1.askTracked)('Return ONLY valid JSON. No markdown, no explanation.', `Convert this to valid JSON matching the schema from the previous request:\n${result.text.slice(0, 2000)}`, llmCfg, undefined, undefined, { temperature: 0, seed });
1219
+ const retryClean = retryResult.text.replace(/^```[\w-]*\s*/m, '').replace(/\s*```\s*$/m, '');
1220
+ const retryMatch = retryClean.match(/\{[\s\S]*\}/);
1221
+ if (retryMatch) {
1222
+ try {
1223
+ parsed = JSON.parse(retryMatch[0]);
1224
+ }
1225
+ catch { /* ignore */ }
1226
+ }
1227
+ }
1228
+ catch { /* ignore retry failure */ }
1229
+ }
1230
+ if (parsed) {
1231
+ applyLLMProfile(parsed);
1232
+ }
1233
+ else if (!jsonMatch) {
1234
+ // LLM returned plain text — treat as conventions
1235
+ conventions = result.text;
1236
+ }
1237
+ }
1238
+ catch {
1239
+ conventions = '';
1240
+ }
1241
+ // basePackage from LLM takes priority; fallback to regex heuristic for Java
1242
+ const javaBasePackage = llmBasePackage ?? deriveJavaBasePackage(folderPattern, sampleFiles.map((f) => f.path));
1243
+ // Heuristic path detection wins over LLM when found in actual repo paths
1244
+ const resolvedTestDataDir = detectedTestDataDir ?? (llmTestDataDir !== undefined ? llmTestDataDir : undefined);
1245
+ const resolvedFixtureDir = detectedFixtureDir ?? (llmFixtureDir !== undefined ? llmFixtureDir : undefined);
1246
+ // locatorsDir: heuristic wins; LLM fills gap when heuristic found nothing
1247
+ const resolvedLocatorsDir = detectedLocatorsDir !== null
1248
+ ? detectedLocatorsDir
1249
+ : (llmLocatorsDir !== undefined ? (llmLocatorsDir ?? undefined) : undefined);
1250
+ // utilsDir: heuristic only — no LLM inference needed
1251
+ const resolvedUtilsDir = detectedUtilsDir !== null ? detectedUtilsDir : undefined;
1252
+ const resolvedConfigDir = detectedConfigDir !== null ? detectedConfigDir : undefined;
1253
+ // Detect isTestFixtureModule from heuristic: fixtureDir path contains a .ts or .js file
1254
+ const heuristicIsFixtureModule = resolvedFixtureDir
1255
+ ? paths.some(p => p.startsWith(resolvedFixtureDir) && /\.(ts|js)$/.test(p))
1256
+ : false;
1257
+ const resolvedIsTestFixtureModule = llmIsTestFixtureModule ?? heuristicIsFixtureModule;
1258
+ // Per-directory file naming stems (heuristic from actual file paths in each dir)
1259
+ const treePaths = fullTreePaths ?? paths;
1260
+ const pageFileStem = detectDirFileStem(treePaths, llmPageObjectLocation);
1261
+ const locatorFileStem = detectDirFileStem(treePaths, resolvedLocatorsDir);
1262
+ const testDataFileStem = detectDirFileStem(treePaths, resolvedTestDataDir);
1263
+ const fixtureFileStem = detectDirFileStem(treePaths, resolvedFixtureDir);
1264
+ // Detect actual base page file location from repo tree
1265
+ const _scanTree = fullTreePaths ?? paths;
1266
+ const _basePageFile = _scanTree.find(p => /(?:^|\/)(?:base\.page|BasePage|base_page)\.(ts|js|py|java|kt)$/.test(p) &&
1267
+ !p.includes('node_modules/') && !p.includes('.next/'));
1268
+ let _detectedBasePageImport;
1269
+ if (_basePageFile && llmPageObjectLocation && typeof llmPageObjectLocation === 'string') {
1270
+ // Compute relative import from pageObjectDir to base page file (without extension)
1271
+ const _from = llmPageObjectLocation.replace(/\/$/, '').split('/');
1272
+ const _to = _basePageFile.replace(/\.(ts|js|py|java|kt)$/, '').split('/');
1273
+ let _i = 0;
1274
+ while (_i < _from.length && _i < _to.length && _from[_i] === _to[_i])
1275
+ _i++;
1276
+ const _ups = Array(_from.length - _i).fill('..');
1277
+ const _rel = [..._ups, ..._to.slice(_i)].join('/');
1278
+ _detectedBasePageImport = _rel.startsWith('.') ? _rel : `./${_rel}`;
1279
+ }
1280
+ return {
1281
+ framework,
1282
+ language,
1283
+ importLines,
1284
+ describePattern,
1285
+ beforeEachPattern,
1286
+ pageObjectPattern,
1287
+ conventions,
1288
+ folderPattern,
1289
+ namingPattern,
1290
+ ...(javaBasePackage ? { javaBasePackage } : {}),
1291
+ ...(llmJavaRoot ? { javaRoot: llmJavaRoot } : {}),
1292
+ ...(llmTestStructurePattern ? { testStructurePattern: llmTestStructurePattern } : {}),
1293
+ ...(llmTestLayers ? { testLayers: llmTestLayers } : {}),
1294
+ ...(llmPageObjectLocation ? { pageObjectLocation: llmPageObjectLocation } : {}),
1295
+ ...(llmComponentDir ? { componentDir: llmComponentDir } : {}),
1296
+ ...(llmTestLocation ? { testLocation: llmTestLocation } : {}),
1297
+ ...(llmTestSubDirs ? { testSubDirs: llmTestSubDirs } : {}),
1298
+ ...(llmBasePageClass ? { basePageClass: llmBasePageClass } : {}),
1299
+ ...(_detectedBasePageImport ? { basePageImport: _detectedBasePageImport } : {}),
1300
+ ...(llmDriverClass ? { driverClass: llmDriverClass } : {}),
1301
+ ...(resolvedTestDataDir !== undefined ? { testDataDir: resolvedTestDataDir } : {}),
1302
+ ...(resolvedFixtureDir !== undefined ? { fixtureDir: resolvedFixtureDir } : {}),
1303
+ ...(resolvedIsTestFixtureModule ? { isTestFixtureModule: true } : {}),
1304
+ ...(resolvedLocatorsDir !== undefined ? { locatorsDir: resolvedLocatorsDir } : {}),
1305
+ ...(resolvedUtilsDir !== undefined ? { utilsDir: resolvedUtilsDir } : {}),
1306
+ ...(resolvedConfigDir !== undefined ? { configDir: resolvedConfigDir } : {}),
1307
+ ...(pageFileStem ? { pageFileStem } : {}),
1308
+ ...(locatorFileStem ? { locatorFileStem } : {}),
1309
+ ...(testDataFileStem ? { testDataFileStem } : {}),
1310
+ ...(fixtureFileStem ? { fixtureFileStem } : {}),
1311
+ };
1312
+ }
1313
+ /**
1314
+ * Extract Java base package from folder pattern or file paths.
1315
+ * e.g. "src/test/java/com/crm/qa/testc/{feature}Test.java" → "com.crm.qa"
1316
+ */
1317
+ function deriveJavaBasePackage(folderPattern, filePaths) {
1318
+ const paths = [folderPattern, ...(filePaths ?? [])].filter(Boolean);
1319
+ for (const p of paths) {
1320
+ const m = p.match(/src\/(?:test|main)\/java\/(.+?)(?:\/[^/]*\{[^}]*\}|\/[A-Z][^/]*\.java|\/?$)/);
1321
+ if (m && m[1]) {
1322
+ // Convert path to package, drop last segment if it looks feature-specific
1323
+ const parts = m[1].replace(/\/$/, '').split('/').filter(Boolean);
1324
+ // A segment is "feature-specific" if it looks like a feature folder (not a standard package segment)
1325
+ // Heuristic: if the last part is a short word matching the test class name pattern, drop it
1326
+ const pkg = parts.join('.');
1327
+ if (pkg && pkg.includes('.'))
1328
+ return pkg;
1329
+ if (pkg)
1330
+ return pkg;
1331
+ }
1332
+ }
1333
+ return null;
1334
+ }
1335
+ /**
1336
+ * Extract public methods from a TypeScript/JavaScript class.
1337
+ * Simple regex-based — good enough for generating import hints.
1338
+ */
1339
+ function extractTSClassMethods(content) {
1340
+ const classMatch = content.match(/(?:export\s+)?(?:default\s+)?class\s+(\w+)/);
1341
+ const className = classMatch?.[1] ?? 'UnknownPage';
1342
+ const methods = [];
1343
+ const seen = new Set();
1344
+ // Regular methods (sync + async, public + no modifier)
1345
+ const methodRe = /(?:async\s+)?(?:public\s+)?(?!private|protected|static|constructor|get\s|set\s)(\w+)\s*\(([^)]*)\)\s*(?::\s*([^{;]+?))?\s*\{/g;
1346
+ let m;
1347
+ while ((m = methodRe.exec(content)) !== null) {
1348
+ const name = m[1];
1349
+ if (['if', 'for', 'while', 'switch', 'catch'].includes(name))
1350
+ continue;
1351
+ if (seen.has(name))
1352
+ continue;
1353
+ seen.add(name);
1354
+ methods.push({ name, params: (m[2] ?? '').trim().slice(0, 80), returnType: (m[3] ?? 'void').trim().slice(0, 40) });
1355
+ }
1356
+ // Arrow function class fields: `name = () =>` or `name = async () =>`
1357
+ const arrowRe = /(?:public\s+)?(\w+)\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*([^=>{]+?))?\s*=>/g;
1358
+ while ((m = arrowRe.exec(content)) !== null) {
1359
+ const name = m[1];
1360
+ if (['constructor', 'if', 'for'].includes(name) || seen.has(name))
1361
+ continue;
1362
+ seen.add(name);
1363
+ methods.push({ name, params: (m[2] ?? '').trim().slice(0, 80), returnType: (m[3] ?? 'void').trim().slice(0, 40) });
1364
+ }
1365
+ // Getter methods: `get name()`
1366
+ const getterRe = /(?:public\s+)?get\s+(\w+)\s*\(\s*\)\s*(?::\s*([^{]+?))?\s*\{/g;
1367
+ while ((m = getterRe.exec(content)) !== null) {
1368
+ const name = `get_${m[1]}`;
1369
+ if (seen.has(name))
1370
+ continue;
1371
+ seen.add(name);
1372
+ methods.push({ name, params: '', returnType: (m[2] ?? 'unknown').trim().slice(0, 40) });
1373
+ }
1374
+ return { className, methods };
1375
+ }
1376
+ /**
1377
+ * Extract public methods from a Python class.
1378
+ */
1379
+ function extractPyClassMethods(content) {
1380
+ const methods = [];
1381
+ const seen = new Set();
1382
+ const lines = content.split('\n');
1383
+ let pendingDecorator = '';
1384
+ for (let i = 0; i < lines.length; i++) {
1385
+ const line = lines[i].trim();
1386
+ // Track last decorator before def
1387
+ if (line.startsWith('@')) {
1388
+ pendingDecorator = line;
1389
+ continue;
1390
+ }
1391
+ // Instance method: def name(self, ...)
1392
+ const instM = line.match(/^def\s+([a-z_]\w*)\s*\(self(?:,\s*([^)]*))?\)\s*(?:->\s*([^:]+))?:/);
1393
+ if (instM) {
1394
+ const name = instM[1];
1395
+ if (!name.startsWith('__') && !seen.has(name)) {
1396
+ seen.add(name);
1397
+ const isProp = pendingDecorator.includes('@property');
1398
+ methods.push({ name: isProp ? `get_${name}` : name, params: (instM[2] ?? '').trim().slice(0, 80), returnType: (instM[3] ?? 'None').trim().slice(0, 40) });
1399
+ }
1400
+ pendingDecorator = '';
1401
+ continue;
1402
+ }
1403
+ // Static/class method: def name(cls, ...) or def name(...)
1404
+ const staticM = line.match(/^def\s+([a-z_]\w*)\s*\((?:cls|(?![a-z]))[^)]*\)\s*(?:->\s*([^:]+))?:/);
1405
+ if (staticM && (pendingDecorator.includes('@staticmethod') || pendingDecorator.includes('@classmethod'))) {
1406
+ const name = staticM[1];
1407
+ if (!name.startsWith('_') && !seen.has(name)) {
1408
+ seen.add(name);
1409
+ methods.push({ name, params: '', returnType: (staticM[2] ?? 'None').trim().slice(0, 40) });
1410
+ }
1411
+ }
1412
+ pendingDecorator = '';
1413
+ }
1414
+ return methods;
1415
+ }
1416
+ function extractJavaClassMethods(content) {
1417
+ const methods = [];
1418
+ const seen = new Set();
1419
+ // Identify test/lifecycle annotation positions so we can skip those methods
1420
+ const testAnnotationRe = /@(?:Test|Before(?:Each|All)?|After(?:Each|All)?|BeforeMethod|AfterMethod|Given|When|Then|And|But)\b/g;
1421
+ const testAnnotationPositions = new Set();
1422
+ let am;
1423
+ while ((am = testAnnotationRe.exec(content)) !== null) {
1424
+ testAnnotationPositions.add(am.index);
1425
+ }
1426
+ // Match public and protected methods (not constructors, not main)
1427
+ const methodRe = /(?:public|protected)\s+(?!class|static\s+void\s+main)(?:[\w<>\[\]]+\s+)?(\w+)\s*\(([^)]*)\)/g;
1428
+ let m;
1429
+ while ((m = methodRe.exec(content)) !== null) {
1430
+ const name = m[1];
1431
+ if (['if', 'for', 'while', 'switch', 'catch', 'new', 'return', 'throw'].includes(name))
1432
+ continue;
1433
+ if (seen.has(name))
1434
+ continue;
1435
+ // Skip if a test annotation appears within 150 chars before this method
1436
+ const before = m.index;
1437
+ const hasTestAnnotation = [...testAnnotationPositions].some(pos => pos >= before - 150 && pos < before);
1438
+ if (hasTestAnnotation)
1439
+ continue;
1440
+ seen.add(name);
1441
+ methods.push({ name, params: (m[2] ?? '').trim().slice(0, 80), returnType: '' });
1442
+ }
1443
+ return methods;
1444
+ }
1445
+ /**
1446
+ * Detect whether a file looks like a Page Object (not a test file).
1447
+ */
1448
+ function isPageObjectFile(filePath, content) {
1449
+ const lower = filePath.toLowerCase();
1450
+ // Path-based signal — common PO/component/helper directories across all frameworks
1451
+ const PO_PATH_PATTERNS = [
1452
+ /(?:^|\/)pages\//i,
1453
+ /(?:^|\/)page-objects\//i,
1454
+ /(?:^|\/)pageobjects\//i,
1455
+ /(?:^|\/)pom\//i,
1456
+ /(?:^|\/)components\//i,
1457
+ /(?:^|\/)helpers?\//i,
1458
+ /(?:^|\/)services\//i,
1459
+ /(?:^|\/)actions\//i,
1460
+ /(?:^|\/)locators?\//i,
1461
+ /(?:^|\/)screens?\//i,
1462
+ /(?:^|\/)fragments?\//i,
1463
+ /(?:^|\/)step[_-]?definitions?\//i,
1464
+ /(?:^|\/)support\//i,
1465
+ /(?:^|\/)utils?\//i,
1466
+ /(?:^|\/)utilities\//i,
1467
+ ];
1468
+ if (PO_PATH_PATTERNS.some(rx => rx.test(lower)))
1469
+ return true;
1470
+ // Name-based signal: any file named *Page.* or *Screen.* or *Component.*
1471
+ if (/page\.(ts|js|py|java|kt)$/.test(lower) || /Page\.(ts|js|py|java|kt)$/.test(filePath))
1472
+ return true;
1473
+ if (/screen\.(ts|js|py|java|kt)$/.test(lower) || /Screen\.(ts|js|py|java|kt)$/.test(filePath))
1474
+ return true;
1475
+ // Content-based — when content is provided (second pass after fetch)
1476
+ if (content) {
1477
+ if (/class\s+\w+Page/.test(content) || /class\s+\w+PO/.test(content))
1478
+ return true;
1479
+ if (/class\s+\w+Component/.test(content) || /class\s+\w+Helper/.test(content))
1480
+ return true;
1481
+ if (/class\s+\w+Screen/.test(content) || /class\s+\w+Fragment/.test(content))
1482
+ return true;
1483
+ if (/class\s+\w+Actions?/.test(content) || /class\s+\w+Service/.test(content))
1484
+ return true;
1485
+ if (content.includes('@FindBy') || content.includes('@FindBys'))
1486
+ return true;
1487
+ // Python: class inherits BasePage or similar
1488
+ if (/class\s+\w+\s*\(\s*\w*[Pp]age\w*\s*\)/.test(content))
1489
+ return true;
1490
+ // Playwright fixture: exports extended `test` object
1491
+ if (/export\s+(?:const\s+)?test\s*=/.test(content) && content.includes('extend'))
1492
+ return true;
1493
+ }
1494
+ return false;
1495
+ }
1496
+ /**
1497
+ * Build the page object inventory from all non-test repo files.
1498
+ * Pass up to 60 candidate files for efficiency.
1499
+ */
1500
+ async function buildPageObjectInventory(token, repoFullName, allPaths, framework) {
1501
+ const isPython = framework === 'pytest' || framework === 'selenium-py';
1502
+ const isJava = framework === 'selenium-java' || framework === 'playwright-java';
1503
+ const ext = isPython ? /\.(py)$/ : isJava ? /\.(java|kt)$/ : /\.(ts|js)$/;
1504
+ const baseFilter = (p) => ext.test(p) && !p.includes('node_modules') && !p.includes('dist/') && !p.includes('.config.');
1505
+ // Pass 1: path-based known PO directories — high confidence
1506
+ const strictCandidates = allPaths.filter(baseFilter).filter((p) => isPageObjectFile(p, ''));
1507
+ // Pass 2: any non-test source file for content-based detection (catches POs in unexpected dirs)
1508
+ const isTestFile = (p) => /\.(spec|test|e2e|cy|steps|integration|smoke)\.(ts|js|py)$/.test(p)
1509
+ || p.endsWith('.feature') || /Test(s)?\.java$/.test(p) || p.endsWith('_test.py') || /\/test_/.test(p);
1510
+ const strictSet = new Set(strictCandidates);
1511
+ // NOTE: for Java, page objects live under src/test/java/pages — do NOT filter /test/ for Java
1512
+ const looseCandidates = allPaths
1513
+ .filter(baseFilter)
1514
+ .filter((p) => !strictSet.has(p) && !isTestFile(p) && (isJava || (!p.includes('/tests/') && !p.includes('/spec/'))))
1515
+ .slice(0, 40);
1516
+ const candidates = [...strictCandidates.slice(0, 110), ...looseCandidates];
1517
+ const processCandidateBatch = async (batch) => {
1518
+ const results = await Promise.all(batch.map(async (filePath) => {
1519
+ try {
1520
+ const content = await fetchFileContent(token, repoFullName, filePath);
1521
+ if (!content || content.length < 50)
1522
+ return null;
1523
+ if (!isPageObjectFile(filePath, content))
1524
+ return null;
1525
+ let className = 'UnknownPage';
1526
+ let methods = [];
1527
+ if (isPython) {
1528
+ const cm = content.match(/class\s+(\w+)/);
1529
+ className = cm?.[1] ?? 'UnknownPage';
1530
+ methods = extractPyClassMethods(content);
1531
+ }
1532
+ else if (isJava) {
1533
+ const cm = content.match(/(?:public\s+)?class\s+(\w+)/);
1534
+ className = cm?.[1] ?? 'UnknownPage';
1535
+ methods = extractJavaClassMethods(content);
1536
+ }
1537
+ else {
1538
+ const result = extractTSClassMethods(content);
1539
+ className = result.className;
1540
+ methods = result.methods;
1541
+ }
1542
+ if (methods.length === 0)
1543
+ return null;
1544
+ return {
1545
+ filePath,
1546
+ className,
1547
+ methods: methods.slice(0, 20),
1548
+ importPath: filePath.replace(/\.(ts|js|java|kt|py)$/, ''),
1549
+ };
1550
+ }
1551
+ catch {
1552
+ return null;
1553
+ }
1554
+ }));
1555
+ return results.filter((r) => r !== null);
1556
+ };
1557
+ // Fetch in batches of 10 — respects GitHub API concurrency without hammering rate limits
1558
+ const BATCH_SIZE = 10;
1559
+ const summaries = [];
1560
+ for (let i = 0; i < candidates.length; i += BATCH_SIZE) {
1561
+ const batch = candidates.slice(i, i + BATCH_SIZE);
1562
+ summaries.push(...await processCandidateBatch(batch));
1563
+ }
1564
+ return summaries;
1565
+ }
1566
+ /**
1567
+ * Format page object inventory as a prompt-friendly string for LLM injection.
1568
+ */
1569
+ function formatPageObjectInventoryForPrompt(inventory, urlHint) {
1570
+ if (inventory.length === 0)
1571
+ return '';
1572
+ // Relevance-rank: boost classes whose file path or class name matches URL segments
1573
+ let ranked = inventory;
1574
+ if (urlHint) {
1575
+ const slug = urlHint.toLowerCase().replace(/[^a-z0-9]/g, '');
1576
+ ranked = [...inventory].sort((a, b) => {
1577
+ const aScore = (a.filePath.toLowerCase().replace(/[^a-z0-9]/g, '').includes(slug) || a.className.toLowerCase().includes(slug)) ? 1 : 0;
1578
+ const bScore = (b.filePath.toLowerCase().replace(/[^a-z0-9]/g, '').includes(slug) || b.className.toLowerCase().includes(slug)) ? 1 : 0;
1579
+ return bScore - aScore;
1580
+ });
1581
+ }
1582
+ const lines = ['=== EXISTING PAGE OBJECTS — reuse these, do not reinvent ==='];
1583
+ for (const po of ranked.slice(0, 25)) {
1584
+ const methodList = po.methods.slice(0, 12).map((m) => `${m.name}(${m.params})`).join(', ');
1585
+ lines.push(`${po.className} (${po.filePath}): ${methodList}`);
1586
+ }
1587
+ return lines.join('\n');
1588
+ }
1589
+ // ---------------------------------------------------------------------------
1590
+ // Repo Target Path Derivation
1591
+ // ---------------------------------------------------------------------------
1592
+ /**
1593
+ * Given a screen URL and the repo's detected style profile, derive
1594
+ * the canonical path where a new test script for this screen should live.
1595
+ *
1596
+ * Example:
1597
+ * url: https://app.acme.com/reports/export
1598
+ * folderPattern: tests/e2e/
1599
+ * framework: playwright-ts
1600
+ * → tests/e2e/reports-export.spec.ts
1601
+ */
1602
+ function deriveRepoTargetPath(screenUrl, styleProfile, framework) {
1603
+ // Determine base folder from style profile. folderPattern is a template like
1604
+ // "e2e/tests/{feature}.spec.ts" (see detectFolderPattern below) — it embeds the
1605
+ // filename shape, not just a directory. Using it verbatim as a directory prefix
1606
+ // produced paths like "e2e/tests/{feature}.spec.ts/login-page.spec.ts": a literal
1607
+ // "{feature}.spec.ts" folder that doesn't exist in the repo, so pushes landed in a
1608
+ // brand-new bogus directory instead of the repo's real test folder. Strip the
1609
+ // {placeholder}... filename segment first, same as pomPaths() does for the same field.
1610
+ const rawPattern = styleProfile.folderPattern ?? '';
1611
+ let base = rawPattern
1612
+ ? rawPattern.replace(/\{[^}]+\}.*$/, '').replace(/\/[^/]*$/, '/').replace(/^\//, '')
1613
+ : '';
1614
+ if (!base) {
1615
+ base = framework.startsWith('cypress') ? 'cypress/e2e/'
1616
+ : (framework === 'pytest' || framework === 'playwright-py' || framework === 'selenium-py') ? 'tests/'
1617
+ : framework === 'junit' || framework === 'playwright-java' || framework === 'selenium-java' ? 'src/test/java/'
1618
+ : 'tests/e2e/';
1619
+ }
1620
+ base = base.replace(/\/?$/, '/'); // ensure trailing slash
1621
+ // Convert URL path to slug
1622
+ let slug;
1623
+ try {
1624
+ const parsed = new URL(screenUrl);
1625
+ slug = parsed.pathname
1626
+ .replace(/^\//, '') // strip leading slash
1627
+ .replace(/\/+$/, '') // strip trailing slash
1628
+ .replace(/[^a-zA-Z0-9/-]/g, '-')
1629
+ .replace(/-+/g, '-')
1630
+ .replace(/\//g, '-')
1631
+ .toLowerCase()
1632
+ || 'home';
1633
+ }
1634
+ catch {
1635
+ slug = 'screen';
1636
+ }
1637
+ // When testSubDirs is populated, pick the most appropriate subdir based on URL path.
1638
+ // Simple heuristic: if URL contains /api/ or path starts with api, prefer tests/api subdir.
1639
+ if (styleProfile.testSubDirs && styleProfile.testSubDirs.length > 1) {
1640
+ let urlPath = '';
1641
+ try {
1642
+ urlPath = new URL(screenUrl).pathname.toLowerCase();
1643
+ }
1644
+ catch { /* ignore */ }
1645
+ const isApiPath = /\/(api|graphql|rest|v\d+)\b/.test(urlPath);
1646
+ const apiDir = styleProfile.testSubDirs.find(d => /api|graphql|rest/.test(d.toLowerCase()));
1647
+ const uiDir = styleProfile.testSubDirs.find(d => /ui|e2e|web|browser/.test(d.toLowerCase()));
1648
+ const preferredDir = isApiPath ? (apiDir ?? styleProfile.testSubDirs[0]) : (uiDir ?? styleProfile.testSubDirs[0]);
1649
+ base = `${preferredDir.replace(/\/?$/, '/')}`;
1650
+ }
1651
+ // Determine file extension — Kotlin gets .kt suffix for Java frameworks
1652
+ const isKotlin = (framework === 'junit' || framework === 'playwright-java' || framework === 'selenium-java')
1653
+ && styleProfile.namingPattern?.endsWith('.kt');
1654
+ const ext = (framework === 'pytest' || framework === 'playwright-py' || framework === 'selenium-py') ? '_test.py'
1655
+ : isKotlin ? 'Test.kt'
1656
+ : (framework === 'junit' || framework === 'playwright-java' || framework === 'selenium-java') ? 'Test.java'
1657
+ : framework.includes('ts') ? '.spec.ts'
1658
+ : '.spec.js';
1659
+ // Apply naming pattern from style profile if it tells us something
1660
+ const naming = styleProfile.namingPattern ?? '';
1661
+ if (naming.includes('.test.')) {
1662
+ return `${base}${slug}.test.${framework.includes('ts') ? 'ts' : 'js'}`;
1663
+ }
1664
+ if (naming.includes('.cy.')) {
1665
+ return `${base}${slug}.cy.${framework.includes('ts') ? 'ts' : 'js'}`;
1666
+ }
1667
+ if (naming.includes('.e2e.')) {
1668
+ return `${base}${slug}.e2e.${framework.includes('ts') ? 'ts' : 'js'}`;
1669
+ }
1670
+ return `${base}${slug}${ext}`;
1671
+ }
1672
+ /** Strip leading ./ and trailing / from a path string. */
1673
+ function normDir(s) { return s.replace(/^\.\//, '').replace(/\/$/, ''); }
1674
+ /** Extract the glob root directory (before first ** or * wildcard). */
1675
+ function globRoot(pattern) {
1676
+ const root = pattern.replace(/\/\*\*.*$/, '').replace(/\/\*[^*].*$/, '');
1677
+ return (root && !root.startsWith('*') && !root.startsWith('{')) ? normDir(root) : null;
1678
+ }
1679
+ /**
1680
+ * Extract path-relevant settings from framework config files using regex/JSON.
1681
+ * No LLM — these files have predictable, well-documented structure.
1682
+ */
1683
+ function parseFrameworkConfig(configType, content) {
1684
+ const result = {};
1685
+ // ── TypeScript / tsconfig.json ────────────────────────────────────────────
1686
+ if (configType === 'tsconfig') {
1687
+ try {
1688
+ const stripped = content.replace(/\/\/[^\n]*/g, '').replace(/,(\s*[}\]])/g, '$1');
1689
+ const parsed = JSON.parse(stripped);
1690
+ const opts = parsed?.compilerOptions ?? {};
1691
+ if (opts.paths && typeof opts.paths === 'object')
1692
+ result.tsconfigPaths = opts.paths;
1693
+ if (typeof opts.baseUrl === 'string')
1694
+ result.tsconfigBaseUrl = opts.baseUrl;
1695
+ }
1696
+ catch { /* malformed */ }
1697
+ return result;
1698
+ }
1699
+ // ── Playwright ────────────────────────────────────────────────────────────
1700
+ if (configType === 'playwright') {
1701
+ const tdM = content.match(/\btestDir\s*:\s*['"`]([^'"`]+)['"`]/);
1702
+ if (tdM)
1703
+ result.testDir = normDir(tdM[1]);
1704
+ const buM = content.match(/\bbaseURL\s*:\s*['"`]([^'"`]+)['"`]/);
1705
+ if (buM)
1706
+ result.baseURL = buM[1];
1707
+ return result;
1708
+ }
1709
+ // ── Cypress ───────────────────────────────────────────────────────────────
1710
+ if (configType === 'cypress') {
1711
+ // Modern: specPattern (v10+)
1712
+ const spM = content.match(/\bspecPattern\s*:\s*['"`]([^'"`]+)['"`]/);
1713
+ if (spM) {
1714
+ const r = globRoot(spM[1]);
1715
+ if (r)
1716
+ result.testDir = r;
1717
+ }
1718
+ // Legacy: testFiles (v9 and below)
1719
+ if (!result.testDir) {
1720
+ const tfM = content.match(/\btestFiles\s*:\s*['"`]([^'"`]+)['"`]/);
1721
+ if (tfM) {
1722
+ const r = globRoot(tfM[1]);
1723
+ if (r)
1724
+ result.testDir = r;
1725
+ }
1726
+ }
1727
+ // Default: integrationFolder (v9)
1728
+ if (!result.testDir) {
1729
+ const ifM = content.match(/\bintegrationFolder\s*:\s*['"`]([^'"`]+)['"`]/);
1730
+ if (ifM)
1731
+ result.testDir = normDir(ifM[1]);
1732
+ }
1733
+ const ffM = content.match(/\bfixturesFolder\s*:\s*['"`]([^'"`]+)['"`]/);
1734
+ if (ffM)
1735
+ result.fixturesFolder = normDir(ffM[1]);
1736
+ const buM = content.match(/\bbaseUrl\s*:\s*['"`]([^'"`]+)['"`]/);
1737
+ if (buM)
1738
+ result.baseURL = buM[1];
1739
+ return result;
1740
+ }
1741
+ // ── Jest ──────────────────────────────────────────────────────────────────
1742
+ if (configType === 'jest') {
1743
+ // roots: ['<rootDir>/src'] or roots: ['src']
1744
+ const rootsM = content.match(/\broots\s*:\s*\[([^\]]+)\]/);
1745
+ if (rootsM) {
1746
+ const firstRoot = rootsM[1].match(/['"`]([^'"`]+)['"`]/)?.[1];
1747
+ if (firstRoot)
1748
+ result.testDir = firstRoot.replace(/<rootDir>\/?/, '').replace(/\/$/, '') || 'src';
1749
+ }
1750
+ // testMatch globs
1751
+ if (!result.testDir) {
1752
+ const tmM = content.match(/\btestMatch\s*:\s*\[([^\]]+)\]/);
1753
+ if (tmM) {
1754
+ const firstPat = tmM[1].match(/['"`]([^'"`]+)['"`]/)?.[1];
1755
+ if (firstPat) {
1756
+ const r = globRoot(firstPat.replace(/<rootDir>\/?/, ''));
1757
+ if (r)
1758
+ result.testDir = r;
1759
+ }
1760
+ }
1761
+ }
1762
+ // testRegex: some repos use regex instead of glob
1763
+ if (!result.testDir) {
1764
+ const trM = content.match(/\btestRegex\s*:\s*['"`]([^'"`]+)['"`]/);
1765
+ if (trM) {
1766
+ // Extract directory prefix from regex (e.g. "__tests__" from "/__tests__/.*\\.test\\.ts$")
1767
+ const dirM = trM[1].match(/^[/\\]?(\w[\w-]*)[/\\]/);
1768
+ if (dirM)
1769
+ result.testDir = dirM[1];
1770
+ }
1771
+ }
1772
+ // moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1' }
1773
+ const mnmM = content.match(/\bmoduleNameMapper\s*:\s*\{([^}]+)\}/s);
1774
+ if (mnmM) {
1775
+ const mapper = {};
1776
+ const pairs = mnmM[1].matchAll(/['"`]([^'"`]+)['"`]\s*:\s*['"`]([^'"`]+)['"`]/g);
1777
+ for (const [, k, v] of pairs)
1778
+ mapper[k] = v;
1779
+ if (Object.keys(mapper).length > 0)
1780
+ result.jestModuleNameMapper = mapper;
1781
+ }
1782
+ return result;
1783
+ }
1784
+ // ── package.json — jest field + test script ───────────────────────────────
1785
+ if (configType === 'package-json') {
1786
+ try {
1787
+ const parsed = JSON.parse(content);
1788
+ const jestCfg = parsed?.jest ?? {};
1789
+ if (Array.isArray(jestCfg.roots) && jestCfg.roots[0]) {
1790
+ result.testDir = String(jestCfg.roots[0]).replace(/<rootDir>\/?/, '').replace(/\/$/, '') || 'src';
1791
+ }
1792
+ if (!result.testDir && Array.isArray(jestCfg.testMatch) && jestCfg.testMatch[0]) {
1793
+ const r = globRoot(String(jestCfg.testMatch[0]).replace(/<rootDir>\/?/, ''));
1794
+ if (r)
1795
+ result.testDir = r;
1796
+ }
1797
+ if (jestCfg.moduleNameMapper && typeof jestCfg.moduleNameMapper === 'object') {
1798
+ result.jestModuleNameMapper = jestCfg.moduleNameMapper;
1799
+ }
1800
+ }
1801
+ catch { /* malformed JSON */ }
1802
+ return result;
1803
+ }
1804
+ // ── Python pytest.ini / tox.ini ───────────────────────────────────────────
1805
+ if (configType === 'pytest-ini') {
1806
+ // Works for pytest.ini ([pytest] section), tox.ini ([pytest] section), setup.cfg ([tool:pytest])
1807
+ const tpM = content.match(/^\s*testpaths\s*=\s*(.+)$/m);
1808
+ if (tpM) {
1809
+ const first = tpM[1].trim().split(/\s+/)[0];
1810
+ if (first)
1811
+ result.testDir = normDir(first);
1812
+ }
1813
+ // python_files hint (e.g. python_files = test_*.py *_tests.py)
1814
+ // Not stored as testDir but logged for awareness
1815
+ return result;
1816
+ }
1817
+ // ── Python pyproject.toml ─────────────────────────────────────────────────
1818
+ if (configType === 'pyproject-toml') {
1819
+ // [tool.pytest.ini_options]
1820
+ // testpaths = ["tests"]
1821
+ const tpM = content.match(/testpaths\s*=\s*\[([^\]]+)\]/);
1822
+ if (tpM) {
1823
+ const first = tpM[1].match(/['"]([^'"]+)['"]/)?.[1];
1824
+ if (first)
1825
+ result.testDir = normDir(first);
1826
+ }
1827
+ // Fallback: testpaths = "tests" (single string)
1828
+ if (!result.testDir) {
1829
+ const tpM2 = content.match(/testpaths\s*=\s*['"]([^'"]+)['"]/);
1830
+ if (tpM2)
1831
+ result.testDir = normDir(tpM2[1]);
1832
+ }
1833
+ return result;
1834
+ }
1835
+ // ── Python setup.cfg ──────────────────────────────────────────────────────
1836
+ if (configType === 'setup-cfg') {
1837
+ // [tool:pytest] or [pytest]
1838
+ // testpaths = tests
1839
+ const tpM = content.match(/^\s*testpaths\s*=\s*(.+)$/m);
1840
+ if (tpM) {
1841
+ const first = tpM[1].trim().split(/[\s,]+/)[0];
1842
+ if (first)
1843
+ result.testDir = normDir(first);
1844
+ }
1845
+ return result;
1846
+ }
1847
+ // ── Java Maven pom.xml ────────────────────────────────────────────────────
1848
+ if (configType === 'pom-xml') {
1849
+ // <testSourceDirectory>src/test/java</testSourceDirectory>
1850
+ const tsdM = content.match(/<testSourceDirectory>\s*([^<]+)\s*<\/testSourceDirectory>/);
1851
+ if (tsdM)
1852
+ result.javaTestSourceDir = normDir(tsdM[1].trim());
1853
+ // Surefire <include>**/*Test.java</include> — no testDir derivation needed (use testSourceDir)
1854
+ return result;
1855
+ }
1856
+ // ── Java Gradle build.gradle / build.gradle.kts ───────────────────────────
1857
+ if (configType === 'build-gradle') {
1858
+ // Groovy DSL: srcDirs = ['src/test/java'] or srcDir 'src/test/java'
1859
+ const srcM = content.match(/srcDir[s]?\s*[=([]?\s*['"`]([^'"`]+)['"`]/);
1860
+ if (srcM)
1861
+ result.javaTestSourceDir = normDir(srcM[1]);
1862
+ // Kotlin DSL: srcDir("src/test/kotlin") or setSrcDirs(listOf("src/test/java"))
1863
+ if (!result.javaTestSourceDir) {
1864
+ const kM = content.match(/srcDir\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/);
1865
+ if (kM)
1866
+ result.javaTestSourceDir = normDir(kM[1]);
1867
+ }
1868
+ if (!result.javaTestSourceDir) {
1869
+ const listM = content.match(/(?:setSrcDirs|setOf|listOf)\s*\(\s*['"`]([^'"`]+)['"`]/);
1870
+ if (listM)
1871
+ result.javaTestSourceDir = normDir(listM[1]);
1872
+ }
1873
+ // sourceSets.test.java.srcDirs = ['...'] Groovy style
1874
+ if (!result.javaTestSourceDir) {
1875
+ const ssM = content.match(/sourceSets\s*\.\s*test\s*\.\s*(?:java|kotlin)\s*\.\s*srcDir[s]?\s*[=([]?\s*['"`]([^'"`]+)['"`]/);
1876
+ if (ssM)
1877
+ result.javaTestSourceDir = normDir(ssM[1]);
1878
+ }
1879
+ return result;
1880
+ }
1881
+ // ── Java TestNG testng.xml ────────────────────────────────────────────────
1882
+ if (configType === 'testng-xml') {
1883
+ // <package name="com.example.tests"/> → testDir is src/test/java (implied, but extract package)
1884
+ // We store nothing here — testng.xml is less reliable for dir; pom.xml is better
1885
+ return result;
1886
+ }
1887
+ // ── WebdriverIO wdio.conf.ts / wdio.conf.js ───────────────────────────────
1888
+ if (configType === 'wdio') {
1889
+ // specs: ['./test/specs/**/*.spec.ts'] or specs: ['test/specs/**/*.e2e.ts']
1890
+ const specsM = content.match(/\bspecs\s*:\s*\[([^\]]+)\]/);
1891
+ if (specsM) {
1892
+ const firstSpec = specsM[1].match(/['"`]([^'"`]+)['"`]/)?.[1];
1893
+ if (firstSpec) {
1894
+ const r = globRoot(firstSpec);
1895
+ if (r)
1896
+ result.testDir = r;
1897
+ }
1898
+ }
1899
+ const buM = content.match(/\bbaseUrl\s*:\s*['"`]([^'"`]+)['"`]/);
1900
+ if (buM)
1901
+ result.baseURL = buM[1];
1902
+ // cucumberOpts.require or specs for page objects
1903
+ const ffM = content.match(/\bfixturesFolder\s*:\s*['"`]([^'"`]+)['"`]/);
1904
+ if (ffM)
1905
+ result.fixturesFolder = normDir(ffM[1]);
1906
+ return result;
1907
+ }
1908
+ // ── Cucumber .cucumber.js / cucumber.json / .cucumber.yaml ───────────────
1909
+ if (configType === 'cucumber') {
1910
+ // JS: paths: ['features/**/*.feature'] or default: { paths: [...] }
1911
+ const pathsM = content.match(/\bpaths\s*:\s*\[([^\]]+)\]/);
1912
+ if (pathsM) {
1913
+ const firstPat = pathsM[1].match(/['"`]([^'"`]+)['"`]/)?.[1];
1914
+ if (firstPat) {
1915
+ const r = globRoot(firstPat);
1916
+ if (r)
1917
+ result.testDir = r;
1918
+ }
1919
+ }
1920
+ // JSON: {"default": {"paths": ["features/**/*.feature"]}}
1921
+ if (!result.testDir) {
1922
+ try {
1923
+ const parsed = JSON.parse(content);
1924
+ const paths = parsed?.default?.paths ?? parsed?.paths;
1925
+ if (Array.isArray(paths) && paths[0]) {
1926
+ const r = globRoot(String(paths[0]));
1927
+ if (r)
1928
+ result.testDir = r;
1929
+ }
1930
+ }
1931
+ catch { /* not JSON */ }
1932
+ }
1933
+ // YAML: paths:\n - features
1934
+ if (!result.testDir) {
1935
+ const yamlM = content.match(/^paths:\s*\n\s+-\s+(.+)$/m);
1936
+ if (yamlM) {
1937
+ const r = globRoot(yamlM[1].trim());
1938
+ if (r)
1939
+ result.testDir = r;
1940
+ }
1941
+ }
1942
+ return result;
1943
+ }
1944
+ return result;
1945
+ }
1946
+ /**
1947
+ * Given a repo's file tree paths and detected framework, return which config
1948
+ * files to fetch and what parser to use for each.
1949
+ * Covers all supported frameworks: playwright, cypress, jest, pytest, junit,
1950
+ * selenium-java/py, webdriverio, cucumber.
1951
+ */
1952
+ function detectConfigFilesToFetch(paths, framework) {
1953
+ const pathSet = new Set(paths);
1954
+ const result = [];
1955
+ const added = new Set();
1956
+ // Exact-match helper — tries known root-level and shallow config paths
1957
+ const tryAdd = (candidates, type) => {
1958
+ for (const c of candidates) {
1959
+ if (pathSet.has(c) && !added.has(c)) {
1960
+ result.push({ path: c, type });
1961
+ added.add(c);
1962
+ break;
1963
+ }
1964
+ }
1965
+ };
1966
+ // Monorepo fallback — scan tree for config at any depth, prefer shallowest
1967
+ const scanTree = (fileRe, type) => {
1968
+ if (result.some(r => r.type === type))
1969
+ return; // already found via exact match
1970
+ const matches = paths.filter(p => fileRe.test(p) && !added.has(p) && !p.includes('node_modules/'));
1971
+ if (matches.length === 0)
1972
+ return;
1973
+ // Pick shallowest (fewest path segments)
1974
+ matches.sort((a, b) => a.split('/').length - b.split('/').length);
1975
+ result.push({ path: matches[0], type });
1976
+ added.add(matches[0]);
1977
+ };
1978
+ const lang = framework.includes('java') ? 'java'
1979
+ : (framework.includes('py') || framework === 'pytest') ? 'python'
1980
+ : 'js';
1981
+ // ── JavaScript / TypeScript frameworks ───────────────────────────────────
1982
+ if (lang === 'js') {
1983
+ if (framework.startsWith('playwright')) {
1984
+ tryAdd([
1985
+ 'playwright.config.ts', 'playwright.config.js', 'playwright.config.mts',
1986
+ 'e2e/playwright.config.ts', 'config/playwright.config.ts', 'config/test.config.ts',
1987
+ ], 'playwright');
1988
+ // Monorepo: e.g. apps/web/playwright.config.ts, packages/e2e/playwright.config.ts
1989
+ scanTree(/(?:^|\/)playwright\.config\.(ts|js|mts|cjs)$/, 'playwright');
1990
+ }
1991
+ if (framework.startsWith('cypress')) {
1992
+ tryAdd([
1993
+ 'cypress.config.ts', 'cypress.config.js',
1994
+ 'e2e/cypress.config.ts', 'cypress/cypress.config.ts',
1995
+ ], 'cypress');
1996
+ scanTree(/(?:^|\/)cypress\.config\.(ts|js)$/, 'cypress');
1997
+ }
1998
+ if (framework === 'jest-ts' || framework === 'jest-js') {
1999
+ tryAdd(['jest.config.ts', 'jest.config.js', 'jest.config.mjs', 'jest.config.cjs', 'jest.config.json'], 'jest');
2000
+ tryAdd(['package.json'], 'package-json');
2001
+ scanTree(/(?:^|\/)jest\.config\.(ts|js|mjs|cjs|json)$/, 'jest');
2002
+ }
2003
+ if (framework === 'webdriverio') {
2004
+ tryAdd(['wdio.conf.ts', 'wdio.conf.js', 'wdio.config.ts', 'wdio.config.js', 'wdio.config.mjs'], 'wdio');
2005
+ scanTree(/(?:^|\/)wdio\.(?:conf|config)\.(ts|js|mjs)$/, 'wdio');
2006
+ }
2007
+ if (framework === 'cucumber' || framework === 'gherkin') {
2008
+ tryAdd(['.cucumber.js', '.cucumber.cjs', 'cucumber.json', '.cucumber.yaml', '.cucumber.yml', '.cucumberrc.json'], 'cucumber');
2009
+ tryAdd(['package.json'], 'package-json');
2010
+ }
2011
+ // tsconfig.json always valuable for TS repos — import aliases
2012
+ tryAdd(['tsconfig.json', 'tsconfig.base.json'], 'tsconfig');
2013
+ scanTree(/(?:^|\/)tsconfig(?:\.base)?\.json$/, 'tsconfig');
2014
+ }
2015
+ // ── Python frameworks ─────────────────────────────────────────────────────
2016
+ if (lang === 'python') {
2017
+ tryAdd(['pytest.ini', '.pytest.ini'], 'pytest-ini');
2018
+ tryAdd(['pyproject.toml'], 'pyproject-toml');
2019
+ tryAdd(['setup.cfg'], 'setup-cfg');
2020
+ tryAdd(['tox.ini'], 'pytest-ini'); // tox.ini may contain [pytest] section
2021
+ scanTree(/(?:^|\/)pyproject\.toml$/, 'pyproject-toml');
2022
+ }
2023
+ // ── Java frameworks ───────────────────────────────────────────────────────
2024
+ if (lang === 'java') {
2025
+ tryAdd(['pom.xml'], 'pom-xml');
2026
+ tryAdd(['build.gradle.kts', 'build.gradle'], 'build-gradle');
2027
+ tryAdd(['testng.xml', 'src/test/resources/testng.xml', 'testng-suite.xml', 'src/test/resources/testng-suite.xml'], 'testng-xml');
2028
+ scanTree(/(?:^|\/)pom\.xml$/, 'pom-xml');
2029
+ scanTree(/(?:^|\/)build\.gradle(?:\.kts)?$/, 'build-gradle');
2030
+ }
2031
+ return result;
2032
+ }