@matware/e2e-runner 1.1.1 → 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/.claude-plugin/plugin.json +9 -0
- package/.mcp.json +9 -0
- package/README.md +475 -307
- package/agents/test-analyzer.md +81 -0
- package/agents/test-creator.md +102 -0
- package/agents/test-improver.md +140 -0
- package/bin/cli.js +194 -6
- package/commands/create-test.md +50 -0
- package/commands/run.md +49 -0
- package/commands/verify-issue.md +63 -0
- package/package.json +10 -2
- package/skills/e2e-testing/SKILL.md +166 -0
- package/skills/e2e-testing/references/action-types.md +100 -0
- package/skills/e2e-testing/references/test-json-format.md +159 -0
- package/skills/e2e-testing/references/troubleshooting.md +182 -0
- package/src/actions.js +273 -18
- package/src/ai-generate.js +87 -7
- package/src/config.js +28 -0
- package/src/dashboard.js +156 -6
- package/src/db.js +207 -13
- package/src/index.js +9 -3
- package/src/learner-markdown.js +177 -0
- package/src/learner-neo4j.js +255 -0
- package/src/learner-sqlite.js +354 -0
- package/src/learner.js +413 -0
- package/src/mcp-tools.js +448 -18
- package/src/module-resolver.js +273 -0
- package/src/narrate.js +225 -0
- package/src/neo4j-pool.js +124 -0
- package/src/reporter.js +35 -2
- package/src/runner.js +120 -46
- package/src/verify.js +5 -3
- package/templates/build-dashboard.js +28 -0
- package/templates/dashboard/app.js +1152 -0
- package/templates/dashboard/styles.css +413 -0
- package/templates/dashboard/template.html +201 -0
- package/templates/dashboard.html +964 -378
- package/templates/docker-compose-neo4j.yml +19 -0
- package/templates/e2e.config.js +3 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# Troubleshooting Guide
|
|
2
|
+
|
|
3
|
+
## Pool Connection Issues
|
|
4
|
+
|
|
5
|
+
### "Pool not reachable" / Connection refused
|
|
6
|
+
|
|
7
|
+
**Cause**: Chrome pool (browserless/chrome Docker container) is not running.
|
|
8
|
+
|
|
9
|
+
**Fix**:
|
|
10
|
+
```bash
|
|
11
|
+
npx e2e-runner pool start
|
|
12
|
+
npx e2e-runner pool status # verify it's running
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Pool management is CLI-only — `pool start` and `pool stop` are not available via MCP.
|
|
16
|
+
|
|
17
|
+
### "Pool at capacity" / Tests queuing
|
|
18
|
+
|
|
19
|
+
**Cause**: All Chrome sessions are occupied.
|
|
20
|
+
|
|
21
|
+
**Fix**: Increase capacity or reduce concurrency:
|
|
22
|
+
```bash
|
|
23
|
+
npx e2e-runner pool stop
|
|
24
|
+
npx e2e-runner pool start --max-sessions 10
|
|
25
|
+
```
|
|
26
|
+
Or reduce test concurrency: `--concurrency 2`
|
|
27
|
+
|
|
28
|
+
The runner checks `/pressure` before each connection and waits up to 60s for a free slot.
|
|
29
|
+
|
|
30
|
+
### Docker not running
|
|
31
|
+
|
|
32
|
+
**Cause**: Docker daemon is not started.
|
|
33
|
+
|
|
34
|
+
**Fix**: Start Docker Desktop or `sudo systemctl start docker`, then `npx e2e-runner pool start`.
|
|
35
|
+
|
|
36
|
+
## React / SPA Issues
|
|
37
|
+
|
|
38
|
+
### React inputs not updating state
|
|
39
|
+
|
|
40
|
+
**Symptom**: `type` action enters text but React state doesn't change (form validation fails, submit disabled).
|
|
41
|
+
|
|
42
|
+
**Fix**: Use `type_react` instead of `type` for React controlled inputs:
|
|
43
|
+
```json
|
|
44
|
+
{ "type": "type_react", "selector": "#email", "value": "user@test.com" }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`type_react` uses the native value setter and dispatches `input` + `change` events that React's synthetic event system recognizes.
|
|
48
|
+
|
|
49
|
+
### SPA navigation not completing
|
|
50
|
+
|
|
51
|
+
**Symptom**: `goto` hangs or times out on client-side route changes.
|
|
52
|
+
|
|
53
|
+
**Fix**: Use `navigate` instead of `goto` for SPA route changes:
|
|
54
|
+
```json
|
|
55
|
+
{ "type": "navigate", "value": "/new-page" }
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`navigate` uses a 5s race timeout and won't block if `load` doesn't fire (common in SPAs).
|
|
59
|
+
|
|
60
|
+
### MUI autocomplete not opening
|
|
61
|
+
|
|
62
|
+
**Symptom**: Clicking or typing in an MUI Autocomplete doesn't open the dropdown.
|
|
63
|
+
|
|
64
|
+
**Fix**: Use `focus_autocomplete` to properly focus by label text:
|
|
65
|
+
```json
|
|
66
|
+
{ "type": "focus_autocomplete", "text": "Search by name" },
|
|
67
|
+
{ "type": "type_react", "selector": "#autocomplete-input", "value": "search term" },
|
|
68
|
+
{ "type": "click_option", "text": "Desired option" }
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Flaky Tests
|
|
72
|
+
|
|
73
|
+
### Intermittent failures on dynamic content
|
|
74
|
+
|
|
75
|
+
**Symptom**: Tests pass sometimes, fail others. Usually timing-related.
|
|
76
|
+
|
|
77
|
+
**Fixes**:
|
|
78
|
+
1. Add explicit `wait` before assertions:
|
|
79
|
+
```json
|
|
80
|
+
{ "type": "wait", "selector": ".data-loaded" },
|
|
81
|
+
{ "type": "assert_text", "text": "Expected content" }
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
2. Use action-level retries for known flaky selectors:
|
|
85
|
+
```json
|
|
86
|
+
{ "type": "click", "selector": "#dynamic-btn", "retries": 3 }
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
3. Use test-level retries:
|
|
90
|
+
```json
|
|
91
|
+
{ "name": "flaky-test", "retries": 2, "actions": [...] }
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
4. Check the learning system for patterns:
|
|
95
|
+
```
|
|
96
|
+
e2e_learnings("flaky") → identify consistently flaky tests
|
|
97
|
+
e2e_learnings("selectors") → find unstable selectors
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Tests interfering with each other
|
|
101
|
+
|
|
102
|
+
**Symptom**: Tests pass individually but fail when run together.
|
|
103
|
+
|
|
104
|
+
**Fix**: Mark tests that share mutable state as `serial`:
|
|
105
|
+
```json
|
|
106
|
+
{ "name": "create-item", "serial": true, "actions": [...] },
|
|
107
|
+
{ "name": "verify-item", "serial": true, "actions": [...] }
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Timeout Issues
|
|
111
|
+
|
|
112
|
+
### Test timeout (default 60s)
|
|
113
|
+
|
|
114
|
+
**Fix**: Increase per-test or globally:
|
|
115
|
+
```json
|
|
116
|
+
{ "name": "slow-test", "timeout": 120000, "actions": [...] }
|
|
117
|
+
```
|
|
118
|
+
Or globally: `--test-timeout 120000`
|
|
119
|
+
|
|
120
|
+
### Action timeout (default 10s)
|
|
121
|
+
|
|
122
|
+
Each action's `waitForSelector` uses the default timeout. Override per-action:
|
|
123
|
+
```json
|
|
124
|
+
{ "type": "wait", "selector": ".slow-element", "timeout": 30000 }
|
|
125
|
+
```
|
|
126
|
+
Or globally: `--timeout 30000`
|
|
127
|
+
|
|
128
|
+
## Network Errors
|
|
129
|
+
|
|
130
|
+
### Tests passing but network requests failing
|
|
131
|
+
|
|
132
|
+
**Symptom**: Tests pass but `networkSummary` shows failed requests.
|
|
133
|
+
|
|
134
|
+
**Fix**: Enable strict mode to fail tests with network errors:
|
|
135
|
+
```
|
|
136
|
+
e2e_run({ all: true, failOnNetworkError: true })
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Or use `assert_no_network_errors` at specific points:
|
|
140
|
+
```json
|
|
141
|
+
{ "type": "goto", "value": "/api-heavy-page" },
|
|
142
|
+
{ "type": "wait", "selector": ".loaded" },
|
|
143
|
+
{ "type": "assert_no_network_errors" }
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Investigating specific failures
|
|
147
|
+
|
|
148
|
+
Use network log drill-down:
|
|
149
|
+
```
|
|
150
|
+
e2e_network_logs(runDbId, errorsOnly: true) → see all failed requests
|
|
151
|
+
e2e_network_logs(runDbId, urlPattern: "/api/patients") → filter by URL
|
|
152
|
+
e2e_network_logs(runDbId, testName: "create-patient", includeBodies: true) → full request/response
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Common Mistakes
|
|
156
|
+
|
|
157
|
+
### Using `beforeAll` for browser state
|
|
158
|
+
|
|
159
|
+
`beforeAll` runs on a separate page that closes before tests. Use `beforeEach` for state setup.
|
|
160
|
+
|
|
161
|
+
### Using `evaluate` for simple assertions
|
|
162
|
+
|
|
163
|
+
Prefer granular assertion actions over `evaluate` with inline JS:
|
|
164
|
+
```json
|
|
165
|
+
// Bad: verbose, error-prone
|
|
166
|
+
{ "type": "evaluate", "value": "if (!document.querySelector('h1').textContent.includes('Dashboard')) throw 'not found'" }
|
|
167
|
+
|
|
168
|
+
// Good: clear, auto-waits
|
|
169
|
+
{ "type": "assert_element_text", "selector": "h1", "text": "Dashboard" }
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Forgetting `cwd` in MCP calls
|
|
173
|
+
|
|
174
|
+
All MCP tools need `cwd` to resolve config files and test directories. Always pass the project root.
|
|
175
|
+
|
|
176
|
+
### Path-only `assert_url`
|
|
177
|
+
|
|
178
|
+
When checking paths, use path-only format (starts with `/`):
|
|
179
|
+
```json
|
|
180
|
+
{ "type": "assert_url", "value": "/dashboard" }
|
|
181
|
+
```
|
|
182
|
+
This compares against the pathname only, ignoring the `host.docker.internal` origin.
|
package/src/actions.js
CHANGED
|
@@ -31,13 +31,14 @@ export async function executeAction(page, action, config) {
|
|
|
31
31
|
await page.waitForSelector(selector, { timeout });
|
|
32
32
|
await page.click(selector);
|
|
33
33
|
} else if (text) {
|
|
34
|
+
const clickTextSelector = 'button, a, [role="button"], [role="tab"], [role="menuitem"], [role="option"], [role="listitem"], div[class*="cursor"], span, li, td, th, label, p, h1, h2, h3, h4, h5, h6, dd, dt';
|
|
34
35
|
await page.waitForFunction(
|
|
35
|
-
(t) => [...document.querySelectorAll(
|
|
36
|
+
(t, sel) => [...document.querySelectorAll(sel)]
|
|
36
37
|
.find(el => el.textContent.includes(t)),
|
|
37
38
|
{ timeout },
|
|
38
|
-
text
|
|
39
|
+
text, clickTextSelector
|
|
39
40
|
);
|
|
40
|
-
await page.$$eval(
|
|
41
|
+
await page.$$eval(clickTextSelector, (els, t) => {
|
|
41
42
|
const el = els.find(e => e.textContent.includes(t));
|
|
42
43
|
if (el) el.click();
|
|
43
44
|
}, text);
|
|
@@ -54,13 +55,21 @@ export async function executeAction(page, action, config) {
|
|
|
54
55
|
|
|
55
56
|
case 'wait':
|
|
56
57
|
if (selector) {
|
|
57
|
-
|
|
58
|
+
try {
|
|
59
|
+
await page.waitForSelector(selector, { timeout });
|
|
60
|
+
} catch (e) {
|
|
61
|
+
throw new Error(`wait failed: selector "${selector}" not found after ${timeout}ms`);
|
|
62
|
+
}
|
|
58
63
|
} else if (text) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
try {
|
|
65
|
+
await page.waitForFunction(
|
|
66
|
+
(t) => document.body.innerText.includes(t),
|
|
67
|
+
{ timeout },
|
|
68
|
+
text
|
|
69
|
+
);
|
|
70
|
+
} catch (e) {
|
|
71
|
+
throw new Error(`wait failed: text "${text}" not found after ${timeout}ms`);
|
|
72
|
+
}
|
|
64
73
|
} else if (value) {
|
|
65
74
|
await sleep(parseInt(value));
|
|
66
75
|
}
|
|
@@ -73,6 +82,13 @@ export async function executeAction(page, action, config) {
|
|
|
73
82
|
}
|
|
74
83
|
// Sanitize: use only the basename to prevent path traversal
|
|
75
84
|
filename = path.basename(filename);
|
|
85
|
+
// Inject timestamp before extension to make filenames unique per run
|
|
86
|
+
// (prevents overwriting previous runs' screenshots)
|
|
87
|
+
if (value) {
|
|
88
|
+
const ext = path.extname(filename);
|
|
89
|
+
const base = filename.slice(0, -ext.length);
|
|
90
|
+
filename = `${base}-${Date.now()}${ext}`;
|
|
91
|
+
}
|
|
76
92
|
const filepath = path.join(screenshotsDir, filename);
|
|
77
93
|
await page.screenshot({ path: filepath, fullPage: action.fullPage || false });
|
|
78
94
|
return { screenshot: filepath };
|
|
@@ -88,8 +104,26 @@ export async function executeAction(page, action, config) {
|
|
|
88
104
|
|
|
89
105
|
case 'assert_url': {
|
|
90
106
|
const currentUrl = page.url();
|
|
91
|
-
|
|
92
|
-
|
|
107
|
+
let match = false;
|
|
108
|
+
if (value.startsWith('/')) {
|
|
109
|
+
// Path-only comparison: extract pathname (+ query if value has ?)
|
|
110
|
+
try {
|
|
111
|
+
const parsed = new URL(currentUrl);
|
|
112
|
+
const compareTo = value.includes('?') ? parsed.pathname + parsed.search : parsed.pathname;
|
|
113
|
+
match = compareTo === value || compareTo.startsWith(value);
|
|
114
|
+
} catch {
|
|
115
|
+
match = currentUrl.includes(value);
|
|
116
|
+
}
|
|
117
|
+
if (!match) {
|
|
118
|
+
const pathname = (() => { try { return new URL(currentUrl).pathname; } catch { return currentUrl; } })();
|
|
119
|
+
throw new Error(`assert_url failed: expected path "${value}", got "${pathname}" (full: ${currentUrl})`);
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
// Full URL comparison (backwards compatible)
|
|
123
|
+
match = currentUrl.includes(value);
|
|
124
|
+
if (!match) {
|
|
125
|
+
throw new Error(`assert_url failed: expected "${value}", got "${currentUrl}"`);
|
|
126
|
+
}
|
|
93
127
|
}
|
|
94
128
|
break;
|
|
95
129
|
}
|
|
@@ -111,13 +145,107 @@ export async function executeAction(page, action, config) {
|
|
|
111
145
|
|
|
112
146
|
case 'assert_count': {
|
|
113
147
|
const count = await page.$$eval(selector, els => els.length);
|
|
114
|
-
const
|
|
115
|
-
if (
|
|
116
|
-
|
|
148
|
+
const opMatch = value.match(/^(>=|<=|>|<)\s*(\d+)$/);
|
|
149
|
+
if (opMatch) {
|
|
150
|
+
const [, op, numStr] = opMatch;
|
|
151
|
+
const expected = parseInt(numStr);
|
|
152
|
+
const passed = op === '>' ? count > expected
|
|
153
|
+
: op === '>=' ? count >= expected
|
|
154
|
+
: op === '<' ? count < expected
|
|
155
|
+
: count <= expected;
|
|
156
|
+
if (!passed) {
|
|
157
|
+
throw new Error(`assert_count failed: "${selector}" has ${count} elements, expected ${op}${expected}`);
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
const expected = parseInt(value);
|
|
161
|
+
if (count !== expected) {
|
|
162
|
+
throw new Error(`assert_count failed: "${selector}" has ${count} elements, expected ${expected}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
case 'assert_element_text': {
|
|
169
|
+
await page.waitForSelector(selector, { timeout });
|
|
170
|
+
const elText = await page.$eval(selector, el => el.textContent);
|
|
171
|
+
if (value === 'exact') {
|
|
172
|
+
if (elText.trim() !== text) {
|
|
173
|
+
throw new Error(`assert_element_text failed: "${selector}" text is "${elText.trim()}", expected exact "${text}"`);
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
if (!elText.includes(text)) {
|
|
177
|
+
throw new Error(`assert_element_text failed: "${selector}" text "${elText.trim()}" does not contain "${text}"`);
|
|
178
|
+
}
|
|
117
179
|
}
|
|
118
180
|
break;
|
|
119
181
|
}
|
|
120
182
|
|
|
183
|
+
case 'assert_attribute': {
|
|
184
|
+
await page.waitForSelector(selector, { timeout });
|
|
185
|
+
const eqIndex = value.indexOf('=');
|
|
186
|
+
if (eqIndex === -1) {
|
|
187
|
+
const hasAttr = await page.$eval(selector, (el, attr) => el.hasAttribute(attr), value);
|
|
188
|
+
if (!hasAttr) {
|
|
189
|
+
throw new Error(`assert_attribute failed: "${selector}" does not have attribute "${value}"`);
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
const attrName = value.slice(0, eqIndex);
|
|
193
|
+
const expectedVal = value.slice(eqIndex + 1);
|
|
194
|
+
const actual = await page.$eval(selector, (el, attr) => el.getAttribute(attr), attrName);
|
|
195
|
+
if (actual !== expectedVal) {
|
|
196
|
+
throw new Error(`assert_attribute failed: "${selector}" attribute "${attrName}" is "${actual}", expected "${expectedVal}"`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
case 'assert_class': {
|
|
203
|
+
await page.waitForSelector(selector, { timeout });
|
|
204
|
+
const hasClass = await page.$eval(selector, (el, cls) => el.classList.contains(cls), value);
|
|
205
|
+
if (!hasClass) {
|
|
206
|
+
throw new Error(`assert_class failed: "${selector}" does not have class "${value}"`);
|
|
207
|
+
}
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
case 'assert_not_visible': {
|
|
212
|
+
const notVisEl = await page.$(selector);
|
|
213
|
+
if (notVisEl) {
|
|
214
|
+
const isVisible = await page.$eval(selector, (e) => {
|
|
215
|
+
const style = window.getComputedStyle(e);
|
|
216
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
|
|
217
|
+
});
|
|
218
|
+
if (isVisible) {
|
|
219
|
+
throw new Error(`assert_not_visible failed: "${selector}" is visible`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
case 'assert_input_value': {
|
|
226
|
+
await page.waitForSelector(selector, { timeout });
|
|
227
|
+
const inputVal = await page.$eval(selector, el => el.value);
|
|
228
|
+
if (!inputVal.includes(value)) {
|
|
229
|
+
throw new Error(`assert_input_value failed: "${selector}" value is "${inputVal}", expected to contain "${value}"`);
|
|
230
|
+
}
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
case 'assert_matches': {
|
|
235
|
+
await page.waitForSelector(selector, { timeout });
|
|
236
|
+
const matchText = await page.$eval(selector, el => el.textContent);
|
|
237
|
+
if (!new RegExp(value).test(matchText)) {
|
|
238
|
+
throw new Error(`assert_matches failed: "${selector}" text "${matchText.trim()}" does not match pattern /${value}/`);
|
|
239
|
+
}
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
case 'get_text': {
|
|
244
|
+
await page.waitForSelector(selector, { timeout });
|
|
245
|
+
const getText = await page.$eval(selector, el => el.textContent.trim());
|
|
246
|
+
return { value: getText };
|
|
247
|
+
}
|
|
248
|
+
|
|
121
249
|
case 'select':
|
|
122
250
|
await page.waitForSelector(selector, { timeout });
|
|
123
251
|
await page.select(selector, value);
|
|
@@ -162,15 +290,142 @@ export async function executeAction(page, action, config) {
|
|
|
162
290
|
break;
|
|
163
291
|
}
|
|
164
292
|
|
|
293
|
+
case 'clear_cookies': {
|
|
294
|
+
const client = await page.createCDPSession();
|
|
295
|
+
await client.send('Network.clearBrowserCookies');
|
|
296
|
+
await client.send('Storage.clearDataForOrigin', {
|
|
297
|
+
origin: value || baseUrl || page.url(),
|
|
298
|
+
storageTypes: 'cookies,local_storage,session_storage',
|
|
299
|
+
});
|
|
300
|
+
await client.detach();
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
case 'type_react': {
|
|
305
|
+
// Types into React controlled inputs using the native value setter.
|
|
306
|
+
// This bypasses React's synthetic event system which ignores programmatic .value changes.
|
|
307
|
+
await page.waitForSelector(selector, { timeout });
|
|
308
|
+
await page.evaluate((sel, val) => {
|
|
309
|
+
const input = document.querySelector(sel);
|
|
310
|
+
if (!input) throw new Error(`type_react: element "${sel}" not found`);
|
|
311
|
+
const proto = input instanceof HTMLTextAreaElement
|
|
312
|
+
? window.HTMLTextAreaElement.prototype
|
|
313
|
+
: window.HTMLInputElement.prototype;
|
|
314
|
+
const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
315
|
+
if (!descriptor || !descriptor.set) {
|
|
316
|
+
throw new Error(`type_react: element "${sel}" has no writable value property`);
|
|
317
|
+
}
|
|
318
|
+
descriptor.set.call(input, val);
|
|
319
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
320
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
321
|
+
input.focus();
|
|
322
|
+
}, selector, value);
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
case 'click_regex': {
|
|
327
|
+
// Click an element whose textContent matches a regex pattern.
|
|
328
|
+
// text = regex pattern (always case-insensitive)
|
|
329
|
+
// selector = optional CSS scope (defaults to common clickable elements)
|
|
330
|
+
// value = "last" to click the last match (default: first)
|
|
331
|
+
const matchSelector = selector || 'button, a, [role="button"], [role="tab"], [role="menuitem"], [role="option"], [role="listitem"], div[class*="cursor"], span, li, td, th, label, p, h1, h2, h3, h4, h5, h6, dd, dt';
|
|
332
|
+
const matchLast = value === 'last';
|
|
333
|
+
await page.waitForFunction(
|
|
334
|
+
(regex, sel) => [...document.querySelectorAll(sel)].some(el => new RegExp(regex, 'i').test(el.textContent)),
|
|
335
|
+
{ timeout },
|
|
336
|
+
text, matchSelector
|
|
337
|
+
);
|
|
338
|
+
const clicked = await page.$$eval(matchSelector, (els, regex, last) => {
|
|
339
|
+
const matches = els.filter(el => new RegExp(regex, 'i').test(el.textContent));
|
|
340
|
+
if (matches.length === 0) return false;
|
|
341
|
+
const target = last ? matches[matches.length - 1] : matches[0];
|
|
342
|
+
target.click();
|
|
343
|
+
return true;
|
|
344
|
+
}, text, matchLast);
|
|
345
|
+
if (!clicked) {
|
|
346
|
+
throw new Error(`click_regex failed: no element matching /${text}/i found`);
|
|
347
|
+
}
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
case 'click_option': {
|
|
352
|
+
// Click a [role="option"] element by text content — common in autocomplete dropdowns.
|
|
353
|
+
await page.waitForFunction(
|
|
354
|
+
(t) => [...document.querySelectorAll('[role="option"]')].some(el => el.textContent.includes(t)),
|
|
355
|
+
{ timeout },
|
|
356
|
+
text
|
|
357
|
+
);
|
|
358
|
+
const optionClicked = await page.$$eval('[role="option"]', (els, t) => {
|
|
359
|
+
const match = els.find(el => el.textContent.includes(t));
|
|
360
|
+
if (match) { match.click(); return true; }
|
|
361
|
+
return false;
|
|
362
|
+
}, text);
|
|
363
|
+
if (!optionClicked) {
|
|
364
|
+
throw new Error(`click_option failed: no [role="option"] containing "${text}" found`);
|
|
365
|
+
}
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
case 'focus_autocomplete': {
|
|
370
|
+
// Focus an autocomplete/combobox input by its label text.
|
|
371
|
+
// Supports MUI Autocomplete (.MuiAutocomplete-root) and generic [role="combobox"].
|
|
372
|
+
const focused = await page.evaluate((labelText) => {
|
|
373
|
+
const containers = [
|
|
374
|
+
...document.querySelectorAll('.MuiAutocomplete-root'),
|
|
375
|
+
...document.querySelectorAll('[role="combobox"]'),
|
|
376
|
+
];
|
|
377
|
+
const match = containers.find(c => {
|
|
378
|
+
const label = c.querySelector('label');
|
|
379
|
+
return label && label.textContent.includes(labelText);
|
|
380
|
+
});
|
|
381
|
+
if (!match) return null;
|
|
382
|
+
const input = match.querySelector('input');
|
|
383
|
+
if (!input) return null;
|
|
384
|
+
input.focus();
|
|
385
|
+
input.click();
|
|
386
|
+
return input.id || 'focused';
|
|
387
|
+
}, text);
|
|
388
|
+
if (!focused) {
|
|
389
|
+
throw new Error(`focus_autocomplete failed: no autocomplete with label "${text}" found`);
|
|
390
|
+
}
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
case 'click_chip': {
|
|
395
|
+
// Click a chip/tag element by text content.
|
|
396
|
+
// Searches MUI Chip classes and common chip patterns.
|
|
397
|
+
const chipClicked = await page.evaluate((chipText) => {
|
|
398
|
+
const chips = Array.from(document.querySelectorAll(
|
|
399
|
+
'[class*="Chip"], [class*="chip"], [data-chip], [role="option"][aria-selected]'
|
|
400
|
+
));
|
|
401
|
+
const match = chips.find(c => c.textContent.includes(chipText));
|
|
402
|
+
if (!match) return false;
|
|
403
|
+
match.click();
|
|
404
|
+
return true;
|
|
405
|
+
}, text);
|
|
406
|
+
if (!chipClicked) {
|
|
407
|
+
throw new Error(`click_chip failed: no chip containing "${text}" found`);
|
|
408
|
+
}
|
|
409
|
+
break;
|
|
410
|
+
}
|
|
411
|
+
|
|
165
412
|
case 'evaluate': {
|
|
166
413
|
// Intentional: runs JS in browser page context (from test JSON files)
|
|
167
|
-
const
|
|
168
|
-
|
|
414
|
+
const jsSnippet = value.length > 120 ? value.slice(0, 120) + '...' : value;
|
|
415
|
+
let evalResult;
|
|
416
|
+
try {
|
|
417
|
+
evalResult = await page.evaluate(value);
|
|
418
|
+
} catch (evalErr) {
|
|
419
|
+
const pageUrl = page.url();
|
|
420
|
+
throw new Error(`evaluate threw on ${pageUrl}: ${evalErr.message}\n JS: ${jsSnippet}`);
|
|
421
|
+
}
|
|
169
422
|
if (typeof evalResult === 'string' && /^(FAIL|ERROR|FAILED)[\s:]/i.test(evalResult)) {
|
|
170
|
-
|
|
423
|
+
const pageUrl = page.url();
|
|
424
|
+
throw new Error(`evaluate failed on ${pageUrl}: ${evalResult}\n JS: ${jsSnippet}`);
|
|
171
425
|
}
|
|
172
426
|
if (evalResult === false) {
|
|
173
|
-
|
|
427
|
+
const pageUrl = page.url();
|
|
428
|
+
throw new Error(`evaluate returned false on ${pageUrl}\n JS: ${jsSnippet}`);
|
|
174
429
|
}
|
|
175
430
|
return evalResult !== undefined && evalResult !== null ? { value: evalResult } : null;
|
|
176
431
|
}
|