refactor
This commit is contained in:
parent
2967f1a5af
commit
618b700feb
6 changed files with 738 additions and 0 deletions
166
web/src/components/fallback-switcher.integration.test.tsx
Normal file
166
web/src/components/fallback-switcher.integration.test.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
/**
|
||||
* 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())
|
||||
})
|
||||
})
|
||||
214
web/src/components/real-controls.integration.test.tsx
Normal file
214
web/src/components/real-controls.integration.test.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
/**
|
||||
* Real-controls integration test.
|
||||
*
|
||||
* The earlier switcher tests used stand-in custom elements, which is exactly why
|
||||
* they passed while the browser did not. This one imports the ACTUAL shipped SDK
|
||||
* components (`sdk/src/components.js`) and wires them to the real `AdminThemeProvider`
|
||||
* and `ThemeSwitcher`, then clicks the real shadow-DOM buttons and asserts that the
|
||||
* document element ends up carrying the attributes the preview stylesheet is scoped
|
||||
* to, and that the choice is persisted.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, render, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
import { AdminThemeProvider } from './admin-theme'
|
||||
import { ThemeSwitcher } from './shell'
|
||||
|
||||
// Import the real controls for their side effect (defining the custom elements).
|
||||
import '../../../sdk/src/components.js'
|
||||
import { DEFAULT_STORAGE_KEY, resetContractState } from '../../../sdk/src/contract.js'
|
||||
|
||||
const THEME_ATTR = 'data-bananas-theme'
|
||||
const APPEARANCE_ATTR = 'data-bananas-appearance'
|
||||
|
||||
const CONTRACT_THEMES = [
|
||||
{ slug: 'midnight', name: 'Midnight', defaultMode: 'dark', version: 1 },
|
||||
{ slug: 'daylight', name: 'Daylight', defaultMode: 'light', version: 1 },
|
||||
]
|
||||
|
||||
vi.mock('../lib/api', () => ({
|
||||
getManifest: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
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: '',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
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')
|
||||
|
||||
function installFetchMock() {
|
||||
const fetchMock = vi.fn((input: string) => {
|
||||
if (input.endsWith('/contract.json')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ themes: CONTRACT_THEMES }),
|
||||
} as Response)
|
||||
}
|
||||
if (input.endsWith('/contract.css')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: () => Promise.resolve(':root{}'),
|
||||
} as Response)
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: () => Promise.resolve({}),
|
||||
text: () => Promise.resolve(''),
|
||||
} as Response)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
function renderRealSwitcher() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } })
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<AdminThemeProvider>
|
||||
<ThemeSwitcher />
|
||||
</AdminThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function shadowButton(tag: string, label: string): HTMLButtonElement {
|
||||
const host = document.querySelector(tag)
|
||||
const buttons = Array.from(host?.shadowRoot?.querySelectorAll('button') ?? [])
|
||||
// The shipped controls label buttons by accessible name (aria-label), not by text
|
||||
// content — the appearance toggle in particular is icon-only.
|
||||
const match = buttons.find(
|
||||
(b) => b.getAttribute('aria-label') === label || b.textContent?.trim() === label,
|
||||
)
|
||||
if (!match)
|
||||
throw new Error(
|
||||
`No shadow button "${label}" in <${tag}> (had: ${buttons
|
||||
.map((b) => b.getAttribute('aria-label') ?? b.textContent?.trim())
|
||||
.join(', ')})`,
|
||||
)
|
||||
return match
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
installFetchMock()
|
||||
window.localStorage.clear()
|
||||
localStorage.removeItem(DEFAULT_STORAGE_KEY)
|
||||
resetContractState()
|
||||
const root = document.documentElement
|
||||
root.removeAttribute('data-theme')
|
||||
root.removeAttribute(THEME_ATTR)
|
||||
root.removeAttribute('data-mode')
|
||||
root.removeAttribute(APPEARANCE_ATTR)
|
||||
root.classList.remove('dark')
|
||||
document.querySelector('link[data-bananas-admin]')?.remove()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('the real shipped controls drive the admin theme', () => {
|
||||
it('renders the theme buttons from contract.json', async () => {
|
||||
renderRealSwitcher()
|
||||
await waitFor(() => expect(shadowButton('bananas-theme-selector', 'Daylight')).toBeTruthy())
|
||||
expect(shadowButton('bananas-theme-selector', 'Midnight')).toBeTruthy()
|
||||
expect(shadowButton('bananas-theme-selector', 'None')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('picking a theme sets both attributes and persists the choice', async () => {
|
||||
renderRealSwitcher()
|
||||
await waitFor(() => expect(shadowButton('bananas-theme-selector', 'Daylight')).toBeTruthy())
|
||||
|
||||
act(() => {
|
||||
shadowButton('bananas-theme-selector', 'Daylight').click()
|
||||
})
|
||||
|
||||
const root = document.documentElement
|
||||
await waitFor(() => expect(root.getAttribute(THEME_ATTR)).toBe('daylight'))
|
||||
expect(root.getAttribute(APPEARANCE_ATTR)).toBe('light')
|
||||
expect(root.classList.contains('dark')).toBe(false)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.updatePreferences).toHaveBeenCalledWith({
|
||||
selectedTheme: 'daylight',
|
||||
selectedAppearance: 'light',
|
||||
}),
|
||||
)
|
||||
expect(
|
||||
document.querySelector<HTMLLinkElement>('link[data-bananas-admin="bananas-admin-preview"]')
|
||||
?.href,
|
||||
).toContain('slug=daylight')
|
||||
})
|
||||
|
||||
it('toggling appearance sets the dark attribute and persists it', async () => {
|
||||
vi.mocked(api.getSession).mockResolvedValue({
|
||||
authenticated: true,
|
||||
mode: 'dev',
|
||||
preferences: { selectedTheme: 'daylight', selectedAppearance: 'light' },
|
||||
})
|
||||
renderRealSwitcher()
|
||||
await waitFor(() => expect(shadowButton('bananas-appearance-toggle', 'Dark')).toBeTruthy())
|
||||
|
||||
act(() => {
|
||||
shadowButton('bananas-appearance-toggle', 'Dark').click()
|
||||
})
|
||||
|
||||
const root = document.documentElement
|
||||
await waitFor(() => expect(root.getAttribute(APPEARANCE_ATTR)).toBe('dark'))
|
||||
expect(root.classList.contains('dark')).toBe(true)
|
||||
await waitFor(() =>
|
||||
expect(api.updatePreferences).toHaveBeenCalledWith({
|
||||
selectedTheme: 'daylight',
|
||||
selectedAppearance: 'dark',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('choosing None returns to the admin chrome and forgets the preference', async () => {
|
||||
renderRealSwitcher()
|
||||
await waitFor(() => expect(shadowButton('bananas-theme-selector', 'Daylight')).toBeTruthy())
|
||||
|
||||
act(() => {
|
||||
shadowButton('bananas-theme-selector', 'Daylight').click()
|
||||
})
|
||||
await waitFor(() => expect(document.documentElement.getAttribute(THEME_ATTR)).toBe('daylight'))
|
||||
|
||||
act(() => {
|
||||
shadowButton('bananas-theme-selector', 'None').click()
|
||||
})
|
||||
|
||||
const root = document.documentElement
|
||||
await waitFor(() => expect(root.hasAttribute(THEME_ATTR)).toBe(false))
|
||||
await waitFor(() => expect(root.hasAttribute(APPEARANCE_ATTR)).toBe(false))
|
||||
expect(root.classList.contains('dark')).toBe(false)
|
||||
await waitFor(() => expect(api.resetPreferences).toHaveBeenCalled())
|
||||
await waitFor(() => expect(document.querySelector('link[data-bananas-admin]')).toBeNull())
|
||||
})
|
||||
})
|
||||
289
web/src/components/theme-switcher.integration.test.tsx
Normal file
289
web/src/components/theme-switcher.integration.test.tsx
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
/**
|
||||
* Integration tests for the header theme switcher.
|
||||
*
|
||||
* These tests drive the real `ThemeSwitcher` component against stand-in custom
|
||||
* elements that behave like the shipped controls: they dispatch the same events
|
||||
* and expose the same `value` property. That lets us verify the wiring between
|
||||
* the admin provider, the shell and the runtime controls without relying on a
|
||||
* live server or network fetch.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
import { AdminThemeProvider } from './admin-theme'
|
||||
import { ThemeSwitcher } from './shell'
|
||||
|
||||
vi.mock('../lib/api', () => ({
|
||||
getManifest: vi.fn(() => Promise.resolve({ baseUrl: '/rt/v1', themes: [] })),
|
||||
getSession: vi.fn(() => Promise.resolve({ authenticated: true, mode: 'oidc' })),
|
||||
updatePreferences: vi.fn(() => Promise.resolve({ selectedTheme: '', selectedAppearance: '' })),
|
||||
resetPreferences: vi.fn(() => Promise.resolve({ selectedTheme: '', selectedAppearance: '' })),
|
||||
}))
|
||||
|
||||
const api = await import('../lib/api')
|
||||
|
||||
const THEME_ATTRIBUTE = 'data-bananas-theme'
|
||||
const APPEARANCE_ATTRIBUTE = 'data-bananas-appearance'
|
||||
|
||||
type TestTheme = { slug: string; name: string; defaultMode: 'light' | 'dark' }
|
||||
|
||||
let testThemes: TestTheme[] = []
|
||||
|
||||
class MockBananasThemeSelector extends HTMLElement {
|
||||
#value = ''
|
||||
|
||||
connectedCallback() {
|
||||
const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' })
|
||||
const group = document.createElement('div')
|
||||
group.setAttribute('role', 'radiogroup')
|
||||
group.setAttribute('aria-label', this.getAttribute('label') ?? 'Theme')
|
||||
|
||||
const none = document.createElement('button')
|
||||
none.type = 'button'
|
||||
none.textContent = 'None'
|
||||
none.addEventListener('click', () => this.#select(null, null))
|
||||
group.append(none)
|
||||
|
||||
for (const theme of testThemes) {
|
||||
const button = document.createElement('button')
|
||||
button.type = 'button'
|
||||
button.textContent = theme.name
|
||||
button.addEventListener('click', () => this.#select(theme.slug, theme.defaultMode))
|
||||
group.append(button)
|
||||
}
|
||||
|
||||
root.replaceChildren(group)
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.#value
|
||||
}
|
||||
|
||||
set value(slug: string) {
|
||||
this.#value = slug
|
||||
this.connectedCallback()
|
||||
}
|
||||
|
||||
#select(slug: string | null, mode: string | null) {
|
||||
this.#value = slug ?? ''
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('bananas:theme', {
|
||||
detail: { slug, mode },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class MockBananasAppearanceToggle extends HTMLElement {
|
||||
#value: 'admin' | 'light' | 'dark' = 'admin'
|
||||
|
||||
connectedCallback() {
|
||||
this.#render()
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.#value
|
||||
}
|
||||
|
||||
set value(mode: 'admin' | 'light' | 'dark') {
|
||||
this.#value = mode
|
||||
this.#render()
|
||||
}
|
||||
|
||||
#render() {
|
||||
const root = this.shadowRoot ?? this.attachShadow({ mode: 'open' })
|
||||
const group = document.createElement('div')
|
||||
group.setAttribute('role', 'radiogroup')
|
||||
group.setAttribute('aria-label', this.getAttribute('label') ?? 'Appearance')
|
||||
|
||||
for (const mode of ['admin', 'light', 'dark'] as const) {
|
||||
const button = document.createElement('button')
|
||||
button.type = 'button'
|
||||
button.textContent = mode
|
||||
button.addEventListener('click', () => {
|
||||
this.#value = mode
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('bananas:appearance', {
|
||||
detail: { mode },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
group.append(button)
|
||||
}
|
||||
|
||||
root.replaceChildren(group)
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get('bananas-theme-selector')) {
|
||||
customElements.define('bananas-theme-selector', MockBananasThemeSelector)
|
||||
}
|
||||
if (!customElements.get('bananas-appearance-toggle')) {
|
||||
customElements.define('bananas-appearance-toggle', MockBananasAppearanceToggle)
|
||||
}
|
||||
|
||||
function renderSwitcher(themes: TestTheme[] = []) {
|
||||
testThemes = themes
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } })
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<AdminThemeProvider>
|
||||
<ThemeSwitcher />
|
||||
</AdminThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function themeSelector() {
|
||||
return document.querySelector<MockBananasThemeSelector>('bananas-theme-selector')
|
||||
}
|
||||
|
||||
function appearanceToggle() {
|
||||
return document.querySelector<MockBananasAppearanceToggle>('bananas-appearance-toggle')
|
||||
}
|
||||
|
||||
function clickShadowButton(host: HTMLElement | null, label: string) {
|
||||
const button = host?.shadowRoot?.querySelectorAll('button')
|
||||
const match = Array.from(button ?? []).find((b) => b.textContent === label)
|
||||
if (!match) throw new Error(`Shadow button "${label}" not found`)
|
||||
match.click()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
document.documentElement.removeAttribute(THEME_ATTRIBUTE)
|
||||
document.documentElement.removeAttribute(APPEARANCE_ATTRIBUTE)
|
||||
document.querySelector('link[data-bananas-admin]')?.remove()
|
||||
testThemes = []
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('theme selector', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.getManifest).mockResolvedValue({
|
||||
apiVersion: 'v1',
|
||||
baseUrl: '/rt/v1',
|
||||
themes: [
|
||||
{
|
||||
slug: 'midnight',
|
||||
name: 'Midnight',
|
||||
defaultAppearance: 'dark',
|
||||
version: 1,
|
||||
css: '/rt/v1/theme.css?slug=midnight',
|
||||
tokens: '/rt/v1/theme.json?slug=midnight',
|
||||
},
|
||||
{
|
||||
slug: 'daylight',
|
||||
name: 'Daylight',
|
||||
defaultAppearance: 'light',
|
||||
version: 1,
|
||||
css: '/rt/v1/theme.css?slug=daylight',
|
||||
tokens: '/rt/v1/theme.json?slug=daylight',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('selects a theme from the dropdown and persists it', async () => {
|
||||
renderSwitcher([
|
||||
{ slug: 'midnight', name: 'Midnight', defaultMode: 'dark' },
|
||||
{ slug: 'daylight', name: 'Daylight', defaultMode: 'light' },
|
||||
])
|
||||
|
||||
await waitFor(() => expect(themeSelector()).toBeInTheDocument())
|
||||
|
||||
clickShadowButton(themeSelector(), 'Daylight')
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.updatePreferences).toHaveBeenCalledWith({
|
||||
selectedTheme: 'daylight',
|
||||
selectedAppearance: 'light',
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
document.querySelector<HTMLLinkElement>('link[data-bananas-admin="bananas-admin-preview"]')
|
||||
?.href,
|
||||
).toContain('/rt/v1/theme.css?slug=daylight&variant=full'),
|
||||
)
|
||||
})
|
||||
|
||||
it('selecting no theme returns to the admin chrome and forgets the preference', async () => {
|
||||
renderSwitcher([{ slug: 'midnight', name: 'Midnight', defaultMode: 'dark' }])
|
||||
|
||||
await waitFor(() => expect(themeSelector()).toBeInTheDocument())
|
||||
|
||||
clickShadowButton(themeSelector(), 'Midnight')
|
||||
await waitFor(() => expect(api.updatePreferences).toHaveBeenCalled())
|
||||
|
||||
clickShadowButton(themeSelector(), 'None')
|
||||
|
||||
await waitFor(() => expect(api.resetPreferences).toHaveBeenCalled())
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector('link[data-bananas-admin]')).not.toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('appearance toggle', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.getSession).mockResolvedValue({
|
||||
authenticated: true,
|
||||
mode: 'oidc',
|
||||
preferences: { selectedTheme: 'midnight', selectedAppearance: 'dark' },
|
||||
})
|
||||
})
|
||||
|
||||
it('switches between light and dark and persists the choice', async () => {
|
||||
renderSwitcher()
|
||||
|
||||
await waitFor(() => expect(appearanceToggle()).toBeInTheDocument())
|
||||
|
||||
clickShadowButton(appearanceToggle(), 'light')
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.updatePreferences).toHaveBeenCalledWith({
|
||||
selectedTheme: 'midnight',
|
||||
selectedAppearance: 'light',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('switching back to admin forgets the preference', async () => {
|
||||
renderSwitcher()
|
||||
|
||||
await waitFor(() => expect(appearanceToggle()).toBeInTheDocument())
|
||||
|
||||
clickShadowButton(appearanceToggle(), 'admin')
|
||||
|
||||
await waitFor(() => expect(api.resetPreferences).toHaveBeenCalled())
|
||||
expect(api.updatePreferences).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('document attributes', () => {
|
||||
it('are not written by the admin provider', async () => {
|
||||
vi.mocked(api.getSession).mockResolvedValue({
|
||||
authenticated: true,
|
||||
mode: 'oidc',
|
||||
preferences: { selectedTheme: 'midnight', selectedAppearance: 'dark' },
|
||||
})
|
||||
|
||||
renderSwitcher()
|
||||
|
||||
await waitFor(() => expect(appearanceToggle()).toBeInTheDocument())
|
||||
|
||||
expect(document.documentElement.hasAttribute(THEME_ATTRIBUTE)).toBe(false)
|
||||
expect(document.documentElement.hasAttribute(APPEARANCE_ATTRIBUTE)).toBe(false)
|
||||
})
|
||||
})
|
||||
16
web/tsconfig.test.json
Normal file
16
web/tsconfig.test.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
// Test files are excluded from tsconfig.app.json so the production image build
|
||||
// (which runs `tsc -p tsconfig.app.json`) never compiles them. This project covers
|
||||
// them instead, and it is the project that tolerates importing the shipped SDK
|
||||
// source directly — the real-controls integration test drives the actual custom
|
||||
// elements, which only typecheck against `sdk/src` (allowJs), not any published
|
||||
// types.
|
||||
"allowJs": true,
|
||||
"checkJs": false
|
||||
},
|
||||
// Clear the app project's test exclusions: here the tests ARE the point.
|
||||
"exclude": [],
|
||||
"include": ["src", "../sdk/src"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue