pursr 0.8.0 → 0.9.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 +62 -9
- package/package.json +8 -5
- package/src/index.js +11 -3
- package/src/mcp.js +74 -128
- package/src/runway.js +33 -21
- package/src/session.js +58 -10
- package/src/visual-operator.js +124 -0
package/README.md
CHANGED
|
@@ -33,8 +33,9 @@
|
|
|
33
33
|
Most teams need **five separate tools** to do visual QA: a screenshot CLI, a regression diff runner, an accessibility auditor, a way to share captures with an AI assistant, and a way to **turn all of that into a PDF report** for stakeholders. **pursr is all five** - built as a single Node.js package with:
|
|
34
34
|
|
|
35
35
|
- **A unified CLI** (`pursr`) for every capture, diff, sweep, and audit.
|
|
36
|
-
- **An agent-grade MCP stdio server** (`pursr-mcp`) with persistent tabs, direct image responses, rendered-state inspection, actions, diagnostics, screenshots, sweeps, and resources.
|
|
37
|
-
- **
|
|
36
|
+
- **An agent-grade MCP stdio server** (`pursr-mcp`) built on the official Model Context Protocol SDK, with persistent tabs, direct image responses, rendered-state inspection, actions, diagnostics, screenshots, sweeps, and resources.
|
|
37
|
+
- **Visual Operator** sessions with a rendered cursor, target labels, click markers, visible Chrome windows, and authenticated Chrome attachment over CDP.
|
|
38
|
+
- **A library API** with 24 subpath modules, so you can embed the browser and QA primitives in your own tooling.
|
|
38
39
|
- **A plugin system** for custom viewports, sweep ops, and capture hooks.
|
|
39
40
|
- **PDF reports + AI diff summaries** built in - render a sweep to a styled PDF or ask a vision LLM to describe the regression in plain language.
|
|
40
41
|
- **Zero browser bundled** - drives your system Chrome via Playwright. No 200 MB Chromium download.
|
|
@@ -79,7 +80,8 @@ pursr sweep ./plan.json # see plans/ for an example
|
|
|
79
80
|
| Multi-viewport capture | 10+ presets (mobile, tablet, desktop, ultrawide) | `--preset mobile-375` |
|
|
80
81
|
| Layered states | entity / terrain / hud / ui isolation | `--layer entity` |
|
|
81
82
|
| Animation freeze | pause CSS/JS animations for stable frames | `--no-animation` |
|
|
82
|
-
| Cursor overlay | pointer / grab / grabbing / crosshair | `--cursor crosshair` |
|
|
83
|
+
| Cursor overlay | pointer / grab / grabbing / crosshair | `--cursor crosshair` |
|
|
84
|
+
| Visual Operator | rendered cursor, target labels, click markers, headed and CDP sessions | MCP session tools |
|
|
83
85
|
| Grid overlay | spacing guides, custom color + tile size | `--grid --grid-tile 64` |
|
|
84
86
|
| Camera control | zoom + pan via mouse wheel/drag | `--zoom 1.5 --panX 200` |
|
|
85
87
|
| Frame timeline | N captures at intervalMs for animations | `pursr frames <url> 8 200` |
|
|
@@ -96,7 +98,7 @@ pursr sweep ./plan.json # see plans/ for an example
|
|
|
96
98
|
| HAR capture | HAR 1.2 spec, written next to your shot | `--har ./req.har.json` |
|
|
97
99
|
| Auth state | Playwright storageState, reuse logged-in sessions | `--auth-state admin` |
|
|
98
100
|
| Plugins | custom viewports, sweep ops, before/after hooks | `pursr-plugin-*` |
|
|
99
|
-
| MCP server | 16 tools
|
|
101
|
+
| MCP server | Official MCP SDK transport, 16 tools, and resources for Claude/Cursor/Codex | `npx pursr-mcp` |
|
|
100
102
|
| PDF report | render sweep.json to a styled, embedded-PNG A4 PDF | `pursr report --sweep ./sweep.json` |
|
|
101
103
|
| AI diff summary | vision LLM describes the diff in plain language | `pursr diff ... --ai` |
|
|
102
104
|
|
|
@@ -188,10 +190,10 @@ npx pursr-mcp --verbose
|
|
|
188
190
|
|
|
189
191
|
| Tool | Description |
|
|
190
192
|
| --- | --- |
|
|
191
|
-
| `pursr_session_open` | Open a
|
|
193
|
+
| `pursr_session_open` | Open a headless, visible, or CDP browser session with optional Visual Operator |
|
|
192
194
|
| `pursr_sessions` | List active browser sessions |
|
|
193
195
|
| `pursr_snapshot` | Visible rendered nodes, geometry, semantics, and computed styles |
|
|
194
|
-
| `pursr_act` |
|
|
196
|
+
| `pursr_act` | Interact plus move cursor, annotate targets, and clear visual feedback |
|
|
195
197
|
| `pursr_screenshot` | Return the current PNG directly to the vision model |
|
|
196
198
|
| `pursr_inspect` | Inspect exact geometry, computed styles, and stacking ancestors |
|
|
197
199
|
| `pursr_diagnostics` | Read console, page errors, failed requests, and HTTP failures |
|
|
@@ -229,6 +231,52 @@ Example action arguments:
|
|
|
229
231
|
]
|
|
230
232
|
}
|
|
231
233
|
```
|
|
234
|
+
|
|
235
|
+
### Visual Operator
|
|
236
|
+
|
|
237
|
+
Set `visual: true` to render the agent cursor and interaction feedback into screenshots. `mode: "visible"` enables it automatically and opens a Chrome window that a developer can watch.
|
|
238
|
+
|
|
239
|
+
```json
|
|
240
|
+
{
|
|
241
|
+
"url": "http://localhost:3000",
|
|
242
|
+
"sessionId": "visual-review",
|
|
243
|
+
"mode": "visible",
|
|
244
|
+
"operatorColor": "#ff2ea6",
|
|
245
|
+
"slowMo": 80
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Visual actions use the regular `pursr_act` tool:
|
|
250
|
+
|
|
251
|
+
```json
|
|
252
|
+
{
|
|
253
|
+
"sessionId": "visual-review",
|
|
254
|
+
"actions": [
|
|
255
|
+
{ "type": "move", "x": 640, "y": 360, "durationMs": 300 },
|
|
256
|
+
{ "type": "annotate", "selector": "role=button|Publish", "label": "Primary CTA" },
|
|
257
|
+
{ "type": "click", "selector": "role=button|Publish" },
|
|
258
|
+
{ "type": "clearAnnotations", "keepCursor": true }
|
|
259
|
+
]
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
To use an existing authenticated Chrome profile, start Chrome with a dedicated remote-debugging profile and attach using CDP. Do not expose the debugging port beyond localhost.
|
|
264
|
+
|
|
265
|
+
```bash
|
|
266
|
+
chrome --remote-debugging-port=9222 --user-data-dir=/tmp/pursr-chrome
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
```json
|
|
270
|
+
{
|
|
271
|
+
"url": "https://app.example.com",
|
|
272
|
+
"sessionId": "signed-in-review",
|
|
273
|
+
"mode": "cdp",
|
|
274
|
+
"cdpUrl": "http://127.0.0.1:9222",
|
|
275
|
+
"visual": true
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Pursr opens a new tab in Chrome's default context, preserving that profile's cookies and login state. Closing the Pursr session disconnects without terminating the owner browser.
|
|
232
280
|
|
|
233
281
|
### Exposed Resources
|
|
234
282
|
|
|
@@ -364,7 +412,8 @@ import {
|
|
|
364
412
|
saveBaseline, diffKey,
|
|
365
413
|
startHarCapture, stopHarCapture, writeHar,
|
|
366
414
|
loadAuthState,
|
|
367
|
-
PursrMCPServer, loadMcpConfig,
|
|
415
|
+
PursrMCPServer, loadMcpConfig, BrowserSessionManager,
|
|
416
|
+
installVisualOperator, moveVisualCursor, highlightVisualTarget,
|
|
368
417
|
validateSweepPlan,
|
|
369
418
|
listResources, readResource,
|
|
370
419
|
listViewports, resolveViewport, VIEWPORTS,
|
|
@@ -389,7 +438,9 @@ import { validateSweepPlan } from "pursr/sweep-schema";
|
|
|
389
438
|
import { startHarCapture, stopHarCapture } from "pursr/har";
|
|
390
439
|
import { saveAuthState, loadAuthState } from "pursr/auth";
|
|
391
440
|
import { listResources, readResource } from "pursr/mcp-resources";
|
|
392
|
-
import { PursrMCPServer } from "pursr/mcp";
|
|
441
|
+
import { PursrMCPServer } from "pursr/mcp";
|
|
442
|
+
import { BrowserSessionManager } from "pursr/session";
|
|
443
|
+
import { moveVisualCursor, highlightVisualTarget } from "pursr/visual-operator";
|
|
393
444
|
```
|
|
394
445
|
|
|
395
446
|
## Plugins
|
|
@@ -417,7 +468,9 @@ Plugins are auto-loaded from `plugins/` (built-in) or via `--plugin <path>`.
|
|
|
417
468
|
```
|
|
418
469
|
src/
|
|
419
470
|
index.js - public library entry
|
|
420
|
-
mcp.js - MCP stdio server
|
|
471
|
+
mcp.js - official MCP SDK stdio server
|
|
472
|
+
session.js - persistent headless, visible, and CDP sessions
|
|
473
|
+
visual-operator.js - rendered cursor and interaction feedback
|
|
421
474
|
shoot.js - runShoot (overlays + camera + frame-stable)
|
|
422
475
|
sweep.js - runSweep (validated, parallel pool)
|
|
423
476
|
diff.js - pixelmatch wrapper
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pursr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "pursr — Visual QA, audit, and MCP for the browser. One CLI + one MCP server for screenshots, sweeps, baselines, diffs, axe-core a11y audits, HAR capture, and auth state — with parallel sweep workers, auto-healing selectors, and a plugin system. Zero browser bundled: drives your system Chrome via Playwright.",
|
|
6
6
|
"homepage": "https://github.com/0xheycat/pursr",
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
"funding": "https://github.com/sponsors/0xheycat",
|
|
13
13
|
"type": "module",
|
|
14
14
|
"bin": {
|
|
15
|
-
"pursr": "
|
|
16
|
-
"pursr-mcp": "
|
|
15
|
+
"pursr": "bin/pursr.mjs",
|
|
16
|
+
"pursr-mcp": "bin/pursr-mcp.mjs"
|
|
17
17
|
},
|
|
18
18
|
"main": "./src/index.js",
|
|
19
19
|
"exports": {
|
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
"./snap": "./src/snap.js",
|
|
40
40
|
"./report": "./src/report.js",
|
|
41
41
|
"./ai-diff": "./src/ai-diff.js",
|
|
42
|
-
"./session": "./src/session.js"
|
|
42
|
+
"./session": "./src/session.js",
|
|
43
|
+
"./visual-operator": "./src/visual-operator.js"
|
|
43
44
|
},
|
|
44
45
|
"files": [
|
|
45
46
|
"bin",
|
|
@@ -77,10 +78,12 @@
|
|
|
77
78
|
],
|
|
78
79
|
"license": "MIT",
|
|
79
80
|
"dependencies": {
|
|
81
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
80
82
|
"axe-core": "^4.12.1",
|
|
81
83
|
"pdfkit": "^0.19.1",
|
|
82
84
|
"pixelmatch": "^5.3.0",
|
|
83
|
-
"pngjs": "^7.0.0"
|
|
85
|
+
"pngjs": "^7.0.0",
|
|
86
|
+
"zod": "^4.4.3"
|
|
84
87
|
},
|
|
85
88
|
"peerDependencies": {
|
|
86
89
|
"playwright-core": "*"
|
package/src/index.js
CHANGED
|
@@ -25,7 +25,7 @@ import { runClick, runType, runWait, runSeq } from "./interact.js";
|
|
|
25
25
|
import { listViewports, resolveViewport, VIEWPORTS } from "./viewport.js";
|
|
26
26
|
import { applyCamera, waitForStableFrame } from "./overlays.js";
|
|
27
27
|
import { loadPlugins, registerPlugin, listPlugins, getSweepOp, getViewportPreset, listViewportPresets, getFlagHelp } from "./plugin.js";
|
|
28
|
-
import { launch, newPage } from "./runway.js";
|
|
28
|
+
import { connectOverCDP, launch, newPage } from "./runway.js";
|
|
29
29
|
import { parseFlags, asNum, asBool, nowIso, shortHash, escapeHtml, renderSweepHtml, renderEveryViewportHtml, findStepPng, readArg, makeOut } from "./util.js";
|
|
30
30
|
import { resolveLocator, parseTextSelector } from "./selector.js";
|
|
31
31
|
import { captureDomSnapshot, captureDomSnapshotSidecar } from "./dom-snapshot.js";
|
|
@@ -45,6 +45,10 @@ import { runCheck } from "./check.js";
|
|
|
45
45
|
import { renderSweepPdf } from "./report.js";
|
|
46
46
|
import { aiDiffSummary, aiDiffSidecar } from "./ai-diff.js";
|
|
47
47
|
import { BrowserSessionManager } from "./session.js";
|
|
48
|
+
import {
|
|
49
|
+
installVisualOperator, moveVisualCursor, highlightVisualTarget,
|
|
50
|
+
markVisualClick, clearVisualAnnotations, visualPointForLocator,
|
|
51
|
+
} from "./visual-operator.js";
|
|
48
52
|
|
|
49
53
|
|
|
50
54
|
// Derive VERSION from package.json to prevent drift
|
|
@@ -64,7 +68,7 @@ export {
|
|
|
64
68
|
// plugin system
|
|
65
69
|
loadPlugins, registerPlugin, listPlugins, getSweepOp, getViewportPreset, listViewportPresets, getFlagHelp,
|
|
66
70
|
// low-level helpers (for plugin authors)
|
|
67
|
-
launch, newPage,
|
|
71
|
+
launch, connectOverCDP, newPage,
|
|
68
72
|
parseFlags, asNum, asBool, nowIso, shortHash, escapeHtml, renderSweepHtml, renderEveryViewportHtml, findStepPng, readArg, makeOut,
|
|
69
73
|
resolveLocator, parseTextSelector,
|
|
70
74
|
// v3: selector healing, CI output, MCP server
|
|
@@ -88,6 +92,8 @@ export {
|
|
|
88
92
|
renderSweepPdf,
|
|
89
93
|
aiDiffSummary, aiDiffSidecar,
|
|
90
94
|
BrowserSessionManager,
|
|
95
|
+
installVisualOperator, moveVisualCursor, highlightVisualTarget,
|
|
96
|
+
markVisualClick, clearVisualAnnotations, visualPointForLocator,
|
|
91
97
|
VERSION,
|
|
92
98
|
};
|
|
93
99
|
|
|
@@ -98,7 +104,7 @@ export default {
|
|
|
98
104
|
listViewports, resolveViewport, VIEWPORTS,
|
|
99
105
|
applyCamera, waitForStableFrame,
|
|
100
106
|
loadPlugins, registerPlugin, listPlugins, getSweepOp, getViewportPreset, listViewportPresets, getFlagHelp,
|
|
101
|
-
launch, newPage,
|
|
107
|
+
launch, connectOverCDP, newPage,
|
|
102
108
|
parseFlags, asNum, asBool, nowIso, shortHash, escapeHtml, renderSweepHtml, renderEveryViewportHtml, findStepPng, readArg, makeOut,
|
|
103
109
|
resolveLocator, parseTextSelector,
|
|
104
110
|
resolveHealedSelector, healStepAction,
|
|
@@ -110,5 +116,7 @@ export default {
|
|
|
110
116
|
// v6: PDF report, AI diff summary
|
|
111
117
|
runDiffWithAi, renderSweepPdf, aiDiffSummary, aiDiffSidecar,
|
|
112
118
|
BrowserSessionManager,
|
|
119
|
+
installVisualOperator, moveVisualCursor, highlightVisualTarget,
|
|
120
|
+
markVisualClick, clearVisualAnnotations, visualPointForLocator,
|
|
113
121
|
VERSION,
|
|
114
122
|
};
|
package/src/mcp.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
// pursr — MCP stdio server (Model Context Protocol).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// Claude Code, Cursor, Continue, and any other MCP host.
|
|
3
|
+
// Uses the official Model Context Protocol SDK over stdio and exposes every
|
|
4
|
+
// pursr capability to Claude Code, Cursor, Codex, and other MCP hosts.
|
|
6
5
|
//
|
|
7
6
|
// Config via PURSR_MCP_CONFIG env or ~/./mcp-config.json:
|
|
8
7
|
// { "plugins": ["./my-plugin.js"], "defaultOutDir": "./mcp-output" }
|
|
@@ -25,12 +24,20 @@ import { makeOut, nowIso } from "./util.js";
|
|
|
25
24
|
import { listResources, readResource, recordResource } from "./mcp-resources.js";
|
|
26
25
|
import { createRequire } from "node:module";
|
|
27
26
|
import { BrowserSessionManager } from "./session.js";
|
|
27
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
28
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
29
|
+
import {
|
|
30
|
+
CallToolRequestSchema,
|
|
31
|
+
ListResourcesRequestSchema,
|
|
32
|
+
ListToolsRequestSchema,
|
|
33
|
+
ReadResourceRequestSchema,
|
|
34
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
28
35
|
|
|
29
36
|
const __require = createRequire(import.meta.url);
|
|
30
37
|
let _pkg = { version: "0.1.0" };
|
|
31
38
|
try { _pkg = __require("../package.json"); } catch {}
|
|
32
39
|
|
|
33
|
-
const MCP_VERSION = "0.1.0";
|
|
40
|
+
const MCP_VERSION = _pkg.version || "0.1.0";
|
|
34
41
|
|
|
35
42
|
// ─── Config ──────────────────────────────────────────────────────────────
|
|
36
43
|
|
|
@@ -60,140 +67,68 @@ class McpError extends Error {
|
|
|
60
67
|
|
|
61
68
|
// ─── Server ──────────────────────────────────────────────────────────────
|
|
62
69
|
|
|
63
|
-
class PursrMCPServer {
|
|
70
|
+
class PursrMCPServer {
|
|
64
71
|
constructor(config = {}) {
|
|
65
|
-
this.config = config;
|
|
66
|
-
this._buffer = Buffer.alloc(0);
|
|
67
|
-
this._contentLength = -1;
|
|
68
|
-
this._initialized = false;
|
|
72
|
+
this.config = config;
|
|
69
73
|
this._verbose = !!config.verbose;
|
|
70
74
|
this.sessions = new BrowserSessionManager({ outputDir: config.defaultOutDir || process.cwd() });
|
|
71
|
-
|
|
75
|
+
this.sdk = new McpServer(
|
|
76
|
+
{ name: "pursr", version: MCP_VERSION },
|
|
77
|
+
{
|
|
78
|
+
capabilities: { tools: {}, resources: {} },
|
|
79
|
+
instructions: "Use a persistent pursr session for iterative visual work: open, snapshot, act, screenshot, inspect, diagnose, then close.",
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
this.server = this.sdk.server;
|
|
83
|
+
this.transport = null;
|
|
84
|
+
this._registerSdkHandlers();
|
|
85
|
+
}
|
|
72
86
|
|
|
73
87
|
log(...args) {
|
|
74
88
|
if (this._verbose) console.error("[pursr-mcp]", ...args);
|
|
75
89
|
}
|
|
76
90
|
|
|
77
|
-
async start() {
|
|
91
|
+
async start() {
|
|
78
92
|
if (this.config.plugins?.length) {
|
|
79
93
|
await loadPlugins(this.config.plugins);
|
|
80
94
|
}
|
|
81
|
-
this.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
this._buffer = Buffer.concat([this._buffer, chunk]);
|
|
85
|
-
this._processBuffer();
|
|
86
|
-
});
|
|
87
|
-
process.stdin.on("end", () => {
|
|
88
|
-
this.log("stdin closed");
|
|
95
|
+
this.transport = new StdioServerTransport();
|
|
96
|
+
this.transport.onclose = () => {
|
|
97
|
+
this.log("stdio transport closed");
|
|
89
98
|
this.sessions.closeAll().catch(() => {});
|
|
99
|
+
};
|
|
100
|
+
await this.sdk.connect(this.transport);
|
|
101
|
+
this.log("server started with official MCP SDK, plugins:", listPlugins());
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async close() {
|
|
105
|
+
await this.sessions.closeAll();
|
|
106
|
+
await this.sdk.close();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
_registerSdkHandlers() {
|
|
110
|
+
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: this._toolDefs() }));
|
|
111
|
+
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
112
|
+
try {
|
|
113
|
+
const content = await this._callTool(request.params.name, request.params.arguments || {});
|
|
114
|
+
return { content };
|
|
115
|
+
} catch (error) {
|
|
116
|
+
this.log("tool error:", error.stack || error.message);
|
|
117
|
+
return {
|
|
118
|
+
isError: true,
|
|
119
|
+
content: [{ type: "text", text: JSON.stringify({ error: error.message, code: error.code || -32603 }, null, 2) }],
|
|
120
|
+
};
|
|
121
|
+
}
|
|
90
122
|
});
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
while (true) {
|
|
101
|
-
if (this._contentLength < 0) {
|
|
102
|
-
const idx = this._buffer.indexOf(Buffer.from("\r\n\r\n"));
|
|
103
|
-
if (idx === -1) break;
|
|
104
|
-
const header = this._buffer.slice(0, idx).toString("utf8");
|
|
105
|
-
const m = header.match(/Content-Length:\s*(\d+)/i);
|
|
106
|
-
if (m) this._contentLength = parseInt(m[1], 10);
|
|
107
|
-
this._buffer = this._buffer.slice(idx + 4);
|
|
108
|
-
}
|
|
109
|
-
if (this._contentLength > 0 && this._buffer.length >= this._contentLength) {
|
|
110
|
-
const raw = this._buffer.slice(0, this._contentLength).toString("utf8");
|
|
111
|
-
this._buffer = this._buffer.slice(this._contentLength);
|
|
112
|
-
this._contentLength = -1;
|
|
113
|
-
try {
|
|
114
|
-
const msg = JSON.parse(raw);
|
|
115
|
-
this._handleMessage(msg);
|
|
116
|
-
} catch (e) {
|
|
117
|
-
console.error("[pursr-mcp] invalid JSON:", e.message);
|
|
118
|
-
}
|
|
119
|
-
} else break;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
_send(msg) {
|
|
124
|
-
const json = JSON.stringify(msg);
|
|
125
|
-
const bytes = Buffer.from(json, "utf8");
|
|
126
|
-
const header = `Content-Length: ${bytes.length}\r\n\r\n`;
|
|
127
|
-
process.stdout.write(header);
|
|
128
|
-
process.stdout.write(bytes);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// ── JSON-RPC dispatcher ─────────────────────────────────────────────
|
|
132
|
-
|
|
133
|
-
async _handleMessage(msg) {
|
|
134
|
-
if (!msg || msg.jsonrpc !== "2.0" || !msg.method) {
|
|
135
|
-
console.error("[pursr-mcp] skipping non-JSON-RPC message");
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
const { method, id } = msg;
|
|
139
|
-
|
|
140
|
-
// Notifications — no id → no response
|
|
141
|
-
if (method === "notifications/initialized" || method === "notifications/cancelled") {
|
|
142
|
-
if (method === "notifications/initialized") this._initialized = true;
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
if (id === undefined || id === null) return; // unnamed notification
|
|
146
|
-
|
|
147
|
-
try {
|
|
148
|
-
switch (method) {
|
|
149
|
-
case "initialize":
|
|
150
|
-
this._initialized = true;
|
|
151
|
-
this._send({
|
|
152
|
-
jsonrpc: "2.0", id,
|
|
153
|
-
result: {
|
|
154
|
-
protocolVersion: msg.params?.protocolVersion || "2024-11-05",
|
|
155
|
-
capabilities: { tools: {} },
|
|
156
|
-
serverInfo: { name: "pursr", version: MCP_VERSION },
|
|
157
|
-
},
|
|
158
|
-
});
|
|
159
|
-
break;
|
|
160
|
-
|
|
161
|
-
case "tools/list":
|
|
162
|
-
this._send({ jsonrpc: "2.0", id, result: { tools: this._toolDefs() } });
|
|
163
|
-
break;
|
|
164
|
-
|
|
165
|
-
case "resources/list":
|
|
166
|
-
this._send({ jsonrpc: "2.0", id, result: { resources: listResources().map(this._toMcpResource, this) } });
|
|
167
|
-
break;
|
|
168
|
-
|
|
169
|
-
case "resources/read":
|
|
170
|
-
if (!msg.params?.uri) throw new McpError(-32602, "Missing uri");
|
|
171
|
-
const data = readResource(msg.params.uri);
|
|
172
|
-
if (!data) throw new McpError(-32602, "Resource not found: " + msg.params.uri);
|
|
173
|
-
this._send({ jsonrpc: "2.0", id, result: { contents: [data] } });
|
|
174
|
-
break;
|
|
175
|
-
|
|
176
|
-
case "tools/call":
|
|
177
|
-
if (!msg.params?.name) throw new McpError(-32602, "Missing tool name");
|
|
178
|
-
const result = await this._callTool(msg.params.name, msg.params.arguments || {});
|
|
179
|
-
this._send({ jsonrpc: "2.0", id, result: { content: result } });
|
|
180
|
-
break;
|
|
181
|
-
|
|
182
|
-
default:
|
|
183
|
-
this._send({
|
|
184
|
-
jsonrpc: "2.0", id,
|
|
185
|
-
error: { code: -32601, message: `Unknown method: ${method}` },
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
} catch (e) {
|
|
189
|
-
if (e instanceof McpError) {
|
|
190
|
-
this._send({ jsonrpc: "2.0", id, error: { code: e.code, message: e.message } });
|
|
191
|
-
} else {
|
|
192
|
-
console.error("[pursr-mcp] handler error:", e.stack || e.message);
|
|
193
|
-
this._send({ jsonrpc: "2.0", id, error: { code: -32603, message: e.message } });
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
}
|
|
123
|
+
this.server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
124
|
+
resources: listResources().map(this._toMcpResource, this),
|
|
125
|
+
}));
|
|
126
|
+
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
|
|
127
|
+
const data = readResource(request.params.uri);
|
|
128
|
+
if (!data) throw new Error("Resource not found: " + request.params.uri);
|
|
129
|
+
return { contents: [data] };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
197
132
|
|
|
198
133
|
// ── Resource shape adapter ─────────────────────────────────────────
|
|
199
134
|
|
|
@@ -212,7 +147,7 @@ class PursrMCPServer {
|
|
|
212
147
|
return [
|
|
213
148
|
{
|
|
214
149
|
name: "pursr_session_open",
|
|
215
|
-
description: "Open a persistent browser tab
|
|
150
|
+
description: "Open a persistent browser tab in headless, visible, or CDP mode. Visual sessions render cursor movement and interaction feedback into screenshots.",
|
|
216
151
|
inputSchema: {
|
|
217
152
|
type: "object",
|
|
218
153
|
properties: {
|
|
@@ -220,6 +155,13 @@ class PursrMCPServer {
|
|
|
220
155
|
sessionId: { type: "string", description: "Stable session name; generated when omitted" },
|
|
221
156
|
preset: { type: "string", description: "Viewport preset" },
|
|
222
157
|
width: { type: "number" }, height: { type: "number" }, dpr: { type: "number" },
|
|
158
|
+
mode: { type: "string", enum: ["headless", "visible", "cdp"], description: "Browser mode (default headless)" },
|
|
159
|
+
visible: { type: "boolean", description: "Alias for mode=visible" },
|
|
160
|
+
visual: { type: "boolean", description: "Enable rendered cursor and interaction overlays" },
|
|
161
|
+
cdpUrl: { type: "string", description: "Chrome DevTools endpoint for mode=cdp, e.g. http://127.0.0.1:9222" },
|
|
162
|
+
slowMo: { type: "number", description: "Delay Playwright operations in milliseconds" },
|
|
163
|
+
operatorColor: { type: "string", description: "Visual Operator accent color" },
|
|
164
|
+
timeoutMs: { type: "number", description: "Navigation/CDP connection timeout" },
|
|
223
165
|
storageState: { description: "Playwright storageState object or file path" },
|
|
224
166
|
},
|
|
225
167
|
required: ["url"],
|
|
@@ -245,7 +187,7 @@ class PursrMCPServer {
|
|
|
245
187
|
},
|
|
246
188
|
{
|
|
247
189
|
name: "pursr_act",
|
|
248
|
-
description: "Perform ordered actions in a persistent session.
|
|
190
|
+
description: "Perform ordered actions in a persistent session. Supports click, hover, fill, type, check, select, press, scroll, wait, sleep, navigate, reload, eval, move, annotate, and clearAnnotations.",
|
|
249
191
|
inputSchema: {
|
|
250
192
|
type: "object",
|
|
251
193
|
properties: {
|
|
@@ -470,7 +412,11 @@ class PursrMCPServer {
|
|
|
470
412
|
|
|
471
413
|
async _sessionOpen(args) {
|
|
472
414
|
if (!args.url) throw new McpError(-32602, "Missing required: url");
|
|
473
|
-
const flags = {
|
|
415
|
+
const flags = {
|
|
416
|
+
preset: args.preset, width: args.width, height: args.height, dpr: args.dpr,
|
|
417
|
+
mode: args.mode, visible: args.visible, visual: args.visual, cdpUrl: args.cdpUrl,
|
|
418
|
+
slowMo: args.slowMo, operatorColor: args.operatorColor, timeoutMs: args.timeoutMs,
|
|
419
|
+
};
|
|
474
420
|
const result = await this.sessions.open({ sessionId: args.sessionId, url: args.url, flags, storageState: args.storageState });
|
|
475
421
|
return this._text(result);
|
|
476
422
|
}
|
package/src/runway.js
CHANGED
|
@@ -43,24 +43,36 @@ function findChrome() {
|
|
|
43
43
|
|
|
44
44
|
const BROWSER_ARGS = Object.freeze(["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"]);
|
|
45
45
|
|
|
46
|
-
export async function launch() {
|
|
47
|
-
const chromium = await getChromium();
|
|
48
|
-
const exec = findChrome();
|
|
49
|
-
if (!exec) throw new Error("system Chrome not found in standard paths");
|
|
50
|
-
return await chromium.launch({
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
46
|
+
export async function launch(options = {}) {
|
|
47
|
+
const chromium = await getChromium();
|
|
48
|
+
const exec = findChrome();
|
|
49
|
+
if (!exec) throw new Error("system Chrome not found in standard paths");
|
|
50
|
+
return await chromium.launch({
|
|
51
|
+
headless: options.headless !== false,
|
|
52
|
+
executablePath: options.executablePath || exec,
|
|
53
|
+
slowMo: Math.max(0, Number(options.slowMo) || 0),
|
|
54
|
+
args: [...BROWSER_ARGS, ...(Array.isArray(options.args) ? options.args : [])],
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function connectOverCDP(endpointURL, options = {}) {
|
|
59
|
+
if (!endpointURL || typeof endpointURL !== "string") throw new Error("cdpUrl is required for CDP mode");
|
|
60
|
+
const chromium = await getChromium();
|
|
61
|
+
return await chromium.connectOverCDP(endpointURL, { timeout: options.timeoutMs || 30_000 });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function newPage(browser, viewport, opts = {}) {
|
|
65
|
+
const ctx = opts.context || await browser.newContext({
|
|
66
|
+
viewport: { width: viewport.width, height: viewport.height },
|
|
67
|
+
deviceScaleFactor: viewport.dpr || 1,
|
|
68
|
+
reducedMotion: "no-preference",
|
|
69
|
+
colorScheme: "light",
|
|
70
|
+
hasTouch: !!(viewport.name && viewport.name.startsWith("mobile")),
|
|
71
|
+
isMobile: !!(viewport.name && viewport.name.startsWith("mobile")),
|
|
72
|
+
storageState: opts.storageState || undefined,
|
|
73
|
+
});
|
|
74
|
+
const page = await ctx.newPage();
|
|
75
|
+
if (opts.context) await page.setViewportSize({ width: viewport.width, height: viewport.height }).catch(() => {});
|
|
76
|
+
page._pursrContext = ctx;
|
|
77
|
+
return page;
|
|
78
|
+
}
|
package/src/session.js
CHANGED
|
@@ -2,10 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
import { mkdirSync, readFileSync } from "node:fs";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
-
import { launch, newPage } from "./runway.js";
|
|
5
|
+
import { connectOverCDP, launch, newPage } from "./runway.js";
|
|
6
6
|
import { resolveViewport } from "./viewport.js";
|
|
7
7
|
import { gotoOrThrow, settle, CLICK_TIMEOUT_MS, WAIT_DEFAULT_TIMEOUT_MS } from "./overlays.js";
|
|
8
8
|
import { resolveLocator } from "./selector.js";
|
|
9
|
+
import {
|
|
10
|
+
clearVisualAnnotations,
|
|
11
|
+
highlightVisualTarget,
|
|
12
|
+
installVisualOperator,
|
|
13
|
+
markVisualClick,
|
|
14
|
+
moveVisualCursor,
|
|
15
|
+
visualPointForLocator,
|
|
16
|
+
} from "./visual-operator.js";
|
|
9
17
|
|
|
10
18
|
const MAX_DIAGNOSTICS = 250;
|
|
11
19
|
const MAX_ACTIONS = 50;
|
|
@@ -37,8 +45,9 @@ function attachDiagnostics(page, diagnostics) {
|
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
export class BrowserSessionManager {
|
|
40
|
-
constructor({ launchBrowser = launch, outputDir = process.cwd() } = {}) {
|
|
48
|
+
constructor({ launchBrowser = launch, connectBrowser = connectOverCDP, outputDir = process.cwd() } = {}) {
|
|
41
49
|
this.launchBrowser = launchBrowser;
|
|
50
|
+
this.connectBrowser = connectBrowser;
|
|
42
51
|
this.outputDir = outputDir;
|
|
43
52
|
this.sessions = new Map();
|
|
44
53
|
}
|
|
@@ -52,24 +61,34 @@ export class BrowserSessionManager {
|
|
|
52
61
|
}
|
|
53
62
|
|
|
54
63
|
list() {
|
|
55
|
-
return [...this.sessions.values()].map(({ id, page, viewport, createdAt }) => ({ sessionId: id, url: page.url(), viewport, createdAt }));
|
|
64
|
+
return [...this.sessions.values()].map(({ id, page, viewport, mode, visual, createdAt }) => ({ sessionId: id, url: page.url(), viewport, mode, visual, createdAt }));
|
|
56
65
|
}
|
|
57
66
|
|
|
58
67
|
async open({ sessionId, url, flags = {}, storageState } = {}) {
|
|
59
68
|
if (!url) throw new Error("url is required");
|
|
60
69
|
const id = cleanId(sessionId);
|
|
61
70
|
if (this.sessions.has(id)) await this.close(id);
|
|
62
|
-
const
|
|
71
|
+
const mode = flags.mode || (flags.cdpUrl ? "cdp" : flags.visible ? "visible" : "headless");
|
|
72
|
+
if (!new Set(["headless", "visible", "cdp"]).has(mode)) throw new Error("mode must be headless, visible, or cdp");
|
|
73
|
+
const visual = flags.visual === true || mode === "visible";
|
|
74
|
+
const operatorOptions = { color: flags.operatorColor || "#ff2ea6" };
|
|
75
|
+
const browser = mode === "cdp"
|
|
76
|
+
? await this.connectBrowser(flags.cdpUrl, { timeoutMs: flags.timeoutMs })
|
|
77
|
+
: await this.launchBrowser({ headless: mode !== "visible", slowMo: flags.slowMo });
|
|
63
78
|
try {
|
|
64
79
|
const viewport = resolveViewport(flags);
|
|
65
|
-
const
|
|
80
|
+
const context = mode === "cdp" ? browser.contexts()[0] : null;
|
|
81
|
+
if (mode === "cdp" && !context) throw new Error("CDP browser has no default context");
|
|
82
|
+
const page = await newPage(browser, viewport, { storageState, context });
|
|
66
83
|
const diagnostics = { console: [], errors: [], requests: [], responses: [] };
|
|
67
84
|
attachDiagnostics(page, diagnostics);
|
|
85
|
+
if (visual) page.on("domcontentloaded", () => installVisualOperator(page, operatorOptions).catch(() => {}));
|
|
68
86
|
const nav = await gotoOrThrow(page, url, { timeoutMs: flags.timeoutMs });
|
|
69
87
|
await settle(page);
|
|
70
|
-
|
|
88
|
+
if (visual) await installVisualOperator(page, operatorOptions);
|
|
89
|
+
const session = { id, browser, page, context: page._pursrContext, viewport, mode, visual, operatorOptions, diagnostics, createdAt: new Date().toISOString() };
|
|
71
90
|
this.sessions.set(id, session);
|
|
72
|
-
return { sessionId: id, url: page.url(), title: await page.title(), viewport, status: nav.status, createdAt: session.createdAt };
|
|
91
|
+
return { sessionId: id, url: page.url(), title: await page.title(), viewport, mode, visual, status: nav.status, createdAt: session.createdAt };
|
|
73
92
|
} catch (error) {
|
|
74
93
|
try { await browser.close(); } catch {}
|
|
75
94
|
throw error;
|
|
@@ -131,7 +150,8 @@ export class BrowserSessionManager {
|
|
|
131
150
|
async act(sessionId, actions = []) {
|
|
132
151
|
if (!Array.isArray(actions) || !actions.length) throw new Error("actions must be a non-empty array");
|
|
133
152
|
if (actions.length > MAX_ACTIONS) throw new Error(`actions cannot exceed ${MAX_ACTIONS}`);
|
|
134
|
-
const
|
|
153
|
+
const session = this.get(sessionId);
|
|
154
|
+
const { page, visual, operatorOptions } = session;
|
|
135
155
|
const trace = [];
|
|
136
156
|
for (let i = 0; i < actions.length; i++) {
|
|
137
157
|
const action = actions[i] || {};
|
|
@@ -141,19 +161,47 @@ export class BrowserSessionManager {
|
|
|
141
161
|
if (["click", "hover", "fill", "type", "check", "select"].includes(op)) {
|
|
142
162
|
const locator = await resolveLocator(page, action.selector);
|
|
143
163
|
await locator.first().waitFor({ state: "visible", timeout: action.timeoutMs || CLICK_TIMEOUT_MS });
|
|
164
|
+
let point = null;
|
|
165
|
+
if (visual) {
|
|
166
|
+
point = await visualPointForLocator(locator.first());
|
|
167
|
+
await moveVisualCursor(page, point.x, point.y, { ...operatorOptions, durationMs: action.durationMs });
|
|
168
|
+
await highlightVisualTarget(page, point.rect, { ...operatorOptions, color: action.color, label: action.label || `${op}: ${action.selector}` });
|
|
169
|
+
step.cursor = { x: Math.round(point.x), y: Math.round(point.y) };
|
|
170
|
+
}
|
|
144
171
|
if (op === "click") await locator.first().click();
|
|
145
172
|
else if (op === "hover") await locator.first().hover();
|
|
146
173
|
else if (op === "fill") await locator.first().fill(String(action.text ?? action.value ?? ""));
|
|
147
174
|
else if (op === "type") await locator.first().pressSequentially(String(action.text ?? ""), { delay: action.delayMs || 10 });
|
|
148
175
|
else if (op === "check") await locator.first().setChecked(action.checked !== false);
|
|
149
176
|
else await locator.first().selectOption(action.value);
|
|
177
|
+
if (visual && op === "click" && point) await markVisualClick(page, point.x, point.y, { ...operatorOptions, color: action.color });
|
|
150
178
|
step.selector = action.selector;
|
|
151
179
|
} else if (op === "press") await page.keyboard.press(String(action.key));
|
|
152
180
|
else if (op === "scroll") await page.mouse.wheel(Number(action.deltaX) || 0, Number(action.deltaY) || 0);
|
|
153
181
|
else if (op === "wait") await (await resolveLocator(page, action.selector)).first().waitFor({ state: action.state || "visible", timeout: action.timeoutMs || WAIT_DEFAULT_TIMEOUT_MS });
|
|
154
182
|
else if (op === "sleep") await page.waitForTimeout(Math.max(0, Number(action.ms) || 0));
|
|
155
|
-
else if (op === "navigate")
|
|
156
|
-
|
|
183
|
+
else if (op === "navigate") {
|
|
184
|
+
await gotoOrThrow(page, action.url, { timeoutMs: action.timeoutMs });
|
|
185
|
+
if (visual) await installVisualOperator(page, operatorOptions);
|
|
186
|
+
} else if (op === "reload") {
|
|
187
|
+
await page.reload({ waitUntil: "domcontentloaded" });
|
|
188
|
+
if (visual) await installVisualOperator(page, operatorOptions);
|
|
189
|
+
} else if (op === "move") {
|
|
190
|
+
if (!visual) throw new Error("move requires a visual session");
|
|
191
|
+
step.cursor = await moveVisualCursor(page, action.x, action.y, { ...operatorOptions, durationMs: action.durationMs });
|
|
192
|
+
} else if (op === "annotate") {
|
|
193
|
+
if (!visual) throw new Error("annotate requires a visual session");
|
|
194
|
+
const locator = await resolveLocator(page, action.selector);
|
|
195
|
+
await locator.first().waitFor({ state: "visible", timeout: action.timeoutMs || CLICK_TIMEOUT_MS });
|
|
196
|
+
const point = await visualPointForLocator(locator.first());
|
|
197
|
+
await moveVisualCursor(page, point.x, point.y, { ...operatorOptions, durationMs: action.durationMs });
|
|
198
|
+
await highlightVisualTarget(page, point.rect, { ...operatorOptions, color: action.color, label: action.label || action.selector });
|
|
199
|
+
step.selector = action.selector;
|
|
200
|
+
step.cursor = { x: Math.round(point.x), y: Math.round(point.y) };
|
|
201
|
+
} else if (op === "clearAnnotations") {
|
|
202
|
+
if (!visual) throw new Error("clearAnnotations requires a visual session");
|
|
203
|
+
await clearVisualAnnotations(page, { keepCursor: action.keepCursor !== false });
|
|
204
|
+
}
|
|
157
205
|
else if (op === "eval") step.result = await page.evaluate(String(action.js || ""));
|
|
158
206
|
else throw new Error(`unknown action type: ${op}`);
|
|
159
207
|
if (action.settleMs) await page.waitForTimeout(Number(action.settleMs));
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Visible cursor and interaction feedback for agent-driven browser sessions.
|
|
2
|
+
|
|
3
|
+
const DEFAULT_COLOR = "#ff2ea6";
|
|
4
|
+
|
|
5
|
+
function safeColor(value) {
|
|
6
|
+
const color = String(value || DEFAULT_COLOR).trim();
|
|
7
|
+
if (/^#[0-9a-f]{3,8}$/i.test(color)) return color;
|
|
8
|
+
if (/^(rgb|hsl)a?\([\d\s.,%+-]+\)$/i.test(color)) return color;
|
|
9
|
+
if (/^[a-z]{1,24}$/i.test(color)) return color;
|
|
10
|
+
return DEFAULT_COLOR;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function installVisualOperator(page, options = {}) {
|
|
14
|
+
const color = safeColor(options.color);
|
|
15
|
+
await page.evaluate(({ color }) => {
|
|
16
|
+
if (document.getElementById("__pursr_operator_style__")) return;
|
|
17
|
+
const style = document.createElement("style");
|
|
18
|
+
style.id = "__pursr_operator_style__";
|
|
19
|
+
style.textContent = `
|
|
20
|
+
#__pursr_cursor__ { position: fixed; left: 0; top: 0; width: 28px; height: 34px;
|
|
21
|
+
pointer-events: none; z-index: 2147483647; transform: translate(24px, 24px);
|
|
22
|
+
filter: drop-shadow(0 2px 2px rgba(0,0,0,.55)); transition: none; }
|
|
23
|
+
#__pursr_cursor__ svg { display: block; width: 100%; height: 100%; }
|
|
24
|
+
.__pursr_target__ { position: fixed; pointer-events: none; z-index: 2147483645;
|
|
25
|
+
border: 3px solid var(--pursr-color); border-radius: 7px;
|
|
26
|
+
box-shadow: 0 0 0 2px rgba(255,255,255,.92), 0 0 18px var(--pursr-color); }
|
|
27
|
+
.__pursr_label__ { position: absolute; left: -3px; bottom: calc(100% + 7px);
|
|
28
|
+
padding: 3px 7px; border-radius: 4px; background: var(--pursr-color); color: white;
|
|
29
|
+
font: 700 12px/1.3 ui-monospace, SFMono-Regular, Consolas, monospace;
|
|
30
|
+
white-space: nowrap; text-shadow: 0 1px 1px rgba(0,0,0,.35); }
|
|
31
|
+
.__pursr_click__ { position: fixed; width: 28px; height: 28px; margin: -14px 0 0 -14px;
|
|
32
|
+
pointer-events: none; z-index: 2147483646; border: 4px solid var(--pursr-color);
|
|
33
|
+
border-radius: 50%; box-shadow: 0 0 0 3px rgba(255,255,255,.9), 0 0 20px var(--pursr-color); }
|
|
34
|
+
`;
|
|
35
|
+
document.documentElement.appendChild(style);
|
|
36
|
+
const cursor = document.createElement("div");
|
|
37
|
+
cursor.id = "__pursr_cursor__";
|
|
38
|
+
cursor.dataset.x = "24";
|
|
39
|
+
cursor.dataset.y = "24";
|
|
40
|
+
cursor.style.setProperty("--pursr-color", color);
|
|
41
|
+
cursor.innerHTML = `<svg viewBox="0 0 28 34" aria-hidden="true"><path d="M3 2.5V27l6.8-6.2 4.7 10.2 5.2-2.5-4.7-9.8 9.4-.2z" fill="${color}" stroke="#fff" stroke-width="2.4" stroke-linejoin="round"/><path d="M3 2.5V27l6.8-6.2 4.7 10.2 5.2-2.5-4.7-9.8 9.4-.2z" fill="none" stroke="#16131a" stroke-width="1" stroke-linejoin="round"/></svg>`;
|
|
42
|
+
document.documentElement.appendChild(cursor);
|
|
43
|
+
}, { color });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function moveVisualCursor(page, x, y, options = {}) {
|
|
47
|
+
await installVisualOperator(page, options);
|
|
48
|
+
const durationMs = Math.max(0, Math.min(3000, Number(options.durationMs) || 220));
|
|
49
|
+
const point = { x: Math.round(Number(x) || 0), y: Math.round(Number(y) || 0) };
|
|
50
|
+
await page.evaluate(async ({ point, durationMs }) => {
|
|
51
|
+
const cursor = document.getElementById("__pursr_cursor__");
|
|
52
|
+
if (!cursor) return;
|
|
53
|
+
const startX = Number(cursor.dataset.x) || 0;
|
|
54
|
+
const startY = Number(cursor.dataset.y) || 0;
|
|
55
|
+
const started = performance.now();
|
|
56
|
+
await new Promise((resolve) => {
|
|
57
|
+
const frame = (now) => {
|
|
58
|
+
const progress = durationMs ? Math.min(1, (now - started) / durationMs) : 1;
|
|
59
|
+
const eased = 1 - Math.pow(1 - progress, 3);
|
|
60
|
+
const nextX = startX + (point.x - startX) * eased;
|
|
61
|
+
const nextY = startY + (point.y - startY) * eased;
|
|
62
|
+
cursor.style.transform = `translate(${nextX}px, ${nextY}px)`;
|
|
63
|
+
if (progress < 1) requestAnimationFrame(frame);
|
|
64
|
+
else resolve();
|
|
65
|
+
};
|
|
66
|
+
requestAnimationFrame(frame);
|
|
67
|
+
});
|
|
68
|
+
cursor.dataset.x = String(point.x);
|
|
69
|
+
cursor.dataset.y = String(point.y);
|
|
70
|
+
}, { point, durationMs });
|
|
71
|
+
await page.mouse.move(point.x, point.y, { steps: Math.max(1, Math.min(20, Math.ceil(durationMs / 20))) });
|
|
72
|
+
return point;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function highlightVisualTarget(page, rect, options = {}) {
|
|
76
|
+
await installVisualOperator(page, options);
|
|
77
|
+
const color = safeColor(options.color);
|
|
78
|
+
const label = String(options.label || "target").slice(0, 80);
|
|
79
|
+
await page.evaluate(({ rect, color, label }) => {
|
|
80
|
+
document.querySelectorAll(".__pursr_target__").forEach((node) => node.remove());
|
|
81
|
+
const target = document.createElement("div");
|
|
82
|
+
target.className = "__pursr_target__";
|
|
83
|
+
target.style.setProperty("--pursr-color", color);
|
|
84
|
+
target.style.left = `${Math.round(rect.x)}px`;
|
|
85
|
+
target.style.top = `${Math.round(rect.y)}px`;
|
|
86
|
+
target.style.width = `${Math.max(0, Math.round(rect.width))}px`;
|
|
87
|
+
target.style.height = `${Math.max(0, Math.round(rect.height))}px`;
|
|
88
|
+
const tag = document.createElement("span");
|
|
89
|
+
tag.className = "__pursr_label__";
|
|
90
|
+
tag.textContent = label;
|
|
91
|
+
target.appendChild(tag);
|
|
92
|
+
document.documentElement.appendChild(target);
|
|
93
|
+
}, { rect, color, label });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function markVisualClick(page, x, y, options = {}) {
|
|
97
|
+
await installVisualOperator(page, options);
|
|
98
|
+
const color = safeColor(options.color);
|
|
99
|
+
await page.evaluate(({ x, y, color }) => {
|
|
100
|
+
document.querySelectorAll(".__pursr_click__").forEach((node) => node.remove());
|
|
101
|
+
const marker = document.createElement("div");
|
|
102
|
+
marker.className = "__pursr_click__";
|
|
103
|
+
marker.style.setProperty("--pursr-color", color);
|
|
104
|
+
marker.style.left = `${Math.round(x)}px`;
|
|
105
|
+
marker.style.top = `${Math.round(y)}px`;
|
|
106
|
+
document.documentElement.appendChild(marker);
|
|
107
|
+
}, { x, y, color });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function clearVisualAnnotations(page, { keepCursor = true } = {}) {
|
|
111
|
+
await page.evaluate(({ keepCursor }) => {
|
|
112
|
+
document.querySelectorAll(".__pursr_target__, .__pursr_click__").forEach((node) => node.remove());
|
|
113
|
+
if (!keepCursor) {
|
|
114
|
+
document.getElementById("__pursr_cursor__")?.remove();
|
|
115
|
+
document.getElementById("__pursr_operator_style__")?.remove();
|
|
116
|
+
}
|
|
117
|
+
}, { keepCursor });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function visualPointForLocator(locator) {
|
|
121
|
+
const rect = await locator.boundingBox();
|
|
122
|
+
if (!rect) throw new Error("target has no visible bounding box");
|
|
123
|
+
return { rect, x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
|
|
124
|
+
}
|