@matware/e2e-runner 1.1.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +21 -0
- package/.claude-plugin/plugin.json +9 -0
- package/.mcp.json +9 -0
- 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 +990 -296
- package/agents/test-analyzer.md +81 -0
- package/agents/test-creator.md +155 -0
- package/agents/test-improver.md +177 -0
- package/bin/cli.js +602 -22
- package/commands/create-test.md +65 -0
- package/commands/run.md +49 -0
- package/commands/verify-issue.md +63 -0
- package/opencode.json +11 -0
- package/package.json +15 -2
- package/scripts/setup-opencode.sh +113 -0
- package/skills/e2e-testing/SKILL.md +173 -0
- package/skills/e2e-testing/references/action-types.md +143 -0
- 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 +163 -0
- package/skills/e2e-testing/references/troubleshooting.md +224 -0
- package/skills/e2e-testing/references/variables.md +41 -0
- package/skills/e2e-testing/references/visual-verification.md +89 -0
- package/src/actions.js +597 -20
- package/src/ai-generate.js +142 -12
- package/src/config.js +171 -0
- package/src/dashboard.js +299 -17
- package/src/db.js +335 -13
- package/src/index.js +15 -8
- package/src/learner-markdown.js +177 -0
- package/src/learner-neo4j.js +255 -0
- package/src/learner-sqlite.js +658 -0
- package/src/learner.js +418 -0
- package/src/mcp-tools.js +1558 -50
- package/src/module-resolver.js +310 -0
- package/src/narrate.js +262 -0
- package/src/neo4j-pool.js +124 -0
- package/src/pool-manager.js +223 -0
- package/src/reporter.js +117 -3
- package/src/runner.js +274 -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 +14 -9
- package/src/watch.js +384 -0
- package/templates/build-dashboard.js +69 -0
- 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 +267 -0
- package/templates/dashboard.html +2171 -530
- package/templates/docker-compose-neo4j.yml +19 -0
- package/templates/e2e.config.js +3 -0
- package/templates/sample-test.json +0 -8
package/src/ai-generate.js
CHANGED
|
@@ -27,23 +27,110 @@ The test format is:
|
|
|
27
27
|
{ "type": "wait", "text": "Expected text" },
|
|
28
28
|
{ "type": "wait", "value": "2000" },
|
|
29
29
|
{ "type": "assert_text", "text": "Expected text on page" },
|
|
30
|
+
{ "type": "assert_element_text", "selector": "#title", "text": "Dashboard" },
|
|
31
|
+
{ "type": "assert_element_text", "selector": "#title", "text": "Dashboard", "value": "exact" },
|
|
32
|
+
{ "type": "assert_attribute", "selector": "input#email", "value": "type=email" },
|
|
33
|
+
{ "type": "assert_attribute", "selector": "button", "value": "disabled" },
|
|
34
|
+
{ "type": "assert_class", "selector": ".nav-item", "value": "active" },
|
|
35
|
+
{ "type": "assert_not_visible", "selector": ".error-banner" },
|
|
36
|
+
{ "type": "assert_input_value", "selector": "#email", "value": "user@example.com" },
|
|
37
|
+
{ "type": "assert_matches", "selector": ".phone", "value": "\\\\d{3}-\\\\d{3}-\\\\d{4}" },
|
|
30
38
|
{ "type": "assert_url", "value": "/expected-path" },
|
|
31
39
|
{ "type": "assert_visible", "selector": ".element" },
|
|
32
40
|
{ "type": "assert_count", "selector": ".items", "value": "5" },
|
|
41
|
+
{ "type": "assert_count", "selector": ".rows", "value": ">3" },
|
|
42
|
+
{ "type": "assert_count", "selector": ".errors", "value": "0" },
|
|
43
|
+
{ "type": "get_text", "selector": "#patient-name" },
|
|
33
44
|
{ "type": "screenshot", "value": "step-name.png" },
|
|
34
45
|
{ "type": "select", "selector": "select#role", "value": "admin" },
|
|
35
46
|
{ "type": "clear", "selector": "input" },
|
|
36
47
|
{ "type": "press", "value": "Enter" },
|
|
37
48
|
{ "type": "scroll", "selector": ".target" },
|
|
38
49
|
{ "type": "hover", "selector": ".menu" },
|
|
39
|
-
{ "type": "evaluate", "value": "document.title" }
|
|
50
|
+
{ "type": "evaluate", "value": "document.title" },
|
|
51
|
+
{ "type": "type_react", "selector": "input#search", "value": "search term" },
|
|
52
|
+
{ "type": "click_regex", "text": "submit order", "selector": "button", "value": "last" },
|
|
53
|
+
{ "type": "click_option", "text": "Option Label" },
|
|
54
|
+
{ "type": "focus_autocomplete", "text": "Search by label" },
|
|
55
|
+
{ "type": "click_chip", "text": "Tag Name" },
|
|
56
|
+
{ "type": "set_storage", "value": "token=abc123" },
|
|
57
|
+
{ "type": "set_storage", "value": "theme=dark", "selector": "session" },
|
|
58
|
+
{ "type": "assert_storage", "value": "token" },
|
|
59
|
+
{ "type": "assert_storage", "value": "theme=dark", "selector": "session" },
|
|
60
|
+
{ "type": "click_icon", "value": "edit" },
|
|
61
|
+
{ "type": "click_icon", "value": "delete", "selector": ".user-card" },
|
|
62
|
+
{ "type": "click_menu_item", "text": "Delete" },
|
|
63
|
+
{ "type": "click_menu_item", "text": "Export", "selector": ".actions-menu" },
|
|
64
|
+
{ "type": "click_in_context", "text": "John Doe", "selector": "button.edit" },
|
|
65
|
+
{ "type": "gql", "value": "{ users { id name } }" },
|
|
66
|
+
{ "type": "gql", "value": "query($id: ID) { user(id: $id) { name } }", "text": "{\"id\": \"123\"}" }
|
|
40
67
|
]
|
|
41
68
|
}
|
|
42
69
|
]
|
|
43
70
|
|
|
71
|
+
Framework-aware action reference (prefer these over evaluate for React/MUI apps):
|
|
72
|
+
- type_react: types into React controlled inputs using native value setter + input/change events (works with both input and textarea)
|
|
73
|
+
- click_regex: click element by regex text match (case-insensitive). Use "value": "last" for last match. Optional "selector" scopes the search
|
|
74
|
+
- click_option: click a [role="option"] element by text — for autocomplete/select dropdowns
|
|
75
|
+
- focus_autocomplete: focus an autocomplete input by its label text (supports MUI .MuiAutocomplete-root and [role="combobox"])
|
|
76
|
+
- click_chip: click a chip/tag element by text (searches [class*="Chip"], [data-chip])
|
|
77
|
+
|
|
78
|
+
Storage actions:
|
|
79
|
+
- set_storage: set a localStorage key. "value": "key=val". Use "selector": "session" for sessionStorage
|
|
80
|
+
- assert_storage: assert a storage key exists ("value": "key") or has a value ("value": "key=expected"). Use "selector": "session" for sessionStorage
|
|
81
|
+
|
|
82
|
+
GraphQL action:
|
|
83
|
+
- gql: execute a GraphQL query/mutation via browser fetch. Auth token is read from localStorage automatically (configurable via gqlAuthHeader, gqlAuthKey, gqlAuthPrefix). "value" is the query string. "text" is variables as JSON string. "selector" is an optional JS assertion expression (receives response as "r"). Throws on GraphQL errors automatically. Also installs window.__e2eGql(query, vars) for use in subsequent evaluate actions
|
|
84
|
+
|
|
85
|
+
Smart interaction actions:
|
|
86
|
+
- click_icon: click an icon by identifier (data-testid fragment, class fragment, aria-label, SVG title). Walks up to nearest clickable parent (button, a, etc.). Optional "selector" scopes the search
|
|
87
|
+
- click_menu_item: click a menu item by text. Searches [role="menuitem"], .dropdown-item, .menu-item, [class*="MenuItem"]. Optional "selector" scopes the search
|
|
88
|
+
- click_in_context: click a child element within a container identified by text. "text" finds the container, "selector" is the child to click. Picks the smallest matching container
|
|
89
|
+
|
|
90
|
+
Assertion action reference:
|
|
91
|
+
- assert_text: checks if text appears anywhere in the page body
|
|
92
|
+
- assert_element_text: checks textContent of a specific element (use "value": "exact" for strict match)
|
|
93
|
+
- assert_attribute: checks HTML attributes — "attr=value" for value check, "attr" alone for existence
|
|
94
|
+
- assert_class: checks if element has a CSS class via classList.contains
|
|
95
|
+
- assert_visible / assert_not_visible: checks element visibility (display, visibility, opacity)
|
|
96
|
+
- assert_input_value: checks the .value of input/select/textarea elements
|
|
97
|
+
- assert_matches: checks element textContent against a regex pattern
|
|
98
|
+
- assert_count: counts matching elements — exact number or operators (">3", ">=1", "<10", "<=5")
|
|
99
|
+
- assert_url: checks if current URL contains the value
|
|
100
|
+
- get_text: extracts element text (non-assertion, returns { value })
|
|
101
|
+
|
|
102
|
+
Reusable modules:
|
|
103
|
+
- Tests can reference shared action sequences: { "$use": "module-name", "params": { "key": "value" } }
|
|
104
|
+
- Use modules for repeated flows like login, navigation, or setup
|
|
105
|
+
|
|
106
|
+
Hooks and DRY patterns:
|
|
107
|
+
- When multiple tests share the same setup (e.g. authentication), use beforeEach instead of repeating it per test
|
|
108
|
+
- Object format with hooks: { "beforeEach": [...], "tests": [{ "name": "...", "actions": [...] }] }
|
|
109
|
+
- Array format (no hooks): [{ "name": "...", "actions": [...] }]
|
|
110
|
+
- If 3+ tests repeat the same action sequence (e.g. goto + wait + screenshot), extract it into a module
|
|
111
|
+
- NEVER repeat the same $use call with identical params across all tests — move it to beforeEach
|
|
112
|
+
|
|
44
113
|
Rules:
|
|
45
|
-
- Output
|
|
46
|
-
-
|
|
114
|
+
- Output valid JSON: either a plain array of test objects, or an object with "beforeEach"/"tests" keys when hooks are needed
|
|
115
|
+
- NEVER use evaluate with inline JS for assertions that can be done with native action types:
|
|
116
|
+
* Use assert_element_text instead of evaluate to check element textContent
|
|
117
|
+
* Use assert_attribute instead of evaluate to check HTML attributes
|
|
118
|
+
* Use assert_class instead of evaluate to check CSS classes
|
|
119
|
+
* Use assert_input_value instead of evaluate to check input/select/textarea values
|
|
120
|
+
* Use assert_matches instead of evaluate for regex text matching
|
|
121
|
+
* Use assert_not_visible instead of evaluate to verify elements are hidden
|
|
122
|
+
* Use type_react instead of evaluate with native value setter for React controlled inputs
|
|
123
|
+
* Use click_regex instead of evaluate with Array.from(querySelectorAll).filter(regex) patterns
|
|
124
|
+
* Use click_option instead of evaluate with querySelectorAll('[role="option"]') patterns
|
|
125
|
+
* Use focus_autocomplete instead of evaluate with MuiAutocomplete-root label search patterns
|
|
126
|
+
* Use click_chip instead of evaluate with querySelectorAll('[class*="Chip"]') patterns
|
|
127
|
+
* Use set_storage instead of evaluate with localStorage.setItem or sessionStorage.setItem
|
|
128
|
+
* Use assert_storage instead of evaluate with localStorage.getItem or sessionStorage.getItem checks
|
|
129
|
+
* Use click_icon instead of evaluate with querySelector('svg[data-testid]').closest('button').click() patterns
|
|
130
|
+
* Use click_menu_item instead of evaluate with querySelectorAll('[role="menuitem"]') patterns
|
|
131
|
+
* Use click_in_context instead of evaluate that finds a container by text then clicks a child element
|
|
132
|
+
* Use gql instead of evaluate with fetch + JSON.stringify + GraphQL queries/mutations
|
|
133
|
+
* Reserve evaluate ONLY for complex logic that cannot be expressed with existing action types
|
|
47
134
|
- "click" with "text" (no selector) finds buttons/links by visible text
|
|
48
135
|
- "goto" values starting with "/" are relative to the app's base URL
|
|
49
136
|
- Include a screenshot action before key assertions for debugging
|
|
@@ -53,6 +140,29 @@ Rules:
|
|
|
53
140
|
- If the issue description is vague, create a reasonable test that covers the described scenario
|
|
54
141
|
- If project context is provided (from CLAUDE.md), use the REAL routes, selectors, and UI patterns described there — never invent routes or selectors`;
|
|
55
142
|
|
|
143
|
+
const E2E_RULES = `
|
|
144
|
+
CRITICAL — UI-first testing rules:
|
|
145
|
+
- Every test MUST start with a "goto" action to navigate to a real page
|
|
146
|
+
- Tests MUST interact with UI elements: click, type, select, hover, scroll
|
|
147
|
+
- Verify results through visible page state: assert_text, assert_visible, assert_element_text, assert_url
|
|
148
|
+
- NEVER use evaluate to call APIs directly (no fetch, no GraphQL, no XHR, no window.__e2e.gql)
|
|
149
|
+
- NEVER use evaluate to set up or verify data — use the UI workflow instead
|
|
150
|
+
- Test the user journey as a real person would use the application
|
|
151
|
+
- Include screenshot actions before key assertions for debugging
|
|
152
|
+
`;
|
|
153
|
+
|
|
154
|
+
const API_RULES = `
|
|
155
|
+
API testing rules:
|
|
156
|
+
- Tests verify backend API behavior directly via gql actions (preferred) or evaluate actions
|
|
157
|
+
- Each test should: set up context → call API → assert response shape and values
|
|
158
|
+
- PREFER the gql action for GraphQL queries/mutations — it handles auth and error checking automatically
|
|
159
|
+
- Use gql with "selector" field for inline assertions on the response (JS expression where "r" is the response)
|
|
160
|
+
- Use evaluate with window.__e2eGql() for complex multi-step GraphQL operations (the helper is installed by any gql action)
|
|
161
|
+
- Name tests clearly describing the API operation (e.g. "createUser-returns-new-user")
|
|
162
|
+
- Include error case tests (invalid input, missing fields, auth failures)
|
|
163
|
+
- No need for goto/click/type — this is not UI testing
|
|
164
|
+
`;
|
|
165
|
+
|
|
56
166
|
/**
|
|
57
167
|
* Reads the project's CLAUDE.md for app context (routes, selectors, UI structure).
|
|
58
168
|
* Returns the content or empty string if not found.
|
|
@@ -77,7 +187,7 @@ function loadProjectContext(cwd) {
|
|
|
77
187
|
* @param {object} config - Loaded config
|
|
78
188
|
* @returns {object}
|
|
79
189
|
*/
|
|
80
|
-
export function buildPrompt(issue, config) {
|
|
190
|
+
export function buildPrompt(issue, config, testType = 'e2e') {
|
|
81
191
|
let existingSuites = [];
|
|
82
192
|
try {
|
|
83
193
|
existingSuites = listSuites(config.testsDir).map(s => s.name);
|
|
@@ -88,7 +198,9 @@ export function buildPrompt(issue, config) {
|
|
|
88
198
|
? `\n## Project Context (from CLAUDE.md)\nUse these REAL routes, selectors, and UI patterns — do NOT invent your own.\n\n${projectContext}\n`
|
|
89
199
|
: '';
|
|
90
200
|
|
|
91
|
-
const
|
|
201
|
+
const categoryRules = testType === 'api' ? API_RULES : E2E_RULES;
|
|
202
|
+
|
|
203
|
+
const prompt = `Based on the following issue, generate ${testType === 'api' ? 'API' : 'E2E'} test actions using the e2e_create_test tool.
|
|
92
204
|
|
|
93
205
|
## Issue: ${issue.title}
|
|
94
206
|
**Repo:** ${issue.repo}
|
|
@@ -99,8 +211,10 @@ export function buildPrompt(issue, config) {
|
|
|
99
211
|
### Description
|
|
100
212
|
${issue.body || 'No description provided.'}
|
|
101
213
|
${contextBlock}
|
|
214
|
+
## Test Category: ${testType}
|
|
215
|
+
${categoryRules}
|
|
102
216
|
## Instructions
|
|
103
|
-
1. Analyze the issue and determine what user flows to test
|
|
217
|
+
1. Analyze the issue and determine what ${testType === 'api' ? 'API operations' : 'user flows'} to test
|
|
104
218
|
2. Create one or more tests that verify the expected behavior
|
|
105
219
|
3. For bug reports: assert the CORRECT behavior (test failure = bug confirmed)
|
|
106
220
|
4. Use the \`e2e_create_test\` tool with suite name \`issue-${issue.number}\`
|
|
@@ -141,7 +255,7 @@ export function hasApiKey(config = {}) {
|
|
|
141
255
|
* @param {object} config - Loaded config
|
|
142
256
|
* @returns {Promise<{ tests: object[], suiteName: string }>}
|
|
143
257
|
*/
|
|
144
|
-
export async function generateTests(issue, config) {
|
|
258
|
+
export async function generateTests(issue, config, testType = 'e2e') {
|
|
145
259
|
const apiKey = config.anthropicApiKey || process.env.ANTHROPIC_API_KEY;
|
|
146
260
|
if (!apiKey) {
|
|
147
261
|
throw new Error('ANTHROPIC_API_KEY is required for test generation. Set it as an environment variable or in config.');
|
|
@@ -155,7 +269,9 @@ export async function generateTests(issue, config) {
|
|
|
155
269
|
? `\n## Project Context (from CLAUDE.md)\nIMPORTANT: Use these REAL routes, selectors, and UI patterns — do NOT invent your own.\n\n${projectContext}\n`
|
|
156
270
|
: '';
|
|
157
271
|
|
|
158
|
-
const
|
|
272
|
+
const categoryRules = testType === 'api' ? API_RULES : E2E_RULES;
|
|
273
|
+
|
|
274
|
+
const userMessage = `Generate ${testType === 'api' ? 'API' : 'E2E'} tests for this issue:
|
|
159
275
|
|
|
160
276
|
Title: ${issue.title}
|
|
161
277
|
Repo: ${issue.repo}
|
|
@@ -165,9 +281,11 @@ State: ${issue.state}
|
|
|
165
281
|
Description:
|
|
166
282
|
${issue.body || 'No description provided.'}
|
|
167
283
|
${contextBlock}
|
|
284
|
+
Test Category: ${testType}
|
|
285
|
+
${categoryRules}
|
|
168
286
|
Base URL: ${config.baseUrl}
|
|
169
287
|
|
|
170
|
-
Output
|
|
288
|
+
Output ONLY valid JSON. Either a plain array of test objects, or an object with "beforeEach" and "tests" keys if hooks are needed. Nothing else.`;
|
|
171
289
|
|
|
172
290
|
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
173
291
|
method: 'POST',
|
|
@@ -208,9 +326,21 @@ Output a JSON array of test objects. Nothing else.`;
|
|
|
208
326
|
throw new Error(`Failed to parse generated tests as JSON: ${err.message}\n\nRaw output:\n${text}`);
|
|
209
327
|
}
|
|
210
328
|
|
|
211
|
-
|
|
212
|
-
|
|
329
|
+
// Accept both array format and object format with hooks
|
|
330
|
+
let hooks;
|
|
331
|
+
if (Array.isArray(tests)) {
|
|
332
|
+
// Plain array: [{ name, actions }]
|
|
333
|
+
} else if (tests && Array.isArray(tests.tests)) {
|
|
334
|
+
// Object with hooks: { beforeEach: [...], tests: [...] }
|
|
335
|
+
hooks = {};
|
|
336
|
+
for (const key of ['beforeAll', 'afterAll', 'beforeEach', 'afterEach']) {
|
|
337
|
+
if (Array.isArray(tests[key])) hooks[key] = tests[key];
|
|
338
|
+
}
|
|
339
|
+
if (Object.keys(hooks).length === 0) hooks = undefined;
|
|
340
|
+
tests = tests.tests;
|
|
341
|
+
} else {
|
|
342
|
+
throw new Error('Generated tests must be a JSON array or an object with a "tests" array');
|
|
213
343
|
}
|
|
214
344
|
|
|
215
|
-
return { tests, suiteName };
|
|
345
|
+
return { tests, hooks, suiteName };
|
|
216
346
|
}
|
package/src/config.js
CHANGED
|
@@ -12,10 +12,27 @@ import fs from 'fs';
|
|
|
12
12
|
import path from 'path';
|
|
13
13
|
import { pathToFileURL } from 'url';
|
|
14
14
|
|
|
15
|
+
/** Deep merge utility for nested config objects */
|
|
16
|
+
function deepMerge(...objects) {
|
|
17
|
+
const result = {};
|
|
18
|
+
for (const obj of objects) {
|
|
19
|
+
if (!obj || typeof obj !== 'object') continue;
|
|
20
|
+
for (const key of Object.keys(obj)) {
|
|
21
|
+
if (obj[key] && typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
|
|
22
|
+
result[key] = deepMerge(result[key] || {}, obj[key]);
|
|
23
|
+
} else if (obj[key] !== undefined) {
|
|
24
|
+
result[key] = obj[key];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
|
|
15
31
|
const DEFAULTS = {
|
|
16
32
|
baseUrl: 'http://host.docker.internal:3000',
|
|
17
33
|
poolUrl: 'ws://localhost:3333',
|
|
18
34
|
testsDir: 'e2e/tests',
|
|
35
|
+
modulesDir: 'e2e/modules',
|
|
19
36
|
screenshotsDir: 'e2e/screenshots',
|
|
20
37
|
concurrency: 3,
|
|
21
38
|
viewport: { width: 1280, height: 720 },
|
|
@@ -33,18 +50,84 @@ const DEFAULTS = {
|
|
|
33
50
|
dashboardPort: 8484,
|
|
34
51
|
maxHistoryRuns: 100,
|
|
35
52
|
projectName: null,
|
|
53
|
+
exclude: [],
|
|
36
54
|
failOnNetworkError: false,
|
|
55
|
+
actionRetries: 0,
|
|
56
|
+
actionRetryDelay: 500,
|
|
37
57
|
anthropicApiKey: null,
|
|
38
58
|
anthropicModel: 'claude-sonnet-4-5-20250929',
|
|
39
59
|
authToken: null,
|
|
40
60
|
authStorageKey: 'accessToken',
|
|
61
|
+
learningsEnabled: true,
|
|
62
|
+
learningsMarkdown: true,
|
|
63
|
+
learningsNeo4j: false,
|
|
64
|
+
learningsDays: 30,
|
|
65
|
+
neo4jBoltUrl: 'bolt://localhost:7687',
|
|
66
|
+
neo4jUser: 'neo4j',
|
|
67
|
+
neo4jPassword: 'e2erunner',
|
|
68
|
+
neo4jBoltPort: 7687,
|
|
69
|
+
neo4jHttpPort: 7474,
|
|
70
|
+
verificationStrictness: 'moderate',
|
|
71
|
+
networkIgnoreDomains: [],
|
|
72
|
+
authLoginEndpoint: null,
|
|
73
|
+
authCredentials: null,
|
|
74
|
+
authTokenPath: 'token',
|
|
75
|
+
gqlEndpoint: '/api/graphql',
|
|
76
|
+
gqlAuthHeader: 'Authorization',
|
|
77
|
+
gqlAuthKey: 'accessToken',
|
|
78
|
+
gqlAuthPrefix: 'Bearer ',
|
|
79
|
+
poolUrls: null,
|
|
80
|
+
watchInterval: null,
|
|
81
|
+
watchRunOnStart: true,
|
|
82
|
+
watchGitPoll: false,
|
|
83
|
+
watchGitBranch: null,
|
|
84
|
+
watchGitInterval: '30s',
|
|
85
|
+
watchWebhookUrl: null,
|
|
86
|
+
watchWebhookEvents: 'failure',
|
|
87
|
+
watchProjects: null,
|
|
88
|
+
|
|
89
|
+
// Sync configuration
|
|
90
|
+
sync: {
|
|
91
|
+
mode: 'standalone', // 'standalone' | 'hub' | 'agent'
|
|
92
|
+
hub: {
|
|
93
|
+
port: null, // null = use dashboardPort
|
|
94
|
+
tls: {
|
|
95
|
+
enabled: false,
|
|
96
|
+
certPath: null,
|
|
97
|
+
keyPath: null,
|
|
98
|
+
mtls: false,
|
|
99
|
+
caPath: null,
|
|
100
|
+
},
|
|
101
|
+
allowRegistration: true,
|
|
102
|
+
requireApproval: false,
|
|
103
|
+
masterKeyEnv: 'E2E_SYNC_MASTER_KEY',
|
|
104
|
+
},
|
|
105
|
+
agent: {
|
|
106
|
+
hubUrl: null,
|
|
107
|
+
instanceId: null,
|
|
108
|
+
displayName: null,
|
|
109
|
+
apiKeyEnv: 'E2E_SYNC_API_KEY',
|
|
110
|
+
totpSecretEnv: 'E2E_SYNC_TOTP',
|
|
111
|
+
tls: {
|
|
112
|
+
certPath: null,
|
|
113
|
+
keyPath: null,
|
|
114
|
+
caPath: null,
|
|
115
|
+
},
|
|
116
|
+
autoSync: true,
|
|
117
|
+
pullOnDashboard: true,
|
|
118
|
+
offlineQueue: true,
|
|
119
|
+
queueRetryInterval: 60,
|
|
120
|
+
},
|
|
121
|
+
},
|
|
41
122
|
};
|
|
42
123
|
|
|
43
124
|
function loadEnvVars() {
|
|
44
125
|
const env = {};
|
|
45
126
|
if (process.env.BASE_URL) env.baseUrl = process.env.BASE_URL;
|
|
46
127
|
if (process.env.CHROME_POOL_URL) env.poolUrl = process.env.CHROME_POOL_URL;
|
|
128
|
+
if (process.env.CHROME_POOL_URLS) env.poolUrls = process.env.CHROME_POOL_URLS.split(',').map(u => u.trim()).filter(Boolean);
|
|
47
129
|
if (process.env.TESTS_DIR) env.testsDir = process.env.TESTS_DIR;
|
|
130
|
+
if (process.env.MODULES_DIR) env.modulesDir = process.env.MODULES_DIR;
|
|
48
131
|
if (process.env.SCREENSHOTS_DIR) env.screenshotsDir = process.env.SCREENSHOTS_DIR;
|
|
49
132
|
if (process.env.CONCURRENCY) env.concurrency = parseInt(process.env.CONCURRENCY);
|
|
50
133
|
if (process.env.DEFAULT_TIMEOUT) env.defaultTimeout = parseInt(process.env.DEFAULT_TIMEOUT);
|
|
@@ -57,10 +140,76 @@ function loadEnvVars() {
|
|
|
57
140
|
if (process.env.E2E_ENV) env.env = process.env.E2E_ENV;
|
|
58
141
|
if (process.env.PROJECT_NAME) env.projectName = process.env.PROJECT_NAME;
|
|
59
142
|
if (process.env.FAIL_ON_NETWORK_ERROR) env.failOnNetworkError = process.env.FAIL_ON_NETWORK_ERROR === 'true' || process.env.FAIL_ON_NETWORK_ERROR === '1';
|
|
143
|
+
if (process.env.ACTION_RETRIES) env.actionRetries = parseInt(process.env.ACTION_RETRIES);
|
|
144
|
+
if (process.env.ACTION_RETRY_DELAY) env.actionRetryDelay = parseInt(process.env.ACTION_RETRY_DELAY);
|
|
60
145
|
if (process.env.ANTHROPIC_API_KEY) env.anthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
|
61
146
|
if (process.env.ANTHROPIC_MODEL) env.anthropicModel = process.env.ANTHROPIC_MODEL;
|
|
62
147
|
if (process.env.AUTH_TOKEN) env.authToken = process.env.AUTH_TOKEN;
|
|
63
148
|
if (process.env.AUTH_STORAGE_KEY) env.authStorageKey = process.env.AUTH_STORAGE_KEY;
|
|
149
|
+
if (process.env.LEARNINGS_ENABLED) env.learningsEnabled = process.env.LEARNINGS_ENABLED !== 'false' && process.env.LEARNINGS_ENABLED !== '0';
|
|
150
|
+
if (process.env.LEARNINGS_MARKDOWN) env.learningsMarkdown = process.env.LEARNINGS_MARKDOWN !== 'false' && process.env.LEARNINGS_MARKDOWN !== '0';
|
|
151
|
+
if (process.env.LEARNINGS_NEO4J) env.learningsNeo4j = process.env.LEARNINGS_NEO4J === 'true' || process.env.LEARNINGS_NEO4J === '1';
|
|
152
|
+
if (process.env.LEARNINGS_DAYS) env.learningsDays = parseInt(process.env.LEARNINGS_DAYS);
|
|
153
|
+
if (process.env.NEO4J_BOLT_URL) env.neo4jBoltUrl = process.env.NEO4J_BOLT_URL;
|
|
154
|
+
if (process.env.NEO4J_USER) env.neo4jUser = process.env.NEO4J_USER;
|
|
155
|
+
if (process.env.NEO4J_PASSWORD) env.neo4jPassword = process.env.NEO4J_PASSWORD;
|
|
156
|
+
if (process.env.NEO4J_BOLT_PORT) env.neo4jBoltPort = parseInt(process.env.NEO4J_BOLT_PORT);
|
|
157
|
+
if (process.env.NEO4J_HTTP_PORT) env.neo4jHttpPort = parseInt(process.env.NEO4J_HTTP_PORT);
|
|
158
|
+
if (process.env.NETWORK_IGNORE_DOMAINS) env.networkIgnoreDomains = process.env.NETWORK_IGNORE_DOMAINS.split(',').map(d => d.trim()).filter(Boolean);
|
|
159
|
+
if (process.env.AUTH_LOGIN_ENDPOINT) env.authLoginEndpoint = process.env.AUTH_LOGIN_ENDPOINT;
|
|
160
|
+
if (process.env.AUTH_TOKEN_PATH) env.authTokenPath = process.env.AUTH_TOKEN_PATH;
|
|
161
|
+
if (process.env.GQL_ENDPOINT) env.gqlEndpoint = process.env.GQL_ENDPOINT;
|
|
162
|
+
if (process.env.GQL_AUTH_HEADER) env.gqlAuthHeader = process.env.GQL_AUTH_HEADER;
|
|
163
|
+
if (process.env.GQL_AUTH_KEY) env.gqlAuthKey = process.env.GQL_AUTH_KEY;
|
|
164
|
+
if (process.env.GQL_AUTH_PREFIX) env.gqlAuthPrefix = process.env.GQL_AUTH_PREFIX;
|
|
165
|
+
if (process.env.WATCH_INTERVAL) env.watchInterval = process.env.WATCH_INTERVAL;
|
|
166
|
+
if (process.env.WATCH_WEBHOOK_URL) env.watchWebhookUrl = process.env.WATCH_WEBHOOK_URL;
|
|
167
|
+
if (process.env.WATCH_WEBHOOK_EVENTS) env.watchWebhookEvents = process.env.WATCH_WEBHOOK_EVENTS;
|
|
168
|
+
if (process.env.WATCH_GIT_POLL) env.watchGitPoll = process.env.WATCH_GIT_POLL === 'true' || process.env.WATCH_GIT_POLL === '1';
|
|
169
|
+
if (process.env.WATCH_GIT_BRANCH) env.watchGitBranch = process.env.WATCH_GIT_BRANCH;
|
|
170
|
+
if (process.env.WATCH_GIT_INTERVAL) env.watchGitInterval = process.env.WATCH_GIT_INTERVAL;
|
|
171
|
+
if (process.env.VERIFICATION_STRICTNESS) {
|
|
172
|
+
const val = process.env.VERIFICATION_STRICTNESS.toLowerCase();
|
|
173
|
+
if (['strict', 'moderate', 'lenient'].includes(val)) {
|
|
174
|
+
env.verificationStrictness = val;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Sync configuration from env vars
|
|
179
|
+
if (process.env.E2E_SYNC_MODE) {
|
|
180
|
+
const mode = process.env.E2E_SYNC_MODE.toLowerCase();
|
|
181
|
+
if (['standalone', 'hub', 'agent'].includes(mode)) {
|
|
182
|
+
env.sync = env.sync || {};
|
|
183
|
+
env.sync.mode = mode;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (process.env.E2E_SYNC_HUB_URL) {
|
|
187
|
+
env.sync = env.sync || {};
|
|
188
|
+
env.sync.agent = env.sync.agent || {};
|
|
189
|
+
env.sync.agent.hubUrl = process.env.E2E_SYNC_HUB_URL;
|
|
190
|
+
}
|
|
191
|
+
if (process.env.E2E_SYNC_INSTANCE_ID) {
|
|
192
|
+
env.sync = env.sync || {};
|
|
193
|
+
env.sync.agent = env.sync.agent || {};
|
|
194
|
+
env.sync.agent.instanceId = process.env.E2E_SYNC_INSTANCE_ID;
|
|
195
|
+
}
|
|
196
|
+
if (process.env.E2E_SYNC_DISPLAY_NAME) {
|
|
197
|
+
env.sync = env.sync || {};
|
|
198
|
+
env.sync.agent = env.sync.agent || {};
|
|
199
|
+
env.sync.agent.displayName = process.env.E2E_SYNC_DISPLAY_NAME;
|
|
200
|
+
}
|
|
201
|
+
if (process.env.E2E_SYNC_HUB_PORT) {
|
|
202
|
+
env.sync = env.sync || {};
|
|
203
|
+
env.sync.hub = env.sync.hub || {};
|
|
204
|
+
env.sync.hub.port = parseInt(process.env.E2E_SYNC_HUB_PORT);
|
|
205
|
+
}
|
|
206
|
+
if (process.env.E2E_SYNC_TLS_ENABLED) {
|
|
207
|
+
env.sync = env.sync || {};
|
|
208
|
+
env.sync.hub = env.sync.hub || {};
|
|
209
|
+
env.sync.hub.tls = env.sync.hub.tls || {};
|
|
210
|
+
env.sync.hub.tls.enabled = process.env.E2E_SYNC_TLS_ENABLED === 'true' || process.env.E2E_SYNC_TLS_ENABLED === '1';
|
|
211
|
+
}
|
|
212
|
+
|
|
64
213
|
return env;
|
|
65
214
|
}
|
|
66
215
|
|
|
@@ -117,6 +266,16 @@ export async function loadConfig(cliArgs = {}, cwd = null) {
|
|
|
117
266
|
...envConfig,
|
|
118
267
|
...cliArgs,
|
|
119
268
|
};
|
|
269
|
+
|
|
270
|
+
// Deep merge sync config (nested objects need special handling)
|
|
271
|
+
if (fileConfig.sync || envConfig.sync || cliArgs.sync) {
|
|
272
|
+
config.sync = deepMerge(
|
|
273
|
+
DEFAULTS.sync,
|
|
274
|
+
fileConfig.sync || {},
|
|
275
|
+
envConfig.sync || {},
|
|
276
|
+
cliArgs.sync || {}
|
|
277
|
+
);
|
|
278
|
+
}
|
|
120
279
|
|
|
121
280
|
// Apply environment profile overrides
|
|
122
281
|
if (config.env && config.env !== 'default' && config.environments?.[config.env]) {
|
|
@@ -129,6 +288,9 @@ export async function loadConfig(cliArgs = {}, cwd = null) {
|
|
|
129
288
|
if (!path.isAbsolute(config.testsDir)) {
|
|
130
289
|
config.testsDir = path.join(cwd, config.testsDir);
|
|
131
290
|
}
|
|
291
|
+
if (config.modulesDir && !path.isAbsolute(config.modulesDir)) {
|
|
292
|
+
config.modulesDir = path.join(cwd, config.modulesDir);
|
|
293
|
+
}
|
|
132
294
|
if (!path.isAbsolute(config.screenshotsDir)) {
|
|
133
295
|
config.screenshotsDir = path.join(cwd, config.screenshotsDir);
|
|
134
296
|
}
|
|
@@ -144,5 +306,14 @@ export async function loadConfig(cliArgs = {}, cwd = null) {
|
|
|
144
306
|
config.projectName = path.basename(cwd);
|
|
145
307
|
}
|
|
146
308
|
|
|
309
|
+
// Normalize pool URLs: poolUrls array → _poolUrls, keep poolUrl as primary
|
|
310
|
+
if (config.poolUrls && Array.isArray(config.poolUrls) && config.poolUrls.length > 0) {
|
|
311
|
+
config._poolUrls = config.poolUrls;
|
|
312
|
+
config.poolUrl = config.poolUrls[0];
|
|
313
|
+
} else {
|
|
314
|
+
config._poolUrls = [config.poolUrl];
|
|
315
|
+
}
|
|
316
|
+
delete config.poolUrls;
|
|
317
|
+
|
|
147
318
|
return config;
|
|
148
319
|
}
|