feat: Simplify user flow for Browser Use connection (#36316)

This commit is contained in:
Dimitri Lavrenük 2026-08-17 10:12:01 +00:00 committed by GitHub
parent 0a61678310
commit f44bd4827f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1178 additions and 98 deletions

View File

@ -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"],

View File

@ -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": {

View File

@ -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 });
});
});

View File

@ -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);

View File

@ -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);

View File

@ -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));
}

View File

@ -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>;

View File

@ -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 };

View File

@ -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)

View File

@ -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.",

View File

@ -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');
});
});

View File

@ -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>

View File

@ -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">

View File

@ -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>

View File

@ -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();
});
});

View File

@ -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();

View File

@ -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');
});
});

View File

@ -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 };
}

View File

@ -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');
},
};
}