@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.
Files changed (39) hide show
  1. package/.claude-plugin/plugin.json +9 -0
  2. package/.mcp.json +9 -0
  3. package/README.md +475 -307
  4. package/agents/test-analyzer.md +81 -0
  5. package/agents/test-creator.md +102 -0
  6. package/agents/test-improver.md +140 -0
  7. package/bin/cli.js +194 -6
  8. package/commands/create-test.md +50 -0
  9. package/commands/run.md +49 -0
  10. package/commands/verify-issue.md +63 -0
  11. package/package.json +10 -2
  12. package/skills/e2e-testing/SKILL.md +166 -0
  13. package/skills/e2e-testing/references/action-types.md +100 -0
  14. package/skills/e2e-testing/references/test-json-format.md +159 -0
  15. package/skills/e2e-testing/references/troubleshooting.md +182 -0
  16. package/src/actions.js +273 -18
  17. package/src/ai-generate.js +87 -7
  18. package/src/config.js +28 -0
  19. package/src/dashboard.js +156 -6
  20. package/src/db.js +207 -13
  21. package/src/index.js +9 -3
  22. package/src/learner-markdown.js +177 -0
  23. package/src/learner-neo4j.js +255 -0
  24. package/src/learner-sqlite.js +354 -0
  25. package/src/learner.js +413 -0
  26. package/src/mcp-tools.js +448 -18
  27. package/src/module-resolver.js +273 -0
  28. package/src/narrate.js +225 -0
  29. package/src/neo4j-pool.js +124 -0
  30. package/src/reporter.js +35 -2
  31. package/src/runner.js +120 -46
  32. package/src/verify.js +5 -3
  33. package/templates/build-dashboard.js +28 -0
  34. package/templates/dashboard/app.js +1152 -0
  35. package/templates/dashboard/styles.css +413 -0
  36. package/templates/dashboard/template.html +201 -0
  37. package/templates/dashboard.html +964 -378
  38. package/templates/docker-compose-neo4j.yml +19 -0
  39. package/templates/e2e.config.js +3 -0
@@ -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
- - Use only the action types listed above
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 prompt = `Based on the following issue, generate E2E test actions using the e2e_create_test tool.
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 userMessage = `Generate E2E tests for this issue:
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