Files
apes-sidebar/tests/lazy-chunk.spec.tsx
T
Menghuan1918 5912274e41 feat: yield the terminal and browser to DSH 0.1.6's own sidebar types
DSH 0.1.6-alpha.2 ships a right-Sidebar terminal and a right-Sidebar browser
of its own. Both were things this plugin had built from scratch, so keeping
them meant two implementations of one surface plus a plugin that shadows a
host type the product is actively developing.

Terminal: the plugin's whole PTY stack goes — the UI-tab manager, the
agent-owned registry, the lazy node-pty loader, the eight model-facing
terminal_* tools, the xterm view and its font/link/scrollback helpers, and
the cross-session 'pinned terminal' rail that existed only to keep a terminal
alive across a session switch. The host's terminal kind becomes the only
provider, which also retires the node-pty dependency, its pnpm allowBuilds
entry, and the installer's -Repair mode (issue #140's whole class of
'node-pty failed to load' support).

Model-facing capability note: the plugin used to inject terminal_* tools
(off by default). Those are the model's only cross-call persistent terminal,
and DSH's own equivalent (@deepseek-ai/dsh-tool-terminal) is not mounted by
any shipped bundle, so removing them leaves the model with the one-shot
bash/pwsh tools only. That is the accepted cost of not maintaining a second
PTY surface; the README documents how to enable the upstream toolset.

Browser: the plugin's browser tab type is dropped, so the host's own browser
kind owns embedded pages. The DOM-level link takeover STAYS (it is the only
thing that can route an https link to the system browser by protocol, which
the host does not do) but now delegates to the host's browser tab via
ctx.sidebarRight.openTab('browser', …) with a window.open fallback, and the
two settings that only ever configured the plugin's own iframe (sandbox
escape hatch, loopback allowlist) go with it — along with the host-side
browser.probe route they fed.

Cards: the empty-pane welcome cards adopt the host's guide-capsule recipe
(0.5px l4 hairline, 24px radius, layer-1 fill, 56px floor, 14px/20px padding)
so the plugin's 'pick what opens here' surface reads as the same design as
DSH's own. A theme.spec guard pins the geometry and the token-only paint —
including that the upstream-only --dsw-alias-bg-l1/-l2 spellings (defined
nowhere, so their declarations are silently dropped upstream) never appear
here.

Also drops the dead state the removed features left behind (SidebarTab.pin,
nextTerminal, agentWaits, bottomOpenedOnce, the agent-tab helpers, the
pinned-tab memo key), re-points every test fixture that used them at
surviving equivalents, and prunes the specs of the deleted features.
2026-09-21 20:35:11 +08:00

161 lines
7.3 KiB
TypeScript

