@matware/e2e-runner 1.1.1 → 1.3.0

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 (89) hide show
  1. package/.claude-plugin/marketplace.json +21 -0
  2. package/.claude-plugin/plugin.json +9 -0
  3. package/.mcp.json +9 -0
  4. package/.opencode/commands/create-test.md +63 -0
  5. package/.opencode/commands/run.md +50 -0
  6. package/.opencode/commands/verify-issue.md +62 -0
  7. package/.opencode/skills/e2e-testing/SKILL.md +181 -0
  8. package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
  9. package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
  10. package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
  11. package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
  12. package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
  13. package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
  14. package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
  15. package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
  16. package/.opencode/skills/e2e-testing/references/variables.md +41 -0
  17. package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
  18. package/OPENCODE.md +166 -0
  19. package/README.md +990 -296
  20. package/agents/test-analyzer.md +81 -0
  21. package/agents/test-creator.md +155 -0
  22. package/agents/test-improver.md +177 -0
  23. package/bin/cli.js +602 -22
  24. package/commands/create-test.md +65 -0
  25. package/commands/run.md +49 -0
  26. package/commands/verify-issue.md +63 -0
  27. package/opencode.json +11 -0
  28. package/package.json +15 -2
  29. package/scripts/setup-opencode.sh +113 -0
  30. package/skills/e2e-testing/SKILL.md +173 -0
  31. package/skills/e2e-testing/references/action-types.md +143 -0
  32. package/skills/e2e-testing/references/auth-strategies.md +91 -0
  33. package/skills/e2e-testing/references/graphql.md +59 -0
  34. package/skills/e2e-testing/references/issue-verification.md +59 -0
  35. package/skills/e2e-testing/references/multi-pool.md +60 -0
  36. package/skills/e2e-testing/references/network-debugging.md +62 -0
  37. package/skills/e2e-testing/references/test-json-format.md +163 -0
  38. package/skills/e2e-testing/references/troubleshooting.md +224 -0
  39. package/skills/e2e-testing/references/variables.md +41 -0
  40. package/skills/e2e-testing/references/visual-verification.md +89 -0
  41. package/src/actions.js +597 -20
  42. package/src/ai-generate.js +142 -12
  43. package/src/config.js +171 -0
  44. package/src/dashboard.js +299 -17
  45. package/src/db.js +335 -13
  46. package/src/index.js +15 -8
  47. package/src/learner-markdown.js +177 -0
  48. package/src/learner-neo4j.js +255 -0
  49. package/src/learner-sqlite.js +658 -0
  50. package/src/learner.js +418 -0
  51. package/src/mcp-tools.js +1558 -50
  52. package/src/module-resolver.js +310 -0
  53. package/src/narrate.js +262 -0
  54. package/src/neo4j-pool.js +124 -0
  55. package/src/pool-manager.js +223 -0
  56. package/src/reporter.js +117 -3
  57. package/src/runner.js +274 -71
  58. package/src/sync/auth.js +354 -0
  59. package/src/sync/client.js +572 -0
  60. package/src/sync/hub-routes.js +816 -0
  61. package/src/sync/index.js +68 -0
  62. package/src/sync/middleware.js +347 -0
  63. package/src/sync/queue.js +209 -0
  64. package/src/sync/schema.js +540 -0
  65. package/src/verify.js +14 -9
  66. package/src/watch.js +384 -0
  67. package/templates/build-dashboard.js +69 -0
  68. package/templates/dashboard/js/api.js +60 -0
  69. package/templates/dashboard/js/init.js +13 -0
  70. package/templates/dashboard/js/keyboard.js +46 -0
  71. package/templates/dashboard/js/state.js +40 -0
  72. package/templates/dashboard/js/toast.js +41 -0
  73. package/templates/dashboard/js/utils.js +196 -0
  74. package/templates/dashboard/js/view-live.js +143 -0
  75. package/templates/dashboard/js/view-runs.js +572 -0
  76. package/templates/dashboard/js/view-tests.js +294 -0
  77. package/templates/dashboard/js/view-watch.js +242 -0
  78. package/templates/dashboard/js/websocket.js +110 -0
  79. package/templates/dashboard/styles/base.css +69 -0
  80. package/templates/dashboard/styles/components.css +110 -0
  81. package/templates/dashboard/styles/view-live.css +74 -0
  82. package/templates/dashboard/styles/view-runs.css +207 -0
  83. package/templates/dashboard/styles/view-tests.css +96 -0
  84. package/templates/dashboard/styles/view-watch.css +53 -0
  85. package/templates/dashboard/template.html +267 -0
  86. package/templates/dashboard.html +2171 -530
  87. package/templates/docker-compose-neo4j.yml +19 -0
  88. package/templates/e2e.config.js +3 -0
  89. package/templates/sample-test.json +0 -8
package/bin/cli.js CHANGED
@@ -14,6 +14,8 @@
14
14
  * e2e-runner pool status Show pool status
15
15
  * e2e-runner pool restart Restart the pool
16
16
  * e2e-runner dashboard Start the web dashboard
17
+ * e2e-runner watch --interval 15m Watch mode: scheduled test runs
18
+ * e2e-runner watch --git Watch mode: run on git changes
17
19
  * e2e-runner capture <url> Capture a screenshot of any URL
18
20
  * e2e-runner issue <url> Fetch issue and show details
19
21
  * e2e-runner issue <url> --generate Generate test file via Claude API
@@ -29,15 +31,36 @@ import path from 'path';
29
31
  import http from 'http';
30
32
  import { fileURLToPath } from 'url';
31
33
  import { loadConfig } from '../src/config.js';
32
- import { startPool, stopPool, restartPool, getPoolStatus, waitForPool, connectToPool } from '../src/pool.js';
34
+ import { startPool, stopPool, restartPool, connectToPool } from '../src/pool.js';
35
+ import { getPoolUrls, getAggregatedPoolStatus, waitForAnyPool, selectPool } from '../src/pool-manager.js';
33
36
  import { runTestsParallel, loadTestFile, loadTestSuite, loadAllSuites, listSuites } from '../src/runner.js';
34
- import { generateReport, saveReport, printReport, persistRun } from '../src/reporter.js';
37
+ import { generateReport, saveReport, printReport, persistRun, printInsights } from '../src/reporter.js';
35
38
  import { startDashboard } from '../src/dashboard.js';
