@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.
Files changed (88) hide show
  1. package/.claude-plugin/marketplace.json +52 -0
  2. package/.claude-plugin/plugin.json +17 -3
  3. package/.mcp.json +2 -2
  4. package/.opencode/commands/create-test.md +63 -0
  5. package/.opencode/commands/run.md +50 -0
  6. package/.opencode/commands/verify-issue.md +62 -0
  7. package/.opencode/skills/e2e-testing/SKILL.md +181 -0
  8. package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
  9. package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
  10. package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
  11. package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
  12. package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
  13. package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
  14. package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
  15. package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
  16. package/.opencode/skills/e2e-testing/references/variables.md +41 -0
  17. package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
  18. package/LICENSE +190 -0
  19. package/OPENCODE.md +166 -0
  20. package/README.md +165 -104
  21. package/agents/test-creator.md +54 -1
  22. package/agents/test-improver.md +37 -0
  23. package/bin/cli.js +409 -16
  24. package/commands/capture.md +45 -0
  25. package/commands/create-test.md +16 -1
  26. package/opencode.json +11 -0
  27. package/package.json +7 -2
  28. package/scripts/setup-opencode.sh +113 -0
  29. package/skills/e2e-testing/SKILL.md +10 -3
  30. package/skills/e2e-testing/references/action-types.md +48 -5
  31. package/skills/e2e-testing/references/auth-strategies.md +91 -0
  32. package/skills/e2e-testing/references/graphql.md +59 -0
  33. package/skills/e2e-testing/references/issue-verification.md +59 -0
  34. package/skills/e2e-testing/references/multi-pool.md +60 -0
  35. package/skills/e2e-testing/references/network-debugging.md +62 -0
  36. package/skills/e2e-testing/references/test-json-format.md +4 -0
  37. package/skills/e2e-testing/references/troubleshooting.md +44 -2
  38. package/skills/e2e-testing/references/variables.md +41 -0
  39. package/skills/e2e-testing/references/visual-verification.md +89 -0
  40. package/src/actions.js +475 -2
  41. package/src/ai-generate.js +139 -8
  42. package/src/app-pool.js +339 -0
  43. package/src/config.js +266 -5
  44. package/src/dashboard.js +216 -17
  45. package/src/db.js +191 -7
  46. package/src/index.js +12 -9
  47. package/src/learner-sqlite.js +458 -0
  48. package/src/learner.js +78 -6
  49. package/src/mcp-tools.js +1348 -51
  50. package/src/module-resolver.js +37 -0
  51. package/src/narrate.js +65 -0
  52. package/src/pool-manager.js +229 -0
  53. package/src/pool.js +301 -31
  54. package/src/reporter.js +86 -2
  55. package/src/runner.js +480 -71
  56. package/src/sync/auth.js +354 -0
  57. package/src/sync/client.js +572 -0
  58. package/src/sync/hub-routes.js +816 -0
  59. package/src/sync/index.js +68 -0
  60. package/src/sync/middleware.js +347 -0
  61. package/src/sync/queue.js +209 -0
  62. package/src/sync/schema.js +540 -0
  63. package/src/verify.js +10 -7
  64. package/src/visual-diff.js +446 -0
  65. package/src/watch.js +384 -0
  66. package/templates/build-dashboard.js +47 -6
  67. package/templates/dashboard/js/api.js +62 -0
  68. package/templates/dashboard/js/init.js +13 -0
  69. package/templates/dashboard/js/keyboard.js +46 -0
  70. package/templates/dashboard/js/state.js +40 -0
  71. package/templates/dashboard/js/toast.js +41 -0
  72. package/templates/dashboard/js/utils.js +216 -0
  73. package/templates/dashboard/js/view-live.js +181 -0
  74. package/templates/dashboard/js/view-runs.js +676 -0
  75. package/templates/dashboard/js/view-tests.js +294 -0
  76. package/templates/dashboard/js/view-watch.js +242 -0
  77. package/templates/dashboard/js/websocket.js +116 -0
  78. package/templates/dashboard/styles/base.css +69 -0
  79. package/templates/dashboard/styles/components.css +117 -0
  80. package/templates/dashboard/styles/view-live.css +97 -0
  81. package/templates/dashboard/styles/view-runs.css +243 -0
  82. package/templates/dashboard/styles/view-tests.css +96 -0
  83. package/templates/dashboard/styles/view-watch.css +53 -0
  84. package/templates/dashboard/template.html +181 -100
  85. package/templates/dashboard.html +1614 -547
  86. package/templates/sample-test.json +0 -8
  87. package/templates/dashboard/app.js +0 -1152
  88. 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 { log } from './logger.js';
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
- log('⚠️', `Unknown action: ${type}`);
907
+ throw new Error(`Unknown action type: "${type}"`);
435
908
  }
436
909
 
437
910
  return null;