@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.
@@ -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
  }