@matware/e2e-runner 1.3.0 → 1.5.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 +37 -6
- package/.claude-plugin/plugin.json +17 -3
- package/LICENSE +190 -0
- package/README.md +151 -527
- package/agents/test-creator.md +4 -2
- package/agents/test-improver.md +5 -3
- package/bin/cli.js +84 -20
- package/commands/capture.md +45 -0
- package/package.json +3 -2
- package/skills/e2e-testing/SKILL.md +3 -2
- package/skills/e2e-testing/references/action-types.md +22 -4
- package/skills/e2e-testing/references/test-json-format.md +23 -0
- package/src/actions.js +321 -14
- package/src/ai-generate.js +81 -0
- package/src/app-pool.js +339 -0
- package/src/config.js +131 -7
- package/src/dashboard.js +209 -11
- package/src/db.js +74 -7
- package/src/index.js +6 -4
- package/src/learner-sqlite.js +154 -0
- package/src/learner.js +70 -3
- package/src/mcp-tools.js +259 -34
- package/src/module-analysis.js +247 -0
- package/src/module-resolver.js +35 -2
- package/src/narrate.js +42 -1
- package/src/pool-manager.js +68 -17
- package/src/pool.js +464 -37
- package/src/reporter.js +4 -1
- package/src/runner.js +410 -63
- package/src/visual-diff.js +515 -0
- package/src/websocket.js +14 -3
- package/src/wizard.js +184 -0
- package/templates/build-dashboard.js +3 -0
- package/templates/dashboard/js/api.js +62 -3
- package/templates/dashboard/js/init.js +46 -0
- package/templates/dashboard/js/keyboard.js +8 -7
- package/templates/dashboard/js/quicksearch.js +277 -0
- package/templates/dashboard/js/state.js +61 -7
- package/templates/dashboard/js/toast.js +1 -1
- package/templates/dashboard/js/utils.js +20 -0
- package/templates/dashboard/js/view-live.js +240 -9
- package/templates/dashboard/js/view-runs.js +540 -94
- package/templates/dashboard/js/view-tests.js +157 -16
- package/templates/dashboard/js/view-tools.js +234 -0
- package/templates/dashboard/js/view-watch.js +2 -2
- package/templates/dashboard/js/websocket.js +36 -0
- package/templates/dashboard/styles/base.css +489 -53
- package/templates/dashboard/styles/components.css +719 -77
- package/templates/dashboard/styles/view-live.css +463 -59
- package/templates/dashboard/styles/view-runs.css +793 -155
- package/templates/dashboard/styles/view-tests.css +440 -77
- package/templates/dashboard/styles/view-tools.css +206 -0
- package/templates/dashboard/styles/view-watch.css +198 -41
- package/templates/dashboard/template.html +369 -56
- package/templates/dashboard.html +5375 -901
- package/templates/docker-compose-lightpanda.yml +7 -0
package/src/app-pool.js
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App Pool — isolated application environments for test isolation.
|
|
3
|
+
*
|
|
4
|
+
* Provides each test with its own application instance via fast VM/container
|
|
5
|
+
* forking. Supports multiple drivers:
|
|
6
|
+
*
|
|
7
|
+
* - "docker" — Docker-based: runs a fresh container per test (slower, ~2-5s)
|
|
8
|
+
* - "zeroboot" — Firecracker microVM fork via Zeroboot SDK (~0.8ms)
|
|
9
|
+
*
|
|
10
|
+
* Lifecycle:
|
|
11
|
+
* 1. Template creation (one-time): boot app, wait for ready, snapshot state
|
|
12
|
+
* 2. Fork (per-test): clone template into isolated instance with unique port
|
|
13
|
+
* 3. Test runs against fork's baseUrl
|
|
14
|
+
* 4. Fork destroyed after test completes
|
|
15
|
+
*
|
|
16
|
+
* The app pool is independent of the Chrome pool — both are selected in
|
|
17
|
+
* parallel by pool-manager.js for maximum throughput.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { log, colors as C } from './logger.js';
|
|
21
|
+
|
|
22
|
+
// ── Port allocator ────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/** Tracks allocated ports to avoid collisions across concurrent forks. */
|
|
25
|
+
const allocatedPorts = new Set();
|
|
26
|
+
|
|
27
|
+
function allocatePort(basePort, maxForks) {
|
|
28
|
+
for (let offset = 0; offset < maxForks; offset++) {
|
|
29
|
+
const port = basePort + offset;
|
|
30
|
+
if (!allocatedPorts.has(port)) {
|
|
31
|
+
allocatedPorts.add(port);
|
|
32
|
+
return port;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
throw new Error(`App pool: no free ports in range ${basePort}-${basePort + maxForks - 1}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function releasePort(port) {
|
|
39
|
+
allocatedPorts.delete(port);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Fork registry ─────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Active fork tracking.
|
|
46
|
+
* Maps forkId → { port, driver, testName, startTime, metadata }
|
|
47
|
+
*/
|
|
48
|
+
const activeForks = new Map();
|
|
49
|
+
let forkCounter = 0;
|
|
50
|
+
|
|
51
|
+
function generateForkId() {
|
|
52
|
+
return `fork-${Date.now().toString(36)}-${(++forkCounter).toString(36)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Health check ──────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Polls a URL until it returns 2xx or timeout is reached.
|
|
59
|
+
* Used to verify a forked app instance is ready to receive traffic.
|
|
60
|
+
*/
|
|
61
|
+
async function waitForReady(url, timeoutMs = 10000, intervalMs = 200) {
|
|
62
|
+
const start = Date.now();
|
|
63
|
+
while (Date.now() - start < timeoutMs) {
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
|
|
66
|
+
if (res.ok) return true;
|
|
67
|
+
} catch { /* not ready yet */ }
|
|
68
|
+
await new Promise(r => setTimeout(r, intervalMs));
|
|
69
|
+
}
|
|
70
|
+
throw new Error(`App pool: fork not ready after ${timeoutMs}ms (checked ${url})`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Driver: Docker ────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Docker driver — runs a fresh container per fork.
|
|
77
|
+
* Slower (~2-5s) but works everywhere Docker is available.
|
|
78
|
+
*
|
|
79
|
+
* Expects appPool config:
|
|
80
|
+
* image: Docker image to run (required)
|
|
81
|
+
* envVars: { KEY: 'value' } environment variables for the container
|
|
82
|
+
* readyCheck: path to poll for readiness (e.g. '/health')
|
|
83
|
+
* readyTimeout: ms to wait for ready (default 15000)
|
|
84
|
+
*/
|
|
85
|
+
async function dockerFork(config, port) {
|
|
86
|
+
const { execFile } = await import('child_process');
|
|
87
|
+
const { promisify } = await import('util');
|
|
88
|
+
const execFileAsync = promisify(execFile);
|
|
89
|
+
|
|
90
|
+
const appConfig = config.appPool;
|
|
91
|
+
const containerName = `e2e-app-${port}`;
|
|
92
|
+
|
|
93
|
+
const args = [
|
|
94
|
+
'run', '-d',
|
|
95
|
+
'--name', containerName,
|
|
96
|
+
'-p', `${port}:${appConfig.containerPort || 3000}`,
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
// Add host.docker.internal access
|
|
100
|
+
args.push('--add-host', 'host.docker.internal:host-gateway');
|
|
101
|
+
|
|
102
|
+
// Environment variables
|
|
103
|
+
if (appConfig.envVars) {
|
|
104
|
+
for (const [key, value] of Object.entries(appConfig.envVars)) {
|
|
105
|
+
args.push('-e', `${key}=${value}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
args.push(appConfig.image);
|
|
110
|
+
|
|
111
|
+
// Optional command override
|
|
112
|
+
if (appConfig.cmd) {
|
|
113
|
+
args.push(...(Array.isArray(appConfig.cmd) ? appConfig.cmd : appConfig.cmd.split(' ')));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await execFileAsync('docker', args);
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
containerId: containerName,
|
|
120
|
+
cleanup: async () => {
|
|
121
|
+
try {
|
|
122
|
+
await execFileAsync('docker', ['rm', '-f', containerName]);
|
|
123
|
+
} catch { /* best effort */ }
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function dockerDestroy(metadata) {
|
|
129
|
+
if (metadata?.cleanup) {
|
|
130
|
+
await metadata.cleanup();
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Driver: Zeroboot ──────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Zeroboot driver — sub-millisecond VM forks via Firecracker snapshots.
|
|
138
|
+
*
|
|
139
|
+
* NOTE: Zeroboot currently has NO networking within VMs (serial I/O only).
|
|
140
|
+
* This driver is a forward-looking implementation for when networking is added.
|
|
141
|
+
* The interface is ready — only the SDK calls need updating.
|
|
142
|
+
*
|
|
143
|
+
* Expects appPool config:
|
|
144
|
+
* zeroboot.apiUrl: Zeroboot API endpoint (default: http://localhost:8484)
|
|
145
|
+
* zeroboot.templateId: pre-created template ID (required)
|
|
146
|
+
* readyCheck: path to poll for readiness
|
|
147
|
+
* readyTimeout: ms to wait for ready (default 5000)
|
|
148
|
+
*/
|
|
149
|
+
|
|
150
|
+
/** Placeholder for Zeroboot SDK — replace with actual import when available. */
|
|
151
|
+
function getZerobootClient(apiUrl) {
|
|
152
|
+
// When Zeroboot publishes their Node SDK:
|
|
153
|
+
// import { ZerobootClient } from '@anthropic-ai/zeroboot';
|
|
154
|
+
// return new ZerobootClient({ apiUrl });
|
|
155
|
+
return {
|
|
156
|
+
async fork(templateId, _options) {
|
|
157
|
+
// SDK call: creates a KVM fork from snapshot in ~0.8ms
|
|
158
|
+
// Returns: { forkId, port, host }
|
|
159
|
+
throw new Error(
|
|
160
|
+
'Zeroboot SDK not installed. Install with: npm install @anthropic-ai/zeroboot\n' +
|
|
161
|
+
'Zeroboot currently requires networking support (not yet available).\n' +
|
|
162
|
+
'See: https://github.com/zerobootdev/zeroboot'
|
|
163
|
+
);
|
|
164
|
+
},
|
|
165
|
+
async destroy(_forkId) {
|
|
166
|
+
// SDK call: destroys the forked VM
|
|
167
|
+
},
|
|
168
|
+
async status() {
|
|
169
|
+
// SDK call: returns template and fork status
|
|
170
|
+
return { templates: [], activeForks: 0, memoryUsed: 0 };
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function zerobootFork(config, port) {
|
|
176
|
+
const appConfig = config.appPool;
|
|
177
|
+
const apiUrl = appConfig.zeroboot?.apiUrl || 'http://localhost:8484';
|
|
178
|
+
const templateId = appConfig.zeroboot?.templateId;
|
|
179
|
+
|
|
180
|
+
if (!templateId) {
|
|
181
|
+
throw new Error('App pool (zeroboot): zeroboot.templateId is required in appPool config');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const client = getZerobootClient(apiUrl);
|
|
185
|
+
const fork = await client.fork(templateId, { port });
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
zerobootForkId: fork.forkId,
|
|
189
|
+
client,
|
|
190
|
+
cleanup: async () => {
|
|
191
|
+
try {
|
|
192
|
+
await client.destroy(fork.forkId);
|
|
193
|
+
} catch { /* best effort */ }
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function zerobootDestroy(metadata) {
|
|
199
|
+
if (metadata?.cleanup) {
|
|
200
|
+
await metadata.cleanup();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Forks a new isolated app instance.
|
|
208
|
+
*
|
|
209
|
+
* @param {object} config - Full e2e-runner config with appPool section
|
|
210
|
+
* @param {string} [testName] - Test name for logging/tracking
|
|
211
|
+
* @returns {{ forkId: string, baseUrl: string, port: number }}
|
|
212
|
+
*/
|
|
213
|
+
export async function forkAppInstance(config, testName = '') {
|
|
214
|
+
const appConfig = config.appPool;
|
|
215
|
+
if (!appConfig?.enabled) {
|
|
216
|
+
throw new Error('App pool is not enabled in config');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const driver = appConfig.driver || 'docker';
|
|
220
|
+
const basePort = appConfig.forkBasePort || 4000;
|
|
221
|
+
const maxForks = appConfig.maxForks || 10;
|
|
222
|
+
const port = allocatePort(basePort, maxForks);
|
|
223
|
+
const forkId = generateForkId();
|
|
224
|
+
|
|
225
|
+
log('🔱', `${C.cyan}Forking app${C.reset} ${C.dim}(${driver}, port ${port}${testName ? `, ${testName}` : ''})${C.reset}`);
|
|
226
|
+
|
|
227
|
+
const startMs = Date.now();
|
|
228
|
+
let metadata;
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
if (driver === 'zeroboot') {
|
|
232
|
+
metadata = await zerobootFork(config, port);
|
|
233
|
+
} else if (driver === 'docker') {
|
|
234
|
+
metadata = await dockerFork(config, port);
|
|
235
|
+
} else {
|
|
236
|
+
throw new Error(`App pool: unknown driver "${driver}". Use "docker" or "zeroboot".`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Determine the baseUrl for the forked instance
|
|
240
|
+
const host = appConfig.forkHost || 'localhost';
|
|
241
|
+
const protocol = appConfig.forkProtocol || 'http';
|
|
242
|
+
const baseUrl = `${protocol}://${host}:${port}`;
|
|
243
|
+
|
|
244
|
+
// For Docker-based apps accessed from Chrome inside Docker:
|
|
245
|
+
const dockerBaseUrl = `http://host.docker.internal:${port}`;
|
|
246
|
+
|
|
247
|
+
// Wait for the app to be ready
|
|
248
|
+
if (appConfig.readyCheck) {
|
|
249
|
+
const checkUrl = `${baseUrl}${appConfig.readyCheck}`;
|
|
250
|
+
const readyTimeout = appConfig.readyTimeout || (driver === 'zeroboot' ? 5000 : 15000);
|
|
251
|
+
await waitForReady(checkUrl, readyTimeout);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const forkTimeMs = Date.now() - startMs;
|
|
255
|
+
log('🔱', `${C.green}App fork ready${C.reset} ${C.dim}(${forkTimeMs}ms, ${baseUrl})${C.reset}`);
|
|
256
|
+
|
|
257
|
+
const forkInfo = {
|
|
258
|
+
forkId,
|
|
259
|
+
port,
|
|
260
|
+
baseUrl,
|
|
261
|
+
dockerBaseUrl,
|
|
262
|
+
driver,
|
|
263
|
+
testName,
|
|
264
|
+
startTime: new Date().toISOString(),
|
|
265
|
+
forkTimeMs,
|
|
266
|
+
metadata,
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
activeForks.set(forkId, forkInfo);
|
|
270
|
+
return forkInfo;
|
|
271
|
+
} catch (error) {
|
|
272
|
+
releasePort(port);
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Destroys a forked app instance and releases its port.
|
|
279
|
+
*
|
|
280
|
+
* @param {string} forkId - Fork ID returned by forkAppInstance
|
|
281
|
+
*/
|
|
282
|
+
export async function destroyFork(forkId) {
|
|
283
|
+
const fork = activeForks.get(forkId);
|
|
284
|
+
if (!fork) return;
|
|
285
|
+
|
|
286
|
+
log('🔱', `${C.dim}Destroying app fork${C.reset} ${C.dim}(port ${fork.port}${fork.testName ? `, ${fork.testName}` : ''})${C.reset}`);
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
if (fork.driver === 'zeroboot') {
|
|
290
|
+
await zerobootDestroy(fork.metadata);
|
|
291
|
+
} else if (fork.driver === 'docker') {
|
|
292
|
+
await dockerDestroy(fork.metadata);
|
|
293
|
+
}
|
|
294
|
+
} finally {
|
|
295
|
+
releasePort(fork.port);
|
|
296
|
+
activeForks.delete(forkId);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Returns the status of the app pool: active forks, port usage, per-fork details.
|
|
302
|
+
*/
|
|
303
|
+
export function getAppPoolStatus() {
|
|
304
|
+
const forks = [];
|
|
305
|
+
for (const [id, fork] of activeForks) {
|
|
306
|
+
forks.push({
|
|
307
|
+
forkId: id,
|
|
308
|
+
port: fork.port,
|
|
309
|
+
driver: fork.driver,
|
|
310
|
+
baseUrl: fork.baseUrl,
|
|
311
|
+
testName: fork.testName,
|
|
312
|
+
startTime: fork.startTime,
|
|
313
|
+
forkTimeMs: fork.forkTimeMs,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
activeForks: activeForks.size,
|
|
319
|
+
allocatedPorts: [...allocatedPorts].sort((a, b) => a - b),
|
|
320
|
+
forks,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Destroys all active forks. Called during cleanup/shutdown.
|
|
326
|
+
*/
|
|
327
|
+
export async function destroyAllForks() {
|
|
328
|
+
const ids = [...activeForks.keys()];
|
|
329
|
+
if (ids.length === 0) return;
|
|
330
|
+
log('🔱', `${C.dim}Destroying ${ids.length} app fork(s)...${C.reset}`);
|
|
331
|
+
await Promise.allSettled(ids.map(id => destroyFork(id)));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Checks if app pool is configured and enabled.
|
|
336
|
+
*/
|
|
337
|
+
export function isAppPoolEnabled(config) {
|
|
338
|
+
return config?.appPool?.enabled === true;
|
|
339
|
+
}
|
package/src/config.js
CHANGED
|
@@ -40,6 +40,7 @@ const DEFAULTS = {
|
|
|
40
40
|
connectRetries: 3,
|
|
41
41
|
connectRetryDelay: 2000,
|
|
42
42
|
poolPort: 3333,
|
|
43
|
+
poolDriver: 'auto',
|
|
43
44
|
maxSessions: 10,
|
|
44
45
|
retries: 0,
|
|
45
46
|
retryDelay: 1000,
|
|
@@ -54,6 +55,17 @@ const DEFAULTS = {
|
|
|
54
55
|
failOnNetworkError: false,
|
|
55
56
|
actionRetries: 0,
|
|
56
57
|
actionRetryDelay: 500,
|
|
58
|
+
screencast: false,
|
|
59
|
+
screencastQuality: 60,
|
|
60
|
+
screencastMaxWidth: 800,
|
|
61
|
+
screencastMaxHeight: 600,
|
|
62
|
+
screencastEveryNthFrame: 1,
|
|
63
|
+
// Auto-capture a thumbnail after each action so the storyline view is fully visual.
|
|
64
|
+
// Adds ~50-100ms per action; set false in CI if you only need final/error screenshots.
|
|
65
|
+
autoCaptureSteps: true,
|
|
66
|
+
autoCaptureWidth: 480,
|
|
67
|
+
autoCaptureHeight: 300,
|
|
68
|
+
autoCaptureQuality: 60,
|
|
57
69
|
anthropicApiKey: null,
|
|
58
70
|
anthropicModel: 'claude-sonnet-4-5-20250929',
|
|
59
71
|
authToken: null,
|
|
@@ -68,7 +80,28 @@ const DEFAULTS = {
|
|
|
68
80
|
neo4jBoltPort: 7687,
|
|
69
81
|
neo4jHttpPort: 7474,
|
|
70
82
|
verificationStrictness: 'moderate',
|
|
83
|
+
verificationThreshold: 0.02,
|
|
84
|
+
goldenDir: null,
|
|
71
85
|
networkIgnoreDomains: [],
|
|
86
|
+
// App pool: isolated app environments per test
|
|
87
|
+
appPool: {
|
|
88
|
+
enabled: false,
|
|
89
|
+
driver: 'docker', // 'docker' | 'zeroboot'
|
|
90
|
+
image: null, // Docker image to run (docker driver)
|
|
91
|
+
containerPort: 3000, // Port the app listens on inside the container
|
|
92
|
+
cmd: null, // Optional command override
|
|
93
|
+
envVars: null, // { KEY: 'value' } for the container
|
|
94
|
+
forkBasePort: 4000, // Host port range start for forked instances
|
|
95
|
+
forkHost: 'localhost', // Host for health checks from runner
|
|
96
|
+
forkProtocol: 'http',
|
|
97
|
+
maxForks: 10, // Max concurrent forks
|
|
98
|
+
readyCheck: null, // Path to poll for readiness (e.g. '/health')
|
|
99
|
+
readyTimeout: 15000, // ms to wait for fork to be ready
|
|
100
|
+
zeroboot: { // Zeroboot-specific config
|
|
101
|
+
apiUrl: 'http://localhost:8484',
|
|
102
|
+
templateId: null, // Pre-created template ID
|
|
103
|
+
},
|
|
104
|
+
},
|
|
72
105
|
authLoginEndpoint: null,
|
|
73
106
|
authCredentials: null,
|
|
74
107
|
authTokenPath: 'token',
|
|
@@ -142,6 +175,12 @@ function loadEnvVars() {
|
|
|
142
175
|
if (process.env.FAIL_ON_NETWORK_ERROR) env.failOnNetworkError = process.env.FAIL_ON_NETWORK_ERROR === 'true' || process.env.FAIL_ON_NETWORK_ERROR === '1';
|
|
143
176
|
if (process.env.ACTION_RETRIES) env.actionRetries = parseInt(process.env.ACTION_RETRIES);
|
|
144
177
|
if (process.env.ACTION_RETRY_DELAY) env.actionRetryDelay = parseInt(process.env.ACTION_RETRY_DELAY);
|
|
178
|
+
if (process.env.POOL_DRIVER) env.poolDriver = process.env.POOL_DRIVER;
|
|
179
|
+
if (process.env.SCREENCAST) env.screencast = process.env.SCREENCAST === 'true' || process.env.SCREENCAST === '1';
|
|
180
|
+
if (process.env.SCREENCAST_QUALITY) env.screencastQuality = parseInt(process.env.SCREENCAST_QUALITY);
|
|
181
|
+
if (process.env.SCREENCAST_MAX_WIDTH) env.screencastMaxWidth = parseInt(process.env.SCREENCAST_MAX_WIDTH);
|
|
182
|
+
if (process.env.SCREENCAST_MAX_HEIGHT) env.screencastMaxHeight = parseInt(process.env.SCREENCAST_MAX_HEIGHT);
|
|
183
|
+
if (process.env.SCREENCAST_EVERY_NTH_FRAME) env.screencastEveryNthFrame = parseInt(process.env.SCREENCAST_EVERY_NTH_FRAME);
|
|
145
184
|
if (process.env.ANTHROPIC_API_KEY) env.anthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
|
146
185
|
if (process.env.ANTHROPIC_MODEL) env.anthropicModel = process.env.ANTHROPIC_MODEL;
|
|
147
186
|
if (process.env.AUTH_TOKEN) env.authToken = process.env.AUTH_TOKEN;
|
|
@@ -158,6 +197,25 @@ function loadEnvVars() {
|
|
|
158
197
|
if (process.env.NETWORK_IGNORE_DOMAINS) env.networkIgnoreDomains = process.env.NETWORK_IGNORE_DOMAINS.split(',').map(d => d.trim()).filter(Boolean);
|
|
159
198
|
if (process.env.AUTH_LOGIN_ENDPOINT) env.authLoginEndpoint = process.env.AUTH_LOGIN_ENDPOINT;
|
|
160
199
|
if (process.env.AUTH_TOKEN_PATH) env.authTokenPath = process.env.AUTH_TOKEN_PATH;
|
|
200
|
+
// credentials.env convention: E2E_USERNAME + E2E_PASSWORD → authCredentials
|
|
201
|
+
// Sends both email and username fields so the API accepts whichever it expects.
|
|
202
|
+
// E2E_AUTH_FIELD overrides to send a single field if desired.
|
|
203
|
+
if (process.env.E2E_USERNAME && process.env.E2E_PASSWORD) {
|
|
204
|
+
if (process.env.E2E_AUTH_FIELD) {
|
|
205
|
+
env.authCredentials = {
|
|
206
|
+
[process.env.E2E_AUTH_FIELD]: process.env.E2E_USERNAME,
|
|
207
|
+
password: process.env.E2E_PASSWORD,
|
|
208
|
+
};
|
|
209
|
+
} else {
|
|
210
|
+
env.authCredentials = {
|
|
211
|
+
email: process.env.E2E_USERNAME,
|
|
212
|
+
username: process.env.E2E_USERNAME,
|
|
213
|
+
password: process.env.E2E_PASSWORD,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (process.env.E2E_LOGIN_ENDPOINT) env.authLoginEndpoint = process.env.E2E_LOGIN_ENDPOINT;
|
|
218
|
+
if (process.env.E2E_TOKEN_PATH) env.authTokenPath = process.env.E2E_TOKEN_PATH;
|
|
161
219
|
if (process.env.GQL_ENDPOINT) env.gqlEndpoint = process.env.GQL_ENDPOINT;
|
|
162
220
|
if (process.env.GQL_AUTH_HEADER) env.gqlAuthHeader = process.env.GQL_AUTH_HEADER;
|
|
163
221
|
if (process.env.GQL_AUTH_KEY) env.gqlAuthKey = process.env.GQL_AUTH_KEY;
|
|
@@ -174,7 +232,53 @@ function loadEnvVars() {
|
|
|
174
232
|
env.verificationStrictness = val;
|
|
175
233
|
}
|
|
176
234
|
}
|
|
177
|
-
|
|
235
|
+
if (process.env.VERIFICATION_THRESHOLD) env.verificationThreshold = parseFloat(process.env.VERIFICATION_THRESHOLD);
|
|
236
|
+
if (process.env.GOLDEN_DIR) env.goldenDir = process.env.GOLDEN_DIR;
|
|
237
|
+
|
|
238
|
+
// App pool configuration from env vars
|
|
239
|
+
if (process.env.APP_POOL_ENABLED) {
|
|
240
|
+
env.appPool = env.appPool || {};
|
|
241
|
+
env.appPool.enabled = process.env.APP_POOL_ENABLED === 'true' || process.env.APP_POOL_ENABLED === '1';
|
|
242
|
+
}
|
|
243
|
+
if (process.env.APP_POOL_DRIVER) {
|
|
244
|
+
env.appPool = env.appPool || {};
|
|
245
|
+
env.appPool.driver = process.env.APP_POOL_DRIVER;
|
|
246
|
+
}
|
|
247
|
+
if (process.env.APP_POOL_IMAGE) {
|
|
248
|
+
env.appPool = env.appPool || {};
|
|
249
|
+
env.appPool.image = process.env.APP_POOL_IMAGE;
|
|
250
|
+
}
|
|
251
|
+
if (process.env.APP_POOL_CONTAINER_PORT) {
|
|
252
|
+
env.appPool = env.appPool || {};
|
|
253
|
+
env.appPool.containerPort = parseInt(process.env.APP_POOL_CONTAINER_PORT);
|
|
254
|
+
}
|
|
255
|
+
if (process.env.APP_POOL_BASE_PORT) {
|
|
256
|
+
env.appPool = env.appPool || {};
|
|
257
|
+
env.appPool.forkBasePort = parseInt(process.env.APP_POOL_BASE_PORT);
|
|
258
|
+
}
|
|
259
|
+
if (process.env.APP_POOL_MAX_FORKS) {
|
|
260
|
+
env.appPool = env.appPool || {};
|
|
261
|
+
env.appPool.maxForks = parseInt(process.env.APP_POOL_MAX_FORKS);
|
|
262
|
+
}
|
|
263
|
+
if (process.env.APP_POOL_READY_CHECK) {
|
|
264
|
+
env.appPool = env.appPool || {};
|
|
265
|
+
env.appPool.readyCheck = process.env.APP_POOL_READY_CHECK;
|
|
266
|
+
}
|
|
267
|
+
if (process.env.APP_POOL_READY_TIMEOUT) {
|
|
268
|
+
env.appPool = env.appPool || {};
|
|
269
|
+
env.appPool.readyTimeout = parseInt(process.env.APP_POOL_READY_TIMEOUT);
|
|
270
|
+
}
|
|
271
|
+
if (process.env.ZEROBOOT_API_URL) {
|
|
272
|
+
env.appPool = env.appPool || {};
|
|
273
|
+
env.appPool.zeroboot = env.appPool.zeroboot || {};
|
|
274
|
+
env.appPool.zeroboot.apiUrl = process.env.ZEROBOOT_API_URL;
|
|
275
|
+
}
|
|
276
|
+
if (process.env.ZEROBOOT_TEMPLATE_ID) {
|
|
277
|
+
env.appPool = env.appPool || {};
|
|
278
|
+
env.appPool.zeroboot = env.appPool.zeroboot || {};
|
|
279
|
+
env.appPool.zeroboot.templateId = process.env.ZEROBOOT_TEMPLATE_ID;
|
|
280
|
+
}
|
|
281
|
+
|
|
178
282
|
// Sync configuration from env vars
|
|
179
283
|
if (process.env.E2E_SYNC_MODE) {
|
|
180
284
|
const mode = process.env.E2E_SYNC_MODE.toLowerCase();
|
|
@@ -231,11 +335,10 @@ async function loadConfigFile(cwd) {
|
|
|
231
335
|
return {};
|
|
232
336
|
}
|
|
233
337
|
|
|
234
|
-
/** Load
|
|
235
|
-
function
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const lines = fs.readFileSync(envPath, 'utf-8').split('\n');
|
|
338
|
+
/** Load a KEY=VALUE file into process.env (no deps). */
|
|
339
|
+
function loadEnvFile(filePath) {
|
|
340
|
+
if (!fs.existsSync(filePath)) return;
|
|
341
|
+
const lines = fs.readFileSync(filePath, 'utf-8').split('\n');
|
|
239
342
|
for (const line of lines) {
|
|
240
343
|
const trimmed = line.trim();
|
|
241
344
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
@@ -254,6 +357,14 @@ function loadDotEnv(cwd) {
|
|
|
254
357
|
}
|
|
255
358
|
}
|
|
256
359
|
|
|
360
|
+
/** Load .env and credentials.env from cwd into process.env. */
|
|
361
|
+
function loadDotEnv(cwd) {
|
|
362
|
+
loadEnvFile(path.join(cwd, '.env'));
|
|
363
|
+
// credentials.env — search e2e/ subdir first, then cwd root
|
|
364
|
+
loadEnvFile(path.join(cwd, 'e2e', 'credentials.env'));
|
|
365
|
+
loadEnvFile(path.join(cwd, 'credentials.env'));
|
|
366
|
+
}
|
|
367
|
+
|
|
257
368
|
export async function loadConfig(cliArgs = {}, cwd = null) {
|
|
258
369
|
cwd = cwd || process.cwd();
|
|
259
370
|
loadDotEnv(cwd);
|
|
@@ -267,7 +378,7 @@ export async function loadConfig(cliArgs = {}, cwd = null) {
|
|
|
267
378
|
...cliArgs,
|
|
268
379
|
};
|
|
269
380
|
|
|
270
|
-
// Deep merge
|
|
381
|
+
// Deep merge nested config objects
|
|
271
382
|
if (fileConfig.sync || envConfig.sync || cliArgs.sync) {
|
|
272
383
|
config.sync = deepMerge(
|
|
273
384
|
DEFAULTS.sync,
|
|
@@ -276,6 +387,14 @@ export async function loadConfig(cliArgs = {}, cwd = null) {
|
|
|
276
387
|
cliArgs.sync || {}
|
|
277
388
|
);
|
|
278
389
|
}
|
|
390
|
+
if (fileConfig.appPool || envConfig.appPool || cliArgs.appPool) {
|
|
391
|
+
config.appPool = deepMerge(
|
|
392
|
+
DEFAULTS.appPool,
|
|
393
|
+
fileConfig.appPool || {},
|
|
394
|
+
envConfig.appPool || {},
|
|
395
|
+
cliArgs.appPool || {}
|
|
396
|
+
);
|
|
397
|
+
}
|
|
279
398
|
|
|
280
399
|
// Apply environment profile overrides
|
|
281
400
|
if (config.env && config.env !== 'default' && config.environments?.[config.env]) {
|
|
@@ -300,6 +419,11 @@ export async function loadConfig(cliArgs = {}, cwd = null) {
|
|
|
300
419
|
fs.mkdirSync(config.screenshotsDir, { recursive: true });
|
|
301
420
|
}
|
|
302
421
|
|
|
422
|
+
// Auto-infer authLoginEndpoint from baseUrl if credentials are available but no endpoint
|
|
423
|
+
if (config.authCredentials && !config.authLoginEndpoint && config.baseUrl) {
|
|
424
|
+
config.authLoginEndpoint = config.baseUrl.replace(/\/+$/, '') + '/api/auth/login';
|
|
425
|
+
}
|
|
426
|
+
|
|
303
427
|
// Stash cwd for project identity (used by db.js)
|
|
304
428
|
config._cwd = cwd;
|
|
305
429
|
if (!config.projectName) {
|