test: issue #42 触发链回归测试 + 诊断文档(底部面板首展自动终端)

#42 的根因与 #25 同源(零尺寸容器同步 open xterm → 渲染器未创建 →
.dimensions/.handleResize 崩溃),已在 v0.11.0 修复(openWhenSized);
「整个面板空白」放大器已由 #31 修复(per-tab 崩溃隔离)消除。
本次无代码改动,补齐缺失的覆盖:

- tests/bottom-auto-terminal.spec.tsx:钉住 #42 触发链(首展自动开终端
  恰好一个 tab 落在底部工作树、bottomOpenedOnce 原子置位、pref / 类型
  禁用双门控、面板完整存活),此前该 effect 零测试覆盖;
- docs/plans/2026-08-14-issue-42-verify-design.md:诊断证据链、
  修复覆盖路径、AttachConsole(node-pty 上游限制,仅文档化)、
  issue 回复草稿。

验证:pnpm test 34 文件 / 430 例全绿;typecheck、build 通过。
This commit is contained in:
Menghuan1918
2026-08-14 20:17:27 +08:00
parent 61e31321d6
commit c70d868c58
2 changed files with 229 additions and 0 deletions
@@ -0,0 +1,61 @@
# Issue #42 诊断与收尾记录(底部面板首展自动终端 → 整面板空白)
**日期**:2026-08-14
**状态**:已诊断、已收尾(无新代码修复;回归测试 + 文档交付)
**关联**:issue #42、issue #25(同根因)、issue #31(放大器)、PR #27 / commit 37d91be、commit 9aeb046
**目标版本**:v0.11.0(根因修复已发布)
## 1. 问题报告
[issue #42](https://github.com/omdsh-dev/DSH-better-sidebar/issues/42)(Windows 11 / QQBrowser / dsh-launcher 宿主 / 插件 0.10.3):
1. 全新安装后重启 + 硬刷新,首次展开底部面板(`bottomPanelAutoTerminal` 默认开)→ **整个底部面板空白,连标签栏都没有**;
2. 控制台两条关键报错:
- `Uncaught TypeError: Cannot read properties of undefined (reading 'dimensions')`(`Viewport._innerRefresh`)
- `Uncaught TypeError: Cannot read properties of undefined (reading 'handleResize')`(`IdleTaskQueue._process`)
3. 关闭「底部面板首次展开自动开终端」后一切正常;右侧栏终端一直正常;
4. 附带问题:Windows 无控制台宿主下每次关闭终端,服务器日志出现 `Error: AttachConsole failed`(node-pty `conpty_console_list_agent.js`)。
## 2. 诊断结论
### 2.1 根因 = issue #25 的同一 bug
与 [issue #25](https://github.com/omdsh-dev/DSH-better-sidebar/issues/25)(WKWebView 底部面板终端空白)完全同源,证据链:
- **触发路径一致**:`Sidebar.tsx` 的「底部面板首次展开自动开终端」effect(`bottomOpenedOnce` 过渡)在面板 slide-in transition 的**同一次 commit** 挂载 `TerminalView`。`docs/plans/2026-08-14-terminal-open-when-sized-design.md` §3 在设计 #25 修复时已明确把该路径列为零尺寸挂载路径之一。
- **崩溃签名逐字一致**:0.10.3 的 `TerminalView` 挂载 effect 同步执行 `term.open(host); fit.fit()`(无尺寸守卫)。xterm 5.3.0 在零尺寸容器 open 时渲染器创建失败,`RenderService._renderer` 保持 undefined → `Viewport._innerRefresh` 读 `_renderService.dimensions`(getter 即 `_renderer.value.dimensions`)、idle 任务读 `handleResize` 全部抛 "Cannot read properties of undefined"——与 #42 两条报错逐字对应。
- **平台差异只是时序**:#25 设计文档注明「Chrome(Blink)对零尺寸布局更宽容所以不崩;WKWebView 稳定复现」。QQBrowser(Chromium fork)在 Windows + launcher 启动的组合下命中同一零尺寸窗口期,属边界时序,不是新缺陷。
- **版本差**:#42 报告针对 **0.10.3**(2026-08-13 发布);根因修复 37d91be(PR #27)于 2026-08-14 合并并随 **v0.11.0** 发布(早于 #42 提交约 1 小时,晚于 reporter 安装的版本)。
### 2.2 修复覆盖路径(无需新代码)
| 层 | 机制 | 位置 | 状态 |
|---|---|---|---|
| 根因(零尺寸 open) | `openWhenSized`:`term.open + fit + sendResize` 推迟到容器有尺寸后恰好执行一次;RO/字体订阅的 `fit()` 全部 try/catch 加固 | `src/client/open-when-sized.ts` + `TerminalView.tsx`(37d91be,v0.11.0) | 已发布 |
| 根因单测 | 手动步进 rAF 的 6 个用例(延迟/一次性/单维缺失/cancel/脱离文档/异常传播) | `tests/open-when-sized.spec.ts` | 已提交 |
| 「整面板空白」放大器 | per-tab `RenderBoundary` 崩溃隔离(单 tab 崩溃只显示该 pane 内条带,toggle 簇/其他 tab/面板存活)+ 根 boundary 兜底 + layout-push CSS 变量卸载清理 | `src/client/RenderBoundary.tsx`、`Sidebar.tsx`、`index.tsx`(9aeb046,#31) | 已提交 |
| 隔离测试 | 崩溃条带/重试恢复/布局变量清理 | `tests/sidebar-crash.spec.tsx` | 已提交 |
| **#42 触发链回归** | 底部面板首展自动开终端:恰好一个 terminal tab 落在底部工作树、`bottomOpenedOnce` 原子置位、pref / 类型禁用双门控、面板完整存活 | `tests/bottom-auto-terminal.spec.tsx`(本次新增,4 例) | 本次提交 |
三者合起来构成 #42 全链回归保护:触发链(本测试)→ 零尺寸延迟(openWhenSized 单测)→ 崩溃隔离(containment 测试)。
## 3. 附带问题:AttachConsole 报错(仅文档化)
`Error: AttachConsole failed`(node-pty `conpty_console_list_agent.js`)是 **node-pty 上游行为**:`kill()` 清理路径 fork 的 console-list 辅助进程在无控制台宿主(launcher/服务方式启动的 `dsh`)下 `AttachConsole(shellPid)` 失败。终端功能本身不受影响(spawn/输入输出正常),仅刷服务器日志。
- 插件侧不可消除:不改 DSH 源码、不 patch node-pty 依赖是仓库硬约束(见 AGENTS.md §0),且 `IPty.kill()` 是 node-pty 的唯一 kill 通道;
- 建议:保持现状(无功能影响),上游如提供无控制台规避选项再跟进;reporter 若在意日志噪音,可反馈 microsoft/node-pty。
## 4. 验证
- 全量测试:`pnpm test` → 34 文件 / 430 例全部通过(基线 425 + 同期 PR #43 的 html 盘符修复 1 例 + 本次新增 4 例);
- `pnpm typecheck`(`tsc --noEmit`)通过;
- `pnpm build`(`tsc -p tsconfig.build.json && tsdown`)通过。
## 5. Issue #42 回复草稿(供 maintainer 发布/关闭用)
> 感谢复现与详细报告。诊断结论:**这是 issue #25 同一根因在不同平台的复现**——底部面板首次展开自动开终端时,终端在容器尚为零尺寸(面板 slide-in 窗口期)就执行了 xterm `Terminal.open()`,渲染器创建失败导致后续 `Viewport._innerRefresh` / 空闲任务读取 `.dimensions` / `.handleResize` 崩溃(与 #25 的控制台报错逐字一致)。
>
> 该根因已在 **v0.11.0** 修复(终端延迟到容器有尺寸后再 open,PR #27);「整个面板空白」的放大器也已在最新 main 消除(单 tab 崩溃隔离 + 布局变量清理,issue #31)。**请升级到 v0.11.0 验证**:预期首次展开自动开终端不再空白;若仍复现,请提供 v0.11.0 下的控制台报错。
>
> 附带问题(`AttachConsole failed` 日志噪音)确认为 node-pty 在无控制台宿主(launcher 启动)下的上游行为,无功能影响,插件侧无法在不改动 node-pty/DSH 的前提下消除;已文档化(`docs/plans/2026-08-14-issue-42-verify-design.md`),并已补 #42 触发链回归测试。
+168
View File
@@ -0,0 +1,168 @@
/**
* Bottom-panel first-expand auto-terminal tests — the TRIGGER CHAIN behind
* issue #42 (bottom panel first expand + auto terminal → whole panel blank).
*
* #42's crash itself was the zero-size xterm open (the same root cause as
* #25, fixed by openWhenSized — see tests/open-when-sized.spec.ts), and the
* "whole panel blank" amplification is covered by the per-tab containment
* tests (tests/sidebar-crash.spec.tsx). What no test pinned was the trigger:
* the Sidebar effect that opens a terminal tab the FIRST time the bottom
* panel expands (`bottomOpenedOnce`), its pref / enable-switch gates, and
* the once-per-session semantics. These tests render the REAL Sidebar shell
* against a minimal fake context (the repo's jsdom pattern) and drive the
* bottom panel through the store, asserting the tab lands in the bottom
* workbench, renders, and the panel itself survives.
*/
// @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).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
import { Sidebar } from '../src/client/Sidebar.tsx'
import { allLeaves, createSidebarStore, 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). */
function mountSidebar(): MountedSidebar {
vi.stubGlobal('WebSocket', FakeWebSocket)
const container = document.createElement('div')
document.body.append(container)
const store = createSidebarStore()
const service = createBetterSidebarService(store)
// Fresh-session seed: the right panel starts OPEN, the bottom panel closed
// (bottomOpen false → the first expansion is a false→true TRANSITION).
store.setSession('s1')
// 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 = {
current: 's1',
// cwd present → api.sessionCwd is never called in these tests.
byId: { s1: { cwd: '/tmp' } },
}
const ctx = {
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
betterSidebar: service,
}
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 = ''
vi.unstubAllGlobals()
})
/** Every tab currently living in the bottom workbench. */
function bottomTabs(store: SidebarStore): Array<{ type: string; title: string }> {
const state = store.getSnapshot().state
if (state === undefined) return []
return allLeaves(state.bottomSplits).flatMap(leaf => leaf.tabs)
}
/** The stub terminal tab: counts renders so the test can see it actually mount. */
function registerStubTerminal(service: BetterSidebarService, renders: { count: number }): void {
service.registerTab({
id: 'terminal',
title: () => 'Terminal',
component: () => {
renders.count += 1
return createElement('div', null, 'terminal-stub-content')
},
})
}
describe('bottom-panel first-expand auto terminal (issue #42 trigger chain)', () => {
it('auto-opens exactly one terminal tab in the bottom workbench on the FIRST expansion', () => {
const { container, store, service } = mountSidebar()
const renders = { count: 0 }
registerStubTerminal(service, renders)
act(() => { store.reduce(toggleBottomPanel) })
const state = store.getSnapshot().state!
expect(state.bottomOpen).toBe(true)
// The once-flag is set atomically with the first fire.
expect(state.bottomOpenedOnce).toBe(true)
// The terminal tab landed in the BOTTOM workbench (the effect pins the
// active pane to the bottom tree's first leaf before opening).
expect(bottomTabs(store).map(tab => tab.type)).toEqual(['terminal'])
// The tab actually mounted and rendered — the chain is live end to end.
expect(renders.count).toBeGreaterThanOrEqual(1)
expect(container.textContent).toContain('terminal-stub-content')
// The panel itself survived (the #42 symptom was a WHOLE blank panel):
// the close control is present and the layout push for the bottom panel
// height is live.
expect(container.querySelector(`[aria-label="${t('collapseBottomPanel')}"]`)).not.toBeNull()
expect(document.documentElement.style.getPropertyValue('--dsh-sidebar-height')).toBe(
`${state.bottomHeight}px`,
)
})
it('never repeats the auto-open on later expansions (once per session)', () => {
const { store, service } = mountSidebar()
registerStubTerminal(service, { count: 0 })
act(() => { store.reduce(toggleBottomPanel) })
expect(bottomTabs(store)).toHaveLength(1)
// Collapse → expand again: the once-flag suppresses any second terminal.
act(() => { store.reduce(toggleBottomPanel) })
expect(store.getSnapshot().state!.bottomOpen).toBe(false)
act(() => { store.reduce(toggleBottomPanel) })
expect(bottomTabs(store)).toHaveLength(1)
})
it('does not auto-open when the bottomPanelAutoTerminal pref is off', () => {
const { store, service } = mountSidebar()
registerStubTerminal(service, { count: 0 })
// setPrefs REPLACES the prefs record — spread the current one so only
// the toggle moves.
act(() => { store.setPrefs({ ...store.getPrefs(), bottomPanelAutoTerminal: false }) })
act(() => { store.reduce(toggleBottomPanel) })
expect(store.getSnapshot().state!.bottomOpen).toBe(true)
expect(bottomTabs(store)).toHaveLength(0)
})
it('does not auto-open when the terminal tab type is disabled in settings', () => {
const { store, service } = mountSidebar()
registerStubTerminal(service, { count: 0 })
act(() => { store.setPrefs({ ...store.getPrefs(), tabsEnabled: { terminal: false } }) })
act(() => { store.reduce(toggleBottomPanel) })
expect(store.getSnapshot().state!.bottomOpen).toBe(true)
expect(bottomTabs(store)).toHaveLength(0)
})
})