File size: 7,084 Bytes
ad48a43 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 | /**
* 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 <body>, outside a transformed drawer, as a labelled modal dialog', () => {
render(
<div data-testid="drawer" style={{ transform: 'translateX(0)' }}>
<ModalSheet labelledBy="t" onRequestClose={() => {}} header={<h2 id="t">Title</h2>} footer={<button>Save</button>}>
<input aria-label="field" />
</ModalSheet>
</div>,
)
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(
<ModalSheet labelledBy="t" onRequestClose={onClose} header={<button>First</button>} footer={<button>Last</button>}>
<span id="t">x</span>
</ModalSheet>,
)
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(<ProfileSettingsModal backendUrl="http://hp" apiKey="" nsfwMode={false} onClose={() => {}} />)
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(<ProfileSettingsModal backendUrl="http://hp" apiKey="" nsfwMode={false} onClose={onClose} />)
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(<AuthGate><div>app</div></AuthGate>)
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(<AuthGate><div>app</div></AuthGate>)
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 <div>recovered</div>
}
render(<AppErrorBoundary><Fragile /></AppErrorBoundary>)
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()
})
})
|