@hover-dev/core 0.12.0 → 0.13.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.
@@ -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.13.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",