@ulpi/browse 0.7.5 → 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/LICENSE +1 -1
- package/README.md +444 -300
- package/dist/browse.mjs +6756 -0
- package/package.json +17 -13
- package/skill/SKILL.md +114 -7
- package/bin/browse.ts +0 -11
- package/src/auth-vault.ts +0 -244
- package/src/browser-manager.ts +0 -961
- package/src/buffers.ts +0 -81
- package/src/bun.d.ts +0 -70
- package/src/cli.ts +0 -683
- package/src/commands/meta.ts +0 -748
- package/src/commands/read.ts +0 -347
- package/src/commands/write.ts +0 -484
- package/src/config.ts +0 -45
- package/src/constants.ts +0 -14
- package/src/diff.d.ts +0 -12
- package/src/domain-filter.ts +0 -140
- 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/runtime.ts +0 -161
- package/src/sanitize.ts +0 -11
- package/src/server.ts +0 -485
- package/src/session-manager.ts +0 -192
- package/src/snapshot.ts +0 -606
- package/src/types.ts +0 -12
package/src/commands/read.ts
DELETED
|
@@ -1,347 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Read commands — extract data from pages without side effects
|
|
3
|
-
*
|
|
4
|
-
* text, html, links, forms, accessibility, js, eval, css, attrs, state,
|
|
5
|
-
* console, network, cookies, storage, perf
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { BrowserManager } from '../browser-manager';
|
|
9
|
-
import { listDevices } from '../browser-manager';
|
|
10
|
-
import type { SessionBuffers } from '../buffers';
|
|
11
|
-
import * as fs from 'fs';
|
|
12
|
-
|
|
13
|
-
export async function handleReadCommand(
|
|
14
|
-
command: string,
|
|
15
|
-
args: string[],
|
|
16
|
-
bm: BrowserManager,
|
|
17
|
-
buffers?: SessionBuffers
|
|
18
|
-
): Promise<string> {
|
|
19
|
-
const page = bm.getPage();
|
|
20
|
-
// When a frame is active, evaluate() calls run inside the frame context.
|
|
21
|
-
// For locator-based commands, resolveRef already scopes through the frame.
|
|
22
|
-
const evalCtx = await bm.getFrameContext() || page;
|
|
23
|
-
|
|
24
|
-
switch (command) {
|
|
25
|
-
case 'text': {
|
|
26
|
-
// TreeWalker-based extraction — never appends to the live DOM,
|
|
27
|
-
// so MutationObservers are not triggered.
|
|
28
|
-
return await evalCtx.evaluate(() => {
|
|
29
|
-
const body = document.body;
|
|
30
|
-
if (!body) return '';
|
|
31
|
-
const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'SVG']);
|
|
32
|
-
const lines: string[] = [];
|
|
33
|
-
const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, {
|
|
34
|
-
acceptNode(node) {
|
|
35
|
-
let el = node.parentElement;
|
|
36
|
-
while (el && el !== body) {
|
|
37
|
-
if (SKIP.has(el.tagName)) return NodeFilter.FILTER_REJECT;
|
|
38
|
-
const style = getComputedStyle(el);
|
|
39
|
-
if (style.display === 'none' || style.visibility === 'hidden') return NodeFilter.FILTER_REJECT;
|
|
40
|
-
el = el.parentElement;
|
|
41
|
-
}
|
|
42
|
-
return NodeFilter.FILTER_ACCEPT;
|
|
43
|
-
},
|
|
44
|
-
});
|
|
45
|
-
let node: Node | null;
|
|
46
|
-
while ((node = walker.nextNode())) {
|
|
47
|
-
const text = (node.textContent || '').trim();
|
|
48
|
-
if (text) lines.push(text);
|
|
49
|
-
}
|
|
50
|
-
return lines.join('\n');
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
case 'html': {
|
|
55
|
-
const selector = args[0];
|
|
56
|
-
if (selector) {
|
|
57
|
-
const resolved = bm.resolveRef(selector);
|
|
58
|
-
if ('locator' in resolved) {
|
|
59
|
-
return await resolved.locator.innerHTML({ timeout: 5000 });
|
|
60
|
-
}
|
|
61
|
-
return await page.innerHTML(resolved.selector);
|
|
62
|
-
}
|
|
63
|
-
// When a frame is active, return the frame's full HTML
|
|
64
|
-
if (bm.getActiveFrameSelector()) {
|
|
65
|
-
return await evalCtx.evaluate(() => document.documentElement.outerHTML);
|
|
66
|
-
}
|
|
67
|
-
return await page.content();
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
case 'links': {
|
|
71
|
-
const links = await evalCtx.evaluate(() =>
|
|
72
|
-
[...document.querySelectorAll('a[href]')].map(a => ({
|
|
73
|
-
text: a.textContent?.trim().slice(0, 120) || '',
|
|
74
|
-
href: (a as HTMLAnchorElement).href,
|
|
75
|
-
})).filter(l => l.text && l.href)
|
|
76
|
-
);
|
|
77
|
-
return links.map(l => `${l.text} → ${l.href}`).join('\n');
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
case 'forms': {
|
|
81
|
-
const forms = await evalCtx.evaluate(() => {
|
|
82
|
-
return [...document.querySelectorAll('form')].map((form, i) => {
|
|
83
|
-
const fields = [...form.querySelectorAll('input, select, textarea')].map(el => {
|
|
84
|
-
const input = el as HTMLInputElement;
|
|
85
|
-
return {
|
|
86
|
-
tag: el.tagName.toLowerCase(),
|
|
87
|
-
type: input.type || undefined,
|
|
88
|
-
name: input.name || undefined,
|
|
89
|
-
id: input.id || undefined,
|
|
90
|
-
placeholder: input.placeholder || undefined,
|
|
91
|
-
required: input.required || undefined,
|
|
92
|
-
value: input.value || undefined,
|
|
93
|
-
options: el.tagName === 'SELECT'
|
|
94
|
-
? [...(el as HTMLSelectElement).options].map(o => ({ value: o.value, text: o.text }))
|
|
95
|
-
: undefined,
|
|
96
|
-
};
|
|
97
|
-
});
|
|
98
|
-
return {
|
|
99
|
-
index: i,
|
|
100
|
-
action: form.action || undefined,
|
|
101
|
-
method: form.method || 'get',
|
|
102
|
-
id: form.id || undefined,
|
|
103
|
-
fields,
|
|
104
|
-
};
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
return JSON.stringify(forms, null, 2);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
case 'accessibility': {
|
|
111
|
-
const root = bm.getLocatorRoot();
|
|
112
|
-
const snapshot = await root.locator('body').ariaSnapshot();
|
|
113
|
-
return snapshot;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
case 'js': {
|
|
117
|
-
const expr = args[0];
|
|
118
|
-
if (!expr) throw new Error('Usage: browse js <expression>');
|
|
119
|
-
const result = await evalCtx.evaluate(expr);
|
|
120
|
-
return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
case 'eval': {
|
|
124
|
-
const filePath = args[0];
|
|
125
|
-
if (!filePath) throw new Error('Usage: browse eval <js-file>');
|
|
126
|
-
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
|
|
127
|
-
const code = fs.readFileSync(filePath, 'utf-8');
|
|
128
|
-
const result = await evalCtx.evaluate(code);
|
|
129
|
-
return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
case 'css': {
|
|
133
|
-
const [selector, property] = args;
|
|
134
|
-
if (!selector || !property) throw new Error('Usage: browse css <selector> <property>');
|
|
135
|
-
const resolved = bm.resolveRef(selector);
|
|
136
|
-
if ('locator' in resolved) {
|
|
137
|
-
const value = await resolved.locator.evaluate(
|
|
138
|
-
(el, prop) => getComputedStyle(el).getPropertyValue(prop),
|
|
139
|
-
property
|
|
140
|
-
);
|
|
141
|
-
return value;
|
|
142
|
-
}
|
|
143
|
-
const value = await evalCtx.evaluate(
|
|
144
|
-
([sel, prop]) => {
|
|
145
|
-
const el = document.querySelector(sel);
|
|
146
|
-
if (!el) return { __notFound: true, selector: sel };
|
|
147
|
-
return getComputedStyle(el).getPropertyValue(prop);
|
|
148
|
-
},
|
|
149
|
-
[resolved.selector, property]
|
|
150
|
-
);
|
|
151
|
-
if (typeof value === 'object' && value !== null && '__notFound' in value) {
|
|
152
|
-
throw new Error(`Element not found: ${(value as any).selector}`);
|
|
153
|
-
}
|
|
154
|
-
return value as string;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
case 'element-state': {
|
|
158
|
-
const selector = args[0];
|
|
159
|
-
if (!selector) throw new Error('Usage: browse element-state <selector>');
|
|
160
|
-
const resolved = bm.resolveRef(selector);
|
|
161
|
-
const locator = 'locator' in resolved
|
|
162
|
-
? resolved.locator
|
|
163
|
-
: page.locator(resolved.selector);
|
|
164
|
-
|
|
165
|
-
const state: Record<string, unknown> = {};
|
|
166
|
-
|
|
167
|
-
// Core state checks — each wrapped individually since not all
|
|
168
|
-
// apply to every element type (e.g. isChecked only for checkbox/radio)
|
|
169
|
-
try { state.visible = await locator.isVisible(); } catch { state.visible = null; }
|
|
170
|
-
try { state.enabled = await locator.isEnabled(); } catch { state.enabled = null; }
|
|
171
|
-
try { state.checked = await locator.isChecked(); } catch { state.checked = null; }
|
|
172
|
-
try { state.editable = await locator.isEditable(); } catch { state.editable = null; }
|
|
173
|
-
|
|
174
|
-
// Properties that require evaluate — grouped in one call for efficiency
|
|
175
|
-
try {
|
|
176
|
-
const domProps = await locator.evaluate((el) => {
|
|
177
|
-
const input = el as HTMLInputElement;
|
|
178
|
-
return {
|
|
179
|
-
focused: document.activeElement === el,
|
|
180
|
-
tag: el.tagName.toLowerCase(),
|
|
181
|
-
type: input.type || null,
|
|
182
|
-
value: input.value ?? null,
|
|
183
|
-
};
|
|
184
|
-
});
|
|
185
|
-
Object.assign(state, domProps);
|
|
186
|
-
} catch {
|
|
187
|
-
state.focused = null;
|
|
188
|
-
state.tag = null;
|
|
189
|
-
state.type = null;
|
|
190
|
-
state.value = null;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// Bounding box — null when element is not visible
|
|
194
|
-
try { state.boundingBox = await locator.boundingBox(); } catch { state.boundingBox = null; }
|
|
195
|
-
|
|
196
|
-
return JSON.stringify(state, null, 2);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
case 'attrs': {
|
|
200
|
-
const selector = args[0];
|
|
201
|
-
if (!selector) throw new Error('Usage: browse attrs <selector>');
|
|
202
|
-
const resolved = bm.resolveRef(selector);
|
|
203
|
-
if ('locator' in resolved) {
|
|
204
|
-
const attrs = await resolved.locator.evaluate((el) => {
|
|
205
|
-
const result: Record<string, string> = {};
|
|
206
|
-
for (const attr of el.attributes) {
|
|
207
|
-
result[attr.name] = attr.value;
|
|
208
|
-
}
|
|
209
|
-
return result;
|
|
210
|
-
});
|
|
211
|
-
return JSON.stringify(attrs, null, 2);
|
|
212
|
-
}
|
|
213
|
-
const attrs = await evalCtx.evaluate((sel) => {
|
|
214
|
-
const el = document.querySelector(sel);
|
|
215
|
-
if (!el) return { __notFound: true, selector: sel };
|
|
216
|
-
const result: Record<string, string> = {};
|
|
217
|
-
for (const attr of el.attributes) {
|
|
218
|
-
result[attr.name] = attr.value;
|
|
219
|
-
}
|
|
220
|
-
return result;
|
|
221
|
-
}, resolved.selector);
|
|
222
|
-
if (typeof attrs === 'object' && attrs !== null && '__notFound' in attrs) {
|
|
223
|
-
throw new Error(`Element not found: ${(attrs as any).selector}`);
|
|
224
|
-
}
|
|
225
|
-
return JSON.stringify(attrs, null, 2);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
case 'dialog': {
|
|
229
|
-
const last = bm.getLastDialog();
|
|
230
|
-
if (!last) return '(no dialog detected)';
|
|
231
|
-
return JSON.stringify(last, null, 2);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
case 'console': {
|
|
235
|
-
const cb = (buffers || bm.getBuffers()).consoleBuffer;
|
|
236
|
-
if (args[0] === '--clear') {
|
|
237
|
-
cb.length = 0;
|
|
238
|
-
return 'Console buffer cleared.';
|
|
239
|
-
}
|
|
240
|
-
if (cb.length === 0) return '(no console messages)';
|
|
241
|
-
return cb.map(e =>
|
|
242
|
-
`[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
|
|
243
|
-
).join('\n');
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
case 'network': {
|
|
247
|
-
const nb = (buffers || bm.getBuffers()).networkBuffer;
|
|
248
|
-
if (args[0] === '--clear') {
|
|
249
|
-
nb.length = 0;
|
|
250
|
-
return 'Network buffer cleared.';
|
|
251
|
-
}
|
|
252
|
-
if (nb.length === 0) return '(no network requests)';
|
|
253
|
-
return nb.map(e =>
|
|
254
|
-
`${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)`
|
|
255
|
-
).join('\n');
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
case 'cookies': {
|
|
259
|
-
const cookies = await page.context().cookies();
|
|
260
|
-
return JSON.stringify(cookies, null, 2);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
case 'storage': {
|
|
264
|
-
if (args[0] === 'set' && args[1]) {
|
|
265
|
-
const key = args[1];
|
|
266
|
-
const value = args[2] || '';
|
|
267
|
-
await evalCtx.evaluate(([k, v]) => localStorage.setItem(k, v), [key, value]);
|
|
268
|
-
return `Set localStorage["${key}"] = "${value}"`;
|
|
269
|
-
}
|
|
270
|
-
const storage = await evalCtx.evaluate(() => ({
|
|
271
|
-
localStorage: { ...localStorage },
|
|
272
|
-
sessionStorage: { ...sessionStorage },
|
|
273
|
-
}));
|
|
274
|
-
return JSON.stringify(storage, null, 2);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
case 'perf': {
|
|
278
|
-
const timings = await evalCtx.evaluate(() => {
|
|
279
|
-
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
|
280
|
-
if (!nav) return 'No navigation timing data available.';
|
|
281
|
-
return {
|
|
282
|
-
dns: Math.round(nav.domainLookupEnd - nav.domainLookupStart),
|
|
283
|
-
tcp: Math.round(nav.connectEnd - nav.connectStart),
|
|
284
|
-
ssl: Math.round(nav.secureConnectionStart > 0 ? nav.connectEnd - nav.secureConnectionStart : 0),
|
|
285
|
-
ttfb: Math.round(nav.responseStart - nav.requestStart),
|
|
286
|
-
download: Math.round(nav.responseEnd - nav.responseStart),
|
|
287
|
-
domParse: Math.round(nav.domInteractive - nav.responseEnd),
|
|
288
|
-
domReady: Math.round(nav.domContentLoadedEventEnd - nav.startTime),
|
|
289
|
-
load: Math.round(nav.loadEventEnd - nav.startTime),
|
|
290
|
-
total: Math.round(nav.loadEventEnd - nav.startTime),
|
|
291
|
-
};
|
|
292
|
-
});
|
|
293
|
-
if (typeof timings === 'string') return timings;
|
|
294
|
-
return Object.entries(timings)
|
|
295
|
-
.map(([k, v]) => `${k.padEnd(12)} ${v}ms`)
|
|
296
|
-
.join('\n');
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
case 'value': {
|
|
300
|
-
const selector = args[0];
|
|
301
|
-
if (!selector) throw new Error('Usage: browse value <selector>');
|
|
302
|
-
const resolved = bm.resolveRef(selector);
|
|
303
|
-
const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
|
|
304
|
-
const value = await locator.inputValue({ timeout: 5000 });
|
|
305
|
-
return value;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
case 'count': {
|
|
309
|
-
const selector = args[0];
|
|
310
|
-
if (!selector) throw new Error('Usage: browse count <selector>');
|
|
311
|
-
const resolved = bm.resolveRef(selector);
|
|
312
|
-
const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
|
|
313
|
-
const count = await locator.count();
|
|
314
|
-
return String(count);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
case 'clipboard': {
|
|
318
|
-
if (args[0] === 'write') {
|
|
319
|
-
const text = args.slice(1).join(' ');
|
|
320
|
-
if (!text) throw new Error('Usage: browse clipboard write <text>');
|
|
321
|
-
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
|
322
|
-
await evalCtx.evaluate((t) => navigator.clipboard.writeText(t), text);
|
|
323
|
-
return `Clipboard set: ${text.slice(0, 50)}${text.length > 50 ? '...' : ''}`;
|
|
324
|
-
}
|
|
325
|
-
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
|
326
|
-
try {
|
|
327
|
-
const text = await evalCtx.evaluate(() => navigator.clipboard.readText());
|
|
328
|
-
return text || '(empty clipboard)';
|
|
329
|
-
} catch {
|
|
330
|
-
return '(clipboard not available)';
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
case 'devices': {
|
|
335
|
-
const filter = args.join(' ').toLowerCase();
|
|
336
|
-
const all = listDevices();
|
|
337
|
-
const filtered = filter ? all.filter(d => d.toLowerCase().includes(filter)) : all;
|
|
338
|
-
if (filtered.length === 0) {
|
|
339
|
-
return `No devices matching "${filter}". Run "browse devices" to see all.`;
|
|
340
|
-
}
|
|
341
|
-
return filtered.join('\n');
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
default:
|
|
345
|
-
throw new Error(`Unknown read command: ${command}`);
|
|
346
|
-
}
|
|
347
|
-
}
|