@matware/e2e-runner 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.claude-plugin/marketplace.json +37 -6
  2. package/.claude-plugin/plugin.json +17 -3
  3. package/LICENSE +190 -0
  4. package/README.md +151 -527
  5. package/agents/test-creator.md +4 -2
  6. package/agents/test-improver.md +5 -3
  7. package/bin/cli.js +84 -20
  8. package/commands/capture.md +45 -0
  9. package/package.json +3 -2
  10. package/skills/e2e-testing/SKILL.md +3 -2
  11. package/skills/e2e-testing/references/action-types.md +22 -4
  12. package/skills/e2e-testing/references/test-json-format.md +23 -0
  13. package/src/actions.js +321 -14
  14. package/src/ai-generate.js +81 -0
  15. package/src/app-pool.js +339 -0
  16. package/src/config.js +131 -7
  17. package/src/dashboard.js +209 -11
  18. package/src/db.js +74 -7
  19. package/src/index.js +6 -4
  20. package/src/learner-sqlite.js +154 -0
  21. package/src/learner.js +70 -3
  22. package/src/mcp-tools.js +259 -34
  23. package/src/module-analysis.js +247 -0
  24. package/src/module-resolver.js +35 -2
  25. package/src/narrate.js +42 -1
  26. package/src/pool-manager.js +68 -17
  27. package/src/pool.js +464 -37
  28. package/src/reporter.js +4 -1
  29. package/src/runner.js +410 -63
  30. package/src/visual-diff.js +515 -0
  31. package/src/websocket.js +14 -3
  32. package/src/wizard.js +184 -0
  33. package/templates/build-dashboard.js +3 -0
  34. package/templates/dashboard/js/api.js +62 -3
  35. package/templates/dashboard/js/init.js +46 -0
  36. package/templates/dashboard/js/keyboard.js +8 -7
  37. package/templates/dashboard/js/quicksearch.js +277 -0
  38. package/templates/dashboard/js/state.js +61 -7
  39. package/templates/dashboard/js/toast.js +1 -1
  40. package/templates/dashboard/js/utils.js +20 -0
  41. package/templates/dashboard/js/view-live.js +240 -9
  42. package/templates/dashboard/js/view-runs.js +540 -94
  43. package/templates/dashboard/js/view-tests.js +157 -16
  44. package/templates/dashboard/js/view-tools.js +234 -0
  45. package/templates/dashboard/js/view-watch.js +2 -2
  46. package/templates/dashboard/js/websocket.js +36 -0
  47. package/templates/dashboard/styles/base.css +489 -53
  48. package/templates/dashboard/styles/components.css +719 -77
  49. package/templates/dashboard/styles/view-live.css +463 -59
  50. package/templates/dashboard/styles/view-runs.css +793 -155
  51. package/templates/dashboard/styles/view-tests.css +440 -77
  52. package/templates/dashboard/styles/view-tools.css +206 -0
  53. package/templates/dashboard/styles/view-watch.css +198 -41
  54. package/templates/dashboard/template.html +369 -56
  55. package/templates/dashboard.html +5375 -901
  56. package/templates/docker-compose-lightpanda.yml +7 -0
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
 
