@hover-dev/core 0.2.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 (55) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +59 -0
  3. package/dist/agents/argv.d.ts +11 -0
  4. package/dist/agents/argv.d.ts.map +1 -0
  5. package/dist/agents/argv.js +23 -0
  6. package/dist/agents/claude.d.ts +3 -0
  7. package/dist/agents/claude.d.ts.map +1 -0
  8. package/dist/agents/claude.js +145 -0
  9. package/dist/agents/detect.d.ts +16 -0
  10. package/dist/agents/detect.d.ts.map +1 -0
  11. package/dist/agents/detect.js +34 -0
  12. package/dist/agents/index.d.ts +6 -0
  13. package/dist/agents/index.d.ts.map +1 -0
  14. package/dist/agents/index.js +5 -0
  15. package/dist/agents/invoke.d.ts +10 -0
  16. package/dist/agents/invoke.d.ts.map +1 -0
  17. package/dist/agents/invoke.js +70 -0
  18. package/dist/agents/registry.d.ts +12 -0
  19. package/dist/agents/registry.d.ts.map +1 -0
  20. package/dist/agents/registry.js +15 -0
  21. package/dist/agents/types.d.ts +88 -0
  22. package/dist/agents/types.d.ts.map +1 -0
  23. package/dist/agents/types.js +23 -0
  24. package/dist/index.d.ts +3 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +2 -0
  27. package/dist/playwright/cdpStatus.d.ts +29 -0
  28. package/dist/playwright/cdpStatus.d.ts.map +1 -0
  29. package/dist/playwright/cdpStatus.js +96 -0
  30. package/dist/playwright/launchChrome.d.ts +29 -0
  31. package/dist/playwright/launchChrome.d.ts.map +1 -0
  32. package/dist/playwright/launchChrome.js +137 -0
  33. package/dist/playwright/preflight.d.ts +31 -0
  34. package/dist/playwright/preflight.d.ts.map +1 -0
  35. package/dist/playwright/preflight.js +71 -0
  36. package/dist/scripts/start-chrome.d.ts +3 -0
  37. package/dist/scripts/start-chrome.d.ts.map +1 -0
  38. package/dist/scripts/start-chrome.js +23 -0
  39. package/dist/service.d.ts +22 -0
  40. package/dist/service.d.ts.map +1 -0
  41. package/dist/service.js +485 -0
  42. package/dist/skills/writeSkill.d.ts +50 -0
  43. package/dist/skills/writeSkill.d.ts.map +1 -0
  44. package/dist/skills/writeSkill.js +169 -0
  45. package/dist/specs/humanSteps.d.ts +25 -0
  46. package/dist/specs/humanSteps.d.ts.map +1 -0
  47. package/dist/specs/humanSteps.js +97 -0
  48. package/dist/specs/writeCaseCsv.d.ts +28 -0
  49. package/dist/specs/writeCaseCsv.d.ts.map +1 -0
  50. package/dist/specs/writeCaseCsv.js +140 -0
  51. package/dist/specs/writeSpec.d.ts +27 -0
  52. package/dist/specs/writeSpec.d.ts.map +1 -0
  53. package/dist/specs/writeSpec.js +265 -0
  54. package/mcp.config.json +12 -0
  55. package/package.json +78 -0
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Save a completed Hover session as a standard Playwright spec.
3
+ *
4
+ * Writes a `.spec.ts` file under `<devRoot>/__vibe_tests__/`. The generated
5
+ * file imports only `@playwright/test` — no Hover runtime, no agent in the
6
+ * loop. The dev project's playwright.config.ts is expected to set
7
+ * `baseURL` so the spec can use relative URLs (matches the dogfood spec).
8
+ *
9
+ * Translation strategy is deterministic, not LLM. Each `browser_*` tool call
10
+ * in the captured session maps to one Playwright call. Element descriptions
11
+ * coming back from Playwright MCP (e.g. "Submit button", "+1 button") are
12
+ * parsed for role + accessible name and emitted as `getByRole(role, { name })`
13
+ * — Hover's official preference because those selectors survive markup
14
+ * changes that don't touch semantics.
15
+ *
16
+ * Assertions (future "Assert This" feature) layer in via the optional
17
+ * `assertions` field on the input.
18
+ */
19
+ import { mkdir, writeFile } from 'node:fs/promises';
20
+ import { existsSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { humanSteps } from './humanSteps.js';
23
+ export class SpecExistsError extends Error {
24
+ slug;
25
+ path;
26
+ constructor(slug, path) {
27
+ super(`Playwright spec "${slug}" already exists at ${path}`);
28
+ this.slug = slug;
29
+ this.path = path;
30
+ this.name = 'SpecExistsError';
31
+ }
32
+ }
33
+ export async function writeSpec(opts) {
34
+ const slug = slugify(opts.name);
35
+ if (!slug)
36
+ throw new Error('spec name must contain at least one alphanumeric character');
37
+ if (!opts.steps.some(s => s.kind === 'step')) {
38
+ throw new Error('spec must contain at least one tool step to replay');
39
+ }
40
+ const dir = join(opts.devRoot, '__vibe_tests__');
41
+ const path = join(dir, `${slug}.spec.ts`);
42
+ if (!opts.overwrite && existsSync(path)) {
43
+ throw new SpecExistsError(slug, path);
44
+ }
45
+ await mkdir(dir, { recursive: true });
46
+ const source = renderSpec(slug, opts.name, opts.description ?? '', opts.steps, opts.assertions ?? []);
47
+ await writeFile(path, source, 'utf-8');
48
+ return { path, slug };
49
+ }
50
+ function slugify(name) {
51
+ return name
52
+ .toLowerCase()
53
+ .trim()
54
+ .replace(/[^a-z0-9]+/g, '-')
55
+ .replace(/^-+|-+$/g, '');
56
+ }
57
+ // Escape sequences that would prematurely terminate the JSDoc block.
58
+ // (Backtick literal of close-comment sequence omitted on purpose — see how
59
+ // the regex below is built — to avoid recursively poisoning *this* file.)
60
+ function jsdocEscape(s) {
61
+ return s.replace(/\*\//g, '*\\/');
62
+ }
63
+ /**
64
+ * Turn the captured assertions (Alt-click "Assert This") and the agent's
65
+ * final summary into bullet lines for the Expected: block.
66
+ *
67
+ * Assertions take priority — they're the developer's explicit "this is
68
+ * what success looks like". When there are none, fall back to the
69
+ * agent's natural-language done-summary (single first sentence) so the
70
+ * block still carries something readable for QA.
71
+ */
72
+ function collectExpected(assertions, doneSummary) {
73
+ if (assertions.length > 0) {
74
+ return assertions.map(a => a.hint ?? a.code);
75
+ }
76
+ if (doneSummary && doneSummary.trim()) {
77
+ // Take the first sentence only — agents sometimes ramble.
78
+ const first = doneSummary.split(/(?<=[.!?])\s+/)[0] ?? doneSummary;
79
+ return [first.trim()];
80
+ }
81
+ return [];
82
+ }
83
+ function renderSpec(slug, displayName, description, steps, assertions) {
84
+ const userMsg = steps.find(s => s.kind === 'user');
85
+ const doneMsg = [...steps].reverse().find(s => s.kind === 'done');
86
+ // Plain-English step + expected blocks for the JSDoc header. QA / PMs
87
+ // can read these without grokking Playwright API; the same prose also
88
+ // populates the Step column when the user exports the session to Xray
89
+ // CSV via writeCaseCsv.
90
+ const proseSteps = humanSteps(steps);
91
+ const expectedLines = collectExpected(assertions, doneMsg?.summary);
92
+ const lines = [];
93
+ lines.push(`import { test, expect } from '@playwright/test';`);
94
+ lines.push('');
95
+ lines.push('/**');
96
+ lines.push(` * Generated by Hover on ${new Date().toISOString().slice(0, 10)}.`);
97
+ if (userMsg?.text)
98
+ lines.push(` * Original prompt: ${jsdocEscape(userMsg.text).slice(0, 240)}`);
99
+ if (doneMsg?.summary)
100
+ lines.push(` * Outcome: ${jsdocEscape(doneMsg.summary.split('\n')[0]).slice(0, 240)}`);
101
+ if (proseSteps.length > 0) {
102
+ lines.push(' *');
103
+ lines.push(' * Steps:');
104
+ proseSteps.forEach((s, i) => lines.push(` * ${i + 1}. ${jsdocEscape(s)}`));
105
+ }
106
+ if (expectedLines.length > 0) {
107
+ lines.push(' *');
108
+ lines.push(' * Expected:');
109
+ for (const e of expectedLines)
110
+ lines.push(` * • ${jsdocEscape(e)}`);
111
+ }
112
+ lines.push(' *');
113
+ lines.push(' * Selectors prefer getByRole / getByLabel / getByTestId — generated from');
114
+ lines.push(' * the agent\'s natural-language element descriptions, not raw CSS ids,');
115
+ lines.push(' * so the spec survives markup changes that don\'t touch semantics.');
116
+ lines.push(' */');
117
+ const safeTitle = displayName.replace(/'/g, "\\'");
118
+ lines.push(`test('${safeTitle}', async ({ page }) => {`);
119
+ let hasAwait = false;
120
+ for (const s of steps) {
121
+ if (s.kind !== 'step' || !s.tool)
122
+ continue;
123
+ const calls = translateStep(s.tool, s.input);
124
+ for (const c of calls) {
125
+ lines.push(` ${c}`);
126
+ hasAwait = true;
127
+ }
128
+ }
129
+ if (assertions.length > 0) {
130
+ if (hasAwait)
131
+ lines.push('');
132
+ lines.push(' // Assertions captured via Alt-click in the Hover widget.');
133
+ for (const a of assertions) {
134
+ if (a.hint)
135
+ lines.push(` // ${a.hint}`);
136
+ lines.push(` await ${a.code};`);
137
+ }
138
+ }
139
+ if (!hasAwait && assertions.length === 0) {
140
+ lines.push(' // (no automatable steps were captured)');
141
+ }
142
+ lines.push('});');
143
+ lines.push('');
144
+ return lines.join('\n');
145
+ }
146
+ function translateStep(tool, rawInput) {
147
+ const input = (rawInput ?? {});
148
+ switch (tool) {
149
+ case 'browser_navigate': {
150
+ const url = String(input.url ?? '');
151
+ const path = stripBaseUrl(url);
152
+ return [`await page.goto(${JSON.stringify(path)});`];
153
+ }
154
+ case 'browser_click':
155
+ return [`await ${selectorFromDescription(String(input.element ?? ''))}.click();`];
156
+ case 'browser_double_click':
157
+ return [`await ${selectorFromDescription(String(input.element ?? ''))}.dblclick();`];
158
+ case 'browser_hover':
159
+ return [`await ${selectorFromDescription(String(input.element ?? ''))}.hover();`];
160
+ case 'browser_fill_form': {
161
+ const fields = input.fields ?? [];
162
+ return fields.map(raw => {
163
+ const f = raw;
164
+ const value = String(f.value ?? '');
165
+ const target = f.name ?? f.element ?? '';
166
+ return `await ${selectorForFormField(target, f.type)}.fill(${JSON.stringify(value)});`;
167
+ });
168
+ }
169
+ case 'browser_type': {
170
+ const text = String(input.text ?? '');
171
+ const target = String(input.element ?? '');
172
+ return [`await ${selectorFromDescription(target)}.fill(${JSON.stringify(text)});`];
173
+ }
174
+ case 'browser_select_option': {
175
+ const target = String(input.element ?? '');
176
+ const values = input.values;
177
+ const val = (values && values.length > 0 ? values[0] : input.value) ?? '';
178
+ return [`await ${selectorFromDescription(target)}.selectOption(${JSON.stringify(String(val))});`];
179
+ }
180
+ case 'browser_press_key': {
181
+ const key = String(input.key ?? '');
182
+ return [`await page.keyboard.press(${JSON.stringify(key)});`];
183
+ }
184
+ case 'browser_wait_for':
185
+ // Skip "wait for" hints — Playwright auto-waits.
186
+ return [];
187
+ case 'browser_tabs':
188
+ case 'browser_snapshot':
189
+ case 'browser_take_screenshot':
190
+ case 'browser_resize':
191
+ case 'browser_evaluate':
192
+ case 'browser_console_messages':
193
+ case 'browser_network_requests':
194
+ // Diagnostic / read-only / non-replayable on a fresh playwright run.
195
+ return [];
196
+ default:
197
+ return [`// TODO: translate ${tool} (skipped — unknown tool for spec emission)`];
198
+ }
199
+ }
200
+ /**
201
+ * Parse element descriptions like "Submit button" / "+1 button" / "Email
202
+ * textbox" / "Plan radio" into `getByRole(role, { name })` selectors. The
203
+ * trailing role keyword is the convention Playwright MCP uses.
204
+ */
205
+ function selectorFromDescription(desc) {
206
+ const trimmed = desc.trim();
207
+ if (!trimmed)
208
+ return `page.locator('body')`;
209
+ // Strip a leading "Link" / "Button" article-style prefix sometimes added
210
+ // by the MCP, e.g. "Link \"Learn more\"". We only handle the trailing form.
211
+ const roleMatch = trimmed.match(/^(.+?)\s+(button|link|textbox|checkbox|radio|combobox|switch|menuitem|tab|listitem|heading|dialog|cell|row|columnheader|rowheader|gridcell)$/i);
212
+ if (roleMatch) {
213
+ const name = roleMatch[1].replace(/^"|"$/g, '');
214
+ const role = roleMatch[2].toLowerCase();
215
+ return `page.getByRole('${role}', { name: ${JSON.stringify(name)} })`;
216
+ }
217
+ // Quoted label, e.g. \"Submit\" — fall back to getByText.
218
+ const quoted = trimmed.match(/^"(.+)"$/);
219
+ if (quoted)
220
+ return `page.getByText(${JSON.stringify(quoted[1])})`;
221
+ return `page.getByText(${JSON.stringify(trimmed)})`;
222
+ }
223
+ /**
224
+ * Form fields from browser_fill_form have a `name` that's typically the
225
+ * accessible name / label / aria-label. getByLabel is the right primitive.
226
+ * Fall back to getByRole('textbox') if we have a hint.
227
+ */
228
+ function selectorForFormField(name, type) {
229
+ const trimmed = name.trim();
230
+ if (!trimmed)
231
+ return `page.locator('input')`;
232
+ if (type) {
233
+ const role = mapInputType(type);
234
+ if (role)
235
+ return `page.getByRole('${role}', { name: ${JSON.stringify(trimmed)} })`;
236
+ }
237
+ return `page.getByLabel(${JSON.stringify(trimmed)})`;
238
+ }
239
+ function mapInputType(type) {
240
+ switch (type.toLowerCase()) {
241
+ case 'textbox':
242
+ case 'text':
243
+ case 'email':
244
+ case 'tel':
245
+ case 'url':
246
+ case 'search':
247
+ case 'password':
248
+ case 'number':
249
+ return 'textbox';
250
+ case 'checkbox': return 'checkbox';
251
+ case 'radio': return 'radio';
252
+ case 'combobox':
253
+ case 'select': return 'combobox';
254
+ case 'slider': return 'slider';
255
+ case 'switch': return 'switch';
256
+ default: return null;
257
+ }
258
+ }
259
+ function stripBaseUrl(url) {
260
+ // http://localhost:5173/checkout → /checkout, http://localhost:5173/ → /
261
+ if (!/^https?:\/\//.test(url))
262
+ return url;
263
+ const u = new URL(url);
264
+ return u.pathname + u.search + u.hash || '/';
265
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "mcpServers": {
3
+ "playwright": {
4
+ "command": "npx",
5
+ "args": [
6
+ "-y",
7
+ "@playwright/mcp@latest",
8
+ "--cdp-endpoint", "http://localhost:9222"
9
+ ]
10
+ }
11
+ }
12
+ }
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@hover-dev/core",
3
+ "version": "0.2.0",
4
+ "description": "Hover's local Node service: agent invocation, Playwright CDP preflight, WebSocket bridge.",
5
+ "license": "Apache-2.0",
6
+ "author": "Hyperyond",
7
+ "homepage": "https://github.com/Hyperyond/Hover#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Hyperyond/Hover.git",
11
+ "directory": "packages/core"
12
+ },
13
+ "bugs": "https://github.com/Hyperyond/Hover/issues",
14
+ "keywords": [
15
+ "hover",
16
+ "playwright",
17
+ "mcp",
18
+ "agent",
19
+ "browser-testing",
20
+ "claude"
21
+ ],
22
+ "type": "module",
23
+ "main": "dist/index.js",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js"
28
+ },
29
+ "./agents": {
30
+ "types": "./dist/agents/index.d.ts",
31
+ "import": "./dist/agents/index.js"
32
+ },
33
+ "./service": {
34
+ "types": "./dist/service.d.ts",
35
+ "import": "./dist/service.js"
36
+ },
37
+ "./launch-chrome": {
38
+ "types": "./dist/playwright/launchChrome.d.ts",
39
+ "import": "./dist/playwright/launchChrome.js"
40
+ }
41
+ },
42
+ "files": [
43
+ "dist",
44
+ "mcp.config.json",
45
+ "README.md"
46
+ ],
47
+ "dependencies": {
48
+ "cross-spawn": "^7.0.6",
49
+ "playwright-core": "^1.50.0",
50
+ "ws": "^8.20.1"
51
+ },
52
+ "devDependencies": {
53
+ "@types/cross-spawn": "^6.0.6",
54
+ "@types/ws": "^8.18.1",
55
+ "vitest": "^3.0.0"
56
+ },
57
+ "publishConfig": {
58
+ "registry": "https://registry.npmjs.org",
59
+ "access": "public"
60
+ },
61
+ "scripts": {
62
+ "build": "tsc -p tsconfig.build.json",
63
+ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
64
+ "smoke": "tsx src/smoke.ts",
65
+ "smoke:chrome": "tsx src/scripts/start-chrome.ts",
66
+ "detect": "tsx src/scripts/detect-cli.ts",
67
+ "verify-widget": "tsx src/scripts/verify-widget.ts",
68
+ "verify-skill": "tsx src/scripts/verify-skill.ts",
69
+ "verify-spec": "tsx src/scripts/verify-spec.ts",
70
+ "ws-smoke": "tsx src/scripts/ws-smoke.ts",
71
+ "test": "vitest run",
72
+ "test:watch": "vitest"
73
+ },
74
+ "types": "dist/index.d.ts",
75
+ "bin": {
76
+ "hover-chrome": "dist/scripts/start-chrome.js"
77
+ }
78
+ }