Files
apes-sidebar/tests/sidebar-crash.spec.tsx
T
Menghuan1918 6586e3e2d3 feat: adapt to DSH 0.1.7-alpha.1 and yield the read-only previews
DSH 0.1.7-alpha.1 is 1299 commits past 0.1.6-alpha.2 and rewrites four
contracts this plugin was built on. The peer floor moves to
^0.1.7-alpha.1 (semver's prerelease rule means ^0.1.6-alpha.2 can never
match a 0.1.7 prerelease); 0.1.6-alpha.2 and earlier stay on v0.19.1.
Released as 0.21.0-alpha.1 (dist-tag alpha).

Hard breaks:
- dsh-settings was rewritten: register(ns, schema) is gone, a form is
  addressed by the plugin row's Loader entry id and read from the
  module's exported Config. The preferences are merged into Config and
  marked volatile — which requires DSH's own schemastery, because only
  that build wraps a volatile field in the cosmokit reference the
  loader's volatile commit walks. With the public package a settings
  write reports success and revision 1 while the effective value never
  changes.
- ui-primitives renamed its whole icon family (IconXxx14/16 ->
  IconXxxRegular/Medium): 26 imported symbols, 17 files. The failing
  mode is render-time, not load-time. Five call sites that relied on the
  old 14px default now pass size={14} explicitly.
- Session format v4 rejects source.kind === 'plugin'; the side-chat
  boundary injection now writes plugin:dsh-better-sidebar, the same kind
  DSH's own v3->v4 migration produces, so historical threads survive.
  Tool results moved to role 'tool' with top-level content/isError; all
  four parsers accept both shapes.
- The client session snapshot dropped subagentsByParent/jobsBySession
  and the on-demand subagent-catalog observation API. The Tasks page
  reads projectionsBySession and a new jobs.list route.

Yields and convergence:
- The read-only previews go to DSH's documentpreview (Office->PDF, a
  spreadsheet worker, zoom, per-directory refresh); editor.canOpen
  refuses those 26 extensions. Markdown, HTML (with its sandbox
  switches) and the editable code buffer stay.
- The host browser kind is desktop-profile-only now, so the web e2e
  lanes expect no browser entry and assert its absence.
- Link takeover claims only urlTarget links and passes the rest to the
  host, fixing a regression from the 0.1.6 round: the fallback pointed
  at a 'browser' type the plugin no longer registers, so http links in
  its own markdown did nothing.

Also fixed, found along the way:
- The file tree refreshes live (new directory watch over the plugin's
  own socket), since the plugin owns the `files` kind.
- ctx.sessions.list has no `current` field at either tag; the three
  readers were dead. They now use ctx.sidebarRight.mounted.
- package.json.files was missing lib/client-locale.js.
- Two genuinely dead call sites: sessions.openSubagent/open/
  refreshSubagents do not exist on the current baseline, so the
  topology click, the jump-back and the retry were all no-ops.
- The test harness window shim lacked addEventListener, which the 0.1.7
  primitives call at module-evaluation time; and the e2e token exchange
  needs Playwright's request stack, not Node's fetch.
2026-09-22 17:09:08 +08:00

198 lines
8.6 KiB
TypeScript