@@ -107,6 +137,11 @@ export const TOOLS = [
107
137
  click_chip: { type: "click_chip", text: "Active" } — MUI Chip / tag elements
108
138
  click_icon: { type: "click_icon", value: "edit" } — SVG/icon by data-testid, aria-label, class
109
139
  click_in_context:{ type: "click_in_context", text: "Row text", selector: "button" } — child within container
140
+ click (in dialog):{ type: "click", text: "Confirm", scope: "dialog", last: true } — only [role=dialog]/MuiDialog; visible:true skips hidden; last:true picks last match
141
+
142
+ **Selecting from a MUI Autocomplete/Select** — DON'T write evaluate to open+filter+pick:
143
+ select_combobox: { type: "select_combobox", selector: "input[role='combobox']", filter: "cardio", text: "Cardiología" }
144
+ — opens the combobox, types optional filter, clicks the matching option (role=option / MuiAutocomplete-option / MuiMenuItem)
110
145
 
111
146
  **Asserting text presence/absence** — DON'T write evaluate with body.includes():
112
147
  assert_text: { type: "assert_text", text: "Welcome" } — text IS on page (case-sensitive). Uses: text
@@ -134,12 +169,13 @@ IMPORTANT field rules:
134
169
  navigate: { type: "navigate", value: "/settings" } — SPA-friendly (won't fail if no page load)
135
170
  wait: { type: "wait", text: "Loading complete" } — wait for text to appear in body
136
171
  wait: { type: "wait", selector: ".results" } — wait for element to appear
137
- wait: { type: "wait", value: "2000" } fixed delay (avoid when possible)
172
+ wait (gone): { type: "wait", gone: ".MuiBackdrop-root" } wait until a selector disappears/hides (spinner, closing dialog)
173
+ wait: { type: "wait", value: "2000" } — fixed delay (last resort — prefer gone/selector/text)
138
174
  wait_network_idle: { type: "wait_network_idle", value: "500" } — wait until no network for N ms
139
175
 
140
176
  **Form interaction** — DON'T write evaluate with native value setters (unless React):
141
177
  type: { type: "type", selector: "#email", value: "a@b.com" } — clears + types
142
- type_react: { type: "type_react", selector: "#email", value: "a@b.com" } — for React controlled inputs
178
+ type_react: { type: "type_react", selector: "#email", value: "a@b.com", waitAfter: "400" } — React controlled inputs; optional blur:true / waitAfter ms
143
179
  select: { type: "select", selector: "select#country", value: "US" }
144
180
  clear: { type: "clear", selector: "#search" }
145
181
  press: { type: "press", value: "Enter" }
@@ -223,6 +259,20 @@ Use { "$use": "module-name", "params": {...} } to reference reusable modules fro
223
259
  },
224
260
  },
225
261
  },
262
+ {
263
+ name: 'e2e_app_pool_status',
264
+ description:
265
+ '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.',
266
+ inputSchema: {
267
+ type: 'object',
268
+ properties: {
269
+ cwd: {
270
+ type: 'string',
271
+ description: 'Absolute path to the project root directory.',
272
+ },
273
+ },
274
+ },
275
+ },
226
276
  {
227
277
  name: 'e2e_screenshot',
228
278
  description:
@@ -264,6 +314,24 @@ Use { "$use": "module-name", "params": {...} } to reference reusable modules fro
264
314
  properties: {},
265
315
  },
266
316
  },
317
+ {
318
+ name: 'e2e_dashboard_restart',
319
+ description:
320
+ '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.',
321
+ inputSchema: {
322
+ type: 'object',
323
+ properties: {
324
+ port: {
325
+ type: 'number',
326
+ description: 'Dashboard port (default: same port or 8484)',
327
+ },
328
+ cwd: {
329
+ type: 'string',
330
+ description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
331
+ },
332
+ },
333
+ },
334
+ },
267
335
  {
268
336
  name: 'e2e_issue',
269
337
  description:
@@ -337,6 +405,11 @@ Use { "$use": "module-name", "params": {...} } to reference reusable modules fro
337
405
  type: 'string',
338
406
  description: 'localStorage key name for the auth token (default: "accessToken")',
339
407
  },
408
+ waitUntil: {
409
+ type: 'string',
410
+ enum: ['networkidle2', 'domcontentloaded', 'load', 'auto'],
411
+ 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).',
412
+ },
340
413
  cwd: {
341
414
  type: 'string',
342
415
  description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
@@ -384,6 +457,11 @@ Use { "$use": "module-name", "params": {...} } to reference reusable modules fro
384
457
  type: 'string',
385
458
  description: 'localStorage key name for the auth token (default: "accessToken")',
386
459
  },
460
+ waitUntil: {
461
+ type: 'string',
462
+ enum: ['networkidle2', 'domcontentloaded', 'load', 'auto'],
463
+ 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).',
464
+ },
387
465
  cwd: {
388
466
  type: 'string',
389
467
  description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
@@ -568,9 +646,9 @@ Good module candidates: auth setup, page navigation, tab clicking, opening sideb
568
646
  },
569
647
  ];
570
648
 
571
- /** Tools exposed on the dashboard — excludes dashboard start/stop (already running). */
649
+ /** Tools exposed on the dashboard — excludes dashboard start/stop/restart (already running). */
572
650
  export const DASHBOARD_TOOLS = TOOLS.filter(
573
- t => t.name !== 'e2e_dashboard_start' && t.name !== 'e2e_dashboard_stop'
651
+ t => t.name !== 'e2e_dashboard_start' && t.name !== 'e2e_dashboard_stop' && t.name !== 'e2e_dashboard_restart'
574
652
  );
