mirror of
https://github.com/aaronjmars/opendia.git
synced 2026-10-19 02:09:10 +00:00
optimize: codebase cleanup across 8 dimensions + tools/list_changed (#63)
One commit per cleanup domain, plus notifications/tools/list_changed. Fixes the CI structure tests, which computed every check then discarded it and could not fail. Aligns get_page_links with the params its implementation reads, five formatter fields that rendered undefined, and stale-socket handling in the extension's reconnect loop.
This commit is contained in:
commit
f9d1b06ec0
14 changed files with 353 additions and 428 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -95,6 +95,9 @@ out
|
|||
.nuxt
|
||||
dist
|
||||
|
||||
# Packaged extension builds (published via GitHub Releases)
|
||||
opendia-extension/releases/
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
|
|
|||
14
build-dxt.sh
14
build-dxt.sh
|
|
@ -74,9 +74,17 @@ cd dist/opendia-dxt
|
|||
npm install --production --silent
|
||||
cd ../..
|
||||
|
||||
# Copy browser extension
|
||||
# Build and copy browser extension. The install instructions below point at
|
||||
# extension/dist/{chrome,firefox}, so those have to exist in the bundle.
|
||||
echo "🌐 Building browser extension..."
|
||||
cd opendia-extension
|
||||
npm install --silent
|
||||
npm run build --silent
|
||||
cd ..
|
||||
|
||||
echo "🌐 Copying browser extension..."
|
||||
cp -r opendia-extension dist/opendia-dxt/extension
|
||||
rm -rf dist/opendia-dxt/extension/node_modules
|
||||
|
||||
# Copy logo/icon files for DXT - try multiple sources
|
||||
echo "🎨 Copying logo files..."
|
||||
|
|
@ -290,7 +298,7 @@ Download the latest extension from: https://github.com/aeonfun/opendia/releases
|
|||
|
||||
2. **Install Extension**
|
||||
- Click "Load unpacked"
|
||||
- Select the `extension/` folder from this DXT package
|
||||
- Select the `extension/dist/chrome/` folder from this DXT package
|
||||
- Extension should appear in your extensions list with OpenDia icon
|
||||
|
||||
#### For Firefox
|
||||
|
|
@ -298,7 +306,7 @@ Download the latest extension from: https://github.com/aeonfun/opendia/releases
|
|||
1. **Load Temporary Add-on**
|
||||
- Go to `about:debugging#/runtime/this-firefox`
|
||||
- Click "Load Temporary Add-on..."
|
||||
- Select the `manifest-firefox.json` file from the `extension/` folder
|
||||
- Select the `manifest.json` file from the `extension/dist/firefox/` folder
|
||||
|
||||
> **Firefox Note**: Extensions are loaded as temporary add-ons and will be removed when Firefox restarts. For permanent installation, use the signed extension from GitHub releases.
|
||||
|
||||
|
|
|
|||
|
|
@ -31,15 +31,6 @@ async function buildForBrowser(browser) {
|
|||
path.join(buildDir, 'src/polyfill/browser-polyfill.min.js')
|
||||
);
|
||||
|
||||
// Browser-specific post-processing
|
||||
if (browser === 'chrome') {
|
||||
console.log('📦 Chrome MV3: Service worker mode enabled');
|
||||
// No additional processing needed for Chrome
|
||||
} else if (browser === 'firefox') {
|
||||
console.log('🦊 Firefox MV2: Background page mode enabled');
|
||||
// No additional processing needed for Firefox
|
||||
}
|
||||
|
||||
console.log(`✅ ${browser} extension built successfully in ${buildDir}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,8 +14,6 @@
|
|||
"activeTab",
|
||||
"storage",
|
||||
"scripting",
|
||||
"webNavigation",
|
||||
"notifications",
|
||||
"bookmarks",
|
||||
"history"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -19,12 +19,8 @@
|
|||
"tabs",
|
||||
"activeTab",
|
||||
"storage",
|
||||
"webNavigation",
|
||||
"notifications",
|
||||
"bookmarks",
|
||||
"history",
|
||||
"webRequest",
|
||||
"webRequestBlocking",
|
||||
"<all_urls>"
|
||||
],
|
||||
"background": {
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
{
|
||||
"manifest_version": 3,
|
||||
"name": "OpenDia",
|
||||
"version": "1.1.0",
|
||||
"description": "Connect your browser to AI models",
|
||||
"icons": {
|
||||
"16": "icon-16.png",
|
||||
"32": "icon-32.png",
|
||||
"48": "icon-48.png",
|
||||
"128": "icon-128.png"
|
||||
},
|
||||
"permissions": [
|
||||
"tabs",
|
||||
"activeTab",
|
||||
"storage",
|
||||
"scripting",
|
||||
"webNavigation",
|
||||
"notifications",
|
||||
"bookmarks",
|
||||
"history"
|
||||
],
|
||||
"host_permissions": [
|
||||
"<all_urls>"
|
||||
],
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_title": "OpenDia"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"externally_connectable": {
|
||||
"ids": ["*"],
|
||||
"matches": ["http://localhost/*"]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
|
|
@ -33,7 +33,7 @@ browser.storage.local.get(['safetyMode'], (result) => {
|
|||
class ConnectionManager {
|
||||
constructor() {
|
||||
this.mcpSocket = null;
|
||||
this.reconnectInterval = null;
|
||||
this.reconnectTimer = null;
|
||||
this.reconnectAttempts = 0;
|
||||
this.heartbeatInterval = null;
|
||||
this.isServiceWorker = browserInfo.isServiceWorker;
|
||||
|
|
@ -69,22 +69,11 @@ class ConnectionManager {
|
|||
});
|
||||
}
|
||||
|
||||
// Reuse a live socket: reconnecting per operation replaced the socket the
|
||||
// request arrived on, so the reply was written to a CONNECTING socket.
|
||||
async connect() {
|
||||
if (this.isServiceWorker) {
|
||||
// Reuse a live socket: reconnecting per operation replaced the socket the
|
||||
// request arrived on, so the reply was written to a CONNECTING socket.
|
||||
if (!this.mcpSocket || this.mcpSocket.readyState !== WebSocket.OPEN) {
|
||||
console.log('🔧 Chrome MV3: Creating temporary connection');
|
||||
await this.createConnection();
|
||||
}
|
||||
} else {
|
||||
// Firefox MV2: Maintain persistent connection
|
||||
if (!this.mcpSocket || this.mcpSocket.readyState !== WebSocket.OPEN) {
|
||||
console.log('🦊 Firefox MV2: Creating persistent connection');
|
||||
await this.createConnection();
|
||||
} else {
|
||||
console.log('🦊 Firefox MV2: Using existing connection');
|
||||
}
|
||||
if (!this.mcpSocket || this.mcpSocket.readyState !== WebSocket.OPEN) {
|
||||
await this.createConnection();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -93,49 +82,55 @@ class ConnectionManager {
|
|||
// Try port discovery if using default URL or if connection failed
|
||||
if (MCP_SERVER_URL === 'ws://localhost:5555' || this.reconnectAttempts > 2) {
|
||||
await this.discoverServerPorts();
|
||||
// No reset here: discovery finding a port is not evidence the
|
||||
// connection succeeded. Resetting made the counter oscillate 0->3->0,
|
||||
// so backoff never grew and the give-up guard was unreachable. The
|
||||
// real reset lives in onopen.
|
||||
// Deliberately no reset: finding a port is not evidence the connection
|
||||
// opened. The reset lives in onopen so backoff can actually grow.
|
||||
}
|
||||
|
||||
console.log('🔗 Connecting to MCP server at', MCP_SERVER_URL);
|
||||
this.mcpSocket = new WebSocket(MCP_SERVER_URL);
|
||||
|
||||
this.mcpSocket.onopen = () => {
|
||||
// Handlers are bound to this specific socket. The server closes the old
|
||||
// socket when the extension reconnects, so a stale close event can arrive
|
||||
// after a healthy one is live; without the identity check it would clear
|
||||
// the new connection's heartbeat and arm a reconnect that never clears.
|
||||
// Mirrors the `chromeExtensionSocket === ws` guard on the server side.
|
||||
const socket = new WebSocket(MCP_SERVER_URL);
|
||||
this.mcpSocket = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
if (this.mcpSocket !== socket) return;
|
||||
console.log('✅ Connected to MCP server');
|
||||
this.clearReconnectInterval();
|
||||
this.clearReconnectTimer();
|
||||
this.reconnectAttempts = 0; // Reset attempts on successful connection
|
||||
|
||||
|
||||
const tools = getAvailableTools();
|
||||
console.log(`🔧 Registering ${tools.length} tools:`, tools.map(t => t.name));
|
||||
|
||||
// Register available browser functions
|
||||
this.mcpSocket.send(JSON.stringify({
|
||||
|
||||
socket.send(JSON.stringify({
|
||||
type: 'register',
|
||||
tools: tools
|
||||
}));
|
||||
|
||||
|
||||
// Setup heartbeat for persistent connections
|
||||
if (!this.isServiceWorker) {
|
||||
this.setupHeartbeat();
|
||||
}
|
||||
};
|
||||
|
||||
this.mcpSocket.onmessage = async (event) => {
|
||||
|
||||
socket.onmessage = async (event) => {
|
||||
if (this.mcpSocket !== socket) return;
|
||||
const message = JSON.parse(event.data);
|
||||
await handleMCPRequest(message);
|
||||
};
|
||||
|
||||
this.mcpSocket.onclose = (event) => {
|
||||
|
||||
socket.onclose = (event) => {
|
||||
if (this.mcpSocket !== socket) return;
|
||||
console.log(`❌ Disconnected from MCP server (code: ${event.code}, reason: ${event.reason})`);
|
||||
this.clearHeartbeat(); // Clear heartbeat on disconnect
|
||||
this.reconnectAttempts++;
|
||||
|
||||
|
||||
// Check if this was a normal closure or abnormal
|
||||
if (event.code !== 1000 && event.code !== 1001) {
|
||||
console.log('🔄 Abnormal WebSocket closure, will attempt reconnection');
|
||||
|
||||
|
||||
if (!this.isServiceWorker) {
|
||||
// Firefox: Attempt to reconnect
|
||||
this.scheduleReconnect();
|
||||
|
|
@ -145,13 +140,15 @@ class ConnectionManager {
|
|||
console.log('🔄 Normal WebSocket closure');
|
||||
}
|
||||
};
|
||||
|
||||
this.mcpSocket.onerror = (error) => {
|
||||
|
||||
// No attempt counting here: onclose always follows onerror for a failed
|
||||
// socket, so incrementing in both halved the effective retry budget.
|
||||
socket.onerror = (error) => {
|
||||
if (this.mcpSocket !== socket) return;
|
||||
console.log('⚠️ MCP WebSocket error:', error);
|
||||
this.reconnectAttempts++;
|
||||
};
|
||||
|
||||
await this.waitForSocketOpen(this.mcpSocket);
|
||||
await this.waitForSocketOpen(socket);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Connection failed:', error);
|
||||
|
|
@ -211,40 +208,36 @@ class ConnectionManager {
|
|||
}
|
||||
}
|
||||
|
||||
// One-shot, not an interval: a fixed period fires again while the previous
|
||||
// attempt is still in port discovery (7 sequential un-timed fetches), which
|
||||
// overwrites this.mcpSocket and orphans the pending socket. Failure re-arms
|
||||
// via createConnection's catch and onclose, so nothing is lost.
|
||||
scheduleReconnect() {
|
||||
this.clearReconnectInterval();
|
||||
|
||||
this.clearReconnectTimer();
|
||||
|
||||
if (this.reconnectAttempts >= 10) {
|
||||
console.log('❌ Maximum reconnection attempts reached');
|
||||
return;
|
||||
}
|
||||
|
||||
// Exponential backoff for reconnection attempts
|
||||
const backoffTime = Math.min(5000 * Math.pow(2, this.reconnectAttempts), 30000);
|
||||
console.log(`🔄 Scheduling reconnection in ${backoffTime}ms (attempt ${this.reconnectAttempts})`);
|
||||
|
||||
this.reconnectInterval = setInterval(() => {
|
||||
if (this.reconnectAttempts < 10) {
|
||||
this.connect().catch(() => {});
|
||||
} else {
|
||||
console.log('❌ Maximum reconnection attempts reached');
|
||||
this.clearReconnectInterval();
|
||||
}
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.connect().catch(() => {});
|
||||
}, backoffTime);
|
||||
}
|
||||
|
||||
clearReconnectInterval() {
|
||||
if (this.reconnectInterval) {
|
||||
clearInterval(this.reconnectInterval);
|
||||
this.reconnectInterval = null;
|
||||
clearReconnectTimer() {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async ensureConnection() {
|
||||
if (this.isServiceWorker) {
|
||||
// Chrome: Always create fresh connection
|
||||
await this.connect();
|
||||
} else {
|
||||
// Firefox: Use existing or create new
|
||||
if (!this.mcpSocket || this.mcpSocket.readyState !== WebSocket.OPEN) {
|
||||
await this.connect();
|
||||
}
|
||||
}
|
||||
await this.connect();
|
||||
return this.mcpSocket;
|
||||
}
|
||||
|
||||
|
|
@ -270,25 +263,30 @@ class ConnectionManager {
|
|||
const connectionManager = new ConnectionManager();
|
||||
|
||||
// Content script management for background tabs
|
||||
// Ping the content script in a tab. Rejects on timeout or extension error.
|
||||
function pingContentScript(tabId, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
reject(new Error(`Content script ping timeout after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
browser.tabs.sendMessage(tabId, { action: 'ping' }, (response) => {
|
||||
clearTimeout(timer);
|
||||
if (browser.runtime.lastError) {
|
||||
reject(new Error(browser.runtime.lastError.message));
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureContentScriptReady(tabId, retries = 3) {
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
try {
|
||||
// Test if content script is responsive
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('Content script ping timeout'));
|
||||
}, 2000);
|
||||
|
||||
browser.tabs.sendMessage(tabId, { action: 'ping' }, (response) => {
|
||||
clearTimeout(timeout);
|
||||
if (browser.runtime.lastError) {
|
||||
reject(new Error(browser.runtime.lastError.message));
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const response = await pingContentScript(tabId, 2000);
|
||||
|
||||
if (response && response.success) {
|
||||
console.log(`✅ Content script ready in tab ${tabId}`);
|
||||
return true;
|
||||
|
|
@ -339,18 +337,8 @@ async function ensureContentScriptReady(tabId, retries = 3) {
|
|||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Test again
|
||||
const testResponse = await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Timeout after injection')), 3000);
|
||||
browser.tabs.sendMessage(tabId, { action: 'ping' }, (response) => {
|
||||
clearTimeout(timeout);
|
||||
if (browser.runtime.lastError) {
|
||||
reject(new Error(browser.runtime.lastError.message));
|
||||
} else {
|
||||
resolve(response);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const testResponse = await pingContentScript(tabId, 3000);
|
||||
|
||||
if (testResponse && testResponse.success) {
|
||||
console.log(`✅ Content script successfully injected into tab ${tabId}`);
|
||||
return true;
|
||||
|
|
@ -402,14 +390,9 @@ async function getTabContentScriptStatus(tabId) {
|
|||
return { ready: false, reason: 'restricted_url', url: tab.url };
|
||||
}
|
||||
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => resolve(null), 3000); // Increase to 3 seconds
|
||||
browser.tabs.sendMessage(tabId, { action: 'ping' }, (response) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
|
||||
// A failed ping is the answer here, not an error: report the tab as not loaded.
|
||||
const response = await pingContentScript(tabId, 3000).catch(() => null);
|
||||
|
||||
if (response && response.success) {
|
||||
return { ready: true, reason: 'active', url: tab.url };
|
||||
} else {
|
||||
|
|
@ -945,20 +928,23 @@ function getAvailableTools() {
|
|||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
link_type: {
|
||||
type: "string",
|
||||
enum: ["all", "internal", "external"],
|
||||
default: "all",
|
||||
description: "Filter by internal/external links"
|
||||
include_internal: {
|
||||
type: "boolean",
|
||||
default: true,
|
||||
description: "Include links pointing at the current domain"
|
||||
},
|
||||
domains: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Filter by specific domains (optional)"
|
||||
include_external: {
|
||||
type: "boolean",
|
||||
default: true,
|
||||
description: "Include links pointing off the current domain"
|
||||
},
|
||||
domain_filter: {
|
||||
type: "string",
|
||||
description: "Only return links whose domain contains this string (optional)"
|
||||
},
|
||||
max_results: {
|
||||
type: "number",
|
||||
default: 50,
|
||||
default: 100,
|
||||
maximum: 200,
|
||||
description: "Maximum links to return"
|
||||
}
|
||||
|
|
@ -1137,30 +1123,34 @@ async function handleMCPRequest(message) {
|
|||
}
|
||||
}
|
||||
|
||||
// Enhanced content script communication with background tab support
|
||||
async function sendToContentScript(action, data, targetTabId = null) {
|
||||
let targetTab;
|
||||
|
||||
if (targetTabId) {
|
||||
// Use specific tab
|
||||
// Content script communication, targeting a specific tab or the active one
|
||||
// Resolve the tab a tool should act on: an explicit tab id, else the active tab.
|
||||
async function resolveTargetTab(tabId = null) {
|
||||
if (tabId) {
|
||||
try {
|
||||
targetTab = await browser.tabs.get(targetTabId);
|
||||
return await browser.tabs.get(tabId);
|
||||
} catch (error) {
|
||||
throw new Error(`Tab ${targetTabId} not found or inaccessible`);
|
||||
// tabs.get also rejects for reasons other than "not found" (bad id type,
|
||||
// incognito access denied), so keep the underlying message.
|
||||
throw new Error(`Tab ${tabId} not found or inaccessible: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
// Fallback to active tab (maintains compatibility)
|
||||
const [activeTab] = await browser.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
});
|
||||
|
||||
if (!activeTab) {
|
||||
throw new Error('No active tab found');
|
||||
}
|
||||
targetTab = activeTab;
|
||||
}
|
||||
|
||||
|
||||
// tabs.query legitimately returns [] when there is no focused normal window.
|
||||
const [activeTab] = await browser.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
});
|
||||
|
||||
if (!activeTab) {
|
||||
throw new Error('No active tab found');
|
||||
}
|
||||
return activeTab;
|
||||
}
|
||||
|
||||
async function sendToContentScript(action, data, targetTabId = null) {
|
||||
const targetTab = await resolveTargetTab(targetTabId);
|
||||
|
||||
// Ensure content script is available in the target tab
|
||||
await ensureContentScriptReady(targetTab.id);
|
||||
|
||||
|
|
@ -1178,16 +1168,7 @@ async function sendToContentScript(action, data, targetTabId = null) {
|
|||
}
|
||||
|
||||
async function navigateToUrl(url, waitFor, timeout = 10000) {
|
||||
const [activeTab] = await browser.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
});
|
||||
|
||||
// tabs.query legitimately returns [] (no focused normal window), which used
|
||||
// to crash here on activeTab.id. Other callers already guard this.
|
||||
if (!activeTab) {
|
||||
throw new Error("No active tab found");
|
||||
}
|
||||
const activeTab = await resolveTargetTab();
|
||||
|
||||
await browser.tabs.update(activeTab.id, { url });
|
||||
|
||||
|
|
@ -1231,7 +1212,7 @@ async function waitForElement(tabId, selector, timeout = 5000) {
|
|||
throw new Error(`Timeout waiting for element: ${selector}`);
|
||||
}
|
||||
|
||||
// Enhanced Tab Management Functions with Batch Support
|
||||
// Tab management, single and batched
|
||||
async function createTab(params) {
|
||||
const {
|
||||
url,
|
||||
|
|
@ -1267,7 +1248,7 @@ async function createTab(params) {
|
|||
const urlArray = Array(count).fill(url);
|
||||
return await createTabsBatch(urlArray, active, wait_for, timeout, batch_settings);
|
||||
} else {
|
||||
// Single tab creation (legacy behavior)
|
||||
// Single tab creation
|
||||
console.log(`📱 Using single tab mode for: ${url || 'about:blank'}`);
|
||||
return await createSingleTab(url, active, wait_for, timeout);
|
||||
}
|
||||
|
|
@ -1309,15 +1290,17 @@ function validateTabCreateParams(params) {
|
|||
}
|
||||
}
|
||||
|
||||
// Validate count
|
||||
if (count < 1 || count > 50) {
|
||||
return { valid: false, error: "Count must be between 1 and 50" };
|
||||
// Validate count. Both comparisons coerce, so a string or fractional count
|
||||
// passed the range check and then broke Array(count): "5" yielded a single tab
|
||||
// reported as a full success, 2.5 threw an opaque "Invalid array length".
|
||||
if (!Number.isInteger(count) || count < 1 || count > 50) {
|
||||
return { valid: false, error: "Count must be an integer between 1 and 50" };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// Single tab creation (original behavior)
|
||||
// Single tab creation
|
||||
async function createSingleTab(url, active, wait_for, timeout) {
|
||||
const createProperties = { active };
|
||||
if (url) {
|
||||
|
|
@ -1393,11 +1376,18 @@ async function createTabsBatch(urls, active, wait_for, timeout, batch_settings =
|
|||
console.log('🔍 createTabsBatch called with:', { urls: urls.length, batch_settings });
|
||||
|
||||
const {
|
||||
chunk_size = 5,
|
||||
chunk_size: requested_chunk_size = 5,
|
||||
delay_between_chunks = 1000,
|
||||
delay_between_tabs = 200
|
||||
} = batch_settings || {};
|
||||
|
||||
|
||||
// A destructuring default only covers undefined, so an explicit 0 or a negative
|
||||
// survived and the chunk loop never advanced - an unbounded spin in the worker
|
||||
// while the server call timed out at 30s.
|
||||
const chunk_size = Number.isInteger(requested_chunk_size) && requested_chunk_size > 0
|
||||
? requested_chunk_size
|
||||
: 5;
|
||||
|
||||
const startTime = Date.now();
|
||||
const totalTabs = urls.length;
|
||||
const createdTabs = [];
|
||||
|
|
@ -1497,6 +1487,7 @@ async function createTabsBatch(urls, active, wait_for, timeout, batch_settings =
|
|||
total_requested: totalTabs,
|
||||
successful: successCount,
|
||||
failed: errorCount,
|
||||
chunks_processed: Math.ceil(totalTabs / chunk_size),
|
||||
execution_time_ms: executionTime
|
||||
},
|
||||
// Only include full tab details for small batches
|
||||
|
|
@ -1852,27 +1843,7 @@ async function getSelectedText(params) {
|
|||
} = params;
|
||||
|
||||
try {
|
||||
let targetTab;
|
||||
|
||||
if (tab_id) {
|
||||
// Use specific tab
|
||||
try {
|
||||
targetTab = await browser.tabs.get(tab_id);
|
||||
} catch (error) {
|
||||
throw new Error(`Tab ${tab_id} not found or inaccessible`);
|
||||
}
|
||||
} else {
|
||||
// Get the active tab
|
||||
const [activeTab] = await browser.tabs.query({
|
||||
active: true,
|
||||
currentWindow: true,
|
||||
});
|
||||
|
||||
if (!activeTab) {
|
||||
throw new Error("No active tab found");
|
||||
}
|
||||
targetTab = activeTab;
|
||||
}
|
||||
const targetTab = await resolveTargetTab(tab_id);
|
||||
|
||||
// Execute script to get selected text - handle browser differences
|
||||
let results;
|
||||
|
|
@ -1944,8 +1915,8 @@ async function getSelectedText(params) {
|
|||
return response;
|
||||
|
||||
} catch (error) {
|
||||
// These used to return has_selection: false, which formatSelectedTextResult
|
||||
// renders as "No text selected" — a failure was reported as an empty page.
|
||||
// Throw rather than returning has_selection:false, which the formatter
|
||||
// renders as "No text selected" - a failure indistinguishable from success.
|
||||
throw new Error(`Failed to get selected text: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -2027,8 +1998,8 @@ browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
|
|||
tools: tools.map(t => t.name)
|
||||
});
|
||||
} else if (request.action === "reconnect") {
|
||||
// Report the real outcome: this used to answer success before the socket
|
||||
// had opened, so the popup's reconnect button always looked like it worked.
|
||||
// Answer after the socket opens, not before, so the popup reports the
|
||||
// real outcome rather than always looking successful.
|
||||
connectionManager.connect()
|
||||
.then(() => sendResponse({ success: true }))
|
||||
.catch((error) => sendResponse({ success: false, error: error.message }));
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
// Enhanced Browser Automation Content Script with Anti-Detection
|
||||
// Browser automation content script. Fills React-controlled editors via
|
||||
// execCommand, which they accept where programmatic value writes are ignored.
|
||||
// Import WebExtension polyfill for cross-browser compatibility
|
||||
if (typeof browser === 'undefined' && typeof chrome !== 'undefined') {
|
||||
globalThis.browser = chrome;
|
||||
|
|
@ -219,7 +220,7 @@ class BrowserAutomation {
|
|||
|
||||
async executeDirectBypass(element, value, platformConfig, element_id) {
|
||||
try {
|
||||
console.log(`🐦 Executing ${platformConfig.bypassMethod} bypass`);
|
||||
console.log(`🔓 Executing ${platformConfig.bypassMethod} bypass`);
|
||||
|
||||
switch (platformConfig.bypassMethod) {
|
||||
case "twitter_direct":
|
||||
|
|
@ -247,23 +248,20 @@ class BrowserAutomation {
|
|||
}
|
||||
|
||||
async twitterDirectBypass(element, value, element_id) {
|
||||
// THE WORKING FORMULA FOR TWITTER:
|
||||
// 1. Focus 2. Click 3. execCommand
|
||||
console.log("🐦 Twitter direct bypass - focus+click+execCommand");
|
||||
|
||||
// Ensure element is in view
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
// The magic sequence that bypasses Twitter detection
|
||||
// Draft.js ignores programmatic .value writes and synthesized input events.
|
||||
// execCommand("insertText") produces a trusted beforeinput, which the editor's
|
||||
// own handler accepts and mirrors into React state. Focus and click first:
|
||||
// the handler is only attached once the editor considers itself active.
|
||||
element.focus();
|
||||
element.click();
|
||||
const execResult = document.execCommand("insertText", false, value);
|
||||
|
||||
// Wait for React state to update
|
||||
// The editor commits on its next render; reading sooner sees the old value.
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
|
||||
// Verify success
|
||||
const currentText = element.textContent || element.value || "";
|
||||
const success = currentText.includes(value);
|
||||
|
||||
|
|
@ -279,16 +277,13 @@ class BrowserAutomation {
|
|||
}
|
||||
|
||||
async linkedinDirectBypass(element, value, element_id) {
|
||||
console.log("💼 LinkedIn direct bypass");
|
||||
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
// LinkedIn-specific sequence
|
||||
element.focus();
|
||||
element.click();
|
||||
|
||||
// Clear existing content first for LinkedIn
|
||||
// LinkedIn's editor appends rather than replaces, so clear before inserting.
|
||||
if (element.textContent) {
|
||||
document.execCommand("selectAll");
|
||||
document.execCommand("delete");
|
||||
|
|
@ -296,7 +291,7 @@ class BrowserAutomation {
|
|||
|
||||
const execResult = document.execCommand("insertText", false, value);
|
||||
|
||||
// LinkedIn needs more time for state updates
|
||||
// Slower to commit than Twitter's; 500ms reads back a partial value.
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
|
||||
const currentText = element.textContent || element.value || "";
|
||||
|
|
@ -314,16 +309,12 @@ class BrowserAutomation {
|
|||
}
|
||||
|
||||
async facebookDirectBypass(element, value, element_id) {
|
||||
console.log("📘 Facebook direct bypass");
|
||||
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
// Facebook-specific sequence
|
||||
element.focus();
|
||||
element.click();
|
||||
|
||||
// Facebook may need selection clearing
|
||||
if (element.textContent) {
|
||||
document.execCommand("selectAll");
|
||||
document.execCommand("delete");
|
||||
|
|
@ -331,7 +322,8 @@ class BrowserAutomation {
|
|||
|
||||
const execResult = document.execCommand("insertText", false, value);
|
||||
|
||||
// Trigger Facebook-specific events
|
||||
// Facebook's composer also listens for these directly, unlike Twitter's,
|
||||
// which picks the change up from beforeinput alone.
|
||||
element.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
element.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
|
||||
|
|
@ -351,13 +343,12 @@ class BrowserAutomation {
|
|||
};
|
||||
}
|
||||
|
||||
// Reached only if a platform config names a bypassMethod without its own case.
|
||||
// Kept as the extension point for adding a platform to ANTI_DETECTION_PLATFORMS.
|
||||
async genericDirectBypass(element, value, element_id) {
|
||||
console.log("🔧 Generic direct bypass");
|
||||
|
||||
element.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
// Generic direct sequence
|
||||
element.focus();
|
||||
element.click();
|
||||
const execResult = document.execCommand("insertText", false, value);
|
||||
|
|
@ -390,7 +381,7 @@ class BrowserAutomation {
|
|||
throw new Error(`Element not found: ${element_id}`);
|
||||
}
|
||||
|
||||
// Enhanced focus sequence for modern web apps
|
||||
// Modern editors attach their input handler on focus, not on load.
|
||||
if (force_focus) {
|
||||
await this.ensureProperFocus(element);
|
||||
} else {
|
||||
|
|
@ -567,7 +558,6 @@ class BrowserAutomation {
|
|||
max_results = 10,
|
||||
}) {
|
||||
const startTime = performance.now();
|
||||
const pageType = this.detectPageType();
|
||||
|
||||
// Use default max results limit
|
||||
max_results = Math.min(max_results, 7); // Allow slightly more for detailed analysis
|
||||
|
|
@ -620,7 +610,6 @@ class BrowserAutomation {
|
|||
}
|
||||
|
||||
async extractContent({ content_type, max_items = 20, summarize = true }) {
|
||||
console.log(`🔍 extractContent called with: content_type=${content_type}, max_items=${max_items}, summarize=${summarize}`);
|
||||
const startTime = performance.now();
|
||||
const extractors = {
|
||||
article: () => this.extractArticleContent(summarize),
|
||||
|
|
@ -650,8 +639,7 @@ class BrowserAutomation {
|
|||
extracted_at: new Date().toISOString(),
|
||||
};
|
||||
} else {
|
||||
// Legacy full content extraction
|
||||
console.log(`🎯 Returning FULL content: ${rawContent?.content?.length || 0} characters`);
|
||||
// Full content, returned when summarize is false
|
||||
return {
|
||||
content: rawContent,
|
||||
method: "semantic_extraction",
|
||||
|
|
@ -663,7 +651,6 @@ class BrowserAutomation {
|
|||
}
|
||||
|
||||
extractArticleContent(summarize = true) {
|
||||
console.log(`📄 extractArticleContent called with summarize=${summarize}`);
|
||||
const article = document.querySelector(
|
||||
'article, [role="article"], .article-content, main'
|
||||
);
|
||||
|
|
@ -672,8 +659,6 @@ class BrowserAutomation {
|
|||
?.textContent?.trim();
|
||||
const content = article?.textContent?.trim() || this.extractMainContent();
|
||||
|
||||
console.log(`📏 Extracted content length: ${content?.length || 0} characters`);
|
||||
console.log(`📝 Content preview: ${content?.substring(0, 200)}...`);
|
||||
|
||||
return {
|
||||
title,
|
||||
|
|
@ -1000,7 +985,7 @@ class BrowserAutomation {
|
|||
}
|
||||
|
||||
calculateQualityScore(results) {
|
||||
// An extractor that matched nothing returns [], which used to make every
|
||||
// An extractor that matched nothing returns [], which would make every
|
||||
// term NaN and ship "NaN%" to the model.
|
||||
if (results.length === 0) return 0;
|
||||
|
||||
|
|
@ -1295,6 +1280,7 @@ class BrowserAutomation {
|
|||
if (checkCondition()) {
|
||||
return {
|
||||
condition_met: true,
|
||||
condition_type: condition_type,
|
||||
wait_time: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
|
@ -1626,7 +1612,6 @@ class BrowserAutomation {
|
|||
}
|
||||
|
||||
async fullEnhancedAnalysis(intent_hint, max_results) {
|
||||
// Enhanced version of semantic analysis with better filtering
|
||||
const relevantElements = document.querySelectorAll(`
|
||||
button, input, select, textarea, a[href],
|
||||
[role="button"], [role="textbox"], [role="searchbox"],
|
||||
|
|
@ -2050,12 +2035,10 @@ class BrowserAutomation {
|
|||
};
|
||||
|
||||
const style = moodMap[mood.toLowerCase()] || moodMap['cozy coffee shop'];
|
||||
return this.buildMoodCSS(style, intensity);
|
||||
return this.buildMoodCSS(style);
|
||||
}
|
||||
|
||||
buildMoodCSS(style, intensity) {
|
||||
const opacity = intensity === 'subtle' ? '0.3' : intensity === 'medium' ? '0.6' : '0.9';
|
||||
|
||||
buildMoodCSS(style) {
|
||||
let css = `
|
||||
body {
|
||||
background: ${style.background} !important;
|
||||
|
|
|
|||
|
|
@ -15,8 +15,7 @@ let currentPage = document.getElementById("currentPage");
|
|||
let serverUrl = document.getElementById("serverUrl");
|
||||
|
||||
// Shown when the background script can't be reached. Kept as one list so the
|
||||
// name list and the count can't disagree — they previously said 17 and omitted
|
||||
// page_style, while the extension registered 18.
|
||||
// names and the count cannot disagree.
|
||||
const KNOWN_TOOLS = [
|
||||
"page_analyze", "page_extract_content", "element_click", "element_fill",
|
||||
"element_get_state", "page_navigate", "page_wait_for", "page_scroll",
|
||||
|
|
|
|||
|
|
@ -1,182 +1,151 @@
|
|||
// Simple test script to verify browser extension compatibility
|
||||
// Structural checks over the built extensions in dist/.
|
||||
// Every check is an assertion: a failing probe fails the process, so CI gates on it.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Prints " <label>: Yes/No" and returns the boolean, so callers can fold results.
|
||||
function check(label, ok) {
|
||||
console.log(` ${label}: ${ok ? 'Yes' : 'No'}`);
|
||||
return ok;
|
||||
}
|
||||
|
||||
function testManifestStructure(browser) {
|
||||
console.log(`\n🔍 Testing ${browser} extension structure...`);
|
||||
|
||||
|
||||
const buildDir = `dist/${browser}`;
|
||||
const manifestPath = path.join(buildDir, 'manifest.json');
|
||||
|
||||
|
||||
try {
|
||||
// Read and parse manifest
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
|
||||
// Check manifest version
|
||||
const results = [];
|
||||
|
||||
console.log(` Manifest version: ${manifest.manifest_version}`);
|
||||
|
||||
// Check background configuration
|
||||
|
||||
if (browser === 'chrome') {
|
||||
console.log(` Background: Service Worker (${manifest.background.service_worker})`);
|
||||
console.log(` Action: ${manifest.action ? 'Present' : 'Missing'}`);
|
||||
console.log(` Host permissions: ${manifest.host_permissions ? manifest.host_permissions.length : 0}`);
|
||||
results.push(check('Manifest V3', manifest.manifest_version === 3));
|
||||
results.push(check('Background service worker', Boolean(manifest.background?.service_worker)));
|
||||
results.push(check('Action', Boolean(manifest.action)));
|
||||
results.push(check('Host permissions', (manifest.host_permissions || []).length > 0));
|
||||
} else {
|
||||
console.log(` Background: Scripts (${manifest.background.scripts.length} files)`);
|
||||
console.log(` Browser action: ${manifest.browser_action ? 'Present' : 'Missing'}`);
|
||||
console.log(` Gecko ID: ${manifest.applications?.gecko?.id || 'Not set'}`);
|
||||
results.push(check('Manifest V2', manifest.manifest_version === 2));
|
||||
results.push(check('Background scripts', (manifest.background?.scripts || []).length > 0));
|
||||
results.push(check('Browser action', Boolean(manifest.browser_action)));
|
||||
results.push(check('Gecko ID', Boolean(manifest.applications?.gecko?.id)));
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
console.log(` Permissions: ${manifest.permissions.length} total`);
|
||||
|
||||
// Check content scripts
|
||||
console.log(` Content scripts: ${manifest.content_scripts?.length || 0} configured`);
|
||||
|
||||
// Check polyfill inclusion
|
||||
const polyfillPath = path.join(buildDir, 'src/polyfill/browser-polyfill.min.js');
|
||||
const polyfillExists = fs.existsSync(polyfillPath);
|
||||
console.log(` WebExtension polyfill: ${polyfillExists ? 'Present' : 'Missing'}`);
|
||||
|
||||
// Check if polyfill is included in content scripts
|
||||
const hasPolyfillInContent = manifest.content_scripts?.[0]?.js?.includes('src/polyfill/browser-polyfill.min.js');
|
||||
console.log(` Polyfill in content scripts: ${hasPolyfillInContent ? 'Yes' : 'No'}`);
|
||||
|
||||
// Check if polyfill is included in background (Firefox only)
|
||||
|
||||
results.push(check('Permissions', (manifest.permissions || []).length > 0));
|
||||
results.push(check('Content scripts', (manifest.content_scripts || []).length > 0));
|
||||
|
||||
// The polyfill is copied from node_modules by build.js, so a missing file here
|
||||
// means the build step silently skipped it.
|
||||
const polyfillRelPath = 'src/polyfill/browser-polyfill.min.js';
|
||||
results.push(check('WebExtension polyfill', fs.existsSync(path.join(buildDir, polyfillRelPath))));
|
||||
results.push(check('Polyfill in content scripts',
|
||||
Boolean(manifest.content_scripts?.[0]?.js?.includes(polyfillRelPath))));
|
||||
if (browser === 'firefox') {
|
||||
const hasPolyfillInBackground = manifest.background?.scripts?.includes('src/polyfill/browser-polyfill.min.js');
|
||||
console.log(` Polyfill in background: ${hasPolyfillInBackground ? 'Yes' : 'No'}`);
|
||||
results.push(check('Polyfill in background',
|
||||
Boolean(manifest.background?.scripts?.includes(polyfillRelPath))));
|
||||
}
|
||||
|
||||
console.log(`✅ ${browser} extension structure looks good!`);
|
||||
return true;
|
||||
|
||||
|
||||
const passed = results.every(Boolean);
|
||||
console.log(passed
|
||||
? `✅ ${browser} extension structure looks good!`
|
||||
: `❌ ${browser} extension structure has failing checks`);
|
||||
return passed;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Error testing ${browser} extension:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function testBackgroundScript(browser) {
|
||||
console.log(`\n🔍 Testing ${browser} background script...`);
|
||||
|
||||
const scriptPath = `dist/${browser}/src/background/background.js`;
|
||||
|
||||
try {
|
||||
const script = fs.readFileSync(scriptPath, 'utf8');
|
||||
|
||||
// Check for browser polyfill usage
|
||||
const usesBrowserAPI = script.includes('browser.') || script.includes('globalThis.browser');
|
||||
console.log(` Uses browser API: ${usesBrowserAPI ? 'Yes' : 'No'}`);
|
||||
|
||||
// Check for connection manager
|
||||
const hasConnectionManager = script.includes('ConnectionManager');
|
||||
console.log(` Has connection manager: ${hasConnectionManager ? 'Yes' : 'No'}`);
|
||||
|
||||
// Check for browser detection
|
||||
const hasBrowserDetection = script.includes('browserInfo') || script.includes('isFirefox') || script.includes('isServiceWorker');
|
||||
console.log(` Has browser detection: ${hasBrowserDetection ? 'Yes' : 'No'}`);
|
||||
|
||||
// Check for WebSocket management
|
||||
const hasWebSocketManagement = script.includes('WebSocket') && script.includes('connect');
|
||||
console.log(` Has WebSocket management: ${hasWebSocketManagement ? 'Yes' : 'No'}`);
|
||||
|
||||
console.log(`✅ ${browser} background script looks good!`);
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Error testing ${browser} background script:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const usesBrowserAPI = (s) => s.includes('browser.') || s.includes('globalThis.browser');
|
||||
|
||||
function testContentScript(browser) {
|
||||
console.log(`\n🔍 Testing ${browser} content script...`);
|
||||
|
||||
const scriptPath = `dist/${browser}/src/content/content.js`;
|
||||
|
||||
try {
|
||||
const script = fs.readFileSync(scriptPath, 'utf8');
|
||||
|
||||
// Check for browser polyfill usage
|
||||
const usesBrowserAPI = script.includes('browser.') || script.includes('globalThis.browser');
|
||||
console.log(` Uses browser API: ${usesBrowserAPI ? 'Yes' : 'No'}`);
|
||||
|
||||
// Check for message handling
|
||||
const hasMessageHandling = script.includes('onMessage') && script.includes('sendResponse');
|
||||
console.log(` Has message handling: ${hasMessageHandling ? 'Yes' : 'No'}`);
|
||||
|
||||
console.log(`✅ ${browser} content script looks good!`);
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Error testing ${browser} content script:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const SCRIPT_TESTS = [
|
||||
{
|
||||
label: 'background script',
|
||||
relPath: 'src/background/background.js',
|
||||
probes: [
|
||||
['Uses browser API', usesBrowserAPI],
|
||||
['Has connection manager', (s) => s.includes('ConnectionManager')],
|
||||
['Has browser detection', (s) =>
|
||||
s.includes('browserInfo') || s.includes('isFirefox') || s.includes('isServiceWorker')],
|
||||
['Has WebSocket management', (s) => s.includes('WebSocket') && s.includes('connect')],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'content script',
|
||||
relPath: 'src/content/content.js',
|
||||
probes: [
|
||||
['Uses browser API', usesBrowserAPI],
|
||||
['Has message handling', (s) => s.includes('onMessage') && s.includes('sendResponse')],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'popup script',
|
||||
relPath: 'src/popup/popup.js',
|
||||
probes: [
|
||||
['Uses browser API', usesBrowserAPI],
|
||||
['Has API abstraction', (s) =>
|
||||
s.includes('runtimeAPI') || s.includes('tabsAPI') || s.includes('storageAPI')],
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function testScript(browser, { label, relPath, probes }) {
|
||||
console.log(`\n🔍 Testing ${browser} ${label}...`);
|
||||
|
||||
function testPopupScript(browser) {
|
||||
console.log(`\n🔍 Testing ${browser} popup script...`);
|
||||
|
||||
const scriptPath = `dist/${browser}/src/popup/popup.js`;
|
||||
|
||||
try {
|
||||
const script = fs.readFileSync(scriptPath, 'utf8');
|
||||
|
||||
// Check for browser polyfill usage
|
||||
const usesBrowserAPI = script.includes('browser.') || script.includes('globalThis.browser');
|
||||
console.log(` Uses browser API: ${usesBrowserAPI ? 'Yes' : 'No'}`);
|
||||
|
||||
// Check for API abstraction
|
||||
const hasAPIAbstraction = script.includes('runtimeAPI') || script.includes('tabsAPI') || script.includes('storageAPI');
|
||||
console.log(` Has API abstraction: ${hasAPIAbstraction ? 'Yes' : 'No'}`);
|
||||
|
||||
console.log(`✅ ${browser} popup script looks good!`);
|
||||
return true;
|
||||
|
||||
const script = fs.readFileSync(path.join(`dist/${browser}`, relPath), 'utf8');
|
||||
const passed = probes
|
||||
.map(([name, probe]) => check(name, probe(script)))
|
||||
.every(Boolean);
|
||||
|
||||
console.log(passed
|
||||
? `✅ ${browser} ${label} looks good!`
|
||||
: `❌ ${browser} ${label} has failing checks`);
|
||||
return passed;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Error testing ${browser} popup script:`, error.message);
|
||||
console.error(`❌ Error testing ${browser} ${label}:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function runAllTests() {
|
||||
console.log('🚀 Testing cross-browser extension compatibility...\n');
|
||||
|
||||
|
||||
const browsers = ['chrome', 'firefox'];
|
||||
let allPassed = true;
|
||||
|
||||
|
||||
for (const browser of browsers) {
|
||||
console.log(`\n🌐 Testing ${browser.toUpperCase()} extension:`);
|
||||
console.log('='.repeat(40));
|
||||
|
||||
const tests = [
|
||||
testManifestStructure,
|
||||
testBackgroundScript,
|
||||
testContentScript,
|
||||
testPopupScript
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
if (!test(browser)) {
|
||||
|
||||
if (!testManifestStructure(browser)) {
|
||||
allPassed = false;
|
||||
}
|
||||
for (const scriptTest of SCRIPT_TESTS) {
|
||||
if (!testScript(browser, scriptTest)) {
|
||||
allPassed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
console.log('\n' + '='.repeat(50));
|
||||
if (allPassed) {
|
||||
console.log('🎉 All tests passed! Cross-browser extension is ready.');
|
||||
console.log('\n📦 Distribution packages:');
|
||||
console.log(' Chrome: dist/opendia-chrome.zip');
|
||||
console.log(' Firefox: dist/opendia-firefox.zip');
|
||||
console.log('\n🧪 Manual testing:');
|
||||
console.log(' 1. Chrome: Load dist/chrome in chrome://extensions');
|
||||
console.log(' 2. Firefox: Load dist/firefox in about:debugging');
|
||||
console.log(' 3. Both should connect to MCP server on localhost:5555/5556');
|
||||
} else {
|
||||
if (!allPassed) {
|
||||
console.log('❌ Some tests failed. Please check the output above.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('🎉 All tests passed! Cross-browser extension is ready.');
|
||||
console.log('\n📦 Distribution packages:');
|
||||
console.log(' Chrome: dist/opendia-chrome.zip');
|
||||
console.log(' Firefox: dist/opendia-firefox.zip');
|
||||
console.log('\n🧪 Manual testing:');
|
||||
console.log(' 1. Chrome: Load dist/chrome in chrome://extensions');
|
||||
console.log(' 2. Firefox: Load dist/firefox in about:debugging');
|
||||
console.log(' 3. Both should connect to MCP server on localhost:5555/5556');
|
||||
}
|
||||
|
||||
// Run tests
|
||||
runAllTests();
|
||||
runAllTests();
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
# MCP Server Configuration
|
||||
PORT=3000
|
||||
HEALTH_PORT=3001
|
||||
NODE_ENV=development
|
||||
|
|
@ -248,6 +248,45 @@ let wss = null;
|
|||
let chromeExtensionSocket = null;
|
||||
let availableTools = [];
|
||||
|
||||
// Open SSE response streams, so tools/list_changed can reach SSE clients too.
|
||||
const sseClients = new Set();
|
||||
// Notifications are only legal after the client has initialized, and only
|
||||
// useful then: a client initializing later lists tools itself.
|
||||
let clientInitialized = false;
|
||||
// What the last tools/list would have returned, so a reconnecting extension
|
||||
// re-registering the same tools doesn't emit a pointless notification.
|
||||
let lastToolSignature = "";
|
||||
|
||||
function toolSignature(tools) {
|
||||
return tools.map((t) => t.name).join(",");
|
||||
}
|
||||
|
||||
// The extension connects independently of the MCP client, so the tool list can
|
||||
// change after startup. Without this the client keeps whatever the first
|
||||
// tools/list returned - typically the fallback schemas - for the whole session.
|
||||
function notifyToolsListChanged() {
|
||||
const signature = availableTools.length > 0 ? toolSignature(availableTools) : "";
|
||||
if (!clientInitialized || signature === lastToolSignature) {
|
||||
return;
|
||||
}
|
||||
lastToolSignature = signature;
|
||||
|
||||
const notification = JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "notifications/tools/list_changed",
|
||||
});
|
||||
|
||||
if (!sseOnly) {
|
||||
process.stdout.write(notification + "\n");
|
||||
}
|
||||
for (const res of sseClients) {
|
||||
res.write(`data: ${notification}\n\n`);
|
||||
}
|
||||
console.error(
|
||||
`📢 Sent tools/list_changed (${availableTools.length} tools, ${sseClients.size} SSE client(s))`
|
||||
);
|
||||
}
|
||||
|
||||
// Tool call tracking
|
||||
const pendingCalls = new Map();
|
||||
// Monotonic counter so concurrent tool calls can never share a call id.
|
||||
|
|
@ -277,10 +316,13 @@ async function handleMCPRequest(request) {
|
|||
console.error(
|
||||
`MCP client initializing: ${params?.clientInfo?.name || "unknown"}`
|
||||
);
|
||||
clientInitialized = true;
|
||||
// The extension may register after this point, so declare that the tool
|
||||
// list can change; without it a client has no reason to ever re-list.
|
||||
result = {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {
|
||||
tools: {},
|
||||
tools: { listChanged: true },
|
||||
},
|
||||
serverInfo: {
|
||||
name: "browser-mcp-server",
|
||||
|
|
@ -323,6 +365,10 @@ async function handleMCPRequest(request) {
|
|||
tools: getFallbackTools(),
|
||||
};
|
||||
}
|
||||
// Record what the client now believes, so notifyToolsListChanged only
|
||||
// fires when the answer would actually differ.
|
||||
lastToolSignature =
|
||||
availableTools.length > 0 ? toolSignature(availableTools) : "";
|
||||
break;
|
||||
|
||||
case "tools/call":
|
||||
|
|
@ -398,7 +444,7 @@ async function handleMCPRequest(request) {
|
|||
}
|
||||
}
|
||||
|
||||
// Enhanced tool result formatting with anti-detection support
|
||||
// Tool result formatting
|
||||
function formatToolResult(toolName, result) {
|
||||
const metadata = {
|
||||
tool: toolName,
|
||||
|
|
@ -465,7 +511,7 @@ function formatToolResult(toolName, result) {
|
|||
return formatPageStyleResult(result, metadata);
|
||||
|
||||
default:
|
||||
// Legacy tools or unknown tools
|
||||
// Tools without a dedicated formatter (get_bookmarks, add_bookmark)
|
||||
return JSON.stringify(result, null, 2);
|
||||
}
|
||||
}
|
||||
|
|
@ -480,9 +526,14 @@ function formatPageAnalyzeResult(result, metadata) {
|
|||
`Found ${result.elements.length} relevant elements using ${result.method}:${platformInfo}\n\n` +
|
||||
result.elements
|
||||
.map((el) => {
|
||||
const readyStatus = el.ready ? "✅ Ready" : "⚠️ Not ready";
|
||||
const stateInfo = el.state === "disabled" ? " (disabled)" : "";
|
||||
return `• ${el.name} (${el.type}) - Confidence: ${el.conf}% ${readyStatus}${stateInfo}\n Element ID: ${el.id}`;
|
||||
// The discover phase emits type/ready/state at the top level; the
|
||||
// detailed phase emits a fingerprint and nests state under meta.
|
||||
const ready = el.ready ?? el.meta?.state?.interaction_ready;
|
||||
const disabled = el.state === "disabled" || el.meta?.state?.disabled;
|
||||
const type = el.type || el.fp || "element";
|
||||
const readyStatus = ready ? "✅ Ready" : "⚠️ Not ready";
|
||||
const stateInfo = disabled ? " (disabled)" : "";
|
||||
return `• ${el.name} (${type}) - Confidence: ${el.conf}% ${readyStatus}${stateInfo}\n Element ID: ${el.id}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
return `${summary}\n\n${JSON.stringify(metadata, null, 2)}`;
|
||||
|
|
@ -500,7 +551,9 @@ function formatPageAnalyzeResult(result, metadata) {
|
|||
}
|
||||
|
||||
function formatContentExtractionResult(result, metadata) {
|
||||
const contentSummary = `Extracted ${result.content_type} content using ${result.method}:\n\n`;
|
||||
// summarize:true (the default) reports extraction_method; summarize:false reports method.
|
||||
const method = result.method || result.extraction_method;
|
||||
const contentSummary = `Extracted ${result.content_type} content using ${method}:\n\n`;
|
||||
if (result.content) {
|
||||
// Check if this is full content extraction (summarize=false) or summary
|
||||
// If it's a content object with properties, show full content
|
||||
|
|
@ -529,7 +582,6 @@ function formatContentExtractionResult(result, metadata) {
|
|||
2
|
||||
)}`;
|
||||
} else if (result.summary) {
|
||||
// Enhanced summarized content response
|
||||
const summaryText = formatContentSummary(
|
||||
result.summary,
|
||||
result.content_type
|
||||
|
|
@ -601,7 +653,6 @@ function formatElementClickResult(result, metadata) {
|
|||
}
|
||||
|
||||
function formatElementFillResult(result, metadata) {
|
||||
// Enhanced formatting for anti-detection bypass methods
|
||||
const methodEmojis = {
|
||||
twitter_direct_bypass: "🐦 Twitter Direct Bypass",
|
||||
linkedin_direct_bypass: "💼 LinkedIn Direct Bypass",
|
||||
|
|
@ -717,13 +768,11 @@ function formatScrollResult(result, metadata) {
|
|||
|
||||
if (result.amount && result.amount !== "custom") {
|
||||
summary += ` (${result.amount})`;
|
||||
} else if (result.pixels) {
|
||||
summary += ` (${result.pixels}px)`;
|
||||
} else if (result.requested_pixels) {
|
||||
summary += ` (${result.requested_pixels}px)`;
|
||||
}
|
||||
|
||||
// These read the field names scrollPage actually returns. They previously
|
||||
// read scroll_position/wait_time, which it never emits, so the position and
|
||||
// wait were silently dropped from every scroll result.
|
||||
// Field names must match what scrollPage returns (new_position, wait_after).
|
||||
if (result.new_position) {
|
||||
summary += `\n📍 New position: x=${result.new_position.x}, y=${result.new_position.y}`;
|
||||
}
|
||||
|
|
@ -905,7 +954,6 @@ ${JSON.stringify(metadata, null, 2)}`;
|
|||
}
|
||||
|
||||
function formatPageStyleResult(result, metadata) {
|
||||
const successIcon = result.success ? '✅' : '❌';
|
||||
const statusText = result.success ? 'successfully applied' : 'failed to apply';
|
||||
|
||||
let summary = `🎨 Page styling ${statusText}\n\n`;
|
||||
|
|
@ -954,7 +1002,7 @@ function formatPageStyleResult(result, metadata) {
|
|||
return `${summary}\n\n${JSON.stringify(metadata, null, 2)}`;
|
||||
}
|
||||
|
||||
// Enhanced fallback tools when extension is not connected
|
||||
// Tool list served while the extension is not connected
|
||||
function getFallbackTools() {
|
||||
return [
|
||||
{
|
||||
|
|
@ -1597,6 +1645,7 @@ function setupWebSocketHandlers() {
|
|||
.map((t) => t.name)
|
||||
.join(", ")}`
|
||||
);
|
||||
notifyToolsListChanged();
|
||||
} else if (message.type === "ping") {
|
||||
// Respond to ping with pong
|
||||
ws.send(JSON.stringify({ type: "pong", timestamp: Date.now() }));
|
||||
|
|
@ -1619,6 +1668,7 @@ function setupWebSocketHandlers() {
|
|||
console.error("Browser Extension disconnected");
|
||||
chromeExtensionSocket = null;
|
||||
availableTools = []; // Clear tools when extension disconnects
|
||||
notifyToolsListChanged();
|
||||
// Reject any in-flight tool calls immediately so the MCP client
|
||||
// sees a real error instead of hanging for up to 30s waiting on
|
||||
// a socket that's gone.
|
||||
|
|
@ -1667,6 +1717,9 @@ app.route('/sse')
|
|||
version: SERVER_VERSION
|
||||
})}\n\n`);
|
||||
|
||||
// Registered so server-initiated notifications reach this stream.
|
||||
sseClients.add(res);
|
||||
|
||||
// Heartbeat to keep connection alive
|
||||
const heartbeat = setInterval(() => {
|
||||
res.write(`data: ${JSON.stringify({
|
||||
|
|
@ -1677,6 +1730,7 @@ app.route('/sse')
|
|||
|
||||
req.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
sseClients.delete(res);
|
||||
console.error('SSE client disconnected');
|
||||
});
|
||||
|
||||
|
|
@ -1816,7 +1870,7 @@ async function startServer() {
|
|||
console.error(`✅ Ports resolved: WebSocket=${WS_PORT}, HTTP=${HTTP_PORT}`);
|
||||
|
||||
// Start HTTP server
|
||||
const httpServer = app.listen(HTTP_PORT, HTTP_HOST, () => {
|
||||
app.listen(HTTP_PORT, HTTP_HOST, () => {
|
||||
console.error(`🌐 HTTP/SSE server running on ${HTTP_HOST}:${HTTP_PORT}`);
|
||||
console.error(`🔌 Browser Extension connects on ws://localhost:${WS_PORT}`);
|
||||
if (!isLoopbackHost(HTTP_HOST)) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue