pursor 0.2.0 → 0.3.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/src/auth.js ADDED
@@ -0,0 +1,92 @@
1
+ // pursor — auth state (browser storage state) management.
2
+ //
3
+ // Playwright's `storageState` is the canonical way to persist
4
+ // cookies + localStorage between browser sessions. pursor wraps it
5
+ // in a small CLI/library API so users can:
6
+ // 1. login once interactively and save the state
7
+ // 2. reuse the saved state in any subsequent capture (CI included)
8
+ //
9
+ // Storage layout:
10
+ // ~/.pursor/auth/<project>/<name>.json
11
+ //
12
+ // Override with PURSOR_AUTH_DIR.
13
+ //
14
+ // Public API:
15
+ // saveAuthState({ project, name, state }) -> manifest + state file
16
+ // loadAuthState({ project, name }) -> state object (Playwright shape)
17
+ // listAuthStates(project) -> [{ name, ts, ... }]
18
+ // deleteAuthState({ project, name }) -> bool
19
+ //
20
+ // CLI:
21
+ // pursor auth save <project> <name> --from <state.json>
22
+ // pursor auth load <project> <name> --out <state.json>
23
+ // pursor auth list [project]
24
+ // pursor auth delete <project> <name>
25
+
26
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, rmSync } from "node:fs";
27
+ import { join } from "node:path";
28
+ import { homedir } from "node:os";
29
+ import { nowIso } from "./util.js";
30
+
31
+ function authRoot() {
32
+ return process.env.PURSOR_AUTH_DIR || join(homedir(), ".pursor", "auth");
33
+ }
34
+
35
+ function authPath(project, name) {
36
+ const root = authRoot();
37
+ const proj = (project || "default").replace(/[^a-zA-Z0-9._-]+/g, "_");
38
+ const nm = String(name || "default").replace(/[^a-zA-Z0-9._-]+/g, "_");
39
+ return join(root, proj, `${nm}.json`);
40
+ }
41
+
42
+ export function saveAuthState({ project, name, state }) {
43
+ if (!state) throw new Error("saveAuthState: missing state object");
44
+ // state shape from Playwright: { cookies, origins }
45
+ if (!Array.isArray(state.cookies)) state.cookies = [];
46
+ if (!Array.isArray(state.origins)) state.origins = [];
47
+ const file = authPath(project, name);
48
+ mkdirSync(join(file, ".."), { recursive: true });
49
+ const blob = {
50
+ _meta: { project: project || "default", name, ts: nowIso() },
51
+ cookies: state.cookies,
52
+ origins: state.origins,
53
+ };
54
+ writeFileSync(file, JSON.stringify(blob, null, 2), "utf8");
55
+ return { file, ...blob._meta };
56
+ }
57
+
58
+ export function loadAuthState({ project, name }) {
59
+ const file = authPath(project, name);
60
+ if (!existsSync(file)) return null;
61
+ try {
62
+ const blob = JSON.parse(readFileSync(file, "utf8"));
63
+ return { cookies: blob.cookies || [], origins: blob.origins || [] };
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ export function listAuthStates(project) {
70
+ const root = join(authRoot(), (project || "default").replace(/[^a-zA-Z0-9._-]+/g, "_"));
71
+ if (!existsSync(root)) return [];
72
+ const out = [];
73
+ for (const f of readdirSync(root)) {
74
+ if (!f.endsWith(".json")) continue;
75
+ try {
76
+ const blob = JSON.parse(readFileSync(join(root, f), "utf8"));
77
+ out.push({
78
+ name: blob?._meta?.name || f.replace(/\.json$/, ""),
79
+ ts: blob?._meta?.ts || null,
80
+ cookies: (blob.cookies || []).length,
81
+ origins: (blob.origins || []).length,
82
+ });
83
+ } catch {}
84
+ }
85
+ return out.sort((a, b) => (b.ts || "").localeCompare(a.ts || ""));
86
+ }
87
+
88
+ export function deleteAuthState({ project, name }) {
89
+ const file = authPath(project, name);
90
+ if (!existsSync(file)) return false;
91
+ try { rmSync(file, { force: true }); return true; } catch { return false; }
92
+ }
package/src/har.js ADDED
@@ -0,0 +1,159 @@
1
+ // pursor — HAR (HTTP Archive) capture.
2
+ //
3
+ // Hooks page.on("request") / page.on("response") / page.on("requestfailed")
4
+ // on an active page and produces a HAR 1.2 spec blob:
5
+ // { log: { version: "1.2", creator: {...}, browser: {...}, pages: [...], entries: [...] } }
6
+ //
7
+ // Use in code:
8
+ // const { startHarCapture, stopHarCapture } = await import("pursor/har");
9
+ // const har = await startHarCapture(page);
10
+ // await page.goto(url);
11
+ // await stopHarCapture(page); // returns the HAR object
12
+ //
13
+ // Or via CLI/sweep:
14
+ // pursor shoot <url> --har ./out/req.har.json
15
+ //
16
+ // HAR is useful for:
17
+ // - killing flakiness from analytics/ads/CDN by mocking responses later
18
+ // - inspecting what the page actually fetched during a capture
19
+ // - regression diffing along with visual diffs
20
+
21
+ import { writeFileSync, mkdirSync } from "node:fs";
22
+ import { dirname } from "node:path";
23
+ import { nowIso } from "./util.js";
24
+
25
+ const SYM = Symbol.for("pursor.har.capture");
26
+
27
+ function makeEntry(req, resp, startTs, endTs) {
28
+ const url = req.url();
29
+ const u = new URL(url);
30
+ const headersList = (obj) => {
31
+ if (!obj) return [];
32
+ try {
33
+ const out = [];
34
+ for (const [name, value] of Object.entries(obj)) {
35
+ if (Array.isArray(value)) {
36
+ for (const v of value) out.push({ name, value: String(v) });
37
+ } else {
38
+ out.push({ name, value: String(value) });
39
+ }
40
+ }
41
+ return out;
42
+ } catch { return []; }
43
+ };
44
+ const queryString = u.search ? u.search.slice(1).split("&").filter(Boolean).map(kv => {
45
+ const [k, v = ""] = kv.split("=");
46
+ return { name: decodeURIComponent(k), value: decodeURIComponent(v) };
47
+ }) : [];
48
+ const postData = req.postData() ? { mimeType: req.postData() || "application/octet-stream", text: req.postData() } : undefined;
49
+ const entry = {
50
+ pageref: req.frame()?.url?.() || "_top",
51
+ startedDateTime: new Date(startTs).toISOString(),
52
+ time: Math.max(0, endTs - startTs),
53
+ request: {
54
+ method: req.method(),
55
+ url,
56
+ httpVersion: "HTTP/1.1",
57
+ cookies: [],
58
+ headers: headersList(req.headers()),
59
+ queryString,
60
+ headersSize: -1,
61
+ bodySize: postData?.text?.length || 0,
62
+ postData,
63
+ },
64
+ response: resp ? {
65
+ status: resp.status(),
66
+ statusText: resp.statusText() || "",
67
+ httpVersion: "HTTP/1.1",
68
+ cookies: [],
69
+ headers: headersList(resp.headers()),
70
+ content: { size: -1, mimeType: resp.headers()?.["content-type"] || "" },
71
+ redirectURL: resp.headers()?.location || "",
72
+ headersSize: -1,
73
+ bodySize: -1,
74
+ } : { status: 0, statusText: "Failed", httpVersion: "HTTP/1.1", cookies: [], headers: [], content: { size: 0, mimeType: "" }, redirectURL: "", headersSize: -1, bodySize: -1 },
75
+ cache: {},
76
+ timings: { send: 0, wait: Math.max(0, endTs - startTs), receive: 0 },
77
+ serverIPAddress: "",
78
+ connection: "",
79
+ };
80
+ return entry;
81
+ }
82
+
83
+ export async function startHarCapture(page, opts = {}) {
84
+ if (!page) throw new Error("startHarCapture: page required");
85
+ if (page[SYM]) return page[SYM]; // idempotent
86
+ const started = Date.now();
87
+ const entries = [];
88
+ const pending = new Map(); // req -> startTs
89
+ const state = {
90
+ started,
91
+ creator: { name: "pursor", version: opts.version || "0.3.0" },
92
+ browser: { name: "chromium", version: "playwright-core" },
93
+ pages: [],
94
+ entries,
95
+ pending,
96
+ };
97
+ const onReq = (req) => {
98
+ pending.set(req, Date.now());
99
+ };
100
+ const onResp = async (resp) => {
101
+ try {
102
+ const req = resp.request();
103
+ const startTs = pending.get(req) || Date.now();
104
+ pending.delete(req);
105
+ const endTs = Date.now();
106
+ entries.push(makeEntry(req, resp, startTs, endTs));
107
+ } catch {}
108
+ };
109
+ const onFailed = (req) => {
110
+ try {
111
+ const startTs = pending.get(req) || Date.now();
112
+ pending.delete(req);
113
+ entries.push(makeEntry(req, null, startTs, Date.now()));
114
+ } catch {}
115
+ };
116
+ page.on("request", onReq);
117
+ page.on("response", onResp);
118
+ page.on("requestfailed", onFailed);
119
+ state._teardown = () => {
120
+ try { page.off("request", onReq); } catch {}
121
+ try { page.off("response", onResp); } catch {}
122
+ try { page.off("requestfailed", onFailed); } catch {}
123
+ };
124
+ page[SYM] = state;
125
+ return state;
126
+ }
127
+
128
+ export function stopHarCapture(page) {
129
+ if (!page || !page[SYM]) return null;
130
+ const state = page[SYM];
131
+ try { state._teardown?.(); } catch {}
132
+ delete page[SYM];
133
+ return finalizeHar(state);
134
+ }
135
+
136
+ export function finalizeHar(state) {
137
+ if (!state) return null;
138
+ return {
139
+ log: {
140
+ version: "1.2",
141
+ creator: state.creator,
142
+ browser: state.browser,
143
+ pages: state.pages,
144
+ entries: state.entries,
145
+ },
146
+ _meta: {
147
+ started: state.started,
148
+ finished: Date.now(),
149
+ entryCount: state.entries.length,
150
+ },
151
+ };
152
+ }
153
+
154
+ export async function writeHar(har, file) {
155
+ if (!har || !file) return null;
156
+ mkdirSync(dirname(file), { recursive: true });
157
+ writeFileSync(file, JSON.stringify(har, null, 2), "utf8");
158
+ return file;
159
+ }
package/src/index.js CHANGED
@@ -1,90 +1,95 @@
1
- // pursor — public library API.
2
- //
3
- // This is the entry point for consumers who want to embed pursor
4
- // inside their own scripts (instead of using the CLI). The CLI in
5
- // bin/pursor.mjs is a thin wrapper around the same exports.
6
- //
7
- // All capture / sweep helpers return a `Result` object: the path to the
8
- // PNG, a sidecar JSON metadata object, and timing info. They never throw
9
- // on capture-time errors — those are reported in the result so the
10
- // caller can decide how to react.
11
- //
12
- // The plugin system lives in src/plugin.js — see that file for how to
13
- // write a custom plugin (viewport, sweep-op, before/after hooks).
14
-
15
- import { runShoot } from "./shoot.js";
16
- import { runSweep } from "./sweep.js";
17
- import { runEveryViewport } from "./every-viewport.js";
18
- import { runFrames } from "./frames.js";
19
- import { runHover } from "./hover.js";
20
- import { runDiff } from "./diff.js";
21
- import { runProbe } from "./probe.js";
22
- import { runShot } from "./shot.js";
23
- import { runEval } from "./eval.js";
24
- import { runClick, runType, runWait, runSeq } from "./interact.js";
25
- import { listViewports, resolveViewport, VIEWPORTS } from "./viewport.js";
26
- import { applyCamera, waitForStableFrame } from "./overlays.js";
27
- import { loadPlugins, registerPlugin, listPlugins, getSweepOp, getViewportPreset, listViewportPresets, getFlagHelp } from "./plugin.js";
28
- import { launch, newPage } from "./runway.js";
29
- import { parseFlags, asNum, asBool, nowIso, shortHash, escapeHtml, renderSweepHtml, renderEveryViewportHtml, findStepPng, readArg, makeOut } from "./util.js";
30
- import { resolveLocator, parseTextSelector } from "./selector.js";
31
- import { captureDomSnapshot, captureDomSnapshotSidecar } from "./dom-snapshot.js";
32
- import { runAudit } from "./plugin-audit.js";
33
- import { resolveHealedSelector, healStepAction } from "./selector-heal.js";
34
- import { writeCiOutput } from "./ci-output.js";
35
- import { PursorMCPServer, loadConfig as loadMcpConfig, MCP_VERSION } from "./mcp.js";
36
- import { createRequire } from "node:module";
37
- import { saveBaseline, loadBaseline, listBaselines, approveBaseline, diffKey, resolveBaselinePath } from "./baseline.js";
38
- import { validateSweepPlan, registerSweepOp } from "./sweep-schema.js";
39
- import { listResources, readResource, recordResource } from "./mcp-resources.js";
40
-
41
-
42
- // Derive VERSION from package.json to prevent drift
43
- const __require = createRequire(import.meta.url);
44
- const pkg = __require("../package.json");
45
- const VERSION = pkg.version;
46
-
47
- export {
48
- // CLI-style actions
49
- runProbe, runShot, runEval, runClick, runType, runWait, runSeq,
50
- runShoot, runFrames, runHover, runSweep, runDiff, runEveryViewport,
51
- // v3: audit, DOM snapshot, MCP
52
- runAudit, captureDomSnapshot, captureDomSnapshotSidecar,
53
- // viewport + camera helpers
54
- listViewports, resolveViewport, VIEWPORTS,
55
- applyCamera, waitForStableFrame,
56
- // plugin system
57
- loadPlugins, registerPlugin, listPlugins, getSweepOp, getViewportPreset, listViewportPresets, getFlagHelp,
58
- // low-level helpers (for plugin authors)
59
- launch, newPage,
60
- parseFlags, asNum, asBool, nowIso, shortHash, escapeHtml, renderSweepHtml, renderEveryViewportHtml, findStepPng, readArg, makeOut,
61
- resolveLocator, parseTextSelector,
62
- // v3: selector healing, CI output, MCP server
63
- resolveHealedSelector, healStepAction,
64
- writeCiOutput,
65
- PursorMCPServer, loadMcpConfig, MCP_VERSION,
66
- // v4: baselines, sweep validation, MCP resources
67
- saveBaseline, loadBaseline, listBaselines, approveBaseline, diffKey, resolveBaselinePath,
68
- validateSweepPlan, registerSweepOp,
69
- listResources, readResource, recordResource,
70
- VERSION,
71
- };
72
-
73
- export default {
74
- runProbe, runShot, runEval, runClick, runType, runWait, runSeq,
75
- runShoot, runFrames, runHover, runSweep, runDiff, runEveryViewport,
76
- runAudit, captureDomSnapshot, captureDomSnapshotSidecar,
77
- listViewports, resolveViewport, VIEWPORTS,
78
- applyCamera, waitForStableFrame,
79
- loadPlugins, registerPlugin, listPlugins, getSweepOp, getViewportPreset, listViewportPresets, getFlagHelp,
80
- launch, newPage,
81
- parseFlags, asNum, asBool, nowIso, shortHash, escapeHtml, renderSweepHtml, renderEveryViewportHtml, findStepPng, readArg, makeOut,
82
- resolveLocator, parseTextSelector,
83
- resolveHealedSelector, healStepAction,
84
- writeCiOutput,
85
- PursorMCPServer, loadMcpConfig, MCP_VERSION,
86
- saveBaseline, loadBaseline, listBaselines, approveBaseline, diffKey, resolveBaselinePath,
87
- validateSweepPlan, registerSweepOp,
88
- listResources, readResource, recordResource,
89
- VERSION,
1
+ // pursor — public library API.
2
+ //
3
+ // This is the entry point for consumers who want to embed pursor
4
+ // inside their own scripts (instead of using the CLI). The CLI in
5
+ // bin/pursor.mjs is a thin wrapper around the same exports.
6
+ //
7
+ // All capture / sweep helpers return a `Result` object: the path to the
8
+ // PNG, a sidecar JSON metadata object, and timing info. They never throw
9
+ // on capture-time errors — those are reported in the result so the
10
+ // caller can decide how to react.
11
+ //
12
+ // The plugin system lives in src/plugin.js — see that file for how to
13
+ // write a custom plugin (viewport, sweep-op, before/after hooks).
14
+
15
+ import { runShoot } from "./shoot.js";
16
+ import { runSweep } from "./sweep.js";
17
+ import { runEveryViewport } from "./every-viewport.js";
18
+ import { runFrames } from "./frames.js";
19
+ import { runHover } from "./hover.js";
20
+ import { runDiff } from "./diff.js";
21
+ import { runProbe } from "./probe.js";
22
+ import { runShot } from "./shot.js";
23
+ import { runEval } from "./eval.js";
24
+ import { runClick, runType, runWait, runSeq } from "./interact.js";
25
+ import { listViewports, resolveViewport, VIEWPORTS } from "./viewport.js";
26
+ import { applyCamera, waitForStableFrame } from "./overlays.js";
27
+ import { loadPlugins, registerPlugin, listPlugins, getSweepOp, getViewportPreset, listViewportPresets, getFlagHelp } from "./plugin.js";
28
+ import { launch, newPage } from "./runway.js";
29
+ import { parseFlags, asNum, asBool, nowIso, shortHash, escapeHtml, renderSweepHtml, renderEveryViewportHtml, findStepPng, readArg, makeOut } from "./util.js";
30
+ import { resolveLocator, parseTextSelector } from "./selector.js";
31
+ import { captureDomSnapshot, captureDomSnapshotSidecar } from "./dom-snapshot.js";
32
+ import { runAudit } from "./plugin-audit.js";
33
+ import { resolveHealedSelector, healStepAction } from "./selector-heal.js";
34
+ import { writeCiOutput } from "./ci-output.js";
35
+ import { PursorMCPServer, loadConfig as loadMcpConfig, MCP_VERSION } from "./mcp.js";
36
+ import { createRequire } from "node:module";
37
+ import { saveBaseline, loadBaseline, listBaselines, approveBaseline, diffKey, resolveBaselinePath } from "./baseline.js";
38
+ import { validateSweepPlan, registerSweepOp } from "./sweep-schema.js";
39
+ import { listResources, readResource, recordResource } from "./mcp-resources.js";
40
+ import { startHarCapture, stopHarCapture, writeHar } from "./har.js";
41
+ import { saveAuthState, loadAuthState, listAuthStates, deleteAuthState } from "./auth.js";
42
+
43
+
44
+ // Derive VERSION from package.json to prevent drift
45
+ const __require = createRequire(import.meta.url);
46
+ const pkg = __require("../package.json");
47
+ const VERSION = pkg.version;
48
+
49
+ export {
50
+ // CLI-style actions
51
+ runProbe, runShot, runEval, runClick, runType, runWait, runSeq,
52
+ runShoot, runFrames, runHover, runSweep, runDiff, runEveryViewport,
53
+ // v3: audit, DOM snapshot, MCP
54
+ runAudit, captureDomSnapshot, captureDomSnapshotSidecar,
55
+ // viewport + camera helpers
56
+ listViewports, resolveViewport, VIEWPORTS,
57
+ applyCamera, waitForStableFrame,
58
+ // plugin system
59
+ loadPlugins, registerPlugin, listPlugins, getSweepOp, getViewportPreset, listViewportPresets, getFlagHelp,
60
+ // low-level helpers (for plugin authors)
61
+ launch, newPage,
62
+ parseFlags, asNum, asBool, nowIso, shortHash, escapeHtml, renderSweepHtml, renderEveryViewportHtml, findStepPng, readArg, makeOut,
63
+ resolveLocator, parseTextSelector,
64
+ // v3: selector healing, CI output, MCP server
65
+ resolveHealedSelector, healStepAction,
66
+ writeCiOutput,
67
+ PursorMCPServer, loadMcpConfig, MCP_VERSION,
68
+ // v4: baselines, sweep validation, MCP resources
69
+ saveBaseline, loadBaseline, listBaselines, approveBaseline, diffKey, resolveBaselinePath,
70
+ validateSweepPlan, registerSweepOp,
71
+ listResources, readResource, recordResource,
72
+ // v5: HAR capture, auth state, parallel sweep
73
+ startHarCapture, stopHarCapture, writeHar,
74
+ saveAuthState, loadAuthState, listAuthStates, deleteAuthState,
75
+ VERSION,
76
+ };
77
+
78
+ export default {
79
+ runProbe, runShot, runEval, runClick, runType, runWait, runSeq,
80
+ runShoot, runFrames, runHover, runSweep, runDiff, runEveryViewport,
81
+ runAudit, captureDomSnapshot, captureDomSnapshotSidecar,
82
+ listViewports, resolveViewport, VIEWPORTS,
83
+ applyCamera, waitForStableFrame,
84
+ loadPlugins, registerPlugin, listPlugins, getSweepOp, getViewportPreset, listViewportPresets, getFlagHelp,
85
+ launch, newPage,
86
+ parseFlags, asNum, asBool, nowIso, shortHash, escapeHtml, renderSweepHtml, renderEveryViewportHtml, findStepPng, readArg, makeOut,
87
+ resolveLocator, parseTextSelector,
88
+ resolveHealedSelector, healStepAction,
89
+ writeCiOutput,
90
+ PursorMCPServer, loadMcpConfig, MCP_VERSION,
91
+ saveBaseline, loadBaseline, listBaselines, approveBaseline, diffKey, resolveBaselinePath,
92
+ validateSweepPlan, registerSweepOp,
93
+ listResources, readResource, recordResource,
94
+ VERSION,
90
95
  };
package/src/runway.js CHANGED
@@ -50,7 +50,7 @@ export async function launch() {
50
50
  return await chromium.launch({ headless: true, executablePath: exec, args: BROWSER_ARGS });
51
51
  }
52
52
 
53
- export async function newPage(browser, viewport) {
53
+ export async function newPage(browser, viewport, opts = {}) {
54
54
  const ctx = await browser.newContext({
55
55
  viewport: { width: viewport.width, height: viewport.height },
56
56
  deviceScaleFactor: viewport.dpr || 1,
@@ -58,6 +58,9 @@ export async function newPage(browser, viewport) {
58
58
  colorScheme: "light",
59
59
  hasTouch: !!(viewport.name && viewport.name.startsWith("mobile")),
60
60
  isMobile: !!(viewport.name && viewport.name.startsWith("mobile")),
61
+ storageState: opts.storageState || undefined,
61
62
  });
62
- return await ctx.newPage();
63
+ const page = await ctx.newPage();
64
+ page._pursorContext = ctx;
65
+ return page;
63
66
  }
package/src/shoot.js CHANGED
@@ -9,6 +9,8 @@ import {
9
9
  } from "./overlays.js";
10
10
  import { asNum, asBool, nowIso, writeSidecar, requireArg } from "./util.js";
11
11
  import { runBeforeShoot, runAfterShoot } from "./plugin.js";
12
+ import { startHarCapture, stopHarCapture, writeHar } from "./har.js";
13
+ import { loadAuthState } from "./auth.js";
12
14
 
13
15
  export async function runShoot({ url, out, flags = {}, prepare, browser: extBrowser }) {
14
16
  requireArg("url", url, "string");
@@ -18,7 +20,9 @@ export async function runShoot({ url, out, flags = {}, prepare, browser: extBrow
18
20
  const cleanups = [];
19
21
  try {
20
22
  return await (async () => {
21
- const page = await newPage(browser, viewport);
23
+ const page = await newPage(browser, viewport, {
24
+ storageState: flags["auth-state"] ? loadAuthState({ project: flags["auth-project"] || "default", name: flags["auth-state"] }) : undefined,
25
+ });
22
26
  const r = await gotoOrThrow(page, url);
23
27
  await settle(page);
24
28
 
@@ -41,8 +45,16 @@ export async function runShoot({ url, out, flags = {}, prepare, browser: extBrow
41
45
  await page.waitForTimeout(400);
42
46
  }
43
47
 
48
+ // Optional HAR capture
49
+ const harState = flags.har ? await startHarCapture(page) : null;
44
50
  await page.screenshot({ path: out, fullPage: asBool(flags.full, false) });
45
51
  const meta = { url, out, ts: nowIso(), status: r.status, title: r.title, viewport, flags: { ...flags } };
52
+ if (harState) {
53
+ const har = stopHarCapture(page);
54
+ const harFile = await writeHar(har, String(flags.har));
55
+ meta.har = harFile;
56
+ meta.harEntryCount = har?._meta?.entryCount || 0;
57
+ }
46
58
  await runAfterShoot(ctx, meta);
47
59
  return meta;
48
60
  })().catch(e => ({ url, out, ts: nowIso(), error: e.message, viewport, flags: { ...flags } }));