@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.
Files changed (89) hide show
  1. package/.claude-plugin/marketplace.json +21 -0
  2. package/.claude-plugin/plugin.json +9 -0
  3. package/.mcp.json +9 -0
  4. package/.opencode/commands/create-test.md +63 -0
  5. package/.opencode/commands/run.md +50 -0
  6. package/.opencode/commands/verify-issue.md +62 -0
  7. package/.opencode/skills/e2e-testing/SKILL.md +181 -0
  8. package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
  9. package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
  10. package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
  11. package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
  12. package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
  13. package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
  14. package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
  15. package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
  16. package/.opencode/skills/e2e-testing/references/variables.md +41 -0
  17. package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
  18. package/OPENCODE.md +166 -0
  19. package/README.md +990 -296
  20. package/agents/test-analyzer.md +81 -0
  21. package/agents/test-creator.md +155 -0
  22. package/agents/test-improver.md +177 -0
  23. package/bin/cli.js +602 -22
  24. package/commands/create-test.md +65 -0
  25. package/commands/run.md +49 -0
  26. package/commands/verify-issue.md +63 -0
  27. package/opencode.json +11 -0
  28. package/package.json +15 -2
  29. package/scripts/setup-opencode.sh +113 -0
  30. package/skills/e2e-testing/SKILL.md +173 -0
  31. package/skills/e2e-testing/references/action-types.md +143 -0
  32. package/skills/e2e-testing/references/auth-strategies.md +91 -0
  33. package/skills/e2e-testing/references/graphql.md +59 -0
  34. package/skills/e2e-testing/references/issue-verification.md +59 -0
  35. package/skills/e2e-testing/references/multi-pool.md +60 -0
  36. package/skills/e2e-testing/references/network-debugging.md +62 -0
  37. package/skills/e2e-testing/references/test-json-format.md +163 -0
  38. package/skills/e2e-testing/references/troubleshooting.md +224 -0
  39. package/skills/e2e-testing/references/variables.md +41 -0
  40. package/skills/e2e-testing/references/visual-verification.md +89 -0
  41. package/src/actions.js +597 -20
  42. package/src/ai-generate.js +142 -12
  43. package/src/config.js +171 -0
  44. package/src/dashboard.js +299 -17
  45. package/src/db.js +335 -13
  46. package/src/index.js +15 -8
  47. package/src/learner-markdown.js +177 -0
  48. package/src/learner-neo4j.js +255 -0
  49. package/src/learner-sqlite.js +658 -0
  50. package/src/learner.js +418 -0
  51. package/src/mcp-tools.js +1558 -50
  52. package/src/module-resolver.js +310 -0
  53. package/src/narrate.js +262 -0
  54. package/src/neo4j-pool.js +124 -0
  55. package/src/pool-manager.js +223 -0
  56. package/src/reporter.js +117 -3
  57. package/src/runner.js +274 -71
  58. package/src/sync/auth.js +354 -0
  59. package/src/sync/client.js +572 -0
  60. package/src/sync/hub-routes.js +816 -0
  61. package/src/sync/index.js +68 -0
  62. package/src/sync/middleware.js +347 -0
  63. package/src/sync/queue.js +209 -0
  64. package/src/sync/schema.js +540 -0
  65. package/src/verify.js +14 -9
  66. package/src/watch.js +384 -0
  67. package/templates/build-dashboard.js +69 -0
  68. package/templates/dashboard/js/api.js +60 -0
  69. package/templates/dashboard/js/init.js +13 -0
  70. package/templates/dashboard/js/keyboard.js +46 -0
  71. package/templates/dashboard/js/state.js +40 -0
  72. package/templates/dashboard/js/toast.js +41 -0
  73. package/templates/dashboard/js/utils.js +196 -0
  74. package/templates/dashboard/js/view-live.js +143 -0
  75. package/templates/dashboard/js/view-runs.js +572 -0
  76. package/templates/dashboard/js/view-tests.js +294 -0
  77. package/templates/dashboard/js/view-watch.js +242 -0
  78. package/templates/dashboard/js/websocket.js +110 -0
  79. package/templates/dashboard/styles/base.css +69 -0
  80. package/templates/dashboard/styles/components.css +110 -0
  81. package/templates/dashboard/styles/view-live.css +74 -0
  82. package/templates/dashboard/styles/view-runs.css +207 -0
  83. package/templates/dashboard/styles/view-tests.css +96 -0
  84. package/templates/dashboard/styles/view-watch.css +53 -0
  85. package/templates/dashboard/template.html +267 -0
  86. package/templates/dashboard.html +2171 -530
  87. package/templates/docker-compose-neo4j.yml +19 -0
  88. package/templates/e2e.config.js +3 -0
  89. package/templates/sample-test.json +0 -8
@@ -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 a JSON array of test objects
46
- - Use only the action types listed above
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 prompt = `Based on the following issue, generate E2E test actions using the e2e_create_test tool.
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 userMessage = `Generate E2E tests for this issue:
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 a JSON array of test objects. Nothing else.`;
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
- if (!Array.isArray(tests)) {
212
- throw new Error('Generated tests must be a JSON array');
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
  }