575
653
 
576
654
  // ── Dashboard broadcast helper ────────────────────────────────────────────────
@@ -620,7 +698,8 @@ async function handleRun(args) {
620
698
  const config = await loadConfig(configOverrides, args.cwd);
621
699
  config.triggeredBy = 'mcp';
622
700
 
623
- await waitForAnyPool(getPoolUrls(config));
701
+ const driverOpts = { poolDriver: config.poolDriver, maxSessions: config.maxSessions };
702
+ await waitForAnyPool(getPoolUrls(config), 30000, driverOpts);
624
703
 
625
704
  let tests, hooks;
626
705
 
@@ -669,8 +748,13 @@ async function handleRun(args) {
669
748
  }));
670
749
 
671
750
  const flaky = report.results
672
- .filter(r => r.success && r.attempt > 1)
673
- .map(r => ({ name: r.name, attempts: r.attempt }));
751
+ .filter(r => r.success && (r.attempt > 1 || r.flaky))
752
+ .map(r => {
753
+ const entry = { name: r.name };
754
+ if (r.voting) entry.voting = r.voting;
755
+ else entry.attempts = r.attempt;
756
+ return entry;
757
+ });
674
758
 
675
759
  const summary = {
676
760
  ...report.summary,
@@ -799,6 +883,19 @@ async function handleRun(args) {
799
883
  } catch { /* never fail the run response */ }
800
884
  }
801
885
 
886
+ // Hindsight hints — LLM-powered fix suggestions for failures (async, never blocks)
887
+ if (hasApiKey(config) && failures.length > 0) {
888
+ try {
889
+ const maxHints = config.hintsMaxFailures ?? 3;
890
+ const hintTargets = failures.slice(0, maxHints);
891
+ const failedResults = hintTargets.map(f => report.results.find(r => r.name === f.name)).filter(Boolean);
892
+ const hints = (await Promise.all(failedResults.map(r => generateHindsightHint(r, config)))).filter(Boolean);
893
+ if (hints.length > 0) {
894
+ summary.hindsightHints = hints;
895
+ }
896
+ } catch { /* never fail the run response */ }
897
+ }
898
+
802
899
  return textResult(JSON.stringify(summary, null, 2));
803
900
  }
804
901
 
@@ -1039,22 +1136,44 @@ function analyzeEvaluateUsage(actions) {
1039
1136
  suggestions.push('No-op evaluate (returns static string) → remove entirely');
1040
1137
  }
1041
1138
 
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)
1139
+ // 🚨 Pattern: evaluate that NEVER fails no throw, no FAIL:/ERROR:, no return false
1140
+ const canFail = /throw\s+new\s+Error/s.test(code) || /\bFAIL[:\s]/s.test(code) || /\bERROR[:\s]/s.test(code)
1141
+ || /return\s+false\b/s.test(code) || /return\s+'FAIL/s.test(code) || /return\s+`FAIL/s.test(code);
1142
+
1143
+ if (!canFail) {
1144
+ // Any template string return → always truthy, test always passes
1047
1145
  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
- }
1146
+ suggestions.push(
1147
+ '🚨 Evaluate returns template string but NEVER throws or returns false — ' +
1148
+ 'this action will ALWAYS PASS regardless of results. Either throw new Error("FAIL: ...") when conditions fail, or use built-in assert actions'
1149
+ );
1057
1150
  }
