@matware/e2e-runner 1.0.3 → 1.1.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/README.md +150 -8
- package/bin/cli.js +242 -2
- package/package.json +6 -3
- package/src/actions.js +28 -4
- package/src/ai-generate.js +216 -0
- package/src/config.js +44 -0
- package/src/dashboard.js +559 -0
- package/src/db.js +387 -0
- package/src/index.js +5 -1
- package/src/issues.js +152 -0
- package/src/mcp-server.js +8 -337
- package/src/mcp-tools.js +656 -0
- package/src/reporter.js +85 -2
- package/src/runner.js +119 -9
- package/src/verify.js +65 -0
- package/src/websocket.js +177 -0
- package/templates/dashboard.html +1281 -0
- package/templates/e2e.config.js +3 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI Test Generation — builds prompts and optionally calls Claude API
|
|
3
|
+
*
|
|
4
|
+
* Two modes:
|
|
5
|
+
* 1. buildPrompt() — Returns issue data + prompt for Claude Code (MCP mode, no API key)
|
|
6
|
+
* 2. generateTests() — Calls Claude API directly (CLI automation, requires ANTHROPIC_API_KEY)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { listSuites } from './runner.js';
|
|
12
|
+
|
|
13
|
+
const SYSTEM_PROMPT = `You are an E2E test generator for a JSON-driven browser test runner.
|
|
14
|
+
|
|
15
|
+
You output ONLY valid JSON — no markdown fences, no explanation, no comments.
|
|
16
|
+
|
|
17
|
+
The test format is:
|
|
18
|
+
[
|
|
19
|
+
{
|
|
20
|
+
"name": "descriptive-test-name",
|
|
21
|
+
"actions": [
|
|
22
|
+
{ "type": "goto", "value": "/path" },
|
|
23
|
+
{ "type": "click", "selector": "#btn" },
|
|
24
|
+
{ "type": "click", "text": "Button Label" },
|
|
25
|
+
{ "type": "type", "selector": "input[name=email]", "value": "user@example.com" },
|
|
26
|
+
{ "type": "wait", "selector": ".loaded" },
|
|
27
|
+
{ "type": "wait", "text": "Expected text" },
|
|
28
|
+
{ "type": "wait", "value": "2000" },
|
|
29
|
+
{ "type": "assert_text", "text": "Expected text on page" },
|
|
30
|
+
{ "type": "assert_url", "value": "/expected-path" },
|
|
31
|
+
{ "type": "assert_visible", "selector": ".element" },
|
|
32
|
+
{ "type": "assert_count", "selector": ".items", "value": "5" },
|
|
33
|
+
{ "type": "screenshot", "value": "step-name.png" },
|
|
34
|
+
{ "type": "select", "selector": "select#role", "value": "admin" },
|
|
35
|
+
{ "type": "clear", "selector": "input" },
|
|
36
|
+
{ "type": "press", "value": "Enter" },
|
|
37
|
+
{ "type": "scroll", "selector": ".target" },
|
|
38
|
+
{ "type": "hover", "selector": ".menu" },
|
|
39
|
+
{ "type": "evaluate", "value": "document.title" }
|
|
40
|
+
]
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
Rules:
|
|
45
|
+
- Output a JSON array of test objects
|
|
46
|
+
- Use only the action types listed above
|
|
47
|
+
- "click" with "text" (no selector) finds buttons/links by visible text
|
|
48
|
+
- "goto" values starting with "/" are relative to the app's base URL
|
|
49
|
+
- Include a screenshot action before key assertions for debugging
|
|
50
|
+
- For bug reports: write tests that assert the CORRECT behavior. If the test fails, the bug is confirmed
|
|
51
|
+
- Keep test names descriptive and kebab-case
|
|
52
|
+
- Prefer CSS selectors that are stable (data-testid, name, role) over fragile ones (nth-child, classes)
|
|
53
|
+
- If the issue description is vague, create a reasonable test that covers the described scenario
|
|
54
|
+
- If project context is provided (from CLAUDE.md), use the REAL routes, selectors, and UI patterns described there — never invent routes or selectors`;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Reads the project's CLAUDE.md for app context (routes, selectors, UI structure).
|
|
58
|
+
* Returns the content or empty string if not found.
|
|
59
|
+
*/
|
|
60
|
+
function loadProjectContext(cwd) {
|
|
61
|
+
if (!cwd) return '';
|
|
62
|
+
const claudeMdPath = path.join(cwd, 'CLAUDE.md');
|
|
63
|
+
if (!fs.existsSync(claudeMdPath)) return '';
|
|
64
|
+
try {
|
|
65
|
+
return fs.readFileSync(claudeMdPath, 'utf-8');
|
|
66
|
+
} catch {
|
|
67
|
+
return '';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Returns a structured prompt + issue data for Claude Code to consume.
|
|
73
|
+
* Claude Code uses its own intelligence to create tests via e2e_create_test.
|
|
74
|
+
* No API key needed.
|
|
75
|
+
*
|
|
76
|
+
* @param {object} issue - Normalized issue from fetchIssue()
|
|
77
|
+
* @param {object} config - Loaded config
|
|
78
|
+
* @returns {object}
|
|
79
|
+
*/
|
|
80
|
+
export function buildPrompt(issue, config) {
|
|
81
|
+
let existingSuites = [];
|
|
82
|
+
try {
|
|
83
|
+
existingSuites = listSuites(config.testsDir).map(s => s.name);
|
|
84
|
+
} catch { /* no suites yet */ }
|
|
85
|
+
|
|
86
|
+
const projectContext = loadProjectContext(config._cwd);
|
|
87
|
+
const contextBlock = projectContext
|
|
88
|
+
? `\n## Project Context (from CLAUDE.md)\nUse these REAL routes, selectors, and UI patterns — do NOT invent your own.\n\n${projectContext}\n`
|
|
89
|
+
: '';
|
|
90
|
+
|
|
91
|
+
const prompt = `Based on the following issue, generate E2E test actions using the e2e_create_test tool.
|
|
92
|
+
|
|
93
|
+
## Issue: ${issue.title}
|
|
94
|
+
**Repo:** ${issue.repo}
|
|
95
|
+
**Labels:** ${issue.labels.join(', ') || 'none'}
|
|
96
|
+
**State:** ${issue.state}
|
|
97
|
+
**URL:** ${issue.url}
|
|
98
|
+
|
|
99
|
+
### Description
|
|
100
|
+
${issue.body || 'No description provided.'}
|
|
101
|
+
${contextBlock}
|
|
102
|
+
## Instructions
|
|
103
|
+
1. Analyze the issue and determine what user flows to test
|
|
104
|
+
2. Create one or more tests that verify the expected behavior
|
|
105
|
+
3. For bug reports: assert the CORRECT behavior (test failure = bug confirmed)
|
|
106
|
+
4. Use the \`e2e_create_test\` tool with suite name \`issue-${issue.number}\`
|
|
107
|
+
5. After creating the test, use \`e2e_run\` with suite \`issue-${issue.number}\` to execute it
|
|
108
|
+
|
|
109
|
+
Base URL: ${config.baseUrl}
|
|
110
|
+
Existing suites: ${existingSuites.join(', ') || 'none'}`;
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
issue: {
|
|
114
|
+
title: issue.title,
|
|
115
|
+
body: issue.body,
|
|
116
|
+
labels: issue.labels,
|
|
117
|
+
url: issue.url,
|
|
118
|
+
number: issue.number,
|
|
119
|
+
repo: issue.repo,
|
|
120
|
+
state: issue.state,
|
|
121
|
+
},
|
|
122
|
+
baseUrl: config.baseUrl,
|
|
123
|
+
prompt,
|
|
124
|
+
existingSuites,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Checks if the Anthropic API key is available.
|
|
130
|
+
* @returns {boolean}
|
|
131
|
+
*/
|
|
132
|
+
export function hasApiKey(config = {}) {
|
|
133
|
+
return !!(config.anthropicApiKey || process.env.ANTHROPIC_API_KEY);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Calls Claude API directly to generate E2E tests from an issue.
|
|
138
|
+
* Requires ANTHROPIC_API_KEY env var or config.anthropicApiKey.
|
|
139
|
+
*
|
|
140
|
+
* @param {object} issue - Normalized issue from fetchIssue()
|
|
141
|
+
* @param {object} config - Loaded config
|
|
142
|
+
* @returns {Promise<{ tests: object[], suiteName: string }>}
|
|
143
|
+
*/
|
|
144
|
+
export async function generateTests(issue, config) {
|
|
145
|
+
const apiKey = config.anthropicApiKey || process.env.ANTHROPIC_API_KEY;
|
|
146
|
+
if (!apiKey) {
|
|
147
|
+
throw new Error('ANTHROPIC_API_KEY is required for test generation. Set it as an environment variable or in config.');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const model = config.anthropicModel || 'claude-sonnet-4-5-20250929';
|
|
151
|
+
const suiteName = `issue-${issue.number}`;
|
|
152
|
+
|
|
153
|
+
const projectContext = loadProjectContext(config._cwd);
|
|
154
|
+
const contextBlock = projectContext
|
|
155
|
+
? `\n## Project Context (from CLAUDE.md)\nIMPORTANT: Use these REAL routes, selectors, and UI patterns — do NOT invent your own.\n\n${projectContext}\n`
|
|
156
|
+
: '';
|
|
157
|
+
|
|
158
|
+
const userMessage = `Generate E2E tests for this issue:
|
|
159
|
+
|
|
160
|
+
Title: ${issue.title}
|
|
161
|
+
Repo: ${issue.repo}
|
|
162
|
+
Labels: ${issue.labels.join(', ') || 'none'}
|
|
163
|
+
State: ${issue.state}
|
|
164
|
+
|
|
165
|
+
Description:
|
|
166
|
+
${issue.body || 'No description provided.'}
|
|
167
|
+
${contextBlock}
|
|
168
|
+
Base URL: ${config.baseUrl}
|
|
169
|
+
|
|
170
|
+
Output a JSON array of test objects. Nothing else.`;
|
|
171
|
+
|
|
172
|
+
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
173
|
+
method: 'POST',
|
|
174
|
+
headers: {
|
|
175
|
+
'Content-Type': 'application/json',
|
|
176
|
+
'x-api-key': apiKey,
|
|
177
|
+
'anthropic-version': '2023-06-01',
|
|
178
|
+
},
|
|
179
|
+
body: JSON.stringify({
|
|
180
|
+
model,
|
|
181
|
+
max_tokens: 16384,
|
|
182
|
+
system: SYSTEM_PROMPT,
|
|
183
|
+
messages: [{ role: 'user', content: userMessage }],
|
|
184
|
+
}),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
if (!response.ok) {
|
|
188
|
+
const body = await response.text();
|
|
189
|
+
throw new Error(`Claude API error (${response.status}): ${body}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const result = await response.json();
|
|
193
|
+
const text = result.content?.[0]?.text;
|
|
194
|
+
if (!text) {
|
|
195
|
+
throw new Error('Claude API returned empty response');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (result.stop_reason === 'max_tokens') {
|
|
199
|
+
throw new Error(`Claude API response was truncated (hit max_tokens). The issue may be too complex. Try simplifying the issue description or increasing anthropicMaxTokens.`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Parse JSON — strip markdown fences if present
|
|
203
|
+
const cleaned = text.replace(/^```(?:json)?\s*\n?/m, '').replace(/\n?```\s*$/m, '').trim();
|
|
204
|
+
let tests;
|
|
205
|
+
try {
|
|
206
|
+
tests = JSON.parse(cleaned);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
throw new Error(`Failed to parse generated tests as JSON: ${err.message}\n\nRaw output:\n${text}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!Array.isArray(tests)) {
|
|
212
|
+
throw new Error('Generated tests must be a JSON array');
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { tests, suiteName };
|
|
216
|
+
}
|
package/src/config.js
CHANGED
|
@@ -30,6 +30,14 @@ const DEFAULTS = {
|
|
|
30
30
|
outputFormat: 'json',
|
|
31
31
|
env: 'default',
|
|
32
32
|
hooks: { beforeAll: [], afterAll: [], beforeEach: [], afterEach: [] },
|
|
33
|
+
dashboardPort: 8484,
|
|
34
|
+
maxHistoryRuns: 100,
|
|
35
|
+
projectName: null,
|
|
36
|
+
failOnNetworkError: false,
|
|
37
|
+
anthropicApiKey: null,
|
|
38
|
+
anthropicModel: 'claude-sonnet-4-5-20250929',
|
|
39
|
+
authToken: null,
|
|
40
|
+
authStorageKey: 'accessToken',
|
|
33
41
|
};
|
|
34
42
|
|
|
35
43
|
function loadEnvVars() {
|
|
@@ -47,6 +55,12 @@ function loadEnvVars() {
|
|
|
47
55
|
if (process.env.TEST_TIMEOUT) env.testTimeout = parseInt(process.env.TEST_TIMEOUT);
|
|
48
56
|
if (process.env.OUTPUT_FORMAT) env.outputFormat = process.env.OUTPUT_FORMAT;
|
|
49
57
|
if (process.env.E2E_ENV) env.env = process.env.E2E_ENV;
|
|
58
|
+
if (process.env.PROJECT_NAME) env.projectName = process.env.PROJECT_NAME;
|
|
59
|
+
if (process.env.FAIL_ON_NETWORK_ERROR) env.failOnNetworkError = process.env.FAIL_ON_NETWORK_ERROR === 'true' || process.env.FAIL_ON_NETWORK_ERROR === '1';
|
|
60
|
+
if (process.env.ANTHROPIC_API_KEY) env.anthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
|
61
|
+
if (process.env.ANTHROPIC_MODEL) env.anthropicModel = process.env.ANTHROPIC_MODEL;
|
|
62
|
+
if (process.env.AUTH_TOKEN) env.authToken = process.env.AUTH_TOKEN;
|
|
63
|
+
if (process.env.AUTH_STORAGE_KEY) env.authStorageKey = process.env.AUTH_STORAGE_KEY;
|
|
50
64
|
return env;
|
|
51
65
|
}
|
|
52
66
|
|
|
@@ -68,8 +82,32 @@ async function loadConfigFile(cwd) {
|
|
|
68
82
|
return {};
|
|
69
83
|
}
|
|
70
84
|
|
|
85
|
+
/** Load .env file from cwd into process.env (no deps, KEY=VALUE format). */
|
|
86
|
+
function loadDotEnv(cwd) {
|
|
87
|
+
const envPath = path.join(cwd, '.env');
|
|
88
|
+
if (!fs.existsSync(envPath)) return;
|
|
89
|
+
const lines = fs.readFileSync(envPath, 'utf-8').split('\n');
|
|
90
|
+
for (const line of lines) {
|
|
91
|
+
const trimmed = line.trim();
|
|
92
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
93
|
+
const eqIdx = trimmed.indexOf('=');
|
|
94
|
+
if (eqIdx === -1) continue;
|
|
95
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
96
|
+
let val = trimmed.slice(eqIdx + 1).trim();
|
|
97
|
+
// Strip surrounding quotes
|
|
98
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
99
|
+
val = val.slice(1, -1);
|
|
100
|
+
}
|
|
101
|
+
// Don't override existing env vars
|
|
102
|
+
if (!(key in process.env)) {
|
|
103
|
+
process.env[key] = val;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
71
108
|
export async function loadConfig(cliArgs = {}, cwd = null) {
|
|
72
109
|
cwd = cwd || process.cwd();
|
|
110
|
+
loadDotEnv(cwd);
|
|
73
111
|
const fileConfig = await loadConfigFile(cwd);
|
|
74
112
|
const envConfig = loadEnvVars();
|
|
75
113
|
|
|
@@ -100,5 +138,11 @@ export async function loadConfig(cliArgs = {}, cwd = null) {
|
|
|
100
138
|
fs.mkdirSync(config.screenshotsDir, { recursive: true });
|
|
101
139
|
}
|
|
102
140
|
|
|
141
|
+
// Stash cwd for project identity (used by db.js)
|
|
142
|
+
config._cwd = cwd;
|
|
143
|
+
if (!config.projectName) {
|
|
144
|
+
config.projectName = path.basename(cwd);
|
|
145
|
+
}
|
|
146
|
+
|
|
103
147
|
return config;
|
|
104
148
|
}
|