test(browser): complete sidebar retention and navigation coverage

This commit is contained in:
imccyu committed 2026-09-21 04:12:54 +08:00
1 parent 599f556deb
commit 8e441f7da2
10 files changed
+255 -18

No files matched your search

@@ -46,23 +46,24 @@ function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRe
const host = useRef<HTMLElement | null>(null)
const body = useRef<HTMLDivElement | null>(null)
useLayoutEffect(() => {
if (host.current !== null) host.current.inert = !visible
// Both refs target unconditional descendants attached before these effects.
const section = host.current as HTMLElement
section.inert = !visible
const focused = document.activeElement
if (!visible && focused instanceof HTMLElement && host.current?.contains(focused)) focused.blur()
if (!visible && focused instanceof HTMLElement && section.contains(focused)) focused.blur()
const request = focusRequest.current
if (!visible || request?.tabId !== tab.id) return
focusRequest.current = undefined
// Preserve deliberate focus taken by another control or the newly shown body.
if (focused !== null && focused !== document.body && focused !== document.documentElement && focused !== request.origin) return
const strip = host.current?.querySelector('[data-dockkit-strip]')
const strip = section.querySelector('[data-dockkit-strip]')
const chip = [...strip?.querySelectorAll<HTMLElement>('[data-dockkit-tab]') ?? []]
.find(element => element.dataset.dockkitTab === tab.id)
chip?.focus({ preventScroll: true })
}, [visible, tab.id, focusRequest])
// Electron emits a non-bubbling focus event on the webview element.
useEffect(() => {
const element = body.current
if (element === null) return
const element = body.current as HTMLDivElement
const focus = (): void => {
if (!visible) return
if (floating) floats.raise(pane.id)
@@ -84,9 +84,17 @@ it('keeps body ancestors through pane moves, floating, raising and docking', ()
fireEvent.pointerDown(h.node('[data-dockkit-divider]'), { button: 0, pointerId: 1 })
expect(h.callbacks.onDividerPressed).toHaveBeenCalledOnce()
h.redraw({ preview: undefined })
const secondPane = getPane(state, split.children[1]!).id
fireEvent.click(h.node(`[data-dockkit-content="${tab}"] [data-dockkit-tab="${tab}"]`))
expect(controller.getSnapshot().state.activePaneId).toBe(split.children[0])
act(() => { controller.focusPane(secondPane); h.redraw() })
fireEvent.focus(body)
expect(controller.getSnapshot().state.activePaneId).toBe(split.children[0])
act(() => { controller.focusPane(secondPane); h.redraw() })
fireEvent.pointerDown(h.node(`[data-dockkit-content="${tab}"]`), { button: 0, pointerId: 1 })
fireEvent.click(body)
fireEvent.focus(body)
expect(controller.getSnapshot().state.activePaneId).toBe(getPane(state, split.children[0]!).id)
const secondPane = getPane(state, split.children[1]!).id
act(() => { controller.placeTab(tab, secondPane, 0); h.redraw() })
expect(h.body(tab)).toBe(body)
expect(body.closest('[data-dockkit-host]')).toBe(host)
@@ -97,6 +105,7 @@ it('keeps body ancestors through pane moves, floating, raising and docking', ()
act(() => { controller.setExpanded(false); h.redraw() })
expect(body.closest('[data-dockkit-content]')?.getAttribute('aria-hidden')).toBeNull()
fireEvent.pointerDown(h.node(`[data-dockkit-float="${floating}"]`), { pointerId: 1, button: 0 })
fireEvent.click(h.node(`[data-dockkit-float="${floating}"]`))
fireEvent.focus(body)
const resize = h.node('[data-dockkit-float-resize]')
fireEvent.pointerDown(resize, { pointerId: 2, button: 0, clientX: 500, clientY: 400 })
@@ -125,6 +134,47 @@ it('unmounts unretained bodies and restores focus to their replacement strip', (
expect(focus).toHaveBeenCalled()
})
it.for(['new-control', 'origin-control', 'document', 'no-focus'] as const)('transfers tab focus without overriding a new owner: %s', (kind, test) => {
const controller = seededController()
controller.setExpanded(true)
const first = getPane(controller.getSnapshot().state, controller.getSnapshot().state.rootId).tabs[0]!
const second = controller.openContent({ kind: 'file', contentId: 'file:b', title: 'B' })
controller.focusTab(first)
const h = mounted(controller)
const control = document.createElement('button')
document.body.append(control)
test.onTestFinished(() => { control.remove() })
if (kind === 'origin-control') control.focus()
vi.spyOn(h.callbacks, 'onFocusTab').mockImplementation((id) => {
if (kind === 'new-control') control.focus()
if (kind === 'document' || kind === 'no-focus') {
vi.spyOn(document, 'activeElement', 'get').mockReturnValue(kind === 'document' ? document.documentElement : null)
}
controller.focusTab(id)
h.redraw()
})
const focus = vi.spyOn(HTMLElement.prototype, 'focus')
fireEvent.click(h.node(`[data-dockkit-tab="${second}"]`))
if (kind === 'new-control') expect(document.activeElement).toBe(control)
else expect(focus).toHaveBeenCalledWith({ preventScroll: true })
})
it('does not require a docked strip when a selected tab becomes floating in the same update', () => {
const controller = seededController()
controller.setExpanded(true)
const first = getPane(controller.getSnapshot().state, controller.getSnapshot().state.rootId).tabs[0]!
const second = controller.openContent({ kind: 'file', contentId: 'file:b', title: 'B' })
controller.focusTab(first)
const h = mounted(controller)
vi.spyOn(h.callbacks, 'onFocusTab').mockImplementation((id) => {
controller.focusTab(id)
controller.floatTab(id)
h.redraw()
})
fireEvent.click(h.node(`[data-dockkit-tab="${second}"]`))
expect(h.node(`[data-dockkit-content="${second}"]`).hasAttribute('data-dockkit-float')).toBe(true)
})
it('renders an empty pane and rejects unsupported docked trees', () => {
const controller = new DockController()
controller.setExpanded(true)
@@ -233,7 +233,6 @@ export function createBrowserControllers(actions: BoundActions<BrowserStore>, cr
}
const created = new BrowserController({ ...request, actions: currentActions, createPage })
const forget = (): void => {
if (controllers.get(tabId)?.controller !== created) return
controllers.delete(tabId)
// Plugin unload also aborts occurrences; only layout removal deletes saved navigation.
if (!isTabOpen(tabId)) currentActions.forget(tabId)
@@ -78,9 +78,7 @@ export class IframeImpl implements BrowserFrame {
this.store.set({ ...this.store.getSnapshot(), sandboxEnabled: enabled })
return
}
const request = this.navigation.reload()
if (request === undefined) this.publish()
else this.load(request)
this.load(this.navigation.reload())
}
private load(request: ReturnType<BrowserNavigation['navigate']> | undefined): void {
@@ -94,7 +94,7 @@ export function BrowserBody(props: BrowserBodyProps): ReactNode {
<button type="submit" className={[css.tool, css.addressGo].join(' ')} aria-label={t('go')} title={t('go')}><IconLinkOutlineRegular /></button>
</div>
<button type="button" className={css.tool} aria-label={t('external')} title={t('external')} disabled={externalUrl === undefined}
onClick={() => { if (externalUrl !== undefined) window.open(externalUrl, '_blank', 'noopener,noreferrer') }}
onClick={externalUrl === undefined ? undefined : () => { window.open(externalUrl, '_blank', 'noopener,noreferrer') }}
><IconRightUpOutlineRegular size={14} /></button>
{sandboxed !== undefined && <button
type="button"
@@ -1,3 +1,4 @@
// @vitest-environment jsdom
/** Browser type, Slot, locale, and HMR disposal through the real registries. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
@@ -9,11 +10,15 @@ import type { BrowserInjected } from '../src/client/browser/BrowserController.ts
import { BROWSER_ID, BROWSER_KIND } from '../src/client/definition.tsx'
import { apply, inject } from '../src/client/index.ts'
import { en, zh } from '../src/client/locales.ts'
import { createBrowserStore } from '../src/client/browser/store.ts'
import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
import type { DesktopBrowserBridge, DesktopBrowserLeaseId } from '../src/types.ts'
const contexts: Context[] = []
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
vi.unstubAllGlobals()
})
interface Recorded {
@@ -47,15 +52,65 @@ async function boot() {
}),
}
ctx.provide('sidebarRightTabs', tabs as never)
ctx.provide('sidebarRight', { openTabs: createSnapshotStore([]) } as never)
const openTabs = createSnapshotStore<readonly { sessionId: string; tabId: TabId }[]>([])
ctx.provide('sidebarRight', { openTabs } as never)
ctx.provide('workspaces', { list: createSnapshotStore({ phase: 'ready', items: [] }) } as never)
ctx.provide('slots', slots as never)
ctx.provide('locale', locale as never)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { tabs, registered, dictionaries, fiber }
return { tabs, registered, dictionaries, fiber, openTabs }
}
describe('ui-sidebar-browser apply', () => {
it.each([0, 1])('binds and rebinds session controllers under desktop protocol %s', async (protocolVersion) => {
const acquire = vi.fn(async () => ({ lease: 'test-lease' as DesktopBrowserLeaseId, partition: 'test-partition' }))
const bridge: DesktopBrowserBridge = {
acquire,
release: vi.fn(async () => {}),
onOpenRequested: vi.fn(() => () => {}),
}
vi.stubGlobal('dshDesktop', { protocolVersion, browser: bridge })
const h = await boot()
expect(h.tabs.get(BROWSER_KIND)?.keepMounted).toBe(protocolVersion === 1)
const injectFace = h.registered.find(entry => entry.name === 'sidebar.right.pane.tab')!.inject as
(sessionId: string, actions: Parameters<BrowserInjected['rebind']>[0]) => BrowserInjected
const firstStore = createBrowserStore().create(`apply-first-${protocolVersion}`)
const replacementStore = createBrowserStore().create(`apply-replacement-${protocolVersion}`)
const controller = injectFace('session', firstStore.actions)
expect(injectFace('session', replacementStore.actions)).toBe(controller)
const host = document.createElement('div')
host.id = `browser-apply-${protocolVersion}`
document.body.append(host)
const signal = new AbortController()
const tabId = 'apply-tab' as TabId
try {
controller.mount({ tabId, signal: signal.signal, viewportId: host.id, applicationOrigin: 'https://dsh.example',
initial: undefined, initialUrl: 'https://example.test/', openTab: vi.fn() })
expect(replacementStore.getSnapshot().byTab[tabId]).toBeDefined()
expect(firstStore.getSnapshot().byTab[tabId]).toBeUndefined()
if (protocolVersion === 1) await vi.waitFor(() => { expect(acquire).toHaveBeenCalledWith('session:session') })
else expect(host.querySelector('iframe')).not.toBeNull()
h.openTabs.set([{ sessionId: 'other', tabId }, { sessionId: 'session', tabId: 'other-tab' as TabId }])
signal.abort()
expect(replacementStore.getSnapshot().byTab[tabId]).toBeUndefined()
const reopened = new AbortController()
controller.mount({ tabId, signal: reopened.signal, viewportId: host.id, applicationOrigin: 'https://dsh.example',
initial: undefined, initialUrl: 'https://retained.example/', openTab: vi.fn() })
h.openTabs.set([{ sessionId: 'session', tabId }])
reopened.abort()
expect(replacementStore.getSnapshot().byTab[tabId]).toBeDefined()
const active = new AbortController()
controller.mount({ tabId, signal: active.signal, viewportId: host.id, applicationOrigin: 'https://dsh.example',
initial: undefined, initialUrl: undefined, openTab: vi.fn() })
await h.fiber.dispose()
expect(controller.keyedHooks.browserState(tabId)).toBeUndefined()
} finally {
await h.fiber.dispose()
host.remove()
}
})
it('registers a multi-instance builtin and its body and title', async () => {
const { tabs, registered, dictionaries } = await boot()
const definition = tabs.get(BROWSER_KIND)
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { useSyncExternalStore } from 'react'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { PaneId, TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
@@ -11,6 +11,10 @@ import { BrowserBody } from '../src/client/view/BrowserBody.tsx'
import { WEB_BROWSER_SANDBOX } from '../src/client/view/IframePresentation.ts'
import { createIframePage } from '../src/client/pages.ts'
import { zh } from '../src/client/locales.ts'
import { browserAddressCheckpoint, type BrowserTabState } from '../src/client/browser/BrowserPersistence.ts'
import type { BrowserPageFactory, BrowserPageOptions } from '../src/client/browser/BrowserPage.ts'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { emptyBrowserFrame, type BrowserFrameState } from '../src/client/browser/BrowserFrame.ts'
const SESSION = 'session' as SessionId
const TAB = 'tab' as TabId
@@ -33,11 +37,13 @@ const absentState = {
getSnapshot: (): BrowserControllerState | undefined => undefined,
}
function mountBrowser(navigation?: { readonly url?: string }) {
function mountBrowser(navigation?: { readonly url?: string },
options: { initial?: BrowserTabState; createPage?: BrowserPageFactory } = {}) {
const store = createBrowserStore().create(`browser-body-test-${String(++mountSequence)}`)
if (options.initial !== undefined) store.actions.replace(TAB, options.initial)
const lifetime = new AbortController()
lifetimes.add(lifetime)
const injected = createBrowserControllers(store.actions, createIframePage, () => true)
const injected = createBrowserControllers(store.actions, options.createPage ?? createIframePage, () => true)
controllers.push(injected)
const { keyedHooks, ...commands } = injected
const tabActions = { openResource: vi.fn(), openTab: vi.fn(), close: vi.fn() }
@@ -67,7 +73,7 @@ function mountBrowser(navigation?: { readonly url?: string }) {
}
const renderBody = () => render(<BrowserBody {...props as BrowserBodyProps} />)
return {
view: renderBody(), remount: renderBody, store, lifetime, injected,
view: renderBody(), remount: renderBody, store, lifetime, injected, tabActions,
}
}
@@ -81,6 +87,39 @@ afterEach(async () => {
})
describe('BrowserBody', () => {
it('shows the saved title and URL without loading until Restore is clicked', async () => {
const target = { kind: 'https' as const, url: 'https://saved.example/page', title: 'Saved title' }
const mounted = mountBrowser(undefined, { initial: browserAddressCheckpoint(target, 1) })
expect(mounted.view.getByText(target.title)).toBeDefined()
expect(mounted.view.getByText(target.url)).toBeDefined()
expect(mounted.view.container.querySelector('iframe')).toBeNull()
expect(mounted.view.getByRole('button', { name: zh.reload })).toHaveProperty('disabled', false)
fireEvent.click(mounted.view.getByRole('button', { name: zh['restore.action'] }))
await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.src).toBe(target.url) })
expect(mounted.view.queryByText(target.title)).toBeNull()
})
it('renders native error details and routes provider open requests through the source tab', () => {
const state = createSnapshotStore<BrowserFrameState>({ ...emptyBrowserFrame(), error: { code: -105, description: 'DNS failure' } })
const providers: BrowserPageOptions[] = []
const mounted = mountBrowser(undefined, { createPage: (options) => {
providers.push(options)
return {
presentation: { mount: () => () => {} },
frame: { getSnapshot: () => state.getSnapshot(), subscribe: listener => state.subscribe(listener),
loadUrl: vi.fn(), goBack: vi.fn(), goForward: vi.fn(), reload: vi.fn(), dispose: async () => {} },
}
} })
expect(mounted.view.getByRole('status').textContent).toContain('DNS failure')
expect(mounted.view.getByRole('status').textContent).toContain('-105')
act(() => { state.set({ ...state.getSnapshot(), error: { code: -105, description: undefined } }) })
expect(mounted.view.getByRole('status').textContent).toBe(zh['load.failed'])
act(() => { providers[0]!.openRequested('https://new.example/') })
expect(mounted.tabActions.openTab).toHaveBeenCalledExactlyOnceWith('browser', {
params: { url: 'https://new.example/' }, revealIfOpened: false,
})
})
it('routes address input to the controller and renders parser failures', async () => {
const mounted = mountBrowser()
const input = mounted.view.getByRole('textbox')
@@ -4,6 +4,10 @@ import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
import { BrowserController, createBrowserControllers } from '../src/client/browser/BrowserController.ts'
import { createBrowserStore } from '../src/client/browser/store.ts'
import { createIframePage } from '../src/client/pages.ts'
import type { BrowserPageFactory, BrowserPageOptions } from '../src/client/browser/BrowserPage.ts'
import { browserAddressCheckpoint } from '../src/client/browser/BrowserPersistence.ts'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { emptyBrowserFrame, type BrowserFrameState } from '../src/client/browser/BrowserFrame.ts'
const TAB = 'tab' as TabId
const APP = 'https://dsh.example'
@@ -50,6 +54,91 @@ afterEach(async () => {
})
describe('BrowserController', () => {
it('restores only on request and redirects saved checkpoints to a replacement binding', () => {
const h = harness()
const saved = browserAddressCheckpoint({ kind: 'https', url: 'https://saved.example/', title: 'Saved page' }, 2)
h.store.actions.replace(TAB, saved)
const tabLifetime = lifetime()
h.mount(tabLifetime.signal)
const state = h.face.keyedHooks.browserState(TAB)!
expect(state.getSnapshot().restoreTarget?.title).toBe('Saved page')
expect(state.getSnapshot().frame.target).toBeUndefined()
const rebound = createBrowserStore().create('rebound-browser-controller')
h.face.rebind(rebound.actions)
h.mount(tabLifetime.signal)
expect(rebound.getSnapshot().byTab[TAB]).toEqual(saved)
h.face.restore(TAB)
expect(h.iframe().src).toBe('https://saved.example/')
h.face.restore(TAB)
h.face.loadUrl(TAB, 'https://next.example/')
expect(rebound.getSnapshot().byTab[TAB]?.entries.at(-1)?.url).toBe('https://next.example/')
expect(h.store.getSnapshot().byTab[TAB]).toEqual(saved)
tabLifetime.abort()
expect(rebound.getSnapshot().byTab[TAB]).toBeDefined()
h.face.restore(TAB)
h.face.setSandbox(TAB, false)
h.mount(tabLifetime.signal)()
expect(h.face.keyedHooks.browserState(TAB)).toBeUndefined()
})
it('refresh restores a saved page and ignores cancellation from a retired physical mount', () => {
const h = harness()
h.store.actions.replace(TAB, browserAddressCheckpoint({ kind: 'https', url: 'https://saved.example/', title: 'Saved' }, 1))
const tabLifetime = lifetime()
const oldMount = h.mount(tabLifetime.signal)
h.face.reload(TAB)
const secondHost = document.createElement('div')
secondHost.id = 'replacement-browser-host'
document.body.append(secondHost)
hosts.push(secondHost)
h.face.mount({ tabId: TAB, signal: tabLifetime.signal, viewportId: secondHost.id,
applicationOrigin: APP, initial: undefined, initialUrl: undefined, openTab: vi.fn() })
oldMount()
expect(secondHost.querySelector('iframe')?.src).toBe('https://saved.example/')
expect(() => h.face.mount({ tabId: TAB, signal: tabLifetime.signal, viewportId: 'missing-browser-host',
applicationOrigin: APP, initial: undefined, initialUrl: undefined, openTab: vi.fn() })).toThrow('not mounted')
})
it('validates provider open requests and ignores late provider callbacks after disposal', async () => {
const saved = browserAddressCheckpoint({ kind: 'https', url: 'https://saved.example/', title: 'Saved' }, 1)
const frames = createSnapshotStore<BrowserFrameState>(emptyBrowserFrame())
const callbacks: BrowserPageOptions[] = []
const pendingNotifications: (() => void)[] = []
const pageFactory: BrowserPageFactory = (options) => {
callbacks.push(options)
return {
frame: { getSnapshot: () => frames.getSnapshot(), subscribe: (listener) => {
pendingNotifications.push(listener)
return frames.subscribe(listener)
}, loadUrl: vi.fn(), goBack: vi.fn(), goForward: vi.fn(), reload: vi.fn(), dispose: async () => {} },
presentation: { mount: () => () => {}, dispose: () => {} },
}
}
const store = createBrowserStore().create('provider-callbacks')
const openTab = vi.fn()
const controller = new BrowserController({ tabId: TAB, signal: lifetime().signal, applicationOrigin: APP,
actions: store.actions, initial: saved, createPage: pageFactory, openTab })
disposables.push(controller)
const provider = callbacks[0]!
provider.openRequested('file:/secret')
expect(controller.getSnapshot().addressFailure).toBe('protocol')
provider.openRequested('https://new.example/path')
expect(openTab).toHaveBeenCalledExactlyOnceWith('https://new.example/path')
controller.setSandbox(false)
frames.set({ ...emptyBrowserFrame(), loading: true })
expect(controller.getSnapshot().restoreTarget?.title).toBe('Saved')
expect(controller.getSnapshot().addressFailure).toBe('protocol')
const before = controller.getSnapshot()
await controller.dispose()
controller.start('https://ignored.example/')
provider.persist(saved)
provider.openRequested('https://ignored.example/')
for (const notify of pendingNotifications) notify()
expect(controller.getSnapshot()).toBe(before)
expect(store.getSnapshot().byTab[TAB]).toBeUndefined()
expect(openTab).toHaveBeenCalledOnce()
})
it('ignores navigation commands after its tab occurrence ends', async () => {
const store = createBrowserStore().create('browser-controller-ended-test')
const tabLifetime = lifetime()
@@ -17,6 +17,9 @@ describe('IframeImpl', () => {
const listener = vi.fn()
const unsubscribe = frame.subscribe(listener)
try {
frame.handleLoaded(0)
expect(frame.getSnapshot().address).toBe('empty')
frame.sandbox.setEnabled(false)
frame.sandbox.setEnabled(false)
expect(frame.getSnapshot().sandboxEnabled).toBe(false)
expect(frame.getSnapshot().target).toBeUndefined()
@@ -43,6 +46,10 @@ describe('IframeImpl', () => {
frame.handleLoadFailed(2)
frame.handleLoaded(2)
frame.reload()
frame.loadUrl(target)
frame.goBack()
frame.goForward()
frame.sandbox.setEnabled(true)
expect(frame.getSnapshot()).toBe(stopped)
} finally {
unsubscribe()
@@ -73,7 +73,6 @@ export class SidebarSessionViews {
}
private publish(): void {
if (this.closed) return
this.source.set([...this.views.values()].sort((a, b) => a.sessionId.localeCompare(b.sessionId)).map(view => ({
sessionId: view.sessionId, reference: view.reference, selected: view.sessionId === this.selected, retainTab: view.retainTab,
})))