go-bananas/web/src/components/fallback-switcher.integration.test.tsx

167 lines
6.1 KiB
TypeScript
Raw Normal View History

2026-10-10 02:30:24 +02:00
/**
* 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())
})
})