pursr 0.5.0 → 0.7.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/frames.js CHANGED
@@ -1,34 +1,34 @@
1
- // Frames: capture N screenshots at intervalMs.
2
-
3
- import { launch, newPage } from "./runway.js";
4
- import { resolveViewport } from "./viewport.js";
5
- import { gotoOrThrow, settle } from "./overlays.js";
6
- import { asNum, nowIso, shortHash, requireArg } from "./util.js";
7
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
- import { join } from "node:path";
9
-
10
- export async function runFrames({ url, count, intervalMs, outDir, flags = {}, browser: extBrowser }) {
11
- requireArg("url", url, "string");
12
- const n = Math.max(1, Math.min(120, asNum(count, 8)));
13
- const stepMs = Math.max(16, asNum(intervalMs, 250));
14
- const dir = outDir;
15
- mkdirSync(dir, { recursive: true });
16
- const viewport = resolveViewport(flags);
17
- const ownBrowser = !extBrowser;
18
- const browser = extBrowser || await launch();
19
- const meta = { url, outDir: dir, count: n, intervalMs: stepMs, viewport, files: [], ts: nowIso() };
20
- try {
21
- const page = await newPage(browser, viewport);
22
- const r = await gotoOrThrow(page, url); await settle(page);
23
- meta.status = r.status; meta.title = r.title;
24
- for (let i = 0; i < n; i++) {
25
- const f = join(dir, `frame-${String(i).padStart(3, "0")}.png`);
26
- await page.screenshot({ path: f, fullPage: false });
27
- const buf = readFileSync(f);
28
- meta.files.push({ i, out: f, size: buf.length, hash: shortHash(buf) });
29
- if (i + 1 < n) await page.waitForTimeout(stepMs);
30
- }
31
- writeFileSync(join(dir, "frames.json"), JSON.stringify(meta, null, 2));
32
- return meta;
33
- } finally { if (ownBrowser) try { await browser.close(); } catch {} }
1
+ // Frames: capture N screenshots at intervalMs.
2
+
3
+ import { launch, newPage } from "./runway.js";
4
+ import { resolveViewport } from "./viewport.js";
5
+ import { gotoOrThrow, settle } from "./overlays.js";
6
+ import { asNum, nowIso, shortHash, requireArg } from "./util.js";
7
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ export async function runFrames({ url, count, intervalMs, outDir, flags = {}, browser: extBrowser }) {
11
+ requireArg("url", url, "string");
12
+ const n = Math.max(1, Math.min(120, asNum(count, 8)));
13
+ const stepMs = Math.max(16, asNum(intervalMs, 250));
14
+ const dir = outDir;
15
+ mkdirSync(dir, { recursive: true });
16
+ const viewport = resolveViewport(flags);
17
+ const ownBrowser = !extBrowser;
18
+ const browser = extBrowser || await launch();
19
+ const meta = { url, outDir: dir, count: n, intervalMs: stepMs, viewport, files: [], ts: nowIso() };
20
+ try {
21
+ const page = await newPage(browser, viewport);
22
+ const r = await gotoOrThrow(page, url); await settle(page);
23
+ meta.status = r.status; meta.title = r.title;
24
+ for (let i = 0; i < n; i++) {
25
+ const f = join(dir, `frame-${String(i).padStart(3, "0")}.png`);
26
+ await page.screenshot({ path: f, fullPage: false });
27
+ const buf = readFileSync(f);
28
+ meta.files.push({ i, out: f, size: buf.length, hash: shortHash(buf) });
29
+ if (i + 1 < n) await page.waitForTimeout(stepMs);
30
+ }
31
+ writeFileSync(join(dir, "frames.json"), JSON.stringify(meta, null, 2));
32
+ return meta;
33
+ } finally { if (ownBrowser) try { await browser.close(); } catch {} }
34
34
  }
package/src/har.js CHANGED
@@ -1,159 +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;
1
+ // pursr — 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("pursr/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
+ // pursr shoot <url> --har ./out/req.har.json
15
+ //
16
+ // HAR is useful for:
17
+ // pursr - killing flakiness from analytics/ads/CDN by mocking responses later
18
+ // pursr - inspecting what the page actually fetched during a capture
19
+ // pursr - 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("pursr.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: "pursr", 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
159
  }
