/**
* Mobile shell regressions: dialogs escape transformed ancestors, Account
* Settings is one sheet with a single-line Save All and protects unsaved edits,
* startup never sits on a silent blank screen, and render errors are recoverable.
*/
import React from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ModalSheet } from '../ui/components/ModalSheet'
import { AppErrorBoundary } from '../ui/components/AppErrorBoundary'
import AuthGate, { AUTH_CHECK_TIMEOUT_MS, SLOW_START_HINT_MS } from '../ui/components/AuthGate'
import ProfileSettingsModal from '../ui/ProfileSettingsModal'
function json(body: unknown, status = 200) {
return Promise.resolve(new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }))
}
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
localStorage.clear()
document.body.style.overflow = ''
})
describe('ModalSheet', () => {
it('renders at
, outside a transformed drawer, as a labelled modal dialog', () => {
render(
{}} header={Title
} footer={}>
,
)
const dialog = screen.getByRole('dialog', { name: 'Title' })
expect(dialog).toHaveAttribute('aria-modal', 'true')
expect(screen.getByTestId('drawer')).not.toContainElement(dialog)
expect(dialog.closest('.hp-sheet-backdrop')?.parentElement).toBe(document.body)
expect(document.body.style.overflow).toBe('hidden')
})
it('closes on Escape and on a backdrop press, and keeps Tab inside', () => {
const onClose = vi.fn()
render(
First} footer={}>
x
,
)
expect(screen.getByText('First')).toHaveFocus()
fireEvent.keyDown(document, { key: 'Tab', shiftKey: true })
expect(screen.getByText('Last')).toHaveFocus()
fireEvent.keyDown(document, { key: 'Escape' })
fireEvent.mouseDown(document.querySelector('.hp-sheet-backdrop')!)
expect(onClose).toHaveBeenCalledTimes(2)
})
})
describe('Account Settings', () => {
beforeEach(() => {
localStorage.setItem('homepilot_auth_token', 't0k')
vi.spyOn(globalThis, 'fetch').mockImplementation((input: RequestInfo | URL) => {
const url = String(input)
if (url.endsWith('/v1/user-profile')) return json({ ok: true, profile: { display_name: 'Alex', timezone: 'UTC' } })
if (url.endsWith('/v1/user-profile/secrets')) return json({ ok: true, secrets: [] })
if (url.endsWith('/v1/user-memory')) return json({ ok: true, memory: { items: [] } })
return json({ ok: true })
})
})
it('is one sheet: scrollable tabs, a single-line Save All, and a labelled close button', async () => {
render( {}} />)
expect(await screen.findByRole('dialog', { name: 'Account Settings' })).toBeInTheDocument()
expect(screen.getByRole('tablist', { name: 'Settings sections' })).toHaveClass('hp-tabs')
expect(screen.getByRole('tab', { name: 'Profile' })).toHaveAttribute('aria-selected', 'true')
const save = screen.getByRole('button', { name: 'Save All' })
expect(save.className).toContain('whitespace-nowrap')
expect(save.closest('.hp-sheet__footer')).not.toBeNull()
expect(screen.getByRole('button', { name: 'Close settings' })).toBeInTheDocument()
})
it('asks before discarding unsaved edits, and closes freely when nothing changed', async () => {
const onClose = vi.fn()
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
render()
await waitFor(() => expect(screen.queryByLabelText('Loading your settings')).not.toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: 'Close settings' }))
expect(confirm).not.toHaveBeenCalled()
expect(onClose).toHaveBeenCalledTimes(1)
fireEvent.change(screen.getByDisplayValue('Alex'), { target: { value: 'Alexandra' } })
expect(screen.getByText('Unsaved changes')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Close settings' }))
expect(confirm).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledTimes(1) // kept open: the person chose not to discard
})
})
describe('Startup', () => {
it('shows a visible startup screen, explains a slow start, then offers Retry instead of a blank screen', async () => {
vi.useFakeTimers()
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation((_input, init) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })))
}),
)
vi.spyOn(console, 'error').mockImplementation(() => {})
render(app
)
expect(screen.getByRole('status')).toHaveTextContent('Starting HomePilot…')
await act(async () => { vi.advanceTimersByTime(SLOW_START_HINT_MS + 10) })
expect(screen.getByRole('status')).toHaveTextContent('waking up')
await act(async () => { vi.advanceTimersByTime(AUTH_CHECK_TIMEOUT_MS) })
expect(screen.getByRole('alert')).toHaveTextContent('Can’t reach HomePilot')
expect(screen.queryByText('app')).not.toBeInTheDocument()
fetchMock.mockImplementation(() => json({ user: null }))
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Retry' })) })
expect(fetchMock).toHaveBeenCalledTimes(2)
expect(screen.getByText('app')).toBeInTheDocument()
})
it('Continue anyway keeps the old pre-auth path available', async () => {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('Failed to fetch'))
vi.spyOn(console, 'error').mockImplementation(() => {})
render(app
)
fireEvent.click(await screen.findByRole('button', { name: 'Continue anyway' }))
expect(screen.getByText('app')).toBeInTheDocument()
})
})
describe('AppErrorBoundary', () => {
it('turns a render error into a recoverable screen instead of a blank page', () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
let explode = true
function Fragile() {
if (explode) throw new Error('kaboom')
return recovered
}
render()
expect(screen.getByRole('alert')).toHaveTextContent('Something went wrong')
expect(screen.getByText('Error: kaboom')).toBeInTheDocument()
explode = false
fireEvent.click(screen.getByRole('button', { name: 'Try again' }))
expect(screen.getByText('recovered')).toBeInTheDocument()
})
})