blob: fe919ebd3bab6e9db6c33985aeb1cc91dfb00f51 [file]
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { theme as antdThemeImport } from 'antd';
import { SupersetClient } from '@superset-ui/core';
import {
type AnyThemeConfig,
type SupersetThemeConfig,
Theme,
ThemeAlgorithm,
ThemeMode,
} from '@apache-superset/core/theme';
import type {
BootstrapThemeDataConfig,
CommonBootstrapData,
} from 'src/types/bootstrapTypes';
import getBootstrapData from 'src/utils/getBootstrapData';
import { LocalStorageAdapter, ThemeController } from '../ThemeController';
jest.mock('../../utils/getBootstrapData');
const mockGetBootstrapData = getBootstrapData as jest.MockedFunction<
typeof getBootstrapData
>;
const mockLocalStorage = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
};
const mockMatchMedia = jest.fn();
const mockThemeFromConfig = jest.fn();
const mockSetConfig = jest.fn();
// Mock data constants
const DEFAULT_THEME: AnyThemeConfig = {
token: {
colorBgBase: '#ededed',
colorTextBase: '#120f0f',
colorLink: '#0062ec',
colorPrimary: '#c96f0f',
colorInfo: '#c96f0f',
colorSuccess: '#3c7c1b',
colorWarning: '#dc9811',
},
};
const DARK_THEME: AnyThemeConfig = {
token: {
colorBgBase: '#141118',
colorTextBase: '#fdc7c7',
colorLink: '#0062ec',
colorPrimary: '#c96f0f',
colorInfo: '#c96f0f',
colorSuccess: '#3c7c1b',
colorWarning: '#dc9811',
},
algorithm: ThemeAlgorithm.DARK,
};
// BootstrapData common template generator
const createMockBootstrapData = (
themeConfig: BootstrapThemeDataConfig = {
default: DEFAULT_THEME,
dark: DARK_THEME,
},
): { common: CommonBootstrapData } => ({
common: {
application_root: '/',
static_assets_prefix: '/static/assets/',
conf: {},
locale: 'en',
feature_flags: {},
language_pack: {},
extra_categorical_color_schemes: [],
extra_sequential_color_schemes: [],
theme: themeConfig,
menu_data: {},
d3_format: {},
d3_time_format: {},
} as any as CommonBootstrapData,
});
const mockThemeObject = {
setConfig: mockSetConfig,
theme: DEFAULT_THEME,
} as unknown as Theme;
// Helper to create a fresh ThemeController with common setup
const createController = (
options: Partial<ConstructorParameters<typeof ThemeController>[0]> = {},
) =>
new ThemeController({
themeObject: mockThemeObject,
...options,
});
// Shared console spies — re-installed in beforeEach so each test starts
// with a fresh call count and a clean implementation.
let consoleSpy: jest.SpyInstance;
let consoleErrorSpy: jest.SpyInstance;
beforeEach(() => {
jest.clearAllMocks();
consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
// Setup DOM environment
Object.defineProperty(window, 'localStorage', {
value: mockLocalStorage,
writable: true,
});
Object.defineProperty(window, 'matchMedia', {
value: mockMatchMedia,
writable: true,
});
mockMatchMedia.mockReturnValue({
matches: false,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
});
mockSetConfig.mockImplementation(() => {});
mockThemeFromConfig.mockReturnValue(mockThemeObject);
// Mock Theme constructor
(Theme as any).fromConfig = mockThemeFromConfig;
// Reset localStorage mocks
mockLocalStorage.getItem.mockReturnValue(null);
mockLocalStorage.setItem.mockImplementation(() => {});
mockLocalStorage.removeItem.mockImplementation(() => {});
// Default BootstrapData
mockGetBootstrapData.mockReturnValue(createMockBootstrapData());
});
afterAll(() => {
consoleSpy.mockRestore();
consoleErrorSpy.mockRestore();
});
// LocalStorageAdapter tests
test('LocalStorageAdapter returns item from localStorage', () => {
const adapter = new LocalStorageAdapter();
mockLocalStorage.getItem.mockReturnValue('test-value');
const result = adapter.getItem('test-key');
expect(mockLocalStorage.getItem).toHaveBeenCalledTimes(1);
expect(mockLocalStorage.getItem).toHaveBeenCalledWith('test-key');
expect(result).toBe('test-value');
});
test('LocalStorageAdapter sets item in localStorage', () => {
const adapter = new LocalStorageAdapter();
adapter.setItem('test-key', 'test-value');
expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(1);
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
'test-key',
'test-value',
);
});
test('LocalStorageAdapter handles localStorage errors while setting an item', () => {
const adapter = new LocalStorageAdapter();
mockLocalStorage.setItem.mockImplementation(() => {
throw new Error('Storage error');
});
adapter.setItem('test-key', 'test-value');
expect(consoleSpy).toHaveBeenCalledTimes(1);
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to write to localStorage:',
expect.any(Error),
);
});
test('LocalStorageAdapter removes item from localStorage', () => {
const adapter = new LocalStorageAdapter();
adapter.removeItem('test-key');
expect(mockLocalStorage.removeItem).toHaveBeenCalledTimes(1);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith('test-key');
});
test('LocalStorageAdapter handles localStorage errors while removing an item', () => {
const adapter = new LocalStorageAdapter();
mockLocalStorage.removeItem.mockImplementation(() => {
throw new Error('Storage error');
});
adapter.removeItem('test-key');
expect(consoleSpy).toHaveBeenCalledTimes(1);
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to remove from localStorage:',
expect.any(Error),
);
});
// ThemeController initialization tests
test('ThemeController initializes with default options', () => {
const controller = createController();
expect(controller.getTheme()).toBe(mockThemeObject);
});
test('ThemeController uses BootstrapData themes when available', () => {
createController();
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.objectContaining({
colorBgBase: '#ededed',
colorPrimary: '#c96f0f',
}),
}),
);
});
test('ThemeController fallbacks to Superset default theme when BootstrapData themes are empty', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: {},
dark: {},
}),
);
const fallbackTheme = {
token: {
colorBgBase: '#ffffff',
colorPrimary: '#1890ff',
},
};
createController({ defaultTheme: fallbackTheme });
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
...fallbackTheme,
algorithm: antdThemeImport.defaultAlgorithm,
}),
);
});
test('ThemeController handles system theme preference', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
const controller = createController();
expect(controller.getCurrentMode()).toBe(ThemeMode.SYSTEM);
});
test('ThemeController handles only default theme', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: {},
}),
);
const controller = createController();
jest.clearAllMocks();
expect(() => controller.setThemeMode(ThemeMode.DARK)).toThrow(
'Theme mode changes are not allowed when only one theme is available',
);
expect(mockSetConfig).not.toHaveBeenCalled();
});
test('ThemeController handles only dark theme', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: {},
dark: DARK_THEME,
}),
);
const lightThemeFallback = {
token: {
colorBgBase: '#fff',
colorTextBase: '#000',
colorPrimary: '#1890ff',
},
};
const controller = createController({ defaultTheme: lightThemeFallback });
expect(mockSetConfig).toHaveBeenCalledTimes(1);
const calledWith = mockSetConfig.mock.calls[0][0];
expect(calledWith.token.colorBgBase).toBe('#fff');
expect(calledWith.token.colorTextBase).toBe('#000');
expect(controller.canSetMode()).toBe(true);
jest.clearAllMocks();
controller.setThemeMode(ThemeMode.DARK);
expect(mockSetConfig).toHaveBeenCalledTimes(1);
});
test('ThemeController handles completely empty BootstrapData', () => {
const fallbackTheme = {
token: {
colorBgBase: '#ffffff',
colorPrimary: '#1890ff',
},
};
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: {},
dark: {},
}),
);
createController({ defaultTheme: fallbackTheme });
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.objectContaining({
colorBgBase: '#ffffff',
colorPrimary: '#1890ff',
}),
algorithm: antdThemeImport.defaultAlgorithm,
}),
);
});
test('ThemeController handles missing theme object', () => {
const fallbackTheme = { token: { colorPrimary: '#fallback' } };
mockGetBootstrapData.mockReturnValue({
common: {
application_root: '/',
static_assets_prefix: '/static/assets/',
conf: {},
locale: 'en',
feature_flags: {},
language_pack: {},
extra_categorical_color_schemes: [],
extra_sequential_color_schemes: [],
menu_data: {},
d3_format: {},
d3_time_format: {},
} as any,
});
createController({ defaultTheme: fallbackTheme });
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.objectContaining({
colorPrimary: '#fallback',
}),
algorithm: antdThemeImport.defaultAlgorithm,
}),
);
});
// Theme Management tests
test('ThemeController updates theme when allowed', () => {
const controller = createController();
const newTheme = {
token: {
colorBgBase: '#000000',
colorPrimary: '#ff0000',
},
};
controller.setTheme(newTheme);
expect(mockSetConfig).toHaveBeenCalledWith(expect.objectContaining(newTheme));
});
test('ThemeController changes theme mode when allowed', () => {
const controller = createController();
jest.clearAllMocks();
controller.setThemeMode(ThemeMode.DARK);
expect(controller.getCurrentMode()).toBe(ThemeMode.DARK);
expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(1);
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
'superset-theme-mode',
ThemeMode.DARK,
);
});
test('ThemeController handles missing theme gracefully', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: {},
}),
);
const controller = createController();
expect(() => controller.setThemeMode(ThemeMode.DARK)).toThrow(
'Theme mode changes are not allowed when only one theme is available',
);
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
expect(consoleSpy).not.toHaveBeenCalled();
});
test('ThemeController does not change mode if already set', () => {
const controller = createController();
jest.clearAllMocks();
controller.setThemeMode(ThemeMode.SYSTEM);
expect(mockLocalStorage.setItem).not.toHaveBeenCalled();
});
test('ThemeController resets to default theme', () => {
const controller = createController();
controller.setThemeMode(ThemeMode.DARK);
controller.resetTheme();
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.objectContaining({
colorBgBase: '#ededed',
colorTextBase: '#120f0f',
}),
}),
);
});
// System Theme Changes tests
test('ThemeController listens to system theme changes', () => {
const mockMediaQuery = {
matches: false,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
};
mockMatchMedia.mockReturnValue(mockMediaQuery);
createController();
expect(mockMediaQuery.addEventListener).toHaveBeenCalledTimes(1);
expect(mockMediaQuery.addEventListener).toHaveBeenCalledWith(
'change',
expect.any(Function),
);
});
test('ThemeController updates theme when system preference changes and mode is SYSTEM', () => {
const mockMediaQuery = {
matches: false,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
};
mockMatchMedia.mockReturnValue(mockMediaQuery);
const controller = createController();
controller.setThemeMode(ThemeMode.SYSTEM);
mockMediaQuery.matches = true;
const changeHandler = mockMediaQuery.addEventListener.mock.calls[0][1];
changeHandler();
expect(mockSetConfig).toHaveBeenCalled();
});
test('ThemeController does not update theme when mode is not SYSTEM', () => {
const mockMediaQuery = {
matches: false,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
};
mockMatchMedia.mockReturnValue(mockMediaQuery);
const controller = createController();
controller.setThemeMode(ThemeMode.DEFAULT);
const initialCallCount = mockSetConfig.mock.calls.length;
mockMediaQuery.matches = true;
const changeHandler = mockMediaQuery.addEventListener.mock.calls[0][1];
changeHandler();
expect(mockSetConfig).toHaveBeenCalledTimes(initialCallCount);
});
test('ThemeController switches to dark theme when system is dark and mode is SYSTEM', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
const mockMediaQueryDark = {
matches: true,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
};
mockMatchMedia.mockReturnValue(mockMediaQueryDark);
const controller = createController();
expect(controller.getCurrentMode()).toBe(ThemeMode.SYSTEM);
expect(mockSetConfig).toHaveBeenCalled();
const lastCall =
mockSetConfig.mock.calls[mockSetConfig.mock.calls.length - 1][0];
expect(lastCall.token.colorBgBase).toBe(DARK_THEME.token!.colorBgBase);
expect(lastCall.token.colorTextBase).toBe(DARK_THEME.token!.colorTextBase);
});
// Persistence tests
test('ThemeController saves theme mode to localStorage', () => {
const controller = createController();
jest.clearAllMocks();
controller.setThemeMode(ThemeMode.DARK);
expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(1);
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
'superset-theme-mode',
ThemeMode.DARK,
);
});
test('ThemeController loads saved theme mode from localStorage', () => {
mockLocalStorage.getItem.mockReturnValue(ThemeMode.DARK);
const controller = createController();
expect(controller.getCurrentMode()).toBe(ThemeMode.DARK);
});
test('ThemeController handles invalid saved theme mode', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: {},
dark: {},
}),
);
mockLocalStorage.getItem.mockReturnValue('invalid-mode' as any);
const controller = createController();
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
});
// Theme Structure tests
test('ThemeController handles theme with token structure', () => {
const controller = createController();
const customTheme = {
token: {
colorBgBase: '#ff0000',
colorTextBase: '#ffffff',
colorPrimary: '#00ff00',
},
};
jest.clearAllMocks();
controller.setTheme(customTheme);
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.objectContaining({
colorBgBase: '#ff0000',
colorTextBase: '#ffffff',
colorPrimary: '#00ff00',
}),
}),
);
});
test('ThemeController preserves algorithm property from dark theme', () => {
const controller = createController();
jest.clearAllMocks();
controller.setThemeMode(ThemeMode.DARK);
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
algorithm: antdThemeImport.darkAlgorithm,
token: expect.objectContaining({
colorBgBase: '#141118',
colorTextBase: '#fdc7c7',
}),
}),
);
});
test('ThemeController handles theme without algorithm property', () => {
const controller = createController();
jest.clearAllMocks();
controller.setThemeMode(ThemeMode.DEFAULT);
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.objectContaining({
colorBgBase: '#ededed',
colorTextBase: '#120f0f',
}),
}),
);
const lastCall =
mockSetConfig.mock.calls[mockSetConfig.mock.calls.length - 1][0];
expect(lastCall.algorithm).toBe(antdThemeImport.defaultAlgorithm);
});
test('ThemeController handles color tokens correctly in theme switching', () => {
const controller = createController();
controller.setThemeMode(ThemeMode.DEFAULT);
let lastCall =
mockSetConfig.mock.calls[mockSetConfig.mock.calls.length - 1][0];
expect(lastCall.token.colorBgBase).toBe('#ededed');
expect(lastCall.token.colorTextBase).toBe('#120f0f');
controller.setThemeMode(ThemeMode.DARK);
lastCall = mockSetConfig.mock.calls[mockSetConfig.mock.calls.length - 1][0];
expect(lastCall.token.colorBgBase).toBe('#141118');
expect(lastCall.token.colorTextBase).toBe('#fdc7c7');
});
// Algorithm Combinations tests
test('ThemeController handles valid algorithm combinations', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: {
...DEFAULT_THEME,
algorithm: [ThemeAlgorithm.DARK, ThemeAlgorithm.COMPACT],
},
dark: DARK_THEME,
}),
);
const controller = createController();
const themeWithAlgorithm = {
...DEFAULT_THEME,
algorithm: [
ThemeAlgorithm.DARK,
ThemeAlgorithm.COMPACT,
] as ThemeAlgorithm[],
};
jest.clearAllMocks();
controller.setTheme(themeWithAlgorithm);
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.objectContaining({
colorBgBase: '#ededed',
colorPrimary: '#c96f0f',
}),
algorithm: [
antdThemeImport.darkAlgorithm,
antdThemeImport.compactAlgorithm,
],
}),
);
});
test('ThemeController handles invalid algorithm combinations', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: {
...DEFAULT_THEME,
algorithm: [ThemeAlgorithm.DARK, ThemeAlgorithm.COMPACT],
},
dark: DARK_THEME,
}),
);
const controller = createController();
const themeWithInvalidAlgorithm = {
...DEFAULT_THEME,
algorithm: ['invalid', 'combination'] as any as ThemeAlgorithm[],
};
jest.clearAllMocks();
controller.setTheme(themeWithInvalidAlgorithm);
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
algorithm: antdThemeImport.defaultAlgorithm,
}),
);
});
// Change Callbacks tests
test('ThemeController calls callback on theme change', () => {
const callback = jest.fn();
const controller = createController({ onChange: callback });
controller.setThemeMode(ThemeMode.DARK);
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(mockThemeObject);
});
test('ThemeController registers additional callbacks', () => {
const callback = jest.fn();
const controller = createController({ onChange: callback });
const additionalCallback = jest.fn();
controller.onChange(additionalCallback);
controller.setThemeMode(ThemeMode.DARK);
expect(callback).toHaveBeenCalled();
expect(additionalCallback).toHaveBeenCalled();
});
test('ThemeController unsubscribes callbacks', () => {
const callback = jest.fn();
const controller = createController({ onChange: callback });
const additionalCallback = jest.fn();
const unsubscribe = controller.onChange(additionalCallback);
unsubscribe();
controller.setThemeMode(ThemeMode.DARK);
expect(additionalCallback).not.toHaveBeenCalled();
});
test('ThemeController handles callback errors', () => {
const callback = jest.fn();
const controller = createController({ onChange: callback });
const errorCallback = jest.fn().mockImplementation(() => {
throw new Error('Callback error');
});
controller.onChange(errorCallback);
controller.setThemeMode(ThemeMode.DARK);
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error in theme change callback:',
expect.any(Error),
);
});
// Error Handling tests
test('ThemeController handles theme application errors', () => {
const controller = createController();
mockSetConfig.mockImplementationOnce(() => {
throw new Error('Theme application error');
});
const fallbackSpy = jest.spyOn(controller as any, 'fallbackToDefaultMode');
fallbackSpy.mockImplementation(() => {
(controller as any).customizations = DEFAULT_THEME;
(controller as any).currentMode = ThemeMode.DEFAULT;
});
controller.setThemeMode(ThemeMode.DARK);
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to apply theme:',
expect.any(Error),
);
expect(fallbackSpy).toHaveBeenCalled();
fallbackSpy.mockRestore();
});
test('ThemeController constructor recovers from corrupted stored theme', () => {
// Simulate corrupted dev theme override in storage
const corruptedTheme = { token: { colorPrimary: '#ff0000' } };
mockLocalStorage.getItem.mockImplementation((key: string) => {
if (key === 'superset-dev-theme-override') {
return JSON.stringify(corruptedTheme);
}
return null;
});
// Mock Theme.fromConfig to return object with toSerializedConfig
mockThemeFromConfig.mockReturnValue({
...mockThemeObject,
toSerializedConfig: () => corruptedTheme,
});
// First call throws (corrupted theme), second call succeeds (fallback)
let callCount = 0;
mockSetConfig.mockImplementation(() => {
callCount += 1;
if (callCount === 1) {
throw new Error('Invalid theme configuration');
}
});
// Should not throw - constructor should recover
const controller = createController();
// Verify recovery happened - use shared consoleSpy to avoid interfering with other tests
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to apply stored theme, clearing invalid overrides:',
expect.any(Error),
);
// Verify invalid overrides were cleared from storage
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-dev-theme-override',
);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-crud-theme-id',
);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-applied-theme-id',
);
// Verify controller is in a valid state
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
});
test('recovery flow: fetchSystemDefaultTheme returns theme → applies fetched theme', async () => {
// Test: fallbackToDefaultMode fetches theme from API and applies it
// Flow: fallbackToDefaultMode → fetchSystemDefaultTheme → applyThemeWithRecovery
const originalFetch = global.fetch;
const controller = createController();
try {
// Mock fetch to return a system default theme from API
const systemTheme = { token: { colorPrimary: '#recovery-theme' } };
const mockFetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({
result: [{ json_data: JSON.stringify(systemTheme) }],
}),
});
global.fetch = mockFetch;
// Track setConfig calls to verify the fetched theme is applied
const setConfigCalls: unknown[] = [];
mockSetConfig.mockImplementation((config: unknown) => {
setConfigCalls.push(config);
});
// Trigger fallbackToDefaultMode (simulates what happens after applyTheme fails)
await (controller as any).fallbackToDefaultMode();
// Verify API was called to fetch system default theme
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/theme/'),
expect.any(Object),
);
// Verify the fetched theme was applied via applyThemeWithRecovery
expect(setConfigCalls.length).toBe(1);
expect(setConfigCalls[0]).toEqual(
expect.objectContaining({
token: expect.objectContaining({ colorPrimary: '#recovery-theme' }),
}),
);
// Verify controller is in default mode
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
} finally {
global.fetch = originalFetch;
}
});
test('recovery flow: both API fetches fail → falls back to cached default theme', async () => {
// Test: When fetchSystemDefaultTheme fails, fallbackToDefaultMode uses cached theme
// Flow: fallbackToDefaultMode → fetchSystemDefaultTheme (fails) → applyTheme(cached)
const originalFetch = global.fetch;
const controller = createController();
try {
// Mock fetch to fail for both API endpoints
const mockFetch = jest.fn().mockRejectedValue(new Error('Network error'));
global.fetch = mockFetch;
// Track setConfig calls
const setConfigCalls: unknown[] = [];
mockSetConfig.mockImplementation((config: unknown) => {
setConfigCalls.push(config);
});
// Trigger fallbackToDefaultMode
await (controller as any).fallbackToDefaultMode();
// Verify fetch was attempted
expect(mockFetch).toHaveBeenCalled();
// Verify fallback to cached default theme was applied via applyTheme
expect(setConfigCalls.length).toBe(1);
expect(setConfigCalls[0]).toEqual(
expect.objectContaining({
token: expect.objectContaining({
colorBgBase: '#ededed', // From DEFAULT_THEME in test setup
}),
}),
);
// Verify controller is in default mode
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
} finally {
global.fetch = originalFetch;
}
});
test('recovery flow: fetched theme fails to apply → falls back to cached default', async () => {
// Test: When applyThemeWithRecovery fails, fallbackToDefaultMode uses cached theme
// Flow: fallbackToDefaultMode → fetchSystemDefaultTheme → applyThemeWithRecovery (fails) → applyTheme(cached)
const originalFetch = global.fetch;
const controller = createController();
try {
// Mock fetch to return a theme
const systemTheme = { token: { colorPrimary: '#bad-theme' } };
const mockFetch = jest.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({
result: [{ json_data: JSON.stringify(systemTheme) }],
}),
});
global.fetch = mockFetch;
// First setConfig call (applyThemeWithRecovery) fails, second (applyTheme) succeeds
const setConfigCalls: unknown[] = [];
mockSetConfig.mockImplementation((config: unknown) => {
setConfigCalls.push(config);
if (setConfigCalls.length === 1) {
throw new Error('Fetched theme failed to apply');
}
});
// Trigger fallbackToDefaultMode
await (controller as any).fallbackToDefaultMode();
// Verify fetch was called
expect(mockFetch).toHaveBeenCalled();
// Verify both attempts were made: fetched theme (failed) then cached default
expect(setConfigCalls.length).toBe(2);
// First call was the fetched theme (which failed)
expect(setConfigCalls[0]).toEqual(
expect.objectContaining({
token: expect.objectContaining({ colorPrimary: '#bad-theme' }),
}),
);
// Second call was the cached default theme
expect(setConfigCalls[1]).toEqual(
expect.objectContaining({
token: expect.objectContaining({
colorBgBase: '#ededed', // From DEFAULT_THEME
}),
}),
);
// Verify controller is in default mode
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
} finally {
global.fetch = originalFetch;
}
});
// Cleanup tests
test('ThemeController cleans up listeners on destroy', () => {
const mockMediaQueryInstance = {
matches: false,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
};
mockMatchMedia.mockReturnValue(mockMediaQueryInstance);
const controller = createController();
controller.destroy();
expect(mockMediaQueryInstance.removeEventListener).toHaveBeenCalledTimes(1);
expect(mockMediaQueryInstance.removeEventListener).toHaveBeenCalledWith(
'change',
expect.any(Function),
);
});
// setThemeConfig tests
test('setThemeConfig sets complete theme configuration', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: {},
dark: {},
}),
);
const controller = createController({ defaultTheme: { token: {} } });
jest.clearAllMocks();
const themeConfig = {
theme_default: DEFAULT_THEME,
theme_dark: DARK_THEME,
};
controller.setThemeConfig(themeConfig);
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.objectContaining(DEFAULT_THEME.token),
algorithm: antdThemeImport.defaultAlgorithm,
}),
);
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
expect(controller.canSetTheme()).toBe(true);
expect(controller.canSetMode()).toBe(true);
});
test('setThemeConfig flags an active theme config override', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({ default: {}, dark: {} }),
);
const controller = createController({ defaultTheme: { token: {} } });
// No override until setThemeConfig is called (e.g. from the Embedded SDK).
expect(controller.hasThemeConfigOverride()).toBe(false);
controller.setThemeConfig({
theme_default: DEFAULT_THEME,
theme_dark: DARK_THEME,
});
expect(controller.hasThemeConfigOverride()).toBe(true);
});
test('setThemeConfig handles theme_default only', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: {},
dark: {},
}),
);
const controller = createController({ defaultTheme: { token: {} } });
jest.clearAllMocks();
const themeConfig = {
theme_default: DEFAULT_THEME,
};
controller.setThemeConfig(themeConfig);
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.objectContaining(DEFAULT_THEME.token),
algorithm: antdThemeImport.defaultAlgorithm,
}),
);
expect(controller.canSetTheme()).toBe(true);
expect(controller.canSetMode()).toBe(false);
});
test('setThemeConfig handles theme_default and theme_dark without settings', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: {},
dark: {},
}),
);
const controller = createController({ defaultTheme: { token: {} } });
jest.clearAllMocks();
const themeConfig = {
theme_default: DEFAULT_THEME,
theme_dark: DARK_THEME,
};
controller.setThemeConfig(themeConfig);
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.objectContaining(DEFAULT_THEME.token),
}),
);
jest.clearAllMocks();
controller.setThemeMode(ThemeMode.DARK);
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.objectContaining(DARK_THEME.token),
algorithm: antdThemeImport.darkAlgorithm,
}),
);
});
test('setThemeConfig applies appropriate theme after configuration', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: {},
dark: {},
}),
);
const controller = createController({ defaultTheme: { token: {} } });
jest.clearAllMocks();
const themeConfig = {
theme_default: {
token: {
colorPrimary: '#00ff00',
},
},
theme_dark: {
token: {
colorPrimary: '#ff0000',
colorBgBase: '#000000',
},
algorithm: 'dark',
},
};
controller.setThemeConfig(themeConfig as SupersetThemeConfig);
expect(mockSetConfig).toHaveBeenCalledTimes(1);
expect(mockSetConfig).toHaveBeenCalledWith(
expect.objectContaining({
token: expect.objectContaining({
colorPrimary: '#00ff00',
}),
algorithm: antdThemeImport.defaultAlgorithm,
}),
);
});
test('setThemeConfig handles missing theme_dark gracefully', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: {},
dark: {},
}),
);
const controller = createController({ defaultTheme: { token: {} } });
const themeConfig = {
theme_default: DEFAULT_THEME,
};
controller.setThemeConfig(themeConfig);
expect(controller.canSetMode()).toBe(false);
});
test('setThemeConfig preserves existing theme mode when possible', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
const controller = createController();
controller.setThemeMode(ThemeMode.DARK);
const initialMode = controller.getCurrentMode();
jest.clearAllMocks();
const themeConfig = {
theme_default: DEFAULT_THEME,
theme_dark: DARK_THEME,
};
controller.setThemeConfig(themeConfig);
expect(controller.getCurrentMode()).toBe(initialMode);
});
test('setThemeConfig triggers onChange callbacks', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: {},
dark: {},
}),
);
const controller = createController({ defaultTheme: { token: {} } });
const changeCallback = jest.fn();
controller.onChange(changeCallback);
const themeConfig = {
theme_default: DEFAULT_THEME,
theme_dark: DARK_THEME,
};
controller.setThemeConfig(themeConfig);
expect(changeCallback).toHaveBeenCalledTimes(1);
expect(changeCallback).toHaveBeenCalledWith(mockThemeObject);
});
test('setThemeConfig handles error in theme application', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: {},
dark: {},
}),
);
const controller = createController({ defaultTheme: { token: {} } });
mockSetConfig.mockImplementationOnce(() => {
throw new Error('Theme application error');
});
const themeConfig = {
theme_default: DEFAULT_THEME,
};
expect(() => {
controller.setThemeConfig(themeConfig);
}).not.toThrow();
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Failed to apply theme:',
expect.any(Error),
);
});
test('setThemeConfig updates stored theme mode', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: {},
dark: {},
}),
);
const controller = createController({ defaultTheme: { token: {} } });
const themeConfig = {
theme_default: DEFAULT_THEME,
theme_dark: DARK_THEME,
};
controller.setThemeConfig(themeConfig);
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
'superset-theme-mode',
expect.any(String),
);
});
// Override and applied theme ID tests
test('setThemeMode clears dev override and crud theme from storage', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
mockLocalStorage.getItem.mockReturnValue(null);
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
Reflect.set(controller, 'devThemeOverride', {
token: { colorPrimary: '#ff0000' },
});
Reflect.set(controller, 'crudThemeId', '123');
jest.clearAllMocks();
controller.setThemeMode(ThemeMode.DARK);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-dev-theme-override',
);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-crud-theme-id',
);
});
test('setThemeMode can be called with same mode when overrides exist', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
mockLocalStorage.getItem.mockReturnValue(null);
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
jest.clearAllMocks();
Reflect.set(controller, 'devThemeOverride', {
token: { colorPrimary: '#ff0000' },
});
controller.setThemeMode(ThemeMode.DEFAULT);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-dev-theme-override',
);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-crud-theme-id',
);
expect(mockSetConfig).toHaveBeenCalled();
});
test('setThemeMode with no override and same mode does not trigger update', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
controller.setThemeMode(ThemeMode.DEFAULT);
jest.clearAllMocks();
controller.setThemeMode(ThemeMode.DEFAULT);
expect(mockSetConfig).not.toHaveBeenCalled();
expect(mockLocalStorage.removeItem).not.toHaveBeenCalled();
});
test('hasDevOverride returns true when dev override is set', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
mockLocalStorage.getItem.mockReturnValue(null);
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
Reflect.set(controller, 'devThemeOverride', {
token: { colorPrimary: '#ff0000' },
});
expect(controller.hasDevOverride()).toBe(true);
});
test('hasDevOverride returns false when no dev override in storage', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
mockLocalStorage.getItem.mockReturnValue(null);
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
expect(controller.hasDevOverride()).toBe(false);
});
test('clearLocalOverrides removes dev override, crud theme, and applied theme ID', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
mockLocalStorage.getItem.mockReturnValue(null);
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
jest.clearAllMocks();
controller.clearLocalOverrides();
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-dev-theme-override',
);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-crud-theme-id',
);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-applied-theme-id',
);
expect(mockSetConfig).toHaveBeenCalled();
});
test('getAppliedThemeId returns stored theme ID', () => {
mockLocalStorage.getItem.mockImplementation((key: string) => {
if (key === 'superset-applied-theme-id') {
return '42';
}
return null;
});
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
expect(controller.getAppliedThemeId()).toBe(42);
});
test('getAppliedThemeId returns null when no theme ID is stored', () => {
mockLocalStorage.getItem.mockReturnValue(null);
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
expect(controller.getAppliedThemeId()).toBeNull();
});
test('setAppliedThemeId stores theme ID in storage', () => {
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
jest.clearAllMocks();
controller.setAppliedThemeId(123);
expect(mockLocalStorage.setItem).toHaveBeenCalledWith(
'superset-applied-theme-id',
'123',
);
});
test('setAppliedThemeId removes theme ID when null is passed', () => {
const controller = new ThemeController({
storage: mockLocalStorage,
themeObject: mockThemeObject,
});
jest.clearAllMocks();
controller.setAppliedThemeId(null);
expect(mockLocalStorage.removeItem).toHaveBeenCalledWith(
'superset-applied-theme-id',
);
});
// Font loading tests
test('font loading: injects font URLs as CSS @import when theme has fontUrls', () => {
document
.querySelectorAll('style[data-superset-fonts]')
.forEach(el => el.remove());
const fontController = new ThemeController({ themeObject: mockThemeObject });
const themeWithFonts = {
token: {
colorPrimary: '#ff0000',
fontUrls: ['https://fonts.googleapis.com/css2?family=Roboto'],
},
};
fontController.setTheme(themeWithFonts);
const style = document.querySelector('style[data-superset-fonts]');
expect(style).toBeTruthy();
expect(style?.textContent).toContain(
'@import url("https://fonts.googleapis.com/css2?family=Roboto")',
);
document
.querySelectorAll('style[data-superset-fonts]')
.forEach(el => el.remove());
});
test('font loading: injects multiple font URLs', () => {
document
.querySelectorAll('style[data-superset-fonts]')
.forEach(el => el.remove());
const fontController = new ThemeController({ themeObject: mockThemeObject });
const themeWithFonts = {
token: {
colorPrimary: '#ff0000',
fontUrls: [
'https://fonts.googleapis.com/css2?family=Roboto',
'https://fonts.googleapis.com/css2?family=Open+Sans',
],
},
};
fontController.setTheme(themeWithFonts);
const style = document.querySelector('style[data-superset-fonts]');
expect(style).toBeTruthy();
expect(style?.textContent).toContain(
'@import url("https://fonts.googleapis.com/css2?family=Roboto")',
);
expect(style?.textContent).toContain(
'@import url("https://fonts.googleapis.com/css2?family=Open+Sans")',
);
document
.querySelectorAll('style[data-superset-fonts]')
.forEach(el => el.remove());
});
test('font loading: does not duplicate font URLs when same theme applied twice', () => {
document
.querySelectorAll('style[data-superset-fonts]')
.forEach(el => el.remove());
const fontController = new ThemeController({ themeObject: mockThemeObject });
const themeWithFonts = {
token: {
colorPrimary: '#ff0000',
fontUrls: ['https://fonts.googleapis.com/css2?family=Roboto'],
},
};
fontController.setTheme(themeWithFonts);
fontController.setTheme(themeWithFonts);
const styles = document.querySelectorAll('style[data-superset-fonts]');
expect(styles.length).toBe(1);
document
.querySelectorAll('style[data-superset-fonts]')
.forEach(el => el.remove());
});
test('font loading: does not inject styles when fontUrls is empty array', () => {
document
.querySelectorAll('style[data-superset-fonts]')
.forEach(el => el.remove());
const fontController = new ThemeController({ themeObject: mockThemeObject });
const themeWithEmptyFonts = {
token: {
colorPrimary: '#ff0000',
fontUrls: [],
},
};
fontController.setTheme(themeWithEmptyFonts);
const style = document.querySelector('style[data-superset-fonts]');
expect(style).toBeNull();
document
.querySelectorAll('style[data-superset-fonts]')
.forEach(el => el.remove());
});
test('font loading: does not inject styles when fontUrls is undefined', () => {
document
.querySelectorAll('style[data-superset-fonts]')
.forEach(el => el.remove());
const fontController = new ThemeController({ themeObject: mockThemeObject });
const themeWithoutFonts = {
token: {
colorPrimary: '#ff0000',
},
};
fontController.setTheme(themeWithoutFonts);
const style = document.querySelector('style[data-superset-fonts]');
expect(style).toBeNull();
document
.querySelectorAll('style[data-superset-fonts]')
.forEach(el => el.remove());
});
test('font loading: adds new font URLs when switching themes', () => {
document
.querySelectorAll('style[data-superset-fonts]')
.forEach(el => el.remove());
const fontController = new ThemeController({ themeObject: mockThemeObject });
const theme1 = {
token: {
colorPrimary: '#ff0000',
fontUrls: ['https://fonts.googleapis.com/css2?family=Roboto'],
},
};
const theme2 = {
token: {
colorPrimary: '#00ff00',
fontUrls: ['https://fonts.googleapis.com/css2?family=Open+Sans'],
},
};
fontController.setTheme(theme1);
fontController.setTheme(theme2);
const styles = document.querySelectorAll('style[data-superset-fonts]');
expect(styles.length).toBe(2);
const allContent = Array.from(styles)
.map(s => s.textContent)
.join('');
expect(allContent).toContain('Roboto');
expect(allContent).toContain('Open+Sans');
document
.querySelectorAll('style[data-superset-fonts]')
.forEach(el => el.remove());
});
test('ThemeController uses initialMode when provided and no saved mode exists', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
const controller = createController({ initialMode: ThemeMode.DEFAULT });
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
});
test('ThemeController defaults to SYSTEM when initialMode is not provided', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
const controller = createController();
expect(controller.getCurrentMode()).toBe(ThemeMode.SYSTEM);
});
test('ThemeController saved mode takes precedence over initialMode', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
mockLocalStorage.getItem.mockReturnValue(ThemeMode.DARK);
const controller = createController({ initialMode: ThemeMode.DEFAULT });
expect(controller.getCurrentMode()).toBe(ThemeMode.DARK);
});
test('ThemeController with initialMode DEFAULT applies light theme even when system prefers dark', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
mockMatchMedia.mockReturnValue({
matches: true, // system prefers dark
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
});
const controller = createController({ initialMode: ThemeMode.DEFAULT });
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
const lastCall =
mockSetConfig.mock.calls[mockSetConfig.mock.calls.length - 1][0];
expect(lastCall.token.colorBgBase).toBe(DEFAULT_THEME.token!.colorBgBase);
});
test('ThemeController with initialMode still allows setThemeMode after init', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
const controller = createController({ initialMode: ThemeMode.DEFAULT });
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
controller.setThemeMode(ThemeMode.DARK);
expect(controller.getCurrentMode()).toBe(ThemeMode.DARK);
controller.setThemeMode(ThemeMode.SYSTEM);
expect(controller.getCurrentMode()).toBe(ThemeMode.SYSTEM);
});
test('ThemeController initialMode is ignored when no dark theme exists', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: {},
}),
);
const controller = createController({ initialMode: ThemeMode.SYSTEM });
// Should still be DEFAULT because there's no dark theme available
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
});
test('ThemeController invalid initialMode falls back to SYSTEM', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: DEFAULT_THEME,
dark: DARK_THEME,
}),
);
const controller = createController({
initialMode: 'invalid' as ThemeMode,
});
// Invalid initialMode should be rejected by isValidThemeMode,
// falling through to the default SYSTEM mode
expect(controller.getCurrentMode()).toBe(ThemeMode.SYSTEM);
});
test('getCurrentModeResolved returns light for light theme', () => {
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: { token: { colorBgBase: '#ffffff' } },
dark: {
token: { colorBgBase: '#000000' },
algorithm: ThemeAlgorithm.DARK,
},
}),
);
const controller = createController();
expect(controller.getCurrentModeResolved()).toBe('light');
controller.setThemeMode(ThemeMode.DARK);
expect(controller.getCurrentModeResolved()).toBe('dark');
});
test('getResolvedThemeMode returns dark when default theme is dark but mode is DEFAULT', () => {
// Setup: default theme is dark (has dark algorithm)
// This simulates single-theme deployments where THEME_DARK=None but default is dark
mockGetBootstrapData.mockReturnValue(
createMockBootstrapData({
default: {
token: { colorBgBase: '#000000' }, // dark background
algorithm: antdThemeImport.darkAlgorithm,
},
dark: {}, // empty - no separate dark theme
}),
);
const controller = createController();
expect(controller.getCurrentMode()).toBe(ThemeMode.DEFAULT);
expect(controller.getCurrentModeResolved()).toBe('dark');
});
test('fallback fetch: uses custom guest token header from SupersetClient when client.get fails', async () => {
const originalFetch = global.fetch;
const mockGet = jest
.spyOn(SupersetClient, 'get')
.mockRejectedValue(new Error('Client not configured'));
const mockGetGuestToken = jest
.spyOn(SupersetClient, 'getGuestToken')
.mockReturnValue('custom-guest-token-123');
// Define getter for guestTokenHeaderName
Object.defineProperty(SupersetClient, 'guestTokenHeaderName', {
value: 'X-Custom-Guest-Header',
configurable: true,
});
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
result: [
{
json_data: JSON.stringify({
token: { colorPrimary: '#custom-header-theme' },
}),
},
],
}),
});
global.fetch = mockFetch;
try {
const controller = createController();
const result = await (controller as any).fetchSystemDefaultTheme();
expect(mockGet).toHaveBeenCalled();
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/theme/'),
expect.objectContaining({
headers: expect.objectContaining({
'X-Custom-Guest-Header': 'custom-guest-token-123',
}),
}),
);
expect(result).toEqual({ token: { colorPrimary: '#custom-header-theme' } });
} finally {
global.fetch = originalFetch;
mockGet.mockRestore();
mockGetGuestToken.mockRestore();
Object.defineProperty(SupersetClient, 'guestTokenHeaderName', {
value: undefined,
configurable: true,
});
}
});
test('fallback fetch: uses bootstrap config for guest token header when SupersetClient is not configured', async () => {
const originalFetch = global.fetch;
const mockGet = jest
.spyOn(SupersetClient, 'get')
.mockRejectedValue(new Error('Client not configured'));
const mockGetGuestToken = jest
.spyOn(SupersetClient, 'getGuestToken')
.mockReturnValue('bootstrap-guest-token');
// Ensure SupersetClient.guestTokenHeaderName is undefined or throws
Object.defineProperty(SupersetClient, 'guestTokenHeaderName', {
get: () => {
throw new Error('Not configured');
},
configurable: true,
});
// Mock bootstrapData.config?.GUEST_TOKEN_HEADER_NAME
mockGetBootstrapData.mockReturnValue({
...createMockBootstrapData(),
config: {
GUEST_TOKEN_HEADER_NAME: 'X-Bootstrap-Custom-Header',
},
} as any);
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
result: [
{
json_data: JSON.stringify({
token: { colorPrimary: '#bootstrap-theme' },
}),
},
],
}),
});
global.fetch = mockFetch;
try {
const controller = createController();
const result = await (controller as any).fetchSystemDefaultTheme();
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/theme/'),
expect.objectContaining({
headers: expect.objectContaining({
'X-Bootstrap-Custom-Header': 'bootstrap-guest-token',
}),
}),
);
expect(result).toEqual({ token: { colorPrimary: '#bootstrap-theme' } });
} finally {
global.fetch = originalFetch;
mockGet.mockRestore();
mockGetGuestToken.mockRestore();
mockGetBootstrapData.mockReturnValue(createMockBootstrapData());
Object.defineProperty(SupersetClient, 'guestTokenHeaderName', {
value: undefined,
configurable: true,
});
}
});
test('SDK override toggling and dynamic transitions', () => {
const controller = createController();
expect(controller.hasThemeConfigOverride()).toBe(false);
// Set theme config override
const sdkThemeConfig: SupersetThemeConfig = {
theme_default: { token: { colorPrimary: '#sdk-default' } },
theme_dark: { token: { colorPrimary: '#sdk-dark' } },
};
controller.setThemeConfig(sdkThemeConfig);
expect(controller.hasThemeConfigOverride()).toBe(true);
// Clear local overrides (which should reset override flag)
controller.clearLocalOverrides();
expect(controller.hasThemeConfigOverride()).toBe(false);
});
test('ThemeController cleans up injected fonts on destroy', () => {
const controller = createController();
// Inject some fonts
(controller as any).loadFonts(['https://fonts.example.com/font-test.css']);
let fontStyle = document.querySelector('style[data-superset-fonts]');
expect(fontStyle).not.toBeNull();
controller.destroy();
fontStyle = document.querySelector('style[data-superset-fonts]');
expect(fontStyle).toBeNull();
});
test('fallback fetch: uses bootstrap GUEST_TOKEN_HEADER_NAME when guestTokenHeaderName getter throws', async () => {
const originalFetch = global.fetch;
// SupersetClient.get throws so we fall through to native fetch
const mockGet = jest
.spyOn(SupersetClient, 'get')
.mockRejectedValue(new Error('Client not configured'));
// getGuestToken succeeds (we have a guest token)
const mockGetGuestToken = jest
.spyOn(SupersetClient, 'getGuestToken')
.mockReturnValue('my-guest-token');
// guestTokenHeaderName getter throws → should fall back to bootstrap config
Object.defineProperty(SupersetClient, 'guestTokenHeaderName', {
get: () => {
throw new Error('Not configured');
},
configurable: true,
});
// Return a bootstrap config with a custom header name
mockGetBootstrapData.mockReturnValue({
...createMockBootstrapData(),
config: {
GUEST_TOKEN_HEADER_NAME: 'X-Bootstrap-Header',
},
} as any);
const mockFetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
result: [
{
json_data: JSON.stringify({
token: { colorPrimary: '#bootstrap-fallback' },
}),
},
],
}),
});
global.fetch = mockFetch;
try {
const controller = createController();
const result = await (controller as any).fetchSystemDefaultTheme();
// Verify the bootstrap header was used instead of SupersetClient.guestTokenHeaderName
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/theme/'),
expect.objectContaining({
headers: expect.objectContaining({
'X-Bootstrap-Header': 'my-guest-token',
}),
}),
);
expect(result).toEqual({ token: { colorPrimary: '#bootstrap-fallback' } });
} finally {
global.fetch = originalFetch;
mockGet.mockRestore();
mockGetGuestToken.mockRestore();
mockGetBootstrapData.mockReturnValue(createMockBootstrapData());
Object.defineProperty(SupersetClient, 'guestTokenHeaderName', {
value: undefined,
configurable: true,
});
}
});
test('fetchSystemDefaultTheme: second named-theme fallback fetch succeeds when first API calls fail', async () => {
const originalFetch = global.fetch;
// SupersetClient.get always throws (not configured)
const mockGet = jest
.spyOn(SupersetClient, 'get')
.mockRejectedValue(new Error('Client not configured'));
const namedTheme = { token: { colorPrimary: '#named-theme' } };
// First fetch call (is_system_default) returns empty result; second (THEME_DEFAULT name) succeeds
const mockFetch = jest
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ result: [] }), // first path: no results
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
result: [{ json_data: JSON.stringify(namedTheme) }],
}),
});
global.fetch = mockFetch;
try {
const controller = createController();
const result = await (controller as any).fetchSystemDefaultTheme();
// Both fetches should have been called
expect(mockFetch).toHaveBeenCalledTimes(2);
// The result should be from the second (named-theme) fallback fetch
expect(result).toEqual(namedTheme);
} finally {
global.fetch = originalFetch;
mockGet.mockRestore();
}
});