init
This commit is contained in:
+33
@@ -0,0 +1,33 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Test artifacts
|
||||
*.png
|
||||
*.jpeg
|
||||
*.jpg
|
||||
|
||||
# Package manager
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
@@ -0,0 +1,123 @@
|
||||
# veb
|
||||
|
||||
Headless browser automation CLI for agents and humans.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
npx playwright install chromium
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Open a URL (auto-starts browser daemon)
|
||||
veb open https://example.com
|
||||
|
||||
# Click elements
|
||||
veb click "#submit-btn"
|
||||
veb click "text=Sign In"
|
||||
|
||||
# Type into inputs
|
||||
veb type "#email" hello@example.com
|
||||
veb type "#search" "search query"
|
||||
|
||||
# Press keyboard keys
|
||||
veb press Enter
|
||||
veb press Tab
|
||||
|
||||
# Wait for things
|
||||
veb wait "#loading" # wait for selector
|
||||
veb wait --text "Welcome" # wait for text
|
||||
veb wait 2000 # wait 2 seconds
|
||||
|
||||
# Take screenshots
|
||||
veb screenshot page.png
|
||||
veb screenshot --full page.png # full page
|
||||
veb screenshot -s "#hero" # specific element
|
||||
|
||||
# Get accessibility snapshot (great for AI agents)
|
||||
veb snapshot
|
||||
|
||||
# Extract HTML content
|
||||
veb extract "table"
|
||||
veb extract "#main"
|
||||
|
||||
# Evaluate JavaScript
|
||||
veb eval "document.title"
|
||||
veb eval "window.location.href"
|
||||
|
||||
# Scroll the page
|
||||
veb scroll down 500
|
||||
veb scroll up
|
||||
veb scroll -s "#container" down
|
||||
|
||||
# Interact with dropdowns
|
||||
veb select "#country" "US"
|
||||
|
||||
# Hover over elements
|
||||
veb hover "#menu"
|
||||
|
||||
# Close browser (stops daemon)
|
||||
veb close
|
||||
```
|
||||
|
||||
## Agent Mode
|
||||
|
||||
Use `--json` flag for machine-readable output:
|
||||
|
||||
```bash
|
||||
veb open https://example.com --json
|
||||
# {"id":"abc123","success":true,"data":{"url":"https://example.com/","title":"Example Domain"}}
|
||||
|
||||
veb snapshot --json
|
||||
# {"id":"def456","success":true,"data":{"snapshot":"..."}}
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
veb runs a background daemon that keeps the browser open between commands. The first command automatically starts the daemon. Use `veb close` to shut it down.
|
||||
|
||||
## Commands Reference
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `open <url>` | Navigate to a URL |
|
||||
| `click <selector>` | Click an element |
|
||||
| `type <selector> <text>` | Type text into an element |
|
||||
| `press <key>` | Press a keyboard key |
|
||||
| `wait <selector\|text\|ms>` | Wait for condition |
|
||||
| `screenshot [path]` | Take a screenshot |
|
||||
| `snapshot` | Get accessibility tree |
|
||||
| `extract <selector>` | Get element HTML |
|
||||
| `eval <script>` | Run JavaScript |
|
||||
| `scroll <dir> [amount]` | Scroll page |
|
||||
| `hover <selector>` | Hover over element |
|
||||
| `select <selector> <val>` | Select dropdown option |
|
||||
| `close` | Close browser |
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--json` | Output raw JSON |
|
||||
| `--full, -f` | Full page screenshot |
|
||||
| `--text, -t` | Wait for text |
|
||||
| `--selector, -s` | Target element |
|
||||
| `--debug` | Show debug timing info |
|
||||
| `--help, -h` | Show help |
|
||||
|
||||
## Selectors
|
||||
|
||||
veb supports all Playwright selectors:
|
||||
|
||||
- CSS: `#id`, `.class`, `div.container`
|
||||
- Text: `text=Click me`, `"Click me"`
|
||||
- XPath: `xpath=//button`
|
||||
- Role: `role=button[name="Submit"]`
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "veb",
|
||||
"version": "1.0.0",
|
||||
"description": "Headless browser automation CLI for agents and humans",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"veb": "./bin/veb"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx src/index.ts"
|
||||
},
|
||||
"keywords": [
|
||||
"browser",
|
||||
"automation",
|
||||
"headless",
|
||||
"playwright",
|
||||
"cli",
|
||||
"agent"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"playwright": "^1.40.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"tsx": "^4.6.0",
|
||||
"typescript": "^5.3.0"
|
||||
}
|
||||
}
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
import type { Page } from 'playwright';
|
||||
import type { BrowserManager } from './browser.js';
|
||||
import type {
|
||||
Command,
|
||||
Response,
|
||||
NavigateCommand,
|
||||
ClickCommand,
|
||||
TypeCommand,
|
||||
PressCommand,
|
||||
ScreenshotCommand,
|
||||
EvaluateCommand,
|
||||
WaitCommand,
|
||||
ScrollCommand,
|
||||
SelectCommand,
|
||||
HoverCommand,
|
||||
ContentCommand,
|
||||
NavigateData,
|
||||
ScreenshotData,
|
||||
EvaluateData,
|
||||
ContentData,
|
||||
} from './types.js';
|
||||
import { successResponse, errorResponse } from './protocol.js';
|
||||
|
||||
// Snapshot response type
|
||||
interface SnapshotData {
|
||||
snapshot: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command and return a response
|
||||
*/
|
||||
export async function executeCommand(
|
||||
command: Command,
|
||||
browser: BrowserManager
|
||||
): Promise<Response> {
|
||||
try {
|
||||
switch (command.action) {
|
||||
case 'launch':
|
||||
return await handleLaunch(command, browser);
|
||||
case 'navigate':
|
||||
return await handleNavigate(command, browser);
|
||||
case 'click':
|
||||
return await handleClick(command, browser);
|
||||
case 'type':
|
||||
return await handleType(command, browser);
|
||||
case 'press':
|
||||
return await handlePress(command, browser);
|
||||
case 'screenshot':
|
||||
return await handleScreenshot(command, browser);
|
||||
case 'snapshot':
|
||||
return await handleSnapshot(command, browser);
|
||||
case 'evaluate':
|
||||
return await handleEvaluate(command, browser);
|
||||
case 'wait':
|
||||
return await handleWait(command, browser);
|
||||
case 'scroll':
|
||||
return await handleScroll(command, browser);
|
||||
case 'select':
|
||||
return await handleSelect(command, browser);
|
||||
case 'hover':
|
||||
return await handleHover(command, browser);
|
||||
case 'content':
|
||||
return await handleContent(command, browser);
|
||||
case 'close':
|
||||
return await handleClose(command, browser);
|
||||
default: {
|
||||
// TypeScript narrows to never here, but we handle it for safety
|
||||
const unknownCommand = command as { id: string; action: string };
|
||||
return errorResponse(unknownCommand.id, `Unknown action: ${unknownCommand.action}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return errorResponse(command.id, message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLaunch(
|
||||
command: Command & { action: 'launch' },
|
||||
browser: BrowserManager
|
||||
): Promise<Response> {
|
||||
await browser.launch(command);
|
||||
return successResponse(command.id, { launched: true });
|
||||
}
|
||||
|
||||
async function handleNavigate(
|
||||
command: NavigateCommand,
|
||||
browser: BrowserManager
|
||||
): Promise<Response<NavigateData>> {
|
||||
const page = browser.getPage();
|
||||
await page.goto(command.url, {
|
||||
waitUntil: command.waitUntil ?? 'load',
|
||||
});
|
||||
|
||||
return successResponse(command.id, {
|
||||
url: page.url(),
|
||||
title: await page.title(),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleClick(
|
||||
command: ClickCommand,
|
||||
browser: BrowserManager
|
||||
): Promise<Response> {
|
||||
const page = browser.getPage();
|
||||
await page.click(command.selector, {
|
||||
button: command.button,
|
||||
clickCount: command.clickCount,
|
||||
delay: command.delay,
|
||||
});
|
||||
|
||||
return successResponse(command.id, { clicked: true });
|
||||
}
|
||||
|
||||
async function handleType(
|
||||
command: TypeCommand,
|
||||
browser: BrowserManager
|
||||
): Promise<Response> {
|
||||
const page = browser.getPage();
|
||||
|
||||
if (command.clear) {
|
||||
await page.fill(command.selector, '');
|
||||
}
|
||||
|
||||
await page.type(command.selector, command.text, {
|
||||
delay: command.delay,
|
||||
});
|
||||
|
||||
return successResponse(command.id, { typed: true });
|
||||
}
|
||||
|
||||
async function handlePress(
|
||||
command: PressCommand,
|
||||
browser: BrowserManager
|
||||
): Promise<Response> {
|
||||
const page = browser.getPage();
|
||||
|
||||
if (command.selector) {
|
||||
await page.press(command.selector, command.key);
|
||||
} else {
|
||||
await page.keyboard.press(command.key);
|
||||
}
|
||||
|
||||
return successResponse(command.id, { pressed: true });
|
||||
}
|
||||
|
||||
async function handleScreenshot(
|
||||
command: ScreenshotCommand,
|
||||
browser: BrowserManager
|
||||
): Promise<Response<ScreenshotData>> {
|
||||
const page = browser.getPage();
|
||||
|
||||
const options: Parameters<Page['screenshot']>[0] = {
|
||||
fullPage: command.fullPage,
|
||||
type: command.format ?? 'png',
|
||||
};
|
||||
|
||||
if (command.format === 'jpeg' && command.quality !== undefined) {
|
||||
options.quality = command.quality;
|
||||
}
|
||||
|
||||
let target: Page | ReturnType<Page['locator']> = page;
|
||||
if (command.selector) {
|
||||
target = page.locator(command.selector);
|
||||
}
|
||||
|
||||
if (command.path) {
|
||||
await target.screenshot({ ...options, path: command.path });
|
||||
return successResponse(command.id, { path: command.path });
|
||||
} else {
|
||||
const buffer = await target.screenshot(options);
|
||||
return successResponse(command.id, { base64: buffer.toString('base64') });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSnapshot(
|
||||
command: Command & { action: 'snapshot' },
|
||||
browser: BrowserManager
|
||||
): Promise<Response<SnapshotData>> {
|
||||
const page = browser.getPage();
|
||||
// Use ariaSnapshot which returns a string representation of the accessibility tree
|
||||
const snapshot = await page.locator(':root').ariaSnapshot();
|
||||
|
||||
return successResponse(command.id, {
|
||||
snapshot: snapshot ?? 'Empty page',
|
||||
});
|
||||
}
|
||||
|
||||
async function handleEvaluate(
|
||||
command: EvaluateCommand,
|
||||
browser: BrowserManager
|
||||
): Promise<Response<EvaluateData>> {
|
||||
const page = browser.getPage();
|
||||
|
||||
// Evaluate the script directly as a string expression
|
||||
const result = await page.evaluate(command.script);
|
||||
|
||||
return successResponse(command.id, { result });
|
||||
}
|
||||
|
||||
async function handleWait(
|
||||
command: WaitCommand,
|
||||
browser: BrowserManager
|
||||
): Promise<Response> {
|
||||
const page = browser.getPage();
|
||||
|
||||
if (command.selector) {
|
||||
await page.waitForSelector(command.selector, {
|
||||
state: command.state ?? 'visible',
|
||||
timeout: command.timeout,
|
||||
});
|
||||
} else if (command.timeout) {
|
||||
await page.waitForTimeout(command.timeout);
|
||||
} else {
|
||||
// Default: wait for load state
|
||||
await page.waitForLoadState('load');
|
||||
}
|
||||
|
||||
return successResponse(command.id, { waited: true });
|
||||
}
|
||||
|
||||
async function handleScroll(
|
||||
command: ScrollCommand,
|
||||
browser: BrowserManager
|
||||
): Promise<Response> {
|
||||
const page = browser.getPage();
|
||||
|
||||
if (command.selector) {
|
||||
const element = page.locator(command.selector);
|
||||
await element.scrollIntoViewIfNeeded();
|
||||
|
||||
if (command.x !== undefined || command.y !== undefined) {
|
||||
await element.evaluate((el, { x, y }) => {
|
||||
el.scrollBy(x ?? 0, y ?? 0);
|
||||
}, { x: command.x, y: command.y });
|
||||
}
|
||||
} else {
|
||||
// Scroll the page
|
||||
let deltaX = command.x ?? 0;
|
||||
let deltaY = command.y ?? 0;
|
||||
|
||||
if (command.direction) {
|
||||
const amount = command.amount ?? 100;
|
||||
switch (command.direction) {
|
||||
case 'up':
|
||||
deltaY = -amount;
|
||||
break;
|
||||
case 'down':
|
||||
deltaY = amount;
|
||||
break;
|
||||
case 'left':
|
||||
deltaX = -amount;
|
||||
break;
|
||||
case 'right':
|
||||
deltaX = amount;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await page.evaluate(`window.scrollBy(${deltaX}, ${deltaY})`);
|
||||
}
|
||||
|
||||
return successResponse(command.id, { scrolled: true });
|
||||
}
|
||||
|
||||
async function handleSelect(
|
||||
command: SelectCommand,
|
||||
browser: BrowserManager
|
||||
): Promise<Response> {
|
||||
const page = browser.getPage();
|
||||
const values = Array.isArray(command.values) ? command.values : [command.values];
|
||||
|
||||
await page.selectOption(command.selector, values);
|
||||
|
||||
return successResponse(command.id, { selected: values });
|
||||
}
|
||||
|
||||
async function handleHover(
|
||||
command: HoverCommand,
|
||||
browser: BrowserManager
|
||||
): Promise<Response> {
|
||||
const page = browser.getPage();
|
||||
await page.hover(command.selector);
|
||||
|
||||
return successResponse(command.id, { hovered: true });
|
||||
}
|
||||
|
||||
async function handleContent(
|
||||
command: ContentCommand,
|
||||
browser: BrowserManager
|
||||
): Promise<Response<ContentData>> {
|
||||
const page = browser.getPage();
|
||||
|
||||
let html: string;
|
||||
if (command.selector) {
|
||||
html = await page.locator(command.selector).innerHTML();
|
||||
} else {
|
||||
html = await page.content();
|
||||
}
|
||||
|
||||
return successResponse(command.id, { html });
|
||||
}
|
||||
|
||||
async function handleClose(
|
||||
command: Command & { action: 'close' },
|
||||
browser: BrowserManager
|
||||
): Promise<Response> {
|
||||
await browser.close();
|
||||
return successResponse(command.id, { closed: true });
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { chromium, firefox, webkit, type Browser, type BrowserContext, type Page } from 'playwright';
|
||||
import type { BrowserState, LaunchCommand } from './types.js';
|
||||
|
||||
/**
|
||||
* Manages the Playwright browser lifecycle
|
||||
*/
|
||||
export class BrowserManager {
|
||||
private state: BrowserState = {
|
||||
browser: null,
|
||||
context: null,
|
||||
page: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if browser is launched
|
||||
*/
|
||||
isLaunched(): boolean {
|
||||
return this.state.browser !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current page, throws if not launched
|
||||
*/
|
||||
getPage(): Page {
|
||||
if (!this.state.page) {
|
||||
throw new Error('Browser not launched. Call launch first.');
|
||||
}
|
||||
return this.state.page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current browser instance
|
||||
*/
|
||||
getBrowser(): Browser | null {
|
||||
return this.state.browser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current context
|
||||
*/
|
||||
getContext(): BrowserContext | null {
|
||||
return this.state.context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the browser with the specified options
|
||||
*/
|
||||
async launch(options: LaunchCommand): Promise<void> {
|
||||
// Close existing browser if any
|
||||
if (this.state.browser) {
|
||||
await this.close();
|
||||
}
|
||||
|
||||
// Select browser type
|
||||
const browserType = options.browser ?? 'chromium';
|
||||
const launcher = browserType === 'firefox'
|
||||
? firefox
|
||||
: browserType === 'webkit'
|
||||
? webkit
|
||||
: chromium;
|
||||
|
||||
// Launch browser
|
||||
this.state.browser = await launcher.launch({
|
||||
headless: options.headless ?? true,
|
||||
});
|
||||
|
||||
// Create context with viewport
|
||||
this.state.context = await this.state.browser.newContext({
|
||||
viewport: options.viewport ?? { width: 1280, height: 720 },
|
||||
});
|
||||
|
||||
// Create initial page
|
||||
this.state.page = await this.state.context.newPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the browser and clean up
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (this.state.page) {
|
||||
await this.state.page.close().catch(() => {});
|
||||
this.state.page = null;
|
||||
}
|
||||
|
||||
if (this.state.context) {
|
||||
await this.state.context.close().catch(() => {});
|
||||
this.state.context = null;
|
||||
}
|
||||
|
||||
if (this.state.browser) {
|
||||
await this.state.browser.close().catch(() => {});
|
||||
this.state.browser = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
import * as net from 'net';
|
||||
import { spawn } from 'child_process';
|
||||
import { fileURLToPath } from 'url';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { getSocketPath, isDaemonRunning } from './daemon.js';
|
||||
import type { Response } from './types.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
let DEBUG = false;
|
||||
|
||||
export function setDebug(enabled: boolean): void {
|
||||
DEBUG = enabled;
|
||||
}
|
||||
|
||||
function debug(...args: unknown[]): void {
|
||||
if (DEBUG) {
|
||||
console.error('[debug]', ...args);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for socket to exist
|
||||
*/
|
||||
async function waitForSocket(maxAttempts = 30): Promise<boolean> {
|
||||
const socketPath = getSocketPath();
|
||||
debug('Waiting for socket at', socketPath);
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
if (fs.existsSync(socketPath)) {
|
||||
debug('Socket found after', i * 100, 'ms');
|
||||
return true;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
}
|
||||
debug('Socket not found after', maxAttempts * 100, 'ms');
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure daemon is running, start if not
|
||||
*/
|
||||
export async function ensureDaemon(): Promise<void> {
|
||||
debug('Checking if daemon is running...');
|
||||
if (isDaemonRunning()) {
|
||||
debug('Daemon already running');
|
||||
return;
|
||||
}
|
||||
|
||||
debug('Starting daemon...');
|
||||
const daemonPath = path.join(__dirname, 'daemon.js');
|
||||
const child = spawn(process.execPath, [daemonPath], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: { ...process.env, VEB_DAEMON: '1' },
|
||||
});
|
||||
child.unref();
|
||||
|
||||
// Wait for socket to be created
|
||||
const ready = await waitForSocket();
|
||||
if (!ready) {
|
||||
throw new Error('Failed to start daemon');
|
||||
}
|
||||
|
||||
debug('Daemon started');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a command to the daemon
|
||||
*/
|
||||
export async function sendCommand(command: Record<string, unknown>): Promise<Response> {
|
||||
const socketPath = getSocketPath();
|
||||
debug('Sending command:', JSON.stringify(command));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let resolved = false;
|
||||
let buffer = '';
|
||||
const startTime = Date.now();
|
||||
|
||||
const socket = net.createConnection(socketPath);
|
||||
|
||||
socket.on('connect', () => {
|
||||
debug('Connected to daemon, sending command...');
|
||||
socket.write(JSON.stringify(command) + '\n');
|
||||
});
|
||||
|
||||
socket.on('data', (data) => {
|
||||
buffer += data.toString();
|
||||
debug('Received data:', buffer.length, 'bytes');
|
||||
|
||||
// Try to parse complete JSON from buffer
|
||||
const newlineIdx = buffer.indexOf('\n');
|
||||
if (newlineIdx !== -1) {
|
||||
const jsonStr = buffer.substring(0, newlineIdx);
|
||||
try {
|
||||
const response = JSON.parse(jsonStr) as Response;
|
||||
debug('Response received in', Date.now() - startTime, 'ms');
|
||||
resolved = true;
|
||||
socket.end();
|
||||
resolve(response);
|
||||
} catch (e) {
|
||||
debug('JSON parse error:', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
debug('Socket error:', err.message);
|
||||
if (!resolved) {
|
||||
reject(new Error(`Connection error: ${err.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
debug('Socket closed, resolved:', resolved, 'buffer:', buffer.length);
|
||||
if (!resolved && buffer.trim()) {
|
||||
try {
|
||||
const response = JSON.parse(buffer.trim()) as Response;
|
||||
resolve(response);
|
||||
} catch {
|
||||
reject(new Error('Invalid response from daemon'));
|
||||
}
|
||||
} else if (!resolved) {
|
||||
reject(new Error('Connection closed without response'));
|
||||
}
|
||||
});
|
||||
|
||||
// Timeout after 60 seconds
|
||||
setTimeout(() => {
|
||||
if (!resolved) {
|
||||
debug('Command timeout after 60s');
|
||||
socket.destroy();
|
||||
reject(new Error('Command timeout'));
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a command, ensuring daemon is running first
|
||||
*/
|
||||
export async function send(command: Record<string, unknown>): Promise<Response> {
|
||||
const startTime = Date.now();
|
||||
await ensureDaemon();
|
||||
debug('ensureDaemon took', Date.now() - startTime, 'ms');
|
||||
return sendCommand(command);
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
import * as net from 'net';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { BrowserManager } from './browser.js';
|
||||
import { parseCommand, serializeResponse, errorResponse } from './protocol.js';
|
||||
import { executeCommand } from './actions.js';
|
||||
|
||||
const SOCKET_PATH = path.join(os.tmpdir(), 'veb.sock');
|
||||
const PID_FILE = path.join(os.tmpdir(), 'veb.pid');
|
||||
|
||||
/**
|
||||
* Get the socket path
|
||||
*/
|
||||
export function getSocketPath(): string {
|
||||
return SOCKET_PATH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the PID file path
|
||||
*/
|
||||
export function getPidFile(): string {
|
||||
return PID_FILE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if daemon is running
|
||||
*/
|
||||
export function isDaemonRunning(): boolean {
|
||||
if (!fs.existsSync(PID_FILE)) return false;
|
||||
|
||||
try {
|
||||
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
|
||||
// Check if process exists
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
// Process doesn't exist, clean up stale files
|
||||
cleanupSocket();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up socket and PID file
|
||||
*/
|
||||
export function cleanupSocket(): void {
|
||||
try {
|
||||
if (fs.existsSync(SOCKET_PATH)) fs.unlinkSync(SOCKET_PATH);
|
||||
if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the daemon server
|
||||
*/
|
||||
export async function startDaemon(): Promise<void> {
|
||||
// Clean up any stale socket
|
||||
cleanupSocket();
|
||||
|
||||
const browser = new BrowserManager();
|
||||
let shuttingDown = false;
|
||||
|
||||
const server = net.createServer((socket) => {
|
||||
let buffer = '';
|
||||
|
||||
socket.on('data', async (data) => {
|
||||
buffer += data.toString();
|
||||
|
||||
// Process complete lines
|
||||
while (buffer.includes('\n')) {
|
||||
const newlineIdx = buffer.indexOf('\n');
|
||||
const line = buffer.substring(0, newlineIdx);
|
||||
buffer = buffer.substring(newlineIdx + 1);
|
||||
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
const parseResult = parseCommand(line);
|
||||
|
||||
if (!parseResult.success) {
|
||||
const resp = errorResponse(parseResult.id ?? 'unknown', parseResult.error);
|
||||
socket.write(serializeResponse(resp) + '\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Auto-launch browser if not already launched and this isn't a launch command
|
||||
if (!browser.isLaunched() && parseResult.command.action !== 'launch' && parseResult.command.action !== 'close') {
|
||||
await browser.launch({ id: 'auto', action: 'launch', headless: true });
|
||||
}
|
||||
|
||||
// Handle close command specially
|
||||
if (parseResult.command.action === 'close') {
|
||||
const response = await executeCommand(parseResult.command, browser);
|
||||
socket.write(serializeResponse(response) + '\n');
|
||||
|
||||
if (!shuttingDown) {
|
||||
shuttingDown = true;
|
||||
setTimeout(() => {
|
||||
server.close();
|
||||
cleanupSocket();
|
||||
process.exit(0);
|
||||
}, 100);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await executeCommand(parseResult.command, browser);
|
||||
socket.write(serializeResponse(response) + '\n');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
socket.write(serializeResponse(errorResponse('error', message)) + '\n');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', () => {
|
||||
// Client disconnected, ignore
|
||||
});
|
||||
});
|
||||
|
||||
// Write PID file before listening
|
||||
fs.writeFileSync(PID_FILE, process.pid.toString());
|
||||
|
||||
server.listen(SOCKET_PATH, () => {
|
||||
// Daemon is ready
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
console.error('Server error:', err);
|
||||
cleanupSocket();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Handle shutdown signals
|
||||
const shutdown = async () => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
await browser.close();
|
||||
server.close();
|
||||
cleanupSocket();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
// Keep process alive
|
||||
process.stdin.resume();
|
||||
}
|
||||
|
||||
// Run daemon if this is the entry point
|
||||
if (process.argv[1]?.endsWith('daemon.js') || process.env.VEB_DAEMON === '1') {
|
||||
startDaemon().catch((err) => {
|
||||
console.error('Daemon error:', err);
|
||||
cleanupSocket();
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
#!/usr/bin/env node
|
||||
import { send, setDebug } from './client.js';
|
||||
import type { Response } from './types.js';
|
||||
|
||||
// ANSI colors
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bold: '\x1b[1m',
|
||||
dim: '\x1b[2m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
magenta: '\x1b[35m',
|
||||
cyan: '\x1b[36m',
|
||||
};
|
||||
|
||||
const c = (color: keyof typeof colors, text: string) => `${colors[color]}${text}${colors.reset}`;
|
||||
|
||||
function printHelp(): void {
|
||||
console.log(`
|
||||
${c('bold', 'veb')} - headless browser automation for agents and humans
|
||||
|
||||
${c('yellow', 'Usage:')}
|
||||
veb <command> [options]
|
||||
|
||||
${c('yellow', 'Commands:')}
|
||||
${c('cyan', 'open')} <url> Open a URL in the browser
|
||||
${c('cyan', 'click')} <selector> Click an element
|
||||
${c('cyan', 'type')} <selector> <text> Type text into an element
|
||||
${c('cyan', 'press')} <key> Press a keyboard key
|
||||
${c('cyan', 'wait')} <selector|text|ms> Wait for element, text, or duration
|
||||
${c('cyan', 'screenshot')} [path] Take a screenshot
|
||||
${c('cyan', 'snapshot')} Get accessibility tree (for agents)
|
||||
${c('cyan', 'extract')} <selector> Extract element content
|
||||
${c('cyan', 'eval')} <script> Evaluate JavaScript
|
||||
${c('cyan', 'scroll')} <direction> [amount] Scroll the page
|
||||
${c('cyan', 'hover')} <selector> Hover over an element
|
||||
${c('cyan', 'select')} <selector> <value> Select dropdown option
|
||||
${c('cyan', 'close')} Close browser and stop daemon
|
||||
|
||||
${c('yellow', 'Options:')}
|
||||
--json Output raw JSON (for agents)
|
||||
--selector, -s <sel> Target specific element
|
||||
--text, -t Wait for text instead of selector
|
||||
--full, -f Full page screenshot
|
||||
--debug Show debug output
|
||||
--help, -h Show help
|
||||
|
||||
${c('yellow', 'Examples:')}
|
||||
veb open https://example.com
|
||||
veb click "#submit-btn"
|
||||
veb type "#email" "hello@example.com"
|
||||
veb wait --text "Welcome"
|
||||
veb wait 2000
|
||||
veb screenshot --full page.png
|
||||
veb extract "table" --json
|
||||
veb eval "document.title"
|
||||
veb scroll down 500
|
||||
`);
|
||||
}
|
||||
|
||||
function genId(): string {
|
||||
return Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
function printResponse(response: Response, jsonMode: boolean): void {
|
||||
if (jsonMode) {
|
||||
console.log(JSON.stringify(response));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.success) {
|
||||
console.error(c('red', '✗ Error:'), response.error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const data = response.data as Record<string, unknown>;
|
||||
|
||||
// Pretty print based on data type
|
||||
if (data.url && data.title) {
|
||||
console.log(c('green', '✓'), c('bold', data.title as string));
|
||||
console.log(c('dim', ` ${data.url}`));
|
||||
} else if (data.html) {
|
||||
console.log(data.html);
|
||||
} else if (data.snapshot) {
|
||||
console.log(data.snapshot);
|
||||
} else if (data.result !== undefined) {
|
||||
const result = data.result;
|
||||
if (typeof result === 'object') {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(result);
|
||||
}
|
||||
} else if (data.base64) {
|
||||
console.log(c('green', '✓'), 'Screenshot captured (base64)');
|
||||
console.log(c('dim', ` ${(data.base64 as string).length} bytes`));
|
||||
} else if (data.path) {
|
||||
console.log(c('green', '✓'), `Saved to ${data.path}`);
|
||||
} else if (data.clicked || data.typed || data.pressed || data.hovered || data.scrolled || data.selected || data.waited) {
|
||||
console.log(c('green', '✓'), 'Done');
|
||||
} else if (data.launched) {
|
||||
console.log(c('green', '✓'), 'Browser launched');
|
||||
} else if (data.closed) {
|
||||
console.log(c('green', '✓'), 'Browser closed');
|
||||
} else {
|
||||
console.log(c('green', '✓'), JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
// Enable debug mode early
|
||||
const debugMode = args.includes('--debug');
|
||||
if (debugMode) {
|
||||
setDebug(true);
|
||||
}
|
||||
|
||||
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const jsonMode = args.includes('--json');
|
||||
const fullPage = args.includes('--full') || args.includes('-f');
|
||||
const textMode = args.includes('--text') || args.includes('-t');
|
||||
|
||||
// Remove flag args and their values
|
||||
const cleanArgs = args.filter((a, i) => {
|
||||
if (a.startsWith('-')) return false;
|
||||
// Check if previous arg was a flag that takes a value
|
||||
const prev = args[i - 1];
|
||||
if (prev === '--selector' || prev === '-s') return false;
|
||||
return true;
|
||||
});
|
||||
const command = cleanArgs[0];
|
||||
|
||||
// Find --selector value
|
||||
let selectorOverride: string | undefined;
|
||||
const sIdx = args.findIndex(a => a === '--selector' || a === '-s');
|
||||
if (sIdx !== -1 && args[sIdx + 1]) {
|
||||
selectorOverride = args[sIdx + 1];
|
||||
}
|
||||
|
||||
const id = genId();
|
||||
let cmd: Record<string, unknown>;
|
||||
|
||||
switch (command) {
|
||||
case 'open':
|
||||
case 'goto':
|
||||
case 'navigate': {
|
||||
const url = cleanArgs[1];
|
||||
if (!url) {
|
||||
console.error(c('red', 'Error:'), 'URL required');
|
||||
process.exit(1);
|
||||
}
|
||||
// Auto-add https if missing
|
||||
const fullUrl = url.startsWith('http') ? url : `https://${url}`;
|
||||
cmd = { id, action: 'navigate', url: fullUrl };
|
||||
break;
|
||||
}
|
||||
|
||||
case 'click': {
|
||||
const selector = cleanArgs[1];
|
||||
if (!selector) {
|
||||
console.error(c('red', 'Error:'), 'Selector required');
|
||||
process.exit(1);
|
||||
}
|
||||
cmd = { id, action: 'click', selector };
|
||||
break;
|
||||
}
|
||||
|
||||
case 'type': {
|
||||
const selector = cleanArgs[1];
|
||||
const text = cleanArgs.slice(2).join(' ');
|
||||
if (!selector || !text) {
|
||||
console.error(c('red', 'Error:'), 'Selector and text required');
|
||||
process.exit(1);
|
||||
}
|
||||
cmd = { id, action: 'type', selector, text, clear: true };
|
||||
break;
|
||||
}
|
||||
|
||||
case 'press': {
|
||||
const key = cleanArgs[1];
|
||||
if (!key) {
|
||||
console.error(c('red', 'Error:'), 'Key required');
|
||||
process.exit(1);
|
||||
}
|
||||
cmd = { id, action: 'press', key, selector: selectorOverride };
|
||||
break;
|
||||
}
|
||||
|
||||
case 'wait': {
|
||||
const target = cleanArgs[1];
|
||||
if (!target) {
|
||||
console.error(c('red', 'Error:'), 'Selector, text, or milliseconds required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check if it's a number (milliseconds)
|
||||
const ms = parseInt(target, 10);
|
||||
if (!isNaN(ms)) {
|
||||
cmd = { id, action: 'wait', timeout: ms };
|
||||
} else if (textMode) {
|
||||
// Wait for text - use evaluate to check for text
|
||||
cmd = { id, action: 'wait', selector: `text=${target}` };
|
||||
} else {
|
||||
cmd = { id, action: 'wait', selector: target };
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'screenshot':
|
||||
case 'ss': {
|
||||
const pathArg = cleanArgs[1];
|
||||
cmd = {
|
||||
id,
|
||||
action: 'screenshot',
|
||||
path: pathArg,
|
||||
fullPage,
|
||||
selector: selectorOverride,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
case 'snapshot':
|
||||
case 'aria':
|
||||
case 'a11y': {
|
||||
cmd = { id, action: 'snapshot' };
|
||||
break;
|
||||
}
|
||||
|
||||
case 'extract':
|
||||
case 'html':
|
||||
case 'content': {
|
||||
const selector = cleanArgs[1] || selectorOverride;
|
||||
cmd = { id, action: 'content', selector };
|
||||
break;
|
||||
}
|
||||
|
||||
case 'eval':
|
||||
case 'js': {
|
||||
const script = cleanArgs.slice(1).join(' ');
|
||||
if (!script) {
|
||||
console.error(c('red', 'Error:'), 'Script required');
|
||||
process.exit(1);
|
||||
}
|
||||
cmd = { id, action: 'evaluate', script };
|
||||
break;
|
||||
}
|
||||
|
||||
case 'scroll': {
|
||||
const dirOrAmount = cleanArgs[1];
|
||||
const amount = parseInt(cleanArgs[2], 10) || 300;
|
||||
|
||||
if (['up', 'down', 'left', 'right'].includes(dirOrAmount)) {
|
||||
cmd = { id, action: 'scroll', direction: dirOrAmount, amount, selector: selectorOverride };
|
||||
} else {
|
||||
const y = parseInt(dirOrAmount, 10) || 300;
|
||||
cmd = { id, action: 'scroll', y, selector: selectorOverride };
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'hover': {
|
||||
const selector = cleanArgs[1];
|
||||
if (!selector) {
|
||||
console.error(c('red', 'Error:'), 'Selector required');
|
||||
process.exit(1);
|
||||
}
|
||||
cmd = { id, action: 'hover', selector };
|
||||
break;
|
||||
}
|
||||
|
||||
case 'select': {
|
||||
const selector = cleanArgs[1];
|
||||
const value = cleanArgs[2];
|
||||
if (!selector || !value) {
|
||||
console.error(c('red', 'Error:'), 'Selector and value required');
|
||||
process.exit(1);
|
||||
}
|
||||
cmd = { id, action: 'select', selector, values: value };
|
||||
break;
|
||||
}
|
||||
|
||||
case 'close':
|
||||
case 'quit':
|
||||
case 'exit': {
|
||||
cmd = { id, action: 'close' };
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.error(c('red', 'Error:'), `Unknown command: ${command}`);
|
||||
console.error(c('dim', 'Run veb --help for usage'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await send(cmd);
|
||||
printResponse(response, jsonMode);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (jsonMode) {
|
||||
console.log(JSON.stringify({ id, success: false, error: message }));
|
||||
} else {
|
||||
console.error(c('red', '✗ Error:'), message);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
import { z } from 'zod';
|
||||
import type { Command, Response } from './types.js';
|
||||
|
||||
// Base schema for all commands
|
||||
const baseCommandSchema = z.object({
|
||||
id: z.string(),
|
||||
action: z.string(),
|
||||
});
|
||||
|
||||
// Individual action schemas
|
||||
const launchSchema = baseCommandSchema.extend({
|
||||
action: z.literal('launch'),
|
||||
headless: z.boolean().optional(),
|
||||
viewport: z.object({
|
||||
width: z.number().positive(),
|
||||
height: z.number().positive(),
|
||||
}).optional(),
|
||||
browser: z.enum(['chromium', 'firefox', 'webkit']).optional(),
|
||||
});
|
||||
|
||||
const navigateSchema = baseCommandSchema.extend({
|
||||
action: z.literal('navigate'),
|
||||
url: z.string().min(1),
|
||||
waitUntil: z.enum(['load', 'domcontentloaded', 'networkidle']).optional(),
|
||||
});
|
||||
|
||||
const clickSchema = baseCommandSchema.extend({
|
||||
action: z.literal('click'),
|
||||
selector: z.string().min(1),
|
||||
button: z.enum(['left', 'right', 'middle']).optional(),
|
||||
clickCount: z.number().positive().optional(),
|
||||
delay: z.number().nonnegative().optional(),
|
||||
});
|
||||
|
||||
const typeSchema = baseCommandSchema.extend({
|
||||
action: z.literal('type'),
|
||||
selector: z.string().min(1),
|
||||
text: z.string(),
|
||||
delay: z.number().nonnegative().optional(),
|
||||
clear: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const pressSchema = baseCommandSchema.extend({
|
||||
action: z.literal('press'),
|
||||
key: z.string().min(1),
|
||||
selector: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const screenshotSchema = baseCommandSchema.extend({
|
||||
action: z.literal('screenshot'),
|
||||
path: z.string().optional(),
|
||||
fullPage: z.boolean().optional(),
|
||||
selector: z.string().min(1).optional(),
|
||||
format: z.enum(['png', 'jpeg']).optional(),
|
||||
quality: z.number().min(0).max(100).optional(),
|
||||
});
|
||||
|
||||
const snapshotSchema = baseCommandSchema.extend({
|
||||
action: z.literal('snapshot'),
|
||||
});
|
||||
|
||||
const evaluateSchema = baseCommandSchema.extend({
|
||||
action: z.literal('evaluate'),
|
||||
script: z.string().min(1),
|
||||
args: z.array(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
const waitSchema = baseCommandSchema.extend({
|
||||
action: z.literal('wait'),
|
||||
selector: z.string().min(1).optional(),
|
||||
timeout: z.number().positive().optional(),
|
||||
state: z.enum(['attached', 'detached', 'visible', 'hidden']).optional(),
|
||||
});
|
||||
|
||||
const scrollSchema = baseCommandSchema.extend({
|
||||
action: z.literal('scroll'),
|
||||
selector: z.string().min(1).optional(),
|
||||
x: z.number().optional(),
|
||||
y: z.number().optional(),
|
||||
direction: z.enum(['up', 'down', 'left', 'right']).optional(),
|
||||
amount: z.number().positive().optional(),
|
||||
});
|
||||
|
||||
const selectSchema = baseCommandSchema.extend({
|
||||
action: z.literal('select'),
|
||||
selector: z.string().min(1),
|
||||
values: z.union([z.string(), z.array(z.string())]),
|
||||
});
|
||||
|
||||
const hoverSchema = baseCommandSchema.extend({
|
||||
action: z.literal('hover'),
|
||||
selector: z.string().min(1),
|
||||
});
|
||||
|
||||
const contentSchema = baseCommandSchema.extend({
|
||||
action: z.literal('content'),
|
||||
selector: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const closeSchema = baseCommandSchema.extend({
|
||||
action: z.literal('close'),
|
||||
});
|
||||
|
||||
// Union schema for all commands
|
||||
const commandSchema = z.discriminatedUnion('action', [
|
||||
launchSchema,
|
||||
navigateSchema,
|
||||
clickSchema,
|
||||
typeSchema,
|
||||
pressSchema,
|
||||
screenshotSchema,
|
||||
snapshotSchema,
|
||||
evaluateSchema,
|
||||
waitSchema,
|
||||
scrollSchema,
|
||||
selectSchema,
|
||||
hoverSchema,
|
||||
contentSchema,
|
||||
closeSchema,
|
||||
]);
|
||||
|
||||
// Parse result type
|
||||
export type ParseResult =
|
||||
| { success: true; command: Command }
|
||||
| { success: false; error: string; id?: string };
|
||||
|
||||
/**
|
||||
* Parse a JSON string into a validated command
|
||||
*/
|
||||
export function parseCommand(input: string): ParseResult {
|
||||
// First, try to parse JSON
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(input);
|
||||
} catch {
|
||||
return { success: false, error: 'Invalid JSON' };
|
||||
}
|
||||
|
||||
// Extract id for error responses if possible
|
||||
const id = typeof json === 'object' && json !== null && 'id' in json
|
||||
? String((json as { id: unknown }).id)
|
||||
: undefined;
|
||||
|
||||
// Validate against schema
|
||||
const result = commandSchema.safeParse(json);
|
||||
|
||||
if (!result.success) {
|
||||
const errors = result.error.errors
|
||||
.map(e => `${e.path.join('.')}: ${e.message}`)
|
||||
.join(', ');
|
||||
return { success: false, error: `Validation error: ${errors}`, id };
|
||||
}
|
||||
|
||||
return { success: true, command: result.data as Command };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a success response
|
||||
*/
|
||||
export function successResponse<T>(id: string, data: T): Response<T> {
|
||||
return { id, success: true, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error response
|
||||
*/
|
||||
export function errorResponse(id: string, error: string): Response {
|
||||
return { id, success: false, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a response to JSON string
|
||||
*/
|
||||
export function serializeResponse(response: Response): string {
|
||||
return JSON.stringify(response);
|
||||
}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
import type { Page, Browser, BrowserContext } from 'playwright';
|
||||
|
||||
// Base command structure
|
||||
export interface BaseCommand {
|
||||
id: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
// Action-specific command types
|
||||
export interface LaunchCommand extends BaseCommand {
|
||||
action: 'launch';
|
||||
headless?: boolean;
|
||||
viewport?: { width: number; height: number };
|
||||
browser?: 'chromium' | 'firefox' | 'webkit';
|
||||
}
|
||||
|
||||
export interface NavigateCommand extends BaseCommand {
|
||||
action: 'navigate';
|
||||
url: string;
|
||||
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
|
||||
}
|
||||
|
||||
export interface ClickCommand extends BaseCommand {
|
||||
action: 'click';
|
||||
selector: string;
|
||||
button?: 'left' | 'right' | 'middle';
|
||||
clickCount?: number;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export interface TypeCommand extends BaseCommand {
|
||||
action: 'type';
|
||||
selector: string;
|
||||
text: string;
|
||||
delay?: number;
|
||||
clear?: boolean;
|
||||
}
|
||||
|
||||
export interface PressCommand extends BaseCommand {
|
||||
action: 'press';
|
||||
key: string;
|
||||
selector?: string;
|
||||
}
|
||||
|
||||
export interface ScreenshotCommand extends BaseCommand {
|
||||
action: 'screenshot';
|
||||
path?: string;
|
||||
fullPage?: boolean;
|
||||
selector?: string;
|
||||
format?: 'png' | 'jpeg';
|
||||
quality?: number;
|
||||
}
|
||||
|
||||
export interface SnapshotCommand extends BaseCommand {
|
||||
action: 'snapshot';
|
||||
}
|
||||
|
||||
export interface EvaluateCommand extends BaseCommand {
|
||||
action: 'evaluate';
|
||||
script: string;
|
||||
args?: unknown[];
|
||||
}
|
||||
|
||||
export interface WaitCommand extends BaseCommand {
|
||||
action: 'wait';
|
||||
selector?: string;
|
||||
timeout?: number;
|
||||
state?: 'attached' | 'detached' | 'visible' | 'hidden';
|
||||
}
|
||||
|
||||
export interface ScrollCommand extends BaseCommand {
|
||||
action: 'scroll';
|
||||
selector?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
direction?: 'up' | 'down' | 'left' | 'right';
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
export interface SelectCommand extends BaseCommand {
|
||||
action: 'select';
|
||||
selector: string;
|
||||
values: string | string[];
|
||||
}
|
||||
|
||||
export interface HoverCommand extends BaseCommand {
|
||||
action: 'hover';
|
||||
selector: string;
|
||||
}
|
||||
|
||||
export interface ContentCommand extends BaseCommand {
|
||||
action: 'content';
|
||||
selector?: string;
|
||||
}
|
||||
|
||||
export interface CloseCommand extends BaseCommand {
|
||||
action: 'close';
|
||||
}
|
||||
|
||||
// Union of all command types
|
||||
export type Command =
|
||||
| LaunchCommand
|
||||
| NavigateCommand
|
||||
| ClickCommand
|
||||
| TypeCommand
|
||||
| PressCommand
|
||||
| ScreenshotCommand
|
||||
| SnapshotCommand
|
||||
| EvaluateCommand
|
||||
| WaitCommand
|
||||
| ScrollCommand
|
||||
| SelectCommand
|
||||
| HoverCommand
|
||||
| ContentCommand
|
||||
| CloseCommand;
|
||||
|
||||
// Response types
|
||||
export interface SuccessResponse<T = unknown> {
|
||||
id: string;
|
||||
success: true;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
id: string;
|
||||
success: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type Response<T = unknown> = SuccessResponse<T> | ErrorResponse;
|
||||
|
||||
// Data types for specific responses
|
||||
export interface NavigateData {
|
||||
url: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface ScreenshotData {
|
||||
path?: string;
|
||||
base64?: string;
|
||||
}
|
||||
|
||||
export interface SnapshotData {
|
||||
snapshot: string;
|
||||
}
|
||||
|
||||
export interface EvaluateData {
|
||||
result: unknown;
|
||||
}
|
||||
|
||||
export interface ContentData {
|
||||
html: string;
|
||||
}
|
||||
|
||||
// Browser state
|
||||
export interface BrowserState {
|
||||
browser: Browser | null;
|
||||
context: BrowserContext | null;
|
||||
page: Page | null;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user