@hover-dev/core 0.12.0 → 0.14.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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  The local Node service. Owns:
4
4
 
5
- - **Agent invocation** (`src/agents/`) — Local CLI Agent First. Spawns `claude` / `codex` / `cursor` / ... and normalizes their output into a single `InvokeEvent` stream.
5
+ - **Agent invocation** (`src/agents/`) — Local CLI Agent First. Spawns `claude` / `codex` / `cursor-agent` / `aider` / `gemini-cli` / `qwen-code` and normalizes their output into a single `InvokeEvent` stream.
6
6
  - **Playwright preflight** (`src/playwright/`) — verifies CDP connection to the user's Chrome.
7
7
  - **Smoke test** (`src/smoke.ts`) — end-to-end verification of the whole chain.
8
8
 
@@ -20,6 +20,9 @@ The local Node service. Owns:
20
20
  | `claude.ts` | Claude Code descriptor: `claude -p`, stream-json parser, hard sandbox flags |
21
21
  | `codex.ts` | OpenAI Codex CLI descriptor: `codex exec --json`, JSONL parser, soft sandbox (`--sandbox read-only`) |
22
22
  | `cursor.ts` | Cursor CLI descriptor (v0.9): stream-JSON / NDJSON parser, soft sandbox |
23
+ | `aider.ts` | Aider CLI descriptor (v0.10): JSON-stream parser, soft sandbox |
24
+ | `gemini.ts` | Gemini CLI descriptor (v0.10): stream parser, soft sandbox |
25
+ | `qwen.ts` | Qwen Code descriptor (v0.10): stream parser, soft sandbox |
23
26
 
24
27
  To add an agent: implement an `AgentDescriptor`, register it in `registry.ts`. Done.
25
28
 
@@ -45,7 +48,7 @@ pnpm smoke http://localhost:5173/ "log in then add a todo named 'verify hover'"
45
48
  Environment variables:
46
49
 
47
50
  - `HOVER_CDP` — CDP URL (default `http://localhost:9222`)
