Files
apes-sidebar/tests/native-surface.spec.ts
T
Menghuan1918 725b2f7cec feat(dsh): adapt to DSH 0.1.5-rc.1 and restore the guide description
Baseline moves to DSH 0.1.5-rc.1 — the 0.1.5 line's first release
candidate, and the npm `latest` dist-tag now: peer floor ^0.1.5-rc.1,
devDependency pins, the CI mount-lane pin and dsh.plugin.json engines
all follow. 0.1.5-alpha.2 users stay on v0.19.0-alpha.1.

The upstream delta from alpha.2 is small: 373 changed files, mostly
version bumps, sidebar preview polish and test snapshots. Exactly two
changes touch this plugin, and only one needs work:

- `SidebarRightGuideEntry.description` is back (optional). The native
  guide renders it only while it lists at most 4 entries; a longer list
  drops every description. The plugin restores `TabDescriptor.description`
  and the six built-in `guideDesc*` lines (20 dictionaries) verbatim from
  0b6ac5d^, but deliberately does NOT restore the old generic
  `nativeGuideDesc` fallback: the host has no fallback of its own, so a
  descriptor that declares none now ships no `description` field at all
  and renders as a clean title-only capsule.

  Consequence worth knowing: the plugin contributes six guide entries, so
  the default composition is over the 4-entry limit and shows titles only.
  The mount lane proves the restored field end to end by shrinking the
  enabled set to three types through the plugin's own settings route
  (scratch profile only) and requiring the Files capsule to grow its line.

- The built-in `files` type declares a guide description again and uses a
  coloured folder glyph; the plugin owns that kind, so nothing to do.

Verified unchanged from alpha.2 (so nobody re-checks them): the global
main-panel model, the file address grammar (util/workspace-path changed
only its package.json), the native tab-body host contract (.paneBody is
still a block scroller with a definite height), the core/agent/session/
subagent host APIs and the ui-primitives export surface.

Restore caveat fixed in the lane: the settings route merges patches
key-wise, so undoing a temporary `tabsEnabled` change by posting the
original (empty) map leaves the disabled keys behind — the e2e restores
every key it touched explicitly.
2026-09-10 14:41:16 +08:00

313 lines
15 KiB
TypeScript

