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/LICENSE +20 -20
- package/README.md +549 -471
- package/assets/icon.svg +20 -20
- package/assets/logo.svg +28 -28
- package/assets/social-preview.svg +76 -76
- package/bin/pursr-mcp.mjs +10 -9
- package/bin/pursr.mjs +44 -12
- package/package.json +95 -92
- package/plans/m5.4-polish.json +21 -21
- package/plugins/plugin-audit.js +57 -57
- package/plugins/plugin-demo.js +63 -63
- package/src/ai-diff.js +125 -0
- package/src/auth.js +92 -91
- package/src/baseline.js +126 -125
- package/src/ci-output.js +156 -156
- package/src/diff.js +76 -48
- package/src/dom-snapshot.js +192 -192
- package/src/eval.js +17 -17
- package/src/every-viewport.js +51 -51
- package/src/frames.js +33 -33
- package/src/har.js +158 -158
- package/src/hover.js +25 -25
- package/src/index.js +15 -7
- package/src/interact.js +137 -137
- package/src/mcp-resources.js +111 -110
- package/src/mcp.js +436 -435
- package/src/overlays.js +169 -169
- package/src/plugin-audit.js +260 -260
- package/src/plugin.js +120 -120
- package/src/probe.js +19 -19
- package/src/report.js +176 -0
- package/src/runway.js +65 -65
- package/src/selector-heal.js +85 -85
- package/src/selector.js +38 -38
- package/src/shoot.js +73 -73
- package/src/shot.js +17 -17
- package/src/snap.js +128 -128
- package/src/sweep-schema.js +69 -69
- package/src/sweep.js +1 -1
- package/src/util.js +204 -188
- package/src/viewport.js +38 -38
- package/src/watch.js +134 -134
package/plans/m5.4-polish.json
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "m5.4-polish",
|
|
3
|
-
"base": "http://localhost:3010",
|
|
4
|
-
"outDir": "./out/m54-sweep",
|
|
5
|
-
"steps": [
|
|
6
|
-
{ "name": "baseline", "shoot": { "preset": "desktop-1280" } },
|
|
7
|
-
{ "name": "cursor-pointer", "shoot": { "preset": "desktop-1280", "cursor": "pointer" } },
|
|
8
|
-
{ "name": "grid-64", "shoot": { "preset": "desktop-1280", "grid": true, "grid-tile": 64 } },
|
|
9
|
-
{ "name": "grid-128", "shoot": { "preset": "desktop-1280", "grid": true, "grid-tile": 128 } },
|
|
10
|
-
{ "name": "layer-entity", "shoot": { "preset": "desktop-1280", "layer": "entity" } },
|
|
11
|
-
{ "name": "layer-terrain", "shoot": { "preset": "desktop-1280", "layer": "terrain" } },
|
|
12
|
-
{ "name": "no-hud", "shoot": { "preset": "desktop-1280", "no-hud": true } },
|
|
13
|
-
{ "name": "frozen", "shoot": { "preset": "desktop-1280", "no-animation": true } },
|
|
14
|
-
{ "name": "tablet-768", "shoot": { "preset": "tablet-768" } },
|
|
15
|
-
{ "name": "mobile-375", "shoot": { "preset": "mobile-375" } },
|
|
16
|
-
{ "name": "ultrawide-3440", "shoot": { "preset": "ultrawide-3440" } },
|
|
17
|
-
{ "name": "hover-build", "hover": { "selector": "text=Build" } },
|
|
18
|
-
{ "name": "hover-decor", "hover": { "selector": "text=Decor" } },
|
|
19
|
-
{ "name": "frames-8", "frames": { "count": 8, "intervalMs": 200 } },
|
|
20
|
-
{ "name": "diff-vs-baseline", "diff": { "ref": "baseline.png" } }
|
|
21
|
-
]
|
|
1
|
+
{
|
|
2
|
+
"name": "m5.4-polish",
|
|
3
|
+
"base": "http://localhost:3010",
|
|
4
|
+
"outDir": "./out/m54-sweep",
|
|
5
|
+
"steps": [
|
|
6
|
+
{ "name": "baseline", "shoot": { "preset": "desktop-1280" } },
|
|
7
|
+
{ "name": "cursor-pointer", "shoot": { "preset": "desktop-1280", "cursor": "pointer" } },
|
|
8
|
+
{ "name": "grid-64", "shoot": { "preset": "desktop-1280", "grid": true, "grid-tile": 64 } },
|
|
9
|
+
{ "name": "grid-128", "shoot": { "preset": "desktop-1280", "grid": true, "grid-tile": 128 } },
|
|
10
|
+
{ "name": "layer-entity", "shoot": { "preset": "desktop-1280", "layer": "entity" } },
|
|
11
|
+
{ "name": "layer-terrain", "shoot": { "preset": "desktop-1280", "layer": "terrain" } },
|
|
12
|
+
{ "name": "no-hud", "shoot": { "preset": "desktop-1280", "no-hud": true } },
|
|
13
|
+
{ "name": "frozen", "shoot": { "preset": "desktop-1280", "no-animation": true } },
|
|
14
|
+
{ "name": "tablet-768", "shoot": { "preset": "tablet-768" } },
|
|
15
|
+
{ "name": "mobile-375", "shoot": { "preset": "mobile-375" } },
|
|
16
|
+
{ "name": "ultrawide-3440", "shoot": { "preset": "ultrawide-3440" } },
|
|
17
|
+
{ "name": "hover-build", "hover": { "selector": "text=Build" } },
|
|
18
|
+
{ "name": "hover-decor", "hover": { "selector": "text=Decor" } },
|
|
19
|
+
{ "name": "frames-8", "frames": { "count": 8, "intervalMs": 200 } },
|
|
20
|
+
{ "name": "diff-vs-baseline", "diff": { "ref": "baseline.png" } }
|
|
21
|
+
]
|
|
22
22
|
}
|
package/plugins/plugin-audit.js
CHANGED
|
@@ -1,57 +1,57 @@
|
|
|
1
|
-
// Built-in plugin: axe-core accessibility audit.
|
|
2
|
-
//
|
|
3
|
-
// Adds:
|
|
4
|
-
// viewport "audit-canvas" — 1280x800 @1x ideal for audit screenshots
|
|
5
|
-
// sweepOp "audit" — run axe-core WCAG audit in a sweep plan
|
|
6
|
-
// sweepOp "every-viewport" — capture every viewport preset
|
|
7
|
-
//
|
|
8
|
-
// The audit sweep-op stores results to ctx.out/audit.json plus a
|
|
9
|
-
// highlighted screenshot. This is a thin wrapper around src/plugin-audit.js
|
|
10
|
-
// that exposes the same functionality through the plugin system.
|
|
11
|
-
|
|
12
|
-
import { runAudit } from "../src/plugin-audit.js";
|
|
13
|
-
import { launch, newPage } from "../src/runway.js";
|
|
14
|
-
import { listViewports, resolveViewport } from "../src/viewport.js";
|
|
15
|
-
import { runShootWithSidecar } from "../src/shoot.js";
|
|
16
|
-
import { join } from "node:path";
|
|
17
|
-
import { writeFileSync } from "node:fs";
|
|
18
|
-
|
|
19
|
-
export default {
|
|
20
|
-
name: "audit",
|
|
21
|
-
|
|
22
|
-
viewport: {
|
|
23
|
-
"audit-canvas": { width: 1280, height: 800, dpr: 1, label: "Audit canvas 1280x800" },
|
|
24
|
-
},
|
|
25
|
-
|
|
26
|
-
sweepOp: {
|
|
27
|
-
// Run axe-core accessibility audit
|
|
28
|
-
"audit": async (ctx, opts) => {
|
|
29
|
-
const url = opts.url || ctx.url;
|
|
30
|
-
if (!url) throw new Error("audit: missing url");
|
|
31
|
-
const tags = opts.tags ? opts.tags.split(",").map(t => t.trim()) : undefined;
|
|
32
|
-
const outDir = opts.outDir || ctx.out?.replace(/\.png$/i, "-audit") || join(process.cwd(), `audit-${Date.now()}`);
|
|
33
|
-
const result = await runAudit({ url, tags, outDir, screenshot: opts.screenshot !== false });
|
|
34
|
-
return { url, mode: "audit", outDir, violations: result.violationSummary?.total || 0, summary: result.violationSummary };
|
|
35
|
-
},
|
|
36
|
-
|
|
37
|
-
// Capture one shot per viewport preset
|
|
38
|
-
"every-viewport": async (ctx, opts) => {
|
|
39
|
-
const url = opts.base || ctx.url;
|
|
40
|
-
if (!url) throw new Error("every-viewport: missing base url");
|
|
41
|
-
const wanted = opts.viewports?.length ? opts.viewports : listViewports().map(v => v.name);
|
|
42
|
-
const dir = ctx.out.replace(/\.png$/i, "-every-viewport");
|
|
43
|
-
const captures = [];
|
|
44
|
-
for (const name of wanted) {
|
|
45
|
-
const out = join(dir, `${name}.png`);
|
|
46
|
-
try {
|
|
47
|
-
const meta = await runShootWithSidecar({ url, out, flags: { preset: name } });
|
|
48
|
-
captures.push({ name, out, ok: true, meta });
|
|
49
|
-
} catch (e) {
|
|
50
|
-
captures.push({ name, out, ok: false, error: e.message });
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
writeFileSync(join(dir, "every-viewport.json"), JSON.stringify({ url, captures, ts: new Date().toISOString() }, null, 2));
|
|
54
|
-
return { url, mode: "every-viewport", outDir: dir, captures };
|
|
55
|
-
},
|
|
56
|
-
},
|
|
57
|
-
};
|
|
1
|
+
// Built-in plugin: axe-core accessibility audit.
|
|
2
|
+
//
|
|
3
|
+
// Adds:
|
|
4
|
+
// viewport "audit-canvas" — 1280x800 @1x ideal for audit screenshots
|
|
5
|
+
// sweepOp "audit" — run axe-core WCAG audit in a sweep plan
|
|
6
|
+
// sweepOp "every-viewport" — capture every viewport preset
|
|
7
|
+
//
|
|
8
|
+
// The audit sweep-op stores results to ctx.out/audit.json plus a
|
|
9
|
+
// highlighted screenshot. This is a thin wrapper around src/plugin-audit.js
|
|
10
|
+
// that exposes the same functionality through the plugin system.
|
|
11
|
+
|
|
12
|
+
import { runAudit } from "../src/plugin-audit.js";
|
|
13
|
+
import { launch, newPage } from "../src/runway.js";
|
|
14
|
+
import { listViewports, resolveViewport } from "../src/viewport.js";
|
|
15
|
+
import { runShootWithSidecar } from "../src/shoot.js";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { writeFileSync } from "node:fs";
|
|
18
|
+
|
|
19
|
+
export default {
|
|
20
|
+
name: "audit",
|
|
21
|
+
|
|
22
|
+
viewport: {
|
|
23
|
+
"audit-canvas": { width: 1280, height: 800, dpr: 1, label: "Audit canvas 1280x800" },
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
sweepOp: {
|
|
27
|
+
// Run axe-core accessibility audit
|
|
28
|
+
"audit": async (ctx, opts) => {
|
|
29
|
+
const url = opts.url || ctx.url;
|
|
30
|
+
if (!url) throw new Error("audit: missing url");
|
|
31
|
+
const tags = opts.tags ? opts.tags.split(",").map(t => t.trim()) : undefined;
|
|
32
|
+
const outDir = opts.outDir || ctx.out?.replace(/\.png$/i, "-audit") || join(process.cwd(), `audit-${Date.now()}`);
|
|
33
|
+
const result = await runAudit({ url, tags, outDir, screenshot: opts.screenshot !== false });
|
|
34
|
+
return { url, mode: "audit", outDir, violations: result.violationSummary?.total || 0, summary: result.violationSummary };
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
// Capture one shot per viewport preset
|
|
38
|
+
"every-viewport": async (ctx, opts) => {
|
|
39
|
+
const url = opts.base || ctx.url;
|
|
40
|
+
if (!url) throw new Error("every-viewport: missing base url");
|
|
41
|
+
const wanted = opts.viewports?.length ? opts.viewports : listViewports().map(v => v.name);
|
|
42
|
+
const dir = ctx.out.replace(/\.png$/i, "-every-viewport");
|
|
43
|
+
const captures = [];
|
|
44
|
+
for (const name of wanted) {
|
|
45
|
+
const out = join(dir, `${name}.png`);
|
|
46
|
+
try {
|
|
47
|
+
const meta = await runShootWithSidecar({ url, out, flags: { preset: name } });
|
|
48
|
+
captures.push({ name, out, ok: true, meta });
|
|
49
|
+
} catch (e) {
|
|
50
|
+
captures.push({ name, out, ok: false, error: e.message });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
writeFileSync(join(dir, "every-viewport.json"), JSON.stringify({ url, captures, ts: new Date().toISOString() }, null, 2));
|
|
54
|
+
return { url, mode: "every-viewport", outDir: dir, captures };
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
};
|
package/plugins/plugin-demo.js
CHANGED
|
@@ -1,63 +1,63 @@
|
|
|
1
|
-
// Built-in demo plugin — shows every plugin API surface.
|
|
2
|
-
//
|
|
3
|
-
// Serves as both a reference implementation AND a useful tool:
|
|
4
|
-
// - adds a `demo-canvas` viewport alias
|
|
5
|
-
// - adds a `nav` sweep-op that clicks each navbar link in turn
|
|
6
|
-
// and captures a screenshot per page
|
|
7
|
-
// - augments sidecar with `demo: { mode }` when `--demo-mode` flag is set
|
|
8
|
-
//
|
|
9
|
-
// Copy this file as a starting point for your own plugin.
|
|
10
|
-
|
|
11
|
-
import { newPage } from "../src/runway.js";
|
|
12
|
-
import { resolveViewport } from "../src/viewport.js";
|
|
13
|
-
import { gotoOrThrow, settle } from "../src/overlays.js";
|
|
14
|
-
import { resolveLocator } from "../src/selector.js";
|
|
15
|
-
|
|
16
|
-
export default {
|
|
17
|
-
name: "demo",
|
|
18
|
-
|
|
19
|
-
viewport: {
|
|
20
|
-
"demo-canvas": { width: 1280, height: 800, dpr: 1, label: "Demo canvas 1280x800" },
|
|
21
|
-
},
|
|
22
|
-
|
|
23
|
-
flagHelp: {
|
|
24
|
-
"demo-mode": "logical mode label recorded in sidecar (e.g. dark / light / settings).",
|
|
25
|
-
},
|
|
26
|
-
|
|
27
|
-
sweepOp: {
|
|
28
|
-
"nav": async (ctx, opts) => {
|
|
29
|
-
// opts: { buttons: string[], settleMs?: number }
|
|
30
|
-
const browser = ctx.browser;
|
|
31
|
-
const page = ctx.page || await newPage(browser, resolveViewport({}));
|
|
32
|
-
const url = ctx.url;
|
|
33
|
-
if (!url) throw new Error("nav: missing url (provide plan.base)");
|
|
34
|
-
const r = await gotoOrThrow(page, url); await settle(page);
|
|
35
|
-
const buttons = opts.buttons || ["Home", "About", "Services", "Portfolio", "Contact"];
|
|
36
|
-
const frames = [];
|
|
37
|
-
for (const label of buttons) {
|
|
38
|
-
try {
|
|
39
|
-
const loc = await resolveLocator(page, `text=${label}`);
|
|
40
|
-
await loc.first().waitFor({ state: "visible", timeout: 5000 });
|
|
41
|
-
await loc.first().click({ timeout: 5000 });
|
|
42
|
-
await page.waitForTimeout(opts.settleMs || 600);
|
|
43
|
-
const f = ctx.out.replace(/\.png$/i, `-${label.toLowerCase()}.png`);
|
|
44
|
-
await page.screenshot({ path: f, fullPage: false });
|
|
45
|
-
frames.push({ button: label, out: f });
|
|
46
|
-
} catch (e) {
|
|
47
|
-
frames.push({ button: label, error: e.message });
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return { ...r, url, mode: "nav-sweep", frames };
|
|
51
|
-
},
|
|
52
|
-
},
|
|
53
|
-
|
|
54
|
-
beforeShoot: async (ctx) => {
|
|
55
|
-
if (ctx.flags["demo-mode"]) {
|
|
56
|
-
ctx._demoMode = ctx.flags["demo-mode"];
|
|
57
|
-
}
|
|
58
|
-
},
|
|
59
|
-
|
|
60
|
-
afterShoot: async (ctx, meta) => {
|
|
61
|
-
if (ctx._demoMode) meta.demo = { mode: ctx._demoMode };
|
|
62
|
-
},
|
|
63
|
-
};
|
|
1
|
+
// Built-in demo plugin — shows every plugin API surface.
|
|
2
|
+
//
|
|
3
|
+
// Serves as both a reference implementation AND a useful tool:
|
|
4
|
+
// pursr - adds a `demo-canvas` viewport alias
|
|
5
|
+
// pursr - adds a `nav` sweep-op that clicks each navbar link in turn
|
|
6
|
+
// and captures a screenshot per page
|
|
7
|
+
// pursr - augments sidecar with `demo: { mode }` when `--demo-mode` flag is set
|
|
8
|
+
//
|
|
9
|
+
// Copy this file as a starting point for your own plugin.
|
|
10
|
+
|
|
11
|
+
import { newPage } from "../src/runway.js";
|
|
12
|
+
import { resolveViewport } from "../src/viewport.js";
|
|
13
|
+
import { gotoOrThrow, settle } from "../src/overlays.js";
|
|
14
|
+
import { resolveLocator } from "../src/selector.js";
|
|
15
|
+
|
|
16
|
+
export default {
|
|
17
|
+
name: "demo",
|
|
18
|
+
|
|
19
|
+
viewport: {
|
|
20
|
+
"demo-canvas": { width: 1280, height: 800, dpr: 1, label: "Demo canvas 1280x800" },
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
flagHelp: {
|
|
24
|
+
"demo-mode": "logical mode label recorded in sidecar (e.g. dark / light / settings).",
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
sweepOp: {
|
|
28
|
+
"nav": async (ctx, opts) => {
|
|
29
|
+
// opts: { buttons: string[], settleMs?: number }
|
|
30
|
+
const browser = ctx.browser;
|
|
31
|
+
const page = ctx.page || await newPage(browser, resolveViewport({}));
|
|
32
|
+
const url = ctx.url;
|
|
33
|
+
if (!url) throw new Error("nav: missing url (provide plan.base)");
|
|
34
|
+
const r = await gotoOrThrow(page, url); await settle(page);
|
|
35
|
+
const buttons = opts.buttons || ["Home", "About", "Services", "Portfolio", "Contact"];
|
|
36
|
+
const frames = [];
|
|
37
|
+
for (const label of buttons) {
|
|
38
|
+
try {
|
|
39
|
+
const loc = await resolveLocator(page, `text=${label}`);
|
|
40
|
+
await loc.first().waitFor({ state: "visible", timeout: 5000 });
|
|
41
|
+
await loc.first().click({ timeout: 5000 });
|
|
42
|
+
await page.waitForTimeout(opts.settleMs || 600);
|
|
43
|
+
const f = ctx.out.replace(/\.png$/i, `-${label.toLowerCase()}.png`);
|
|
44
|
+
await page.screenshot({ path: f, fullPage: false });
|
|
45
|
+
frames.push({ button: label, out: f });
|
|
46
|
+
} catch (e) {
|
|
47
|
+
frames.push({ button: label, error: e.message });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { ...r, url, mode: "nav-sweep", frames };
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
beforeShoot: async (ctx) => {
|
|
55
|
+
if (ctx.flags["demo-mode"]) {
|
|
56
|
+
ctx._demoMode = ctx.flags["demo-mode"];
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
afterShoot: async (ctx, meta) => {
|
|
61
|
+
if (ctx._demoMode) meta.demo = { mode: ctx._demoMode };
|
|
62
|
+
},
|
|
63
|
+
};
|
package/src/ai-diff.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// pursr - AI diff summary.
|
|
2
|
+
//
|
|
3
|
+
// Sends two images (reference + current) to a vision-capable LLM and asks
|
|
4
|
+
// it to describe the visual differences in plain language. This gives you
|
|
5
|
+
// a human-readable summary alongside the pixel-diff percentage.
|
|
6
|
+
//
|
|
7
|
+
// Supports any OpenAI-compatible chat completions endpoint that accepts
|
|
8
|
+
// image_url content parts (OpenAI, Anthropic via proxy, local llama.cpp,
|
|
9
|
+
// Codex tokenrouter, etc).
|
|
10
|
+
//
|
|
11
|
+
// CLI:
|
|
12
|
+
// pursr diff <url> <ref.png> <out.png> --ai
|
|
13
|
+
// pursr diff <url> <ref.png> <out.png> --ai-model gh/gpt-5.4
|
|
14
|
+
//
|
|
15
|
+
// Library:
|
|
16
|
+
// import { aiDiffSummary } from "pursr/ai-diff";
|
|
17
|
+
// const summary = await aiDiffSummary({ refPath, curPath, url, model });
|
|
18
|
+
|
|
19
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
20
|
+
import { __PURSR_GET } from "./util.js";
|
|
21
|
+
|
|
22
|
+
// Read env at call time so tests can mutate process.env between calls.
|
|
23
|
+
function _defaultBase() { return __PURSR_GET("PURSR_AI_BASE_URL") || process.env.ANTHROPIC_BASE_URL || "https://api.openai.com/v1"; }
|
|
24
|
+
function _defaultKey() { return __PURSR_GET("PURSR_AI_API_KEY") || process.env.ANTHROPIC_AUTH_TOKEN || process.env.OPENAI_API_KEY; }
|
|
25
|
+
function _defaultModel(){ return __PURSR_GET("PURSR_AI_MODEL") || process.env.ANTHROPIC_DEFAULT_SONNET_MODEL || "gpt-4o"; }
|
|
26
|
+
|
|
27
|
+
const SYSTEM_PROMPT = `You are a visual regression analyst. Given two screenshots of the same web page (reference vs current), produce a concise, structured report of the visual differences.
|
|
28
|
+
|
|
29
|
+
Output format (markdown, keep under 250 words):
|
|
30
|
+
|
|
31
|
+
**Overall:** one sentence verdict (looks identical / minor changes / major regression).
|
|
32
|
+
**Layout shifts:** list any element that moved, resized, or appeared/disappeared.
|
|
33
|
+
**Color / style:** any color, font, or spacing changes.
|
|
34
|
+
**Content:** new, removed, or changed text/imagery.
|
|
35
|
+
**Likely cause:** best guess at what code or content change caused this.
|
|
36
|
+
|
|
37
|
+
Be specific (mention element labels, regions). Be honest about uncertainty.`;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Send reference + current PNGs to a vision model and return a textual diff summary.
|
|
41
|
+
*
|
|
42
|
+
* @param {object} opts
|
|
43
|
+
* @param {string} opts.refPath - Path to reference PNG
|
|
44
|
+
* @param {string} opts.curPath - Path to current PNG
|
|
45
|
+
* @param {string} [opts.url] - URL that was captured (for context)
|
|
46
|
+
* @param {string} [opts.model] - Model id (default: gpt-4o)
|
|
47
|
+
* @param {string} [opts.baseUrl] - OpenAI-compatible base URL
|
|
48
|
+
* @param {string} [opts.apiKey] - API key
|
|
49
|
+
* @param {number} [opts.maxTokens=600]
|
|
50
|
+
* @returns {Promise<{ summary: string, model: string, elapsedMs: number, usage?: object }>}
|
|
51
|
+
*/
|
|
52
|
+
export async function aiDiffSummary(opts) {
|
|
53
|
+
if (!opts.refPath || !opts.curPath) throw new Error("aiDiffSummary: refPath and curPath required");
|
|
54
|
+
if (!existsSync(opts.refPath)) throw new Error(`aiDiffSummary: ref not found: ${opts.refPath}`);
|
|
55
|
+
if (!existsSync(opts.curPath)) throw new Error(`aiDiffSummary: cur not found: ${opts.curPath}`);
|
|
56
|
+
|
|
57
|
+
const baseUrl = (opts.baseUrl || _defaultBase()).replace(/\/+$/, "");
|
|
58
|
+
const apiKey = opts.apiKey || _defaultKey();
|
|
59
|
+
const model = opts.model || _defaultModel();
|
|
60
|
+
if (!apiKey) {
|
|
61
|
+
throw new Error("aiDiffSummary: no API key. Set PURSR_$1, ANTHROPIC_AUTH_TOKEN, or OPENAI_API_KEY.");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const refB64 = readFileSync(opts.refPath).toString("base64");
|
|
65
|
+
const curB64 = readFileSync(opts.curPath).toString("base64");
|
|
66
|
+
const userText = opts.url
|
|
67
|
+
? `URL: ${opts.url}\n\nCompare these two screenshots of the same page (reference first, current second). Describe the visual differences.`
|
|
68
|
+
: `Compare these two screenshots (reference first, current second). Describe the visual differences.`;
|
|
69
|
+
|
|
70
|
+
const body = {
|
|
71
|
+
model,
|
|
72
|
+
max_tokens: opts.maxTokens || 600,
|
|
73
|
+
temperature: 0.2,
|
|
74
|
+
messages: [
|
|
75
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
76
|
+
{
|
|
77
|
+
role: "user",
|
|
78
|
+
content: [
|
|
79
|
+
{ type: "text", text: "REFERENCE:" },
|
|
80
|
+
{ type: "image_url", image_url: { url: `data:image/png;base64,${refB64}` } },
|
|
81
|
+
{ type: "text", text: "CURRENT:" },
|
|
82
|
+
{ type: "image_url", image_url: { url: `data:image/png;base64,${curB64}` } },
|
|
83
|
+
{ type: "text", text: userText },
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const t0 = Date.now();
|
|
90
|
+
const res = await fetch(`${baseUrl}/chat/completions`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: {
|
|
93
|
+
"Content-Type": "application/json",
|
|
94
|
+
Authorization: `Bearer ${apiKey}`,
|
|
95
|
+
},
|
|
96
|
+
body: JSON.stringify(body),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (!res.ok) {
|
|
100
|
+
const errText = await res.text().catch(() => "");
|
|
101
|
+
throw new Error(`aiDiffSummary: ${res.status} ${res.statusText} - ${errText.slice(0, 300)}`);
|
|
102
|
+
}
|
|
103
|
+
const data = await res.json();
|
|
104
|
+
const summary = data.choices?.[0]?.message?.content?.trim() || "(empty response)";
|
|
105
|
+
return {
|
|
106
|
+
summary,
|
|
107
|
+
model,
|
|
108
|
+
elapsedMs: Date.now() - t0,
|
|
109
|
+
usage: data.usage,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Compare two PNGs and return a JSON-friendly object suitable for embedding
|
|
115
|
+
* in a sweep step's meta sidecar.
|
|
116
|
+
*/
|
|
117
|
+
export async function aiDiffSidecar(opts) {
|
|
118
|
+
const r = await aiDiffSummary(opts);
|
|
119
|
+
return {
|
|
120
|
+
aiSummary: r.summary,
|
|
121
|
+
aiModel: r.model,
|
|
122
|
+
aiElapsedMs: r.elapsedMs,
|
|
123
|
+
aiAt: new Date().toISOString(),
|
|
124
|
+
};
|
|
125
|
+
}
|
package/src/auth.js
CHANGED
|
@@ -1,92 +1,93 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// Playwright's `storageState` is the canonical way to persist
|
|
4
|
-
// cookies + localStorage between browser sessions.
|
|
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
|
-
// ~/.
|
|
11
|
-
//
|
|
12
|
-
// Override with
|
|
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
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (!Array.isArray(state.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
1
|
+
// pursr — auth state (browser storage state) management.
|
|
2
|
+
//
|
|
3
|
+
// Playwright's `storageState` is the canonical way to persist
|
|
4
|
+
// cookies + localStorage between browser sessions. pursr 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
|
+
// ~/.pursr/auth/<project>/<name>.json
|
|
11
|
+
//
|
|
12
|
+
// Override with PURSR_$1.
|
|
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
|
+
// auth save <project> <name> --from <state.json>
|
|
22
|
+
// auth load <project> <name> --out <state.json>
|
|
23
|
+
// auth list [project]
|
|
24
|
+
// 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
|
+
import { __PURSR_GET } from "./util.js";
|
|
31
|
+
|
|
32
|
+
function authRoot() {
|
|
33
|
+
return __PURSR_GET("PURSR_AUTH_DIR") || join(homedir(), ".pursr", "auth");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function authPath(project, name) {
|
|
37
|
+
const root = authRoot();
|
|
38
|
+
const proj = (project || "default").replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
39
|
+
const nm = String(name || "default").replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
40
|
+
return join(root, proj, `${nm}.json`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function saveAuthState({ project, name, state }) {
|
|
44
|
+
if (!state) throw new Error("saveAuthState: missing state object");
|
|
45
|
+
// state shape from Playwright: { cookies, origins }
|
|
46
|
+
if (!Array.isArray(state.cookies)) state.cookies = [];
|
|
47
|
+
if (!Array.isArray(state.origins)) state.origins = [];
|
|
48
|
+
const file = authPath(project, name);
|
|
49
|
+
mkdirSync(join(file, ".."), { recursive: true });
|
|
50
|
+
const blob = {
|
|
51
|
+
_meta: { project: project || "default", name, ts: nowIso() },
|
|
52
|
+
cookies: state.cookies,
|
|
53
|
+
origins: state.origins,
|
|
54
|
+
};
|
|
55
|
+
writeFileSync(file, JSON.stringify(blob, null, 2), "utf8");
|
|
56
|
+
return { file, ...blob._meta };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function loadAuthState({ project, name }) {
|
|
60
|
+
const file = authPath(project, name);
|
|
61
|
+
if (!existsSync(file)) return null;
|
|
62
|
+
try {
|
|
63
|
+
const blob = JSON.parse(readFileSync(file, "utf8"));
|
|
64
|
+
return { cookies: blob.cookies || [], origins: blob.origins || [] };
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function listAuthStates(project) {
|
|
71
|
+
const root = join(authRoot(), (project || "default").replace(/[^a-zA-Z0-9._-]+/g, "_"));
|
|
72
|
+
if (!existsSync(root)) return [];
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const f of readdirSync(root)) {
|
|
75
|
+
if (!f.endsWith(".json")) continue;
|
|
76
|
+
try {
|
|
77
|
+
const blob = JSON.parse(readFileSync(join(root, f), "utf8"));
|
|
78
|
+
out.push({
|
|
79
|
+
name: blob?._meta?.name || f.replace(/\.json$/, ""),
|
|
80
|
+
ts: blob?._meta?.ts || null,
|
|
81
|
+
cookies: (blob.cookies || []).length,
|
|
82
|
+
origins: (blob.origins || []).length,
|
|
83
|
+
});
|
|
84
|
+
} catch {}
|
|
85
|
+
}
|
|
86
|
+
return out.sort((a, b) => (b.ts || "").localeCompare(a.ts || ""));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function deleteAuthState({ project, name }) {
|
|
90
|
+
const file = authPath(project, name);
|
|
91
|
+
if (!existsSync(file)) return false;
|
|
92
|
+
try { rmSync(file, { force: true }); return true; } catch { return false; }
|
|
92
93
|
}
|