Merge pull request #524 from omdsh-dev/feat/test-utils
feat(tests): 收敛组件测试样板到共享 test-utils
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,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)
|
||||
})
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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,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() })
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user