@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/src/mcp-tools.js CHANGED
@@ -15,18 +15,48 @@ import http from 'http';
15
15
  import { loadConfig } from './config.js';
16
16
  import { connectToPool } from './pool.js';
17
17
  import { waitForAnyPool, getPoolUrls, getAggregatedPoolStatus, selectPool } from './pool-manager.js';
18
- import { runTestsParallel, loadTestFile, loadTestSuite, loadAllSuites, listSuites } from './runner.js';
18
+ import { runTestsParallel, loadTestFile, loadTestSuite, loadAllSuites, listSuites, fetchAuthToken } from './runner.js';
19
19
  import { generateReport, saveReport, persistRun } from './reporter.js';
20
20
  import { narrateTest } from './narrate.js';
21
21
  import { startDashboard, stopDashboard } from './dashboard.js';
22
22
  import { lookupScreenshotHash, ensureProject, computeScreenshotHash, registerScreenshotHash, getNetworkLogs, setVariable, getVariables, deleteVariable, listVariables } from './db.js';
23
23
  import { fetchIssue, checkCliAuth, detectProvider } from './issues.js';
24
- import { buildPrompt, hasApiKey } from './ai-generate.js';
24
+ import { buildPrompt, hasApiKey, generateHindsightHint } from './ai-generate.js';
25
25
  import { verifyIssue } from './verify.js';
26
26
  import { listModules } from './module-resolver.js';
27
- import { getLearningsSummary, getFlakySummary, getSelectorStability, getPageHealth, getApiHealth, getErrorPatterns, getTestTrends, getRunInsights, getTestHistory, getPageHistory, getSelectorHistory, getHealthSnapshot, getTestCreationContext, generateImprovements } from './learner-sqlite.js';
27
+ import { getLearningsSummary, getFlakySummary, getSelectorStability, getPageHealth, getApiHealth, getErrorPatterns, getTestTrends, getRunInsights, getTestHistory, getPageHistory, getSelectorHistory, getHealthSnapshot, getTestCreationContext, generateImprovements, getActionHealthScores } from './learner-sqlite.js';
28
28
  import { queryGraph } from './learner-neo4j.js';
29
29
  import { startNeo4j, stopNeo4j, getNeo4jStatus } from './neo4j-pool.js';
30
+ import { getAppPoolStatus, isAppPoolEnabled } from './app-pool.js';
31
+
32
+ /**
33
+ * Resolves auth token from config: uses static authToken if set,
34
+ * otherwise auto-logs in via authLoginEndpoint + authCredentials.
35
+ * If the endpoint is a Docker-internal hostname (e.g. "nginx", "api")
36
+ * and fails with ENOTFOUND, retries with localhost.
37
+ * Returns the token string or null if no auth is configured.
38
+ */
39
+ async function resolveAuthToken(config) {
40
+ if (config.authToken) return config.authToken;
41
+ if (config.authLoginEndpoint && config.authCredentials) {
42
+ const tokenPath = config.authTokenPath || 'token';
43
+ try {
44
+ return await fetchAuthToken(config.authLoginEndpoint, config.authCredentials, tokenPath);
45
+ } catch (err) {
46
+ // Docker-internal hostname? Retry with localhost from host machine
47
+ if (err.message && err.message.includes('ENOTFOUND')) {
48
+ const url = new URL(config.authLoginEndpoint);
49
+ if (!url.hostname.includes('.')) {
50
+ // Simple hostname (nginx, api, etc.) → likely Docker service name
51
+ const localhostUrl = `http://localhost${url.port && url.port !== '80' ? ':' + url.port : ''}${url.pathname}${url.search}`;
52
+ return await fetchAuthToken(localhostUrl, config.authCredentials, tokenPath);
53
+ }
54
+ }
55
+ throw err;
56
+ }
57
+ }
58
+ return null;
59
+ }
30
60
 
31
61
  // ── Tool definitions ──────────────────────────────────────────────────────────
32
62
 
@@ -223,6 +253,20 @@ Use { "$use": "module-name", "params": {...} } to reference reusable modules fro
223
253
  },
224
254
  },
225
255
  },
