Fix tests

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2026-02-09 13:57:46 +00:00
committed by Patrik Oldsberg
parent 0cb14cb5d9
commit 2f4e800d66
6 changed files with 188 additions and 135 deletions
@@ -357,6 +357,7 @@ describe('createApp', () => {
<api:app/dialog out=[core.api.factory] />
<api:app/discovery out=[core.api.factory] />
<api:app/alert out=[core.api.factory] />
<api:app/toast out=[core.api.factory] />
<api:app/analytics out=[core.api.factory] />
<api:app/error out=[core.api.factory] />
<api:app/storage out=[core.api.factory] />
@@ -102,7 +102,7 @@ describe('appModulePublicSignIn', () => {
<div>
<div
aria-label="0 notifications."
class="toast-container"
class="container"
data-react-aria-top-layer="true"
data-theme-mode="dark"
role="region"
@@ -20,6 +20,8 @@ import {
alertApiRef,
AlertApi,
AlertMessage,
appThemeApiRef,
AppThemeApi,
} from '@backstage/core-plugin-api';
import { toastApiRef } from '@backstage/frontend-plugin-api';
import { Observable } from '@backstage/types';
@@ -47,6 +49,31 @@ class MockAlertApi implements AlertApi {
}
}
// Mock AppThemeApi
const mockAppThemeApi: AppThemeApi = {
getInstalledThemes: () => [
{
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<string | undefined>(subscriber => {
subscriber.next('light');
return () => {};
}) as Observable<string | undefined>,
setActiveThemeId: () => {},
};
describe('ToastDisplay', () => {
let toastApi: ToastApiForwarder;
let alertApi: MockAlertApi;
@@ -62,6 +89,7 @@ describe('ToastDisplay', () => {
apis={[
[alertApiRef, alertApi],
[toastApiRef, toastApi],
[appThemeApiRef, mockAppThemeApi],
]}
>
<ToastDisplay />
@@ -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();
});
});
@@ -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<string | undefined>
>();
return {
getInstalledThemes: () =>
themes.map(t => ({
...t,
Provider: ({ children }: { children?: ReactNode }) => children,
})) as ReturnType<AppThemeApi['getInstalledThemes']>,
getActiveThemeId: () => activeId,
activeThemeId$: () =>
new ObservableImpl<string | undefined>(subscriber => {
subscribers.add(subscriber);
subscriber.next(activeId);
return () => {
subscribers.delete(subscriber);
};
}) as Observable<string | undefined>,
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');
});
});
@@ -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);