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