256
+ {
257
+ name: 'e2e_app_pool_status',
258
+ description:
259
+ 'Get the status of the app environment pool. Shows active forks, allocated ports, per-fork details (driver, baseUrl, test name, fork time). Only relevant when appPool is enabled in config.',
260
+ inputSchema: {
261
+ type: 'object',
262
+ properties: {
263
+ cwd: {
264
+ type: 'string',
265
+ description: 'Absolute path to the project root directory.',
266
+ },
267
+ },
268
+ },
269
+ },
226
270
  {
227
271
  name: 'e2e_screenshot',
228
272
  description:
@@ -264,6 +308,24 @@ Use { "$use": "module-name", "params": {...} } to reference reusable modules fro
264
308
  properties: {},
265
309
  },
266
310
  },
311
+ {
312
+ name: 'e2e_dashboard_restart',
313
+ description:
314
+ 'Restart the E2E Runner web dashboard. Stops the current instance and starts a new one, optionally with a new cwd or port. Useful when switching projects or when the dashboard was started from another session.',
315
+ inputSchema: {
316
+ type: 'object',
317
+ properties: {
318
+ port: {
319
+ type: 'number',
320
+ description: 'Dashboard port (default: same port or 8484)',
321
+ },
322
+ cwd: {
323
+ type: 'string',
324
+ description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
325
+ },
326
+ },
327
+ },
328
+ },
267
329
  {
268
330
  name: 'e2e_issue',
269
331
  description:
@@ -337,6 +399,11 @@ Use { "$use": "module-name", "params": {...} } to reference reusable modules fro
337
399
  type: 'string',
338
400
  description: 'localStorage key name for the auth token (default: "accessToken")',
339
401
  },
402
+ waitUntil: {
403
+ type: 'string',
404
+ enum: ['networkidle2', 'domcontentloaded', 'load', 'auto'],
405
+ description: 'Navigation wait strategy. "auto" (default) tries networkidle2 first with a short timeout, then falls back to domcontentloaded + delay for SPA/WebSocket apps. Use "domcontentloaded" for apps with persistent connections (WebSocket, SSE).',
406
+ },
340
407
  cwd: {
341
408
  type: 'string',
342
409
  description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
@@ -384,6 +451,11 @@ Use { "$use": "module-name", "params": {...} } to reference reusable modules fro
384
451
  type: 'string',
385
452
  description: 'localStorage key name for the auth token (default: "accessToken")',
386
453
  },
454
+ waitUntil: {
455
+ type: 'string',
456
+ enum: ['networkidle2', 'domcontentloaded', 'load', 'auto'],
457
+ description: 'Navigation wait strategy. "auto" (default) tries networkidle2 first with a short timeout, then falls back to domcontentloaded + delay for SPA/WebSocket apps. Use "domcontentloaded" for apps with persistent connections (WebSocket, SSE).',
458
+ },
387
459
  cwd: {
388
460
  type: 'string',
389
461
  description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
@@ -568,9 +640,9 @@ Good module candidates: auth setup, page navigation, tab clicking, opening sideb
568
640
  },
569
641
  ];
570
642
 
571
- /** Tools exposed on the dashboard — excludes dashboard start/stop (already running). */
643
+ /** Tools exposed on the dashboard — excludes dashboard start/stop/restart (already running). */
572
644
  export const DASHBOARD_TOOLS = TOOLS.filter(
573
- t => t.name !== 'e2e_dashboard_start' && t.name !== 'e2e_dashboard_stop'
645
+ t => t.name !== 'e2e_dashboard_start' && t.name !== 'e2e_dashboard_stop' && t.name !== 'e2e_dashboard_restart'
574
646
  );
575
647
 
576
648
  // ── Dashboard broadcast helper ────────────────────────────────────────────────
@@ -620,7 +692,8 @@ async function handleRun(args) {
620
692
  const config = await loadConfig(configOverrides, args.cwd);
621
693
  config.triggeredBy = 'mcp';
622
694
 
623
- await waitForAnyPool(getPoolUrls(config));
695
+ const driverOpts = { poolDriver: config.poolDriver, maxSessions: config.maxSessions };
696
+ await waitForAnyPool(getPoolUrls(config), 30000, driverOpts);
624
697
 
625
698
  let tests, hooks;
626
699
 
@@ -669,8 +742,13 @@ async function handleRun(args) {
669
742
  }));
670
743
 
671
744
  const flaky = report.results
672
- .filter(r => r.success && r.attempt > 1)
673
- .map(r => ({ name: r.name, attempts: r.attempt }));
745
+ .filter(r => r.success && (r.attempt > 1 || r.flaky))
746
+ .map(r => {
747
+ const entry = { name: r.name };
748
+ if (r.voting) entry.voting = r.voting;
749
+ else entry.attempts = r.attempt;
750
+ return entry;
751
+ });
674
752
 
