@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/.claude-plugin/marketplace.json +37 -6
- package/.claude-plugin/plugin.json +17 -3
- package/LICENSE +190 -0
- package/README.md +61 -526
- package/bin/cli.js +5 -4
- package/commands/capture.md +45 -0
- package/package.json +1 -1
- package/src/actions.js +151 -0
- package/src/ai-generate.js +81 -0
- package/src/app-pool.js +339 -0
- package/src/config.js +125 -7
- package/src/dashboard.js +75 -8
- package/src/db.js +63 -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 +251 -32
- package/src/narrate.js +28 -0
- package/src/pool-manager.js +22 -16
- package/src/pool.js +301 -31
- package/src/reporter.js +4 -1
- package/src/runner.js +335 -55
- package/src/visual-diff.js +446 -0
- package/templates/dashboard/js/api.js +2 -0
- package/templates/dashboard/js/utils.js +20 -0
- package/templates/dashboard/js/view-live.js +40 -2
- package/templates/dashboard/js/view-runs.js +161 -57
- package/templates/dashboard/js/websocket.js +6 -0
- package/templates/dashboard/styles/components.css +7 -0
- package/templates/dashboard/styles/view-live.css +24 -1
- package/templates/dashboard/styles/view-runs.css +36 -0
- package/templates/dashboard/template.html +24 -9
- package/templates/dashboard.html +322 -310
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
|
-
|
|
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 =>
|
|
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
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
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
|
-
//
|
|
1712
|
-
const authToken = args.authToken || config
|
|
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
|
|
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
|
-
//
|
|
1801
|
-
const authToken = args.authToken || config
|
|
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
|
|
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
|
}
|
package/src/pool-manager.js
CHANGED
|
@@ -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
|
|
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'
|
|
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
|
|
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
|
}
|