pursr 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ };
@@ -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 CHANGED
@@ -1,4 +1,4 @@
1
- // pursor - AI diff summary.
1
+ // pursr - AI diff summary.
2
2
  //
3
3
  // Sends two images (reference + current) to a vision-capable LLM and asks
4
4
  // it to describe the visual differences in plain language. This gives you
@@ -16,12 +16,13 @@
16
16
  // import { aiDiffSummary } from "pursr/ai-diff";
17
17
  // const summary = await aiDiffSummary({ refPath, curPath, url, model });
18
18
 
19
- import { readFileSync, existsSync } from "node:fs";
19
+ import { readFileSync, existsSync } from "node:fs";
20
+ import { __PURSR_GET } from "./util.js";
20
21
 
21
22
  // Read env at call time so tests can mutate process.env between calls.
22
- function _defaultBase() { return process.env.PURSOR_AI_BASE_URL || process.env.ANTHROPIC_BASE_URL || "https://api.openai.com/v1"; }
23
- function _defaultKey() { return process.env.PURSOR_AI_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN || process.env.OPENAI_API_KEY; }
24
- function _defaultModel(){ return process.env.PURSOR_AI_MODEL || process.env.ANTHROPIC_DEFAULT_SONNET_MODEL || "gpt-4o"; }
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"; }
25
26
 
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.
27
28
 
@@ -57,7 +58,7 @@ export async function aiDiffSummary(opts) {
57
58
  const apiKey = opts.apiKey || _defaultKey();
58
59
  const model = opts.model || _defaultModel();
59
60
  if (!apiKey) {
60
- throw new Error("aiDiffSummary: no API key. Set PURSOR_AI_API_KEY, ANTHROPIC_AUTH_TOKEN, or OPENAI_API_KEY.");
61
+ throw new Error("aiDiffSummary: no API key. Set PURSR_$1, ANTHROPIC_AUTH_TOKEN, or OPENAI_API_KEY.");
61
62
  }
62
63
 
63
64
  const refB64 = readFileSync(opts.refPath).toString("base64");
package/src/auth.js CHANGED
@@ -1,92 +1,93 @@
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; }
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
  }