refactor
This commit is contained in:
parent
2967f1a5af
commit
618b700feb
6 changed files with 738 additions and 0 deletions
43
hack/deploy-lcl.sh
Executable file
43
hack/deploy-lcl.sh
Executable file
|
|
@ -0,0 +1,43 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# deploy-lcl.sh — build go-bananas, push it to the local registry, and deploy it to
|
||||||
|
# the lcl k3s cluster, then verify it is live through the TLS edge.
|
||||||
|
#
|
||||||
|
# Thin wrapper around `make deploy-lcl` that adds the two things that actually go
|
||||||
|
# wrong locally: talking to the cluster, and pulling a stale cached image.
|
||||||
|
#
|
||||||
|
# ./hack/deploy-lcl.sh
|
||||||
|
# KUBECONFIG=/etc/rancher/k3s/k3s.yaml ./hack/deploy-lcl.sh
|
||||||
|
# LCL_TAG=v1.2.3 ./hack/deploy-lcl.sh
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
LCL_REGISTRY="${LCL_REGISTRY:-localhost:5000}"
|
||||||
|
LCL_NAMESPACE="${LCL_NAMESPACE:-go-insane-lcl}"
|
||||||
|
|
||||||
|
# Prefer an explicit KUBECONFIG; otherwise try the direct k3s config if the current
|
||||||
|
# context cannot reach a node. The default `local` context routes through a Rancher
|
||||||
|
# proxy that can 502, and a deploy against it fails with a confusing transport error
|
||||||
|
# rather than a real one.
|
||||||
|
if [ -z "${KUBECONFIG:-}" ]; then
|
||||||
|
if ! timeout 10 kubectl get nodes >/dev/null 2>&1 && [ -r /etc/rancher/k3s/k3s.yaml ]; then
|
||||||
|
echo "note: current kube context cannot reach a node; using /etc/rancher/k3s/k3s.yaml" >&2
|
||||||
|
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== preflight ==="
|
||||||
|
timeout 6 curl -sf "${LCL_REGISTRY}/v2/" >/dev/null || {
|
||||||
|
echo "error: no registry answering at ${LCL_REGISTRY}/v2 — start it before deploying" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
kubectl get nodes >/dev/null 2>&1 || {
|
||||||
|
echo "error: cannot reach the cluster (KUBECONFIG=${KUBECONFIG:-<default>})" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "registry OK (${LCL_REGISTRY}) ; cluster OK ($(kubectl config current-context 2>/dev/null || echo "$KUBECONFIG"))"
|
||||||
|
|
||||||
|
echo "=== build + push + deploy + verify ==="
|
||||||
|
exec make deploy-lcl LCL_NAMESPACE="$LCL_NAMESPACE"
|
||||||
10
sdk/src/global.d.ts
vendored
Normal file
10
sdk/src/global.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
/* eslint-disable @typescript-eslint/consistent-type-definitions */
|
||||||
|
|
||||||
|
export {}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
/** Global escape hatch so script-tag consumers can reach the shared store. */
|
||||||
|
__BANANAS_GET_CONTRACT_STATE__?: typeof import('./contract.js').getContractState
|
||||||
|
}
|
||||||
|
}
|
||||||
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