48
- - `HOVER_AGENT` — agent id (omit to auto-detect; tries the user's stated preference, then the first installed agent in registry order — `claude` → `codex` → `cursor-agent` today)
51
+ - `HOVER_AGENT` — agent id (omit to auto-detect; tries the user's stated preference, then the first installed agent in registry order — `claude` → `codex` → `cursor-agent` → `aider` → `gemini-cli` → `qwen-code` today)
49
52
  - `HOVER_MODEL` — model for the agent (default `sonnet`, much cheaper than opus)
50
53
 
51
54
  ## Sandboxing (what the smoke test enforces)
@@ -53,9 +56,9 @@ Environment variables:
53
56
  The `claude -p` invocation is locked down so Claude can only drive the browser:
54
57
 
55
58
  - `--strict-mcp-config` — ignore any MCP servers in `~/.claude/` or `.mcp.json`
56
- - `--allowedTools mcp__playwright` — only Playwright MCP is callable
57
- - `--disallowedTools Bash Edit Write Read Grep Glob Task WebFetch WebSearch` — every built-in tool explicitly denied
59
+ - `--allowedTools mcp__playwright Skill` — only Playwright MCP and the Skill tool are callable
60
+ - `--disallowedTools Bash Edit Write Read Grep Glob Task WebFetch WebSearch EnterWorktree CronCreate …` — every built-in tool explicitly denied (full list in `CLAUDE_DEFAULT_DISALLOWED_TOOLS` in `claude.ts`)
58
61
  - `--permission-mode dontAsk` — anything not whitelisted aborts the run
59
- - `--max-budget-usd 0.50` — hard ceiling per session
62
+ - `--max-budget-usd <n>`optional hard $ ceiling per session (no default; pass `maxBudgetUsd` in plugin options or via the CLI flag to enable)
60
63
 
61
64
  Together these enforce the rule that the spawned agent can only reach the browser via Playwright MCP — never the host filesystem, shell, or network. A hijacked prompt or hallucinated destructive action has nowhere to land.
@@ -34,7 +34,7 @@ export async function preflightCDP(cdpUrl, timeoutMs = 2000) {
34
34
  catch (err) {
35
35
  return {
36
36
  ok: false,
37
- reason: `Chrome debug session not detected at ${cdpUrl}. Start it with: pnpm exec hover-chrome (or: npx hover-chrome)`,
37
+ reason: `Chrome debug session not detected at ${cdpUrl}. Click the ✨ launcher in the widget to start it, or run \`pnpm exec hover-chrome\` (npx hover-chrome).`,
38
38
  };
39
39
  }
40
40
  if (!versionRes.ok) {
@@ -33,6 +33,10 @@ export interface HoverPluginMode {
33
33
  label: string;
34
34
  /** One-liner help text shown in the dropdown. */
35
35
  description?: string;
36
+ /** Short status shown in the mode bar's right-hand hint slot while this
37
+ * mode is engaged. Defaults to "active" if omitted. Keep it terse — e.g.
38
+ * "MITM proxy active". */
39
+ engagedHint?: string;
36
40
  /** Mode ids this mode cannot be active alongside. Two plugins both
37
41
  * needing an exclusive proxy would set each other here. */
38
42
  conflictsWith?: string[];
@@ -113,10 +117,29 @@ export interface ModeActivateCtx extends HoverHookCtxBase {
113
117
  export interface ModeDeactivateCtx extends HoverHookCtxBase {
114
118
  modeId: string;
115
119
  }
120
+ /** Fired exactly once when the host service starts, BEFORE the debug Chrome
121
+ * is (auto-)launched. A plugin that needs Chrome to be born with specific
122
+ * flags — e.g. a resident MITM proxy that Chrome must point through from the
123
+ * first navigation — boots that sidecar here and calls setChromeProxy so the
124
+ * host bakes the flags into the single Chrome launch. This is what lets the
125
+ * security plugin run one always-on (transparent-by-default) proxy instead
126
+ * of launching a second Chrome on mode entry. */
127
+ export interface ServiceStartCtx extends HoverHookCtxBase {
128
+ /** Tell the host "the debug Chrome should be launched with these proxy
129
+ * settings". Set once here; persists for the whole session. */
130
+ setChromeProxy(proxy: {
131
+ port: number;
132
+ spki: string;
133
+ } | null): void;
134
+ /** Same as the activate-time variant — seed runtime env for a declared MCP
135
+ * server before it's spawned. */
136
+ setMcpServerEnv(id: string, env: Record<string, string>): void;
137
+ }
116
138
  /** Fired exactly once when the host service is shutting down for any
117
139
  * reason. Hooks must release subprocesses and file handles. */
118
140
  export type ShutdownCtx = HoverHookCtxBase;
119
141
  export interface HoverHooks {
142
+ 'hover:service:start'?: (ctx: ServiceStartCtx) => void | Promise<void>;
120
143
  'hover:mode:activate'?: (ctx: ModeActivateCtx) => void | Promise<void>;
121
144
  'hover:mode:deactivate'?: (ctx: ModeDeactivateCtx) => void | Promise<void>;
122
145
  'hover:service:shutdown'?: (ctx: ShutdownCtx) => void | Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"plugin-api.d.ts","sourceRoot":"","sources":["../src/plugin-api.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"plugin-api.d.ts","sourceRoot":"","sources":["../src/plugin-api.ts"],"names":[],"mappings":"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"}
package/dist/service.d.ts CHANGED
@@ -18,6 +18,18 @@ export interface ServiceOptions {
18
18
  * pre-plugin Hover" — important for the long tail of users who never
19
19
  * install one. */
20
20
  plugins?: HoverPluginManifest[];
21
+ /** When true, the service launches the single debug Chrome itself at
22
+ * startup — AFTER firing plugin `hover:service:start` hooks, so a plugin
23
+ * that set a resident proxy (e.g. security's MITM) has its flags baked
24
+ * into that one Chrome. Previously each bundler shim called
25
+ * launchDebugChrome() directly, which bypassed the service and so couldn't
26
+ * see the proxy; moving it here is what enables the single-Chrome model.
27
+ * Default false (shims pass it through from their own option). */
28
+ autoLaunchChrome?: boolean;
29
+ /** The dev-server URL the auto-launched Chrome should open. Each shim knows
30
+ * its own framework's dev URL and passes it here. Defaults to the cdp host
31
+ * if unset, but shims should always provide it. */
32
+ devUrl?: string;
21
33
  }
22
34
  export interface ServiceHandle {
23
35
  /** The port the WebSocketServer actually bound to. May differ from
@@ -1 +1 @@
1
- {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AA6EA,OAAO,EAEL,KAAK,mBAAmB,EAEzB,MAAM,iBAAiB,CAAC;AAEzB,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gFAAgF;IAChF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;6EAGyE;IACzE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;uBAImB;IACnB,OAAO,CAAC,EAAE,mBAAmB,EAAE,CAAC;CACjC;AAED,MAAM,WAAW,aAAa;IAC5B;4EACwE;IACxE,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAiDD,wBAAsB,YAAY,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC,CAqyB/E"}
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AA8EA,OAAO,EAEL,KAAK,mBAAmB,EAEzB,MAAM,iBAAiB,CAAC;AAEzB,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gFAAgF;IAChF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;6EAGyE;IACzE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;uBAImB;IACnB,OAAO,CAAC,EAAE,mBAAmB,EAAE,CAAC;IAChC;;;;;;uEAMmE;IACnE,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B;;wDAEoD;IACpD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,aAAa;IAC5B;4EACwE;IACxE,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAiED,wBAAsB,YAAY,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC,CAs2B/E"}
package/dist/service.js CHANGED
@@ -54,6 +54,7 @@ import { listAgentAvailability, pickPrimaryAgent, } from './agents/detect.js';
54
54
  import { getAgent } from './agents/registry.js';
55
55
  import { getPreflight, invalidatePreflight } from './playwright/preflightCache.js';
56
56
  import { resolveMcpConfig } from './playwright/resolveMcpConfig.js';
57
+ import { launchDebugChrome } from './playwright/launchChrome.js';
57
58
  import { listSkills } from './skills/writeSkill.js';
58
59
  import { listSpecs } from './specs/listSpecs.js';
59
60
  import { send, sendIfOpen } from './service/types.js';
@@ -65,6 +66,19 @@ import { CURRENT_API_VERSION, } from './plugin-api.js';
65
66
  // handler modules can share them. See those files for the wire shape.
66
67
  const PROTOCOL_VERSION = 1;
67
68
  const PORT_RETRIES = 10;
69
+ /** CJK-presence test — mirrors voice.js's detectLanguage. Any Han character
70
+ * in the prompt flips the agent's prose output to Chinese. */
71
+ const CJK_RE = /[一-鿿]/;
72
+ /** Appended to the agent's system prompt when the user's prompt contains CJK,
73
+ * so the human-facing prose (verification summary / ## Findings / step
74
+ * narration) comes back in Chinese — matching how Voice mode picks a Chinese
75
+ * TTS voice for the same prompt. Deliberately scoped to PROSE only: the agent
76
+ * must still use the page's real (often English) accessible names, labels,
77
+ * and selectors when driving the browser. */
78
+ const ZH_OUTPUT_DIRECTIVE = '用户使用中文下达指令。请用简体中文撰写所有面向用户的文字输出:验证结论摘要、' +
79
+ '`## Findings` 区块(bug / 问题 / 备注)、以及每一步的中文描述。' +
80
+ '注意:这只影响你写给用户看的文字。操作浏览器时仍要使用页面真实的(通常是英文的)' +
81
+ '角色名、标签、可访问名称和选择器——不要把它们翻译成中文。';
68
82
  /**
69
83
  * Try to bind a WebSocketServer to <host>:<port>. Resolves with the wss on
70
84
  * success; rejects with the bind error (typically EADDRINUSE) on failure.
@@ -175,15 +189,11 @@ export async function startService(opts) {
175
189
  }
176
190
  }
177
191
  }
178
- // In an active mode, the Playwright MCP must point at THAT mode's
179
- // Chrome (e.g. security mode's 9333), not the default 9222.
180
- // effectiveLaunchExtras().cdpPort is the source of truth.
181
- const extras = effectiveLaunchExtras();
182
- const effectiveCdpUrl = extras?.cdpPort
183
- ? `http://localhost:${extras.cdpPort}`
184
- : cdpUrl;
192
+ // Single-Chrome model: the Playwright MCP always points at the one debug
193
+ // Chrome on the normal cdpUrl. (Pre-single-Chrome this branched to a
194
+ // mode-specific port like 9333; there's no second Chrome anymore.)
185
195
  return resolveMcpConfig({
186
- cdpUrl: effectiveCdpUrl,
196
+ cdpUrl,
187
197
  port,
188
198
  extra,
189
199
  // Suffix the filename by the mode so different mode toggles within
@@ -223,44 +233,31 @@ export async function startService(opts) {
223
233
  }
224
234
  /** id of the currently-active mode, or null for normal (unmoded) mode. */
225
235
  let currentModeId = null;
226
- /** Chrome-proxy settings the active mode's activate hook set on us.
227
- * Read by `effectiveLaunchExtras()` and threaded into the cdp handlers
228
- * (check-cdp / launch-chrome / focus-debug) so the secured Chrome on
229
- * 9333 actually gets `--proxy-server` + SPKI pin when the user clicks
230
- * Launch from the widget. */
231
- let modeChromeProxy = null;
236
+ /** Chrome-proxy settings a plugin's `hover:service:start` hook set on us
237
+ * (security's resident MITM). RESIDENT for the whole session — set once
238
+ * before Chrome launches, never cleared on mode change — so the single
239
+ * debug Chrome is born with `--proxy-server` + the SPKI pin and entering
240
+ * Security mode is just a runtime flip of the proxy, not a Chrome relaunch.
241
+ * Read by `effectiveLaunchExtras()` and threaded into every cdp handler
242
+ * (check-cdp / launch-chrome / focus-debug) plus the initial auto-launch. */
243
+ let residentChromeProxy = null;
232
244
  /** Runtime env overrides keyed by mcpServer id, set by plugin
233
245
  * activate hooks (via ctx.setMcpServerEnv). Cleared on mode change.
234
246
  * Merged with the manifest-declared env when the agent's spawn-time
235
247
  * MCP config is built. */
236
248
  const mcpEnvOverrides = new Map();
237
- /** The cdp-handler extras (port, userDataDir, proxy) for the active
238
- * mode's chromeFlags manifest field, or undefined when no mode is
239
- * active. The widget's launch-chrome / check-cdp / focus-debug paths
240
- * all consume these so a Chrome relaunch obeys the mode's needs. */
249
+ /** The cdp-handler extras (proxy) threaded into launch-chrome / check-cdp /
250
+ * focus-debug and the initial auto-launch. In the single-Chrome model this
251
+ * is driven purely by the RESIDENT proxy (set in `hover:service:start`),
252
+ * NOT by the active mode — there is one Chrome on the normal CDP port that
253
+ * is always proxied; entering Security mode flips the proxy's behaviour,
254
+ * it does not relaunch Chrome on a different port. Returns undefined when
255
+ * no plugin set a resident proxy (the common no-security case), so plain
256
+ * Hover is byte-for-byte unchanged. */
241
257
  const effectiveLaunchExtras = () => {
242
- if (!currentModeId)
258
+ if (!residentChromeProxy)
243
259
  return undefined;
244
- const plugin = pluginsByModeId.get(currentModeId);
245
- const flags = plugin?.chromeFlags;
246
- if (!flags && !modeChromeProxy)
247
- return undefined;
248
- // Belt + suspenders — flags.activeInModes is honoured if set, but
249
- // since chromeFlags lives on the plugin that contributed this mode,
250
- // the default of "applies in own mode" matches what we want.
251
- if (flags?.activeInModes && !flags.activeInModes.includes('*') && !flags.activeInModes.includes(currentModeId)) {
252
- // Plugin explicitly restricted its chromeFlags to a different mode.
253
- // Honour that and only carry modeChromeProxy (set by setChromeProxy).
254
- return modeChromeProxy ? { proxy: modeChromeProxy } : undefined;
255
- }
256
- return {
257
- cdpPort: flags?.cdpPort,
258
- userDataDir: flags?.userDataDir,
259
- // modeChromeProxy wins over flags.proxy because it's the runtime
260
- // value the activate hook computed (after starting mockttp);
261
- // flags.proxy is only ever set by tests stubbing the manifest.
262
- proxy: modeChromeProxy ?? flags?.proxy,
263
- };
260
+ return { proxy: residentChromeProxy };
264
261
  };
265
262
  /** Send the current mode catalogue to one ws (or all if undefined). */
266
263
  const broadcastModes = (target) => {
@@ -309,8 +306,14 @@ export async function startService(opts) {
309
306
  }
310
307
  }
311
308
  }
312
- modeChromeProxy = null;
313
- mcpEnvOverrides.clear();
309
+ // NOTE: neither residentChromeProxy NOR mcpEnvOverrides is cleared here.
310
+ // In the single-Chrome model both are RESIDENT — set once in
311
+ // service:start (e.g. security's HOVER_SECURITY_API base + token), they
312
+ // must survive every mode toggle so the agent's spawned MCP server can
313
+ // always reach the control plane. Clearing them on mode change was the
314
+ // pre-resident behaviour and would leave the security MCP server with no
315
+ // env → it exits with "failed". Mode changes now only flip plugin runtime
316
+ // state via the plugin's own activate/deactivate hooks.
314
317
  currentModeId = null;
315
318
  // Bring up new mode
316
319
  if (newModeId) {
@@ -325,7 +328,10 @@ export async function startService(opts) {
325
328
  broadcast: broadcastPluginEvent,
326
329
  modeId: newModeId,
327
330
  setChromeProxy(proxy) {
328
- modeChromeProxy = proxy;
331
+ // Retained for API compatibility. In the single-Chrome model the
332
+ // proxy is normally set once in service:start; if an activate hook
333
+ // still calls this, treat it as updating the resident proxy.
334
+ residentChromeProxy = proxy;
329
335
  },
330
336
  setMcpServerEnv(id, env) {
331
337
  mcpEnvOverrides.set(id, env);
@@ -339,9 +345,10 @@ export async function startService(opts) {
339
345
  // pretend to be in `newModeId` with no sidecars running.
340
346
  // Widget still trusts the broadcast below to learn we're back
341
347
  // to default. The error is rethrown so the caller can surface
342
- // it to the user.
343
- modeChromeProxy = null;
344
- mcpEnvOverrides.clear();
348
+ // it to the user. residentChromeProxy and mcpEnvOverrides are NOT
349
+ // touched — both are owned by service:start, independent of mode
350
+ // activation (clearing the env would break the resident security
351
+ // MCP server).
345
352
  currentModeId = null;
346
353
  broadcastModes();
347
354
  throw err;
@@ -694,6 +701,17 @@ export async function startService(opts) {
694
701
  }
695
702
  }
696
703
  }
704
+ // Mirror the prompt's language in the agent's *prose* output — the
705
+ // verification summary (Result card), the ## Findings block, and the
706
+ // step narration — the same way Voice mode mirrors it in TTS. A
707
+ // Chinese prompt should produce a Chinese report. This does NOT change
708
+ // how the agent operates the browser: selectors, role names, and the
709
+ // app's own (often English) UI text are unaffected — only the agent's
710
+ // human-facing writing follows the user. Detection mirrors voice.js's
711
+ // detectLanguage (CJK presence → zh).
712
+ if (CJK_RE.test(text)) {
713
+ appendSystemPrompt = `${appendSystemPrompt}\n\n${ZH_OUTPUT_DIRECTIVE}`;
714
+ }
697
715
  // Snapshot the agent id so a switch-agent message during the run
698
716
  // can't smear two agents across one invocation. (We also gate
699
717
  // switch-agent on `busy`, but defense in depth.)
@@ -844,6 +862,58 @@ export async function startService(opts) {
844
862
  }
845
863
  });
846
864
  });
865
+ // ───────────────────────── service:start + single Chrome ─────────────────
866
+ // Fire plugin `hover:service:start` hooks BEFORE launching Chrome, so a
867
+ // plugin (security) can boot its resident proxy and call setChromeProxy.
868
+ // residentChromeProxy is then baked into the one auto-launched Chrome.
869
+ for (const p of plugins) {
870
+ const hook = p.hooks?.['hover:service:start'];
871
+ if (!hook)
872
+ continue;
873
+ try {
874
+ await hook({
875
+ devRoot,
876
+ broadcast: broadcastPluginEvent,
877
+ setChromeProxy(proxy) {
878
+ residentChromeProxy = proxy;
879
+ },
880
+ setMcpServerEnv(id, env) {
881
+ mcpEnvOverrides.set(id, env);
882
+ },
883
+ });
884
+ }
885
+ catch (err) {
886
+ process.stderr.write(`[hover] plugin "${p.name}" service:start failed: ${err instanceof Error ? err.message : String(err)}\n`);
887
+ }
888
+ }
889
+ // Auto-launch the single debug Chrome here (moved out of the bundler shims
890
+ // so it happens AFTER service:start and can carry residentChromeProxy).
891
+ // Fire-and-forget — startup must not block on Chrome, and a launch failure
892
+ // is non-fatal (the widget's amber ✨ lets the user retry on demand).
893
+ if (opts.autoLaunchChrome) {
894
+ const launchPort = (() => {
895
+ try {
896
+ return Number(new URL(cdpUrl).port) || 9222;
897
+ }
898
+ catch {
899
+ return 9222;
900
+ }
901
+ })();
902
+ const launchUrl = opts.devUrl ?? cdpUrl;
903
+ launchDebugChrome({
904
+ url: launchUrl,
905
+ port: launchPort,
906
+ proxy: residentChromeProxy ?? undefined,
907
+ })
908
+ .then((r) => {
909
+ if (!r.ok) {
910
+ process.stderr.write(`[hover] auto-launch Chrome failed: ${r.reason}\n`);
911
+ }
912
+ })
913
+ .catch((err) => {
914
+ process.stderr.write(`[hover] auto-launch Chrome error: ${err instanceof Error ? err.message : String(err)}\n`);
915
+ });
916
+ }
847
917
  return {
848
918
  port,
849
919
  async close() {
@@ -122,10 +122,19 @@ function renderSpec(slug, displayName, description, steps, assertions) {
122
122
  continue;
123
123
  const calls = translateStep(s.tool, s.input);
124
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 `{ … }`.
125
129
  lines.push(` ${c}`);
126
130
  hasAwait = true;
127
131
  }
128
132
  }
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.)
129
138
  if (assertions.length > 0) {
130
139
  if (hasAwait)
131
140
  lines.push('');
@@ -152,30 +161,30 @@ function translateStep(tool, rawInput) {
152
161
  return [`await page.goto(${JSON.stringify(path)});`];
153
162
  }
154
163
  case 'browser_click':
155
- return [`await ${selectorFromDescription(String(input.element ?? ''))}.click();`];
164
+ return emitInteraction(selectorFromDescription(String(input.element ?? '')), 'click()');
156
165
  case 'browser_double_click':
157
- return [`await ${selectorFromDescription(String(input.element ?? ''))}.dblclick();`];
166
+ return emitInteraction(selectorFromDescription(String(input.element ?? '')), 'dblclick()');
158
167
  case 'browser_hover':
159
- return [`await ${selectorFromDescription(String(input.element ?? ''))}.hover();`];
168
+ return emitInteraction(selectorFromDescription(String(input.element ?? '')), 'hover()');
160
169
  case 'browser_fill_form': {
161
170
  const fields = input.fields ?? [];
162
- return fields.map(raw => {
171
+ return fields.flatMap(raw => {
163
172
  const f = raw;
164
173
  const value = String(f.value ?? '');
165
174
  const target = f.name ?? f.element ?? '';
166
- return `await ${selectorForFormField(target, f.type)}.fill(${JSON.stringify(value)});`;
175
+ return emitInteraction(selectorForFormField(target, f.type), `fill(${JSON.stringify(value)})`);
167
176
  });
168
177
  }
169
178
  case 'browser_type': {
170
179
  const text = String(input.text ?? '');
171
180
  const target = String(input.element ?? '');
172
- return [`await ${selectorFromDescription(target)}.fill(${JSON.stringify(text)});`];
181
+ return emitInteraction(selectorFromDescription(target), `fill(${JSON.stringify(text)})`);
173
182
  }
174
183
  case 'browser_select_option': {
175
184
  const target = String(input.element ?? '');
176
185
  const values = input.values;
177
186
  const val = (values && values.length > 0 ? values[0] : input.value) ?? '';
178
- return [`await ${selectorFromDescription(target)}.selectOption(${JSON.stringify(String(val))});`];
187
+ return emitInteraction(selectorFromDescription(target), `selectOption(${JSON.stringify(String(val))})`);
179
188
  }
180
189
  case 'browser_press_key': {
181
190
  const key = String(input.key ?? '');
@@ -197,6 +206,40 @@ function translateStep(tool, rawInput) {
197
206
  return [`// TODO: translate ${tool} (skipped — unknown tool for spec emission)`];
198
207
  }
199
208
  }
209
+ /**
210
+ * Wrap an interaction (click / dblclick / hover / fill / selectOption) in a
211
+ * block-scoped visibility prelude. Replaces the prior one-liner emit:
212
+ *
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
+ * }
222
+ *
223
+ * Why: `getByRole` is "visible OR attached" by default. A button that drifted
224
+ * behind a closed `<details>` / kebab menu / drawer is still in the role tree,
225
+ * 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.
230
+ *
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.
233
+ */
234
+ function emitInteraction(selectorExpr, action) {
235
+ return [
236
+ `{`,
237
+ ` const el = ${selectorExpr};`,
238
+ ` await expect(el).toBeVisible();`,
239
+ ` await el.${action};`,
240
+ `}`,
241
+ ];
242
+ }
200
243
  /**
201
244
  * Parse element descriptions like "Submit button" / "+1 button" / "Email
202
245
  * textbox" / "Plan radio" into `getByRole(role, { name })` selectors. The
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hover-dev/core",
3
- "version": "0.12.0",
3
+ "version": "0.14.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",