@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.
Files changed (65) hide show
  1. package/README.md +73 -1
  2. package/dist/agents/claude.d.ts.map +1 -1
  3. package/dist/agents/claude.js +14 -0
  4. package/dist/agents/codex.d.ts.map +1 -1
  5. package/dist/agents/codex.js +1 -0
  6. package/dist/agents/invoke.d.ts.map +1 -1
  7. package/dist/agents/invoke.js +10 -1
  8. package/dist/agents/types.d.ts +11 -0
  9. package/dist/agents/types.d.ts.map +1 -1
  10. package/dist/playwright/resolveMcpConfig.d.ts +5 -0
  11. package/dist/playwright/resolveMcpConfig.d.ts.map +1 -1
  12. package/dist/playwright/resolveMcpConfig.js +2 -1
  13. package/dist/runSession.d.ts +42 -0
  14. package/dist/runSession.d.ts.map +1 -0
  15. package/dist/runSession.js +76 -0
  16. package/dist/service/cdpHint.d.ts.map +1 -1
  17. package/dist/service/cdpHint.js +30 -14
  18. package/dist/service/conventions.d.ts +8 -0
  19. package/dist/service/conventions.d.ts.map +1 -0
  20. package/dist/service/conventions.js +42 -0
  21. package/dist/service/saveHandlers.d.ts +10 -13
  22. package/dist/service/saveHandlers.d.ts.map +1 -1
  23. package/dist/service/saveHandlers.js +9 -25
  24. package/dist/service/types.d.ts +5 -0
  25. package/dist/service/types.d.ts.map +1 -1
  26. package/dist/service.d.ts +7 -4
  27. package/dist/service.d.ts.map +1 -1
  28. package/dist/service.js +141 -104
  29. package/dist/skills/writeSkill.d.ts +12 -35
  30. package/dist/skills/writeSkill.d.ts.map +1 -1
  31. package/dist/skills/writeSkill.js +10 -166
  32. package/dist/specs/detectSharedFlows.d.ts +35 -0
  33. package/dist/specs/detectSharedFlows.d.ts.map +1 -0
  34. package/dist/specs/detectSharedFlows.js +171 -0
  35. package/dist/specs/extractPageObjects.d.ts +18 -0
  36. package/dist/specs/extractPageObjects.d.ts.map +1 -0
  37. package/dist/specs/extractPageObjects.js +98 -0
  38. package/dist/specs/generatePageObject.d.ts +29 -0
  39. package/dist/specs/generatePageObject.d.ts.map +1 -0
  40. package/dist/specs/generatePageObject.js +149 -0
  41. package/dist/specs/listSpecs.d.ts +12 -0
  42. package/dist/specs/listSpecs.d.ts.map +1 -1
  43. package/dist/specs/listSpecs.js +27 -2
  44. package/dist/specs/optimizationSuggestion.d.ts +26 -0
  45. package/dist/specs/optimizationSuggestion.d.ts.map +1 -0
  46. package/dist/specs/optimizationSuggestion.js +28 -0
  47. package/dist/specs/optimizeSpec.d.ts +42 -0
  48. package/dist/specs/optimizeSpec.d.ts.map +1 -0
  49. package/dist/specs/optimizeSpec.js +166 -0
  50. package/dist/specs/optimizeSpecWithAgent.d.ts +11 -0
  51. package/dist/specs/optimizeSpecWithAgent.d.ts.map +1 -0
  52. package/dist/specs/optimizeSpecWithAgent.js +40 -0
  53. package/dist/specs/pageObjectManifest.d.ts +20 -0
  54. package/dist/specs/pageObjectManifest.d.ts.map +1 -0
  55. package/dist/specs/pageObjectManifest.js +40 -0
  56. package/dist/specs/seeds.d.ts +36 -0
  57. package/dist/specs/seeds.d.ts.map +1 -0
  58. package/dist/specs/seeds.js +74 -0
  59. package/dist/specs/sidecar.d.ts +25 -0
  60. package/dist/specs/sidecar.d.ts.map +1 -0
  61. package/dist/specs/sidecar.js +38 -0
  62. package/dist/specs/writeSpec.d.ts +50 -0
  63. package/dist/specs/writeSpec.d.ts.map +1 -1
  64. package/dist/specs/writeSpec.js +249 -75
  65. package/package.json +1 -2
@@ -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
- const source = renderSpec(slug, opts.name, opts.description ?? '', opts.steps, opts.assertions ?? []);
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(`import { test, expect } from '@playwright/test';`);
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
- lines.push(`test('${safeTitle}', async ({ page }) => {`);
119
- let hasAwait = false;
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 calls = translateStep(s.tool, s.input);
124
- for (const c of calls) {
125
- // Each emitted line gets the test-body indent (2 spaces). Lines
126
- // inside an emitInteraction block carry an additional 2 spaces of
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
- // `expect` is imported at the top of the generated file regardless of
134
- // whether assertions are present — the visibility preludes emitted by
135
- // translateStep depend on it for any element-targeting step. (Cheap:
136
- // unused imports get tree-shaken out of any future test bundle, and
137
- // Playwright's runner doesn't care.)
138
- if (assertions.length > 0) {
139
- if (hasAwait)
140
- lines.push('');
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 (!hasAwait && assertions.length === 0) {
149
- lines.push(' // (no automatable steps were captured)');
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
- lines.push('});');
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 page.goto(${JSON.stringify(path)});`];
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
- return emitInteraction(selectorForFormField(target, f.type), `fill(${JSON.stringify(value)})`);
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 page.keyboard.press(${JSON.stringify(key)});`];
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
- return [`// TODO: translate ${tool} (skipped unknown tool for spec emission)`];
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
- * Wrap an interaction (click / dblclick / hover / fill / selectOption) in a
211
- * block-scoped visibility prelude. Replaces the prior one-liner emit:
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
- * // before
214
- * await page.getByRole('button', { name: 'Submit' }).click();
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 before each interaction makes
227
- * that drift fail loudly with "Locator expected to be visible" instead of
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
- * Block-scoped (`{ }`) so each step's local `el` doesn't shadow the next.
232
- * The 4-line shape keeps the diff readable when re-record regenerates a spec.
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
- ` const el = ${selectorExpr};`,
238
- ` await expect(el).toBeVisible();`,
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 `page.locator('body')`;
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 `page.getByRole('${role}', { name: ${JSON.stringify(name)} })`;
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 `page.getByText(${JSON.stringify(quoted[1])})`;
264
- return `page.getByText(${JSON.stringify(trimmed)})`;
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 `page.locator('input')`;
448
+ return `${pageVar}.locator('input')`;
275
449
  if (type) {
276
450
  const role = mapInputType(type);
277
451
  if (role)
278
- return `page.getByRole('${role}', { name: ${JSON.stringify(trimmed)} })`;
452
+ return `${pageVar}.getByRole('${role}', { name: ${JSON.stringify(trimmed)} })`;
279
453
  }
280
- return `page.getByLabel(${JSON.stringify(trimmed)})`;
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.14.1",
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",