@matware/e2e-runner 1.2.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 (82) hide show
  1. package/.claude-plugin/marketplace.json +21 -0
  2. package/.mcp.json +2 -2
  3. package/.opencode/commands/create-test.md +63 -0
  4. package/.opencode/commands/run.md +50 -0
  5. package/.opencode/commands/verify-issue.md +62 -0
  6. package/.opencode/skills/e2e-testing/SKILL.md +181 -0
  7. package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
  8. package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
  9. package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
  10. package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
  11. package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
  12. package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
  13. package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
  14. package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
  15. package/.opencode/skills/e2e-testing/references/variables.md +41 -0
  16. package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
  17. package/OPENCODE.md +166 -0
  18. package/README.md +581 -55
  19. package/agents/test-creator.md +54 -1
  20. package/agents/test-improver.md +37 -0
  21. package/bin/cli.js +408 -16
  22. package/commands/create-test.md +16 -1
  23. package/opencode.json +11 -0
  24. package/package.json +7 -2
  25. package/scripts/setup-opencode.sh +113 -0
  26. package/skills/e2e-testing/SKILL.md +10 -3
  27. package/skills/e2e-testing/references/action-types.md +48 -5
  28. package/skills/e2e-testing/references/auth-strategies.md +91 -0
  29. package/skills/e2e-testing/references/graphql.md +59 -0
  30. package/skills/e2e-testing/references/issue-verification.md +59 -0
  31. package/skills/e2e-testing/references/multi-pool.md +60 -0
  32. package/skills/e2e-testing/references/network-debugging.md +62 -0
  33. package/skills/e2e-testing/references/test-json-format.md +4 -0
  34. package/skills/e2e-testing/references/troubleshooting.md +44 -2
  35. package/skills/e2e-testing/references/variables.md +41 -0
  36. package/skills/e2e-testing/references/visual-verification.md +89 -0
  37. package/src/actions.js +324 -2
  38. package/src/ai-generate.js +58 -8
  39. package/src/config.js +143 -0
  40. package/src/dashboard.js +145 -13
  41. package/src/db.js +130 -2
  42. package/src/index.js +7 -6
  43. package/src/learner-sqlite.js +304 -0
  44. package/src/learner.js +8 -3
  45. package/src/mcp-tools.js +1121 -43
  46. package/src/module-resolver.js +37 -0
  47. package/src/narrate.js +37 -0
  48. package/src/pool-manager.js +223 -0
  49. package/src/reporter.js +82 -1
  50. package/src/runner.js +157 -28
  51. package/src/sync/auth.js +354 -0
  52. package/src/sync/client.js +572 -0
  53. package/src/sync/hub-routes.js +816 -0
  54. package/src/sync/index.js +68 -0
  55. package/src/sync/middleware.js +347 -0
  56. package/src/sync/queue.js +209 -0
  57. package/src/sync/schema.js +540 -0
  58. package/src/verify.js +10 -7
  59. package/src/watch.js +384 -0
  60. package/templates/build-dashboard.js +47 -6
  61. package/templates/dashboard/js/api.js +60 -0
  62. package/templates/dashboard/js/init.js +13 -0
  63. package/templates/dashboard/js/keyboard.js +46 -0
  64. package/templates/dashboard/js/state.js +40 -0
  65. package/templates/dashboard/js/toast.js +41 -0
  66. package/templates/dashboard/js/utils.js +196 -0
  67. package/templates/dashboard/js/view-live.js +143 -0
  68. package/templates/dashboard/js/view-runs.js +572 -0
  69. package/templates/dashboard/js/view-tests.js +294 -0
  70. package/templates/dashboard/js/view-watch.js +242 -0
  71. package/templates/dashboard/js/websocket.js +110 -0
  72. package/templates/dashboard/styles/base.css +69 -0
  73. package/templates/dashboard/styles/components.css +110 -0
  74. package/templates/dashboard/styles/view-live.css +74 -0
  75. package/templates/dashboard/styles/view-runs.css +207 -0
  76. package/templates/dashboard/styles/view-tests.css +96 -0
  77. package/templates/dashboard/styles/view-watch.css +53 -0
  78. package/templates/dashboard/template.html +165 -99
  79. package/templates/dashboard.html +1596 -541
  80. package/templates/sample-test.json +0 -8
  81. package/templates/dashboard/app.js +0 -1152
  82. package/templates/dashboard/styles.css +0 -413
@@ -69,6 +69,17 @@ You are a specialist in creating robust E2E tests for web applications. You expl
69
69
  - Clear field → `clear`
