@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.
Files changed (56) hide show
  1. package/.claude-plugin/marketplace.json +37 -6
  2. package/.claude-plugin/plugin.json +17 -3
  3. package/LICENSE +190 -0
  4. package/README.md +151 -527
  5. package/agents/test-creator.md +4 -2
  6. package/agents/test-improver.md +5 -3
  7. package/bin/cli.js +84 -20
  8. package/commands/capture.md +45 -0
  9. package/package.json +3 -2
  10. package/skills/e2e-testing/SKILL.md +3 -2
  11. package/skills/e2e-testing/references/action-types.md +22 -4
  12. package/skills/e2e-testing/references/test-json-format.md +23 -0
  13. package/src/actions.js +321 -14
  14. package/src/ai-generate.js +81 -0
  15. package/src/app-pool.js +339 -0
  16. package/src/config.js +131 -7
  17. package/src/dashboard.js +209 -11
  18. package/src/db.js +74 -7
  19. package/src/index.js +6 -4
  20. package/src/learner-sqlite.js +154 -0
  21. package/src/learner.js +70 -3
  22. package/src/mcp-tools.js +259 -34
  23. package/src/module-analysis.js +247 -0
  24. package/src/module-resolver.js +35 -2
  25. package/src/narrate.js +42 -1
  26. package/src/pool-manager.js +68 -17
  27. package/src/pool.js +464 -37
  28. package/src/reporter.js +4 -1
  29. package/src/runner.js +410 -63
  30. package/src/visual-diff.js +515 -0
  31. package/src/websocket.js +14 -3
  32. package/src/wizard.js +184 -0
  33. package/templates/build-dashboard.js +3 -0
  34. package/templates/dashboard/js/api.js +62 -3
  35. package/templates/dashboard/js/init.js +46 -0
  36. package/templates/dashboard/js/keyboard.js +8 -7
  37. package/templates/dashboard/js/quicksearch.js +277 -0
  38. package/templates/dashboard/js/state.js +61 -7
  39. package/templates/dashboard/js/toast.js +1 -1
  40. package/templates/dashboard/js/utils.js +20 -0
  41. package/templates/dashboard/js/view-live.js +240 -9
  42. package/templates/dashboard/js/view-runs.js +540 -94
  43. package/templates/dashboard/js/view-tests.js +157 -16
  44. package/templates/dashboard/js/view-tools.js +234 -0
  45. package/templates/dashboard/js/view-watch.js +2 -2
  46. package/templates/dashboard/js/websocket.js +36 -0
  47. package/templates/dashboard/styles/base.css +489 -53
  48. package/templates/dashboard/styles/components.css +719 -77
  49. package/templates/dashboard/styles/view-live.css +463 -59
  50. package/templates/dashboard/styles/view-runs.css +793 -155
  51. package/templates/dashboard/styles/view-tests.css +440 -77
  52. package/templates/dashboard/styles/view-tools.css +206 -0
  53. package/templates/dashboard/styles/view-watch.css +198 -41
  54. package/templates/dashboard/template.html +369 -56
  55. package/templates/dashboard.html +5375 -901
  56. package/templates/docker-compose-lightpanda.yml +7 -0
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Module Analysis — deterministic duplication detector.
3
+ *
4
+ * Scans every test JSON in testsDir, normalizes action sequences to
5
+ * signatures (literal values become placeholders, but selectors/text
6
+ * stay so two unrelated clicks don't collide), and reports sequences of
7
+ * length 3-8 that appear in 2+ different tests. These are the canonical
8
+ * candidates the test-improver agent would extract into a `$use` module.
9
+ *
10
+ * Also enumerates current modules and counts how often each is referenced
11
+ * via `$use` across the project so users can see adoption.
12
+ */
13
+
14
+ import fs from 'fs';
15
+ import path from 'path';
16
+
17
+ const MIN_SEQ_LEN = 3;
18
+ const MAX_SEQ_LEN = 8;
19
+
20
+ /** Stable signature for one action — literals → '*', identifiers kept. */
21
+ function signatureOf(action) {
22
+ if (!action || typeof action !== 'object') return '?';
23
+ if (action.$use) return `$use:${action.$use}`;
24
+ const type = action.type || '?';
25
+ // Keep selector + text (semantic identifiers); replace `value` with `*`
26
+ // since values are usually parameterizable.
27
+ const parts = [type];
28
+ if (action.selector) parts.push(`@${action.selector}`);
29
+ if (action.text != null) parts.push(`"${String(action.text).slice(0, 40)}"`);
30
+ if (action.value != null) parts.push('*');
31
+ return parts.join('|');
32
+ }
33
+
34
+ function walkTests(testsDir) {
35
+ const out = [];
36
+ let files = [];
37
+ try {
38
+ files = fs.readdirSync(testsDir).filter(f => f.endsWith('.json')).sort();
39
+ } catch { return out; }
40
+ for (const file of files) {
41
+ const fp = path.join(testsDir, file);
42
+ let suite;
43
+ try { suite = JSON.parse(fs.readFileSync(fp, 'utf-8')); } catch { continue; }
44
+ const tests = Array.isArray(suite) ? suite : suite.tests || [];
45
+ for (const t of tests) {
46
+ if (!t || !Array.isArray(t.actions)) continue;
47
+ out.push({
48
+ file,
49
+ suite: file.replace(/\.json$/, ''),
50
+ test: t.name || '(unnamed)',
51
+ actions: t.actions,
52
+ signatures: t.actions.map(signatureOf),
53
+ });
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+
59
+ function findCandidates(tests) {
60
+ // Map signature-sequence-string → [{testIdx, start}]
61
+ const seen = new Map();
62
+ for (let ti = 0; ti < tests.length; ti++) {
63
+ const sig = tests[ti].signatures;
64
+ for (let len = MIN_SEQ_LEN; len <= MAX_SEQ_LEN; len++) {
65
+ for (let i = 0; i + len <= sig.length; i++) {
66
+ const key = sig.slice(i, i + len).join(' >> ');
67
+ if (!seen.has(key)) seen.set(key, []);
68
+ seen.get(key).push({ testIdx: ti, start: i, len });
69
+ }
70
+ }
71
+ }
72
+
73
+ const candidates = [];
74
+ for (const [key, hits] of seen) {
75
+ // Distinct tests, not just same test repeated
76
+ const distinct = new Map();
77
+ for (const h of hits) {
78
+ const t = tests[h.testIdx];
79
+ const id = t.suite + '::' + t.test;
80
+ if (!distinct.has(id)) distinct.set(id, { test: t, hits: [] });
81
+ distinct.get(id).hits.push(h);
82
+ }
83
+ if (distinct.size < 2) continue;
84
+ candidates.push({
85
+ signature: key,
86
+ length: hits[0].len,
87
+ occurrenceCount: hits.length,
88
+ testCount: distinct.size,
89
+ // Best representative — first hit's actions, lifted from the test
90
+ sample: tests[hits[0].testIdx].actions.slice(hits[0].start, hits[0].start + hits[0].len),
91
+ usedBy: [...distinct.values()].map(d => ({
92
+ suite: d.test.suite,
93
+ test: d.test.test,
94
+ occurrences: d.hits.length,
95
+ })),
96
+ });
97
+ }
98
+
99
+ // Rank: maximize (savings ≈ length * (testCount - 1))
100
+ // Then prefer longer sequences over shorter ones.
101
+ candidates.sort((a, b) => {
102
+ const savingsA = a.length * (a.testCount - 1);
103
+ const savingsB = b.length * (b.testCount - 1);
104
+ if (savingsA !== savingsB) return savingsB - savingsA;
105
+ return b.length - a.length;
106
+ });
107
+
108
+ // Prune: drop sequences that are strict substrings of a higher-scored one
109
+ // covering the same set of tests (the shorter one is redundant once the
110
+ // longer one is extracted).
111
+ const kept = [];
112
+ for (const c of candidates) {
113
+ const covered = kept.find(k =>
114
+ k.signature.includes(c.signature) &&
115
+ JSON.stringify(k.usedBy.map(u => u.suite+'::'+u.test).sort()) ===
116
+ JSON.stringify(c.usedBy.map(u => u.suite+'::'+u.test).sort())
117
+ );
118
+ if (!covered) kept.push(c);
119
+ }
120
+
121
+ // Suggest a name from the dominant action types
122
+ for (const c of kept) {
123
+ c.suggestedName = suggestModuleName(c.sample);
124
+ }
125
+
126
+ return kept.slice(0, 30);
127
+ }
128
+
129
+ function suggestModuleName(actions) {
130
+ if (!actions || !actions.length) return 'module';
131
+ const types = actions.map(a => a?.type).filter(Boolean);
132
+ // Heuristics for common patterns
133
+ const hasGoto = types.includes('goto');
134
+ const hasType = types.includes('type') || types.includes('fill') || types.includes('type_react');
135
+ const hasClick = types.some(t => t && t.startsWith('click'));
136
+ const hasAssert = types.some(t => t && t.startsWith('assert'));
137
+ // Pull a noun-y hint from selector or text of first non-goto action
138
+ const hint = (function () {
139
+ for (const a of actions) {
140
+ const s = a?.text || a?.selector || a?.value;
141
+ if (typeof s === 'string' && s.length > 0 && s.length < 30) {
142
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24);
143
+ }
144
+ }
145
+ return '';
146
+ })();
147
+ if (hasGoto && hasType && hasClick) return 'navigate-and-submit' + (hint ? '-' + hint : '');
148
+ if (hasType && hasClick) return 'fill-form' + (hint ? '-' + hint : '');
149
+ if (hasGoto && hasAssert) return 'open-and-verify' + (hint ? '-' + hint : '');
150
+ if (hasGoto) return 'navigate' + (hint ? '-' + hint : '');
151
+ if (hint) return hint;
152
+ return 'extracted-module';
153
+ }
154
+
155
+ function loadModules(modulesDir) {
156
+ if (!modulesDir || !fs.existsSync(modulesDir)) return [];
157
+ const files = fs.readdirSync(modulesDir).filter(f => f.endsWith('.json')).sort();
158
+ return files.map(f => {
159
+ const fp = path.join(modulesDir, f);
160
+ let data = {};
161
+ try { data = JSON.parse(fs.readFileSync(fp, 'utf-8')); } catch { /* */ }
162
+ return {
163
+ name: f.replace(/\.json$/, ''),
164
+ file: f,
165
+ description: data.description || null,
166
+ params: data.params || [],
167
+ actionCount: Array.isArray(data.actions) ? data.actions.length : 0,
168
+ };
169
+ });
170
+ }
171
+
172
+ function countModuleUsage(tests, modules) {
173
+ const usage = new Map(modules.map(m => [m.name, { count: 0, usedBy: new Set() }]));
174
+ function walk(actions, testInfo) {
175
+ if (!Array.isArray(actions)) return;
176
+ for (const a of actions) {
177
+ if (a && a.$use) {
178
+ const u = usage.get(a.$use);
179
+ if (u) { u.count++; u.usedBy.add(testInfo); }
180
+ }
181
+ }
182
+ }
183
+ for (const t of tests) {
184
+ walk(t.actions, t.suite + '::' + t.test);
185
+ }
186
+ return modules.map(m => {
187
+ const u = usage.get(m.name) || { count: 0, usedBy: new Set() };
188
+ return { ...m, usageCount: u.count, usedBy: [...u.usedBy] };
189
+ });
190
+ }
191
+
192
+ export function runModuleAnalysis(testsDir, modulesDir) {
193
+ const tests = walkTests(testsDir);
194
+ const modules = loadModules(modulesDir);
195
+ const candidates = findCandidates(tests);
196
+ const modulesWithUsage = countModuleUsage(tests, modules);
197
+
198
+ // Build a Claude Code prompt the user can copy verbatim.
199
+ const prompt = buildAgentPrompt(testsDir, modulesDir, candidates, modulesWithUsage);
200
+
201
+ return {
202
+ testsDir,
203
+ modulesDir,
204
+ summary: {
205
+ testCount: tests.length,
206
+ moduleCount: modulesWithUsage.length,
207
+ candidateCount: candidates.length,
208
+ unusedModules: modulesWithUsage.filter(m => m.usageCount === 0).length,
209
+ },
210
+ modules: modulesWithUsage,
211
+ candidates,
212
+ agentPrompt: prompt,
213
+ };
214
+ }
215
+
216
+ function buildAgentPrompt(testsDir, modulesDir, candidates, modules) {
217
+ const topCandidates = candidates.slice(0, 10);
218
+ return [
219
+ 'Analyze E2E test modules and recommend changes.',
220
+ '',
221
+ `Tests directory: ${testsDir}`,
222
+ `Modules directory: ${modulesDir}`,
223
+ '',
224
+ 'Use the test-improver capabilities to:',
225
+ '1. Review the candidate sequences below and decide which should be extracted into reusable modules via `e2e_create_module`.',
226
+ '2. For each extracted module, suggest the parameters needed (selectors/text that vary between usages).',
227
+ '3. Check the current modules list for any that are unused or could be consolidated.',
228
+ '4. After creating modules, Edit the affected test files to replace inline action sequences with `{ "$use": "<module-name>", "params": {...} }`.',
229
+ '',
230
+ `## Top ${topCandidates.length} extraction candidates`,
231
+ '',
232
+ ...topCandidates.map((c, i) =>
233
+ `${i + 1}. **${c.suggestedName}** (${c.length} actions, used in ${c.testCount} tests, ${c.occurrenceCount} total occurrences)\n` +
234
+ ` Signature: \`${c.signature}\`\n` +
235
+ ` Used by: ${c.usedBy.map(u => `${u.suite}::${u.test}`).join(', ')}`
236
+ ),
237
+ '',
238
+ '## Current modules',
239
+ '',
240
+ ...modules.map(m =>
241
+ `- **${m.name}** — ${m.actionCount} actions, ${m.params.length} params, used ${m.usageCount}x` +
242
+ (m.description ? `\n > ${m.description}` : '')
243
+ ),
244
+ '',
245
+ 'After making changes, run the affected tests to confirm nothing broke.',
246
+ ].join('\n');
247
+ }
@@ -14,6 +14,7 @@
14
14
  import fs from 'fs';
15
15
  import path from 'path';
16
16
  import { KNOWN_ACTION_TYPES } from './actions.js';
17
+ import { KNOWN_DRIVERS } from './pool.js';
17
18
 
18
19
  /**
19
20
  * Loads all module definitions from a directory.
@@ -170,8 +171,17 @@ function resolveActions(actions, registry, contextParams = {}, visited = new Set
170
171
  const moduleActions = moduleDef.actions || [];
171
172
  const substituted = moduleActions.map(a => {
172
173
  if (a.$use) {
173
- // Nested $use — pass through for recursive resolution
174
- return { ...a, params: { ...mergedParams, ...a.params } };
174
+ // Nested $use — resolve {{param}} placeholders in the nested call's
175
+ // params against THIS module's scope (its merged params + defaults)
176
+ // so a module can forward its own params/defaults to a module it
177
+ // $uses. Then merge over the inherited context params as fallback.
178
+ const nestedParams = {};
179
+ for (const [k, v] of Object.entries(a.params || {})) {
180
+ nestedParams[k] = typeof v === 'string'
181
+ ? substituteParams(v, mergedParams, moduleDef.params)
182
+ : v;
183
+ }
184
+ return { ...a, params: { ...mergedParams, ...nestedParams } };
175
185
  }
176
186
  return substituteActionParams(a, mergedParams, moduleDef.params, moduleName);
177
187
  });
@@ -307,4 +317,27 @@ export function validateActionTypes(data, context) {
307
317
  const details = unknown.map(u => `"${u.type}" in ${u.location}`).join(', ');
308
318
  throw new Error(`Unknown action type(s) in ${context}: ${details}`);
309
319
  }
320
+
321
+ // Validate per-test driver / fallbackDriver fields
322
+ const knownExceptAuto = [...KNOWN_DRIVERS].filter(d => d !== 'auto');
323
+ for (const test of data.tests || []) {
324
+ if (test.driver !== undefined && !knownExceptAuto.includes(test.driver)) {
325
+ throw new Error(
326
+ `Invalid driver "${test.driver}" in test "${test.name}" (${context}). ` +
327
+ `Allowed: ${knownExceptAuto.join(', ')}.`
328
+ );
329
+ }
330
+ if (test.fallbackDriver !== undefined && !knownExceptAuto.includes(test.fallbackDriver)) {
331
+ throw new Error(
332
+ `Invalid fallbackDriver "${test.fallbackDriver}" in test "${test.name}" (${context}). ` +
333
+ `Allowed: ${knownExceptAuto.join(', ')}.`
334
+ );
335
+ }
336
+ if (test.fallbackDriver !== undefined && test.driver === undefined) {
337
+ throw new Error(
338
+ `Test "${test.name}" (${context}) declares fallbackDriver without driver. ` +
339
+ `fallbackDriver only applies when driver is set.`
340
+ );
341
+ }
342
+ }
310
343
  }
package/src/narrate.js CHANGED
@@ -36,15 +36,24 @@ export function narrateAction(action, result) {
36
36
  return `Typed "${masked}" into "${selector}"${time}`;
37
37
  }
38
38
 
39
- case 'wait':
39
+ case 'wait': {
40
+ const goneTarget = typeof action.gone === 'string' ? action.gone : (action.gone === true ? (selector || (text ? `text "${text}"` : null)) : null);
41
+ if (goneTarget) return `Waited for ${goneTarget.startsWith('text ') ? goneTarget : `"${goneTarget}"`} to disappear${time}`;
40
42
  if (selector) return `Waited for "${selector}" to appear${time}`;
41
43
  if (text) return `Waited for text "${text}" to appear${time}`;
42
44
  return `Waited ${value}ms`;
45
+ }
43
46
 
44
47
  case 'screenshot':
45
48
  if (result.result?.screenshot) {
46
49
  return `Captured screenshot: ${result.result.screenshot}`;
47
50
  }
51
+ if (result.result?.skipped) {
52
+ const reason = result.result.skipped === 'blank-page'
53
+ ? 'page was blank'
54
+ : 'render looked blank';
55
+ return `Skipped screenshot (${reason})`;
56
+ }
48
57
  return `Captured screenshot${value ? `: ${value}` : ''}`;
49
58
 
50
59
  case 'assert_text':
@@ -123,6 +132,9 @@ export function narrateAction(action, result) {
123
132
  case 'click_option':
124
133
  return `Clicked dropdown option "${text}"${time}`;
125
134
 
135
+ case 'select_combobox':
136
+ return `Selected "${text || action.option}" from combobox${action.filter ? ` (filtered "${action.filter}")` : ''}${time}`;
137
+
126
138
  case 'focus_autocomplete':
127
139
  return `Focused autocomplete labeled "${text}"${time}`;
128
140
 
@@ -167,6 +179,28 @@ export function narrateAction(action, result) {
167
179
  return `Executed JS: ${snippet}${time}`;
168
180
  }
169
181
 
182
+ case 'assert_visual': {
183
+ const vr = result.result;
184
+ if (vr?.goldenCreated) return `Saved golden reference: ${value}${time}`;
185
+ const pct = vr?.diffPercentage != null ? (vr.diffPercentage * 100).toFixed(2) + '% diff' : '';
186
+ return `Visual comparison against "${value}": ${pct}${time}`;
187
+ }
188
+
189
+ case 'open_tab':
190
+ return `Opened new tab${text ? ` "${text}"` : ''} → ${value}${time}`;
191
+
192
+ case 'switch_tab':
193
+ return `Switched to tab "${value}"${time}`;
194
+
195
+ case 'close_tab':
196
+ return `Closed tab${value ? ` "${value}"` : ''}${time}`;
197
+
198
+ case 'assert_tab_count':
199
+ return `Verified ${value} tab(s) open${time}`;
200
+
201
+ case 'wait_for_tab':
202
+ return `Waited for new tab to open${text ? ` (labeled "${text}")` : ''}${time}`;
203
+
170
204
  default:
171
205
  return `Unknown action "${type}"${time}`;
172
206
  }
@@ -210,6 +244,7 @@ function describeIntent(action) {
210
244
  case 'type_react': return `Type into React input "${selector}"`;
211
245
  case 'click_regex': return `Click element matching /${text}/i`;
212
246
  case 'click_option': return `Click option "${text}"`;
247
+ case 'select_combobox': return `Select "${text || action.option}" from combobox`;
213
248
  case 'focus_autocomplete': return `Focus autocomplete "${text}"`;
214
249
  case 'click_chip': return `Click chip "${text}"`;
215
250
  case 'set_storage': return `Set ${selector === 'session' ? 'sessionStorage' : 'localStorage'} key "${value?.split('=')[0] || value}"`;
@@ -223,6 +258,12 @@ function describeIntent(action) {
223
258
  case 'click_menu_item': return `Click menu item "${text}"`;
224
259
  case 'click_in_context': return `Click "${selector}" in context of "${text}"`;
225
260
  case 'evaluate': return 'Execute JS';
261
+ case 'assert_visual': return `Visual compare against "${value}"`;
262
+ case 'open_tab': return `Open new tab → ${value}`;
263
+ case 'switch_tab': return `Switch to tab "${value}"`;
264
+ case 'close_tab': return `Close tab${value ? ` "${value}"` : ''}`;
265
+ case 'assert_tab_count': return `Assert ${value} tab(s) open`;
266
+ case 'wait_for_tab': return 'Wait for new tab';
226
267
  default: return `Action "${type}"`;
227
268
  }
228
269
  }
@@ -11,7 +11,7 @@
11
11
  * subsequent calls factor in connections that are in-flight.
12
12
  */
13
13
 
14
- import { getPoolStatus, connectToPool } from './pool.js';
14
+ import { getPoolStatus, connectToPool, getCachedDriver } from './pool.js';
15
15
  import { log, colors as C } from './logger.js';
16
16
 
17
17
  function sleep(ms) {
@@ -42,16 +42,22 @@ function getPending(poolUrl) {
42
42
  return pendingConnections.get(poolUrl) || 0;
43
43
  }
44
44
 
45
+ /** Extracts pool driver options from config for passing to getPoolStatus. */
46
+ function driverOpts(config) {
47
+ if (!config) return {};
48
+ return { poolDriver: config.poolDriver || 'auto', maxSessions: config.maxSessions || 10 };
49
+ }
50
+
45
51
  /** Returns the normalized pool URL array from config. Always an array, even for single pool. */
46
52
  export function getPoolUrls(config) {
47
53
  return config._poolUrls || [config.poolUrl];
48
54
  }
49
55
 
50
- /** Fetches /pressure from all pools in parallel. Returns [{ url, status, error }]. */
51
- export async function getAllPoolStatuses(poolUrls) {
56
+ /** Fetches status from all pools in parallel. Returns [{ url, status, error }]. */
57
+ export async function getAllPoolStatuses(poolUrls, options = {}) {
52
58
  return Promise.all(poolUrls.map(async (url) => {
53
59
  try {
54
- const status = await getPoolStatus(url);
60
+ const status = await getPoolStatus(url, options);
55
61
  return { url, status, error: null };
56
62
  } catch (error) {
57
63
  return { url, status: null, error: error.message };
@@ -60,8 +66,8 @@ export async function getAllPoolStatuses(poolUrls) {
60
66
  }
61
67
 
62
68
  /** Combined view across all pools: totalRunning, totalMaxConcurrent, per-pool details. */
63
- export async function getAggregatedPoolStatus(poolUrls) {
64
- const results = await getAllPoolStatuses(poolUrls);
69
+ export async function getAggregatedPoolStatus(poolUrls, options = {}) {
70
+ const results = await getAllPoolStatuses(poolUrls, options);
65
71
 
66
72
  let totalRunning = 0;
67
73
  let totalMaxConcurrent = 0;
@@ -90,11 +96,11 @@ export async function getAggregatedPoolStatus(poolUrls) {
90
96
  }
91
97
 
92
98
  /** Blocks until at least one pool is reachable and available. */
93
- export async function waitForAnyPool(poolUrls, maxWaitMs = 30000) {
99
+ export async function waitForAnyPool(poolUrls, maxWaitMs = 30000, options = {}) {
94
100
  const start = Date.now();
95
101
 
96
102
  while (Date.now() - start < maxWaitMs) {
97
- const results = await getAllPoolStatuses(poolUrls);
103
+ const results = await getAllPoolStatuses(poolUrls, options);
98
104
  const available = results.find(r => r.status?.available);
99
105
  if (available) return available.status;
100
106
 
@@ -115,17 +121,17 @@ export async function waitForAnyPool(poolUrls, maxWaitMs = 30000) {
115
121
  * Picks the pool with the lowest pressure ratio.
116
122
  *
117
123
  * Algorithm:
118
- * 1. Query all pools' /pressure in parallel
124
+ * 1. Query all pools' status in parallel (driver-aware)
119
125
  * 2. Add local pending count to each pool's running total
120
126
  * 3. Filter to reachable pools with (running + pending) < maxConcurrent
121
127
  * 4. Sort by: lowest effective pressure → fewest queued → most free slots
122
128
  * 5. Track selection in pending counter, return best candidate URL
123
129
  * 6. If all full, poll every 2s up to 60s, then pick least-pressured anyway
124
130
  */
125
- export async function selectPool(poolUrls, pollIntervalMs = 2000, maxWaitMs = 60000) {
131
+ export async function selectPool(poolUrls, pollIntervalMs = 2000, maxWaitMs = 60000, options = {}) {
126
132
  // Fast path: single pool
127
133
  if (poolUrls.length === 1) {
128
- await waitForSlotOnPool(poolUrls[0], pollIntervalMs, maxWaitMs);
134
+ await waitForSlotOnPool(poolUrls[0], pollIntervalMs, maxWaitMs, options);
129
135
  trackPending(poolUrls[0]);
130
136
  return poolUrls[0];
131
137
  }
@@ -133,7 +139,7 @@ export async function selectPool(poolUrls, pollIntervalMs = 2000, maxWaitMs = 60
133
139
  const start = Date.now();
134
140
 
135
141
  while (Date.now() - start < maxWaitMs) {
136
- const results = await getAllPoolStatuses(poolUrls);
142
+ const results = await getAllPoolStatuses(poolUrls, options);
137
143
  const candidates = results
138
144
  .filter(r => r.status && !r.error && r.status.available)
139
145
  .map(r => {
@@ -173,7 +179,7 @@ export async function selectPool(poolUrls, pollIntervalMs = 2000, maxWaitMs = 60
173
179
  }
174
180
 
175
181
  // Timeout — pick the least-pressured pool anyway (let connectToPool deal with it)
176
- const results = await getAllPoolStatuses(poolUrls);
182
+ const results = await getAllPoolStatuses(poolUrls, options);
177
183
  const reachable = results
178
184
  .filter(r => r.status && !r.error)
179
185
  .sort((a, b) => {
@@ -195,19 +201,64 @@ export async function selectPool(poolUrls, pollIntervalMs = 2000, maxWaitMs = 60
195
201
  return poolUrls[0];
196
202
  }
197
203
 
204
+ /**
205
+ * Filters pool URLs to those whose detected driver matches `driver`,
206
+ * with explicit opt-in fallback to `fallbackDriver`.
207
+ *
208
+ * Probes all pools once via getAllPoolStatuses() to warm the per-URL driver cache,
209
+ * then filters by getCachedDriver(url). Pools that are unreachable have a null
210
+ * detected driver and are excluded.
211
+ *
212
+ * Throws if no pool matches the requested driver and no usable fallback exists.
213
+ * Pool busyness is NOT a fallback trigger — selectPool() handles capacity waits
214
+ * inside the filtered set.
215
+ *
216
+ * @param {string[]} poolUrls
217
+ * @param {string} driver - Required driver name (e.g. 'obscura')
218
+ * @param {string|null} fallbackDriver - Explicit fallback driver, or null/undefined for hard error
219
+ * @param {object} options - { poolDriver, maxSessions } passed to getPoolStatus
220
+ * @returns {Promise<{urls: string[], driver: string, usedFallback: boolean}>}
221
+ */
222
+ export async function resolvePoolsForTest(poolUrls, driver, fallbackDriver, options = {}) {
223
+ // Warm driver cache for all reachable pools
224
+ await getAllPoolStatuses(poolUrls, options);
225
+
226
+ const matching = poolUrls.filter(url => getCachedDriver(url) === driver);
227
+ if (matching.length > 0) {
228
+ return { urls: matching, driver, usedFallback: false };
229
+ }
230
+
231
+ if (fallbackDriver) {
232
+ const fallbackMatching = poolUrls.filter(url => getCachedDriver(url) === fallbackDriver);
233
+ if (fallbackMatching.length > 0) {
234
+ log('⚠️', `${C.yellow}No pool with driver=${driver}, falling back to ${fallbackDriver}${C.reset}`);
235
+ return { urls: fallbackMatching, driver: fallbackDriver, usedFallback: true };
236
+ }
237
+ throw new Error(
238
+ `No pool available for driver "${driver}" and fallback driver "${fallbackDriver}" also unavailable. ` +
239
+ `Reachable pools: ${poolUrls.map(u => `${u}=${getCachedDriver(u) || 'unreachable'}`).join(', ')}`
240
+ );
241
+ }
242
+
243
+ throw new Error(
244
+ `No pool available for driver "${driver}" and no fallbackDriver specified. ` +
245
+ `Reachable pools: ${poolUrls.map(u => `${u}=${getCachedDriver(u) || 'unreachable'}`).join(', ')}`
246
+ );
247
+ }
248
+
198
249
  /** Convenience: selectPool + connectToPool in one call. */
199
250
  export async function selectAndConnect(config) {
200
251
  const poolUrls = getPoolUrls(config);
201
- const chosenUrl = await selectPool(poolUrls);
252
+ const chosenUrl = await selectPool(poolUrls, 2000, 60000, driverOpts(config));
202
253
  return connectToPool(chosenUrl, config.connectRetries, config.connectRetryDelay);
203
254
  }
204
255
 
205
- /** Waits until a single pool has capacity (replaces the old waitForSlot from runner.js). */
206
- async function waitForSlotOnPool(poolUrl, pollIntervalMs = 2000, maxWaitMs = 60000) {
256
+ /** Waits until a single pool has capacity. */
257
+ async function waitForSlotOnPool(poolUrl, pollIntervalMs = 2000, maxWaitMs = 60000, options = {}) {
207
258
  const start = Date.now();
208
259
  while (Date.now() - start < maxWaitMs) {
209
260
  try {
210
- const status = await getPoolStatus(poolUrl);
261
+ const status = await getPoolStatus(poolUrl, options);
211
262
  if (status.available && status.running < status.maxConcurrent) {
212
263
  return;
213
264
  }