From c70d868c58a0da09f0179494e2c84bc736e6334b Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Fri, 14 Aug 2026 20:17:27 +0800 Subject: [PATCH] =?UTF-8?q?test:=20issue=20#42=20=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E9=93=BE=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95=20+=20=E8=AF=8A?= =?UTF-8?q?=E6=96=AD=E6=96=87=E6=A1=A3=EF=BC=88=E5=BA=95=E9=83=A8=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E9=A6=96=E5=B1=95=E8=87=AA=E5=8A=A8=E7=BB=88=E7=AB=AF?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 通过。 --- .../2026-08-14-issue-42-verify-design.md | 61 +++++++ tests/bottom-auto-terminal.spec.tsx | 168 ++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 docs/plans/2026-08-14-issue-42-verify-design.md create mode 100644 tests/bottom-auto-terminal.spec.tsx diff --git a/docs/plans/2026-08-14-issue-42-verify-design.md b/docs/plans/2026-08-14-issue-42-verify-design.md new file mode 100644 index 0000000..c6b073d --- /dev/null +++ b/docs/plans/2026-08-14-issue-42-verify-design.md @@ -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 触发链回归测试。 diff --git a/tests/bottom-auto-terminal.spec.tsx b/tests/bottom-auto-terminal.spec.tsx new file mode 100644 index 0000000..dfba81d --- /dev/null +++ b/tests/bottom-auto-terminal.spec.tsx @@ -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).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) + }) +})