1151
+ // Returns a plain string (not template) that isn't FAIL/ERROR
1152
+ else if (/return\s+['"][^'"]*['"]/s.test(code) && code.length > 60) {
1153
+ suggestions.push(
1154
+ '⚠️ Evaluate returns a plain string but never fails — informational-only. Add failure conditions or replace with assert actions'
1155
+ );
1156
+ }
1157
+ }
1158
+
1159
+ // 🚨 Pattern: .click() inside evaluate — should use built-in click action
1160
+ if (/\.click\(\)/s.test(code) && !(/\.textContent[^]*\.click\(\)/s.test(code))) {
1161
+ // Only flag if not already caught by the textContent click pattern above
1162
+ if (/\.filter\([^)]*text/s.test(code) || /querySelectorAll[^)]*\)[^]*\.click/s.test(code) || /querySelector[^)]*\)[^]*\.click/s.test(code)) {
1163
+ suggestions.push(
1164
+ '🚨 Element click via evaluate → use { type: "click", text: "..." } or { type: "click", selector: "..." }. ' +
1165
+ 'Built-in click has retries, waits, and better error reporting'
1166
+ );
1167
+ }
1168
+ }
1169
+
1170
+ // 🚨 Pattern: MUI/framework selectors inside evaluate — fragile
1171
+ const muiMatches = code.match(/\.Mui[\w-]+/g) || [];
1172
+ if (muiMatches.length > 0) {
1173
+ suggestions.push(
1174
+ `⚠️ MUI class selectors (${muiMatches.slice(0, 3).join(', ')}) are auto-generated and change between versions. ` +
1175
+ `Prefer [data-testid="..."], [role="..."], or text-based selectors`
1176
+ );
1058
1177
  }
1059
1178
 
1060
1179
  // 🚨 Pattern: sets window.__e2e_* globals for cross-test state sharing
@@ -1093,6 +1212,23 @@ function analyzeActionPatterns(tests) {
1093
1212
  }
1094
1213
  }
1095
1214
 
1215
+ // Detect MUI/framework selectors in action selectors
1216
+ for (const test of tests) {
1217
+ if (!test.actions) continue;
1218
+ for (const action of test.actions) {
1219
+ const sel = action.selector || '';
1220
+ if (/\.Mui[\w-]+/.test(sel) || /\.ant-[\w-]+/.test(sel) || /\.v-[\w-]+/.test(sel)) {
1221
+ const match = sel.match(/\.(Mui[\w-]+|ant-[\w-]+|v-[\w-]+)/);
1222
+ warnings.push(
1223
+ `⚠️ Framework selector ".${match[1]}" in "${test.name}" (${action.type}) — ` +
1224
+ `these class names are auto-generated and break on version upgrades. ` +
1225
+ `Prefer [data-testid="..."], [role="..."], or text-based actions`
1226
+ );
1227
+ break;
1228
+ }
1229
+ }
1230
+ }
1231
+
1096
1232
  // Detect cross-test state: test N writes window.__e2e_*, test M reads it
1097
1233
  const writers = new Map(); // varName → test name
1098
1234
  const readers = new Map(); // varName → [test names]
