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 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):**
@@ -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
- const root = resolve(parentProjectFolder);
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: 'Folder not found or empty; using defaults.',
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
- if (hasPy && (hasRequirements || hasPyproject || hasPy)) {
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
- if ((scriptType === 'typescript' || scriptType === 'javascript') && hasPackageJson) {
152
+ function hasCucumberInPackage(pkgPath) {
66
153
  try {
67
- const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
154
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
68
155
  const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
69
- if (deps['@cucumber/cucumber'] || deps['cucumber']) {
70
- bddFramework = 'cucumber';
71
- summaryParts.push('BDD: Cucumber');
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
- } catch (_) {}
175
+ }
74
176
  }
75
177
  const hasFeatureFiles = hasExtension(root, '.feature');
76
178
  if (scriptType === 'python') {
77
179
  try {
78
- if (hasRequirements) {
79
- const req = readFileSync(join(root, 'requirements.txt'), 'utf8');
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
- } else if (/behave/i.test(req)) {
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
- bddFramework = 'behave';
90
- summaryParts.push('BDD: Behave (features/ present)');
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
- summaryParts.push('Found .feature files (consider Cucumber)');
243
+ bddFramework = 'cucumber';
244
+ summaryParts.push('BDD: Cucumber (from .feature files)');
97
245
  }
98
246
 
99
- // Page Object: pages/ or *Page.ts, *Page.js, *_page.py
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 / *Page.js / *_page.py files');
282
+ summaryParts.push('Page Object: *Page.ts / *page.ts / *_page.py files');
111
283
  }
112
284
  }
113
285
  } catch (_) {}
114
286
 
115
- const projectSummary = summaryParts.length ? summaryParts.join('; ') : 'No framework hints detected.';
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
- const entries = readdirSync(dir, { withFileTypes: true });
133
- for (const e of entries) {
134
- const full = join(dir, e.name);
135
- if (e.isDirectory()) {
136
- if (e.name !== 'node_modules' && e.name !== '.git' && e.name !== '__pycache__' && e.name !== '.venv' && e.name !== 'venv') {
137
- walk(full, predicate, acc);
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
@@ -52,7 +52,7 @@ function insecureHttpsFetch(url, { method = 'GET', headers = {}, body }) {
52
52
  const server = new McpServer(
53
53
  {
54
54
  name: 'GenAT',
55
- version: '2.2.4',
55
+ version: '2.2.6',
56
56
  },
57
57
  {
58
58
  capabilities: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "genat-mcp",
3
- "version": "2.2.4",
3
+ "version": "2.2.6",
4
4
  "mcpName": "io.github.asokans@oclc.org/genat",
5
5
  "description": "MCP server GenAT: generate accessibility tests via n8n workflow (url + project folder)",
6
6
  "type": "module",