@ulpi/browse 0.1.0 → 0.2.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 +9 -3
- package/bin/browse.ts +10 -1
- package/package.json +9 -8
- package/skill/SKILL.md +163 -26
- package/src/auth-vault.ts +244 -0
- package/src/browser-manager.ts +177 -2
- package/src/cli.ts +159 -25
- package/src/commands/meta.ts +176 -5
- package/src/commands/read.ts +39 -13
- package/src/commands/write.ts +200 -6
- package/src/config.ts +44 -0
- package/src/constants.ts +4 -2
- package/src/domain-filter.ts +134 -0
- package/src/har.ts +66 -0
- package/src/policy.ts +94 -0
- package/src/sanitize.ts +11 -0
- package/src/server.ts +196 -56
- package/src/session-manager.ts +65 -2
- package/src/snapshot.ts +18 -13
package/src/server.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { SessionManager, type Session } from './session-manager';
|
|
|
14
14
|
import { handleReadCommand } from './commands/read';
|
|
15
15
|
import { handleWriteCommand } from './commands/write';
|
|
16
16
|
import { handleMetaCommand } from './commands/meta';
|
|
17
|
+
import { PolicyChecker } from './policy';
|
|
17
18
|
import { DEFAULTS } from './constants';
|
|
18
19
|
import { type LogEntry, type NetworkEntry } from './buffers';
|
|
19
20
|
import * as fs from 'fs';
|
|
@@ -26,7 +27,8 @@ export { type LogEntry, type NetworkEntry };
|
|
|
26
27
|
// ─── Auth (inline) ─────────────────────────────────────────────
|
|
27
28
|
const AUTH_TOKEN = crypto.randomUUID();
|
|
28
29
|
const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10); // 0 = auto-scan
|
|
29
|
-
const
|
|
30
|
+
const BROWSE_INSTANCE = process.env.BROWSE_INSTANCE || '';
|
|
31
|
+
const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : (BROWSE_INSTANCE ? `-${BROWSE_INSTANCE}` : '');
|
|
30
32
|
const LOCAL_DIR = process.env.BROWSE_LOCAL_DIR || '/tmp';
|
|
31
33
|
const STATE_FILE = process.env.BROWSE_STATE_FILE || `${LOCAL_DIR}/browse-server${INSTANCE_SUFFIX}.json`;
|
|
32
34
|
const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || String(DEFAULTS.IDLE_TIMEOUT_MS), 10);
|
|
@@ -46,9 +48,8 @@ function flushAllBuffers(sessionManager: SessionManager, final = false) {
|
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
function flushSessionBuffers(session: Session, final: boolean) {
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const networkPath = `${LOCAL_DIR}/browse-network${suffix}.log`;
|
|
51
|
+
const consolePath = `${session.outputDir}/console.log`;
|
|
52
|
+
const networkPath = `${session.outputDir}/network.log`;
|
|
52
53
|
const buffers = session.buffers;
|
|
53
54
|
|
|
54
55
|
// Console flush
|
|
@@ -98,19 +99,25 @@ function flushSessionBuffers(session: Session, final: boolean) {
|
|
|
98
99
|
let sessionManager: SessionManager;
|
|
99
100
|
let browser: Browser;
|
|
100
101
|
let isShuttingDown = false;
|
|
102
|
+
let isRemoteBrowser = false;
|
|
103
|
+
const policyChecker = new PolicyChecker();
|
|
101
104
|
|
|
102
105
|
// Read/write/meta command sets for routing
|
|
103
106
|
const READ_COMMANDS = new Set([
|
|
104
107
|
'text', 'html', 'links', 'forms', 'accessibility',
|
|
105
|
-
'js', 'eval', 'css', 'attrs', 'state', 'dialog',
|
|
108
|
+
'js', 'eval', 'css', 'attrs', 'element-state', 'dialog',
|
|
106
109
|
'console', 'network', 'cookies', 'storage', 'perf', 'devices',
|
|
110
|
+
'value', 'count',
|
|
107
111
|
]);
|
|
108
112
|
|
|
109
113
|
const WRITE_COMMANDS = new Set([
|
|
110
114
|
'goto', 'back', 'forward', 'reload',
|
|
111
|
-
'click', '
|
|
115
|
+
'click', 'dblclick', 'fill', 'select', 'hover', 'focus', 'check', 'uncheck',
|
|
116
|
+
'type', 'press', 'scroll', 'wait',
|
|
112
117
|
'viewport', 'cookie', 'header', 'useragent',
|
|
113
118
|
'upload', 'dialog-accept', 'dialog-dismiss', 'emulate',
|
|
119
|
+
'drag', 'keydown', 'keyup',
|
|
120
|
+
'highlight', 'download', 'route', 'offline',
|
|
114
121
|
]);
|
|
115
122
|
|
|
116
123
|
const META_COMMANDS = new Set([
|
|
@@ -120,42 +127,133 @@ const META_COMMANDS = new Set([
|
|
|
120
127
|
'chain', 'diff',
|
|
121
128
|
'url', 'snapshot', 'snapshot-diff',
|
|
122
129
|
'sessions', 'session-close',
|
|
130
|
+
'frame', 'state',
|
|
131
|
+
'auth', 'har',
|
|
123
132
|
]);
|
|
124
133
|
|
|
134
|
+
// Probe if a port is free using net.createServer (not Bun.serve which fatally crashes on EADDRINUSE)
|
|
135
|
+
import * as net from 'net';
|
|
136
|
+
|
|
137
|
+
function isPortFree(port: number): Promise<boolean> {
|
|
138
|
+
return new Promise((resolve) => {
|
|
139
|
+
const srv = net.createServer();
|
|
140
|
+
srv.once('error', () => resolve(false));
|
|
141
|
+
srv.once('listening', () => { srv.close(() => resolve(true)); });
|
|
142
|
+
srv.listen(port, '127.0.0.1');
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
125
146
|
// Find port: use BROWSE_PORT or scan range
|
|
126
147
|
async function findPort(): Promise<number> {
|
|
127
148
|
if (BROWSE_PORT) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
testServer.stop();
|
|
131
|
-
return BROWSE_PORT;
|
|
132
|
-
} catch {
|
|
133
|
-
throw new Error(`[browse] Port ${BROWSE_PORT} is in use`);
|
|
134
|
-
}
|
|
149
|
+
if (await isPortFree(BROWSE_PORT)) return BROWSE_PORT;
|
|
150
|
+
throw new Error(`[browse] Port ${BROWSE_PORT} is in use`);
|
|
135
151
|
}
|
|
136
152
|
|
|
137
153
|
// Scan range
|
|
138
154
|
const start = parseInt(process.env.BROWSE_PORT_START || String(DEFAULTS.PORT_RANGE_START), 10);
|
|
139
155
|
const end = start + (DEFAULTS.PORT_RANGE_END - DEFAULTS.PORT_RANGE_START);
|
|
140
156
|
for (let port = start; port <= end; port++) {
|
|
141
|
-
|
|
142
|
-
const testServer = Bun.serve({ port, fetch: () => new Response('ok') });
|
|
143
|
-
testServer.stop();
|
|
144
|
-
return port;
|
|
145
|
-
} catch {
|
|
146
|
-
continue;
|
|
147
|
-
}
|
|
157
|
+
if (await isPortFree(port)) return port;
|
|
148
158
|
}
|
|
149
159
|
throw new Error(`[browse] No available port in range ${start}-${end}`);
|
|
150
160
|
}
|
|
151
161
|
|
|
152
|
-
|
|
162
|
+
// Commands that return page-derived content (for --content-boundaries wrapping).
|
|
163
|
+
// Action commands (click, goto) and meta commands (status, tabs) are NOT wrapped.
|
|
164
|
+
const PAGE_CONTENT_COMMANDS = new Set([
|
|
165
|
+
'text', 'html', 'links', 'forms', 'accessibility',
|
|
166
|
+
'js', 'eval', 'console', 'network', 'snapshot',
|
|
167
|
+
]);
|
|
168
|
+
|
|
169
|
+
// Nonce for content boundaries — generated once per server process
|
|
170
|
+
const BOUNDARY_NONCE = crypto.randomUUID();
|
|
171
|
+
|
|
172
|
+
interface RequestOptions {
|
|
173
|
+
jsonMode: boolean;
|
|
174
|
+
contentBoundaries: boolean;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Rewrite Playwright error messages into actionable hints for AI agents.
|
|
179
|
+
* Raw errors like "locator.click: Timeout 5000ms exceeded" are unhelpful.
|
|
180
|
+
*/
|
|
181
|
+
function rewriteError(msg: string): string {
|
|
182
|
+
if (msg.includes('strict mode violation')) {
|
|
183
|
+
const countMatch = msg.match(/resolved to (\d+) elements/);
|
|
184
|
+
return `Multiple elements matched (${countMatch?.[1] || 'several'}). Use a more specific selector or run 'snapshot -i' to find exact refs.`;
|
|
185
|
+
}
|
|
186
|
+
if (msg.includes('Timeout') && msg.includes('exceeded')) {
|
|
187
|
+
const timeMatch = msg.match(/Timeout (\d+)ms/);
|
|
188
|
+
return `Element not found within ${timeMatch?.[1] || '?'}ms. The element may not exist, be hidden, or the page is still loading. Try 'wait <selector>' first, or check with 'snapshot -i'.`;
|
|
189
|
+
}
|
|
190
|
+
if (msg.includes('waiting for locator') || msg.includes('waiting for selector')) {
|
|
191
|
+
return `Element not found on the page. Run 'snapshot -i' to see available elements, or check the current URL with 'url'.`;
|
|
192
|
+
}
|
|
193
|
+
if (msg.includes('not an HTMLInputElement') || msg.includes('not an input')) {
|
|
194
|
+
return `Cannot fill this element — it's not an input field. Use 'click' instead, or run 'snapshot -i' to find the correct input.`;
|
|
195
|
+
}
|
|
196
|
+
if (msg.includes('Element is not visible')) {
|
|
197
|
+
return `Element exists but is hidden (display:none or visibility:hidden). Try scrolling to it with 'scroll <selector>' or wait for it with 'wait <selector>'.`;
|
|
198
|
+
}
|
|
199
|
+
if (msg.includes('Element is outside of the viewport')) {
|
|
200
|
+
return `Element is off-screen. Scroll to it first with 'scroll <selector>'.`;
|
|
201
|
+
}
|
|
202
|
+
if (msg.includes('intercepts pointer events')) {
|
|
203
|
+
return `Another element is covering the target (e.g., a modal, overlay, or cookie banner). Close the overlay first or use 'js' to click directly.`;
|
|
204
|
+
}
|
|
205
|
+
if (msg.includes('Frame was detached') || msg.includes('frame was detached')) {
|
|
206
|
+
return `The iframe was removed or navigated away. Run 'frame main' to return to the main page, then re-navigate.`;
|
|
207
|
+
}
|
|
208
|
+
if (msg.includes('Target closed') || msg.includes('target closed')) {
|
|
209
|
+
return `The page or tab was closed. Use 'tabs' to list open tabs, or 'goto' to navigate to a new page.`;
|
|
210
|
+
}
|
|
211
|
+
if (msg.includes('net::ERR_')) {
|
|
212
|
+
const errMatch = msg.match(/(net::\w+)/);
|
|
213
|
+
return `Network error: ${errMatch?.[1] || 'connection failed'}. Check the URL and ensure the site is reachable.`;
|
|
214
|
+
}
|
|
215
|
+
return msg;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function handleCommand(body: any, session: Session, opts: RequestOptions): Promise<Response> {
|
|
153
219
|
const { command, args = [] } = body;
|
|
154
220
|
|
|
155
221
|
if (!command) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
222
|
+
const error = 'Missing "command" field';
|
|
223
|
+
if (opts.jsonMode) {
|
|
224
|
+
return new Response(JSON.stringify({ success: false, error }), {
|
|
225
|
+
status: 400, headers: { 'Content-Type': 'application/json' },
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
return new Response(JSON.stringify({ error }), {
|
|
229
|
+
status: 400, headers: { 'Content-Type': 'application/json' },
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Policy check
|
|
234
|
+
const policyResult = policyChecker.check(command);
|
|
235
|
+
if (policyResult === 'deny') {
|
|
236
|
+
const error = `Command '${command}' denied by policy`;
|
|
237
|
+
const hint = 'Update browse-policy.json to allow this command.';
|
|
238
|
+
if (opts.jsonMode) {
|
|
239
|
+
return new Response(JSON.stringify({ success: false, error, hint }), {
|
|
240
|
+
status: 403, headers: { 'Content-Type': 'application/json' },
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return new Response(JSON.stringify({ error, hint }), {
|
|
244
|
+
status: 403, headers: { 'Content-Type': 'application/json' },
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
if (policyResult === 'confirm') {
|
|
248
|
+
const error = `Command '${command}' requires confirmation (policy). Non-interactive CLI cannot confirm.`;
|
|
249
|
+
const hint = 'Move this command to the allow list in browse-policy.json.';
|
|
250
|
+
if (opts.jsonMode) {
|
|
251
|
+
return new Response(JSON.stringify({ success: false, error, hint }), {
|
|
252
|
+
status: 403, headers: { 'Content-Type': 'application/json' },
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return new Response(JSON.stringify({ error, hint }), {
|
|
256
|
+
status: 403, headers: { 'Content-Type': 'application/json' },
|
|
159
257
|
});
|
|
160
258
|
}
|
|
161
259
|
|
|
@@ -165,27 +263,47 @@ async function handleCommand(body: any, session: Session): Promise<Response> {
|
|
|
165
263
|
if (READ_COMMANDS.has(command)) {
|
|
166
264
|
result = await handleReadCommand(command, args, session.manager, session.buffers);
|
|
167
265
|
} else if (WRITE_COMMANDS.has(command)) {
|
|
168
|
-
result = await handleWriteCommand(command, args, session.manager);
|
|
266
|
+
result = await handleWriteCommand(command, args, session.manager, session.domainFilter);
|
|
169
267
|
} else if (META_COMMANDS.has(command)) {
|
|
170
268
|
result = await handleMetaCommand(command, args, session.manager, shutdown, sessionManager, session);
|
|
171
269
|
} else {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
270
|
+
const error = `Unknown command: ${command}`;
|
|
271
|
+
const hint = `Available commands: ${[...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS].sort().join(', ')}`;
|
|
272
|
+
if (opts.jsonMode) {
|
|
273
|
+
return new Response(JSON.stringify({ success: false, error, hint }), {
|
|
274
|
+
status: 400, headers: { 'Content-Type': 'application/json' },
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
return new Response(JSON.stringify({ error, hint }), {
|
|
278
|
+
status: 400, headers: { 'Content-Type': 'application/json' },
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Apply content boundaries for page-content commands
|
|
283
|
+
if (opts.contentBoundaries && PAGE_CONTENT_COMMANDS.has(command)) {
|
|
284
|
+
const origin = session.manager.getCurrentUrl();
|
|
285
|
+
result = `--- BROWSE_CONTENT nonce=${BOUNDARY_NONCE} origin=${origin} ---\n${result}\n--- END_BROWSE_CONTENT nonce=${BOUNDARY_NONCE} ---`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Apply JSON wrapping
|
|
289
|
+
if (opts.jsonMode) {
|
|
290
|
+
return new Response(JSON.stringify({ success: true, data: result, command }), {
|
|
291
|
+
status: 200, headers: { 'Content-Type': 'application/json' },
|
|
178
292
|
});
|
|
179
293
|
}
|
|
180
294
|
|
|
181
295
|
return new Response(result, {
|
|
182
|
-
status: 200,
|
|
183
|
-
headers: { 'Content-Type': 'text/plain' },
|
|
296
|
+
status: 200, headers: { 'Content-Type': 'text/plain' },
|
|
184
297
|
});
|
|
185
298
|
} catch (err: any) {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
299
|
+
const friendlyError = rewriteError(err.message);
|
|
300
|
+
if (opts.jsonMode) {
|
|
301
|
+
return new Response(JSON.stringify({ success: false, error: friendlyError, command }), {
|
|
302
|
+
status: 500, headers: { 'Content-Type': 'application/json' },
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
return new Response(JSON.stringify({ error: friendlyError }), {
|
|
306
|
+
status: 500, headers: { 'Content-Type': 'application/json' },
|
|
189
307
|
});
|
|
190
308
|
}
|
|
191
309
|
}
|
|
@@ -201,8 +319,8 @@ async function shutdown() {
|
|
|
201
319
|
|
|
202
320
|
await sessionManager.closeAll();
|
|
203
321
|
|
|
204
|
-
// Close the shared browser
|
|
205
|
-
if (browser) {
|
|
322
|
+
// Close the shared browser (skip if remote — we don't own it)
|
|
323
|
+
if (browser && !isRemoteBrowser) {
|
|
206
324
|
browser.removeAllListeners('disconnected');
|
|
207
325
|
await browser.close().catch(() => {});
|
|
208
326
|
}
|
|
@@ -246,24 +364,41 @@ const sessionCleanupInterval = setInterval(async () => {
|
|
|
246
364
|
async function start() {
|
|
247
365
|
const port = await findPort();
|
|
248
366
|
|
|
249
|
-
// Launch
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
367
|
+
// Launch or connect to browser
|
|
368
|
+
const cdpUrl = process.env.BROWSE_CDP_URL;
|
|
369
|
+
if (cdpUrl) {
|
|
370
|
+
// Connect to remote Chrome via CDP
|
|
371
|
+
browser = await chromium.connectOverCDP(cdpUrl);
|
|
372
|
+
isRemoteBrowser = true;
|
|
373
|
+
console.log(`[browse] Connected to remote Chrome via CDP: ${cdpUrl}`);
|
|
374
|
+
} else {
|
|
375
|
+
// Launch local Chromium
|
|
376
|
+
const launchOptions: Record<string, any> = { headless: true };
|
|
377
|
+
const proxyServer = process.env.BROWSE_PROXY;
|
|
378
|
+
if (proxyServer) {
|
|
379
|
+
launchOptions.proxy = { server: proxyServer };
|
|
380
|
+
if (process.env.BROWSE_PROXY_BYPASS) {
|
|
381
|
+
launchOptions.proxy.bypass = process.env.BROWSE_PROXY_BYPASS;
|
|
260
382
|
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
|
|
383
|
+
}
|
|
384
|
+
browser = await chromium.launch(launchOptions);
|
|
385
|
+
|
|
386
|
+
// Chromium crash → flush, cleanup, exit (only for owned browser)
|
|
387
|
+
browser.on('disconnected', () => {
|
|
388
|
+
console.error('[browse] FATAL: Chromium process crashed or was killed. Server exiting.');
|
|
389
|
+
if (sessionManager) flushAllBuffers(sessionManager, true);
|
|
390
|
+
try {
|
|
391
|
+
const currentState = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
|
|
392
|
+
if (currentState.pid === process.pid || currentState.token === AUTH_TOKEN) {
|
|
393
|
+
fs.unlinkSync(STATE_FILE);
|
|
394
|
+
}
|
|
395
|
+
} catch {}
|
|
396
|
+
process.exit(1);
|
|
397
|
+
});
|
|
398
|
+
}
|
|
264
399
|
|
|
265
400
|
// Create session manager
|
|
266
|
-
sessionManager = new SessionManager(browser);
|
|
401
|
+
sessionManager = new SessionManager(browser, LOCAL_DIR);
|
|
267
402
|
|
|
268
403
|
const startTime = Date.now();
|
|
269
404
|
const server = Bun.serve({
|
|
@@ -274,7 +409,7 @@ async function start() {
|
|
|
274
409
|
|
|
275
410
|
// Health check — no auth required
|
|
276
411
|
if (url.pathname === '/health') {
|
|
277
|
-
const healthy = browser.isConnected();
|
|
412
|
+
const healthy = !isShuttingDown && browser.isConnected();
|
|
278
413
|
return new Response(JSON.stringify({
|
|
279
414
|
status: healthy ? 'healthy' : 'unhealthy',
|
|
280
415
|
uptime: Math.floor((Date.now() - startTime) / 1000),
|
|
@@ -296,8 +431,13 @@ async function start() {
|
|
|
296
431
|
if (url.pathname === '/command' && req.method === 'POST') {
|
|
297
432
|
const body = await req.json();
|
|
298
433
|
const sessionId = req.headers.get('x-browse-session') || 'default';
|
|
299
|
-
const
|
|
300
|
-
|
|
434
|
+
const allowedDomains = req.headers.get('x-browse-allowed-domains') || undefined;
|
|
435
|
+
const session = await sessionManager.getOrCreate(sessionId, allowedDomains);
|
|
436
|
+
const opts: RequestOptions = {
|
|
437
|
+
jsonMode: req.headers.get('x-browse-json') === '1',
|
|
438
|
+
contentBoundaries: req.headers.get('x-browse-boundaries') === '1',
|
|
439
|
+
};
|
|
440
|
+
return handleCommand(body, session, opts);
|
|
301
441
|
}
|
|
302
442
|
|
|
303
443
|
return new Response('Not found', { status: 404 });
|
package/src/session-manager.ts
CHANGED
|
@@ -9,11 +9,17 @@
|
|
|
9
9
|
import type { Browser } from 'playwright';
|
|
10
10
|
import { BrowserManager } from './browser-manager';
|
|
11
11
|
import { SessionBuffers } from './buffers';
|
|
12
|
+
import { DomainFilter } from './domain-filter';
|
|
13
|
+
import { sanitizeName } from './sanitize';
|
|
14
|
+
import * as fs from 'fs';
|
|
15
|
+
import * as path from 'path';
|
|
12
16
|
|
|
13
17
|
export interface Session {
|
|
14
18
|
id: string;
|
|
15
19
|
manager: BrowserManager;
|
|
16
20
|
buffers: SessionBuffers;
|
|
21
|
+
domainFilter: DomainFilter | null;
|
|
22
|
+
outputDir: string;
|
|
17
23
|
lastActivity: number;
|
|
18
24
|
createdAt: number;
|
|
19
25
|
}
|
|
@@ -21,30 +27,87 @@ export interface Session {
|
|
|
21
27
|
export class SessionManager {
|
|
22
28
|
private sessions = new Map<string, Session>();
|
|
23
29
|
private browser: Browser;
|
|
30
|
+
private localDir: string;
|
|
24
31
|
|
|
25
|
-
constructor(browser: Browser) {
|
|
32
|
+
constructor(browser: Browser, localDir: string = '/tmp') {
|
|
26
33
|
this.browser = browser;
|
|
34
|
+
this.localDir = localDir;
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
/**
|
|
30
38
|
* Get an existing session or create a new one.
|
|
31
39
|
* Creating a session launches a new BrowserContext on the shared Chromium.
|
|
32
40
|
*/
|
|
33
|
-
async getOrCreate(sessionId: string): Promise<Session> {
|
|
41
|
+
async getOrCreate(sessionId: string, allowedDomains?: string): Promise<Session> {
|
|
34
42
|
let session = this.sessions.get(sessionId);
|
|
35
43
|
if (session) {
|
|
36
44
|
session.lastActivity = Date.now();
|
|
45
|
+
// Update domain filter if provided and session doesn't already have one
|
|
46
|
+
if (allowedDomains && !session.domainFilter) {
|
|
47
|
+
const domains = allowedDomains.split(',').map(d => d.trim()).filter(Boolean);
|
|
48
|
+
if (domains.length > 0) {
|
|
49
|
+
const domainFilter = new DomainFilter(domains);
|
|
50
|
+
session.manager.setDomainFilter(domainFilter);
|
|
51
|
+
const context = session.manager.getContext();
|
|
52
|
+
if (context) {
|
|
53
|
+
await context.route('**/*', (route) => {
|
|
54
|
+
const url = route.request().url();
|
|
55
|
+
if (domainFilter.isAllowed(url)) {
|
|
56
|
+
route.continue();
|
|
57
|
+
} else {
|
|
58
|
+
route.abort('blockedbyclient');
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
const initScript = domainFilter.generateInitScript();
|
|
62
|
+
await context.addInitScript(initScript);
|
|
63
|
+
session.manager.setInitScript(initScript);
|
|
64
|
+
}
|
|
65
|
+
session.domainFilter = domainFilter;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
37
68
|
return session;
|
|
38
69
|
}
|
|
39
70
|
|
|
71
|
+
// Create per-session output directory
|
|
72
|
+
const outputDir = path.join(this.localDir, 'sessions', sanitizeName(sessionId));
|
|
73
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
74
|
+
|
|
40
75
|
const buffers = new SessionBuffers();
|
|
41
76
|
const manager = new BrowserManager(buffers);
|
|
42
77
|
await manager.launchWithBrowser(this.browser);
|
|
43
78
|
|
|
79
|
+
// Apply domain filter if allowed domains are specified
|
|
80
|
+
let domainFilter: DomainFilter | null = null;
|
|
81
|
+
if (allowedDomains) {
|
|
82
|
+
const domains = allowedDomains.split(',').map(d => d.trim()).filter(Boolean);
|
|
83
|
+
if (domains.length > 0) {
|
|
84
|
+
domainFilter = new DomainFilter(domains);
|
|
85
|
+
manager.setDomainFilter(domainFilter);
|
|
86
|
+
const context = manager.getContext();
|
|
87
|
+
if (context) {
|
|
88
|
+
// Block disallowed domains at the network level via Playwright route()
|
|
89
|
+
await context.route('**/*', (route) => {
|
|
90
|
+
const url = route.request().url();
|
|
91
|
+
if (domainFilter!.isAllowed(url)) {
|
|
92
|
+
route.continue();
|
|
93
|
+
} else {
|
|
94
|
+
route.abort('blockedbyclient');
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
// Block WebSocket, EventSource, sendBeacon via JS injection
|
|
98
|
+
const initScript = domainFilter.generateInitScript();
|
|
99
|
+
await context.addInitScript(initScript);
|
|
100
|
+
manager.setInitScript(initScript);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
44
105
|
session = {
|
|
45
106
|
id: sessionId,
|
|
46
107
|
manager,
|
|
47
108
|
buffers,
|
|
109
|
+
domainFilter,
|
|
110
|
+
outputDir,
|
|
48
111
|
lastActivity: Date.now(),
|
|
49
112
|
createdAt: Date.now(),
|
|
50
113
|
};
|
package/src/snapshot.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* Later: "click @e3" → look up Locator → locator.click()
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import type { Page, Locator } from 'playwright';
|
|
21
|
+
import type { Page, Frame, FrameLocator, Locator } from 'playwright';
|
|
22
22
|
import type { BrowserManager } from './browser-manager';
|
|
23
23
|
|
|
24
24
|
// Roles considered "interactive" for the -i flag
|
|
@@ -146,13 +146,13 @@ const NATIVE_INTERACTIVE_TAGS = new Set([
|
|
|
146
146
|
* - Elements already covered by ARIA roles
|
|
147
147
|
*/
|
|
148
148
|
async function findCursorInteractiveElements(
|
|
149
|
-
|
|
149
|
+
evalCtx: Page | Frame,
|
|
150
150
|
scopeSelector?: string,
|
|
151
151
|
): Promise<CursorElement[]> {
|
|
152
152
|
const interactiveRolesList = [...INTERACTIVE_ROLES];
|
|
153
153
|
const nativeTagsList = [...NATIVE_INTERACTIVE_TAGS];
|
|
154
154
|
|
|
155
|
-
return await
|
|
155
|
+
return await evalCtx.evaluate(
|
|
156
156
|
({ scopeSel, interactiveRoles, nativeTags }) => {
|
|
157
157
|
const root = scopeSel
|
|
158
158
|
? document.querySelector(scopeSel) || document.body
|
|
@@ -323,23 +323,27 @@ export async function handleSnapshot(
|
|
|
323
323
|
): Promise<string> {
|
|
324
324
|
const opts = parseSnapshotArgs(args);
|
|
325
325
|
const page = bm.getPage();
|
|
326
|
+
// When a frame is active, scope snapshot to the frame's content
|
|
327
|
+
const locatorRoot = bm.getLocatorRoot();
|
|
326
328
|
|
|
327
329
|
// Get accessibility tree via ariaSnapshot
|
|
328
330
|
let rootLocator: Locator;
|
|
329
331
|
if (opts.selector) {
|
|
330
|
-
rootLocator =
|
|
332
|
+
rootLocator = locatorRoot.locator(opts.selector);
|
|
331
333
|
const count = await rootLocator.count();
|
|
332
334
|
if (count === 0) throw new Error(`Selector not found: ${opts.selector}`);
|
|
333
335
|
} else {
|
|
334
|
-
rootLocator =
|
|
336
|
+
rootLocator = locatorRoot.locator('body');
|
|
335
337
|
}
|
|
336
338
|
|
|
337
339
|
const ariaText = await rootLocator.ariaSnapshot();
|
|
340
|
+
// Get frame context for evaluate calls (cursor-interactive scan)
|
|
341
|
+
const evalCtx = await bm.getFrameContext() || page;
|
|
338
342
|
if (!ariaText || ariaText.trim().length === 0) {
|
|
339
343
|
bm.setRefMap(new Map());
|
|
340
344
|
// If -C is active, still scan for cursor-interactive even with empty ARIA
|
|
341
345
|
if (opts.cursor) {
|
|
342
|
-
const result = await appendCursorElements(
|
|
346
|
+
const result = await appendCursorElements(evalCtx, locatorRoot, opts, [], new Map(), 1, bm);
|
|
343
347
|
bm.setLastSnapshot(result, args);
|
|
344
348
|
return result;
|
|
345
349
|
}
|
|
@@ -396,11 +400,11 @@ export async function handleSnapshot(
|
|
|
396
400
|
|
|
397
401
|
let locator: Locator;
|
|
398
402
|
if (opts.selector) {
|
|
399
|
-
locator =
|
|
403
|
+
locator = locatorRoot.locator(opts.selector).getByRole(node.role as any, {
|
|
400
404
|
name: node.name || undefined,
|
|
401
405
|
});
|
|
402
406
|
} else {
|
|
403
|
-
locator =
|
|
407
|
+
locator = locatorRoot.getByRole(node.role as any, {
|
|
404
408
|
name: node.name || undefined,
|
|
405
409
|
});
|
|
406
410
|
}
|
|
@@ -423,7 +427,7 @@ export async function handleSnapshot(
|
|
|
423
427
|
|
|
424
428
|
// Cursor-interactive detection: supplement ARIA tree with DOM-level scan
|
|
425
429
|
if (opts.cursor) {
|
|
426
|
-
const result = await appendCursorElements(
|
|
430
|
+
const result = await appendCursorElements(evalCtx, locatorRoot, opts, output, refMap, refCounter, bm);
|
|
427
431
|
bm.setLastSnapshot(result, args);
|
|
428
432
|
return result;
|
|
429
433
|
}
|
|
@@ -446,14 +450,15 @@ export async function handleSnapshot(
|
|
|
446
450
|
* Called when -C flag is active.
|
|
447
451
|
*/
|
|
448
452
|
async function appendCursorElements(
|
|
449
|
-
|
|
453
|
+
evalCtx: Page | Frame,
|
|
454
|
+
locatorRoot: Page | FrameLocator,
|
|
450
455
|
opts: SnapshotOptions,
|
|
451
456
|
output: string[],
|
|
452
457
|
refMap: Map<string, Locator>,
|
|
453
458
|
refCounter: number,
|
|
454
459
|
bm: BrowserManager,
|
|
455
460
|
): Promise<string> {
|
|
456
|
-
const cursorElements = await findCursorInteractiveElements(
|
|
461
|
+
const cursorElements = await findCursorInteractiveElements(evalCtx, opts.selector);
|
|
457
462
|
|
|
458
463
|
if (cursorElements.length > 0) {
|
|
459
464
|
output.push('');
|
|
@@ -468,9 +473,9 @@ async function appendCursorElements(
|
|
|
468
473
|
// share the same selector.
|
|
469
474
|
let baseLocator: Locator;
|
|
470
475
|
if (opts.selector) {
|
|
471
|
-
baseLocator =
|
|
476
|
+
baseLocator = locatorRoot.locator(opts.selector).locator(elem.cssSelector);
|
|
472
477
|
} else {
|
|
473
|
-
baseLocator =
|
|
478
|
+
baseLocator = locatorRoot.locator(elem.cssSelector);
|
|
474
479
|
}
|
|
475
480
|
const locator = baseLocator.nth(elem.selectorIndex);
|
|
476
481
|
|