bui-themer: split + test + fixes

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2025-09-13 18:53:02 +02:00
parent 01dbd7b3db
commit 6dfa9e796c
6 changed files with 303 additions and 9 deletions
@@ -0,0 +1,68 @@
/*
* 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 } from '@testing-library/react';
import '@testing-library/jest-dom';
import { BuiThemePreview } from './BuiThemePreview';
describe('BuiThemePreview', () => {
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
});
it('renders headings and sample components', () => {
const { container } = render(
<BuiThemePreview
mode="light"
styleObject={{
'--bui-bg-surface-2': '#ffffff',
'--bui-border': '#cccccc',
'--bui-radius-2': '4px',
'--bui-space-3': '12px',
'--bui-fg-secondary': '#777777',
'--bui-bg-solid': '#000000',
'--bui-fg-solid': '#ffffff',
'--bui-fg-primary': '#111111',
'--bui-bg-surface-1': '#f5f5f5',
}}
/>,
);
expect(container.firstChild).toHaveAttribute('data-theme-mode', 'light');
expect(screen.getByText('Theme Preview')).toBeInTheDocument();
expect(screen.getByText('Button Variants')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Primary' })).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Secondary' }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Tertiary' }),
).toBeInTheDocument();
expect(screen.getByText('Form Inputs')).toBeInTheDocument();
expect(screen.getByText('Surface Variations')).toBeInTheDocument();
});
});
@@ -89,12 +89,9 @@ export function BuiThemePreview({ mode, styleObject }: IsolatedPreviewProps) {
/>
<Checkbox label="Checkbox Option" />
<RadioGroup label="Radio Group" orientation="horizontal">
<Radio value="radio-option" />
<Text variant="body-small">Option 1</Text>
<Radio value="radio-option" />
<Text variant="body-small">Option 2</Text>
<Radio value="radio-option" />
<Text variant="body-small">Option 3</Text>
<Radio value="option-1">Option 1</Radio>
<Radio value="option-2">Option 2</Radio>
<Radio value="option-3">Option 3</Radio>
</RadioGroup>
</Flex>
</CardBody>
@@ -0,0 +1,82 @@
/*
* 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 { screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { BuiThemerPage } from './BuiThemerPage';
import { appThemeApiRef } from '@backstage/core-plugin-api';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { ThemeProvider, createTheme } from '@mui/material/styles';
function makeAppTheme(id: string, title: string, variant: 'light' | 'dark') {
return {
id,
title,
variant,
Provider: ({ children }: { children: React.ReactNode }) => (
<ThemeProvider theme={createTheme()}>{children}</ThemeProvider>
),
};
}
describe('BuiThemerPage', () => {
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
});
it('renders empty state when no themes installed', async () => {
const apis = [[appThemeApiRef, { getInstalledThemes: () => [] }]] as const;
await renderInTestApp(
<TestApiProvider apis={apis}>
<BuiThemerPage />
</TestApiProvider>,
);
expect(
screen.getByText(/No themes found. Please install some themes/i),
).toBeInTheDocument();
});
it('renders ThemeContent for each installed theme', async () => {
const themes = [
makeAppTheme('light', 'Light', 'light'),
makeAppTheme('dark', 'Dark', 'dark'),
];
const apis = [
[appThemeApiRef, { getInstalledThemes: () => themes }],
] as const;
await renderInTestApp(
<TestApiProvider apis={apis}>
<BuiThemerPage />
</TestApiProvider>,
);
expect(screen.getByText('Light')).toBeInTheDocument();
expect(screen.getByText('Dark')).toBeInTheDocument();
});
});
@@ -0,0 +1,41 @@
/*
* 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 } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MuiThemeExtractor } from './MuiThemeExtractor';
import { ThemeProvider, createTheme } from '@mui/material/styles';
const mockAppTheme = {
id: 'mock',
title: 'Mock',
variant: 'light' as const,
Provider: ({ children }: { children: React.ReactNode }) => (
<ThemeProvider theme={createTheme()}>{children}</ThemeProvider>
),
};
describe('MuiThemeExtractor', () => {
it('invokes render prop with extracted theme', async () => {
render(
<MuiThemeExtractor appTheme={mockAppTheme}>
{theme => <div>theme-palette-mode-{theme.palette.mode}</div>}
</MuiThemeExtractor>,
);
expect(screen.getByText('theme-palette-mode-light')).toBeInTheDocument();
});
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { AppTheme } from '@backstage/core-plugin-api';
import { Theme, useTheme } from '@mui/material/styles';
@@ -36,7 +36,14 @@ export function MuiThemeExtractor(props: {
);
}
function MuiThemeExtractorInner(props: { setTheme: (theme: Theme) => void }) {
props.setTheme(useTheme());
function MuiThemeExtractorInner({
setTheme,
}: {
setTheme: (theme: Theme) => void;
}) {
const theme = useTheme();
useEffect(() => {
setTheme(theme);
}, [theme, setTheme]);
return null;
}
@@ -0,0 +1,99 @@
/*
* 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 { screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { ThemeContent } from './ThemeContent';
import { createTheme } from '@mui/material/styles';
import { renderInTestApp } from '@backstage/test-utils';
jest.mock('./convertMuiToBuiTheme', () => ({
convertMuiToBuiTheme: () => ({
css: ':root { --bui-color: #000; }',
styleObject: { '--bui-color': '#000' },
}),
}));
describe('ThemeContent', () => {
const muiTheme = createTheme();
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
});
it('renders title, variant and tabs', async () => {
await renderInTestApp(
<ThemeContent
themeId="light"
themeTitle="Light Theme"
variant="light"
muiTheme={muiTheme}
/>,
);
expect(screen.getByText('Light Theme')).toBeInTheDocument();
expect(screen.getByText('light theme')).toBeInTheDocument();
expect(screen.getByText('Generated CSS')).toBeInTheDocument();
expect(screen.getByText('Live Preview')).toBeInTheDocument();
expect(
screen.getByText(':root { --bui-color: #000; }'),
).toBeInTheDocument();
});
it('handles Copy CSS click', async () => {
const writeText = jest.fn();
Object.assign(window.navigator, { clipboard: { writeText } });
await renderInTestApp(
<ThemeContent
themeId="light"
themeTitle="Light Theme"
variant="light"
muiTheme={muiTheme}
/>,
);
fireEvent.click(screen.getByRole('button', { name: 'Copy CSS' }));
expect(writeText).toHaveBeenCalledWith(':root { --bui-color: #000; }');
});
it('switches to Live Preview tab', async () => {
await renderInTestApp(
<ThemeContent
themeId="light"
themeTitle="Light Theme"
variant="light"
muiTheme={muiTheme}
/>,
);
fireEvent.click(screen.getByText('Live Preview'));
// The preview renders content from BuiThemePreview; basic smoke check:
expect(screen.getByText('Theme Preview')).toBeInTheDocument();
});
});