@hover-dev/core 0.16.0 → 0.17.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 +26 -55
- package/dist/agentDirectives.d.ts +55 -0
- package/dist/agentDirectives.d.ts.map +1 -0
- package/dist/agentDirectives.js +276 -0
- package/dist/agents/claude.d.ts.map +1 -1
- package/dist/agents/claude.js +28 -3
- package/dist/agents/codex.d.ts.map +1 -1
- package/dist/agents/codex.js +29 -14
- package/dist/agents/invoke.d.ts.map +1 -1
- package/dist/agents/invoke.js +3 -6
- package/dist/agents/registry.d.ts.map +1 -1
- package/dist/agents/registry.js +0 -4
- package/dist/agents/types.d.ts +19 -11
- package/dist/agents/types.d.ts.map +1 -1
- package/dist/engine.d.ts +53 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +78 -0
- package/dist/mcp/actuateServer.d.ts +3 -0
- package/dist/mcp/actuateServer.d.ts.map +1 -0
- package/dist/mcp/actuateServer.js +594 -0
- package/dist/mcp/sourceFence.d.ts.map +1 -1
- package/dist/mcp/sourceFence.js +4 -0
- package/dist/mcp/sourceServer.js +75 -0
- package/dist/memory/businessMemory.d.ts +29 -0
- package/dist/memory/businessMemory.d.ts.map +1 -0
- package/dist/memory/businessMemory.js +125 -0
- package/dist/modes.d.ts +39 -0
- package/dist/modes.d.ts.map +1 -0
- package/dist/modes.js +34 -0
- package/dist/playwright/cdpStatus.d.ts +0 -15
- package/dist/playwright/cdpStatus.d.ts.map +1 -1
- package/dist/playwright/cdpStatus.js +0 -67
- package/dist/playwright/launchChrome.d.ts +18 -0
- package/dist/playwright/launchChrome.d.ts.map +1 -1
- package/dist/playwright/launchChrome.js +46 -3
- package/dist/playwright/resolveMcpConfig.d.ts +7 -1
- package/dist/playwright/resolveMcpConfig.d.ts.map +1 -1
- package/dist/playwright/resolveMcpConfig.js +22 -4
- package/dist/plugin-api.d.ts +28 -26
- package/dist/plugin-api.d.ts.map +1 -1
- package/dist/plugin-api.js +2 -2
- package/dist/qa/candidates.d.ts +32 -0
- package/dist/qa/candidates.d.ts.map +1 -0
- package/dist/qa/candidates.js +20 -0
- package/dist/qa/classify.d.ts +38 -0
- package/dist/qa/classify.d.ts.map +1 -0
- package/dist/qa/classify.js +138 -0
- package/dist/qa/intensity.d.ts +33 -0
- package/dist/qa/intensity.d.ts.map +1 -0
- package/dist/qa/intensity.js +25 -0
- package/dist/qa/qaReport.d.ts +19 -0
- package/dist/qa/qaReport.d.ts.map +1 -0
- package/dist/qa/qaReport.js +50 -0
- package/dist/runSession.d.ts +14 -3
- package/dist/runSession.d.ts.map +1 -1
- package/dist/runSession.js +26 -11
- package/dist/service/cdpHandlers.d.ts +1 -21
- package/dist/service/cdpHandlers.d.ts.map +1 -1
- package/dist/service/cdpHandlers.js +4 -39
- package/dist/service/cdpHint.d.ts +21 -28
- package/dist/service/cdpHint.d.ts.map +1 -1
- package/dist/service/cdpHint.js +106 -164
- package/dist/service/relayHandlers.d.ts +28 -0
- package/dist/service/relayHandlers.d.ts.map +1 -0
- package/dist/service/relayHandlers.js +105 -0
- package/dist/service/saveHandlers.d.ts +1 -3
- package/dist/service/saveHandlers.d.ts.map +1 -1
- package/dist/service/saveHandlers.js +17 -15
- package/dist/service/types.d.ts +108 -8
- package/dist/service/types.d.ts.map +1 -1
- package/dist/service.d.ts +7 -3
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +907 -200
- package/dist/sessions/sessions.d.ts +125 -0
- package/dist/sessions/sessions.d.ts.map +1 -0
- package/dist/sessions/sessions.js +175 -0
- package/dist/specs/authFixture.d.ts +30 -0
- package/dist/specs/authFixture.d.ts.map +1 -0
- package/dist/specs/authFixture.js +145 -0
- package/dist/specs/businessMap.d.ts +29 -0
- package/dist/specs/businessMap.d.ts.map +1 -0
- package/dist/specs/businessMap.js +95 -0
- package/dist/specs/detectSharedFlows.d.ts +1 -1
- package/dist/specs/detectSharedFlows.d.ts.map +1 -1
- package/dist/specs/detectSharedFlows.js +20 -21
- package/dist/specs/generatePageObject.d.ts +1 -1
- package/dist/specs/generatePageObject.d.ts.map +1 -1
- package/dist/specs/healPrompt.d.ts +19 -0
- package/dist/specs/healPrompt.d.ts.map +1 -0
- package/dist/specs/healPrompt.js +48 -0
- package/dist/specs/humanSteps.d.ts +4 -8
- package/dist/specs/humanSteps.d.ts.map +1 -1
- package/dist/specs/humanSteps.js +6 -1
- package/dist/specs/optimizeSpec.d.ts +15 -8
- package/dist/specs/optimizeSpec.d.ts.map +1 -1
- package/dist/specs/optimizeSpec.js +71 -41
- package/dist/specs/optimizeSpecWithAgent.d.ts +0 -2
- package/dist/specs/optimizeSpecWithAgent.d.ts.map +1 -1
- package/dist/specs/optimizeSpecWithAgent.js +0 -1
- package/dist/specs/pageObjectManifest.d.ts +3 -1
- package/dist/specs/pageObjectManifest.d.ts.map +1 -1
- package/dist/specs/pageObjectManifest.js +13 -9
- package/dist/specs/replayGrounded.d.ts +45 -0
- package/dist/specs/replayGrounded.d.ts.map +1 -0
- package/dist/specs/replayGrounded.js +155 -0
- package/dist/specs/runFailures.d.ts +34 -0
- package/dist/specs/runFailures.d.ts.map +1 -0
- package/dist/specs/runFailures.js +93 -0
- package/dist/specs/seeds.d.ts +16 -15
- package/dist/specs/seeds.d.ts.map +1 -1
- package/dist/specs/seeds.js +86 -54
- package/dist/specs/sidecar.d.ts +34 -6
- package/dist/specs/sidecar.d.ts.map +1 -1
- package/dist/specs/sidecar.js +79 -9
- package/dist/specs/specStep.d.ts +21 -0
- package/dist/specs/specStep.d.ts.map +1 -0
- package/dist/specs/specStep.js +1 -0
- package/dist/specs/text.d.ts +8 -6
- package/dist/specs/text.d.ts.map +1 -1
- package/dist/specs/text.js +10 -7
- package/dist/specs/writeSpec.d.ts +62 -1
- package/dist/specs/writeSpec.d.ts.map +1 -1
- package/dist/specs/writeSpec.js +596 -21
- package/package.json +6 -9
- package/dist/agents/aider.d.ts +0 -16
- package/dist/agents/aider.d.ts.map +0 -1
- package/dist/agents/aider.js +0 -161
- package/dist/agents/cursor.d.ts +0 -18
- package/dist/agents/cursor.d.ts.map +0 -1
- package/dist/agents/cursor.js +0 -220
- package/dist/playwright/raiseWindow.d.ts +0 -10
- package/dist/playwright/raiseWindow.d.ts.map +0 -1
- package/dist/playwright/raiseWindow.js +0 -158
- package/dist/scripts/bench-multi-tab.d.ts +0 -2
- package/dist/scripts/bench-multi-tab.d.ts.map +0 -1
- package/dist/scripts/bench-multi-tab.js +0 -192
- package/dist/scripts/bench-ttfb.d.ts +0 -2
- package/dist/scripts/bench-ttfb.d.ts.map +0 -1
- package/dist/scripts/bench-ttfb.js +0 -127
- package/dist/scripts/start-chrome.d.ts +0 -3
- package/dist/scripts/start-chrome.d.ts.map +0 -1
- package/dist/scripts/start-chrome.js +0 -23
- package/dist/skills/writeSkill.d.ts +0 -27
- package/dist/skills/writeSkill.d.ts.map +0 -1
- package/dist/skills/writeSkill.js +0 -13
- package/dist/specs/listSpecs.d.ts +0 -52
- package/dist/specs/listSpecs.d.ts.map +0 -1
- package/dist/specs/listSpecs.js +0 -139
- package/dist/specs/optimizationSuggestion.d.ts +0 -26
- package/dist/specs/optimizationSuggestion.d.ts.map +0 -1
- package/dist/specs/optimizationSuggestion.js +0 -28
- package/dist/specs/writeCaseCsv.d.ts +0 -28
- package/dist/specs/writeCaseCsv.d.ts.map +0 -1
- package/dist/specs/writeCaseCsv.js +0 -134
package/dist/mcp/sourceServer.js
CHANGED
|
@@ -26,6 +26,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
26
26
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
27
27
|
import { z } from 'zod';
|
|
28
28
|
import { readFileSync, realpathSync, statSync, readdirSync } from 'node:fs';
|
|
29
|
+
import { WebSocket } from 'ws';
|
|
29
30
|
import { resolveSourcePath, isWithinRoot } from './sourceFence.js';
|
|
30
31
|
const root = process.env.HOVER_PROJECT_ROOT;
|
|
31
32
|
if (!root) {
|
|
@@ -37,6 +38,76 @@ const MAX_BYTES = 256 * 1024;
|
|
|
37
38
|
function md(text) {
|
|
38
39
|
return { content: [{ type: 'text', text }] };
|
|
39
40
|
}
|
|
41
|
+
// ── Read-approval gate ──────────────────────────────────────────────────────
|
|
42
|
+
// When the host runs us with HOVER_SOURCE_GATE=ask, every read/list first asks
|
|
43
|
+
// the editor (over the Hover service WS at HOVER_APPROVAL_PORT) for the user's
|
|
44
|
+
// one-click approval. The reader is fenced + read-only, so the gate is consent
|
|
45
|
+
// UX, not a security boundary: if the editor can't be reached or doesn't answer
|
|
46
|
+
// within 30s we FAIL OPEN (allow) rather than stall the agent's run.
|
|
47
|
+
const GATE = process.env.HOVER_SOURCE_GATE;
|
|
48
|
+
const APPROVAL_PORT = process.env.HOVER_APPROVAL_PORT;
|
|
49
|
+
let approvalWs = null;
|
|
50
|
+
let approvalSeq = 0;
|
|
51
|
+
const pendingApprovals = new Map();
|
|
52
|
+
function ensureApprovalWs() {
|
|
53
|
+
if (GATE !== 'ask' || !APPROVAL_PORT)
|
|
54
|
+
return null;
|
|
55
|
+
if (approvalWs && (approvalWs.readyState === WebSocket.OPEN || approvalWs.readyState === WebSocket.CONNECTING))
|
|
56
|
+
return approvalWs;
|
|
57
|
+
try {
|
|
58
|
+
const sock = new WebSocket(`ws://127.0.0.1:${APPROVAL_PORT}`);
|
|
59
|
+
sock.on('message', (data) => {
|
|
60
|
+
try {
|
|
61
|
+
const m = JSON.parse(data.toString());
|
|
62
|
+
if (m?.type === 'source-approval-response' && m.payload?.approvalId) {
|
|
63
|
+
const settle = pendingApprovals.get(m.payload.approvalId);
|
|
64
|
+
if (settle)
|
|
65
|
+
settle(m.payload.allow === true);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch { /* ignore malformed */ }
|
|
69
|
+
});
|
|
70
|
+
// Channel lost → fail OPEN for every waiting read (the reader is fenced +
|
|
71
|
+
// read-only; the gate is consent UX, not a security boundary, so never hang
|
|
72
|
+
// a run on a dead channel). The user taking their time is NOT a loss — only
|
|
73
|
+
// a closed/errored socket settles here.
|
|
74
|
+
const drain = () => { for (const s of [...pendingApprovals.values()])
|
|
75
|
+
s(true); };
|
|
76
|
+
sock.on('error', () => { drain(); });
|
|
77
|
+
sock.on('close', () => { if (approvalWs === sock)
|
|
78
|
+
approvalWs = null; drain(); });
|
|
79
|
+
approvalWs = sock;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
approvalWs = null;
|
|
83
|
+
}
|
|
84
|
+
return approvalWs;
|
|
85
|
+
}
|
|
86
|
+
async function approve(path, kind) {
|
|
87
|
+
if (GATE !== 'ask' || !APPROVAL_PORT)
|
|
88
|
+
return true; // not gated → allow
|
|
89
|
+
const sock = ensureApprovalWs();
|
|
90
|
+
if (!sock)
|
|
91
|
+
return true; // no channel → fail open
|
|
92
|
+
const id = `a${++approvalSeq}`;
|
|
93
|
+
return new Promise((resolve) => {
|
|
94
|
+
// NO timeout: the consent prompt waits for the user (they may not see it for
|
|
95
|
+
// a while — that must never auto-allow). It settles only on their answer, or
|
|
96
|
+
// when the channel drops (drain() above → fail open), or run cancel.
|
|
97
|
+
const settle = (allow) => {
|
|
98
|
+
if (!pendingApprovals.has(id))
|
|
99
|
+
return;
|
|
100
|
+
pendingApprovals.delete(id);
|
|
101
|
+
resolve(allow);
|
|
102
|
+
};
|
|
103
|
+
pendingApprovals.set(id, settle);
|
|
104
|
+
const req = () => sock.send(JSON.stringify({ type: 'source-approval-request', payload: { approvalId: id, sourcePath: path, sourceKind: kind } }));
|
|
105
|
+
if (sock.readyState === WebSocket.OPEN)
|
|
106
|
+
req();
|
|
107
|
+
else
|
|
108
|
+
sock.once('open', req);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
40
111
|
const server = new McpServer({ name: 'hover-source', version: '0.0.0' });
|
|
41
112
|
server.registerTool('read_source', {
|
|
42
113
|
description: "Read a source file from THIS project (read-only). Pass a repo-relative path (e.g. the one in an element's data-hover-source, `src/app/login.tsx:42` → path `src/app/login.tsx`). Fenced to the project root: paths that escape it, or that name secrets / keys / .env / .git / node_modules / build output, are refused. Use this to write tests against the real selectors & routes, or — in security/pentest mode — to confirm a finding against the actual server code (the SQL query, the authz check). You cannot write, run, or delete anything.",
|
|
@@ -44,6 +115,8 @@ server.registerTool('read_source', {
|
|
|
44
115
|
path: z.string().describe('Repo-relative path to a source file, e.g. "src/api/orders.ts".'),
|
|
45
116
|
},
|
|
46
117
|
}, async ({ path }) => {
|
|
118
|
+
if (!(await approve(path, 'read')))
|
|
119
|
+
return md(`✗ source read declined by the user — continue from what's visible on the page.`);
|
|
47
120
|
const f = resolveSourcePath(ROOT, path);
|
|
48
121
|
if (!f.ok)
|
|
49
122
|
return md(`✗ ${f.reason}`);
|
|
@@ -85,6 +158,8 @@ server.registerTool('list_source', {
|
|
|
85
158
|
subdir: z.string().optional().describe('Repo-relative directory, e.g. "src/api". Omit for the root.'),
|
|
86
159
|
},
|
|
87
160
|
}, async ({ subdir }) => {
|
|
161
|
+
if (!(await approve(subdir || '.', 'list')))
|
|
162
|
+
return md(`✗ source listing declined by the user.`);
|
|
88
163
|
let dirAbs = ROOT;
|
|
89
164
|
let base = '';
|
|
90
165
|
if (subdir && subdir.trim() && subdir.trim() !== '.') {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** A learned business fact about the app under test. */
|
|
2
|
+
export interface BusinessFact {
|
|
3
|
+
/** kebab-case slug; also the filename stem. */
|
|
4
|
+
name: string;
|
|
5
|
+
/** One-line summary used for recall relevance + the index hook. */
|
|
6
|
+
description: string;
|
|
7
|
+
/** What kind of knowledge this is. */
|
|
8
|
+
type: 'business-rule' | 'expected-behavior' | 'validation' | 'access-policy';
|
|
9
|
+
/** The fact itself (markdown). */
|
|
10
|
+
body: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function memoryDir(devRoot: string): string;
|
|
13
|
+
/** kebab-case a title into a safe filename stem. */
|
|
14
|
+
export declare function slugify(s: string): string;
|
|
15
|
+
/** Load every fact under `.hover/memory/` (excluding the MEMORY.md index).
|
|
16
|
+
* Pure + total: a missing dir / unreadable file just yields fewer facts. */
|
|
17
|
+
export declare function loadMemory(devRoot: string): Promise<BusinessFact[]>;
|
|
18
|
+
/** Format loaded facts as a system-prompt block, or '' when there are none (so
|
|
19
|
+
* the caller appends nothing). Grouped nothing-fancy: one bullet per fact. */
|
|
20
|
+
export declare function formatMemoryForPrompt(facts: BusinessFact[]): string;
|
|
21
|
+
/** Write (or overwrite) a fact file + refresh the MEMORY.md index line. NEVER
|
|
22
|
+
* throws — returns the path or an error string for the caller to log. Business
|
|
23
|
+
* RULES only; the caller must never pass secrets / PII / credentials. */
|
|
24
|
+
export declare function writeFact(devRoot: string, fact: BusinessFact): Promise<{
|
|
25
|
+
path: string;
|
|
26
|
+
} | {
|
|
27
|
+
error: string;
|
|
28
|
+
}>;
|
|
29
|
+
//# sourceMappingURL=businessMemory.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"businessMemory.d.ts","sourceRoot":"","sources":["../../src/memory/businessMemory.ts"],"names":[],"mappings":"AAwBA,wDAAwD;AACxD,MAAM,WAAW,YAAY;IAC3B,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAC;IACb,mEAAmE;IACnE,WAAW,EAAE,MAAM,CAAC;IACpB,sCAAsC;IACtC,IAAI,EAAE,eAAe,GAAG,mBAAmB,GAAG,YAAY,GAAG,eAAe,CAAC;IAC7E,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAC;CACd;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAEjD;AAED,oDAAoD;AACpD,wBAAgB,OAAO,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAMzC;AAsBD;6EAC6E;AAC7E,wBAAsB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAkBzE;AAED;+EAC+E;AAC/E,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,YAAY,EAAE,GAAG,MAAM,CAQnE;AAED;;0EAE0E;AAC1E,wBAAsB,SAAS,CAC7B,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,YAAY,GACjB,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAc/C"}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Business memory — the per-app knowledge QA / API modes accumulate so they stop
|
|
3
|
+
* re-asking the same business questions every run. Lives at `<devRoot>/.hover/
|
|
4
|
+
* memory/`, mirroring Claude's own memory layout (an index + one fact per file):
|
|
5
|
+
*
|
|
6
|
+
* .hover/memory/
|
|
7
|
+
* MEMORY.md ← index: one `- [title](file.md) — hook` line per fact
|
|
8
|
+
* checkout-tax.md ← frontmatter (name / description / type) + the fact body
|
|
9
|
+
* ...
|
|
10
|
+
*
|
|
11
|
+
* Loop: load at run start → inject as agent context ("Known business rules…");
|
|
12
|
+
* after the user answers a business clarification (trigger-B, a later QA stage),
|
|
13
|
+
* write the learned fact. The more an app is tested, the fewer popups — see the
|
|
14
|
+
* QA-tester-mode design + project-moat-strategy.
|
|
15
|
+
*
|
|
16
|
+
* CONTRACT: business RULES only, NEVER secrets / PII / credentials (extends the
|
|
17
|
+
* standing "never read env / secrets" rule). Writes are best-effort — a memory
|
|
18
|
+
* failure must NEVER break a run (same rule as the session ledger). Used by QA +
|
|
19
|
+
* API modes only; Flow / Pentest don't read or write it.
|
|
20
|
+
*/
|
|
21
|
+
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { hoverDir } from '../specs/sidecar.js';
|
|
24
|
+
export function memoryDir(devRoot) {
|
|
25
|
+
return join(hoverDir(devRoot), 'memory');
|
|
26
|
+
}
|
|
27
|
+
/** kebab-case a title into a safe filename stem. */
|
|
28
|
+
export function slugify(s) {
|
|
29
|
+
return s
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
32
|
+
.replace(/^-+|-+$/g, '')
|
|
33
|
+
.slice(0, 60) || 'fact';
|
|
34
|
+
}
|
|
35
|
+
/** Parse a fact file's `---` frontmatter + body. Minimal + total — a malformed
|
|
36
|
+
* file yields nulls and is skipped by the caller. Only the three known keys are
|
|
37
|
+
* read; everything after the closing `---` is the body. */
|
|
38
|
+
function parseFact(slug, raw) {
|
|
39
|
+
const m = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
40
|
+
if (!m)
|
|
41
|
+
return null;
|
|
42
|
+
const fm = m[1];
|
|
43
|
+
const body = m[2].trim();
|
|
44
|
+
const field = (k) => {
|
|
45
|
+
const f = fm.match(new RegExp(`^${k}\\s*:\\s*(.+)$`, 'm'));
|
|
46
|
+
return f ? f[1].trim().replace(/^["']|["']$/g, '') : '';
|
|
47
|
+
};
|
|
48
|
+
const description = field('description');
|
|
49
|
+
const rawType = field('type');
|
|
50
|
+
const types = ['business-rule', 'expected-behavior', 'validation', 'access-policy'];
|
|
51
|
+
const type = types.includes(rawType) ? rawType : 'business-rule';
|
|
52
|
+
if (!body)
|
|
53
|
+
return null;
|
|
54
|
+
return { name: field('name') || slug, description, type, body };
|
|
55
|
+
}
|
|
56
|
+
/** Load every fact under `.hover/memory/` (excluding the MEMORY.md index).
|
|
57
|
+
* Pure + total: a missing dir / unreadable file just yields fewer facts. */
|
|
58
|
+
export async function loadMemory(devRoot) {
|
|
59
|
+
try {
|
|
60
|
+
const dir = memoryDir(devRoot);
|
|
61
|
+
const entries = (await readdir(dir)).filter((e) => e.endsWith('.md') && e.toLowerCase() !== 'memory.md');
|
|
62
|
+
const facts = [];
|
|
63
|
+
for (const entry of entries.sort()) {
|
|
64
|
+
try {
|
|
65
|
+
const raw = await readFile(join(dir, entry), 'utf-8');
|
|
66
|
+
const fact = parseFact(entry.replace(/\.md$/, ''), raw);
|
|
67
|
+
if (fact)
|
|
68
|
+
facts.push(fact);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* skip unreadable */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return facts;
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** Format loaded facts as a system-prompt block, or '' when there are none (so
|
|
81
|
+
* the caller appends nothing). Grouped nothing-fancy: one bullet per fact. */
|
|
82
|
+
export function formatMemoryForPrompt(facts) {
|
|
83
|
+
if (!facts.length)
|
|
84
|
+
return '';
|
|
85
|
+
const lines = facts.map((f) => `- ${f.description ? f.description + ' — ' : ''}${f.body.replace(/\s+/g, ' ').trim()}`);
|
|
86
|
+
return ('KNOWN BUSINESS KNOWLEDGE FOR THIS APP (learned from earlier runs — treat as ' +
|
|
87
|
+
'ground truth; do NOT re-ask what these already answer):\n' +
|
|
88
|
+
lines.join('\n'));
|
|
89
|
+
}
|
|
90
|
+
/** Write (or overwrite) a fact file + refresh the MEMORY.md index line. NEVER
|
|
91
|
+
* throws — returns the path or an error string for the caller to log. Business
|
|
92
|
+
* RULES only; the caller must never pass secrets / PII / credentials. */
|
|
93
|
+
export async function writeFact(devRoot, fact) {
|
|
94
|
+
try {
|
|
95
|
+
const dir = memoryDir(devRoot);
|
|
96
|
+
await mkdir(dir, { recursive: true });
|
|
97
|
+
const slug = slugify(fact.name);
|
|
98
|
+
const file = `${slug}.md`;
|
|
99
|
+
const content = `---\nname: ${slug}\ndescription: ${fact.description}\ntype: ${fact.type}\n---\n\n${fact.body.trim()}\n`;
|
|
100
|
+
await writeFile(join(dir, file), content, 'utf-8');
|
|
101
|
+
await upsertIndex(dir, slug, fact);
|
|
102
|
+
return { path: join(dir, file) };
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Add/replace this fact's line in MEMORY.md (`- [title](file.md) — hook`),
|
|
109
|
+
* keyed by the file link so re-writing a fact updates rather than duplicates. */
|
|
110
|
+
async function upsertIndex(dir, slug, fact) {
|
|
111
|
+
const indexPath = join(dir, 'MEMORY.md');
|
|
112
|
+
const link = `(${slug}.md)`;
|
|
113
|
+
const line = `- [${fact.name}](${slug}.md) — ${fact.description || fact.type}`;
|
|
114
|
+
let existing = '';
|
|
115
|
+
try {
|
|
116
|
+
existing = await readFile(indexPath, 'utf-8');
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
existing = '# Business memory\n\nWhat Hover has learned about this app. One fact per file.\n';
|
|
120
|
+
}
|
|
121
|
+
const kept = existing
|
|
122
|
+
.split('\n')
|
|
123
|
+
.filter((l) => !(l.startsWith('- [') && l.includes(link)));
|
|
124
|
+
await writeFile(indexPath, `${kept.join('\n').replace(/\n+$/, '')}\n${line}\n`, 'utf-8');
|
|
125
|
+
}
|
package/dist/modes.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Built-in (non-plugin) mode behavior. Hover's core modes — Flow (the default,
|
|
3
|
+
* modeId `null`) and the planned QA mode — are pure agent-behavior config: which
|
|
4
|
+
* actuation tools the agent uses and how a run crystallizes. (Plugin modes —
|
|
5
|
+
* api-test / pentest — bring runtime machinery instead: a MITM proxy, sidecars,
|
|
6
|
+
* Chrome proxy flags, lifecycle hooks; they resolve to PLUGIN_MODE_BEHAVIOR.)
|
|
7
|
+
*
|
|
8
|
+
* `resolveModeBehavior(modeId)` is the SINGLE place that answers "how does this
|
|
9
|
+
* mode drive the agent", replacing scattered `currentModeId === null` checks.
|
|
10
|
+
* Adding a built-in mode (e.g. QA) = one entry in BUILTIN_MODE_BEHAVIOR, not new
|
|
11
|
+
* conditionals threaded through the run-assembly path.
|
|
12
|
+
*/
|
|
13
|
+
export interface ModeBehavior {
|
|
14
|
+
/** Deny Playwright's loose interaction tools (browser_click / type / fill_form
|
|
15
|
+
* / select_option / file_upload) AND inject the grounded-actuation directive,
|
|
16
|
+
* so the agent actuates via the Hover control MCP and saved selectors are
|
|
17
|
+
* role+name (record == replay). Flow and QA want this — they crystallize
|
|
18
|
+
* browser steps; plugin modes don't — they explore to capture traffic and
|
|
19
|
+
* keep the Playwright tools. */
|
|
20
|
+
groundedActuation: boolean;
|
|
21
|
+
}
|
|
22
|
+
/** Resolve the agent-behavior config for a mode id (null = Flow). Built-in modes
|
|
23
|
+
* are table-driven; any other (plugin) id falls back to PLUGIN_MODE_BEHAVIOR. */
|
|
24
|
+
export declare function resolveModeBehavior(modeId: string | null): ModeBehavior;
|
|
25
|
+
/** Picker metadata for built-in non-Flow modes (Flow is the implicit null
|
|
26
|
+
* default, not listed). core's broadcastModes merges these into the mode
|
|
27
|
+
* catalogue alongside plugin-contributed modes, and switchMode/set-mode accept
|
|
28
|
+
* them without requiring a plugin. */
|
|
29
|
+
export interface BuiltinMode {
|
|
30
|
+
id: string;
|
|
31
|
+
label: string;
|
|
32
|
+
description: string;
|
|
33
|
+
accent: string;
|
|
34
|
+
}
|
|
35
|
+
export declare const BUILTIN_MODES: BuiltinMode[];
|
|
36
|
+
/** True for a built-in non-null mode id (currently just `qa`). Lets core accept
|
|
37
|
+
* it in set-mode / switchMode without a contributing plugin. */
|
|
38
|
+
export declare function isBuiltinMode(modeId: string): boolean;
|
|
39
|
+
//# sourceMappingURL=modes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"modes.d.ts","sourceRoot":"","sources":["../src/modes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,YAAY;IAC3B;;;;;qCAKiC;IACjC,iBAAiB,EAAE,OAAO,CAAC;CAC5B;AAmBD;kFACkF;AAClF,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,GAAG,YAAY,CAGvE;AAED;;;uCAGuC;AACvC,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;CAChB;AACD,eAAO,MAAM,aAAa,EAAE,WAAW,EAOtC,CAAC;AAEF;iEACiE;AACjE,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAErD"}
|
package/dist/modes.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Flow — the default mode (modeId `null`): author a Playwright spec via grounded
|
|
2
|
+
* actuation. */
|
|
3
|
+
const FLOW_MODE_BEHAVIOR = { groundedActuation: true };
|
|
4
|
+
/** Built-in modes other than Flow, keyed by modeId.
|
|
5
|
+
* QA = comprehensive testing umbrella; its Functional capability reuses Flow's
|
|
6
|
+
* grounded actuation (record == replay for promoted candidate flows). (Its API
|
|
7
|
+
* capability — when enabled — delegates to the api-test plugin's MITM runtime;
|
|
8
|
+
* that orchestration is a later QA stage, not a ModeBehavior field.) */
|
|
9
|
+
const BUILTIN_MODE_BEHAVIOR = {
|
|
10
|
+
qa: { groundedActuation: true },
|
|
11
|
+
};
|
|
12
|
+
/** Plugin-contributed modes (api-test / pentest): full Playwright tool access —
|
|
13
|
+
* they explore to capture traffic, not to crystallize browser steps. */
|
|
14
|
+
const PLUGIN_MODE_BEHAVIOR = { groundedActuation: false };
|
|
15
|
+
/** Resolve the agent-behavior config for a mode id (null = Flow). Built-in modes
|
|
16
|
+
* are table-driven; any other (plugin) id falls back to PLUGIN_MODE_BEHAVIOR. */
|
|
17
|
+
export function resolveModeBehavior(modeId) {
|
|
18
|
+
if (modeId === null)
|
|
19
|
+
return FLOW_MODE_BEHAVIOR;
|
|
20
|
+
return BUILTIN_MODE_BEHAVIOR[modeId] ?? PLUGIN_MODE_BEHAVIOR;
|
|
21
|
+
}
|
|
22
|
+
export const BUILTIN_MODES = [
|
|
23
|
+
{
|
|
24
|
+
id: 'qa',
|
|
25
|
+
label: 'QA Testing',
|
|
26
|
+
description: 'explore the whole app → findings report + promotable specs',
|
|
27
|
+
accent: '#22c55e',
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
/** True for a built-in non-null mode id (currently just `qa`). Lets core accept
|
|
31
|
+
* it in set-mode / switchMode without a contributing plugin. */
|
|
32
|
+
export function isBuiltinMode(modeId) {
|
|
33
|
+
return modeId in BUILTIN_MODE_BEHAVIOR;
|
|
34
|
+
}
|
|
@@ -11,19 +11,4 @@ export interface CdpStatusResult {
|
|
|
11
11
|
reason?: string;
|
|
12
12
|
}
|
|
13
13
|
export declare function checkCdpStatus(cdpUrl: string, pageUrl: string): Promise<CdpStatusResult>;
|
|
14
|
-
/**
|
|
15
|
-
* Bring the debug-Chrome tab matching `pageUrl`'s origin to the front. If no
|
|
16
|
-
* matching tab exists, open a new tab on the origin. Returns the URL of the
|
|
17
|
-
* tab that was focused (or opened) for logging.
|
|
18
|
-
*
|
|
19
|
-
* Uses a short-lived playwright-core connection — opens it, does the work,
|
|
20
|
-
* closes it. We don't keep a long-lived browser handle.
|
|
21
|
-
*/
|
|
22
|
-
export declare function focusDebugTab(cdpUrl: string, pageUrl: string): Promise<{
|
|
23
|
-
ok: true;
|
|
24
|
-
focusedUrl: string;
|
|
25
|
-
} | {
|
|
26
|
-
ok: false;
|
|
27
|
-
reason: string;
|
|
28
|
-
}>;
|
|
29
14
|
//# sourceMappingURL=cdpStatus.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cdpStatus.d.ts","sourceRoot":"","sources":["../../src/playwright/cdpStatus.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"cdpStatus.d.ts","sourceRoot":"","sources":["../../src/playwright/cdpStatus.ts"],"names":[],"mappings":"AAeA,MAAM,MAAM,QAAQ,GAAG,aAAa,GAAG,cAAc,GAAG,QAAQ,CAAC;AAEjE,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,QAAQ,CAAC;IAChB,yCAAyC;IACzC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8EAA8E;IAC9E,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,yEAAyE;IACzE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qDAAqD;IACrD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAeD,wBAAsB,cAAc,CAClC,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,GACd,OAAO,CAAC,eAAe,CAAC,CA4B1B"}
|
|
@@ -11,9 +11,7 @@
|
|
|
11
11
|
* - 'no-cdp' no debug Chrome at all; the widget should let the user
|
|
12
12
|
* trigger a launch.
|
|
13
13
|
*/
|
|
14
|
-
import { chromium } from 'playwright-core';
|
|
15
14
|
import { getPreflight } from './preflightCache.js';
|
|
16
|
-
import { findCdpPid, raiseChromeWindow } from './raiseWindow.js';
|
|
17
15
|
/**
|
|
18
16
|
* Parse a page URL down to its origin (protocol + host + port). We compare
|
|
19
17
|
* by origin, not full URL — the user might be on /login while the debug
|
|
@@ -52,68 +50,3 @@ export async function checkCdpStatus(cdpUrl, pageUrl) {
|
|
|
52
50
|
browser: cdp.browser,
|
|
53
51
|
};
|
|
54
52
|
}
|
|
55
|
-
/**
|
|
56
|
-
* Bring the debug-Chrome tab matching `pageUrl`'s origin to the front. If no
|
|
57
|
-
* matching tab exists, open a new tab on the origin. Returns the URL of the
|
|
58
|
-
* tab that was focused (or opened) for logging.
|
|
59
|
-
*
|
|
60
|
-
* Uses a short-lived playwright-core connection — opens it, does the work,
|
|
61
|
-
* closes it. We don't keep a long-lived browser handle.
|
|
62
|
-
*/
|
|
63
|
-
export async function focusDebugTab(cdpUrl, pageUrl) {
|
|
64
|
-
const wantOrigin = originOf(pageUrl);
|
|
65
|
-
if (!wantOrigin) {
|
|
66
|
-
return { ok: false, reason: `unparseable page URL: ${pageUrl}` };
|
|
67
|
-
}
|
|
68
|
-
let browser;
|
|
69
|
-
try {
|
|
70
|
-
browser = await chromium.connectOverCDP(cdpUrl);
|
|
71
|
-
}
|
|
72
|
-
catch (err) {
|
|
73
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
74
|
-
return { ok: false, reason: `couldn't connect to CDP at ${cdpUrl}: ${msg}` };
|
|
75
|
-
}
|
|
76
|
-
let focusedUrl;
|
|
77
|
-
try {
|
|
78
|
-
const pages = browser.contexts().flatMap(c => c.pages());
|
|
79
|
-
const match = pages.find(p => originOf(p.url()) === wantOrigin);
|
|
80
|
-
if (match) {
|
|
81
|
-
await match.bringToFront();
|
|
82
|
-
focusedUrl = match.url();
|
|
83
|
-
}
|
|
84
|
-
else {
|
|
85
|
-
// No tab on the dev origin yet — open one so the widget appears.
|
|
86
|
-
const context = browser.contexts()[0] ?? (await browser.newContext());
|
|
87
|
-
const page = await context.newPage();
|
|
88
|
-
await page.goto(pageUrl, { waitUntil: 'domcontentloaded' });
|
|
89
|
-
await page.bringToFront();
|
|
90
|
-
focusedUrl = page.url();
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
catch (err) {
|
|
94
|
-
await browser.close().catch(() => { });
|
|
95
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
96
|
-
return { ok: false, reason: `bringToFront failed: ${msg}` };
|
|
97
|
-
}
|
|
98
|
-
await browser.close().catch(() => { });
|
|
99
|
-
// CDP-level bringToFront only activates the tab inside the Chrome process;
|
|
100
|
-
// on macOS in particular the Chrome *window* stays buried if it wasn't
|
|
101
|
-
// foreground already. Raise the OS window too. Best-effort, never fatal.
|
|
102
|
-
const port = portFromCdpUrl(cdpUrl);
|
|
103
|
-
if (port !== null) {
|
|
104
|
-
const pid = await findCdpPid(port);
|
|
105
|
-
if (pid !== null)
|
|
106
|
-
await raiseChromeWindow(pid);
|
|
107
|
-
}
|
|
108
|
-
return { ok: true, focusedUrl };
|
|
109
|
-
}
|
|
110
|
-
function portFromCdpUrl(cdpUrl) {
|
|
111
|
-
try {
|
|
112
|
-
const u = new URL(cdpUrl);
|
|
113
|
-
const port = Number.parseInt(u.port, 10);
|
|
114
|
-
return Number.isInteger(port) && port > 0 ? port : null;
|
|
115
|
-
}
|
|
116
|
-
catch {
|
|
117
|
-
return null;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
@@ -21,6 +21,17 @@ export interface LaunchOptions {
|
|
|
21
21
|
/** Base64 SHA-256 of the MITM CA's SubjectPublicKeyInfo. */
|
|
22
22
|
spki: string;
|
|
23
23
|
};
|
|
24
|
+
/** Launch Chrome headless (`--headless=new`) — no visible window. Still
|
|
25
|
+
* CDP-drivable and still uses the persistent profile, so login state set in
|
|
26
|
+
* a prior headed launch carries over. Used by the VSCode extension's silent
|
|
27
|
+
* mode. Default false (visible window). */
|
|
28
|
+
headless?: boolean;
|
|
29
|
+
/** Close any existing debug Chrome on this port FIRST, then launch fresh.
|
|
30
|
+
* The plain launch is idempotent (returns the running instance), so it can't
|
|
31
|
+
* switch headless↔visible or recover a window that's there-but-not-showing —
|
|
32
|
+
* `force` makes the headless/visible toggle and the "reopen browser" action
|
|
33
|
+
* actually relaunch. Default false. */
|
|
34
|
+
force?: boolean;
|
|
24
35
|
}
|
|
25
36
|
export type LaunchResult = {
|
|
26
37
|
ok: true;
|
|
@@ -32,6 +43,13 @@ export type LaunchResult = {
|
|
|
32
43
|
reason: string;
|
|
33
44
|
};
|
|
34
45
|
export declare function findChromeBinary(): string | null;
|
|
46
|
+
/**
|
|
47
|
+
* Close a debug Chrome on `port` by sending CDP `Browser.close` over its
|
|
48
|
+
* DevTools WebSocket (from /json/version). Works without a child-process handle
|
|
49
|
+
* — Hover spawns Chrome detached — so it's the only way to relaunch in a
|
|
50
|
+
* different mode. Resolves once closed (or the timeout lapses); never throws.
|
|
51
|
+
*/
|
|
52
|
+
export declare function closeDebugChrome(port: number, timeoutMs?: number): Promise<boolean>;
|
|
35
53
|
/**
|
|
36
54
|
* Start (or detect) a debug Chrome listening on the given CDP port. Detaches
|
|
37
55
|
* the child process so the calling script can exit cleanly while Chrome keeps
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"launchChrome.d.ts","sourceRoot":"","sources":["../../src/playwright/launchChrome.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,aAAa;IAC5B,yCAAyC;IACzC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,gEAAgE;IAChE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2CAA2C;IAC3C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,sEAAsE;IACtE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,mDAAmD;IACnD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;wEAKoE;IACpE,KAAK,CAAC,EAAE;QACN,oDAAoD;QACpD,IAAI,EAAE,MAAM,CAAC;QACb,4DAA4D;QAC5D,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;
|
|
1
|
+
{"version":3,"file":"launchChrome.d.ts","sourceRoot":"","sources":["../../src/playwright/launchChrome.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,aAAa;IAC5B,yCAAyC;IACzC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,gEAAgE;IAChE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,2CAA2C;IAC3C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,sEAAsE;IACtE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,mDAAmD;IACnD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;wEAKoE;IACpE,KAAK,CAAC,EAAE;QACN,oDAAoD;QACpD,IAAI,EAAE,MAAM,CAAC;QACb,4DAA4D;QAC5D,IAAI,EAAE,MAAM,CAAC;KACd,CAAC;IACF;;;gDAG4C;IAC5C,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;4CAIwC;IACxC,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,MAAM,YAAY,GACpB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,cAAc,EAAE,OAAO,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACxE;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAMlC,wBAAgB,gBAAgB,IAAI,MAAM,GAAG,IAAI,CA0ChD;AAED;;;;;GAKG;AACH,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,SAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAmBvF;AAiCD;;;;GAIG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,GAAE,aAAkB,GAAG,OAAO,CAAC,YAAY,CAAC,CAuEvF"}
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cross-platform launcher for an isolated debug Chrome on a known CDP port.
|
|
3
3
|
*
|
|
4
|
-
* Idempotent — if the port already responds, returns immediately.
|
|
5
|
-
*
|
|
6
|
-
* - `pnpm exec hover-chrome` (npm consumers) via vite-plugin-hover's bin
|
|
4
|
+
* Idempotent — if the port already responds, returns immediately. The VS Code
|
|
5
|
+
* extension calls this on demand (first ✨ click) to bring up the debug Chrome.
|
|
7
6
|
*
|
|
8
7
|
* The user-data-dir is isolated under tmpdir so we never touch the user's
|
|
9
8
|
* primary Chrome profile.
|
|
@@ -12,6 +11,7 @@ import { spawn } from 'node:child_process';
|
|
|
12
11
|
import { existsSync, unlinkSync } from 'node:fs';
|
|
13
12
|
import { platform, tmpdir } from 'node:os';
|
|
14
13
|
import { join } from 'node:path';
|
|
14
|
+
import { WebSocket } from 'ws';
|
|
15
15
|
const DEFAULT_PORT = 9222;
|
|
16
16
|
const DEFAULT_READY_TIMEOUT_MS = 9000;
|
|
17
17
|
const DEFAULT_POLL_MS = 300;
|
|
@@ -53,6 +53,39 @@ export function findChromeBinary() {
|
|
|
53
53
|
}
|
|
54
54
|
return null;
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Close a debug Chrome on `port` by sending CDP `Browser.close` over its
|
|
58
|
+
* DevTools WebSocket (from /json/version). Works without a child-process handle
|
|
59
|
+
* — Hover spawns Chrome detached — so it's the only way to relaunch in a
|
|
60
|
+
* different mode. Resolves once closed (or the timeout lapses); never throws.
|
|
61
|
+
*/
|
|
62
|
+
export async function closeDebugChrome(port, timeoutMs = 4000) {
|
|
63
|
+
let wsUrl;
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch(`http://localhost:${port}/json/version`, { signal: AbortSignal.timeout(1500) });
|
|
66
|
+
if (!res.ok)
|
|
67
|
+
return false;
|
|
68
|
+
wsUrl = (await res.json()).webSocketDebuggerUrl ?? '';
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
if (!wsUrl)
|
|
74
|
+
return false;
|
|
75
|
+
return await new Promise((resolve) => {
|
|
76
|
+
let done = false;
|
|
77
|
+
const finish = (ok) => { if (done)
|
|
78
|
+
return; done = true; try {
|
|
79
|
+
sock.close();
|
|
80
|
+
}
|
|
81
|
+
catch { /* ignore */ } resolve(ok); };
|
|
82
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
83
|
+
const sock = new WebSocket(wsUrl);
|
|
84
|
+
sock.on('open', () => sock.send(JSON.stringify({ id: 1, method: 'Browser.close' })));
|
|
85
|
+
sock.on('message', () => { clearTimeout(timer); finish(true); });
|
|
86
|
+
sock.on('error', () => { clearTimeout(timer); finish(false); });
|
|
87
|
+
});
|
|
88
|
+
}
|
|
56
89
|
async function isCdpAlive(port) {
|
|
57
90
|
try {
|
|
58
91
|
const res = await fetch(`http://localhost:${port}/json/version`, {
|
|
@@ -95,6 +128,14 @@ export async function launchDebugChrome(opts = {}) {
|
|
|
95
128
|
const url = opts.url ?? 'about:blank';
|
|
96
129
|
const readyTimeoutMs = opts.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
|
|
97
130
|
const pollMs = opts.pollMs ?? DEFAULT_POLL_MS;
|
|
131
|
+
// force: close any existing instance first so a headless↔visible switch (or
|
|
132
|
+
// a "reopen browser" click) actually relaunches instead of no-opping.
|
|
133
|
+
if (opts.force && (await isCdpAlive(port))) {
|
|
134
|
+
await closeDebugChrome(port);
|
|
135
|
+
// Give the port a moment to free up before relaunch.
|
|
136
|
+
for (let i = 0; i < 20 && (await isCdpAlive(port)); i++)
|
|
137
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
138
|
+
}
|
|
98
139
|
if (await isCdpAlive(port)) {
|
|
99
140
|
return { ok: true, alreadyRunning: true, userDataDir, port };
|
|
100
141
|
}
|
|
@@ -112,6 +153,8 @@ export async function launchDebugChrome(opts = {}) {
|
|
|
112
153
|
'--no-first-run',
|
|
113
154
|
'--no-default-browser-check',
|
|
114
155
|
];
|
|
156
|
+
if (opts.headless)
|
|
157
|
+
args.push('--headless=new');
|
|
115
158
|
if (opts.proxy) {
|
|
116
159
|
args.push(`--proxy-server=127.0.0.1:${opts.proxy.port}`, `--ignore-certificate-errors-spki-list=${opts.proxy.spki}`);
|
|
117
160
|
}
|
|
@@ -28,7 +28,7 @@ export interface ExtraMcpServer {
|
|
|
28
28
|
}
|
|
29
29
|
/** The `mcp__<id>` tool-name prefix Claude Code exposes a plugin MCP server's
|
|
30
30
|
* tools under: non-alphanumerics collapse to `_` and edges are trimmed (e.g.
|
|
31
|
-
* `@hover-dev/
|
|
31
|
+
* `@hover-dev/api-test:flows` → `mcp__hover_dev_api_test_flows`). Used to build
|
|
32
32
|
* the hard-sandbox allow-list. Single source so the service and the CLI scan
|
|
33
33
|
* command can't drift on how the prefix is derived. */
|
|
34
34
|
export declare function mcpToolPrefix(serverId: string): string;
|
|
@@ -46,6 +46,12 @@ export declare function resolveMcpConfig(opts: {
|
|
|
46
46
|
/** Suffix for the output filename so multiple parallel configs from
|
|
47
47
|
* the same service (e.g. mode toggle round-trips) don't share state. */
|
|
48
48
|
suffix?: string;
|
|
49
|
+
/** Directory the Playwright MCP server writes its output files
|
|
50
|
+
* (screenshots, PDFs, traces) into — passed as `--output-dir`. Hover
|
|
51
|
+
* points this at `<devRoot>/.hover/screenshots/<session>` so test
|
|
52
|
+
* artifacts land in the project's Hover home, grouped per run, instead
|
|
53
|
+
* of the MCP server's default OS temp dir. Created if missing. */
|
|
54
|
+
outputDir?: string;
|
|
49
55
|
/** Project root to resolve `@playwright/mcp` from. Defaults to
|
|
50
56
|
* `process.cwd()`. `hover run --cwd apps/web` passes the target workspace
|
|
51
57
|
* so a monorepo that installed `@hover-dev/core` only under that app (not
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resolveMcpConfig.d.ts","sourceRoot":"","sources":["../../src/playwright/resolveMcpConfig.ts"],"names":[],"mappings":"AAMA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,WAAW,cAAc;IAC7B;2EACuE;IACvE,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC9B;AAED;;;;wDAIwD;AACxD,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEtD;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE;IACrC,gEAAgE;IAChE,MAAM,EAAE,MAAM,CAAC;IACf,6DAA6D;IAC7D,IAAI,EAAE,MAAM,CAAC;IACb;;;;2CAIuC;IACvC,KAAK,CAAC,EAAE,cAAc,EAAE,CAAC;IACzB;6EACyE;IACzE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;kFAG8E;IAC9E,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,GAAG,MAAM,
|
|
1
|
+
{"version":3,"file":"resolveMcpConfig.d.ts","sourceRoot":"","sources":["../../src/playwright/resolveMcpConfig.ts"],"names":[],"mappings":"AAMA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,WAAW,cAAc;IAC7B;2EACuE;IACvE,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC9B;AAED;;;;wDAIwD;AACxD,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAEtD;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE;IACrC,gEAAgE;IAChE,MAAM,EAAE,MAAM,CAAC;IACf,6DAA6D;IAC7D,IAAI,EAAE,MAAM,CAAC;IACb;;;;2CAIuC;IACvC,KAAK,CAAC,EAAE,cAAc,EAAE,CAAC;IACzB;6EACyE;IACzE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;uEAImE;IACnE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;kFAG8E;IAC9E,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,GAAG,MAAM,CAyET"}
|
|
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os';
|
|
|
5
5
|
import process from 'node:process';
|
|
6
6
|
/** The `mcp__<id>` tool-name prefix Claude Code exposes a plugin MCP server's
|
|
7
7
|
* tools under: non-alphanumerics collapse to `_` and edges are trimmed (e.g.
|
|
8
|
-
* `@hover-dev/
|
|
8
|
+
* `@hover-dev/api-test:flows` → `mcp__hover_dev_api_test_flows`). Used to build
|
|
9
9
|
* the hard-sandbox allow-list. Single source so the service and the CLI scan
|
|
10
10
|
* command can't drift on how the prefix is derived. */
|
|
11
11
|
export function mcpToolPrefix(serverId) {
|
|
@@ -28,17 +28,35 @@ export function resolveMcpConfig(opts) {
|
|
|
28
28
|
// and `@playwright/mcp` is always reachable from there because it's
|
|
29
29
|
// a declared dependency of `@hover-dev/core`, which the user installed.
|
|
30
30
|
// The caller may override with an explicit `cwd` (e.g. `hover run --cwd`).
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
//
|
|
32
|
+
// Fallback for the engine-in-extension model (`@hover-dev/vscode-ext`): there
|
|
33
|
+
// the project (devRoot) is the USER's repo, which does NOT have
|
|
34
|
+
// `@playwright/mcp` — the engine is shipped as a flat node_modules inside the
|
|
35
|
+
// .vsix, so `@playwright/mcp` lives next to `@hover-dev/core` itself. When the
|
|
36
|
+
// cwd-based resolution fails, fall back to resolving from this module's own
|
|
37
|
+
// location (which reaches the staged engine's node_modules).
|
|
38
|
+
let pkgJsonPath;
|
|
39
|
+
try {
|
|
40
|
+
pkgJsonPath = createRequire(resolve(opts.cwd ?? process.cwd(), 'package.json')).resolve('@playwright/mcp/package.json');
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
pkgJsonPath = createRequire(import.meta.url).resolve('@playwright/mcp/package.json');
|
|
44
|
+
}
|
|
33
45
|
const pkgRoot = dirname(pkgJsonPath);
|
|
34
46
|
// The package's `bin` map declares "playwright-mcp": "cli.js" — we
|
|
35
47
|
// pin to that file directly via Node so the user doesn't need the
|
|
36
48
|
// bin shim on PATH and we skip yet another resolution layer.
|
|
37
49
|
const cliPath = resolve(pkgRoot, 'cli.js');
|
|
50
|
+
const playwrightArgs = [cliPath, '--cdp-endpoint', opts.cdpUrl];
|
|
51
|
+
if (opts.outputDir) {
|
|
52
|
+
const abs = resolve(opts.outputDir);
|
|
53
|
+
mkdirSync(abs, { recursive: true });
|
|
54
|
+
playwrightArgs.push('--output-dir', abs);
|
|
55
|
+
}
|
|
38
56
|
const mcpServers = {
|
|
39
57
|
playwright: {
|
|
40
58
|
command: process.execPath, // current Node binary
|
|
41
|
-
args:
|
|
59
|
+
args: playwrightArgs,
|
|
42
60
|
},
|
|
43
61
|
};
|
|
44
62
|
for (const extra of opts.extra ?? []) {
|