pursr 0.7.3 → 0.8.1
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 +43 -9
- package/package.json +8 -5
- package/src/dom-snapshot.js +24 -3
- package/src/index.js +9 -6
- package/src/mcp.js +221 -141
- package/src/session.js +204 -0
package/README.md
CHANGED
|
@@ -33,8 +33,8 @@
|
|
|
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 MCP stdio server** (`pursr-mcp`)
|
|
37
|
-
- **A library** with
|
|
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
|
+
- **A library API** with 23 subpath modules, so you can embed the browser and QA primitives in your own tooling.
|
|
38
38
|
- **A plugin system** for custom viewports, sweep ops, and capture hooks.
|
|
39
39
|
- **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
40
|
- **Zero browser bundled** - drives your system Chrome via Playwright. No 200 MB Chromium download.
|
|
@@ -96,7 +96,7 @@ pursr sweep ./plan.json # see plans/ for an example
|
|
|
96
96
|
| HAR capture | HAR 1.2 spec, written next to your shot | `--har ./req.har.json` |
|
|
97
97
|
| Auth state | Playwright storageState, reuse logged-in sessions | `--auth-state admin` |
|
|
98
98
|
| Plugins | custom viewports, sweep ops, before/after hooks | `pursr-plugin-*` |
|
|
99
|
-
| MCP server |
|
|
99
|
+
| MCP server | Official MCP SDK transport, 16 tools, and resources for Claude/Cursor/Codex | `npx pursr-mcp` |
|
|
100
100
|
| PDF report | render sweep.json to a styled, embedded-PNG A4 PDF | `pursr report --sweep ./sweep.json` |
|
|
101
101
|
| AI diff summary | vision LLM describes the diff in plain language | `pursr diff ... --ai` |
|
|
102
102
|
|
|
@@ -184,17 +184,51 @@ npx pursr-mcp
|
|
|
184
184
|
npx pursr-mcp --verbose
|
|
185
185
|
```
|
|
186
186
|
|
|
187
|
-
### Exposed Tools
|
|
188
|
-
|
|
189
|
-
| Tool | Description |
|
|
190
|
-
| --- | --- |
|
|
191
|
-
| `
|
|
187
|
+
### Exposed Tools
|
|
188
|
+
|
|
189
|
+
| Tool | Description |
|
|
190
|
+
| --- | --- |
|
|
191
|
+
| `pursr_session_open` | Open a persistent browser tab for iterative agent work |
|
|
192
|
+
| `pursr_sessions` | List active browser sessions |
|
|
193
|
+
| `pursr_snapshot` | Visible rendered nodes, geometry, semantics, and computed styles |
|
|
194
|
+
| `pursr_act` | Click, hover, fill, type, scroll, navigate, reload, and more |
|
|
195
|
+
| `pursr_screenshot` | Return the current PNG directly to the vision model |
|
|
196
|
+
| `pursr_inspect` | Inspect exact geometry, computed styles, and stacking ancestors |
|
|
197
|
+
| `pursr_diagnostics` | Read console, page errors, failed requests, and HTTP failures |
|
|
198
|
+
| `pursr_session_close` | Close the tab and release its browser process |
|
|
199
|
+
| `pursr_shoot` | Rich screenshot capture (viewport, grid, layer, cursor, camera, animation freeze, HAR) |
|
|
192
200
|
| `pursr_diff` | Pixel-diff a URL against a reference PNG |
|
|
193
201
|
| `pursr_sweep` | Execute a batch sweep plan |
|
|
194
202
|
| `pursr_frames` | Capture an N-frame animation timeline |
|
|
195
203
|
| `pursr_probe` | Health-check a URL |
|
|
196
204
|
| `pursr_audit` | axe-core WCAG audit + highlighted screenshot |
|
|
197
|
-
| `pursr_dom_snapshot` | Full DOM + selector map snapshot |
|
|
205
|
+
| `pursr_dom_snapshot` | Full DOM + selector map snapshot |
|
|
206
|
+
| `pursr_check` | CI visual regression check against a stable baseline |
|
|
207
|
+
|
|
208
|
+
### Agent workflow
|
|
209
|
+
|
|
210
|
+
Use persistent sessions for the same inspect-act-verify loop as an interactive browser agent:
|
|
211
|
+
|
|
212
|
+
1. Call `pursr_session_open` once with a stable `sessionId`.
|
|
213
|
+
2. Call `pursr_snapshot` to understand the rendered page before acting.
|
|
214
|
+
3. Use `pursr_act` for a small, ordered interaction sequence.
|
|
215
|
+
4. Call `pursr_screenshot` when visual judgment matters; the model receives the PNG directly.
|
|
216
|
+
5. Use `pursr_inspect` for layout, clipping, typography, or stacking problems.
|
|
217
|
+
6. Read `pursr_diagnostics`, then reload and verify after source changes.
|
|
218
|
+
7. Call `pursr_session_close` when the review is complete.
|
|
219
|
+
|
|
220
|
+
Example action arguments:
|
|
221
|
+
|
|
222
|
+
```json
|
|
223
|
+
{
|
|
224
|
+
"sessionId": "farm",
|
|
225
|
+
"actions": [
|
|
226
|
+
{ "type": "hover", "selector": "role=button|Build" },
|
|
227
|
+
{ "type": "click", "selector": "text=Barn" },
|
|
228
|
+
{ "type": "wait", "selector": "role=dialog" }
|
|
229
|
+
]
|
|
230
|
+
}
|
|
231
|
+
```
|
|
198
232
|
|
|
199
233
|
### Exposed Resources
|
|
200
234
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pursr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
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": {
|
|
@@ -38,7 +38,8 @@
|
|
|
38
38
|
"./watch": "./src/watch.js",
|
|
39
39
|
"./snap": "./src/snap.js",
|
|
40
40
|
"./report": "./src/report.js",
|
|
41
|
-
"./ai-diff": "./src/ai-diff.js"
|
|
41
|
+
"./ai-diff": "./src/ai-diff.js",
|
|
42
|
+
"./session": "./src/session.js"
|
|
42
43
|
},
|
|
43
44
|
"files": [
|
|
44
45
|
"bin",
|
|
@@ -76,10 +77,12 @@
|
|
|
76
77
|
],
|
|
77
78
|
"license": "MIT",
|
|
78
79
|
"dependencies": {
|
|
80
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
79
81
|
"axe-core": "^4.12.1",
|
|
80
82
|
"pdfkit": "^0.19.1",
|
|
81
83
|
"pixelmatch": "^5.3.0",
|
|
82
|
-
"pngjs": "^7.0.0"
|
|
84
|
+
"pngjs": "^7.0.0",
|
|
85
|
+
"zod": "^4.4.3"
|
|
83
86
|
},
|
|
84
87
|
"peerDependencies": {
|
|
85
88
|
"playwright-core": "*"
|
package/src/dom-snapshot.js
CHANGED
|
@@ -95,7 +95,7 @@ const SNAPSHOT_PAGE_SCRIPT = `(() => {
|
|
|
95
95
|
const href = el.getAttribute('href');
|
|
96
96
|
const src = el.getAttribute('src');
|
|
97
97
|
|
|
98
|
-
const entry = {
|
|
98
|
+
const entry = {
|
|
99
99
|
tag,
|
|
100
100
|
id,
|
|
101
101
|
css: getCSSSelector(el),
|
|
@@ -108,8 +108,29 @@ const SNAPSHOT_PAGE_SCRIPT = `(() => {
|
|
|
108
108
|
href: href || null,
|
|
109
109
|
src: src || null,
|
|
110
110
|
rect: visible ? { x: round(rect.x), y: round(rect.y), w: round(rect.width), h: round(rect.height) } : null,
|
|
111
|
-
visible,
|
|
112
|
-
};
|
|
111
|
+
visible,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (visible) {
|
|
115
|
+
const computed = getComputedStyle(el);
|
|
116
|
+
entry.computedStyle = {
|
|
117
|
+
display: computed.display,
|
|
118
|
+
position: computed.position,
|
|
119
|
+
zIndex: computed.zIndex,
|
|
120
|
+
overflowX: computed.overflowX,
|
|
121
|
+
overflowY: computed.overflowY,
|
|
122
|
+
opacity: computed.opacity,
|
|
123
|
+
visibility: computed.visibility,
|
|
124
|
+
color: computed.color,
|
|
125
|
+
backgroundColor: computed.backgroundColor,
|
|
126
|
+
fontFamily: computed.fontFamily,
|
|
127
|
+
fontSize: computed.fontSize,
|
|
128
|
+
fontWeight: computed.fontWeight,
|
|
129
|
+
lineHeight: computed.lineHeight,
|
|
130
|
+
transform: computed.transform,
|
|
131
|
+
boxShadow: computed.boxShadow,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
113
134
|
|
|
114
135
|
// get computed role from accessibility tree
|
|
115
136
|
try { entry.ariaRole = el.computedRole || el.getAttribute('role') || null; } catch {}
|
package/src/index.js
CHANGED
|
@@ -43,7 +43,8 @@ import { startWatch, matchGlob, shouldFire } from "./watch.js";
|
|
|
43
43
|
import { runSnap, approveSnapsAsBaselines } from "./snap.js";
|
|
44
44
|
import { runCheck } from "./check.js";
|
|
45
45
|
import { renderSweepPdf } from "./report.js";
|
|
46
|
-
import { aiDiffSummary, aiDiffSidecar } from "./ai-diff.js";
|
|
46
|
+
import { aiDiffSummary, aiDiffSidecar } from "./ai-diff.js";
|
|
47
|
+
import { BrowserSessionManager } from "./session.js";
|
|
47
48
|
|
|
48
49
|
|
|
49
50
|
// Derive VERSION from package.json to prevent drift
|
|
@@ -85,8 +86,9 @@ export {
|
|
|
85
86
|
// v6: PDF report, AI diff summary
|
|
86
87
|
runDiffWithAi,
|
|
87
88
|
renderSweepPdf,
|
|
88
|
-
aiDiffSummary, aiDiffSidecar,
|
|
89
|
-
|
|
89
|
+
aiDiffSummary, aiDiffSidecar,
|
|
90
|
+
BrowserSessionManager,
|
|
91
|
+
VERSION,
|
|
90
92
|
};
|
|
91
93
|
|
|
92
94
|
export default {
|
|
@@ -106,6 +108,7 @@ export default {
|
|
|
106
108
|
validateSweepPlan, registerSweepOp,
|
|
107
109
|
listResources, readResource, recordResource,
|
|
108
110
|
// v6: PDF report, AI diff summary
|
|
109
|
-
runDiffWithAi, renderSweepPdf, aiDiffSummary, aiDiffSidecar,
|
|
110
|
-
|
|
111
|
-
|
|
111
|
+
runDiffWithAi, renderSweepPdf, aiDiffSummary, aiDiffSidecar,
|
|
112
|
+
BrowserSessionManager,
|
|
113
|
+
VERSION,
|
|
114
|
+
};
|
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" }
|
|
@@ -23,13 +22,22 @@ import { runAudit } from "./plugin-audit.js";
|
|
|
23
22
|
import { loadPlugins, listPlugins } from "./plugin.js";
|
|
24
23
|
import { makeOut, nowIso } from "./util.js";
|
|
25
24
|
import { listResources, readResource, recordResource } from "./mcp-resources.js";
|
|
26
|
-
import { createRequire } from "node:module";
|
|
25
|
+
import { createRequire } from "node:module";
|
|
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";
|
|
27
35
|
|
|
28
36
|
const __require = createRequire(import.meta.url);
|
|
29
37
|
let _pkg = { version: "0.1.0" };
|
|
30
38
|
try { _pkg = __require("../package.json"); } catch {}
|
|
31
39
|
|
|
32
|
-
const MCP_VERSION = "0.1.0";
|
|
40
|
+
const MCP_VERSION = _pkg.version || "0.1.0";
|
|
33
41
|
|
|
34
42
|
// ─── Config ──────────────────────────────────────────────────────────────
|
|
35
43
|
|
|
@@ -59,138 +67,68 @@ class McpError extends Error {
|
|
|
59
67
|
|
|
60
68
|
// ─── Server ──────────────────────────────────────────────────────────────
|
|
61
69
|
|
|
62
|
-
class PursrMCPServer {
|
|
63
|
-
constructor(config = {}) {
|
|
64
|
-
this.config = config;
|
|
65
|
-
this.
|
|
66
|
-
this.
|
|
67
|
-
this.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
class PursrMCPServer {
|
|
71
|
+
constructor(config = {}) {
|
|
72
|
+
this.config = config;
|
|
73
|
+
this._verbose = !!config.verbose;
|
|
74
|
+
this.sessions = new BrowserSessionManager({ outputDir: config.defaultOutDir || process.cwd() });
|
|
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
|
+
}
|
|
70
86
|
|
|
71
87
|
log(...args) {
|
|
72
88
|
if (this._verbose) console.error("[pursr-mcp]", ...args);
|
|
73
89
|
}
|
|
74
90
|
|
|
75
|
-
async start() {
|
|
91
|
+
async start() {
|
|
76
92
|
if (this.config.plugins?.length) {
|
|
77
93
|
await loadPlugins(this.config.plugins);
|
|
78
94
|
}
|
|
79
|
-
this.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
this.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
} else break;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
_send(msg) {
|
|
121
|
-
const json = JSON.stringify(msg);
|
|
122
|
-
const bytes = Buffer.from(json, "utf8");
|
|
123
|
-
const header = `Content-Length: ${bytes.length}\r\n\r\n`;
|
|
124
|
-
process.stdout.write(header);
|
|
125
|
-
process.stdout.write(bytes);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// ── JSON-RPC dispatcher ─────────────────────────────────────────────
|
|
129
|
-
|
|
130
|
-
async _handleMessage(msg) {
|
|
131
|
-
if (!msg || msg.jsonrpc !== "2.0" || !msg.method) {
|
|
132
|
-
console.error("[pursr-mcp] skipping non-JSON-RPC message");
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
const { method, id } = msg;
|
|
136
|
-
|
|
137
|
-
// Notifications — no id → no response
|
|
138
|
-
if (method === "notifications/initialized" || method === "notifications/cancelled") {
|
|
139
|
-
if (method === "notifications/initialized") this._initialized = true;
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
if (id === undefined || id === null) return; // unnamed notification
|
|
143
|
-
|
|
144
|
-
try {
|
|
145
|
-
switch (method) {
|
|
146
|
-
case "initialize":
|
|
147
|
-
this._initialized = true;
|
|
148
|
-
this._send({
|
|
149
|
-
jsonrpc: "2.0", id,
|
|
150
|
-
result: {
|
|
151
|
-
protocolVersion: msg.params?.protocolVersion || "2024-11-05",
|
|
152
|
-
capabilities: { tools: {} },
|
|
153
|
-
serverInfo: { name: "pursr", version: MCP_VERSION },
|
|
154
|
-
},
|
|
155
|
-
});
|
|
156
|
-
break;
|
|
157
|
-
|
|
158
|
-
case "tools/list":
|
|
159
|
-
this._send({ jsonrpc: "2.0", id, result: { tools: this._toolDefs() } });
|
|
160
|
-
break;
|
|
161
|
-
|
|
162
|
-
case "resources/list":
|
|
163
|
-
this._send({ jsonrpc: "2.0", id, result: { resources: listResources().map(this._toMcpResource, this) } });
|
|
164
|
-
break;
|
|
165
|
-
|
|
166
|
-
case "resources/read":
|
|
167
|
-
if (!msg.params?.uri) throw new McpError(-32602, "Missing uri");
|
|
168
|
-
const data = readResource(msg.params.uri);
|
|
169
|
-
if (!data) throw new McpError(-32602, "Resource not found: " + msg.params.uri);
|
|
170
|
-
this._send({ jsonrpc: "2.0", id, result: { contents: [data] } });
|
|
171
|
-
break;
|
|
172
|
-
|
|
173
|
-
case "tools/call":
|
|
174
|
-
if (!msg.params?.name) throw new McpError(-32602, "Missing tool name");
|
|
175
|
-
const result = await this._callTool(msg.params.name, msg.params.arguments || {});
|
|
176
|
-
this._send({ jsonrpc: "2.0", id, result: { content: result } });
|
|
177
|
-
break;
|
|
178
|
-
|
|
179
|
-
default:
|
|
180
|
-
this._send({
|
|
181
|
-
jsonrpc: "2.0", id,
|
|
182
|
-
error: { code: -32601, message: `Unknown method: ${method}` },
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
} catch (e) {
|
|
186
|
-
if (e instanceof McpError) {
|
|
187
|
-
this._send({ jsonrpc: "2.0", id, error: { code: e.code, message: e.message } });
|
|
188
|
-
} else {
|
|
189
|
-
console.error("[pursr-mcp] handler error:", e.stack || e.message);
|
|
190
|
-
this._send({ jsonrpc: "2.0", id, error: { code: -32603, message: e.message } });
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
}
|
|
95
|
+
this.transport = new StdioServerTransport();
|
|
96
|
+
this.transport.onclose = () => {
|
|
97
|
+
this.log("stdio transport closed");
|
|
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
|
+
}
|
|
122
|
+
});
|
|
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
|
+
}
|
|
194
132
|
|
|
195
133
|
// ── Resource shape adapter ─────────────────────────────────────────
|
|
196
134
|
|
|
@@ -205,10 +143,88 @@ class PursrMCPServer {
|
|
|
205
143
|
|
|
206
144
|
// ── Tool definitions ────────────────────────────────────────────────
|
|
207
145
|
|
|
208
|
-
_toolDefs() {
|
|
209
|
-
return [
|
|
210
|
-
{
|
|
211
|
-
name: "
|
|
146
|
+
_toolDefs() {
|
|
147
|
+
return [
|
|
148
|
+
{
|
|
149
|
+
name: "pursr_session_open",
|
|
150
|
+
description: "Open a persistent browser tab for iterative agent work. State, hover, scroll, dialogs, and navigation persist until closed.",
|
|
151
|
+
inputSchema: {
|
|
152
|
+
type: "object",
|
|
153
|
+
properties: {
|
|
154
|
+
url: { type: "string", description: "Initial URL" },
|
|
155
|
+
sessionId: { type: "string", description: "Stable session name; generated when omitted" },
|
|
156
|
+
preset: { type: "string", description: "Viewport preset" },
|
|
157
|
+
width: { type: "number" }, height: { type: "number" }, dpr: { type: "number" },
|
|
158
|
+
storageState: { description: "Playwright storageState object or file path" },
|
|
159
|
+
},
|
|
160
|
+
required: ["url"],
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: "pursr_sessions",
|
|
165
|
+
description: "List active persistent browser sessions.",
|
|
166
|
+
inputSchema: { type: "object", properties: {} },
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: "pursr_snapshot",
|
|
170
|
+
description: "Read the current rendered state from a persistent session as concise visible nodes, geometry, semantics, and computed visual styles.",
|
|
171
|
+
inputSchema: {
|
|
172
|
+
type: "object",
|
|
173
|
+
properties: {
|
|
174
|
+
sessionId: { type: "string" }, selector: { type: "string", description: "CSS root selector (default body)" },
|
|
175
|
+
maxNodes: { type: "number", description: "Maximum returned nodes, 1-1000" },
|
|
176
|
+
includeStyles: { type: "boolean", description: "Include compact computed styles (default true)" },
|
|
177
|
+
},
|
|
178
|
+
required: ["sessionId"],
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
name: "pursr_act",
|
|
183
|
+
description: "Perform ordered actions in a persistent session. Supported types: click, hover, fill, type, check, select, press, scroll, wait, sleep, navigate, reload, eval.",
|
|
184
|
+
inputSchema: {
|
|
185
|
+
type: "object",
|
|
186
|
+
properties: {
|
|
187
|
+
sessionId: { type: "string" },
|
|
188
|
+
actions: { type: "array", minItems: 1, maxItems: 50, items: { type: "object" } },
|
|
189
|
+
},
|
|
190
|
+
required: ["sessionId", "actions"],
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: "pursr_screenshot",
|
|
195
|
+
description: "Capture the current persistent session and return the PNG directly to the model as image content.",
|
|
196
|
+
inputSchema: {
|
|
197
|
+
type: "object",
|
|
198
|
+
properties: {
|
|
199
|
+
sessionId: { type: "string" }, out: { type: "string" }, full: { type: "boolean" },
|
|
200
|
+
selector: { type: "string", description: "Capture only the first matching element" },
|
|
201
|
+
},
|
|
202
|
+
required: ["sessionId"],
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
name: "pursr_inspect",
|
|
207
|
+
description: "Inspect one rendered element: HTML, exact geometry, computed style, and clipping/stacking ancestors.",
|
|
208
|
+
inputSchema: {
|
|
209
|
+
type: "object", properties: { sessionId: { type: "string" }, selector: { type: "string" } }, required: ["sessionId", "selector"],
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
name: "pursr_diagnostics",
|
|
214
|
+
description: "Read console messages, page errors, failed requests, and HTTP 4xx/5xx responses accumulated during a persistent session.",
|
|
215
|
+
inputSchema: {
|
|
216
|
+
type: "object", properties: { sessionId: { type: "string" }, clear: { type: "boolean" } }, required: ["sessionId"],
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
name: "pursr_session_close",
|
|
221
|
+
description: "Close a persistent browser session and release its browser process.",
|
|
222
|
+
inputSchema: {
|
|
223
|
+
type: "object", properties: { sessionId: { type: "string" } }, required: ["sessionId"],
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
name: "pursr_shoot",
|
|
212
228
|
description: "Capture a screenshot of a URL with full feature control (viewport, grid, layer, cursor, camera, animation freeze). Returns PNG path and sidecar metadata.",
|
|
213
229
|
inputSchema: {
|
|
214
230
|
type: "object",
|
|
@@ -354,9 +370,17 @@ class PursrMCPServer {
|
|
|
354
370
|
|
|
355
371
|
// ── Tool dispatcher ─────────────────────────────────────────────────
|
|
356
372
|
|
|
357
|
-
async _callTool(name, args) {
|
|
358
|
-
switch (name) {
|
|
359
|
-
case "
|
|
373
|
+
async _callTool(name, args) {
|
|
374
|
+
switch (name) {
|
|
375
|
+
case "pursr_session_open": return await this._sessionOpen(args);
|
|
376
|
+
case "pursr_sessions": return this._text(this.sessions.list());
|
|
377
|
+
case "pursr_snapshot": return await this._sessionSnapshot(args);
|
|
378
|
+
case "pursr_act": return await this._sessionAct(args);
|
|
379
|
+
case "pursr_screenshot": return await this._sessionScreenshot(args);
|
|
380
|
+
case "pursr_inspect": return await this._sessionInspect(args);
|
|
381
|
+
case "pursr_diagnostics": return this._sessionDiagnostics(args);
|
|
382
|
+
case "pursr_session_close": return await this._sessionClose(args);
|
|
383
|
+
case "pursr_shoot": return await this._shoot(args);
|
|
360
384
|
case "pursr_diff": return await this._diff(args);
|
|
361
385
|
case "pursr_sweep": return await this._sweep(args);
|
|
362
386
|
case "pursr_frames": return await this._frames(args);
|
|
@@ -368,9 +392,61 @@ class PursrMCPServer {
|
|
|
368
392
|
}
|
|
369
393
|
}
|
|
370
394
|
|
|
371
|
-
// ── Tool implementations ────────────────────────────────────────────
|
|
372
|
-
|
|
373
|
-
|
|
395
|
+
// ── Tool implementations ────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
_text(value) {
|
|
398
|
+
return [{ type: "text", text: JSON.stringify(value, null, 2) }];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
_requireSessionId(args) {
|
|
402
|
+
if (!args.sessionId) throw new McpError(-32602, "Missing required: sessionId");
|
|
403
|
+
return args.sessionId;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async _sessionOpen(args) {
|
|
407
|
+
if (!args.url) throw new McpError(-32602, "Missing required: url");
|
|
408
|
+
const flags = { preset: args.preset, width: args.width, height: args.height, dpr: args.dpr };
|
|
409
|
+
const result = await this.sessions.open({ sessionId: args.sessionId, url: args.url, flags, storageState: args.storageState });
|
|
410
|
+
return this._text(result);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async _sessionSnapshot(args) {
|
|
414
|
+
const result = await this.sessions.snapshot(this._requireSessionId(args), args);
|
|
415
|
+
return this._text(result);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async _sessionAct(args) {
|
|
419
|
+
const result = await this.sessions.act(this._requireSessionId(args), args.actions);
|
|
420
|
+
return this._text(result);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async _sessionScreenshot(args) {
|
|
424
|
+
const result = await this.sessions.screenshot(this._requireSessionId(args), args);
|
|
425
|
+
recordResource({
|
|
426
|
+
kind: "session", id: args.sessionId, name: `session screenshot: ${args.sessionId}`,
|
|
427
|
+
description: result.url, uri: `pursr://session/${encodeURIComponent(args.sessionId)}`,
|
|
428
|
+
mimeType: result.mimeType, file: result.out, meta: { url: result.url, ts: nowIso() },
|
|
429
|
+
});
|
|
430
|
+
return [
|
|
431
|
+
{ type: "text", text: JSON.stringify({ sessionId: result.sessionId, out: result.out, url: result.url }, null, 2) },
|
|
432
|
+
{ type: "image", data: result.data, mimeType: result.mimeType },
|
|
433
|
+
];
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async _sessionInspect(args) {
|
|
437
|
+
const result = await this.sessions.inspect(this._requireSessionId(args), args.selector);
|
|
438
|
+
return this._text(result);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
_sessionDiagnostics(args) {
|
|
442
|
+
return this._text(this.sessions.diagnostics(this._requireSessionId(args), { clear: !!args.clear }));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async _sessionClose(args) {
|
|
446
|
+
return this._text(await this.sessions.close(this._requireSessionId(args)));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async _shoot(args) {
|
|
374
450
|
const url = args.url;
|
|
375
451
|
if (!url) throw new McpError(-32602, "Missing required: url");
|
|
376
452
|
|
|
@@ -397,7 +473,9 @@ class PursrMCPServer {
|
|
|
397
473
|
file: out, meta: { url, flags, ts: sidecar?.ts },
|
|
398
474
|
});
|
|
399
475
|
|
|
400
|
-
|
|
476
|
+
const content = [{ type: "text", text: JSON.stringify({ out, meta: sidecar }, null, 2) }];
|
|
477
|
+
if (existsSync(out)) content.push({ type: "image", data: readFileSync(out).toString("base64"), mimeType: "image/png" });
|
|
478
|
+
return content;
|
|
401
479
|
}
|
|
402
480
|
|
|
403
481
|
async _diff(args) {
|
|
@@ -414,7 +492,9 @@ class PursrMCPServer {
|
|
|
414
492
|
if (k !== "url" && k !== "ref" && k !== "out" && k !== "threshold") flags[k] = v;
|
|
415
493
|
}
|
|
416
494
|
const result = await runDiff(url, ref, out, threshold, flags);
|
|
417
|
-
|
|
495
|
+
const content = [{ type: "text", text: JSON.stringify(result, null, 2) }];
|
|
496
|
+
if (existsSync(out)) content.push({ type: "image", data: readFileSync(out).toString("base64"), mimeType: "image/png" });
|
|
497
|
+
return content;
|
|
418
498
|
}
|
|
419
499
|
|
|
420
500
|
async _sweep(args) {
|
package/src/session.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Persistent browser sessions for agent-driven visual QA.
|
|
2
|
+
|
|
3
|
+
import { mkdirSync, readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { launch, newPage } from "./runway.js";
|
|
6
|
+
import { resolveViewport } from "./viewport.js";
|
|
7
|
+
import { gotoOrThrow, settle, CLICK_TIMEOUT_MS, WAIT_DEFAULT_TIMEOUT_MS } from "./overlays.js";
|
|
8
|
+
import { resolveLocator } from "./selector.js";
|
|
9
|
+
|
|
10
|
+
const MAX_DIAGNOSTICS = 250;
|
|
11
|
+
const MAX_ACTIONS = 50;
|
|
12
|
+
|
|
13
|
+
function cleanId(value) {
|
|
14
|
+
const id = String(value || "").trim();
|
|
15
|
+
if (!id) return `session-${Date.now().toString(36)}`;
|
|
16
|
+
if (!/^[a-zA-Z0-9._-]{1,80}$/.test(id)) throw new Error("sessionId must use only letters, numbers, dot, underscore, or dash");
|
|
17
|
+
return id;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function pushCapped(list, value) {
|
|
21
|
+
list.push(value);
|
|
22
|
+
if (list.length > MAX_DIAGNOSTICS) list.splice(0, list.length - MAX_DIAGNOSTICS);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function attachDiagnostics(page, diagnostics) {
|
|
26
|
+
page.on("console", (msg) => pushCapped(diagnostics.console, { type: msg.type(), text: msg.text(), ts: new Date().toISOString() }));
|
|
27
|
+
page.on("pageerror", (error) => pushCapped(diagnostics.errors, { message: error.message, stack: error.stack || null, ts: new Date().toISOString() }));
|
|
28
|
+
page.on("requestfailed", (request) => pushCapped(diagnostics.requests, {
|
|
29
|
+
method: request.method(), url: request.url(), failure: request.failure()?.errorText || "failed", ts: new Date().toISOString(),
|
|
30
|
+
}));
|
|
31
|
+
page.on("response", (response) => {
|
|
32
|
+
if (response.status() < 400) return;
|
|
33
|
+
pushCapped(diagnostics.responses, {
|
|
34
|
+
status: response.status(), method: response.request().method(), url: response.url(), ts: new Date().toISOString(),
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class BrowserSessionManager {
|
|
40
|
+
constructor({ launchBrowser = launch, outputDir = process.cwd() } = {}) {
|
|
41
|
+
this.launchBrowser = launchBrowser;
|
|
42
|
+
this.outputDir = outputDir;
|
|
43
|
+
this.sessions = new Map();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
get size() { return this.sessions.size; }
|
|
47
|
+
|
|
48
|
+
get(sessionId) {
|
|
49
|
+
const session = this.sessions.get(String(sessionId || ""));
|
|
50
|
+
if (!session) throw new Error(`unknown session: ${sessionId}`);
|
|
51
|
+
return session;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
list() {
|
|
55
|
+
return [...this.sessions.values()].map(({ id, page, viewport, createdAt }) => ({ sessionId: id, url: page.url(), viewport, createdAt }));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async open({ sessionId, url, flags = {}, storageState } = {}) {
|
|
59
|
+
if (!url) throw new Error("url is required");
|
|
60
|
+
const id = cleanId(sessionId);
|
|
61
|
+
if (this.sessions.has(id)) await this.close(id);
|
|
62
|
+
const browser = await this.launchBrowser();
|
|
63
|
+
try {
|
|
64
|
+
const viewport = resolveViewport(flags);
|
|
65
|
+
const page = await newPage(browser, viewport, { storageState });
|
|
66
|
+
const diagnostics = { console: [], errors: [], requests: [], responses: [] };
|
|
67
|
+
attachDiagnostics(page, diagnostics);
|
|
68
|
+
const nav = await gotoOrThrow(page, url, { timeoutMs: flags.timeoutMs });
|
|
69
|
+
await settle(page);
|
|
70
|
+
const session = { id, browser, page, context: page._pursrContext, viewport, diagnostics, createdAt: new Date().toISOString() };
|
|
71
|
+
this.sessions.set(id, session);
|
|
72
|
+
return { sessionId: id, url: page.url(), title: await page.title(), viewport, status: nav.status, createdAt: session.createdAt };
|
|
73
|
+
} catch (error) {
|
|
74
|
+
try { await browser.close(); } catch {}
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async snapshot(sessionId, { selector = "body", maxNodes = 250, includeStyles = true } = {}) {
|
|
80
|
+
const { page } = this.get(sessionId);
|
|
81
|
+
const limit = Math.max(1, Math.min(1000, Number(maxNodes) || 250));
|
|
82
|
+
return await page.evaluate(({ selector, limit, includeStyles }) => {
|
|
83
|
+
const roots = [...document.querySelectorAll(selector)];
|
|
84
|
+
const elements = roots.flatMap((root) => [root, ...root.querySelectorAll("*")]);
|
|
85
|
+
const nodes = [];
|
|
86
|
+
for (const el of elements) {
|
|
87
|
+
if (nodes.length >= limit) break;
|
|
88
|
+
const rect = el.getBoundingClientRect();
|
|
89
|
+
const style = getComputedStyle(el);
|
|
90
|
+
if (rect.width <= 0 || rect.height <= 0 || style.visibility === "hidden" || style.display === "none") continue;
|
|
91
|
+
const text = (el.innerText || el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 160) || null;
|
|
92
|
+
const item = {
|
|
93
|
+
node: nodes.length + 1, tag: el.tagName.toLowerCase(), id: el.id || null,
|
|
94
|
+
role: el.getAttribute("role") || null,
|
|
95
|
+
name: el.getAttribute("aria-label") || el.getAttribute("alt") || el.getAttribute("title") || text,
|
|
96
|
+
text, rect: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
|
|
97
|
+
state: { disabled: "disabled" in el ? !!el.disabled : undefined, checked: "checked" in el ? !!el.checked : undefined, expanded: el.getAttribute("aria-expanded") },
|
|
98
|
+
};
|
|
99
|
+
if (includeStyles) item.style = {
|
|
100
|
+
display: style.display, position: style.position, zIndex: style.zIndex,
|
|
101
|
+
overflow: `${style.overflowX} ${style.overflowY}`, opacity: style.opacity,
|
|
102
|
+
color: style.color, backgroundColor: style.backgroundColor,
|
|
103
|
+
font: `${style.fontWeight} ${style.fontSize}/${style.lineHeight} ${style.fontFamily}`,
|
|
104
|
+
transform: style.transform, boxShadow: style.boxShadow,
|
|
105
|
+
};
|
|
106
|
+
nodes.push(item);
|
|
107
|
+
}
|
|
108
|
+
return { url: location.href, title: document.title, selector, truncated: elements.length > limit, nodes };
|
|
109
|
+
}, { selector, limit, includeStyles: includeStyles !== false });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async inspect(sessionId, selector) {
|
|
113
|
+
if (!selector) throw new Error("selector is required");
|
|
114
|
+
const { page } = this.get(sessionId);
|
|
115
|
+
const locator = await resolveLocator(page, selector);
|
|
116
|
+
await locator.first().waitFor({ state: "attached", timeout: CLICK_TIMEOUT_MS });
|
|
117
|
+
return await locator.first().evaluate((el) => {
|
|
118
|
+
const rect = el.getBoundingClientRect();
|
|
119
|
+
const style = getComputedStyle(el);
|
|
120
|
+
const ancestors = [];
|
|
121
|
+
for (let node = el.parentElement; node && ancestors.length < 6; node = node.parentElement) {
|
|
122
|
+
const s = getComputedStyle(node);
|
|
123
|
+
ancestors.push({ tag: node.tagName.toLowerCase(), id: node.id || null, position: s.position, overflow: `${s.overflowX} ${s.overflowY}`, zIndex: s.zIndex, transform: s.transform });
|
|
124
|
+
}
|
|
125
|
+
const computedStyle = {};
|
|
126
|
+
for (const key of ["display","position","inset","width","height","margin","padding","gap","overflow","opacity","visibility","zIndex","transform","transformOrigin","color","background","border","borderRadius","boxShadow","fontFamily","fontSize","fontWeight","lineHeight","textAlign","objectFit","pointerEvents"]) computedStyle[key] = style[key];
|
|
127
|
+
return { tag: el.tagName.toLowerCase(), html: el.outerHTML.slice(0, 2000), rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, computedStyle, ancestors };
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async act(sessionId, actions = []) {
|
|
132
|
+
if (!Array.isArray(actions) || !actions.length) throw new Error("actions must be a non-empty array");
|
|
133
|
+
if (actions.length > MAX_ACTIONS) throw new Error(`actions cannot exceed ${MAX_ACTIONS}`);
|
|
134
|
+
const { page } = this.get(sessionId);
|
|
135
|
+
const trace = [];
|
|
136
|
+
for (let i = 0; i < actions.length; i++) {
|
|
137
|
+
const action = actions[i] || {};
|
|
138
|
+
const op = action.type || action.op;
|
|
139
|
+
const step = { index: i, type: op };
|
|
140
|
+
try {
|
|
141
|
+
if (["click", "hover", "fill", "type", "check", "select"].includes(op)) {
|
|
142
|
+
const locator = await resolveLocator(page, action.selector);
|
|
143
|
+
await locator.first().waitFor({ state: "visible", timeout: action.timeoutMs || CLICK_TIMEOUT_MS });
|
|
144
|
+
if (op === "click") await locator.first().click();
|
|
145
|
+
else if (op === "hover") await locator.first().hover();
|
|
146
|
+
else if (op === "fill") await locator.first().fill(String(action.text ?? action.value ?? ""));
|
|
147
|
+
else if (op === "type") await locator.first().pressSequentially(String(action.text ?? ""), { delay: action.delayMs || 10 });
|
|
148
|
+
else if (op === "check") await locator.first().setChecked(action.checked !== false);
|
|
149
|
+
else await locator.first().selectOption(action.value);
|
|
150
|
+
step.selector = action.selector;
|
|
151
|
+
} else if (op === "press") await page.keyboard.press(String(action.key));
|
|
152
|
+
else if (op === "scroll") await page.mouse.wheel(Number(action.deltaX) || 0, Number(action.deltaY) || 0);
|
|
153
|
+
else if (op === "wait") await (await resolveLocator(page, action.selector)).first().waitFor({ state: action.state || "visible", timeout: action.timeoutMs || WAIT_DEFAULT_TIMEOUT_MS });
|
|
154
|
+
else if (op === "sleep") await page.waitForTimeout(Math.max(0, Number(action.ms) || 0));
|
|
155
|
+
else if (op === "navigate") await gotoOrThrow(page, action.url, { timeoutMs: action.timeoutMs });
|
|
156
|
+
else if (op === "reload") await page.reload({ waitUntil: "domcontentloaded" });
|
|
157
|
+
else if (op === "eval") step.result = await page.evaluate(String(action.js || ""));
|
|
158
|
+
else throw new Error(`unknown action type: ${op}`);
|
|
159
|
+
if (action.settleMs) await page.waitForTimeout(Number(action.settleMs));
|
|
160
|
+
step.ok = true;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
step.ok = false; step.error = error.message; trace.push(step); break;
|
|
163
|
+
}
|
|
164
|
+
trace.push(step);
|
|
165
|
+
}
|
|
166
|
+
return { sessionId, url: page.url(), title: await page.title(), trace, failed: trace.some((step) => !step.ok) };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async screenshot(sessionId, { out, full = false, selector } = {}) {
|
|
170
|
+
const { page } = this.get(sessionId);
|
|
171
|
+
const file = out || join(this.outputDir, `pursr-${sessionId}-${Date.now()}.png`);
|
|
172
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
173
|
+
if (selector) {
|
|
174
|
+
const locator = await resolveLocator(page, selector);
|
|
175
|
+
await locator.first().screenshot({ path: file });
|
|
176
|
+
} else await page.screenshot({ path: file, fullPage: !!full });
|
|
177
|
+
return { sessionId, out: file, url: page.url(), data: readFileSync(file).toString("base64"), mimeType: "image/png" };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
diagnostics(sessionId, { clear = false } = {}) {
|
|
181
|
+
const session = this.get(sessionId);
|
|
182
|
+
const result = JSON.parse(JSON.stringify(session.diagnostics));
|
|
183
|
+
if (clear) {
|
|
184
|
+
session.diagnostics.console.length = 0;
|
|
185
|
+
session.diagnostics.errors.length = 0;
|
|
186
|
+
session.diagnostics.requests.length = 0;
|
|
187
|
+
session.diagnostics.responses.length = 0;
|
|
188
|
+
}
|
|
189
|
+
return { sessionId, ...result };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async close(sessionId) {
|
|
193
|
+
const id = String(sessionId || "");
|
|
194
|
+
const session = this.sessions.get(id);
|
|
195
|
+
if (!session) return { sessionId: id, closed: false };
|
|
196
|
+
this.sessions.delete(id);
|
|
197
|
+
try { await session.browser.close(); } catch {}
|
|
198
|
+
return { sessionId: id, closed: true };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async closeAll() {
|
|
202
|
+
await Promise.all([...this.sessions.keys()].map((id) => this.close(id)));
|
|
203
|
+
}
|
|
204
|
+
}
|