Fix tests
Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
committed by
Patrik Oldsberg
parent
0cb14cb5d9
commit
2f4e800d66
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user