genat-mcp 2.2.5 → 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,12 +7,62 @@ 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',
@@ -22,6 +72,8 @@ export function detectFramework(parentProjectFolder) {
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;
@@ -48,14 +100,39 @@ export function detectFramework(parentProjectFolder) {
48
100
 
49
101
  // When both package.json and pyproject.toml exist, prefer Python if pyproject indicates Python project
50
102
  let pyprojectIndicatesPython = false;
103
+ let playwrightInPython = false;
51
104
  if (hasPyproject) {
52
105
  try {
53
106
  const pyproject = readFileSync(join(root, 'pyproject.toml'), 'utf8');
54
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));
55
132
  } catch (_) {}
56
133
  }
57
134
 
58
- if ((hasPy || hasConftest || pyprojectIndicatesPython) && (hasRequirements || hasPyproject || hasPy || hasConftest)) {
135
+ if ((hasPy || hasConftest || pyprojectIndicatesPython) && (hasRequirements || hasPyproject || hasPy || hasConftest) && !(playwrightInNode && !playwrightInPython && !hasPy && !hasConftest)) {
59
136
  scriptType = 'python';
60
137
  summaryParts.push('Language: Python');
61
138
  } else if (hasPackageJson || hasTsConfig || hasTs || hasJs) {
@@ -72,27 +149,58 @@ export function detectFramework(parentProjectFolder) {
72
149
  }
73
150
 
74
151
  // BDD: Cucumber (package.json), pytest-bdd, Behave
75
- if ((scriptType === 'typescript' || scriptType === 'javascript') && hasPackageJson) {
152
+ function hasCucumberInPackage(pkgPath) {
76
153
  try {
77
- const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
154
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
78
155
  const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
79
- if (deps['@cucumber/cucumber'] || deps['cucumber']) {
80
- bddFramework = 'cucumber';
81
- 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
+ }
82
174
  }
83
- } catch (_) {}
175
+ }
84
176
  }
85
177
  const hasFeatureFiles = hasExtension(root, '.feature');
86
178
  if (scriptType === 'python') {
87
179
  try {
88
- if (hasRequirements) {
89
- 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);
90
195
  if (/pytest-bdd/i.test(req)) {
91
196
  bddFramework = 'pytest-bdd';
92
197
  summaryParts.push('BDD: pytest-bdd');
93
- } else if (/behave/i.test(req)) {
198
+ break;
199
+ }
200
+ if (/behave/i.test(req)) {
94
201
  bddFramework = 'behave';
95
202
  summaryParts.push('BDD: Behave');
203
+ break;
96
204
  }
97
205
  }
98
206
  if (bddFramework === 'none' && hasPyproject) {
@@ -132,7 +240,8 @@ export function detectFramework(parentProjectFolder) {
132
240
  } catch (_) {}
133
241
  }
134
242
  if ((scriptType === 'typescript' || scriptType === 'javascript') && hasFeatureFiles && bddFramework === 'none') {
135
- summaryParts.push('Found .feature files (consider Cucumber)');
243
+ bddFramework = 'cucumber';
244
+ summaryParts.push('BDD: Cucumber (from .feature files)');
136
245
  }
137
246
 
138
247
  // Page Object: pages/, page_objects/, or *Page.ts, *Page.js, *_page.py, *page.py
@@ -141,8 +250,8 @@ export function detectFramework(parentProjectFolder) {
141
250
  try {
142
251
  const entries = readdirSync(dir, { withFileTypes: true });
143
252
  for (const e of entries) {
144
- if (e.isDirectory() && (e.name === 'pages' || e.name === 'page_objects')) return true;
145
- if (e.isDirectory() && !['node_modules', '.git', '__pycache__', '.venv', 'venv'].includes(e.name)) {
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)) {
146
255
  if (findPagesDir(join(dir, e.name))) return true;
147
256
  }
148
257
  }
@@ -155,16 +264,22 @@ export function detectFramework(parentProjectFolder) {
155
264
  } else if (existsSync(join(root, 'page_objects'))) {
156
265
  pageObject = true;
157
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');
158
273
  } else if (hasPagesInSubdir) {
159
274
  pageObject = true;
160
- summaryParts.push('Page Object: pages/ or page_objects/ in subdir');
275
+ summaryParts.push('Page Object: pages/ or page_objects/ or support/ in subdir');
161
276
  } else {
162
- const pageTs = walk(root, (f) => f.endsWith('Page.ts') || f.endsWith('Page.tsx'));
163
- const pageJs = walk(root, (f) => f.endsWith('Page.js') || f.endsWith('Page.jsx'));
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'));
164
279
  const pagePy = walk(root, (f) => f.endsWith('_page.py') || f.endsWith('Page.py') || f.endsWith('page.py'));
165
280
  if (pageTs.length || pageJs.length || pagePy.length) {
166
281
  pageObject = true;
167
- summaryParts.push('Page Object: *Page.ts / *Page.js / *_page.py files');
282
+ summaryParts.push('Page Object: *Page.ts / *page.ts / *_page.py files');
168
283
  }
169
284
  }
170
285
  } catch (_) {}
@@ -195,7 +310,7 @@ function walkWithPaths(dir, predicate, acc = []) {
195
310
  for (const e of entries) {
196
311
  const full = join(dir, e.name);
197
312
  if (e.isDirectory()) {
198
- if (e.name !== 'node_modules' && e.name !== '.git' && e.name !== '__pycache__' && e.name !== '.venv' && e.name !== 'venv') {
313
+ if (!SKIP_DIRS.includes(e.name)) {
199
314
  walkWithPaths(full, predicate, acc);
200
315
  }
201
316
  } else if (predicate(e.name)) {
@@ -213,16 +328,18 @@ function walkWithPaths(dir, predicate, acc = []) {
213
328
  * @returns {string[]}
214
329
  */
215
330
  function walk(dir, predicate, acc = []) {
216
- const entries = readdirSync(dir, { withFileTypes: true });
217
- for (const e of entries) {
218
- const full = join(dir, e.name);
219
- if (e.isDirectory()) {
220
- if (e.name !== 'node_modules' && e.name !== '.git' && e.name !== '__pycache__' && e.name !== '.venv' && e.name !== 'venv') {
221
- 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);
222
341
  }
223
- } else if (predicate(e.name)) {
224
- acc.push(e.name);
225
342
  }
226
- }
343
+ } catch (_) {}
227
344
  return acc;
228
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.5',
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.5",
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",