70
70
  - Submit → `click` on submit button or `press` Enter
71
71
 
72
+ ### Storage
73
+ - Set localStorage key → `set_storage` with `value: "key=val"`
74
+ - Set sessionStorage key → `set_storage` with `value: "key=val"`, `selector: "session"`
75
+ - Assert storage key exists → `assert_storage` with `value: "key"`
76
+ - Assert storage value → `assert_storage` with `value: "key=expected"`
77
+
78
+ ### Smart Clicks
79
+ - Click icon button → `click_icon` with `value` (icon identifier like "edit", "delete")
80
+ - Click menu item → `click_menu_item` with `text` (after opening the menu)
81
+ - Click element in a specific row/card → `click_in_context` with `text` (row text) + `selector` (child to click)
82
+
72
83
  ### Waiting
73
84
  - Element appears → `wait` with `selector`
74
85
  - Text appears → `wait` with `text`
@@ -86,12 +97,54 @@ You are a specialist in creating robust E2E tests for web applications. You expl
86
97
  - CSS class → `assert_class`
87
98
  - URL → `assert_url`
88
99
 
100
+ ### Naming Rules (CRITICAL)
101
+ - **Suite file names MUST be unique and specific** to the feature, issue, or user flow being tested
102
+ - NEVER use generic names like `all`, `test`, `tests`, `debug`, `new`, `temp`, `main`, `suite`
103
+ - Include the feature or issue context: `login-valid-credentials`, `issue-1743-auth-redirect`, `checkout-payment-flow`
104
+ - If testing a GitHub/GitLab issue, include the issue number: `issue-1743-auth-timeout`, `bug-502-duplicate-submit`
105
+ - Before creating a test, call `e2e_list` and verify your chosen name doesn't already exist
106
+ - Individual test names within a suite must also be unique and descriptive
107
+
108
+ ### Variables
109
+ - Use `{{var.KEY}}` to reference project variables instead of hardcoding sensitive values (tokens, IDs, secrets)
110
+ - Use `{{env.KEY}}` to reference environment variables from `process.env`
111
+ - Variables are stored in SQLite and managed via `e2e_vars` MCP tool or the dashboard UI
112
+ - Suite-scoped variables override project-scoped variables with the same key
113
+ - Example: `{ "type": "set_storage", "value": "accessToken={{var.JWT_TOKEN}}" }`
114
+ - Example: `{ "type": "goto", "value": "/patient/{{var.PATIENT_ID}}" }`
115
+
116
+ ### DRY Patterns (CRITICAL)
117
+
118
+ Before creating tests, **always check existing modules** with `Glob` on `e2e/modules/*.json`. Reuse existing modules instead of duplicating actions.
119
+
120
+ **Use `beforeEach` when auth or setup is repeated across tests:**
121
+ When multiple tests in a suite share the same setup (e.g., same auth-jwt call), use the object format with `beforeEach` instead of repeating it in every test:
122
+
123
+ ```json
124
+ {
125
+ "beforeEach": [
126
+ { "$use": "auth-jwt", "params": { "token": "{{var.JWT_TOKEN}}", "institutionId": "{{var.INST_ID}}" } }
127
+ ],
128
+ "tests": [
129
+ { "name": "test-one", "actions": [...] },
130
+ { "name": "test-two", "actions": [...] }
131
+ ]
132
+ }
133
+ ```
134
+
135
+ **Create modules for repeated action patterns:**
136
+ When 3+ tests repeat the same sequence (e.g., goto → wait → screenshot), create a module first with `e2e_create_module`, then use `$use` in the tests. This reduces test size by 70-80%.
137
+
138
+ **Use the object format (not array) when hooks are needed:**
139
+ - Array format: `[{ "name": ..., "actions": [...] }]` — no hooks
140
+ - Object format: `{ "beforeEach": [...], "tests": [...] }` — with hooks
141
+
89
142
  ### Best Practices
90
143
  - Never use `evaluate` when a built-in action exists
144
+ - **Never hardcode tokens, passwords, or IDs in test files** — use `{{var.KEY}}` variables instead
91
145
  - Add `retries` to actions on dynamically loaded elements
92
146
  - Mark state-sharing tests as `serial: true`
93
147
  - Use `screenshot` actions at key points for debugging
94
- - Keep test names descriptive and kebab-case (`login-valid-credentials`)
95
148
 
96
149
  ## Output
97
150
 
@@ -73,6 +73,11 @@ When you find an `evaluate` action, check if it matches one of these patterns
73
73
  | `MuiAutocomplete-root...input.focus()` | `focus_autocomplete` with `text` |
