Files
deepseek-harness/packages/client/ui-entry-customization/tests/menu.client.spec.tsx
T
imccyu 39d2903841 fix(ui): separate navigation pins from selection and show menu icons (#6883)
* fix(ui): separate navigation pins from selection and show menu icons

* fix(ui): simplify More menus and fold conversation views on resize

* test(ui): fix navigation CI regressions and refresh catalogs

* refactor(ui): render sidebar panel icons directly

* fix(ui): keep Chat visible and stop persisting navigation pins

* refactor(ui): drop unreachable pin validation from in-memory stores
2026-10-22 20:05:33 +08:00

191 lines
10 KiB
TypeScript

// @vitest-environment jsdom
/** The plugin fills each region's More menu with the discovery and Creator actions. */
import type { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, within } from '@testing-library/react'
import type { ComponentProps } from 'react'
import { useState } from 'react'
import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import type { TrackProductEvent } from '@deepseek-ai/dsh-client-product-analytics/client'
import { EntryMenuContent } from '../src/client/EntryMenuContent.tsx'
import { en } from '../src/client/locales.ts'
import { apply, inject } from '../src/client/index.ts'
type Seats = 'sidebar.panellist.actions' | 'conversation.session.header.views.actions' | 'sidebar.right.tab.guide.actions'
const SEATS = ['sidebar.panellist.actions', 'conversation.session.header.views.actions', 'sidebar.right.tab.guide.actions'] as const
const runtimes = new Set<SlotTestRuntime>()
afterEach(async () => {
try { for (const runtime of runtimes) await runtime.dispose() }
finally { runtimes.clear(); cleanup(); vi.useRealTimers() }
})
type ContentProps = ComponentProps<typeof EntryMenuContent>
const unused = (): never => { throw new Error('The menu rows read no framework hooks') }
function content(overrides: Partial<ContentProps> = {}) {
const callbacks = {
onOpen: vi.fn<ContentProps['onOpen']>(), onPinnedChange: vi.fn<ContentProps['onPinnedChange']>(),
onDismiss: vi.fn<ContentProps['onDismiss']>(), browsePlugins: vi.fn<ContentProps['browsePlugins']>(),
createPlugin: vi.fn<ContentProps['createPlugin']>(),
}
const props = {
entries: [{ id: 'notes', label: 'Notes', pinned: true }, { id: 'tasks', label: 'Tasks', pinned: false }],
region: 'sidebar', t: makeTranslate(en),
usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused,
useResource: unused, useWorkspaces: unused,
...callbacks,
...overrides,
} as ContentProps
const view = render(<div role="menu"><EntryMenuContent {...props} /></div>)
return { view, ...callbacks }
}
describe('More menu rows', () => {
it.each(['sidebar', 'rightbar'] as const)('shows only discovery and creation in %s regardless of entries', (region) => {
const b = content({ region, entries: [
{ id: 'notes', label: 'Notes', pinned: true, active: true },
{ id: 'tasks', label: 'Tasks', pinned: false },
] })
expect(b.view.getAllByRole('menuitem').map(row => row.textContent)).toEqual(['Browse other plugins', 'Create your plugin'])
expect(b.view.queryByRole('separator')).toBeNull()
fireEvent.click(b.view.getByRole('menuitem', { name: 'Browse other plugins' }))
expect(b.browsePlugins).toHaveBeenCalledExactlyOnceWith()
expect(b.onDismiss.mock.invocationCallOrder[0]).toBeLessThan(b.browsePlugins.mock.invocationCallOrder[0]!)
fireEvent.click(b.view.getByRole('menuitem', { name: 'Create your plugin' }))
expect(b.createPlugin).toHaveBeenCalledExactlyOnceWith()
expect(b.onDismiss.mock.invocationCallOrder[1]).toBeLessThan(b.createPlugin.mock.invocationCallOrder[0]!)
expect(b.onOpen).not.toHaveBeenCalled()
expect(b.onPinnedChange).not.toHaveBeenCalled()
})
it('opens folded Conversation Views and marks the active View without pin controls', () => {
const b = content({ region: 'conversation', entries: [
{ id: 'notes', label: 'Notes', pinned: true, active: true },
{ id: 'tasks', label: 'Tasks', pinned: true, active: false },
] })
expect(b.view.getAllByRole('menuitem').map(row => row.textContent))
.toEqual(['Notes', 'Tasks', 'Browse other plugins', 'Create your plugin'])
const currentRow = b.view.getByRole('menuitem', { name: 'Notes' })
expect(currentRow.getAttribute('aria-current')).toBe('page')
expect(currentRow.querySelector('svg')?.previousElementSibling?.textContent).toBe('Notes')
expect(b.view.getByRole('menuitem', { name: 'Tasks' }).hasAttribute('aria-current')).toBe(false)
expect(b.view.getAllByRole('separator')).toHaveLength(1)
fireEvent.click(b.view.getByRole('menuitem', { name: 'Tasks' }))
expect(b.onOpen).toHaveBeenCalledExactlyOnceWith('tasks')
expect(b.onDismiss.mock.invocationCallOrder[0]).toBeLessThan(b.onOpen.mock.invocationCallOrder[0]!)
expect(b.onPinnedChange).not.toHaveBeenCalled()
})
it('keeps both actions for an empty roster', () => {
const b = content({ entries: [] })
expect(b.view.getAllByRole('menuitem').map(row => row.textContent)).toEqual(['Browse other plugins', 'Create your plugin'])
expect(b.view.queryByRole('separator')).toBeNull()
})
})
function Frame({ renderSlot }: PropsRenderSlots<Seats>) {
const [pinned, setPinned] = useState(true)
const [opened, setOpened] = useState('')
const entries = [{ id: 'notes', label: 'Notes', pinned }]
return <>
{SEATS.map(seat => <div key={seat} role="menu" aria-label={seat}>
{renderSlot(seat, {
entries: seat === 'conversation.session.header.views.actions' ? [] : entries,
onOpen: (id) => { setOpened(`${seat}:${id}`) },
onPinnedChange: (_id, next) => { setPinned(next) },
onDismiss: () => {},
})}
</div>)}
<output data-testid="opened">{opened}</output>
</>
}
async function bench(language: 'en' | 'zh' = 'en', { analytics = true }: { analytics?: boolean } = {}) {
const runtime = await SlotTestRuntime.create()
runtimes.add(runtime)
const openList = vi.fn(), startSession = vi.fn(), track = vi.fn<TrackProductEvent>()
await runtime.mount({
inject: ['slots'],
apply(ctx: Context) {
const locale = new LocaleRuntime(ctx)
locale.setLocale(language)
ctx.provide('locale', locale)
ctx.slots.installLocale(locale)
ctx.provide('pluginNavigation', { openList } as never)
ctx.provide('uiWorkspace', { startSession } as never)
if (analytics) ctx.provide('productAnalytics', { track } as never)
},
})
await runtime.root.declare({
'sidebar.panellist.actions': { kind: 'list', scope: 'root' },
'conversation.session.header.views.actions': { kind: 'list', scope: 'root' },
'sidebar.right.tab.guide.actions': { kind: 'list', scope: 'root' },
}, Frame)
const plugin = await runtime.mount({ inject: [...inject], apply })
const view = runtime.renderRoot()
return { runtime, openList, startSession, track, plugin, view }
}
describe('More menu registrations', () => {
it('registers one row set per region and removes all of them when the plugin unloads', async () => {
const b = await bench()
for (const seat of SEATS) {
expect(b.runtime.slots.entries(seat).map(entry => entry.options.id)).toEqual(['entry-customization'])
expect(within(b.view.getByRole('menu', { name: seat })).getAllByRole('menuitem').map(row => row.textContent))
.toEqual(['Browse other plugins', 'Create your plugin'])
}
const sidebar = within(b.view.getByRole('menu', { name: 'sidebar.panellist.actions' }))
fireEvent.click(sidebar.getByRole('menuitem', { name: 'Browse other plugins' }))
expect(b.openList).toHaveBeenCalledExactlyOnceWith()
await b.plugin.dispose()
for (const seat of SEATS) {
expect(b.runtime.slots.entries(seat)).toEqual([])
expect(within(b.view.getByRole('menu', { name: seat })).queryByRole('menuitem')).toBeNull()
}
})
it.each([
['en', 'sidebar.panellist.actions', 'Create a DSH plugin with a left sidebar entry.'],
['en', 'conversation.session.header.views.actions', 'Create a DSH conversation view plugin.'],
['en', 'sidebar.right.tab.guide.actions', 'Create a DSH plugin with a right sidebar entry.'],
['zh', 'sidebar.panellist.actions', '创建一个带左侧栏入口的 DSH 插件。'],
['zh', 'conversation.session.header.views.actions', '创建一个 DSH 会话视图插件。'],
['zh', 'sidebar.right.tab.guide.actions', '创建一个带右侧栏入口的 DSH 插件。'],
] as const)('starts an unsent %s Creator draft from %s', async (language, seat, prompt) => {
const b = await bench(language)
fireEvent.click(within(b.view.getByRole('menu', { name: seat }))
.getByRole('menuitem', { name: language === 'zh' ? '创建你的插件' : 'Create your plugin' }))
expect(b.startSession).toHaveBeenCalledExactlyOnceWith(undefined, {
agentPreset: 'cordis', prompt, whenDraftExists: 'append',
})
})
it.each([
['sidebar.panellist.actions', 'sidebar'],
['conversation.session.header.views.actions', 'conversation'],
['sidebar.right.tab.guide.actions', 'rightbar'],
] as const)('reports both footer actions of %s as the %s region before acting', async (seat, region) => {
const b = await bench()
const menu = within(b.view.getByRole('menu', { name: seat }))
fireEvent.click(menu.getByRole('menuitem', { name: 'Browse other plugins' }))
expect(b.track).toHaveBeenCalledExactlyOnceWith('more_menu_item_click', { region, action: 'browse_plugins' })
expect(b.track.mock.invocationCallOrder[0]).toBeLessThan(b.openList.mock.invocationCallOrder[0]!)
fireEvent.click(menu.getByRole('menuitem', { name: 'Create your plugin' }))
expect(b.track).toHaveBeenLastCalledWith('more_menu_item_click', { region, action: 'create_plugin' })
expect(b.track.mock.invocationCallOrder[1]).toBeLessThan(b.startSession.mock.invocationCallOrder[0]!)
expect(b.track).toHaveBeenCalledTimes(2)
})
it('keeps both footer actions working without the analytics service', async () => {
const b = await bench('en', { analytics: false })
const menu = within(b.view.getByRole('menu', { name: 'sidebar.right.tab.guide.actions' }))
fireEvent.click(menu.getByRole('menuitem', { name: 'Browse other plugins' }))
fireEvent.click(menu.getByRole('menuitem', { name: 'Create your plugin' }))
expect(b.openList).toHaveBeenCalledExactlyOnceWith()
expect(b.startSession).toHaveBeenCalledOnce()
expect(b.track).not.toHaveBeenCalled()
})
})