/**
* Lazy wrapper tests (src/client/lazy-chunk.tsx): the wrapper that mounts
* chunk-resident components from the built-in descriptors. Pins the two
* contracts that matter for the descriptor API:
* - the wrapper is a plain render-prop function — `component(props)` can be
* called directly (Sidebar's style) without a chunk registered and
* without throwing; hooks live in the inner component only,
* - loading → placeholder, failure → error + retry that recovers, success →
* the chunk component rendered.
*/
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createElement, type ComponentType, type ReactNode } from 'react'
import { act } from 'react-dom/test-utils'
import { renderRoot } from './test-utils.ts'
import { builtinTabs } from '../src/client/builtins/tabs.tsx'
import { builtinViewers } from '../src/client/builtins/viewers.tsx'
import { registerChunkForTests, resetChunks } from '../src/client/chunk-loader.ts'
import { lazyChunkComponent } from '../src/client/lazy-chunk.tsx'
import type { FileViewerProps, TabComponentProps } from '../src/client/service.ts'
import css from '../src/client/sidebar.module.css'
const Marker = (): ReactNode => createElement('div', { 'data-testid': 'chunk-rendered' }, 'loaded')
beforeEach(() => {
resetChunks()
})
afterEach(() => {
// Defensive: drop any containers left by failed assertions.
for (const el of document.querySelectorAll('body > div')) el.remove()
})
describe('lazyChunkComponent', () => {
it('renders the loading placeholder first, then the chunk component', async () => {
let calls = 0
registerChunkForTests('editor', async () => {
calls += 1
return { TextEditor: Marker }
})
const Wrapper = lazyChunkComponent<{ label: string }>('editor', (mod) => mod.TextEditor as ComponentType<{ label: string }> | undefined)
const { container, unmount } = renderRoot(createElement(Wrapper, { label: 'x' }))
// Initial paint: the loading placeholder (no chunk loaded yet).
expect(container.querySelector(`.${css.editorPlaceholder}`)).not.toBeNull()
await act(async () => {})
expect(container.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
expect(container.textContent).toContain('loaded')
expect(calls).toBe(1)
unmount()
})
it('shows the failure reason with a retry that recovers', async () => {
let fail = true
registerChunkForTests('editor', async () => {
if (fail) throw new Error('boom')
return { TextEditor: Marker }
})
const Wrapper = lazyChunkComponent<Record<string, never>>('editor', (mod) => mod.TextEditor as ComponentType<Record<string, never>> | undefined)
const { container, unmount } = renderRoot(createElement(Wrapper, {}))
await act(async () => {})
expect(container.textContent).toContain('boom')
// The failed load cleared the loader cache; retry now succeeds.
fail = false
const button = container.querySelector('button')
expect(button).not.toBeNull()
await act(async () => { button!.click() })
await act(async () => {})
expect(container.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
unmount()
})
it('props flow through to the chunk component', async () => {
const Recorder = (props: { label: string }): ReactNode => createElement('div', { 'data-testid': 'rec', 'data-label': props.label })
registerChunkForTests('mermaid', async () => ({ MermaidMarkdown: Recorder }))
const Wrapper = lazyChunkComponent<{ label: string }>('mermaid', (mod) => mod.MermaidMarkdown as ComponentType<{ label: string }> | undefined)
const { container, unmount } = renderRoot(createElement(Wrapper, { label: 'hello' }))
await act(async () => {})
expect(container.querySelector('[data-testid="rec"]')?.getAttribute('data-label')).toBe('hello')
unmount()
})
})
describe('built-in descriptor contract (render-prop functions)', () => {
it('every heavy built-in viewer component is callable as a plain function without a chunk (returns an element)', () => {
const viewers = builtinViewers()
for (const id of ['markdown', 'html', 'code']) {
const descriptor = viewers.find(viewer => viewer.id === id)
expect(descriptor, id).toBeDefined()
// No chunk registered: calling must not throw — it returns the lazy
// element (the loading placeholder renders once mounted).
expect(() => descriptor!.component({} as FileViewerProps), id).not.toThrow()
}
})
it('a built-in tab component keeps the same contract', () => {
const tabs = builtinTabs()
const editor = tabs.find(tab => tab.id === 'editor')
expect(editor).toBeDefined()
// The descriptor renders from props; a real tab is part of the contract —
// Sidebar always provides one.
const props = { tab: { id: 'editor:1', type: 'editor', title: '文件' } } as unknown as TabComponentProps
expect(() => editor!.component(props)).not.toThrow()
})
it('a built-in viewer mounted without a chunk available degrades to the error + retry affordance (no crash)', async () => {
// No test chunk registered and jsdom has no client module system: the
// load fails and the wrapper must degrade gracefully, never throw.
const viewers = builtinViewers()
const code = viewers.find(viewer => viewer.id === 'code')!
const { container, unmount } = renderRoot(createElement(code.component, {} as FileViewerProps))
await act(async () => {})
expect(container.querySelector(`.${css.editorError}`)).not.toBeNull()
expect(container.querySelector('button')).not.toBeNull()
unmount()
})
it('a registered chunk makes the built-in viewer render through the real descriptor path', async () => {
registerChunkForTests('editor', async () => ({ TextEditor: Marker }))
const viewers = builtinViewers()
const markdown = viewers.find(viewer => viewer.id === 'markdown')!
// EditorHost renders viewer components via createElement(component, props).
const { container, unmount } = renderRoot(createElement(markdown.component, {
ctx: {},
store: undefined,
scope: { sessionId: 's1', cwd: '/p' },
path: '/p/a.md',
title: 'a.md',
viewerId: 'markdown',
} as unknown as FileViewerProps))
await act(async () => {})
expect(container.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
unmount()
})
it('the loader cache survives across descriptor mounts (chunk fetched once)', async () => {
let calls = 0
registerChunkForTests('editor', async () => {
calls += 1
return { TextEditor: Marker }
})
const markdown = builtinViewers().find(viewer => viewer.id === 'markdown')!
const props = {
ctx: {},
store: undefined,
scope: { sessionId: 's1', cwd: '/p' },
path: '/p/a.md',
title: 'a.md',
viewerId: 'markdown',
} as unknown as FileViewerProps
const { container: first, unmount: unmountFirst } = renderRoot(createElement(markdown.component, props))
await act(async () => {})
expect(first.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
unmountFirst()
const { container: second, unmount: unmountSecond } = renderRoot(createElement(markdown.component, props))
await act(async () => {})
expect(second.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
unmountSecond()
expect(calls).toBe(1)
})
})