pursr 0.9.0 → 0.10.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 +45 -3
- package/bin/pursr.mjs +40 -4
- package/package.json +2 -1
- package/plans/operator-tutorial.json +16 -0
- package/src/index.js +3 -0
- package/src/mcp.js +3 -1
- package/src/operator.js +61 -0
- package/src/runway.js +4 -0
- package/src/session.js +48 -5
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ Most teams need **five separate tools** to do visual QA: a screenshot CLI, a reg
|
|
|
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
|
|
38
|
+
- **A library API** with 25 subpath modules, so you can embed the browser and QA primitives in your own tooling.
|
|
39
39
|
- **A plugin system** for custom viewports, sweep ops, and capture hooks.
|
|
40
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.
|
|
41
41
|
- **Zero browser bundled** - drives your system Chrome via Playwright. No 200 MB Chromium download.
|
|
@@ -81,7 +81,7 @@ pursr sweep ./plan.json # see plans/ for an example
|
|
|
81
81
|
| Layered states | entity / terrain / hud / ui isolation | `--layer entity` |
|
|
82
82
|
| Animation freeze | pause CSS/JS animations for stable frames | `--no-animation` |
|
|
83
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 |
|
|
84
|
+
| Visual Operator | rendered cursor, target labels, click markers, WebM recording, headed and CDP sessions | `operator` CLI + MCP session tools |
|
|
85
85
|
| Grid overlay | spacing guides, custom color + tile size | `--grid --grid-tile 64` |
|
|
86
86
|
| Camera control | zoom + pan via mouse wheel/drag | `--zoom 1.5 --panX 200` |
|
|
87
87
|
| Frame timeline | N captures at intervalMs for animations | `pursr frames <url> 8 200` |
|
|
@@ -161,7 +161,8 @@ pursr validate ./plan.json
|
|
|
161
161
|
| `probe` | Health check (HTTP status, page title) |
|
|
162
162
|
| `shot` / `full` | Viewport / full-page screenshot |
|
|
163
163
|
| `eval` | Execute JS in the page, return result |
|
|
164
|
-
| `click` / `type` / `wait` / `seq` | Interaction primitives |
|
|
164
|
+
| `click` / `type` / `wait` / `seq` | Interaction primitives |
|
|
165
|
+
| `operator` | Run a visible action plan with cursor feedback, screenshot, trace, diagnostics, and optional WebM video |
|
|
165
166
|
| `diff` | Pixel-level diff vs a reference PNG |
|
|
166
167
|
| `viewports` | List all registered viewport presets |
|
|
167
168
|
| `shoot` | Rich capture (overlays, freeze, camera, plugins) |
|
|
@@ -236,6 +237,45 @@ Example action arguments:
|
|
|
236
237
|
|
|
237
238
|
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
|
|
|
240
|
+
#### CLI: scripted tutorials and repeatable recordings
|
|
241
|
+
|
|
242
|
+
Use the CLI when the steps are already known. It needs no MCP host and produces a final screenshot, JSON trace, diagnostics, and an optional WebM recording.
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
pursr operator http://localhost:3000 @plans/operator-tutorial.json \
|
|
246
|
+
--visible \
|
|
247
|
+
--start-delay 3000 \
|
|
248
|
+
--slow-mo 100 \
|
|
249
|
+
--video ./recordings \
|
|
250
|
+
--out ./recordings/final.png
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
The action plan is a JSON array. The same action objects work through `pursr_act` in MCP:
|
|
254
|
+
|
|
255
|
+
```json
|
|
256
|
+
[
|
|
257
|
+
{ "type": "annotate", "selector": "role=button|Build", "label": "Open build menu" },
|
|
258
|
+
{ "type": "click", "selector": "role=button|Build", "durationMs": 350, "settleMs": 500 },
|
|
259
|
+
{ "type": "click", "x": 640, "y": 420, "durationMs": 250 },
|
|
260
|
+
{ "type": "drag", "fromX": 520, "fromY": 400, "toX": 760, "toY": 520, "steps": 30 },
|
|
261
|
+
{ "type": "keyDown", "key": "Shift" },
|
|
262
|
+
{ "type": "keyUp", "key": "Shift" },
|
|
263
|
+
{ "type": "press", "key": "Escape" },
|
|
264
|
+
{ "type": "sleep", "ms": 800 },
|
|
265
|
+
{ "type": "clearAnnotations", "keepCursor": true }
|
|
266
|
+
]
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Chrome records the browser viewport as silent WebM video. Add narration or system audio in your editor, and convert to MP4 when needed:
|
|
270
|
+
|
|
271
|
+
```bash
|
|
272
|
+
ffmpeg -i recording.webm -c:v libx264 -pix_fmt yuv420p tutorial.mp4
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
#### MCP: adaptive agent operation
|
|
276
|
+
|
|
277
|
+
Use MCP when the agent must inspect the current page, decide the next action, verify visual results, or pause for human approval. MCP is not required for CLI recording. Both interfaces use the same session and Visual Operator engine.
|
|
278
|
+
|
|
239
279
|
```json
|
|
240
280
|
{
|
|
241
281
|
"url": "http://localhost:3000",
|
|
@@ -246,6 +286,8 @@ Set `visual: true` to render the agent cursor and interaction feedback into scre
|
|
|
246
286
|
}
|
|
247
287
|
```
|
|
248
288
|
|
|
289
|
+
Add `recordVideoDir` to record an MCP session in headless or visible mode. The final video path is returned by `pursr_session_close`. CDP sessions preserve an existing browser profile but cannot record video because Chrome owns that context.
|
|
290
|
+
|
|
249
291
|
Visual actions use the regular `pursr_act` tool:
|
|
250
292
|
|
|
251
293
|
```json
|
package/bin/pursr.mjs
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// pursr CLI. Thin wrapper around src/* that mirrors the npm bin.
|
|
3
3
|
|
|
4
4
|
import { VERSION } from "../src/index.js";
|
|
5
|
-
import { runClick, runType, runWait, runSeq } from "../src/interact.js";
|
|
5
|
+
import { runClick, runType, runWait, runSeq } from "../src/interact.js";
|
|
6
|
+
import { runOperator } from "../src/operator.js";
|
|
6
7
|
import { runEval } from "../src/eval.js";
|
|
7
8
|
import { runProbe } from "../src/probe.js";
|
|
8
9
|
import { runShot } from "../src/shot.js";
|
|
@@ -24,7 +25,8 @@ import { loadPlugins, listPlugins, getFlagHelp } from "../src/plugin.js";
|
|
|
24
25
|
|
|
25
26
|
const USAGE = `usage:
|
|
26
27
|
v1: pursr {probe|shot|full|eval|click|type|wait|diff|seq} <url> [...]
|
|
27
|
-
v2: pursr {viewports|shoot|layer|frames|hover|sweep} <...>
|
|
28
|
+
v2: pursr {viewports|shoot|layer|frames|hover|sweep} <...>
|
|
29
|
+
operator: pursr operator <url> <actions.json|@file> [--visible] [--start-delay 3000] [--video <dir>] [--out <final.png>]
|
|
28
30
|
flags: --preset <name> --width N --height N --dpr N
|
|
29
31
|
--zoom 1.5 --panX 200 --panY -100
|
|
30
32
|
--cursor pointer|grab|grabbing|crosshair|none
|
|
@@ -90,7 +92,41 @@ await loadPlugins(pluginPaths);
|
|
|
90
92
|
: await runDiff(url, ref, out, threshold, flags);
|
|
91
93
|
console.log(JSON.stringify(r, null, 2)); break;
|
|
92
94
|
}
|
|
93
|
-
case "seq": { if (!url) die("missing url"); const actions = readArg(b); if (!actions) die("seq: missing <actions.json> (or @file)"); const out = c || makeOut("seq.png"); const r = await runSeq(url, actions, out); console.log(JSON.stringify(r, null, 2)); break; }
|
|
95
|
+
case "seq": { if (!url) die("missing url"); const actions = readArg(b); if (!actions) die("seq: missing <actions.json> (or @file)"); const out = c || makeOut("seq.png"); const r = await runSeq(url, actions, out); console.log(JSON.stringify(r, null, 2)); break; }
|
|
96
|
+
case "operator": {
|
|
97
|
+
if (!url) die("operator: missing <url>");
|
|
98
|
+
const actions = readArg(b); if (!actions) die("operator: missing <actions.json> (or @file)");
|
|
99
|
+
const flags = parseFlags(argv.slice(5));
|
|
100
|
+
const out = flags.out || makeOut("operator.png");
|
|
101
|
+
const videoValue = flags.video ?? flags["record-video"];
|
|
102
|
+
const recordVideoDir = videoValue
|
|
103
|
+
? (videoValue === true ? dirname(out) : String(videoValue))
|
|
104
|
+
: null;
|
|
105
|
+
const r = await runOperator({
|
|
106
|
+
url,
|
|
107
|
+
actions,
|
|
108
|
+
out,
|
|
109
|
+
outputDir: dirname(out),
|
|
110
|
+
sessionId: flags.session || undefined,
|
|
111
|
+
flags: {
|
|
112
|
+
mode: flags.mode || (flags.cdp ? "cdp" : flags.visible ? "visible" : "headless"),
|
|
113
|
+
visual: !flags["no-visual"],
|
|
114
|
+
cdpUrl: flags.cdp || flags["cdp-url"],
|
|
115
|
+
slowMo: asNum(flags["slow-mo"] ?? flags.slowMo, 0),
|
|
116
|
+
startDelayMs: asNum(flags["start-delay"] ?? flags.startDelayMs, 0),
|
|
117
|
+
operatorColor: flags["operator-color"] || flags.operatorColor,
|
|
118
|
+
recordVideoDir,
|
|
119
|
+
width: flags.width,
|
|
120
|
+
height: flags.height,
|
|
121
|
+
dpr: flags.dpr,
|
|
122
|
+
preset: flags.preset,
|
|
123
|
+
full: !!flags.full,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
console.log(JSON.stringify(r, null, 2));
|
|
127
|
+
if (!r.ok) process.exitCode = 1;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
94
130
|
case "viewports": { console.log(JSON.stringify(listViewports(), null, 2)); break; }
|
|
95
131
|
case "shoot": {
|
|
96
132
|
if (!url) die("missing url");
|
|
@@ -342,4 +378,4 @@ await loadPlugins(pluginPaths);
|
|
|
342
378
|
console.error(JSON.stringify({ error: e.message, stack: e.stack?.split("\n").slice(0, 3).join("\n") }, null, 2));
|
|
343
379
|
process.exit(1);
|
|
344
380
|
}
|
|
345
|
-
})();
|
|
381
|
+
})();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pursr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"./report": "./src/report.js",
|
|
41
41
|
"./ai-diff": "./src/ai-diff.js",
|
|
42
42
|
"./session": "./src/session.js",
|
|
43
|
+
"./operator": "./src/operator.js",
|
|
43
44
|
"./visual-operator": "./src/visual-operator.js"
|
|
44
45
|
},
|
|
45
46
|
"files": [
|
package/src/index.js
CHANGED
|
@@ -45,6 +45,7 @@ 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 { runOperator } from "./operator.js";
|
|
48
49
|
import {
|
|
49
50
|
installVisualOperator, moveVisualCursor, highlightVisualTarget,
|
|
50
51
|
markVisualClick, clearVisualAnnotations, visualPointForLocator,
|
|
@@ -92,6 +93,7 @@ export {
|
|
|
92
93
|
renderSweepPdf,
|
|
93
94
|
aiDiffSummary, aiDiffSidecar,
|
|
94
95
|
BrowserSessionManager,
|
|
96
|
+
runOperator,
|
|
95
97
|
installVisualOperator, moveVisualCursor, highlightVisualTarget,
|
|
96
98
|
markVisualClick, clearVisualAnnotations, visualPointForLocator,
|
|
97
99
|
VERSION,
|
|
@@ -116,6 +118,7 @@ export default {
|
|
|
116
118
|
// v6: PDF report, AI diff summary
|
|
117
119
|
runDiffWithAi, renderSweepPdf, aiDiffSummary, aiDiffSidecar,
|
|
118
120
|
BrowserSessionManager,
|
|
121
|
+
runOperator,
|
|
119
122
|
installVisualOperator, moveVisualCursor, highlightVisualTarget,
|
|
120
123
|
markVisualClick, clearVisualAnnotations, visualPointForLocator,
|
|
121
124
|
VERSION,
|
package/src/mcp.js
CHANGED
|
@@ -161,6 +161,7 @@ class PursrMCPServer {
|
|
|
161
161
|
cdpUrl: { type: "string", description: "Chrome DevTools endpoint for mode=cdp, e.g. http://127.0.0.1:9222" },
|
|
162
162
|
slowMo: { type: "number", description: "Delay Playwright operations in milliseconds" },
|
|
163
163
|
operatorColor: { type: "string", description: "Visual Operator accent color" },
|
|
164
|
+
recordVideoDir: { type: "string", description: "Directory for a WebM recording (headless or visible mode only)" },
|
|
164
165
|
timeoutMs: { type: "number", description: "Navigation/CDP connection timeout" },
|
|
165
166
|
storageState: { description: "Playwright storageState object or file path" },
|
|
166
167
|
},
|
|
@@ -187,7 +188,7 @@ class PursrMCPServer {
|
|
|
187
188
|
},
|
|
188
189
|
{
|
|
189
190
|
name: "pursr_act",
|
|
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.",
|
|
191
|
+
description: "Perform ordered actions in a persistent session. Supports selector or coordinate click/doubleClick, drag, hover, fill, type, check, select, press, keyDown, keyUp, scroll, wait, sleep, navigate, reload, eval, move, annotate, and clearAnnotations.",
|
|
191
192
|
inputSchema: {
|
|
192
193
|
type: "object",
|
|
193
194
|
properties: {
|
|
@@ -416,6 +417,7 @@ class PursrMCPServer {
|
|
|
416
417
|
preset: args.preset, width: args.width, height: args.height, dpr: args.dpr,
|
|
417
418
|
mode: args.mode, visible: args.visible, visual: args.visual, cdpUrl: args.cdpUrl,
|
|
418
419
|
slowMo: args.slowMo, operatorColor: args.operatorColor, timeoutMs: args.timeoutMs,
|
|
420
|
+
recordVideoDir: args.recordVideoDir,
|
|
419
421
|
};
|
|
420
422
|
const result = await this.sessions.open({ sessionId: args.sessionId, url: args.url, flags, storageState: args.storageState });
|
|
421
423
|
return this._text(result);
|
package/src/operator.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// One-shot Visual Operator workflow for CLI and library consumers.
|
|
2
|
+
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { mkdirSync } from "node:fs";
|
|
5
|
+
import { BrowserSessionManager } from "./session.js";
|
|
6
|
+
|
|
7
|
+
function normalizeActions(actions) {
|
|
8
|
+
if (typeof actions === "string") actions = JSON.parse(actions);
|
|
9
|
+
if (!Array.isArray(actions) || !actions.length) throw new Error("operator actions must be a non-empty JSON array");
|
|
10
|
+
return actions;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function runOperator({
|
|
14
|
+
url,
|
|
15
|
+
actions,
|
|
16
|
+
out,
|
|
17
|
+
outputDir = process.cwd(),
|
|
18
|
+
sessionId = `operator-${Date.now().toString(36)}`,
|
|
19
|
+
flags = {},
|
|
20
|
+
} = {}) {
|
|
21
|
+
if (!url) throw new Error("operator url is required");
|
|
22
|
+
const steps = normalizeActions(actions);
|
|
23
|
+
const screenshotOut = resolve(out || join(outputDir, `${sessionId}.png`));
|
|
24
|
+
mkdirSync(dirname(screenshotOut), { recursive: true });
|
|
25
|
+
|
|
26
|
+
const manager = new BrowserSessionManager({ outputDir });
|
|
27
|
+
let opened = null;
|
|
28
|
+
let acted = null;
|
|
29
|
+
let shot = null;
|
|
30
|
+
let diagnostics = null;
|
|
31
|
+
let closed = null;
|
|
32
|
+
try {
|
|
33
|
+
opened = await manager.open({
|
|
34
|
+
sessionId,
|
|
35
|
+
url,
|
|
36
|
+
storageState: flags.storageState,
|
|
37
|
+
flags: { ...flags, visual: flags.visual !== false },
|
|
38
|
+
});
|
|
39
|
+
if (Number(flags.startDelayMs) > 0) {
|
|
40
|
+
await manager.get(sessionId).page.waitForTimeout(Number(flags.startDelayMs));
|
|
41
|
+
}
|
|
42
|
+
acted = await manager.act(sessionId, steps);
|
|
43
|
+
shot = await manager.screenshot(sessionId, { out: screenshotOut, full: !!flags.full });
|
|
44
|
+
diagnostics = manager.diagnostics(sessionId);
|
|
45
|
+
} finally {
|
|
46
|
+
closed = await manager.close(sessionId).catch(() => ({ sessionId, closed: false, video: null }));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
ok: !acted?.failed,
|
|
51
|
+
sessionId,
|
|
52
|
+
mode: opened?.mode,
|
|
53
|
+
visual: opened?.visual,
|
|
54
|
+
url: acted?.url || opened?.url || url,
|
|
55
|
+
title: acted?.title || opened?.title || null,
|
|
56
|
+
trace: acted?.trace || [],
|
|
57
|
+
screenshot: shot?.out || null,
|
|
58
|
+
video: closed?.video || null,
|
|
59
|
+
diagnostics,
|
|
60
|
+
};
|
|
61
|
+
}
|
package/src/runway.js
CHANGED
|
@@ -70,6 +70,10 @@ export async function newPage(browser, viewport, opts = {}) {
|
|
|
70
70
|
hasTouch: !!(viewport.name && viewport.name.startsWith("mobile")),
|
|
71
71
|
isMobile: !!(viewport.name && viewport.name.startsWith("mobile")),
|
|
72
72
|
storageState: opts.storageState || undefined,
|
|
73
|
+
recordVideo: opts.recordVideoDir ? {
|
|
74
|
+
dir: opts.recordVideoDir,
|
|
75
|
+
size: { width: viewport.width, height: viewport.height },
|
|
76
|
+
} : undefined,
|
|
73
77
|
});
|
|
74
78
|
const page = await ctx.newPage();
|
|
75
79
|
if (opts.context) await page.setViewportSize({ width: viewport.width, height: viewport.height }).catch(() => {});
|
package/src/session.js
CHANGED
|
@@ -71,6 +71,9 @@ export class BrowserSessionManager {
|
|
|
71
71
|
const mode = flags.mode || (flags.cdpUrl ? "cdp" : flags.visible ? "visible" : "headless");
|
|
72
72
|
if (!new Set(["headless", "visible", "cdp"]).has(mode)) throw new Error("mode must be headless, visible, or cdp");
|
|
73
73
|
const visual = flags.visual === true || mode === "visible";
|
|
74
|
+
const recordVideoDir = flags.recordVideoDir || null;
|
|
75
|
+
if (recordVideoDir && mode === "cdp") throw new Error("video recording is not available in CDP mode; use visible or headless mode");
|
|
76
|
+
if (recordVideoDir) mkdirSync(recordVideoDir, { recursive: true });
|
|
74
77
|
const operatorOptions = { color: flags.operatorColor || "#ff2ea6" };
|
|
75
78
|
const browser = mode === "cdp"
|
|
76
79
|
? await this.connectBrowser(flags.cdpUrl, { timeoutMs: flags.timeoutMs })
|
|
@@ -79,14 +82,18 @@ export class BrowserSessionManager {
|
|
|
79
82
|
const viewport = resolveViewport(flags);
|
|
80
83
|
const context = mode === "cdp" ? browser.contexts()[0] : null;
|
|
81
84
|
if (mode === "cdp" && !context) throw new Error("CDP browser has no default context");
|
|
82
|
-
const page = await newPage(browser, viewport, { storageState, context });
|
|
85
|
+
const page = await newPage(browser, viewport, { storageState, context, recordVideoDir });
|
|
83
86
|
const diagnostics = { console: [], errors: [], requests: [], responses: [] };
|
|
84
87
|
attachDiagnostics(page, diagnostics);
|
|
85
88
|
if (visual) page.on("domcontentloaded", () => installVisualOperator(page, operatorOptions).catch(() => {}));
|
|
86
89
|
const nav = await gotoOrThrow(page, url, { timeoutMs: flags.timeoutMs });
|
|
87
90
|
await settle(page);
|
|
88
91
|
if (visual) await installVisualOperator(page, operatorOptions);
|
|
89
|
-
const session = {
|
|
92
|
+
const session = {
|
|
93
|
+
id, browser, page, context: page._pursrContext, viewport, mode, visual,
|
|
94
|
+
operatorOptions, diagnostics, video: page.video?.() || null,
|
|
95
|
+
createdAt: new Date().toISOString(),
|
|
96
|
+
};
|
|
90
97
|
this.sessions.set(id, session);
|
|
91
98
|
return { sessionId: id, url: page.url(), title: await page.title(), viewport, mode, visual, status: nav.status, createdAt: session.createdAt };
|
|
92
99
|
} catch (error) {
|
|
@@ -158,7 +165,7 @@ export class BrowserSessionManager {
|
|
|
158
165
|
const op = action.type || action.op;
|
|
159
166
|
const step = { index: i, type: op };
|
|
160
167
|
try {
|
|
161
|
-
if (["click", "hover", "fill", "type", "check", "select"].includes(op)) {
|
|
168
|
+
if (["click", "doubleClick", "hover", "fill", "type", "check", "select"].includes(op) && action.selector) {
|
|
162
169
|
const locator = await resolveLocator(page, action.selector);
|
|
163
170
|
await locator.first().waitFor({ state: "visible", timeout: action.timeoutMs || CLICK_TIMEOUT_MS });
|
|
164
171
|
let point = null;
|
|
@@ -169,14 +176,42 @@ export class BrowserSessionManager {
|
|
|
169
176
|
step.cursor = { x: Math.round(point.x), y: Math.round(point.y) };
|
|
170
177
|
}
|
|
171
178
|
if (op === "click") await locator.first().click();
|
|
179
|
+
else if (op === "doubleClick") await locator.first().dblclick();
|
|
172
180
|
else if (op === "hover") await locator.first().hover();
|
|
173
181
|
else if (op === "fill") await locator.first().fill(String(action.text ?? action.value ?? ""));
|
|
174
182
|
else if (op === "type") await locator.first().pressSequentially(String(action.text ?? ""), { delay: action.delayMs || 10 });
|
|
175
183
|
else if (op === "check") await locator.first().setChecked(action.checked !== false);
|
|
176
184
|
else await locator.first().selectOption(action.value);
|
|
177
|
-
if (visual &&
|
|
185
|
+
if (visual && ["click", "doubleClick"].includes(op) && point) await markVisualClick(page, point.x, point.y, { ...operatorOptions, color: action.color });
|
|
178
186
|
step.selector = action.selector;
|
|
187
|
+
} else if (["click", "doubleClick"].includes(op) && Number.isFinite(Number(action.x)) && Number.isFinite(Number(action.y))) {
|
|
188
|
+
const x = Number(action.x), y = Number(action.y);
|
|
189
|
+
if (visual) await moveVisualCursor(page, x, y, { ...operatorOptions, durationMs: action.durationMs });
|
|
190
|
+
await page.mouse[op === "doubleClick" ? "dblclick" : "click"](x, y, { button: action.button || "left" });
|
|
191
|
+
if (visual) await markVisualClick(page, x, y, { ...operatorOptions, color: action.color });
|
|
192
|
+
step.cursor = { x: Math.round(x), y: Math.round(y) };
|
|
193
|
+
} else if (op === "drag") {
|
|
194
|
+
const start = action.fromSelector
|
|
195
|
+
? await visualPointForLocator((await resolveLocator(page, action.fromSelector)).first())
|
|
196
|
+
: { x: Number(action.fromX), y: Number(action.fromY) };
|
|
197
|
+
const end = action.toSelector
|
|
198
|
+
? await visualPointForLocator((await resolveLocator(page, action.toSelector)).first())
|
|
199
|
+
: { x: Number(action.toX), y: Number(action.toY) };
|
|
200
|
+
if (![start.x, start.y, end.x, end.y].every(Number.isFinite)) throw new Error("drag requires from/to coordinates or selectors");
|
|
201
|
+
if (visual) await moveVisualCursor(page, start.x, start.y, { ...operatorOptions, durationMs: action.durationMs });
|
|
202
|
+
await page.mouse.move(start.x, start.y);
|
|
203
|
+
await page.mouse.down({ button: action.button || "left" });
|
|
204
|
+
const steps = Math.max(1, Math.min(100, Number(action.steps) || 20));
|
|
205
|
+
await page.mouse.move(end.x, end.y, { steps });
|
|
206
|
+
await page.mouse.up({ button: action.button || "left" });
|
|
207
|
+
if (visual) {
|
|
208
|
+
await moveVisualCursor(page, end.x, end.y, { ...operatorOptions, durationMs: 0 });
|
|
209
|
+
await markVisualClick(page, end.x, end.y, { ...operatorOptions, color: action.color });
|
|
210
|
+
}
|
|
211
|
+
step.cursor = { x: Math.round(end.x), y: Math.round(end.y) };
|
|
179
212
|
} else if (op === "press") await page.keyboard.press(String(action.key));
|
|
213
|
+
else if (op === "keyDown") await page.keyboard.down(String(action.key));
|
|
214
|
+
else if (op === "keyUp") await page.keyboard.up(String(action.key));
|
|
180
215
|
else if (op === "scroll") await page.mouse.wheel(Number(action.deltaX) || 0, Number(action.deltaY) || 0);
|
|
181
216
|
else if (op === "wait") await (await resolveLocator(page, action.selector)).first().waitFor({ state: action.state || "visible", timeout: action.timeoutMs || WAIT_DEFAULT_TIMEOUT_MS });
|
|
182
217
|
else if (op === "sleep") await page.waitForTimeout(Math.max(0, Number(action.ms) || 0));
|
|
@@ -242,8 +277,16 @@ export class BrowserSessionManager {
|
|
|
242
277
|
const session = this.sessions.get(id);
|
|
243
278
|
if (!session) return { sessionId: id, closed: false };
|
|
244
279
|
this.sessions.delete(id);
|
|
280
|
+
let video = null;
|
|
281
|
+
try {
|
|
282
|
+
if (session.mode === "cdp") await session.page.close();
|
|
283
|
+
else await session.context.close();
|
|
284
|
+
} catch {}
|
|
245
285
|
try { await session.browser.close(); } catch {}
|
|
246
|
-
|
|
286
|
+
if (session.video) {
|
|
287
|
+
try { video = await session.video.path(); } catch {}
|
|
288
|
+
}
|
|
289
|
+
return { sessionId: id, closed: true, video };
|
|
247
290
|
}
|
|
248
291
|
|
|
249
292
|
async closeAll() {
|