Add tests and deprecation warnings

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2026-02-05 21:34:31 +00:00
committed by Patrik Oldsberg
parent a47f27c0a9
commit eea95b8ae2
12 changed files with 868 additions and 19 deletions
@@ -0,0 +1,238 @@
/*
* 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 { ToastApiForwarder } from './ToastApiForwarder';
describe('ToastApiForwarder', () => {
let forwarder: ToastApiForwarder;
beforeEach(() => {
forwarder = new ToastApiForwarder();
});
describe('post', () => {
it('should return a unique key for each toast', () => {
const key1 = forwarder.post({ title: 'Toast 1' });
const key2 = forwarder.post({ title: 'Toast 2' });
expect(key1).toBeDefined();
expect(key2).toBeDefined();
expect(key1).not.toBe(key2);
});
it('should emit toast to subscribers', () => {
const received: Array<{ title: unknown; key: string }> = [];
forwarder.toast$().subscribe(toast => {
received.push(toast);
});
forwarder.post({ title: 'Test Toast', status: 'success' });
expect(received).toHaveLength(1);
expect(received[0].title).toBe('Test Toast');
expect(received[0].key).toBeDefined();
});
it('should include all toast properties in emitted message', () => {
const received: Array<{
title: unknown;
description?: unknown;
status?: string;
timeout?: number;
}> = [];
forwarder.toast$().subscribe(toast => {
received.push(toast);
});
forwarder.post({
title: 'Title',
description: 'Description',
status: 'warning',
timeout: 5000,
links: [{ label: 'Link', href: '/test' }],
});
expect(received[0]).toMatchObject({
title: 'Title',
description: 'Description',
status: 'warning',
timeout: 5000,
links: [{ label: 'Link', href: '/test' }],
});
});
});
describe('close', () => {
it('should emit close event to subscribers', () => {
const closedKeys: string[] = [];
forwarder.close$().subscribe(key => {
closedKeys.push(key);
});
const key = forwarder.post({ title: 'Test' });
forwarder.close(key);
expect(closedKeys).toHaveLength(1);
expect(closedKeys[0]).toBe(key);
});
it('should remove toast from replay buffer', () => {
const key = forwarder.post({ title: 'Test' });
forwarder.close(key);
// New subscriber should not receive the closed toast
const received: Array<{ key: string }> = [];
forwarder.toast$().subscribe(toast => {
received.push(toast);
});
expect(received).toHaveLength(0);
});
});
describe('toast$ replay', () => {
it('should replay recent toasts to new subscribers', async () => {
forwarder.post({ title: 'Toast 1' });
forwarder.post({ title: 'Toast 2' });
const received: Array<{ title: unknown }> = [];
await new Promise<void>(resolve => {
const subscription = forwarder.toast$().subscribe({
next: toast => {
received.push(toast);
// After receiving replayed toasts, unsubscribe
if (received.length === 2) {
subscription.unsubscribe();
resolve();
}
},
});
// Also resolve after a short timeout in case no toasts are replayed
setTimeout(() => resolve(), 100);
});
expect(received).toHaveLength(2);
expect(received[0].title).toBe('Toast 1');
expect(received[1].title).toBe('Toast 2');
});
it('should not replay closed toasts to new subscribers', async () => {
const key1 = forwarder.post({ title: 'Toast 1' });
forwarder.post({ title: 'Toast 2' });
forwarder.close(key1);
const received: Array<{ title: unknown }> = [];
await new Promise<void>(resolve => {
const subscription = forwarder.toast$().subscribe({
next: toast => {
received.push(toast);
subscription.unsubscribe();
resolve();
},
});
setTimeout(() => resolve(), 100);
});
expect(received).toHaveLength(1);
expect(received[0].title).toBe('Toast 2');
});
it('should limit replay buffer size', async () => {
// Post more than maxBufferSize (10) toasts
for (let i = 0; i < 15; i++) {
forwarder.post({ title: `Toast ${i}` });
}
const received: Array<{ title: unknown }> = [];
await new Promise<void>(resolve => {
const subscription = forwarder.toast$().subscribe({
next: toast => {
received.push(toast);
if (received.length === 10) {
subscription.unsubscribe();
resolve();
}
},
});
setTimeout(() => resolve(), 100);
});
// Should only have last 10 toasts
expect(received).toHaveLength(10);
expect(received[0].title).toBe('Toast 5');
expect(received[9].title).toBe('Toast 14');
});
});
describe('close$ observable', () => {
it('should allow multiple subscribers', () => {
const subscriber1: string[] = [];
const subscriber2: string[] = [];
forwarder.close$().subscribe(key => subscriber1.push(key));
forwarder.close$().subscribe(key => subscriber2.push(key));
const key = forwarder.post({ title: 'Test' });
forwarder.close(key);
expect(subscriber1).toEqual([key]);
expect(subscriber2).toEqual([key]);
});
});
describe('subscription cleanup', () => {
it('should stop receiving toasts after unsubscribe', () => {
const received: Array<{ title: unknown }> = [];
const subscription = forwarder.toast$().subscribe(toast => {
received.push(toast);
});
forwarder.post({ title: 'Before unsubscribe' });
subscription.unsubscribe();
forwarder.post({ title: 'After unsubscribe' });
expect(received).toHaveLength(1);
expect(received[0].title).toBe('Before unsubscribe');
});
it('should stop receiving close events after unsubscribe', () => {
const closedKeys: string[] = [];
const subscription = forwarder.close$().subscribe(key => {
closedKeys.push(key);
});
const key1 = forwarder.post({ title: 'Toast 1' });
forwarder.close(key1);
subscription.unsubscribe();
const key2 = forwarder.post({ title: 'Toast 2' });
forwarder.close(key2);
expect(closedKeys).toHaveLength(1);
expect(closedKeys[0]).toBe(key1);
});
});
});
@@ -0,0 +1,232 @@
/*
* 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 { render, screen, act, waitFor } from '@testing-library/react';
import { TestApiProvider } from '@backstage/test-utils';
import {
alertApiRef,
AlertApi,
AlertMessage,
} from '@backstage/core-plugin-api';
import { toastApiRef } from '@backstage/frontend-plugin-api';
import { Observable } from '@backstage/types';
import ObservableImpl from 'zen-observable';
import { ToastDisplay } from './ToastDisplay';
import { ToastApiForwarder } from '../../apis';
import { toastQueue } from './ToastQueue';
// Mock AlertApi with proper Observable implementation
class MockAlertApi implements AlertApi {
private subscribers = new Set<
ZenObservable.SubscriptionObserver<AlertMessage>
>();
post(alert: AlertMessage) {
this.subscribers.forEach(subscriber => subscriber.next(alert));
}
alert$(): Observable<AlertMessage> {
return new ObservableImpl<AlertMessage>(subscriber => {
this.subscribers.add(subscriber);
return () => {
this.subscribers.delete(subscriber);
};
});
}
}
describe('ToastDisplay', () => {
let toastApi: ToastApiForwarder;
let alertApi: MockAlertApi;
beforeEach(() => {
// Clear the toast queue before each test
while (toastQueue.visibleToasts.length > 0) {
toastQueue.close(toastQueue.visibleToasts[0].key);
}
toastApi = new ToastApiForwarder();
alertApi = new MockAlertApi();
});
const renderToastDisplay = () => {
return render(
<TestApiProvider
apis={[
[alertApiRef, alertApi],
[toastApiRef, toastApi],
]}
>
<ToastDisplay />
</TestApiProvider>,
);
};
describe('ToastApi integration', () => {
it('should display a toast when posted via ToastApi', async () => {
renderToastDisplay();
await act(async () => {
toastApi.post({
title: 'Test Toast Title',
status: 'success',
});
});
await expect(
screen.findByText('Test Toast Title'),
).resolves.toBeInTheDocument();
});
it('should display toast with description', async () => {
renderToastDisplay();
await act(async () => {
toastApi.post({
title: 'Title',
description: 'This is a description',
status: 'info',
});
});
await expect(screen.findByText('Title')).resolves.toBeInTheDocument();
await expect(
screen.findByText('This is a description'),
).resolves.toBeInTheDocument();
});
it('should display toast with links', async () => {
renderToastDisplay();
await act(async () => {
toastApi.post({
title: 'Toast with link',
links: [{ label: 'Click here', href: '/test' }],
});
});
const link = await screen.findByText('Click here');
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', '/test');
});
it('should display multiple toasts', async () => {
renderToastDisplay();
await act(async () => {
toastApi.post({ title: 'Toast 1', status: 'success' });
toastApi.post({ title: 'Toast 2', status: 'warning' });
toastApi.post({ title: 'Toast 3', status: 'danger' });
});
await expect(screen.findByText('Toast 1')).resolves.toBeInTheDocument();
await expect(screen.findByText('Toast 2')).resolves.toBeInTheDocument();
await expect(screen.findByText('Toast 3')).resolves.toBeInTheDocument();
});
it('should allow programmatic dismiss via close()', async () => {
renderToastDisplay();
let toastKey: string;
await act(async () => {
toastKey = toastApi.post({
title: 'Dismissable Toast',
status: 'info',
});
});
await expect(
screen.findByText('Dismissable Toast'),
).resolves.toBeInTheDocument();
await act(async () => {
toastApi.close(toastKey!);
// Wait for animation
await new Promise(resolve => setTimeout(resolve, 600));
});
await waitFor(() => {
expect(screen.queryByText('Dismissable Toast')).not.toBeInTheDocument();
});
});
});
describe('AlertApi integration (legacy)', () => {
it('should display alert as toast when posted via AlertApi', async () => {
renderToastDisplay();
await act(async () => {
alertApi.post({
message: 'Legacy Alert Message',
severity: 'success',
});
});
await expect(
screen.findByText('Legacy Alert Message'),
).resolves.toBeInTheDocument();
});
it('should map alert error severity to danger status', async () => {
renderToastDisplay();
await act(async () => {
alertApi.post({
message: 'Error Alert',
severity: 'error',
});
});
const toast = await screen.findByText('Error Alert');
expect(toast.closest('[data-status]')).toHaveAttribute(
'data-status',
'danger',
);
});
it('should default to success status when no severity', async () => {
renderToastDisplay();
await act(async () => {
alertApi.post({
message: 'No Severity Alert',
});
});
const toast = await screen.findByText('No Severity Alert');
expect(toast.closest('[data-status]')).toHaveAttribute(
'data-status',
'success',
);
});
});
describe('concurrent usage', () => {
it('should handle both ToastApi and AlertApi messages', async () => {
renderToastDisplay();
await act(async () => {
toastApi.post({ title: 'New Toast', status: 'success' });
alertApi.post({ message: 'Legacy Alert', severity: 'warning' });
});
await expect(screen.findByText('New Toast')).resolves.toBeInTheDocument();
await expect(
screen.findByText('Legacy Alert'),
).resolves.toBeInTheDocument();
});
});
});
@@ -39,15 +39,20 @@ function mapSeverity(
* ToastDisplay bridges both the ToastApi and AlertApi with the Toast notification system.
*
* @remarks
* This component subscribes to:
* - `toastApi.toast$()` - New toast notifications with full features (title, description, links, icons)
* - `alertApi.alert$()` - Legacy alerts for backward compatibility (message maps to title only)
* This component provides a migration bridge between the deprecated AlertApi and the new ToastApi.
* During the migration period, it subscribes to both APIs simultaneously, allowing plugins to
* migrate incrementally without breaking existing functionality.
*
* For ToastApi:
* **Subscriptions:**
* - `toastApi.toast$()` - New toast notifications with full features (title, description, links, icons)
* - `alertApi.alert$()` - Deprecated alerts for backward compatibility (message maps to title only)
*
* **ToastApi (recommended):**
* - Uses toast content directly (title, description, status, icon, links)
* - Uses the provided timeout from the toast message
* - Supports programmatic dismiss via returned key
*
* For AlertApi (legacy):
* **AlertApi (deprecated - please migrate to ToastApi):**
* - `alert.message` → `toast.title`
* - `alert.severity` → `toast.status` ('error' maps to 'danger')
* - `alert.display` → `timeout` (transient gets default timeout, permanent stays until dismissed)
@@ -57,7 +62,9 @@ function mapSeverity(
* // In your app root element extension
* <ToastDisplay transientTimeoutMs={5000} />
*
* // Using the new ToastApi:
* // Using the new ToastApi (recommended):
* import { toastApiRef, useApi } from '@backstage/frontend-plugin-api';
* const toastApi = useApi(toastApiRef);
* toastApi.post({
* title: 'Entity saved',
* description: 'Your changes have been saved successfully.',
@@ -65,7 +72,9 @@ function mapSeverity(
* timeout: 5000,
* });
*
* // Using the legacy AlertApi:
* // Using the deprecated AlertApi (migrate to ToastApi):
* import { alertApiRef, useApi } from '@backstage/core-plugin-api';
* const alertApi = useApi(alertApiRef);
* alertApi.post({ message: 'Saved!', severity: 'success', display: 'transient' });
* ```
*
@@ -115,7 +124,8 @@ export function ToastDisplay(props: ToastDisplayProps) {
return () => subscription.unsubscribe();
}, [toastApi]);
// Subscribe to AlertApi (legacy support)
// Subscribe to AlertApi (deprecated - provides backward compatibility during migration)
// This subscription will be removed when AlertApi is fully deprecated
useEffect(() => {
const subscription = alertApi.alert$().subscribe(alert => {
const content: ToastContent = {
@@ -0,0 +1,131 @@
/*
* 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();
});
});