@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.
- package/.claude-plugin/marketplace.json +52 -0
- package/.claude-plugin/plugin.json +17 -3
- package/.mcp.json +2 -2
- package/.opencode/commands/create-test.md +63 -0
- package/.opencode/commands/run.md +50 -0
- package/.opencode/commands/verify-issue.md +62 -0
- package/.opencode/skills/e2e-testing/SKILL.md +181 -0
- package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
- package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
- package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
- package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
- package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
- package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
- package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
- package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
- package/.opencode/skills/e2e-testing/references/variables.md +41 -0
- package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
- package/LICENSE +190 -0
- package/OPENCODE.md +166 -0
- package/README.md +165 -104
- package/agents/test-creator.md +54 -1
- package/agents/test-improver.md +37 -0
- package/bin/cli.js +409 -16
- package/commands/capture.md +45 -0
- package/commands/create-test.md +16 -1
- package/opencode.json +11 -0
- package/package.json +7 -2
- package/scripts/setup-opencode.sh +113 -0
- package/skills/e2e-testing/SKILL.md +10 -3
- package/skills/e2e-testing/references/action-types.md +48 -5
- package/skills/e2e-testing/references/auth-strategies.md +91 -0
- package/skills/e2e-testing/references/graphql.md +59 -0
- package/skills/e2e-testing/references/issue-verification.md +59 -0
- package/skills/e2e-testing/references/multi-pool.md +60 -0
- package/skills/e2e-testing/references/network-debugging.md +62 -0
- package/skills/e2e-testing/references/test-json-format.md +4 -0
- package/skills/e2e-testing/references/troubleshooting.md +44 -2
- package/skills/e2e-testing/references/variables.md +41 -0
- package/skills/e2e-testing/references/visual-verification.md +89 -0
- package/src/actions.js +475 -2
- package/src/ai-generate.js +139 -8
- package/src/app-pool.js +339 -0
- package/src/config.js +266 -5
- package/src/dashboard.js +216 -17
- package/src/db.js +191 -7
- package/src/index.js +12 -9
- package/src/learner-sqlite.js +458 -0
- package/src/learner.js +78 -6
- package/src/mcp-tools.js +1348 -51
- package/src/module-resolver.js +37 -0
- package/src/narrate.js +65 -0
- package/src/pool-manager.js +229 -0
- package/src/pool.js +301 -31
- package/src/reporter.js +86 -2
- package/src/runner.js +480 -71
- package/src/sync/auth.js +354 -0
- package/src/sync/client.js +572 -0
- package/src/sync/hub-routes.js +816 -0
- package/src/sync/index.js +68 -0
- package/src/sync/middleware.js +347 -0
- package/src/sync/queue.js +209 -0
- package/src/sync/schema.js +540 -0
- package/src/verify.js +10 -7
- package/src/visual-diff.js +446 -0
- package/src/watch.js +384 -0
- package/templates/build-dashboard.js +47 -6
- package/templates/dashboard/js/api.js +62 -0
- package/templates/dashboard/js/init.js +13 -0
- package/templates/dashboard/js/keyboard.js +46 -0
- package/templates/dashboard/js/state.js +40 -0
- package/templates/dashboard/js/toast.js +41 -0
- package/templates/dashboard/js/utils.js +216 -0
- package/templates/dashboard/js/view-live.js +181 -0
- package/templates/dashboard/js/view-runs.js +676 -0
- package/templates/dashboard/js/view-tests.js +294 -0
- package/templates/dashboard/js/view-watch.js +242 -0
- package/templates/dashboard/js/websocket.js +116 -0
- package/templates/dashboard/styles/base.css +69 -0
- package/templates/dashboard/styles/components.css +117 -0
- package/templates/dashboard/styles/view-live.css +97 -0
- package/templates/dashboard/styles/view-runs.css +243 -0
- package/templates/dashboard/styles/view-tests.css +96 -0
- package/templates/dashboard/styles/view-watch.css +53 -0
- package/templates/dashboard/template.html +181 -100
- package/templates/dashboard.html +1614 -547
- package/templates/sample-test.json +0 -8
- package/templates/dashboard/app.js +0 -1152
- 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,
|
|
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
|
-
|
|
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('🔌',
|
|
202
|
-
const pressure = await
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
286
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/commands/create-test.md
CHANGED
|
@@ -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.
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@matware/e2e-runner",
|
|
3
|
-
"version": "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
|
],
|