@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 +8 -5
- package/dist/specs/writeSpec.js +50 -7
- package/package.json +1 -1
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` /
|
|
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
|
|
57
|
-
- `--disallowedTools Bash Edit Write Read Grep Glob Task WebFetch WebSearch
|
|
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
|
|
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.
|
package/dist/specs/writeSpec.js
CHANGED
|
@@ -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
|
|
164
|
+
return emitInteraction(selectorFromDescription(String(input.element ?? '')), 'click()');
|
|
156
165
|
case 'browser_double_click':
|
|
157
|
-
return
|
|
166
|
+
return emitInteraction(selectorFromDescription(String(input.element ?? '')), 'dblclick()');
|
|
158
167
|
case 'browser_hover':
|
|
159
|
-
return
|
|
168
|
+
return emitInteraction(selectorFromDescription(String(input.element ?? '')), 'hover()');
|
|
160
169
|
case 'browser_fill_form': {
|
|
161
170
|
const fields = input.fields ?? [];
|
|
162
|
-
return fields.
|
|
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
|
|
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
|
|
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
|
|
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
|