// src/layout/__tests__/Header.test.tsx
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, cleanup, fireEvent } from '@testing-library/react'
import { Header } from '../Header'
import { LayoutProvider } from '../LayoutContext'
const updates = vi.hoisted(() => ({ available: false, apply: vi.fn(), retry: vi.fn(), error: '', flush: vi.fn() }))
vi.mock('../../app/pwa-updates', () => ({
updateAvailable: () => updates.available,
updateError: () => updates.error,
subscribeToUpdates: () => () => {},
applyPwaUpdate: updates.apply,
retryPwaUpdate: updates.retry,
registerPwaUpdateFlush: updates.flush,
}))
describe('Header', () => {
beforeEach(() => {
vi.useFakeTimers()
updates.available = false
updates.error = ''
updates.apply.mockReset()
updates.retry.mockReset()
})
afterEach(() => {
Object.defineProperty(window, 'visualViewport', { value: undefined, writable: true, configurable: true })
vi.useRealTimers()
cleanup()
})
function renderHeader() {
return render(
)
}
it('displays Reams branding text', () => {
renderHeader()
expect(screen.getByText('Reams')).toBeInTheDocument()
})
it('does not display document title', () => {
renderHeader()
const header = screen.getByRole('banner')
expect(header).not.toHaveTextContent('No document open')
})
it('uses the shared action button when an update is ready', () => {
updates.available = true
renderHeader()
expect(screen.getByRole('button', { name: 'Update' })).toHaveClass('reams-action-button', 'header-update-btn')
})
it('does not render an editor mode button, including with legacy props', () => {
render(
)
expect(screen.queryByRole('button', { name: /current mode/i })).not.toBeInTheDocument()
})
it('does not register a no-op flush when no before-update callback is provided', () => {
renderHeader()
expect(updates.flush).not.toHaveBeenCalled()
})
it('registers the provided before-update callback as the update flush', () => {
const onBeforeUpdate = vi.fn().mockResolvedValue(undefined)
render(
)
expect(updates.flush).toHaveBeenCalledWith(onBeforeUpdate)
})
it('shows a retry button when an update error is reported', () => {
updates.error = 'write failed'
renderHeader()
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument()
expect(screen.getByRole('alert')).toHaveTextContent('write failed')
})
it('retries the update when the retry button is clicked', async () => {
updates.error = 'write failed'
updates.retry.mockResolvedValue(undefined)
renderHeader()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(updates.retry).toHaveBeenCalledTimes(1)
})
describe('branding', () => {
let removeItemSpy: ReturnType
let reloadSpy: ReturnType
let deleteDatabaseSpy: ReturnType
beforeEach(() => {
removeItemSpy = vi.spyOn(Storage.prototype, 'removeItem')
deleteDatabaseSpy = vi.fn()
Object.defineProperty(window, 'indexedDB', {
value: { deleteDatabase: deleteDatabaseSpy }, writable: true, configurable: true,
})
// Mock location.reload — jsdom doesn't support navigation
// Use Object.defineProperty since location.reload is non-configurable
reloadSpy = vi.fn() as unknown as ReturnType
Object.defineProperty(window, 'location', {
value: { ...window.location, reload: reloadSpy },
writable: true,
configurable: true,
})
})
afterEach(() => {
removeItemSpy.mockRestore()
Object.defineProperty(window, 'indexedDB', { value: undefined, writable: true, configurable: true })
})
it('preserves local state after repeated branding clicks', () => {
localStorage.setItem('reams:ribbon-config', '{}')
localStorage.setItem('other-app:data', 'keep')
renderHeader()
const branding = screen.getByText('Reams')
for (let i = 0; i < 8; i++) {
fireEvent.click(branding)
}
expect(removeItemSpy).not.toHaveBeenCalled()
expect(deleteDatabaseSpy).not.toHaveBeenCalled()
expect(localStorage.getItem('reams:ribbon-config')).toBe('{}')
expect(localStorage.getItem('other-app:data')).toBe('keep')
expect(reloadSpy).not.toHaveBeenCalled()
})
})
})