diff --git a/packages/frontend-defaults/src/createApp.test.tsx b/packages/frontend-defaults/src/createApp.test.tsx index 3a9ea91bec..d0817d6428 100644 --- a/packages/frontend-defaults/src/createApp.test.tsx +++ b/packages/frontend-defaults/src/createApp.test.tsx @@ -357,6 +357,7 @@ describe('createApp', () => { + diff --git a/plugins/app/src/alpha/appModulePublicSignIn.test.tsx b/plugins/app/src/alpha/appModulePublicSignIn.test.tsx index 220090b83d..c657eaa633 100644 --- a/plugins/app/src/alpha/appModulePublicSignIn.test.tsx +++ b/plugins/app/src/alpha/appModulePublicSignIn.test.tsx @@ -102,7 +102,7 @@ describe('appModulePublicSignIn', () => {
[ + { + id: 'light', + variant: 'light', + title: 'Light', + Provider: ({ children }: any) => children, + }, + { + id: 'dark', + variant: 'dark', + title: 'Dark', + Provider: ({ children }: any) => children, + }, + ], + getActiveThemeId: () => 'light', + activeThemeId$: () => + new ObservableImpl(subscriber => { + subscriber.next('light'); + return () => {}; + }) as Observable, + setActiveThemeId: () => {}, +}; + describe('ToastDisplay', () => { let toastApi: ToastApiForwarder; let alertApi: MockAlertApi; @@ -62,6 +89,7 @@ describe('ToastDisplay', () => { apis={[ [alertApiRef, alertApi], [toastApiRef, toastApi], + [appThemeApiRef, mockAppThemeApi], ]} > diff --git a/plugins/app/src/hooks/useInvertedThemeMode.test.ts b/plugins/app/src/hooks/useInvertedThemeMode.test.ts deleted file mode 100644 index d0e8e45ab6..0000000000 --- a/plugins/app/src/hooks/useInvertedThemeMode.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright 2025 The Backstage Authors - * - * Licensed 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 { renderHook, act } from '@testing-library/react'; -import { useInvertedThemeMode } from './useInvertedThemeMode'; - -describe('useInvertedThemeMode', () => { - const originalBodyTheme = document.body.getAttribute('data-theme-mode'); - const originalHtmlTheme = - document.documentElement.getAttribute('data-theme-mode'); - - afterEach(() => { - // Restore original attributes - if (originalBodyTheme) { - document.body.setAttribute('data-theme-mode', originalBodyTheme); - } else { - document.body.removeAttribute('data-theme-mode'); - } - if (originalHtmlTheme) { - document.documentElement.setAttribute( - 'data-theme-mode', - originalHtmlTheme, - ); - } else { - document.documentElement.removeAttribute('data-theme-mode'); - } - }); - - it('should return dark when no theme is set', () => { - document.body.removeAttribute('data-theme-mode'); - document.documentElement.removeAttribute('data-theme-mode'); - - const { result } = renderHook(() => useInvertedThemeMode()); - - expect(result.current).toBe('dark'); - }); - - it('should return light when body theme is dark', () => { - document.body.setAttribute('data-theme-mode', 'dark'); - - const { result } = renderHook(() => useInvertedThemeMode()); - - expect(result.current).toBe('light'); - }); - - it('should return dark when body theme is light', () => { - document.body.setAttribute('data-theme-mode', 'light'); - - const { result } = renderHook(() => useInvertedThemeMode()); - - expect(result.current).toBe('dark'); - }); - - it('should prefer body theme over html theme', () => { - document.documentElement.setAttribute('data-theme-mode', 'light'); - document.body.setAttribute('data-theme-mode', 'dark'); - - const { result } = renderHook(() => useInvertedThemeMode()); - - // Body is dark, so inverted should be light - expect(result.current).toBe('light'); - }); - - it('should fall back to html theme when body has no theme', () => { - document.body.removeAttribute('data-theme-mode'); - document.documentElement.setAttribute('data-theme-mode', 'dark'); - - const { result } = renderHook(() => useInvertedThemeMode()); - - expect(result.current).toBe('light'); - }); - - it('should update when body theme changes', async () => { - document.body.setAttribute('data-theme-mode', 'light'); - - const { result } = renderHook(() => useInvertedThemeMode()); - - expect(result.current).toBe('dark'); - - // Change theme - await act(async () => { - document.body.setAttribute('data-theme-mode', 'dark'); - // Wait for MutationObserver to fire - await new Promise(resolve => setTimeout(resolve, 0)); - }); - - expect(result.current).toBe('light'); - }); - - it('should update when html theme changes', async () => { - document.body.removeAttribute('data-theme-mode'); - document.documentElement.setAttribute('data-theme-mode', 'light'); - - const { result } = renderHook(() => useInvertedThemeMode()); - - expect(result.current).toBe('dark'); - - // Change theme - await act(async () => { - document.documentElement.setAttribute('data-theme-mode', 'dark'); - // Wait for MutationObserver to fire - await new Promise(resolve => setTimeout(resolve, 0)); - }); - - expect(result.current).toBe('light'); - }); - - it('should clean up observer on unmount', () => { - const disconnectSpy = jest.spyOn(MutationObserver.prototype, 'disconnect'); - - const { unmount } = renderHook(() => useInvertedThemeMode()); - - unmount(); - - expect(disconnectSpy).toHaveBeenCalled(); - disconnectSpy.mockRestore(); - }); -}); diff --git a/plugins/app/src/hooks/useInvertedThemeMode.test.tsx b/plugins/app/src/hooks/useInvertedThemeMode.test.tsx new file mode 100644 index 0000000000..18b62a9739 --- /dev/null +++ b/plugins/app/src/hooks/useInvertedThemeMode.test.tsx @@ -0,0 +1,150 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed 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 { renderHook, act } from '@testing-library/react'; +import { TestApiProvider } from '@backstage/test-utils'; +import { appThemeApiRef, AppThemeApi } from '@backstage/core-plugin-api'; +import { Observable } from '@backstage/types'; +import ObservableImpl from 'zen-observable'; +import { createElement, type ReactNode } from 'react'; +import { useInvertedThemeMode } from './useInvertedThemeMode'; + +// Helper to create a mock AppThemeApi +function createMockAppThemeApi(opts?: { + activeThemeId?: string; + themes?: Array<{ id: string; variant: 'light' | 'dark'; title: string }>; +}): AppThemeApi & { setActiveThemeId: (id?: string) => void } { + const themes = opts?.themes ?? [ + { id: 'light', variant: 'light' as const, title: 'Light' }, + { id: 'dark', variant: 'dark' as const, title: 'Dark' }, + ]; + let activeId = opts?.activeThemeId; + const subscribers = new Set< + ZenObservable.SubscriptionObserver + >(); + + return { + getInstalledThemes: () => + themes.map(t => ({ + ...t, + Provider: ({ children }: { children?: ReactNode }) => children, + })) as ReturnType, + getActiveThemeId: () => activeId, + activeThemeId$: () => + new ObservableImpl(subscriber => { + subscribers.add(subscriber); + subscriber.next(activeId); + return () => { + subscribers.delete(subscriber); + }; + }) as Observable, + setActiveThemeId: (id?: string) => { + activeId = id; + subscribers.forEach(s => s.next(id)); + }, + }; +} + +// Creates a wrapper that provides the AppThemeApi via TestApiProvider +function createWrapper(appThemeApi: AppThemeApi) { + return ({ children }: { children: ReactNode }) => + createElement( + TestApiProvider as any, + { apis: [[appThemeApiRef, appThemeApi]] }, + children, + ); +} + +describe('useInvertedThemeMode', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should return dark when active theme is light', () => { + const appThemeApi = createMockAppThemeApi({ activeThemeId: 'light' }); + const { result } = renderHook(() => useInvertedThemeMode(), { + wrapper: createWrapper(appThemeApi), + }); + expect(result.current).toBe('dark'); + }); + + it('should return light when active theme is dark', () => { + const appThemeApi = createMockAppThemeApi({ activeThemeId: 'dark' }); + const { result } = renderHook(() => useInvertedThemeMode(), { + wrapper: createWrapper(appThemeApi), + }); + expect(result.current).toBe('light'); + }); + + it('should use system preference when no theme is selected (auto mode, prefers dark)', () => { + const appThemeApi = createMockAppThemeApi({ activeThemeId: undefined }); + const { result } = renderHook(() => useInvertedThemeMode(), { + wrapper: createWrapper(appThemeApi), + }); + // matchMedia is unavailable in jsdom → prefersDark defaults to false + // → picks light theme → inverted is dark + expect(result.current).toBe('dark'); + }); + + it('should update when active theme changes', async () => { + const appThemeApi = createMockAppThemeApi({ activeThemeId: 'light' }); + const { result } = renderHook(() => useInvertedThemeMode(), { + wrapper: createWrapper(appThemeApi), + }); + expect(result.current).toBe('dark'); + + await act(async () => { + appThemeApi.setActiveThemeId('dark'); + }); + + expect(result.current).toBe('light'); + }); + + it('should fall back to first installed theme when no match', () => { + const appThemeApi = createMockAppThemeApi({ + activeThemeId: undefined, + themes: [{ id: 'custom', variant: 'dark', title: 'Custom' }], + }); + const { result } = renderHook(() => useInvertedThemeMode(), { + wrapper: createWrapper(appThemeApi), + }); + // Only dark theme, system prefers light (default) but no light theme + // → falls back to first theme (dark) → inverted is light + expect(result.current).toBe('light'); + }); + + it('should default to dark when no themes are installed', () => { + const appThemeApi = createMockAppThemeApi({ + activeThemeId: undefined, + themes: [], + }); + const { result } = renderHook(() => useInvertedThemeMode(), { + wrapper: createWrapper(appThemeApi), + }); + // No themes → can't determine variant → defaults to 'dark' + expect(result.current).toBe('dark'); + }); + + it('should handle matchMedia being unavailable gracefully', () => { + const appThemeApi = createMockAppThemeApi({ activeThemeId: undefined }); + const { result } = renderHook(() => useInvertedThemeMode(), { + wrapper: createWrapper(appThemeApi), + }); + // matchMedia is not available in jsdom → prefersDark defaults to false + // → picks light variant → inverted is dark + expect(result.current).toBe('dark'); + }); +}); diff --git a/plugins/app/src/hooks/useInvertedThemeMode.ts b/plugins/app/src/hooks/useInvertedThemeMode.ts index 19673fc8dd..c3d2540a4a 100644 --- a/plugins/app/src/hooks/useInvertedThemeMode.ts +++ b/plugins/app/src/hooks/useInvertedThemeMode.ts @@ -37,14 +37,19 @@ export function useInvertedThemeMode(): ThemeMode { appThemeApi.getActiveThemeId(), ); - // Track system color scheme preference for "auto" mode + // Track system color scheme preference for "auto" mode. + // Guard against environments where matchMedia is unavailable (e.g. jsdom in tests). const mediaQuery = useMemo( - () => window.matchMedia('(prefers-color-scheme: dark)'), + () => + typeof window.matchMedia === 'function' + ? window.matchMedia('(prefers-color-scheme: dark)') + : undefined, [], ); - const [prefersDark, setPrefersDark] = useState(mediaQuery.matches); + const [prefersDark, setPrefersDark] = useState(mediaQuery?.matches ?? false); useEffect(() => { + if (!mediaQuery) return undefined; const listener = (e: MediaQueryListEvent) => setPrefersDark(e.matches); mediaQuery.addEventListener('change', listener); return () => mediaQuery.removeEventListener('change', listener);