diff --git a/tests/add-plugin-modal.spec.tsx b/tests/add-plugin-modal.spec.tsx index 6aedab3..d508dd0 100644 --- a/tests/add-plugin-modal.spec.tsx +++ b/tests/add-plugin-modal.spec.tsx @@ -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).IS_REACT_ACT_ENVIRONMENT = true +import { setupReactAct } from './test-utils.ts' +setupReactAct() const officeEntry = builtinViewerPlugins[0] as PluginEntry diff --git a/tests/bottom-auto-terminal.spec.tsx b/tests/bottom-auto-terminal.spec.tsx index 507eea3..8602197 100644 --- a/tests/bottom-auto-terminal.spec.tsx +++ b/tests/bottom-auto-terminal.spec.tsx @@ -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).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' diff --git a/tests/changes-tab.spec.tsx b/tests/changes-tab.spec.tsx index 8187ce7..3093679 100644 --- a/tests/changes-tab.spec.tsx +++ b/tests/changes-tab.spec.tsx @@ -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).IS_REACT_ACT_ENVIRONMENT = true +import { setupReactAct } from './test-utils.ts' +setupReactAct() const MAIN = 'C:/repo/main' diff --git a/tests/chunk-artifact.spec.ts b/tests/chunk-artifact.spec.ts index 687d671..ec39364 100644 --- a/tests/chunk-artifact.spec.ts +++ b/tests/chunk-artifact.spec.ts @@ -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 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 diff --git a/tests/diff-files-collapse.spec.tsx b/tests/diff-files-collapse.spec.tsx index ce0627e..408c670 100644 --- a/tests/diff-files-collapse.spec.tsx +++ b/tests/diff-files-collapse.spec.tsx @@ -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).IS_REACT_ACT_ENVIRONMENT = true +import { setupReactAct } from './test-utils.ts' +setupReactAct() const diff = [ 'diff --git a/src/a.ts b/src/a.ts', diff --git a/tests/editor-host.spec.tsx b/tests/editor-host.spec.tsx index 88a97e0..5f99828 100644 --- a/tests/editor-host.spec.tsx +++ b/tests/editor-host.spec.tsx @@ -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).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). */ diff --git a/tests/editor-refresh.spec.tsx b/tests/editor-refresh.spec.tsx index 23ace84..6fcedb7 100644 --- a/tests/editor-refresh.spec.tsx +++ b/tests/editor-refresh.spec.tsx @@ -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).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. */ diff --git a/tests/file-tree-drop.spec.tsx b/tests/file-tree-drop.spec.tsx index cfb414a..3a549b1 100644 --- a/tests/file-tree-drop.spec.tsx +++ b/tests/file-tree-drop.spec.tsx @@ -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).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. diff --git a/tests/file-tree-open-with.spec.tsx b/tests/file-tree-open-with.spec.tsx index 50f8be7..013eadb 100644 --- a/tests/file-tree-open-with.spec.tsx +++ b/tests/file-tree-open-with.spec.tsx @@ -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).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(() => { diff --git a/tests/file-tree-reveal-scroll.spec.tsx b/tests/file-tree-reveal-scroll.spec.tsx index 8ec025f..0d21cab 100644 --- a/tests/file-tree-reveal-scroll.spec.tsx +++ b/tests/file-tree-reveal-scroll.spec.tsx @@ -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).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() diff --git a/tests/free-window.spec.tsx b/tests/free-window.spec.tsx index 963f720..4f27842 100644 --- a/tests/free-window.spec.tsx +++ b/tests/free-window.spec.tsx @@ -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).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' diff --git a/tests/git-view-worktree.spec.tsx b/tests/git-view-worktree.spec.tsx index b399f42..e3a1b8a 100644 --- a/tests/git-view-worktree.spec.tsx +++ b/tests/git-view-worktree.spec.tsx @@ -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).IS_REACT_ACT_ENVIRONMENT = true +import { setupReactAct } from './test-utils.ts' +setupReactAct() const MAIN = 'C:/repo/main' const AGENT = 'C:/repo/agent' diff --git a/tests/lazy-chunk.spec.tsx b/tests/lazy-chunk.spec.tsx index 3f23acf..f54523c 100644 --- a/tests/lazy-chunk.spec.tsx +++ b/tests/lazy-chunk.spec.tsx @@ -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>('editor', (mod) => mod.TextEditor as ComponentType> | 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() diff --git a/tests/manifest-consistency.spec.ts b/tests/manifest-consistency.spec.ts index 00539e9..04ba007 100644 --- a/tests/manifest-consistency.spec.ts +++ b/tests/manifest-consistency.spec.ts @@ -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) }) diff --git a/tests/markdown-html-render.spec.tsx b/tests/markdown-html-render.spec.tsx index 5e5d22e..8e0ca9d 100644 --- a/tests/markdown-html-render.spec.tsx +++ b/tests/markdown-html-render.spec.tsx @@ -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).IS_REACT_ACT_ENVIRONMENT = true +import { setupReactAct } from './test-utils.ts' +setupReactAct() const media: MarkdownHtmlMedia = { scope: { sessionId: 's1', cwd: '/ws' }, diff --git a/tests/markdown-manual-refresh.spec.tsx b/tests/markdown-manual-refresh.spec.tsx index d95acfd..1f2df30 100644 --- a/tests/markdown-manual-refresh.spec.tsx +++ b/tests/markdown-manual-refresh.spec.tsx @@ -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).IS_REACT_ACT_ENVIRONMENT = true +setupReactAct() function toolbar(dirty: boolean) { return { @@ -68,28 +68,16 @@ function setup(): { } function mount(ctx: Context, store: ReturnType, 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 { diff --git a/tests/md-toc.spec.tsx b/tests/md-toc.spec.tsx index e0a0bb8..bd57384 100644 --- a/tests/md-toc.spec.tsx +++ b/tests/md-toc.spec.tsx @@ -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).IS_REACT_ACT_ENVIRONMENT = true +import { setupReactAct } from './test-utils.ts' +setupReactAct() const scrollIntoView = vi.fn() Element.prototype.scrollIntoView = scrollIntoView diff --git a/tests/mermaid-markdown.spec.tsx b/tests/mermaid-markdown.spec.tsx index f5e3f37..3286dd8 100644 --- a/tests/mermaid-markdown.spec.tsx +++ b/tests/mermaid-markdown.spec.tsx @@ -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).IS_REACT_ACT_ENVIRONMENT = true +import { setupReactAct } from './test-utils.ts' +setupReactAct() vi.mock('mermaid', () => ({ default: { diff --git a/tests/open-with-settings.spec.tsx b/tests/open-with-settings.spec.tsx index 06eabb3..7faf7c1 100644 --- a/tests/open-with-settings.spec.tsx +++ b/tests/open-with-settings.spec.tsx @@ -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).IS_REACT_ACT_ENVIRONMENT = true +import { setupReactAct } from './test-utils.ts' +setupReactAct() beforeAll(() => { Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true }) diff --git a/tests/selection-popup.spec.tsx b/tests/selection-popup.spec.tsx index c366c2e..0915a2c 100644 --- a/tests/selection-popup.spec.tsx +++ b/tests/selection-popup.spec.tsx @@ -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).IS_REACT_ACT_ENVIRONMENT = true +import { setupReactAct } from './test-utils.ts' +setupReactAct() /** jsdom ships no IntersectionObserver; a controllable stand-in. */ class FakeIntersectionObserver implements IntersectionObserver { diff --git a/tests/side-card-section-rows.spec.tsx b/tests/side-card-section-rows.spec.tsx index a873266..271f33f 100644 --- a/tests/side-card-section-rows.spec.tsx +++ b/tests/side-card-section-rows.spec.tsx @@ -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).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: () => {}, diff --git a/tests/sidebar-auto-activation.spec.tsx b/tests/sidebar-auto-activation.spec.tsx index 13efa58..548e4f6 100644 --- a/tests/sidebar-auto-activation.spec.tsx +++ b/tests/sidebar-auto-activation.spec.tsx @@ -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).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' diff --git a/tests/sidebar-crash.spec.tsx b/tests/sidebar-crash.spec.tsx index 9bab02c..bf2bb7b 100644 --- a/tests/sidebar-crash.spec.tsx +++ b/tests/sidebar-crash.spec.tsx @@ -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).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' diff --git a/tests/sidechat-view.spec.tsx b/tests/sidechat-view.spec.tsx index 0915199..2d52500 100644 --- a/tests/sidechat-view.spec.tsx +++ b/tests/sidechat-view.spec.tsx @@ -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).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 { 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() }) diff --git a/tests/subagent-jobs-view.spec.tsx b/tests/subagent-jobs-view.spec.tsx index 9c5e37b..1af5b00 100644 --- a/tests/subagent-jobs-view.spec.tsx +++ b/tests/subagent-jobs-view.spec.tsx @@ -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 diff --git a/tests/subagent-live-polling.spec.tsx b/tests/subagent-live-polling.spec.tsx index 4bfc4b0..685c841 100644 --- a/tests/subagent-live-polling.spec.tsx +++ b/tests/subagent-live-polling.spec.tsx @@ -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): 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() }) diff --git a/tests/tab-bar-context-menu.spec.tsx b/tests/tab-bar-context-menu.spec.tsx index 1397fd8..3133ce3 100644 --- a/tests/tab-bar-context-menu.spec.tsx +++ b/tests/tab-bar-context-menu.spec.tsx @@ -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).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' diff --git a/tests/tab-bar-middle-click.spec.tsx b/tests/tab-bar-middle-click.spec.tsx index 74c95b4..ad8ed15 100644 --- a/tests/tab-bar-middle-click.spec.tsx +++ b/tests/tab-bar-middle-click.spec.tsx @@ -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).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' diff --git a/tests/tab-bar-wheel.spec.tsx b/tests/tab-bar-wheel.spec.tsx index 78e69f9..99b4cf9 100644 --- a/tests/tab-bar-wheel.spec.tsx +++ b/tests/tab-bar-wheel.spec.tsx @@ -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).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' diff --git a/tests/terminal-deps-banner.spec.tsx b/tests/terminal-deps-banner.spec.tsx index 2ab76ad..8cc55ea 100644 --- a/tests/terminal-deps-banner.spec.tsx +++ b/tests/terminal-deps-banner.spec.tsx @@ -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).IS_REACT_ACT_ENVIRONMENT = true +import { setupReactAct } from './test-utils.ts' +setupReactAct() const deps = { ok: false as const, diff --git a/tests/test-utils.ts b/tests/test-utils.ts new file mode 100644 index 0000000..cb38baf --- /dev/null +++ b/tests/test-utils.ts @@ -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).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() + }, + } +}