@hover-dev/core 0.17.0 → 0.18.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/dist/engine.d.ts +14 -39
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +16 -67
- package/dist/specs/pageObjectManifest.d.ts.map +1 -1
- package/dist/specs/pageObjectManifest.js +11 -10
- package/dist/specs/replayGrounded.d.ts.map +1 -1
- package/package.json +5 -22
- package/dist/agents/argv.d.ts +0 -11
- package/dist/agents/argv.d.ts.map +0 -1
- package/dist/agents/argv.js +0 -23
- package/dist/agents/claude.d.ts +0 -3
- package/dist/agents/claude.d.ts.map +0 -1
- package/dist/agents/claude.js +0 -220
- package/dist/agents/codex.d.ts +0 -19
- package/dist/agents/codex.d.ts.map +0 -1
- package/dist/agents/codex.js +0 -231
- package/dist/agents/detect.d.ts +0 -46
- package/dist/agents/detect.d.ts.map +0 -1
- package/dist/agents/detect.js +0 -80
- package/dist/agents/gemini.d.ts +0 -17
- package/dist/agents/gemini.d.ts.map +0 -1
- package/dist/agents/gemini.js +0 -186
- package/dist/agents/index.d.ts +0 -6
- package/dist/agents/index.d.ts.map +0 -1
- package/dist/agents/index.js +0 -5
- package/dist/agents/invoke.d.ts +0 -12
- package/dist/agents/invoke.d.ts.map +0 -1
- package/dist/agents/invoke.js +0 -93
- package/dist/agents/qwen.d.ts +0 -17
- package/dist/agents/qwen.d.ts.map +0 -1
- package/dist/agents/qwen.js +0 -172
- package/dist/agents/registry.d.ts +0 -19
- package/dist/agents/registry.d.ts.map +0 -1
- package/dist/agents/registry.js +0 -30
- package/dist/agents/shared.d.ts +0 -28
- package/dist/agents/shared.d.ts.map +0 -1
- package/dist/agents/shared.js +0 -35
- package/dist/agents/types.d.ts +0 -194
- package/dist/agents/types.d.ts.map +0 -1
- package/dist/agents/types.js +0 -23
- package/dist/index.d.ts +0 -3
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -2
- package/dist/mcp/actuateServer.d.ts +0 -3
- package/dist/mcp/actuateServer.d.ts.map +0 -1
- package/dist/mcp/actuateServer.js +0 -594
- package/dist/mcp/sourceFence.d.ts +0 -23
- package/dist/mcp/sourceFence.d.ts.map +0 -1
- package/dist/mcp/sourceFence.js +0 -79
- package/dist/mcp/sourceServer.d.ts +0 -3
- package/dist/mcp/sourceServer.d.ts.map +0 -1
- package/dist/mcp/sourceServer.js +0 -191
- package/dist/modes.d.ts +0 -39
- package/dist/modes.d.ts.map +0 -1
- package/dist/modes.js +0 -34
- package/dist/playwright/cdpStatus.d.ts +0 -14
- package/dist/playwright/cdpStatus.d.ts.map +0 -1
- package/dist/playwright/cdpStatus.js +0 -52
- package/dist/playwright/preflight.d.ts +0 -31
- package/dist/playwright/preflight.d.ts.map +0 -1
- package/dist/playwright/preflight.js +0 -82
- package/dist/playwright/preflightCache.d.ts +0 -27
- package/dist/playwright/preflightCache.d.ts.map +0 -1
- package/dist/playwright/preflightCache.js +0 -21
- package/dist/playwright/resolveMcpConfig.d.ts +0 -61
- package/dist/playwright/resolveMcpConfig.d.ts.map +0 -1
- package/dist/playwright/resolveMcpConfig.js +0 -84
- package/dist/plugin-api.d.ts +0 -237
- package/dist/plugin-api.d.ts.map +0 -1
- package/dist/plugin-api.js +0 -52
- package/dist/qa/classify.d.ts +0 -38
- package/dist/qa/classify.d.ts.map +0 -1
- package/dist/qa/classify.js +0 -138
- package/dist/runSession.d.ts +0 -53
- package/dist/runSession.d.ts.map +0 -1
- package/dist/runSession.js +0 -96
- package/dist/service/cdpHandlers.d.ts +0 -24
- package/dist/service/cdpHandlers.d.ts.map +0 -1
- package/dist/service/cdpHandlers.js +0 -50
- package/dist/service/cdpHint.d.ts +0 -41
- package/dist/service/cdpHint.d.ts.map +0 -1
- package/dist/service/cdpHint.js +0 -158
- package/dist/service/conventions.d.ts +0 -8
- package/dist/service/conventions.d.ts.map +0 -1
- package/dist/service/conventions.js +0 -42
- package/dist/service/relayHandlers.d.ts +0 -28
- package/dist/service/relayHandlers.d.ts.map +0 -1
- package/dist/service/relayHandlers.js +0 -105
- package/dist/service/saveHandlers.d.ts +0 -50
- package/dist/service/saveHandlers.d.ts.map +0 -1
- package/dist/service/saveHandlers.js +0 -77
- package/dist/service/types.d.ts +0 -158
- package/dist/service/types.d.ts.map +0 -1
- package/dist/service/types.js +0 -26
- package/dist/service.d.ts +0 -54
- package/dist/service.d.ts.map +0 -1
- package/dist/service.js +0 -1772
- package/dist/specs/businessMap.d.ts +0 -29
- package/dist/specs/businessMap.d.ts.map +0 -1
- package/dist/specs/businessMap.js +0 -95
- package/dist/specs/extractPageObjects.d.ts +0 -18
- package/dist/specs/extractPageObjects.d.ts.map +0 -1
- package/dist/specs/extractPageObjects.js +0 -98
- package/dist/specs/optimizeSpecWithAgent.d.ts +0 -9
- package/dist/specs/optimizeSpecWithAgent.d.ts.map +0 -1
- package/dist/specs/optimizeSpecWithAgent.js +0 -39
|
@@ -1,594 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* Hover control-actuation MCP server — always spawned alongside Playwright MCP.
|
|
4
|
-
*
|
|
5
|
-
* Why it exists: the Playwright MCP's `browser_click` does strict actionability,
|
|
6
|
-
* so it can't toggle a "visually-hidden" form control — a real <input> clipped
|
|
7
|
-
* to 1px / opacity-0 behind a styled label (the ubiquitous sr-only radio /
|
|
8
|
-
* checkbox / switch pattern). A human clicks the big label and the browser
|
|
9
|
-
* forwards to the hidden input; Playwright clicks the 1px input and bails with
|
|
10
|
-
* "intercepts pointer events". Before Hover disabled the arbitrary-JS browser
|
|
11
|
-
* tools (run_code / evaluate) the agent could JS-click the input as a fallback;
|
|
12
|
-
* that escape hatch is gone, leaving these controls undriveable.
|
|
13
|
-
*
|
|
14
|
-
* This server restores actuation WITHOUT reopening arbitrary JS: it connects to
|
|
15
|
-
* the same debug Chrome over CDP and runs Playwright's own `.check()` /
|
|
16
|
-
* `.uncheck()` with `{ force: true }` (which skips the actionability hit-test
|
|
17
|
-
* the visible label otherwise fails). Crucially the action is a STANDARD
|
|
18
|
-
* Playwright call, so the crystallizer maps it straight to
|
|
19
|
-
* `page.getByRole(role, { name }).check()` — the saved spec stays deterministic
|
|
20
|
-
* and reproducible (unlike a dropped raw-JS step).
|
|
21
|
-
*
|
|
22
|
-
* Env (set by the host):
|
|
23
|
-
* HOVER_CDP_URL CDP endpoint of the debug Chrome (e.g. http://localhost:9222)
|
|
24
|
-
* HOVER_DEV_URL the dev-server URL, so we pick the right page by origin
|
|
25
|
-
*
|
|
26
|
-
* Tool:
|
|
27
|
-
* check_control({ role, name, checked? }) → force-check/uncheck the control
|
|
28
|
-
*/
|
|
29
|
-
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
30
|
-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
31
|
-
import { z } from 'zod';
|
|
32
|
-
import { chromium } from 'playwright-core';
|
|
33
|
-
import { WebSocket } from 'ws';
|
|
34
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
35
|
-
import { isAbsolute, join, resolve } from 'node:path';
|
|
36
|
-
const CDP_URL = process.env.HOVER_CDP_URL || 'http://localhost:9222';
|
|
37
|
-
const DEV_URL = process.env.HOVER_DEV_URL || '';
|
|
38
|
-
const APPROVAL_PORT = process.env.HOVER_APPROVAL_PORT;
|
|
39
|
-
const PROJECT_ROOT = process.env.HOVER_PROJECT_ROOT || process.cwd();
|
|
40
|
-
/** Where take_screenshot writes its PNGs — the run's `.hover/screenshots/<tag>`
|
|
41
|
-
* dir, the same one the service scans with newestPng() to surface a shot in the
|
|
42
|
-
* chat. Set by the host (buildMcpConfig); falls back to the project .hover. */
|
|
43
|
-
const SHOT_DIR = process.env.HOVER_SHOT_DIR || join(PROJECT_ROOT, '.hover', 'screenshots');
|
|
44
|
-
/** Stable, commit-worthy placeholder fixture path (relative to the project) —
|
|
45
|
-
* the spec references this so the upload step replays. */
|
|
46
|
-
const PLACEHOLDER_REL = '__vibe_tests__/fixtures/hover-placeholder.png';
|
|
47
|
-
/** A minimal valid 1×1 PNG — the engine writes this when the user picks
|
|
48
|
-
* "upload a placeholder" so the agent never has to fabricate a file. */
|
|
49
|
-
const PLACEHOLDER_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64');
|
|
50
|
-
function md(text) {
|
|
51
|
-
return { content: [{ type: 'text', text }] };
|
|
52
|
-
}
|
|
53
|
-
/**
|
|
54
|
-
* Candidate-flow capture. Every SUCCESSFUL grounded actuation (click / fill /
|
|
55
|
-
* select / check / upload) is buffered here as a crystallizable step. When the
|
|
56
|
-
* agent calls record_candidate after finishing a flow, we hand it the steps
|
|
57
|
-
* since the previous marker — no fragile step-number citing by the agent (which
|
|
58
|
-
* drifted over long runs). This server is a fresh stdio subprocess per run, so
|
|
59
|
-
* the buffer resets each run automatically. Steps use bare tool names + the same
|
|
60
|
-
* input shape writeSpec.translateStep reads, so they crystallize 1:1.
|
|
61
|
-
*/
|
|
62
|
-
const candidateSteps = [];
|
|
63
|
-
let candidateMark = 0;
|
|
64
|
-
function noteActuation(tool, input) {
|
|
65
|
-
candidateSteps.push({ kind: 'step', tool, input });
|
|
66
|
-
}
|
|
67
|
-
function originOf(u) {
|
|
68
|
-
try {
|
|
69
|
-
return new URL(u).origin;
|
|
70
|
-
}
|
|
71
|
-
catch {
|
|
72
|
-
return null;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
/** First line of an error, for a compact tool result. */
|
|
76
|
-
function errLine(e) {
|
|
77
|
-
return e instanceof Error ? e.message.split('\n')[0] : String(e);
|
|
78
|
-
}
|
|
79
|
-
/** Human label for a grounded target, for the tool's ✓/✗ result text. */
|
|
80
|
-
function describe(g) {
|
|
81
|
-
if (g.role && g.name)
|
|
82
|
-
return `${g.role} "${g.name}"`;
|
|
83
|
-
if (g.testId)
|
|
84
|
-
return `testId "${g.testId}"`;
|
|
85
|
-
if (g.text)
|
|
86
|
-
return `text "${g.text}"`;
|
|
87
|
-
return '(no target)';
|
|
88
|
-
}
|
|
89
|
-
/** Pick the page on the dev origin (the app under test), else the first page. */
|
|
90
|
-
async function pickPage() {
|
|
91
|
-
let browser;
|
|
92
|
-
try {
|
|
93
|
-
browser = await chromium.connectOverCDP(CDP_URL, { timeout: 5000 });
|
|
94
|
-
}
|
|
95
|
-
catch {
|
|
96
|
-
return null;
|
|
97
|
-
}
|
|
98
|
-
const close = async () => { try {
|
|
99
|
-
await browser.close();
|
|
100
|
-
}
|
|
101
|
-
catch { /* disconnect only */ } };
|
|
102
|
-
const wantOrigin = originOf(DEV_URL);
|
|
103
|
-
const pages = browser.contexts().flatMap((c) => c.pages());
|
|
104
|
-
if (pages.length === 0) {
|
|
105
|
-
await close();
|
|
106
|
-
return null;
|
|
107
|
-
}
|
|
108
|
-
const matches = wantOrigin ? pages.filter((p) => originOf(p.url()) === wantOrigin) : [];
|
|
109
|
-
const candidates = matches.length ? matches : pages;
|
|
110
|
-
// Multiple same-origin tabs (e.g. one opened to escape a dialog) → drive the
|
|
111
|
-
// FOREGROUND one, not whichever happens to be first, so steps don't split
|
|
112
|
-
// across tabs. Fall back to the last (most-recently-opened) match.
|
|
113
|
-
let chosen = candidates[candidates.length - 1];
|
|
114
|
-
for (const p of candidates) {
|
|
115
|
-
try {
|
|
116
|
-
if (await p.evaluate(() => document.visibilityState === 'visible')) {
|
|
117
|
-
chosen = p;
|
|
118
|
-
break;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
catch { /* page busy/closed — skip */ }
|
|
122
|
-
}
|
|
123
|
-
return { page: chosen, close };
|
|
124
|
-
}
|
|
125
|
-
/**
|
|
126
|
-
* Build a grounded locator from what the agent read off the snapshot, in
|
|
127
|
-
* preference order: role+name (semantic, survives markup churn — Hover's
|
|
128
|
-
* preferred form) → testId (stable) → text (real visible text). All three are
|
|
129
|
-
* deterministic and crystallize 1:1; the agent never passes a freeform
|
|
130
|
-
* description, so the saved selector can't be a confabulation. Returns null
|
|
131
|
-
* when nothing usable was supplied.
|
|
132
|
-
*/
|
|
133
|
-
function locate(page, g) {
|
|
134
|
-
// `within` scopes to a container (e.g. a radiogroup) by role+name first, so a
|
|
135
|
-
// repeated option label ("No" in three Yes/No groups) or a display:none input
|
|
136
|
-
// (not in the a11y tree → unreachable by role) resolves to exactly one match
|
|
137
|
-
// via its visible label inside the right group.
|
|
138
|
-
const base = g.within?.role && g.within?.name
|
|
139
|
-
? page.getByRole(g.within.role, { name: g.within.name, exact: true })
|
|
140
|
-
: page;
|
|
141
|
-
if (g.role && g.name)
|
|
142
|
-
return base.getByRole(g.role, { name: g.name, exact: true });
|
|
143
|
-
if (g.testId)
|
|
144
|
-
return base.getByTestId(g.testId);
|
|
145
|
-
// .first(): a label-wrapped control's text matches both the <label> and its
|
|
146
|
-
// inner <span> → strict-mode violation. Clicking either forwards to the
|
|
147
|
-
// control, so resolve to the first (the outer label).
|
|
148
|
-
if (g.text)
|
|
149
|
-
return base.getByText(g.text).first();
|
|
150
|
-
return null;
|
|
151
|
-
}
|
|
152
|
-
/** Locate a file <input> for upload_file: by its label (aria-label/associated
|
|
153
|
-
* label), its testId, or — by default — the single file input on the page,
|
|
154
|
-
* optionally scoped to a `within` container. Resolves hidden inputs too. */
|
|
155
|
-
function fileInput(page, g) {
|
|
156
|
-
const base = g.within?.role && g.within?.name
|
|
157
|
-
? page.getByRole(g.within.role, { name: g.within.name, exact: true })
|
|
158
|
-
: page;
|
|
159
|
-
if (g.name)
|
|
160
|
-
return base.getByLabel(g.name);
|
|
161
|
-
if (g.testId)
|
|
162
|
-
return base.getByTestId(g.testId);
|
|
163
|
-
return base.locator('input[type="file"]');
|
|
164
|
-
}
|
|
165
|
-
/** Shared "where" schema for the grounded actuation tools. */
|
|
166
|
-
const GROUND = {
|
|
167
|
-
role: z.string().optional().describe("ARIA role from the snapshot, e.g. 'button', 'textbox', 'link'. Pair with `name`."),
|
|
168
|
-
name: z.string().optional().describe("Accessible name from the snapshot, exactly as shown. Pair with `role`."),
|
|
169
|
-
testId: z.string().optional().describe('A data-testid, if the element has one and no clean role+name (e.g. an unlabeled icon button).'),
|
|
170
|
-
text: z.string().optional().describe('Real visible text on the element — last resort when there is no role+name or testId.'),
|
|
171
|
-
within: z.object({
|
|
172
|
-
role: z.string().describe("The container's role from the snapshot, e.g. 'radiogroup' or 'group'."),
|
|
173
|
-
name: z.string().describe("The container's accessible name, e.g. the group/question name."),
|
|
174
|
-
}).optional().describe('Scope the search to a container first. Use when an option label repeats across groups (e.g. "No" in several Yes/No groups) or the real input is hidden — target the visible label inside the right group.'),
|
|
175
|
-
dynamic: z.boolean().optional().describe("Set true when `name`/`text` is page CONTENT that varies run-to-run (a drawn word, a generated id, a date, a counter) rather than a stable UI label like 'Submit' or 'Email'. Tells Hover to anchor on something stable (testId / role / the `within` container) instead of freezing this run's literal value — so the saved test still passes next run."),
|
|
176
|
-
};
|
|
177
|
-
const NEED_TARGET = '✗ pass role+name (preferred), or testId, or text — taken from the snapshot.';
|
|
178
|
-
const server = new McpServer({ name: 'hover-control', version: '0.0.0' });
|
|
179
|
-
server.registerTool('check_control', {
|
|
180
|
-
description: "Select (or clear) a radio / checkbox / switch by its accessible role + name. Use this when browser_click on the control reports \"intercepts pointer events\" or times out, or otherwise leaves it unchanged — the input is a visually-hidden (sr-only) element behind a styled label, and this tool force-toggles it the way a label click would. Pass the SAME role + name you see in the snapshot (e.g. role 'radio', name 'sex male'). Omit `checked` (or pass true) to select; pass false to clear a checkbox. Crystallizes into page.getByRole(role, { name }).check().",
|
|
181
|
-
inputSchema: {
|
|
182
|
-
role: z.string().describe("The control's ARIA role, e.g. 'radio', 'checkbox', 'switch'."),
|
|
183
|
-
name: z.string().describe("The control's accessible name exactly as shown in the snapshot, e.g. 'sex male'."),
|
|
184
|
-
checked: z.boolean().optional().describe('true (default) = select/check; false = uncheck a checkbox.'),
|
|
185
|
-
dynamic: z.boolean().optional().describe("Set true when `name` is content that varies run-to-run rather than a fixed label — Hover then anchors stably instead of freezing this run's literal."),
|
|
186
|
-
},
|
|
187
|
-
}, async ({ role, name, checked, dynamic }) => {
|
|
188
|
-
const picked = await pickPage();
|
|
189
|
-
if (!picked)
|
|
190
|
-
return md(`✗ could not reach the page over CDP (${CDP_URL}).`);
|
|
191
|
-
const { page, close } = picked;
|
|
192
|
-
try {
|
|
193
|
-
const locator = page.getByRole(role, { name, exact: true });
|
|
194
|
-
if (checked === false)
|
|
195
|
-
await locator.uncheck({ force: true, timeout: 5000 });
|
|
196
|
-
else
|
|
197
|
-
await locator.check({ force: true, timeout: 5000 });
|
|
198
|
-
const ok = await locator.isChecked().catch(() => null);
|
|
199
|
-
noteActuation('check_control', { role, name, checked, dynamic });
|
|
200
|
-
return md(`✓ ${checked === false ? 'unchecked' : 'checked'} ${role} "${name}"${ok === null ? '' : ` (isChecked=${ok})`}`);
|
|
201
|
-
}
|
|
202
|
-
catch (e) {
|
|
203
|
-
return md(`✗ could not toggle ${role} "${name}": ${e instanceof Error ? e.message.split('\n')[0] : String(e)}`);
|
|
204
|
-
}
|
|
205
|
-
finally {
|
|
206
|
-
await close();
|
|
207
|
-
}
|
|
208
|
-
});
|
|
209
|
-
server.registerTool('click_control', {
|
|
210
|
-
description: "Click an element by its accessible role + name (or testId / visible text) taken from the snapshot. This is how you click in Hover — Playwright's browser_click is disabled in this mode because its free-form element description doesn't round-trip to a replayable selector. Pass the role + name you see in browser_snapshot (e.g. role 'button', name 'Continue'). Crystallizes into page.getByRole(role, { name }).click().",
|
|
211
|
-
inputSchema: { ...GROUND },
|
|
212
|
-
}, async (g) => {
|
|
213
|
-
const picked = await pickPage();
|
|
214
|
-
if (!picked)
|
|
215
|
-
return md(`✗ could not reach the page over CDP (${CDP_URL}).`);
|
|
216
|
-
const { page, close } = picked;
|
|
217
|
-
try {
|
|
218
|
-
const loc = locate(page, g);
|
|
219
|
-
if (!loc)
|
|
220
|
-
return md(NEED_TARGET);
|
|
221
|
-
await loc.click({ timeout: 5000 });
|
|
222
|
-
noteActuation('click_control', g);
|
|
223
|
-
return md(`✓ clicked ${describe(g)}`);
|
|
224
|
-
}
|
|
225
|
-
catch (e) {
|
|
226
|
-
return md(`✗ could not click ${describe(g)}: ${errLine(e)}`);
|
|
227
|
-
}
|
|
228
|
-
finally {
|
|
229
|
-
await close();
|
|
230
|
-
}
|
|
231
|
-
});
|
|
232
|
-
server.registerTool('fill_control', {
|
|
233
|
-
description: "Type a value into a text field by its accessible role + name (usually role 'textbox') taken from the snapshot, or testId / its label text. Use instead of Playwright's browser_type / browser_fill_form (disabled here). Crystallizes into page.getByRole('textbox', { name }).fill(value).",
|
|
234
|
-
inputSchema: { ...GROUND, value: z.string().describe('The text to type into the field.') },
|
|
235
|
-
}, async ({ value, ...g }) => {
|
|
236
|
-
const picked = await pickPage();
|
|
237
|
-
if (!picked)
|
|
238
|
-
return md(`✗ could not reach the page over CDP (${CDP_URL}).`);
|
|
239
|
-
const { page, close } = picked;
|
|
240
|
-
try {
|
|
241
|
-
const loc = locate(page, g);
|
|
242
|
-
if (!loc)
|
|
243
|
-
return md(NEED_TARGET);
|
|
244
|
-
await loc.fill(value, { timeout: 5000 });
|
|
245
|
-
noteActuation('fill_control', { ...g, value });
|
|
246
|
-
return md(`✓ filled ${describe(g)} = "${value}"`);
|
|
247
|
-
}
|
|
248
|
-
catch (e) {
|
|
249
|
-
return md(`✗ could not fill ${describe(g)}: ${errLine(e)}`);
|
|
250
|
-
}
|
|
251
|
-
finally {
|
|
252
|
-
await close();
|
|
253
|
-
}
|
|
254
|
-
});
|
|
255
|
-
server.registerTool('select_control', {
|
|
256
|
-
description: "Choose an option in a <select> by its accessible name (role defaults to 'combobox') taken from the snapshot. Use instead of Playwright's browser_select_option (disabled here). Crystallizes into page.getByRole('combobox', { name }).selectOption(value).",
|
|
257
|
-
inputSchema: { ...GROUND, value: z.string().describe('The option label or value to choose.') },
|
|
258
|
-
}, async ({ value, ...g }) => {
|
|
259
|
-
const picked = await pickPage();
|
|
260
|
-
if (!picked)
|
|
261
|
-
return md(`✗ could not reach the page over CDP (${CDP_URL}).`);
|
|
262
|
-
const { page, close } = picked;
|
|
263
|
-
try {
|
|
264
|
-
// A <select> is role 'combobox' — default it so the agent can pass name alone.
|
|
265
|
-
const loc = locate(page, { ...g, role: g.role ?? (g.name ? 'combobox' : undefined) });
|
|
266
|
-
if (!loc)
|
|
267
|
-
return md(NEED_TARGET);
|
|
268
|
-
await loc.selectOption(value, { timeout: 5000 });
|
|
269
|
-
noteActuation('select_control', { ...g, value });
|
|
270
|
-
return md(`✓ selected "${value}" in ${describe(g)}`);
|
|
271
|
-
}
|
|
272
|
-
catch (e) {
|
|
273
|
-
return md(`✗ could not select in ${describe(g)}: ${errLine(e)}`);
|
|
274
|
-
}
|
|
275
|
-
finally {
|
|
276
|
-
await close();
|
|
277
|
-
}
|
|
278
|
-
});
|
|
279
|
-
server.registerTool('upload_file', {
|
|
280
|
-
description: "Upload a file to a file <input> by setting it DIRECTLY (no native file dialog is opened, so it never wedges the page). This runs in the Hover engine (you have no filesystem access yourself): pass `path` for a real file the user gave you, OR `placeholder:true` for a generated placeholder image (only after the user approved it via ask_user). Target the input by its `name` (its label/aria-label) or `testId`; if omitted, the single file input on the page is used. Crystallizes into locator.setInputFiles(...).",
|
|
281
|
-
inputSchema: {
|
|
282
|
-
...GROUND,
|
|
283
|
-
path: z.string().optional().describe('Path to a real file to upload (absolute, or relative to the project root).'),
|
|
284
|
-
placeholder: z.boolean().optional().describe('Upload an engine-generated placeholder image instead of a real file (user-approved fallback).'),
|
|
285
|
-
},
|
|
286
|
-
}, async ({ path, placeholder, ...g }) => {
|
|
287
|
-
const picked = await pickPage();
|
|
288
|
-
if (!picked)
|
|
289
|
-
return md(`✗ could not reach the page over CDP (${CDP_URL}).`);
|
|
290
|
-
const { page, close } = picked;
|
|
291
|
-
try {
|
|
292
|
-
let absPath;
|
|
293
|
-
if (placeholder) {
|
|
294
|
-
absPath = resolve(PROJECT_ROOT, PLACEHOLDER_REL);
|
|
295
|
-
await mkdir(join(PROJECT_ROOT, '__vibe_tests__', 'fixtures'), { recursive: true });
|
|
296
|
-
await writeFile(absPath, PLACEHOLDER_PNG);
|
|
297
|
-
}
|
|
298
|
-
else if (path) {
|
|
299
|
-
absPath = isAbsolute(path) ? path : resolve(PROJECT_ROOT, path);
|
|
300
|
-
}
|
|
301
|
-
else {
|
|
302
|
-
return md('✗ pass `path` (a real file) or `placeholder:true`.');
|
|
303
|
-
}
|
|
304
|
-
// setInputFiles on the file <input> directly — works even when the input
|
|
305
|
-
// is display:none, and (unlike clicking to open a chooser) leaves no
|
|
306
|
-
// dangling file-dialog state that would poison later browser_* calls.
|
|
307
|
-
await fileInput(page, g).setInputFiles(absPath, { timeout: 5000 });
|
|
308
|
-
noteActuation('upload_file', { ...g, path, placeholder });
|
|
309
|
-
return md(`✓ uploaded ${placeholder ? 'a placeholder image' : absPath}`);
|
|
310
|
-
}
|
|
311
|
-
catch (e) {
|
|
312
|
-
return md(`✗ could not upload: ${errLine(e)}`);
|
|
313
|
-
}
|
|
314
|
-
finally {
|
|
315
|
-
await close();
|
|
316
|
-
}
|
|
317
|
-
});
|
|
318
|
-
let askWs = null;
|
|
319
|
-
let askSeq = 0;
|
|
320
|
-
const pendingAsks = new Map();
|
|
321
|
-
function ensureAskWs() {
|
|
322
|
-
if (!APPROVAL_PORT)
|
|
323
|
-
return null;
|
|
324
|
-
if (askWs && (askWs.readyState === WebSocket.OPEN || askWs.readyState === WebSocket.CONNECTING))
|
|
325
|
-
return askWs;
|
|
326
|
-
try {
|
|
327
|
-
const sock = new WebSocket(`ws://127.0.0.1:${APPROVAL_PORT}`);
|
|
328
|
-
sock.on('message', (data) => {
|
|
329
|
-
try {
|
|
330
|
-
const m = JSON.parse(data.toString());
|
|
331
|
-
if (m?.type === 'ask-user-response' && m.payload?.askId) {
|
|
332
|
-
const settle = pendingAsks.get(m.payload.askId);
|
|
333
|
-
if (settle)
|
|
334
|
-
settle({ value: m.payload.value, cancelled: m.payload.cancelled });
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
catch { /* ignore malformed */ }
|
|
338
|
-
});
|
|
339
|
-
// Channel lost → settle every waiting ask as cancelled (the agent can't get
|
|
340
|
-
// an answer, so it proceeds/reports rather than hanging). The user taking
|
|
341
|
-
// their time is NOT a loss — only a closed/errored socket settles here.
|
|
342
|
-
const drop = () => { for (const s of [...pendingAsks.values()])
|
|
343
|
-
s({ cancelled: true }); };
|
|
344
|
-
sock.on('error', () => { drop(); });
|
|
345
|
-
sock.on('close', () => { if (askWs === sock)
|
|
346
|
-
askWs = null; drop(); });
|
|
347
|
-
askWs = sock;
|
|
348
|
-
}
|
|
349
|
-
catch {
|
|
350
|
-
askWs = null;
|
|
351
|
-
}
|
|
352
|
-
return askWs;
|
|
353
|
-
}
|
|
354
|
-
server.registerTool('ask_user', {
|
|
355
|
-
description: "Ask the human running the test a question and WAIT for their answer — use this instead of stopping when you are genuinely blocked: missing credentials, a file you cannot provide (e.g. a document upload), an ambiguous choice only the user can make, or a step you cannot complete on your own. Offer concrete `options` when you can (e.g. saved accounts, 'skip this step', 'stop here'); set allowFreeText so they can type their own answer. Returns the user's choice or typed text; act on it and continue. Do NOT use it for routine interactions — those go through click/fill/select_control.",
|
|
356
|
-
inputSchema: {
|
|
357
|
-
question: z.string().describe('The question to show the user — be specific about what you need and why.'),
|
|
358
|
-
options: z.array(z.object({
|
|
359
|
-
label: z.string().describe('A choice the user can pick.'),
|
|
360
|
-
description: z.string().optional().describe('Optional one-line clarification of this choice.'),
|
|
361
|
-
})).optional().describe('Concrete choices to offer. Omit for a free-form question.'),
|
|
362
|
-
allowFreeText: z.boolean().optional().describe('Let the user type a custom answer in addition to (or instead of) the options.'),
|
|
363
|
-
},
|
|
364
|
-
}, async ({ question, options, allowFreeText }) => {
|
|
365
|
-
if (!APPROVAL_PORT)
|
|
366
|
-
return md('✗ ask_user is unavailable (no editor channel). Continue from what you can do, and report what you needed.');
|
|
367
|
-
const sock = ensureAskWs();
|
|
368
|
-
if (!sock)
|
|
369
|
-
return md('✗ could not reach the editor to ask. Continue and report what you needed.');
|
|
370
|
-
const id = `q${++askSeq}`;
|
|
371
|
-
// NO timeout: a human-in-the-loop prompt waits for the human. The user may
|
|
372
|
-
// not see it for a while — that must never auto-resolve. It settles only
|
|
373
|
-
// when they answer, or when the channel drops (drop() above), or when the
|
|
374
|
-
// run is cancelled (which kills this process).
|
|
375
|
-
const answer = await new Promise((resolve) => {
|
|
376
|
-
const settle = (a) => {
|
|
377
|
-
if (!pendingAsks.has(id))
|
|
378
|
-
return;
|
|
379
|
-
pendingAsks.delete(id);
|
|
380
|
-
resolve(a);
|
|
381
|
-
};
|
|
382
|
-
pendingAsks.set(id, settle);
|
|
383
|
-
const payload = { askId: id, question, options: options ?? [], allowFreeText: allowFreeText === true };
|
|
384
|
-
const req = () => sock.send(JSON.stringify({ type: 'ask-user-request', payload }));
|
|
385
|
-
if (sock.readyState === WebSocket.OPEN)
|
|
386
|
-
req();
|
|
387
|
-
else
|
|
388
|
-
sock.once('open', req);
|
|
389
|
-
});
|
|
390
|
-
if (answer.cancelled || answer.value == null || answer.value === '') {
|
|
391
|
-
return md('The user dismissed the prompt without answering. Do not ask again for the same thing — continue with what you can, then report what was blocked.');
|
|
392
|
-
}
|
|
393
|
-
return md(`The user answered: ${answer.value}`);
|
|
394
|
-
});
|
|
395
|
-
// ── record_fact: persist a learned business rule (QA / API modes) ────────────
|
|
396
|
-
// Fire-and-forget: send `record-fact` over the same engine channel; the engine
|
|
397
|
-
// writes it into .hover/memory/ (only in QA/API modes — ignored elsewhere) so
|
|
398
|
-
// the rule isn't re-asked next run. RULES only; never secrets/PII.
|
|
399
|
-
server.registerTool('record_fact', {
|
|
400
|
-
description: "Remember a durable BUSINESS RULE about this app so you (and future runs) never have to re-ask it — e.g. an expected behavior, a validation rule, an access policy, or a business-logic fact you confirmed (often right after the user answered an ask_user about whether something is a bug or by-design). State it as a clean, self-contained rule. RULES ONLY — never store secrets, passwords, tokens, API keys, or personal data. Use it whenever you learn something about how this app is SUPPOSED to behave; it makes Hover smarter every run.",
|
|
401
|
-
inputSchema: {
|
|
402
|
-
title: z.string().describe('A short title for the rule (becomes its filename + index entry).'),
|
|
403
|
-
rule: z.string().describe('The rule itself, stated clearly and self-contained (no secrets/PII).'),
|
|
404
|
-
type: z.enum(['business-rule', 'expected-behavior', 'validation', 'access-policy']).optional()
|
|
405
|
-
.describe('What kind of knowledge this is. Defaults to business-rule.'),
|
|
406
|
-
},
|
|
407
|
-
}, async ({ title, rule, type }) => {
|
|
408
|
-
const sock = ensureAskWs();
|
|
409
|
-
if (!sock)
|
|
410
|
-
return md('✓ noted (memory channel unavailable; continuing).');
|
|
411
|
-
const send = () => sock.send(JSON.stringify({ type: 'record-fact', payload: { fact: { title, rule, type } } }));
|
|
412
|
-
if (sock.readyState === WebSocket.OPEN)
|
|
413
|
-
send();
|
|
414
|
-
else
|
|
415
|
-
sock.once('open', send);
|
|
416
|
-
return md(`✓ remembered: ${title}`);
|
|
417
|
-
});
|
|
418
|
-
// ── take_screenshot: a VIEWPORT screenshot that never resizes the page ───────
|
|
419
|
-
// Why this exists instead of Playwright's browser_take_screenshot: a fullPage
|
|
420
|
-
// screenshot on a real (connectOverCDP, headed) browser captures the full
|
|
421
|
-
// document by RESIZING the window, which fires a window 'resize' event. Apps
|
|
422
|
-
// that re-layout on resize (responsive breakpoints, etc.) can lose transient UI
|
|
423
|
-
// state — e.g. a flipped flashcard snapping back — so the agent never sees the
|
|
424
|
-
// result of its own click and thrashes. A viewport screenshot uses
|
|
425
|
-
// Page.captureScreenshot of the current viewport: no resize, no side effects.
|
|
426
|
-
// In grounded modes the host DENIES browser_take_screenshot and routes here; the
|
|
427
|
-
// PNG lands in the run's shot dir so the service surfaces it in the chat exactly
|
|
428
|
-
// like before.
|
|
429
|
-
let shotSeq = 0;
|
|
430
|
-
server.registerTool('take_screenshot', {
|
|
431
|
-
description: "Take a screenshot of the CURRENT viewport to see the page as the user sees it. Use this instead of Playwright's browser_take_screenshot (disabled here): a full-page screenshot resizes the live browser window, which can reset transient page state, so Hover captures the viewport only — no resize, no side effects. To see content below the fold, scroll first, then screenshot. For finding elements to act on, rely on browser_snapshot (the accessibility tree covers the whole page, off-screen included).",
|
|
432
|
-
inputSchema: {},
|
|
433
|
-
}, async () => {
|
|
434
|
-
const picked = await pickPage();
|
|
435
|
-
if (!picked)
|
|
436
|
-
return md(`✗ could not reach the page over CDP (${CDP_URL}).`);
|
|
437
|
-
const { page, close } = picked;
|
|
438
|
-
try {
|
|
439
|
-
const png = await page.screenshot({ timeout: 5000 }); // viewport only — never fullPage
|
|
440
|
-
await mkdir(SHOT_DIR, { recursive: true });
|
|
441
|
-
const file = join(SHOT_DIR, `hover-shot-${String(++shotSeq).padStart(4, '0')}.png`);
|
|
442
|
-
await writeFile(file, png);
|
|
443
|
-
return md('✓ screenshot captured (viewport).');
|
|
444
|
-
}
|
|
445
|
-
catch (e) {
|
|
446
|
-
return md(`✗ could not take screenshot: ${errLine(e)}`);
|
|
447
|
-
}
|
|
448
|
-
finally {
|
|
449
|
-
await close();
|
|
450
|
-
}
|
|
451
|
-
});
|
|
452
|
-
// ── assert_visible: record a verification (the invariant a flow proves) ──────
|
|
453
|
-
// Captured into the same candidate buffer as actuations, so it crystallizes
|
|
454
|
-
// inline with the flow (writeSpec translates it to an expect(...) line). We
|
|
455
|
-
// confirm the assertion holds NOW before recording — record==replay means we
|
|
456
|
-
// never save an assertion that already fails. For content that varies run-to-run
|
|
457
|
-
// the agent sets dynamic:true + a non-literal matcher so the spec asserts the
|
|
458
|
-
// invariant ("a word is shown"), not this run's value ("apple").
|
|
459
|
-
server.registerTool('assert_visible', {
|
|
460
|
-
description: "Record a VERIFICATION at the current checkpoint — the invariant a flow proves — as a Playwright assertion saved inline with the flow. Call it when a flow reaches a state worth checking (after login a greeting shows; after flipping a flashcard a word/definition is visible). Target the element by role+name / testId / text from the snapshot, exactly like the *_control tools. Pick `matcher`: 'visible' (default — the element is present), 'non-empty' (it shows SOME text), 'text-contains' (its text contains `expected`), 'text-exact' (equals `expected`), 'count' (`count` matches). CRUCIAL — if the asserted content varies run-to-run (a drawn word, a generated id, a date), set dynamic:true and use 'non-empty' or 'text-contains', NEVER 'text-exact' on the literal. Record at least one assertion per flow, before record_candidate.",
|
|
461
|
-
inputSchema: {
|
|
462
|
-
...GROUND,
|
|
463
|
-
matcher: z.enum(['visible', 'non-empty', 'text-contains', 'text-exact', 'count']).optional()
|
|
464
|
-
.describe("What to assert. Default 'visible'."),
|
|
465
|
-
expected: z.string().optional().describe("For text-contains / text-exact: the (stable) text to assert. Omit text-exact's value to use what's observed now."),
|
|
466
|
-
count: z.number().optional().describe("For matcher 'count': how many matches to expect."),
|
|
467
|
-
},
|
|
468
|
-
}, async ({ matcher, expected, count, ...g }) => {
|
|
469
|
-
const picked = await pickPage();
|
|
470
|
-
if (!picked)
|
|
471
|
-
return md(`✗ could not reach the page over CDP (${CDP_URL}).`);
|
|
472
|
-
const { page, close } = picked;
|
|
473
|
-
try {
|
|
474
|
-
const loc = locate(page, g);
|
|
475
|
-
if (!loc)
|
|
476
|
-
return md(NEED_TARGET);
|
|
477
|
-
const m = matcher ?? 'visible';
|
|
478
|
-
const target = loc.first();
|
|
479
|
-
// Confirm the assertion holds NOW — never record one that already fails.
|
|
480
|
-
const visible = await target.isVisible({ timeout: 5000 }).catch(() => false);
|
|
481
|
-
if (!visible)
|
|
482
|
-
return md(`✗ ${describe(g)} is not visible now — not recording an assertion that would fail on replay.`);
|
|
483
|
-
const observed = (await target.textContent().catch(() => null))?.trim() || undefined;
|
|
484
|
-
if (m === 'non-empty' && !observed)
|
|
485
|
-
return md(`✗ ${describe(g)} shows no text — pick matcher 'visible' instead.`);
|
|
486
|
-
noteActuation('assert_visible', { ...g, matcher: m, expected, count, observed });
|
|
487
|
-
return md(`✓ asserted ${describe(g)} — ${m}${g.dynamic ? ' (dynamic → invariant)' : ''}`);
|
|
488
|
-
}
|
|
489
|
-
catch (e) {
|
|
490
|
-
return md(`✗ could not assert ${describe(g)}: ${errLine(e)}`);
|
|
491
|
-
}
|
|
492
|
-
finally {
|
|
493
|
-
await close();
|
|
494
|
-
}
|
|
495
|
-
});
|
|
496
|
-
// ── clear_client_state: reset the app to a clean slate for reproducible runs ─
|
|
497
|
-
// Used during RECON (debt-2 reproducible-state-isolation): the agent clears
|
|
498
|
-
// client state, reloads, and observes whether the consumed state came back —
|
|
499
|
-
// client-side state resets (Tier 1), backend-synced state re-hydrates (Tier 2).
|
|
500
|
-
// The full-wipe path is exactly what the generated resetState() helper mirrors,
|
|
501
|
-
// so what recon verifies here is what runs in CI. NOT buffered as a candidate
|
|
502
|
-
// step — reset is emitted by the helper / beforeEach, not as an inline flow step.
|
|
503
|
-
server.registerTool('clear_client_state', {
|
|
504
|
-
description: "Reset the app's CLIENT-side state to a clean slate, then reload. Use this during RECON to learn whether a flow can re-enter from a fresh state: clear → reload → check if the consumed state came back. If the app returns to its initial state, its state is client-side and resettable; if the consumed state is re-hydrated (from a backend / logged-in account), it is NOT — report that. Pass `keys` to remove ONLY those localStorage keys (leaves a logged-in session intact); omit to clear everything (cookies + localStorage + sessionStorage + IndexedDB). This is the same operation a saved test's resetState() will mirror, so what you verify here is what runs in CI.",
|
|
505
|
-
inputSchema: {
|
|
506
|
-
keys: z.array(z.string()).optional().describe('localStorage keys to remove (scoped clear, keeps session/cookies). Omit to clear ALL client storage.'),
|
|
507
|
-
},
|
|
508
|
-
}, async ({ keys }) => {
|
|
509
|
-
const picked = await pickPage();
|
|
510
|
-
if (!picked)
|
|
511
|
-
return md(`✗ could not reach the page over CDP (${CDP_URL}).`);
|
|
512
|
-
const { page, close } = picked;
|
|
513
|
-
try {
|
|
514
|
-
if (keys && keys.length > 0) {
|
|
515
|
-
await page.evaluate((ks) => { for (const k of ks)
|
|
516
|
-
localStorage.removeItem(k); }, keys);
|
|
517
|
-
}
|
|
518
|
-
else {
|
|
519
|
-
await page.context().clearCookies();
|
|
520
|
-
await page.evaluate(async () => {
|
|
521
|
-
localStorage.clear();
|
|
522
|
-
sessionStorage.clear();
|
|
523
|
-
// Best-effort IndexedDB wipe (Chromium exposes databases()).
|
|
524
|
-
const idb = indexedDB;
|
|
525
|
-
if (typeof idb.databases === 'function') {
|
|
526
|
-
const dbs = await idb.databases();
|
|
527
|
-
await Promise.all(dbs.map(d => d.name
|
|
528
|
-
? new Promise((res) => {
|
|
529
|
-
const req = indexedDB.deleteDatabase(d.name);
|
|
530
|
-
req.onsuccess = req.onerror = req.onblocked = () => res();
|
|
531
|
-
})
|
|
532
|
-
: Promise.resolve()));
|
|
533
|
-
}
|
|
534
|
-
});
|
|
535
|
-
}
|
|
536
|
-
await page.reload({ timeout: 10000, waitUntil: 'domcontentloaded' });
|
|
537
|
-
return md(`✓ cleared client state${keys?.length ? ` (keys: ${keys.join(', ')})` : ' (cookies + storage + IndexedDB)'} and reloaded.`);
|
|
538
|
-
}
|
|
539
|
-
catch (e) {
|
|
540
|
-
return md(`✗ could not clear client state: ${errLine(e)}`);
|
|
541
|
-
}
|
|
542
|
-
finally {
|
|
543
|
-
await close();
|
|
544
|
-
}
|
|
545
|
-
});
|
|
546
|
-
// ── record_reset_recipe: persist how this app resets to a clean state (recon) ─
|
|
547
|
-
// Fire-and-forget over the engine channel; the engine forwards it to the
|
|
548
|
-
// extension's environment store (.hover/environments.json), keyed to the active
|
|
549
|
-
// env. The classification is made by clear_client_state + observation in recon.
|
|
550
|
-
server.registerTool('record_reset_recipe', {
|
|
551
|
-
description: "Record HOW this app resets to a clean starting state, so saved tests can re-enter deterministically. Call it ONCE after probing with clear_client_state during recon. tier 1 = client-side state (resettable by clearing storage) — pass `storageKeys` if only specific localStorage keys gate the flow's state, omit to clear all. tier 2 = state is re-hydrated from a backend / logged-in account (NOT client-resettable) — Hover flags affected tests as needing a fixture. tier 3 = needs an external setup hook. Set verified:true only if you actually saw the reset work. CONFIG only — never secrets.",
|
|
552
|
-
inputSchema: {
|
|
553
|
-
tier: z.number().int().min(1).max(3).describe('1 = client-side resettable; 2 = backend-synced (not client-resettable); 3 = needs an external setup hook.'),
|
|
554
|
-
storageKeys: z.array(z.string()).optional().describe('Tier 1: the localStorage keys that gate the flow state. Omit to clear all client storage.'),
|
|
555
|
-
verified: z.boolean().optional().describe('true if you confirmed the reset actually returns the app to a clean state.'),
|
|
556
|
-
note: z.string().optional().describe('One line on what you observed (no secrets).'),
|
|
557
|
-
},
|
|
558
|
-
}, async ({ tier, storageKeys, verified, note }) => {
|
|
559
|
-
const sock = ensureAskWs();
|
|
560
|
-
if (!sock)
|
|
561
|
-
return md('✓ noted (recipe channel unavailable; continuing).');
|
|
562
|
-
const send = () => sock.send(JSON.stringify({ type: 'record-reset-recipe', payload: { recipe: { tier, storageKeys, verified, note } } }));
|
|
563
|
-
if (sock.readyState === WebSocket.OPEN)
|
|
564
|
-
send();
|
|
565
|
-
else
|
|
566
|
-
sock.once('open', send);
|
|
567
|
-
return md(`✓ recorded reset recipe (tier ${tier}${verified ? ', verified' : ''}).`);
|
|
568
|
-
});
|
|
569
|
-
// ── record_candidate: mark a clean flow worth crystallizing (QA mode) ────────
|
|
570
|
-
// You just name the flow — the engine captures the actual grounded steps you
|
|
571
|
-
// performed since your LAST record_candidate (or run start), so there's no
|
|
572
|
-
// step-number bookkeeping. Sent over the engine channel; offered to the user as
|
|
573
|
-
// a one-click "Crystallize" → Playwright spec.
|
|
574
|
-
server.registerTool('record_candidate', {
|
|
575
|
-
description: "Record a CANDIDATE FLOW you JUST completed — a clean, coherent end-to-end sequence worth saving as a reusable regression test (e.g. \"Log in\", \"Add item to cart\", \"Submit the registration form\"). Call this the moment you finish such a flow, BEFORE starting the next one: Hover automatically captures the successful click / fill / select / check / upload actions you did since your last record_candidate, so you only give it a name. Do NOT batch flows or call it after unrelated exploration — call it right after each distinct flow so its steps are exactly that flow's.",
|
|
576
|
-
inputSchema: {
|
|
577
|
-
name: z.string().describe('Short imperative flow name IN ENGLISH (becomes the spec filename + test name), e.g. "Log in" or "Add item to cart".'),
|
|
578
|
-
description: z.string().optional().describe('One line on what this flow verifies.'),
|
|
579
|
-
},
|
|
580
|
-
}, async ({ name, description }) => {
|
|
581
|
-
// Steps since the previous marker — the flow the agent just finished.
|
|
582
|
-
const steps = candidateSteps.slice(candidateMark);
|
|
583
|
-
candidateMark = candidateSteps.length;
|
|
584
|
-
const sock = ensureAskWs();
|
|
585
|
-
if (!sock)
|
|
586
|
-
return md('✓ noted (candidate channel unavailable; continuing).');
|
|
587
|
-
const send = () => sock.send(JSON.stringify({ type: 'record-candidate', payload: { candidate: { name, description, steps } } }));
|
|
588
|
-
if (sock.readyState === WebSocket.OPEN)
|
|
589
|
-
send();
|
|
590
|
-
else
|
|
591
|
-
sock.once('open', send);
|
|
592
|
-
return md(`✓ candidate flow recorded: "${name}" (${steps.length} step${steps.length === 1 ? '' : 's'})`);
|
|
593
|
-
});
|
|
594
|
-
await server.connect(new StdioServerTransport());
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
export interface FenceOk {
|
|
2
|
-
ok: true;
|
|
3
|
-
/** Absolute, root-anchored path the server may stat/read (after realpath). */
|
|
4
|
-
abs: string;
|
|
5
|
-
/** POSIX-style path relative to the root — safe to echo back to the agent. */
|
|
6
|
-
rel: string;
|
|
7
|
-
}
|
|
8
|
-
export interface FenceErr {
|
|
9
|
-
ok: false;
|
|
10
|
-
reason: string;
|
|
11
|
-
}
|
|
12
|
-
/**
|
|
13
|
-
* Resolve `input` against `root`, refusing anything outside the root or matching
|
|
14
|
-
* a secret pattern. `input` is treated as relative to the root; an absolute
|
|
15
|
-
* input is resolved too but will fail the containment check unless it happens to
|
|
16
|
-
* live under the root (the agent should pass repo-relative paths).
|
|
17
|
-
*/
|
|
18
|
-
export declare function resolveSourcePath(root: string, input: string): FenceOk | FenceErr;
|
|
19
|
-
/** True if a resolved-and-realpathed absolute path is still inside the root.
|
|
20
|
-
* The server calls this AFTER realpath to defeat symlink escape (a symlink
|
|
21
|
-
* whose lexical path passed resolveSourcePath but points outside the root). */
|
|
22
|
-
export declare function isWithinRoot(root: string, realAbs: string): boolean;
|
|
23
|
-
//# sourceMappingURL=sourceFence.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"sourceFence.d.ts","sourceRoot":"","sources":["../../src/mcp/sourceFence.ts"],"names":[],"mappings":"AAoCA,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,IAAI,CAAC;IACT,8EAA8E;IAC9E,GAAG,EAAE,MAAM,CAAC;IACZ,8EAA8E;IAC9E,GAAG,EAAE,MAAM,CAAC;CACb;AACD,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,KAAK,CAAC;IACV,MAAM,EAAE,MAAM,CAAC;CAChB;AAUD;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,GAAG,QAAQ,CAsBjF;AAED;;gFAEgF;AAChF,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAInE"}
|