mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-19 16:55:02 +02:00
feat: Add feature flag for separate browser use (no-changelog) (#33115)
This commit is contained in:
parent
eb629befa0
commit
7e0192ca2d
|
|
@ -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,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export { useInstanceAiBrowserUseExperiment } from './useInstanceAiBrowserUseExperiment';
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user