genat-mcp 2.2.4 → 2.2.6
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 +8 -0
- package/detect-framework.js +231 -30
- package/index.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,6 +40,14 @@ npm install /path/to/n8n_playwright_tests/mcp-genat
|
|
|
40
40
|
| **N8N_ANALYZE_STATES** | No | (off) | Set to `1`, `true`, or `yes` to analyze dropdown/combobox expanded states and include state-specific violations in generated tests. Used as default when the tool is invoked without `analyzeStates`; also used by run-genat.mjs. Increases analysis time. |
|
|
41
41
|
| **GENAT_PROJECT_ROOT** | No | (cwd) | Base path for resolving `parentProjectFolder`. When set, `parentProjectFolder` is resolved relative to this path. Use when your project is outside the MCP's working directory. Example: if your pytest project is at `/Users/me/projects/ill_tests`, set `GENAT_PROJECT_ROOT=/Users/me/projects` and use `parentProjectFolder ill_tests` in the prompt. |
|
|
42
42
|
|
|
43
|
+
## Framework detection and auto-discovery
|
|
44
|
+
|
|
45
|
+
GenAT detects **language** (TypeScript, JavaScript, Python), **BDD framework** (Cucumber, pytest-bdd, Behave), and **Page Object pattern** from the project folder.
|
|
46
|
+
|
|
47
|
+
**Auto-discovery:** When you pass a folder that contains both tooling (e.g. `package.json` at root) and a test subfolder (`tests/`, `e2e/`, `tests/e2e/`, `tests/accessibility/`), GenAT scores each candidate and uses the folder with the most test-specific content (`.feature` files, `conftest.py`, `*_test.py`, `.spec.ts`, `pages/`, `steps/`, etc.). This ensures correct detection when tests live in subfolders.
|
|
48
|
+
|
|
49
|
+
**Path guidance:** Pass the project root (e.g. `.` or `/path/to/project`). GenAT will auto-discover the best folder when tests are in `tests/` or `e2e/`. For monorepos, pass the specific test project folder (e.g. `packages/e2e-tests`) when the root has mixed content.
|
|
50
|
+
|
|
43
51
|
## Cursor MCP config
|
|
44
52
|
|
|
45
53
|
**Using npx (local install):**
|
package/detect-framework.js
CHANGED
|
@@ -7,21 +7,73 @@ import { join, resolve } from 'path';
|
|
|
7
7
|
|
|
8
8
|
const ALLOWED_BDD = ['cucumber', 'pytest-bdd', 'behave', 'none'];
|
|
9
9
|
|
|
10
|
+
const SKIP_DIRS = ['node_modules', '.git', '__pycache__', '.venv', 'venv', 'dist', 'build', 'coverage'];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Score a directory for test-specific content (higher = more test content).
|
|
14
|
+
* @param {string} dir
|
|
15
|
+
* @param {(d: string, p: (f: string) => boolean) => string[]} w
|
|
16
|
+
* @returns {number}
|
|
17
|
+
*/
|
|
18
|
+
function scoreTestContent(dir, w) {
|
|
19
|
+
let score = 0;
|
|
20
|
+
if (existsSync(join(dir, 'pages'))) score += 2;
|
|
21
|
+
if (existsSync(join(dir, 'page_objects'))) score += 2;
|
|
22
|
+
if (existsSync(join(dir, 'steps'))) score += 2;
|
|
23
|
+
if (existsSync(join(dir, 'features'))) score += 2;
|
|
24
|
+
score += w(dir, (f) => f.endsWith('.feature')).length;
|
|
25
|
+
score += w(dir, (f) => f === 'conftest.py').length;
|
|
26
|
+
score += w(dir, (f) => f.endsWith('_test.py')).length;
|
|
27
|
+
score += w(dir, (f) => f.endsWith('.spec.ts')).length;
|
|
28
|
+
score += w(dir, (f) => f.endsWith('.spec.js')).length;
|
|
29
|
+
return score;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Auto-discover best project root when tests live in subfolders.
|
|
34
|
+
* @param {string} root - resolved path passed by user
|
|
35
|
+
* @returns {string} - root or subfolder with higher test content score
|
|
36
|
+
*/
|
|
37
|
+
function findBestProjectRoot(root) {
|
|
38
|
+
const candidates = [root];
|
|
39
|
+
const subdirs = ['tests', 'e2e', 'tests/e2e', 'tests/accessibility'];
|
|
40
|
+
for (const sub of subdirs) {
|
|
41
|
+
const p = join(root, sub);
|
|
42
|
+
if (existsSync(p)) candidates.push(p);
|
|
43
|
+
}
|
|
44
|
+
if (candidates.length === 1) return root;
|
|
45
|
+
|
|
46
|
+
const w = (d, pred) => walk(d, pred);
|
|
47
|
+
let best = root;
|
|
48
|
+
let bestScore = scoreTestContent(root, w);
|
|
49
|
+
for (const c of candidates) {
|
|
50
|
+
if (c === root) continue;
|
|
51
|
+
const s = scoreTestContent(c, w);
|
|
52
|
+
if (s > bestScore) {
|
|
53
|
+
bestScore = s;
|
|
54
|
+
best = c;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return best;
|
|
58
|
+
}
|
|
59
|
+
|
|
10
60
|
/**
|
|
11
61
|
* @param {string} parentProjectFolder - absolute or relative path to project root
|
|
12
62
|
* @returns {{ scriptType: 'typescript'|'javascript'|'python', bddFramework: string, pageObject: boolean, projectSummary: string }}
|
|
13
63
|
*/
|
|
14
64
|
export function detectFramework(parentProjectFolder) {
|
|
15
|
-
|
|
65
|
+
let root = resolve(parentProjectFolder);
|
|
16
66
|
if (!existsSync(root) || !readdirSync(root).length) {
|
|
17
67
|
return {
|
|
18
68
|
scriptType: 'typescript',
|
|
19
69
|
bddFramework: 'none',
|
|
20
70
|
pageObject: false,
|
|
21
|
-
projectSummary:
|
|
71
|
+
projectSummary: `Folder not found or empty; using defaults. (resolved: ${root})`,
|
|
22
72
|
};
|
|
23
73
|
}
|
|
24
74
|
|
|
75
|
+
root = findBestProjectRoot(root);
|
|
76
|
+
|
|
25
77
|
let scriptType = 'typescript';
|
|
26
78
|
let bddFramework = 'none';
|
|
27
79
|
let pageObject = false;
|
|
@@ -44,8 +96,43 @@ export function detectFramework(parentProjectFolder) {
|
|
|
44
96
|
const hasTs = hasExtension(root, '.ts') || hasExtension(root, '.spec.ts');
|
|
45
97
|
const hasJs = hasExtension(root, '.js') || hasExtension(root, '.spec.js') || hasExtension(root, '.jsx') || hasExtension(root, '.spec.jsx');
|
|
46
98
|
const hasPy = hasExtension(root, '.py') || hasExtension(root, '_test.py');
|
|
99
|
+
const hasConftest = walk(root, (f) => f === 'conftest.py').length > 0;
|
|
47
100
|
|
|
48
|
-
|
|
101
|
+
// When both package.json and pyproject.toml exist, prefer Python if pyproject indicates Python project
|
|
102
|
+
let pyprojectIndicatesPython = false;
|
|
103
|
+
let playwrightInPython = false;
|
|
104
|
+
if (hasPyproject) {
|
|
105
|
+
try {
|
|
106
|
+
const pyproject = readFileSync(join(root, 'pyproject.toml'), 'utf8');
|
|
107
|
+
pyprojectIndicatesPython = /\[project\]/.test(pyproject) && (/pytest/i.test(pyproject) || /pytest-bdd/i.test(pyproject));
|
|
108
|
+
playwrightInPython = /playwright/i.test(pyproject);
|
|
109
|
+
} catch (_) {}
|
|
110
|
+
}
|
|
111
|
+
if (hasRequirements && !playwrightInPython) {
|
|
112
|
+
try {
|
|
113
|
+
playwrightInPython = /playwright/i.test(readFileSync(join(root, 'requirements.txt'), 'utf8'));
|
|
114
|
+
} catch (_) {}
|
|
115
|
+
}
|
|
116
|
+
for (const sub of ['tests', 'e2e']) {
|
|
117
|
+
const p = join(root, sub, 'requirements.txt');
|
|
118
|
+
if (existsSync(p) && !playwrightInPython) {
|
|
119
|
+
try {
|
|
120
|
+
playwrightInPython = /playwright/i.test(readFileSync(p, 'utf8'));
|
|
121
|
+
if (playwrightInPython) break;
|
|
122
|
+
} catch (_) {}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let playwrightInNode = false;
|
|
127
|
+
if (hasPackageJson) {
|
|
128
|
+
try {
|
|
129
|
+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
130
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
131
|
+
playwrightInNode = Object.keys(deps).some((k) => /@playwright\/test|^playwright$/i.test(k));
|
|
132
|
+
} catch (_) {}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if ((hasPy || hasConftest || pyprojectIndicatesPython) && (hasRequirements || hasPyproject || hasPy || hasConftest) && !(playwrightInNode && !playwrightInPython && !hasPy && !hasConftest)) {
|
|
49
136
|
scriptType = 'python';
|
|
50
137
|
summaryParts.push('Language: Python');
|
|
51
138
|
} else if (hasPackageJson || hasTsConfig || hasTs || hasJs) {
|
|
@@ -62,57 +149,145 @@ export function detectFramework(parentProjectFolder) {
|
|
|
62
149
|
}
|
|
63
150
|
|
|
64
151
|
// BDD: Cucumber (package.json), pytest-bdd, Behave
|
|
65
|
-
|
|
152
|
+
function hasCucumberInPackage(pkgPath) {
|
|
66
153
|
try {
|
|
67
|
-
const pkg = JSON.parse(readFileSync(
|
|
154
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
68
155
|
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
156
|
+
const keys = Object.keys(deps);
|
|
157
|
+
return keys.some((k) => /@cucumber\/cucumber|cucumber-js|cucumber/i.test(k));
|
|
158
|
+
} catch (_) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if ((scriptType === 'typescript' || scriptType === 'javascript') && hasPackageJson) {
|
|
163
|
+
if (hasCucumberInPackage(join(root, 'package.json'))) {
|
|
164
|
+
bddFramework = 'cucumber';
|
|
165
|
+
summaryParts.push('BDD: Cucumber');
|
|
166
|
+
} else {
|
|
167
|
+
for (const sub of ['tests', 'e2e', 'tests/e2e']) {
|
|
168
|
+
const p = join(root, sub, 'package.json');
|
|
169
|
+
if (existsSync(p) && hasCucumberInPackage(p)) {
|
|
170
|
+
bddFramework = 'cucumber';
|
|
171
|
+
summaryParts.push('BDD: Cucumber (subfolder)');
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
72
174
|
}
|
|
73
|
-
}
|
|
175
|
+
}
|
|
74
176
|
}
|
|
75
177
|
const hasFeatureFiles = hasExtension(root, '.feature');
|
|
76
178
|
if (scriptType === 'python') {
|
|
77
179
|
try {
|
|
78
|
-
|
|
79
|
-
|
|
180
|
+
function readReq(path) {
|
|
181
|
+
try {
|
|
182
|
+
return readFileSync(path, 'utf8');
|
|
183
|
+
} catch (_) {
|
|
184
|
+
return '';
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const reqPaths = [join(root, 'requirements.txt')];
|
|
188
|
+
for (const sub of ['tests', 'e2e', 'tests/e2e']) {
|
|
189
|
+
const p = join(root, sub, 'requirements.txt');
|
|
190
|
+
if (existsSync(p)) reqPaths.push(p);
|
|
191
|
+
}
|
|
192
|
+
for (const reqPath of reqPaths) {
|
|
193
|
+
if (!existsSync(reqPath)) continue;
|
|
194
|
+
const req = readReq(reqPath);
|
|
80
195
|
if (/pytest-bdd/i.test(req)) {
|
|
81
196
|
bddFramework = 'pytest-bdd';
|
|
82
197
|
summaryParts.push('BDD: pytest-bdd');
|
|
83
|
-
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
if (/behave/i.test(req)) {
|
|
84
201
|
bddFramework = 'behave';
|
|
85
202
|
summaryParts.push('BDD: Behave');
|
|
203
|
+
break;
|
|
86
204
|
}
|
|
87
205
|
}
|
|
206
|
+
if (bddFramework === 'none' && hasPyproject) {
|
|
207
|
+
try {
|
|
208
|
+
const pyproject = readFileSync(join(root, 'pyproject.toml'), 'utf8');
|
|
209
|
+
if (/pytest-bdd/i.test(pyproject)) {
|
|
210
|
+
bddFramework = 'pytest-bdd';
|
|
211
|
+
summaryParts.push('BDD: pytest-bdd (pyproject.toml)');
|
|
212
|
+
} else if (/behave/i.test(pyproject)) {
|
|
213
|
+
bddFramework = 'behave';
|
|
214
|
+
summaryParts.push('BDD: Behave (pyproject.toml)');
|
|
215
|
+
}
|
|
216
|
+
} catch (_) {}
|
|
217
|
+
}
|
|
88
218
|
if (bddFramework === 'none' && existsSync(join(root, 'features'))) {
|
|
89
|
-
|
|
90
|
-
|
|
219
|
+
// features/ exists: check .py files for pytest_bdd before defaulting to behave
|
|
220
|
+
const pyFilePaths = walkWithPaths(root, (f) => f.endsWith('.py'));
|
|
221
|
+
let foundPytestBdd = false;
|
|
222
|
+
for (const full of pyFilePaths) {
|
|
223
|
+
try {
|
|
224
|
+
const content = readFileSync(full, 'utf8');
|
|
225
|
+
if (/pytest_bdd|pytest-bdd|from pytest_bdd|import pytest_bdd/i.test(content)) {
|
|
226
|
+
foundPytestBdd = true;
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
} catch (_) {}
|
|
230
|
+
}
|
|
231
|
+
if (foundPytestBdd) {
|
|
232
|
+
bddFramework = 'pytest-bdd';
|
|
233
|
+
summaryParts.push('BDD: pytest-bdd (found in .py files)');
|
|
234
|
+
} else {
|
|
235
|
+
bddFramework = 'behave';
|
|
236
|
+
summaryParts.push('BDD: Behave (features/ present)');
|
|
237
|
+
}
|
|
91
238
|
}
|
|
92
239
|
if (bddFramework === 'none' && hasFeatureFiles) summaryParts.push('Found .feature files');
|
|
93
240
|
} catch (_) {}
|
|
94
241
|
}
|
|
95
242
|
if ((scriptType === 'typescript' || scriptType === 'javascript') && hasFeatureFiles && bddFramework === 'none') {
|
|
96
|
-
|
|
243
|
+
bddFramework = 'cucumber';
|
|
244
|
+
summaryParts.push('BDD: Cucumber (from .feature files)');
|
|
97
245
|
}
|
|
98
246
|
|
|
99
|
-
// Page Object: pages
|
|
247
|
+
// Page Object: pages/, page_objects/, or *Page.ts, *Page.js, *_page.py, *page.py
|
|
100
248
|
try {
|
|
249
|
+
const hasPagesInSubdir = (function findPagesDir(dir) {
|
|
250
|
+
try {
|
|
251
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
252
|
+
for (const e of entries) {
|
|
253
|
+
if (e.isDirectory() && (e.name === 'pages' || e.name === 'page_objects' || e.name === 'support' || e.name === 'locators')) return true;
|
|
254
|
+
if (e.isDirectory() && !SKIP_DIRS.includes(e.name)) {
|
|
255
|
+
if (findPagesDir(join(dir, e.name))) return true;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
} catch (_) {}
|
|
259
|
+
return false;
|
|
260
|
+
})(root);
|
|
101
261
|
if (existsSync(join(root, 'pages'))) {
|
|
102
262
|
pageObject = true;
|
|
103
263
|
summaryParts.push('Page Object: pages/ directory');
|
|
264
|
+
} else if (existsSync(join(root, 'page_objects'))) {
|
|
265
|
+
pageObject = true;
|
|
266
|
+
summaryParts.push('Page Object: page_objects/ directory');
|
|
267
|
+
} else if (existsSync(join(root, 'support'))) {
|
|
268
|
+
pageObject = true;
|
|
269
|
+
summaryParts.push('Page Object: support/ directory');
|
|
270
|
+
} else if (existsSync(join(root, 'locators'))) {
|
|
271
|
+
pageObject = true;
|
|
272
|
+
summaryParts.push('Page Object: locators/ directory');
|
|
273
|
+
} else if (hasPagesInSubdir) {
|
|
274
|
+
pageObject = true;
|
|
275
|
+
summaryParts.push('Page Object: pages/ or page_objects/ or support/ in subdir');
|
|
104
276
|
} else {
|
|
105
|
-
const pageTs = walk(root, (f) => f.endsWith('Page.ts') || f.endsWith('Page.tsx'));
|
|
106
|
-
const pageJs = walk(root, (f) => f.endsWith('Page.js') || f.endsWith('Page.jsx'));
|
|
107
|
-
const pagePy = walk(root, (f) => f.endsWith('_page.py') || f.endsWith('Page.py'));
|
|
277
|
+
const pageTs = walk(root, (f) => f.endsWith('Page.ts') || f.endsWith('Page.tsx') || f.endsWith('page.ts') || f.endsWith('page.tsx'));
|
|
278
|
+
const pageJs = walk(root, (f) => f.endsWith('Page.js') || f.endsWith('Page.jsx') || f.endsWith('page.js') || f.endsWith('page.jsx'));
|
|
279
|
+
const pagePy = walk(root, (f) => f.endsWith('_page.py') || f.endsWith('Page.py') || f.endsWith('page.py'));
|
|
108
280
|
if (pageTs.length || pageJs.length || pagePy.length) {
|
|
109
281
|
pageObject = true;
|
|
110
|
-
summaryParts.push('Page Object: *Page.ts / *
|
|
282
|
+
summaryParts.push('Page Object: *Page.ts / *page.ts / *_page.py files');
|
|
111
283
|
}
|
|
112
284
|
}
|
|
113
285
|
} catch (_) {}
|
|
114
286
|
|
|
115
|
-
const projectSummary =
|
|
287
|
+
const projectSummary = [
|
|
288
|
+
...(summaryParts.length ? summaryParts : ['No framework hints detected']),
|
|
289
|
+
`(scanned: ${root})`,
|
|
290
|
+
].join('; ');
|
|
116
291
|
|
|
117
292
|
return {
|
|
118
293
|
scriptType,
|
|
@@ -122,6 +297,30 @@ export function detectFramework(parentProjectFolder) {
|
|
|
122
297
|
};
|
|
123
298
|
}
|
|
124
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Walk directory and return full paths of files matching predicate.
|
|
302
|
+
* @param {string} dir
|
|
303
|
+
* @param {(f: string) => boolean} predicate
|
|
304
|
+
* @param {string[]} [acc]
|
|
305
|
+
* @returns {string[]}
|
|
306
|
+
*/
|
|
307
|
+
function walkWithPaths(dir, predicate, acc = []) {
|
|
308
|
+
try {
|
|
309
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
310
|
+
for (const e of entries) {
|
|
311
|
+
const full = join(dir, e.name);
|
|
312
|
+
if (e.isDirectory()) {
|
|
313
|
+
if (!SKIP_DIRS.includes(e.name)) {
|
|
314
|
+
walkWithPaths(full, predicate, acc);
|
|
315
|
+
}
|
|
316
|
+
} else if (predicate(e.name)) {
|
|
317
|
+
acc.push(full);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
} catch (_) {}
|
|
321
|
+
return acc;
|
|
322
|
+
}
|
|
323
|
+
|
|
125
324
|
/**
|
|
126
325
|
* @param {string} dir
|
|
127
326
|
* @param {(f: string) => boolean} predicate
|
|
@@ -129,16 +328,18 @@ export function detectFramework(parentProjectFolder) {
|
|
|
129
328
|
* @returns {string[]}
|
|
130
329
|
*/
|
|
131
330
|
function walk(dir, predicate, acc = []) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
if (e.
|
|
137
|
-
|
|
331
|
+
try {
|
|
332
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
333
|
+
for (const e of entries) {
|
|
334
|
+
const full = join(dir, e.name);
|
|
335
|
+
if (e.isDirectory()) {
|
|
336
|
+
if (!SKIP_DIRS.includes(e.name)) {
|
|
337
|
+
walk(full, predicate, acc);
|
|
338
|
+
}
|
|
339
|
+
} else if (predicate(e.name)) {
|
|
340
|
+
acc.push(e.name);
|
|
138
341
|
}
|
|
139
|
-
} else if (predicate(e.name)) {
|
|
140
|
-
acc.push(e.name);
|
|
141
342
|
}
|
|
142
|
-
}
|
|
343
|
+
} catch (_) {}
|
|
143
344
|
return acc;
|
|
144
345
|
}
|
package/index.js
CHANGED
package/package.json
CHANGED