167 lines
6.1 KiB
TypeScript
167 lines
6.1 KiB
TypeScript
|
|
/**
|
||
|
|
* Fallback switcher integration test.
|
||
|
|
*
|
||
|
|
* If the shipped controls never upgrade — a blocked module load, an older bundle, a
|
||
|
|
* proxy hiccup — the admin falls back to `LegacyThemeSwitcher`. This test keeps that
|
||
|
|
* path genuinely usable: the theme dropdown must always be present (even in the admin
|
||
|
|
* chrome), and choosing a theme or toggling light/dark must apply the document
|
||
|
|
* attributes the preview stylesheet is scoped to and persist the choice.
|
||
|
|
*
|
||
|
|
* It renders the REAL `ThemeSwitcher` under the REAL `AdminThemeProvider` with
|
||
|
|
* `loadRuntimeControls` mocked to never resolve, so `controlsReady` stays false and the
|
||
|
|
* fallback is what renders.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||
|
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||
|
|
|
||
|
|
import { AdminThemeProvider } from './admin-theme'
|
||
|
|
import { ThemeSwitcher } from './shell'
|
||
|
|
|
||
|
|
vi.mock('../lib/runtime-controls', () => ({
|
||
|
|
// Never resolves: the shipped controls never upgrade, so the fallback stays in place.
|
||
|
|
loadRuntimeControls: () => new Promise<void>(() => undefined),
|
||
|
|
}))
|
||
|
|
|
||
|
|
vi.mock('../lib/api', () => ({
|
||
|
|
getManifest: vi.fn(() => Promise.resolve({ apiVersion: '1', baseUrl: '/rt/v1', themes: [] })),
|
||
|
|
getSession: vi.fn(() => Promise.resolve({ authenticated: true, mode: 'dev' })),
|
||
|
|
updatePreferences: vi.fn(() => Promise.resolve({ selectedTheme: '', selectedAppearance: '' })),
|
||
|
|
resetPreferences: vi.fn(() => Promise.resolve({ selectedTheme: '', selectedAppearance: '' })),
|
||
|
|
}))
|
||
|
|
|
||
|
|
const api = await import('../lib/api')
|
||
|
|
|
||
|
|
const THEME_ATTR = 'data-bananas-theme'
|
||
|
|
const APPEARANCE_ATTR = 'data-bananas-appearance'
|
||
|
|
|
||
|
|
function renderSwitcher() {
|
||
|
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } })
|
||
|
|
return render(
|
||
|
|
<QueryClientProvider client={client}>
|
||
|
|
<AdminThemeProvider>
|
||
|
|
<ThemeSwitcher />
|
||
|
|
</AdminThemeProvider>
|
||
|
|
</QueryClientProvider>,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// The dropdown is populated from the runtime manifest, which arrives asynchronously. A
|
||
|
|
// controlled <select> ignores a value that has no matching <option> yet, so the tests
|
||
|
|
// must wait for the themes to be listed before choosing one — the same as a human.
|
||
|
|
async function previewSelect() {
|
||
|
|
const select = await screen.findByLabelText('Preview theme')
|
||
|
|
await waitFor(() => expect(select).toHaveTextContent('daylight'))
|
||
|
|
await waitFor(() => expect(select).toHaveTextContent('midnight'))
|
||
|
|
return select
|
||
|
|
}
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
window.localStorage.clear()
|
||
|
|
vi.mocked(api.getManifest).mockResolvedValue({
|
||
|
|
apiVersion: '1',
|
||
|
|
baseUrl: '/rt/v1',
|
||
|
|
themes: [
|
||
|
|
{
|
||
|
|
slug: 'midnight',
|
||
|
|
name: 'Midnight',
|
||
|
|
defaultAppearance: 'dark',
|
||
|
|
version: 1,
|
||
|
|
css: '',
|
||
|
|
tokens: '',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
slug: 'daylight',
|
||
|
|
name: 'Daylight',
|
||
|
|
defaultAppearance: 'light',
|
||
|
|
version: 1,
|
||
|
|
css: '',
|
||
|
|
tokens: '',
|
||
|
|
},
|
||
|
|
],
|
||
|
|
})
|
||
|
|
vi.mocked(api.getSession).mockResolvedValue({ authenticated: true, mode: 'dev' })
|
||
|
|
document.documentElement.removeAttribute(THEME_ATTR)
|
||
|
|
document.documentElement.removeAttribute(APPEARANCE_ATTR)
|
||
|
|
document.querySelector('link[data-bananas-admin]')?.remove()
|
||
|
|
})
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
vi.clearAllMocks()
|
||
|
|
})
|
||
|
|
|
||
|
|
describe('fallback switcher (shipped controls never loaded)', () => {
|
||
|
|
it('renders the theme dropdown even while in the admin chrome', async () => {
|
||
|
|
renderSwitcher()
|
||
|
|
const select = await previewSelect()
|
||
|
|
// Both published themes are offered regardless of the current mode.
|
||
|
|
expect(select).toHaveTextContent(/daylight/i)
|
||
|
|
expect(select).toHaveTextContent(/midnight/i)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('choosing a theme from the dropdown applies its default appearance', async () => {
|
||
|
|
renderSwitcher()
|
||
|
|
const select = await previewSelect()
|
||
|
|
|
||
|
|
fireEvent.change(select, { target: { value: 'daylight' } })
|
||
|
|
|
||
|
|
const root = document.documentElement
|
||
|
|
// Provider is the sole writer in the fallback, so the preview attributes must land.
|
||
|
|
await waitFor(() => expect(root.getAttribute(THEME_ATTR)).toBe('daylight'))
|
||
|
|
expect(root.getAttribute(APPEARANCE_ATTR)).toBe('light')
|
||
|
|
await waitFor(() =>
|
||
|
|
expect(api.updatePreferences).toHaveBeenCalledWith({
|
||
|
|
selectedTheme: 'daylight',
|
||
|
|
selectedAppearance: 'light',
|
||
|
|
}),
|
||
|
|
)
|
||
|
|
expect(
|
||
|
|
document
|
||
|
|
.querySelector('link[data-bananas-admin="bananas-admin-preview"]')
|
||
|
|
?.getAttribute('href'),
|
||
|
|
).toContain('slug=daylight')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('choosing a dark-default theme previews it dark', async () => {
|
||
|
|
renderSwitcher()
|
||
|
|
const select = await previewSelect()
|
||
|
|
|
||
|
|
fireEvent.change(select, { target: { value: 'midnight' } })
|
||
|
|
|
||
|
|
await waitFor(() => expect(document.documentElement.getAttribute(THEME_ATTR)).toBe('midnight'))
|
||
|
|
expect(document.documentElement.getAttribute(APPEARANCE_ATTR)).toBe('dark')
|
||
|
|
})
|
||
|
|
|
||
|
|
it('the light/dark buttons repaint and persist', async () => {
|
||
|
|
renderSwitcher()
|
||
|
|
// First pick a theme so we are in a preview mode with a concrete slug.
|
||
|
|
const select = await previewSelect()
|
||
|
|
fireEvent.change(select, { target: { value: 'daylight' } })
|
||
|
|
await waitFor(() => expect(document.documentElement.getAttribute(THEME_ATTR)).toBe('daylight'))
|
||
|
|
|
||
|
|
fireEvent.click(screen.getByRole('button', { name: 'Dark' }))
|
||
|
|
|
||
|
|
await waitFor(() => expect(document.documentElement.getAttribute(APPEARANCE_ATTR)).toBe('dark'))
|
||
|
|
await waitFor(() =>
|
||
|
|
expect(api.updatePreferences).toHaveBeenCalledWith({
|
||
|
|
selectedTheme: 'daylight',
|
||
|
|
selectedAppearance: 'dark',
|
||
|
|
}),
|
||
|
|
)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('returning to admin clears the preview attributes', async () => {
|
||
|
|
renderSwitcher()
|
||
|
|
const select = await previewSelect()
|
||
|
|
fireEvent.change(select, { target: { value: 'daylight' } })
|
||
|
|
await waitFor(() => expect(document.documentElement.hasAttribute(THEME_ATTR)).toBe(true))
|
||
|
|
|
||
|
|
fireEvent.click(screen.getByRole('button', { name: 'Admin' }))
|
||
|
|
|
||
|
|
await waitFor(() => expect(document.documentElement.hasAttribute(THEME_ATTR)).toBe(false))
|
||
|
|
await waitFor(() => expect(document.documentElement.hasAttribute(APPEARANCE_ATTR)).toBe(false))
|
||
|
|
await waitFor(() => expect(api.resetPreferences).toHaveBeenCalled())
|
||
|
|
})
|
||
|
|
})
|