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 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 24 subpath modules, so you can embed the browser and QA primitives in your own tooling.
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.9.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": [
@@ -0,0 +1,16 @@
1
+ [
2
+ {
3
+ "type": "annotate",
4
+ "selector": "body",
5
+ "label": "Visual Operator session",
6
+ "durationMs": 300
7
+ },
8
+ {
9
+ "type": "sleep",
10
+ "ms": 700
11
+ },
12
+ {
13
+ "type": "clearAnnotations",
14
+ "keepCursor": true
15
+ }
16
+ ]
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);
@@ -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 = { id, browser, page, context: page._pursrContext, viewport, mode, visual, operatorOptions, diagnostics, createdAt: new Date().toISOString() };
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 && op === "click" && point) await markVisualClick(page, point.x, point.y, { ...operatorOptions, color: action.color });
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
- return { sessionId: id, closed: true };
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() {