@taodigital/agents 0.0.0-stage → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -2
- package/dist/api-test-agent.d.ts +126 -0
- package/dist/api-test-agent.js +267 -0
- package/dist/artifact-intel-agent.d.ts +11 -0
- package/dist/artifact-intel-agent.js +152 -0
- package/dist/coverage-gap-agent.d.ts +25 -0
- package/dist/coverage-gap-agent.js +269 -0
- package/dist/coverage-map-agent.d.ts +15 -0
- package/dist/coverage-map-agent.js +255 -0
- package/dist/crawl-diff-agent.d.ts +27 -0
- package/dist/crawl-diff-agent.js +412 -0
- package/dist/data-agent.d.ts +101 -0
- package/dist/data-agent.js +2284 -0
- package/dist/document-agent.d.ts +10 -0
- package/dist/document-agent.js +32 -0
- package/dist/enhancement-agent.d.ts +48 -0
- package/dist/enhancement-agent.js +342 -0
- package/dist/field-detector-agent.d.ts +35 -0
- package/dist/field-detector-agent.js +220 -0
- package/dist/flow-agent.d.ts +130 -0
- package/dist/flow-agent.js +169 -0
- package/dist/github-agent.d.ts +18 -0
- package/dist/github-agent.js +39 -0
- package/dist/github-write-agent.d.ts +67 -0
- package/dist/github-write-agent.js +249 -0
- package/dist/grouping-agent.d.ts +18 -0
- package/dist/grouping-agent.js +338 -0
- package/dist/healing-agent.d.ts +73 -0
- package/dist/healing-agent.js +137 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +67 -0
- package/dist/integration-test-agent.d.ts +118 -0
- package/dist/integration-test-agent.js +165 -0
- package/dist/label-assignment-agent.d.ts +15 -0
- package/dist/label-assignment-agent.js +97 -0
- package/dist/llm-config.d.ts +38 -0
- package/dist/llm-config.js +287 -0
- package/dist/llm-mock.d.ts +77 -0
- package/dist/llm-mock.js +192 -0
- package/dist/llm.d.ts +58 -0
- package/dist/llm.js +438 -0
- package/dist/nlp-agent.d.ts +208 -0
- package/dist/nlp-agent.js +340 -0
- package/dist/orchestrator.d.ts +285 -0
- package/dist/orchestrator.js +141 -0
- package/dist/plan-agent.d.ts +4 -0
- package/dist/plan-agent.js +200 -0
- package/dist/repo-scan-agent.d.ts +189 -0
- package/dist/repo-scan-agent.js +2032 -0
- package/dist/requirements-agent.d.ts +59 -0
- package/dist/requirements-agent.js +641 -0
- package/dist/scenario-agent.d.ts +20 -0
- package/dist/scenario-agent.js +123 -0
- package/dist/script-agent.d.ts +447 -0
- package/dist/script-agent.js +11813 -0
- package/dist/security-test-agent.d.ts +111 -0
- package/dist/security-test-agent.js +141 -0
- package/dist/skill-file-agent.d.ts +264 -0
- package/dist/skill-file-agent.js +3470 -0
- package/dist/step-coverage.d.ts +46 -0
- package/dist/step-coverage.js +288 -0
- package/dist/step-utils.d.ts +2 -0
- package/dist/step-utils.js +62 -0
- package/dist/test-case-agent.d.ts +206 -0
- package/dist/test-case-agent.js +2141 -0
- package/dist/test-import-agent.d.ts +148 -0
- package/dist/test-import-agent.js +3235 -0
- package/dist/test-script-agent.d.ts +16 -0
- package/dist/test-script-agent.js +48 -0
- package/dist/token-pricing.d.ts +14 -0
- package/dist/token-pricing.js +62 -0
- package/dist/types.d.ts +232 -0
- package/dist/types.js +7 -0
- package/dist/visual-agent.d.ts +114 -0
- package/dist/visual-agent.js +261 -0
- 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
|
+
}
|