@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/ai-generate.js
CHANGED
|
@@ -27,23 +27,74 @@ The test format is:
|
|
|
27
27
|
{ "type": "wait", "text": "Expected text" },
|
|
28
28
|
{ "type": "wait", "value": "2000" },
|
|
29
29
|
{ "type": "assert_text", "text": "Expected text on page" },
|
|
30
|
+
{ "type": "assert_element_text", "selector": "#title", "text": "Dashboard" },
|
|
31
|
+
{ "type": "assert_element_text", "selector": "#title", "text": "Dashboard", "value": "exact" },
|
|
32
|
+
{ "type": "assert_attribute", "selector": "input#email", "value": "type=email" },
|
|
33
|
+
{ "type": "assert_attribute", "selector": "button", "value": "disabled" },
|
|
34
|
+
{ "type": "assert_class", "selector": ".nav-item", "value": "active" },
|
|
35
|
+
{ "type": "assert_not_visible", "selector": ".error-banner" },
|
|
36
|
+
{ "type": "assert_input_value", "selector": "#email", "value": "user@example.com" },
|
|
37
|
+
{ "type": "assert_matches", "selector": ".phone", "value": "\\\\d{3}-\\\\d{3}-\\\\d{4}" },
|
|
30
38
|
{ "type": "assert_url", "value": "/expected-path" },
|
|
31
39
|
{ "type": "assert_visible", "selector": ".element" },
|
|
32
40
|
{ "type": "assert_count", "selector": ".items", "value": "5" },
|
|
41
|
+
{ "type": "assert_count", "selector": ".rows", "value": ">3" },
|
|
42
|
+
{ "type": "assert_count", "selector": ".errors", "value": "0" },
|
|
43
|
+
{ "type": "get_text", "selector": "#patient-name" },
|
|
33
44
|
{ "type": "screenshot", "value": "step-name.png" },
|
|
34
45
|
{ "type": "select", "selector": "select#role", "value": "admin" },
|
|
35
46
|
{ "type": "clear", "selector": "input" },
|
|
36
47
|
{ "type": "press", "value": "Enter" },
|
|
37
48
|
{ "type": "scroll", "selector": ".target" },
|
|
38
49
|
{ "type": "hover", "selector": ".menu" },
|
|
39
|
-
{ "type": "evaluate", "value": "document.title" }
|
|
50
|
+
{ "type": "evaluate", "value": "document.title" },
|
|
51
|
+
{ "type": "type_react", "selector": "input#search", "value": "search term" },
|
|
52
|
+
{ "type": "click_regex", "text": "submit order", "selector": "button", "value": "last" },
|
|
53
|
+
{ "type": "click_option", "text": "Option Label" },
|
|
54
|
+
{ "type": "focus_autocomplete", "text": "Search by label" },
|
|
55
|
+
{ "type": "click_chip", "text": "Tag Name" }
|
|
40
56
|
]
|
|
41
57
|
}
|
|
42
58
|
]
|
|
43
59
|
|
|
60
|
+
Framework-aware action reference (prefer these over evaluate for React/MUI apps):
|
|
61
|
+
- type_react: types into React controlled inputs using native value setter + input/change events (works with both input and textarea)
|
|
62
|
+
- click_regex: click element by regex text match (case-insensitive). Use "value": "last" for last match. Optional "selector" scopes the search
|
|
63
|
+
- click_option: click a [role="option"] element by text — for autocomplete/select dropdowns
|
|
64
|
+
- focus_autocomplete: focus an autocomplete input by its label text (supports MUI .MuiAutocomplete-root and [role="combobox"])
|
|
65
|
+
- click_chip: click a chip/tag element by text (searches [class*="Chip"], [data-chip])
|
|
66
|
+
|
|
67
|
+
Assertion action reference:
|
|
68
|
+
- assert_text: checks if text appears anywhere in the page body
|
|
69
|
+
- assert_element_text: checks textContent of a specific element (use "value": "exact" for strict match)
|
|
70
|
+
- assert_attribute: checks HTML attributes — "attr=value" for value check, "attr" alone for existence
|
|
71
|
+
- assert_class: checks if element has a CSS class via classList.contains
|
|
72
|
+
- assert_visible / assert_not_visible: checks element visibility (display, visibility, opacity)
|
|
73
|
+
- assert_input_value: checks the .value of input/select/textarea elements
|
|
74
|
+
- assert_matches: checks element textContent against a regex pattern
|
|
75
|
+
- assert_count: counts matching elements — exact number or operators (">3", ">=1", "<10", "<=5")
|
|
76
|
+
- assert_url: checks if current URL contains the value
|
|
77
|
+
- get_text: extracts element text (non-assertion, returns { value })
|
|
78
|
+
|
|
79
|
+
Reusable modules:
|
|
80
|
+
- Tests can reference shared action sequences: { "$use": "module-name", "params": { "key": "value" } }
|
|
81
|
+
- Use modules for repeated flows like login, navigation, or setup
|
|
82
|
+
|
|
44
83
|
Rules:
|
|
45
84
|
- Output a JSON array of test objects
|
|
46
|
-
-
|
|
85
|
+
- NEVER use evaluate with inline JS for assertions that can be done with native action types:
|
|
86
|
+
* Use assert_element_text instead of evaluate to check element textContent
|
|
87
|
+
* Use assert_attribute instead of evaluate to check HTML attributes
|
|
88
|
+
* Use assert_class instead of evaluate to check CSS classes
|
|
89
|
+
* Use assert_input_value instead of evaluate to check input/select/textarea values
|
|
90
|
+
* Use assert_matches instead of evaluate for regex text matching
|
|
91
|
+
* Use assert_not_visible instead of evaluate to verify elements are hidden
|
|
92
|
+
* Use type_react instead of evaluate with native value setter for React controlled inputs
|
|
93
|
+
* Use click_regex instead of evaluate with Array.from(querySelectorAll).filter(regex) patterns
|
|
94
|
+
* Use click_option instead of evaluate with querySelectorAll('[role="option"]') patterns
|
|
95
|
+
* Use focus_autocomplete instead of evaluate with MuiAutocomplete-root label search patterns
|
|
96
|
+
* Use click_chip instead of evaluate with querySelectorAll('[class*="Chip"]') patterns
|
|
97
|
+
* Reserve evaluate ONLY for complex logic that cannot be expressed with existing action types
|
|
47
98
|
- "click" with "text" (no selector) finds buttons/links by visible text
|
|
48
99
|
- "goto" values starting with "/" are relative to the app's base URL
|
|
49
100
|
- Include a screenshot action before key assertions for debugging
|
|
@@ -53,6 +104,27 @@ Rules:
|
|
|
53
104
|
- If the issue description is vague, create a reasonable test that covers the described scenario
|
|
54
105
|
- If project context is provided (from CLAUDE.md), use the REAL routes, selectors, and UI patterns described there — never invent routes or selectors`;
|
|
55
106
|
|
|
107
|
+
const E2E_RULES = `
|
|
108
|
+
CRITICAL — UI-first testing rules:
|
|
109
|
+
- Every test MUST start with a "goto" action to navigate to a real page
|
|
110
|
+
- Tests MUST interact with UI elements: click, type, select, hover, scroll
|
|
111
|
+
- Verify results through visible page state: assert_text, assert_visible, assert_element_text, assert_url
|
|
112
|
+
- NEVER use evaluate to call APIs directly (no fetch, no GraphQL, no XHR, no window.__e2e.gql)
|
|
113
|
+
- NEVER use evaluate to set up or verify data — use the UI workflow instead
|
|
114
|
+
- Test the user journey as a real person would use the application
|
|
115
|
+
- Include screenshot actions before key assertions for debugging
|
|
116
|
+
`;
|
|
117
|
+
|
|
118
|
+
const API_RULES = `
|
|
119
|
+
API testing rules:
|
|
120
|
+
- Tests verify backend API behavior directly via evaluate actions
|
|
121
|
+
- Each test should: set up context → call API → assert response shape and values
|
|
122
|
+
- Use evaluate for GraphQL mutations, queries, and REST calls
|
|
123
|
+
- Name tests clearly describing the API operation (e.g. "createUser-returns-new-user")
|
|
124
|
+
- Include error case tests (invalid input, missing fields, auth failures)
|
|
125
|
+
- No need for goto/click/type — this is not UI testing
|
|
126
|
+
`;
|
|
127
|
+
|
|
56
128
|
/**
|
|
57
129
|
* Reads the project's CLAUDE.md for app context (routes, selectors, UI structure).
|
|
58
130
|
* Returns the content or empty string if not found.
|
|
@@ -77,7 +149,7 @@ function loadProjectContext(cwd) {
|
|
|
77
149
|
* @param {object} config - Loaded config
|
|
78
150
|
* @returns {object}
|
|
79
151
|
*/
|
|
80
|
-
export function buildPrompt(issue, config) {
|
|
152
|
+
export function buildPrompt(issue, config, testType = 'e2e') {
|
|
81
153
|
let existingSuites = [];
|
|
82
154
|
try {
|
|
83
155
|
existingSuites = listSuites(config.testsDir).map(s => s.name);
|
|
@@ -88,7 +160,9 @@ export function buildPrompt(issue, config) {
|
|
|
88
160
|
? `\n## Project Context (from CLAUDE.md)\nUse these REAL routes, selectors, and UI patterns — do NOT invent your own.\n\n${projectContext}\n`
|
|
89
161
|
: '';
|
|
90
162
|
|
|
91
|
-
const
|
|
163
|
+
const categoryRules = testType === 'api' ? API_RULES : E2E_RULES;
|
|
164
|
+
|
|
165
|
+
const prompt = `Based on the following issue, generate ${testType === 'api' ? 'API' : 'E2E'} test actions using the e2e_create_test tool.
|
|
92
166
|
|
|
93
167
|
## Issue: ${issue.title}
|
|
94
168
|
**Repo:** ${issue.repo}
|
|
@@ -99,8 +173,10 @@ export function buildPrompt(issue, config) {
|
|
|
99
173
|
### Description
|
|
100
174
|
${issue.body || 'No description provided.'}
|
|
101
175
|
${contextBlock}
|
|
176
|
+
## Test Category: ${testType}
|
|
177
|
+
${categoryRules}
|
|
102
178
|
## Instructions
|
|
103
|
-
1. Analyze the issue and determine what user flows to test
|
|
179
|
+
1. Analyze the issue and determine what ${testType === 'api' ? 'API operations' : 'user flows'} to test
|
|
104
180
|
2. Create one or more tests that verify the expected behavior
|
|
105
181
|
3. For bug reports: assert the CORRECT behavior (test failure = bug confirmed)
|
|
106
182
|
4. Use the \`e2e_create_test\` tool with suite name \`issue-${issue.number}\`
|
|
@@ -141,7 +217,7 @@ export function hasApiKey(config = {}) {
|
|
|
141
217
|
* @param {object} config - Loaded config
|
|
142
218
|
* @returns {Promise<{ tests: object[], suiteName: string }>}
|
|
143
219
|
*/
|
|
144
|
-
export async function generateTests(issue, config) {
|
|
220
|
+
export async function generateTests(issue, config, testType = 'e2e') {
|
|
145
221
|
const apiKey = config.anthropicApiKey || process.env.ANTHROPIC_API_KEY;
|
|
146
222
|
if (!apiKey) {
|
|
147
223
|
throw new Error('ANTHROPIC_API_KEY is required for test generation. Set it as an environment variable or in config.');
|
|
@@ -155,7 +231,9 @@ export async function generateTests(issue, config) {
|
|
|
155
231
|
? `\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
232
|
: '';
|
|
157
233
|
|
|
158
|
-
const
|
|
234
|
+
const categoryRules = testType === 'api' ? API_RULES : E2E_RULES;
|
|
235
|
+
|
|
236
|
+
const userMessage = `Generate ${testType === 'api' ? 'API' : 'E2E'} tests for this issue:
|
|
159
237
|
|
|
160
238
|
Title: ${issue.title}
|
|
161
239
|
Repo: ${issue.repo}
|
|
@@ -165,6 +243,8 @@ State: ${issue.state}
|
|
|
165
243
|
Description:
|
|
166
244
|
${issue.body || 'No description provided.'}
|
|
167
245
|
${contextBlock}
|
|
246
|
+
Test Category: ${testType}
|
|
247
|
+
${categoryRules}
|
|
168
248
|
Base URL: ${config.baseUrl}
|
|
169
249
|
|
|
170
250
|
Output a JSON array of test objects. Nothing else.`;
|
package/src/config.js
CHANGED
|
@@ -16,6 +16,7 @@ const DEFAULTS = {
|
|
|
16
16
|
baseUrl: 'http://host.docker.internal:3000',
|
|
17
17
|
poolUrl: 'ws://localhost:3333',
|
|
18
18
|
testsDir: 'e2e/tests',
|
|
19
|
+
modulesDir: 'e2e/modules',
|
|
19
20
|
screenshotsDir: 'e2e/screenshots',
|
|
20
21
|
concurrency: 3,
|
|
21
22
|
viewport: { width: 1280, height: 720 },
|
|
@@ -33,11 +34,23 @@ const DEFAULTS = {
|
|
|
33
34
|
dashboardPort: 8484,
|
|
34
35
|
maxHistoryRuns: 100,
|
|
35
36
|
projectName: null,
|
|
37
|
+
exclude: [],
|
|
36
38
|
failOnNetworkError: false,
|
|
39
|
+
actionRetries: 0,
|
|
40
|
+
actionRetryDelay: 500,
|
|
37
41
|
anthropicApiKey: null,
|
|
38
42
|
anthropicModel: 'claude-sonnet-4-5-20250929',
|
|
39
43
|
authToken: null,
|
|
40
44
|
authStorageKey: 'accessToken',
|
|
45
|
+
learningsEnabled: true,
|
|
46
|
+
learningsMarkdown: true,
|
|
47
|
+
learningsNeo4j: false,
|
|
48
|
+
learningsDays: 30,
|
|
49
|
+
neo4jBoltUrl: 'bolt://localhost:7687',
|
|
50
|
+
neo4jUser: 'neo4j',
|
|
51
|
+
neo4jPassword: 'e2erunner',
|
|
52
|
+
neo4jBoltPort: 7687,
|
|
53
|
+
neo4jHttpPort: 7474,
|
|
41
54
|
};
|
|
42
55
|
|
|
43
56
|
function loadEnvVars() {
|
|
@@ -45,6 +58,7 @@ function loadEnvVars() {
|
|
|
45
58
|
if (process.env.BASE_URL) env.baseUrl = process.env.BASE_URL;
|
|
46
59
|
if (process.env.CHROME_POOL_URL) env.poolUrl = process.env.CHROME_POOL_URL;
|
|
47
60
|
if (process.env.TESTS_DIR) env.testsDir = process.env.TESTS_DIR;
|
|
61
|
+
if (process.env.MODULES_DIR) env.modulesDir = process.env.MODULES_DIR;
|
|
48
62
|
if (process.env.SCREENSHOTS_DIR) env.screenshotsDir = process.env.SCREENSHOTS_DIR;
|
|
49
63
|
if (process.env.CONCURRENCY) env.concurrency = parseInt(process.env.CONCURRENCY);
|
|
50
64
|
if (process.env.DEFAULT_TIMEOUT) env.defaultTimeout = parseInt(process.env.DEFAULT_TIMEOUT);
|
|
@@ -57,10 +71,21 @@ function loadEnvVars() {
|
|
|
57
71
|
if (process.env.E2E_ENV) env.env = process.env.E2E_ENV;
|
|
58
72
|
if (process.env.PROJECT_NAME) env.projectName = process.env.PROJECT_NAME;
|
|
59
73
|
if (process.env.FAIL_ON_NETWORK_ERROR) env.failOnNetworkError = process.env.FAIL_ON_NETWORK_ERROR === 'true' || process.env.FAIL_ON_NETWORK_ERROR === '1';
|
|
74
|
+
if (process.env.ACTION_RETRIES) env.actionRetries = parseInt(process.env.ACTION_RETRIES);
|
|
75
|
+
if (process.env.ACTION_RETRY_DELAY) env.actionRetryDelay = parseInt(process.env.ACTION_RETRY_DELAY);
|
|
60
76
|
if (process.env.ANTHROPIC_API_KEY) env.anthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
|
61
77
|
if (process.env.ANTHROPIC_MODEL) env.anthropicModel = process.env.ANTHROPIC_MODEL;
|
|
62
78
|
if (process.env.AUTH_TOKEN) env.authToken = process.env.AUTH_TOKEN;
|
|
63
79
|
if (process.env.AUTH_STORAGE_KEY) env.authStorageKey = process.env.AUTH_STORAGE_KEY;
|
|
80
|
+
if (process.env.LEARNINGS_ENABLED) env.learningsEnabled = process.env.LEARNINGS_ENABLED !== 'false' && process.env.LEARNINGS_ENABLED !== '0';
|
|
81
|
+
if (process.env.LEARNINGS_MARKDOWN) env.learningsMarkdown = process.env.LEARNINGS_MARKDOWN !== 'false' && process.env.LEARNINGS_MARKDOWN !== '0';
|
|
82
|
+
if (process.env.LEARNINGS_NEO4J) env.learningsNeo4j = process.env.LEARNINGS_NEO4J === 'true' || process.env.LEARNINGS_NEO4J === '1';
|
|
83
|
+
if (process.env.LEARNINGS_DAYS) env.learningsDays = parseInt(process.env.LEARNINGS_DAYS);
|
|
84
|
+
if (process.env.NEO4J_BOLT_URL) env.neo4jBoltUrl = process.env.NEO4J_BOLT_URL;
|
|
85
|
+
if (process.env.NEO4J_USER) env.neo4jUser = process.env.NEO4J_USER;
|
|
86
|
+
if (process.env.NEO4J_PASSWORD) env.neo4jPassword = process.env.NEO4J_PASSWORD;
|
|
87
|
+
if (process.env.NEO4J_BOLT_PORT) env.neo4jBoltPort = parseInt(process.env.NEO4J_BOLT_PORT);
|
|
88
|
+
if (process.env.NEO4J_HTTP_PORT) env.neo4jHttpPort = parseInt(process.env.NEO4J_HTTP_PORT);
|
|
64
89
|
return env;
|
|
65
90
|
}
|
|
66
91
|
|
|
@@ -129,6 +154,9 @@ export async function loadConfig(cliArgs = {}, cwd = null) {
|
|
|
129
154
|
if (!path.isAbsolute(config.testsDir)) {
|
|
130
155
|
config.testsDir = path.join(cwd, config.testsDir);
|
|
131
156
|
}
|
|
157
|
+
if (config.modulesDir && !path.isAbsolute(config.modulesDir)) {
|
|
158
|
+
config.modulesDir = path.join(cwd, config.modulesDir);
|
|
159
|
+
}
|
|
132
160
|
if (!path.isAbsolute(config.screenshotsDir)) {
|
|
133
161
|
config.screenshotsDir = path.join(cwd, config.screenshotsDir);
|
|
134
162
|
}
|
package/src/dashboard.js
CHANGED
|
@@ -17,9 +17,10 @@ import { createWebSocketServer } from './websocket.js';
|
|
|
17
17
|
import { getPoolStatus, waitForPool } from './pool.js';
|
|
18
18
|
import { runTestsParallel, loadAllSuites, loadTestSuite, listSuites } from './runner.js';
|
|
19
19
|
import { generateReport, generateJUnitXML, saveReport, persistRun, loadHistory, loadHistoryRun } from './reporter.js';
|
|
20
|
-
import { listProjects as dbListProjects, getProjectRuns as dbGetProjectRuns, getRunDetail as dbGetRunDetail, getAllRuns as dbGetAllRuns, getRunCount as dbGetRunCount, getProjectScreenshotsDir as dbGetProjectScreenshotsDir, getProjectTestsDir as dbGetProjectTestsDir, getProjectCwd as dbGetProjectCwd, lookupScreenshotHash as dbLookupScreenshotHash, closeDb } from './db.js';
|
|
20
|
+
import { listProjects as dbListProjects, getProjectRuns as dbGetProjectRuns, getRunDetail as dbGetRunDetail, getAllRuns as dbGetAllRuns, getRunCount as dbGetRunCount, getProjectScreenshotsDir as dbGetProjectScreenshotsDir, getProjectTestsDir as dbGetProjectTestsDir, getProjectCwd as dbGetProjectCwd, lookupScreenshotHash as dbLookupScreenshotHash, ensureProject as dbEnsureProject, getNetworkLogs as dbGetNetworkLogs, closeDb } from './db.js';
|
|
21
21
|
import { loadConfig } from './config.js';
|
|
22
22
|
import { log, colors as C } from './logger.js';
|
|
23
|
+
import { getLearningsSummary, getFlakySummary, getSelectorStability, getPageHealth, getApiHealth, getErrorPatterns, getTestTrends } from './learner-sqlite.js';
|
|
23
24
|
|
|
24
25
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
25
26
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
@@ -204,6 +205,27 @@ export async function startDashboard(config) {
|
|
|
204
205
|
return;
|
|
205
206
|
}
|
|
206
207
|
|
|
208
|
+
// API: DB — network logs for a run (filterable)
|
|
209
|
+
const networkLogsMatch = pathname.match(/^\/api\/db\/runs\/(\d+)\/network-logs$/);
|
|
210
|
+
if (networkLogsMatch) {
|
|
211
|
+
try {
|
|
212
|
+
const runDbId = parseInt(networkLogsMatch[1], 10);
|
|
213
|
+
const filters = {};
|
|
214
|
+
if (url.searchParams.has('testName')) filters.testName = url.searchParams.get('testName');
|
|
215
|
+
if (url.searchParams.has('method')) filters.method = url.searchParams.get('method');
|
|
216
|
+
if (url.searchParams.has('statusMin')) filters.statusMin = parseInt(url.searchParams.get('statusMin'), 10);
|
|
217
|
+
if (url.searchParams.has('statusMax')) filters.statusMax = parseInt(url.searchParams.get('statusMax'), 10);
|
|
218
|
+
if (url.searchParams.has('urlPattern')) filters.urlPattern = url.searchParams.get('urlPattern');
|
|
219
|
+
if (url.searchParams.get('errorsOnly') === 'true') filters.errorsOnly = true;
|
|
220
|
+
if (url.searchParams.get('includeHeaders') === 'true') filters.includeHeaders = true;
|
|
221
|
+
if (url.searchParams.get('includeBodies') === 'true') filters.includeBodies = true;
|
|
222
|
+
jsonResponse(res, dbGetNetworkLogs(runDbId, filters));
|
|
223
|
+
} catch (error) {
|
|
224
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
225
|
+
}
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
207
229
|
// API: DB — project screenshots list
|
|
208
230
|
const projectScreenshotsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/screenshots$/);
|
|
209
231
|
if (projectScreenshotsMatch) {
|
|
@@ -239,6 +261,124 @@ export async function startDashboard(config) {
|
|
|
239
261
|
return;
|
|
240
262
|
}
|
|
241
263
|
|
|
264
|
+
// API: DB — suite detail (tests + actions)
|
|
265
|
+
const suiteDetailMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/suites\/(.+)$/);
|
|
266
|
+
if (suiteDetailMatch) {
|
|
267
|
+
try {
|
|
268
|
+
const projectId = parseInt(suiteDetailMatch[1], 10);
|
|
269
|
+
const suiteName = decodeURIComponent(suiteDetailMatch[2]);
|
|
270
|
+
const dir = dbGetProjectTestsDir(projectId);
|
|
271
|
+
if (!dir || !fs.existsSync(dir)) {
|
|
272
|
+
jsonResponse(res, { error: 'Tests directory not found' }, 404);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const { tests, hooks } = loadTestSuite(suiteName, dir);
|
|
276
|
+
jsonResponse(res, { name: suiteName, tests, hooks });
|
|
277
|
+
} catch (error) {
|
|
278
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
279
|
+
}
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// API: DB — cross-project learnings (when no project selected)
|
|
284
|
+
const crossLearningsMatch = pathname.match(/^\/api\/db\/learnings(?:\/(\w+))?$/);
|
|
285
|
+
if (crossLearningsMatch) {
|
|
286
|
+
try {
|
|
287
|
+
const category = crossLearningsMatch[1] || 'summary';
|
|
288
|
+
const days = parseInt(url.searchParams.get('days') || '30', 10);
|
|
289
|
+
let data;
|
|
290
|
+
switch (category) {
|
|
291
|
+
case 'summary': {
|
|
292
|
+
const summary = getLearningsSummary(null);
|
|
293
|
+
const trends = getTestTrends(null, 7);
|
|
294
|
+
data = { ...summary, recentTrend: trends };
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
default:
|
|
298
|
+
jsonResponse(res, { error: `Unknown learnings category: ${category}` }, 400);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
jsonResponse(res, data);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
304
|
+
}
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// API: DB — project modules list
|
|
309
|
+
const projectModulesMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/modules$/);
|
|
310
|
+
if (projectModulesMatch) {
|
|
311
|
+
try {
|
|
312
|
+
const projectId = parseInt(projectModulesMatch[1], 10);
|
|
313
|
+
const projectCwd = dbGetProjectCwd(projectId);
|
|
314
|
+
if (!projectCwd) { jsonResponse(res, []); return; }
|
|
315
|
+
const modulesDir = path.join(projectCwd, 'e2e', 'modules');
|
|
316
|
+
if (!fs.existsSync(modulesDir)) { jsonResponse(res, []); return; }
|
|
317
|
+
const files = fs.readdirSync(modulesDir).filter(f => f.endsWith('.json')).sort();
|
|
318
|
+
const modules = files.map(f => {
|
|
319
|
+
try {
|
|
320
|
+
const data = JSON.parse(fs.readFileSync(path.join(modulesDir, f), 'utf-8'));
|
|
321
|
+
return {
|
|
322
|
+
name: f.replace('.json', ''),
|
|
323
|
+
file: f,
|
|
324
|
+
description: data.description || null,
|
|
325
|
+
params: data.params || [],
|
|
326
|
+
actionCount: Array.isArray(data.actions) ? data.actions.length : 0,
|
|
327
|
+
};
|
|
328
|
+
} catch { return { name: f.replace('.json', ''), file: f, description: null, params: [], actionCount: 0 }; }
|
|
329
|
+
});
|
|
330
|
+
jsonResponse(res, modules);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
333
|
+
}
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// API: DB — project learnings (summary or specific category)
|
|
338
|
+
const learningsMatch = pathname.match(/^\/api\/db\/projects\/(\d+)\/learnings(?:\/(\w+))?$/);
|
|
339
|
+
if (learningsMatch) {
|
|
340
|
+
try {
|
|
341
|
+
const projectId = parseInt(learningsMatch[1], 10);
|
|
342
|
+
const category = learningsMatch[2] || 'summary';
|
|
343
|
+
const days = parseInt(url.searchParams.get('days') || '30', 10);
|
|
344
|
+
|
|
345
|
+
let data;
|
|
346
|
+
switch (category) {
|
|
347
|
+
case 'summary': {
|
|
348
|
+
const summary = getLearningsSummary(projectId);
|
|
349
|
+
const trends = getTestTrends(projectId, 7);
|
|
350
|
+
data = { ...summary, recentTrend: trends };
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
case 'flaky':
|
|
354
|
+
data = getFlakySummary(projectId, days);
|
|
355
|
+
break;
|
|
356
|
+
case 'selectors':
|
|
357
|
+
data = getSelectorStability(projectId, days);
|
|
358
|
+
break;
|
|
359
|
+
case 'pages':
|
|
360
|
+
data = getPageHealth(projectId, days);
|
|
361
|
+
break;
|
|
362
|
+
case 'apis':
|
|
363
|
+
data = getApiHealth(projectId, days);
|
|
364
|
+
break;
|
|
365
|
+
case 'errors':
|
|
366
|
+
data = getErrorPatterns(projectId);
|
|
367
|
+
break;
|
|
368
|
+
case 'trends':
|
|
369
|
+
data = getTestTrends(projectId, days);
|
|
370
|
+
break;
|
|
371
|
+
default:
|
|
372
|
+
jsonResponse(res, { error: `Unknown learnings category: ${category}` }, 400);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
jsonResponse(res, data);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
jsonResponse(res, { error: error.message }, 500);
|
|
378
|
+
}
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
242
382
|
// API: serve screenshot by hash (e.g. /api/screenshot-hash/a3f2b1c9)
|
|
243
383
|
const ssHashMatch = pathname.match(/^\/api\/screenshot-hash\/([a-f0-9]{8})$/);
|
|
244
384
|
if (ssHashMatch) {
|
|
@@ -262,7 +402,7 @@ export async function startDashboard(config) {
|
|
|
262
402
|
const ext = path.extname(realPath).toLowerCase();
|
|
263
403
|
const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp' };
|
|
264
404
|
if (!mimeTypes[ext]) { jsonResponse(res, { error: 'Not an image' }, 400); return; }
|
|
265
|
-
res.writeHead(200, { 'Content-Type': mimeTypes[ext] });
|
|
405
|
+
res.writeHead(200, { 'Content-Type': mimeTypes[ext], 'Cache-Control': 'no-store' });
|
|
266
406
|
fs.createReadStream(realPath).pipe(res);
|
|
267
407
|
} catch (error) {
|
|
268
408
|
jsonResponse(res, { error: error.message }, 500);
|
|
@@ -302,7 +442,7 @@ export async function startDashboard(config) {
|
|
|
302
442
|
jsonResponse(res, { error: 'Not an image' }, 400);
|
|
303
443
|
return;
|
|
304
444
|
}
|
|
305
|
-
res.writeHead(200, { 'Content-Type': mimeTypes[ext] });
|
|
445
|
+
res.writeHead(200, { 'Content-Type': mimeTypes[ext], 'Cache-Control': 'no-store' });
|
|
306
446
|
fs.createReadStream(realPath).pipe(res);
|
|
307
447
|
return;
|
|
308
448
|
}
|
|
@@ -347,7 +487,7 @@ export async function startDashboard(config) {
|
|
|
347
487
|
return;
|
|
348
488
|
}
|
|
349
489
|
if (fs.existsSync(resolvedPath)) {
|
|
350
|
-
res.writeHead(200, { 'Content-Type': imageMimeTypes[ext] });
|
|
490
|
+
res.writeHead(200, { 'Content-Type': imageMimeTypes[ext], 'Cache-Control': 'no-store' });
|
|
351
491
|
fs.createReadStream(resolvedPath).pipe(res);
|
|
352
492
|
} else {
|
|
353
493
|
jsonResponse(res, { error: 'Not found' }, 404);
|
|
@@ -504,7 +644,7 @@ export async function startDashboard(config) {
|
|
|
504
644
|
if (params.suite) {
|
|
505
645
|
({ tests, hooks } = loadTestSuite(params.suite, runConfig.testsDir));
|
|
506
646
|
} else {
|
|
507
|
-
({ tests, hooks } = loadAllSuites(runConfig.testsDir));
|
|
647
|
+
({ tests, hooks } = loadAllSuites(runConfig.testsDir, runConfig.modulesDir, runConfig.exclude));
|
|
508
648
|
}
|
|
509
649
|
|
|
510
650
|
await waitForPool(runConfig.poolUrl);
|
|
@@ -521,8 +661,18 @@ export async function startDashboard(config) {
|
|
|
521
661
|
}
|
|
522
662
|
}
|
|
523
663
|
|
|
524
|
-
return new Promise((resolve) => {
|
|
664
|
+
return new Promise((resolve, reject) => {
|
|
525
665
|
const host = config.dashboardHost || '127.0.0.1';
|
|
666
|
+
|
|
667
|
+
server.on('error', (err) => {
|
|
668
|
+
if (err.code === 'EADDRINUSE') {
|
|
669
|
+
log('❌', `${C.red}Port ${port} is already in use. Try a different port with --port <number>.${C.reset}`);
|
|
670
|
+
reject(new Error(`Port ${port} is already in use`));
|
|
671
|
+
} else {
|
|
672
|
+
reject(err);
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
|
|
526
676
|
server.listen(port, host, () => {
|
|
527
677
|
log('🖥️', `${C.bold}Dashboard${C.reset} running at ${C.cyan}http://${host}:${port}${C.reset}`);
|
|
528
678
|
|