@matware/e2e-runner 1.2.1 → 1.3.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 (88) hide show
  1. package/.claude-plugin/marketplace.json +52 -0
  2. package/.claude-plugin/plugin.json +17 -3
  3. package/.mcp.json +2 -2
  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/LICENSE +190 -0
  19. package/OPENCODE.md +166 -0
  20. package/README.md +165 -104
  21. package/agents/test-creator.md +54 -1
  22. package/agents/test-improver.md +37 -0
  23. package/bin/cli.js +409 -16
  24. package/commands/capture.md +45 -0
  25. package/commands/create-test.md +16 -1
  26. package/opencode.json +11 -0
  27. package/package.json +7 -2
  28. package/scripts/setup-opencode.sh +113 -0
  29. package/skills/e2e-testing/SKILL.md +10 -3
  30. package/skills/e2e-testing/references/action-types.md +48 -5
  31. package/skills/e2e-testing/references/auth-strategies.md +91 -0
  32. package/skills/e2e-testing/references/graphql.md +59 -0
  33. package/skills/e2e-testing/references/issue-verification.md +59 -0
  34. package/skills/e2e-testing/references/multi-pool.md +60 -0
  35. package/skills/e2e-testing/references/network-debugging.md +62 -0
  36. package/skills/e2e-testing/references/test-json-format.md +4 -0
  37. package/skills/e2e-testing/references/troubleshooting.md +44 -2
  38. package/skills/e2e-testing/references/variables.md +41 -0
  39. package/skills/e2e-testing/references/visual-verification.md +89 -0
  40. package/src/actions.js +475 -2
  41. package/src/ai-generate.js +139 -8
  42. package/src/app-pool.js +339 -0
  43. package/src/config.js +266 -5
  44. package/src/dashboard.js +216 -17
  45. package/src/db.js +191 -7
  46. package/src/index.js +12 -9
  47. package/src/learner-sqlite.js +458 -0
  48. package/src/learner.js +78 -6
  49. package/src/mcp-tools.js +1348 -51
  50. package/src/module-resolver.js +37 -0
  51. package/src/narrate.js +65 -0
  52. package/src/pool-manager.js +229 -0
  53. package/src/pool.js +301 -31
  54. package/src/reporter.js +86 -2
  55. package/src/runner.js +480 -71
  56. package/src/sync/auth.js +354 -0
  57. package/src/sync/client.js +572 -0
  58. package/src/sync/hub-routes.js +816 -0
  59. package/src/sync/index.js +68 -0
  60. package/src/sync/middleware.js +347 -0
  61. package/src/sync/queue.js +209 -0
  62. package/src/sync/schema.js +540 -0
  63. package/src/verify.js +10 -7
  64. package/src/visual-diff.js +446 -0
  65. package/src/watch.js +384 -0
  66. package/templates/build-dashboard.js +47 -6
  67. package/templates/dashboard/js/api.js +62 -0
  68. package/templates/dashboard/js/init.js +13 -0
  69. package/templates/dashboard/js/keyboard.js +46 -0
  70. package/templates/dashboard/js/state.js +40 -0
  71. package/templates/dashboard/js/toast.js +41 -0
  72. package/templates/dashboard/js/utils.js +216 -0
  73. package/templates/dashboard/js/view-live.js +181 -0
  74. package/templates/dashboard/js/view-runs.js +676 -0
  75. package/templates/dashboard/js/view-tests.js +294 -0
  76. package/templates/dashboard/js/view-watch.js +242 -0
  77. package/templates/dashboard/js/websocket.js +116 -0
  78. package/templates/dashboard/styles/base.css +69 -0
  79. package/templates/dashboard/styles/components.css +117 -0
  80. package/templates/dashboard/styles/view-live.css +97 -0
  81. package/templates/dashboard/styles/view-runs.css +243 -0
  82. package/templates/dashboard/styles/view-tests.css +96 -0
  83. package/templates/dashboard/styles/view-watch.css +53 -0
  84. package/templates/dashboard/template.html +181 -100
  85. package/templates/dashboard.html +1614 -547
  86. package/templates/sample-test.json +0 -8
  87. package/templates/dashboard/app.js +0 -1152
  88. package/templates/dashboard/styles.css +0 -413
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,10 +31,12 @@ 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';
@@ -41,6 +45,22 @@ import { log, colors as C } from '../src/logger.js';
41
45
  import { listModules } from '../src/module-resolver.js';
