@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.
Files changed (99) hide show
  1. package/README.md +73 -1
  2. package/dist/agents/aider.d.ts.map +1 -1
  3. package/dist/agents/aider.js +6 -14
  4. package/dist/agents/claude.d.ts.map +1 -1
  5. package/dist/agents/claude.js +14 -0
  6. package/dist/agents/codex.d.ts.map +1 -1
  7. package/dist/agents/codex.js +10 -4
  8. package/dist/agents/cursor.d.ts.map +1 -1
  9. package/dist/agents/cursor.js +8 -17
  10. package/dist/agents/gemini.d.ts.map +1 -1
  11. package/dist/agents/gemini.js +3 -14
  12. package/dist/agents/invoke.d.ts.map +1 -1
  13. package/dist/agents/invoke.js +10 -1
  14. package/dist/agents/qwen.d.ts.map +1 -1
  15. package/dist/agents/qwen.js +3 -14
  16. package/dist/agents/shared.d.ts +28 -0
  17. package/dist/agents/shared.d.ts.map +1 -0
  18. package/dist/agents/shared.js +35 -0
  19. package/dist/agents/types.d.ts +11 -0
  20. package/dist/agents/types.d.ts.map +1 -1
  21. package/dist/mcp/sourceFence.d.ts +23 -0
  22. package/dist/mcp/sourceFence.d.ts.map +1 -0
  23. package/dist/mcp/sourceFence.js +75 -0
  24. package/dist/mcp/sourceServer.d.ts +3 -0
  25. package/dist/mcp/sourceServer.d.ts.map +1 -0
  26. package/dist/mcp/sourceServer.js +116 -0
  27. package/dist/playwright/preflight.d.ts.map +1 -1
  28. package/dist/playwright/preflight.js +6 -1
  29. package/dist/playwright/raiseWindow.d.ts.map +1 -1
  30. package/dist/playwright/raiseWindow.js +22 -3
  31. package/dist/playwright/resolveMcpConfig.d.ts +11 -0
  32. package/dist/playwright/resolveMcpConfig.d.ts.map +1 -1
  33. package/dist/playwright/resolveMcpConfig.js +17 -3
  34. package/dist/plugin-api.d.ts +7 -0
  35. package/dist/plugin-api.d.ts.map +1 -1
  36. package/dist/runSession.d.ts +42 -0
  37. package/dist/runSession.d.ts.map +1 -0
  38. package/dist/runSession.js +81 -0
  39. package/dist/service/cdpHandlers.d.ts +3 -7
  40. package/dist/service/cdpHandlers.d.ts.map +1 -1
  41. package/dist/service/cdpHandlers.js +4 -16
  42. package/dist/service/cdpHint.d.ts.map +1 -1
  43. package/dist/service/cdpHint.js +30 -14
  44. package/dist/service/conventions.d.ts +8 -0
  45. package/dist/service/conventions.d.ts.map +1 -0
  46. package/dist/service/conventions.js +42 -0
  47. package/dist/service/saveHandlers.d.ts +10 -13
  48. package/dist/service/saveHandlers.d.ts.map +1 -1
  49. package/dist/service/saveHandlers.js +9 -25
  50. package/dist/service/types.d.ts +5 -0
  51. package/dist/service/types.d.ts.map +1 -1
  52. package/dist/service.d.ts +13 -4
  53. package/dist/service.d.ts.map +1 -1
  54. package/dist/service.js +264 -148
  55. package/dist/skills/writeSkill.d.ts +12 -35
  56. package/dist/skills/writeSkill.d.ts.map +1 -1
  57. package/dist/skills/writeSkill.js +10 -166
  58. package/dist/specs/detectSharedFlows.d.ts +35 -0
  59. package/dist/specs/detectSharedFlows.d.ts.map +1 -0
  60. package/dist/specs/detectSharedFlows.js +171 -0
  61. package/dist/specs/extractPageObjects.d.ts +18 -0
  62. package/dist/specs/extractPageObjects.d.ts.map +1 -0
  63. package/dist/specs/extractPageObjects.js +98 -0
  64. package/dist/specs/generatePageObject.d.ts +29 -0
  65. package/dist/specs/generatePageObject.d.ts.map +1 -0
  66. package/dist/specs/generatePageObject.js +149 -0
  67. package/dist/specs/listSpecs.d.ts +12 -0
  68. package/dist/specs/listSpecs.d.ts.map +1 -1
  69. package/dist/specs/listSpecs.js +27 -2
  70. package/dist/specs/optimizationSuggestion.d.ts +26 -0
  71. package/dist/specs/optimizationSuggestion.d.ts.map +1 -0
  72. package/dist/specs/optimizationSuggestion.js +28 -0
  73. package/dist/specs/optimizeSpec.d.ts +42 -0
  74. package/dist/specs/optimizeSpec.d.ts.map +1 -0
  75. package/dist/specs/optimizeSpec.js +188 -0
  76. package/dist/specs/optimizeSpecWithAgent.d.ts +11 -0
  77. package/dist/specs/optimizeSpecWithAgent.d.ts.map +1 -0
  78. package/dist/specs/optimizeSpecWithAgent.js +40 -0
  79. package/dist/specs/pageObjectManifest.d.ts +20 -0
  80. package/dist/specs/pageObjectManifest.d.ts.map +1 -0
  81. package/dist/specs/pageObjectManifest.js +40 -0
  82. package/dist/specs/seeds.d.ts +36 -0
  83. package/dist/specs/seeds.d.ts.map +1 -0
  84. package/dist/specs/seeds.js +74 -0
  85. package/dist/specs/sidecar.d.ts +25 -0
  86. package/dist/specs/sidecar.d.ts.map +1 -0
  87. package/dist/specs/sidecar.js +38 -0
  88. package/dist/specs/softBatch.d.ts +14 -0
  89. package/dist/specs/softBatch.d.ts.map +1 -0
  90. package/dist/specs/softBatch.js +177 -0
  91. package/dist/specs/text.d.ts +17 -0
  92. package/dist/specs/text.d.ts.map +1 -0
  93. package/dist/specs/text.js +24 -0
  94. package/dist/specs/writeCaseCsv.d.ts.map +1 -1
  95. package/dist/specs/writeCaseCsv.js +2 -8
  96. package/dist/specs/writeSpec.d.ts +50 -0
  97. package/dist/specs/writeSpec.d.ts.map +1 -1
  98. package/dist/specs/writeSpec.js +251 -84
  99. package/package.json +5 -3
