pursr 0.4.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/bin/pursr.mjs ADDED
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env node
2
+ // pursor CLI. Thin wrapper around src/* that mirrors the npm bin.
3
+
4
+ import { VERSION } from "../src/index.js";
5
+ import { runClick, runType, runWait, runSeq } from "../src/interact.js";
6
+ import { runEval } from "../src/eval.js";
7
+ import { runProbe } from "../src/probe.js";
8
+ import { runShot } from "../src/shot.js";
9
+ import { runShootWithSidecar } from "../src/shoot.js";
10
+ import { runHover } from "../src/hover.js";
11
+ import { runFrames } from "../src/frames.js";
12
+ import { runDiff } from "../src/diff.js";
13
+ import { runSweep } from "../src/sweep.js";
14
+ import { runEveryViewport } from "../src/every-viewport.js";
15
+ import { runAudit } from "../src/plugin-audit.js";
16
+ import { captureDomSnapshot } from "../src/dom-snapshot.js";
17
+ import { listViewports } from "../src/viewport.js";
18
+ import { parseFlags, asNum, readArg, makeOut, pickOutPath } from "../src/util.js";
19
+ import { writeFileSync } from "node:fs";
20
+ import { readFileSync as _readFileSync } from "node:fs";
21
+ const readFile = _readFileSync;
22
+ import { loadPlugins, listPlugins, getFlagHelp } from "../src/plugin.js";
23
+
24
+ const USAGE = `usage:
25
+ v1: pursor {probe|shot|full|eval|click|type|wait|diff|seq} <url> [...]
26
+ v2: pursor {viewports|shoot|layer|frames|hover|sweep} <...>
27
+ flags: --preset <name> --width N --height N --dpr N
28
+ --zoom 1.5 --panX 200 --panY -100
29
+ --cursor pointer|grab|grabbing|crosshair|none
30
+ --layer entity|terrain|hud|ui
31
+ --grid --grid-tile 64 --grid-color rgba(255,0,255,0.35)
32
+ --no-animation --wait-frame 600 --full
33
+ @file prefix reads argv contents from file (UTF-8, newline trimmed).
34
+ plugins: pursor automatically loads built-in plugins from plugins/.
35
+ You can also pass --plugin <path> to load custom plugins (repeatable).`;
36
+
37
+ function die(msg, code = 2) {
38
+ console.error(JSON.stringify({ error: msg, usage: USAGE }, null, 2));
39
+ process.exit(code);
40
+ }
41
+
42
+ const argv = process.argv;
43
+ const [, , cmd, a, b, c, d] = argv;
44
+ const url = process.env.PURSOR_URL || a;
45
+
46
+ // Plugin loading: scan for --plugin <path> and built-in plugins/
47
+ const pluginPaths = [];
48
+ for (let i = 0; i < argv.length; i++) if (argv[i] === "--plugin" && i + 1 < argv.length) pluginPaths.push(argv[++i]);
49
+ await loadPlugins(pluginPaths);
50
+
51
+ (async () => {
52
+ try {
53
+ switch (cmd) {
54
+ case undefined: case "help": case "--help": case "-h": { console.log(JSON.stringify({ usage: USAGE }, null, 2)); break; }
55
+ case "version": case "--version": case "-v": {
56
+ console.log(JSON.stringify({ name: "pursor", version: VERSION, plugins: listPlugins() }, null, 2));
57
+ break;
58
+ }
59
+ case "probe": { if (!url) die("missing url"); const r = await runProbe(url); console.log(JSON.stringify(r, null, 2)); break; }
60
+ case "shot": { if (!url) die("missing url"); const out = b || makeOut("shot.png"); const r = await runShot(url, out, { fullPage: false }); console.log(JSON.stringify(r, null, 2)); break; }
61
+ case "full": { if (!url) die("missing url"); const out = b || makeOut("full.png"); const r = await runShot(url, out, { fullPage: true }); console.log(JSON.stringify(r, null, 2)); break; }
62
+ case "eval": { if (!url) die("missing url"); const js = readArg(b); if (!js) die("eval: missing <js> (or @file)"); const out = c || makeOut("eval.png"); const r = await runEval(url, js, out); console.log(JSON.stringify(r, null, 2)); break; }
63
+ case "click": { if (!url) die("missing url"); const sel = b; if (!sel) die("click: missing <selector>"); const out = c || makeOut(`click-${(sel||"").replace(/[^a-z0-9]+/gi, "_").slice(0, 32)}.png`); const r = await runClick(url, sel, out); console.log(JSON.stringify(r, null, 2)); break; }
64
+ case "type": { if (!url) die("missing url"); const sel = b; const text = readArg(c); if (!sel || text === undefined) die("type: missing <selector> or <text> (text can be @file)"); const out = d || makeOut(`type-${(sel||"").replace(/[^a-z0-9]+/gi, "_").slice(0, 32)}.png`); const r = await runType(url, sel, text, out); console.log(JSON.stringify(r, null, 2)); break; }
65
+ case "wait": { if (!url) die("missing url"); const sel = b; if (!sel) die("wait: missing <selector>"); const t = c !== undefined ? asNum(c, 30000) : 30000; const r = await runWait(url, sel, t); console.log(JSON.stringify(r, null, 2)); break; }
66
+ case "diff": { if (!url) die("missing url"); const ref = b; if (!ref) die("diff: missing <ref.png>"); const out = c || makeOut("diff.png"); const threshold = d !== undefined ? Number(d) : 0.1; const r = await runDiff(url, ref, out, threshold); console.log(JSON.stringify(r, null, 2)); break; }
67
+ 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; }
68
+ case "viewports": { console.log(JSON.stringify(listViewports(), null, 2)); break; }
69
+ case "shoot": {
70
+ if (!url) die("missing url");
71
+ const out = (b && !b.startsWith("--")) ? b : pickOutPath(argv.slice(5)) || makeOut("shoot.png");
72
+ const r = await runShootWithSidecar({ url, out, flags: parseFlags(argv.slice(5)) });
73
+ console.log(JSON.stringify(r, null, 2));
74
+ break;
75
+ }
76
+ case "layer": {
77
+ if (!url) die("missing url");
78
+ const layerName = b; if (!layerName) die("layer: missing <name>");
79
+ const out = (c && !c.startsWith("--")) ? c : pickOutPath(argv.slice(6)) || makeOut(`layer-${layerName}.png`);
80
+ const flags = parseFlags(argv.slice(7)); flags.layer = layerName;
81
+ const r = await runShootWithSidecar({ url, out, flags });
82
+ console.log(JSON.stringify(r, null, 2));
83
+ break;
84
+ }
85
+ case "frames": {
86
+ if (!url) die("missing url");
87
+ const count = asNum(b, 8);
88
+ const stepMs = asNum(c, 250);
89
+ const outDir = (d && !d.startsWith("--")) ? d : makeOut(`frames-${count}x${stepMs}ms`);
90
+ const r = await runFrames({ url, count, intervalMs: stepMs, outDir, flags: parseFlags(argv.slice(7)) });
91
+ console.log(JSON.stringify(r, null, 2));
92
+ break;
93
+ }
94
+ case "hover": {
95
+ if (!url) die("missing url");
96
+ const sel = b; if (!sel) die("hover: missing <selector>");
97
+ const out = (c && !c.startsWith("--")) ? c : pickOutPath(argv.slice(6)) || makeOut(`hover-${(sel||"").replace(/[^a-z0-9]+/gi, "_").slice(0, 32)}.png`);
98
+ const r = await runHover({ url, selector: sel, out, flags: parseFlags(argv.slice(6)) });
99
+ console.log(JSON.stringify(r, null, 2));
100
+ break;
101
+ }
102
+ case "sweep": {
103
+ const planPath = readArg(a);
104
+ if (!planPath) die("sweep: missing <plan.json> (or @file)");
105
+ const outDirArg = (b && !b.startsWith("--")) ? b : undefined;
106
+ const r = await runSweep(planPath, outDirArg);
107
+ console.log(JSON.stringify(r, null, 2));
108
+ break;
109
+ }
110
+ case "every-viewport": {
111
+ if (!url) die("missing url");
112
+ const outDir = (b && !b.startsWith("--")) ? b : undefined;
113
+ const viewports = c?.startsWith("--") ? undefined : c?.split(",");
114
+ const r = await runEveryViewport({ url, outDir, viewports });
115
+ console.log(JSON.stringify(r, null, 2));
116
+ break;
117
+ }
118
+ case "audit": {
119
+ if (!url) die("missing url");
120
+ const tags = (b && !b.startsWith("--")) ? b : undefined;
121
+ const outDir = (c && !c.startsWith("--")) ? c : undefined;
122
+ const r = await runAudit({ url, tags: tags?.split(",").map(t => t.trim()), outDir });
123
+ console.log(JSON.stringify(r, null, 2));
124
+ break;
125
+ }
126
+ case "dom-snapshot": case "dom": {
127
+ if (!url) die("missing url");
128
+ const out = (b && !b.startsWith("--")) ? b : undefined;
129
+ const r = await captureDomSnapshot({ url, out });
130
+ console.log(JSON.stringify({ url: r.url, title: r.title, elements: r.selectorMap?.length, domSize: r.dom?.length, out: r.url?.replace(/[^/]+$/, "") + "dom.json" }, null, 2));
131
+ break;
132
+ }
133
+ case "validate": {
134
+ const planPath = readArg(a);
135
+ if (!planPath) die("validate: missing <plan.json> (or @file)");
136
+ let plan;
137
+ try { plan = JSON.parse(readFile(planPath, "utf8")); }
138
+ catch (e) { die("validate: " + e.message); }
139
+ const { validateSweepPlan } = await import("../src/sweep-schema.js");
140
+ const v = validateSweepPlan(plan);
141
+ console.log(JSON.stringify({ valid: v.valid, errors: v.errors, plan: planPath }, null, 2));
142
+ if (!v.valid) process.exit(1);
143
+ break;
144
+ }
145
+ case "baseline": {
146
+ // pursor baseline <sub> [...args]
147
+ // sub=list -> list baselines
148
+ // sub=save <project> <png> <step> [--id <id>] [--url <u>] [--meta-json <file>]
149
+ // sub=approve <project> <png> <step> [--id <id>] [--url <u>]
150
+ // sub=show <project> <step> [--id <id>] [--url <u>]
151
+ const sub = a;
152
+ const { saveBaseline, listBaselines, loadBaseline, approveBaseline, diffKey } = await import("../src/baseline.js");
153
+ if (sub === "list") {
154
+ // baseline list [project]
155
+ const project = b;
156
+ console.log(JSON.stringify(listBaselines(project), null, 2));
157
+ } else if (sub === "save") {
158
+ if (!b || !c || !d) die("baseline save: <project> <png> <step> [--id <id>] [--url <u>] [--meta-json <file>]");
159
+ const project = b, png = c, step = d;
160
+ const flags = parseFlags(argv.slice(7));
161
+ let meta = null;
162
+ if (flags["meta-json"]) meta = JSON.parse(readFile(flags["meta-json"], "utf8"));
163
+ else if (flags.url) meta = { url: flags.url };
164
+ const id = flags.id || diffKey({ url: meta?.url || "", viewport: meta?.viewport, flags: meta?.flags || {} });
165
+ const result = saveBaseline({ project, id, step, png, meta });
166
+ console.log(JSON.stringify({ saved: true, ...result }, null, 2));
167
+ } else if (sub === "approve") {
168
+ if (!b || !c || !d) die("baseline approve: <project> <png> <step> [--id <id>] [--url <u>]");
169
+ const project = b, png = c, step = d;
170
+ const flags = parseFlags(argv.slice(7));
171
+ const id = flags.id || diffKey({ url: flags.url || "", flags: {} });
172
+ const result = approveBaseline({ project, id, step, fromPng: png });
173
+ console.log(JSON.stringify({ approved: true, ...result }, null, 2));
174
+ } else if (sub === "show") {
175
+ if (!b || !c) die("baseline show: <project> <step> [--id <id>] [--url <u>]");
176
+ const project = b, step = c;
177
+ const flags = parseFlags(argv.slice(5));
178
+ const id = flags.id || diffKey({ url: flags.url || "", flags: {} });
179
+ const r = loadBaseline({ project, id, step });
180
+ console.log(JSON.stringify(r, null, 2));
181
+ } else {
182
+ die("baseline subcommand: list | save | approve | show");
183
+ }
184
+ break;
185
+ }
186
+ case "auth": {
187
+ // pursor auth <sub> [...args]
188
+ // save <project> <name> --from <state.json>
189
+ // load <project> <name> --out <state.json>
190
+ // list [project]
191
+ // delete <project> <name>
192
+ const sub = a;
193
+ const { saveAuthState, loadAuthState, listAuthStates, deleteAuthState } = await import("../src/auth.js");
194
+ if (sub === "list") {
195
+ const project = b;
196
+ console.log(JSON.stringify(listAuthStates(project), null, 2));
197
+ } else if (sub === "save") {
198
+ if (!b || !c) die("auth save: <project> <name> --from <state.json>");
199
+ const fromFile = argv[argv.indexOf("--from") + 1];
200
+ if (!fromFile) die("auth save: missing --from <state.json>");
201
+ const state = JSON.parse(readFile(fromFile, "utf8"));
202
+ const r = saveAuthState({ project: b, name: c, state });
203
+ console.log(JSON.stringify({ saved: true, ...r }, null, 2));
204
+ } else if (sub === "load") {
205
+ if (!b || !c) die("auth load: <project> <name> --out <state.json>");
206
+ const outFile = argv[argv.indexOf("--out") + 1];
207
+ if (!outFile) die("auth load: missing --out <state.json>");
208
+ const state = loadAuthState({ project: b, name: c });
209
+ if (!state) { console.error("not found"); process.exit(2); }
210
+ writeFileSync(outFile, JSON.stringify(state, null, 2), "utf8");
211
+ console.log(JSON.stringify({ loaded: true, file: outFile, cookies: state.cookies.length, origins: state.origins.length }, null, 2));
212
+ } else if (sub === "delete") {
213
+ if (!b || !c) die("auth delete: <project> <name>");
214
+ const ok = deleteAuthState({ project: b, name: c });
215
+ console.log(JSON.stringify({ deleted: ok }, null, 2));
216
+ } else {
217
+ die("auth subcommand: list | save | load | delete");
218
+ }
219
+ break;
220
+ }
221
+ default: { die(`unknown subcommand: ${cmd}`); }
222
+ }
223
+ } catch (e) {
224
+ console.error(JSON.stringify({ error: e.message, stack: e.stack?.split("\n").slice(0, 3).join("\n") }, null, 2));
225
+ process.exit(1);
226
+ }
227
+ })();
package/package.json ADDED
@@ -0,0 +1,90 @@
1
+ {
2
+ "name": "pursr",
3
+ "version": "0.4.0",
4
+ "private": false,
5
+ "description": "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
+ "homepage": "https://github.com/0xheycat/pursr",
7
+ "bugs": "https://github.com/0xheycat/pursr/issues",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/0xheycat/pursr.git"
11
+ },
12
+ "funding": "https://github.com/sponsors/0xheycat",
13
+ "type": "module",
14
+ "bin": {
15
+ "pursr": "./bin/pursr.mjs",
16
+ "pursr-mcp": "./bin/pursr-mcp.mjs"
17
+ },
18
+ "main": "./src/index.js",
19
+ "exports": {
20
+ ".": "./src/index.js",
21
+ "./plugin": "./src/plugin.js",
22
+ "./plugins/*": "./plugins/*.js",
23
+ "./util": "./src/util.js",
24
+ "./runway": "./src/runway.js",
25
+ "./selector": "./src/selector.js",
26
+ "./overlays": "./src/overlays.js",
27
+ "./viewport": "./src/viewport.js",
28
+ "./dom-snapshot": "./src/dom-snapshot.js",
29
+ "./plugin-audit": "./src/plugin-audit.js",
30
+ "./selector-heal": "./src/selector-heal.js",
31
+ "./ci-output": "./src/ci-output.js",
32
+ "./mcp": "./src/mcp.js",
33
+ "./baseline": "./src/baseline.js",
34
+ "./sweep-schema": "./src/sweep-schema.js",
35
+ "./mcp-resources": "./src/mcp-resources.js",
36
+ "./har": "./src/har.js",
37
+ "./auth": "./src/auth.js"
38
+ },
39
+ "files": [
40
+ "bin",
41
+ "src",
42
+ "plugins",
43
+ "plans",
44
+ "assets",
45
+ "README.md",
46
+ "LICENSE"
47
+ ],
48
+ "scripts": {
49
+ "start": "node bin/pursor.mjs",
50
+ "test": "node --test \"test/*.test.js\"",
51
+ "smoke": "node bin/pursor.mjs viewports"
52
+ },
53
+ "engines": {
54
+ "node": ">=18"
55
+ },
56
+ "keywords": [
57
+ "visual-qa",
58
+ "visual-regression",
59
+ "screenshot",
60
+ "audit",
61
+ "accessibility",
62
+ "axe-core",
63
+ "playwright",
64
+ "mcp",
65
+ "model-context-protocol",
66
+ "baseline",
67
+ "diff",
68
+ "har",
69
+ "testing",
70
+ "devtools",
71
+ "pursr"
72
+ ],
73
+ "license": "MIT",
74
+ "dependencies": {
75
+ "axe-core": "^4.12.1",
76
+ "pixelmatch": "^5.3.0",
77
+ "pngjs": "^7.0.0"
78
+ },
79
+ "peerDependencies": {
80
+ "playwright-core": "*"
81
+ },
82
+ "peerDependenciesMeta": {
83
+ "playwright-core": {
84
+ "optional": true
85
+ }
86
+ },
87
+ "devDependencies": {
88
+ "playwright-core": "^1.61.0"
89
+ }
90
+ }
@@ -0,0 +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
+ ]
22
+ }
@@ -0,0 +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
+ };
@@ -0,0 +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
+ };
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
+ }