39
+ import { startWatch } from '../src/watch.js';
36
40
  import { fetchIssue } from '../src/issues.js';
37
41
  import { buildPrompt, generateTests, hasApiKey } from '../src/ai-generate.js';
38
42
  import { verifyIssue } from '../src/verify.js';
39
43
  import { ensureProject, computeScreenshotHash, registerScreenshotHash } from '../src/db.js';
40
44
  import { log, colors as C } from '../src/logger.js';
45
+ import { listModules } from '../src/module-resolver.js';
46
+ import { getLearningsSummary, getFlakySummary, getSelectorStability, getPageHealth, getApiHealth, getErrorPatterns, getTestTrends } from '../src/learner-sqlite.js';
47
+ import { startNeo4j, stopNeo4j, getNeo4jStatus } from '../src/neo4j-pool.js';
48
+ import {
49
+ generateApiKey,
50
+ generateTotpSecret,
51
+ generateTotpUri,
52
+ generateMasterKey,
53
+ hashApiKey,
54
+ migrateSyncSchema,
55
+ createInstance,
56
+ getInstance,
57
+ listInstances,
58
+ updateInstanceStatus,
59
+ getHubConnection,
60
+ getQueueStats,
61
+ getSyncClient,
62
+ pullRuns,
63
+ } from '../src/sync/index.js';
41
64
 
42
65
  const __filename = fileURLToPath(import.meta.url);
43
66
  const __dirname = path.dirname(__filename);
