From 7e0192ca2dee596a95f5602f1a37a6980fadefe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dimitri=20Lavren=C3=BCk?= <20122620+dlavrenuek@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:58:52 +0200 Subject: [PATCH] feat: Add feature flag for separate browser use (no-changelog) (#33115) --- .../src/app/constants/experiments.ts | 3 + .../experiments/instanceAiBrowserUse/index.ts | 1 + .../useInstanceAiBrowserUseExperiment.test.ts | 39 +++++++++ .../useInstanceAiBrowserUseExperiment.ts | 16 ++++ .../__tests__/SettingsInstanceAiView.test.ts | 30 ++++++- .../instanceAi/components/ConnectionsCard.vue | 18 +++-- .../__tests__/ConnectionsCard.test.ts | 80 +++++++++++++++++-- .../views/SettingsInstanceAiView.vue | 4 +- 8 files changed, 174 insertions(+), 17 deletions(-) create mode 100644 packages/frontend/editor-ui/src/experiments/instanceAiBrowserUse/index.ts create mode 100644 packages/frontend/editor-ui/src/experiments/instanceAiBrowserUse/useInstanceAiBrowserUseExperiment.test.ts create mode 100644 packages/frontend/editor-ui/src/experiments/instanceAiBrowserUse/useInstanceAiBrowserUseExperiment.ts diff --git a/packages/frontend/editor-ui/src/app/constants/experiments.ts b/packages/frontend/editor-ui/src/app/constants/experiments.ts index 13125d99d30..0712bf78bb1 100644 --- a/packages/frontend/editor-ui/src/app/constants/experiments.ts +++ b/packages/frontend/editor-ui/src/app/constants/experiments.ts @@ -131,6 +131,8 @@ export const INSTANCE_AI_SPLIT_EMPTY_STATE_EXPERIMENT = createExperiment( '089_instance_ai_split_empty_state', ); +export const INSTANCE_AI_BROWSER_USE_EXPERIMENT = createExperiment('090_instance_ai_browser_use'); + export const INSTANCE_AI_COMPUTER_USE_EXPERIMENT = createExperiment('091_instance_ai_computer_use'); export const EXPERIMENTS_TO_TRACK = [ @@ -166,5 +168,6 @@ export const EXPERIMENTS_TO_TRACK = [ INSTANCE_AI_MCP_CONNECTIONS_EXPERIMENT.name, INSTANCE_AI_PERSONALIZED_PROMPT_SUGGESTIONS_EXPERIMENT.name, INSTANCE_AI_SPLIT_EMPTY_STATE_EXPERIMENT.name, + INSTANCE_AI_BROWSER_USE_EXPERIMENT.name, INSTANCE_AI_COMPUTER_USE_EXPERIMENT.name, ]; diff --git a/packages/frontend/editor-ui/src/experiments/instanceAiBrowserUse/index.ts b/packages/frontend/editor-ui/src/experiments/instanceAiBrowserUse/index.ts new file mode 100644 index 00000000000..7b9a3c95bf2 --- /dev/null +++ b/packages/frontend/editor-ui/src/experiments/instanceAiBrowserUse/index.ts @@ -0,0 +1 @@ +export { useInstanceAiBrowserUseExperiment } from './useInstanceAiBrowserUseExperiment'; diff --git a/packages/frontend/editor-ui/src/experiments/instanceAiBrowserUse/useInstanceAiBrowserUseExperiment.test.ts b/packages/frontend/editor-ui/src/experiments/instanceAiBrowserUse/useInstanceAiBrowserUseExperiment.test.ts new file mode 100644 index 00000000000..4e313c2e97c --- /dev/null +++ b/packages/frontend/editor-ui/src/experiments/instanceAiBrowserUse/useInstanceAiBrowserUseExperiment.test.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + EXPERIMENTS_TO_TRACK, + INSTANCE_AI_BROWSER_USE_EXPERIMENT, +} from '@/app/constants/experiments'; + +import { useInstanceAiBrowserUseExperiment } from './useInstanceAiBrowserUseExperiment'; + +const getVariant = vi.fn(); + +vi.mock('@/app/stores/posthog.store', () => ({ + usePostHog: vi.fn(() => ({ + getVariant, + })), +})); + +describe('useInstanceAiBrowserUseExperiment', () => { + beforeEach(() => { + getVariant.mockReset(); + }); + + it.each([ + { variant: INSTANCE_AI_BROWSER_USE_EXPERIMENT.variant, enabled: true }, + { variant: INSTANCE_AI_BROWSER_USE_EXPERIMENT.control, enabled: false }, + { variant: undefined, enabled: false }, + ])('returns $enabled when PostHog variant is $variant', ({ variant, enabled }) => { + getVariant.mockReturnValue(variant); + + const { isFeatureEnabled } = useInstanceAiBrowserUseExperiment(); + + expect(isFeatureEnabled.value).toBe(enabled); + expect(getVariant).toHaveBeenCalledWith(INSTANCE_AI_BROWSER_USE_EXPERIMENT.name); + }); + + it('registers the experiment for centralized enrollment tracking', () => { + expect(EXPERIMENTS_TO_TRACK).toContain(INSTANCE_AI_BROWSER_USE_EXPERIMENT.name); + }); +}); diff --git a/packages/frontend/editor-ui/src/experiments/instanceAiBrowserUse/useInstanceAiBrowserUseExperiment.ts b/packages/frontend/editor-ui/src/experiments/instanceAiBrowserUse/useInstanceAiBrowserUseExperiment.ts new file mode 100644 index 00000000000..d1831630a0b --- /dev/null +++ b/packages/frontend/editor-ui/src/experiments/instanceAiBrowserUse/useInstanceAiBrowserUseExperiment.ts @@ -0,0 +1,16 @@ +import { computed } from 'vue'; + +import { INSTANCE_AI_BROWSER_USE_EXPERIMENT } from '@/app/constants/experiments'; +import { usePostHog } from '@/app/stores/posthog.store'; + +export function useInstanceAiBrowserUseExperiment() { + const posthogStore = usePostHog(); + + const isFeatureEnabled = computed( + () => + posthogStore.getVariant(INSTANCE_AI_BROWSER_USE_EXPERIMENT.name) === + INSTANCE_AI_BROWSER_USE_EXPERIMENT.variant, + ); + + return { isFeatureEnabled }; +} diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/SettingsInstanceAiView.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/SettingsInstanceAiView.test.ts index 05bdebeb102..d4081e59478 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/SettingsInstanceAiView.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/SettingsInstanceAiView.test.ts @@ -50,15 +50,21 @@ vi.mock('@/app/utils/rbac/permissions', () => ({ hasPermission: vi.fn().mockReturnValue(true), })); -const { mcpConnectionsExperimentMock, computerUseExperimentMock } = vi.hoisted(() => ({ - mcpConnectionsExperimentMock: vi.fn(), - computerUseExperimentMock: vi.fn(), -})); +const { mcpConnectionsExperimentMock, computerUseExperimentMock, browserUseExperimentMock } = + vi.hoisted(() => ({ + mcpConnectionsExperimentMock: vi.fn(), + browserUseExperimentMock: vi.fn(), + computerUseExperimentMock: vi.fn(), + })); vi.mock('@/experiments/instanceAiMcpConnections', () => ({ useInstanceAiMcpConnectionsExperiment: mcpConnectionsExperimentMock, })); +vi.mock('@/experiments/instanceAiBrowserUse', () => ({ + useInstanceAiBrowserUseExperiment: browserUseExperimentMock, +})); + vi.mock('@/experiments/instanceAiComputerUse', () => ({ useInstanceAiComputerUseExperiment: computerUseExperimentMock, })); @@ -111,6 +117,7 @@ describe('SettingsInstanceAiView', () => { beforeEach(() => { vi.clearAllMocks(); mcpConnectionsExperimentMock.mockReturnValue({ isFeatureEnabled: ref(true) }); + browserUseExperimentMock.mockReturnValue({ isFeatureEnabled: ref(true) }); computerUseExperimentMock.mockReturnValue({ isFeatureEnabled: ref(true) }); const pinia = createTestingPinia({ stubActions: false }); setActivePinia(pinia); @@ -272,6 +279,21 @@ describe('SettingsInstanceAiView', () => { }); }); + describe('Browser use settings', () => { + it('shows the browser use toggle when the experiment is enabled', () => { + const { getByTestId } = renderComponent(); + expect(getByTestId('n8n-agent-browser-use-toggle')).toBeVisible(); + }); + + it('hides the browser use toggle when the experiment is disabled', () => { + browserUseExperimentMock.mockReturnValue({ isFeatureEnabled: ref(false) }); + + const { queryByTestId } = renderComponent(); + + expect(queryByTestId('n8n-agent-browser-use-toggle')).toBeNull(); + }); + }); + describe('Computer use settings', () => { it('shows the computer use toggle when the experiment is enabled', () => { const { getByTestId } = renderComponent(); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConnectionsCard.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConnectionsCard.vue index 83b13650221..b7ac07faad2 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConnectionsCard.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConnectionsCard.vue @@ -10,6 +10,7 @@ import { INSTANCE_AI_TOOLS_CONNECTION_MODAL_KEY, } from '@/app/constants/modals'; import { useInstanceAiMcpConnectionsExperiment } from '@/experiments/instanceAiMcpConnections'; +import { useInstanceAiBrowserUseExperiment } from '@/experiments/instanceAiBrowserUse'; import { useInstanceAiComputerUseExperiment } from '@/experiments/instanceAiComputerUse'; import { useInstanceAiSettingsStore } from '../instanceAiSettings.store'; import { useInstanceAiMcpStore } from '../instanceAiMcp.store'; @@ -31,6 +32,7 @@ const store = useInstanceAiSettingsStore(); const mcpStore = useInstanceAiMcpStore(); const mcpTelemetry = useInstanceAiMcpTelemetry(); const { isFeatureEnabled: isMcpFeatureEnabled } = useInstanceAiMcpConnectionsExperiment(); +const { isFeatureEnabled: isBrowserUseEnabled } = useInstanceAiBrowserUseExperiment(); const { isFeatureEnabled: isComputerUseEnabled } = useInstanceAiComputerUseExperiment(); const props = defineProps<{ @@ -38,15 +40,21 @@ const props = defineProps<{ }>(); const isMcpEnabled = computed(() => isMcpFeatureEnabled.value && store.settings?.mcpAccessEnabled); -const singletonConnections = computed(() => - isComputerUseEnabled.value +const singletonConnections = computed(() => { + const filteredTypes: SingletonConnectionType[] = []; + if (!isBrowserUseEnabled.value) filteredTypes.push('browser-use'); + if (!isComputerUseEnabled.value) filteredTypes.push('computer-use'); + return !filteredTypes.length ? store.connections - : store.connections.filter((connection) => connection.type !== 'computer-use'), -); + : store.connections.filter(({ type }) => !filteredTypes.includes(type)); +}); + const mcpConnections = computed(() => (isMcpEnabled.value ? mcpStore.connections : [])); const isVisible = computed(() => { const anyChannelEnabled = - !store.isLocalGatewayDisabledByAdmin || store.isBrowserUseEnabledByAdmin || isMcpEnabled.value; + (!store.isLocalGatewayDisabledByAdmin && isComputerUseEnabled.value) || + (store.isBrowserUseEnabledByAdmin && isBrowserUseEnabled.value) || + isMcpEnabled.value; const statusReady = store.gatewayStatusLoaded || store.browserStatusLoaded || diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/ConnectionsCard.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/ConnectionsCard.test.ts index 16648a21386..661341e1a8e 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/ConnectionsCard.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/ConnectionsCard.test.ts @@ -12,15 +12,22 @@ vi.mock('@n8n/i18n', async (importOriginal) => ({ }), })); -const { mcpExperimentMock, computerUseExperimentMock } = vi.hoisted(() => ({ - mcpExperimentMock: vi.fn(), - computerUseExperimentMock: vi.fn(), -})); +const { mcpExperimentMock, browserUseExperimentMock, computerUseExperimentMock } = vi.hoisted( + () => ({ + mcpExperimentMock: vi.fn(), + browserUseExperimentMock: vi.fn(), + computerUseExperimentMock: vi.fn(), + }), +); vi.mock('@/experiments/instanceAiMcpConnections', () => ({ useInstanceAiMcpConnectionsExperiment: mcpExperimentMock, })); +vi.mock('@/experiments/instanceAiBrowserUse', () => ({ + useInstanceAiBrowserUseExperiment: browserUseExperimentMock, +})); + vi.mock('@/experiments/instanceAiComputerUse', () => ({ useInstanceAiComputerUseExperiment: computerUseExperimentMock, })); @@ -61,13 +68,20 @@ const COMPUTER_USE_CONNECTION = { status: 'disconnected' as const, }; +const BROWSER_USE_CONNECTION = { + type: 'browser-use' as const, + name: 'browser-use-row', + subtitle: 'subtitle', + status: 'disconnected' as const, +}; + function makeSettingsStore(overrides: Record = {}) { return { - connections: [COMPUTER_USE_CONNECTION], + connections: [COMPUTER_USE_CONNECTION, BROWSER_USE_CONNECTION], settings: { mcpAccessEnabled: false }, isLocalGatewayDisabledByAdmin: false, isLocalGatewayDisabled: false, - isBrowserUseEnabledByAdmin: false, + isBrowserUseEnabledByAdmin: true, gatewayStatusLoaded: true, browserStatusLoaded: true, fetch: vi.fn(), @@ -96,15 +110,37 @@ describe('ConnectionsCard', () => { setActivePinia(createTestingPinia({ stubActions: false })); mcpExperimentMock.mockReturnValue({ isFeatureEnabled: ref(false) }); computerUseExperimentMock.mockReturnValue({ isFeatureEnabled: ref(true) }); + browserUseExperimentMock.mockReturnValue({ isFeatureEnabled: ref(true) }); settingsStoreMock.mockReturnValue(makeSettingsStore()); }); + it('renders the browser use row when the experiment is enabled', () => { + const { getByText } = renderComponent(); + expect(getByText('browser-use-row')).toBeVisible(); + }); + it('renders the computer use row when the experiment is enabled', () => { const { getByText } = renderComponent(); expect(getByText('computer-use-row')).toBeVisible(); }); - describe('when the experiment is disabled', () => { + describe('when browser use experiment is disabled', () => { + beforeEach(() => { + browserUseExperimentMock.mockReturnValue({ isFeatureEnabled: ref(false) }); + }); + + it('hides the browser use row', () => { + const { queryByText } = renderComponent(); + expect(queryByText('browser-use-row')).toBeNull(); + }); + + it('keeps other connection rows visible', () => { + const { getByText } = renderComponent(); + expect(getByText('computer-use-row')).toBeVisible(); + }); + }); + + describe('when computer use experiment is disabled', () => { beforeEach(() => { computerUseExperimentMock.mockReturnValue({ isFeatureEnabled: ref(false) }); }); @@ -115,6 +151,9 @@ describe('ConnectionsCard', () => { }); it('hides the empty-state CTA and shows the simplified title', () => { + settingsStoreMock.mockReturnValue( + makeSettingsStore({ connections: [COMPUTER_USE_CONNECTION] }), + ); const { queryByTestId, getByText } = renderComponent(); expect(queryByTestId('instance-ai-connections-empty-cta')).toBeNull(); expect(getByText('instanceAi.connections.empty.titleNoComputerUse')).toBeVisible(); @@ -131,4 +170,31 @@ describe('ConnectionsCard', () => { const { getByTestId } = renderComponent(); expect(getByTestId('instance-ai-connections-empty-cta')).toBeVisible(); }); + + describe('card visibility', () => { + it('hides the entire card when no channel is enabled', () => { + browserUseExperimentMock.mockReturnValue({ isFeatureEnabled: ref(false) }); + computerUseExperimentMock.mockReturnValue({ isFeatureEnabled: ref(false) }); + const { queryByText } = renderComponent(); + expect(queryByText('instanceAi.connections.title')).toBeNull(); + }); + + it('hides the card when computer use is the only enabled channel but the local gateway is disabled by admin', () => { + browserUseExperimentMock.mockReturnValue({ isFeatureEnabled: ref(false) }); + settingsStoreMock.mockReturnValue(makeSettingsStore({ isLocalGatewayDisabledByAdmin: true })); + const { queryByText } = renderComponent(); + expect(queryByText('instanceAi.connections.title')).toBeNull(); + }); + + it('shows the card via MCP even when both singleton experiments are disabled', () => { + browserUseExperimentMock.mockReturnValue({ isFeatureEnabled: ref(false) }); + computerUseExperimentMock.mockReturnValue({ isFeatureEnabled: ref(false) }); + mcpExperimentMock.mockReturnValue({ isFeatureEnabled: ref(true) }); + settingsStoreMock.mockReturnValue( + makeSettingsStore({ settings: { mcpAccessEnabled: true } }), + ); + const { getByText } = renderComponent(); + expect(getByText('instanceAi.connections.title')).toBeVisible(); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/views/SettingsInstanceAiView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/views/SettingsInstanceAiView.vue index 4f89e3c5971..1bd4b8fc588 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/views/SettingsInstanceAiView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/views/SettingsInstanceAiView.vue @@ -5,6 +5,7 @@ import { ElSwitch } from 'element-plus'; import { useI18n } from '@n8n/i18n'; import { useDocumentTitle } from '@/app/composables/useDocumentTitle'; import { useInstanceAiMcpConnectionsExperiment } from '@/experiments/instanceAiMcpConnections'; +import { useInstanceAiBrowserUseExperiment } from '@/experiments/instanceAiBrowserUse'; import { useInstanceAiComputerUseExperiment } from '@/experiments/instanceAiComputerUse'; import type { InstanceAiPermissions, InstanceAiPermissionMode } from '@n8n/api-types'; import type { BaseTextKey } from '@n8n/i18n'; @@ -22,6 +23,7 @@ const store = useInstanceAiSettingsStore(); const { isFeatureEnabled: isMcpConnectionsExperimentEnabled } = useInstanceAiMcpConnectionsExperiment(); +const { isFeatureEnabled: isBrowserUseEnabled } = useInstanceAiBrowserUseExperiment(); const { isFeatureEnabled: isComputerUseExperimentEnabled } = useInstanceAiComputerUseExperiment(); const isAdmin = computed(() => store.canManage); @@ -169,7 +171,7 @@ function handlePermissionChange(key: keyof InstanceAiPermissions, value: Instanc -
+