mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat: Simplify user flow for Browser Use connection (#36316)
This commit is contained in:
parent
0a61678310
commit
f44bd4827f
|
|
@ -2,10 +2,20 @@
|
|||
"manifest_version": 3,
|
||||
"name": "n8n Browser Use",
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvRoWEdJjhgP9qs6R1jcemywrw+I91EJZtYur5C97hjjUc5nPao4jSv1qXFksdKuMddb9IEvzBElr5EYsXSaiVqdbRl8Gge0xYV1gGga653T2d9BuXL7NKv/wZxJ2i/coHSjhhIULQUBAVwu0JFMbHY5T8LfqrzBljuY7u1Xa7jmLmx0QrsoKLbGUoOBVZz4ztEGKEQHEelgg+ph2LrcYJczMBZ80PaHQAaWrvbCYF4vnZLd++Svy70ZCt7gr93L8BXHc8j1c3VojQTk+Uvqhm/4nZdYHlEmruQkd0pE+zTyegbcDlw0oc+6sLbsc0CqmJz8zH0OvSTfSVK7h6LNwbQIDAQAB",
|
||||
"version": "0.0.5",
|
||||
"version": "0.0.6",
|
||||
"description": "Let n8n AI set up credentials, fill out forms, and automate anything in your browser",
|
||||
"permissions": ["debugger", "activeTab", "tabs", "storage", "webNavigation"],
|
||||
"host_permissions": ["<all_urls>"],
|
||||
"externally_connectable": {
|
||||
"matches": [
|
||||
"https://*.app.n8n.cloud/*",
|
||||
"https://*.stage-app.n8n.cloud/*",
|
||||
"http://localhost/*",
|
||||
"https://localhost/*",
|
||||
"http://127.0.0.1/*",
|
||||
"https://127.0.0.1/*"
|
||||
]
|
||||
},
|
||||
"web_accessible_resources": [
|
||||
{
|
||||
"resources": ["connect.html"],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@n8n/mcp-browser-extension",
|
||||
"version": "0.0.5",
|
||||
"version": "0.0.6",
|
||||
"private": true,
|
||||
"description": "Chrome extension that lets n8n AI control browser tabs via CDP",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -6,17 +6,27 @@
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TabUpdatedHandler = (tabId: number, changeInfo: chrome.tabs.TabChangeInfo) => void;
|
||||
type ExternalMessageHandler = (
|
||||
message: unknown,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response: unknown) => void,
|
||||
) => unknown;
|
||||
|
||||
const EXT_ORIGIN = 'chrome-extension://testextensionid/';
|
||||
const CONNECT_URL = `${EXT_ORIGIN}connect.html`;
|
||||
|
||||
const tabUpdatedListeners: TabUpdatedHandler[] = [];
|
||||
const tabRemovedListeners: Array<(tabId: number) => void> = [];
|
||||
const externalMessageListeners: ExternalMessageHandler[] = [];
|
||||
|
||||
const chromeMock = {
|
||||
runtime: {
|
||||
getURL: vi.fn((path: string) => `${EXT_ORIGIN}${path}`),
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
onMessage: { addListener: vi.fn() },
|
||||
onMessageExternal: {
|
||||
addListener: vi.fn((fn: ExternalMessageHandler) => externalMessageListeners.push(fn)),
|
||||
},
|
||||
},
|
||||
tabs: {
|
||||
query: vi.fn().mockResolvedValue([]),
|
||||
|
|
@ -24,12 +34,18 @@ const chromeMock = {
|
|||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
reload: vi.fn().mockResolvedValue(undefined),
|
||||
onCreated: { addListener: vi.fn() },
|
||||
onRemoved: { addListener: vi.fn() },
|
||||
onRemoved: {
|
||||
addListener: vi.fn((fn: (tabId: number) => void) => tabRemovedListeners.push(fn)),
|
||||
},
|
||||
onUpdated: {
|
||||
addListener: vi.fn((fn: TabUpdatedHandler) => tabUpdatedListeners.push(fn)),
|
||||
},
|
||||
},
|
||||
windows: { update: vi.fn().mockResolvedValue(undefined) },
|
||||
windows: {
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
getLastFocused: vi.fn().mockResolvedValue({ left: 0, top: 0, width: 1920, height: 1080 }),
|
||||
},
|
||||
storage: {
|
||||
session: {
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -61,6 +77,26 @@ function simulateTabUpdated(tabId: number, url: string): void {
|
|||
for (const fn of tabUpdatedListeners) fn(tabId, { url } as chrome.tabs.TabChangeInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke the registered external-message listeners. Connect responses are held
|
||||
* open until the flow settles, so the result is exposed as a getter that
|
||||
* reflects late resolutions too.
|
||||
*/
|
||||
async function simulateExternalMessage(message: unknown, origin: string): Promise<() => unknown> {
|
||||
const holder: { value?: unknown } = {};
|
||||
for (const fn of externalMessageListeners) {
|
||||
fn(message, { origin } as chrome.runtime.MessageSender, (r) => (holder.value = r));
|
||||
}
|
||||
await flush();
|
||||
return () => holder.value;
|
||||
}
|
||||
|
||||
/** Invoke the registered tab-removed listeners as Chrome would. */
|
||||
async function simulateTabRemoved(tabId: number): Promise<void> {
|
||||
for (const fn of tabRemovedListeners) fn(tabId);
|
||||
await flush();
|
||||
}
|
||||
|
||||
/** Flush pending microtasks/macrotasks so the listener's async IIFE settles. */
|
||||
const flush = async () => await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
|
|
@ -148,3 +184,162 @@ describe('connect.html tab deduplication', () => {
|
|||
expect(chromeMock.tabs.query).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('external messages (direct connect flow)', () => {
|
||||
const ALLOWED_ORIGIN = 'https://acme.app.n8n.cloud';
|
||||
const RELAY_URL = 'wss://acme.app.n8n.cloud/browser-use/extension/abc?token=bu_x';
|
||||
const POPUP_TAB_ID = 99;
|
||||
|
||||
// The throttle keys off Date.now(); advance it past the window before each
|
||||
// test. Re-applied per test because the config restores mocks after each one.
|
||||
let nowMs = 0;
|
||||
beforeEach(() => {
|
||||
nowMs += 60_000;
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => nowMs);
|
||||
chromeMock.tabs.query.mockResolvedValue([]);
|
||||
chromeMock.windows.create.mockResolvedValue({ tabs: [{ id: POPUP_TAB_ID }] });
|
||||
chromeMock.windows.getLastFocused.mockResolvedValue({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores messages from disallowed origins', async () => {
|
||||
const response = await simulateExternalMessage(
|
||||
{ type: 'connect', relayUrl: RELAY_URL },
|
||||
'https://evil.example.com',
|
||||
);
|
||||
expect(response()).toBeUndefined();
|
||||
expect(chromeMock.windows.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores malformed messages', async () => {
|
||||
const response = await simulateExternalMessage({ type: 'connect' }, ALLOWED_ORIGIN);
|
||||
expect(response()).toBeUndefined();
|
||||
expect(chromeMock.windows.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens a centered popup window and accepts the request', async () => {
|
||||
const response = await simulateExternalMessage(
|
||||
{ type: 'connect', relayUrl: RELAY_URL },
|
||||
ALLOWED_ORIGIN,
|
||||
);
|
||||
|
||||
expect(chromeMock.storage.session.set).toHaveBeenCalledWith({ pendingRelayUrl: RELAY_URL });
|
||||
expect(chromeMock.windows.create).toHaveBeenCalledWith({
|
||||
url: connectUrlWithRelay(RELAY_URL),
|
||||
type: 'popup',
|
||||
width: 620,
|
||||
height: 640,
|
||||
left: 650,
|
||||
top: 220,
|
||||
});
|
||||
expect(response()).toEqual({ accepted: true });
|
||||
});
|
||||
|
||||
it('reuses an already-open connect page instead of opening a new popup', async () => {
|
||||
chromeMock.tabs.query.mockResolvedValue([
|
||||
{ id: 5, windowId: 50, url: connectUrlWithRelay('ws://localhost:1111') },
|
||||
]);
|
||||
|
||||
const response = await simulateExternalMessage(
|
||||
{ type: 'connect', relayUrl: RELAY_URL },
|
||||
ALLOWED_ORIGIN,
|
||||
);
|
||||
|
||||
expect(chromeMock.windows.create).not.toHaveBeenCalled();
|
||||
expect(chromeMock.tabs.update).toHaveBeenCalledWith(5, { active: true });
|
||||
expect(chromeMock.windows.update).toHaveBeenCalledWith(50, { focused: true });
|
||||
expect(chromeMock.runtime.sendMessage).toHaveBeenCalledWith({
|
||||
type: 'relayUrlReady',
|
||||
relayUrl: RELAY_URL,
|
||||
});
|
||||
expect(response()).toEqual({ accepted: true });
|
||||
});
|
||||
|
||||
it('rejects a relay URL that is not a recognized n8n instance', async () => {
|
||||
const response = await simulateExternalMessage(
|
||||
{ type: 'connect', relayUrl: 'wss://evil.example.com/relay' },
|
||||
ALLOWED_ORIGIN,
|
||||
);
|
||||
|
||||
expect(response()).toEqual({ accepted: false });
|
||||
expect(chromeMock.windows.create).not.toHaveBeenCalled();
|
||||
expect(chromeMock.storage.session.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('responds not accepted when opening the popup fails', async () => {
|
||||
chromeMock.windows.create.mockRejectedValue(new Error('window creation failed'));
|
||||
|
||||
const response = await simulateExternalMessage(
|
||||
{ type: 'connect', relayUrl: RELAY_URL },
|
||||
ALLOWED_ORIGIN,
|
||||
);
|
||||
|
||||
expect(response()).toEqual({ accepted: false });
|
||||
});
|
||||
|
||||
it('throttles rapid connect requests', async () => {
|
||||
const first = await simulateExternalMessage(
|
||||
{ type: 'connect', relayUrl: RELAY_URL },
|
||||
ALLOWED_ORIGIN,
|
||||
);
|
||||
nowMs += 500;
|
||||
const second = await simulateExternalMessage(
|
||||
{ type: 'connect', relayUrl: RELAY_URL },
|
||||
ALLOWED_ORIGIN,
|
||||
);
|
||||
|
||||
expect(first()).toEqual({ accepted: true });
|
||||
expect(second()).toEqual({ accepted: false });
|
||||
expect(chromeMock.windows.create).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('holds the connect result open until the connect page is closed', async () => {
|
||||
await simulateExternalMessage({ type: 'connect', relayUrl: RELAY_URL }, ALLOWED_ORIGIN);
|
||||
const result = await simulateExternalMessage(
|
||||
{ type: 'connectResult', relayUrl: RELAY_URL },
|
||||
ALLOWED_ORIGIN,
|
||||
);
|
||||
expect(result()).toBeUndefined();
|
||||
|
||||
await simulateTabRemoved(POPUP_TAB_ID);
|
||||
|
||||
expect(result()).toEqual({ connected: false });
|
||||
});
|
||||
|
||||
it('ignores unrelated tab closures while a flow is pending', async () => {
|
||||
await simulateExternalMessage({ type: 'connect', relayUrl: RELAY_URL }, ALLOWED_ORIGIN);
|
||||
const result = await simulateExternalMessage(
|
||||
{ type: 'connectResult', relayUrl: RELAY_URL },
|
||||
ALLOWED_ORIGIN,
|
||||
);
|
||||
|
||||
await simulateTabRemoved(123);
|
||||
|
||||
expect(result()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('answers a connect result for an unknown flow immediately', async () => {
|
||||
const result = await simulateExternalMessage(
|
||||
{ type: 'connectResult', relayUrl: 'wss://acme.app.n8n.cloud/other' },
|
||||
ALLOWED_ORIGIN,
|
||||
);
|
||||
|
||||
expect(result()).toEqual({ connected: false });
|
||||
});
|
||||
|
||||
it('resolves a superseded flow when a newer connect request arrives', async () => {
|
||||
await simulateExternalMessage({ type: 'connect', relayUrl: RELAY_URL }, ALLOWED_ORIGIN);
|
||||
const firstResult = await simulateExternalMessage(
|
||||
{ type: 'connectResult', relayUrl: RELAY_URL },
|
||||
ALLOWED_ORIGIN,
|
||||
);
|
||||
nowMs += 60_000;
|
||||
await simulateExternalMessage({ type: 'connect', relayUrl: RELAY_URL }, ALLOWED_ORIGIN);
|
||||
|
||||
expect(firstResult()).toEqual({ connected: false });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,14 +6,21 @@
|
|||
*/
|
||||
|
||||
import { createLogger } from './logger';
|
||||
import { isAllowedRelayUrl } from './relayAllowlist';
|
||||
import { isAllowedPageOrigin, isAllowedRelayUrl } from './relayAllowlist';
|
||||
import { RelayConnection, isEligibleTab } from './relayConnection';
|
||||
import type { ExtensionMessage, TabManagementSettings } from './types';
|
||||
import type {
|
||||
ExtensionMessage,
|
||||
ExternalConnectResponse,
|
||||
ExternalConnectResultResponse,
|
||||
TabManagementSettings,
|
||||
} from './types';
|
||||
import { isExternalMessage } from './types';
|
||||
|
||||
const log = createLogger('bg');
|
||||
|
||||
interface ConnectionState {
|
||||
relay: RelayConnection;
|
||||
relayUrl: string;
|
||||
}
|
||||
|
||||
let activeConnection: ConnectionState | null = null;
|
||||
|
|
@ -132,40 +139,162 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
|
|||
log.debug('connect.html tab detected:', tabId, 'relayUrl:', relayUrl);
|
||||
|
||||
void (async () => {
|
||||
// A new relay URL means the server started a new session — disconnect any existing one
|
||||
if (activeConnection) {
|
||||
log.debug('new relay URL received while connected, disconnecting old session');
|
||||
disconnect();
|
||||
}
|
||||
|
||||
// Store relay URL for the UI to pick up
|
||||
await chrome.storage.session.set({ [RELAY_URL_KEY]: relayUrl });
|
||||
|
||||
// Check for an existing connect.html tab to reuse
|
||||
const connectUrl = chrome.runtime.getURL(CONNECT_PAGE);
|
||||
const allConnectTabs = await chrome.tabs.query({ url: `${connectUrl}*` });
|
||||
const existing = allConnectTabs.find((t) => t.id !== tabId && t.id !== undefined);
|
||||
|
||||
const existing = await deliverRelayUrl(relayUrl, tabId);
|
||||
if (existing?.id !== undefined) {
|
||||
// Reuse existing tab: focus it and close the duplicate
|
||||
log.debug('reusing existing connect.html tab:', existing.id);
|
||||
await chrome.tabs.update(existing.id, { active: true });
|
||||
if (existing.windowId !== undefined) {
|
||||
await chrome.windows.update(existing.windowId, { focused: true });
|
||||
}
|
||||
await chrome.tabs.remove(tabId);
|
||||
|
||||
// The existing tab stays loaded, so its listener is alive to apply the new relay URL.
|
||||
try {
|
||||
await chrome.runtime.sendMessage({ type: 'relayUrlReady', relayUrl });
|
||||
} catch {
|
||||
// Defensive: the stored RELAY_URL_KEY covers a missed message on next mount.
|
||||
}
|
||||
}
|
||||
// If no existing tab, let the new one load normally — App.vue reads relay URL from storage
|
||||
})();
|
||||
});
|
||||
|
||||
/**
|
||||
* Stores a fresh relay URL and hands it to an already-open connect page if there
|
||||
* is one (focused + notified via `relayUrlReady`). Returns the reused tab, if any.
|
||||
*/
|
||||
async function deliverRelayUrl(
|
||||
relayUrl: string,
|
||||
excludeTabId?: number,
|
||||
): Promise<chrome.tabs.Tab | undefined> {
|
||||
// A new relay URL means the server started a new session — disconnect any existing one
|
||||
if (activeConnection) {
|
||||
log.debug('new relay URL received while connected, disconnecting old session');
|
||||
disconnect();
|
||||
}
|
||||
|
||||
// Store relay URL for the UI to pick up
|
||||
await chrome.storage.session.set({ [RELAY_URL_KEY]: relayUrl });
|
||||
|
||||
// Check for an existing connect.html tab to reuse
|
||||
const connectUrl = chrome.runtime.getURL(CONNECT_PAGE);
|
||||
const allConnectTabs = await chrome.tabs.query({ url: `${connectUrl}*` });
|
||||
const existing = allConnectTabs.find((t) => t.id !== excludeTabId && t.id !== undefined);
|
||||
if (existing?.id === undefined) return undefined;
|
||||
|
||||
log.debug('reusing existing connect.html tab:', existing.id);
|
||||
await chrome.tabs.update(existing.id, { active: true });
|
||||
if (existing.windowId !== undefined) {
|
||||
await chrome.windows.update(existing.windowId, { focused: true });
|
||||
}
|
||||
|
||||
// The existing tab stays loaded, so its listener is alive to apply the new relay URL.
|
||||
try {
|
||||
await chrome.runtime.sendMessage({ type: 'relayUrlReady', relayUrl });
|
||||
} catch {
|
||||
// Defensive: the stored RELAY_URL_KEY covers a missed message on next mount.
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// External messages from n8n pages (externally_connectable) — the n8n UI
|
||||
// requests a connection and the user confirms in an extension-owned popup.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EXTERNAL_CONNECT_THROTTLE_MS = 1000;
|
||||
const CONNECT_POPUP_WIDTH = 620;
|
||||
const CONNECT_POPUP_HEIGHT = 640;
|
||||
|
||||
let lastExternalConnectAt = 0;
|
||||
|
||||
interface PendingConnectFlow {
|
||||
relayUrl: string;
|
||||
tabId: number | null;
|
||||
notify: ((response: ExternalConnectResultResponse) => void) | null;
|
||||
}
|
||||
|
||||
let pendingConnectFlow: PendingConnectFlow | null = null;
|
||||
|
||||
function settleConnectFlow(connected: boolean): void {
|
||||
if (!pendingConnectFlow) return;
|
||||
log.debug('settling connect flow:', pendingConnectFlow.relayUrl, 'connected:', connected);
|
||||
try {
|
||||
pendingConnectFlow.notify?.({ connected });
|
||||
} finally {
|
||||
pendingConnectFlow = null;
|
||||
}
|
||||
}
|
||||
|
||||
chrome.runtime.onMessageExternal.addListener(
|
||||
(
|
||||
message: unknown,
|
||||
sender: chrome.runtime.MessageSender,
|
||||
sendResponse: (response: unknown) => void,
|
||||
) => {
|
||||
if (!isExternalMessage(message)) return false;
|
||||
if (!isAllowedPageOrigin(sender.origin)) {
|
||||
log.warn('ignoring external message from disallowed origin:', sender.origin);
|
||||
return false;
|
||||
}
|
||||
log.debug('external message received:', message.type, 'from', sender.origin);
|
||||
|
||||
if (message.type === 'connect') {
|
||||
void handleExternalConnect(message.relayUrl).then(sendResponse, (error: unknown) => {
|
||||
log.warn('external connect failed:', error);
|
||||
sendResponse({ accepted: false });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (activeConnection?.relayUrl === message.relayUrl) {
|
||||
sendResponse({ connected: true });
|
||||
return false;
|
||||
}
|
||||
if (pendingConnectFlow?.relayUrl === message.relayUrl) {
|
||||
pendingConnectFlow.notify = sendResponse;
|
||||
return true;
|
||||
}
|
||||
sendResponse({ connected: false });
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
async function handleExternalConnect(relayUrl: string): Promise<ExternalConnectResponse> {
|
||||
if (!isAllowedRelayUrl(relayUrl)) {
|
||||
log.warn('refusing external connect to disallowed relay:', relayUrl);
|
||||
return { accepted: false };
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastExternalConnectAt < EXTERNAL_CONNECT_THROTTLE_MS) {
|
||||
log.debug('throttled external connect request');
|
||||
return { accepted: false };
|
||||
}
|
||||
lastExternalConnectAt = now;
|
||||
|
||||
settleConnectFlow(false);
|
||||
|
||||
const existing = await deliverRelayUrl(relayUrl);
|
||||
const tabId = existing?.id ?? (await openConnectPopup(relayUrl));
|
||||
pendingConnectFlow = { relayUrl, tabId, notify: null };
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
async function openConnectPopup(relayUrl: string): Promise<number | null> {
|
||||
const url = `${chrome.runtime.getURL(CONNECT_PAGE)}?mcpRelayUrl=${encodeURIComponent(relayUrl)}`;
|
||||
let left: number | undefined;
|
||||
let top: number | undefined;
|
||||
try {
|
||||
const focused = await chrome.windows.getLastFocused();
|
||||
if (focused.left !== undefined && focused.width !== undefined) {
|
||||
left = Math.max(0, Math.round(focused.left + (focused.width - CONNECT_POPUP_WIDTH) / 2));
|
||||
}
|
||||
if (focused.top !== undefined && focused.height !== undefined) {
|
||||
top = Math.max(0, Math.round(focused.top + (focused.height - CONNECT_POPUP_HEIGHT) / 2));
|
||||
}
|
||||
} catch {
|
||||
// No focused window — let Chrome pick the position
|
||||
}
|
||||
const popup = await chrome.windows.create({
|
||||
url,
|
||||
type: 'popup',
|
||||
width: CONNECT_POPUP_WIDTH,
|
||||
height: CONNECT_POPUP_HEIGHT,
|
||||
left,
|
||||
top,
|
||||
});
|
||||
return popup?.tabs?.[0]?.id ?? null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tab lifecycle listeners — only auto-register agent-created tabs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -261,6 +390,9 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
|
|||
});
|
||||
|
||||
chrome.tabs.onRemoved.addListener((tabId) => {
|
||||
if (pendingConnectFlow?.tabId === tabId && !activeConnection) {
|
||||
settleConnectFlow(false);
|
||||
}
|
||||
if (!activeConnection) return;
|
||||
log.debug('tab removed:', tabId);
|
||||
activeConnection.relay.removeTab(tabId);
|
||||
|
|
@ -319,7 +451,7 @@ async function connectToRelay(
|
|||
throw error;
|
||||
}
|
||||
|
||||
activeConnection = { relay };
|
||||
activeConnection = { relay, relayUrl };
|
||||
|
||||
relay.onclose = () => {
|
||||
log.debug('relay connection closed');
|
||||
|
|
@ -337,6 +469,7 @@ async function connectToRelay(
|
|||
log.debug('connected, controlling', tabCount, 'tabs');
|
||||
updateBadge(tabCount);
|
||||
broadcastStatusChange();
|
||||
settleConnectFlow(pendingConnectFlow?.relayUrl === relayUrl);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
log.error('connectToRelay failed:', error);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { getRelayHost, isAllowedRelayUrl, isLocalhostRelay } from './relayAllowlist';
|
||||
import {
|
||||
getRelayHost,
|
||||
isAllowedPageOrigin,
|
||||
isAllowedRelayUrl,
|
||||
isLocalhostRelay,
|
||||
} from './relayAllowlist';
|
||||
|
||||
describe('isAllowedRelayUrl', () => {
|
||||
it('allows n8n cloud tenant subdomains over wss', () => {
|
||||
|
|
@ -41,6 +46,35 @@ describe('isAllowedRelayUrl', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('isAllowedPageOrigin', () => {
|
||||
it('allows n8n cloud origins over https', () => {
|
||||
expect(isAllowedPageOrigin('https://acme.app.n8n.cloud')).toBe(true);
|
||||
expect(isAllowedPageOrigin('https://acme.stage-app.n8n.cloud')).toBe(true);
|
||||
});
|
||||
|
||||
it('allows local origins over http and https', () => {
|
||||
expect(isAllowedPageOrigin('http://localhost:5678')).toBe(true);
|
||||
expect(isAllowedPageOrigin('https://localhost:5678')).toBe(true);
|
||||
expect(isAllowedPageOrigin('http://127.0.0.1:5678')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects cloud hosts over plain http', () => {
|
||||
expect(isAllowedPageOrigin('http://acme.app.n8n.cloud')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects unrecognized and suffix-spoofing origins', () => {
|
||||
expect(isAllowedPageOrigin('https://evil.example.com')).toBe(false);
|
||||
expect(isAllowedPageOrigin('https://app.n8n.cloud.evil.com')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects malformed or empty input', () => {
|
||||
expect(isAllowedPageOrigin('not a url')).toBe(false);
|
||||
expect(isAllowedPageOrigin('')).toBe(false);
|
||||
expect(isAllowedPageOrigin(null)).toBe(false);
|
||||
expect(isAllowedPageOrigin(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLocalhostRelay', () => {
|
||||
it('is true only for local hosts', () => {
|
||||
expect(isLocalhostRelay('ws://localhost:5680/x')).toBe(true);
|
||||
|
|
|
|||
|
|
@ -25,7 +25,22 @@ export function isAllowedRelayUrl(url: string | null | undefined): boolean {
|
|||
return false;
|
||||
}
|
||||
if (parsed.protocol !== 'ws:' && parsed.protocol !== 'wss:') return false;
|
||||
const host = parsed.hostname;
|
||||
return isAllowedHost(parsed.hostname);
|
||||
}
|
||||
|
||||
export function isAllowedPageOrigin(origin: string | null | undefined): boolean {
|
||||
if (!origin) return false;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(origin);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (parsed.protocol === 'https:') return isAllowedHost(parsed.hostname);
|
||||
return parsed.protocol === 'http:' && LOCAL_HOSTS.has(parsed.hostname);
|
||||
}
|
||||
|
||||
function isAllowedHost(host: string): boolean {
|
||||
if (LOCAL_HOSTS.has(host)) return true;
|
||||
return N8N_CLOUD_SUFFIXES.some((suffix) => host === suffix.slice(1) || host.endsWith(suffix));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,30 @@ export type ExtensionMessage =
|
|||
| GetRelayUrlMessage
|
||||
| ClearRelayUrlMessage;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// External messages (web page → background, via externally_connectable)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ExternalConnectMessage {
|
||||
type: 'connect';
|
||||
relayUrl: string;
|
||||
}
|
||||
|
||||
export interface ExternalConnectResultMessage {
|
||||
type: 'connectResult';
|
||||
relayUrl: string;
|
||||
}
|
||||
|
||||
export type ExternalMessage = ExternalConnectMessage | ExternalConnectResultMessage;
|
||||
|
||||
export interface ExternalConnectResponse {
|
||||
accepted: boolean;
|
||||
}
|
||||
|
||||
export interface ExternalConnectResultResponse {
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Background → UI push messages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -94,6 +118,14 @@ export type BackgroundPushMessage = RelayUrlReadyMessage | StatusChangedMessage;
|
|||
// Type guards
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isExternalMessage(raw: unknown): raw is ExternalMessage {
|
||||
if (raw === null || typeof raw !== 'object') return false;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
return (
|
||||
(obj.type === 'connect' || obj.type === 'connectResult') && typeof obj.relayUrl === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export function isConnectResponse(raw: unknown): raw is ConnectResponse {
|
||||
if (raw === null || typeof raw !== 'object') return false;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
|
|
|
|||
|
|
@ -57,6 +57,9 @@ const chromeMock = {
|
|||
}),
|
||||
},
|
||||
},
|
||||
windows: {
|
||||
getCurrent: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
Object.assign(globalThis, { chrome: chromeMock });
|
||||
|
|
@ -137,6 +140,7 @@ beforeEach(() => {
|
|||
});
|
||||
|
||||
chromeMock.tabs.get.mockImplementation(async (id: number) => await Promise.resolve(makeTab(id)));
|
||||
chromeMock.windows.getCurrent.mockResolvedValue({ type: 'normal' } as chrome.windows.Window);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -413,6 +417,42 @@ describe('useConnection', () => {
|
|||
wrapper.unmount();
|
||||
});
|
||||
|
||||
// Runs a successful connect and reports whether the page closed itself.
|
||||
async function connectAndCheckWindowClosed(windowType: string): Promise<boolean> {
|
||||
chromeMock.windows.getCurrent.mockResolvedValue({
|
||||
type: windowType,
|
||||
} as chrome.windows.Window);
|
||||
chromeMock.runtime.sendMessage.mockImplementation(async (msg: { type: string }) => {
|
||||
if (msg.type === 'getSettings') return { allowTabCreation: true, allowTabClosing: false };
|
||||
if (msg.type === 'getRelayUrl') return 'ws://localhost:1234';
|
||||
if (msg.type === 'getStatus') return { connected: false, tabIds: [] };
|
||||
if (msg.type === 'getTabs') return [makeTab(1)];
|
||||
if (msg.type === 'connect') return { success: true };
|
||||
if (msg.type === 'clearRelayUrl') return { success: true };
|
||||
return await Promise.resolve({});
|
||||
});
|
||||
const closeSpy = vi.spyOn(window, 'close').mockImplementation(() => {});
|
||||
|
||||
const { wrapper, result } = mountComposable();
|
||||
await flush();
|
||||
|
||||
await result().connect();
|
||||
await flush();
|
||||
|
||||
const closed = closeSpy.mock.calls.length > 0;
|
||||
closeSpy.mockRestore();
|
||||
wrapper.unmount();
|
||||
return closed;
|
||||
}
|
||||
|
||||
it('closes the window after connecting when opened as a popup', async () => {
|
||||
expect(await connectAndCheckWindowClosed('popup')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the window open after connecting in a regular tab', async () => {
|
||||
expect(await connectAndCheckWindowClosed('normal')).toBe(false);
|
||||
});
|
||||
|
||||
it('does nothing when no relay URL is available', async () => {
|
||||
chromeMock.runtime.sendMessage.mockImplementation(async (msg: { type: string }) => {
|
||||
if (msg.type === 'getSettings') return { allowTabCreation: true, allowTabClosing: false };
|
||||
|
|
|
|||
|
|
@ -152,6 +152,10 @@ export function useConnection() {
|
|||
if (isStatusResponse(statusResponse)) {
|
||||
controlledTabIds.value = statusResponse.tabIds ?? [];
|
||||
}
|
||||
const currentWindow = await chrome.windows.getCurrent();
|
||||
if (currentWindow.type === 'popup') {
|
||||
window.close();
|
||||
}
|
||||
} else {
|
||||
status.value = 'disconnected';
|
||||
errorMessage.value = isConnectResponse(raw)
|
||||
|
|
|
|||
|
|
@ -6773,6 +6773,9 @@
|
|||
"instanceAi.browserUse.step.connect.description": "Open the extension's connect page, choose which tabs to share, and click Connect.",
|
||||
"instanceAi.browserUse.step.connect.cta": "Open Browser Use extension",
|
||||
"instanceAi.browserUse.step.connect.extensionMissing": "We can't detect the extension. Install it above, or check that it's enabled in your browser.",
|
||||
"instanceAi.browserUse.directConnect.waiting": "Confirm the connection in the extension popup…",
|
||||
"instanceAi.browserUse.directConnect.failed": "Problems connecting your browser?",
|
||||
"instanceAi.browserUse.directConnect.retry": "Try again",
|
||||
"instanceAi.browserUse.unsupportedBrowser": "Browser Use requires Google Chrome or another Chromium-based browser like Microsoft Edge or Brave. Open n8n in a supported browser to continue.",
|
||||
"instanceAi.browserUse.connected": "Browser Use is connected",
|
||||
"instanceAi.browserUse.connected.description": "The agent can now use your browser. You can disconnect anytime from the extension or from the Connections list.",
|
||||
|
|
|
|||
|
|
@ -40,4 +40,11 @@ describe('instance ai browser use telemetry', () => {
|
|||
expect(track).toHaveBeenCalledTimes(1);
|
||||
expect(track).toHaveBeenCalledWith('Instance AI Open Browser Use Extension button clicked');
|
||||
});
|
||||
|
||||
test('tracks a direct connect request', () => {
|
||||
useInstanceAiBrowserUseTelemetry().trackDirectConnectRequested();
|
||||
|
||||
expect(track).toHaveBeenCalledTimes(1);
|
||||
expect(track).toHaveBeenCalledWith('Instance AI Browser Use direct connect requested');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
<script lang="ts" setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { N8nButton, N8nIcon, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useInstanceAiSettingsStore } from '../../instanceAiSettings.store';
|
||||
import { useInstanceAiBrowserUseTelemetry } from '../../instanceAiBrowserUse.telemetry';
|
||||
import { useExtensionDirectConnect } from '../../composables/useExtensionDirectConnect';
|
||||
|
||||
const CONNECT_URL_REFRESH_MARGIN_MS = 30_000;
|
||||
const CONNECT_POPUP_WIDTH = 620;
|
||||
const CONNECT_POPUP_HEIGHT = 640;
|
||||
|
||||
const props = withDefaults(defineProps<{ autoConnect?: boolean }>(), { autoConnect: false });
|
||||
|
||||
const i18n = useI18n();
|
||||
const store = useInstanceAiSettingsStore();
|
||||
const telemetry = useInstanceAiBrowserUseTelemetry();
|
||||
const { status, attempt } = useExtensionDirectConnect();
|
||||
|
||||
const connectUrl = ref<string | null>(null);
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function clearRefreshTimer(): void {
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshConnectUrl(): Promise<void> {
|
||||
clearRefreshTimer();
|
||||
connectUrl.value = await store.fetchBrowserConnectUrl();
|
||||
|
||||
const expiresAt = store.browserConnectUrlExpiresAt;
|
||||
if (!connectUrl.value || !expiresAt) return;
|
||||
|
||||
const delay = Date.parse(expiresAt) - Date.now() - CONNECT_URL_REFRESH_MARGIN_MS;
|
||||
if (!Number.isFinite(delay) || delay <= 0) return;
|
||||
|
||||
refreshTimer = setTimeout(() => void refreshConnectUrl(), delay);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshConnectUrl();
|
||||
if (!props.autoConnect || !connectUrl.value) return;
|
||||
telemetry.trackDirectConnectRequested();
|
||||
await attempt(connectUrl.value);
|
||||
});
|
||||
|
||||
async function retry(): Promise<void> {
|
||||
if (!connectUrl.value) await refreshConnectUrl();
|
||||
if (!connectUrl.value) return;
|
||||
telemetry.trackDirectConnectRequested();
|
||||
await attempt(connectUrl.value);
|
||||
}
|
||||
|
||||
function openConnectPage(): void {
|
||||
if (!connectUrl.value) return;
|
||||
telemetry.trackOpenExtensionClicked();
|
||||
const left = Math.max(
|
||||
0,
|
||||
Math.round(window.screenX + (window.outerWidth - CONNECT_POPUP_WIDTH) / 2),
|
||||
);
|
||||
const top = Math.max(
|
||||
0,
|
||||
Math.round(window.screenY + (window.outerHeight - CONNECT_POPUP_HEIGHT) / 2),
|
||||
);
|
||||
window.open(
|
||||
connectUrl.value,
|
||||
'n8n-browser-use-connect',
|
||||
`popup,width=${CONNECT_POPUP_WIDTH},height=${CONNECT_POPUP_HEIGHT},left=${left},top=${top}`,
|
||||
);
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearRefreshTimer();
|
||||
store.clearBrowserConnectUrl();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.passthrough">
|
||||
<N8nText :bold="true" size="small">
|
||||
{{ i18n.baseText('instanceAi.browserUse.step.connect.title') }}
|
||||
</N8nText>
|
||||
|
||||
<div
|
||||
v-if="status === 'waiting'"
|
||||
:class="$style.waiting"
|
||||
data-test-id="browser-use-direct-connect-waiting"
|
||||
>
|
||||
<N8nIcon icon="spinner" color="primary" spin size="small" />
|
||||
<N8nText color="text-light" size="small">
|
||||
{{ i18n.baseText('instanceAi.browserUse.directConnect.waiting') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<template v-else-if="status === 'failed'">
|
||||
<N8nText color="text-light" size="small">
|
||||
{{ i18n.baseText('instanceAi.browserUse.directConnect.failed') }}
|
||||
</N8nText>
|
||||
<N8nButton
|
||||
:label="i18n.baseText('instanceAi.browserUse.directConnect.retry')"
|
||||
variant="solid"
|
||||
size="medium"
|
||||
data-test-id="browser-use-direct-connect-retry"
|
||||
@click="retry"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<N8nText color="text-light" size="small">
|
||||
{{ i18n.baseText('instanceAi.browserUse.step.connect.description') }}
|
||||
</N8nText>
|
||||
<N8nButton
|
||||
:label="i18n.baseText('instanceAi.browserUse.step.connect.cta')"
|
||||
variant="solid"
|
||||
size="medium"
|
||||
icon="external-link"
|
||||
:disabled="!connectUrl"
|
||||
data-test-id="browser-use-open-connect-page"
|
||||
@click="openConnectPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.passthrough {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.waiting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useEventListener } from '@vueuse/core';
|
||||
import { N8nButton, N8nCallout, N8nHeading, N8nIcon, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
|
@ -7,6 +7,7 @@ import { isBrowserUseSupportedForBrowser } from '@/experiments/instanceAiBrowser
|
|||
import { useDocumentVisibility } from '@/app/composables/useDocumentVisibility';
|
||||
import { useInstanceAiSettingsStore } from '../../instanceAiSettings.store';
|
||||
import { useInstanceAiBrowserUseTelemetry } from '../../instanceAiBrowserUse.telemetry';
|
||||
import BrowserUseConnectStep from './BrowserUseConnectStep.vue';
|
||||
|
||||
import { CHROME_EXTENSION_URL } from '../../constants';
|
||||
import {
|
||||
|
|
@ -14,14 +15,14 @@ import {
|
|||
type BrowserUseExtensionState,
|
||||
} from '../../utils/browserUseExtension';
|
||||
|
||||
const CONNECT_URL_REFRESH_MARGIN_MS = 30_000;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
embedded?: boolean;
|
||||
autoConnect?: boolean;
|
||||
}>(),
|
||||
{
|
||||
embedded: false,
|
||||
autoConnect: false,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -34,11 +35,10 @@ const { onDocumentVisible } = useDocumentVisibility();
|
|||
|
||||
const isBrowserSupported = isBrowserUseSupportedForBrowser();
|
||||
const isConnected = computed(() => store.browserConnected);
|
||||
const connectUrl = ref<string | null>(null);
|
||||
const statusChecked = ref(false);
|
||||
const extensionState = ref<BrowserUseExtensionState>('unknown');
|
||||
const isExtensionMissing = computed(() => extensionState.value === 'not-installed');
|
||||
const isExtensionInstalled = computed(() => extensionState.value === 'installed');
|
||||
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let isProbingExtension = false;
|
||||
|
||||
async function refreshExtensionState(): Promise<void> {
|
||||
|
|
@ -53,34 +53,11 @@ async function refreshExtensionState(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
function clearRefreshTimer(): void {
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer);
|
||||
refreshTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshConnectUrl(): Promise<void> {
|
||||
clearRefreshTimer();
|
||||
connectUrl.value = await store.fetchBrowserConnectUrl();
|
||||
|
||||
const expiresAt = store.browserConnectUrlExpiresAt;
|
||||
if (!connectUrl.value || !expiresAt) return;
|
||||
|
||||
const delay = Date.parse(expiresAt) - Date.now() - CONNECT_URL_REFRESH_MARGIN_MS;
|
||||
if (!Number.isFinite(delay) || delay <= 0) return;
|
||||
|
||||
refreshTimer = setTimeout(() => void refreshConnectUrl(), delay);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
telemetry.trackModalOpened(isBrowserSupported);
|
||||
if (!isBrowserSupported) return;
|
||||
void store.fetchBrowserStatus();
|
||||
if (!store.browserConnected) {
|
||||
void refreshConnectUrl();
|
||||
}
|
||||
void refreshExtensionState();
|
||||
await Promise.all([refreshExtensionState(), store.fetchBrowserStatus()]);
|
||||
statusChecked.value = true;
|
||||
});
|
||||
|
||||
// Re-probe when the user returns from installing the extension. Coming back by tab switch
|
||||
|
|
@ -89,11 +66,6 @@ onMounted(() => {
|
|||
const reprobeExtension = () => void refreshExtensionState();
|
||||
onDocumentVisible(reprobeExtension);
|
||||
useEventListener(window, 'focus', reprobeExtension);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearRefreshTimer();
|
||||
store.clearBrowserConnectUrl();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -155,34 +127,17 @@ onBeforeUnmount(() => {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div :class="$style.step">
|
||||
<div v-if="isExtensionMissing" :class="$style.step">
|
||||
<N8nText :bold="true" size="small">
|
||||
{{ i18n.baseText('instanceAi.browserUse.step.connect.title') }}
|
||||
</N8nText>
|
||||
<N8nText
|
||||
v-if="isExtensionMissing"
|
||||
color="text-light"
|
||||
size="small"
|
||||
data-test-id="browser-use-extension-missing-note"
|
||||
>
|
||||
<N8nText color="text-light" size="small" data-test-id="browser-use-extension-missing-note">
|
||||
{{ i18n.baseText('instanceAi.browserUse.step.connect.extensionMissing') }}
|
||||
</N8nText>
|
||||
<template v-else>
|
||||
<N8nText color="text-light" size="small">
|
||||
{{ i18n.baseText('instanceAi.browserUse.step.connect.description') }}
|
||||
</N8nText>
|
||||
<N8nButton
|
||||
:label="i18n.baseText('instanceAi.browserUse.step.connect.cta')"
|
||||
:href="connectUrl ?? undefined"
|
||||
target="_blank"
|
||||
variant="solid"
|
||||
size="medium"
|
||||
icon="external-link"
|
||||
:disabled="!connectUrl"
|
||||
data-test-id="browser-use-open-connect-page"
|
||||
@click="telemetry.trackOpenExtensionClicked"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-else-if="statusChecked" :class="$style.step">
|
||||
<BrowserUseConnectStep :auto-connect="props.autoConnect" />
|
||||
</div>
|
||||
|
||||
<div v-if="!isExtensionMissing" :class="$style.waitingRow">
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ const modalBus = createEventBus();
|
|||
width="540"
|
||||
>
|
||||
<template #content>
|
||||
<BrowserUseSetupContent @close="modalBus.emit('close')" />
|
||||
<BrowserUseSetupContent auto-connect @close="modalBus.emit('close')" />
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { fireEvent } from '@testing-library/vue';
|
||||
import { flushPromises } from '@vue/test-utils';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import BrowserUseConnectStep from '../BrowserUseConnectStep.vue';
|
||||
|
||||
vi.mock('@n8n/i18n', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
useI18n: () => ({
|
||||
baseText: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { telemetryMock } = vi.hoisted(() => ({
|
||||
telemetryMock: {
|
||||
trackModalOpened: vi.fn(),
|
||||
trackInstallExtensionClicked: vi.fn(),
|
||||
trackOpenExtensionClicked: vi.fn(),
|
||||
trackDirectConnectRequested: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../instanceAiBrowserUse.telemetry', () => ({
|
||||
useInstanceAiBrowserUseTelemetry: () => telemetryMock,
|
||||
}));
|
||||
|
||||
const settingsStoreMock = vi.fn();
|
||||
vi.mock('../../../instanceAiSettings.store', () => ({
|
||||
useInstanceAiSettingsStore: () => settingsStoreMock(),
|
||||
}));
|
||||
|
||||
const RELAY_URL = 'wss://acme.app.n8n.cloud/browser-use/extension/session?token=bu_x';
|
||||
const CONNECT_URL = `chrome-extension://testextensionid/connect.html?mcpRelayUrl=${encodeURIComponent(RELAY_URL)}`;
|
||||
|
||||
function makeSettingsStore(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
browserConnectUrlExpiresAt: null,
|
||||
fetchBrowserConnectUrl: vi.fn().mockResolvedValue(CONNECT_URL),
|
||||
clearBrowserConnectUrl: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function installExtensionMock(responses: Record<string, unknown>): void {
|
||||
const runtime = {
|
||||
lastError: undefined as { message?: string } | undefined,
|
||||
sendMessage: (
|
||||
_extensionId: string,
|
||||
message: unknown,
|
||||
callback: (response: unknown) => void,
|
||||
) => {
|
||||
const type = (message as { type: string }).type;
|
||||
if (type in responses) callback(responses[type]);
|
||||
},
|
||||
};
|
||||
(globalThis as { chrome?: unknown }).chrome = { runtime };
|
||||
}
|
||||
|
||||
const renderComponent = createComponentRenderer(BrowserUseConnectStep, {
|
||||
props: { autoConnect: true },
|
||||
});
|
||||
|
||||
describe('BrowserUseConnectStep', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
settingsStoreMock.mockReturnValue(makeSettingsStore());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as { chrome?: unknown }).chrome;
|
||||
});
|
||||
|
||||
it('does not start a direct connect attempt when autoConnect is off', async () => {
|
||||
installExtensionMock({ connect: { accepted: true } });
|
||||
const { getByTestId, queryByTestId } = createComponentRenderer(BrowserUseConnectStep)();
|
||||
await flushPromises();
|
||||
|
||||
expect(getByTestId('browser-use-open-connect-page')).toBeVisible();
|
||||
expect(queryByTestId('browser-use-direct-connect-waiting')).toBeNull();
|
||||
expect(telemetryMock.trackDirectConnectRequested).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the manual connect link when the extension cannot be messaged', async () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(getByTestId('browser-use-open-connect-page')).toBeVisible();
|
||||
expect(queryByTestId('browser-use-direct-connect-waiting')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the manual connect link when the extension does not accept the request', async () => {
|
||||
installExtensionMock({ connect: { accepted: false } });
|
||||
const { getByTestId, queryByTestId } = renderComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(getByTestId('browser-use-open-connect-page')).toBeVisible();
|
||||
expect(queryByTestId('browser-use-direct-connect-retry')).toBeNull();
|
||||
});
|
||||
|
||||
it('waits for the connect result once the extension opened the popup', async () => {
|
||||
installExtensionMock({ connect: { accepted: true } });
|
||||
const { getByTestId, queryByTestId } = renderComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(getByTestId('browser-use-direct-connect-waiting')).toBeVisible();
|
||||
expect(queryByTestId('browser-use-open-connect-page')).toBeNull();
|
||||
expect(telemetryMock.trackDirectConnectRequested).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('offers a retry when the connect did not succeed', async () => {
|
||||
installExtensionMock({ connect: { accepted: true }, connectResult: { connected: false } });
|
||||
const { getByTestId } = renderComponent();
|
||||
await flushPromises();
|
||||
|
||||
await fireEvent.click(getByTestId('browser-use-direct-connect-retry'));
|
||||
await flushPromises();
|
||||
|
||||
expect(telemetryMock.trackDirectConnectRequested).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('shows the manual connect link when the extension stops responding on retry', async () => {
|
||||
installExtensionMock({ connect: { accepted: true }, connectResult: { connected: false } });
|
||||
const { getByTestId } = renderComponent();
|
||||
await flushPromises();
|
||||
|
||||
delete (globalThis as { chrome?: unknown }).chrome;
|
||||
await fireEvent.click(getByTestId('browser-use-direct-connect-retry'));
|
||||
await flushPromises();
|
||||
|
||||
expect(getByTestId('browser-use-open-connect-page')).toBeVisible();
|
||||
});
|
||||
|
||||
it('opens the connect page as a popup on the manual connect button', async () => {
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null);
|
||||
const { getByTestId } = renderComponent();
|
||||
await flushPromises();
|
||||
|
||||
await fireEvent.click(getByTestId('browser-use-open-connect-page'));
|
||||
|
||||
expect(telemetryMock.trackOpenExtensionClicked).toHaveBeenCalledTimes(1);
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
CONNECT_URL,
|
||||
'n8n-browser-use-connect',
|
||||
expect.stringMatching(/^popup,width=620,height=640,left=\d+,top=\d+$/),
|
||||
);
|
||||
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('clears the stored connect URL when unmounted', async () => {
|
||||
const store = makeSettingsStore();
|
||||
settingsStoreMock.mockReturnValue(store);
|
||||
|
||||
const { unmount } = renderComponent();
|
||||
await flushPromises();
|
||||
unmount();
|
||||
|
||||
expect(store.clearBrowserConnectUrl).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { fireEvent } from '@testing-library/vue';
|
||||
import { flushPromises } from '@vue/test-utils';
|
||||
import { reactive } from 'vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import BrowserUseSetupContent from '../BrowserUseSetupContent.vue';
|
||||
import BrowserUseSetupModal from '../BrowserUseSetupModal.vue';
|
||||
|
|
@ -18,6 +19,7 @@ const { telemetryMock } = vi.hoisted(() => ({
|
|||
trackModalOpened: vi.fn(),
|
||||
trackInstallExtensionClicked: vi.fn(),
|
||||
trackOpenExtensionClicked: vi.fn(),
|
||||
trackDirectConnectRequested: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -35,17 +37,35 @@ vi.mock('../../../utils/browserUseExtension', () => ({
|
|||
detectBrowserUseExtension: detectExtensionMock,
|
||||
}));
|
||||
|
||||
const CONNECT_URL =
|
||||
'chrome-extension://testextensionid/connect.html?mcpRelayUrl=wss%3A%2F%2Facme.app.n8n.cloud%2Frelay';
|
||||
|
||||
function makeSettingsStore(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
browserConnected: false,
|
||||
browserConnectUrlExpiresAt: null,
|
||||
fetchBrowserStatus: vi.fn(),
|
||||
fetchBrowserConnectUrl: vi.fn().mockResolvedValue('https://connect.example/extension'),
|
||||
fetchBrowserConnectUrl: vi.fn().mockResolvedValue(CONNECT_URL),
|
||||
clearBrowserConnectUrl: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function installExtensionMock(responses: Record<string, unknown>): void {
|
||||
const runtime = {
|
||||
lastError: undefined as { message?: string } | undefined,
|
||||
sendMessage: (
|
||||
_extensionId: string,
|
||||
message: unknown,
|
||||
callback: (response: unknown) => void,
|
||||
) => {
|
||||
const type = (message as { type: string }).type;
|
||||
if (type in responses) callback(responses[type]);
|
||||
},
|
||||
};
|
||||
(globalThis as { chrome?: unknown }).chrome = { runtime };
|
||||
}
|
||||
|
||||
const renderComponent = createComponentRenderer(BrowserUseSetupModal, {
|
||||
props: { modalName: 'browserUseSetup' },
|
||||
global: {
|
||||
|
|
@ -81,6 +101,10 @@ describe('BrowserUseSetupModal', () => {
|
|||
detectExtensionMock.mockResolvedValue('unknown');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as { chrome?: unknown }).chrome;
|
||||
});
|
||||
|
||||
it('tracks the modal opening on mount', () => {
|
||||
renderComponent();
|
||||
expect(telemetryMock.trackModalOpened).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -97,12 +121,15 @@ describe('BrowserUseSetupModal', () => {
|
|||
});
|
||||
|
||||
it('tracks the open extension button click', async () => {
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null);
|
||||
const { getByTestId } = renderComponent();
|
||||
await flushPromises();
|
||||
|
||||
await fireEvent.click(getByTestId('browser-use-open-connect-page'));
|
||||
|
||||
expect(telemetryMock.trackOpenExtensionClicked).toHaveBeenCalledTimes(1);
|
||||
|
||||
openSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('replaces the connect step with an explanation when the extension is not installed', async () => {
|
||||
|
|
@ -167,6 +194,20 @@ describe('BrowserUseSetupModal', () => {
|
|||
expect(detectExtensionMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('starts a fresh direct connect attempt once the extension gets installed', async () => {
|
||||
installExtensionMock({ connect: { accepted: true } });
|
||||
const { getByTestId, queryByTestId } = await renderWithExtensionState('not-installed');
|
||||
expect(telemetryMock.trackDirectConnectRequested).not.toHaveBeenCalled();
|
||||
expect(queryByTestId('browser-use-direct-connect-waiting')).toBeNull();
|
||||
|
||||
detectExtensionMock.mockResolvedValue('installed');
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
await flushPromises();
|
||||
|
||||
expect(telemetryMock.trackDirectConnectRequested).toHaveBeenCalledTimes(1);
|
||||
expect(getByTestId('browser-use-direct-connect-waiting')).toBeVisible();
|
||||
});
|
||||
|
||||
it('does not render the connect steps when already connected', () => {
|
||||
settingsStoreMock.mockReturnValue(makeSettingsStore({ browserConnected: true }));
|
||||
const { queryByTestId } = renderComponent();
|
||||
|
|
@ -175,6 +216,42 @@ describe('BrowserUseSetupModal', () => {
|
|||
expect(queryByTestId('browser-use-open-connect-page')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the install step visible while the connect step waits for confirmation', async () => {
|
||||
installExtensionMock({ connect: { accepted: true } });
|
||||
const { getByTestId } = renderComponent();
|
||||
await flushPromises();
|
||||
|
||||
// The direct connect flow is scoped to the connect step — the rest of the view stays put.
|
||||
expect(getByTestId('browser-use-install-extension')).toBeVisible();
|
||||
expect(getByTestId('browser-use-direct-connect-waiting')).toBeVisible();
|
||||
});
|
||||
|
||||
it('does not request a direct connection when already connected', async () => {
|
||||
installExtensionMock({ connect: { accepted: true } });
|
||||
settingsStoreMock.mockReturnValue(makeSettingsStore({ browserConnected: true }));
|
||||
renderComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(telemetryMock.trackDirectConnectRequested).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not request a direct connection when the status fetch reveals a live session', async () => {
|
||||
installExtensionMock({ connect: { accepted: true } });
|
||||
// Store starts stale (disconnected); the status fetch corrects it. Requesting a
|
||||
// connection here would make the extension drop the live session.
|
||||
const store = reactive(makeSettingsStore());
|
||||
store.fetchBrowserStatus = vi.fn().mockImplementation(async () => {
|
||||
store.browserConnected = true;
|
||||
});
|
||||
settingsStoreMock.mockReturnValue(store);
|
||||
|
||||
renderComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(telemetryMock.trackDirectConnectRequested).not.toHaveBeenCalled();
|
||||
expect(store.fetchBrowserConnectUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not render the unsupported browser message on a Chromium browser', () => {
|
||||
const { queryByTestId } = renderComponent();
|
||||
|
||||
|
|
@ -225,6 +302,14 @@ describe('BrowserUseSetupModal', () => {
|
|||
expect(store.fetchBrowserConnectUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not request a direct connection even when the extension is present', async () => {
|
||||
installExtensionMock({ connect: { accepted: true } });
|
||||
renderComponent();
|
||||
await flushPromises();
|
||||
|
||||
expect(telemetryMock.trackDirectConnectRequested).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not probe for the extension', async () => {
|
||||
renderComponent();
|
||||
await flushPromises();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,158 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
useExtensionDirectConnect,
|
||||
DIRECT_CONNECT_CONFIRMATION_TIMEOUT_MS,
|
||||
} from './useExtensionDirectConnect';
|
||||
|
||||
const RELAY_URL = 'wss://acme.app.n8n.cloud/browser-use/extension/session?token=bu_x';
|
||||
const CONNECT_URL = `chrome-extension://testextensionid/connect.html?mcpRelayUrl=${encodeURIComponent(RELAY_URL)}`;
|
||||
|
||||
type SendMessage = (
|
||||
extensionId: string,
|
||||
message: unknown,
|
||||
callback: (response: unknown) => void,
|
||||
) => void;
|
||||
|
||||
const chromeMock: { runtime: { sendMessage: SendMessage; lastError?: { message?: string } } } = {
|
||||
runtime: { sendMessage: vi.fn() },
|
||||
};
|
||||
|
||||
const HOLD_RESPONSE = Symbol('hold');
|
||||
let heldCallbacks: Array<(response: unknown) => void> = [];
|
||||
|
||||
function mockExtensionResponses(responses: Record<string, unknown | typeof HOLD_RESPONSE>): void {
|
||||
chromeMock.runtime.lastError = undefined;
|
||||
heldCallbacks = [];
|
||||
chromeMock.runtime.sendMessage = vi.fn(
|
||||
(_extensionId: string, message: unknown, callback: (response: unknown) => void) => {
|
||||
const type = (message as { type: string }).type;
|
||||
if (!(type in responses)) {
|
||||
chromeMock.runtime.lastError = { message: 'Receiving end does not exist.' };
|
||||
callback(undefined);
|
||||
return;
|
||||
}
|
||||
const value = responses[type];
|
||||
if (value === HOLD_RESPONSE) {
|
||||
heldCallbacks.push(callback);
|
||||
return;
|
||||
}
|
||||
callback(value);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
(globalThis as { chrome?: unknown }).chrome = chromeMock;
|
||||
mockExtensionResponses({ connect: { accepted: true }, connectResult: HOLD_RESPONSE });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (globalThis as { chrome?: unknown }).chrome;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('useExtensionDirectConnect', () => {
|
||||
it('is unsupported when the page has no extension messaging API', async () => {
|
||||
delete (globalThis as { chrome?: unknown }).chrome;
|
||||
const { status, attempt } = useExtensionDirectConnect();
|
||||
|
||||
await attempt(CONNECT_URL);
|
||||
|
||||
expect(status.value).toBe('unsupported');
|
||||
});
|
||||
|
||||
it('is unsupported when the connect URL lacks a relay URL', async () => {
|
||||
const { status, attempt } = useExtensionDirectConnect();
|
||||
|
||||
await attempt('chrome-extension://testextensionid/connect.html');
|
||||
|
||||
expect(status.value).toBe('unsupported');
|
||||
});
|
||||
|
||||
it('is unsupported when the extension does not answer', async () => {
|
||||
mockExtensionResponses({});
|
||||
const { status, attempt } = useExtensionDirectConnect();
|
||||
|
||||
await attempt(CONNECT_URL);
|
||||
|
||||
expect(status.value).toBe('unsupported');
|
||||
});
|
||||
|
||||
it('is unsupported when the extension does not accept the request', async () => {
|
||||
mockExtensionResponses({ connect: { accepted: false } });
|
||||
const { status, attempt } = useExtensionDirectConnect();
|
||||
|
||||
await attempt(CONNECT_URL);
|
||||
|
||||
expect(status.value).toBe('unsupported');
|
||||
});
|
||||
|
||||
it('waits for the connect result after the extension opened the popup', async () => {
|
||||
const { status, attempt } = useExtensionDirectConnect();
|
||||
|
||||
void attempt(CONNECT_URL);
|
||||
await settle();
|
||||
|
||||
expect(status.value).toBe('waiting');
|
||||
expect(chromeMock.runtime.sendMessage).toHaveBeenCalledWith(
|
||||
'testextensionid',
|
||||
{ type: 'connectResult', relayUrl: RELAY_URL },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('fails as soon as the extension reports an unsuccessful connect', async () => {
|
||||
const { status, attempt } = useExtensionDirectConnect();
|
||||
const pending = attempt(CONNECT_URL);
|
||||
await settle();
|
||||
|
||||
heldCallbacks[0]({ connected: false });
|
||||
await pending;
|
||||
|
||||
expect(status.value).toBe('failed');
|
||||
});
|
||||
|
||||
it('stays waiting when the extension reports a successful connect', async () => {
|
||||
const { status, attempt } = useExtensionDirectConnect();
|
||||
const pending = attempt(CONNECT_URL);
|
||||
await settle();
|
||||
|
||||
heldCallbacks[0]({ connected: true });
|
||||
await pending;
|
||||
|
||||
expect(status.value).toBe('waiting');
|
||||
});
|
||||
|
||||
it('fails when no connect result arrives in time', async () => {
|
||||
const { status, attempt } = useExtensionDirectConnect();
|
||||
void attempt(CONNECT_URL);
|
||||
await settle();
|
||||
|
||||
vi.advanceTimersByTime(DIRECT_CONNECT_CONFIRMATION_TIMEOUT_MS);
|
||||
await settle();
|
||||
|
||||
expect(status.value).toBe('failed');
|
||||
});
|
||||
|
||||
it('ignores a second attempt while one is in flight', async () => {
|
||||
mockExtensionResponses({ connect: HOLD_RESPONSE, connectResult: HOLD_RESPONSE });
|
||||
const { status, attempt } = useExtensionDirectConnect();
|
||||
void attempt(CONNECT_URL);
|
||||
await settle();
|
||||
void attempt(CONNECT_URL);
|
||||
await settle();
|
||||
|
||||
expect(chromeMock.runtime.sendMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
heldCallbacks[0]({ accepted: true });
|
||||
await settle();
|
||||
expect(status.value).toBe('waiting');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import { ref, type Ref } from 'vue';
|
||||
|
||||
export const DIRECT_CONNECT_CONFIRMATION_TIMEOUT_MS = 15_000;
|
||||
|
||||
export type DirectConnectStatus = 'idle' | 'unsupported' | 'waiting' | 'failed';
|
||||
|
||||
interface ExtensionRuntime {
|
||||
sendMessage: (
|
||||
extensionId: string,
|
||||
message: unknown,
|
||||
callback: (response: unknown) => void,
|
||||
) => void;
|
||||
lastError?: { message?: string };
|
||||
}
|
||||
|
||||
function getExtensionRuntime(): ExtensionRuntime | null {
|
||||
const runtime = (globalThis as { chrome?: { runtime?: ExtensionRuntime } }).chrome?.runtime;
|
||||
return typeof runtime?.sendMessage === 'function' ? runtime : null;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object';
|
||||
}
|
||||
|
||||
async function sendToExtension(
|
||||
runtime: ExtensionRuntime,
|
||||
extensionId: string,
|
||||
message: unknown,
|
||||
): Promise<unknown> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
try {
|
||||
runtime.sendMessage(extensionId, message, (response) => {
|
||||
const error = runtime.lastError;
|
||||
if (error) reject(new Error(error.message ?? 'Extension messaging failed'));
|
||||
else resolve(response);
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function timeout(ms: number): Promise<undefined> {
|
||||
return await new Promise((resolve) => setTimeout(() => resolve(undefined), ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct Browser Use connect flow: asks the installed extension (via
|
||||
* `externally_connectable` messaging) to show its connect confirmation in an
|
||||
* extension-owned popup, so the user only confirms once. `unsupported` means
|
||||
* the extension did not open the popup — callers show the link-based flow.
|
||||
*/
|
||||
export function useExtensionDirectConnect() {
|
||||
const status: Ref<DirectConnectStatus> = ref('idle');
|
||||
let isAttempting = false;
|
||||
|
||||
async function attempt(connectUrl: string): Promise<void> {
|
||||
if (isAttempting) return;
|
||||
isAttempting = true;
|
||||
try {
|
||||
await runAttempt(connectUrl);
|
||||
} finally {
|
||||
isAttempting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runAttempt(connectUrl: string): Promise<void> {
|
||||
const runtime = getExtensionRuntime();
|
||||
let extensionId = '';
|
||||
let relayUrl: string | null = null;
|
||||
try {
|
||||
const parsed = new URL(connectUrl);
|
||||
extensionId = parsed.hostname;
|
||||
relayUrl = parsed.searchParams.get('mcpRelayUrl');
|
||||
} catch {}
|
||||
if (!runtime || !extensionId || !relayUrl) {
|
||||
status.value = 'unsupported';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await sendToExtension(runtime, extensionId, { type: 'connect', relayUrl });
|
||||
if (!isRecord(response) || response.accepted !== true) {
|
||||
status.value = 'unsupported';
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
status.value = 'unsupported';
|
||||
return;
|
||||
}
|
||||
|
||||
status.value = 'waiting';
|
||||
|
||||
let connected = false;
|
||||
try {
|
||||
const result = await Promise.race([
|
||||
sendToExtension(runtime, extensionId, { type: 'connectResult', relayUrl }),
|
||||
timeout(DIRECT_CONNECT_CONFIRMATION_TIMEOUT_MS),
|
||||
]);
|
||||
connected = isRecord(result) && result.connected === true;
|
||||
} catch {}
|
||||
if (!connected) {
|
||||
status.value = 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
return { status, attempt };
|
||||
}
|
||||
|
|
@ -14,5 +14,8 @@ export function useInstanceAiBrowserUseTelemetry() {
|
|||
trackOpenExtensionClicked() {
|
||||
telemetry.track('Instance AI Open Browser Use Extension button clicked');
|
||||
},
|
||||
trackDirectConnectRequested() {
|
||||
telemetry.track('Instance AI Browser Use direct connect requested');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user