From d36abb5a3ad6c1c698e94316914f08aac512b2fc Mon Sep 17 00:00:00 2001 From: Csaba Tuncsik Date: Thu, 17 Jul 2025 16:06:21 +0200 Subject: [PATCH] feat(editor): Using special env vars as feature flags in the frontend (#17355) Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- cypress/e2e/env-feature-flags.cy.ts | 37 +++ cypress/scripts/run-e2e.js | 14 +- cypress/support/commands.ts | 21 +- cypress/support/index.ts | 7 +- .../@n8n/api-types/src/frontend-settings.ts | 4 + .../cli/src/controllers/e2e.controller.ts | 45 ++- .../__tests__/frontend.service.test.ts | 269 ++++++++++++++++++ packages/cli/src/services/frontend.service.ts | 18 +- .../editor-ui/src/__tests__/defaults.ts | 1 + .../env-feature-flag/EnvFeatureFlag.test.ts | 163 +++++++++++ .../env-feature-flag/EnvFeatureFlag.vue | 27 ++ .../env-feature-flag/useEnvFeatureFlag.ts | 30 ++ packages/frontend/editor-ui/vite.config.mts | 2 +- 13 files changed, 632 insertions(+), 6 deletions(-) create mode 100644 cypress/e2e/env-feature-flags.cy.ts create mode 100644 packages/cli/src/services/__tests__/frontend.service.test.ts create mode 100644 packages/frontend/editor-ui/src/features/env-feature-flag/EnvFeatureFlag.test.ts create mode 100644 packages/frontend/editor-ui/src/features/env-feature-flag/EnvFeatureFlag.vue create mode 100644 packages/frontend/editor-ui/src/features/env-feature-flag/useEnvFeatureFlag.ts diff --git a/cypress/e2e/env-feature-flags.cy.ts b/cypress/e2e/env-feature-flags.cy.ts new file mode 100644 index 0000000000..55bbfb78d7 --- /dev/null +++ b/cypress/e2e/env-feature-flags.cy.ts @@ -0,0 +1,37 @@ +describe('Environment Feature Flags', () => { + it('should set feature flags at runtime and load it back in envFeatureFlags from backend settings', () => { + cy.setEnvFeatureFlags({ + N8N_ENV_FEAT_TEST: true, + }); + cy.signinAsOwner(); + cy.intercept('GET', '/rest/settings').as('getSettings'); + cy.visit('/'); + cy.wait('@getSettings').then((interception) => { + expect(interception.response?.body.data.envFeatureFlags).to.be.an('object'); + expect(interception.response?.body.data.envFeatureFlags['N8N_ENV_FEAT_TEST']).to.equal( + 'true', + ); + }); + }); + + it('should reset feature flags at runtime', () => { + cy.setEnvFeatureFlags({ + N8N_ENV_FEAT_TEST: true, + }); + cy.signinAsOwner(); + cy.intercept('GET', '/rest/settings').as('getSettings'); + cy.visit('/'); + cy.wait('@getSettings').then((interception) => { + expect(interception.response?.body.data.envFeatureFlags['N8N_ENV_FEAT_TEST']).to.equal( + 'true', + ); + }); + + cy.clearEnvFeatureFlags(); + cy.visit('/'); + cy.wait('@getSettings').then((interception) => { + expect(interception.response?.body.data.envFeatureFlags).to.be.an('object'); + expect(interception.response?.body.data.envFeatureFlags['N8N_ENV_FEAT_TEST']).to.be.undefined; + }); + }); +}); diff --git a/cypress/scripts/run-e2e.js b/cypress/scripts/run-e2e.js index 597388d413..db50583b71 100755 --- a/cypress/scripts/run-e2e.js +++ b/cypress/scripts/run-e2e.js @@ -14,6 +14,14 @@ function runTests(options) { process.env.E2E_TESTS = 'true'; process.env.NODE_OPTIONS = '--dns-result-order=ipv4first'; + // Automatically pass through any N8N_ENV_FEAT_* environment variables + Object.keys(process.env).forEach((key) => { + if (key.startsWith('N8N_ENV_FEAT_')) { + // These are already in process.env and will be inherited by the spawned process + console.log(`Passing through environment feature flag: ${key}=${process.env[key]}`); + } + }); + if (options.customEnv) { Object.keys(options.customEnv).forEach((key) => { process.env[key] = options.customEnv[key]; @@ -21,7 +29,11 @@ function runTests(options) { } const cmd = `start-server-and-test ${options.startCommand} ${options.url} '${options.testCommand}'`; - const testProcess = spawn(cmd, [], { stdio: 'inherit', shell: true }); + const testProcess = spawn(cmd, [], { + stdio: 'inherit', + shell: true, + env: process.env, // TODO: Maybe pass only the necessary environment variables instead of all + }); // Listen for termination signals to properly kill the test process process.on('SIGINT', () => { diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts index 0417bf71f7..91e9f540c7 100644 --- a/cypress/support/commands.ts +++ b/cypress/support/commands.ts @@ -1,5 +1,5 @@ import 'cypress-real-events'; -import type { FrontendSettings } from '@n8n/api-types'; +import type { FrontendSettings, N8nEnvFeatFlags } from '@n8n/api-types'; import FakeTimers from '@sinonjs/fake-timers'; import { @@ -115,6 +115,25 @@ Cypress.Commands.add('disableFeature', (feature: string) => setFeature(feature, Cypress.Commands.add('enableQueueMode', () => setQueueMode(true)); Cypress.Commands.add('disableQueueMode', () => setQueueMode(false)); +const setEnvFeatureFlags = (flags: N8nEnvFeatFlags) => + cy.request('PATCH', `${BACKEND_BASE_URL}/rest/e2e/env-feature-flags`, { + flags, + }); + +const getEnvFeatureFlags = () => + cy.request('GET', `${BACKEND_BASE_URL}/rest/e2e/env-feature-flags`); + +// Environment feature flags commands (using E2E API) +Cypress.Commands.add('setEnvFeatureFlags', (flags: N8nEnvFeatFlags) => + setEnvFeatureFlags(flags).then((response) => response.body.data), +); +Cypress.Commands.add('clearEnvFeatureFlags', () => + setEnvFeatureFlags({}).then((response) => response.body.data), +); +Cypress.Commands.add('getEnvFeatureFlags', () => + getEnvFeatureFlags().then((response) => response.body.data), +); + Cypress.Commands.add('grantBrowserPermissions', (...permissions: string[]) => { if (Cypress.isBrowser('chrome')) { cy.wrap( diff --git a/cypress/support/index.ts b/cypress/support/index.ts index 19fd0497c6..784e8b6001 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -1,7 +1,7 @@ // Load type definitions that come with Cypress module /// -import type { FrontendSettings, PushPayload, PushType } from '@n8n/api-types'; +import type { FrontendSettings, PushPayload, PushType, N8nEnvFeatFlags } from '@n8n/api-types'; Cypress.Keyboard.defaults({ keystrokeDelay: 0, @@ -51,6 +51,11 @@ declare global { enableQueueMode(): void; disableQueueMode(): void; changeQuota(feature: string, value: number): void; + setEnvFeatureFlags( + flags: N8nEnvFeatFlags, + ): Chainable<{ success: boolean; flags?: N8nEnvFeatFlags; error?: string }>; + clearEnvFeatureFlags(): Chainable<{ success: boolean; flags: N8nEnvFeatFlags }>; + getEnvFeatureFlags(): Chainable; waitForLoad(waitForIntercepts?: boolean): void; grantBrowserPermissions(...permissions: string[]): void; readClipboard(): Chainable; diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index d84952f427..2d4adf9cdd 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -202,6 +202,7 @@ export interface FrontendSettings { /** Backend modules that were initialized during startup. */ activeModules: string[]; + envFeatureFlags: N8nEnvFeatFlags; } export type FrontendModuleSettings = { @@ -218,3 +219,6 @@ export type FrontendModuleSettings = { dateRanges: InsightsDateRange[]; }; }; + +export type N8nEnvFeatFlagValue = boolean | string | number | undefined; +export type N8nEnvFeatFlags = Record<`N8N_ENV_FEAT_${Uppercase}`, N8nEnvFeatFlagValue>; diff --git a/packages/cli/src/controllers/e2e.controller.ts b/packages/cli/src/controllers/e2e.controller.ts index 1d6b041c1c..55b5ef622f 100644 --- a/packages/cli/src/controllers/e2e.controller.ts +++ b/packages/cli/src/controllers/e2e.controller.ts @@ -3,7 +3,7 @@ import { Logger } from '@n8n/backend-common'; import type { BooleanLicenseFeature, NumericLicenseFeature } from '@n8n/constants'; import { LICENSE_FEATURES, LICENSE_QUOTAS, UNLIMITED_LICENSE_QUOTA } from '@n8n/constants'; import { SettingsRepository, UserRepository } from '@n8n/db'; -import { Patch, Post, RestController } from '@n8n/decorators'; +import { Get, Patch, Post, RestController } from '@n8n/decorators'; import { Container } from '@n8n/di'; import { Request } from 'express'; import { v4 as uuid } from 'uuid'; @@ -17,6 +17,7 @@ import { License } from '@/license'; import { MfaService } from '@/mfa/mfa.service'; import { Push } from '@/push'; import { CacheService } from '@/services/cache/cache.service'; +import { FrontendService } from '@/services/frontend.service'; import { PasswordUtility } from '@/services/password.utility'; if (!inE2ETests) { @@ -154,6 +155,7 @@ export class E2EController { private readonly passwordUtility: PasswordUtility, private readonly eventBus: MessageEventBus, private readonly userRepository: UserRepository, + private readonly frontendService: FrontendService, ) { license.isLicensed = (feature: BooleanLicenseFeature) => this.enabledFeatures[feature] ?? false; @@ -207,6 +209,47 @@ export class E2EController { return { success: true, message: `Queue mode set to ${config.getEnv('executions.mode')}` }; } + @Get('/env-feature-flags', { skipAuth: true }) + async getEnvFeatureFlags() { + const currentFlags = this.frontendService.getSettings().envFeatureFlags; + return currentFlags; + } + + @Patch('/env-feature-flags', { skipAuth: true }) + async setEnvFeatureFlags(req: Request<{}, {}, { flags: Record }>) { + const { flags } = req.body; + + // Validate that all flags start with N8N_ENV_FEAT_ + for (const key of Object.keys(flags)) { + if (!key.startsWith('N8N_ENV_FEAT_')) { + return { + success: false, + message: `Invalid flag key: ${key}. Must start with N8N_ENV_FEAT_`, + }; + } + } + + // Clear existing N8N_ENV_FEAT_ environment variables + for (const key of Object.keys(process.env)) { + if (key.startsWith('N8N_ENV_FEAT_')) { + delete process.env[key]; + } + } + + // Set new environment variables + for (const [key, value] of Object.entries(flags)) { + process.env[key] = value; + } + + // Return the current environment feature flags + const currentFlags = this.frontendService.getSettings().envFeatureFlags; + return { + success: true, + message: 'Environment feature flags updated', + flags: currentFlags, + }; + } + private resetFeatures() { for (const feature of Object.keys(this.enabledFeatures)) { this.enabledFeatures[feature as BooleanLicenseFeature] = false; diff --git a/packages/cli/src/services/__tests__/frontend.service.test.ts b/packages/cli/src/services/__tests__/frontend.service.test.ts new file mode 100644 index 0000000000..cf5207c27f --- /dev/null +++ b/packages/cli/src/services/__tests__/frontend.service.test.ts @@ -0,0 +1,269 @@ +import { mock } from 'jest-mock-extended'; +import type { GlobalConfig, SecurityConfig } from '@n8n/config'; +import type { Logger, LicenseState, ModuleRegistry } from '@n8n/backend-common'; +import type { InstanceSettings, BinaryDataConfig } from 'n8n-core'; +import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials'; +import type { CredentialTypes } from '@/credential-types'; +import type { CredentialsOverwrites } from '@/credentials-overwrites'; +import type { License } from '@/license'; +import type { UserManagementMailer } from '@/user-management/email'; +import type { UrlService } from '@/services/url.service'; +import type { PushConfig } from '@/push/push.config'; +import type { MfaService } from '@/mfa/mfa.service'; + +import { FrontendService } from '@/services/frontend.service'; + +describe('FrontendService', () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = process.env; + jest.clearAllMocks(); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('envFeatureFlags functionality', () => { + const createMockService = () => { + const globalConfig = mock({ + database: { type: 'sqlite' }, + endpoints: { rest: 'rest' }, + diagnostics: { enabled: false }, + templates: { enabled: false, host: '' }, + nodes: { communityPackages: { enabled: false } }, + tags: { disabled: false }, + logging: { level: 'info' }, + hiringBanner: { enabled: false }, + versionNotifications: { + enabled: false, + endpoint: '', + whatsNewEnabled: false, + whatsNewEndpoint: '', + infoUrl: '', + }, + personalization: { enabled: false }, + defaultLocale: 'en', + auth: { cookie: { secure: false } }, + generic: { releaseChannel: 'stable', timezone: 'UTC' }, + publicApi: { path: 'api', swaggerUiDisabled: false }, + workflows: { callerPolicyDefaultOption: 'workflowsFromSameOwner' }, + executions: { pruneData: false, pruneDataMaxAge: 336, pruneDataMaxCount: 10000 }, + hideUsagePage: false, + license: { tenantId: 1 }, + mfa: { enabled: false }, + deployment: { type: 'default' }, + workflowHistory: { enabled: false }, + partialExecutions: { version: 1 }, + path: '', + sso: { + ldap: { loginEnabled: false }, + saml: { loginEnabled: false }, + oidc: { loginEnabled: false }, + }, + }); + + const logger = mock(); + const instanceSettings = mock({ + isDocker: false, + instanceId: 'test-instance', + isMultiMain: false, + hostId: 'test-host', + staticCacheDir: '/tmp/test-cache', + }); + + const loadNodesAndCredentials = mock({ + addPostProcessor: jest.fn(), + types: { + credentials: [], + nodes: [], + }, + }); + + const binaryDataConfig = mock({ + mode: 'default', + availableModes: ['default'], + }); + + const credentialTypes = mock({ + getParentTypes: jest.fn().mockReturnValue([]), + }); + + const credentialsOverwrites = mock({ + getAll: jest.fn().mockReturnValue({}), + }); + + const license = mock({ + getUsersLimit: jest.fn().mockReturnValue(100), + getPlanName: jest.fn().mockReturnValue('Community'), + getConsumerId: jest.fn().mockReturnValue('test-consumer'), + isSharingEnabled: jest.fn().mockReturnValue(false), + isLogStreamingEnabled: jest.fn().mockReturnValue(false), + isLdapEnabled: jest.fn().mockReturnValue(false), + isSamlEnabled: jest.fn().mockReturnValue(false), + isAdvancedExecutionFiltersEnabled: jest.fn().mockReturnValue(false), + isVariablesEnabled: jest.fn().mockReturnValue(false), + isSourceControlLicensed: jest.fn().mockReturnValue(false), + isExternalSecretsEnabled: jest.fn().mockReturnValue(false), + isLicensed: jest.fn().mockReturnValue(false), + isDebugInEditorLicensed: jest.fn().mockReturnValue(false), + isWorkflowHistoryLicensed: jest.fn().mockReturnValue(false), + isWorkerViewLicensed: jest.fn().mockReturnValue(false), + isAdvancedPermissionsLicensed: jest.fn().mockReturnValue(false), + isApiKeyScopesEnabled: jest.fn().mockReturnValue(false), + getVariablesLimit: jest.fn().mockReturnValue(0), + getTeamProjectLimit: jest.fn().mockReturnValue(0), + isBinaryDataS3Licensed: jest.fn().mockReturnValue(false), + isAiAssistantEnabled: jest.fn().mockReturnValue(false), + isAskAiEnabled: jest.fn().mockReturnValue(false), + isAiCreditsEnabled: jest.fn().mockReturnValue(false), + getAiCredits: jest.fn().mockReturnValue(0), + isFoldersEnabled: jest.fn().mockReturnValue(false), + }); + + const mailer = mock({ + isEmailSetUp: false, + }); + + const urlService = mock({ + getInstanceBaseUrl: jest.fn().mockReturnValue('http://localhost:5678'), + getWebhookBaseUrl: jest.fn().mockReturnValue('http://localhost:5678'), + }); + + const securityConfig = mock({ + blockFileAccessToN8nFiles: false, + }); + + const pushConfig = mock({ + backend: 'websocket', + }); + + const licenseState = mock({ + isOidcLicensed: jest.fn().mockReturnValue(false), + isMFAEnforcementLicensed: jest.fn().mockReturnValue(false), + getMaxWorkflowsWithEvaluations: jest.fn().mockReturnValue(0), + }); + + const moduleRegistry = mock({ + getActiveModules: jest.fn().mockReturnValue([]), + }); + + const mfaService = mock({ + isMFAEnforced: jest.fn().mockReturnValue(false), + }); + + return new FrontendService( + globalConfig, + logger, + loadNodesAndCredentials, + credentialTypes, + credentialsOverwrites, + license, + mailer, + instanceSettings, + urlService, + securityConfig, + pushConfig, + binaryDataConfig, + licenseState, + moduleRegistry, + mfaService, + ); + }; + + describe('collectEnvFeatureFlags', () => { + it('should collect environment variables with N8N_ENV_FEAT_ prefix', () => { + process.env = { + N8N_ENV_FEAT_TEST_FLAG: 'true', + N8N_ENV_FEAT_ANOTHER_FLAG: 'false', + N8N_ENV_FEAT_NUMERIC_FLAG: '123', + REGULAR_ENV_VAR: 'should-not-be-included', + N8N_OTHER_PREFIX: 'should-not-be-included', + }; + + const service = createMockService(); + const collectEnvFeatureFlags = (service as any).collectEnvFeatureFlags.bind(service); + const result = collectEnvFeatureFlags(); + + expect(result).toEqual({ + N8N_ENV_FEAT_TEST_FLAG: 'true', + N8N_ENV_FEAT_ANOTHER_FLAG: 'false', + N8N_ENV_FEAT_NUMERIC_FLAG: '123', + }); + }); + + it('should return empty object when no N8N_ENV_FEAT_ variables are set', () => { + process.env = { + REGULAR_ENV_VAR: 'value', + N8N_OTHER_PREFIX: 'value', + }; + + const service = createMockService(); + const collectEnvFeatureFlags = (service as any).collectEnvFeatureFlags.bind(service); + const result = collectEnvFeatureFlags(); + + expect(result).toEqual({}); + }); + + it('should filter out undefined environment variable values', () => { + process.env = { + N8N_ENV_FEAT_DEFINED_FLAG: 'true', + N8N_ENV_FEAT_UNDEFINED_FLAG: undefined, + }; + + const service = createMockService(); + const collectEnvFeatureFlags = (service as any).collectEnvFeatureFlags.bind(service); + const result = collectEnvFeatureFlags(); + + expect(result).toEqual({ + N8N_ENV_FEAT_DEFINED_FLAG: 'true', + // N8N_ENV_FEAT_UNDEFINED_FLAG should be filtered out + }); + }); + }); + + describe('settings integration', () => { + it('should include envFeatureFlags in initial settings', () => { + process.env = { + N8N_ENV_FEAT_INIT_FLAG: 'true', + N8N_ENV_FEAT_ANOTHER_FLAG: 'false', + }; + + const service = createMockService(); + + expect(service.settings.envFeatureFlags).toEqual({ + N8N_ENV_FEAT_INIT_FLAG: 'true', + N8N_ENV_FEAT_ANOTHER_FLAG: 'false', + }); + }); + + it('should refresh envFeatureFlags when getSettings is called', () => { + process.env = { + N8N_ENV_FEAT_INITIAL_FLAG: 'true', + }; + + const service = createMockService(); + + // Verify initial state + expect(service.settings.envFeatureFlags).toEqual({ + N8N_ENV_FEAT_INITIAL_FLAG: 'true', + }); + + // Change environment + process.env = { + N8N_ENV_FEAT_INITIAL_FLAG: 'false', + N8N_ENV_FEAT_NEW_FLAG: 'true', + }; + + // getSettings should refresh the flags + const settings = service.getSettings(); + + expect(settings.envFeatureFlags).toEqual({ + N8N_ENV_FEAT_INITIAL_FLAG: 'false', + N8N_ENV_FEAT_NEW_FLAG: 'true', + }); + }); + }); + }); +}); diff --git a/packages/cli/src/services/frontend.service.ts b/packages/cli/src/services/frontend.service.ts index dcc78600a4..40acd807cd 100644 --- a/packages/cli/src/services/frontend.service.ts +++ b/packages/cli/src/services/frontend.service.ts @@ -1,4 +1,4 @@ -import type { FrontendSettings, ITelemetrySettings } from '@n8n/api-types'; +import type { FrontendSettings, ITelemetrySettings, N8nEnvFeatFlags } from '@n8n/api-types'; import { LicenseState, Logger, ModuleRegistry } from '@n8n/backend-common'; import { GlobalConfig, SecurityConfig } from '@n8n/config'; import { LICENSE_FEATURES } from '@n8n/constants'; @@ -66,6 +66,18 @@ export class FrontendService { } } + private collectEnvFeatureFlags(): N8nEnvFeatFlags { + const envFeatureFlags: N8nEnvFeatFlags = {}; + + for (const [key, value] of Object.entries(process.env)) { + if (key.startsWith('N8N_ENV_FEAT_') && value !== undefined) { + envFeatureFlags[key as keyof N8nEnvFeatFlags] = value; + } + } + + return envFeatureFlags; + } + private initSettings() { const instanceBaseUrl = this.urlService.getInstanceBaseUrl(); const restEndpoint = this.globalConfig.endpoints.rest; @@ -260,6 +272,7 @@ export class FrontendService { quota: this.licenseState.getMaxWorkflowsWithEvaluations(), }, activeModules: this.moduleRegistry.getActiveModules(), + envFeatureFlags: this.collectEnvFeatureFlags(), }; } @@ -404,6 +417,9 @@ export class FrontendService { // Refresh evaluation settings this.settings.evaluation.quota = this.licenseState.getMaxWorkflowsWithEvaluations(); + // Refresh environment feature flags + this.settings.envFeatureFlags = this.collectEnvFeatureFlags(); + return this.settings; } diff --git a/packages/frontend/editor-ui/src/__tests__/defaults.ts b/packages/frontend/editor-ui/src/__tests__/defaults.ts index 6681c02f16..ed5bdb6982 100644 --- a/packages/frontend/editor-ui/src/__tests__/defaults.ts +++ b/packages/frontend/editor-ui/src/__tests__/defaults.ts @@ -153,4 +153,5 @@ export const defaultSettings: FrontendSettings = { quota: 0, }, activeModules: [], + envFeatureFlags: {}, }; diff --git a/packages/frontend/editor-ui/src/features/env-feature-flag/EnvFeatureFlag.test.ts b/packages/frontend/editor-ui/src/features/env-feature-flag/EnvFeatureFlag.test.ts new file mode 100644 index 0000000000..5662b83ce4 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/env-feature-flag/EnvFeatureFlag.test.ts @@ -0,0 +1,163 @@ +import { createTestingPinia } from '@pinia/testing'; +import type { FrontendSettings, N8nEnvFeatFlags, N8nEnvFeatFlagValue } from '@n8n/api-types'; +import { createComponentRenderer } from '@/__tests__/render'; +import { mockedStore, type MockedStore } from '@/__tests__/utils'; +import EnvFeatureFlag from '@/features/env-feature-flag/EnvFeatureFlag.vue'; +import { useSettingsStore } from '@/stores/settings.store'; + +const renderComponent = createComponentRenderer(EnvFeatureFlag); + +describe('EnvFeatureFlag', () => { + let settingsStore: MockedStore; + const originalEnv = { ...import.meta.env }; + + beforeEach(() => { + Object.keys(import.meta.env).forEach((key) => { + if (key.startsWith('N8N_ENV_FEAT_')) { + delete (import.meta.env as N8nEnvFeatFlags)[key as keyof N8nEnvFeatFlags]; + } + }); + createTestingPinia(); + + settingsStore = mockedStore(useSettingsStore); + settingsStore.settings = { + envFeatureFlags: {}, + } as FrontendSettings; + }); + + afterEach(() => { + Object.assign(import.meta.env, originalEnv); + }); + + test.each<[N8nEnvFeatFlagValue, Uppercase, boolean]>([ + // Truthy values that should render content + ['true', 'TEST_FLAG', true], + ['enabled', 'TEST_FLAG', true], + ['yes', 'TEST_FLAG', true], + ['1', 'TEST_FLAG', true], + ['on', 'TEST_FLAG', true], + [true, 'TEST_FLAG', true], + + // Falsy values that should not render content + ['false', 'TEST_FLAG', false], + [false, 'TEST_FLAG', false], + ['', 'TEST_FLAG', false], + [undefined, 'TEST_FLAG', false], + [0, 'TEST_FLAG', false], + ])( + 'should %s render slot content when feature flag value is %s', + (value, flagName, shouldRender) => { + const envKey: keyof N8nEnvFeatFlags = `N8N_ENV_FEAT_${flagName}`; + + settingsStore.settings.envFeatureFlags = { + [envKey]: value, + }; + + const { container } = renderComponent({ + props: { + name: flagName, + }, + slots: { + default: '
Feature content
', + }, + }); + + if (shouldRender) { + expect(container.querySelector('[data-testid="slot-content"]')).toBeTruthy(); + } else { + expect(container.querySelector('[data-testid="slot-content"]')).toBeNull(); + } + }, + ); + + it('should work with different flag names', () => { + settingsStore.settings.envFeatureFlags = { + N8N_ENV_FEAT_WORKFLOW_DIFFS: 'true', + N8N_ENV_FEAT_ANOTHER_FEATURE: 'false', + }; + + const { container: container1 } = renderComponent({ + props: { + name: 'WORKFLOW_DIFFS', + }, + slots: { + default: '
Feature 1
', + }, + }); + + const { container: container2 } = renderComponent({ + props: { + name: 'ANOTHER_FEATURE', + }, + slots: { + default: '
Feature 2
', + }, + }); + + expect(container1.querySelector('[data-testid="feature-1"]')).toBeTruthy(); + expect(container2.querySelector('[data-testid="feature-2"]')).toBeNull(); + }); + + describe('runtime vs build-time priority', () => { + it('should prioritize runtime settings over build-time env vars', () => { + // Set build-time env var + (import.meta.env as N8nEnvFeatFlags).N8N_ENV_FEAT_TEST_FLAG = 'true'; + + // Set runtime setting to override + settingsStore.settings.envFeatureFlags = { + N8N_ENV_FEAT_TEST_FLAG: 'false', + }; + + const { container } = renderComponent({ + props: { + name: 'TEST_FLAG', + }, + slots: { + default: '
Feature content
', + }, + }); + + // Should use runtime value (false) over build-time value (true) + expect(container.querySelector('[data-testid="slot-content"]')).toBeNull(); + }); + + it('should fallback to build-time env vars when runtime settings are not available', () => { + // Set build-time env var + (import.meta.env as N8nEnvFeatFlags).N8N_ENV_FEAT_TEST_FLAG = 'true'; + + // Runtime settings are empty + settingsStore.settings.envFeatureFlags = {}; + + const { container } = renderComponent({ + props: { + name: 'TEST_FLAG', + }, + slots: { + default: '
Feature content
', + }, + }); + + // Should use build-time value + expect(container.querySelector('[data-testid="slot-content"]')).toBeTruthy(); + }); + + it('should return false when neither runtime nor build-time values are set', () => { + // No runtime setting + settingsStore.settings.envFeatureFlags = {}; + + // No build-time env var (already cleared in beforeEach) + + const { container } = renderComponent({ + props: { + name: 'TEST_FLAG', + }, + slots: { + default: '
Feature content
', + }, + }); + + // Should default to false + expect(container.querySelector('[data-testid="slot-content"]')).toBeNull(); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/env-feature-flag/EnvFeatureFlag.vue b/packages/frontend/editor-ui/src/features/env-feature-flag/EnvFeatureFlag.vue new file mode 100644 index 0000000000..c33e69bda1 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/env-feature-flag/EnvFeatureFlag.vue @@ -0,0 +1,27 @@ + diff --git a/packages/frontend/editor-ui/src/features/env-feature-flag/useEnvFeatureFlag.ts b/packages/frontend/editor-ui/src/features/env-feature-flag/useEnvFeatureFlag.ts new file mode 100644 index 0000000000..833b6e7b6b --- /dev/null +++ b/packages/frontend/editor-ui/src/features/env-feature-flag/useEnvFeatureFlag.ts @@ -0,0 +1,30 @@ +import { computed } from 'vue'; +import type { N8nEnvFeatFlags } from '@n8n/api-types'; +import { useSettingsStore } from '@/stores/settings.store'; + +export const useEnvFeatureFlag = () => { + const settingsStore = useSettingsStore(); + + const check = computed(() => (flag: Uppercase): boolean => { + const key = `N8N_ENV_FEAT_${flag}` as const; + + // Settings provided by the backend take precedence over build-time or runtime flags + const settingsProvidedEnvFeatFlag = settingsStore.settings.envFeatureFlags?.[key]; + if (settingsProvidedEnvFeatFlag !== undefined) { + return settingsProvidedEnvFeatFlag !== 'false' && !!settingsProvidedEnvFeatFlag; + } + + // "Vite exposes certain constants under the special import.meta.env object. These constants are defined as global variables during dev and statically replaced at build time to make tree-shaking effective." + // See https://vite.dev/guide/env-and-mode.html + const buildTimeValue = (import.meta.env as N8nEnvFeatFlags)[key]; + if (buildTimeValue !== undefined) { + return buildTimeValue !== 'false' && !!buildTimeValue; + } + + return false; + }); + + return { + check, + }; +}; diff --git a/packages/frontend/editor-ui/vite.config.mts b/packages/frontend/editor-ui/vite.config.mts index 4818e93bcb..a42c492f6a 100644 --- a/packages/frontend/editor-ui/vite.config.mts +++ b/packages/frontend/editor-ui/vite.config.mts @@ -141,7 +141,7 @@ export default mergeConfig( plugins, resolve: { alias }, base: publicPath, - envPrefix: 'VUE', + envPrefix: ['VUE', 'N8N_ENV_FEAT'], css: { preprocessorOptions: { scss: {