Merge origin/master into worktree/web-plugin-config, adapting to ctx.remote.$on

#2201 landed three moves this branch sits on top of. `bindSettingsScope` became
`ctx.settingsScope.bind` on the Settings surface, so the section injects that
service and the Remote face instead of importing a free function; the
`SettingsScope` types moved into the runtime contract module, which is where
this branch's `base`, `user`, and `unset` now live; and the client
`credentials/changed` event became the forwarded owner event
`credentials/updated`, which the web-search card subscribes to through
`ctx.remote.$on`.

The generated module graph crossed mermaid's 1000-edge render guard with this
branch's 14 new edges. The guard doubles again, exactly as it did at 500 — the
graph counts real package edges, and trimming them to fit a renderer default
would make the document lie about the dependency set.
This commit is contained in:
Yichen Jiang
2026-08-11 19:52:34 +08:00
340 changed files with 6517 additions and 1950 deletions

View File

@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client'
import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
import { GeneralSection } from '../src/client/GeneralSection.tsx'
@@ -33,6 +33,9 @@ async function bench(isLoopback = true) {
await ctx.plugin(SlotsService).await()
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
// The plugins inject `remote`; forwarded events reach them through the
// same `$dispatch` handoff the connection sink makes.
new TestRemote(ctx)
const settingsDescribe = vi.fn(() => Promise.resolve({
rpcId: 'settings-general' as never,
result: {
@@ -86,7 +89,7 @@ function generalEntry(slots: SlotsService) {
describe('ui-settings-general apply', () => {
it('declares the services it uses', () => {
expect(inject).toEqual(['slots', 'locale', 'connection'])
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote'])
})
it('fills all six seats for declarations before or after apply', async () => {
@@ -167,9 +170,9 @@ describe('ui-settings-general apply', () => {
const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)()
await controller.load()
expect(b.settingsDescribe).toHaveBeenCalledOnce()
b.ctx.emit('settings/changed', 'unrelated')
b.ctx.remote.$dispatch('settings/document-updated', ['unrelated', 1])
expect(b.settingsDescribe).toHaveBeenCalledOnce()
b.ctx.emit('settings/changed', WELCOME_NOTICE_SETTINGS_NAMESPACE)
b.ctx.remote.$dispatch('settings/document-updated', [WELCOME_NOTICE_SETTINGS_NAMESPACE, 1])
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
b.ctx.emit('connection/reset')
await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) })

View File

@@ -0,0 +1,269 @@
// @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/shell-contract.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' },
{ id: 'agent-presets', order: 20, label: 'Agent presets' },
],
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('gives every section a nav glyph, distinct for the ids the shell knows', () => {
mount({
rows: [
{ id: 'general', order: 0, label: 'General' },
{ id: 'models', order: 10, label: 'Models' },
{ id: 'agent-presets', order: 20, label: 'Agent presets' },
{ id: 'plugins', order: 30, label: 'Plugins' },
{ id: 'contributed', order: 40, label: 'Contributed' },
],
})
openPanel()
// Glyphs carry no id of their own, so the drawn paths are what tells them apart.
const glyphs = ['General', 'Models', 'Agent presets', 'Plugins', 'Contributed']
.map(name => screen.getByRole('button', { name }).querySelector('svg')?.innerHTML)
expect(glyphs.every(glyph => glyph !== undefined && glyph !== '')).toBe(true)
// The three ids the shell names get their own glyph; every other section —
// including one this package never heard of — shares the gear.
expect(new Set(glyphs.slice(0, 4)).size).toBe(4)
expect(glyphs[4]).toBe(glyphs[0])
})
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)
})
})

View File

@@ -0,0 +1,157 @@
/** Settings shell registration: slot declaration injection, the ledger projections, and HMR recovery. */
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '../src/client/index.ts'
import type { SettingsRootInjected } from '../src/client/shell-contract.ts'
import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
// Copy machinery the shell only reads a revision from; the real locale
// plugin would drag its own settings-row dependencies into this bench.
ctx.provide('locale', {
register: () => () => {},
bind: () => (key: string) => key,
getSnapshot: () => ({ active: 'zh', locales: [], revision: 0 }),
subscribe: () => () => {},
} as never)
ctx.provide('connection', {
api: { settings: { describe: async () => ({ result: { ok: false } }) } },
isLoopback: false,
} as never)
ctx.provide('remote', { $on: () => () => {} } as never)
return { ctx, slots: ctx.get('slots') as SlotsService }
}
function declare(slots: SlotsService): () => void {
return slots.register(
{ name: 'root', children: { 'sidebar.settings': { kind: 'single', scope: 'root' } } } as never,
() => null,
)
}
function injectedOf(slots: SlotsService): SettingsRootInjected {
const entry = slots.entries('sidebar.settings')[0]!
return (entry.inject as () => SettingsRootInjected)()
}
/** The shell's child declarations (chrome, actions, sections, and onboarding overlays). */
const CHILD_SPECS = {
'settings.trigger': { kind: 'single', scope: 'root' },
'settings.header': { kind: 'single', scope: 'root' },
'settings.action': { kind: 'list', scope: 'root' },
'settings.close': { kind: 'single', scope: 'root' },
'settings.section': { kind: 'list', scope: 'root' },
'settings.onboarding': { kind: 'list', scope: 'root' },
} as const
describe('ui-settings apply', () => {
it('declares only the slot registry (a pure composition face, no locale)', () => {
expect(inject).toEqual(['slots', 'locale', 'connection', 'remote'])
})
it('registers the shell and declares every child slot, before or after the declaration', async () => {
const before = await bench()
declare(before.slots)
await before.ctx.plugin({ inject: [...inject], apply }).await()
expect(before.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot)
for (const name of Object.keys(CHILD_SPECS) as Array<keyof typeof CHILD_SPECS>) {
expect(before.slots.spec(name)).toEqual(CHILD_SPECS[name])
}
const after = await bench()
await after.ctx.plugin({ inject: [...inject], apply }).await()
expect(after.slots.entries('sidebar.settings')).toHaveLength(0)
declare(after.slots)
await Promise.resolve()
expect(after.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot)
// The self-inflicted ledger notifications hit the duplicate guard.
expect(after.slots.entries('sidebar.settings')).toHaveLength(1)
})
it('projects the section ledger into ordered nav rows with option defaults', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const { sections } = injectedOf(b.slots).hooks
// This package registers the General section itself; every other section
// arrives from a feature registrant.
const GENERAL = { id: 'general', order: 0, label: 'general.nav' }
expect(sections.getSnapshot()).toEqual([GENERAL])
b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null)
// No order and no label: both projection defaults apply.
b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null)
const rows = sections.getSnapshot()
expect(rows).toEqual([
GENERAL,
{ id: 'a', order: 0, label: '' },
{ id: 'z', order: 20, label: 'Z' },
])
// Snapshot identity is stable until the ledger moves (uSES contract).
expect(sections.getSnapshot()).toBe(rows)
const listener = vi.fn()
const off = sections.subscribe(listener)
b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null)
await Promise.resolve()
expect(listener).toHaveBeenCalled()
expect(sections.getSnapshot()).not.toBe(rows)
off()
})
it('projects onboarding entries into stable coordinator order', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
const { onboardingSteps } = injectedOf(b.slots).hooks
b.slots.register({ name: 'settings.onboarding', id: 'credential', order: 0 } as never, () => null)
b.slots.register({ name: 'settings.onboarding', id: 'welcome', order: -100 } as never, () => null)
b.slots.register({ name: 'settings.onboarding', id: 'default-order' } as never, () => null)
const steps = onboardingSteps.getSnapshot()
expect(steps).toEqual([
// This package's own onboarding page, registered by the same apply.
{ id: 'welcome-notice', order: -100 },
{ id: 'welcome', order: -100 },
{ id: 'credential', order: 0 },
{ id: 'default-order', order: 0 },
])
expect(onboardingSteps.getSnapshot()).toBe(steps)
const listener = vi.fn()
const off = onboardingSteps.subscribe(listener)
b.slots.register({ name: 'settings.onboarding', id: 'later', order: 10 } as never, () => null)
await Promise.resolve()
expect(listener).toHaveBeenCalledOnce()
off()
})
it('re-registers after an HMR collapse re-declares the slot (stale disposer must not block)', async () => {
const b = await bench()
const redeclare = declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
expect(b.slots.entries('sidebar.settings')).toHaveLength(1)
// Declarer unload: the cascade removes our entry and every child
// declaration while our local disposer variable goes stale.
redeclare()
expect(b.slots.entries('sidebar.settings')).toHaveLength(0)
expect(b.slots.spec('settings.trigger')).toBeUndefined()
declare(b.slots)
await Promise.resolve()
expect(b.slots.entries('sidebar.settings')[0]!.component).toBe(SettingsRoot)
for (const name of Object.keys(CHILD_SPECS) as Array<keyof typeof CHILD_SPECS>) {
expect(b.slots.spec(name)).toEqual(CHILD_SPECS[name])
}
})
it('unregisters the shell and collapses every child slot on teardown', async () => {
const b = await bench()
declare(b.slots)
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
await fiber.dispose()
expect(b.slots.entries('sidebar.settings')).toHaveLength(0)
for (const name of Object.keys(CHILD_SPECS) as Array<keyof typeof CHILD_SPECS>) {
expect(b.slots.spec(name)).toBeUndefined()
}
})
})