@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
|
@@ -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
|
|
|
@@ -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
|
}
|
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) {
|
|
@@ -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
|
|
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'
|
|
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
|
|
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
|
}
|