74
74
  | `querySelectorAll('button').filter(regex)...click()` | `click_regex` with `text` + optional `selector` + `value` |
75
75
  | `querySelectorAll('[class*="Chip"]')...click()` | `click_chip` with `text` |
76
+ | `localStorage.setItem(key, val)` or `sessionStorage.setItem(...)` | `set_storage` with `value: "key=val"`, `selector: "session"` for session |
77
+ | `localStorage.getItem(key)` check or `sessionStorage.getItem(...)` | `assert_storage` with `value: "key"` or `"key=expected"`, `selector: "session"` for session |
78
+ | `querySelector('svg[data-testid]').closest('button').click()` | `click_icon` with `value` (icon identifier) + optional `selector` (scope) |
79
+ | `querySelectorAll('[role="menuitem"]')...click()` | `click_menu_item` with `text` + optional `selector` (scope) |
80
+ | Container-by-text then child click: `rows.find(r => r.textContent.includes(text))...querySelector(child).click()` | `click_in_context` with `text` (container) + `selector` (child) |
76
81
  | `document.title` or simple property read | `get_text` or `evaluate` (keep if no built-in equivalent) |
77
82
 
78
83
  ### Replacement Examples
@@ -101,6 +106,38 @@ When you find an `evaluate` action, check if it matches one of these patterns
101
106
  { "type": "click_option", "text": "Cefalea" }
102
107
  ```
103
108
 
109
+ ```json
110
+ // BEFORE: evaluate for localStorage
111
+ { "type": "evaluate", "value": "localStorage.setItem('authToken', 'abc123')" }
112
+
113
+ // AFTER: one action
114
+ { "type": "set_storage", "value": "authToken=abc123" }
115
+ ```
116
+
117
+ ```json
118
+ // BEFORE: evaluate for icon click
119
+ { "type": "evaluate", "value": "document.querySelector('svg[data-testid=\"EditIcon\"]').closest('button').click()" }
120
+
121
+ // AFTER: one action
122
+ { "type": "click_icon", "value": "Edit" }
123
+ ```
124
+
125
+ ```json
126
+ // BEFORE: evaluate for menu item click
127
+ { "type": "evaluate", "value": "const items = [...document.querySelectorAll('[role=\"menuitem\"]')]; items.find(el => el.textContent.includes('Delete')).click();" }
128
+
129
+ // AFTER: one action
130
+ { "type": "click_menu_item", "text": "Delete" }
131
+ ```
132
+
133
+ ```json
134
+ // BEFORE: evaluate for contextual click
135
+ { "type": "evaluate", "value": "const rows = [...document.querySelectorAll('tr')]; const row = rows.find(r => r.textContent.includes('John Doe')); row.querySelector('button.edit').click();" }
136
+
137
+ // AFTER: one action
138
+ { "type": "click_in_context", "text": "John Doe", "selector": "button.edit" }
139
+ ```
140
+
104
141
  ## Duplication Detection
105
142
 
106
143
  Look for these common duplication patterns:
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);
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);
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,14 @@ 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
+ await waitForAnyPool(capturePoolUrls);
410
498
 
411
499
  let browser;
412
500
  try {
413
- browser = await connectToPool(config.poolUrl);
501
+ const capturePool = await selectPool(capturePoolUrls);
502
+ browser = await connectToPool(capturePool);
414
503
  const page = await browser.newPage();
415
504
  await page.setViewport(config.viewport);
416
505
 
@@ -547,6 +636,46 @@ async function cmdIssue() {
547
636
  console.log('');
548
637
  }
549
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
+
550
679
  async function cmdLearnings() {
551
680
  const cliArgs = parseCLIConfig();
552
681
  const config = await loadConfig(cliArgs);
@@ -696,6 +825,261 @@ async function cmdNeo4j() {
696
825
  }
697
826
  }
698
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
+
699
1083
  // ==================== Main ====================
700
1084
 
701
1085
  async function main() {
@@ -728,6 +1112,10 @@ async function main() {
728
1112
  await cmdDashboard();
729
1113
  break;
730
1114
 
1115
+ case 'watch':
1116
+ await cmdWatch();
1117
+ break;
1118
+
731
1119
  case 'capture':
732
1120
  await cmdCapture();
733
1121
  break;
@@ -744,6 +1132,10 @@ async function main() {
744
1132
  await cmdNeo4j();
745
1133
  break;
746
1134
 
1135
+ case 'sync':
1136
+ await cmdSync();
1137
+ break;
1138
+
747
1139
  case 'init':
748
1140
  cmdInit();
749
1141
  break;