@matware/e2e-runner 1.1.1 → 1.2.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/plugin.json +9 -0
- package/.mcp.json +9 -0
- package/README.md +475 -307
- package/agents/test-analyzer.md +81 -0
- package/agents/test-creator.md +102 -0
- package/agents/test-improver.md +140 -0
- package/bin/cli.js +194 -6
- package/commands/create-test.md +50 -0
- package/commands/run.md +49 -0
- package/commands/verify-issue.md +63 -0
- package/package.json +10 -2
- package/skills/e2e-testing/SKILL.md +166 -0
- package/skills/e2e-testing/references/action-types.md +100 -0
- package/skills/e2e-testing/references/test-json-format.md +159 -0
- package/skills/e2e-testing/references/troubleshooting.md +182 -0
- package/src/actions.js +273 -18
- package/src/ai-generate.js +87 -7
- package/src/config.js +28 -0
- package/src/dashboard.js +156 -6
- package/src/db.js +207 -13
- package/src/index.js +9 -3
- package/src/learner-markdown.js +177 -0
- package/src/learner-neo4j.js +255 -0
- package/src/learner-sqlite.js +354 -0
- package/src/learner.js +413 -0
- package/src/mcp-tools.js +448 -18
- package/src/module-resolver.js +273 -0
- package/src/narrate.js +225 -0
- package/src/neo4j-pool.js +124 -0
- package/src/reporter.js +35 -2
- package/src/runner.js +120 -46
- package/src/verify.js +5 -3
- package/templates/build-dashboard.js +28 -0
- package/templates/dashboard/app.js +1152 -0
- package/templates/dashboard/styles.css +413 -0
- package/templates/dashboard/template.html +201 -0
- package/templates/dashboard.html +964 -378
- package/templates/docker-compose-neo4j.yml +19 -0
- package/templates/e2e.config.js +3 -0
package/src/mcp-tools.js
CHANGED
|
@@ -16,11 +16,16 @@ import { loadConfig } from './config.js';
|
|
|
16
16
|
import { waitForPool, getPoolStatus, connectToPool } from './pool.js';
|
|
17
17
|
import { runTestsParallel, loadTestFile, loadTestSuite, loadAllSuites, listSuites } from './runner.js';
|
|
18
18
|
import { generateReport, saveReport, persistRun } from './reporter.js';
|
|
19
|
+
import { narrateTest } from './narrate.js';
|
|
19
20
|
import { startDashboard, stopDashboard } from './dashboard.js';
|
|
20
|
-
import { lookupScreenshotHash, ensureProject, computeScreenshotHash, registerScreenshotHash } from './db.js';
|
|
21
|
+
import { lookupScreenshotHash, ensureProject, computeScreenshotHash, registerScreenshotHash, getNetworkLogs } from './db.js';
|
|
21
22
|
import { fetchIssue, checkCliAuth, detectProvider } from './issues.js';
|
|
22
23
|
import { buildPrompt, hasApiKey } from './ai-generate.js';
|
|
23
24
|
import { verifyIssue } from './verify.js';
|
|
25
|
+
import { listModules } from './module-resolver.js';
|
|
26
|
+
import { getLearningsSummary, getFlakySummary, getSelectorStability, getPageHealth, getApiHealth, getErrorPatterns, getTestTrends, getRunInsights, getTestHistory, getPageHistory, getSelectorHistory } from './learner-sqlite.js';
|
|
27
|
+
import { queryGraph } from './learner-neo4j.js';
|
|
28
|
+
import { startNeo4j, stopNeo4j, getNeo4jStatus } from './neo4j-pool.js';
|
|
24
29
|
|
|
25
30
|
// ── Tool definitions ──────────────────────────────────────────────────────────
|
|
26
31
|
|
|
@@ -84,7 +89,7 @@ export const TOOLS = [
|
|
|
84
89
|
{
|
|
85
90
|
name: 'e2e_create_test',
|
|
86
91
|
description:
|
|
87
|
-
'Create a new E2E test JSON file. Provide the suite name and an array of test objects, each with a name and actions array.',
|
|
92
|
+
'Create a new E2E test JSON file. Provide the suite name and an array of test objects, each with a name and actions array. Actions can include { "$use": "module-name", "params": {...} } to reference reusable modules.',
|
|
88
93
|
inputSchema: {
|
|
89
94
|
type: 'object',
|
|
90
95
|
properties: {
|
|
@@ -108,7 +113,7 @@ export const TOOLS = [
|
|
|
108
113
|
properties: {
|
|
109
114
|
type: {
|
|
110
115
|
type: 'string',
|
|
111
|
-
description: 'Action type: goto, click, type, wait, assert_text,
|
|
116
|
+
description: 'Action type: goto, click, click_regex, click_option, click_chip, type, type_react, focus_autocomplete, wait, assert_text, assert_element_text, assert_attribute, assert_class, assert_visible, assert_not_visible, assert_input_value, assert_matches, assert_url, assert_count, assert_no_network_errors, get_text, screenshot, select, clear, clear_cookies, press, scroll, hover, evaluate, navigate',
|
|
112
117
|
},
|
|
113
118
|
selector: { type: 'string', description: 'CSS selector' },
|
|
114
119
|
value: { type: 'string', description: 'Value for the action' },
|
|
@@ -211,6 +216,11 @@ export const TOOLS = [
|
|
|
211
216
|
description:
|
|
212
217
|
'prompt = return issue + prompt for Claude Code to create tests (default). verify = auto-generate tests via Claude API and run them.',
|
|
213
218
|
},
|
|
219
|
+
testType: {
|
|
220
|
+
type: 'string',
|
|
221
|
+
enum: ['e2e', 'api'],
|
|
222
|
+
description: "Test category: 'e2e' (default) for UI-driven tests, 'api' for backend API tests",
|
|
223
|
+
},
|
|
214
224
|
authToken: {
|
|
215
225
|
type: 'string',
|
|
216
226
|
description: 'JWT or auth token to inject into localStorage before running tests (for authenticated apps)',
|
|
@@ -254,6 +264,14 @@ export const TOOLS = [
|
|
|
254
264
|
type: 'number',
|
|
255
265
|
description: 'Wait N milliseconds after page load before capturing (default: 0)',
|
|
256
266
|
},
|
|
267
|
+
authToken: {
|
|
268
|
+
type: 'string',
|
|
269
|
+
description: 'JWT or auth token to inject into localStorage before navigating (for authenticated pages)',
|
|
270
|
+
},
|
|
271
|
+
authStorageKey: {
|
|
272
|
+
type: 'string',
|
|
273
|
+
description: 'localStorage key name for the auth token (default: "accessToken")',
|
|
274
|
+
},
|
|
257
275
|
cwd: {
|
|
258
276
|
type: 'string',
|
|
259
277
|
description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
|
|
@@ -262,6 +280,146 @@ export const TOOLS = [
|
|
|
262
280
|
required: ['url'],
|
|
263
281
|
},
|
|
264
282
|
},
|
|
283
|
+
{
|
|
284
|
+
name: 'e2e_create_module',
|
|
285
|
+
description:
|
|
286
|
+
'Create a reusable module for E2E tests. Modules define action sequences that can be referenced from tests via { "$use": "module-name", "params": {...} }. Useful for auth setup, navigation patterns, and other repeated sequences.',
|
|
287
|
+
inputSchema: {
|
|
288
|
+
type: 'object',
|
|
289
|
+
properties: {
|
|
290
|
+
name: {
|
|
291
|
+
type: 'string',
|
|
292
|
+
description: 'Module name (used in $use references, e.g. "auth-jwt", "navigate-patient")',
|
|
293
|
+
},
|
|
294
|
+
description: {
|
|
295
|
+
type: 'string',
|
|
296
|
+
description: 'Human-readable description of what this module does',
|
|
297
|
+
},
|
|
298
|
+
params: {
|
|
299
|
+
type: 'object',
|
|
300
|
+
description: 'Parameter definitions. Each key is a param name, value is { required: boolean, default?: string, description?: string }',
|
|
301
|
+
additionalProperties: {
|
|
302
|
+
type: 'object',
|
|
303
|
+
properties: {
|
|
304
|
+
required: { type: 'boolean' },
|
|
305
|
+
default: { type: 'string' },
|
|
306
|
+
description: { type: 'string' },
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
actions: {
|
|
311
|
+
type: 'array',
|
|
312
|
+
description: 'Sequential actions with {{param}} placeholders for substitution',
|
|
313
|
+
items: {
|
|
314
|
+
type: 'object',
|
|
315
|
+
properties: {
|
|
316
|
+
type: { type: 'string', description: 'Action type (goto, click, evaluate, wait, etc.) or omit for $use references' },
|
|
317
|
+
selector: { type: 'string' },
|
|
318
|
+
value: { type: 'string' },
|
|
319
|
+
text: { type: 'string' },
|
|
320
|
+
$use: { type: 'string', description: 'Reference another module by name' },
|
|
321
|
+
params: { type: 'object', description: 'Parameters for nested $use' },
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
cwd: {
|
|
326
|
+
type: 'string',
|
|
327
|
+
description: 'Absolute path to the project root directory.',
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
required: ['name', 'actions'],
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
name: 'e2e_learnings',
|
|
335
|
+
description:
|
|
336
|
+
'Query the E2E learning system for insights about test stability, flaky tests, selector health, page health, API health, error patterns, and trends. Builds knowledge across runs.',
|
|
337
|
+
inputSchema: {
|
|
338
|
+
type: 'object',
|
|
339
|
+
properties: {
|
|
340
|
+
query: {
|
|
341
|
+
type: 'string',
|
|
342
|
+
description: 'What to query: "summary" (full overview), "flaky" (flaky tests), "selectors" (selector stability), "pages" (page health), "apis" (API health), "errors" (error patterns), "trends" (7-day trend). Drill-down: "test:<name>", "page:<path>", "selector:<value>".',
|
|
343
|
+
},
|
|
344
|
+
days: {
|
|
345
|
+
type: 'number',
|
|
346
|
+
description: 'Analysis window in days (default: 30)',
|
|
347
|
+
},
|
|
348
|
+
cwd: {
|
|
349
|
+
type: 'string',
|
|
350
|
+
description: 'Absolute path to the project root directory. Claude Code should pass its current working directory.',
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
required: ['query'],
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
name: 'e2e_neo4j',
|
|
358
|
+
description:
|
|
359
|
+
'Manage the Neo4j knowledge graph container for E2E learnings. Requires Docker.',
|
|
360
|
+
inputSchema: {
|
|
361
|
+
type: 'object',
|
|
362
|
+
properties: {
|
|
363
|
+
action: {
|
|
364
|
+
type: 'string',
|
|
365
|
+
enum: ['start', 'stop', 'status'],
|
|
366
|
+
description: 'Container lifecycle action',
|
|
367
|
+
},
|
|
368
|
+
cwd: {
|
|
369
|
+
type: 'string',
|
|
370
|
+
description: 'Absolute path to the project root directory.',
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
required: ['action'],
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
name: 'e2e_network_logs',
|
|
378
|
+
description:
|
|
379
|
+
'Query network request/response logs for a specific test run. Returns filtered logs from SQLite. Use the runDbId from e2e_run results to drill down into network details on demand.',
|
|
380
|
+
inputSchema: {
|
|
381
|
+
type: 'object',
|
|
382
|
+
properties: {
|
|
383
|
+
runDbId: {
|
|
384
|
+
type: 'number',
|
|
385
|
+
description: 'The run database ID (returned by e2e_run in the summary)',
|
|
386
|
+
},
|
|
387
|
+
testName: {
|
|
388
|
+
type: 'string',
|
|
389
|
+
description: 'Filter by test name',
|
|
390
|
+
},
|
|
391
|
+
method: {
|
|
392
|
+
type: 'string',
|
|
393
|
+
description: 'Filter by HTTP method (GET, POST, etc.)',
|
|
394
|
+
},
|
|
395
|
+
statusMin: {
|
|
396
|
+
type: 'number',
|
|
397
|
+
description: 'Minimum HTTP status code (e.g. 400 for errors only)',
|
|
398
|
+
},
|
|
399
|
+
statusMax: {
|
|
400
|
+
type: 'number',
|
|
401
|
+
description: 'Maximum HTTP status code',
|
|
402
|
+
},
|
|
403
|
+
urlPattern: {
|
|
404
|
+
type: 'string',
|
|
405
|
+
description: 'Regex pattern to match against request URLs',
|
|
406
|
+
},
|
|
407
|
+
errorsOnly: {
|
|
408
|
+
type: 'boolean',
|
|
409
|
+
description: 'Only return requests with status >= 400',
|
|
410
|
+
},
|
|
411
|
+
includeHeaders: {
|
|
412
|
+
type: 'boolean',
|
|
413
|
+
description: 'Include request/response headers (default: false)',
|
|
414
|
+
},
|
|
415
|
+
includeBodies: {
|
|
416
|
+
type: 'boolean',
|
|
417
|
+
description: 'Include request/response bodies (default: false, implies includeHeaders)',
|
|
418
|
+
},
|
|
419
|
+
},
|
|
420
|
+
required: ['runDbId'],
|
|
421
|
+
},
|
|
422
|
+
},
|
|
265
423
|
];
|
|
266
424
|
|
|
267
425
|
/** Tools exposed on the dashboard — excludes dashboard start/stop (already running). */
|
|
@@ -320,13 +478,13 @@ async function handleRun(args) {
|
|
|
320
478
|
let tests, hooks;
|
|
321
479
|
|
|
322
480
|
if (args.all) {
|
|
323
|
-
({ tests, hooks } = loadAllSuites(config.testsDir));
|
|
481
|
+
({ tests, hooks } = loadAllSuites(config.testsDir, config.modulesDir, config.exclude));
|
|
324
482
|
} else if (args.suite) {
|
|
325
|
-
({ tests, hooks } = loadTestSuite(args.suite, config.testsDir));
|
|
483
|
+
({ tests, hooks } = loadTestSuite(args.suite, config.testsDir, config.modulesDir));
|
|
326
484
|
} else if (args.file) {
|
|
327
485
|
const cwd = args.cwd || process.cwd();
|
|
328
486
|
const filePath = path.isAbsolute(args.file) ? args.file : path.resolve(cwd, args.file);
|
|
329
|
-
({ tests, hooks } = loadTestFile(filePath));
|
|
487
|
+
({ tests, hooks } = loadTestFile(filePath, config.modulesDir));
|
|
330
488
|
} else {
|
|
331
489
|
return errorResult('Provide one of: all (true), suite (name), or file (path)');
|
|
332
490
|
}
|
|
@@ -348,7 +506,7 @@ async function handleRun(args) {
|
|
|
348
506
|
|
|
349
507
|
const report = generateReport(results);
|
|
350
508
|
saveReport(report, config.screenshotsDir, config);
|
|
351
|
-
persistRun(report, config, args.suite || null);
|
|
509
|
+
const { runDbId } = persistRun(report, config, args.suite || null);
|
|
352
510
|
|
|
353
511
|
const failures = report.results
|
|
354
512
|
.filter(r => !r.success)
|
|
@@ -366,6 +524,7 @@ async function handleRun(args) {
|
|
|
366
524
|
...report.summary,
|
|
367
525
|
reportPath: path.join(config.screenshotsDir, 'report.json'),
|
|
368
526
|
};
|
|
527
|
+
if (runDbId) summary.runDbId = runDbId;
|
|
369
528
|
|
|
370
529
|
const consoleErrors = report.results
|
|
371
530
|
.filter(r => r.consoleLogs?.some(l => l.type === 'error' || l.type === 'warning'))
|
|
@@ -374,9 +533,33 @@ async function handleRun(args) {
|
|
|
374
533
|
.filter(r => r.networkErrors?.length > 0)
|
|
375
534
|
.map(r => ({ name: r.name, errors: r.networkErrors }));
|
|
376
535
|
|
|
377
|
-
|
|
536
|
+
// Compact network summary — full logs available on-demand via e2e_network_logs
|
|
537
|
+
const networkSummary = report.results
|
|
378
538
|
.filter(r => r.networkLogs?.length > 0)
|
|
379
|
-
.map(r =>
|
|
539
|
+
.map(r => {
|
|
540
|
+
const logs = r.networkLogs;
|
|
541
|
+
const statusDist = { '2xx': 0, '3xx': 0, '4xx': 0, '5xx': 0, other: 0 };
|
|
542
|
+
let totalDuration = 0;
|
|
543
|
+
for (const l of logs) {
|
|
544
|
+
const s = l.status;
|
|
545
|
+
if (s >= 200 && s < 300) statusDist['2xx']++;
|
|
546
|
+
else if (s >= 300 && s < 400) statusDist['3xx']++;
|
|
547
|
+
else if (s >= 400 && s < 500) statusDist['4xx']++;
|
|
548
|
+
else if (s >= 500 && s < 600) statusDist['5xx']++;
|
|
549
|
+
else statusDist.other++;
|
|
550
|
+
totalDuration += l.duration || 0;
|
|
551
|
+
}
|
|
552
|
+
const failed = logs.filter(l => l.status >= 400).map(l => ({ url: l.url, method: l.method, status: l.status }));
|
|
553
|
+
const slowest = [...logs].sort((a, b) => (b.duration || 0) - (a.duration || 0)).slice(0, 3).map(l => ({ url: l.url, method: l.method, status: l.status, duration: l.duration }));
|
|
554
|
+
return {
|
|
555
|
+
name: r.name,
|
|
556
|
+
totalRequests: logs.length,
|
|
557
|
+
statusDistribution: statusDist,
|
|
558
|
+
avgDurationMs: logs.length > 0 ? Math.round(totalDuration / logs.length) : 0,
|
|
559
|
+
failedRequests: failed,
|
|
560
|
+
slowestRequests: slowest,
|
|
561
|
+
};
|
|
562
|
+
});
|
|
380
563
|
|
|
381
564
|
const verifications = report.results
|
|
382
565
|
.filter(r => r.expect && r.verificationScreenshot)
|
|
@@ -390,13 +573,54 @@ async function handleRun(args) {
|
|
|
390
573
|
if (flaky.length > 0) summary.flaky = flaky;
|
|
391
574
|
if (failures.length > 0) summary.failures = failures;
|
|
392
575
|
if (consoleErrors.length > 0) summary.consoleErrors = consoleErrors;
|
|
393
|
-
if (networkErrors.length > 0)
|
|
394
|
-
|
|
576
|
+
if (networkErrors.length > 0) {
|
|
577
|
+
summary.networkErrors = networkErrors;
|
|
578
|
+
// Warn when tests pass but have network errors and failOnNetworkError is off
|
|
579
|
+
if (!config.failOnNetworkError) {
|
|
580
|
+
const totalNetErrors = networkErrors.reduce((sum, r) => sum + r.errors.length, 0);
|
|
581
|
+
const passingWithErrors = networkErrors.filter(r => report.results.find(rr => rr.name === r.name)?.success).length;
|
|
582
|
+
if (passingWithErrors > 0) {
|
|
583
|
+
summary.networkWarning = `⚠️ ${passingWithErrors} test(s) PASSED but had ${totalNetErrors} network error(s). Set failOnNetworkError: true to fail these tests.`;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (networkSummary.length > 0) {
|
|
588
|
+
summary.networkSummary = networkSummary;
|
|
589
|
+
if (runDbId) summary.networkLogsHint = 'Full network logs available via e2e_network_logs tool using the runDbId above.';
|
|
590
|
+
}
|
|
395
591
|
if (verifications.length > 0) {
|
|
396
592
|
summary.verifications = verifications;
|
|
397
593
|
summary.verificationInstructions = 'For each verification, call e2e_screenshot with the screenshotHash to view the screenshot. Then compare what you see against the "expect" description. Report any mismatches as FAIL.';
|
|
398
594
|
}
|
|
399
595
|
|
|
596
|
+
// Build per-test narrative: a step-by-step human-readable story of what happened
|
|
597
|
+
const narratives = report.results.map(r => ({
|
|
598
|
+
name: r.name,
|
|
599
|
+
status: r.success ? 'PASSED' : 'FAILED',
|
|
600
|
+
steps: narrateTest(r),
|
|
601
|
+
}));
|
|
602
|
+
if (narratives.length > 0) summary.narratives = narratives;
|
|
603
|
+
|
|
604
|
+
// Enrich with learning insights (fire-and-forget — never fails the response)
|
|
605
|
+
if (config.learningsEnabled !== false) {
|
|
606
|
+
try {
|
|
607
|
+
const projectId = ensureProject(config._cwd, config.projectName, config.screenshotsDir, config.testsDir);
|
|
608
|
+
const insights = getRunInsights(projectId, report);
|
|
609
|
+
if (insights.length > 0) {
|
|
610
|
+
summary.learnings = {
|
|
611
|
+
insights,
|
|
612
|
+
tip: insights.find(i => i.type === 'new-failure')
|
|
613
|
+
? 'New test failure detected — this test was previously stable. Check recent code changes.'
|
|
614
|
+
: insights.find(i => i.type === 'unstable-selectors')
|
|
615
|
+
? 'Unstable selectors detected in this run. Consider using more specific selectors or data-testid attributes.'
|
|
616
|
+
: insights.find(i => i.type === 'flaky')
|
|
617
|
+
? 'Known flaky tests in this run. Consider increasing timeouts or adding waits.'
|
|
618
|
+
: null,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
} catch { /* never fail the run response */ }
|
|
622
|
+
}
|
|
623
|
+
|
|
400
624
|
return textResult(JSON.stringify(summary, null, 2));
|
|
401
625
|
}
|
|
402
626
|
|
|
@@ -404,13 +628,26 @@ async function handleList(args) {
|
|
|
404
628
|
const config = await loadConfig({}, args.cwd);
|
|
405
629
|
const suites = listSuites(config.testsDir);
|
|
406
630
|
|
|
631
|
+
const lines = [];
|
|
632
|
+
|
|
407
633
|
if (suites.length === 0) {
|
|
408
|
-
|
|
634
|
+
lines.push('No test suites found in ' + config.testsDir);
|
|
635
|
+
} else {
|
|
636
|
+
lines.push(...suites.map(s =>
|
|
637
|
+
`${s.name} (${s.testCount} tests): ${s.tests.join(', ')}`
|
|
638
|
+
));
|
|
409
639
|
}
|
|
410
640
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
)
|
|
641
|
+
// List available modules
|
|
642
|
+
const modules = listModules(config.modulesDir);
|
|
643
|
+
if (modules.length > 0) {
|
|
644
|
+
lines.push('');
|
|
645
|
+
lines.push('Available modules:');
|
|
646
|
+
for (const mod of modules) {
|
|
647
|
+
const paramNames = mod.params.map(p => p.required ? p.name : `${p.name}?`).join(', ');
|
|
648
|
+
lines.push(` ${mod.name} (${paramNames}) — ${mod.description || mod.file}`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
414
651
|
|
|
415
652
|
return textResult(lines.join('\n'));
|
|
416
653
|
}
|
|
@@ -438,7 +675,22 @@ async function handleCreateTest(args) {
|
|
|
438
675
|
}
|
|
439
676
|
|
|
440
677
|
fs.writeFileSync(filePath, JSON.stringify(content, null, 2) + '\n');
|
|
441
|
-
|
|
678
|
+
|
|
679
|
+
// Warn about beforeAll pitfall
|
|
680
|
+
let warning = '';
|
|
681
|
+
const beforeAll = args.hooks?.beforeAll;
|
|
682
|
+
if (beforeAll?.length) {
|
|
683
|
+
const stateActions = beforeAll.filter(a =>
|
|
684
|
+
['evaluate', 'goto', 'navigate', 'clear_cookies', 'type', 'click', 'select'].includes(a.type)
|
|
685
|
+
);
|
|
686
|
+
if (stateActions.length > 0) {
|
|
687
|
+
warning = '\n\n⚠️ Warning: beforeAll runs on a separate browser page that is closed before tests start. ' +
|
|
688
|
+
'Actions that set browser state (evaluate, goto, cookies, etc.) will NOT carry over to individual tests. ' +
|
|
689
|
+
'Use beforeEach instead if tests need this setup.';
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
return textResult(`Created test file: ${filePath}\n\n${args.tests.length} test(s) defined.${warning}`);
|
|
442
694
|
}
|
|
443
695
|
|
|
444
696
|
async function handlePoolStatus(args) {
|
|
@@ -477,9 +729,16 @@ async function handleScreenshot(args) {
|
|
|
477
729
|
const filename = path.basename(row.file_path);
|
|
478
730
|
const hash = row.hash;
|
|
479
731
|
|
|
732
|
+
// Build description with metadata if available
|
|
733
|
+
const metaParts = [`Screenshot ss:${hash} (${filename})`];
|
|
734
|
+
if (row.test_name) metaParts.push(`Test: ${row.test_name}`);
|
|
735
|
+
if (row.screenshot_type) metaParts.push(`Type: ${row.screenshot_type}`);
|
|
736
|
+
if (row.step_index != null) metaParts.push(`Step: ${row.step_index}`);
|
|
737
|
+
if (row.page_url) metaParts.push(`URL: ${row.page_url}`);
|
|
738
|
+
|
|
480
739
|
return {
|
|
481
740
|
content: [
|
|
482
|
-
{ type: 'text', text:
|
|
741
|
+
{ type: 'text', text: metaParts.join('\n') },
|
|
483
742
|
{ type: 'image', data: base64, mimeType },
|
|
484
743
|
],
|
|
485
744
|
};
|
|
@@ -489,6 +748,7 @@ async function handleIssue(args) {
|
|
|
489
748
|
if (!args.url) return errorResult('Missing required parameter: url');
|
|
490
749
|
|
|
491
750
|
const mode = args.mode || 'prompt';
|
|
751
|
+
const testType = args.testType || 'e2e';
|
|
492
752
|
const config = await loadConfig({}, args.cwd);
|
|
493
753
|
|
|
494
754
|
// Check provider and auth
|
|
@@ -511,6 +771,7 @@ async function handleIssue(args) {
|
|
|
511
771
|
|
|
512
772
|
if (args.authToken) config.authToken = args.authToken;
|
|
513
773
|
if (args.authStorageKey) config.authStorageKey = args.authStorageKey;
|
|
774
|
+
config.testType = testType;
|
|
514
775
|
|
|
515
776
|
const result = await verifyIssue(args.url, config);
|
|
516
777
|
const status = result.bugConfirmed ? 'BUG CONFIRMED' : 'NOT REPRODUCIBLE';
|
|
@@ -533,11 +794,43 @@ async function handleIssue(args) {
|
|
|
533
794
|
|
|
534
795
|
// Default: prompt mode
|
|
535
796
|
const issue = fetchIssue(args.url);
|
|
536
|
-
const promptData = buildPrompt(issue, config);
|
|
797
|
+
const promptData = buildPrompt(issue, config, testType);
|
|
537
798
|
|
|
538
799
|
return textResult(promptData.prompt);
|
|
539
800
|
}
|
|
540
801
|
|
|
802
|
+
async function handleCreateModule(args) {
|
|
803
|
+
const config = await loadConfig({}, args.cwd);
|
|
804
|
+
|
|
805
|
+
if (!config.modulesDir) {
|
|
806
|
+
return errorResult('modulesDir not configured');
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (!fs.existsSync(config.modulesDir)) {
|
|
810
|
+
fs.mkdirSync(config.modulesDir, { recursive: true });
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const safeName = path.basename(args.name);
|
|
814
|
+
const filename = safeName.endsWith('.json') ? safeName : `${safeName}.json`;
|
|
815
|
+
const filePath = path.join(config.modulesDir, filename);
|
|
816
|
+
|
|
817
|
+
if (fs.existsSync(filePath)) {
|
|
818
|
+
return errorResult(`Module file already exists: ${filePath}`);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const module = {
|
|
822
|
+
$module: args.name,
|
|
823
|
+
description: args.description || '',
|
|
824
|
+
params: args.params || {},
|
|
825
|
+
actions: args.actions,
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
fs.writeFileSync(filePath, JSON.stringify(module, null, 2) + '\n');
|
|
829
|
+
|
|
830
|
+
const paramNames = Object.keys(args.params || {});
|
|
831
|
+
return textResult(`Created module: ${filePath}\n\nName: ${args.name}\nParams: ${paramNames.length ? paramNames.join(', ') : 'none'}\nActions: ${args.actions.length}\n\nUsage in tests: { "$use": "${args.name}", "params": { ... } }`);
|
|
832
|
+
}
|
|
833
|
+
|
|
541
834
|
async function handleCapture(args) {
|
|
542
835
|
if (!args.url) return errorResult('Missing required parameter: url');
|
|
543
836
|
|
|
@@ -550,6 +843,17 @@ async function handleCapture(args) {
|
|
|
550
843
|
browser = await connectToPool(config.poolUrl);
|
|
551
844
|
const page = await browser.newPage();
|
|
552
845
|
await page.setViewport(config.viewport);
|
|
846
|
+
|
|
847
|
+
// Inject auth token into localStorage before navigation
|
|
848
|
+
const authToken = args.authToken || config.authToken;
|
|
849
|
+
if (authToken) {
|
|
850
|
+
const storageKey = args.authStorageKey || config.authStorageKey || 'accessToken';
|
|
851
|
+
// Navigate to origin first so localStorage is accessible
|
|
852
|
+
const origin = new URL(args.url).origin;
|
|
853
|
+
await page.goto(origin, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
|
854
|
+
await page.evaluate((key, token) => { localStorage.setItem(key, token); }, storageKey, authToken);
|
|
855
|
+
}
|
|
856
|
+
|
|
553
857
|
await page.goto(args.url, { waitUntil: 'networkidle2', timeout: 30000 });
|
|
554
858
|
|
|
555
859
|
if (args.selector) {
|
|
@@ -617,6 +921,124 @@ async function handleDashboardStop() {
|
|
|
617
921
|
return textResult('Dashboard stopped');
|
|
618
922
|
}
|
|
619
923
|
|
|
924
|
+
async function handleNeo4j(args) {
|
|
925
|
+
if (!args.action) return errorResult('Missing required parameter: action');
|
|
926
|
+
|
|
927
|
+
const config = await loadConfig({}, args.cwd);
|
|
928
|
+
|
|
929
|
+
switch (args.action) {
|
|
930
|
+
case 'start':
|
|
931
|
+
try {
|
|
932
|
+
startNeo4j(config, args.cwd);
|
|
933
|
+
return textResult(`Neo4j started. Bolt: bolt://localhost:${config.neo4jBoltPort || 7687}, Browser: http://localhost:${config.neo4jHttpPort || 7474}`);
|
|
934
|
+
} catch (err) {
|
|
935
|
+
return errorResult(`Failed to start Neo4j: ${err.message}`);
|
|
936
|
+
}
|
|
937
|
+
case 'stop':
|
|
938
|
+
try {
|
|
939
|
+
stopNeo4j(config, args.cwd);
|
|
940
|
+
return textResult('Neo4j stopped');
|
|
941
|
+
} catch (err) {
|
|
942
|
+
return errorResult(`Failed to stop Neo4j: ${err.message}`);
|
|
943
|
+
}
|
|
944
|
+
case 'status': {
|
|
945
|
+
const status = getNeo4jStatus(config, args.cwd);
|
|
946
|
+
const lines = [
|
|
947
|
+
`Running: ${status.running ? 'yes' : 'no'}`,
|
|
948
|
+
];
|
|
949
|
+
if (status.running) {
|
|
950
|
+
lines.push(`Bolt: bolt://localhost:${status.boltPort}`);
|
|
951
|
+
lines.push(`Browser: http://localhost:${status.httpPort}`);
|
|
952
|
+
}
|
|
953
|
+
if (status.error) lines.push(`Error: ${status.error}`);
|
|
954
|
+
return textResult(lines.join('\n'));
|
|
955
|
+
}
|
|
956
|
+
default:
|
|
957
|
+
return errorResult('Unknown action. Use: start, stop, status');
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
async function handleLearnings(args) {
|
|
962
|
+
if (!args.query) return errorResult('Missing required parameter: query');
|
|
963
|
+
|
|
964
|
+
const config = await loadConfig({}, args.cwd);
|
|
965
|
+
const days = Math.min(Math.max(parseInt(args.days || config.learningsDays || 30, 10) || 30, 1), 365);
|
|
966
|
+
const projectId = ensureProject(config._cwd, config.projectName, config.screenshotsDir, config.testsDir);
|
|
967
|
+
|
|
968
|
+
const query = args.query.trim().toLowerCase();
|
|
969
|
+
|
|
970
|
+
// Drill-down queries (enriched with graph data when Neo4j is available)
|
|
971
|
+
if (query.startsWith('test:')) {
|
|
972
|
+
const testName = args.query.slice(5).trim();
|
|
973
|
+
const history = getTestHistory(projectId, testName, days);
|
|
974
|
+
const result = { query: args.query, testName, history };
|
|
975
|
+
const graphDeps = await queryGraph(config, 'test-dependencies', { testName }).catch(() => null);
|
|
976
|
+
if (graphDeps) result.relatedTests = graphDeps;
|
|
977
|
+
return textResult(JSON.stringify(result, null, 2));
|
|
978
|
+
}
|
|
979
|
+
if (query.startsWith('page:')) {
|
|
980
|
+
const urlPath = args.query.slice(5).trim();
|
|
981
|
+
const history = getPageHistory(projectId, urlPath, days);
|
|
982
|
+
const result = { query: args.query, urlPath, history };
|
|
983
|
+
const graphImpact = await queryGraph(config, 'page-impact', { path: urlPath }).catch(() => null);
|
|
984
|
+
if (graphImpact) result.affectedTests = graphImpact;
|
|
985
|
+
return textResult(JSON.stringify(result, null, 2));
|
|
986
|
+
}
|
|
987
|
+
if (query.startsWith('selector:')) {
|
|
988
|
+
const selector = args.query.slice(9).trim();
|
|
989
|
+
const history = getSelectorHistory(projectId, selector, days);
|
|
990
|
+
const result = { query: args.query, selector, history };
|
|
991
|
+
const graphUsage = await queryGraph(config, 'selector-usage', { selector }).catch(() => null);
|
|
992
|
+
if (graphUsage) result.usage = graphUsage;
|
|
993
|
+
return textResult(JSON.stringify(result, null, 2));
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// Category queries
|
|
997
|
+
switch (query) {
|
|
998
|
+
case 'summary': {
|
|
999
|
+
const summary = getLearningsSummary(projectId);
|
|
1000
|
+
const trendsResult = getTestTrends(projectId, 7);
|
|
1001
|
+
return textResult(JSON.stringify({ ...summary, recentTrend: trendsResult }, null, 2));
|
|
1002
|
+
}
|
|
1003
|
+
case 'flaky':
|
|
1004
|
+
return textResult(JSON.stringify(getFlakySummary(projectId, days), null, 2));
|
|
1005
|
+
case 'selectors':
|
|
1006
|
+
return textResult(JSON.stringify(getSelectorStability(projectId, days), null, 2));
|
|
1007
|
+
case 'pages':
|
|
1008
|
+
return textResult(JSON.stringify(getPageHealth(projectId, days), null, 2));
|
|
1009
|
+
case 'apis':
|
|
1010
|
+
return textResult(JSON.stringify(getApiHealth(projectId, days), null, 2));
|
|
1011
|
+
case 'errors':
|
|
1012
|
+
return textResult(JSON.stringify(getErrorPatterns(projectId), null, 2));
|
|
1013
|
+
case 'trends':
|
|
1014
|
+
return textResult(JSON.stringify(getTestTrends(projectId, days), null, 2));
|
|
1015
|
+
default:
|
|
1016
|
+
return errorResult(`Unknown query: "${args.query}". Use: summary, flaky, selectors, pages, apis, errors, trends, test:<name>, page:<path>, selector:<value>`);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
async function handleNetworkLogs(args) {
|
|
1021
|
+
if (!args.runDbId) return errorResult('Missing required parameter: runDbId');
|
|
1022
|
+
|
|
1023
|
+
const filters = {};
|
|
1024
|
+
if (args.testName) filters.testName = args.testName;
|
|
1025
|
+
if (args.method) filters.method = args.method;
|
|
1026
|
+
if (args.statusMin !== undefined) filters.statusMin = args.statusMin;
|
|
1027
|
+
if (args.statusMax !== undefined) filters.statusMax = args.statusMax;
|
|
1028
|
+
if (args.urlPattern) filters.urlPattern = args.urlPattern;
|
|
1029
|
+
if (args.errorsOnly) filters.errorsOnly = true;
|
|
1030
|
+
if (args.includeHeaders) filters.includeHeaders = true;
|
|
1031
|
+
if (args.includeBodies) filters.includeBodies = true;
|
|
1032
|
+
|
|
1033
|
+
const results = getNetworkLogs(args.runDbId, filters);
|
|
1034
|
+
|
|
1035
|
+
if (results.length === 0) {
|
|
1036
|
+
return textResult('No network logs found for the given filters.');
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
return textResult(JSON.stringify(results, null, 2));
|
|
1040
|
+
}
|
|
1041
|
+
|
|
620
1042
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
621
1043
|
|
|
622
1044
|
export function textResult(text) {
|
|
@@ -648,8 +1070,16 @@ export async function dispatchTool(name, args = {}) {
|
|
|
648
1070
|
return await handleDashboardStop();
|
|
649
1071
|
case 'e2e_issue':
|
|
650
1072
|
return await handleIssue(args);
|
|
1073
|
+
case 'e2e_create_module':
|
|
1074
|
+
return await handleCreateModule(args);
|
|
651
1075
|
case 'e2e_capture':
|
|
652
1076
|
return await handleCapture(args);
|
|
1077
|
+
case 'e2e_learnings':
|
|
1078
|
+
return await handleLearnings(args);
|
|
1079
|
+
case 'e2e_neo4j':
|
|
1080
|
+
return await handleNeo4j(args);
|
|
1081
|
+
case 'e2e_network_logs':
|
|
1082
|
+
return await handleNetworkLogs(args);
|
|
653
1083
|
default:
|
|
654
1084
|
return errorResult(`Unknown tool: ${name}`);
|
|
655
1085
|
}
|