@@ -60,7 +83,9 @@ function parseCLIConfig() {
60
83
  const cliArgs = {};
61
84
  if (getFlag('--base-url')) cliArgs.baseUrl = getFlag('--base-url');
62
85
  if (getFlag('--pool-url')) cliArgs.poolUrl = getFlag('--pool-url');
86
+ if (getFlag('--pool-urls')) cliArgs.poolUrls = getFlag('--pool-urls').split(',').map(u => u.trim()).filter(Boolean);
63
87
  if (getFlag('--tests-dir')) cliArgs.testsDir = getFlag('--tests-dir');
88
+ if (getFlag('--modules-dir')) cliArgs.modulesDir = getFlag('--modules-dir');
64
89
  if (getFlag('--screenshots-dir')) cliArgs.screenshotsDir = getFlag('--screenshots-dir');
65
90
  if (getFlag('--concurrency')) cliArgs.concurrency = parseInt(getFlag('--concurrency'));
66
91
  if (getFlag('--pool-port')) cliArgs.poolPort = parseInt(getFlag('--pool-port'));
@@ -75,8 +100,24 @@ function parseCLIConfig() {
75
100
  if (getFlag('--dashboard-port')) cliArgs.dashboardPort = parseInt(getFlag('--dashboard-port'));
76
101
  if (getFlag('--project-name')) cliArgs.projectName = getFlag('--project-name');
77
102
  if (hasFlag('--fail-on-network-error')) cliArgs.failOnNetworkError = true;
103
+ if (getFlag('--action-retries')) cliArgs.actionRetries = parseInt(getFlag('--action-retries'));
104
+ if (getFlag('--action-retry-delay')) cliArgs.actionRetryDelay = parseInt(getFlag('--action-retry-delay'));
78
105
  if (getFlag('--auth-token')) cliArgs.authToken = getFlag('--auth-token');
79
106
  if (getFlag('--auth-storage-key')) cliArgs.authStorageKey = getFlag('--auth-storage-key');
107
+ if (getFlag('--test-type')) cliArgs.testType = getFlag('--test-type');
108
+ if (getFlag('--network-ignore-domains')) cliArgs.networkIgnoreDomains = getFlag('--network-ignore-domains').split(',').map(d => d.trim()).filter(Boolean);
109
+ if (getFlag('--auth-login-endpoint')) cliArgs.authLoginEndpoint = getFlag('--auth-login-endpoint');
110
+ if (getFlag('--auth-token-path')) cliArgs.authTokenPath = getFlag('--auth-token-path');
111
+ if (getFlag('--gql-endpoint')) cliArgs.gqlEndpoint = getFlag('--gql-endpoint');
112
+ if (getFlag('--gql-auth-header')) cliArgs.gqlAuthHeader = getFlag('--gql-auth-header');
113
+ if (getFlag('--gql-auth-key')) cliArgs.gqlAuthKey = getFlag('--gql-auth-key');
114
+ if (getFlag('--gql-auth-prefix')) cliArgs.gqlAuthPrefix = getFlag('--gql-auth-prefix');
115
+ if (getFlag('--verification-strictness')) {
116
+ const val = getFlag('--verification-strictness');
117
+ if (['strict', 'moderate', 'lenient'].includes(val)) {
118
+ cliArgs.verificationStrictness = val;
119
+ }
120
+ }
80
121
  return cliArgs;
81
122
  }
82
123
 
@@ -96,6 +137,12 @@ ${C.bold}Usage:${C.reset}
96
137
  e2e-runner dashboard Start the web dashboard
97
138
  e2e-runner dashboard --port <port> Custom port (default: 8484)
98
139
 
140
+ e2e-runner watch --interval 15m Watch mode: run tests on schedule
141
+ e2e-runner watch --git Watch mode: run on git changes
142
+ e2e-runner watch --interval 15m --git Both triggers
143
+ e2e-runner watch --webhook <url> Notify on failure/recovery
144
+ e2e-runner watch --projects <file> Multi-project watch
145
+
99
146
  e2e-runner capture <url> Capture a screenshot of any URL
100
147
  e2e-runner capture <url> --full-page Capture full scrollable page
101
148
  e2e-runner capture <url> --selector <sel> Wait for selector before capture
@@ -106,18 +153,36 @@ ${C.bold}Usage:${C.reset}
106
153
  e2e-runner issue <url> --generate Generate test file via Claude API
107
154
  e2e-runner issue <url> --verify Generate + run + report bug status
108
155
  e2e-runner issue <url> --prompt Output the AI prompt (for piping)
156
+ e2e-runner issue <url> --test-type e2e|api Test category (default: e2e)
109
157
 
110
158
  e2e-runner pool start Start the Chrome Pool
111
159
  e2e-runner pool stop Stop the Chrome Pool
112
160
  e2e-runner pool status Show pool status
113
161
  e2e-runner pool restart Restart the Chrome Pool
114
162
 
163
+ e2e-runner learnings Show test learnings summary
164
+ e2e-runner learnings --query <q> Query: flaky, selectors, pages, apis, errors, trends
165
+
166
+ e2e-runner neo4j start Start the Neo4j knowledge graph
167
+ e2e-runner neo4j stop Stop the Neo4j container
168
+ e2e-runner neo4j status Show Neo4j status
169
+
170
+ e2e-runner sync status Show sync connection status
171
+ e2e-runner sync add-instance Register new agent (hub mode)
172
+ e2e-runner sync list-instances List registered agents (hub mode)
173
+ e2e-runner sync approve <id> Approve pending agent (hub mode)
174
+ e2e-runner sync revoke <id> Suspend an agent (hub mode)
175
+ e2e-runner sync push Process sync queue (agent mode)
176
+ e2e-runner sync pull Pull runs from hub (agent mode)
177
+
115
178
  e2e-runner init Scaffold e2e/ in the current project
116
179
 
117
180
  ${C.bold}Options:${C.reset}
118
181
  --base-url <url> App base URL (default: http://host.docker.internal:3000)
119
182
  --pool-url <ws-url> Chrome Pool URL (default: ws://localhost:3333)
183
+ --pool-urls <urls> Multiple Chrome Pool URLs, comma-separated (distributes tests)
120
184
  --tests-dir <dir> Tests directory (default: e2e/tests)
185
+ --modules-dir <dir> Reusable modules directory (default: e2e/modules)
121
186
  --screenshots-dir <dir> Screenshots directory (default: e2e/screenshots)
122
187
  --concurrency <n> Parallel test workers (default: 3)
123
188
  --pool-port <port> Chrome Pool port (default: 3333)
@@ -130,6 +195,20 @@ ${C.bold}Options:${C.reset}
130
195
  --env <name> Environment profile from config (default: default)
131
196
  --project-name <name> Project display name for dashboard (default: directory name)
132
197
  --fail-on-network-error Fail tests when network requests fail (e.g. ERR_CONNECTION_REFUSED)
198
+ --network-ignore-domains <d1,d2> Ignore network errors from these domains (comma-separated)
199
+ --auth-login-endpoint <url> Auto-login: POST credentials to this URL to get auth token
200
+ --auth-token-path <path> Dot-path to token in auth response (default: token)
201
+ --verification-strictness <level> Visual verification: strict, moderate (default), lenient
202
+
203
+ ${C.bold}Watch Options:${C.reset}
204
+ --interval <time> Run interval: 15m, 1h, 30s (required for schedule mode)
205
+ --git Poll git for new commits
206
+ --git-branch <branch> Branch to watch (default: HEAD)
207
+ --git-interval <time> Git poll frequency (default: 30s)
208
+ --webhook <url> Webhook URL for notifications
209
+ --webhook-events <events> When to notify: failure (default), recovery, always
210
+ --projects <file.json> Multi-project config file
211
+ --no-run-on-start Skip initial run on startup
133
212
 
134
213
  ${C.bold}Config:${C.reset}
135
214
  Looks for e2e.config.js or e2e.config.json in the current directory.
@@ -144,22 +223,24 @@ async function cmdRun() {
144
223
  let tests = [];
145
224
  let hooks = {};
146
225
 
226
+ const poolUrls = getPoolUrls(config);
147
227
  console.log(`\n${C.bold}${C.cyan}@matware/e2e-runner${C.reset} v${pkg.version}`);
148
- console.log(`${C.dim}Pool: ${config.poolUrl} | Base: ${config.baseUrl} | Concurrency: ${config.concurrency}${C.reset}\n`);
228
+ const poolDisplay = poolUrls.length > 1 ? `${poolUrls.length} pools` : config.poolUrl;
229
+ console.log(`${C.dim}Pool: ${poolDisplay} | Base: ${config.baseUrl} | Concurrency: ${config.concurrency}${C.reset}\n`);
149
230
 
150
231
  if (hasFlag('--all')) {
151
- const loaded = loadAllSuites(config.testsDir);
232
+ const loaded = loadAllSuites(config.testsDir, config.modulesDir, config.exclude);
152
233
  tests = loaded.tests;
153
234
  hooks = loaded.hooks;
154
235
  } else if (getFlag('--suite')) {
155
236
  const name = getFlag('--suite');
156
- const loaded = loadTestSuite(name, config.testsDir);
237
+ const loaded = loadTestSuite(name, config.testsDir, config.modulesDir);
157
238
  tests = loaded.tests;
158
239
  hooks = loaded.hooks;
159
240
  log('📋', `${C.cyan}${name}${C.reset} (${tests.length} tests)`);
160
241
  } else if (getFlag('--tests')) {
161
242
  const file = getFlag('--tests');
162
- const loaded = loadTestFile(path.resolve(file));
243
+ const loaded = loadTestFile(path.resolve(file), config.modulesDir);
163
244
  tests = loaded.tests;
164
245
  hooks = loaded.hooks;
165
246
  log('📋', `${C.cyan}${file}${C.reset} (${tests.length} tests)`);
@@ -182,8 +263,8 @@ async function cmdRun() {
182
263
  }
183
264
 
184
265
  // Verify pool connectivity
185
- log('🔌', 'Checking Chrome Pool...');
186
- const pressure = await waitForPool(config.poolUrl);
266
+ log('🔌', `Checking Chrome Pool${poolUrls.length > 1 ? 's' : ''}...`);
267
+ const pressure = await waitForAnyPool(poolUrls);
187
268
  log('✅', `Pool ready (${pressure.running}/${pressure.maxConcurrent} sessions, queued: ${pressure.queued})`);
188
269
 
189
270
  // Wire up live progress to dashboard if running
@@ -206,12 +287,17 @@ async function cmdRun() {
206
287
 
207
288
  // Execute tests
208
289
  console.log('');
209
- const suiteName = getFlag('--suite') || (hasFlag('--all') ? null : null);
290
+ // Derive suite name: --suite flag > --tests file basename > null (for --all/--inline)
291
+ let suiteName = getFlag('--suite') || null;
292
+ if (!suiteName && getFlag('--tests')) {
293
+ suiteName = path.basename(getFlag('--tests'), '.json');
294
+ }
210
295
  const results = await runTestsParallel(tests, config, hooks);
211
296
  const report = generateReport(results);
212
297
  saveReport(report, config.screenshotsDir, config);
213
- persistRun(report, config, suiteName);
298
+ await persistRun(report, config, suiteName);
214
299
  printReport(report, config.screenshotsDir);
300
+ printInsights(report, config);
215
301
 
216
302
  // Wait for the last dashboard broadcast (run:complete) to flush before exiting
217
303
  if (_lastBroadcast) await _lastBroadcast;
@@ -230,6 +316,18 @@ async function cmdList() {
230
316
  console.log(` ${C.dim}- ${test}${C.reset}`);
231
317
  }
232
318
  }
319
+
320
+ const modules = listModules(config.modulesDir);
321
+ if (modules.length > 0) {
322
+ console.log(`${C.bold}Available modules:${C.reset}\n`);
323
+ for (const mod of modules) {
324
+ const paramNames = mod.params.map(p => p.required ? p.name : `${C.dim}${p.name}?${C.reset}`).join(', ');
325
+ console.log(` ${C.cyan}${mod.name}${C.reset} (${paramNames})`);
326
+ if (mod.description) {
327
+ console.log(` ${C.dim}${mod.description}${C.reset}`);
328
+ }
329
+ }
330
+ }
233
331
  console.log('');
234
332
  }
235
333
 
@@ -252,15 +350,32 @@ async function cmdPool() {
252
350
  break;
253
351
 
254
352
  case 'status': {
255
- const status = await getPoolStatus(config.poolUrl);
353
+ const statusPoolUrls = getPoolUrls(config);
354
+ const aggregated = await getAggregatedPoolStatus(statusPoolUrls);
256
355
  console.log(`\n${C.bold}Chrome Pool Status:${C.reset}\n`);
257
- if (status.error) {
258
- console.log(` ${C.red}Offline${C.reset}: ${status.error}`);
356
+
357
+ if (statusPoolUrls.length > 1) {
358
+ console.log(` Pools: ${aggregated.totalPools} (${aggregated.availableCount} available)`);
359
+ console.log(` Running: ${aggregated.totalRunning}/${aggregated.totalMaxConcurrent}`);
360
+ console.log(` Queued: ${aggregated.totalQueued}`);
361
+ console.log('');
362
+ for (const pool of aggregated.pools) {
363
+ const label = pool.available ? `${C.green}Available${C.reset}` : pool.error ? `${C.red}Offline${C.reset}` : `${C.red}Busy${C.reset}`;
364
+ console.log(` ${C.cyan}${pool.url}${C.reset}`);
365
+ console.log(` Status: ${label}${pool.error ? ` (${pool.error})` : ''}`);
366
+ console.log(` Running: ${pool.running}/${pool.maxConcurrent}`);
367
+ console.log(` Queued: ${pool.queued}`);
368
+ }
259
369
  } else {
260
- console.log(` Status: ${status.available ? `${C.green}Available${C.reset}` : `${C.red}Busy${C.reset}`}`);
261
- console.log(` Running: ${status.running}/${status.maxConcurrent}`);
262
- console.log(` Queued: ${status.queued}`);
263
- console.log(` Sessions: ${status.sessions.length}`);
370
+ const pool = aggregated.pools[0];
371
+ if (pool.error) {
372
+ console.log(` ${C.red}Offline${C.reset}: ${pool.error}`);
373
+ } else {
374
+ console.log(` Status: ${pool.available ? `${C.green}Available${C.reset}` : `${C.red}Busy${C.reset}`}`);
375
+ console.log(` Running: ${pool.running}/${pool.maxConcurrent}`);
376
+ console.log(` Queued: ${pool.queued}`);
377
+ console.log(` Sessions: ${pool.sessions?.length ?? 0}`);
378
+ }
264
379
  }
265
380
  console.log('');
266
381
  break;
@@ -279,6 +394,7 @@ function cmdInit() {
279
394
  // Create directory structure
280
395
  const dirs = [
281
396
  path.join(cwd, 'e2e', 'tests'),
397
+ path.join(cwd, 'e2e', 'modules'),
282
398
  path.join(cwd, 'e2e', 'screenshots'),
283
399
  ];
284
400
 
@@ -376,12 +492,14 @@ async function cmdCapture() {
376
492
 
377
493
  console.log(`\n${C.bold}${C.cyan}@matware/e2e-runner${C.reset} v${pkg.version}`);
378
494
 
495
+ const capturePoolUrls = getPoolUrls(config);
379
496
  log('🔌', 'Checking Chrome Pool...');
380
- await waitForPool(config.poolUrl);
497
+ await waitForAnyPool(capturePoolUrls);
381
498
 
382
499
  let browser;
383
500
  try {
384
- browser = await connectToPool(config.poolUrl);
501
+ const capturePool = await selectPool(capturePoolUrls);
502
+ browser = await connectToPool(capturePool);
385
503
  const page = await browser.newPage();
386
504
  await page.setViewport(config.viewport);
387
505
 
@@ -436,11 +554,12 @@ async function cmdIssue() {
436
554
 
437
555
  const cliArgs = parseCLIConfig();
438
556
  const config = await loadConfig(cliArgs);
557
+ const testType = cliArgs.testType || 'e2e';
439
558
 
440
559
  if (hasFlag('--prompt')) {
441
560
  // Output AI prompt as JSON to stdout
442
561
  const issue = fetchIssue(url);
443
- const promptData = buildPrompt(issue, config);
562
+ const promptData = buildPrompt(issue, config, testType);
444
563
  console.log(JSON.stringify(promptData, null, 2));
445
564
  return;
446
565
  }
@@ -455,6 +574,7 @@ async function cmdIssue() {
455
574
  console.log(`\n${C.bold}${C.cyan}@matware/e2e-runner${C.reset} v${pkg.version}`);
456
575
  log('🔍', 'Fetching issue...');
457
576
 
577
+ config.testType = testType;
458
578
  const result = await verifyIssue(url, config);
459
579
  const { issue, report, bugConfirmed } = result;
460
580
 
@@ -483,9 +603,9 @@ async function cmdIssue() {
483
603
 
484
604
  const issue = fetchIssue(url);
485
605
  log('📋', `${C.cyan}${issue.title}${C.reset}`);
486
- log('🤖', 'Generating tests via Claude API...');
606
+ log('🤖', `Generating ${testType} tests via Claude API...`);
487
607
 
488
- const { tests, suiteName } = await generateTests(issue, config);
608
+ const { tests, suiteName } = await generateTests(issue, config, testType);
489
609
 
490
610
  if (!fs.existsSync(config.testsDir)) {
491
611
  fs.mkdirSync(config.testsDir, { recursive: true });
@@ -516,6 +636,450 @@ async function cmdIssue() {
516
636
  console.log('');
517
637
  }
518
638
 
639
+ async function cmdWatch() {
640
+ const cliArgs = parseCLIConfig();
641
+
642
+ // Parse watch-specific flags
643
+ if (getFlag('--interval')) cliArgs.watchInterval = getFlag('--interval');
644
+ if (getFlag('--webhook')) cliArgs.watchWebhookUrl = getFlag('--webhook');
645
+ if (getFlag('--webhook-events')) cliArgs.watchWebhookEvents = getFlag('--webhook-events');
646
+ if (hasFlag('--git')) cliArgs.watchGitPoll = true;
647
+ if (getFlag('--git-branch')) cliArgs.watchGitBranch = getFlag('--git-branch');
648
+ if (getFlag('--git-interval')) cliArgs.watchGitInterval = getFlag('--git-interval');
649
+ if (hasFlag('--no-run-on-start')) cliArgs.watchRunOnStart = false;
650
+
651
+ // Multi-project file
652
+ const projectsFile = getFlag('--projects');
653
+ if (projectsFile) {
654
+ const resolved = path.resolve(projectsFile);
655
+ if (!fs.existsSync(resolved)) {
656
+ console.error(`${C.red}Projects file not found: ${resolved}${C.reset}`);
657
+ process.exit(1);
658
+ }
659
+ cliArgs.watchProjects = JSON.parse(fs.readFileSync(resolved, 'utf-8'));
660
+ }
661
+
662
+ const config = await loadConfig(cliArgs);
663
+
664
+ console.log(`\n${C.bold}${C.cyan}@matware/e2e-runner${C.reset} v${pkg.version}`);
665
+ console.log(`${C.dim}Watch mode — dashboard on port ${config.dashboardPort}${C.reset}\n`);
666
+
667
+ const handle = await startWatch(config);
668
+
669
+ // Graceful shutdown
670
+ const shutdown = () => {
671
+ console.log(`\n${C.dim}Stopping watch...${C.reset}`);
672
+ handle.stop();
673
+ process.exit(0);
674
+ };
675
+ process.on('SIGINT', shutdown);
676
+ process.on('SIGTERM', shutdown);
677
+ }
678
+
679
+ async function cmdLearnings() {
680
+ const cliArgs = parseCLIConfig();
681
+ const config = await loadConfig(cliArgs);
682
+ const projectId = ensureProject(config._cwd, config.projectName, config.screenshotsDir, config.testsDir);
683
+ const days = config.learningsDays || 30;
684
+ const query = getFlag('--query') || 'summary';
685
+
686
+ console.log(`\n${C.bold}${C.cyan}@matware/e2e-runner${C.reset} v${pkg.version}`);
687
+ console.log(`${C.dim}Project: ${config.projectName} | Analysis window: ${days} days${C.reset}\n`);
688
+
689
+ switch (query) {
690
+ case 'summary': {
691
+ const summary = getLearningsSummary(projectId);
692
+ if (summary.totalRuns === 0) {
693
+ console.log(`${C.dim}No learnings data yet. Run some tests to start building knowledge.${C.reset}\n`);
694
+ return;
695
+ }
696
+ console.log(`${C.bold}Health Overview${C.reset}`);
697
+ console.log(`${'─'.repeat(50)}`);
698
+ console.log(` Total Runs: ${C.bold}${summary.totalRuns}${C.reset}`);
699
+ console.log(` Total Tests: ${C.bold}${summary.totalTests}${C.reset}`);
700
+ console.log(` Pass Rate: ${summary.overallPassRate >= 90 ? C.green : summary.overallPassRate >= 70 ? '' : C.red}${summary.overallPassRate}%${C.reset}`);
701
+ console.log(` Avg Duration: ${summary.avgDurationMs < 1000 ? summary.avgDurationMs + 'ms' : (summary.avgDurationMs / 1000).toFixed(1) + 's'}`);
702
+ console.log(` Flaky Tests: ${summary.flakyTests.length > 0 ? C.red : C.green}${summary.flakyTests.length}${C.reset}`);
703
+ console.log(` Unstable Selectors: ${summary.unstableSelectors.length > 0 ? C.red : C.green}${summary.unstableSelectors.length}${C.reset}`);
704
+
705
+ if (summary.flakyTests.length > 0) {
706
+ console.log(`\n${C.bold}Top Flaky Tests${C.reset}`);
707
+ summary.flakyTests.slice(0, 5).forEach(f => {
708
+ console.log(` ${C.yellow}⚠${C.reset} ${f.test_name} — ${f.flaky_rate}% flaky`);
709
+ });
710
+ }
711
+ if (summary.topErrors.length > 0) {
712
+ console.log(`\n${C.bold}Top Errors${C.reset}`);
713
+ summary.topErrors.slice(0, 5).forEach(e => {
714
+ console.log(` ${C.red}✗${C.reset} [${e.category}] ${e.pattern.slice(0, 60)}${e.pattern.length > 60 ? '...' : ''} (${e.occurrence_count}x)`);
715
+ });
716
+ }
717
+ console.log('');
718
+ break;
719
+ }
720
+ case 'flaky': {
721
+ const flaky = getFlakySummary(projectId, days);
722
+ if (flaky.length === 0) { console.log(`${C.green}No flaky tests found.${C.reset}\n`); return; }
723
+ console.log(`${C.bold}Flaky Tests${C.reset}\n`);
724
+ flaky.forEach(f => {
725
+ console.log(` ${C.yellow}⚠${C.reset} ${C.bold}${f.test_name}${C.reset}`);
726
+ console.log(` Rate: ${f.flaky_rate}% | Occurrences: ${f.flaky_count}/${f.total_runs} | Avg attempts: ${f.avg_attempts}`);
727
+ });
728
+ console.log('');
729
+ break;
730
+ }
731
+ case 'selectors': {
732
+ const sels = getSelectorStability(projectId, days);
733
+ if (sels.length === 0) { console.log(`${C.green}All selectors are stable.${C.reset}\n`); return; }
734
+ console.log(`${C.bold}Unstable Selectors${C.reset}\n`);
735
+ sels.forEach(s => {
736
+ console.log(` ${C.red}✗${C.reset} ${C.dim}${s.selector}${C.reset}`);
737
+ console.log(` Action: ${s.action_type} | Fail: ${s.fail_rate}% | Uses: ${s.total_uses} | Tests: ${s.used_by_tests}`);
738
+ });
739
+ console.log('');
740
+ break;
741
+ }
742
+ case 'pages': {
743
+ const pages = getPageHealth(projectId, days);
744
+ const failing = pages.filter(p => p.fail_rate > 0);
745
+ if (failing.length === 0) { console.log(`${C.green}All pages are healthy.${C.reset}\n`); return; }
746
+ console.log(`${C.bold}Failing Pages${C.reset}\n`);
747
+ failing.forEach(p => {
748
+ console.log(` ${C.red}✗${C.reset} ${C.bold}${p.url_path}${C.reset}`);
749
+ console.log(` Fail: ${p.fail_rate}% | Visits: ${p.total_visits} | Console errors: ${p.console_errors} | Network errors: ${p.network_errors}`);
750
+ });
751
+ console.log('');
752
+ break;
753
+ }
754
+ case 'apis': {
755
+ const apis = getApiHealth(projectId, days);
756
+ const issues = apis.filter(a => a.error_rate > 0);
757
+ if (issues.length === 0) { console.log(`${C.green}All API endpoints are healthy.${C.reset}\n`); return; }
758
+ console.log(`${C.bold}API Issues${C.reset}\n`);
759
+ issues.forEach(a => {
760
+ console.log(` ${C.red}✗${C.reset} ${C.bold}${a.endpoint}${C.reset}`);
761
+ console.log(` Error: ${a.error_rate}% | Calls: ${a.total_calls} | Avg: ${Math.round(a.avg_duration_ms)}ms | Status: ${a.status_codes}`);
762
+ });
763
+ console.log('');
764
+ break;
765
+ }
766
+ case 'errors': {
767
+ const errors = getErrorPatterns(projectId);
768
+ if (errors.length === 0) { console.log(`${C.green}No error patterns recorded.${C.reset}\n`); return; }
769
+ console.log(`${C.bold}Error Patterns${C.reset}\n`);
770
+ errors.forEach(e => {
771
+ console.log(` ${C.red}✗${C.reset} [${e.category}] ${e.pattern.slice(0, 70)}${e.pattern.length > 70 ? '...' : ''}`);
772
+ console.log(` Count: ${e.occurrence_count} | Last: ${(e.last_seen || '').split('T')[0]} | Test: ${e.example_test || '-'}`);
773
+ });
774
+ console.log('');
775
+ break;
776
+ }
777
+ case 'trends': {
778
+ const trends = getTestTrends(projectId, days);
779
+ if (trends.length === 0) { console.log(`${C.dim}No trend data available.${C.reset}\n`); return; }
780
+ console.log(`${C.bold}Test Trends (${days} days)${C.reset}\n`);
781
+ console.log(` ${'Date'.padEnd(12)} ${'Pass Rate'.padEnd(11)} ${'Tests'.padEnd(7)} ${'Pass'.padEnd(6)} ${'Fail'.padEnd(6)} Flaky`);
782
+ console.log(` ${'─'.repeat(55)}`);
783
+ trends.forEach(t => {
784
+ const rateColor = t.pass_rate >= 90 ? C.green : t.pass_rate >= 70 ? '' : C.red;
785
+ console.log(` ${t.date.padEnd(12)} ${rateColor}${(t.pass_rate + '%').padEnd(11)}${C.reset} ${String(t.total_tests).padEnd(7)} ${C.green}${String(t.passed).padEnd(6)}${C.reset} ${t.failed > 0 ? C.red : ''}${String(t.failed).padEnd(6)}${C.reset} ${t.flaky_count}`);
786
+ });
787
+ console.log('');
788
+ break;
789
+ }
790
+ default:
791
+ console.error(`${C.red}Unknown query: ${query}. Available: summary, flaky, selectors, pages, apis, errors, trends${C.reset}`);
792
+ process.exit(1);
793
+ }
794
+ }
795
+
796
+ async function cmdNeo4j() {
797
+ const subCmd = args[1];
798
+ const cliArgs = parseCLIConfig();
799
+ const config = await loadConfig(cliArgs);
800
+
801
+ switch (subCmd) {
802
+ case 'start':
803
+ startNeo4j(config);
804
+ break;
805
+ case 'stop':
806
+ stopNeo4j(config);
807
+ break;
808
+ case 'status': {
809
+ const status = getNeo4jStatus(config);
810
+ console.log(`\n${C.bold}Neo4j Status:${C.reset}\n`);
811
+ if (status.running) {
812
+ console.log(` Status: ${C.green}Running${C.reset}`);
813
+ console.log(` Bolt: ${C.cyan}bolt://localhost:${status.boltPort}${C.reset}`);
814
+ console.log(` Browser: ${C.cyan}http://localhost:${status.httpPort}${C.reset}`);
815
+ } else {
816
+ console.log(` Status: ${C.red}Stopped${C.reset}`);
817
+ if (status.error) console.log(` ${C.dim}${status.error}${C.reset}`);
818
+ }
819
+ console.log('');
820
+ break;
821
+ }
822
+ default:
823
+ console.error(`${C.red}Unknown subcommand: ${subCmd}. Available: start, stop, status${C.reset}`);
824
+ process.exit(1);
825
+ }
826
+ }
827
+
828
+ // ==================== Sync ====================
829
+
830
+ async function cmdSync() {
831
+ const subCmd = args[1];
832
+ const cliArgs = parseCLIConfig();
833
+ const config = await loadConfig(cliArgs);
834
+
835
+ // Ensure schema is migrated
836
+ migrateSyncSchema();
837
+
838
+ switch (subCmd) {
839
+ case 'status': {
840
+ const mode = config.sync?.mode || 'standalone';
841
+ console.log(`\n${C.bold}Sync Status:${C.reset}\n`);
842
+ console.log(` Mode: ${C.cyan}${mode}${C.reset}`);
843
+
844
+ if (mode === 'hub') {
845
+ const instances = listInstances();
846
+ const active = instances.filter(i => i.status === 'active').length;
847
+ const online = instances.filter(i => {
848
+ if (!i.last_seen) return false;
849
+ const lastSeen = new Date(i.last_seen + 'Z').getTime();
850
+ return Date.now() - lastSeen < 5 * 60 * 1000;
851
+ }).length;
852
+ console.log(` Instances: ${instances.length} total, ${active} active, ${online} online`);
853
+ } else if (mode === 'agent') {
854
+ const conn = getHubConnection();
855
+ if (conn) {
856
+ console.log(` Hub URL: ${C.cyan}${conn.hub_url}${C.reset}`);
857
+ console.log(` Instance: ${conn.instance_id}`);
858
+ console.log(` Status: ${conn.status === 'connected' ? C.green : C.red}${conn.status}${C.reset}`);
859
+ console.log(` Last push: ${conn.last_push || 'never'}`);
860
+ console.log(` Last pull: ${conn.last_pull || 'never'}`);
861
+ } else {
862
+ console.log(` ${C.dim}Not connected to any hub${C.reset}`);
863
+ }
864
+
865
+ const queueStats = getQueueStats();
866
+ if (queueStats.length > 0) {
867
+ const pending = queueStats.find(s => s.status === 'pending')?.count || 0;
868
+ if (pending > 0) {
869
+ console.log(` Queue: ${C.yellow}${pending} pending${C.reset}`);
870
+ }
871
+ }
872
+ }
873
+ console.log('');
874
+ break;
875
+ }
876
+
877
+ case 'add-instance': {
878
+ if (config.sync?.mode !== 'hub') {
879
+ console.error(`${C.red}Error: This command only works in hub mode${C.reset}`);
880
+ process.exit(1);
881
+ }
882
+
883
+ const instanceId = getFlag('--id') || `instance-${Date.now().toString(36)}`;
884
+ const displayName = getFlag('--name') || instanceId;
885
+ const role = getFlag('--role') || 'member';
886
+ const environment = getFlag('--env') || 'development';
887
+
888
+ // Check if already exists
889
+ if (getInstance(instanceId)) {
890
+ console.error(`${C.red}Error: Instance '${instanceId}' already exists${C.reset}`);
891
+ process.exit(1);
892
+ }
893
+
894
+ // Generate credentials
895
+ const apiKey = generateApiKey();
896
+ const totpSecret = generateTotpSecret();
897
+
898
+ // Create instance
899
+ createInstance({
900
+ instanceId,
901
+ displayName,
902
+ hostname: null,
903
+ environment,
904
+ apiKeyHash: hashApiKey(apiKey),
905
+ totpSecret,
906
+ role,
907
+ status: config.sync?.hub?.requireApproval ? 'pending' : 'active',
908
+ });
909
+
910
+ console.log(`\n${C.green}${C.bold}Instance created successfully!${C.reset}\n`);
911
+ console.log(`${C.bold}Instance ID:${C.reset} ${instanceId}`);
912
+ console.log(`${C.bold}Display Name:${C.reset} ${displayName}`);
913
+ console.log(`${C.bold}Role:${C.reset} ${role}`);
914
+ console.log(`${C.bold}Status:${C.reset} ${config.sync?.hub?.requireApproval ? 'pending' : 'active'}`);
915
+ console.log('');
916
+ console.log(`${C.bold}${C.yellow}SAVE THESE CREDENTIALS (shown only once):${C.reset}`);
917
+ console.log(`${C.bold}API Key:${C.reset} ${apiKey}`);
918
+ console.log(`${C.bold}TOTP Secret:${C.reset} ${totpSecret}`);
919
+ console.log(`${C.bold}TOTP URI:${C.reset} ${generateTotpUri(totpSecret, instanceId)}`);
920
+ console.log('');
921
+ console.log(`${C.dim}Configure the agent with:${C.reset}`);
922
+ console.log(` export E2E_SYNC_API_KEY="${apiKey}"`);
923
+ console.log(` export E2E_SYNC_TOTP="${totpSecret}"`);
924
+ console.log('');
925
+ break;
926
+ }
927
+
928
+ case 'list-instances': {
929
+ if (config.sync?.mode !== 'hub') {
930
+ console.error(`${C.red}Error: This command only works in hub mode${C.reset}`);
931
+ process.exit(1);
932
+ }
933
+
934
+ const status = getFlag('--status');
935
+ const instances = listInstances(status);
936
+
937
+ console.log(`\n${C.bold}Registered Instances:${C.reset}\n`);
938
+
939
+ if (instances.length === 0) {
940
+ console.log(` ${C.dim}No instances registered${C.reset}`);
941
+ } else {
942
+ for (const inst of instances) {
943
+ const isOnline = inst.last_seen && (Date.now() - new Date(inst.last_seen + 'Z').getTime() < 5 * 60 * 1000);
944
+ const statusColor = inst.status === 'active' ? C.green : inst.status === 'pending' ? C.yellow : C.red;
945
+ const onlineIndicator = isOnline ? `${C.green}*${C.reset}` : ' ';
946
+
947
+ console.log(` ${onlineIndicator} ${C.bold}${inst.instance_id}${C.reset}`);
948
+ console.log(` Name: ${inst.display_name}`);
949
+ console.log(` Status: ${statusColor}${inst.status}${C.reset}`);
950
+ console.log(` Role: ${inst.role}`);
951
+ console.log(` Seen: ${inst.last_seen || 'never'}`);
952
+ console.log('');
953
+ }
954
+ }
955
+ break;
956
+ }
957
+
958
+ case 'approve': {
959
+ if (config.sync?.mode !== 'hub') {
960
+ console.error(`${C.red}Error: This command only works in hub mode${C.reset}`);
961
+ process.exit(1);
962
+ }
963
+
964
+ const instanceId = args[2];
965
+ if (!instanceId) {
966
+ console.error(`${C.red}Error: Instance ID required${C.reset}`);
967
+ process.exit(1);
968
+ }
969
+
970
+ const instance = getInstance(instanceId);
971
+ if (!instance) {
972
+ console.error(`${C.red}Error: Instance '${instanceId}' not found${C.reset}`);
973
+ process.exit(1);
974
+ }
975
+
976
+ updateInstanceStatus(instanceId, 'active');
977
+ console.log(`${C.green}Instance '${instanceId}' approved and activated${C.reset}`);
978
+ break;
979
+ }
980
+
981
+ case 'revoke': {
982
+ if (config.sync?.mode !== 'hub') {
983
+ console.error(`${C.red}Error: This command only works in hub mode${C.reset}`);
984
+ process.exit(1);
985
+ }
986
+
987
+ const instanceId = args[2];
988
+ if (!instanceId) {
989
+ console.error(`${C.red}Error: Instance ID required${C.reset}`);
990
+ process.exit(1);
991
+ }
992
+
993
+ updateInstanceStatus(instanceId, 'suspended');
994
+ console.log(`${C.yellow}Instance '${instanceId}' suspended${C.reset}`);
995
+ break;
996
+ }
997
+
998
+ case 'push': {
999
+ if (config.sync?.mode !== 'agent') {
1000
+ console.error(`${C.red}Error: This command only works in agent mode${C.reset}`);
1001
+ process.exit(1);
1002
+ }
1003
+
1004
+ const client = await getSyncClient(config);
1005
+ if (!client.isConfigured()) {
1006
+ console.error(`${C.red}Error: Sync credentials not configured${C.reset}`);
1007
+ console.log(`${C.dim}Set E2E_SYNC_API_KEY and E2E_SYNC_TOTP environment variables${C.reset}`);
1008
+ process.exit(1);
1009
+ }
1010
+
1011
+ console.log('Processing sync queue...');
1012
+ await client.processQueue();
1013
+ console.log(`${C.green}Queue processed${C.reset}`);
1014
+ break;
1015
+ }
1016
+
1017
+ case 'pull': {
1018
+ if (config.sync?.mode !== 'agent') {
1019
+ console.error(`${C.red}Error: This command only works in agent mode${C.reset}`);
1020
+ process.exit(1);
1021
+ }
1022
+
1023
+ const since = getFlag('--since');
1024
+ const project = getFlag('--project');
1025
+ const limit = getFlag('--limit') ? parseInt(getFlag('--limit')) : 50;
1026
+
1027
+ console.log('Pulling runs from hub...');
1028
+ const result = await pullRuns(config, { since, project, limit });
1029
+
1030
+ if (result) {
1031
+ console.log(`${C.green}Pulled ${result.runs?.length || 0} runs${C.reset}`);
1032
+
1033
+ if (result.runs?.length > 0) {
1034
+ console.log('');
1035
+ for (const run of result.runs.slice(0, 10)) {
1036
+ const status = run.failed > 0 ? C.red + 'FAIL' : C.green + 'PASS';
1037
+ console.log(` ${status}${C.reset} ${run.project_name} - ${run.suite_name || 'default'} (${run.passed}/${run.total})`);
1038
+ }
1039
+ if (result.runs.length > 10) {
1040
+ console.log(` ${C.dim}... and ${result.runs.length - 10} more${C.reset}`);
1041
+ }
1042
+ }
1043
+ } else {
1044
+ console.log(`${C.yellow}No runs pulled (check configuration)${C.reset}`);
1045
+ }
1046
+ break;
1047
+ }
1048
+
1049
+ case 'generate-master-key': {
1050
+ const key = generateMasterKey();
1051
+ console.log(`\n${C.bold}Generated Master Key:${C.reset}\n`);
1052
+ console.log(` ${key}`);
1053
+ console.log('');
1054
+ console.log(`${C.dim}Set this in your hub environment:${C.reset}`);
1055
+ console.log(` export E2E_SYNC_MASTER_KEY="${key}"`);
1056
+ console.log('');
1057
+ break;
1058
+ }
1059
+
1060
+ default:
1061
+ console.log(`\n${C.bold}Sync Commands:${C.reset}\n`);
1062
+ console.log(' status Show sync status');
1063
+ console.log(' add-instance Register a new agent (hub mode)');
1064
+ console.log(' list-instances List registered agents (hub mode)');
1065
+ console.log(' approve <id> Approve pending agent (hub mode)');
1066
+ console.log(' revoke <id> Suspend an agent (hub mode)');
1067
+ console.log(' push Process sync queue (agent mode)');
1068
+ console.log(' pull Pull runs from hub (agent mode)');
1069
+ console.log(' generate-master-key Generate encryption master key');
1070
+ console.log('');
1071
+ console.log(`${C.bold}Options:${C.reset}`);
1072
+ console.log(' --id <id> Instance ID for add-instance');
1073
+ console.log(' --name <name> Display name for add-instance');
1074
+ console.log(' --role <role> Role: admin, member, readonly');
1075
+ console.log(' --status <status> Filter by status: pending, active, suspended');
1076
+ console.log(' --since <datetime> Pull runs since timestamp');
1077
+ console.log(' --project <slug> Filter by project');
1078
+ console.log(' --limit <n> Limit number of runs to pull');
1079
+ console.log('');
1080
+ }
1081
+ }
1082
+
519
1083
  // ==================== Main ====================
520
1084
 
521
1085
  async function main() {
@@ -548,6 +1112,10 @@ async function main() {
548
1112
  await cmdDashboard();
549
1113
  break;
550
1114
 
1115
+ case 'watch':
1116
+ await cmdWatch();
1117
+ break;
1118
+
551
1119
  case 'capture':
552
1120
  await cmdCapture();
553
1121
  break;
@@ -556,6 +1124,18 @@ async function main() {
556
1124
  await cmdIssue();
557
1125
  break;
558
1126
 
1127
+ case 'learnings':
1128
+ await cmdLearnings();
1129
+ break;
1130
+
1131
+ case 'neo4j':
1132
+ await cmdNeo4j();
1133
+ break;
1134
+
1135
+ case 'sync':
1136
+ await cmdSync();
1137
+ break;
1138
+
559
1139
  case 'init':
560
1140
  cmdInit();
561
1141
  break;