@matware/e2e-runner 1.2.1 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +52 -0
- package/.claude-plugin/plugin.json +17 -3
- package/.mcp.json +2 -2
- package/.opencode/commands/create-test.md +63 -0
- package/.opencode/commands/run.md +50 -0
- package/.opencode/commands/verify-issue.md +62 -0
- package/.opencode/skills/e2e-testing/SKILL.md +181 -0
- package/.opencode/skills/e2e-testing/references/action-types.md +143 -0
- package/.opencode/skills/e2e-testing/references/auth-strategies.md +91 -0
- package/.opencode/skills/e2e-testing/references/graphql.md +59 -0
- package/.opencode/skills/e2e-testing/references/issue-verification.md +59 -0
- package/.opencode/skills/e2e-testing/references/multi-pool.md +60 -0
- package/.opencode/skills/e2e-testing/references/network-debugging.md +62 -0
- package/.opencode/skills/e2e-testing/references/test-json-format.md +163 -0
- package/.opencode/skills/e2e-testing/references/troubleshooting.md +224 -0
- package/.opencode/skills/e2e-testing/references/variables.md +41 -0
- package/.opencode/skills/e2e-testing/references/visual-verification.md +89 -0
- package/LICENSE +190 -0
- package/OPENCODE.md +166 -0
- package/README.md +165 -104
- package/agents/test-creator.md +54 -1
- package/agents/test-improver.md +37 -0
- package/bin/cli.js +409 -16
- package/commands/capture.md +45 -0
- package/commands/create-test.md +16 -1
- package/opencode.json +11 -0
- package/package.json +7 -2
- package/scripts/setup-opencode.sh +113 -0
- package/skills/e2e-testing/SKILL.md +10 -3
- package/skills/e2e-testing/references/action-types.md +48 -5
- package/skills/e2e-testing/references/auth-strategies.md +91 -0
- package/skills/e2e-testing/references/graphql.md +59 -0
- package/skills/e2e-testing/references/issue-verification.md +59 -0
- package/skills/e2e-testing/references/multi-pool.md +60 -0
- package/skills/e2e-testing/references/network-debugging.md +62 -0
- package/skills/e2e-testing/references/test-json-format.md +4 -0
- package/skills/e2e-testing/references/troubleshooting.md +44 -2
- package/skills/e2e-testing/references/variables.md +41 -0
- package/skills/e2e-testing/references/visual-verification.md +89 -0
- package/src/actions.js +475 -2
- package/src/ai-generate.js +139 -8
- package/src/app-pool.js +339 -0
- package/src/config.js +266 -5
- package/src/dashboard.js +216 -17
- package/src/db.js +191 -7
- package/src/index.js +12 -9
- package/src/learner-sqlite.js +458 -0
- package/src/learner.js +78 -6
- package/src/mcp-tools.js +1348 -51
- package/src/module-resolver.js +37 -0
- package/src/narrate.js +65 -0
- package/src/pool-manager.js +229 -0
- package/src/pool.js +301 -31
- package/src/reporter.js +86 -2
- package/src/runner.js +480 -71
- package/src/sync/auth.js +354 -0
- package/src/sync/client.js +572 -0
- package/src/sync/hub-routes.js +816 -0
- package/src/sync/index.js +68 -0
- package/src/sync/middleware.js +347 -0
- package/src/sync/queue.js +209 -0
- package/src/sync/schema.js +540 -0
- package/src/verify.js +10 -7
- package/src/visual-diff.js +446 -0
- package/src/watch.js +384 -0
- package/templates/build-dashboard.js +47 -6
- package/templates/dashboard/js/api.js +62 -0
- package/templates/dashboard/js/init.js +13 -0
- package/templates/dashboard/js/keyboard.js +46 -0
- package/templates/dashboard/js/state.js +40 -0
- package/templates/dashboard/js/toast.js +41 -0
- package/templates/dashboard/js/utils.js +216 -0
- package/templates/dashboard/js/view-live.js +181 -0
- package/templates/dashboard/js/view-runs.js +676 -0
- package/templates/dashboard/js/view-tests.js +294 -0
- package/templates/dashboard/js/view-watch.js +242 -0
- package/templates/dashboard/js/websocket.js +116 -0
- package/templates/dashboard/styles/base.css +69 -0
- package/templates/dashboard/styles/components.css +117 -0
- package/templates/dashboard/styles/view-live.css +97 -0
- package/templates/dashboard/styles/view-runs.css +243 -0
- package/templates/dashboard/styles/view-tests.css +96 -0
- package/templates/dashboard/styles/view-watch.css +53 -0
- package/templates/dashboard/template.html +181 -100
- package/templates/dashboard.html +1614 -547
- package/templates/sample-test.json +0 -8
- package/templates/dashboard/app.js +0 -1152
- package/templates/dashboard/styles.css +0 -413
package/src/ai-generate.js
CHANGED
|
@@ -52,7 +52,18 @@ The test format is:
|
|
|
52
52
|
{ "type": "click_regex", "text": "submit order", "selector": "button", "value": "last" },
|
|
53
53
|
{ "type": "click_option", "text": "Option Label" },
|
|
54
54
|
{ "type": "focus_autocomplete", "text": "Search by label" },
|
|
55
|
-
{ "type": "click_chip", "text": "Tag Name" }
|
|
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\"}" }
|
|
56
67
|
]
|
|
57
68
|
}
|
|
58
69
|
]
|
|
@@ -64,6 +75,28 @@ Framework-aware action reference (prefer these over evaluate for React/MUI apps)
|
|
|
64
75
|
- focus_autocomplete: focus an autocomplete input by its label text (supports MUI .MuiAutocomplete-root and [role="combobox"])
|
|
65
76
|
- click_chip: click a chip/tag element by text (searches [class*="Chip"], [data-chip])
|
|
66
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
|
+
Visual regression:
|
|
91
|
+
- assert_visual: compare current page against a golden reference screenshot. "value" is the golden filename (e.g. "login-page.png"). First run auto-saves the golden. "text" is optional max diff percentage (default "0.02" = 2%). "selector" captures only that element. "maskRegions" ignores dynamic areas: [{ "x": 10, "y": 5, "width": 200, "height": 30 }]. Example: { "type": "assert_visual", "value": "dashboard.png", "text": "0.05" }
|
|
92
|
+
|
|
93
|
+
Multi-tab actions (for OAuth, popups, admin+user flows):
|
|
94
|
+
- open_tab: open a new tab with URL in "value". Optional "text" assigns a label for switch_tab. Example: { "type": "open_tab", "value": "/admin", "text": "admin" }
|
|
95
|
+
- switch_tab: switch to a tab by label, title regex, URL substring, or index. Example: { "type": "switch_tab", "value": "admin" }
|
|
96
|
+
- close_tab: close current tab or a named tab ("value" = label). Automatically switches to previous tab. Example: { "type": "close_tab", "value": "admin" }
|
|
97
|
+
- wait_for_tab: wait for a popup/new tab opened by the page (window.open, target=_blank). Optional "text" labels it. Example: { "type": "wait_for_tab", "text": "oauth" }
|
|
98
|
+
- assert_tab_count: verify number of open tabs. "value" is count or operator. Example: { "type": "assert_tab_count", "value": "2" }
|
|
99
|
+
|
|
67
100
|
Assertion action reference:
|
|
68
101
|
- assert_text: checks if text appears anywhere in the page body
|
|
69
102
|
- assert_element_text: checks textContent of a specific element (use "value": "exact" for strict match)
|
|
@@ -80,8 +113,15 @@ Reusable modules:
|
|
|
80
113
|
- Tests can reference shared action sequences: { "$use": "module-name", "params": { "key": "value" } }
|
|
81
114
|
- Use modules for repeated flows like login, navigation, or setup
|
|
82
115
|
|
|
116
|
+
Hooks and DRY patterns:
|
|
117
|
+
- When multiple tests share the same setup (e.g. authentication), use beforeEach instead of repeating it per test
|
|
118
|
+
- Object format with hooks: { "beforeEach": [...], "tests": [{ "name": "...", "actions": [...] }] }
|
|
119
|
+
- Array format (no hooks): [{ "name": "...", "actions": [...] }]
|
|
120
|
+
- If 3+ tests repeat the same action sequence (e.g. goto + wait + screenshot), extract it into a module
|
|
121
|
+
- NEVER repeat the same $use call with identical params across all tests — move it to beforeEach
|
|
122
|
+
|
|
83
123
|
Rules:
|
|
84
|
-
- Output
|
|
124
|
+
- Output valid JSON: either a plain array of test objects, or an object with "beforeEach"/"tests" keys when hooks are needed
|
|
85
125
|
- NEVER use evaluate with inline JS for assertions that can be done with native action types:
|
|
86
126
|
* Use assert_element_text instead of evaluate to check element textContent
|
|
87
127
|
* Use assert_attribute instead of evaluate to check HTML attributes
|
|
@@ -94,6 +134,12 @@ Rules:
|
|
|
94
134
|
* Use click_option instead of evaluate with querySelectorAll('[role="option"]') patterns
|
|
95
135
|
* Use focus_autocomplete instead of evaluate with MuiAutocomplete-root label search patterns
|
|
96
136
|
* Use click_chip instead of evaluate with querySelectorAll('[class*="Chip"]') patterns
|
|
137
|
+
* Use set_storage instead of evaluate with localStorage.setItem or sessionStorage.setItem
|
|
138
|
+
* Use assert_storage instead of evaluate with localStorage.getItem or sessionStorage.getItem checks
|
|
139
|
+
* Use click_icon instead of evaluate with querySelector('svg[data-testid]').closest('button').click() patterns
|
|
140
|
+
* Use click_menu_item instead of evaluate with querySelectorAll('[role="menuitem"]') patterns
|
|
141
|
+
* Use click_in_context instead of evaluate that finds a container by text then clicks a child element
|
|
142
|
+
* Use gql instead of evaluate with fetch + JSON.stringify + GraphQL queries/mutations
|
|
97
143
|
* Reserve evaluate ONLY for complex logic that cannot be expressed with existing action types
|
|
98
144
|
- "click" with "text" (no selector) finds buttons/links by visible text
|
|
99
145
|
- "goto" values starting with "/" are relative to the app's base URL
|
|
@@ -117,9 +163,11 @@ CRITICAL — UI-first testing rules:
|
|
|
117
163
|
|
|
118
164
|
const API_RULES = `
|
|
119
165
|
API testing rules:
|
|
120
|
-
- Tests verify backend API behavior directly via evaluate actions
|
|
166
|
+
- Tests verify backend API behavior directly via gql actions (preferred) or evaluate actions
|
|
121
167
|
- Each test should: set up context → call API → assert response shape and values
|
|
122
|
-
-
|
|
168
|
+
- PREFER the gql action for GraphQL queries/mutations — it handles auth and error checking automatically
|
|
169
|
+
- Use gql with "selector" field for inline assertions on the response (JS expression where "r" is the response)
|
|
170
|
+
- Use evaluate with window.__e2eGql() for complex multi-step GraphQL operations (the helper is installed by any gql action)
|
|
123
171
|
- Name tests clearly describing the API operation (e.g. "createUser-returns-new-user")
|
|
124
172
|
- Include error case tests (invalid input, missing fields, auth failures)
|
|
125
173
|
- No need for goto/click/type — this is not UI testing
|
|
@@ -201,6 +249,77 @@ Existing suites: ${existingSuites.join(', ') || 'none'}`;
|
|
|
201
249
|
};
|
|
202
250
|
}
|
|
203
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Generates a hindsight hint for a failed test result.
|
|
254
|
+
* Sends the error + action context to Claude API and returns a concrete fix suggestion.
|
|
255
|
+
* Returns null if API key is unavailable or on any error.
|
|
256
|
+
*/
|
|
257
|
+
export async function generateHindsightHint(failedResult, config = {}) {
|
|
258
|
+
const apiKey = config.anthropicApiKey || process.env.ANTHROPIC_API_KEY;
|
|
259
|
+
if (!apiKey) return null;
|
|
260
|
+
|
|
261
|
+
const model = config.hintsModel || config.anthropicModel || 'claude-sonnet-4-5-20250929';
|
|
262
|
+
const lastActions = (failedResult.actions || []).slice(-8);
|
|
263
|
+
const failedAction = lastActions.find(a => a.success === false);
|
|
264
|
+
|
|
265
|
+
const consoleErrors = (failedResult.consoleLogs || [])
|
|
266
|
+
.filter(l => l.type === 'error')
|
|
267
|
+
.slice(-5)
|
|
268
|
+
.map(l => l.text);
|
|
269
|
+
|
|
270
|
+
const networkErrors = (failedResult.networkErrors || [])
|
|
271
|
+
.slice(-5)
|
|
272
|
+
.map(e => `${e.url} (${e.error})`);
|
|
273
|
+
|
|
274
|
+
const prompt = `Analyze this failed E2E test and suggest a concrete fix.
|
|
275
|
+
|
|
276
|
+
TEST: "${failedResult.name}"
|
|
277
|
+
ERROR: ${failedResult.error}
|
|
278
|
+
|
|
279
|
+
LAST ACTIONS:
|
|
280
|
+
${lastActions.map((a, i) => ` ${i + 1}. ${a.type}${a.selector ? ' selector=' + a.selector : ''}${a.text ? ' text=' + a.text : ''}${a.value ? ' value=' + (a.value.length > 80 ? a.value.slice(0, 80) + '...' : a.value) : ''} → ${a.success === false ? 'FAILED: ' + a.error : 'OK'} (${a.duration}ms)`).join('\n')}
|
|
281
|
+
|
|
282
|
+
${failedAction ? `FAILED ACTION: ${JSON.stringify({ type: failedAction.type, selector: failedAction.selector, text: failedAction.text, value: failedAction.value?.slice?.(0, 200) })}` : ''}
|
|
283
|
+
${consoleErrors.length ? `CONSOLE ERRORS:\n${consoleErrors.join('\n')}` : ''}
|
|
284
|
+
${networkErrors.length ? `NETWORK ERRORS:\n${networkErrors.join('\n')}` : ''}
|
|
285
|
+
|
|
286
|
+
Respond with ONLY a JSON object: { "suggestion": "concrete fix description", "confidence": "high"|"medium"|"low", "fixType": "selector"|"wait"|"timeout"|"logic"|"infra"|"data" }`;
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
const controller = new AbortController();
|
|
290
|
+
const timeout = setTimeout(() => controller.abort(), 15000);
|
|
291
|
+
|
|
292
|
+
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
293
|
+
method: 'POST',
|
|
294
|
+
headers: {
|
|
295
|
+
'Content-Type': 'application/json',
|
|
296
|
+
'x-api-key': apiKey,
|
|
297
|
+
'anthropic-version': '2023-06-01',
|
|
298
|
+
},
|
|
299
|
+
body: JSON.stringify({
|
|
300
|
+
model,
|
|
301
|
+
max_tokens: 1024,
|
|
302
|
+
system: 'You are an E2E test debugging expert. Given a failed test, suggest the most likely fix. Be specific: name exact selectors, wait times, or code changes. Keep suggestions under 100 words.',
|
|
303
|
+
messages: [{ role: 'user', content: prompt }],
|
|
304
|
+
}),
|
|
305
|
+
signal: controller.signal,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
clearTimeout(timeout);
|
|
309
|
+
|
|
310
|
+
if (!response.ok) return null;
|
|
311
|
+
const result = await response.json();
|
|
312
|
+
const text = result.content?.[0]?.text;
|
|
313
|
+
if (!text) return null;
|
|
314
|
+
|
|
315
|
+
const cleaned = text.replace(/^```(?:json)?\s*\n?/m, '').replace(/\n?```\s*$/m, '').trim();
|
|
316
|
+
const hint = JSON.parse(cleaned);
|
|
317
|
+
return { test: failedResult.name, ...hint };
|
|
318
|
+
} catch {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
204
323
|
/**
|
|
205
324
|
* Checks if the Anthropic API key is available.
|
|
206
325
|
* @returns {boolean}
|
|
@@ -247,7 +366,7 @@ Test Category: ${testType}
|
|
|
247
366
|
${categoryRules}
|
|
248
367
|
Base URL: ${config.baseUrl}
|
|
249
368
|
|
|
250
|
-
Output
|
|
369
|
+
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.`;
|
|
251
370
|
|
|
252
371
|
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
253
372
|
method: 'POST',
|
|
@@ -288,9 +407,21 @@ Output a JSON array of test objects. Nothing else.`;
|
|
|
288
407
|
throw new Error(`Failed to parse generated tests as JSON: ${err.message}\n\nRaw output:\n${text}`);
|
|
289
408
|
}
|
|
290
409
|
|
|
291
|
-
|
|
292
|
-
|
|
410
|
+
// Accept both array format and object format with hooks
|
|
411
|
+
let hooks;
|
|
412
|
+
if (Array.isArray(tests)) {
|
|
413
|
+
// Plain array: [{ name, actions }]
|
|
414
|
+
} else if (tests && Array.isArray(tests.tests)) {
|
|
415
|
+
// Object with hooks: { beforeEach: [...], tests: [...] }
|
|
416
|
+
hooks = {};
|
|
417
|
+
for (const key of ['beforeAll', 'afterAll', 'beforeEach', 'afterEach']) {
|
|
418
|
+
if (Array.isArray(tests[key])) hooks[key] = tests[key];
|
|
419
|
+
}
|
|
420
|
+
if (Object.keys(hooks).length === 0) hooks = undefined;
|
|
421
|
+
tests = tests.tests;
|
|
422
|
+
} else {
|
|
423
|
+
throw new Error('Generated tests must be a JSON array or an object with a "tests" array');
|
|
293
424
|
}
|
|
294
425
|
|
|
295
|
-
return { tests, suiteName };
|
|
426
|
+
return { tests, hooks, suiteName };
|
|
296
427
|
}
|
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
|
+
}
|