feat: Add feature flag for separate browser use (no-changelog) (#33115)

This commit is contained in:
Dimitri Lavrenük 2026-06-29 10:58:52 +02:00 committed by GitHub
parent eb629befa0
commit 7e0192ca2d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 174 additions and 17 deletions

View File

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

View File

@ -0,0 +1 @@
export { useInstanceAiBrowserUseExperiment } from './useInstanceAiBrowserUseExperiment';

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, unknown> = {}) {
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();
});
});
});

View File

@ -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
</div>
</div>
<div v-if="isAdmin" :class="$style.card">
<div v-if="isAdmin && isBrowserUseEnabled" :class="$style.card">
<div :class="$style.settingsRow">
<div :class="$style.settingsRowLeft">
<span :class="$style.settingsRowLabel">