@matware/e2e-runner 1.3.1 → 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 +4 -4
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +110 -21
- package/agents/test-creator.md +4 -2
- package/agents/test-improver.md +5 -3
- package/bin/cli.js +80 -17
- 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 +170 -14
- package/src/config.js +6 -0
- package/src/dashboard.js +135 -4
- package/src/db.js +11 -0
- package/src/mcp-tools.js +8 -2
- package/src/module-analysis.js +247 -0
- package/src/module-resolver.js +35 -2
- package/src/narrate.js +14 -1
- package/src/pool-manager.js +46 -1
- package/src/pool.js +177 -20
- package/src/runner.js +77 -10
- package/src/visual-diff.js +69 -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 +60 -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/view-live.js +235 -42
- package/templates/dashboard/js/view-runs.js +379 -37
- 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 +33 -3
- package/templates/dashboard/styles/base.css +489 -53
- package/templates/dashboard/styles/components.css +719 -84
- package/templates/dashboard/styles/view-live.css +459 -78
- package/templates/dashboard/styles/view-runs.css +779 -177
- 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 +354 -56
- package/templates/dashboard.html +5173 -711
- 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
|
+
}
|
package/src/module-resolver.js
CHANGED
|
@@ -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 —
|
|
174
|
-
|
|
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
|
|
|
@@ -232,6 +244,7 @@ function describeIntent(action) {
|
|
|
232
244
|
case 'type_react': return `Type into React input "${selector}"`;
|
|
233
245
|
case 'click_regex': return `Click element matching /${text}/i`;
|
|
234
246
|
case 'click_option': return `Click option "${text}"`;
|
|
247
|
+
case 'select_combobox': return `Select "${text || action.option}" from combobox`;
|
|
235
248
|
case 'focus_autocomplete': return `Focus autocomplete "${text}"`;
|
|
236
249
|
case 'click_chip': return `Click chip "${text}"`;
|
|
237
250
|
case 'set_storage': return `Set ${selector === 'session' ? 'sessionStorage' : 'localStorage'} key "${value?.split('=')[0] || value}"`;
|
package/src/pool-manager.js
CHANGED
|
@@ -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) {
|
|
@@ -201,6 +201,51 @@ export async function selectPool(poolUrls, pollIntervalMs = 2000, maxWaitMs = 60
|
|
|
201
201
|
return poolUrls[0];
|
|
202
202
|
}
|
|
203
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
|
+
|
|
204
249
|
/** Convenience: selectPool + connectToPool in one call. */
|
|
205
250
|
export async function selectAndConnect(config) {
|
|
206
251
|
const poolUrls = getPoolUrls(config);
|