675
753
  const summary = {
676
754
  ...report.summary,
@@ -799,6 +877,19 @@ async function handleRun(args) {
799
877
  } catch { /* never fail the run response */ }
800
878
  }
801
879
 
880
+ // Hindsight hints — LLM-powered fix suggestions for failures (async, never blocks)
881
+ if (hasApiKey(config) && failures.length > 0) {
882
+ try {
883
+ const maxHints = config.hintsMaxFailures ?? 3;
884
+ const hintTargets = failures.slice(0, maxHints);
885
+ const failedResults = hintTargets.map(f => report.results.find(r => r.name === f.name)).filter(Boolean);
886
+ const hints = (await Promise.all(failedResults.map(r => generateHindsightHint(r, config)))).filter(Boolean);
887
+ if (hints.length > 0) {
888
+ summary.hindsightHints = hints;
889
+ }
890
+ } catch { /* never fail the run response */ }
891
+ }
892
+
802
893
  return textResult(JSON.stringify(summary, null, 2));
803
894
  }
804
895
 
@@ -1039,24 +1130,46 @@ function analyzeEvaluateUsage(actions) {
1039
1130
  suggestions.push('No-op evaluate (returns static string) → remove entirely');
1040
1131
  }
1041
1132
 
1042
- // 🚨 Pattern: evaluate returns template string interpolating booleans but never throws/fails
1043
- // e.g. return `Foo: ${hasFoo}, Bar: ${hasBar}` — always truthy, never fails
1044
- if (!(/throw\s+new\s+Error/s.test(code) || /\bFAIL[:\s]/s.test(code) || /\bERROR[:\s]/s.test(code)
1045
- || /return\s+false\b/s.test(code) || /return\s+'FAIL/s.test(code) || /return\s+`FAIL/s.test(code))) {
1046
- // Check for template returns with ${var} interpolation (informational, never fails)
1133
+ // 🚨 Pattern: evaluate that NEVER fails no throw, no FAIL:/ERROR:, no return false
1134
+ const canFail = /throw\s+new\s+Error/s.test(code) || /\bFAIL[:\s]/s.test(code) || /\bERROR[:\s]/s.test(code)
1135
+ || /return\s+false\b/s.test(code) || /return\s+'FAIL/s.test(code) || /return\s+`FAIL/s.test(code);
1136
+
1137
+ if (!canFail) {
1138
+ // Any template string return → always truthy, test always passes
1047
1139
  if (/return\s+`[^`]*\$\{[^}]+\}[^`]*`/s.test(code)) {
1048
- // Heuristic: does the template interpolate boolean-like variables?
1049
- const hasConditionInterpolation = /\$\{(has\w+|is\w+|no\w+|found|exists|present|visible|loaded)\}/i.test(code);
1050
- const hasComparisonInterpolation = /\$\{[^}]*(===|!==|>|<|&&|\|\|)[^}]*\}/s.test(code);
1051
- if (hasConditionInterpolation || hasComparisonInterpolation) {
1052
- suggestions.push(
1053
- '🚨 Evaluate returns informational template string with boolean/condition values but NEVER throws or returns false — ' +
1054
- 'this test will ALWAYS PASS. Either throw new Error("FAIL: ...") when conditions are not met, or replace with built-in assert actions'
1055
- );
1056
- }
1140
+ suggestions.push(
1141
+ '🚨 Evaluate returns template string but NEVER throws or returns false — ' +
1142
+ 'this action will ALWAYS PASS regardless of results. Either throw new Error("FAIL: ...") when conditions fail, or use built-in assert actions'
1143
+ );
1144
+ }
1145
+ // Returns a plain string (not template) that isn't FAIL/ERROR
1146
+ else if (/return\s+['"][^'"]*['"]/s.test(code) && code.length > 60) {
1147
+ suggestions.push(
1148
+ '⚠️ Evaluate returns a plain string but never fails — informational-only. Add failure conditions or replace with assert actions'
1149
+ );
1057
1150
  }
1058
1151
  }
1059
1152
 
