pursr 0.10.0 → 0.10.2
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/README.md +51 -19
- package/SKILL.md +169 -0
- package/bin/pursr.mjs +111 -86
- package/package.json +5 -1
- package/src/browser-discovery.js +196 -0
- package/src/cli-args.js +33 -0
- package/src/diff.js +3 -2
- package/src/doctor.js +88 -0
- package/src/eval.js +16 -10
- package/src/hover.js +8 -3
- package/src/interact.js +20 -14
- package/src/runway.js +40 -42
- package/src/shoot.js +7 -4
- package/src/shot.js +14 -10
- package/src/update-notifier.js +74 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pursr",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "pursr — 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
6
|
"homepage": "https://github.com/0xheycat/pursr",
|
|
@@ -35,6 +35,9 @@
|
|
|
35
35
|
"./mcp-resources": "./src/mcp-resources.js",
|
|
36
36
|
"./har": "./src/har.js",
|
|
37
37
|
"./auth": "./src/auth.js",
|
|
38
|
+
"./browser-discovery": "./src/browser-discovery.js",
|
|
39
|
+
"./doctor": "./src/doctor.js",
|
|
40
|
+
"./update-notifier": "./src/update-notifier.js",
|
|
38
41
|
"./watch": "./src/watch.js",
|
|
39
42
|
"./snap": "./src/snap.js",
|
|
40
43
|
"./report": "./src/report.js",
|
|
@@ -49,6 +52,7 @@
|
|
|
49
52
|
"plugins",
|
|
50
53
|
"plans",
|
|
51
54
|
"assets",
|
|
55
|
+
"SKILL.md",
|
|
52
56
|
"README.md",
|
|
53
57
|
"LICENSE"
|
|
54
58
|
],
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { posix, win32 } from "node:path";
|
|
4
|
+
|
|
5
|
+
function uniq(list) {
|
|
6
|
+
return [...new Set(list.filter(Boolean).map(String))];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function envPath(env, key) {
|
|
10
|
+
return env && env[key] ? String(env[key]) : "";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function pathJoin(platform, ...parts) {
|
|
14
|
+
return platform === "win32" ? win32.join(...parts) : posix.join(...parts);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function joinIf(platform, base, ...parts) {
|
|
18
|
+
return base ? pathJoin(platform, base, ...parts) : "";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function pathExecutableCandidates(env, names, platform) {
|
|
22
|
+
const pathValue = envPath(env, "PATH");
|
|
23
|
+
if (!pathValue) return [];
|
|
24
|
+
const dirs = pathValue.split(platform === "win32" ? ";" : ":").filter(Boolean);
|
|
25
|
+
const exts = platform === "win32"
|
|
26
|
+
? (envPath(env, "PATHEXT") || ".EXE;.CMD;.BAT").split(";").filter(Boolean)
|
|
27
|
+
: [""];
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const dir of dirs) {
|
|
30
|
+
for (const name of names) {
|
|
31
|
+
if (platform === "win32" && /\.[a-z0-9]+$/i.test(name)) out.push(pathJoin(platform, dir, name));
|
|
32
|
+
else for (const ext of exts) out.push(pathJoin(platform, dir, name + ext.toLowerCase()));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function browserCandidates(options = {}) {
|
|
39
|
+
const platform = options.platform || process.platform;
|
|
40
|
+
const env = options.env || process.env;
|
|
41
|
+
const home = options.homeDir || homedir();
|
|
42
|
+
const localAppData = envPath(env, "LOCALAPPDATA");
|
|
43
|
+
const programFiles = envPath(env, "ProgramFiles") || "C:\\Program Files";
|
|
44
|
+
const programFilesX86 = envPath(env, "ProgramFiles(x86)") || "C:\\Program Files (x86)";
|
|
45
|
+
|
|
46
|
+
const explicit = [
|
|
47
|
+
envPath(env, "PURSR_BROWSER_PATH"),
|
|
48
|
+
envPath(env, "CHROME_PATH"),
|
|
49
|
+
envPath(env, "PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH"),
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
if (platform === "win32") {
|
|
53
|
+
const pathNames = [
|
|
54
|
+
"chrome.exe", "msedge.exe", "brave.exe", "chromium.exe",
|
|
55
|
+
];
|
|
56
|
+
return uniq([
|
|
57
|
+
...explicit,
|
|
58
|
+
pathJoin(platform, programFiles, "Google", "Chrome", "Application", "chrome.exe"),
|
|
59
|
+
pathJoin(platform, programFilesX86, "Google", "Chrome", "Application", "chrome.exe"),
|
|
60
|
+
joinIf(platform, localAppData, "Google", "Chrome", "Application", "chrome.exe"),
|
|
61
|
+
pathJoin(platform, programFiles, "Google", "Chrome Beta", "Application", "chrome.exe"),
|
|
62
|
+
pathJoin(platform, programFilesX86, "Google", "Chrome Beta", "Application", "chrome.exe"),
|
|
63
|
+
joinIf(platform, localAppData, "Google", "Chrome Beta", "Application", "chrome.exe"),
|
|
64
|
+
pathJoin(platform, programFiles, "Google", "Chrome Dev", "Application", "chrome.exe"),
|
|
65
|
+
pathJoin(platform, programFilesX86, "Google", "Chrome Dev", "Application", "chrome.exe"),
|
|
66
|
+
joinIf(platform, localAppData, "Google", "Chrome Dev", "Application", "chrome.exe"),
|
|
67
|
+
joinIf(platform, localAppData, "Google", "Chrome SxS", "Application", "chrome.exe"),
|
|
68
|
+
pathJoin(platform, programFiles, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
69
|
+
pathJoin(platform, programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
70
|
+
joinIf(platform, localAppData, "Microsoft", "Edge", "Application", "msedge.exe"),
|
|
71
|
+
pathJoin(platform, programFiles, "Microsoft", "Edge Beta", "Application", "msedge.exe"),
|
|
72
|
+
pathJoin(platform, programFilesX86, "Microsoft", "Edge Beta", "Application", "msedge.exe"),
|
|
73
|
+
joinIf(platform, localAppData, "Microsoft", "Edge Beta", "Application", "msedge.exe"),
|
|
74
|
+
pathJoin(platform, programFiles, "Microsoft", "Edge Dev", "Application", "msedge.exe"),
|
|
75
|
+
pathJoin(platform, programFilesX86, "Microsoft", "Edge Dev", "Application", "msedge.exe"),
|
|
76
|
+
joinIf(platform, localAppData, "Microsoft", "Edge Dev", "Application", "msedge.exe"),
|
|
77
|
+
pathJoin(platform, programFiles, "Microsoft", "Edge SxS", "Application", "msedge.exe"),
|
|
78
|
+
pathJoin(platform, programFilesX86, "Microsoft", "Edge SxS", "Application", "msedge.exe"),
|
|
79
|
+
joinIf(platform, localAppData, "Microsoft", "Edge SxS", "Application", "msedge.exe"),
|
|
80
|
+
pathJoin(platform, programFiles, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
|
|
81
|
+
pathJoin(platform, programFilesX86, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
|
|
82
|
+
joinIf(platform, localAppData, "BraveSoftware", "Brave-Browser", "Application", "brave.exe"),
|
|
83
|
+
pathJoin(platform, programFiles, "BraveSoftware", "Brave-Browser-Beta", "Application", "brave.exe"),
|
|
84
|
+
pathJoin(platform, programFilesX86, "BraveSoftware", "Brave-Browser-Beta", "Application", "brave.exe"),
|
|
85
|
+
joinIf(platform, localAppData, "BraveSoftware", "Brave-Browser-Beta", "Application", "brave.exe"),
|
|
86
|
+
pathJoin(platform, programFiles, "BraveSoftware", "Brave-Browser-Nightly", "Application", "brave.exe"),
|
|
87
|
+
pathJoin(platform, programFilesX86, "BraveSoftware", "Brave-Browser-Nightly", "Application", "brave.exe"),
|
|
88
|
+
joinIf(platform, localAppData, "BraveSoftware", "Brave-Browser-Nightly", "Application", "brave.exe"),
|
|
89
|
+
pathJoin(platform, programFiles, "Chromium", "Application", "chrome.exe"),
|
|
90
|
+
pathJoin(platform, programFilesX86, "Chromium", "Application", "chrome.exe"),
|
|
91
|
+
...pathExecutableCandidates(env, pathNames, platform),
|
|
92
|
+
]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (platform === "darwin") {
|
|
96
|
+
return uniq([
|
|
97
|
+
...explicit,
|
|
98
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
99
|
+
pathJoin(platform, home, "Applications", "Google Chrome.app", "Contents", "MacOS", "Google Chrome"),
|
|
100
|
+
"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
|
|
101
|
+
pathJoin(platform, home, "Applications", "Google Chrome Beta.app", "Contents", "MacOS", "Google Chrome Beta"),
|
|
102
|
+
"/Applications/Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev",
|
|
103
|
+
pathJoin(platform, home, "Applications", "Google Chrome Dev.app", "Contents", "MacOS", "Google Chrome Dev"),
|
|
104
|
+
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
105
|
+
pathJoin(platform, home, "Applications", "Google Chrome Canary.app", "Contents", "MacOS", "Google Chrome Canary"),
|
|
106
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
107
|
+
"/Applications/Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta",
|
|
108
|
+
"/Applications/Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev",
|
|
109
|
+
"/Applications/Microsoft Edge Canary.app/Contents/MacOS/Microsoft Edge Canary",
|
|
110
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
111
|
+
"/Applications/Brave Browser Beta.app/Contents/MacOS/Brave Browser Beta",
|
|
112
|
+
"/Applications/Brave Browser Nightly.app/Contents/MacOS/Brave Browser Nightly",
|
|
113
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
114
|
+
...pathExecutableCandidates(env, [
|
|
115
|
+
"google-chrome", "google-chrome-stable", "google-chrome-beta", "google-chrome-unstable",
|
|
116
|
+
"chromium", "chromium-browser", "microsoft-edge", "microsoft-edge-beta",
|
|
117
|
+
"microsoft-edge-dev", "brave-browser", "brave-browser-beta", "brave-browser-nightly",
|
|
118
|
+
], platform),
|
|
119
|
+
]);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return uniq([
|
|
123
|
+
...explicit,
|
|
124
|
+
"/usr/bin/google-chrome",
|
|
125
|
+
"/usr/bin/google-chrome-stable",
|
|
126
|
+
"/usr/bin/google-chrome-beta",
|
|
127
|
+
"/usr/bin/google-chrome-unstable",
|
|
128
|
+
"/usr/bin/chromium",
|
|
129
|
+
"/usr/bin/chromium-browser",
|
|
130
|
+
"/usr/bin/microsoft-edge",
|
|
131
|
+
"/usr/bin/microsoft-edge-stable",
|
|
132
|
+
"/usr/bin/microsoft-edge-beta",
|
|
133
|
+
"/usr/bin/microsoft-edge-dev",
|
|
134
|
+
"/usr/bin/brave-browser",
|
|
135
|
+
"/usr/bin/brave-browser-beta",
|
|
136
|
+
"/usr/bin/brave-browser-nightly",
|
|
137
|
+
"/snap/bin/chromium",
|
|
138
|
+
"/snap/bin/brave",
|
|
139
|
+
...pathExecutableCandidates(env, [
|
|
140
|
+
"google-chrome", "google-chrome-stable", "google-chrome-beta", "google-chrome-unstable",
|
|
141
|
+
"chromium", "chromium-browser", "microsoft-edge", "microsoft-edge-stable",
|
|
142
|
+
"microsoft-edge-beta", "microsoft-edge-dev", "brave-browser", "brave-browser-beta",
|
|
143
|
+
"brave-browser-nightly",
|
|
144
|
+
], platform),
|
|
145
|
+
]);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function discoverBrowsers(options = {}) {
|
|
149
|
+
const exists = options.exists || existsSync;
|
|
150
|
+
const candidates = browserCandidates(options);
|
|
151
|
+
const found = candidates.filter((path) => {
|
|
152
|
+
try { return exists(path); } catch { return false; }
|
|
153
|
+
});
|
|
154
|
+
return {
|
|
155
|
+
found,
|
|
156
|
+
preferred: found[0] || null,
|
|
157
|
+
candidates,
|
|
158
|
+
env: {
|
|
159
|
+
PURSR_BROWSER_PATH: !!(options.env || process.env).PURSR_BROWSER_PATH,
|
|
160
|
+
CHROME_PATH: !!(options.env || process.env).CHROME_PATH,
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function findBrowserExecutable(options = {}) {
|
|
166
|
+
return discoverBrowsers(options).preferred;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function browserSetupHints(platform = process.platform) {
|
|
170
|
+
const common = [
|
|
171
|
+
"Set PURSR_BROWSER_PATH to a Chrome-compatible executable if auto-detection misses it.",
|
|
172
|
+
"Install playwright-core in the project when using pursr as a local dependency: npm i -D playwright-core",
|
|
173
|
+
];
|
|
174
|
+
if (platform === "win32") {
|
|
175
|
+
return [
|
|
176
|
+
"Install Google Chrome, Microsoft Edge, Brave, or Chromium.",
|
|
177
|
+
"Dev/Beta/Canary channels and PATH-installed browser executables are also detected when available.",
|
|
178
|
+
"Windows example: setx PURSR_BROWSER_PATH \"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe\"",
|
|
179
|
+
...common,
|
|
180
|
+
];
|
|
181
|
+
}
|
|
182
|
+
if (platform === "darwin") {
|
|
183
|
+
return [
|
|
184
|
+
"Install Google Chrome, Microsoft Edge, Brave, or Chromium.",
|
|
185
|
+
"Dev/Beta/Canary channels, user Applications, and PATH-installed browser executables are also detected when available.",
|
|
186
|
+
"macOS example: export PURSR_BROWSER_PATH=\"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome\"",
|
|
187
|
+
...common,
|
|
188
|
+
];
|
|
189
|
+
}
|
|
190
|
+
return [
|
|
191
|
+
"Install google-chrome-stable, chromium, microsoft-edge-stable, or brave-browser.",
|
|
192
|
+
"Dev/Beta/unstable channels and PATH-installed browser executables are also detected when available.",
|
|
193
|
+
"Linux example: export PURSR_BROWSER_PATH=/usr/bin/google-chrome",
|
|
194
|
+
...common,
|
|
195
|
+
];
|
|
196
|
+
}
|
package/src/cli-args.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Order-independent CLI argument parsing for pursr subcommands.
|
|
2
|
+
|
|
3
|
+
const DEFAULT_BOOLEAN_FLAGS = new Set([
|
|
4
|
+
"ai", "full", "grid", "help", "json", "no-animation", "no-embed",
|
|
5
|
+
"no-hud", "no-visual", "update", "visible",
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
export function parseCommandArgs(args = [], { booleanFlags = DEFAULT_BOOLEAN_FLAGS } = {}) {
|
|
9
|
+
const flags = {};
|
|
10
|
+
const positionals = [];
|
|
11
|
+
for (let i = 0; i < args.length; i++) {
|
|
12
|
+
const token = args[i];
|
|
13
|
+
if (!token?.startsWith("--")) {
|
|
14
|
+
positionals.push(token);
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const eq = token.indexOf("=");
|
|
19
|
+
const key = token.slice(2, eq >= 0 ? eq : undefined);
|
|
20
|
+
let value;
|
|
21
|
+
if (eq >= 0) value = token.slice(eq + 1);
|
|
22
|
+
else if (booleanFlags.has(key)) value = true;
|
|
23
|
+
else if (i + 1 < args.length && !args[i + 1].startsWith("--")) value = args[++i];
|
|
24
|
+
else value = true;
|
|
25
|
+
flags[key] = value;
|
|
26
|
+
}
|
|
27
|
+
return { flags, positionals };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function filePathArg(value) {
|
|
31
|
+
if (typeof value !== "string") return value;
|
|
32
|
+
return value.startsWith("@") ? value.slice(1) : value;
|
|
33
|
+
}
|
package/src/diff.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Pixelmatch diff against a reference PNG.
|
|
2
2
|
|
|
3
|
-
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
4
4
|
import { join, dirname } from "node:path";
|
|
5
5
|
import { launch, newPage } from "./runway.js";
|
|
6
6
|
import { resolveViewport } from "./viewport.js";
|
|
@@ -24,7 +24,8 @@ export async function runDiff(url, refPath, out, threshold, flags = {}, browser)
|
|
|
24
24
|
requireArg("url", url, "string");
|
|
25
25
|
requireArg("refPath", refPath, "string");
|
|
26
26
|
const t = threshold !== undefined ? Number(threshold) : DIFF_DEFAULT_THRESHOLD;
|
|
27
|
-
if (!existsSync(refPath)) return { url, refPath, error: "reference file not found" };
|
|
27
|
+
if (!existsSync(refPath)) return { url, refPath, error: "reference file not found" };
|
|
28
|
+
if (out) mkdirSync(dirname(out), { recursive: true });
|
|
28
29
|
const PNG = await loadPngjs();
|
|
29
30
|
const pixelmatch = await loadPixelmatch();
|
|
30
31
|
const ownBrowser = !browser;
|
package/src/doctor.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { browserSetupHints, discoverBrowsers } from "./browser-discovery.js";
|
|
5
|
+
import { resolvePlaywrightCore } from "./runway.js";
|
|
6
|
+
|
|
7
|
+
function nodeMajor(version = process.version) {
|
|
8
|
+
const m = String(version).match(/^v?(\d+)/);
|
|
9
|
+
return m ? Number(m[1]) : 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function okCheck(name, ok, details = {}) {
|
|
13
|
+
return { name, ok: !!ok, ...details };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function runDoctor(options = {}) {
|
|
17
|
+
const version = options.version || process.version;
|
|
18
|
+
const browsers = discoverBrowsers(options);
|
|
19
|
+
let playwright = null;
|
|
20
|
+
try {
|
|
21
|
+
const resolved = await resolvePlaywrightCore();
|
|
22
|
+
playwright = okCheck("playwright-core", true, { source: resolved.source });
|
|
23
|
+
} catch (e) {
|
|
24
|
+
playwright = okCheck("playwright-core", false, { error: e.message });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const packageRoot = options.packageRoot || dirname(dirname(fileURLToPath(import.meta.url)));
|
|
28
|
+
const skillPath = join(packageRoot, "SKILL.md");
|
|
29
|
+
const checks = [
|
|
30
|
+
okCheck("node", nodeMajor(version) >= 18, { version, required: ">=18" }),
|
|
31
|
+
playwright,
|
|
32
|
+
okCheck("browser", !!browsers.preferred, { preferred: browsers.preferred, found: browsers.found }),
|
|
33
|
+
okCheck("skill", existsSync(skillPath), { path: skillPath }),
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const ok = checks.every((check) => check.ok);
|
|
37
|
+
return {
|
|
38
|
+
ok,
|
|
39
|
+
checks,
|
|
40
|
+
browsers,
|
|
41
|
+
hints: ok ? [] : browserSetupHints(options.platform || process.platform),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function renderDoctorText(result) {
|
|
46
|
+
const lines = [];
|
|
47
|
+
lines.push(result.ok ? "pursr doctor: OK" : "pursr doctor: attention needed");
|
|
48
|
+
for (const check of result.checks) {
|
|
49
|
+
const mark = check.ok ? "ok" : "missing";
|
|
50
|
+
let detail = "";
|
|
51
|
+
if (check.name === "browser") detail = check.preferred ? ` (${check.preferred})` : "";
|
|
52
|
+
if (check.name === "playwright-core") detail = check.source ? ` (${check.source})` : check.error ? ` (${check.error})` : "";
|
|
53
|
+
if (check.name === "node") detail = ` (${check.version}, required ${check.required})`;
|
|
54
|
+
lines.push(`- ${mark}: ${check.name}${detail}`);
|
|
55
|
+
}
|
|
56
|
+
if (result.hints.length) {
|
|
57
|
+
lines.push("");
|
|
58
|
+
lines.push("Next steps:");
|
|
59
|
+
for (const hint of result.hints) lines.push(`- ${hint}`);
|
|
60
|
+
}
|
|
61
|
+
return lines.join("\n");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function runSetup(options = {}) {
|
|
65
|
+
const doctor = await runDoctor(options);
|
|
66
|
+
return {
|
|
67
|
+
ok: doctor.ok,
|
|
68
|
+
doctor,
|
|
69
|
+
recommended: doctor.ok ? [
|
|
70
|
+
"Run: pursr probe https://example.com",
|
|
71
|
+
"For agents, point them at node_modules/pursr/SKILL.md or enable the pursr MCP server.",
|
|
72
|
+
] : [
|
|
73
|
+
...doctor.hints,
|
|
74
|
+
"After fixing dependencies, run: pursr doctor",
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function renderSetupText(result) {
|
|
80
|
+
const lines = [];
|
|
81
|
+
lines.push(result.ok ? "pursr setup: ready" : "pursr setup: manual steps required");
|
|
82
|
+
lines.push("");
|
|
83
|
+
lines.push(renderDoctorText(result.doctor));
|
|
84
|
+
lines.push("");
|
|
85
|
+
lines.push("Recommended:");
|
|
86
|
+
for (const item of result.recommended) lines.push(`- ${item}`);
|
|
87
|
+
return lines.join("\n");
|
|
88
|
+
}
|
package/src/eval.js
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
// Evaluate a JS string in the page, optionally screenshot after.
|
|
2
2
|
|
|
3
3
|
import { launch, newPage } from "./runway.js";
|
|
4
|
-
import {
|
|
4
|
+
import { resolveViewport } from "./viewport.js";
|
|
5
5
|
import { gotoOrThrow } from "./overlays.js";
|
|
6
|
-
import { requireArg } from "./util.js";
|
|
6
|
+
import { requireArg } from "./util.js";
|
|
7
|
+
import { mkdirSync } from "node:fs";
|
|
8
|
+
import { dirname } from "node:path";
|
|
7
9
|
|
|
8
|
-
export async function runEval(url, js, out) {
|
|
9
|
-
requireArg("url", url, "string");
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
export async function runEval(url, js, out, flags = {}) {
|
|
11
|
+
requireArg("url", url, "string");
|
|
12
|
+
const viewport = resolveViewport(flags);
|
|
13
|
+
const browser = await launch();
|
|
14
|
+
try {
|
|
15
|
+
const page = await newPage(browser, viewport);
|
|
13
16
|
const r = await gotoOrThrow(page, url);
|
|
14
17
|
const result = await page.evaluate(js);
|
|
15
|
-
if (out)
|
|
16
|
-
|
|
18
|
+
if (out) {
|
|
19
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
20
|
+
await page.screenshot({ path: out, fullPage: false });
|
|
21
|
+
}
|
|
22
|
+
return { ...r, url, out, viewport, result };
|
|
17
23
|
} finally { try { await browser.close(); } catch {} }
|
|
18
|
-
}
|
|
24
|
+
}
|
package/src/hover.js
CHANGED
|
@@ -4,7 +4,9 @@ import { launch, newPage } from "./runway.js";
|
|
|
4
4
|
import { resolveViewport } from "./viewport.js";
|
|
5
5
|
import { gotoOrThrow, settle, CLICK_TIMEOUT_MS } from "./overlays.js";
|
|
6
6
|
import { resolveLocator } from "./selector.js";
|
|
7
|
-
import { asNum, asBool, nowIso, writeSidecar, requireArg } from "./util.js";
|
|
7
|
+
import { asNum, asBool, nowIso, writeSidecar, requireArg } from "./util.js";
|
|
8
|
+
import { mkdirSync } from "node:fs";
|
|
9
|
+
import { dirname } from "node:path";
|
|
8
10
|
|
|
9
11
|
export async function runHover({ url, selector, out, flags = {} }) {
|
|
10
12
|
requireArg("url", url, "string");
|
|
@@ -18,9 +20,12 @@ export async function runHover({ url, selector, out, flags = {} }) {
|
|
|
18
20
|
await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
|
|
19
21
|
await loc.first().hover({ timeout: CLICK_TIMEOUT_MS });
|
|
20
22
|
await page.waitForTimeout(asNum(flags["hover-ms"], 250));
|
|
21
|
-
if (out)
|
|
23
|
+
if (out) {
|
|
24
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
25
|
+
await page.screenshot({ path: out, fullPage: asBool(flags.full, false) });
|
|
26
|
+
}
|
|
22
27
|
const meta = { ...r, url, out, selector, viewport, ts: nowIso() };
|
|
23
28
|
if (out) await writeSidecar(meta);
|
|
24
29
|
return meta;
|
|
25
30
|
} finally { try { await browser.close(); } catch {} }
|
|
26
|
-
}
|
|
31
|
+
}
|
package/src/interact.js
CHANGED
|
@@ -1,50 +1,56 @@
|
|
|
1
1
|
// click, type, wait, seq — interaction primitives.
|
|
2
2
|
|
|
3
3
|
import { launch, newPage } from "./runway.js";
|
|
4
|
-
import {
|
|
4
|
+
import { resolveViewport } from "./viewport.js";
|
|
5
5
|
import { gotoOrThrow, settle, CLICK_TIMEOUT_MS, WAIT_DEFAULT_TIMEOUT_MS } from "./overlays.js";
|
|
6
6
|
import { resolveLocator } from "./selector.js";
|
|
7
|
-
import { requireArg } from "./util.js";
|
|
7
|
+
import { requireArg } from "./util.js";
|
|
8
|
+
import { mkdirSync } from "node:fs";
|
|
9
|
+
import { dirname } from "node:path";
|
|
10
|
+
|
|
11
|
+
function ensureScreenshotDir(out) {
|
|
12
|
+
if (out) mkdirSync(dirname(out), { recursive: true });
|
|
13
|
+
}
|
|
8
14
|
|
|
9
|
-
export async function runClick(url, selector, out) {
|
|
15
|
+
export async function runClick(url, selector, out, flags = {}) {
|
|
10
16
|
requireArg("url", url, "string");
|
|
11
17
|
requireArg("selector", selector, "string");
|
|
12
18
|
const browser = await launch();
|
|
13
19
|
try {
|
|
14
|
-
const page = await newPage(browser,
|
|
20
|
+
const page = await newPage(browser, resolveViewport(flags));
|
|
15
21
|
const r = await gotoOrThrow(page, url); await settle(page);
|
|
16
22
|
const loc = await resolveLocator(page, selector);
|
|
17
23
|
await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
|
|
18
24
|
await loc.first().click({ timeout: CLICK_TIMEOUT_MS });
|
|
19
25
|
await settle(page);
|
|
20
|
-
if (out) await page.screenshot({ path: out, fullPage: false });
|
|
26
|
+
if (out) { ensureScreenshotDir(out); await page.screenshot({ path: out, fullPage: false }); }
|
|
21
27
|
return { ...r, url, out, selector, clicked: true };
|
|
22
28
|
} finally { try { await browser.close(); } catch {} }
|
|
23
29
|
}
|
|
24
30
|
|
|
25
|
-
export async function runType(url, selector, text, out) {
|
|
31
|
+
export async function runType(url, selector, text, out, flags = {}) {
|
|
26
32
|
requireArg("url", url, "string");
|
|
27
33
|
requireArg("selector", selector, "string");
|
|
28
34
|
const browser = await launch();
|
|
29
35
|
try {
|
|
30
|
-
const page = await newPage(browser,
|
|
36
|
+
const page = await newPage(browser, resolveViewport(flags));
|
|
31
37
|
const r = await gotoOrThrow(page, url); await settle(page);
|
|
32
38
|
const loc = await resolveLocator(page, selector);
|
|
33
39
|
await loc.first().waitFor({ state: "visible", timeout: CLICK_TIMEOUT_MS });
|
|
34
40
|
await loc.first().click({ timeout: CLICK_TIMEOUT_MS });
|
|
35
41
|
await page.keyboard.type(String(text ?? ""), { delay: 10 });
|
|
36
42
|
await settle(page);
|
|
37
|
-
if (out) await page.screenshot({ path: out, fullPage: false });
|
|
43
|
+
if (out) { ensureScreenshotDir(out); await page.screenshot({ path: out, fullPage: false }); }
|
|
38
44
|
return { ...r, url, out, selector, text, typed: true };
|
|
39
45
|
} finally { try { await browser.close(); } catch {} }
|
|
40
46
|
}
|
|
41
47
|
|
|
42
|
-
export async function runWait(url, selector, timeoutMs) {
|
|
48
|
+
export async function runWait(url, selector, timeoutMs, flags = {}) {
|
|
43
49
|
requireArg("url", url, "string");
|
|
44
50
|
requireArg("selector", selector, "string");
|
|
45
51
|
const browser = await launch();
|
|
46
52
|
try {
|
|
47
|
-
const page = await newPage(browser,
|
|
53
|
+
const page = await newPage(browser, resolveViewport(flags));
|
|
48
54
|
const r = await gotoOrThrow(page, url);
|
|
49
55
|
const loc = await resolveLocator(page, selector);
|
|
50
56
|
const t = timeoutMs || WAIT_DEFAULT_TIMEOUT_MS;
|
|
@@ -57,7 +63,7 @@ export async function runWait(url, selector, timeoutMs) {
|
|
|
57
63
|
} finally { try { await browser.close(); } catch {} }
|
|
58
64
|
}
|
|
59
65
|
|
|
60
|
-
export async function runSeq(url, actionsJson, out) {
|
|
66
|
+
export async function runSeq(url, actionsJson, out, flags = {}) {
|
|
61
67
|
requireArg("url", url, "string");
|
|
62
68
|
let actions;
|
|
63
69
|
try { actions = JSON.parse(actionsJson); }
|
|
@@ -66,7 +72,7 @@ export async function runSeq(url, actionsJson, out) {
|
|
|
66
72
|
if (!actions.length) throw new Error("actions array is empty");
|
|
67
73
|
const browser = await launch();
|
|
68
74
|
try {
|
|
69
|
-
const page = await newPage(browser,
|
|
75
|
+
const page = await newPage(browser, resolveViewport(flags));
|
|
70
76
|
const r = await gotoOrThrow(page, url); await settle(page);
|
|
71
77
|
const trace = [];
|
|
72
78
|
let failed = false;
|
|
@@ -132,7 +138,7 @@ export async function runSeq(url, actionsJson, out) {
|
|
|
132
138
|
trace.push(step);
|
|
133
139
|
if (failed) break;
|
|
134
140
|
}
|
|
135
|
-
if (out) await page.screenshot({ path: out, fullPage: false });
|
|
141
|
+
if (out) { ensureScreenshotDir(out); await page.screenshot({ path: out, fullPage: false }); }
|
|
136
142
|
return { ...r, url, out, steps: trace, failed };
|
|
137
143
|
} finally { try { await browser.close(); } catch {} }
|
|
138
|
-
}
|
|
144
|
+
}
|
package/src/runway.js
CHANGED
|
@@ -1,55 +1,53 @@
|
|
|
1
|
-
// Browser launcher: auto-detect Playwright + system Chrome.
|
|
2
|
-
|
|
3
|
-
import { existsSync } from "node:fs";
|
|
4
|
-
import { homedir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
1
|
+
// Browser launcher: auto-detect Playwright + system Chrome.
|
|
2
|
+
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { findBrowserExecutable } from "./browser-discovery.js";
|
|
7
|
+
|
|
8
|
+
let _chromium = null;
|
|
9
|
+
export async function resolvePlaywrightCore() {
|
|
10
|
+
if (_chromium) return { chromium: _chromium, source: "cached" };
|
|
11
|
+
// Try local node_modules first
|
|
12
|
+
try {
|
|
13
|
+
_chromium = (await import("playwright-core")).chromium;
|
|
14
|
+
return { chromium: _chromium, source: "playwright-core" };
|
|
15
|
+
} catch {}
|
|
16
|
+
// Try Codex cua_node runtime (current Codex Desktop bundles it).
|
|
17
|
+
// The cua_node folder has a hash subdir (e.g. 789504f803e82e2b), so we
|
|
18
|
+
// walk it to find the playwright-core entry.
|
|
15
19
|
const cuaRoot = join(homedir(), "AppData", "Local", "OpenAI", "Codex", "runtimes", "cua_node");
|
|
16
20
|
if (existsSync(cuaRoot)) {
|
|
17
21
|
const { readdirSync } = await import("node:fs");
|
|
18
22
|
const subdirs = (() => { try { return readdirSync(cuaRoot); } catch { return []; } })();
|
|
19
23
|
for (const sub of subdirs) {
|
|
20
24
|
const cand = join(cuaRoot, sub, "bin", "node_modules", "playwright-core", "index.mjs");
|
|
21
|
-
if (existsSync(cand)) {
|
|
22
|
-
try {
|
|
23
|
-
const url = "file:///" + cand.replace(/\\/g, "/");
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
];
|
|
39
|
-
|
|
40
|
-
for (const p of CHROME_PATHS) if (existsSync(p)) return p;
|
|
41
|
-
return null;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const BROWSER_ARGS = Object.freeze(["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"]);
|
|
45
|
-
|
|
25
|
+
if (existsSync(cand)) {
|
|
26
|
+
try {
|
|
27
|
+
const url = "file:///" + cand.replace(/\\/g, "/");
|
|
28
|
+
_chromium = (await import(url)).chromium;
|
|
29
|
+
return { chromium: _chromium, source: "codex-cua-node" };
|
|
30
|
+
} catch {}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
throw new Error("playwright-core not found. Install it: npm i -D playwright-core");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function getChromium() {
|
|
38
|
+
const resolved = await resolvePlaywrightCore();
|
|
39
|
+
return resolved.chromium;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const BROWSER_ARGS = Object.freeze(["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"]);
|
|
43
|
+
|
|
46
44
|
export async function launch(options = {}) {
|
|
47
45
|
const chromium = await getChromium();
|
|
48
|
-
const exec =
|
|
49
|
-
if (!exec) throw new Error("
|
|
46
|
+
const exec = options.executablePath || findBrowserExecutable();
|
|
47
|
+
if (!exec) throw new Error("Chrome-compatible browser not found. Run: pursr doctor");
|
|
50
48
|
return await chromium.launch({
|
|
51
49
|
headless: options.headless !== false,
|
|
52
|
-
executablePath:
|
|
50
|
+
executablePath: exec,
|
|
53
51
|
slowMo: Math.max(0, Number(options.slowMo) || 0),
|
|
54
52
|
args: [...BROWSER_ARGS, ...(Array.isArray(options.args) ? options.args : [])],
|
|
55
53
|
});
|
package/src/shoot.js
CHANGED
|
@@ -10,10 +10,13 @@ import {
|
|
|
10
10
|
import { asNum, asBool, nowIso, writeSidecar, requireArg } from "./util.js";
|
|
11
11
|
import { runBeforeShoot, runAfterShoot } from "./plugin.js";
|
|
12
12
|
import { startHarCapture, stopHarCapture, writeHar } from "./har.js";
|
|
13
|
-
import { loadAuthState } from "./auth.js";
|
|
13
|
+
import { loadAuthState } from "./auth.js";
|
|
14
|
+
import { mkdirSync } from "node:fs";
|
|
15
|
+
import { dirname } from "node:path";
|
|
14
16
|
|
|
15
|
-
export async function runShoot({ url, out, flags = {}, prepare, browser: extBrowser }) {
|
|
16
|
-
requireArg("url", url, "string");
|
|
17
|
+
export async function runShoot({ url, out, flags = {}, prepare, browser: extBrowser }) {
|
|
18
|
+
requireArg("url", url, "string");
|
|
19
|
+
if (out) mkdirSync(dirname(out), { recursive: true });
|
|
17
20
|
const viewport = resolveViewport(flags);
|
|
18
21
|
const ownBrowser = !extBrowser;
|
|
19
22
|
const browser = extBrowser || await launch();
|
|
@@ -71,4 +74,4 @@ export async function runShootWithSidecar(args) {
|
|
|
71
74
|
const meta = await runShoot(args);
|
|
72
75
|
await writeSidecar(meta);
|
|
73
76
|
return meta;
|
|
74
|
-
}
|
|
77
|
+
}
|