// @vitest-environment jsdom
/**
* Unit tests for the native-surface adapter: the per-tab record registry
* (src/client/native/tab-adapter.tsx) and the service's routing into it
* (src/client/service.ts `setSurface`).
*/
import { describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { createNativeTabRecords, NativeTabBody } from '../src/client/native/tab-adapter.tsx'
import { registerNativeSurface } from '../src/client/native/index.ts'
import { createBetterSidebarService, type SidebarSurface } from '../src/client/service.ts'
import { createSidebarStore } from '../src/client/state.ts'
const scope = { sessionId: 's1', cwd: '/work' }
describe('createNativeTabRecords', () => {
it('mints a synthetic tab from the native record + params', () => {
const records = createNativeTabRecords()
const view = records.ensure({
id: 'tab-1', kind: 'browser', title: 'Browser', params: { url: 'https://a.test', meta: { k: 1 } }, scope,
})
expect(view.tab).toMatchObject({ id: 'tab-1', type: 'browser', title: 'Browser', meta: { k: 1 } })
expect(view.scope).toBe(scope)
expect(view.expanded).toEqual([])
})
it('calls the descriptor factory once for a record that arrives without seed fields', () => {
const records = createNativeTabRecords()
const mint = vi.fn(() => ({ title: 'Side chat', meta: { autoCreate: true } }))
const view = records.ensure({ id: 'tab-2', kind: 'sidechat', title: 'Side Chat', params: undefined, scope, mint })
expect(mint).toHaveBeenCalledTimes(1)
expect(view.tab).toMatchObject({ title: 'Side chat', meta: { autoCreate: true } })
// A second render of the same record does not re-mint.
records.ensure({ id: 'tab-2', kind: 'sidechat', title: 'Side Chat', params: undefined, scope, mint })
expect(mint).toHaveBeenCalledTimes(1)
})
it('refreshes the seed fields on navigation but keeps the record identity', () => {
const records = createNativeTabRecords()
records.ensure({ id: 'tab-3', kind: 'editor', title: 'a.ts', params: { path: '/work/a.ts' }, scope })
records.update('tab-3', { title: 'renamed.ts' })
const view = records.ensure({ id: 'tab-3', kind: 'editor', title: 'b.ts', params: { path: '/work/b.ts' }, scope })
expect(view.tab).toMatchObject({ id: 'tab-3', path: '/work/b.ts', title: 'renamed.ts' })
})
it('tracks expansion per record and bumps its version', () => {
const records = createNativeTabRecords()
records.ensure({ id: 'tab-4', kind: 'editor', title: 'Files', params: undefined, scope })
const before = records.versionOf('tab-4')
records.toggleExpanded('tab-4', '/work/src')
expect(records.get('tab-4')?.expanded).toEqual(['/work/src'])
expect(records.versionOf('tab-4')).toBeGreaterThan(before)
records.toggleExpanded('tab-4', '/work/src')
expect(records.get('tab-4')?.expanded).toEqual([])
})
it('notifies subscribers and forgets a dropped record', () => {
const records = createNativeTabRecords()
records.ensure({ id: 'tab-5', kind: 'terminal', title: 'Terminal', params: undefined, scope })
const listener = vi.fn()
const off = records.subscribe(listener)
records.update('tab-5', { title: 'zsh' })
expect(listener).toHaveBeenCalledTimes(1)
expect(records.get('tab-5')?.tab.title).toBe('zsh')
records.drop('tab-5')
expect(records.has('tab-5')).toBe(false)
off()
records.ensure({ id: 'tab-6', kind: 'terminal', title: 'Terminal', params: undefined, scope })
records.update('tab-6', { title: 'x' })
expect(listener).toHaveBeenCalledTimes(2)
})
})
describe('service routing into the native surface', () => {
const mount = (): { surface: SidebarSurface; calls: unknown[]; service: ReturnType<typeof createBetterSidebarService> } => {
const calls: unknown[] = []
const surface: SidebarSurface = {
openTab: input => { calls.push({ op: 'openTab', ...input }) },
openResource: input => { calls.push({ op: 'openResource', ...input }) },
fileAddress: (sessionId, cwd, path) => `addr://${sessionId}${cwd === undefined ? '' : cwd}${path}`,
close: (sessionId, tabId) => ({ type: 'terminal', title: `closed ${tabId} in ${sessionId}` }),
update: tabId => tabId === 'native-1',
activate: tabId => tabId === 'native-1',
has: tabId => tabId === 'native-1',
}
const store = createSidebarStore()
store.setSession('s1')
const service = createBetterSidebarService(store)
service.setSurface(surface)
service.registerTab({ id: 'terminal', title: 'Terminal', component: () => null, createTab: state => ({ tab: { id: `terminal:${state.nextTerminal}`, type: 'terminal', title: 'Terminal', meta: { n: state.nextTerminal } } }) })
service.registerTab({ id: 'git', title: 'Changes', component: () => null })
service.registerTab({ id: 'editor', title: 'Files', component: () => null, icon: () => null })
return { surface, calls, service }
}
it('opens a page type natively, carrying the descriptor factory seed', () => {
const { service, calls } = mount()
service.openTab({ type: 'terminal' }, scope)
expect(calls).toEqual([{
op: 'openTab',
sessionId: 's1',
kind: 'terminal',
params: { title: 'Terminal', meta: { n: 1 } },
revealIfOpened: false,
}])
})
it('opens a file path as a resource address', () => {
const { service, calls } = mount()
service.openTab({ type: 'editor', path: '/work/a.ts', title: 'a.ts' }, scope)
expect(calls).toEqual([{ op: 'openResource', sessionId: 's1', address: 'addr://s1/work/work/a.ts', revealIfOpened: true }])
})
it('maps a path-less editor open to the files page kind', () => {
const { service, calls } = mount()
service.openTab({ type: 'editor' }, scope)
expect(calls).toEqual([{ op: 'openTab', sessionId: 's1', kind: 'files', params: {}, revealIfOpened: true }])
})
it('keeps a bottom-targeted open in the plugin layout', () => {
const { service, calls } = mount()
service.openTab({ type: 'terminal', target: 'bottom' }, scope)
expect(calls).toEqual([])
})
it('routes record operations to the native surface when the id is native', () => {
const { service } = mount()
expect(() => service.updateTab('native-1', { title: 'x' })).not.toThrow()
expect(() => service.activateTab('native-1')).not.toThrow()
expect(() => service.closeTab('native-1', scope)).not.toThrow()
// A non-native id keeps the plugin's own layout path (a strict no-op here).
expect(() => service.updateTab('other', { title: 'x' })).not.toThrow()
expect(() => service.closeTab('other', scope)).not.toThrow()
})
it('refuses a disabled type before touching the surface', () => {
const { service, calls } = mount()
service.setSurface(undefined)
const store = createSidebarStore()
void store
service.setSurface({
openTab: input => { calls.push({ op: 'openTab', ...input }) },
openResource: () => { calls.push({ op: 'openResource' }) },
fileAddress: () => 'addr',
close: () => undefined,
update: () => false,
activate: () => false,
has: () => false,
})
service.openTab({ type: 'missing' }, scope)
expect(calls).toEqual([])
})
})
describe('registerNativeSurface lifecycle (service-driven registration)', () => {
it('registers the native tab types when the tab-type registry ARRIVES after the slot declaration', () => {
// Regression: the native seat declares `sidebar.right.pane.tab` before it
// provides `sidebarRightTabs`, so a registration driven by the slot
// declaration reads the service as missing and registers nothing —
// observed on a real DSH profile (the guide page stayed empty while the
// same build worked in the scratch mount lane, where activation order
// happened to differ). The registration must follow the SERVICE.
const store = createSidebarStore()
store.setSession('s1')
const service = createBetterSidebarService(store)
service.registerTab({ id: 'terminal', title: 'Terminal', component: () => null, description: () => 'Runs a shell' })
service.registerTab({ id: 'editor', title: 'Files', component: () => null, icon: () => null, description: () => 'Browse the tree' })
service.registerTab({ id: 'browser', title: 'Browser', component: () => null })
const records = createNativeTabRecords()
const registered: Array<{ id: string; kind: string; title: (address: string) => string; guide: unknown }> = []
const slotKeys: string[] = []
// The registry is ABSENT while the slot callback fires and appears later
// (that ordering is the regression): a holder keeps the timing honest
// without a reassigned binding.
const registry: { current: { register: (definition: { id: string; kind: string; title: (address: string) => string; guide?: unknown }) => () => void } | undefined } = { current: undefined }
let runInjected: (() => void) | undefined
const ctx = {
inject: (deps: readonly string[], callback: (injected: { get: (name: string) => unknown }) => void) => {
expect(deps).toEqual(['sidebarRightTabs'])
runInjected = () => { callback({ get: () => registry.current }) }
return { dispose: () => { runInjected = undefined } }
},
get: () => registry.current,
slots: {
// The slot is already declared when this plugin activates: the
// callback runs immediately, with no service in sight.
inject: (_key: string, callback: () => () => void) => callback(),
register: (options: { name: string; key?: string }) => {
slotKeys.push(options.key ?? options.name)
return () => {}
},
},
}
const dispose = registerNativeSurface({ ctx: ctx as never, store, service, records })
// Nothing may register while the registry is absent…
expect(registered).toHaveLength(0)
expect(slotKeys).toHaveLength(0)
// …and everything registers once it appears.
registry.current = {
register: (definition) => {
registered.push({ id: definition.id, kind: definition.kind, title: definition.title, guide: definition.guide })
return () => {}
},
}
runInjected?.()
expect(registered.map(entry => entry.kind).sort()).toEqual(['browser', 'editor', 'files', 'terminal'])
expect(registered.map(entry => entry.id)).toContain('dsh-better-sidebar:files')
expect(slotKeys).toContain('dsh-better-sidebar:terminal')
expect(slotKeys).toContain('dsh-better-sidebar:files')
// A resource tab is titled by the FILE it shows (the descriptor's own
// title would make every open file look identical in the strip), while a
// page tab keeps the descriptor's title.
const editorType = registered.find(entry => entry.kind === 'editor')
expect(editorType?.title('dsh-resource://file/session/s1/src/main.ts')).toBe('main.ts')
expect(editorType?.title('dsh-resource://file/absolute/work/pkg/a/b.txt')).toBe('b.txt')
expect(editorType?.title('sidebar://editor')).toBe('Files')
// The new-tab/guide list must offer ONE "Files" row: the `files` kind
// takeover draws the same explorer the editor page would, so the editor
// type contributes no guide entry of its own.
expect(editorType?.guide).toBeUndefined()
const filesType = registered.find(entry => entry.kind === 'files')
expect(filesType?.guide).toBeDefined()
// The takeover carries the editor's glyph, so the "Files" guide row is
// not the only one with a blank icon slot.
const filesGuide = filesType?.guide as Array<{ icon?: unknown; title: () => string; description?: () => string }> | undefined
expect(filesGuide?.[0]?.icon).toBeDefined()
// DSH 0.1.5-rc.1 restored the guide `description` as an optional
// `() => string` (rendered only while the guide lists at most 4
// entries). The takeover IS the editor's page, so its guide line is the
// EDITOR descriptor's description (the takeover reuses it, exactly as it
// reuses the glyph), evaluated fresh per call so a thunk follows the
// active locale.
expect(filesGuide?.[0]?.description?.()).toBe('Browse the tree')
const terminalGuide = registered.find(entry => entry.kind === 'terminal')?.guide as
Array<{ description?: () => string }> | undefined
expect(terminalGuide?.[0]?.description?.()).toBe('Runs a shell')
// A descriptor that declares NO description must reach the host with no
// `description` field at all — the host has no fallback of its own, so
// an empty thunk would render a blank second line instead of a clean
// title-only capsule.
const browserGuide = registered.find(entry => entry.kind === 'browser')?.guide as
Array<{ title: () => string; description?: unknown }> | undefined
expect(browserGuide?.[0]?.description).toBeUndefined()
expect('description' in (browserGuide?.[0] ?? {})).toBe(false)
expect(browserGuide?.[0]?.title?.()).toBe('Browser')
dispose()
})
})
describe('NativeTabBody full-height host wrapper', () => {
it('renders the descriptor component inside the [data-dsh-native-tab-host] wrapper', () => {
// DSH's native tab body host (`.paneBody`) is a BLOCK scroller with a
// definite height, not a flex container — our tab roots (`flex: 1;
// min-height: 0`) collapse there without a column-flex wrapper of
// height:100%. The stable `data-dsh-native-tab-host` marker (mirroring
// `data-dsh-better-sidebar`) lets the e2e lane assert the fill; this
// unit check pins the structure: the marker wrapper exists and the
// descriptor's output is INSIDE it (before the fix the component was
// rendered bare, with no wrapper at all).
const store = createSidebarStore()
store.setSession('s1')
const service = createBetterSidebarService(store)
service.registerTab({
id: 'stub',
title: 'Stub',
component: () => createElement('div', { 'data-stub-body': '' }, 'stub body'),
})
const records = createNativeTabRecords()
const sessions = { list: { subscribe: () => () => {}, getSnapshot: () => ({ byId: {} }) } }
const ctx = { sessions } as never
const info = {
tab: {
id: 'native-9',
kind: 'stub',
title: 'Stub',
contentId: 'sidebar://stub',
visible: true,
navigation: { address: 'sidebar://stub', params: undefined, revision: 0 },
signal: new AbortController().signal,
},
}
const host = document.createElement('div')
document.body.appendChild(host)
let root: Root | undefined
act(() => {
root = createRoot(host)
root.render(createElement(NativeTabBody, {
sessionId: 's1',
ctx,
store,
service,
records,
descriptorId: 'stub',
useTabInfo: () => info,
}))
})
const wrapper = host.querySelector('[data-dsh-native-tab-host=""]')
expect(wrapper, 'the full-height host wrapper must exist').not.toBeNull()
expect(wrapper!.querySelector('[data-stub-body]'), 'the descriptor component renders inside the wrapper').not.toBeNull()
expect(wrapper!.childElementCount).toBe(1)
act(() => { root?.unmount() })
host.remove()
})
})