@@ -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
- const source = renderSpec(slug, opts.name, opts.description ?? '', opts.steps, opts.assertions ?? []);
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
- const first = doneSummary.split(/(?<=[.!?])\s+/)[0] ?? doneSummary;
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(`import { test, expect } from '@playwright/test';`);
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
- lines.push(`test('${safeTitle}', async ({ page }) => {`);
119
- let hasAwait = false;
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 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
- }
267
+ const sig = stepSignature(s.tool, s.input);
268
+ if (sig == null)
269
+ continue;
270
+ sigSteps.push({ sig, step: s });
132
271
  }
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};`);
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 (!hasAwait && assertions.length === 0) {
149
- lines.push(' // (no automatable steps were captured)');
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
- lines.push('});');
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 page.goto(${JSON.stringify(path)});`];
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
- return emitInteraction(selectorForFormField(target, f.type), `fill(${JSON.stringify(value)})`);
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 page.keyboard.press(${JSON.stringify(key)});`];
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
- return [`// TODO: translate ${tool} (skipped unknown tool for spec emission)`];
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
- * Wrap an interaction (click / dblclick / hover / fill / selectOption) in a
211
- * block-scoped visibility prelude. Replaces the prior one-liner emit:
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
- * // 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
- * }
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 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.
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
- * 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.
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
- ` const el = ${selectorExpr};`,
238
- ` await expect(el).toBeVisible();`,
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 `page.locator('body')`;
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 `page.getByRole('${role}', { name: ${JSON.stringify(name)} })`;
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 `page.getByText(${JSON.stringify(quoted[1])})`;
264
- return `page.getByText(${JSON.stringify(trimmed)})`;
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 `page.locator('input')`;
441
+ return `${pageVar}.locator('input')`;
275
442
  if (type) {
276
443
  const role = mapInputType(type);
277
444
  if (role)
278
- return `page.getByRole('${role}', { name: ${JSON.stringify(trimmed)} })`;
445
+ return `${pageVar}.getByRole('${role}', { name: ${JSON.stringify(trimmed)} })`;
279
446
  }
280
- return `page.getByLabel(${JSON.stringify(trimmed)})`;
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.14.1",
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
- "ws": "^8.20.1"
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",