@matware/e2e-runner 1.3.0 β†’ 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/bin/cli.js CHANGED
@@ -264,7 +264,7 @@ async function cmdRun() {
264
264
 
265
265
  // Verify pool connectivity
266
266
  log('πŸ”Œ', `Checking Chrome Pool${poolUrls.length > 1 ? 's' : ''}...`);
267
- const pressure = await waitForAnyPool(poolUrls);
267
+ const pressure = await waitForAnyPool(poolUrls, 30000, { poolDriver: config.poolDriver, maxSessions: config.maxSessions });
268
268
  log('βœ…', `Pool ready (${pressure.running}/${pressure.maxConcurrent} sessions, queued: ${pressure.queued})`);
269
269
 
270
270
  // Wire up live progress to dashboard if running
@@ -351,7 +351,7 @@ async function cmdPool() {
351
351
 
352
352
  case 'status': {
353
353
  const statusPoolUrls = getPoolUrls(config);
354
- const aggregated = await getAggregatedPoolStatus(statusPoolUrls);
354
+ const aggregated = await getAggregatedPoolStatus(statusPoolUrls, { poolDriver: config.poolDriver, maxSessions: config.maxSessions });
355
355
  console.log(`\n${C.bold}Chrome Pool Status:${C.reset}\n`);
356
356
 
357
357
  if (statusPoolUrls.length > 1) {
@@ -494,11 +494,12 @@ async function cmdCapture() {
494
494
 
495
495
  const capturePoolUrls = getPoolUrls(config);
496
496
  log('πŸ”Œ', 'Checking Chrome Pool...');
497
- await waitForAnyPool(capturePoolUrls);
497
+ const captureDriverOpts = { poolDriver: config.poolDriver, maxSessions: config.maxSessions };
498
+ await waitForAnyPool(capturePoolUrls, 30000, captureDriverOpts);
498
499
 
499
500
  let browser;
500
501
  try {
501
- const capturePool = await selectPool(capturePoolUrls);
502
+ const capturePool = await selectPool(capturePoolUrls, 2000, 60000, captureDriverOpts);
502
503
  browser = await connectToPool(capturePool);
503
504
  const page = await browser.newPage();
504
505
  await page.setViewport(config.viewport);
@@ -0,0 +1,45 @@
1
+ ---
2
+ description: Capture a screenshot of any URL with automatic authentication
3
+ user_invocable: true
4
+ allowed_tools:
5
+ - mcp__e2e-runner__e2e_pool_status
6
+ - mcp__e2e-runner__e2e_capture
7
+ - mcp__e2e-runner__e2e_analyze
8
+ - mcp__e2e-runner__e2e_screenshot
9
+ ---
10
+
11
+ # Quick Capture
12
+
13
+ Take a screenshot of any URL in one step. Handles pool checks and authentication automatically.
14
+
15
+ ## Workflow
16
+
17
+ 1. **Check pool** β€” Call `e2e_pool_status` to confirm the Chrome pool is running. If not available, tell the user to run `npx e2e-runner pool start` via CLI and stop.
18
+
19
+ 2. **Capture** β€” Call `e2e_capture` with:
20
+ - `url`: The URL from the user's request (REQUIRED)
21
+ - `cwd`: The current working directory (REQUIRED β€” always pass this)
22
+ - `fullPage`: true if user says "full page", "full", "complete", or "toda la pΓ‘gina"
23
+ - `selector`: CSS selector if user wants to wait for a specific element
24
+ - `delay`: milliseconds if user says "wait", "delay", or "espera"
25
+ - `waitUntil`: "domcontentloaded" if user mentions WebSocket, SSE, or real-time apps
26
+ - `filename`: if user specifies a name
27
+
28
+ **Authentication is automatic**: the tool reads `authToken`, `authLoginEndpoint`, and `authCredentials` from the project's `e2e.config.js`. You do NOT need to pass `authToken` unless the user explicitly provides one.
29
+
30
+ 3. **Show result** β€” The tool returns the screenshot as an inline image. Show it to the user with the file path.
31
+
32
+ ## Arguments
33
+
34
+ The user passes the URL after the command:
35
+ - `/e2e-runner:capture http://localhost:3000/dashboard` β†’ capture that URL
36
+ - `/e2e-runner:capture http://localhost/concept-maps --full-page` β†’ full page capture
37
+ - `/e2e-runner:capture http://localhost/admin --delay 3000` β†’ wait 3s before capture
38
+
39
+ If no URL is provided, ask the user for one.
40
+
41
+ ## Important
42
+
43
+ - Do NOT try to manually authenticate, fetch tokens, write test files, or use curl. The tool handles auth automatically from project config.
44
+ - Do NOT use the `e2e_run` tool β€” this is a screenshot capture, not a test run.
45
+ - Keep it simple: one `e2e_pool_status` call + one `e2e_capture` call. That's it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matware/e2e-runner",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "mcpName": "io.github.fastslack/e2e-runner",
5
5
  "description": "E2E test runner using Chrome Pool (browserless/chrome) with parallel execution",
6
6
  "type": "module",
package/src/actions.js CHANGED
@@ -8,6 +8,8 @@
8
8
  */
9
9
 
10
10
  import path from 'path';
11
+ import fs from 'fs';
12
+ import { assertVisualMatch } from './visual-diff.js';
11
13
 
12
14
  /** All recognized action types β€” single source of truth for validation. */
13
15
  export const KNOWN_ACTION_TYPES = new Set([
@@ -22,6 +24,8 @@ export const KNOWN_ACTION_TYPES = new Set([
22
24
  'set_storage', 'click_icon', 'click_menu_item', 'click_in_context',
23
25
  'assert_text_in', 'assert_no_text',
24
26
  'gql', 'wait_network_idle',
27
+ 'open_tab', 'switch_tab', 'close_tab', 'assert_tab_count', 'wait_for_tab',
28
+ 'assert_visual',
25
29
  ]);
26
30
 
27
31
  function sleep(ms) {
@@ -752,6 +756,153 @@ export async function executeAction(page, action, config) {
752
756
  break;
753
757
  }
754
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
+
755
906
  default:
756
907
  throw new Error(`Unknown action type: "${type}"`);
757
908
  }
@@ -87,6 +87,16 @@ Smart interaction actions:
87
87
  - click_menu_item: click a menu item by text. Searches [role="menuitem"], .dropdown-item, .menu-item, [class*="MenuItem"]. Optional "selector" scopes the search
88
88
  - click_in_context: click a child element within a container identified by text. "text" finds the container, "selector" is the child to click. Picks the smallest matching container
89
89
 
90
+ Visual regression:
91
+ - assert_visual: compare current page against a golden reference screenshot. "value" is the golden filename (e.g. "login-page.png"). First run auto-saves the golden. "text" is optional max diff percentage (default "0.02" = 2%). "selector" captures only that element. "maskRegions" ignores dynamic areas: [{ "x": 10, "y": 5, "width": 200, "height": 30 }]. Example: { "type": "assert_visual", "value": "dashboard.png", "text": "0.05" }
92
+
93
+ Multi-tab actions (for OAuth, popups, admin+user flows):
94
+ - open_tab: open a new tab with URL in "value". Optional "text" assigns a label for switch_tab. Example: { "type": "open_tab", "value": "/admin", "text": "admin" }
95
+ - switch_tab: switch to a tab by label, title regex, URL substring, or index. Example: { "type": "switch_tab", "value": "admin" }
96
+ - close_tab: close current tab or a named tab ("value" = label). Automatically switches to previous tab. Example: { "type": "close_tab", "value": "admin" }
97
+ - wait_for_tab: wait for a popup/new tab opened by the page (window.open, target=_blank). Optional "text" labels it. Example: { "type": "wait_for_tab", "text": "oauth" }
98
+ - assert_tab_count: verify number of open tabs. "value" is count or operator. Example: { "type": "assert_tab_count", "value": "2" }
99
+
90
100
  Assertion action reference:
91
101
  - assert_text: checks if text appears anywhere in the page body
92
102
  - assert_element_text: checks textContent of a specific element (use "value": "exact" for strict match)
@@ -239,6 +249,77 @@ Existing suites: ${existingSuites.join(', ') || 'none'}`;
239
249
  };
240
250
  }
241
251
 
252
+ /**
253
+ * Generates a hindsight hint for a failed test result.
254
+ * Sends the error + action context to Claude API and returns a concrete fix suggestion.
255
+ * Returns null if API key is unavailable or on any error.
256
+ */
257
+ export async function generateHindsightHint(failedResult, config = {}) {
258
+ const apiKey = config.anthropicApiKey || process.env.ANTHROPIC_API_KEY;
259
+ if (!apiKey) return null;
260
+
261
+ const model = config.hintsModel || config.anthropicModel || 'claude-sonnet-4-5-20250929';
262
+ const lastActions = (failedResult.actions || []).slice(-8);
263
+ const failedAction = lastActions.find(a => a.success === false);
264
+
265
+ const consoleErrors = (failedResult.consoleLogs || [])
266
+ .filter(l => l.type === 'error')
267
+ .slice(-5)
268
+ .map(l => l.text);
269
+
270
+ const networkErrors = (failedResult.networkErrors || [])
271
+ .slice(-5)
272
+ .map(e => `${e.url} (${e.error})`);
273
+
274
+ const prompt = `Analyze this failed E2E test and suggest a concrete fix.
275
+
276
+ TEST: "${failedResult.name}"
277
+ ERROR: ${failedResult.error}
278
+
279
+ LAST ACTIONS:
280
+ ${lastActions.map((a, i) => ` ${i + 1}. ${a.type}${a.selector ? ' selector=' + a.selector : ''}${a.text ? ' text=' + a.text : ''}${a.value ? ' value=' + (a.value.length > 80 ? a.value.slice(0, 80) + '...' : a.value) : ''} β†’ ${a.success === false ? 'FAILED: ' + a.error : 'OK'} (${a.duration}ms)`).join('\n')}
281
+
282
+ ${failedAction ? `FAILED ACTION: ${JSON.stringify({ type: failedAction.type, selector: failedAction.selector, text: failedAction.text, value: failedAction.value?.slice?.(0, 200) })}` : ''}
283
+ ${consoleErrors.length ? `CONSOLE ERRORS:\n${consoleErrors.join('\n')}` : ''}
284
+ ${networkErrors.length ? `NETWORK ERRORS:\n${networkErrors.join('\n')}` : ''}
285
+
286
+ Respond with ONLY a JSON object: { "suggestion": "concrete fix description", "confidence": "high"|"medium"|"low", "fixType": "selector"|"wait"|"timeout"|"logic"|"infra"|"data" }`;
287
+
288
+ try {
289
+ const controller = new AbortController();
290
+ const timeout = setTimeout(() => controller.abort(), 15000);
291
+
292
+ const response = await fetch('https://api.anthropic.com/v1/messages', {
293
+ method: 'POST',
294
+ headers: {
295
+ 'Content-Type': 'application/json',
296
+ 'x-api-key': apiKey,
297
+ 'anthropic-version': '2023-06-01',
298
+ },
299
+ body: JSON.stringify({
300
+ model,
301
+ max_tokens: 1024,
302
+ system: 'You are an E2E test debugging expert. Given a failed test, suggest the most likely fix. Be specific: name exact selectors, wait times, or code changes. Keep suggestions under 100 words.',
303
+ messages: [{ role: 'user', content: prompt }],
304
+ }),
305
+ signal: controller.signal,
306
+ });
307
+
308
+ clearTimeout(timeout);
309
+
310
+ if (!response.ok) return null;
311
+ const result = await response.json();
312
+ const text = result.content?.[0]?.text;
313
+ if (!text) return null;
314
+
315
+ const cleaned = text.replace(/^```(?:json)?\s*\n?/m, '').replace(/\n?```\s*$/m, '').trim();
316
+ const hint = JSON.parse(cleaned);
317
+ return { test: failedResult.name, ...hint };
318
+ } catch {
319
+ return null;
320
+ }
321
+ }
322
+
242
323
  /**
243
324
  * Checks if the Anthropic API key is available.
244
325
  * @returns {boolean}