package/src/hover.js CHANGED
@@ -1,26 +1,26 @@
1
- // Hover capture: navigate, hover a selector, screenshot.
2
-
3
- import { launch, newPage } from "./runway.js";
4
- import { resolveViewport } from "./viewport.js";
5
- import { gotoOrThrow, settle, CLICK_TIMEOUT_MS } from "./overlays.js";
6
- import { resolveLocator } from "./selector.js";
7
- import { asNum, asBool, nowIso, writeSidecar, requireArg } from "./util.js";
8
-
9
- export async function runHover({ url, selector, out, flags = {} }) {
10
- requireArg("url", url, "string");
11
- requireArg("selector", selector, "string");
12
- const viewport = resolveViewport(flags);
13
- const browser = await launch();
14
- try {
15
- const page = await newPage(browser, viewport);
16
- const r = await gotoOrThrow(page, url); await settle(page);
17
- const loc = await resolveLocator(page, selector);
18
- await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
19
- await loc.first().hover({ timeout: CLICK_TIMEOUT_MS });
20
- await page.waitForTimeout(asNum(flags["hover-ms"], 250));
21
- if (out) await page.screenshot({ path: out, fullPage: asBool(flags.full, false) });
22
- const meta = { ...r, url, out, selector, viewport, ts: nowIso() };
23
- if (out) await writeSidecar(meta);
24
- return meta;
25
- } finally { try { await browser.close(); } catch {} }
1
+ // Hover capture: navigate, hover a selector, screenshot.
2
+
3
+ import { launch, newPage } from "./runway.js";
4
+ import { resolveViewport } from "./viewport.js";
5
+ import { gotoOrThrow, settle, CLICK_TIMEOUT_MS } from "./overlays.js";
6
+ import { resolveLocator } from "./selector.js";
7
+ import { asNum, asBool, nowIso, writeSidecar, requireArg } from "./util.js";
8
+
9
+ export async function runHover({ url, selector, out, flags = {} }) {
10
+ requireArg("url", url, "string");
11
+ requireArg("selector", selector, "string");
12
+ const viewport = resolveViewport(flags);
13
+ const browser = await launch();
14
+ try {
15
+ const page = await newPage(browser, viewport);
16
+ const r = await gotoOrThrow(page, url); await settle(page);
17
+ const loc = await resolveLocator(page, selector);
18
+ await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
19
+ await loc.first().hover({ timeout: CLICK_TIMEOUT_MS });
20
+ await page.waitForTimeout(asNum(flags["hover-ms"], 250));
21
+ if (out) await page.screenshot({ path: out, fullPage: asBool(flags.full, false) });
22
+ const meta = { ...r, url, out, selector, viewport, ts: nowIso() };
23
+ if (out) await writeSidecar(meta);
24
+ return meta;
25
+ } finally { try { await browser.close(); } catch {} }
26
26
  }
package/src/index.js CHANGED
@@ -1,8 +1,8 @@
1
- // pursor — public library API.
1
+ // pursr — public library API.
2
2
  //
3
- // This is the entry point for consumers who want to embed pursor
3
+ // This is the entry point for consumers who want to embed pursr
4
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.
5
+ // bin/pursr.mjs is a thin wrapper around the same exports.
6
6
  //
7
7
  // All capture / sweep helpers return a `Result` object: the path to the
8
8
  // PNG, a sidecar JSON metadata object, and timing info. They never throw
@@ -17,7 +17,7 @@ import { runSweep } from "./sweep.js";
17
17
  import { runEveryViewport } from "./every-viewport.js";
18
18
  import { runFrames } from "./frames.js";
19
19
  import { runHover } from "./hover.js";
20
- import { runDiff } from "./diff.js";
20
+ import { runDiff, runDiffWithAi } from "./diff.js";
21
21
  import { runProbe } from "./probe.js";
22
22
  import { runShot } from "./shot.js";
23
23
  import { runEval } from "./eval.js";
@@ -32,7 +32,7 @@ import { captureDomSnapshot, captureDomSnapshotSidecar } from "./dom-snapshot.js
32
32
  import { runAudit } from "./plugin-audit.js";
33
33
  import { resolveHealedSelector, healStepAction } from "./selector-heal.js";
34
34
  import { writeCiOutput } from "./ci-output.js";
35
- import { PursorMCPServer, loadConfig as loadMcpConfig, MCP_VERSION } from "./mcp.js";
35
+ import { PursrMCPServer, loadConfig as loadMcpConfig, MCP_VERSION } from "./mcp.js";
36
36
  import { createRequire } from "node:module";
37
37
  import { saveBaseline, loadBaseline, listBaselines, approveBaseline, diffKey, resolveBaselinePath } from "./baseline.js";
38
38
  import { validateSweepPlan, registerSweepOp } from "./sweep-schema.js";
@@ -41,6 +41,8 @@ import { startHarCapture, stopHarCapture, writeHar } from "./har.js";
41
41
  import { saveAuthState, loadAuthState, listAuthStates, deleteAuthState } from "./auth.js";
42
42
  import { startWatch, matchGlob, shouldFire } from "./watch.js";
43
43
  import { runSnap, approveSnapsAsBaselines } from "./snap.js";
44
+ import { renderSweepPdf } from "./report.js";
45
+ import { aiDiffSummary, aiDiffSidecar } from "./ai-diff.js";
44
46
 
45
47
 
46
48
  // Derive VERSION from package.json to prevent drift
@@ -66,7 +68,7 @@ export {
66
68
  // v3: selector healing, CI output, MCP server
67
69
  resolveHealedSelector, healStepAction,
68
70
  writeCiOutput,
69
- PursorMCPServer, loadMcpConfig, MCP_VERSION,
71
+ PursrMCPServer, loadMcpConfig, MCP_VERSION,
70
72
  // v4: baselines, sweep validation, MCP resources
71
73
  saveBaseline, loadBaseline, listBaselines, approveBaseline, diffKey, resolveBaselinePath,
72
74
  validateSweepPlan, registerSweepOp,
@@ -77,6 +79,10 @@ export {
77
79
  // v6: watch mode, component snapshot
78
80
  startWatch, matchGlob, shouldFire,
79
81
  runSnap, approveSnapsAsBaselines,
82
+ // v6: PDF report, AI diff summary
83
+ runDiffWithAi,
84
+ renderSweepPdf,
85
+ aiDiffSummary, aiDiffSidecar,
80
86
  VERSION,
81
87
  };
82
88
 
@@ -92,9 +98,11 @@ export default {
92
98
  resolveLocator, parseTextSelector,
93
99
  resolveHealedSelector, healStepAction,
94
100
  writeCiOutput,
95
- PursorMCPServer, loadMcpConfig, MCP_VERSION,
101
+ PursrMCPServer, loadMcpConfig, MCP_VERSION,
96
102
  saveBaseline, loadBaseline, listBaselines, approveBaseline, diffKey, resolveBaselinePath,
97
103
  validateSweepPlan, registerSweepOp,
98
104
  listResources, readResource, recordResource,
105
+ // v6: PDF report, AI diff summary
106
+ runDiffWithAi, renderSweepPdf, aiDiffSummary, aiDiffSidecar,
99
107
  VERSION,
100
108
  };