refactor
Some checks failed
Build, test, publish, deploy / build-test (push) Has been cancelled
Build, test, publish, deploy / publish (push) Has been cancelled
Build, test, publish, deploy / deploy-prd (push) Has been cancelled

This commit is contained in:
Daniel Noulet 2026-10-10 02:30:24 +02:00
parent 2967f1a5af
commit 618b700feb
6 changed files with 738 additions and 0 deletions

View 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())
})
})

View 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())
})
})

View 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
View 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"]
}