@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.
- package/.claude-plugin/marketplace.json +37 -6
- package/.claude-plugin/plugin.json +17 -3
- package/LICENSE +190 -0
- package/README.md +151 -527
- package/agents/test-creator.md +4 -2
- package/agents/test-improver.md +5 -3
- package/bin/cli.js +84 -20
- package/commands/capture.md +45 -0
- package/package.json +3 -2
- package/skills/e2e-testing/SKILL.md +3 -2
- package/skills/e2e-testing/references/action-types.md +22 -4
- package/skills/e2e-testing/references/test-json-format.md +23 -0
- package/src/actions.js +321 -14
- package/src/ai-generate.js +81 -0
- package/src/app-pool.js +339 -0
- package/src/config.js +131 -7
- package/src/dashboard.js +209 -11
- package/src/db.js +74 -7
- package/src/index.js +6 -4
- package/src/learner-sqlite.js +154 -0
- package/src/learner.js +70 -3
- package/src/mcp-tools.js +259 -34
- package/src/module-analysis.js +247 -0
- package/src/module-resolver.js +35 -2
- package/src/narrate.js +42 -1
- package/src/pool-manager.js +68 -17
- package/src/pool.js +464 -37
- package/src/reporter.js +4 -1
- package/src/runner.js +410 -63
- package/src/visual-diff.js +515 -0
- package/src/websocket.js +14 -3
- package/src/wizard.js +184 -0
- package/templates/build-dashboard.js +3 -0
- package/templates/dashboard/js/api.js +62 -3
- package/templates/dashboard/js/init.js +46 -0
- package/templates/dashboard/js/keyboard.js +8 -7
- package/templates/dashboard/js/quicksearch.js +277 -0
- package/templates/dashboard/js/state.js +61 -7
- package/templates/dashboard/js/toast.js +1 -1
- package/templates/dashboard/js/utils.js +20 -0
- package/templates/dashboard/js/view-live.js +240 -9
- package/templates/dashboard/js/view-runs.js +540 -94
- package/templates/dashboard/js/view-tests.js +157 -16
- package/templates/dashboard/js/view-tools.js +234 -0
- package/templates/dashboard/js/view-watch.js +2 -2
- package/templates/dashboard/js/websocket.js +36 -0
- package/templates/dashboard/styles/base.css +489 -53
- package/templates/dashboard/styles/components.css +719 -77
- package/templates/dashboard/styles/view-live.css +463 -59
- package/templates/dashboard/styles/view-runs.css +793 -155
- package/templates/dashboard/styles/view-tests.css +440 -77
- package/templates/dashboard/styles/view-tools.css +206 -0
- package/templates/dashboard/styles/view-watch.css +198 -41
- package/templates/dashboard/template.html +369 -56
- package/templates/dashboard.html +5375 -901
- 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:
|
|
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" } —
|
|
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
|
-
|
|
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 =>
|
|
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
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
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
|
-
//
|
|
1712
|
-
const authToken = args.authToken || config
|
|
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
|
|
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
|
-
//
|
|
1801
|
-
const authToken = args.authToken || config
|
|
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
|
|
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':
|