42
46
  import { getLearningsSummary, getFlakySummary, getSelectorStability, getPageHealth, getApiHealth, getErrorPatterns, getTestTrends } from '../src/learner-sqlite.js';
43
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';
44
64
 
45
65
  const __filename = fileURLToPath(import.meta.url);
46
66
  const __dirname = path.dirname(__filename);
@@ -63,6 +83,7 @@ function parseCLIConfig() {
63
83
  const cliArgs = {};
64
84
  if (getFlag('--base-url')) cliArgs.baseUrl = getFlag('--base-url');
65
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);
66
87
  if (getFlag('--tests-dir')) cliArgs.testsDir = getFlag('--tests-dir');
67
88
  if (getFlag('--modules-dir')) cliArgs.modulesDir = getFlag('--modules-dir');
68
89
  if (getFlag('--screenshots-dir')) cliArgs.screenshotsDir = getFlag('--screenshots-dir');
@@ -84,6 +105,19 @@ function parseCLIConfig() {
84
105
  if (getFlag('--auth-token')) cliArgs.authToken = getFlag('--auth-token');
85
106
  if (getFlag('--auth-storage-key')) cliArgs.authStorageKey = getFlag('--auth-storage-key');
86
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
+ }
87
121
  return cliArgs;
88
122
  }
89
123
 
@@ -103,6 +137,12 @@ ${C.bold}Usage:${C.reset}
103
137
  e2e-runner dashboard Start the web dashboard
104
138
  e2e-runner dashboard --port <port> Custom port (default: 8484)
105
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
+
106
146
  e2e-runner capture <url> Capture a screenshot of any URL
107
147
  e2e-runner capture <url> --full-page Capture full scrollable page
108
148
  e2e-runner capture <url> --selector <sel> Wait for selector before capture
@@ -127,11 +167,20 @@ ${C.bold}Usage:${C.reset}
127
167
  e2e-runner neo4j stop Stop the Neo4j container
128
168
  e2e-runner neo4j status Show Neo4j status
129
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
+
130
178
  e2e-runner init Scaffold e2e/ in the current project
131
179
 
132
180
  ${C.bold}Options:${C.reset}