1153
+ // 🚨 Pattern: .click() inside evaluate — should use built-in click action
1154
+ if (/\.click\(\)/s.test(code) && !(/\.textContent[^]*\.click\(\)/s.test(code))) {
1155
+ // Only flag if not already caught by the textContent click pattern above
1156
+ if (/\.filter\([^)]*text/s.test(code) || /querySelectorAll[^)]*\)[^]*\.click/s.test(code) || /querySelector[^)]*\)[^]*\.click/s.test(code)) {
1157
+ suggestions.push(
1158
+ '🚨 Element click via evaluate → use { type: "click", text: "..." } or { type: "click", selector: "..." }. ' +
1159
+ 'Built-in click has retries, waits, and better error reporting'
1160
+ );
1161
+ }
1162
+ }
1163
+
1164
+ // 🚨 Pattern: MUI/framework selectors inside evaluate — fragile
1165
+ const muiMatches = code.match(/\.Mui[\w-]+/g) || [];
1166
+ if (muiMatches.length > 0) {
1167
+ suggestions.push(
1168
+ `⚠️ MUI class selectors (${muiMatches.slice(0, 3).join(', ')}) are auto-generated and change between versions. ` +
1169
+ `Prefer [data-testid="..."], [role="..."], or text-based selectors`
1170
+ );
1171
+ }
1172
+
1060
1173
  // 🚨 Pattern: sets window.__e2e_* globals for cross-test state sharing