/**
* Sidebar crash tests — the two failure modes behind issue #31.
*
* 1. Layout-push leak: the layout-push effect writes
* `--dsh-sidebar-height` on document.documentElement (the right column
* belongs to DSH's native Sidebar, so no width is pushed).
* Unmounting the Sidebar for ANY reason (error-boundary swap, plugin
* disable, HMR) must clear it — otherwise layout.css keeps squeezing
* `#root` with a stale margin and "the sidebar cannot be hidden" until a
* full page reload.
*
* 2. Tab crash containment: a render error inside ONE tab's content must not
* take down the whole sidebar. The per-tab boundary shows a strip inside
* that tab's pane while the toggle cluster, the other tabs, and the panel
* itself stay alive; the retry button recovers a transient crash.
*
* Rendered with the REAL Sidebar shell + real store/service against a minimal
* fake context (createRoot + act(), the repo's jsdom pattern).
*/
// @vitest-environment jsdom
import { afterEach, 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'
// The act() environment flag (React 18.2 reads it before flushing effects).
import { setupReactAct } from './test-utils.ts'
setupReactAct()
import { Sidebar } from '../src/client/Sidebar.tsx'
import { createSidebarStore, setBottomHeight, toggleBottomPanel, type SidebarStore } from '../src/client/state.ts'
import { createBetterSidebarService, type BetterSidebarService } from '../src/client/service.ts'
import { t } from '../src/client/locales.ts'
/** jsdom has no WebSocket; the agent-terminals push effect constructs one on mount. */
class FakeWebSocket {
onmessage: ((event: { data: unknown }) => void) | null = null
onclose: (() => void) | null = null
onerror: (() => void) | null = null
close = (): void => {}
constructor(_url: string) {}
}
interface MountedSidebar {
container: HTMLDivElement
store: SidebarStore
service: BetterSidebarService
unmount: () => void
}
/** Mount the real Sidebar shell against a minimal context (real store + service). */
/** Unique per-test session ids (see the comment inside). */
let sessionSeq = 0
function mountSidebar(): MountedSidebar {
vi.stubGlobal('WebSocket', FakeWebSocket)
const container = document.createElement('div')
document.body.append(container)
const store = createSidebarStore()
const service = createBetterSidebarService(store)
// Unique session per test — the store persists per-session state to
// localStorage (200ms debounce); a shared id lets a previous test's late
// write leak into this store's setSession restore.
const sessionId = `s1-${++sessionSeq}`
store.setSession(sessionId)
// useSyncExternalStore requires STABLE snapshots across calls (the real DSH
// services return stable objects) — a fresh object per call loops forever.
const localeSnapshot = { active: 'en' }
const sessionsSnapshot = {
// cwd present → api.sessionCwd is never called in these tests.
byId: { [sessionId]: { cwd: '/tmp' } },
}
// The conversation on screen is the native surface's mounted seat
// (`ctx.sidebarRight.mounted`); the session-list snapshot never had a
// current-session field, so the shell binds its per-session state to THIS.
const mounted = {
getSnapshot: () => sessionId,
subscribe: () => () => {},
}
const ctx = {
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
betterSidebar: service,
get: (name: string) => name === 'betterSidebar'
? service
: name === 'sidebarRight' ? { mounted } : undefined,
}
const root: Root = createRoot(container)
act(() => { root.render(createElement(Sidebar, { ctx: ctx as never, store })) })
return {
container,
store,
service,
unmount: () => {
act(() => { root.unmount() })
container.remove()
},
}
}
afterEach(() => {
document.body.innerHTML = ''
// Belt and braces: drop any persisted layout a pending 200ms debounce
// write left behind between tests (unique session ids already isolate).
localStorage.clear()
vi.unstubAllGlobals()
})
describe('layout-push variable cleanup', () => {
it('clears --dsh-sidebar-height when the sidebar unmounts (and never pushes a width)', () => {
const { unmount } = mountSidebar()
const htmlStyle = document.documentElement.style
// A fresh session seeds the bottom workbench collapsed: the push is 0.
expect(htmlStyle.getPropertyValue('--dsh-sidebar-height')).toBe('0px')
// The right column is DSH's native Sidebar — this shell writes no width.
expect(htmlStyle.getPropertyValue('--dsh-sidebar-width')).toBe('')
// Any unmount (boundary swap, plugin disable, HMR) must release the push.
unmount()
expect(htmlStyle.getPropertyValue('--dsh-sidebar-height')).toBe('')
})
it('a size change (release commit) re-applies the variable without removing it mid-commit', () => {
const { store, unmount } = mountSidebar()
const htmlStyle = document.documentElement.style
const removeSpy = vi.spyOn(htmlStyle, 'removeProperty')
// Simulate a drag release: the bottom panel opens and its height commits.
act(() => { store.reduce(toggleBottomPanel) })
act(() => { store.reduce(s => setBottomHeight(s, 300)) })
expect(htmlStyle.getPropertyValue('--dsh-sidebar-height')).toBe('300px')
// The commit must NOT have removed the variables at any point. React
// runs every effect cleanup before every effect setup in a commit, so a
// cleanup here would remove the variables before the draggingRef effect
// (declared above the layout push) forces a layout in measureCenter
// (getBoundingClientRect). That forced recalc resolves #root's
// margin-right to the 0px fallback, caches it as the transition start
// value, and measures centerRect too wide; once transitions re-enable on
// release, the shell animates margin-right 0 → width — the "expand to
// the full page then bounce back" flash.
const removalProps = removeSpy.mock.calls
.map(call => call[0] as string)
.filter(prop => prop === '--dsh-sidebar-height')
expect(removalProps).toHaveLength(0)
// Only unmounting may remove it (issue #31).
unmount()
expect(removeSpy.mock.calls.some(call => call[0] === '--dsh-sidebar-height')).toBe(true)
removeSpy.mockRestore()
})
})
describe('tab crash containment', () => {
it('a crashing tab shows an in-pane strip while the panel survives', () => {
const { container, service, store } = mountSidebar()
service.registerTab({
id: 'crash',
title: 'Crash',
component: () => { throw new Error('boom') },
})
act(() => { service.openTab({ type: 'crash', title: 'Crash' }) })
// The strip lives inside the tab's pane — the crash is contained.
expect(container.textContent).toContain('boom')
expect(container.textContent).toContain(t('terminalRetry'))
// The workbench itself survived (no full-tree swap): the collapse control
// is still there and the layout push is still live (the content open
// expanded the panel).
expect(container.querySelector(`[aria-label="${t('collapseBottomPanel')}"]`)).not.toBeNull()
expect(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')).toBe(
`${store.getSnapshot().state!.bottomHeight}px`,
)
})
it('the retry button recovers a tab whose crash has since been fixed', () => {
// A PERSISTENT render error reaches the boundary strip (React 18.2
// auto-recovers transient throws — one bad render followed by a good
// retry is swallowed as a recoverable error and never shows a strip).
let shouldThrow = true
const { container, service } = mountSidebar()
service.registerTab({
id: 'crash-until-fixed',
title: 'Crash until fixed',
component: () => {
if (shouldThrow) throw new Error('transient')
return createElement('div', null, 'recovered')
},
})
act(() => { service.openTab({ type: 'crash-until-fixed', title: 'Crash until fixed' }) })
expect(container.textContent).toContain('transient')
const retry = [...container.querySelectorAll('button')]
.find(button => button.textContent === t('terminalRetry'))
expect(retry).toBeDefined()
// The crash condition is gone (e.g. the data arrived): the retry button
// remounts the tab's content and the strip clears.
shouldThrow = false
act(() => { retry!.click() })
expect(container.textContent).toContain('recovered')
expect(container.textContent).not.toContain('transient')
})
})