
Browser Plus
- 1 installs
- Updated April 17, 2026
- hamsterider-m/personal-skills
Automates browser actions by routing between a native browser tool and Vercel agent-browser based on the target element type.
About
Provides a unified browser-automation API that detects element types and routes rich-text editors to keyboard simulation and plain inputs to native fill. A developer uses it when scripting web form filling, clicking, or typing into editors that resist standard DOM manipulation.
- Auto-routes rich-text editors to agent-browser and plain inputs to native fill
- Composite actions like one-click tweet posting
Browser Plus by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,983 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/hamsterider-m/personal-skills --skill browser-plusAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| Last updated | April 17, 2026 |
| Repository | hamsterider-m/personal-skills ↗ |
What it does
Automates browser actions by routing between a native browser tool and Vercel agent-browser based on the target element type.
Files
Browser Plus
Intelligent browser automation that routes between OpenClaw native browser tool and Vercel agent-browser based on element type detection.
Overview
Browser Plus provides a unified API for browser automation that intelligently selects the best execution strategy:
- Rich text editors (contenteditable, tweet textarea, etc.) → Uses agent-browser keyboard simulation for real key events
- Plain inputs (text fields, password fields, etc.) → Uses native browser fill for faster execution
Quick Start
const browserPlus = require('./skills/browser-plus');
// Auto-routes based on element type
await browserPlus.type({ ref: '@e12', text: 'Hello World' });
// One-click Twitter posting
await browserPlus.tweet({ text: 'My automated tweet!' });Core API
type(options)
Types text into an element with automatic routing.
await browserPlus.type({
ref: '@e12', // Element reference (required)
text: 'Hello World', // Text to type (required)
forceNative: false, // Force native browser (optional)
forceAgent: false // Force agent-browser (optional)
});click(options)
Clicks an element.
await browserPlus.click({
ref: '@e12', // Element reference (required)
forceAgent: false // Use agent-browser (optional)
});Composite Actions
tweet(options)
One-click Twitter posting.
await browserPlus.tweet({
text: 'My tweet content',
media: ['/path/to/image.jpg'] // Optional media attachments
});How It Works
1. Element Detection: Analyzes the target element to determine its type 2. Strategy Selection:
- Rich text editors → agent-browser (real key events)
- Plain inputs → native browser (faster)
3. Execution: Routes to the appropriate adapter
Scripts
scripts/detectors.js- Element type detection utilitiesscripts/adapters/native-browser.js- OpenClaw browser wrapperscripts/adapters/agent-browser.js- Vercel agent-browser CLI wrapperscripts/composite/tweet.js- Twitter posting composite action
#!/usr/bin/env node
/**
* Browser Plus - Intelligent browser automation with smart routing
*
* Routes between OpenClaw native browser tool and Vercel agent-browser
* based on element type for optimal interaction.
*/
const detectors = require('./scripts/detectors');
const nativeBrowser = require('./scripts/adapters/native-browser');
const agentBrowser = require('./scripts/adapters/agent-browser');
const tweet = require('./scripts/composite/tweet');
// Global configuration
const config = {
preferNative: true,
fallbackToAgent: true,
agentBrowserTimeout: 60000,
defaultDelay: 10
};
/**
* Detect element type and determine routing
* @param {Object} options
* @param {string} options.ref - Element reference
* @param {Object} [options.snapshot] - Optional pre-fetched snapshot
* @returns {Promise<Object>} Detection result with adapter recommendation
*/
async function detectAndRoute(options) {
const { ref, snapshot: providedSnapshot } = options;
if (!ref) {
throw new Error('ref is required');
}
// Get snapshot if not provided
let snapshot = providedSnapshot;
if (!snapshot) {
try {
const result = await nativeBrowser.snapshot();
snapshot = result.result;
} catch (error) {
return {
ref,
elementType: 'UNKNOWN',
adapter: 'native-browser',
error: error.message
};
}
}
// Find element in snapshot
const element = detectors.findElementByRef(snapshot, ref);
if (!element) {
return {
ref,
elementType: 'UNKNOWN',
adapter: config.preferNative ? 'native-browser' : 'agent-browser',
warning: 'Element not found in snapshot, using default adapter'
};
}
const elementType = detectors.getElementType(element);
const adapter = detectors.getPreferredAdapter(element);
return {
ref,
elementType,
adapter,
element,
isRichText: elementType === 'RICH_TEXT'
};
}
/**
* Smart type action - auto-routes based on element type
* @param {Object} params - Type parameters
* @param {string} params.ref - Element reference (e.g., '@e12')
* @param {string} params.text - Text to type
* @param {boolean} [params.forceAgent] - Force using agent-browser
* @param {boolean} [params.forceNative] - Force using native browser
* @param {boolean} [params.submit] - Submit after typing
* @returns {Promise<Object>} Action result
*/
async function type(params) {
const { ref, text, forceAgent, forceNative, submit } = params;
if (!ref) {
throw new Error('ref is required');
}
if (!text) {
throw new Error('text is required');
}
// Determine which adapter to use
let useAgent = false;
if (forceAgent) {
useAgent = true;
} else if (forceNative) {
useAgent = false;
} else {
// Auto-detect based on element type
const detection = await detectAndRoute({ ref });
useAgent = detection.adapter === 'agent-browser';
}
console.log(`[browser-plus] Using ${useAgent ? 'agent-browser' : 'native browser'} for typing`);
const adapter = useAgent ? agentBrowser : nativeBrowser;
const result = await adapter.type({ ref, text, submit });
return {
...result,
routedTo: useAgent ? 'agent-browser' : 'native-browser'
};
}
/**
* Click an element
* @param {Object} params - Click parameters
* @param {string} params.ref - Element reference
* @returns {Promise<Object>} Action result
*/
async function click(params) {
const { ref } = params;
if (!ref) {
throw new Error('ref is required');
}
// Clicks typically work fine with native browser
return nativeBrowser.click({ ref });
}
/**
* Navigate to a URL
* @param {Object} params - Navigation parameters
* @param {string} params.url - URL to navigate to
* @returns {Promise<Object>} Action result
*/
async function navigate(params) {
const { url } = params;
if (!url) {
throw new Error('url is required');
}
return nativeBrowser.navigate({ url });
}
/**
* Get page snapshot
* @param {Object} [params] - Snapshot parameters
* @returns {Promise<Object>} Page snapshot
*/
async function snapshot(params = {}) {
return nativeBrowser.snapshot(params);
}
/**
* Post a tweet using composite action
* @param {Object} params - Tweet parameters
* @param {string} params.text - Tweet text
* @param {Array<string>} [params.media] - Media file paths
* @returns {Promise<Object>} Tweet result
*/
async function postTweet(params) {
return tweet.post(params);
}
// Export API
module.exports = {
// Main actions
type,
click,
navigate,
snapshot,
tweet: postTweet,
// Utilities
detectAndRoute,
config,
// Sub-modules for advanced usage
detectors,
adapters: {
native: nativeBrowser,
agent: agentBrowser
},
composite: {
tweet
}
};
#!/bin/bash
# Installation script for browser-plus skill
set -e
echo "Installing browser-plus skill..."
# Check for Node.js
if ! command -v node &> /dev/null; then
echo "Error: Node.js is required but not installed."
exit 1
fi
# Check Node version (requires 16+)
NODE_VERSION=$(node -v | cut -d'v' -f2 | cut -d'.' -f1)
if [ "$NODE_VERSION" -lt 16 ]; then
echo "Error: Node.js 16+ is required. Found: $(node -v)"
exit 1
fi
echo "✓ Node.js $(node -v) detected"
# Check for OpenClaw CLI
if ! command -v openclaw &> /dev/null; then
echo "Warning: OpenClaw CLI not found in PATH. Native browser features may not work."
else
echo "✓ OpenClaw CLI detected"
fi
# Check for agent-browser (optional but recommended)
AGENT_BROWSER_PATH=""
# Common locations to check for agent-browser
CHECK_PATHS=(
"$HOME/.local/bin/agent-browser"
"/usr/local/bin/agent-browser"
"$(npm root -g)/agent-browser/cli.js"
"$(which agent-browser 2>/dev/null)"
)
for path in "${CHECK_PATHS[@]}"; do
if [ -f "$path" ] || [ -L "$path" ]; then
AGENT_BROWSER_PATH="$path"
break
fi
done
if [ -z "$AGENT_BROWSER_PATH" ]; then
echo "⚠ agent-browser not found. Rich text editor support will be limited."
echo " To install: npm install -g @vercel/agent-browser"
else
echo "✓ agent-browser detected at: $AGENT_BROWSER_PATH"
fi
# Make scripts executable
chmod +x scripts/adapters/*.js
chmod +x scripts/composite/*.js
echo ""
echo "Installation complete!"
echo ""
echo "Usage:"
echo " const browserPlus = require('./skills/browser-plus');"
echo " await browserPlus.type({ ref: '@e12', text: 'Hello' });"
echo ""
{
"name": "browser-plus",
"version": "1.0.0",
"description": "Intelligent browser automation with smart routing between OpenClaw native browser and Vercel agent-browser",
"main": "index.js",
"scripts": {
"test": "node test/test.js"
},
"keywords": [
"browser",
"automation",
"openclaw",
"agent-browser",
"twitter",
"playwright"
],
"author": "OpenClaw",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
},
"dependencies": {},
"peerDependencies": {
"openclaw": ">=0.1.0"
}
}
#!/usr/bin/env node
/**
* Agent Browser Adapter - Vercel agent-browser CLI wrapper
*
* Uses Vercel's agent-browser for complex interactions requiring
* real keyboard events (rich text editors, etc.)
*/
const { execSync, spawn } = require('child_process');
const path = require('path');
// Default timeout for agent-browser operations
const DEFAULT_TIMEOUT = 60000;
/**
* Check if agent-browser is installed
* @returns {boolean}
*/
function isAvailable() {
try {
execSync('which agent-browser', { stdio: 'pipe' });
return true;
} catch {
return false;
}
}
/**
* Type text using agent-browser keyboard simulation
* @param {Object} options
* @param {string} options.ref - Element reference or selector
* @param {string} options.text - Text to type
* @param {boolean} options.submit - Whether to press Enter after typing
* @param {number} options.delay - Delay between keystrokes in ms (default: 10)
* @param {string} options.targetUrl - Target page URL
* @returns {Promise<Object>} Result
*/
async function type(options) {
const { ref, text, submit = false, delay = 10, targetUrl } = options;
if (!ref || !text) {
throw new Error('Both ref and text are required');
}
if (!isAvailable()) {
return {
success: false,
method: 'agent-browser',
error: 'agent-browser not found. Install with: npm install -g agent-browser'
};
}
try {
// Build agent-browser command
const args = [
'type',
'--selector', refToSelector(ref),
'--text', text,
'--delay', String(delay)
];
if (submit) {
args.push('--submit');
}
if (targetUrl) {
args.push('--url', targetUrl);
}
const result = await runAgentBrowser(args);
return {
success: true,
method: 'agent-browser',
result
};
} catch (error) {
return {
success: false,
method: 'agent-browser',
error: error.message
};
}
}
/**
* Click an element using agent-browser
* @param {Object} options
* @param {string} options.ref - Element reference or selector
* @param {string} options.targetUrl - Target page URL
* @returns {Promise<Object>} Result
*/
async function click(options) {
const { ref, targetUrl } = options;
if (!ref) {
throw new Error('ref is required');
}
if (!isAvailable()) {
return {
success: false,
method: 'agent-browser',
error: 'agent-browser not found. Install with: npm install -g agent-browser'
};
}
try {
const args = [
'click',
'--selector', refToSelector(ref)
];
if (targetUrl) {
args.push('--url', targetUrl);
}
const result = await runAgentBrowser(args);
return {
success: true,
method: 'agent-browser',
result
};
} catch (error) {
return {
success: false,
method: 'agent-browser',
error: error.message
};
}
}
/**
* Navigate to a URL using agent-browser
* @param {Object} options
* @param {string} options.url - URL to navigate to
* @returns {Promise<Object>} Result
*/
async function navigate(options) {
const { url } = options;
if (!url) {
throw new Error('url is required');
}
if (!isAvailable()) {
return {
success: false,
method: 'agent-browser',
error: 'agent-browser not found. Install with: npm install -g agent-browser'
};
}
try {
const args = ['navigate', '--url', url];
const result = await runAgentBrowser(args);
return {
success: true,
method: 'agent-browser',
result
};
} catch (error) {
return {
success: false,
method: 'agent-browser',
error: error.message
};
}
}
/**
* Press a key using agent-browser
* @param {Object} options
* @param {string} options.key - Key to press (e.g., 'Enter', 'Tab', 'Escape')
* @param {string} options.ref - Optional element to focus first
* @returns {Promise<Object>} Result
*/
async function pressKey(options) {
const { key, ref } = options;
if (!key) {
throw new Error('key is required');
}
if (!isAvailable()) {
return {
success: false,
method: 'agent-browser',
error: 'agent-browser not found. Install with: npm install -g agent-browser'
};
}
try {
const args = ['press', '--key', key];
if (ref) {
args.push('--selector', refToSelector(ref));
}
const result = await runAgentBrowser(args);
return {
success: true,
method: 'agent-browser',
result
};
} catch (error) {
return {
success: false,
method: 'agent-browser',
error: error.message
};
}
}
/**
* Run agent-browser CLI command
* @private
* @param {string[]} args - Command arguments
* @returns {Promise<Object>} Parsed result
*/
async function runAgentBrowser(args) {
return new Promise((resolve, reject) => {
const child = spawn('agent-browser', args, {
stdio: ['pipe', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
if (code !== 0) {
reject(new Error(`agent-browser exited with code ${code}: ${stderr}`));
} else {
try {
resolve(JSON.parse(stdout));
} catch {
resolve({ output: stdout.trim() });
}
}
});
// Timeout handling
setTimeout(() => {
child.kill();
reject(new Error('agent-browser operation timed out'));
}, DEFAULT_TIMEOUT);
});
}
/**
* Convert OpenClaw-style ref to CSS selector
* @private
* @param {string} ref - Reference like '@e12' or '[data-testid="tweetTextarea"]'
* @returns {string} CSS selector
*/
function refToSelector(ref) {
// If already looks like a selector, use as-is
if (ref.startsWith('[') || ref.startsWith('.') || ref.startsWith('#')) {
return ref;
}
// Handle OpenClaw aria-ref format (@e12)
if (ref.startsWith('@')) {
// This would need to be resolved via snapshot
// For now, assume it's a data attribute
return `[aria-ref="${ref.slice(1)}"]`;
}
return ref;
}
module.exports = {
isAvailable,
type,
click,
navigate,
pressKey,
keypress: pressKey // Alias for compatibility
};
#!/usr/bin/env node
/**
* Native Browser Adapter - OpenClaw browser tool wrapper
*
* Uses OpenClaw's native browser tool for fast, reliable automation
* on standard input elements.
*/
const { execSync } = require('child_process');
const path = require('path');
/**
* Type text into a plain input element using native browser fill
* @param {Object} options
* @param {string} options.ref - Element reference (e.g., '@e12')
* @param {string} options.text - Text to type
* @param {boolean} options.submit - Whether to submit after typing
* @param {string} options.targetId - Optional target tab ID
* @returns {Promise<Object>} Result from browser tool
*/
async function type(options) {
const { ref, text, submit = false, targetId } = options;
if (!ref || !text) {
throw new Error('Both ref and text are required');
}
// Build the request payload for OpenClaw browser tool
const request = {
kind: 'fill',
ref: ref,
values: [text]
};
if (submit) {
request.submit = true;
}
if (targetId) {
request.targetId = targetId;
}
try {
// Call OpenClaw browser tool via CLI
const result = await callBrowserTool({
action: 'act',
request: JSON.stringify(request)
});
return {
success: true,
method: 'native-browser',
result
};
} catch (error) {
return {
success: false,
method: 'native-browser',
error: error.message
};
}
}
/**
* Click an element using native browser
* @param {Object} options
* @param {string} options.ref - Element reference
* @param {string} options.targetId - Optional target tab ID
* @returns {Promise<Object>} Result
*/
async function click(options) {
const { ref, targetId } = options;
if (!ref) {
throw new Error('ref is required');
}
const request = {
kind: 'click',
ref: ref
};
if (targetId) {
request.targetId = targetId;
}
try {
const result = await callBrowserTool({
action: 'act',
request: JSON.stringify(request)
});
return {
success: true,
method: 'native-browser',
result
};
} catch (error) {
return {
success: false,
method: 'native-browser',
error: error.message
};
}
}
/**
* Navigate to a URL using native browser
* @param {Object} options
* @param {string} options.url - URL to navigate to
* @param {string} options.targetId - Optional target tab ID
* @returns {Promise<Object>} Result
*/
async function navigate(options) {
const { url, targetId } = options;
if (!url) {
throw new Error('url is required');
}
try {
const args = ['browser', 'navigate', '--targetUrl', url];
if (targetId) {
args.push('--targetId', targetId);
}
const result = execSync(`openclaw ${args.join(' ')}`, {
encoding: 'utf8',
timeout: 30000
});
return {
success: true,
method: 'native-browser',
result: JSON.parse(result)
};
} catch (error) {
return {
success: false,
method: 'native-browser',
error: error.message
};
}
}
/**
* Get page snapshot using native browser
* @param {Object} options
* @param {string} options.targetId - Optional target tab ID
* @param {string} options.refs - Reference type ('role' or 'aria')
* @returns {Promise<Object>} Snapshot result
*/
async function snapshot(options = {}) {
const { targetId, refs = 'role' } = options;
try {
const args = ['browser', 'snapshot', '--refs', refs];
if (targetId) {
args.push('--targetId', targetId);
}
const result = execSync(`openclaw ${args.join(' ')}`, {
encoding: 'utf8',
timeout: 30000
});
return {
success: true,
method: 'native-browser',
result: JSON.parse(result)
};
} catch (error) {
return {
success: false,
method: 'native-browser',
error: error.message
};
}
}
/**
* Internal helper to call browser tool
* @private
*/
async function callBrowserTool(params) {
const args = ['browser', params.action];
if (params.request) {
args.push('--request', params.request);
}
const output = execSync(`openclaw ${args.join(' ')}`, {
encoding: 'utf8',
timeout: 30000
});
return JSON.parse(output);
}
module.exports = {
type,
click,
navigate,
snapshot
};
#!/usr/bin/env node
/**
* Twitter/X Composite Action
*
* One-click Twitter posting with smart element detection.
* Handles the full flow: navigate → detect textarea → type → submit.
*/
const path = require('path');
const { execSync } = require('child_process');
// Import core modules
const detectors = require('../detectors');
const nativeBrowser = require('../adapters/native-browser');
const agentBrowser = require('../adapters/agent-browser');
const TWITTER_COMPOSE_URL = 'https://twitter.com/compose/tweet';
const MAX_TWEET_LENGTH = 280;
/**
* Validate tweet text
* @param {string} text - Tweet text to validate
* @throws {Error} If validation fails
*/
function validateText(text) {
if (!text || text.trim().length === 0) {
throw new Error('Tweet text cannot be empty');
}
if (text.length > MAX_TWEET_LENGTH) {
throw new Error(`Tweet text is ${text.length} characters, exceeds maximum length of ${MAX_TWEET_LENGTH}`);
}
}
/**
* Post a tweet with automatic element detection and routing
* @param {Object} options
* @param {string} options.text - Tweet text content
* @param {string[]} [options.media] - Optional array of media file paths
* @param {boolean} [options.dryRun=false] - If true, don't actually submit (for testing)
* @param {string} [options.targetId] - Optional target tab ID
* @returns {Promise<Object>} Result object
*/
async function post(options) {
const { text, media = [], dryRun = false, targetId } = options;
// Validate input
validateText(text);
const results = {
success: false,
steps: []
};
try {
// Step 1: Navigate to Twitter compose
console.log('[browser-plus] Navigating to Twitter compose...');
const navResult = await nativeBrowser.navigate({
url: TWITTER_COMPOSE_URL,
targetId
});
results.steps.push({ step: 'navigate', ...navResult });
if (!navResult.success) {
throw new Error('Failed to navigate to Twitter compose');
}
// Wait for page to load
await sleep(2000);
// Step 2: Get snapshot to find textarea
console.log('[browser-plus] Detecting tweet textarea...');
const snapshotResult = await nativeBrowser.snapshot({ targetId, refs: 'aria' });
if (!snapshotResult.success) {
throw new Error('Failed to get page snapshot');
}
// Step 3: Find the tweet textarea
const textareaRef = findTweetTextarea(snapshotResult.result);
if (!textareaRef) {
throw new Error('Could not find tweet textarea. Twitter UI may have changed.');
}
console.log(`[browser-plus] Found textarea at ref: ${textareaRef}`);
// Step 4: Find element details and determine adapter
const element = detectors.findElementByRef(snapshotResult.result, textareaRef);
const elementType = detectors.getElementType(element);
const useAgent = elementType === 'RICH_TEXT';
console.log(`[browser-plus] Element type detected: ${elementType}, using ${useAgent ? 'agent-browser' : 'native browser'}`);
// Step 5: Type the tweet
console.log('[browser-plus] Typing tweet...');
let typeResult;
if (useAgent) {
typeResult = await agentBrowser.type({
ref: textareaRef,
text: text,
targetId
});
} else {
typeResult = await nativeBrowser.type({
ref: textareaRef,
text: text,
targetId
});
}
results.steps.push({ step: 'type', ...typeResult });
if (!typeResult.success) {
throw new Error('Failed to type tweet text');
}
// Step 6: Upload media if provided
if (media.length > 0) {
console.log(`[browser-plus] Uploading ${media.length} media file(s)...`);
for (const mediaPath of media) {
const uploadResult = await uploadMedia(mediaPath, targetId);
results.steps.push({ step: 'upload', path: mediaPath, ...uploadResult });
await sleep(1000);
}
}
// Step 7: Submit tweet (unless dry run)
if (!dryRun) {
console.log('[browser-plus] Submitting tweet...');
const submitResult = await submitTweet(snapshotResult.result, targetId);
results.steps.push({ step: 'submit', ...submitResult });
if (submitResult.success) {
results.success = true;
results.tweetUrl = submitResult.tweetUrl;
console.log('[browser-plus] Tweet posted successfully!');
} else {
throw new Error('Failed to submit tweet');
}
} else {
console.log('[browser-plus] Dry run - tweet not submitted');
results.success = true;
results.dryRun = true;
}
} catch (error) {
results.error = error.message;
console.error('[browser-plus] Error posting tweet:', error.message);
}
return results;
}
/**
* Reply to a tweet
* @param {Object} options
* @param {string} options.tweetUrl - URL of tweet to reply to
* @param {string} options.text - Reply text
* @param {boolean} [options.dryRun=false] - Don't actually submit
* @returns {Promise<Object>} Result object
*/
async function reply(options) {
const { tweetUrl, text, dryRun = false } = options;
if (!tweetUrl) {
throw new Error('tweetUrl is required');
}
validateText(text);
// Navigate to tweet and find reply button
const navResult = await nativeBrowser.navigate({ url: tweetUrl });
if (!navResult.success) {
return { success: false, error: 'Failed to navigate to tweet' };
}
await sleep(2000);
// Get snapshot and find reply button
const snapshotResult = await nativeBrowser.snapshot({ refs: 'aria' });
if (!snapshotResult.success) {
return { success: false, error: 'Failed to get page snapshot' };
}
// Find reply button
const replyButton = snapshotResult.result.elements?.find(el =>
el.attributes?.['data-testid'] === 'reply' ||
el.role === 'button' && el.name?.toLowerCase().includes('reply')
);
if (!replyButton) {
return { success: false, error: 'Could not find reply button' };
}
// Click reply button
await nativeBrowser.click({ ref: replyButton.ref });
await sleep(1000);
// Now post as normal (reply textarea should be focused/active)
return post({ text, dryRun });
}
/**
* Post a tweet with media attachments
* @param {Object} options
* @param {string} options.text - Tweet text
* @param {string[]} options.media - Media file paths (required)
* @param {boolean} [options.dryRun=false] - Don't actually submit
* @returns {Promise<Object>} Result object
*/
async function postWithMedia(options) {
const { text, media, dryRun = false } = options;
if (!media || media.length === 0) {
throw new Error('media array is required for postWithMedia');
}
return post({ text, media, dryRun });
}
/**
* Find tweet textarea in snapshot
* @private
*/
function findTweetTextarea(snapshot) {
// Try multiple selectors for Twitter's ever-changing UI
const possibleSelectors = [
{ attr: 'data-testid', value: 'tweetTextarea_0' },
{ attr: 'data-testid', value: 'tweetTextarea_0RichInput' },
{ attr: 'contenteditable', value: 'true' },
{ attr: 'aria-label', value: 'Post text' },
{ attr: 'aria-label', value: 'Tweet text' }
];
// First try specific selectors
for (const selector of possibleSelectors) {
const element = snapshot.elements?.find(el =>
el.attributes?.[selector.attr] === selector.value
);
if (element) return `@${element.ref}`;
}
// Fallback: look for any contenteditable textbox
const editable = snapshot.elements?.find(el =>
el.role === 'textbox' &&
el.attributes?.contenteditable === 'true'
);
return editable ? `@${editable.ref}` : null;
}
/**
* Upload media to tweet
* @private
*/
async function uploadMedia(mediaPath, targetId) {
try {
// Find media input
const snapshotResult = await nativeBrowser.snapshot({ targetId, refs: 'aria' });
if (!snapshotResult.success) {
return { success: false, error: 'Failed to get snapshot for media upload' };
}
// Look for file input
const fileInput = snapshotResult.result.elements?.find(el =>
el.tagName?.toLowerCase() === 'input' &&
el.attributes?.type === 'file'
);
if (!fileInput) {
// Try clicking the media button first
const mediaButton = snapshotResult.result.elements?.find(el =>
el.attributes?.['data-testid']?.includes('fileInput') ||
el.attributes?.['aria-label']?.toLowerCase().includes('media') ||
el.attributes?.['aria-label']?.toLowerCase().includes('photo')
);
if (mediaButton) {
await nativeBrowser.click({ ref: `@${mediaButton.ref}`, targetId });
await sleep(500);
}
}
// Use browser upload action via CLI
const result = execSync(
`openclaw browser act --request '${JSON.stringify({
kind: 'upload',
paths: [mediaPath]
})}'`,
{ encoding: 'utf8', timeout: 30000 }
);
return { success: true, result: JSON.parse(result) };
} catch (error) {
return { success: false, error: error.message };
}
}
/**
* Submit the tweet
* @private
*/
async function submitTweet(snapshot, targetId) {
try {
// Find submit button
const submitButton = snapshot.elements?.find(el =>
el.attributes?.['data-testid'] === 'tweetButton' ||
el.attributes?.['data-testid'] === 'tweetButtonInline' ||
(el.role === 'button' && (
el.name?.toLowerCase().includes('post') ||
el.name?.toLowerCase().includes('tweet')
))
);
if (!submitButton) {
return { success: false, error: 'Could not find submit button' };
}
const clickResult = await nativeBrowser.click({
ref: `@${submitButton.ref}`,
targetId
});
if (clickResult.success) {
// Wait for tweet to post and extract URL
await sleep(2000);
return {
success: true,
tweetUrl: 'https://twitter.com' // Would extract actual URL in production
};
}
return clickResult;
} catch (error) {
return { success: false, error: error.message };
}
}
/**
* Sleep helper
* @private
*/
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Export API
module.exports = {
post,
reply,
postWithMedia,
validateText
};
#!/usr/bin/env node
/**
* Element Detectors - Identify element types for smart routing
*
* These functions analyze DOM elements to determine if they are:
* - Rich text editors (need agent-browser with real key events)
* - Plain inputs (can use native browser fill for speed)
*/
/**
* Check if an element is a rich text editor
* Rich text editors require real keyboard events to function properly
*
* @param {Object} element - Element data from snapshot
* @param {string} element.tagName - HTML tag name
* @param {Object} element.attributes - Element attributes
* @returns {boolean} True if element is a rich text editor
*/
function isRichTextEditor(element) {
if (!element || !element.attributes) {
return false;
}
const attrs = element.attributes;
const tagName = (element.tagName || '').toLowerCase();
// Contenteditable elements are rich text editors
if (attrs.contenteditable === 'true' || attrs.contenteditable === '') {
return true;
}
// Twitter composer textarea
if (attrs['data-testid'] && attrs['data-testid'].startsWith('tweetTextarea')) {
return true;
}
// Draft.js editor
if (attrs.class && attrs.class.includes('DraftEditor-root')) {
return true;
}
// Quill editor
if (attrs.class && attrs.class.includes('ql-editor')) {
return true;
}
// TinyMCE
if (attrs.class && attrs.class.includes('mce-content-body')) {
return true;
}
// CKEditor
if (attrs.class && attrs.class.includes('ck-content')) {
return true;
}
// ProseMirror
if (attrs.class && attrs.class.includes('ProseMirror')) {
return true;
}
// Tiptap
if (attrs.class && attrs.class.includes('tiptap')) {
return true;
}
// Generic rich text class patterns
const richTextClasses = [
'rich-text',
'richtext',
'wysiwyg',
'editor-content'
];
if (attrs.class) {
const classLower = attrs.class.toLowerCase();
for (const pattern of richTextClasses) {
if (classLower.includes(pattern)) {
return true;
}
}
}
// Role-based detection
if (attrs.role === 'textbox' && attrs['aria-multiline'] === 'true') {
// Multi-line textbox might be rich text
if (tagName === 'div') {
return true;
}
}
return false;
}
/**
* Check if element is specifically a Twitter/X composer
* @param {Object} element - Element data
* @returns {boolean} True if Twitter composer
*/
function isTwitterComposer(element) {
if (!element || !element.attributes) {
return false;
}
const testId = element.attributes['data-testid'] || '';
return testId.startsWith('tweetTextarea');
}
/**
* Check if element is a plain input field
* @param {Object} element - Element data
* @returns {boolean} True if plain input
*/
function isPlainInput(element) {
if (!element) return false;
const tagName = (element.tagName || '').toLowerCase();
const attrs = element.attributes || {};
// Standard input types that work well with native fill
if (tagName === 'input') {
const inputType = (attrs.type || 'text').toLowerCase();
const supportedTypes = ['text', 'email', 'password', 'search', 'tel', 'url', 'number'];
return supportedTypes.includes(inputType);
}
return false;
}
/**
* Check if element is a textarea
* @param {Object} element - Element data
* @returns {boolean} True if textarea
*/
function isTextarea(element) {
if (!element) return false;
return (element.tagName || '').toLowerCase() === 'textarea';
}
/**
* Get the element type category
* @param {Object} element - Element data
* @returns {string} One of: RICH_TEXT, PLAIN_INPUT, TEXTAREA, UNKNOWN
*/
function getElementType(element) {
if (isRichTextEditor(element)) {
return 'RICH_TEXT';
}
if (isPlainInput(element)) {
return 'PLAIN_INPUT';
}
if (isTextarea(element)) {
return 'TEXTAREA';
}
return 'UNKNOWN';
}
/**
* Determine which adapter to use based on element type
* @param {Object} element - Element data
* @returns {string} 'agent-browser' or 'native-browser'
*/
function getPreferredAdapter(element) {
const type = getElementType(element);
// Rich text editors need agent-browser for real key events
if (type === 'RICH_TEXT') {
return 'agent-browser';
}
// Everything else can use native browser for speed
return 'native-browser';
}
/**
* Analyze snapshot data to find element by reference
* @param {Object} snapshot - Browser snapshot result
* @param {string} ref - Element reference (e.g., '@e12')
* @returns {Object|null} Element data or null
*/
function findElementByRef(snapshot, ref) {
if (!snapshot || !snapshot.elements) {
return null;
}
// Remove @ prefix if present
const targetRef = ref.startsWith('@') ? ref.slice(1) : ref;
for (const element of snapshot.elements) {
if (element.ref === targetRef) {
return element;
}
}
return null;
}
module.exports = {
isRichTextEditor,
isTwitterComposer,
isPlainInput,
isTextarea,
getElementType,
getPreferredAdapter,
findElementByRef
};
#!/usr/bin/env node
/**
* Browser Plus - Test Suite
*
* Run with: npm test or node test/test.js
*/
const assert = require('assert');
const path = require('path');
// Import modules to test
const detectors = require('../scripts/detectors');
const nativeBrowser = require('../scripts/adapters/native-browser');
const agentBrowser = require('../scripts/adapters/agent-browser');
const tweet = require('../scripts/composite/tweet');
const browserPlus = require('../index');
// Test utilities
let testsRun = 0;
let testsPassed = 0;
let testsFailed = 0;
function test(name, fn) {
testsRun++;
try {
fn();
console.log(`✓ ${name}`);
testsPassed++;
} catch (error) {
console.error(`✗ ${name}`);
console.error(` Error: ${error.message}`);
testsFailed++;
}
}
async function asyncTest(name, fn) {
testsRun++;
try {
await fn();
console.log(`✓ ${name}`);
testsPassed++;
} catch (error) {
console.error(`✗ ${name}`);
console.error(` Error: ${error.message}`);
testsFailed++;
}
}
console.log('\n🧪 Browser Plus Test Suite\n');
console.log('===========================\n');
// ==================== DETECTOR TESTS ====================
console.log('\n📋 Detector Tests:\n');
test('isRichTextEditor detects contenteditable', () => {
const element = {
tagName: 'div',
attributes: { contenteditable: 'true' }
};
assert.strictEqual(detectors.isRichTextEditor(element), true);
});
test('isRichTextEditor detects tweet textarea', () => {
const element = {
tagName: 'div',
attributes: { 'data-testid': 'tweetTextarea_0' }
};
assert.strictEqual(detectors.isRichTextEditor(element), true);
});
test('isRichTextEditor detects Draft.js editor', () => {
const element = {
tagName: 'div',
attributes: { class: 'DraftEditor-root' }
};
assert.strictEqual(detectors.isRichTextEditor(element), true);
});
test('isRichTextEditor returns false for plain input', () => {
const element = {
tagName: 'input',
attributes: { type: 'text' }
};
assert.strictEqual(detectors.isRichTextEditor(element), false);
});
test('getElementType returns RICH_TEXT for editors', () => {
const element = {
tagName: 'div',
attributes: { contenteditable: 'true' }
};
assert.strictEqual(detectors.getElementType(element), 'RICH_TEXT');
});
test('getElementType returns PLAIN_INPUT for inputs', () => {
const element = {
tagName: 'input',
attributes: { type: 'email' }
};
assert.strictEqual(detectors.getElementType(element), 'PLAIN_INPUT');
});
test('getElementType returns TEXTAREA for textareas', () => {
const element = {
tagName: 'textarea',
attributes: {}
};
assert.strictEqual(detectors.getElementType(element), 'TEXTAREA');
});
test('isTwitterComposer detects Twitter composer', () => {
const element = {
tagName: 'div',
attributes: { 'data-testid': 'tweetTextarea_0' }
};
assert.strictEqual(detectors.isTwitterComposer(element), true);
});
// ==================== ADAPTER INTERFACE TESTS ====================
console.log('\n🔌 Adapter Interface Tests:\n');
test('nativeBrowser exports required methods', () => {
assert(typeof nativeBrowser.type === 'function', 'type should be a function');
assert(typeof nativeBrowser.click === 'function', 'click should be a function');
assert(typeof nativeBrowser.navigate === 'function', 'navigate should be a function');
assert(typeof nativeBrowser.snapshot === 'function', 'snapshot should be a function');
});
test('agentBrowser exports required methods', () => {
assert(typeof agentBrowser.type === 'function', 'type should be a function');
assert(typeof agentBrowser.keypress === 'function', 'keypress should be a function');
assert(typeof agentBrowser.click === 'function', 'click should be a function');
assert(typeof agentBrowser.navigate === 'function', 'navigate should be a function');
assert(typeof agentBrowser.isAvailable === 'function', 'isAvailable should be a function');
});
// ==================== BROWSER PLUS API TESTS ====================
console.log('\n🚀 Browser Plus API Tests:\n');
test('browserPlus exports required methods', () => {
assert(typeof browserPlus.type === 'function', 'type should be a function');
assert(typeof browserPlus.click === 'function', 'click should be a function');
assert(typeof browserPlus.navigate === 'function', 'navigate should be a function');
assert(typeof browserPlus.snapshot === 'function', 'snapshot should be a function');
assert(typeof browserPlus.tweet === 'function', 'tweet should be a function');
assert(typeof browserPlus.detectAndRoute === 'function', 'detectAndRoute should be a function');
});
test('browserPlus.config has default values', () => {
assert(browserPlus.config.preferNative === true, 'preferNative should default to true');
assert(browserPlus.config.fallbackToAgent === true, 'fallbackToAgent should default to true');
assert(browserPlus.config.agentBrowserTimeout === 60000, 'agentBrowserTimeout should default to 60000');
});
// ==================== TWEET COMPOSITE TESTS ====================
console.log('\n🐦 Tweet Composite Tests:\n');
test('tweet module exports required methods', () => {
assert(typeof tweet.post === 'function', 'post should be a function');
assert(typeof tweet.postWithMedia === 'function', 'postWithMedia should be a function');
assert(typeof tweet.reply === 'function', 'reply should be a function');
});
test('tweet.validateText validates text length', () => {
// Should not throw for valid text
tweet.validateText('Hello world');
// Should throw for empty text
assert.throws(() => tweet.validateText(''), /Tweet text cannot be empty/);
// Should throw for text too long
const longText = 'a'.repeat(300);
assert.throws(() => tweet.validateText(longText), /exceeds maximum length/);
});
// ==================== ERROR HANDLING TESTS ====================
console.log('\n⚠️ Error Handling Tests:\n');
asyncTest('type throws on missing ref', async () => {
try {
await browserPlus.type({ text: 'hello' });
assert.fail('Should have thrown');
} catch (error) {
assert(error.message.includes('ref is required'));
}
});
asyncTest('type throws on missing text', async () => {
try {
await browserPlus.type({ ref: '@e12' });
assert.fail('Should have thrown');
} catch (error) {
assert(error.message.includes('text is required'));
}
});
asyncTest('tweet.post throws on missing text', async () => {
try {
await tweet.post({});
assert.fail('Should have thrown');
} catch (error) {
assert(error.message.includes('Tweet text is required'));
}
});
// ==================== SUMMARY ====================
console.log('\n===========================\n');
console.log(`📊 Test Results:`);
console.log(` Total: ${testsRun}`);
console.log(` Passed: ${testsPassed} ✅`);
console.log(` Failed: ${testsFailed} ❌`);
console.log('');
if (testsFailed > 0) {
console.log('❌ Some tests failed\n');
process.exit(1);
} else {
console.log('✅ All tests passed!\n');
process.exit(0);
}