@matware/e2e-runner 1.1.1 → 1.3.0
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/marketplace.json +21 -0
- package/.claude-plugin/plugin.json +9 -0
- package/.mcp.json +9 -0
- package/.opencode/commands/create-test.md +63 -0
- package/.opencode/commands/run.md +50 -0
- package/.opencode/commands/verify-issue.md +62 -0
- package/.opencode/skills/e2e-testing/SKILL.md +181 -0
- package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
- package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
- package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
- package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
- package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
- package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
- package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
- package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
- package/.opencode/skills/e2e-testing/references/variables.md +41 -0
- package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
- package/OPENCODE.md +166 -0
- package/README.md +990 -296
- package/agents/test-analyzer.md +81 -0
- package/agents/test-creator.md +155 -0
- package/agents/test-improver.md +177 -0
- package/bin/cli.js +602 -22
- package/commands/create-test.md +65 -0
- package/commands/run.md +49 -0
- package/commands/verify-issue.md +63 -0
- package/opencode.json +11 -0
- package/package.json +15 -2
- package/scripts/setup-opencode.sh +113 -0
- package/skills/e2e-testing/SKILL.md +173 -0
- package/skills/e2e-testing/references/action-types.md +143 -0
- package/skills/e2e-testing/references/auth-strategies.md +91 -0
- package/skills/e2e-testing/references/graphql.md +59 -0
- package/skills/e2e-testing/references/issue-verification.md +59 -0
- package/skills/e2e-testing/references/multi-pool.md +60 -0
- package/skills/e2e-testing/references/network-debugging.md +62 -0
- package/skills/e2e-testing/references/test-json-format.md +163 -0
- package/skills/e2e-testing/references/troubleshooting.md +224 -0
- package/skills/e2e-testing/references/variables.md +41 -0
- package/skills/e2e-testing/references/visual-verification.md +89 -0
- package/src/actions.js +597 -20
- package/src/ai-generate.js +142 -12
- package/src/config.js +171 -0
- package/src/dashboard.js +299 -17
- package/src/db.js +335 -13
- package/src/index.js +15 -8
- package/src/learner-markdown.js +177 -0
- package/src/learner-neo4j.js +255 -0
- package/src/learner-sqlite.js +658 -0
- package/src/learner.js +418 -0
- package/src/mcp-tools.js +1558 -50
- package/src/module-resolver.js +310 -0
- package/src/narrate.js +262 -0
- package/src/neo4j-pool.js +124 -0
- package/src/pool-manager.js +223 -0
- package/src/reporter.js +117 -3
- package/src/runner.js +274 -71
- package/src/sync/auth.js +354 -0
- package/src/sync/client.js +572 -0
- package/src/sync/hub-routes.js +816 -0
- package/src/sync/index.js +68 -0
- package/src/sync/middleware.js +347 -0
- package/src/sync/queue.js +209 -0
- package/src/sync/schema.js +540 -0
- package/src/verify.js +14 -9
- package/src/watch.js +384 -0
- package/templates/build-dashboard.js +69 -0
- package/templates/dashboard/js/api.js +60 -0
- package/templates/dashboard/js/init.js +13 -0
- package/templates/dashboard/js/keyboard.js +46 -0
- package/templates/dashboard/js/state.js +40 -0
- package/templates/dashboard/js/toast.js +41 -0
- package/templates/dashboard/js/utils.js +196 -0
- package/templates/dashboard/js/view-live.js +143 -0
- package/templates/dashboard/js/view-runs.js +572 -0
- package/templates/dashboard/js/view-tests.js +294 -0
- package/templates/dashboard/js/view-watch.js +242 -0
- package/templates/dashboard/js/websocket.js +110 -0
- package/templates/dashboard/styles/base.css +69 -0
- package/templates/dashboard/styles/components.css +110 -0
- package/templates/dashboard/styles/view-live.css +74 -0
- package/templates/dashboard/styles/view-runs.css +207 -0
- package/templates/dashboard/styles/view-tests.css +96 -0
- package/templates/dashboard/styles/view-watch.css +53 -0
- package/templates/dashboard/template.html +267 -0
- package/templates/dashboard.html +2171 -530
- package/templates/docker-compose-neo4j.yml +19 -0
- package/templates/e2e.config.js +3 -0
- package/templates/sample-test.json +0 -8
package/src/actions.js
CHANGED
|
@@ -8,7 +8,21 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import path from 'path';
|
|
11
|
-
|
|
11
|
+
|
|
12
|
+
/** All recognized action types — single source of truth for validation. */
|
|
13
|
+
export const KNOWN_ACTION_TYPES = new Set([
|
|
14
|
+
'goto', 'click', 'type', 'fill', 'wait', 'screenshot',
|
|
15
|
+
'assert_text', 'assert_url', 'assert_visible', 'assert_count',
|
|
16
|
+
'assert_element_text', 'assert_attribute', 'assert_class',
|
|
17
|
+
'assert_not_visible', 'assert_input_value', 'assert_matches',
|
|
18
|
+
'assert_no_network_errors', 'assert_storage',
|
|
19
|
+
'get_text', 'select', 'clear', 'clear_cookies', 'press', 'scroll', 'hover',
|
|
20
|
+
'navigate', 'evaluate',
|
|
21
|
+
'type_react', 'click_regex', 'click_option', 'focus_autocomplete', 'click_chip',
|
|
22
|
+
'set_storage', 'click_icon', 'click_menu_item', 'click_in_context',
|
|
23
|
+
'assert_text_in', 'assert_no_text',
|
|
24
|
+
'gql', 'wait_network_idle',
|
|
25
|
+
]);
|
|
12
26
|
|
|
13
27
|
function sleep(ms) {
|
|
14
28
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
@@ -31,13 +45,14 @@ export async function executeAction(page, action, config) {
|
|
|
31
45
|
await page.waitForSelector(selector, { timeout });
|
|
32
46
|
await page.click(selector);
|
|
33
47
|
} else if (text) {
|
|
48
|
+
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
49
|
await page.waitForFunction(
|
|
35
|
-
(t) => [...document.querySelectorAll(
|
|
50
|
+
(t, sel) => [...document.querySelectorAll(sel)]
|
|
36
51
|
.find(el => el.textContent.includes(t)),
|
|
37
52
|
{ timeout },
|
|
38
|
-
text
|
|
53
|
+
text, clickTextSelector
|
|
39
54
|
);
|
|
40
|
-
await page.$$eval(
|
|
55
|
+
await page.$$eval(clickTextSelector, (els, t) => {
|
|
41
56
|
const el = els.find(e => e.textContent.includes(t));
|
|
42
57
|
if (el) el.click();
|
|
43
58
|
}, text);
|
|
@@ -54,13 +69,21 @@ export async function executeAction(page, action, config) {
|
|
|
54
69
|
|
|
55
70
|
case 'wait':
|
|
56
71
|
if (selector) {
|
|
57
|
-
|
|
72
|
+
try {
|
|
73
|
+
await page.waitForSelector(selector, { timeout });
|
|
74
|
+
} catch (e) {
|
|
75
|
+
throw new Error(`wait failed: selector "${selector}" not found after ${timeout}ms`);
|
|
76
|
+
}
|
|
58
77
|
} else if (text) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
78
|
+
try {
|
|
79
|
+
await page.waitForFunction(
|
|
80
|
+
(t) => document.body.innerText.includes(t),
|
|
81
|
+
{ timeout },
|
|
82
|
+
text
|
|
83
|
+
);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
throw new Error(`wait failed: text "${text}" not found after ${timeout}ms`);
|
|
86
|
+
}
|
|
64
87
|
} else if (value) {
|
|
65
88
|
await sleep(parseInt(value));
|
|
66
89
|
}
|
|
@@ -73,6 +96,13 @@ export async function executeAction(page, action, config) {
|
|
|
73
96
|
}
|
|
74
97
|
// Sanitize: use only the basename to prevent path traversal
|
|
75
98
|
filename = path.basename(filename);
|
|
99
|
+
// Inject timestamp before extension to make filenames unique per run
|
|
100
|
+
// (prevents overwriting previous runs' screenshots)
|
|
101
|
+
if (value) {
|
|
102
|
+
const ext = path.extname(filename);
|
|
103
|
+
const base = filename.slice(0, -ext.length);
|
|
104
|
+
filename = `${base}-${Date.now()}${ext}`;
|
|
105
|
+
}
|
|
76
106
|
const filepath = path.join(screenshotsDir, filename);
|
|
77
107
|
await page.screenshot({ path: filepath, fullPage: action.fullPage || false });
|
|
78
108
|
return { screenshot: filepath };
|
|
@@ -86,10 +116,38 @@ export async function executeAction(page, action, config) {
|
|
|
86
116
|
break;
|
|
87
117
|
}
|
|
88
118
|
|
|
119
|
+
case 'assert_no_text': {
|
|
120
|
+
// Assert that text does NOT appear anywhere on the page.
|
|
121
|
+
// text: substring to check for absence (required)
|
|
122
|
+
const bodyTextNo = await page.evaluate(() => document.body.innerText);
|
|
123
|
+
if (bodyTextNo.includes(text)) {
|
|
124
|
+
throw new Error(`assert_no_text failed: "${text}" was found on the page but should not be present`);
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
|
|
89
129
|
case 'assert_url': {
|
|
90
130
|
const currentUrl = page.url();
|
|
91
|
-
|
|
92
|
-
|
|
131
|
+
let match = false;
|
|
132
|
+
if (value.startsWith('/')) {
|
|
133
|
+
// Path-only comparison: extract pathname (+ query if value has ?)
|
|
134
|
+
try {
|
|
135
|
+
const parsed = new URL(currentUrl);
|
|
136
|
+
const compareTo = value.includes('?') ? parsed.pathname + parsed.search : parsed.pathname;
|
|
137
|
+
match = compareTo === value || compareTo.startsWith(value);
|
|
138
|
+
} catch {
|
|
139
|
+
match = currentUrl.includes(value);
|
|
140
|
+
}
|
|
141
|
+
if (!match) {
|
|
142
|
+
const pathname = (() => { try { return new URL(currentUrl).pathname; } catch { return currentUrl; } })();
|
|
143
|
+
throw new Error(`assert_url failed: expected path "${value}", got "${pathname}" (full: ${currentUrl})`);
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
// Full URL comparison (backwards compatible)
|
|
147
|
+
match = currentUrl.includes(value);
|
|
148
|
+
if (!match) {
|
|
149
|
+
throw new Error(`assert_url failed: expected "${value}", got "${currentUrl}"`);
|
|
150
|
+
}
|
|
93
151
|
}
|
|
94
152
|
break;
|
|
95
153
|
}
|
|
@@ -111,13 +169,131 @@ export async function executeAction(page, action, config) {
|
|
|
111
169
|
|
|
112
170
|
case 'assert_count': {
|
|
113
171
|
const count = await page.$$eval(selector, els => els.length);
|
|
114
|
-
const
|
|
115
|
-
if (
|
|
116
|
-
|
|
172
|
+
const opMatch = value.match(/^(>=|<=|>|<)\s*(\d+)$/);
|
|
173
|
+
if (opMatch) {
|
|
174
|
+
const [, op, numStr] = opMatch;
|
|
175
|
+
const expected = parseInt(numStr);
|
|
176
|
+
const passed = op === '>' ? count > expected
|
|
177
|
+
: op === '>=' ? count >= expected
|
|
178
|
+
: op === '<' ? count < expected
|
|
179
|
+
: count <= expected;
|
|
180
|
+
if (!passed) {
|
|
181
|
+
throw new Error(`assert_count failed: "${selector}" has ${count} elements, expected ${op}${expected}`);
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
const expected = parseInt(value);
|
|
185
|
+
if (count !== expected) {
|
|
186
|
+
throw new Error(`assert_count failed: "${selector}" has ${count} elements, expected ${expected}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
case 'assert_element_text': {
|
|
193
|
+
await page.waitForSelector(selector, { timeout });
|
|
194
|
+
const elText = await page.$eval(selector, el => el.textContent);
|
|
195
|
+
if (value === 'exact') {
|
|
196
|
+
if (elText.trim() !== text) {
|
|
197
|
+
throw new Error(`assert_element_text failed: "${selector}" text is "${elText.trim()}", expected exact "${text}"`);
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
if (!elText.includes(text)) {
|
|
201
|
+
throw new Error(`assert_element_text failed: "${selector}" text "${elText.trim()}" does not contain "${text}"`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
case 'assert_attribute': {
|
|
208
|
+
await page.waitForSelector(selector, { timeout });
|
|
209
|
+
const eqIndex = value.indexOf('=');
|
|
210
|
+
if (eqIndex === -1) {
|
|
211
|
+
const hasAttr = await page.$eval(selector, (el, attr) => el.hasAttribute(attr), value);
|
|
212
|
+
if (!hasAttr) {
|
|
213
|
+
throw new Error(`assert_attribute failed: "${selector}" does not have attribute "${value}"`);
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
const attrName = value.slice(0, eqIndex);
|
|
217
|
+
const expectedVal = value.slice(eqIndex + 1);
|
|
218
|
+
const actual = await page.$eval(selector, (el, attr) => el.getAttribute(attr), attrName);
|
|
219
|
+
if (actual !== expectedVal) {
|
|
220
|
+
throw new Error(`assert_attribute failed: "${selector}" attribute "${attrName}" is "${actual}", expected "${expectedVal}"`);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
case 'assert_class': {
|
|
227
|
+
await page.waitForSelector(selector, { timeout });
|
|
228
|
+
const hasClass = await page.$eval(selector, (el, cls) => el.classList.contains(cls), value);
|
|
229
|
+
if (!hasClass) {
|
|
230
|
+
throw new Error(`assert_class failed: "${selector}" does not have class "${value}"`);
|
|
117
231
|
}
|
|
118
232
|
break;
|
|
119
233
|
}
|
|
120
234
|
|
|
235
|
+
case 'assert_not_visible': {
|
|
236
|
+
const notVisEl = await page.$(selector);
|
|
237
|
+
if (notVisEl) {
|
|
238
|
+
const isVisible = await page.$eval(selector, (e) => {
|
|
239
|
+
const style = window.getComputedStyle(e);
|
|
240
|
+
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
|
|
241
|
+
});
|
|
242
|
+
if (isVisible) {
|
|
243
|
+
throw new Error(`assert_not_visible failed: "${selector}" is visible`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
case 'assert_input_value': {
|
|
250
|
+
await page.waitForSelector(selector, { timeout });
|
|
251
|
+
const inputVal = await page.$eval(selector, el => el.value);
|
|
252
|
+
if (!inputVal.includes(value)) {
|
|
253
|
+
throw new Error(`assert_input_value failed: "${selector}" value is "${inputVal}", expected to contain "${value}"`);
|
|
254
|
+
}
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
case 'assert_matches': {
|
|
259
|
+
await page.waitForSelector(selector, { timeout });
|
|
260
|
+
const matchText = await page.$eval(selector, el => el.textContent);
|
|
261
|
+
if (!new RegExp(value).test(matchText)) {
|
|
262
|
+
throw new Error(`assert_matches failed: "${selector}" text "${matchText.trim()}" does not match pattern /${value}/`);
|
|
263
|
+
}
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
case 'assert_text_in': {
|
|
268
|
+
// Assert that text exists inside a scoped container element.
|
|
269
|
+
// selector: CSS selector for the container (required)
|
|
270
|
+
// text: substring or regex pattern to match against container's textContent (required)
|
|
271
|
+
// value: "i" for case-insensitive regex (default), "exact" for case-sensitive substring
|
|
272
|
+
if (!selector) throw new Error('assert_text_in requires "selector"');
|
|
273
|
+
if (!text) throw new Error('assert_text_in requires "text"');
|
|
274
|
+
await page.waitForSelector(selector, { timeout });
|
|
275
|
+
const containerText = await page.$$eval(selector, els => els.map(el => el.textContent).join(' '));
|
|
276
|
+
const flags = value === 'exact' ? '' : 'i';
|
|
277
|
+
if (value === 'exact') {
|
|
278
|
+
if (!containerText.includes(text)) {
|
|
279
|
+
const preview = containerText.length > 200 ? containerText.slice(0, 200) + '...' : containerText;
|
|
280
|
+
throw new Error(`assert_text_in failed: "${text}" not found in "${selector}"\n Content: ${preview}`);
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
if (!new RegExp(text, flags).test(containerText)) {
|
|
284
|
+
const preview = containerText.length > 200 ? containerText.slice(0, 200) + '...' : containerText;
|
|
285
|
+
throw new Error(`assert_text_in failed: /${text}/${flags} not found in "${selector}"\n Content: ${preview}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
case 'get_text': {
|
|
292
|
+
await page.waitForSelector(selector, { timeout });
|
|
293
|
+
const getText = await page.$eval(selector, el => el.textContent.trim());
|
|
294
|
+
return { value: getText };
|
|
295
|
+
}
|
|
296
|
+
|
|
121
297
|
case 'select':
|
|
122
298
|
await page.waitForSelector(selector, { timeout });
|
|
123
299
|
await page.select(selector, value);
|
|
@@ -162,21 +338,422 @@ export async function executeAction(page, action, config) {
|
|
|
162
338
|
break;
|
|
163
339
|
}
|
|
164
340
|
|
|
341
|
+
case 'clear_cookies': {
|
|
342
|
+
const client = await page.createCDPSession();
|
|
343
|
+
await client.send('Network.clearBrowserCookies');
|
|
344
|
+
await client.send('Storage.clearDataForOrigin', {
|
|
345
|
+
origin: value || baseUrl || page.url(),
|
|
346
|
+
storageTypes: 'cookies,local_storage,session_storage',
|
|
347
|
+
});
|
|
348
|
+
await client.detach();
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
case 'type_react': {
|
|
353
|
+
// Types into React controlled inputs using the native value setter.
|
|
354
|
+
// This bypasses React's synthetic event system which ignores programmatic .value changes.
|
|
355
|
+
await page.waitForSelector(selector, { timeout });
|
|
356
|
+
await page.evaluate((sel, val) => {
|
|
357
|
+
const input = document.querySelector(sel);
|
|
358
|
+
if (!input) throw new Error(`type_react: element "${sel}" not found`);
|
|
359
|
+
const proto = input instanceof HTMLTextAreaElement
|
|
360
|
+
? window.HTMLTextAreaElement.prototype
|
|
361
|
+
: window.HTMLInputElement.prototype;
|
|
362
|
+
const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
|
|
363
|
+
if (!descriptor || !descriptor.set) {
|
|
364
|
+
throw new Error(`type_react: element "${sel}" has no writable value property`);
|
|
365
|
+
}
|
|
366
|
+
descriptor.set.call(input, val);
|
|
367
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
368
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
369
|
+
input.focus();
|
|
370
|
+
}, selector, value);
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
case 'click_regex': {
|
|
375
|
+
// Click an element whose textContent matches a regex pattern.
|
|
376
|
+
// text = regex pattern (always case-insensitive)
|
|
377
|
+
// selector = optional CSS scope (defaults to common clickable elements)
|
|
378
|
+
// value = "last" to click the last match (default: first)
|
|
379
|
+
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';
|
|
380
|
+
const matchLast = value === 'last';
|
|
381
|
+
await page.waitForFunction(
|
|
382
|
+
(regex, sel) => [...document.querySelectorAll(sel)].some(el => new RegExp(regex, 'i').test(el.textContent)),
|
|
383
|
+
{ timeout },
|
|
384
|
+
text, matchSelector
|
|
385
|
+
);
|
|
386
|
+
const clicked = await page.$$eval(matchSelector, (els, regex, last) => {
|
|
387
|
+
const matches = els.filter(el => new RegExp(regex, 'i').test(el.textContent));
|
|
388
|
+
if (matches.length === 0) return false;
|
|
389
|
+
const target = last ? matches[matches.length - 1] : matches[0];
|
|
390
|
+
target.click();
|
|
391
|
+
return true;
|
|
392
|
+
}, text, matchLast);
|
|
393
|
+
if (!clicked) {
|
|
394
|
+
throw new Error(`click_regex failed: no element matching /${text}/i found`);
|
|
395
|
+
}
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
case 'click_option': {
|
|
400
|
+
// Click a [role="option"] element by text content — common in autocomplete dropdowns.
|
|
401
|
+
await page.waitForFunction(
|
|
402
|
+
(t) => [...document.querySelectorAll('[role="option"]')].some(el => el.textContent.includes(t)),
|
|
403
|
+
{ timeout },
|
|
404
|
+
text
|
|
405
|
+
);
|
|
406
|
+
const optionClicked = await page.$$eval('[role="option"]', (els, t) => {
|
|
407
|
+
const match = els.find(el => el.textContent.includes(t));
|
|
408
|
+
if (match) { match.click(); return true; }
|
|
409
|
+
return false;
|
|
410
|
+
}, text);
|
|
411
|
+
if (!optionClicked) {
|
|
412
|
+
throw new Error(`click_option failed: no [role="option"] containing "${text}" found`);
|
|
413
|
+
}
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
case 'focus_autocomplete': {
|
|
418
|
+
// Focus an autocomplete/combobox input by its label text.
|
|
419
|
+
// Supports MUI Autocomplete (.MuiAutocomplete-root) and generic [role="combobox"].
|
|
420
|
+
const focused = await page.evaluate((labelText) => {
|
|
421
|
+
const containers = [
|
|
422
|
+
...document.querySelectorAll('.MuiAutocomplete-root'),
|
|
423
|
+
...document.querySelectorAll('[role="combobox"]'),
|
|
424
|
+
];
|
|
425
|
+
const match = containers.find(c => {
|
|
426
|
+
const label = c.querySelector('label');
|
|
427
|
+
return label && label.textContent.includes(labelText);
|
|
428
|
+
});
|
|
429
|
+
if (!match) return null;
|
|
430
|
+
const input = match.querySelector('input');
|
|
431
|
+
if (!input) return null;
|
|
432
|
+
input.focus();
|
|
433
|
+
input.click();
|
|
434
|
+
return input.id || 'focused';
|
|
435
|
+
}, text);
|
|
436
|
+
if (!focused) {
|
|
437
|
+
throw new Error(`focus_autocomplete failed: no autocomplete with label "${text}" found`);
|
|
438
|
+
}
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
case 'click_chip': {
|
|
443
|
+
// Click a chip/tag element by text content.
|
|
444
|
+
// Searches MUI Chip classes and common chip patterns.
|
|
445
|
+
const chipClicked = await page.evaluate((chipText) => {
|
|
446
|
+
const chips = Array.from(document.querySelectorAll(
|
|
447
|
+
'[class*="Chip"], [class*="chip"], [data-chip], [role="option"][aria-selected]'
|
|
448
|
+
));
|
|
449
|
+
const match = chips.find(c => c.textContent.includes(chipText));
|
|
450
|
+
if (!match) return false;
|
|
451
|
+
match.click();
|
|
452
|
+
return true;
|
|
453
|
+
}, text);
|
|
454
|
+
if (!chipClicked) {
|
|
455
|
+
throw new Error(`click_chip failed: no chip containing "${text}" found`);
|
|
456
|
+
}
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
case 'set_storage': {
|
|
461
|
+
// Set a localStorage or sessionStorage key.
|
|
462
|
+
// value: "key=val", selector: "session" for sessionStorage (default: localStorage)
|
|
463
|
+
const eqIdx = value.indexOf('=');
|
|
464
|
+
if (eqIdx === -1) {
|
|
465
|
+
throw new Error(`set_storage: value must be "key=value", got "${value}"`);
|
|
466
|
+
}
|
|
467
|
+
const storageKey = value.slice(0, eqIdx);
|
|
468
|
+
const storageVal = value.slice(eqIdx + 1);
|
|
469
|
+
const storageType = selector === 'session' ? 'sessionStorage' : 'localStorage';
|
|
470
|
+
await page.evaluate((sType, k, v) => {
|
|
471
|
+
window[sType].setItem(k, v);
|
|
472
|
+
}, storageType, storageKey, storageVal);
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
case 'assert_storage': {
|
|
477
|
+
// Assert a localStorage or sessionStorage key exists or has a specific value.
|
|
478
|
+
// value: "key" (existence) or "key=expected" (value match)
|
|
479
|
+
// selector: "session" for sessionStorage (default: localStorage)
|
|
480
|
+
const storageType = selector === 'session' ? 'sessionStorage' : 'localStorage';
|
|
481
|
+
const eqIdx = value.indexOf('=');
|
|
482
|
+
if (eqIdx === -1) {
|
|
483
|
+
// Existence check
|
|
484
|
+
const exists = await page.evaluate((sType, k) => window[sType].getItem(k) !== null, storageType, value);
|
|
485
|
+
if (!exists) {
|
|
486
|
+
throw new Error(`assert_storage failed: ${storageType} key "${value}" does not exist`);
|
|
487
|
+
}
|
|
488
|
+
} else {
|
|
489
|
+
const storageKey = value.slice(0, eqIdx);
|
|
490
|
+
const expectedVal = value.slice(eqIdx + 1);
|
|
491
|
+
const actual = await page.evaluate((sType, k) => window[sType].getItem(k), storageType, storageKey);
|
|
492
|
+
if (actual === null) {
|
|
493
|
+
throw new Error(`assert_storage failed: ${storageType} key "${storageKey}" does not exist`);
|
|
494
|
+
}
|
|
495
|
+
if (actual !== expectedVal) {
|
|
496
|
+
throw new Error(`assert_storage failed: ${storageType} key "${storageKey}" is "${actual}", expected "${expectedVal}"`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
case 'click_icon': {
|
|
503
|
+
// Click an icon element by identifier — works with MUI, FontAwesome, Heroicons, Bootstrap Icons, etc.
|
|
504
|
+
// value: icon identifier (data-testid fragment, class fragment, aria-label, or SVG text/title)
|
|
505
|
+
// selector: optional CSS scope to narrow the search
|
|
506
|
+
const iconId = value;
|
|
507
|
+
const iconScope = selector || null;
|
|
508
|
+
await page.waitForFunction(
|
|
509
|
+
(id, scope) => {
|
|
510
|
+
const root = scope ? document.querySelector(scope) : document;
|
|
511
|
+
if (!root) return false;
|
|
512
|
+
// Search by common icon attribute patterns
|
|
513
|
+
const attrSelectors = [
|
|
514
|
+
`[data-testid*="${id}"]`,
|
|
515
|
+
`[data-icon*="${id}"]`,
|
|
516
|
+
`[aria-label*="${id}"]`,
|
|
517
|
+
`svg[class*="${id}"]`,
|
|
518
|
+
`i[class*="${id}"]`,
|
|
519
|
+
`span[class*="${id}"]`,
|
|
520
|
+
];
|
|
521
|
+
for (const sel of attrSelectors) {
|
|
522
|
+
if (root.querySelector(sel)) return true;
|
|
523
|
+
}
|
|
524
|
+
// Search all SVGs for matching text content or title
|
|
525
|
+
for (const svg of root.querySelectorAll('svg')) {
|
|
526
|
+
const title = svg.querySelector('title');
|
|
527
|
+
if (title && title.textContent.toLowerCase().includes(id.toLowerCase())) return true;
|
|
528
|
+
if (svg.getAttribute('aria-label')?.toLowerCase().includes(id.toLowerCase())) return true;
|
|
529
|
+
}
|
|
530
|
+
return false;
|
|
531
|
+
},
|
|
532
|
+
{ timeout },
|
|
533
|
+
iconId, iconScope
|
|
534
|
+
);
|
|
535
|
+
const clicked = await page.evaluate(
|
|
536
|
+
(id, scope) => {
|
|
537
|
+
const root = scope ? document.querySelector(scope) : document;
|
|
538
|
+
if (!root) return false;
|
|
539
|
+
let icon = null;
|
|
540
|
+
const attrSelectors = [
|
|
541
|
+
`[data-testid*="${id}"]`,
|
|
542
|
+
`[data-icon*="${id}"]`,
|
|
543
|
+
`[aria-label*="${id}"]`,
|
|
544
|
+
`svg[class*="${id}"]`,
|
|
545
|
+
`i[class*="${id}"]`,
|
|
546
|
+
`span[class*="${id}"]`,
|
|
547
|
+
];
|
|
548
|
+
for (const sel of attrSelectors) {
|
|
549
|
+
icon = root.querySelector(sel);
|
|
550
|
+
if (icon) break;
|
|
551
|
+
}
|
|
552
|
+
// Fallback: search SVGs by title/aria-label text
|
|
553
|
+
if (!icon) {
|
|
554
|
+
for (const svg of root.querySelectorAll('svg')) {
|
|
555
|
+
const title = svg.querySelector('title');
|
|
556
|
+
if (title && title.textContent.toLowerCase().includes(id.toLowerCase())) { icon = svg; break; }
|
|
557
|
+
if (svg.getAttribute('aria-label')?.toLowerCase().includes(id.toLowerCase())) { icon = svg; break; }
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (!icon) return false;
|
|
561
|
+
// Walk up to nearest clickable ancestor
|
|
562
|
+
const clickableSelector = 'button, a, [role="button"], [role="tab"], [role="menuitem"]';
|
|
563
|
+
const clickable = icon.closest(clickableSelector);
|
|
564
|
+
(clickable || icon).click();
|
|
565
|
+
return true;
|
|
566
|
+
},
|
|
567
|
+
iconId, iconScope
|
|
568
|
+
);
|
|
569
|
+
if (!clicked) {
|
|
570
|
+
throw new Error(`click_icon failed: no icon matching "${iconId}" found${iconScope ? ` in "${iconScope}"` : ''}`);
|
|
571
|
+
}
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
case 'click_menu_item': {
|
|
576
|
+
// Click a menu item by text content.
|
|
577
|
+
// text: menu item text to match (case-sensitive, substring)
|
|
578
|
+
// selector: optional CSS scope
|
|
579
|
+
const menuSelector = [
|
|
580
|
+
'[role="menuitem"]',
|
|
581
|
+
'[role="menuitemradio"]',
|
|
582
|
+
'[role="menuitemcheckbox"]',
|
|
583
|
+
'.dropdown-item',
|
|
584
|
+
'.menu-item',
|
|
585
|
+
'[class*="MenuItem"]',
|
|
586
|
+
'[role="menu"] > li',
|
|
587
|
+
].join(', ');
|
|
588
|
+
const menuScope = selector || null;
|
|
589
|
+
await page.waitForFunction(
|
|
590
|
+
(t, sel, scope) => {
|
|
591
|
+
const root = scope ? document.querySelector(scope) : document;
|
|
592
|
+
if (!root) return false;
|
|
593
|
+
return [...root.querySelectorAll(sel)].some(el => el.textContent.includes(t));
|
|
594
|
+
},
|
|
595
|
+
{ timeout },
|
|
596
|
+
text, menuSelector, menuScope
|
|
597
|
+
);
|
|
598
|
+
const clicked = await page.evaluate(
|
|
599
|
+
(t, sel, scope) => {
|
|
600
|
+
const root = scope ? document.querySelector(scope) : document;
|
|
601
|
+
if (!root) return false;
|
|
602
|
+
const match = [...root.querySelectorAll(sel)].find(el => el.textContent.includes(t));
|
|
603
|
+
if (match) { match.click(); return true; }
|
|
604
|
+
return false;
|
|
605
|
+
},
|
|
606
|
+
text, menuSelector, menuScope
|
|
607
|
+
);
|
|
608
|
+
if (!clicked) {
|
|
609
|
+
throw new Error(`click_menu_item failed: no menu item containing "${text}" found${menuScope ? ` in "${menuScope}"` : ''}`);
|
|
610
|
+
}
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
case 'click_in_context': {
|
|
615
|
+
// Click a child element within a container identified by text content.
|
|
616
|
+
// text: text to find the container (required)
|
|
617
|
+
// selector: CSS selector for the child to click within that container (required)
|
|
618
|
+
if (!text || !selector) {
|
|
619
|
+
throw new Error('click_in_context requires both "text" (container text) and "selector" (child to click)');
|
|
620
|
+
}
|
|
621
|
+
const containerSelectors = [
|
|
622
|
+
'section', 'article',
|
|
623
|
+
'[class*="card"]', '[class*="Card"]',
|
|
624
|
+
'[class*="panel"]', '[class*="Panel"]',
|
|
625
|
+
'[class*="item"]', '[class*="Item"]',
|
|
626
|
+
'.MuiGrid-item', '[class*="MuiGrid2"]',
|
|
627
|
+
'[class*="row"]', '[class*="Row"]',
|
|
628
|
+
'details', 'fieldset',
|
|
629
|
+
'[role="region"]', '[role="group"]', '[role="listitem"]',
|
|
630
|
+
'li', 'tr', 'div[class]',
|
|
631
|
+
].join(', ');
|
|
632
|
+
await page.waitForFunction(
|
|
633
|
+
(t, childSel, containerSels) => {
|
|
634
|
+
const containers = [...document.querySelectorAll(containerSels)]
|
|
635
|
+
.filter(el => el.textContent.includes(t));
|
|
636
|
+
// Sort by innerHTML length (smallest = most specific)
|
|
637
|
+
containers.sort((a, b) => a.innerHTML.length - b.innerHTML.length);
|
|
638
|
+
for (const c of containers) {
|
|
639
|
+
if (c.querySelector(childSel)) return true;
|
|
640
|
+
}
|
|
641
|
+
return false;
|
|
642
|
+
},
|
|
643
|
+
{ timeout },
|
|
644
|
+
text, selector, containerSelectors
|
|
645
|
+
);
|
|
646
|
+
const clicked = await page.evaluate(
|
|
647
|
+
(t, childSel, containerSels) => {
|
|
648
|
+
const containers = [...document.querySelectorAll(containerSels)]
|
|
649
|
+
.filter(el => el.textContent.includes(t));
|
|
650
|
+
containers.sort((a, b) => a.innerHTML.length - b.innerHTML.length);
|
|
651
|
+
for (const c of containers) {
|
|
652
|
+
const child = c.querySelector(childSel);
|
|
653
|
+
if (child) { child.click(); return true; }
|
|
654
|
+
}
|
|
655
|
+
return false;
|
|
656
|
+
},
|
|
657
|
+
text, selector, containerSelectors
|
|
658
|
+
);
|
|
659
|
+
if (!clicked) {
|
|
660
|
+
throw new Error(`click_in_context failed: no "${selector}" found in container with text "${text}"`);
|
|
661
|
+
}
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
case 'gql': {
|
|
666
|
+
// Execute a GraphQL query/mutation via browser fetch.
|
|
667
|
+
// Reads auth token from localStorage and sends it as a configurable header.
|
|
668
|
+
// Installs window.__e2eGql(query, vars) helper for use in subsequent evaluate actions.
|
|
669
|
+
//
|
|
670
|
+
// value: GraphQL query/mutation string (required)
|
|
671
|
+
// text: variables as JSON string (optional)
|
|
672
|
+
// selector: JS expression assertion — receives response as `r` (optional)
|
|
673
|
+
const gqlEndpoint = config.gqlEndpoint || '/api/graphql';
|
|
674
|
+
const gqlAuthHeader = config.gqlAuthHeader || 'Authorization';
|
|
675
|
+
const gqlAuthKey = config.gqlAuthKey || 'accessToken';
|
|
676
|
+
const gqlAuthPrefix = config.gqlAuthPrefix ?? 'Bearer ';
|
|
677
|
+
const gqlVars = text || undefined;
|
|
678
|
+
|
|
679
|
+
const gqlResult = await page.evaluate(async (query, varsJson, endpoint, authHdr, authKey, authPfx) => {
|
|
680
|
+
// Install reusable helper on first call
|
|
681
|
+
if (!window.__e2eGql) {
|
|
682
|
+
window.__e2eGqlConfig = { endpoint, authHeader: authHdr, authKey, authPrefix: authPfx };
|
|
683
|
+
window.__e2eGql = async (q, v) => {
|
|
684
|
+
const cfg = window.__e2eGqlConfig;
|
|
685
|
+
const token = localStorage.getItem(cfg.authKey);
|
|
686
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
687
|
+
if (token) headers[cfg.authHeader] = cfg.authPrefix + token;
|
|
688
|
+
const resp = await fetch(location.origin + cfg.endpoint, {
|
|
689
|
+
method: 'POST', headers,
|
|
690
|
+
body: JSON.stringify({ query: q, variables: v }),
|
|
691
|
+
});
|
|
692
|
+
return resp.json();
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const vars = varsJson ? JSON.parse(varsJson) : undefined;
|
|
697
|
+
const response = await window.__e2eGql(query, vars);
|
|
698
|
+
window.__e2eLastGql = response;
|
|
699
|
+
return response;
|
|
700
|
+
}, value, gqlVars, gqlEndpoint, gqlAuthHeader, gqlAuthKey, gqlAuthPrefix);
|
|
701
|
+
|
|
702
|
+
// Check for GraphQL errors
|
|
703
|
+
if (gqlResult.errors?.length) {
|
|
704
|
+
throw new Error(`gql failed: ${gqlResult.errors.map(e => e.message).join('; ')}`);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Optional assertion via selector field (JS expression, `r` = full response)
|
|
708
|
+
// Intentional: runs JS in browser page context from team-authored JSON test files,
|
|
709
|
+
// same security model as the 'evaluate' action type.
|
|
710
|
+
if (selector) {
|
|
711
|
+
const assertResult = await page.evaluate((code, r) => {
|
|
712
|
+
const fn = new Function('r', `return (${code})`); // eslint-disable-line no-new-func
|
|
713
|
+
return fn(r);
|
|
714
|
+
}, selector, gqlResult);
|
|
715
|
+
|
|
716
|
+
if (typeof assertResult === 'string' && /^(FAIL|ERROR|FAILED)[\s:]/i.test(assertResult)) {
|
|
717
|
+
throw new Error(`gql assertion: ${assertResult}`);
|
|
718
|
+
}
|
|
719
|
+
if (assertResult === false) {
|
|
720
|
+
throw new Error(`gql assertion returned false`);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
return { value: gqlResult.data };
|
|
725
|
+
}
|
|
726
|
+
|
|
165
727
|
case 'evaluate': {
|
|
166
728
|
// Intentional: runs JS in browser page context (from test JSON files)
|
|
167
|
-
const
|
|
168
|
-
|
|
729
|
+
const jsSnippet = value.length > 120 ? value.slice(0, 120) + '...' : value;
|
|
730
|
+
let evalResult;
|
|
731
|
+
try {
|
|
732
|
+
evalResult = await page.evaluate(value);
|
|
733
|
+
} catch (evalErr) {
|
|
734
|
+
const pageUrl = page.url();
|
|
735
|
+
throw new Error(`evaluate threw on ${pageUrl}: ${evalErr.message}\n JS: ${jsSnippet}`);
|
|
736
|
+
}
|
|
169
737
|
if (typeof evalResult === 'string' && /^(FAIL|ERROR|FAILED)[\s:]/i.test(evalResult)) {
|
|
170
|
-
|
|
738
|
+
const pageUrl = page.url();
|
|
739
|
+
throw new Error(`evaluate failed on ${pageUrl}: ${evalResult}\n JS: ${jsSnippet}`);
|
|
171
740
|
}
|
|
172
741
|
if (evalResult === false) {
|
|
173
|
-
|
|
742
|
+
const pageUrl = page.url();
|
|
743
|
+
throw new Error(`evaluate returned false on ${pageUrl}\n JS: ${jsSnippet}`);
|
|
174
744
|
}
|
|
175
745
|
return evalResult !== undefined && evalResult !== null ? { value: evalResult } : null;
|
|
176
746
|
}
|
|
177
747
|
|
|
748
|
+
case 'wait_network_idle': {
|
|
749
|
+
const idleTime = value ? parseInt(value) : 500;
|
|
750
|
+
const maxTimeout = action.timeout ? parseInt(action.timeout) : 30000;
|
|
751
|
+
await page.waitForNetworkIdle({ idleTime, timeout: maxTimeout });
|
|
752
|
+
break;
|
|
753
|
+
}
|
|
754
|
+
|
|
178
755
|
default:
|
|
179
|
-
|
|
756
|
+
throw new Error(`Unknown action type: "${type}"`);
|
|
180
757
|
}
|
|
181
758
|
|
|
182
759
|
return null;
|