mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
The settings shell painted the onboarding overlay (opaque stage, mask, #root inert) the moment a step was registered and not locally completed, while every step still had to load its private join before deciding whether to show — rendering null could not suppress the shell-owned chrome. Every reload on the hero therefore flashed a full-screen opaque layer (white in the light palette) for one settings/credential RPC round-trip after the session list turned ready. The chrome now belongs to the step: a new zero-cordis OnboardingSurface primitive (ui-primitives) renders the body-portaled overlay/mask/stage verbatim from the former SettingsRoot stylesheet and holds #root inert for exactly its own lifetime. WelcomeNotice and DeepSeekOnboardingDialog wrap only their visible branch in it, so their existing null branches paint and block nothing by construction. SettingsRoot keeps the coordinator unchanged but renders the elected step bare, and the settings.onboarding contract now names the surface wrap as the registrant's obligation. The onboarding e2e gains a held-join reload scenario pinning that a configured world never mounts the takeover chrome or inerts the app.
247 lines
9.2 KiB
TypeScript
247 lines
9.2 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { useEffect, useState } from 'react'
|
|
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
import type { SettingsRootComponentProps } from '../src/client/contract/slots.ts'
|
|
import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
|
|
|
|
afterEach(cleanup)
|
|
|
|
type Row = { id: string; order: number; label: string }
|
|
type Step = { id: string; order: number }
|
|
|
|
/** Slot-content stand-ins: the shell renders whatever the seats contribute. */
|
|
const SEAT_CONTENT: Record<string, string> = {
|
|
'settings.trigger': 'Settings',
|
|
'settings.header': 'Settings Title',
|
|
'settings.action': 'Open configuration file',
|
|
'settings.close': 'Close',
|
|
}
|
|
|
|
function mount({
|
|
wide = true,
|
|
onboardingActive = true,
|
|
rows = [
|
|
{ id: 'general', order: 0, label: 'General' },
|
|
{ id: 'models', order: 10, label: 'Models' },
|
|
],
|
|
steps = [
|
|
{ id: 'welcome', order: -100 },
|
|
{ id: 'credential', order: 0 },
|
|
],
|
|
}: { wide?: boolean; onboardingActive?: boolean; rows?: Row[]; steps?: Step[] } = {}) {
|
|
// Mutable row source standing in for the bound useSections hook; bump()
|
|
// plays a ledger change through the same observable contract.
|
|
let current = rows
|
|
const listeners = new Set<() => void>()
|
|
const renderSlot = vi.fn(
|
|
((key: string, _owner: unknown, opts?: { only?: string }) => {
|
|
if (key === 'settings.section') return <div data-testid={`section-${opts?.only ?? 'all'}`} />
|
|
return SEAT_CONTENT[key]
|
|
}) as SettingsRootComponentProps['renderSlot'],
|
|
)
|
|
const useSessions = ((select: (state: unknown) => unknown) => select(onboardingActive
|
|
? { phase: 'ready', current: undefined, byId: {} }
|
|
: {
|
|
phase: 'ready',
|
|
current: 'active-session',
|
|
byId: { 'active-session': { blank: false } },
|
|
})) as never
|
|
const unusedHook = (() => { throw new Error('unused by SettingsRoot') }) as never
|
|
const props: SettingsRootComponentProps = {
|
|
useSessions,
|
|
useWorkspaces: unusedHook,
|
|
wide,
|
|
useOnboardingSteps: select => select(steps),
|
|
useSections: (select) => {
|
|
const [, force] = useState(0)
|
|
useEffect(() => {
|
|
const listener = () => { force(n => n + 1) }
|
|
listeners.add(listener)
|
|
return () => { listeners.delete(listener) }
|
|
}, [])
|
|
return select(current)
|
|
},
|
|
renderSlot,
|
|
}
|
|
const view = render(<SettingsRoot {...props} />)
|
|
const bump = (next: Row[]) => {
|
|
act(() => {
|
|
current = next
|
|
for (const fn of [...listeners]) fn()
|
|
})
|
|
}
|
|
return { view, renderSlot, bump, listeners }
|
|
}
|
|
|
|
function openPanel() {
|
|
fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
|
|
}
|
|
|
|
describe('SettingsRoot trigger', () => {
|
|
it('renders the trigger seat content as the accessible name (no aria-label of its own)', () => {
|
|
const { renderSlot } = mount()
|
|
const trigger = screen.getByRole('button', { name: 'Settings' })
|
|
expect(trigger.hasAttribute('aria-label')).toBe(false)
|
|
expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide: true })
|
|
expect(trigger.getAttribute('aria-expanded')).toBe('false')
|
|
fireEvent.click(trigger)
|
|
expect(screen.getByRole('dialog')).toBeTruthy()
|
|
expect(screen.getByRole('button', { name: 'Settings', expanded: true })).toBeTruthy()
|
|
})
|
|
|
|
it('hands the rail state to the trigger seat', () => {
|
|
const { renderSlot } = mount({ wide: false })
|
|
expect(renderSlot).toHaveBeenCalledWith('settings.trigger', { wide: false })
|
|
})
|
|
})
|
|
|
|
describe('SettingsPanel chrome seats', () => {
|
|
it('names the dialog via aria-labelledby pointing at the header seat node', () => {
|
|
mount()
|
|
openPanel()
|
|
const dialog = screen.getByRole('dialog')
|
|
const titleId = dialog.getAttribute('aria-labelledby')!
|
|
expect(titleId).toBeTruthy()
|
|
const title = document.getElementById(titleId)!
|
|
expect(title.textContent).toBe('Settings Title')
|
|
expect(screen.getByRole('dialog', { name: 'Settings Title' })).toBeTruthy()
|
|
})
|
|
|
|
it('names the close button through the visually-hidden close seat text', () => {
|
|
mount()
|
|
openPanel()
|
|
const close = screen.getByRole('button', { name: 'Close' })
|
|
expect(close.hasAttribute('aria-label')).toBe(false)
|
|
expect(close.textContent).toContain('Close')
|
|
})
|
|
|
|
it('renders header actions before the shell-owned close control', () => {
|
|
const { renderSlot } = mount()
|
|
openPanel()
|
|
expect(screen.getByText('Open configuration file')).toBeTruthy()
|
|
expect(renderSlot).toHaveBeenCalledWith('settings.action', {})
|
|
})
|
|
})
|
|
|
|
describe('SettingsPanel close paths', () => {
|
|
it('closes via the header button', () => {
|
|
mount()
|
|
openPanel()
|
|
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
|
|
expect(screen.queryByRole('dialog')).toBeNull()
|
|
})
|
|
|
|
it('closes via a mask click', () => {
|
|
mount()
|
|
openPanel()
|
|
const dialog = screen.getByRole('dialog')
|
|
fireEvent.click(dialog.parentElement!.firstElementChild!)
|
|
expect(screen.queryByRole('dialog')).toBeNull()
|
|
})
|
|
|
|
it('closes via document-level Escape and unhooks the listener with the panel', () => {
|
|
mount()
|
|
openPanel()
|
|
fireEvent.keyDown(document, { key: 'Escape' })
|
|
expect(screen.queryByRole('dialog')).toBeNull()
|
|
// Ignored while closed (listener removed with the panel) and non-Escape
|
|
// keys are ignored while open.
|
|
fireEvent.keyDown(document, { key: 'Escape' })
|
|
openPanel()
|
|
fireEvent.keyDown(document, { key: 'Enter' })
|
|
expect(screen.getByRole('dialog')).toBeTruthy()
|
|
})
|
|
|
|
it('lands focus on the close button when the dialog opens', () => {
|
|
mount()
|
|
openPanel()
|
|
expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Close' }))
|
|
})
|
|
})
|
|
|
|
describe('SettingsPanel navigation', () => {
|
|
it('projects rows, marks the first active, and renders only that section', () => {
|
|
mount()
|
|
openPanel()
|
|
expect(screen.getByRole('button', { name: 'General' }).getAttribute('aria-current')).toBe('true')
|
|
expect(screen.getByRole('button', { name: 'Models' }).getAttribute('aria-current')).toBeNull()
|
|
expect(screen.getByTestId('section-general')).toBeTruthy()
|
|
})
|
|
|
|
it('switches the rendered section on nav click', () => {
|
|
mount()
|
|
openPanel()
|
|
fireEvent.click(screen.getByRole('button', { name: 'Models' }))
|
|
expect(screen.getByRole('button', { name: 'Models' }).getAttribute('aria-current')).toBe('true')
|
|
expect(screen.getByTestId('section-models')).toBeTruthy()
|
|
expect(screen.queryByTestId('section-general')).toBeNull()
|
|
})
|
|
|
|
it('mounts onboarding steps in order and transfers ownership only on completion', () => {
|
|
const { renderSlot } = mount()
|
|
const first = renderSlot.mock.calls.find(call => call[0] === 'settings.onboarding')
|
|
expect(first?.[1]).toMatchObject({ stepId: 'welcome' })
|
|
expect(first?.[2]).toEqual({ only: 'welcome' })
|
|
act(() => {
|
|
(first?.[1] as { complete: () => void }).complete()
|
|
;(first?.[1] as { complete: () => void }).complete()
|
|
})
|
|
const onboardingCalls = renderSlot.mock.calls.filter(call => call[0] === 'settings.onboarding')
|
|
const second = onboardingCalls.at(-1)
|
|
expect(second?.[1]).toMatchObject({ stepId: 'credential' })
|
|
expect(second?.[2]).toEqual({ only: 'credential' })
|
|
|
|
act(() => {
|
|
(second?.[1] as { openSection: (id: string) => void }).openSection('models')
|
|
})
|
|
expect(screen.getByRole('dialog')).toBeTruthy()
|
|
expect(screen.getByTestId('section-models')).toBeTruthy()
|
|
|
|
cleanup()
|
|
const inactive = mount({ onboardingActive: false }).renderSlot.mock.calls
|
|
.filter(call => call[0] === 'settings.onboarding')
|
|
expect(inactive).toHaveLength(0)
|
|
})
|
|
|
|
it('paints no takeover chrome of its own around the mounted step', () => {
|
|
// The chrome (mask, opaque stage, #root inert) belongs to the step via
|
|
// the OnboardingSurface primitive — a mounted-but-deciding step that
|
|
// renders null must show and block nothing (the reload white-flash fix;
|
|
// onboarding-surface.spec.tsx pins the primitive's half).
|
|
const appRoot = document.createElement('div')
|
|
appRoot.id = 'root'
|
|
document.body.append(appRoot)
|
|
const { view } = mount()
|
|
expect(view.container.querySelector('[class*="onboarding"]')).toBeNull()
|
|
expect(document.body.querySelector('[class*="onboarding"]')).toBeNull()
|
|
expect(appRoot.inert).not.toBe(true)
|
|
view.unmount()
|
|
appRoot.remove()
|
|
})
|
|
|
|
it('falls back to the first row when the active entry unregisters', () => {
|
|
const { bump } = mount()
|
|
openPanel()
|
|
fireEvent.click(screen.getByRole('button', { name: 'Models' }))
|
|
bump([{ id: 'general', order: 0, label: 'General' }])
|
|
expect(screen.queryByRole('button', { name: 'Models' })).toBeNull()
|
|
expect(screen.getByTestId('section-general')).toBeTruthy()
|
|
})
|
|
|
|
it('renders an empty content column when the ledger is empty', () => {
|
|
const { renderSlot } = mount({ rows: [] })
|
|
openPanel()
|
|
expect(screen.getByRole('dialog')).toBeTruthy()
|
|
const sectionCalls = renderSlot.mock.calls.filter(c => c[0] === 'settings.section')
|
|
expect(sectionCalls).toHaveLength(0)
|
|
})
|
|
|
|
it('drops the ledger subscription on unmount', () => {
|
|
const { view, listeners } = mount()
|
|
expect(listeners.size).toBe(1)
|
|
view.unmount()
|
|
expect(listeners.size).toBe(0)
|
|
})
|
|
})
|