1061
1174
  if (/window\.__e2e_\w+\s*=/.test(code) && !/window\.__e2e\./.test(code.replace(/window\.__e2e_\w+\s*=/g, ''))) {
1062
1175
  suggestions.push(
@@ -1093,6 +1206,23 @@ function analyzeActionPatterns(tests) {
1093
1206
  }
1094
1207
  }
1095
1208
 
1209
+ // Detect MUI/framework selectors in action selectors
1210
+ for (const test of tests) {
1211
+ if (!test.actions) continue;
1212
+ for (const action of test.actions) {
1213
+ const sel = action.selector || '';
1214
+ if (/\.Mui[\w-]+/.test(sel) || /\.ant-[\w-]+/.test(sel) || /\.v-[\w-]+/.test(sel)) {
1215
+ const match = sel.match(/\.(Mui[\w-]+|ant-[\w-]+|v-[\w-]+)/);
1216
+ warnings.push(
1217
+ `⚠️ Framework selector ".${match[1]}" in "${test.name}" (${action.type}) — ` +
1218
+ `these class names are auto-generated and break on version upgrades. ` +
1219
+ `Prefer [data-testid="..."], [role="..."], or text-based actions`
1220
+ );
1221
+ break;
1222
+ }
1223
+ }
1224
+ }
1225
+
1096
1226
  // Detect cross-test state: test N writes window.__e2e_*, test M reads it
1097
1227
  const writers = new Map(); // varName → test name
1098
1228
  const readers = new Map(); // varName → [test names]
@@ -1132,7 +1262,7 @@ function analyzeActionPatterns(tests) {
1132
1262
  async function handlePoolStatus(args) {
1133
1263
  const config = await loadConfig({}, args.cwd);
1134
1264
  const poolUrls = getPoolUrls(config);
1135
- const aggregated = await getAggregatedPoolStatus(poolUrls);
1265
+ const aggregated = await getAggregatedPoolStatus(poolUrls, { poolDriver: config.poolDriver, maxSessions: config.maxSessions });
1136
1266
 
1137
1267
  const lines = [];
1138
1268
 
@@ -1159,6 +1289,30 @@ async function handlePoolStatus(args) {
1159
1289
  return textResult(lines.join('\n'));
1160
1290
  }
1161
1291
 
1292
+ async function handleAppPoolStatus(args) {
1293
+ const config = await loadConfig({}, args.cwd);
1294
+ if (!isAppPoolEnabled(config)) {
1295
+ return textResult('App pool is not enabled. Set appPool.enabled = true in e2e.config.js to use isolated app environments per test.');
1296
+ }
1297
+
1298
+ const status = getAppPoolStatus();
1299
+ const lines = [
1300
+ `Driver: ${config.appPool.driver}`,
1301
+ `Active forks: ${status.activeForks}/${config.appPool.maxForks}`,
1302
+ `Port range: ${config.appPool.forkBasePort}-${config.appPool.forkBasePort + config.appPool.maxForks - 1}`,
1303
+ `Allocated: ${status.allocatedPorts.length ? status.allocatedPorts.join(', ') : 'none'}`,
1304
+ ];
1305
+
1306
+ if (status.forks.length > 0) {
1307
+ lines.push('');
1308
+ for (const fork of status.forks) {
1309
+ lines.push(` ${fork.forkId}: port ${fork.port}, ${fork.baseUrl} (${fork.testName || 'unnamed'}, ${fork.forkTimeMs}ms)`);
1310
+ }
1311
+ }
1312
+
1313
+ return textResult(lines.join('\n'));
1314
+ }
1315
+
1162
1316
  async function handleScreenshot(args) {
1163
1317
  if (!args.hash) return errorResult('Missing required parameter: hash');
1164
1318
 
@@ -1695,12 +1849,42 @@ function buildSuggestedTests(structure, pageUrl) {
1695
1849
  return tests;
1696
1850
  }
1697
1851
 
1852
+ /**
1853
+ * Smart page navigation with fallback for SPA/WebSocket apps.
1854
+ * - "auto" (default): tries networkidle2 with 10s timeout, falls back to domcontentloaded + 2s delay
1855
+ * - "networkidle2"/"load"/"domcontentloaded": uses that strategy directly with 30s timeout
1856
+ */
1857
+ async function smartNavigate(page, url, waitUntil) {
1858
+ const strategy = waitUntil || 'auto';
1859
+
1860
+ if (strategy === 'auto') {
1861
+ try {
1862
+ await page.goto(url, { waitUntil: 'networkidle2', timeout: 10000 });
1863
+ } catch (err) {
1864
+ if (err.name === 'TimeoutError' || (err.message && err.message.includes('timeout'))) {
1865
+ // networkidle2 timed out — likely a SPA with WebSocket/SSE/polling
1866
+ // Fall back to domcontentloaded + wait for hydration
1867
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
1868
+ await new Promise(r => setTimeout(r, 2000));
1869
+ } else {
1870
+ throw err;
1871
+ }
1872
+ }
1873
+ } else {
1874
+ await page.goto(url, { waitUntil: strategy, timeout: 30000 });
1875
+ // For domcontentloaded, add a small hydration delay for SPAs
1876
+ if (strategy === 'domcontentloaded') {
1877
+ await new Promise(r => setTimeout(r, 1500));
1878
+ }
1879
+ }
1880
+ }
1881
+
1698
1882
  async function handleAnalyze(args) {
1699
1883
  if (!args.url) return errorResult('Missing required parameter: url');
1700
1884
 
1701
1885
  const config = await loadConfig({}, args.cwd);
1702
1886
  const poolUrls = getPoolUrls(config);
1703
- const chosenPool = await selectPool(poolUrls);
1887
+ const chosenPool = await selectPool(poolUrls, 2000, 60000, { poolDriver: config.poolDriver, maxSessions: config.maxSessions });
1704
1888
 
1705
1889
  let browser;
1706
1890
  try {
@@ -1708,8 +1892,8 @@ async function handleAnalyze(args) {
1708
1892
  const page = await browser.newPage();
1709
1893
  await page.setViewport(config.viewport);
1710
1894
 
1711
- // Inject auth token into localStorage before navigation
1712
- const authToken = args.authToken || config.authToken;
1895
+ // Resolve auth token: explicit arg > config static > auto-login
1896
+ const authToken = args.authToken || await resolveAuthToken(config);
1713
1897
  if (authToken) {
1714
1898
  const storageKey = args.authStorageKey || config.authStorageKey || 'accessToken';
1715
1899
  const origin = new URL(args.url).origin;
@@ -1717,7 +1901,7 @@ async function handleAnalyze(args) {
1717
1901
  await page.evaluate((key, token) => { localStorage.setItem(key, token); }, storageKey, authToken);
1718
1902
  }
1719
1903
 
1720
- await page.goto(args.url, { waitUntil: 'networkidle2', timeout: 30000 });
1904
+ await smartNavigate(page, args.url, args.waitUntil);
1721
1905
 
1722
1906
  if (args.selector) {
1723
1907
  await page.waitForSelector(args.selector, { timeout: 10000 });
@@ -1789,7 +1973,7 @@ async function handleCapture(args) {
1789
1973
 
1790
1974
  const config = await loadConfig({}, args.cwd);
1791
1975
  const capturePoolUrls = getPoolUrls(config);
1792
- const capturePool = await selectPool(capturePoolUrls);
1976
+ const capturePool = await selectPool(capturePoolUrls, 2000, 60000, { poolDriver: config.poolDriver, maxSessions: config.maxSessions });
1793
1977
 
1794
1978
  let browser;
1795
1979
  try {
@@ -1797,8 +1981,8 @@ async function handleCapture(args) {
1797
1981
  const page = await browser.newPage();
1798
1982
  await page.setViewport(config.viewport);
1799
1983
 
1800
- // Inject auth token into localStorage before navigation
1801
- const authToken = args.authToken || config.authToken;
1984
+ // Resolve auth token: explicit arg > config static > auto-login
1985
+ const authToken = args.authToken || await resolveAuthToken(config);
1802
1986
  if (authToken) {
1803
1987
  const storageKey = args.authStorageKey || config.authStorageKey || 'accessToken';
1804
1988
  // Navigate to origin first so localStorage is accessible
@@ -1807,7 +1991,7 @@ async function handleCapture(args) {
1807
1991
  await page.evaluate((key, token) => { localStorage.setItem(key, token); }, storageKey, authToken);
1808
1992
  }
1809
1993
 
1810
- await page.goto(args.url, { waitUntil: 'networkidle2', timeout: 30000 });
1994
+ await smartNavigate(page, args.url, args.waitUntil);
1811
1995
 
1812
1996
  if (args.selector) {
1813
1997
  await page.waitForSelector(args.selector, { timeout: 10000 });
@@ -1874,6 +2058,35 @@ async function handleDashboardStop() {
1874
2058
  return textResult('Dashboard stopped');
1875
2059
  }
1876
2060
 
2061
+ async function handleDashboardRestart(args) {
2062
+ const port = args.port || (dashboardHandle ? dashboardHandle.port : 8484);
2063
+
2064
+ // Stop current instance if we own it
2065
+ if (dashboardHandle) {
2066
+ stopDashboard(dashboardHandle);
2067
+ dashboardHandle = null;
2068
+ }
2069
+
2070
+ // Kill any process occupying the target port (e.g. from another session)
2071
+ try {
2072
+ const { execFileSync } = await import('child_process');
2073
+ const lsof = execFileSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
2074
+ if (lsof) {
2075
+ for (const pid of lsof.split('\n').filter(Boolean)) {
2076
+ try { process.kill(Number(pid), 'SIGTERM'); } catch {}
2077
+ }
2078
+ // Brief wait for port to free up
2079
+ await new Promise(r => setTimeout(r, 500));
2080
+ }
2081
+ } catch {}
2082
+
2083
+ // Start fresh
2084
+ const overrides = { dashboardPort: port };
2085
+ const config = await loadConfig(overrides, args.cwd);
2086
+ dashboardHandle = await startDashboard(config);
2087
+ return textResult(`Dashboard restarted at http://localhost:${dashboardHandle.port}`);
2088
+ }
2089
+
1877
2090
  async function handleNeo4j(args) {
1878
2091
  if (!args.action) return errorResult('Missing required parameter: action');
1879
2092
 
@@ -1965,8 +2178,10 @@ async function handleLearnings(args) {
1965
2178
  return textResult(JSON.stringify(getErrorPatterns(projectId), null, 2));
1966
2179
  case 'trends':
1967
2180
  return textResult(JSON.stringify(getTestTrends(projectId, days), null, 2));
2181
+ case 'actions':
2182
+ return textResult(JSON.stringify(getActionHealthScores(projectId, days), null, 2));
1968
2183
  default:
1969
- return errorResult(`Unknown query: "${args.query}". Use: summary, flaky, selectors, pages, apis, errors, trends, test:<name>, page:<path>, selector:<value>`);
2184
+ return errorResult(`Unknown query: "${args.query}". Use: summary, flaky, selectors, pages, apis, errors, trends, actions, test:<name>, page:<path>, selector:<value>`);
1970
2185
  }
1971
2186
  }
1972
2187
 
@@ -2136,12 +2351,16 @@ export async function dispatchTool(name, args = {}) {
2136
2351
  return await handleCreateTest(args);
2137
2352
  case 'e2e_pool_status':
2138
2353
  return await handlePoolStatus(args);
2354
+ case 'e2e_app_pool_status':
2355
+ return await handleAppPoolStatus(args);
2139
2356
  case 'e2e_screenshot':
2140
2357
  return await handleScreenshot(args);
2141
2358
  case 'e2e_dashboard_start':
2142
2359
  return await handleDashboardStart(args);
2143
2360
  case 'e2e_dashboard_stop':
2144
2361
  return await handleDashboardStop();
2362
+ case 'e2e_dashboard_restart':
2363
+ return await handleDashboardRestart(args);
2145
2364
  case 'e2e_issue':
2146
2365
  return await handleIssue(args);
2147
2366
  case 'e2e_create_module':
package/src/narrate.js CHANGED
@@ -167,6 +167,28 @@ export function narrateAction(action, result) {
167
167
  return `Executed JS: ${snippet}${time}`;
168
168
  }
169
169
 
170
+ case 'assert_visual': {
171
+ const vr = result.result;
172
+ if (vr?.goldenCreated) return `Saved golden reference: ${value}${time}`;
173
+ const pct = vr?.diffPercentage != null ? (vr.diffPercentage * 100).toFixed(2) + '% diff' : '';
174
+ return `Visual comparison against "${value}": ${pct}${time}`;
175
+ }
176
+
177
+ case 'open_tab':
178
+ return `Opened new tab${text ? ` "${text}"` : ''} → ${value}${time}`;
179
+
180
+ case 'switch_tab':
181
+ return `Switched to tab "${value}"${time}`;
182
+
183
+ case 'close_tab':
184
+ return `Closed tab${value ? ` "${value}"` : ''}${time}`;
185
+
186
+ case 'assert_tab_count':
187
+ return `Verified ${value} tab(s) open${time}`;
188
+
189
+ case 'wait_for_tab':
190
+ return `Waited for new tab to open${text ? ` (labeled "${text}")` : ''}${time}`;
191
+
170
192
  default:
171
193
  return `Unknown action "${type}"${time}`;
172
194
  }
@@ -223,6 +245,12 @@ function describeIntent(action) {
223
245
  case 'click_menu_item': return `Click menu item "${text}"`;
224
246
  case 'click_in_context': return `Click "${selector}" in context of "${text}"`;
225
247
  case 'evaluate': return 'Execute JS';
248
+ case 'assert_visual': return `Visual compare against "${value}"`;
249
+ case 'open_tab': return `Open new tab → ${value}`;
250
+ case 'switch_tab': return `Switch to tab "${value}"`;
251
+ case 'close_tab': return `Close tab${value ? ` "${value}"` : ''}`;
252
+ case 'assert_tab_count': return `Assert ${value} tab(s) open`;
253
+ case 'wait_for_tab': return 'Wait for new tab';
226
254
  default: return `Action "${type}"`;
227
255
  }
228
256
  }
@@ -42,16 +42,22 @@ function getPending(poolUrl) {
42
42
  return pendingConnections.get(poolUrl) || 0;
43
43
  }
44
44
 
45
+ /** Extracts pool driver options from config for passing to getPoolStatus. */
46
+ function driverOpts(config) {
47
+ if (!config) return {};
48
+ return { poolDriver: config.poolDriver || 'auto', maxSessions: config.maxSessions || 10 };
49
+ }
50
+
45
51
  /** Returns the normalized pool URL array from config. Always an array, even for single pool. */
46
52
  export function getPoolUrls(config) {
47
53
  return config._poolUrls || [config.poolUrl];
48
54
  }
49
55
 
50
- /** Fetches /pressure from all pools in parallel. Returns [{ url, status, error }]. */
51
- export async function getAllPoolStatuses(poolUrls) {
56
+ /** Fetches status from all pools in parallel. Returns [{ url, status, error }]. */
57
+ export async function getAllPoolStatuses(poolUrls, options = {}) {
52
58
  return Promise.all(poolUrls.map(async (url) => {
53
59
  try {
54
- const status = await getPoolStatus(url);
60
+ const status = await getPoolStatus(url, options);
55
61
  return { url, status, error: null };
56
62
  } catch (error) {
57
63
  return { url, status: null, error: error.message };
@@ -60,8 +66,8 @@ export async function getAllPoolStatuses(poolUrls) {
60
66
  }
61
67
 
62
68
  /** Combined view across all pools: totalRunning, totalMaxConcurrent, per-pool details. */
63
- export async function getAggregatedPoolStatus(poolUrls) {
64
- const results = await getAllPoolStatuses(poolUrls);
69
+ export async function getAggregatedPoolStatus(poolUrls, options = {}) {
70
+ const results = await getAllPoolStatuses(poolUrls, options);
65
71
 
66
72
  let totalRunning = 0;
67
73
  let totalMaxConcurrent = 0;
@@ -90,11 +96,11 @@ export async function getAggregatedPoolStatus(poolUrls) {
90
96
  }
91
97
 
92
98
  /** Blocks until at least one pool is reachable and available. */
93
- export async function waitForAnyPool(poolUrls, maxWaitMs = 30000) {
99
+ export async function waitForAnyPool(poolUrls, maxWaitMs = 30000, options = {}) {
94
100
  const start = Date.now();
95
101
 
96
102
  while (Date.now() - start < maxWaitMs) {
97
- const results = await getAllPoolStatuses(poolUrls);
103
+ const results = await getAllPoolStatuses(poolUrls, options);
98
104
  const available = results.find(r => r.status?.available);
99
105
  if (available) return available.status;
100
106
 
@@ -115,17 +121,17 @@ export async function waitForAnyPool(poolUrls, maxWaitMs = 30000) {
115
121
  * Picks the pool with the lowest pressure ratio.
116
122
  *
117
123
  * Algorithm:
118
- * 1. Query all pools' /pressure in parallel
124
+ * 1. Query all pools' status in parallel (driver-aware)
119
125
  * 2. Add local pending count to each pool's running total
120
126
  * 3. Filter to reachable pools with (running + pending) < maxConcurrent
121
127
  * 4. Sort by: lowest effective pressure → fewest queued → most free slots
122
128
  * 5. Track selection in pending counter, return best candidate URL
123
129
  * 6. If all full, poll every 2s up to 60s, then pick least-pressured anyway
124
130
  */
125
- export async function selectPool(poolUrls, pollIntervalMs = 2000, maxWaitMs = 60000) {
131
+ export async function selectPool(poolUrls, pollIntervalMs = 2000, maxWaitMs = 60000, options = {}) {
126
132
  // Fast path: single pool
127
133
  if (poolUrls.length === 1) {
128
- await waitForSlotOnPool(poolUrls[0], pollIntervalMs, maxWaitMs);
134
+ await waitForSlotOnPool(poolUrls[0], pollIntervalMs, maxWaitMs, options);
129
135
  trackPending(poolUrls[0]);
130
136
  return poolUrls[0];
131
137
  }
@@ -133,7 +139,7 @@ export async function selectPool(poolUrls, pollIntervalMs = 2000, maxWaitMs = 60
133
139
  const start = Date.now();
134
140
 
135
141
  while (Date.now() - start < maxWaitMs) {
136
- const results = await getAllPoolStatuses(poolUrls);
142
+ const results = await getAllPoolStatuses(poolUrls, options);
137
143
  const candidates = results
138
144
  .filter(r => r.status && !r.error && r.status.available)
139
145
  .map(r => {
@@ -173,7 +179,7 @@ export async function selectPool(poolUrls, pollIntervalMs = 2000, maxWaitMs = 60
173
179
  }
174
180
 
175
181
  // Timeout — pick the least-pressured pool anyway (let connectToPool deal with it)
176
- const results = await getAllPoolStatuses(poolUrls);
182
+ const results = await getAllPoolStatuses(poolUrls, options);
177
183
  const reachable = results
178
184
  .filter(r => r.status && !r.error)
179
185
  .sort((a, b) => {
@@ -198,16 +204,16 @@ export async function selectPool(poolUrls, pollIntervalMs = 2000, maxWaitMs = 60
198
204
  /** Convenience: selectPool + connectToPool in one call. */
199
205
  export async function selectAndConnect(config) {
200
206
  const poolUrls = getPoolUrls(config);
201
- const chosenUrl = await selectPool(poolUrls);
207
+ const chosenUrl = await selectPool(poolUrls, 2000, 60000, driverOpts(config));
202
208
  return connectToPool(chosenUrl, config.connectRetries, config.connectRetryDelay);
203
209
  }
204
210
 
205
- /** Waits until a single pool has capacity (replaces the old waitForSlot from runner.js). */
206
- async function waitForSlotOnPool(poolUrl, pollIntervalMs = 2000, maxWaitMs = 60000) {
211
+ /** Waits until a single pool has capacity. */
212
+ async function waitForSlotOnPool(poolUrl, pollIntervalMs = 2000, maxWaitMs = 60000, options = {}) {
207
213
  const start = Date.now();
208
214
  while (Date.now() - start < maxWaitMs) {
209
215
  try {
210
- const status = await getPoolStatus(poolUrl);
216
+ const status = await getPoolStatus(poolUrl, options);
211
217
  if (status.available && status.running < status.maxConcurrent) {
212
218
  return;
213
219
  }