@ulpi/browse 0.10.0 → 1.0.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/README.md +6 -6
- package/dist/browse.mjs +6756 -0
- package/package.json +17 -13
- package/skill/SKILL.md +2 -3
- package/bin/browse.ts +0 -11
- package/src/auth-vault.ts +0 -196
- package/src/browser-manager.ts +0 -976
- package/src/buffers.ts +0 -81
- package/src/bun.d.ts +0 -65
- package/src/chrome-discover.ts +0 -73
- package/src/cli.ts +0 -783
- package/src/commands/meta.ts +0 -986
- package/src/commands/read.ts +0 -375
- package/src/commands/write.ts +0 -704
- package/src/config.ts +0 -44
- package/src/constants.ts +0 -14
- package/src/cookie-import.ts +0 -410
- package/src/diff.d.ts +0 -12
- package/src/domain-filter.ts +0 -140
- package/src/encryption.ts +0 -48
- package/src/har.ts +0 -66
- package/src/install-skill.ts +0 -98
- package/src/png-compare.ts +0 -247
- package/src/policy.ts +0 -94
- package/src/rebrowser.d.ts +0 -7
- package/src/record-export.ts +0 -98
- package/src/runtime.ts +0 -161
- package/src/sanitize.ts +0 -11
- package/src/server.ts +0 -526
- package/src/session-manager.ts +0 -240
- package/src/session-persist.ts +0 -192
- package/src/snapshot.ts +0 -606
- package/src/types.ts +0 -12
package/src/har.ts
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HAR 1.2 export — converts NetworkEntry[] to HTTP Archive format
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import type { NetworkEntry } from './buffers';
|
|
6
|
-
|
|
7
|
-
export interface HarRecording {
|
|
8
|
-
startTime: number;
|
|
9
|
-
active: boolean;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
function parseQueryString(url: string): Array<{ name: string; value: string }> {
|
|
13
|
-
try {
|
|
14
|
-
const u = new URL(url);
|
|
15
|
-
return [...u.searchParams.entries()].map(([name, value]) => ({ name, value }));
|
|
16
|
-
} catch {
|
|
17
|
-
return [];
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function formatAsHar(entries: NetworkEntry[], startTime: number): object {
|
|
22
|
-
const harEntries = entries
|
|
23
|
-
.filter(e => e.timestamp >= startTime)
|
|
24
|
-
.map(e => ({
|
|
25
|
-
startedDateTime: new Date(e.timestamp).toISOString(),
|
|
26
|
-
time: e.duration || 0,
|
|
27
|
-
request: {
|
|
28
|
-
method: e.method,
|
|
29
|
-
url: e.url,
|
|
30
|
-
httpVersion: 'HTTP/1.1',
|
|
31
|
-
cookies: [],
|
|
32
|
-
headers: [],
|
|
33
|
-
queryString: parseQueryString(e.url),
|
|
34
|
-
headersSize: -1,
|
|
35
|
-
bodySize: -1,
|
|
36
|
-
},
|
|
37
|
-
response: {
|
|
38
|
-
status: e.status || 0,
|
|
39
|
-
statusText: '',
|
|
40
|
-
httpVersion: 'HTTP/1.1',
|
|
41
|
-
cookies: [],
|
|
42
|
-
headers: [],
|
|
43
|
-
content: {
|
|
44
|
-
size: e.size || 0,
|
|
45
|
-
mimeType: '',
|
|
46
|
-
},
|
|
47
|
-
redirectURL: '',
|
|
48
|
-
headersSize: -1,
|
|
49
|
-
bodySize: e.size || -1,
|
|
50
|
-
},
|
|
51
|
-
cache: {},
|
|
52
|
-
timings: {
|
|
53
|
-
send: 0,
|
|
54
|
-
wait: e.duration || 0,
|
|
55
|
-
receive: 0,
|
|
56
|
-
},
|
|
57
|
-
}));
|
|
58
|
-
|
|
59
|
-
return {
|
|
60
|
-
log: {
|
|
61
|
-
version: '1.2',
|
|
62
|
-
creator: { name: '@ulpi/browse', version: '0.2.0' },
|
|
63
|
-
entries: harEntries,
|
|
64
|
-
},
|
|
65
|
-
};
|
|
66
|
-
}
|
package/src/install-skill.ts
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* browse install-skill — install the browse Claude Code skill into a project
|
|
3
|
-
*
|
|
4
|
-
* Copies SKILL.md to .claude/skills/browse/SKILL.md
|
|
5
|
-
* and adds browse permission rules to .claude/settings.json
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import * as fs from 'fs';
|
|
9
|
-
import * as path from 'path';
|
|
10
|
-
|
|
11
|
-
const PERMISSIONS = [
|
|
12
|
-
'Bash(browse:*)',
|
|
13
|
-
'Bash(browse goto:*)', 'Bash(browse back:*)', 'Bash(browse forward:*)',
|
|
14
|
-
'Bash(browse reload:*)', 'Bash(browse url:*)', 'Bash(browse text:*)',
|
|
15
|
-
'Bash(browse html:*)', 'Bash(browse links:*)', 'Bash(browse forms:*)',
|
|
16
|
-
'Bash(browse accessibility:*)', 'Bash(browse snapshot:*)',
|
|
17
|
-
'Bash(browse snapshot-diff:*)', 'Bash(browse click:*)',
|
|
18
|
-
'Bash(browse fill:*)', 'Bash(browse select:*)', 'Bash(browse hover:*)',
|
|
19
|
-
'Bash(browse type:*)', 'Bash(browse press:*)', 'Bash(browse scroll:*)',
|
|
20
|
-
'Bash(browse wait:*)', 'Bash(browse viewport:*)', 'Bash(browse upload:*)',
|
|
21
|
-
'Bash(browse dialog-accept:*)', 'Bash(browse dialog-dismiss:*)',
|
|
22
|
-
'Bash(browse js:*)', 'Bash(browse eval:*)', 'Bash(browse css:*)',
|
|
23
|
-
'Bash(browse attrs:*)', 'Bash(browse state:*)', 'Bash(browse dialog:*)',
|
|
24
|
-
'Bash(browse console:*)', 'Bash(browse network:*)',
|
|
25
|
-
'Bash(browse cookies:*)', 'Bash(browse storage:*)', 'Bash(browse perf:*)',
|
|
26
|
-
'Bash(browse devices:*)', 'Bash(browse emulate:*)',
|
|
27
|
-
'Bash(browse screenshot:*)', 'Bash(browse pdf:*)',
|
|
28
|
-
'Bash(browse responsive:*)', 'Bash(browse diff:*)',
|
|
29
|
-
'Bash(browse chain:*)', 'Bash(browse tabs:*)', 'Bash(browse tab:*)',
|
|
30
|
-
'Bash(browse newtab:*)', 'Bash(browse closetab:*)',
|
|
31
|
-
'Bash(browse sessions:*)', 'Bash(browse session-close:*)',
|
|
32
|
-
'Bash(browse status:*)', 'Bash(browse stop:*)', 'Bash(browse restart:*)',
|
|
33
|
-
'Bash(browse cookie:*)', 'Bash(browse header:*)',
|
|
34
|
-
'Bash(browse useragent:*)',
|
|
35
|
-
];
|
|
36
|
-
|
|
37
|
-
export function installSkill(targetDir?: string) {
|
|
38
|
-
const dir = targetDir || process.cwd();
|
|
39
|
-
|
|
40
|
-
const hasGit = fs.existsSync(path.join(dir, '.git'));
|
|
41
|
-
const hasClaude = fs.existsSync(path.join(dir, '.claude'));
|
|
42
|
-
if (!hasGit && !hasClaude) {
|
|
43
|
-
console.error(`Not a project root: ${dir}`);
|
|
44
|
-
console.error('Run from a directory with .git or .claude, or pass the path as an argument.');
|
|
45
|
-
process.exit(1);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// 1. Copy SKILL.md
|
|
49
|
-
const skillDir = path.join(dir, '.claude', 'skills', 'browse');
|
|
50
|
-
fs.mkdirSync(skillDir, { recursive: true });
|
|
51
|
-
|
|
52
|
-
const skillSource = path.resolve(import.meta.dir, '..', 'skill', 'SKILL.md');
|
|
53
|
-
const skillDest = path.join(skillDir, 'SKILL.md');
|
|
54
|
-
|
|
55
|
-
if (!fs.existsSync(skillSource)) {
|
|
56
|
-
console.error(`SKILL.md not found at ${skillSource}`);
|
|
57
|
-
console.error('Is @ulpi/browse installed correctly?');
|
|
58
|
-
process.exit(1);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
fs.copyFileSync(skillSource, skillDest);
|
|
62
|
-
console.log(`Skill installed: ${path.relative(dir, skillDest)}`);
|
|
63
|
-
|
|
64
|
-
// 2. Update .claude/settings.json with permissions
|
|
65
|
-
const settingsPath = path.join(dir, '.claude', 'settings.json');
|
|
66
|
-
let settings: any = {};
|
|
67
|
-
|
|
68
|
-
if (fs.existsSync(settingsPath)) {
|
|
69
|
-
try {
|
|
70
|
-
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
71
|
-
} catch {
|
|
72
|
-
console.error(`Warning: could not parse ${settingsPath}, creating fresh`);
|
|
73
|
-
settings = {};
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
if (!settings.permissions) settings.permissions = {};
|
|
78
|
-
if (!Array.isArray(settings.permissions.allow)) settings.permissions.allow = [];
|
|
79
|
-
|
|
80
|
-
const existing = new Set(settings.permissions.allow);
|
|
81
|
-
let added = 0;
|
|
82
|
-
for (const perm of PERMISSIONS) {
|
|
83
|
-
if (!existing.has(perm)) {
|
|
84
|
-
settings.permissions.allow.push(perm);
|
|
85
|
-
added++;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
90
|
-
|
|
91
|
-
if (added > 0) {
|
|
92
|
-
console.log(`Permissions: ${added} rules added to ${path.relative(dir, settingsPath)}`);
|
|
93
|
-
} else {
|
|
94
|
-
console.log(`Permissions: already configured`);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
console.log('\nDone. Claude Code will now use browse for web tasks automatically.');
|
|
98
|
-
}
|
package/src/png-compare.ts
DELETED
|
@@ -1,247 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Self-contained PNG decoder, encoder + pixel comparator.
|
|
3
|
-
* No external deps — uses only zlib (Node/Bun built-in).
|
|
4
|
-
* Works in both dev mode (bun run) and compiled binary ($bunfs).
|
|
5
|
-
*
|
|
6
|
-
* Decoder supports: 8-bit RGB (color type 2) and RGBA (color type 6).
|
|
7
|
-
* Handles all 5 PNG scanline filter types (None/Sub/Up/Average/Paeth).
|
|
8
|
-
* Encoder outputs: 8-bit RGBA (color type 6), filter None, zlib-compressed.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import * as zlib from 'zlib';
|
|
12
|
-
|
|
13
|
-
const PNG_MAGIC = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
14
|
-
|
|
15
|
-
export interface DecodedImage {
|
|
16
|
-
width: number;
|
|
17
|
-
height: number;
|
|
18
|
-
data: Buffer; // RGBA pixels
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface CompareResult {
|
|
22
|
-
totalPixels: number;
|
|
23
|
-
diffPixels: number;
|
|
24
|
-
mismatchPct: number;
|
|
25
|
-
passed: boolean;
|
|
26
|
-
diffImage?: Buffer;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function decodePNG(buf: Buffer): DecodedImage {
|
|
30
|
-
for (let i = 0; i < 8; i++) {
|
|
31
|
-
if (buf[i] !== PNG_MAGIC[i]) throw new Error('Not a valid PNG file');
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const width = buf.readUInt32BE(16);
|
|
35
|
-
const height = buf.readUInt32BE(20);
|
|
36
|
-
const bitDepth = buf[24];
|
|
37
|
-
const colorType = buf[25];
|
|
38
|
-
const interlace = buf[28];
|
|
39
|
-
|
|
40
|
-
if (bitDepth !== 8) throw new Error(`Unsupported PNG bit depth: ${bitDepth} (only 8-bit supported)`);
|
|
41
|
-
if (colorType !== 2 && colorType !== 6) throw new Error(`Unsupported PNG color type: ${colorType} (only RGB=2 and RGBA=6 supported)`);
|
|
42
|
-
if (interlace !== 0) throw new Error('Interlaced PNGs are not supported');
|
|
43
|
-
|
|
44
|
-
const channels = colorType === 6 ? 4 : 3;
|
|
45
|
-
const stride = width * channels;
|
|
46
|
-
|
|
47
|
-
// Collect IDAT chunks
|
|
48
|
-
const idats: Buffer[] = [];
|
|
49
|
-
let off = 8;
|
|
50
|
-
while (off < buf.length) {
|
|
51
|
-
const len = buf.readUInt32BE(off);
|
|
52
|
-
const type = buf.toString('ascii', off + 4, off + 8);
|
|
53
|
-
if (type === 'IDAT') idats.push(buf.slice(off + 8, off + 8 + len));
|
|
54
|
-
if (type === 'IEND') break;
|
|
55
|
-
off += 12 + len;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const raw = zlib.inflateSync(Buffer.concat(idats));
|
|
59
|
-
const pixels = Buffer.alloc(width * height * 4);
|
|
60
|
-
const prev = Buffer.alloc(stride);
|
|
61
|
-
|
|
62
|
-
for (let y = 0; y < height; y++) {
|
|
63
|
-
const filterType = raw[y * (stride + 1)];
|
|
64
|
-
const scanline = Buffer.from(raw.slice(y * (stride + 1) + 1, (y + 1) * (stride + 1)));
|
|
65
|
-
|
|
66
|
-
for (let x = 0; x < stride; x++) {
|
|
67
|
-
const a = x >= channels ? scanline[x - channels] : 0;
|
|
68
|
-
const b = prev[x];
|
|
69
|
-
const c = x >= channels ? prev[x - channels] : 0;
|
|
70
|
-
|
|
71
|
-
switch (filterType) {
|
|
72
|
-
case 0: break;
|
|
73
|
-
case 1: scanline[x] = (scanline[x] + a) & 0xff; break;
|
|
74
|
-
case 2: scanline[x] = (scanline[x] + b) & 0xff; break;
|
|
75
|
-
case 3: scanline[x] = (scanline[x] + ((a + b) >> 1)) & 0xff; break;
|
|
76
|
-
case 4: {
|
|
77
|
-
const p = a + b - c;
|
|
78
|
-
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
|
79
|
-
scanline[x] = (scanline[x] + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c)) & 0xff;
|
|
80
|
-
break;
|
|
81
|
-
}
|
|
82
|
-
default: throw new Error(`Unknown PNG filter type: ${filterType}`);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
for (let x = 0; x < width; x++) {
|
|
87
|
-
const si = x * channels;
|
|
88
|
-
const di = (y * width + x) * 4;
|
|
89
|
-
pixels[di] = scanline[si];
|
|
90
|
-
pixels[di + 1] = scanline[si + 1];
|
|
91
|
-
pixels[di + 2] = scanline[si + 2];
|
|
92
|
-
pixels[di + 3] = channels === 4 ? scanline[si + 3] : 255;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
scanline.copy(prev);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
return { width, height, data: pixels };
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Encode a DecodedImage (RGBA pixels) into a PNG buffer.
|
|
103
|
-
* Uses filter type None (0) for simplicity — zlib handles compression.
|
|
104
|
-
*/
|
|
105
|
-
export function encodePNG(img: DecodedImage): Buffer {
|
|
106
|
-
// Helper: write a PNG chunk (length + type + data + CRC32)
|
|
107
|
-
function writeChunk(type: string, data: Buffer): Buffer {
|
|
108
|
-
const chunk = Buffer.alloc(12 + data.length);
|
|
109
|
-
chunk.writeUInt32BE(data.length, 0);
|
|
110
|
-
chunk.write(type, 4, 4, 'ascii');
|
|
111
|
-
data.copy(chunk, 8);
|
|
112
|
-
// CRC32 covers type + data
|
|
113
|
-
const crcData = chunk.slice(4, 8 + data.length);
|
|
114
|
-
chunk.writeUInt32BE(zlib.crc32(crcData) >>> 0, 8 + data.length);
|
|
115
|
-
return chunk;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// PNG signature
|
|
119
|
-
const signature = Buffer.from(PNG_MAGIC);
|
|
120
|
-
|
|
121
|
-
// IHDR: width(4) + height(4) + bitDepth(1) + colorType(1) + compression(1) + filter(1) + interlace(1)
|
|
122
|
-
const ihdr = Buffer.alloc(13);
|
|
123
|
-
ihdr.writeUInt32BE(img.width, 0);
|
|
124
|
-
ihdr.writeUInt32BE(img.height, 4);
|
|
125
|
-
ihdr[8] = 8; // bit depth
|
|
126
|
-
ihdr[9] = 6; // color type: RGBA
|
|
127
|
-
ihdr[10] = 0; // compression method
|
|
128
|
-
ihdr[11] = 0; // filter method
|
|
129
|
-
ihdr[12] = 0; // no interlace
|
|
130
|
-
|
|
131
|
-
// IDAT: for each scanline, prepend filter byte 0 (None), then raw RGBA pixels
|
|
132
|
-
const rawStride = img.width * 4;
|
|
133
|
-
const rawData = Buffer.alloc(img.height * (1 + rawStride));
|
|
134
|
-
for (let y = 0; y < img.height; y++) {
|
|
135
|
-
const outOff = y * (1 + rawStride);
|
|
136
|
-
rawData[outOff] = 0; // filter type: None
|
|
137
|
-
img.data.copy(rawData, outOff + 1, y * rawStride, (y + 1) * rawStride);
|
|
138
|
-
}
|
|
139
|
-
const compressed = zlib.deflateSync(rawData);
|
|
140
|
-
|
|
141
|
-
// IEND: empty chunk
|
|
142
|
-
const iend = Buffer.alloc(0);
|
|
143
|
-
|
|
144
|
-
return Buffer.concat([
|
|
145
|
-
signature,
|
|
146
|
-
writeChunk('IHDR', ihdr),
|
|
147
|
-
writeChunk('IDAT', compressed),
|
|
148
|
-
writeChunk('IEND', iend),
|
|
149
|
-
]);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Generate a visual diff image highlighting pixel differences.
|
|
154
|
-
* - Pixels only in one image (size mismatch): bright red (255,0,0,255)
|
|
155
|
-
* - Pixels differing beyond threshold: red-tinted (255, g/3, b/3, 255)
|
|
156
|
-
* - Pixels matching: dimmed (r/3, g/3, b/3, 128)
|
|
157
|
-
*/
|
|
158
|
-
export function generateDiffImage(base: DecodedImage, curr: DecodedImage, colorThreshold: number): Buffer {
|
|
159
|
-
const w = Math.max(base.width, curr.width);
|
|
160
|
-
const h = Math.max(base.height, curr.height);
|
|
161
|
-
const diffData = Buffer.alloc(w * h * 4);
|
|
162
|
-
const colorThreshSq = colorThreshold * colorThreshold * 3;
|
|
163
|
-
|
|
164
|
-
for (let y = 0; y < h; y++) {
|
|
165
|
-
for (let x = 0; x < w; x++) {
|
|
166
|
-
const di = (y * w + x) * 4;
|
|
167
|
-
const inBase = x < base.width && y < base.height;
|
|
168
|
-
const inCurr = x < curr.width && y < curr.height;
|
|
169
|
-
|
|
170
|
-
if (!inBase || !inCurr) {
|
|
171
|
-
// Size mismatch — bright red
|
|
172
|
-
diffData[di] = 255;
|
|
173
|
-
diffData[di + 1] = 0;
|
|
174
|
-
diffData[di + 2] = 0;
|
|
175
|
-
diffData[di + 3] = 255;
|
|
176
|
-
continue;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const bi = (y * base.width + x) * 4;
|
|
180
|
-
const ci = (y * curr.width + x) * 4;
|
|
181
|
-
const dr = base.data[bi] - curr.data[ci];
|
|
182
|
-
const dg = base.data[bi + 1] - curr.data[ci + 1];
|
|
183
|
-
const db = base.data[bi + 2] - curr.data[ci + 2];
|
|
184
|
-
const distSq = dr * dr + dg * dg + db * db;
|
|
185
|
-
const isDiff = colorThreshold === 0 ? distSq > 0 : distSq > colorThreshSq;
|
|
186
|
-
|
|
187
|
-
if (isDiff) {
|
|
188
|
-
// Different — red-tinted using current image colors
|
|
189
|
-
diffData[di] = 255;
|
|
190
|
-
diffData[di + 1] = (curr.data[ci + 1] / 3) | 0;
|
|
191
|
-
diffData[di + 2] = (curr.data[ci + 2] / 3) | 0;
|
|
192
|
-
diffData[di + 3] = 255;
|
|
193
|
-
} else {
|
|
194
|
-
// Matching — dimmed
|
|
195
|
-
diffData[di] = (curr.data[ci] / 3) | 0;
|
|
196
|
-
diffData[di + 1] = (curr.data[ci + 1] / 3) | 0;
|
|
197
|
-
diffData[di + 2] = (curr.data[ci + 2] / 3) | 0;
|
|
198
|
-
diffData[di + 3] = 128;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
return encodePNG({ width: w, height: h, data: diffData });
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
export function compareScreenshots(
|
|
207
|
-
baselineBuf: Buffer,
|
|
208
|
-
currentBuf: Buffer,
|
|
209
|
-
thresholdPct: number = 0.1,
|
|
210
|
-
colorThreshold: number = 30,
|
|
211
|
-
): CompareResult {
|
|
212
|
-
const base = decodePNG(baselineBuf);
|
|
213
|
-
const curr = decodePNG(currentBuf);
|
|
214
|
-
|
|
215
|
-
const w = Math.max(base.width, curr.width);
|
|
216
|
-
const h = Math.max(base.height, curr.height);
|
|
217
|
-
const totalPixels = w * h;
|
|
218
|
-
let diffPixels = 0;
|
|
219
|
-
// Squared color distance threshold. 0 = exact match (any difference counts).
|
|
220
|
-
const colorThreshSq = colorThreshold * colorThreshold * 3; // across R,G,B channels
|
|
221
|
-
|
|
222
|
-
for (let y = 0; y < h; y++) {
|
|
223
|
-
for (let x = 0; x < w; x++) {
|
|
224
|
-
const inBase = x < base.width && y < base.height;
|
|
225
|
-
const inCurr = x < curr.width && y < curr.height;
|
|
226
|
-
if (!inBase || !inCurr) { diffPixels++; continue; }
|
|
227
|
-
|
|
228
|
-
const bi = (y * base.width + x) * 4;
|
|
229
|
-
const ci = (y * curr.width + x) * 4;
|
|
230
|
-
const dr = base.data[bi] - curr.data[ci];
|
|
231
|
-
const dg = base.data[bi + 1] - curr.data[ci + 1];
|
|
232
|
-
const db = base.data[bi + 2] - curr.data[ci + 2];
|
|
233
|
-
const distSq = dr * dr + dg * dg + db * db;
|
|
234
|
-
if (colorThreshold === 0 ? distSq > 0 : distSq > colorThreshSq) diffPixels++;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
const mismatchPct = totalPixels > 0 ? (diffPixels / totalPixels) * 100 : 0;
|
|
239
|
-
const passed = mismatchPct <= thresholdPct;
|
|
240
|
-
const result: CompareResult = { totalPixels, diffPixels, mismatchPct, passed };
|
|
241
|
-
|
|
242
|
-
if (!passed) {
|
|
243
|
-
result.diffImage = generateDiffImage(base, curr, colorThreshold);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return result;
|
|
247
|
-
}
|
package/src/policy.ts
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Action policy — gate commands via JSON config
|
|
3
|
-
*
|
|
4
|
-
* File: browse-policy.json (project root) or BROWSE_POLICY env var
|
|
5
|
-
* Format: { default: "allow"|"deny", deny?: string[], confirm?: string[], allow?: string[] }
|
|
6
|
-
* Precedence: deny > confirm > allow whitelist > default
|
|
7
|
-
* Hot-reloads on mtime change.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import * as fs from 'fs';
|
|
11
|
-
import * as path from 'path';
|
|
12
|
-
|
|
13
|
-
interface ActionPolicy {
|
|
14
|
-
default?: 'allow' | 'deny';
|
|
15
|
-
deny?: string[];
|
|
16
|
-
confirm?: string[];
|
|
17
|
-
allow?: string[];
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export type PolicyResult = 'allow' | 'deny' | 'confirm';
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Walk up from cwd looking for a file by name.
|
|
24
|
-
* Returns the full path if found, or null.
|
|
25
|
-
*/
|
|
26
|
-
function findFileUpward(filename: string): string | null {
|
|
27
|
-
let dir = process.cwd();
|
|
28
|
-
for (let i = 0; i < 20; i++) {
|
|
29
|
-
const candidate = path.join(dir, filename);
|
|
30
|
-
if (fs.existsSync(candidate)) return candidate;
|
|
31
|
-
const parent = path.dirname(dir);
|
|
32
|
-
if (parent === dir) break;
|
|
33
|
-
dir = parent;
|
|
34
|
-
}
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export class PolicyChecker {
|
|
39
|
-
private filePath: string | null = null;
|
|
40
|
-
private lastMtime: number = 0;
|
|
41
|
-
private policy: ActionPolicy | null = null;
|
|
42
|
-
private confirmOverrides: Set<string> | null = null;
|
|
43
|
-
|
|
44
|
-
constructor(filePath?: string) {
|
|
45
|
-
// Explicit path from env or argument, or walk up from cwd to find browse-policy.json.
|
|
46
|
-
this.filePath = filePath || process.env.BROWSE_POLICY || findFileUpward('browse-policy.json') || 'browse-policy.json';
|
|
47
|
-
|
|
48
|
-
// Parse BROWSE_CONFIRM_ACTIONS env var
|
|
49
|
-
const confirmEnv = process.env.BROWSE_CONFIRM_ACTIONS;
|
|
50
|
-
if (confirmEnv) {
|
|
51
|
-
this.confirmOverrides = new Set(
|
|
52
|
-
confirmEnv.split(',').map(s => s.trim()).filter(Boolean)
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
this.reload();
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
private reload(): void {
|
|
60
|
-
if (!this.filePath) return;
|
|
61
|
-
try {
|
|
62
|
-
const stat = fs.statSync(this.filePath);
|
|
63
|
-
if (stat.mtimeMs === this.lastMtime) return;
|
|
64
|
-
this.lastMtime = stat.mtimeMs;
|
|
65
|
-
|
|
66
|
-
const raw = fs.readFileSync(this.filePath, 'utf-8');
|
|
67
|
-
this.policy = JSON.parse(raw);
|
|
68
|
-
} catch {
|
|
69
|
-
// File missing or invalid — if it was loaded before, keep last-known-good.
|
|
70
|
-
// If it never existed, policy stays null (everything allowed).
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
check(command: string): PolicyResult {
|
|
75
|
-
this.reload();
|
|
76
|
-
|
|
77
|
-
// Env var overrides take priority for confirm
|
|
78
|
-
if (this.confirmOverrides?.has(command)) return 'confirm';
|
|
79
|
-
|
|
80
|
-
if (!this.policy) return 'allow';
|
|
81
|
-
|
|
82
|
-
// Precedence: deny > confirm > allow whitelist > default
|
|
83
|
-
if (this.policy.deny?.includes(command)) return 'deny';
|
|
84
|
-
if (this.policy.confirm?.includes(command)) return 'confirm';
|
|
85
|
-
if (this.policy.allow) {
|
|
86
|
-
return this.policy.allow.includes(command) ? 'allow' : 'deny';
|
|
87
|
-
}
|
|
88
|
-
return this.policy.default || 'allow';
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
isActive(): boolean {
|
|
92
|
-
return this.policy !== null || this.confirmOverrides !== null;
|
|
93
|
-
}
|
|
94
|
-
}
|
package/src/rebrowser.d.ts
DELETED
package/src/record-export.ts
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Record export — converts recorded browse commands into replayable formats.
|
|
3
|
-
* - browse: chain-compatible JSON (replay with `browse chain`)
|
|
4
|
-
* - replay: Chrome DevTools Recorder format (replay with `npx @puppeteer/replay` or Playwright)
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
export interface RecordedStep {
|
|
8
|
-
command: string;
|
|
9
|
-
args: string[];
|
|
10
|
-
timestamp: number;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
// ─── Helpers ──────────────────────────────────────────
|
|
14
|
-
|
|
15
|
-
function escapeJS(s: string): string {
|
|
16
|
-
return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r');
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function parseViewport(sizeArg: string): { width: number; height: number } | null {
|
|
20
|
-
const match = sizeArg.match(/^(\d+)x(\d+)$/i);
|
|
21
|
-
if (!match) return null;
|
|
22
|
-
return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) };
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// ─── Browse JSON (chain-compatible) ──────────────────
|
|
26
|
-
|
|
27
|
-
export function exportBrowse(steps: RecordedStep[]): string {
|
|
28
|
-
const commands = steps.map(step => [step.command, ...step.args]);
|
|
29
|
-
return JSON.stringify(commands);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// ─── Chrome DevTools Recorder (replay format) ────────
|
|
33
|
-
|
|
34
|
-
function replayStep(step: RecordedStep): object | null {
|
|
35
|
-
const { command, args } = step;
|
|
36
|
-
const selector = args[0] || '';
|
|
37
|
-
|
|
38
|
-
switch (command) {
|
|
39
|
-
case 'goto':
|
|
40
|
-
return { type: 'navigate', url: args[0] || '', assertedEvents: [{ type: 'navigation', url: args[0] || '', title: '' }] };
|
|
41
|
-
case 'click':
|
|
42
|
-
return { type: 'click', selectors: [[selector]], offsetX: 1, offsetY: 1 };
|
|
43
|
-
case 'dblclick':
|
|
44
|
-
return { type: 'doubleClick', selectors: [[selector]], offsetX: 1, offsetY: 1 };
|
|
45
|
-
case 'fill':
|
|
46
|
-
return { type: 'change', selectors: [[selector]], value: args.slice(1).join(' ') };
|
|
47
|
-
case 'type':
|
|
48
|
-
return null; // handled as individual key events in exportReplay
|
|
49
|
-
case 'press':
|
|
50
|
-
return { type: 'keyDown', key: args[0] || '' };
|
|
51
|
-
case 'select':
|
|
52
|
-
return { type: 'change', selectors: [[selector]], value: args.slice(1).join(' ') };
|
|
53
|
-
case 'scroll':
|
|
54
|
-
if (args[0] === 'down') return { type: 'scroll', x: 0, y: 500 };
|
|
55
|
-
if (args[0] === 'up') return { type: 'scroll', x: 0, y: -500 };
|
|
56
|
-
return { type: 'scroll', selectors: [[selector]], x: 0, y: 200 };
|
|
57
|
-
case 'hover':
|
|
58
|
-
return { type: 'hover', selectors: [[selector]] };
|
|
59
|
-
case 'viewport': {
|
|
60
|
-
const vp = parseViewport(args[0] || '');
|
|
61
|
-
if (!vp) return null;
|
|
62
|
-
return { type: 'setViewport', width: vp.width, height: vp.height, deviceScaleFactor: 1, isMobile: false, hasTouch: false, isLandscape: false };
|
|
63
|
-
}
|
|
64
|
-
case 'wait':
|
|
65
|
-
if (args[0] === '--network-idle') return { type: 'waitForExpression', expression: 'new Promise(r => setTimeout(r, 2000))' };
|
|
66
|
-
if (args[0] === '--url') return { type: 'waitForExpression', expression: `location.href.includes('${escapeJS(args[1] || '')}')` };
|
|
67
|
-
return { type: 'waitForElement', selectors: [[selector]] };
|
|
68
|
-
case 'back':
|
|
69
|
-
return { type: 'waitForExpression', expression: '(window.history.back(), true)' };
|
|
70
|
-
case 'forward':
|
|
71
|
-
return { type: 'waitForExpression', expression: '(window.history.forward(), true)' };
|
|
72
|
-
default:
|
|
73
|
-
return null;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export function exportReplay(steps: RecordedStep[]): string {
|
|
78
|
-
const replaySteps: object[] = [
|
|
79
|
-
{ type: 'setViewport', width: 1920, height: 1080, deviceScaleFactor: 1, isMobile: false, hasTouch: false, isLandscape: false },
|
|
80
|
-
];
|
|
81
|
-
|
|
82
|
-
for (const step of steps) {
|
|
83
|
-
if (step.command === 'type') {
|
|
84
|
-
const text = step.args.join(' ');
|
|
85
|
-
for (const char of text) {
|
|
86
|
-
replaySteps.push({ type: 'keyDown', key: char });
|
|
87
|
-
replaySteps.push({ type: 'keyUp', key: char });
|
|
88
|
-
}
|
|
89
|
-
continue;
|
|
90
|
-
}
|
|
91
|
-
const converted = replayStep(step);
|
|
92
|
-
if (converted) {
|
|
93
|
-
replaySteps.push(converted);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return JSON.stringify({ title: 'browse recording', steps: replaySteps }, null, 2);
|
|
98
|
-
}
|