test(specs): 收敛 28 个组件 spec 的渲染样板到共享 test-utils

- 7 个 spec 的本地 mount()(createRoot + act 容器循环)替换为
  renderRoot():sidechat-view、subagent-jobs-view、subagent-live-polling、
  lazy-chunk、side-card-section-rows(本地 mount 删除,调用点改名);
  editor-refresh、markdown-manual-refresh(EditorHost 包装 mount 改为
  委托 renderRoot,调用点不动)
- 24 个 spec 的内联 IS_REACT_ACT_ENVIRONMENT 赋值替换为 setupReactAct()
  (subagent-jobs-view / subagent-live-polling / lazy-chunk 原本未设
  flag,保持原样不引入)

全部为语义相同的样板替换:迁移后全量 118 files / 1241 passed +
9 skipped,与迁移前一致。
This commit is contained in:
Menghuan1918
2026-09-03 12:04:35 +08:00
parent a627a4c84d
commit 936df662b0
28 changed files with 103 additions and 181 deletions
+2 -1
View File
@@ -26,7 +26,8 @@ import { createBetterSidebarService } from '../src/client/service.ts'
import { createSidebarStore } from '../src/client/state.ts'
// The act() environment flag (React 18.2 reads it before flushing effects).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
import { setupReactAct } from './test-utils.ts'
setupReactAct()
const officeEntry = builtinViewerPlugins[0] as PluginEntry
+2 -1
View File
@@ -20,7 +20,8 @@ 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 { setupReactAct } from './test-utils.ts'
setupReactAct()
import { Sidebar } from '../src/client/Sidebar.tsx'
import { allLeaves, createSidebarStore, toggleBottomPanel, type SidebarStore } from '../src/client/state.ts'
+2 -1
View File
@@ -16,7 +16,8 @@ import { api, type GitStatusResult, type GitWorktree } from '../src/client/api.t
import type { Context } from '../src/context-types.ts'
import type { SidebarTab } from '../src/client/state.ts'
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
import { setupReactAct } from './test-utils.ts'
setupReactAct()
const MAIN = 'C:/repo/main'
+2 -1
View File
@@ -5,7 +5,8 @@ import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { DiffFiles } from '../src/client/diff/DiffFiles.tsx'
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
import { setupReactAct } from './test-utils.ts'
setupReactAct()
const diff = [
'diff --git a/src/a.ts b/src/a.ts',
+2 -1
View File
@@ -19,7 +19,8 @@ import { createBetterSidebarService } from '../src/client/service.ts'
import { allLeaves, createSidebarStore, type SidebarTab } from '../src/client/state.ts'
// The act() environment flag (React 18.2 reads it before flushing effects).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
import { setupReactAct } from './test-utils.ts'
setupReactAct()
/** A store with the seeded editor-home tab (default prefs: separate mode;
* merged-mode scenarios re-enable editorExplorer explicitly). */
+6 -18
View File
@@ -8,15 +8,15 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createElement, useEffect, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { renderRoot, setupReactAct } from './test-utils.ts'
import type { Context } from '../src/context-types.ts'
import { EditorHost } from '../src/client/EditorHost.tsx'
import { createBetterSidebarService, type FileViewerProps } from '../src/client/service.ts'
import { allLeaves, createSidebarStore, type SidebarTab } from '../src/client/state.ts'
// The act() environment flag (React 18.2 reads it before flushing effects).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
setupReactAct()
const fsRead = vi.fn()
vi.mock('../src/client/api.ts', () => ({
@@ -90,22 +90,10 @@ function setup(initialMode: 'preview' | 'edit' = 'preview', dirty = false): {
}
function mount(ctx: Context, tab: () => SidebarTab): { container: HTMLDivElement; unmount: () => void } {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
act(() => {
root.render(createElement(EditorHost, {
ctx, store: ctx.betterSidebar as never, scope: { sessionId: 'editor-home-session' },
tab: tab(), expanded: [], revealed: [], onToggleDir: () => {}, onReferenceFile: () => {},
}))
})
return {
container,
unmount: () => {
act(() => { root.unmount() })
container.remove()
},
}
return renderRoot(createElement(EditorHost, {
ctx, store: ctx.betterSidebar as never, scope: { sessionId: 'editor-home-session' },
tab: tab(), expanded: [], revealed: [], onToggleDir: () => {}, onReferenceFile: () => {},
}))
}
/** The header's refresh button (locale-dependent aria-label), or null. */
+2 -1
View File
@@ -19,7 +19,8 @@ import { TAB_DRAG_TYPE } from '../src/client/TabBar.tsx'
import type { UploadItem } from '../src/client/upload.ts'
// The act() environment flag (React 18.2 reads it before flushing effects).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
import { setupReactAct } from './test-utils.ts'
setupReactAct()
// vitest 4.1.11+ follows the OS locale; pin en-US so hint assertions are
// deterministic regardless of the developer machine.
+2 -1
View File
@@ -15,7 +15,8 @@ import { createSidebarStore } from '../src/client/state.ts'
import type { OpenWithTarget } from '../src/client/open-with.ts'
// The act() environment flag (React 18.2 reads it before flushing effects).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
import { setupReactAct } from './test-utils.ts'
setupReactAct()
// vitest 4.1.11+ follows the OS locale; pin en-US so menu copy is English.
beforeAll(() => {
+2 -1
View File
@@ -16,7 +16,8 @@ import { act } from 'react-dom/test-utils'
import { FileTree } from '../src/client/FileTree.tsx'
import { createSidebarStore } from '../src/client/state.ts'
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
import { setupReactAct } from './test-utils.ts'
setupReactAct()
// jsdom defines no scrollIntoView; counting calls needs a real function.
const scrollIntoView = vi.fn()
+2 -1
View File
@@ -20,7 +20,8 @@ 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 { setupReactAct } from './test-utils.ts'
setupReactAct()
import { Sidebar } from '../src/client/Sidebar.tsx'
import { createSidebarStore, togglePanel, type SidebarStore, floatTab as floatTabReducer } from '../src/client/state.ts'
+2 -1
View File
@@ -13,7 +13,8 @@ import { GitLens } from '../src/client/changes/GitLens.tsx'
import { createSidebarStore } from '../src/client/state.ts'
import { api, type GitLogEntry, type GitStatusResult, type GitWorktree } from '../src/client/api.ts'
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
import { setupReactAct } from './test-utils.ts'
setupReactAct()
const MAIN = 'C:/repo/main'
const AGENT = 'C:/repo/agent'
+9 -24
View File
@@ -11,8 +11,8 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createElement, type ComponentType, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
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'
@@ -21,21 +21,6 @@ import type { Context } from '../src/context-types.ts'
import type { FileViewerProps, TabComponentProps } from '../src/client/service.ts'
import css from '../src/client/sidebar.module.css'
/** Render `node` into a detached body container under React's act(). */
function mount(node: ReactNode): { container: HTMLDivElement; unmount: () => void } {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
// act() flushes the (concurrent) initial commit — the placeholder must be
// in the DOM before the async load flushes below.
act(() => { root.render(node) })
const unmount = (): void => {
act(() => { root.unmount() })
container.remove()
}
return { container, unmount }
}
const Marker = (): ReactNode => createElement('div', { 'data-testid': 'chunk-rendered' }, 'loaded')
beforeEach(() => {
@@ -55,7 +40,7 @@ describe('lazyChunkComponent', () => {
return { TextEditor: Marker }
})
const Wrapper = lazyChunkComponent<{ label: string }>('editor', (mod) => mod.TextEditor as ComponentType<{ label: string }> | undefined)
const { container, unmount } = mount(createElement(Wrapper, { label: 'x' }))
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 () => {})
@@ -72,7 +57,7 @@ describe('lazyChunkComponent', () => {
return { TextEditor: Marker }
})
const Wrapper = lazyChunkComponent<Record<string, never>>('editor', (mod) => mod.TextEditor as ComponentType<Record<string, never>> | undefined)
const { container, unmount } = mount(createElement(Wrapper, {}))
const { container, unmount } = renderRoot(createElement(Wrapper, {}))
await act(async () => {})
expect(container.textContent).toContain('boom')
// The failed load cleared the loader cache; retry now succeeds.
@@ -89,7 +74,7 @@ describe('lazyChunkComponent', () => {
const Recorder = (props: { label: string }): ReactNode => createElement('div', { 'data-testid': 'rec', 'data-label': props.label })
registerChunkForTests('terminal', async () => ({ TerminalView: Recorder }))
const Wrapper = lazyChunkComponent<{ label: string }>('terminal', (mod) => mod.TerminalView as ComponentType<{ label: string }> | undefined)
const { container, unmount } = mount(createElement(Wrapper, { label: 'hello' }))
const { container, unmount } = renderRoot(createElement(Wrapper, { label: 'hello' }))
await act(async () => {})
expect(container.querySelector('[data-testid="rec"]')?.getAttribute('data-label')).toBe('hello')
unmount()
@@ -123,7 +108,7 @@ describe('built-in descriptor contract (render-prop functions)', () => {
// load fails and the wrapper must degrade gracefully, never throw.
const viewers = builtinViewers()
const code = viewers.find(viewer => viewer.id === 'code')!
const { container, unmount } = mount(createElement(code.component, {} as FileViewerProps))
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()
@@ -135,7 +120,7 @@ describe('built-in descriptor contract (render-prop functions)', () => {
const viewers = builtinViewers()
const markdown = viewers.find(viewer => viewer.id === 'markdown')!
// EditorHost renders viewer components via createElement(component, props).
const { container, unmount } = mount(createElement(markdown.component, {
const { container, unmount } = renderRoot(createElement(markdown.component, {
ctx: {},
store: undefined,
scope: { sessionId: 's1', cwd: '/p' },
@@ -165,7 +150,7 @@ describe('built-in descriptor contract (render-prop functions)', () => {
tab: { id: 'terminal:2', type: 'terminal', title: '终端 2' },
visible: true,
} as unknown as TabComponentProps
const { container, unmount } = mount(createElement(terminal.component, props))
const { container, unmount } = renderRoot(createElement(terminal.component, props))
await act(async () => {})
expect(container.querySelector('[data-testid="terminal-tabid"]')?.textContent).toBe('terminal:2')
expect(received).toBe('terminal:2')
@@ -181,11 +166,11 @@ describe('built-in descriptor contract (render-prop functions)', () => {
const tabs = builtinTabs({} as Context)
const terminal = tabs.find(tab => tab.id === 'terminal')!
const props = { tab: { id: 'terminal:1', type: 'terminal', title: '终端 1' } } as unknown as TabComponentProps
const { container: first, unmount: unmountFirst } = mount(createElement(terminal.component, props))
const { container: first, unmount: unmountFirst } = renderRoot(createElement(terminal.component, props))
await act(async () => {})
expect(first.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
unmountFirst()
const { container: second, unmount: unmountSecond } = mount(createElement(terminal.component, props))
const { container: second, unmount: unmountSecond } = renderRoot(createElement(terminal.component, props))
await act(async () => {})
expect(second.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
unmountSecond()
+2 -1
View File
@@ -15,7 +15,8 @@ import { MarkdownDocument, type MarkdownHtmlMedia } from '../src/client/Markdown
import { analyzeMarkdownHtml } from '../src/client/markdown-html.ts'
// The act() environment flag (React 18.2 reads it before flushing effects).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
import { setupReactAct } from './test-utils.ts'
setupReactAct()
const media: MarkdownHtmlMedia = {
scope: { sessionId: 's1', cwd: '/ws' },
+12 -24
View File
@@ -5,15 +5,15 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createElement, useEffect } from 'react'
import { createRoot } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { renderRoot, setupReactAct } from './test-utils.ts'
import type { Context } from '../src/context-types.ts'
import { EditorHost } from '../src/client/EditorHost.tsx'
import { api } from '../src/client/api.ts'
import { createBetterSidebarService, type FileViewerProps } from '../src/client/service.ts'
import { allLeaves, createSidebarStore, type SidebarTab } from '../src/client/state.ts'
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
setupReactAct()
function toolbar(dirty: boolean) {
return {
@@ -68,28 +68,16 @@ function setup(): {
}
function mount(ctx: Context, store: ReturnType<typeof createSidebarStore>, tab: SidebarTab) {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
act(() => {
root.render(createElement(EditorHost, {
ctx,
store,
scope: { sessionId: 'markdown-manual-refresh-session', cwd: '/tmp' },
tab,
expanded: [],
revealed: [],
onToggleDir: () => {},
onReferenceFile: () => {},
}))
})
return {
container,
unmount: () => {
act(() => { root.unmount() })
container.remove()
},
}
return renderRoot(createElement(EditorHost, {
ctx,
store,
scope: { sessionId: 'markdown-manual-refresh-session', cwd: '/tmp' },
tab,
expanded: [],
revealed: [],
onToggleDir: () => {},
onReferenceFile: () => {},
}))
}
async function flush(): Promise<void> {
+2 -1
View File
@@ -13,7 +13,8 @@ import { act } from 'react-dom/test-utils'
import { MdToc, TOC_MIN_HEADINGS } from '../src/client/md-toc.tsx'
// The act() environment flag (React 18.2 reads it before flushing effects).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
import { setupReactAct } from './test-utils.ts'
setupReactAct()
const scrollIntoView = vi.fn()
Element.prototype.scrollIntoView = scrollIntoView
+2 -1
View File
@@ -12,7 +12,8 @@ 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 { setupReactAct } from './test-utils.ts'
setupReactAct()
vi.mock('mermaid', () => ({
default: {
+2 -1
View File
@@ -11,7 +11,8 @@ import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { OpenWithSettings } from '../src/client/open-with-settings.tsx'
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
import { setupReactAct } from './test-utils.ts'
setupReactAct()
beforeAll(() => {
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
+2 -1
View File
@@ -16,7 +16,8 @@ import { act } from 'react-dom/test-utils'
import { useSelectionPopup } from '../src/client/selection-popup.ts'
// The act() environment flag (React 18.2 reads it before flushing effects).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
import { setupReactAct } from './test-utils.ts'
setupReactAct()
/** jsdom ships no IntersectionObserver; a controllable stand-in. */
class FakeIntersectionObserver implements IntersectionObserver {
+10 -26
View File
@@ -12,32 +12,16 @@
*/
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { createElement, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { createElement } from 'react'
import { act } from 'react-dom/test-utils'
import { renderRoot, setupReactAct } from './test-utils.ts'
// The act() environment flag (React 18.2 reads it before flushing effects).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
setupReactAct()
import type { SidebarSettingToggle } from '../src/client/service.ts'
import { FeatureSettingsRows } from '../src/client/SideCardSection.tsx'
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
/** Render the rows into a detached container under React's act(). */
function mount(node: ReactNode): { container: HTMLDivElement; rerender: (node: ReactNode) => void; unmount: () => void } {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
act(() => { root.render(node) })
return {
container,
rerender: (next) => { act(() => { root.render(next) }) },
unmount: () => {
act(() => { root.unmount() })
container.remove()
},
}
}
/** Type into an input and commit it via blur (React 18: input event +
* focusout). The native setter bypasses React's value tracker so the
* change is actually seen. */
@@ -60,7 +44,7 @@ describe('FeatureSettingsRows typed rows (interactive)', () => {
type: 'text',
title: () => 'Font family',
}
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
const { container, unmount } = renderRoot(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs,
onToggle: () => {},
@@ -86,7 +70,7 @@ describe('FeatureSettingsRows typed rows (interactive)', () => {
min: 9,
max: 32,
}
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
const { container, unmount } = renderRoot(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs: { ...prefs, terminalFontSize: 13 },
onToggle: () => {},
@@ -113,7 +97,7 @@ describe('FeatureSettingsRows typed rows (interactive)', () => {
min: 9,
max: 32,
}
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
const { container, unmount } = renderRoot(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs: { ...prefs, terminalFontSize: 13 },
onToggle: () => {},
@@ -142,7 +126,7 @@ describe('FeatureSettingsRows typed rows (interactive)', () => {
// The parent mirrors the real handler: the optimistic commit adopts the
// typed value, and a FAILED write reverts prefs to the stored one.
let prefsNow = { ...prefs, terminalFontFamily: 'Old' }
const { container, rerender, unmount } = mount(createElement(FeatureSettingsRows, {
const { container, rerender, unmount } = renderRoot(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs: prefsNow,
onToggle: () => {},
@@ -198,7 +182,7 @@ describe('FeatureSettingsRows select rows (interactive)', () => {
title: () => 'Editor explorer',
options,
}
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
const { container, unmount } = renderRoot(createElement(FeatureSettingsRows, {
toggles: [toggle],
// Merged is the selected option for this scenario (the default is now
// separate — the anchor must render whatever value the prefs carry).
@@ -239,7 +223,7 @@ describe('FeatureSettingsRows select rows (interactive)', () => {
onSelectValue: (t, next) => { commits.push([t.key, next]) },
valueSource: () => value,
})
const { container, rerender, unmount } = mount(rows(['c', 'a']))
const { container, rerender, unmount } = renderRoot(rows(['c', 'a']))
// The anchor shows the picked titles; options follow the declared order.
const items = openSelect(container)
expect(items.map(item => item.textContent)).toEqual(['Alpha', 'Beta', 'Gamma'])
@@ -263,7 +247,7 @@ describe('FeatureSettingsRows select rows (interactive)', () => {
title: () => 'Editor explorer',
options,
}
const { container, unmount } = mount(createElement(FeatureSettingsRows, {
const { container, unmount } = renderRoot(createElement(FeatureSettingsRows, {
toggles: [toggle],
prefs,
onToggle: () => {},
+2 -1
View File
@@ -9,7 +9,8 @@ import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
import { setupReactAct } from './test-utils.ts'
setupReactAct()
import { Sidebar } from '../src/client/Sidebar.tsx'
import { allLeaves, createSidebarStore, firstLeaf, type SidebarStore } from '../src/client/state.ts'
+2 -1
View File
@@ -23,7 +23,8 @@ 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 { setupReactAct } from './test-utils.ts'
setupReactAct()
import { Sidebar } from '../src/client/Sidebar.tsx'
import { createSidebarStore, setBottomHeight, toggleBottomPanel, type SidebarStore } from '../src/client/state.ts'
+7 -20
View File
@@ -8,15 +8,15 @@
*/
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createElement, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { createElement } from 'react'
import { act } from 'react-dom/test-utils'
import { renderRoot, setupReactAct } from './test-utils.ts'
import { SideChatView } from '../src/client/SideChatView.tsx'
import { attachLocale } from '../src/client/locales.ts'
import type { Context, SidebarSessionList } from '../src/context-types.ts'
import type { SidebarTab } from '../src/client/state.ts'
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
setupReactAct()
/** Minimal structural fake of the DSH LocaleService face the sidebar uses. */
class FakeLocale {
@@ -110,19 +110,6 @@ function jsonResponse(value: unknown): Response {
return { ok: true, status: 200, json: async () => value } as unknown as Response
}
/** Render `node` into a detached body container under React's act(). */
function mount(node: ReactNode): { container: HTMLDivElement; unmount: () => void } {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
act(() => { root.render(node) })
const unmount = (): void => {
act(() => { root.unmount() })
container.remove()
}
return { container, unmount }
}
let eventsPulls = 0
beforeEach(() => {
@@ -168,7 +155,7 @@ function threadStore(): ReturnType<typeof makeStore> {
describe('SideChatView rendering', () => {
it('renders the bash pair as a TerminalBlock and the turn tail with usage + duration', async () => {
const props = viewProps(makeCtx(threadStore()))
const { container, unmount } = mount(createElement(SideChatView, props))
const { container, unmount } = renderRoot(createElement(SideChatView, props))
await act(async () => {}) // flush the initial transcript pull
const text = container.textContent ?? ''
// Terminal surface: the command line and the marker-stripped output body.
@@ -182,7 +169,7 @@ describe('SideChatView rendering', () => {
it('shows the disconnect banner, reconnects on click, and pulls immediately on recovery', async () => {
const connection = makeConnection('disconnected')
const props = viewProps(makeCtx(threadStore(), connection))
const { container, unmount } = mount(createElement(SideChatView, props))
const { container, unmount } = renderRoot(createElement(SideChatView, props))
await act(async () => {}) // flush the initial transcript pull
expect(container.textContent ?? '').toContain('连接已断开')
@@ -200,12 +187,12 @@ describe('SideChatView rendering', () => {
})
it('renders no banner without a connection service or while connected', async () => {
const absent = mount(createElement(SideChatView, viewProps(makeCtx(threadStore()))))
const absent = renderRoot(createElement(SideChatView, viewProps(makeCtx(threadStore()))))
expect(absent.container.textContent ?? '').not.toContain('连接已断开')
absent.unmount()
const connection = makeConnection('connected')
const connected = mount(createElement(SideChatView, viewProps(makeCtx(threadStore(), connection))))
const connected = renderRoot(createElement(SideChatView, viewProps(makeCtx(threadStore(), connection))))
expect(connected.container.textContent ?? '').not.toContain('连接已断开')
connected.unmount()
})
+11 -24
View File
@@ -7,9 +7,9 @@
*/
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createElement, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { createElement } from 'react'
import { act } from 'react-dom/test-utils'
import { renderRoot } from './test-utils.ts'
import { SubagentView } from '../src/client/SubagentView.tsx'
import type { Context, SidebarSessionList } from '../src/context-types.ts'
@@ -52,19 +52,6 @@ function makeCtx(store: Store): Context {
} as unknown as Context
}
/** Render `node` into a detached body container under React's act(). */
function mount(node: ReactNode): { container: HTMLDivElement; unmount: () => void } {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
act(() => { root.render(node) })
const unmount = (): void => {
act(() => { root.unmount() })
container.remove()
}
return { container, unmount }
}
const outputCalls: Array<{ sessionId: string; id: string }> = []
const killCalls: Array<{ sessionId: string; id: string }> = []
@@ -129,7 +116,7 @@ afterEach(() => {
describe('SubagentView background jobs', () => {
it('renders the tree jobs with status, durations, and owner labels', () => {
const store = makeStore(baseSnapshot())
const { container, unmount } = mount(
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const text = container.textContent ?? ''
@@ -147,7 +134,7 @@ describe('SubagentView background jobs', () => {
it('renders nothing job-related when the mirror is empty', () => {
const store = makeStore({ current: 'root', byId: { root: { id: 'root', displayTitle: '主会话' } }, subagentsByParent: {}, jobsBySession: {} })
const { container, unmount } = mount(
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
expect(container.textContent).not.toContain('后台任务')
@@ -156,7 +143,7 @@ describe('SubagentView background jobs', () => {
it('survives the mirror emptying while mounted (hook-order regression #300)', async () => {
const store = makeStore(baseSnapshot())
const { container, unmount } = mount(
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
expect(container.textContent).toContain('sleep 300')
@@ -174,7 +161,7 @@ describe('SubagentView background jobs', () => {
it('shows the selected job output in the bottom dock, closeable', async () => {
const store = makeStore(baseSnapshot())
const { container, unmount } = mount(
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
@@ -195,7 +182,7 @@ describe('SubagentView background jobs', () => {
it('switches the single dock between selected rows', async () => {
const store = makeStore(baseSnapshot())
const { container, unmount } = mount(
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const first = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
@@ -223,7 +210,7 @@ describe('SubagentView background jobs', () => {
],
}
const store = makeStore(snapshot)
const { container, unmount } = mount(
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const row = container.querySelector('button[aria-label*="unread cmd"]') as HTMLButtonElement
@@ -249,7 +236,7 @@ describe('SubagentView background jobs', () => {
subagentsByParent: {},
jobsBySession: { root: many },
})
const { container, unmount } = mount(
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
expect(container.textContent).toContain('60 个后台任务 · 30 运行中')
@@ -264,7 +251,7 @@ describe('SubagentView background jobs', () => {
it('kills a live job only after the two-click confirm', async () => {
const store = makeStore(baseSnapshot())
const { container, unmount } = mount(
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const kill = container.querySelector('button[aria-label="终止"]') as HTMLButtonElement
@@ -282,7 +269,7 @@ describe('SubagentView background jobs', () => {
vi.useFakeTimers()
try {
const store = makeStore(baseSnapshot())
const { container, unmount } = mount(
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: false, ctx: makeCtx(store) }),
)
const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
+6 -24
View File
@@ -6,9 +6,9 @@
*/
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createElement, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { createElement } from 'react'
import { act } from 'react-dom/test-utils'
import { renderRoot } from './test-utils.ts'
import { SubagentView } from '../src/client/SubagentView.tsx'
import type { Context, SidebarSessionList } from '../src/context-types.ts'
@@ -51,24 +51,6 @@ function makeCtx(store: Store, historySpy: ReturnType<typeof vi.fn>): Context {
} as unknown as Context
}
/** Render `node` into a detached body container under React's act(). */
function mount(node: ReactNode): {
container: HTMLDivElement
rerender: (next: ReactNode) => void
unmount: () => void
} {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
const rerender = (next: ReactNode): void => { act(() => { root.render(next) }) }
act(() => { root.render(node) })
const unmount = (): void => {
act(() => { root.unmount() })
container.remove()
}
return { container, rerender, unmount }
}
function jsonResponse(value: unknown): Response {
return { ok: true, status: 200, json: async () => value } as unknown as Response
}
@@ -156,7 +138,7 @@ describe('SubagentView live polling', () => {
})
const store = makeStore(runningSnapshot())
const { container, unmount } = mount(
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store, historySpy) }),
)
// Initial load plus the first scheduled tick.
@@ -187,7 +169,7 @@ describe('SubagentView live polling', () => {
})
const store = makeStore(runningSnapshot())
const { unmount } = mount(
const { unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store, historySpy) }),
)
await act(async () => { await Promise.resolve() })
@@ -225,7 +207,7 @@ describe('SubagentView live polling', () => {
})
const store = makeStore(runningSnapshot())
const { rerender, unmount } = mount(
const { rerender, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store, historySpy) }),
)
await act(async () => { await Promise.resolve() })
@@ -264,7 +246,7 @@ describe('SubagentView live polling', () => {
})
const store = makeStore(runningSnapshot())
const { container, unmount } = mount(
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store, historySpy) }),
)
await act(async () => { await Promise.resolve() })
+2 -1
View File
@@ -16,7 +16,8 @@ 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 { setupReactAct } from './test-utils.ts'
setupReactAct()
import { TabBar } from '../src/client/TabBar.tsx'
import type { SidebarTab } from '../src/client/state.ts'
+2 -1
View File
@@ -17,7 +17,8 @@ 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 { setupReactAct } from './test-utils.ts'
setupReactAct()
import { TabBar } from '../src/client/TabBar.tsx'
import type { SidebarTab } from '../src/client/state.ts'
+2 -1
View File
@@ -14,7 +14,8 @@ 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 { setupReactAct } from './test-utils.ts'
setupReactAct()
import { TabBar } from '../src/client/TabBar.tsx'
import type { SidebarTab } from '../src/client/state.ts'
+2 -1
View File
@@ -19,7 +19,8 @@ import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
import { TerminalDepsBanner } from '../src/client/TerminalView.tsx'
// The act() environment flag (React 18.2 reads it before flushing effects).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
import { setupReactAct } from './test-utils.ts'
setupReactAct()
const deps = {
ok: false as const,