Merge pull request #524 from omdsh-dev/feat/test-utils

feat(tests): 收敛组件测试样板到共享 test-utils
This commit is contained in:
Atlas Zeng
2026-09-03 16:30:00 +08:00
committed by GitHub
31 changed files with 188 additions and 185 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'
+11 -2
View File
@@ -5,8 +5,10 @@
* be callable with a require that resolves the platform externals — the
* exact shape the loader (src/client/chunk-loader.ts) depends on. Reads the
* built lib/ output, so run `pnpm build` first (like manifest-consistency).
* A missing lib/ (fresh clone before the first build) skips the whole suite
* instead of crashing on ENOENT.
*/
import { readFileSync } from 'node:fs'
import { existsSync, readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
// Browser globals first: chunk bodies probe `self`/`document` at evaluation
// (xterm's UMD wrapper, CodeMirror's UA probe).
@@ -17,7 +19,14 @@ const g = globalThis as Record<string, unknown>
const CHUNKS = ['terminal', 'editor', 'mermaid']
describe('built chunk artifacts', () => {
/** All chunk artifacts present (tsdown emits the whole lib/ in one run). */
const chunksBuilt = CHUNKS.every(name => existsSync(`lib/client-${name}.js`))
if (!chunksBuilt) {
console.warn('[chunk-artifact] lib/ chunk artifacts missing — run `pnpm build` first; skipping this suite')
}
describe.skipIf(!chunksBuilt)('built chunk artifacts', () => {
it('each chunk assigns its global registry slot when executed as a script', () => {
g.window = g // classic-script globals
// mermaid's core hooks window.addEventListener('load') at module scope
+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()
+11 -2
View File
@@ -11,7 +11,9 @@
* bundles must never be swapped.
*
* Reads the built lib/ output, so run `pnpm build` first (the registry
* install validates main/client.main existence the same way).
* install validates main/client.main existence the same way). A missing
* lib/ (fresh clone before the first build) skips the whole suite instead
* of crashing on ENOENT.
*/
import { describe, expect, it } from 'vitest'
import { existsSync, readFileSync } from 'node:fs'
@@ -20,6 +22,13 @@ import { fileURLToPath } from 'node:url'
const ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)))
/** The build output is present (tsdown emits the whole lib/ in one run). */
const libBuilt = existsSync(resolve(ROOT, 'lib/client.js'))
if (!libBuilt) {
console.warn('[manifest-consistency] lib/ build output missing — run `pnpm build` first; skipping this suite')
}
/** The registry manifest face this spec touches (mirror of the registry's PluginManifest). */
interface PluginManifest {
id: string
@@ -72,7 +81,7 @@ function chunkSlot(file: string): string {
return match[1]!
}
describe('registry manifest consistency (dsh.plugin.json)', () => {
describe.skipIf(!libBuilt)('registry manifest consistency (dsh.plugin.json)', () => {
it('id is a valid two-segment registry id (native form)', () => {
expect(manifest.id).toMatch(ID_PATTERN)
})
+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,
+63
View File
@@ -0,0 +1,63 @@
/**
* Shared React helpers for the component specs (tests/*.spec.tsx): the two
* pieces of boilerplate every createRoot-based spec used to repeat inline —
* - the React 18 act() environment flag, without which act() warns on every
* flush (setupReactAct(), idempotent, called at module scope per spec —
* vitest isolates each spec file in its own global, so per-file setup is
* required, exactly like the inlined statement it replaces),
* - the detached-container render cycle (div → document.body.append →
* createRoot → act(render) → act(unmount) + container.remove), collected
* as renderRoot().
*
* Deliberately minimal: no querying DSL, no auto-cleanup registry, no new
* dependencies. Specs with bespoke harnesses (domain fixtures around the
* render, SSR renders, module-scoped container state) keep their own mounts.
*/
import type { ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
/**
* Declare this file's environment as a React act() environment (React 18.2
* reads the flag before flushing effects). Assigning `true` is idempotent,
* so every spec may call it unconditionally at module scope.
*/
export function setupReactAct(): void {
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
}
/** What renderRoot() hands back: the DOM container plus act()-wrapped controls. */
export interface RenderedRoot {
/** The div renderRoot() appended to document.body. */
container: HTMLDivElement
/** The react-dom root, for direct render/unmount control if ever needed. */
root: Root
/** Re-render a new element tree under act(). */
rerender: (next: ReactNode) => void
/** Unmount under act() and drop the container from document.body. */
unmount: () => void
}
/**
* Render `node` into a detached div appended to document.body and flush the
* initial commit under act() — the concurrent initial commit is flushed, so
* the rendered tree is already in the DOM when renderRoot() returns.
* rerender(next) re-renders under act(); unmount() unmounts under act() and
* removes the container (call it once — like the per-spec mounts this
* replaces, it is not guarded against double unmount).
*/
export function renderRoot(node: ReactNode): RenderedRoot {
const container = document.createElement('div')
document.body.append(container)
const root: Root = createRoot(container)
act(() => { root.render(node) })
return {
container,
root,
rerender: (next: ReactNode): void => { act(() => { root.render(next) }) },
unmount: (): void => {
act(() => { root.unmount() })
container.remove()
},
}
}