@hover-dev/core 0.14.1 → 0.16.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/README.md +73 -1
- package/dist/agents/aider.d.ts.map +1 -1
- package/dist/agents/aider.js +6 -14
- package/dist/agents/claude.d.ts.map +1 -1
- package/dist/agents/claude.js +14 -0
- package/dist/agents/codex.d.ts.map +1 -1
- package/dist/agents/codex.js +10 -4
- package/dist/agents/cursor.d.ts.map +1 -1
- package/dist/agents/cursor.js +8 -17
- package/dist/agents/gemini.d.ts.map +1 -1
- package/dist/agents/gemini.js +3 -14
- package/dist/agents/invoke.d.ts.map +1 -1
- package/dist/agents/invoke.js +10 -1
- package/dist/agents/qwen.d.ts.map +1 -1
- package/dist/agents/qwen.js +3 -14
- package/dist/agents/shared.d.ts +28 -0
- package/dist/agents/shared.d.ts.map +1 -0
- package/dist/agents/shared.js +35 -0
- package/dist/agents/types.d.ts +11 -0
- package/dist/agents/types.d.ts.map +1 -1
- package/dist/mcp/sourceFence.d.ts +23 -0
- package/dist/mcp/sourceFence.d.ts.map +1 -0
- package/dist/mcp/sourceFence.js +75 -0
- package/dist/mcp/sourceServer.d.ts +3 -0
- package/dist/mcp/sourceServer.d.ts.map +1 -0
- package/dist/mcp/sourceServer.js +116 -0
- package/dist/playwright/preflight.d.ts.map +1 -1
- package/dist/playwright/preflight.js +6 -1
- package/dist/playwright/raiseWindow.d.ts.map +1 -1
- package/dist/playwright/raiseWindow.js +22 -3
- package/dist/playwright/resolveMcpConfig.d.ts +11 -0
- package/dist/playwright/resolveMcpConfig.d.ts.map +1 -1
- package/dist/playwright/resolveMcpConfig.js +17 -3
- package/dist/plugin-api.d.ts +7 -0
- package/dist/plugin-api.d.ts.map +1 -1
- package/dist/runSession.d.ts +42 -0
- package/dist/runSession.d.ts.map +1 -0
- package/dist/runSession.js +81 -0
- package/dist/service/cdpHandlers.d.ts +3 -7
- package/dist/service/cdpHandlers.d.ts.map +1 -1
- package/dist/service/cdpHandlers.js +4 -16
- package/dist/service/cdpHint.d.ts.map +1 -1
- package/dist/service/cdpHint.js +30 -14
- package/dist/service/conventions.d.ts +8 -0
- package/dist/service/conventions.d.ts.map +1 -0
- package/dist/service/conventions.js +42 -0
- package/dist/service/saveHandlers.d.ts +10 -13
- package/dist/service/saveHandlers.d.ts.map +1 -1
- package/dist/service/saveHandlers.js +9 -25
- package/dist/service/types.d.ts +5 -0
- package/dist/service/types.d.ts.map +1 -1
- package/dist/service.d.ts +13 -4
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +264 -148
- package/dist/skills/writeSkill.d.ts +12 -35
- package/dist/skills/writeSkill.d.ts.map +1 -1
- package/dist/skills/writeSkill.js +10 -166
- package/dist/specs/detectSharedFlows.d.ts +35 -0
- package/dist/specs/detectSharedFlows.d.ts.map +1 -0
- package/dist/specs/detectSharedFlows.js +171 -0
- package/dist/specs/extractPageObjects.d.ts +18 -0
- package/dist/specs/extractPageObjects.d.ts.map +1 -0
- package/dist/specs/extractPageObjects.js +98 -0
- package/dist/specs/generatePageObject.d.ts +29 -0
- package/dist/specs/generatePageObject.d.ts.map +1 -0
- package/dist/specs/generatePageObject.js +149 -0
- package/dist/specs/listSpecs.d.ts +12 -0
- package/dist/specs/listSpecs.d.ts.map +1 -1
- package/dist/specs/listSpecs.js +27 -2
- package/dist/specs/optimizationSuggestion.d.ts +26 -0
- package/dist/specs/optimizationSuggestion.d.ts.map +1 -0
- package/dist/specs/optimizationSuggestion.js +28 -0
- package/dist/specs/optimizeSpec.d.ts +42 -0
- package/dist/specs/optimizeSpec.d.ts.map +1 -0
- package/dist/specs/optimizeSpec.js +188 -0
- package/dist/specs/optimizeSpecWithAgent.d.ts +11 -0
- package/dist/specs/optimizeSpecWithAgent.d.ts.map +1 -0
- package/dist/specs/optimizeSpecWithAgent.js +40 -0
- package/dist/specs/pageObjectManifest.d.ts +20 -0
- package/dist/specs/pageObjectManifest.d.ts.map +1 -0
- package/dist/specs/pageObjectManifest.js +40 -0
- package/dist/specs/seeds.d.ts +36 -0
- package/dist/specs/seeds.d.ts.map +1 -0
- package/dist/specs/seeds.js +74 -0
- package/dist/specs/sidecar.d.ts +25 -0
- package/dist/specs/sidecar.d.ts.map +1 -0
- package/dist/specs/sidecar.js +38 -0
- package/dist/specs/softBatch.d.ts +14 -0
- package/dist/specs/softBatch.d.ts.map +1 -0
- package/dist/specs/softBatch.js +177 -0
- package/dist/specs/text.d.ts +17 -0
- package/dist/specs/text.d.ts.map +1 -0
- package/dist/specs/text.js +24 -0
- package/dist/specs/writeCaseCsv.d.ts.map +1 -1
- package/dist/specs/writeCaseCsv.js +2 -8
- package/dist/specs/writeSpec.d.ts +50 -0
- package/dist/specs/writeSpec.d.ts.map +1 -1
- package/dist/specs/writeSpec.js +251 -84
- package/package.json +5 -3
package/dist/specs/writeSpec.js
CHANGED
|
@@ -19,7 +19,26 @@
|
|
|
19
19
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
20
20
|
import { existsSync } from 'node:fs';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
|
-
import { humanSteps } from './humanSteps.js';
|
|
22
|
+
import { humanSteps, humanStep } from './humanSteps.js';
|
|
23
|
+
import { writeSidecar } from './sidecar.js';
|
|
24
|
+
import { readPageObjectManifest, } from './pageObjectManifest.js';
|
|
25
|
+
import { stepSignature } from './detectSharedFlows.js';
|
|
26
|
+
import { slugify, firstSentence } from './text.js';
|
|
27
|
+
/**
|
|
28
|
+
* Marker the deterministic translator leaves where a captured action is a real
|
|
29
|
+
* interaction but has no single-step Playwright translation (e.g. file upload,
|
|
30
|
+
* drag, a dialog handler) — a shape that needs a multi-step pattern. It is a
|
|
31
|
+
* structured signal, not a `// TODO`: the optimization pass (F7) and the
|
|
32
|
+
* "seeds could complete this — review?" suggestion grep for it, and
|
|
33
|
+
* `countOptimizableMarkers` reads it back off a saved spec.
|
|
34
|
+
*/
|
|
35
|
+
export const OPTIMIZABLE_MARKER = '// hover:optimizable';
|
|
36
|
+
/** How many `// hover:optimizable` markers a generated spec carries. Used to
|
|
37
|
+
* surface "this spec has an interaction the deterministic pass couldn't fully
|
|
38
|
+
* translate — the optimization pass can complete it". */
|
|
39
|
+
export function countOptimizableMarkers(source) {
|
|
40
|
+
return source.split('\n').filter(l => l.trimStart().startsWith(OPTIMIZABLE_MARKER)).length;
|
|
41
|
+
}
|
|
23
42
|
export class SpecExistsError extends Error {
|
|
24
43
|
slug;
|
|
25
44
|
path;
|
|
@@ -43,17 +62,25 @@ export async function writeSpec(opts) {
|
|
|
43
62
|
throw new SpecExistsError(slug, path);
|
|
44
63
|
}
|
|
45
64
|
await mkdir(dir, { recursive: true });
|
|
46
|
-
|
|
65
|
+
// Stage 3c: if a prior extraction left a Page Object whose flow prefixes
|
|
66
|
+
// this spec, consume it (await loginPage.login(…)) instead of re-emitting
|
|
67
|
+
// the steps inline. No manifest (extraction never ran) → plain spec.
|
|
68
|
+
const manifest = await readPageObjectManifest(opts.devRoot);
|
|
69
|
+
const match = manifest ? matchPageObject(opts.steps, manifest) : null;
|
|
70
|
+
const source = renderSpec(slug, opts.name, opts.description ?? '', opts.steps, opts.assertions ?? [], match);
|
|
47
71
|
await writeFile(path, source, 'utf-8');
|
|
72
|
+
// Persist the structured session next to the spec so cross-session
|
|
73
|
+
// extraction (F4) and the optimization pass (F7) read real SpecStep[]
|
|
74
|
+
// instead of parsing the generated code. Lands in .hover/, which
|
|
75
|
+
// Playwright's *.spec.ts glob never collects.
|
|
76
|
+
await writeSidecar(opts.devRoot, {
|
|
77
|
+
slug,
|
|
78
|
+
name: opts.name,
|
|
79
|
+
steps: opts.steps,
|
|
80
|
+
assertions: opts.assertions ?? [],
|
|
81
|
+
});
|
|
48
82
|
return { path, slug };
|
|
49
83
|
}
|
|
50
|
-
function slugify(name) {
|
|
51
|
-
return name
|
|
52
|
-
.toLowerCase()
|
|
53
|
-
.trim()
|
|
54
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
55
|
-
.replace(/^-+|-+$/g, '');
|
|
56
|
-
}
|
|
57
84
|
// Escape sequences that would prematurely terminate the JSDoc block.
|
|
58
85
|
// (Backtick literal of close-comment sequence omitted on purpose — see how
|
|
59
86
|
// the regex below is built — to avoid recursively poisoning *this* file.)
|
|
@@ -75,12 +102,11 @@ function collectExpected(assertions, doneSummary) {
|
|
|
75
102
|
}
|
|
76
103
|
if (doneSummary && doneSummary.trim()) {
|
|
77
104
|
// Take the first sentence only — agents sometimes ramble.
|
|
78
|
-
|
|
79
|
-
return [first.trim()];
|
|
105
|
+
return [firstSentence(doneSummary)];
|
|
80
106
|
}
|
|
81
107
|
return [];
|
|
82
108
|
}
|
|
83
|
-
function renderSpec(slug, displayName, description, steps, assertions) {
|
|
109
|
+
function renderSpec(slug, displayName, description, steps, assertions, match) {
|
|
84
110
|
const userMsg = steps.find(s => s.kind === 'user');
|
|
85
111
|
const doneMsg = [...steps].reverse().find(s => s.kind === 'done');
|
|
86
112
|
// Plain-English step + expected blocks for the JSDoc header. QA / PMs
|
|
@@ -89,8 +115,77 @@ function renderSpec(slug, displayName, description, steps, assertions) {
|
|
|
89
115
|
// CSV via writeCaseCsv.
|
|
90
116
|
const proseSteps = humanSteps(steps);
|
|
91
117
|
const expectedLines = collectExpected(assertions, doneMsg?.summary);
|
|
118
|
+
// ── Walk the steps into the test body first, so we know whether any F6
|
|
119
|
+
// popup pairing needs the `context` fixture before we write the
|
|
120
|
+
// signature. Each step becomes one `test.step(...)` (Given/When/Then by
|
|
121
|
+
// position) so Playwright's HTML report reads as named stages. ──
|
|
122
|
+
const body = [];
|
|
123
|
+
let sawInteraction = false;
|
|
124
|
+
let sigSeen = 0;
|
|
125
|
+
let emittedPageObject = false;
|
|
126
|
+
let pageVar = 'page';
|
|
127
|
+
let popupCount = 0;
|
|
128
|
+
let usesContext = false;
|
|
129
|
+
const actions = steps.filter(s => s.kind === 'step' && !!s.tool);
|
|
130
|
+
for (let i = 0; i < actions.length; i++) {
|
|
131
|
+
const s = actions[i];
|
|
132
|
+
const next = actions[i + 1];
|
|
133
|
+
// Stage 3c: fold the matched login/entry prefix into one Page Object call.
|
|
134
|
+
if (match && sigSeen < match.consumedSigs && stepSignature(s.tool, s.input) != null) {
|
|
135
|
+
sigSeen++;
|
|
136
|
+
if (!emittedPageObject) {
|
|
137
|
+
pushTestStep(body, `Given · ${match.entry.methodName}`, [`await ${match.entry.fixtureName}.${match.entry.methodName}(${match.args.join(', ')});`]);
|
|
138
|
+
emittedPageObject = true;
|
|
139
|
+
sawInteraction = true;
|
|
140
|
+
}
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
// F6: a click that opens a new tab, immediately followed by a tab switch →
|
|
144
|
+
// pair them into Promise.all([context.waitForEvent('page'), …click()]) and
|
|
145
|
+
// re-target subsequent steps onto the new page. No visibility prelude on
|
|
146
|
+
// the click — the open must race the waitForEvent, not await visibility.
|
|
147
|
+
if (isPopupClick(s) && next && isTabSelectNew(next)) {
|
|
148
|
+
usesContext = true;
|
|
149
|
+
popupCount += 1;
|
|
150
|
+
const newVar = popupCount === 1 ? 'newPage' : `newPage${popupCount}`;
|
|
151
|
+
const clickSel = selectorFromDescription(String(s.input.element ?? ''), pageVar);
|
|
152
|
+
pushTestStep(body, `When · ${humanStep(s.tool, s.input) ?? s.tool}`, [
|
|
153
|
+
`const [${newVar}] = await Promise.all([`,
|
|
154
|
+
` context.waitForEvent('page'),`,
|
|
155
|
+
` ${clickSel}.click(),`,
|
|
156
|
+
`]);`,
|
|
157
|
+
]);
|
|
158
|
+
pageVar = newVar;
|
|
159
|
+
sawInteraction = true;
|
|
160
|
+
i++; // also consume the paired tab-switch step
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
// A standalone tab switch back to the original tab → re-target to `page`.
|
|
164
|
+
if (s.tool === 'browser_tabs') {
|
|
165
|
+
if (isTabSelectOriginal(s))
|
|
166
|
+
pageVar = 'page';
|
|
167
|
+
continue; // tab switches don't emit a line of their own
|
|
168
|
+
}
|
|
169
|
+
const bodyLines = translateStep(s.tool, s.input, pageVar);
|
|
170
|
+
if (bodyLines.length === 0)
|
|
171
|
+
continue; // diagnostic / non-replayable
|
|
172
|
+
const phase = !sawInteraction && s.tool === 'browser_navigate' ? 'Given' : 'When';
|
|
173
|
+
pushTestStep(body, `${phase} · ${humanStep(s.tool, s.input) ?? s.tool}`, bodyLines);
|
|
174
|
+
if (s.tool !== 'browser_navigate')
|
|
175
|
+
sawInteraction = true;
|
|
176
|
+
}
|
|
177
|
+
// Then: Alt-click assertions group under the report's final stage.
|
|
178
|
+
if (assertions.length > 0 && body.length > 0)
|
|
179
|
+
body.push('');
|
|
180
|
+
for (const a of assertions) {
|
|
181
|
+
pushTestStep(body, `Then · ${a.hint ?? 'assertion'}`, [`await ${a.code};`]);
|
|
182
|
+
}
|
|
183
|
+
// ── Assemble: import + JSDoc header + signature (widened to { context } when
|
|
184
|
+
// a popup pairing needs it, and the page-object fixture when matched). ──
|
|
92
185
|
const lines = [];
|
|
93
|
-
lines.push(
|
|
186
|
+
lines.push(match
|
|
187
|
+
? `import { test, expect } from './fixtures';`
|
|
188
|
+
: `import { test, expect } from '@playwright/test';`);
|
|
94
189
|
lines.push('');
|
|
95
190
|
lines.push('/**');
|
|
96
191
|
lines.push(` * Generated by Hover on ${new Date().toISOString().slice(0, 10)}.`);
|
|
@@ -115,80 +210,150 @@ function renderSpec(slug, displayName, description, steps, assertions) {
|
|
|
115
210
|
lines.push(' * so the spec survives markup changes that don\'t touch semantics.');
|
|
116
211
|
lines.push(' */');
|
|
117
212
|
const safeTitle = displayName.replace(/'/g, "\\'");
|
|
118
|
-
|
|
119
|
-
|
|
213
|
+
const params = ['page'];
|
|
214
|
+
if (usesContext)
|
|
215
|
+
params.unshift('context');
|
|
216
|
+
if (match)
|
|
217
|
+
params.push(match.entry.fixtureName);
|
|
218
|
+
lines.push(`test('${safeTitle}', async ({ ${params.join(', ')} }) => {`);
|
|
219
|
+
if (body.length === 0) {
|
|
220
|
+
lines.push(' // (no automatable steps were captured)');
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
for (const b of body)
|
|
224
|
+
lines.push(b);
|
|
225
|
+
}
|
|
226
|
+
lines.push('});');
|
|
227
|
+
lines.push('');
|
|
228
|
+
return lines.join('\n');
|
|
229
|
+
}
|
|
230
|
+
/** Push one `await test.step('<label>', async () => { … })` block (4-space
|
|
231
|
+
* body indent) onto the assembled spec lines. */
|
|
232
|
+
function pushTestStep(out, label, inner) {
|
|
233
|
+
out.push(` await test.step(${JSON.stringify(label)}, async () => {`);
|
|
234
|
+
for (const l of inner)
|
|
235
|
+
out.push(` ${l}`);
|
|
236
|
+
out.push(` });`);
|
|
237
|
+
}
|
|
238
|
+
/** A click that may open a new tab — the opener half of an F6 popup pairing. */
|
|
239
|
+
function isPopupClick(s) {
|
|
240
|
+
return s.tool === 'browser_click' || s.tool === 'browser_double_click';
|
|
241
|
+
}
|
|
242
|
+
/** A browser_tabs step that selects a NEW tab (idx > 0) — the switch half of an
|
|
243
|
+
* F6 popup pairing. A select back to idx 0 is a return, not a popup open. */
|
|
244
|
+
function isTabSelectNew(s) {
|
|
245
|
+
const i = (s.input ?? {});
|
|
246
|
+
return s.tool === 'browser_tabs'
|
|
247
|
+
&& i.action === 'select'
|
|
248
|
+
&& Number(i.idx ?? i.index ?? -1) > 0;
|
|
249
|
+
}
|
|
250
|
+
/** A tab switch back to the original tab (index/idx 0) → re-target to `page`. */
|
|
251
|
+
function isTabSelectOriginal(s) {
|
|
252
|
+
const i = (s.input ?? {});
|
|
253
|
+
if (i.action !== 'select')
|
|
254
|
+
return false;
|
|
255
|
+
return Number(i.idx ?? i.index ?? -1) === 0;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Stage 3c: find the longest Page Object whose recorded signature prefix
|
|
259
|
+
* matches this spec's leading steps, so the spec can call it instead of
|
|
260
|
+
* re-emitting those steps. Returns null when nothing matches.
|
|
261
|
+
*/
|
|
262
|
+
function matchPageObject(steps, manifest) {
|
|
263
|
+
const sigSteps = [];
|
|
120
264
|
for (const s of steps) {
|
|
121
265
|
if (s.kind !== 'step' || !s.tool)
|
|
122
266
|
continue;
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
// relative indent embedded in the line itself, so this single
|
|
128
|
-
// 2-space prefix produces correct 4-space nesting inside `{ … }`.
|
|
129
|
-
lines.push(` ${c}`);
|
|
130
|
-
hasAwait = true;
|
|
131
|
-
}
|
|
267
|
+
const sig = stepSignature(s.tool, s.input);
|
|
268
|
+
if (sig == null)
|
|
269
|
+
continue;
|
|
270
|
+
sigSteps.push({ sig, step: s });
|
|
132
271
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
lines.push(' // Assertions captured via Alt-click in the Hover widget.');
|
|
142
|
-
for (const a of assertions) {
|
|
143
|
-
if (a.hint)
|
|
144
|
-
lines.push(` // ${a.hint}`);
|
|
145
|
-
lines.push(` await ${a.code};`);
|
|
272
|
+
const specSigs = sigSteps.map(x => x.sig);
|
|
273
|
+
let best = null;
|
|
274
|
+
for (const p of manifest.pages) {
|
|
275
|
+
if (p.signatures.length === 0 || p.signatures.length > specSigs.length)
|
|
276
|
+
continue;
|
|
277
|
+
if (p.signatures.every((sig, i) => sig === specSigs[i])) {
|
|
278
|
+
if (!best || p.signatures.length > best.signatures.length)
|
|
279
|
+
best = p;
|
|
146
280
|
}
|
|
147
281
|
}
|
|
148
|
-
if (!
|
|
149
|
-
|
|
282
|
+
if (!best)
|
|
283
|
+
return null;
|
|
284
|
+
const consumed = sigSteps.slice(0, best.signatures.length).map(x => x.step);
|
|
285
|
+
return { entry: best, args: flowArgValues(consumed), consumedSigs: best.signatures.length };
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* The data values the Page Object method takes, in declaration order — the
|
|
289
|
+
* fill / type / select values from the consumed prefix steps. Mirrors the
|
|
290
|
+
* params generatePageObject lifts out, so the positions line up.
|
|
291
|
+
*/
|
|
292
|
+
function flowArgValues(steps) {
|
|
293
|
+
const out = [];
|
|
294
|
+
for (const s of steps) {
|
|
295
|
+
const i = (s.input ?? {});
|
|
296
|
+
switch (s.tool) {
|
|
297
|
+
case 'browser_type':
|
|
298
|
+
out.push(JSON.stringify(String(i.text ?? '')));
|
|
299
|
+
break;
|
|
300
|
+
case 'browser_select_option': {
|
|
301
|
+
const values = i.values;
|
|
302
|
+
const val = (values && values.length > 0 ? values[0] : i.value) ?? '';
|
|
303
|
+
out.push(JSON.stringify(String(val)));
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
case 'browser_fill_form': {
|
|
307
|
+
const fields = i.fields ?? [];
|
|
308
|
+
for (const f of fields)
|
|
309
|
+
out.push(JSON.stringify(String(f.value ?? '')));
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
default:
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
150
315
|
}
|
|
151
|
-
|
|
152
|
-
lines.push('');
|
|
153
|
-
return lines.join('\n');
|
|
316
|
+
return out;
|
|
154
317
|
}
|
|
155
|
-
function translateStep(tool, rawInput) {
|
|
318
|
+
function translateStep(tool, rawInput, pageVar = 'page') {
|
|
156
319
|
const input = (rawInput ?? {});
|
|
157
320
|
switch (tool) {
|
|
158
321
|
case 'browser_navigate': {
|
|
159
322
|
const url = String(input.url ?? '');
|
|
160
323
|
const path = stripBaseUrl(url);
|
|
161
|
-
return [`await
|
|
324
|
+
return [`await ${pageVar}.goto(${JSON.stringify(path)});`];
|
|
162
325
|
}
|
|
163
326
|
case 'browser_click':
|
|
164
|
-
return emitInteraction(selectorFromDescription(String(input.element ?? '')), 'click()');
|
|
327
|
+
return emitInteraction(selectorFromDescription(String(input.element ?? ''), pageVar), 'click()');
|
|
165
328
|
case 'browser_double_click':
|
|
166
|
-
return emitInteraction(selectorFromDescription(String(input.element ?? '')), 'dblclick()');
|
|
329
|
+
return emitInteraction(selectorFromDescription(String(input.element ?? ''), pageVar), 'dblclick()');
|
|
167
330
|
case 'browser_hover':
|
|
168
|
-
return emitInteraction(selectorFromDescription(String(input.element ?? '')), 'hover()');
|
|
331
|
+
return emitInteraction(selectorFromDescription(String(input.element ?? ''), pageVar), 'hover()');
|
|
169
332
|
case 'browser_fill_form': {
|
|
170
333
|
const fields = input.fields ?? [];
|
|
171
334
|
return fields.flatMap(raw => {
|
|
172
335
|
const f = raw;
|
|
173
336
|
const value = String(f.value ?? '');
|
|
174
337
|
const target = f.name ?? f.element ?? '';
|
|
175
|
-
|
|
338
|
+
// Each field gets its own block scope so the per-field `const el`
|
|
339
|
+
// declarations don't collide inside the step's shared test.step closure.
|
|
340
|
+
return blockScope(emitInteraction(selectorForFormField(target, f.type, pageVar), `fill(${JSON.stringify(value)})`));
|
|
176
341
|
});
|
|
177
342
|
}
|
|
178
343
|
case 'browser_type': {
|
|
179
344
|
const text = String(input.text ?? '');
|
|
180
345
|
const target = String(input.element ?? '');
|
|
181
|
-
return emitInteraction(selectorFromDescription(target), `fill(${JSON.stringify(text)})`);
|
|
346
|
+
return emitInteraction(selectorFromDescription(target, pageVar), `fill(${JSON.stringify(text)})`);
|
|
182
347
|
}
|
|
183
348
|
case 'browser_select_option': {
|
|
184
349
|
const target = String(input.element ?? '');
|
|
185
350
|
const values = input.values;
|
|
186
351
|
const val = (values && values.length > 0 ? values[0] : input.value) ?? '';
|
|
187
|
-
return emitInteraction(selectorFromDescription(target), `selectOption(${JSON.stringify(String(val))})`);
|
|
352
|
+
return emitInteraction(selectorFromDescription(target, pageVar), `selectOption(${JSON.stringify(String(val))})`);
|
|
188
353
|
}
|
|
189
354
|
case 'browser_press_key': {
|
|
190
355
|
const key = String(input.key ?? '');
|
|
191
|
-
return [`await
|
|
356
|
+
return [`await ${pageVar}.keyboard.press(${JSON.stringify(key)});`];
|
|
192
357
|
}
|
|
193
358
|
case 'browser_wait_for':
|
|
194
359
|
// Skip "wait for" hints — Playwright auto-waits.
|
|
@@ -203,81 +368,83 @@ function translateStep(tool, rawInput) {
|
|
|
203
368
|
// Diagnostic / read-only / non-replayable on a fresh playwright run.
|
|
204
369
|
return [];
|
|
205
370
|
default:
|
|
206
|
-
|
|
371
|
+
// A real action with no single-step translation. Leave a structured
|
|
372
|
+
// marker (not a TODO) so the optimization pass / seed library can
|
|
373
|
+
// complete it; the deterministic draft stays runnable around it.
|
|
374
|
+
return [`${OPTIMIZABLE_MARKER}: ${tool} — no single-step translation; the optimization pass or a .hover/rules/ seed can complete this`];
|
|
207
375
|
}
|
|
208
376
|
}
|
|
209
377
|
/**
|
|
210
|
-
*
|
|
211
|
-
*
|
|
378
|
+
* Emit an interaction (click / dblclick / hover / fill / selectOption) as a
|
|
379
|
+
* visibility-guarded prelude: hoist the locator to `el`, assert it's visible,
|
|
380
|
+
* then act.
|
|
212
381
|
*
|
|
213
|
-
*
|
|
214
|
-
* await
|
|
215
|
-
*
|
|
216
|
-
* // after
|
|
217
|
-
* {
|
|
218
|
-
* const el = page.getByRole('button', { name: 'Submit' });
|
|
219
|
-
* await expect(el).toBeVisible();
|
|
220
|
-
* await el.click();
|
|
221
|
-
* }
|
|
382
|
+
* const el = page.getByRole('button', { name: 'Submit' });
|
|
383
|
+
* await expect(el).toBeVisible();
|
|
384
|
+
* await el.click();
|
|
222
385
|
*
|
|
223
386
|
* Why: `getByRole` is "visible OR attached" by default. A button that drifted
|
|
224
387
|
* behind a closed `<details>` / kebab menu / drawer is still in the role tree,
|
|
225
388
|
* so the locator stays green AND `.click()` may still fire — but the actual
|
|
226
|
-
* user flow has degraded. Asserting visibility
|
|
227
|
-
*
|
|
228
|
-
* silently passing or timing out generically. Issue raised externally;
|
|
229
|
-
* the fix is a 1-emit change here.
|
|
389
|
+
* user flow has degraded. Asserting visibility first makes that drift fail
|
|
390
|
+
* loudly with "Locator expected to be visible" instead of silently passing.
|
|
230
391
|
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
392
|
+
* Scoping: renderSpec wraps each step in its own `test.step(… async () => {})`,
|
|
393
|
+
* whose closure already scopes `el`, so a single interaction needs no extra
|
|
394
|
+
* braces. browser_fill_form emits several fields into one step, so it wraps
|
|
395
|
+
* each field in `blockScope(...)` to keep the per-field `el` from colliding.
|
|
233
396
|
*/
|
|
234
|
-
function emitInteraction(selectorExpr, action) {
|
|
397
|
+
export function emitInteraction(selectorExpr, action) {
|
|
235
398
|
return [
|
|
236
|
-
`{
|
|
237
|
-
`
|
|
238
|
-
`
|
|
239
|
-
` await el.${action};`,
|
|
240
|
-
`}`,
|
|
399
|
+
`const el = ${selectorExpr};`,
|
|
400
|
+
`await expect(el).toBeVisible();`,
|
|
401
|
+
`await el.${action};`,
|
|
241
402
|
];
|
|
242
403
|
}
|
|
404
|
+
/** Wrap lines in a `{ … }` block scope (2-space inner indent). Used by
|
|
405
|
+
* browser_fill_form so each field's `const el` lives in its own scope inside
|
|
406
|
+
* the shared test.step closure. */
|
|
407
|
+
export function blockScope(lines) {
|
|
408
|
+
return ['{', ...lines.map(l => ` ${l}`), '}'];
|
|
409
|
+
}
|
|
243
410
|
/**
|
|
244
411
|
* Parse element descriptions like "Submit button" / "+1 button" / "Email
|
|
245
412
|
* textbox" / "Plan radio" into `getByRole(role, { name })` selectors. The
|
|
246
413
|
* trailing role keyword is the convention Playwright MCP uses.
|
|
247
414
|
*/
|
|
248
|
-
function selectorFromDescription(desc) {
|
|
415
|
+
export function selectorFromDescription(desc, pageVar = 'page') {
|
|
249
416
|
const trimmed = desc.trim();
|
|
250
417
|
if (!trimmed)
|
|
251
|
-
return
|
|
418
|
+
return `${pageVar}.locator('body')`;
|
|
252
419
|
// Strip a leading "Link" / "Button" article-style prefix sometimes added
|
|
253
420
|
// by the MCP, e.g. "Link \"Learn more\"". We only handle the trailing form.
|
|
254
421
|
const roleMatch = trimmed.match(/^(.+?)\s+(button|link|textbox|checkbox|radio|combobox|switch|menuitem|tab|listitem|heading|dialog|cell|row|columnheader|rowheader|gridcell)$/i);
|
|
255
422
|
if (roleMatch) {
|
|
256
423
|
const name = roleMatch[1].replace(/^"|"$/g, '');
|
|
257
424
|
const role = roleMatch[2].toLowerCase();
|
|
258
|
-
return
|
|
425
|
+
return `${pageVar}.getByRole('${role}', { name: ${JSON.stringify(name)} })`;
|
|
259
426
|
}
|
|
260
427
|
// Quoted label, e.g. \"Submit\" — fall back to getByText.
|
|
261
428
|
const quoted = trimmed.match(/^"(.+)"$/);
|
|
262
429
|
if (quoted)
|
|
263
|
-
return
|
|
264
|
-
return
|
|
430
|
+
return `${pageVar}.getByText(${JSON.stringify(quoted[1])})`;
|
|
431
|
+
return `${pageVar}.getByText(${JSON.stringify(trimmed)})`;
|
|
265
432
|
}
|
|
266
433
|
/**
|
|
267
434
|
* Form fields from browser_fill_form have a `name` that's typically the
|
|
268
435
|
* accessible name / label / aria-label. getByLabel is the right primitive.
|
|
269
436
|
* Fall back to getByRole('textbox') if we have a hint.
|
|
270
437
|
*/
|
|
271
|
-
function selectorForFormField(name, type) {
|
|
438
|
+
export function selectorForFormField(name, type, pageVar = 'page') {
|
|
272
439
|
const trimmed = name.trim();
|
|
273
440
|
if (!trimmed)
|
|
274
|
-
return
|
|
441
|
+
return `${pageVar}.locator('input')`;
|
|
275
442
|
if (type) {
|
|
276
443
|
const role = mapInputType(type);
|
|
277
444
|
if (role)
|
|
278
|
-
return
|
|
445
|
+
return `${pageVar}.getByRole('${role}', { name: ${JSON.stringify(trimmed)} })`;
|
|
279
446
|
}
|
|
280
|
-
return
|
|
447
|
+
return `${pageVar}.getByLabel(${JSON.stringify(trimmed)})`;
|
|
281
448
|
}
|
|
282
449
|
function mapInputType(type) {
|
|
283
450
|
switch (type.toLowerCase()) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hover-dev/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Hover's local Node service: agent invocation, Playwright CDP preflight, WebSocket bridge.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Hyperyond",
|
|
@@ -49,10 +49,13 @@
|
|
|
49
49
|
"README.md"
|
|
50
50
|
],
|
|
51
51
|
"dependencies": {
|
|
52
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
52
53
|
"@playwright/mcp": "0.0.75",
|
|
53
54
|
"cross-spawn": "^7.0.6",
|
|
54
55
|
"playwright-core": "^1.50.0",
|
|
55
|
-
"
|
|
56
|
+
"ts-morph": "^28.0.0",
|
|
57
|
+
"ws": "^8.20.1",
|
|
58
|
+
"zod": "^4.4.3"
|
|
56
59
|
},
|
|
57
60
|
"devDependencies": {
|
|
58
61
|
"@types/cross-spawn": "^6.0.6",
|
|
@@ -71,7 +74,6 @@
|
|
|
71
74
|
"smoke:chrome": "tsx src/scripts/start-chrome.ts",
|
|
72
75
|
"detect": "tsx src/scripts/detect-cli.ts",
|
|
73
76
|
"verify-widget": "tsx src/scripts/verify-widget.ts",
|
|
74
|
-
"verify-skill": "tsx src/scripts/verify-skill.ts",
|
|
75
77
|
"verify-spec": "tsx src/scripts/verify-spec.ts",
|
|
76
78
|
"ws-smoke": "tsx src/scripts/ws-smoke.ts",
|
|
77
79
|
"bench-ttfb": "tsx src/scripts/bench-ttfb.ts",
|