@@ -1132,7 +1268,7 @@ function analyzeActionPatterns(tests) {
1132
1268
  async function handlePoolStatus(args) {
1133
1269
  const config = await loadConfig({}, args.cwd);
1134
1270
  const poolUrls = getPoolUrls(config);
1135
- const aggregated = await getAggregatedPoolStatus(poolUrls);
1271
+ const aggregated = await getAggregatedPoolStatus(poolUrls, { poolDriver: config.poolDriver, maxSessions: config.maxSessions });
1136
1272
 
1137
1273
  const lines = [];
1138
1274
 
@@ -1159,6 +1295,30 @@ async function handlePoolStatus(args) {
1159
1295
  return textResult(lines.join('\n'));
1160
1296
  }
1161
1297
 
1298
+ async function handleAppPoolStatus(args) {
1299
+ const config = await loadConfig({}, args.cwd);
1300
+ if (!isAppPoolEnabled(config)) {
1301
+ return textResult('App pool is not enabled. Set appPool.enabled = true in e2e.config.js to use isolated app environments per test.');
1302
+ }
1303
+
1304
+ const status = getAppPoolStatus();
1305
+ const lines = [
1306
+ `Driver: ${config.appPool.driver}`,
1307
+ `Active forks: ${status.activeForks}/${config.appPool.maxForks}`,
1308
+ `Port range: ${config.appPool.forkBasePort}-${config.appPool.forkBasePort + config.appPool.maxForks - 1}`,
1309
+ `Allocated: ${status.allocatedPorts.length ? status.allocatedPorts.join(', ') : 'none'}`,
1310
+ ];
1311
+
1312
+ if (status.forks.length > 0) {
1313
+ lines.push('');
1314
+ for (const fork of status.forks) {
1315
+ lines.push(` ${fork.forkId}: port ${fork.port}, ${fork.baseUrl} (${fork.testName || 'unnamed'}, ${fork.forkTimeMs}ms)`);
1316
+ }
1317
+ }
1318
+
1319
+ return textResult(lines.join('\n'));
1320
+ }
1321
+
1162
1322
  async function handleScreenshot(args) {
1163
1323
  if (!args.hash) return errorResult('Missing required parameter: hash');
1164
1324
 
@@ -1695,12 +1855,42 @@ function buildSuggestedTests(structure, pageUrl) {
1695
1855
  return tests;
1696
1856
  }
1697
1857
 
1858
+ /**
1859
+ * Smart page navigation with fallback for SPA/WebSocket apps.
1860
+ * - "auto" (default): tries networkidle2 with 10s timeout, falls back to domcontentloaded + 2s delay
1861
+ * - "networkidle2"/"load"/"domcontentloaded": uses that strategy directly with 30s timeout
1862
+ */
1863
+ async function smartNavigate(page, url, waitUntil) {
1864
+ const strategy = waitUntil || 'auto';
1865
+
1866
+ if (strategy === 'auto') {
1867
+ try {
1868
+ await page.goto(url, { waitUntil: 'networkidle2', timeout: 10000 });
1869
+ } catch (err) {
1870
+ if (err.name === 'TimeoutError' || (err.message && err.message.includes('timeout'))) {
1871
+ // networkidle2 timed out — likely a SPA with WebSocket/SSE/polling
1872
+ // Fall back to domcontentloaded + wait for hydration
1873
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
1874
+ await new Promise(r => setTimeout(r, 2000));
1875
+ } else {
1876
+ throw err;
1877
+ }
1878
+ }
1879
+ } else {
1880
+ await page.goto(url, { waitUntil: strategy, timeout: 30000 });
1881
+ // For domcontentloaded, add a small hydration delay for SPAs
1882
+ if (strategy === 'domcontentloaded') {
1883
+ await new Promise(r => setTimeout(r, 1500));
1884
+ }
1885
+ }
1886
+ }
1887
+
1698
1888
  async function handleAnalyze(args) {
1699
1889
  if (!args.url) return errorResult('Missing required parameter: url');
1700
1890
 
1701
1891
  const config = await loadConfig({}, args.cwd);
1702
1892
  const poolUrls = getPoolUrls(config);
1703
- const chosenPool = await selectPool(poolUrls);
1893
+ const chosenPool = await selectPool(poolUrls, 2000, 60000, { poolDriver: config.poolDriver, maxSessions: config.maxSessions });
1704
1894
 
1705
1895
  let browser;
1706
1896
  try {
@@ -1708,8 +1898,8 @@ async function handleAnalyze(args) {
1708
1898
  const page = await browser.newPage();
1709
1899
  await page.setViewport(config.viewport);
1710
1900
 
1711
- // Inject auth token into localStorage before navigation
1712
- const authToken = args.authToken || config.authToken;
1901
+ // Resolve auth token: explicit arg > config static > auto-login
1902
+ const authToken = args.authToken || await resolveAuthToken(config);
1713
1903
  if (authToken) {
1714
1904
  const storageKey = args.authStorageKey || config.authStorageKey || 'accessToken';
1715
1905
  const origin = new URL(args.url).origin;
@@ -1717,7 +1907,7 @@ async function handleAnalyze(args) {
1717
1907
  await page.evaluate((key, token) => { localStorage.setItem(key, token); }, storageKey, authToken);
1718
1908
  }
1719
1909
 
1720
- await page.goto(args.url, { waitUntil: 'networkidle2', timeout: 30000 });
1910
+ await smartNavigate(page, args.url, args.waitUntil);
1721
1911
 
1722
1912
  if (args.selector) {
1723
1913
  await page.waitForSelector(args.selector, { timeout: 10000 });
@@ -1789,7 +1979,7 @@ async function handleCapture(args) {
1789
1979
 
1790
1980
  const config = await loadConfig({}, args.cwd);
1791
1981
  const capturePoolUrls = getPoolUrls(config);
1792
- const capturePool = await selectPool(capturePoolUrls);
1982
+ const capturePool = await selectPool(capturePoolUrls, 2000, 60000, { poolDriver: config.poolDriver, maxSessions: config.maxSessions });
1793
1983
 
1794
1984
  let browser;
1795
1985
  try {
@@ -1797,8 +1987,8 @@ async function handleCapture(args) {
1797
1987
  const page = await browser.newPage();
1798
1988
  await page.setViewport(config.viewport);
1799
1989
 
1800
- // Inject auth token into localStorage before navigation
1801
- const authToken = args.authToken || config.authToken;
1990
+ // Resolve auth token: explicit arg > config static > auto-login
1991
+ const authToken = args.authToken || await resolveAuthToken(config);
1802
1992
  if (authToken) {
1803
1993
  const storageKey = args.authStorageKey || config.authStorageKey || 'accessToken';
1804
1994
  // Navigate to origin first so localStorage is accessible
@@ -1807,7 +1997,7 @@ async function handleCapture(args) {
1807
1997
  await page.evaluate((key, token) => { localStorage.setItem(key, token); }, storageKey, authToken);
1808
1998
  }
1809
1999
 
1810
- await page.goto(args.url, { waitUntil: 'networkidle2', timeout: 30000 });
2000
+ await smartNavigate(page, args.url, args.waitUntil);
1811
2001
 
1812
2002
  if (args.selector) {
1813
2003
  await page.waitForSelector(args.selector, { timeout: 10000 });
@@ -1874,6 +2064,35 @@ async function handleDashboardStop() {
1874
2064
  return textResult('Dashboard stopped');
1875
2065
  }
1876
2066
 
2067
+ async function handleDashboardRestart(args) {
2068
+ const port = args.port || (dashboardHandle ? dashboardHandle.port : 8484);
2069
+
2070
+ // Stop current instance if we own it
2071
+ if (dashboardHandle) {
2072
+ stopDashboard(dashboardHandle);
2073
+ dashboardHandle = null;
2074
+ }
2075
+
2076
+ // Kill any process occupying the target port (e.g. from another session)
2077
+ try {
2078
+ const { execFileSync } = await import('child_process');
2079
+ const lsof = execFileSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
2080
+ if (lsof) {
2081
+ for (const pid of lsof.split('\n').filter(Boolean)) {
2082
+ try { process.kill(Number(pid), 'SIGTERM'); } catch {}
2083
+ }
2084
+ // Brief wait for port to free up
2085
+ await new Promise(r => setTimeout(r, 500));
2086
+ }
2087
+ } catch {}
2088
+
2089
+ // Start fresh
2090
+ const overrides = { dashboardPort: port };
2091
+ const config = await loadConfig(overrides, args.cwd);
2092
+ dashboardHandle = await startDashboard(config);
2093
+ return textResult(`Dashboard restarted at http://localhost:${dashboardHandle.port}`);
2094
+ }
2095
+
1877
2096
  async function handleNeo4j(args) {
1878
2097
  if (!args.action) return errorResult('Missing required parameter: action');
1879
2098
 
@@ -1965,8 +2184,10 @@ async function handleLearnings(args) {
1965
2184
  return textResult(JSON.stringify(getErrorPatterns(projectId), null, 2));
1966
2185
  case 'trends':
1967
2186
  return textResult(JSON.stringify(getTestTrends(projectId, days), null, 2));
2187
+ case 'actions':
2188
+ return textResult(JSON.stringify(getActionHealthScores(projectId, days), null, 2));
1968
2189
  default:
1969
- return errorResult(`Unknown query: "${args.query}". Use: summary, flaky, selectors, pages, apis, errors, trends, test:<name>, page:<path>, selector:<value>`);
2190
+ return errorResult(`Unknown query: "${args.query}". Use: summary, flaky, selectors, pages, apis, errors, trends, actions, test:<name>, page:<path>, selector:<value>`);
1970
2191
  }
1971
2192
  }
1972
2193
 
@@ -2136,12 +2357,16 @@ export async function dispatchTool(name, args = {}) {
2136
2357
  return await handleCreateTest(args);
2137
2358
  case 'e2e_pool_status':
2138
2359
  return await handlePoolStatus(args);
2360
+ case 'e2e_app_pool_status':
2361
+ return await handleAppPoolStatus(args);
2139
2362
  case 'e2e_screenshot':
2140
2363
  return await handleScreenshot(args);
2141
2364
  case 'e2e_dashboard_start':
2142
2365
  return await handleDashboardStart(args);
2143
2366
  case 'e2e_dashboard_stop':
2144
2367
  return await handleDashboardStop();
2368
+ case 'e2e_dashboard_restart':
2369
+ return await handleDashboardRestart(args);
2145
2370
  case 'e2e_issue':
2146
2371
  return await handleIssue(args);
2147
2372
  case 'e2e_create_module':