[
+ {
+ 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);