@matware/e2e-runner 1.2.1 → 1.3.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/marketplace.json +52 -0
- package/.claude-plugin/plugin.json +17 -3
- package/.mcp.json +2 -2
- 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/LICENSE +190 -0
- package/OPENCODE.md +166 -0
- package/README.md +165 -104
- package/agents/test-creator.md +54 -1
- package/agents/test-improver.md +37 -0
- package/bin/cli.js +409 -16
- package/commands/capture.md +45 -0
- package/commands/create-test.md +16 -1
- package/opencode.json +11 -0
- package/package.json +7 -2
- package/scripts/setup-opencode.sh +113 -0
- package/skills/e2e-testing/SKILL.md +10 -3
- package/skills/e2e-testing/references/action-types.md +48 -5
- 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 +4 -0
- package/skills/e2e-testing/references/troubleshooting.md +44 -2
- package/skills/e2e-testing/references/variables.md +41 -0
- package/skills/e2e-testing/references/visual-verification.md +89 -0
- package/src/actions.js +475 -2
- package/src/ai-generate.js +139 -8
- package/src/app-pool.js +339 -0
- package/src/config.js +266 -5
- package/src/dashboard.js +216 -17
- package/src/db.js +191 -7
- package/src/index.js +12 -9
- package/src/learner-sqlite.js +458 -0
- package/src/learner.js +78 -6
- package/src/mcp-tools.js +1348 -51
- package/src/module-resolver.js +37 -0
- package/src/narrate.js +65 -0
- package/src/pool-manager.js +229 -0
- package/src/pool.js +301 -31
- package/src/reporter.js +86 -2
- package/src/runner.js +480 -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 +10 -7
- package/src/visual-diff.js +446 -0
- package/src/watch.js +384 -0
- package/templates/build-dashboard.js +47 -6
- package/templates/dashboard/js/api.js +62 -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 +216 -0
- package/templates/dashboard/js/view-live.js +181 -0
- package/templates/dashboard/js/view-runs.js +676 -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 +116 -0
- package/templates/dashboard/styles/base.css +69 -0
- package/templates/dashboard/styles/components.css +117 -0
- package/templates/dashboard/styles/view-live.css +97 -0
- package/templates/dashboard/styles/view-runs.css +243 -0
- package/templates/dashboard/styles/view-tests.css +96 -0
- package/templates/dashboard/styles/view-watch.css +53 -0
- package/templates/dashboard/template.html +181 -100
- package/templates/dashboard.html +1614 -547
- package/templates/sample-test.json +0 -8
- package/templates/dashboard/app.js +0 -1152
- package/templates/dashboard/styles.css +0 -413
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Variables Reference
|
|
2
|
+
|
|
3
|
+
Variables replace hardcoded sensitive values (JWT tokens, user IDs, API keys, etc.) in test JSON. Stored in SQLite (`~/.e2e-runner/dashboard.db`), scoped per project and per suite, editable from the dashboard UI.
|
|
4
|
+
|
|
5
|
+
## Syntax
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
{{var.TOKEN}} → resolves from DB (suite scope → project scope)
|
|
9
|
+
{{env.MY_VAR}} → resolves from process.env
|
|
10
|
+
{{param}} → existing module param substitution (unchanged)
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
**Resolution priority:** suite vars > project vars > error if not found.
|
|
14
|
+
|
|
15
|
+
## Usage in Test JSON
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{ "$use": "auth-jwt", "params": { "token": "{{var.JWT_TOKEN}}", "orgId": "{{var.ORG_ID}}" } }
|
|
19
|
+
{ "type": "goto", "value": "/users/{{var.USER_ID}}/profile" }
|
|
20
|
+
{ "type": "gql", "value": "{ user(id: \"{{var.USER_ID}}\") { name } }" }
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## MCP Tool (`e2e_vars`)
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
e2e_vars({ action: "set", key: "TOKEN", value: "abc123", scope: "project" })
|
|
27
|
+
e2e_vars({ action: "set", key: "TOKEN", value: "xyz789", scope: "auth" }) // suite-specific override
|
|
28
|
+
e2e_vars({ action: "list" })
|
|
29
|
+
e2e_vars({ action: "get", key: "TOKEN" })
|
|
30
|
+
e2e_vars({ action: "delete", key: "TOKEN", scope: "project" })
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Dashboard UI
|
|
34
|
+
|
|
35
|
+
Variables tab shows all variables grouped by scope. Values are masked by default (click to reveal). Inline edit, add new, and delete are supported.
|
|
36
|
+
|
|
37
|
+
## REST API
|
|
38
|
+
|
|
39
|
+
- `GET /api/db/projects/:id/variables` — list all vars for project
|
|
40
|
+
- `PUT /api/db/projects/:id/variables` — set a variable `{ scope, key, value }`
|
|
41
|
+
- `DELETE /api/db/projects/:id/variables/:scope/:key` — delete a variable
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# Visual Verification Reference
|
|
2
|
+
|
|
3
|
+
Tests can include an `expect` field for AI-powered visual verification. No API key required — Claude Code itself does the visual judgment.
|
|
4
|
+
|
|
5
|
+
## Expect Field Formats
|
|
6
|
+
|
|
7
|
+
### String form — free-form description
|
|
8
|
+
```json
|
|
9
|
+
{
|
|
10
|
+
"name": "dashboard-loads",
|
|
11
|
+
"expect": "Should show the data table with at least 3 rows, no error messages, and the sidebar with navigation links",
|
|
12
|
+
"actions": [
|
|
13
|
+
{ "type": "goto", "value": "/dashboard" },
|
|
14
|
+
{ "type": "wait", "selector": ".data-table" }
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Array form — per-criterion checklist (each evaluated independently as PASS/FAIL)
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"name": "dashboard-loads",
|
|
23
|
+
"expect": [
|
|
24
|
+
"Data table visible with at least 3 rows",
|
|
25
|
+
"No error messages or red banners",
|
|
26
|
+
"Sidebar shows navigation links"
|
|
27
|
+
],
|
|
28
|
+
"actions": [
|
|
29
|
+
{ "type": "goto", "value": "/dashboard" },
|
|
30
|
+
{ "type": "wait", "selector": ".data-table" }
|
|
31
|
+
]
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Double Screenshot (Before/After)
|
|
36
|
+
|
|
37
|
+
When `expect` is present, the runner captures TWO screenshots:
|
|
38
|
+
1. **Baseline** (`baseline-{name}-{timestamp}.png`) — captured BEFORE test actions run (after `beforeEach` hooks)
|
|
39
|
+
2. **Verification** (`verify-{name}-{timestamp}.png`) — captured AFTER all actions complete
|
|
40
|
+
|
|
41
|
+
Both hashes are registered in SQLite and returned in the MCP response for before/after comparison.
|
|
42
|
+
|
|
43
|
+
## Verification Strictness
|
|
44
|
+
|
|
45
|
+
Controls how strictly Claude Code evaluates visual verification. Set via:
|
|
46
|
+
- Config: `verificationStrictness: 'moderate'`
|
|
47
|
+
- CLI: `--verification-strictness strict`
|
|
48
|
+
- Env: `VERIFICATION_STRICTNESS=strict`
|
|
49
|
+
- MCP: `verificationStrictness: 'strict'` in `e2e_run` args
|
|
50
|
+
|
|
51
|
+
| Level | Behavior |
|
|
52
|
+
|-------|----------|
|
|
53
|
+
| **`strict`** | No ambiguity allowed. If any criterion is unclear, not fully visible, or doubtful → FAIL. |
|
|
54
|
+
| **`moderate`** (default) | Reasonable judgment. Minor cosmetic differences acceptable, functional mismatches → FAIL. |
|
|
55
|
+
| **`lenient`** | Only fail on clear, obvious contradictions. |
|
|
56
|
+
|
|
57
|
+
## MCP Response Format
|
|
58
|
+
|
|
59
|
+
The `e2e_run` response includes a `verifications` array:
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"verifications": [
|
|
63
|
+
{
|
|
64
|
+
"name": "dashboard-loads",
|
|
65
|
+
"expect": ["Data table visible...", "No error messages..."],
|
|
66
|
+
"success": true,
|
|
67
|
+
"screenshotHash": "ss:a3f2b1c9",
|
|
68
|
+
"baselineScreenshotHash": "ss:b4e1c2d8",
|
|
69
|
+
"isChecklist": true
|
|
70
|
+
}
|
|
71
|
+
],
|
|
72
|
+
"verificationInstructions": "Verification strictness: MODERATE — ..."
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Verdict Format
|
|
77
|
+
|
|
78
|
+
After calling `e2e_screenshot` for each hash (after + baseline), Claude Code reports a structured verdict:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
TEST: dashboard-loads
|
|
82
|
+
VERDICT: PASS
|
|
83
|
+
STATE CHANGE: Page loaded from blank to populated dashboard
|
|
84
|
+
CRITERIA:
|
|
85
|
+
- "Data table visible with at least 3 rows": PASS
|
|
86
|
+
- "No error messages or red banners": PASS
|
|
87
|
+
- "Sidebar shows navigation links": PASS
|
|
88
|
+
REASON: All criteria met, dashboard fully loaded with expected content
|
|
89
|
+
```
|
package/src/actions.js
CHANGED
|
@@ -8,7 +8,25 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import path from 'path';
|
|
11
|
-
import
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import { assertVisualMatch } from './visual-diff.js';
|
|
13
|
+
|
|
14
|
+
/** All recognized action types — single source of truth for validation. */
|
|
15
|
+
export const KNOWN_ACTION_TYPES = new Set([
|
|
16
|
+
'goto', 'click', 'type', 'fill', 'wait', 'screenshot',
|
|
17
|
+
'assert_text', 'assert_url', 'assert_visible', 'assert_count',
|
|
18
|
+
'assert_element_text', 'assert_attribute', 'assert_class',
|
|
19
|
+
'assert_not_visible', 'assert_input_value', 'assert_matches',
|
|
20
|
+
'assert_no_network_errors', 'assert_storage',
|
|
21
|
+
'get_text', 'select', 'clear', 'clear_cookies', 'press', 'scroll', 'hover',
|
|
22
|
+
'navigate', 'evaluate',
|
|
23
|
+
'type_react', 'click_regex', 'click_option', 'focus_autocomplete', 'click_chip',
|
|
24
|
+
'set_storage', 'click_icon', 'click_menu_item', 'click_in_context',
|
|
25
|
+
'assert_text_in', 'assert_no_text',
|
|
26
|
+
'gql', 'wait_network_idle',
|
|
27
|
+
'open_tab', 'switch_tab', 'close_tab', 'assert_tab_count', 'wait_for_tab',
|
|
28
|
+
'assert_visual',
|
|
29
|
+
]);
|
|
12
30
|
|
|
13
31
|
function sleep(ms) {
|
|
14
32
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
@@ -102,6 +120,16 @@ export async function executeAction(page, action, config) {
|
|
|
102
120
|
break;
|
|
103
121
|
}
|
|
104
122
|
|
|
123
|
+
case 'assert_no_text': {
|
|
124
|
+
// Assert that text does NOT appear anywhere on the page.
|
|
125
|
+
// text: substring to check for absence (required)
|
|
126
|
+
const bodyTextNo = await page.evaluate(() => document.body.innerText);
|
|
127
|
+
if (bodyTextNo.includes(text)) {
|
|
128
|
+
throw new Error(`assert_no_text failed: "${text}" was found on the page but should not be present`);
|
|
129
|
+
}
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
|
|
105
133
|
case 'assert_url': {
|
|
106
134
|
const currentUrl = page.url();
|
|
107
135
|
let match = false;
|
|
@@ -240,6 +268,30 @@ export async function executeAction(page, action, config) {
|
|
|
240
268
|
break;
|
|
241
269
|
}
|
|
242
270
|
|
|
271
|
+
case 'assert_text_in': {
|
|
272
|
+
// Assert that text exists inside a scoped container element.
|
|
273
|
+
// selector: CSS selector for the container (required)
|
|
274
|
+
// text: substring or regex pattern to match against container's textContent (required)
|
|
275
|
+
// value: "i" for case-insensitive regex (default), "exact" for case-sensitive substring
|
|
276
|
+
if (!selector) throw new Error('assert_text_in requires "selector"');
|
|
277
|
+
if (!text) throw new Error('assert_text_in requires "text"');
|
|
278
|
+
await page.waitForSelector(selector, { timeout });
|
|
279
|
+
const containerText = await page.$$eval(selector, els => els.map(el => el.textContent).join(' '));
|
|
280
|
+
const flags = value === 'exact' ? '' : 'i';
|
|
281
|
+
if (value === 'exact') {
|
|
282
|
+
if (!containerText.includes(text)) {
|
|
283
|
+
const preview = containerText.length > 200 ? containerText.slice(0, 200) + '...' : containerText;
|
|
284
|
+
throw new Error(`assert_text_in failed: "${text}" not found in "${selector}"\n Content: ${preview}`);
|
|
285
|
+
}
|
|
286
|
+
} else {
|
|
287
|
+
if (!new RegExp(text, flags).test(containerText)) {
|
|
288
|
+
const preview = containerText.length > 200 ? containerText.slice(0, 200) + '...' : containerText;
|
|
289
|
+
throw new Error(`assert_text_in failed: /${text}/${flags} not found in "${selector}"\n Content: ${preview}`);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
|
|
243
295
|
case 'get_text': {
|
|
244
296
|
await page.waitForSelector(selector, { timeout });
|
|
245
297
|
const getText = await page.$eval(selector, el => el.textContent.trim());
|
|
@@ -409,6 +461,273 @@ export async function executeAction(page, action, config) {
|
|
|
409
461
|
break;
|
|
410
462
|
}
|
|
411
463
|
|
|
464
|
+
case 'set_storage': {
|
|
465
|
+
// Set a localStorage or sessionStorage key.
|
|
466
|
+
// value: "key=val", selector: "session" for sessionStorage (default: localStorage)
|
|
467
|
+
const eqIdx = value.indexOf('=');
|
|
468
|
+
if (eqIdx === -1) {
|
|
469
|
+
throw new Error(`set_storage: value must be "key=value", got "${value}"`);
|
|
470
|
+
}
|
|
471
|
+
const storageKey = value.slice(0, eqIdx);
|
|
472
|
+
const storageVal = value.slice(eqIdx + 1);
|
|
473
|
+
const storageType = selector === 'session' ? 'sessionStorage' : 'localStorage';
|
|
474
|
+
await page.evaluate((sType, k, v) => {
|
|
475
|
+
window[sType].setItem(k, v);
|
|
476
|
+
}, storageType, storageKey, storageVal);
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
case 'assert_storage': {
|
|
481
|
+
// Assert a localStorage or sessionStorage key exists or has a specific value.
|
|
482
|
+
// value: "key" (existence) or "key=expected" (value match)
|
|
483
|
+
// selector: "session" for sessionStorage (default: localStorage)
|
|
484
|
+
const storageType = selector === 'session' ? 'sessionStorage' : 'localStorage';
|
|
485
|
+
const eqIdx = value.indexOf('=');
|
|
486
|
+
if (eqIdx === -1) {
|
|
487
|
+
// Existence check
|
|
488
|
+
const exists = await page.evaluate((sType, k) => window[sType].getItem(k) !== null, storageType, value);
|
|
489
|
+
if (!exists) {
|
|
490
|
+
throw new Error(`assert_storage failed: ${storageType} key "${value}" does not exist`);
|
|
491
|
+
}
|
|
492
|
+
} else {
|
|
493
|
+
const storageKey = value.slice(0, eqIdx);
|
|
494
|
+
const expectedVal = value.slice(eqIdx + 1);
|
|
495
|
+
const actual = await page.evaluate((sType, k) => window[sType].getItem(k), storageType, storageKey);
|
|
496
|
+
if (actual === null) {
|
|
497
|
+
throw new Error(`assert_storage failed: ${storageType} key "${storageKey}" does not exist`);
|
|
498
|
+
}
|
|
499
|
+
if (actual !== expectedVal) {
|
|
500
|
+
throw new Error(`assert_storage failed: ${storageType} key "${storageKey}" is "${actual}", expected "${expectedVal}"`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
case 'click_icon': {
|
|
507
|
+
// Click an icon element by identifier — works with MUI, FontAwesome, Heroicons, Bootstrap Icons, etc.
|
|
508
|
+
// value: icon identifier (data-testid fragment, class fragment, aria-label, or SVG text/title)
|
|
509
|
+
// selector: optional CSS scope to narrow the search
|
|
510
|
+
const iconId = value;
|
|
511
|
+
const iconScope = selector || null;
|
|
512
|
+
await page.waitForFunction(
|
|
513
|
+
(id, scope) => {
|
|
514
|
+
const root = scope ? document.querySelector(scope) : document;
|
|
515
|
+
if (!root) return false;
|
|
516
|
+
// Search by common icon attribute patterns
|
|
517
|
+
const attrSelectors = [
|
|
518
|
+
`[data-testid*="${id}"]`,
|
|
519
|
+
`[data-icon*="${id}"]`,
|
|
520
|
+
`[aria-label*="${id}"]`,
|
|
521
|
+
`svg[class*="${id}"]`,
|
|
522
|
+
`i[class*="${id}"]`,
|
|
523
|
+
`span[class*="${id}"]`,
|
|
524
|
+
];
|
|
525
|
+
for (const sel of attrSelectors) {
|
|
526
|
+
if (root.querySelector(sel)) return true;
|
|
527
|
+
}
|
|
528
|
+
// Search all SVGs for matching text content or title
|
|
529
|
+
for (const svg of root.querySelectorAll('svg')) {
|
|
530
|
+
const title = svg.querySelector('title');
|
|
531
|
+
if (title && title.textContent.toLowerCase().includes(id.toLowerCase())) return true;
|
|
532
|
+
if (svg.getAttribute('aria-label')?.toLowerCase().includes(id.toLowerCase())) return true;
|
|
533
|
+
}
|
|
534
|
+
return false;
|
|
535
|
+
},
|
|
536
|
+
{ timeout },
|
|
537
|
+
iconId, iconScope
|
|
538
|
+
);
|
|
539
|
+
const clicked = await page.evaluate(
|
|
540
|
+
(id, scope) => {
|
|
541
|
+
const root = scope ? document.querySelector(scope) : document;
|
|
542
|
+
if (!root) return false;
|
|
543
|
+
let icon = null;
|
|
544
|
+
const attrSelectors = [
|
|
545
|
+
`[data-testid*="${id}"]`,
|
|
546
|
+
`[data-icon*="${id}"]`,
|
|
547
|
+
`[aria-label*="${id}"]`,
|
|
548
|
+
`svg[class*="${id}"]`,
|
|
549
|
+
`i[class*="${id}"]`,
|
|
550
|
+
`span[class*="${id}"]`,
|
|
551
|
+
];
|
|
552
|
+
for (const sel of attrSelectors) {
|
|
553
|
+
icon = root.querySelector(sel);
|
|
554
|
+
if (icon) break;
|
|
555
|
+
}
|
|
556
|
+
// Fallback: search SVGs by title/aria-label text
|
|
557
|
+
if (!icon) {
|
|
558
|
+
for (const svg of root.querySelectorAll('svg')) {
|
|
559
|
+
const title = svg.querySelector('title');
|
|
560
|
+
if (title && title.textContent.toLowerCase().includes(id.toLowerCase())) { icon = svg; break; }
|
|
561
|
+
if (svg.getAttribute('aria-label')?.toLowerCase().includes(id.toLowerCase())) { icon = svg; break; }
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
if (!icon) return false;
|
|
565
|
+
// Walk up to nearest clickable ancestor
|
|
566
|
+
const clickableSelector = 'button, a, [role="button"], [role="tab"], [role="menuitem"]';
|
|
567
|
+
const clickable = icon.closest(clickableSelector);
|
|
568
|
+
(clickable || icon).click();
|
|
569
|
+
return true;
|
|
570
|
+
},
|
|
571
|
+
iconId, iconScope
|
|
572
|
+
);
|
|
573
|
+
if (!clicked) {
|
|
574
|
+
throw new Error(`click_icon failed: no icon matching "${iconId}" found${iconScope ? ` in "${iconScope}"` : ''}`);
|
|
575
|
+
}
|
|
576
|
+
break;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
case 'click_menu_item': {
|
|
580
|
+
// Click a menu item by text content.
|
|
581
|
+
// text: menu item text to match (case-sensitive, substring)
|
|
582
|
+
// selector: optional CSS scope
|
|
583
|
+
const menuSelector = [
|
|
584
|
+
'[role="menuitem"]',
|
|
585
|
+
'[role="menuitemradio"]',
|
|
586
|
+
'[role="menuitemcheckbox"]',
|
|
587
|
+
'.dropdown-item',
|
|
588
|
+
'.menu-item',
|
|
589
|
+
'[class*="MenuItem"]',
|
|
590
|
+
'[role="menu"] > li',
|
|
591
|
+
].join(', ');
|
|
592
|
+
const menuScope = selector || null;
|
|
593
|
+
await page.waitForFunction(
|
|
594
|
+
(t, sel, scope) => {
|
|
595
|
+
const root = scope ? document.querySelector(scope) : document;
|
|
596
|
+
if (!root) return false;
|
|
597
|
+
return [...root.querySelectorAll(sel)].some(el => el.textContent.includes(t));
|
|
598
|
+
},
|
|
599
|
+
{ timeout },
|
|
600
|
+
text, menuSelector, menuScope
|
|
601
|
+
);
|
|
602
|
+
const clicked = await page.evaluate(
|
|
603
|
+
(t, sel, scope) => {
|
|
604
|
+
const root = scope ? document.querySelector(scope) : document;
|
|
605
|
+
if (!root) return false;
|
|
606
|
+
const match = [...root.querySelectorAll(sel)].find(el => el.textContent.includes(t));
|
|
607
|
+
if (match) { match.click(); return true; }
|
|
608
|
+
return false;
|
|
609
|
+
},
|
|
610
|
+
text, menuSelector, menuScope
|
|
611
|
+
);
|
|
612
|
+
if (!clicked) {
|
|
613
|
+
throw new Error(`click_menu_item failed: no menu item containing "${text}" found${menuScope ? ` in "${menuScope}"` : ''}`);
|
|
614
|
+
}
|
|
615
|
+
break;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
case 'click_in_context': {
|
|
619
|
+
// Click a child element within a container identified by text content.
|
|
620
|
+
// text: text to find the container (required)
|
|
621
|
+
// selector: CSS selector for the child to click within that container (required)
|
|
622
|
+
if (!text || !selector) {
|
|
623
|
+
throw new Error('click_in_context requires both "text" (container text) and "selector" (child to click)');
|
|
624
|
+
}
|
|
625
|
+
const containerSelectors = [
|
|
626
|
+
'section', 'article',
|
|
627
|
+
'[class*="card"]', '[class*="Card"]',
|
|
628
|
+
'[class*="panel"]', '[class*="Panel"]',
|
|
629
|
+
'[class*="item"]', '[class*="Item"]',
|
|
630
|
+
'.MuiGrid-item', '[class*="MuiGrid2"]',
|
|
631
|
+
'[class*="row"]', '[class*="Row"]',
|
|
632
|
+
'details', 'fieldset',
|
|
633
|
+
'[role="region"]', '[role="group"]', '[role="listitem"]',
|
|
634
|
+
'li', 'tr', 'div[class]',
|
|
635
|
+
].join(', ');
|
|
636
|
+
await page.waitForFunction(
|
|
637
|
+
(t, childSel, containerSels) => {
|
|
638
|
+
const containers = [...document.querySelectorAll(containerSels)]
|
|
639
|
+
.filter(el => el.textContent.includes(t));
|
|
640
|
+
// Sort by innerHTML length (smallest = most specific)
|
|
641
|
+
containers.sort((a, b) => a.innerHTML.length - b.innerHTML.length);
|
|
642
|
+
for (const c of containers) {
|
|
643
|
+
if (c.querySelector(childSel)) return true;
|
|
644
|
+
}
|
|
645
|
+
return false;
|
|
646
|
+
},
|
|
647
|
+
{ timeout },
|
|
648
|
+
text, selector, containerSelectors
|
|
649
|
+
);
|
|
650
|
+
const clicked = await page.evaluate(
|
|
651
|
+
(t, childSel, containerSels) => {
|
|
652
|
+
const containers = [...document.querySelectorAll(containerSels)]
|
|
653
|
+
.filter(el => el.textContent.includes(t));
|
|
654
|
+
containers.sort((a, b) => a.innerHTML.length - b.innerHTML.length);
|
|
655
|
+
for (const c of containers) {
|
|
656
|
+
const child = c.querySelector(childSel);
|
|
657
|
+
if (child) { child.click(); return true; }
|
|
658
|
+
}
|
|
659
|
+
return false;
|
|
660
|
+
},
|
|
661
|
+
text, selector, containerSelectors
|
|
662
|
+
);
|
|
663
|
+
if (!clicked) {
|
|
664
|
+
throw new Error(`click_in_context failed: no "${selector}" found in container with text "${text}"`);
|
|
665
|
+
}
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
case 'gql': {
|
|
670
|
+
// Execute a GraphQL query/mutation via browser fetch.
|
|
671
|
+
// Reads auth token from localStorage and sends it as a configurable header.
|
|
672
|
+
// Installs window.__e2eGql(query, vars) helper for use in subsequent evaluate actions.
|
|
673
|
+
//
|
|
674
|
+
// value: GraphQL query/mutation string (required)
|
|
675
|
+
// text: variables as JSON string (optional)
|
|
676
|
+
// selector: JS expression assertion — receives response as `r` (optional)
|
|
677
|
+
const gqlEndpoint = config.gqlEndpoint || '/api/graphql';
|
|
678
|
+
const gqlAuthHeader = config.gqlAuthHeader || 'Authorization';
|
|
679
|
+
const gqlAuthKey = config.gqlAuthKey || 'accessToken';
|
|
680
|
+
const gqlAuthPrefix = config.gqlAuthPrefix ?? 'Bearer ';
|
|
681
|
+
const gqlVars = text || undefined;
|
|
682
|
+
|
|
683
|
+
const gqlResult = await page.evaluate(async (query, varsJson, endpoint, authHdr, authKey, authPfx) => {
|
|
684
|
+
// Install reusable helper on first call
|
|
685
|
+
if (!window.__e2eGql) {
|
|
686
|
+
window.__e2eGqlConfig = { endpoint, authHeader: authHdr, authKey, authPrefix: authPfx };
|
|
687
|
+
window.__e2eGql = async (q, v) => {
|
|
688
|
+
const cfg = window.__e2eGqlConfig;
|
|
689
|
+
const token = localStorage.getItem(cfg.authKey);
|
|
690
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
691
|
+
if (token) headers[cfg.authHeader] = cfg.authPrefix + token;
|
|
692
|
+
const resp = await fetch(location.origin + cfg.endpoint, {
|
|
693
|
+
method: 'POST', headers,
|
|
694
|
+
body: JSON.stringify({ query: q, variables: v }),
|
|
695
|
+
});
|
|
696
|
+
return resp.json();
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const vars = varsJson ? JSON.parse(varsJson) : undefined;
|
|
701
|
+
const response = await window.__e2eGql(query, vars);
|
|
702
|
+
window.__e2eLastGql = response;
|
|
703
|
+
return response;
|
|
704
|
+
}, value, gqlVars, gqlEndpoint, gqlAuthHeader, gqlAuthKey, gqlAuthPrefix);
|
|
705
|
+
|
|
706
|
+
// Check for GraphQL errors
|
|
707
|
+
if (gqlResult.errors?.length) {
|
|
708
|
+
throw new Error(`gql failed: ${gqlResult.errors.map(e => e.message).join('; ')}`);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Optional assertion via selector field (JS expression, `r` = full response)
|
|
712
|
+
// Intentional: runs JS in browser page context from team-authored JSON test files,
|
|
713
|
+
// same security model as the 'evaluate' action type.
|
|
714
|
+
if (selector) {
|
|
715
|
+
const assertResult = await page.evaluate((code, r) => {
|
|
716
|
+
const fn = new Function('r', `return (${code})`); // eslint-disable-line no-new-func
|
|
717
|
+
return fn(r);
|
|
718
|
+
}, selector, gqlResult);
|
|
719
|
+
|
|
720
|
+
if (typeof assertResult === 'string' && /^(FAIL|ERROR|FAILED)[\s:]/i.test(assertResult)) {
|
|
721
|
+
throw new Error(`gql assertion: ${assertResult}`);
|
|
722
|
+
}
|
|
723
|
+
if (assertResult === false) {
|
|
724
|
+
throw new Error(`gql assertion returned false`);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
return { value: gqlResult.data };
|
|
729
|
+
}
|
|
730
|
+
|
|
412
731
|
case 'evaluate': {
|
|
413
732
|
// Intentional: runs JS in browser page context (from test JSON files)
|
|
414
733
|
const jsSnippet = value.length > 120 ? value.slice(0, 120) + '...' : value;
|
|
@@ -430,8 +749,162 @@ export async function executeAction(page, action, config) {
|
|
|
430
749
|
return evalResult !== undefined && evalResult !== null ? { value: evalResult } : null;
|
|
431
750
|
}
|
|
432
751
|
|
|
752
|
+
case 'wait_network_idle': {
|
|
753
|
+
const idleTime = value ? parseInt(value) : 500;
|
|
754
|
+
const maxTimeout = action.timeout ? parseInt(action.timeout) : 30000;
|
|
755
|
+
await page.waitForNetworkIdle({ idleTime, timeout: maxTimeout });
|
|
756
|
+
break;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// ── Visual regression ───────────────────────────────────────────────────
|
|
760
|
+
|
|
761
|
+
case 'assert_visual': {
|
|
762
|
+
// Compares a live screenshot against a golden reference image.
|
|
763
|
+
//
|
|
764
|
+
// value: golden image filename (relative to screenshotsDir or goldenDir) — required
|
|
765
|
+
// selector: optional CSS selector — screenshot only that element instead of full page
|
|
766
|
+
// text: optional max diff percentage as string, e.g. "0.02" (default: config.verificationThreshold or 0.02)
|
|
767
|
+
//
|
|
768
|
+
// Additional fields via action object:
|
|
769
|
+
// fullPage: boolean (default: true)
|
|
770
|
+
// maskRegions: [{ x, y, width, height }] — regions to ignore (timestamps, avatars, etc.)
|
|
771
|
+
// threshold: number — pixel color sensitivity 0-1 (default: 0.1)
|
|
772
|
+
//
|
|
773
|
+
// Returns: { diffPercentage, differentPixels, totalPixels, diffImagePath, baselinePath, currentPath }
|
|
774
|
+
|
|
775
|
+
if (!value) throw new Error('assert_visual requires "value" (golden image filename)');
|
|
776
|
+
|
|
777
|
+
// Resolve golden image path
|
|
778
|
+
const goldenDir = config.goldenDir || path.join(config.screenshotsDir, 'golden');
|
|
779
|
+
const goldenPath = path.isAbsolute(value) ? value : path.join(goldenDir, value);
|
|
780
|
+
|
|
781
|
+
if (!fs.existsSync(goldenPath)) {
|
|
782
|
+
// First run: save current screenshot as the golden reference
|
|
783
|
+
if (!fs.existsSync(goldenDir)) fs.mkdirSync(goldenDir, { recursive: true });
|
|
784
|
+
const screenshotOpts = { path: goldenPath, fullPage: action.fullPage !== false };
|
|
785
|
+
if (selector) {
|
|
786
|
+
const el = await page.$(selector);
|
|
787
|
+
if (!el) throw new Error(`assert_visual: selector "${selector}" not found`);
|
|
788
|
+
await el.screenshot(screenshotOpts);
|
|
789
|
+
} else {
|
|
790
|
+
await page.screenshot(screenshotOpts);
|
|
791
|
+
}
|
|
792
|
+
return {
|
|
793
|
+
goldenCreated: true,
|
|
794
|
+
goldenPath,
|
|
795
|
+
message: `Golden image saved: ${path.basename(goldenPath)}. Re-run to compare.`,
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Capture current screenshot
|
|
800
|
+
const safeName = path.basename(value, path.extname(value));
|
|
801
|
+
const currentPath = path.join(screenshotsDir, `current-${safeName}-${Date.now()}.png`);
|
|
802
|
+
const screenshotOpts = { path: currentPath, fullPage: action.fullPage !== false };
|
|
803
|
+
if (selector) {
|
|
804
|
+
const el = await page.$(selector);
|
|
805
|
+
if (!el) throw new Error(`assert_visual: selector "${selector}" not found`);
|
|
806
|
+
await el.screenshot(screenshotOpts);
|
|
807
|
+
} else {
|
|
808
|
+
await page.screenshot(screenshotOpts);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Compare
|
|
812
|
+
const maxDiff = text ? parseFloat(text) : (config.verificationThreshold || 0.02);
|
|
813
|
+
const diffPath = path.join(screenshotsDir, `diff-${safeName}-${Date.now()}.png`);
|
|
814
|
+
const compareResult = assertVisualMatch(goldenPath, currentPath, maxDiff, {
|
|
815
|
+
threshold: action.threshold || 0.1,
|
|
816
|
+
maskRegions: action.maskRegions || [],
|
|
817
|
+
diffOutputPath: diffPath,
|
|
818
|
+
includeAntiAlias: action.includeAntiAlias || false,
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
if (!compareResult.passed) {
|
|
822
|
+
const pct = (compareResult.diffPercentage * 100).toFixed(2);
|
|
823
|
+
const maxPct = (maxDiff * 100).toFixed(2);
|
|
824
|
+
throw new Error(
|
|
825
|
+
`assert_visual failed: ${pct}% pixels differ (threshold: ${maxPct}%). ` +
|
|
826
|
+
`${compareResult.differentPixels}/${compareResult.totalPixels} pixels changed. ` +
|
|
827
|
+
`Diff: ${path.basename(diffPath)}`
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
return {
|
|
832
|
+
diffPercentage: compareResult.diffPercentage,
|
|
833
|
+
differentPixels: compareResult.differentPixels,
|
|
834
|
+
totalPixels: compareResult.totalPixels,
|
|
835
|
+
diffImagePath: compareResult.diffImagePath,
|
|
836
|
+
baselinePath: goldenPath,
|
|
837
|
+
currentPath,
|
|
838
|
+
screenshot: diffPath,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// ── Multi-tab actions ─────────────────────────────────────────────────────
|
|
843
|
+
// These actions are intercepted by the runner (runTest) which manages the
|
|
844
|
+
// tab registry and swaps the active page. The actual tab lifecycle happens
|
|
845
|
+
// in runner.js — these cases handle the in-page parts only.
|
|
846
|
+
|
|
847
|
+
case 'open_tab': {
|
|
848
|
+
// Opens a new tab and navigates to the given URL.
|
|
849
|
+
// value: URL (absolute or relative to baseUrl) — required
|
|
850
|
+
// text: optional label for the tab (used by switch_tab)
|
|
851
|
+
// The runner intercepts this to create the page and register it.
|
|
852
|
+
// If we reach here, it means the runner already created the page and
|
|
853
|
+
// we just need to navigate.
|
|
854
|
+
const tabUrl = value.startsWith('http') ? value : `${baseUrl}${value}`;
|
|
855
|
+
await page.goto(tabUrl, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
|
856
|
+
break;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
case 'switch_tab': {
|
|
860
|
+
// Switches to another open tab. The runner handles the actual page swap.
|
|
861
|
+
// This case is a no-op — the runner already switched the page reference.
|
|
862
|
+
break;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
case 'close_tab': {
|
|
866
|
+
// Closes the current tab. The runner handles page cleanup and switching.
|
|
867
|
+
// This case is a no-op — the runner closes the page and swaps back.
|
|
868
|
+
break;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
case 'assert_tab_count': {
|
|
872
|
+
// Asserts the number of open tabs.
|
|
873
|
+
// value: expected count (number or operator expression like ">=2")
|
|
874
|
+
// The runner injects __tabCount into the action result before we get here.
|
|
875
|
+
// If we reach here directly, we use browser context pages.
|
|
876
|
+
const tabCount = action.__tabCount;
|
|
877
|
+
if (tabCount === undefined) {
|
|
878
|
+
throw new Error('assert_tab_count: tab count not available (action must be run via runner)');
|
|
879
|
+
}
|
|
880
|
+
const opMatch = value.match(/^(>=|<=|>|<)\s*(\d+)$/);
|
|
881
|
+
if (opMatch) {
|
|
882
|
+
const [, op, numStr] = opMatch;
|
|
883
|
+
const expected = parseInt(numStr);
|
|
884
|
+
const passed = op === '>' ? tabCount > expected
|
|
885
|
+
: op === '>=' ? tabCount >= expected
|
|
886
|
+
: op === '<' ? tabCount < expected
|
|
887
|
+
: tabCount <= expected;
|
|
888
|
+
if (!passed) {
|
|
889
|
+
throw new Error(`assert_tab_count failed: ${tabCount} tabs open, expected ${op}${expected}`);
|
|
890
|
+
}
|
|
891
|
+
} else {
|
|
892
|
+
const expected = parseInt(value);
|
|
893
|
+
if (tabCount !== expected) {
|
|
894
|
+
throw new Error(`assert_tab_count failed: ${tabCount} tabs open, expected ${expected}`);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
break;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
case 'wait_for_tab': {
|
|
901
|
+
// Waits for a new tab/popup to appear. The runner handles this.
|
|
902
|
+
// This case is a no-op — the runner already waited and registered the new tab.
|
|
903
|
+
break;
|
|
904
|
+
}
|
|
905
|
+
|
|
433
906
|
default:
|
|
434
|
-
|
|
907
|
+
throw new Error(`Unknown action type: "${type}"`);
|
|
435
908
|
}
|
|
436
909
|
|
|
437
910
|
return null;
|