133
181
  --base-url <url> App base URL (default: http://host.docker.internal:3000)
134
182
  --pool-url <ws-url> Chrome Pool URL (default: ws://localhost:3333)
183
+ --pool-urls <urls> Multiple Chrome Pool URLs, comma-separated (distributes tests)
135
184
  --tests-dir <dir> Tests directory (default: e2e/tests)
136
185
  --modules-dir <dir> Reusable modules directory (default: e2e/modules)
137
186
  --screenshots-dir <dir> Screenshots directory (default: e2e/screenshots)
@@ -146,6 +195,20 @@ ${C.bold}Options:${C.reset}
146
195
  --env <name> Environment profile from config (default: default)
147
196
  --project-name <name> Project display name for dashboard (default: directory name)
148
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
149
212
 
150
213
  ${C.bold}Config:${C.reset}
151
214
  Looks for e2e.config.js or e2e.config.json in the current directory.
@@ -160,8 +223,10 @@ async function cmdRun() {
160
223
  let tests = [];
161
224
  let hooks = {};
162
225
 
226
+ const poolUrls = getPoolUrls(config);
163
227
  console.log(`\n${C.bold}${C.cyan}@matware/e2e-runner${C.reset} v${pkg.version}`);
164
- 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`);
165
230
 
166
231
  if (hasFlag('--all')) {
167
232
  const loaded = loadAllSuites(config.testsDir, config.modulesDir, config.exclude);
@@ -198,8 +263,8 @@ async function cmdRun() {
198
263
  }
199
264
 
200
265
  // Verify pool connectivity
201
- log('🔌', 'Checking Chrome Pool...');
202
- const pressure = await waitForPool(config.poolUrl);
266
+ log('🔌', `Checking Chrome Pool${poolUrls.length > 1 ? 's' : ''}...`);
267
+ const pressure = await waitForAnyPool(poolUrls, 30000, { poolDriver: config.poolDriver, maxSessions: config.maxSessions });
203
268
  log('✅', `Pool ready (${pressure.running}/${pressure.maxConcurrent} sessions, queued: ${pressure.queued})`);
204
269
 
205
270
  // Wire up live progress to dashboard if running
@@ -222,12 +287,17 @@ async function cmdRun() {
222
287
 
223
288
  // Execute tests
224
289
  console.log('');
225
- 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
+ }
226
295
  const results = await runTestsParallel(tests, config, hooks);
227
296
  const report = generateReport(results);
228
297
  saveReport(report, config.screenshotsDir, config);
229
- persistRun(report, config, suiteName);
298
+ await persistRun(report, config, suiteName);
230
299
  printReport(report, config.screenshotsDir);
300
+ printInsights(report, config);
231
301
 
232
302
  // Wait for the last dashboard broadcast (run:complete) to flush before exiting
233
303
  if (_lastBroadcast) await _lastBroadcast;
@@ -280,15 +350,32 @@ async function cmdPool() {
280
350
  break;
281
351
 
282
352
  case 'status': {
283
- const status = await getPoolStatus(config.poolUrl);
353
+ const statusPoolUrls = getPoolUrls(config);
354
+ const aggregated = await getAggregatedPoolStatus(statusPoolUrls, { poolDriver: config.poolDriver, maxSessions: config.maxSessions });
284
355
  console.log(`\n${C.bold}Chrome Pool Status:${C.reset}\n`);
285
- if (status.error) {
286
- 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
+ }
287
369
  } else {
288
- console.log(` Status: ${status.available ? `${C.green}Available${C.reset}` : `${C.red}Busy${C.reset}`}`);
289
- console.log(` Running: ${status.running}/${status.maxConcurrent}`);
290
- console.log(` Queued: ${status.queued}`);
291
- 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
+ }
292
379
  }
293
380
  console.log('');
294
381
  break;
@@ -405,12 +492,15 @@ async function cmdCapture() {
405
492
 
406
493
  console.log(`\n${C.bold}${C.cyan}@matware/e2e-runner${C.reset} v${pkg.version}`);
407
494
 
495
+ const capturePoolUrls = getPoolUrls(config);
408
496
  log('🔌', 'Checking Chrome Pool...');
409
- await waitForPool(config.poolUrl);
497
+ const captureDriverOpts = { poolDriver: config.poolDriver, maxSessions: config.maxSessions };
498
+ await waitForAnyPool(capturePoolUrls, 30000, captureDriverOpts);
410
499
 
411
500
  let browser;
412
501
  try {
413
- browser = await connectToPool(config.poolUrl);
502
+ const capturePool = await selectPool(capturePoolUrls, 2000, 60000, captureDriverOpts);
503
+ browser = await connectToPool(capturePool);
414
504
  const page = await browser.newPage();
415
505
  await page.setViewport(config.viewport);
416
506
 
@@ -547,6 +637,46 @@ async function cmdIssue() {
547
637
  console.log('');
548
638
  }
549
639
 
640
+ async function cmdWatch() {
641
+ const cliArgs = parseCLIConfig();
642
+
643
+ // Parse watch-specific flags
644
+ if (getFlag('--interval')) cliArgs.watchInterval = getFlag('--interval');
645
+ if (getFlag('--webhook')) cliArgs.watchWebhookUrl = getFlag('--webhook');
646
+ if (getFlag('--webhook-events')) cliArgs.watchWebhookEvents = getFlag('--webhook-events');
647
+ if (hasFlag('--git')) cliArgs.watchGitPoll = true;
648
+ if (getFlag('--git-branch')) cliArgs.watchGitBranch = getFlag('--git-branch');
649
+ if (getFlag('--git-interval')) cliArgs.watchGitInterval = getFlag('--git-interval');
650
+ if (hasFlag('--no-run-on-start')) cliArgs.watchRunOnStart = false;
651
+
652
+ // Multi-project file
653
+ const projectsFile = getFlag('--projects');
654
+ if (projectsFile) {
655
+ const resolved = path.resolve(projectsFile);
656
+ if (!fs.existsSync(resolved)) {
657
+ console.error(`${C.red}Projects file not found: ${resolved}${C.reset}`);
658
+ process.exit(1);
659
+ }
660
+ cliArgs.watchProjects = JSON.parse(fs.readFileSync(resolved, 'utf-8'));
661
+ }
662
+
663
+ const config = await loadConfig(cliArgs);
664
+
665
+ console.log(`\n${C.bold}${C.cyan}@matware/e2e-runner${C.reset} v${pkg.version}`);
666
+ console.log(`${C.dim}Watch mode — dashboard on port ${config.dashboardPort}${C.reset}\n`);
667
+
668
+ const handle = await startWatch(config);
669
+
670
+ // Graceful shutdown
671
+ const shutdown = () => {
672
+ console.log(`\n${C.dim}Stopping watch...${C.reset}`);
673
+ handle.stop();
674
+ process.exit(0);
675
+ };
676
+ process.on('SIGINT', shutdown);
677
+ process.on('SIGTERM', shutdown);
678
+ }
679
+
550
680
  async function cmdLearnings() {
551
681
  const cliArgs = parseCLIConfig();
552
682
  const config = await loadConfig(cliArgs);
@@ -696,6 +826,261 @@ async function cmdNeo4j() {
696
826
  }
697
827
  }
698
828
 
829
+ // ==================== Sync ====================
830
+
831
+ async function cmdSync() {
832
+ const subCmd = args[1];
833
+ const cliArgs = parseCLIConfig();
834
+ const config = await loadConfig(cliArgs);
835
+
836
+ // Ensure schema is migrated
837
+ migrateSyncSchema();
838
+
839
+ switch (subCmd) {
840
+ case 'status': {
841
+ const mode = config.sync?.mode || 'standalone';
842
+ console.log(`\n${C.bold}Sync Status:${C.reset}\n`);
843
+ console.log(` Mode: ${C.cyan}${mode}${C.reset}`);
844
+
845
+ if (mode === 'hub') {
846
+ const instances = listInstances();
847
+ const active = instances.filter(i => i.status === 'active').length;
848
+ const online = instances.filter(i => {
849
+ if (!i.last_seen) return false;
850
+ const lastSeen = new Date(i.last_seen + 'Z').getTime();
851
+ return Date.now() - lastSeen < 5 * 60 * 1000;
852
+ }).length;
853
+ console.log(` Instances: ${instances.length} total, ${active} active, ${online} online`);
854
+ } else if (mode === 'agent') {
855
+ const conn = getHubConnection();
856
+ if (conn) {
857
+ console.log(` Hub URL: ${C.cyan}${conn.hub_url}${C.reset}`);
858
+ console.log(` Instance: ${conn.instance_id}`);
859
+ console.log(` Status: ${conn.status === 'connected' ? C.green : C.red}${conn.status}${C.reset}`);
860
+ console.log(` Last push: ${conn.last_push || 'never'}`);
861
+ console.log(` Last pull: ${conn.last_pull || 'never'}`);
862
+ } else {
863
+ console.log(` ${C.dim}Not connected to any hub${C.reset}`);
864
+ }
865
+
866
+ const queueStats = getQueueStats();
867
+ if (queueStats.length > 0) {
868
+ const pending = queueStats.find(s => s.status === 'pending')?.count || 0;
869
+ if (pending > 0) {
870
+ console.log(` Queue: ${C.yellow}${pending} pending${C.reset}`);
871
+ }
872
+ }
873
+ }
874
+ console.log('');
875
+ break;
876
+ }
877
+
878
+ case 'add-instance': {
879
+ if (config.sync?.mode !== 'hub') {
880
+ console.error(`${C.red}Error: This command only works in hub mode${C.reset}`);
881
+ process.exit(1);
882
+ }
883
+
884
+ const instanceId = getFlag('--id') || `instance-${Date.now().toString(36)}`;
885
+ const displayName = getFlag('--name') || instanceId;
886
+ const role = getFlag('--role') || 'member';
887
+ const environment = getFlag('--env') || 'development';
888
+
889
+ // Check if already exists
890
+ if (getInstance(instanceId)) {
891
+ console.error(`${C.red}Error: Instance '${instanceId}' already exists${C.reset}`);
892
+ process.exit(1);
893
+ }
894
+
895
+ // Generate credentials
896
+ const apiKey = generateApiKey();
897
+ const totpSecret = generateTotpSecret();
898
+
899
+ // Create instance
900
+ createInstance({
901
+ instanceId,
902
+ displayName,
903
+ hostname: null,
904
+ environment,
905
+ apiKeyHash: hashApiKey(apiKey),
906
+ totpSecret,
907
+ role,
908
+ status: config.sync?.hub?.requireApproval ? 'pending' : 'active',
909
+ });
910
+
911
+ console.log(`\n${C.green}${C.bold}Instance created successfully!${C.reset}\n`);
912
+ console.log(`${C.bold}Instance ID:${C.reset} ${instanceId}`);
913
+ console.log(`${C.bold}Display Name:${C.reset} ${displayName}`);
914
+ console.log(`${C.bold}Role:${C.reset} ${role}`);
915
+ console.log(`${C.bold}Status:${C.reset} ${config.sync?.hub?.requireApproval ? 'pending' : 'active'}`);
916
+ console.log('');
917
+ console.log(`${C.bold}${C.yellow}SAVE THESE CREDENTIALS (shown only once):${C.reset}`);
918
+ console.log(`${C.bold}API Key:${C.reset} ${apiKey}`);
919
+ console.log(`${C.bold}TOTP Secret:${C.reset} ${totpSecret}`);
920
+ console.log(`${C.bold}TOTP URI:${C.reset} ${generateTotpUri(totpSecret, instanceId)}`);
921
+ console.log('');
922
+ console.log(`${C.dim}Configure the agent with:${C.reset}`);
923
+ console.log(` export E2E_SYNC_API_KEY="${apiKey}"`);
924
+ console.log(` export E2E_SYNC_TOTP="${totpSecret}"`);
925
+ console.log('');
926
+ break;
927
+ }
928
+
929
+ case 'list-instances': {
930
+ if (config.sync?.mode !== 'hub') {
931
+ console.error(`${C.red}Error: This command only works in hub mode${C.reset}`);
932
+ process.exit(1);
933
+ }
934
+
935
+ const status = getFlag('--status');
936
+ const instances = listInstances(status);
937
+
938
+ console.log(`\n${C.bold}Registered Instances:${C.reset}\n`);
939
+
940
+ if (instances.length === 0) {
941
+ console.log(` ${C.dim}No instances registered${C.reset}`);
942
+ } else {
943
+ for (const inst of instances) {
944
+ const isOnline = inst.last_seen && (Date.now() - new Date(inst.last_seen + 'Z').getTime() < 5 * 60 * 1000);
945
+ const statusColor = inst.status === 'active' ? C.green : inst.status === 'pending' ? C.yellow : C.red;
946
+ const onlineIndicator = isOnline ? `${C.green}*${C.reset}` : ' ';
947
+
948
+ console.log(` ${onlineIndicator} ${C.bold}${inst.instance_id}${C.reset}`);
949
+ console.log(` Name: ${inst.display_name}`);
950
+ console.log(` Status: ${statusColor}${inst.status}${C.reset}`);
951
+ console.log(` Role: ${inst.role}`);
952
+ console.log(` Seen: ${inst.last_seen || 'never'}`);
953
+ console.log('');
954
+ }
955
+ }
956
+ break;
957
+ }
958
+
959
+ case 'approve': {
960
+ if (config.sync?.mode !== 'hub') {
961
+ console.error(`${C.red}Error: This command only works in hub mode${C.reset}`);
962
+ process.exit(1);
963
+ }
964
+
965
+ const instanceId = args[2];
966
+ if (!instanceId) {
967
+ console.error(`${C.red}Error: Instance ID required${C.reset}`);
968
+ process.exit(1);
969
+ }
970
+
971
+ const instance = getInstance(instanceId);
972
+ if (!instance) {
973
+ console.error(`${C.red}Error: Instance '${instanceId}' not found${C.reset}`);
974
+ process.exit(1);
975
+ }
976
+
977
+ updateInstanceStatus(instanceId, 'active');
978
+ console.log(`${C.green}Instance '${instanceId}' approved and activated${C.reset}`);
979
+ break;
980
+ }
981
+
982
+ case 'revoke': {
983
+ if (config.sync?.mode !== 'hub') {
984
+ console.error(`${C.red}Error: This command only works in hub mode${C.reset}`);
985
+ process.exit(1);
986
+ }
987
+
988
+ const instanceId = args[2];
989
+ if (!instanceId) {
990
+ console.error(`${C.red}Error: Instance ID required${C.reset}`);
991
+ process.exit(1);
992
+ }
993
+
994
+ updateInstanceStatus(instanceId, 'suspended');
995
+ console.log(`${C.yellow}Instance '${instanceId}' suspended${C.reset}`);
996
+ break;
997
+ }
998
+
999
+ case 'push': {
1000
+ if (config.sync?.mode !== 'agent') {
1001
+ console.error(`${C.red}Error: This command only works in agent mode${C.reset}`);
1002
+ process.exit(1);
1003
+ }
1004
+
1005
+ const client = await getSyncClient(config);
1006
+ if (!client.isConfigured()) {
1007
+ console.error(`${C.red}Error: Sync credentials not configured${C.reset}`);
1008
+ console.log(`${C.dim}Set E2E_SYNC_API_KEY and E2E_SYNC_TOTP environment variables${C.reset}`);
1009
+ process.exit(1);
1010
+ }
1011
+
1012
+ console.log('Processing sync queue...');
1013
+ await client.processQueue();
1014
+ console.log(`${C.green}Queue processed${C.reset}`);
1015
+ break;
1016
+ }
1017
+
1018
+ case 'pull': {
1019
+ if (config.sync?.mode !== 'agent') {
1020
+ console.error(`${C.red}Error: This command only works in agent mode${C.reset}`);
1021
+ process.exit(1);
1022
+ }
1023
+
1024
+ const since = getFlag('--since');
1025
+ const project = getFlag('--project');
1026
+ const limit = getFlag('--limit') ? parseInt(getFlag('--limit')) : 50;
1027
+
1028
+ console.log('Pulling runs from hub...');
1029
+ const result = await pullRuns(config, { since, project, limit });
1030
+
1031
+ if (result) {
1032
+ console.log(`${C.green}Pulled ${result.runs?.length || 0} runs${C.reset}`);
1033
+
1034
+ if (result.runs?.length > 0) {
1035
+ console.log('');
1036
+ for (const run of result.runs.slice(0, 10)) {
1037
+ const status = run.failed > 0 ? C.red + 'FAIL' : C.green + 'PASS';
1038
+ console.log(` ${status}${C.reset} ${run.project_name} - ${run.suite_name || 'default'} (${run.passed}/${run.total})`);
1039
+ }
1040
+ if (result.runs.length > 10) {
1041
+ console.log(` ${C.dim}... and ${result.runs.length - 10} more${C.reset}`);
1042
+ }
1043
+ }
1044
+ } else {
1045
+ console.log(`${C.yellow}No runs pulled (check configuration)${C.reset}`);
1046
+ }
1047
+ break;
1048
+ }
1049
+
1050
+ case 'generate-master-key': {
1051
+ const key = generateMasterKey();
1052
+ console.log(`\n${C.bold}Generated Master Key:${C.reset}\n`);
1053
+ console.log(` ${key}`);
1054
+ console.log('');
1055
+ console.log(`${C.dim}Set this in your hub environment:${C.reset}`);
1056
+ console.log(` export E2E_SYNC_MASTER_KEY="${key}"`);
1057
+ console.log('');
1058
+ break;
1059
+ }
1060
+
1061
+ default:
1062
+ console.log(`\n${C.bold}Sync Commands:${C.reset}\n`);
1063
+ console.log(' status Show sync status');
1064
+ console.log(' add-instance Register a new agent (hub mode)');
1065
+ console.log(' list-instances List registered agents (hub mode)');
1066
+ console.log(' approve <id> Approve pending agent (hub mode)');
1067
+ console.log(' revoke <id> Suspend an agent (hub mode)');
1068
+ console.log(' push Process sync queue (agent mode)');
1069
+ console.log(' pull Pull runs from hub (agent mode)');
1070
+ console.log(' generate-master-key Generate encryption master key');
1071
+ console.log('');
1072
+ console.log(`${C.bold}Options:${C.reset}`);
1073
+ console.log(' --id <id> Instance ID for add-instance');
1074
+ console.log(' --name <name> Display name for add-instance');
1075
+ console.log(' --role <role> Role: admin, member, readonly');
1076
+ console.log(' --status <status> Filter by status: pending, active, suspended');
1077
+ console.log(' --since <datetime> Pull runs since timestamp');
1078
+ console.log(' --project <slug> Filter by project');
1079
+ console.log(' --limit <n> Limit number of runs to pull');
1080
+ console.log('');
1081
+ }
1082
+ }
1083
+
699
1084
  // ==================== Main ====================
700
1085
 
701
1086
  async function main() {
@@ -728,6 +1113,10 @@ async function main() {
728
1113
  await cmdDashboard();
729
1114
  break;
730
1115
 
1116
+ case 'watch':
1117
+ await cmdWatch();
1118
+ break;
1119
+
731
1120
  case 'capture':
732
1121
  await cmdCapture();
733
1122
  break;
@@ -744,6 +1133,10 @@ async function main() {
744
1133
  await cmdNeo4j();
745
1134
  break;
746
1135
 
1136
+ case 'sync':
1137
+ await cmdSync();
1138
+ break;
1139
+
747
1140
  case 'init':
748
1141
  cmdInit();
749
1142
  break;
@@ -0,0 +1,45 @@
1
+ ---
2
+ description: Capture a screenshot of any URL with automatic authentication
3
+ user_invocable: true
4
+ allowed_tools:
5
+ - mcp__e2e-runner__e2e_pool_status
6
+ - mcp__e2e-runner__e2e_capture
7
+ - mcp__e2e-runner__e2e_analyze
8
+ - mcp__e2e-runner__e2e_screenshot
9
+ ---
10
+
11
+ # Quick Capture
12
+
13
+ Take a screenshot of any URL in one step. Handles pool checks and authentication automatically.
14
+
15
+ ## Workflow
16
+
17
+ 1. **Check pool** — Call `e2e_pool_status` to confirm the Chrome pool is running. If not available, tell the user to run `npx e2e-runner pool start` via CLI and stop.
18
+
19
+ 2. **Capture** — Call `e2e_capture` with:
20
+ - `url`: The URL from the user's request (REQUIRED)
21
+ - `cwd`: The current working directory (REQUIRED — always pass this)
22
+ - `fullPage`: true if user says "full page", "full", "complete", or "toda la página"
23
+ - `selector`: CSS selector if user wants to wait for a specific element
24
+ - `delay`: milliseconds if user says "wait", "delay", or "espera"
25
+ - `waitUntil`: "domcontentloaded" if user mentions WebSocket, SSE, or real-time apps
26
+ - `filename`: if user specifies a name
27
+
28
+ **Authentication is automatic**: the tool reads `authToken`, `authLoginEndpoint`, and `authCredentials` from the project's `e2e.config.js`. You do NOT need to pass `authToken` unless the user explicitly provides one.
29
+
30
+ 3. **Show result** — The tool returns the screenshot as an inline image. Show it to the user with the file path.
31
+
32
+ ## Arguments
33
+
34
+ The user passes the URL after the command:
35
+ - `/e2e-runner:capture http://localhost:3000/dashboard` → capture that URL
36
+ - `/e2e-runner:capture http://localhost/concept-maps --full-page` → full page capture
37
+ - `/e2e-runner:capture http://localhost/admin --delay 3000` → wait 3s before capture
38
+
39
+ If no URL is provided, ask the user for one.
40
+
41
+ ## Important
42
+
43
+ - Do NOT try to manually authenticate, fetch tokens, write test files, or use curl. The tool handles auth automatically from project config.
44
+ - Do NOT use the `e2e_run` tool — this is a screenshot capture, not a test run.
45
+ - Keep it simple: one `e2e_pool_status` call + one `e2e_capture` call. That's it.
@@ -37,11 +37,26 @@ Help the user create a new E2E test file by exploring the application and design
37
37
  - Add `wait` actions before assertions on dynamic content
38
38
  - Add `assert_no_network_errors` after critical page loads
39
39
  - Consider adding an `expect` field for visual verification
40
+ - **DRY**: If auth/setup is repeated across tests, use `beforeEach` hook (object format) instead of repeating per test
41
+ - **DRY**: If 3+ tests repeat the same action pattern, create a module with `e2e_create_module` first
40
42
 
41
- 7. **Create the test** — Call `e2e_create_test` with the designed test structure. Consider creating reusable modules with `e2e_create_module` for repeated sequences (auth, navigation).
43
+ 7. **Create the test** — Call `e2e_create_test` with the designed test structure.
44
+ - Check existing modules (`e2e/modules/`) — reuse them via `$use` instead of duplicating actions
45
+ - Create new modules with `e2e_create_module` for repeated sequences (auth, navigation, screenshot patterns)
46
+ - Use object format `{ "beforeEach": [...], "tests": [...] }` when hooks are needed
42
47
 
43
48
  8. **Validate** — Run the newly created test with `e2e_run` using the `suite` parameter. Analyze results and iterate if needed.
44
49
 
50
+ ## Naming Rules (CRITICAL)
51
+
52
+ Suite names MUST be unique and specific to the feature, issue, or user flow:
53
+ - GOOD: `login-valid-credentials`, `issue-1743-auth-redirect`, `checkout-payment-flow`
54
+ - BAD: `all`, `test`, `debug`, `new`, `temp`, `main`, `suite`
55
+
56
+ If testing a GitHub/GitLab issue, include the issue number: `issue-1743-auth-timeout`, `bug-502-duplicate-submit`
57
+
58
+ Before creating, always call `e2e_list` to verify the name doesn't already exist.
59
+
45
60
  ## Arguments
46
61
 
47
62
  The user may provide:
package/opencode.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "$schema": "https://opencode.ai/schemas/opencode.json",
3
+ "mcp": {
4
+ "e2e-runner": {
5
+ "type": "local",
6
+ "command": "node",
7
+ "args": ["bin/mcp-server.js"],
8
+ "cwd": "${workspaceFolder}"
9
+ }
10
+ }
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matware/e2e-runner",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
4
4
  "mcpName": "io.github.fastslack/e2e-runner",
5
5
  "description": "E2E test runner using Chrome Pool (browserless/chrome) with parallel execution",
6
6
  "type": "module",
@@ -20,7 +20,11 @@
20
20
  ".mcp.json",
21
21
  "skills/",
22
22
  "commands/",
23
- "agents/"
23
+ "agents/",
24
+ ".opencode/",
25
+ "opencode.json",
26
+ "OPENCODE.md",
27
+ "scripts/setup-opencode.sh"
24
28
  ],
25
29
  "keywords": [
26
30
  "e2e",
@@ -31,6 +35,7 @@
31
35
  "parallel",
32
36
  "mcp",
33
37
  "claude-code",
38
+ "opencode",
34
39
  "github-issues",
35
40
  "ai-testing"
36
41
  ],