pursr 0.8.1 → 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 +60 -7
- package/package.json +3 -2
- package/src/index.js +11 -3
- package/src/mcp.js +14 -3
- package/src/runway.js +33 -21
- package/src/session.js +58 -10
- package/src/visual-operator.js +124 -0
package/README.md
CHANGED
|
@@ -34,7 +34,8 @@ Most teams need **five separate tools** to do visual QA: a screenshot CLI, a reg
|
|
|
34
34
|
|
|
35
35
|
- **A unified CLI** (`pursr`) for every capture, diff, sweep, and audit.
|
|
36
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
|
-
- **
|
|
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` |
|
|
@@ -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",
|
|
@@ -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",
|
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
|
@@ -147,7 +147,7 @@ class PursrMCPServer {
|
|
|
147
147
|
return [
|
|
148
148
|
{
|
|
149
149
|
name: "pursr_session_open",
|
|
150
|
-
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.",
|
|
151
151
|
inputSchema: {
|
|
152
152
|
type: "object",
|
|
153
153
|
properties: {
|
|
@@ -155,6 +155,13 @@ class PursrMCPServer {
|
|
|
155
155
|
sessionId: { type: "string", description: "Stable session name; generated when omitted" },
|
|
156
156
|
preset: { type: "string", description: "Viewport preset" },
|
|
157
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" },
|
|
158
165
|
storageState: { description: "Playwright storageState object or file path" },
|
|
159
166
|
},
|
|
160
167
|
required: ["url"],
|
|
@@ -180,7 +187,7 @@ class PursrMCPServer {
|
|
|
180
187
|
},
|
|
181
188
|
{
|
|
182
189
|
name: "pursr_act",
|
|
183
|
-
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.",
|
|
184
191
|
inputSchema: {
|
|
185
192
|
type: "object",
|
|
186
193
|
properties: {
|
|
@@ -405,7 +412,11 @@ class PursrMCPServer {
|
|
|
405
412
|
|
|
406
413
|
async _sessionOpen(args) {
|
|
407
414
|
if (!args.url) throw new McpError(-32602, "Missing required: url");
|
|
408
|
-
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
|
+
};
|
|
409
420
|
const result = await this.sessions.open({ sessionId: args.sessionId, url: args.url, flags, storageState: args.storageState });
|
|
410
421
|
return this._text(result);
|
|
411
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
|
+
}
|