@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.
- package/.claude-plugin/marketplace.json +21 -0
- 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/OPENCODE.md +166 -0
- package/README.md +581 -55
- package/agents/test-creator.md +54 -1
- package/agents/test-improver.md +37 -0
- package/bin/cli.js +408 -16
- 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 +324 -2
- package/src/ai-generate.js +58 -8
- package/src/config.js +143 -0
- package/src/dashboard.js +145 -13
- package/src/db.js +130 -2
- package/src/index.js +7 -6
- package/src/learner-sqlite.js +304 -0
- package/src/learner.js +8 -3
- package/src/mcp-tools.js +1121 -43
- package/src/module-resolver.js +37 -0
- package/src/narrate.js +37 -0
- package/src/pool-manager.js +223 -0
- package/src/reporter.js +82 -1
- package/src/runner.js +157 -28
- 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/watch.js +384 -0
- package/templates/build-dashboard.js +47 -6
- package/templates/dashboard/js/api.js +60 -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 +196 -0
- package/templates/dashboard/js/view-live.js +143 -0
- package/templates/dashboard/js/view-runs.js +572 -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 +110 -0
- package/templates/dashboard/styles/base.css +69 -0
- package/templates/dashboard/styles/components.css +110 -0
- package/templates/dashboard/styles/view-live.css +74 -0
- package/templates/dashboard/styles/view-runs.css +207 -0
- package/templates/dashboard/styles/view-tests.css +96 -0
- package/templates/dashboard/styles/view-watch.css +53 -0
- package/templates/dashboard/template.html +165 -99
- package/templates/dashboard.html +1596 -541
- package/templates/sample-test.json +0 -8
- package/templates/dashboard/app.js +0 -1152
- package/templates/dashboard/styles.css +0 -413
package/agents/test-creator.md
CHANGED
|
@@ -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
|
|
package/agents/test-improver.md
CHANGED
|
@@ -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,
|
|
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);
|
|
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);
|
|
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,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
|
|
497
|
+
await waitForAnyPool(capturePoolUrls);
|
|
410
498
|
|
|
411
499
|
let browser;
|
|
412
500
|
try {
|
|
413
|
-
|
|
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;
|