- 7 个 spec 的本地 mount()(createRoot + act 容器循环)替换为 renderRoot():sidechat-view、subagent-jobs-view、subagent-live-polling、 lazy-chunk、side-card-section-rows(本地 mount 删除,调用点改名); editor-refresh、markdown-manual-refresh(EditorHost 包装 mount 改为 委托 renderRoot,调用点不动) - 24 个 spec 的内联 IS_REACT_ACT_ENVIRONMENT 赋值替换为 setupReactAct() (subagent-jobs-view / subagent-live-polling / lazy-chunk 原本未设 flag,保持原样不引入) 全部为语义相同的样板替换:迁移后全量 118 files / 1241 passed + 9 skipped,与迁移前一致。
180 lines
8.4 KiB
TypeScript
180 lines
8.4 KiB
TypeScript
/**
|
|
* Lazy wrapper tests (src/client/lazy-chunk.tsx): the wrapper that mounts
|
|
* chunk-resident components from the built-in descriptors. Pins the two
|
|
* contracts that matter for the descriptor API:
|
|
* - the wrapper is a plain render-prop function — `component(props)` can be
|
|
* called directly (Sidebar's style) without a chunk registered and
|
|
* without throwing; hooks live in the inner component only,
|
|
* - loading → placeholder, failure → error + retry that recovers, success →
|
|
* the chunk component rendered.
|
|
*/
|
|
// @vitest-environment jsdom
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import { createElement, type ComponentType, type ReactNode } from 'react'
|
|
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'
|
|
import { lazyChunkComponent } from '../src/client/lazy-chunk.tsx'
|
|
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'
|
|
|
|
const Marker = (): ReactNode => createElement('div', { 'data-testid': 'chunk-rendered' }, 'loaded')
|
|
|
|
beforeEach(() => {
|
|
resetChunks()
|
|
})
|
|
|
|
afterEach(() => {
|
|
// Defensive: drop any containers left by failed assertions.
|
|
for (const el of document.querySelectorAll('body > div')) el.remove()
|
|
})
|
|
|
|
describe('lazyChunkComponent', () => {
|
|
it('renders the loading placeholder first, then the chunk component', async () => {
|
|
let calls = 0
|
|
registerChunkForTests('editor', async () => {
|
|
calls += 1
|
|
return { TextEditor: Marker }
|
|
})
|
|
const Wrapper = lazyChunkComponent<{ label: string }>('editor', (mod) => mod.TextEditor as ComponentType<{ label: string }> | undefined)
|
|
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 () => {})
|
|
expect(container.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
|
|
expect(container.textContent).toContain('loaded')
|
|
expect(calls).toBe(1)
|
|
unmount()
|
|
})
|
|
|
|
it('shows the failure reason with a retry that recovers', async () => {
|
|
let fail = true
|
|
registerChunkForTests('editor', async () => {
|
|
if (fail) throw new Error('boom')
|
|
return { TextEditor: Marker }
|
|
})
|
|
const Wrapper = lazyChunkComponent<Record<string, never>>('editor', (mod) => mod.TextEditor as ComponentType<Record<string, never>> | undefined)
|
|
const { container, unmount } = renderRoot(createElement(Wrapper, {}))
|
|
await act(async () => {})
|
|
expect(container.textContent).toContain('boom')
|
|
// The failed load cleared the loader cache; retry now succeeds.
|
|
fail = false
|
|
const button = container.querySelector('button')
|
|
expect(button).not.toBeNull()
|
|
await act(async () => { button!.click() })
|
|
await act(async () => {})
|
|
expect(container.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
|
|
unmount()
|
|
})
|
|
|
|
it('props flow through to the chunk component', async () => {
|
|
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 } = renderRoot(createElement(Wrapper, { label: 'hello' }))
|
|
await act(async () => {})
|
|
expect(container.querySelector('[data-testid="rec"]')?.getAttribute('data-label')).toBe('hello')
|
|
unmount()
|
|
})
|
|
})
|
|
|
|
describe('built-in descriptor contract (render-prop functions)', () => {
|
|
it('every heavy built-in viewer component is callable as a plain function without a chunk (returns an element)', () => {
|
|
const viewers = builtinViewers()
|
|
for (const id of ['markdown', 'html', 'code']) {
|
|
const descriptor = viewers.find(viewer => viewer.id === id)
|
|
expect(descriptor, id).toBeDefined()
|
|
// No chunk registered: calling must not throw — it returns the lazy
|
|
// element (the loading placeholder renders once mounted).
|
|
expect(() => descriptor!.component({} as FileViewerProps), id).not.toThrow()
|
|
}
|
|
})
|
|
|
|
it('the terminal tab component keeps the same contract', () => {
|
|
const tabs = builtinTabs({} as Context)
|
|
const terminal = tabs.find(tab => tab.id === 'terminal')
|
|
expect(terminal).toBeDefined()
|
|
// The descriptor reads tab.id (the tabId mapping); a real tab is part of
|
|
// the contract — Sidebar always provides one.
|
|
const props = { tab: { id: 'terminal:1', type: 'terminal', title: '终端 1' } } as unknown as TabComponentProps
|
|
expect(() => terminal!.component(props)).not.toThrow()
|
|
})
|
|
|
|
it('a built-in viewer mounted without a chunk available degrades to the error + retry affordance (no crash)', async () => {
|
|
// No test chunk registered and jsdom has no client module system: the
|
|
// load fails and the wrapper must degrade gracefully, never throw.
|
|
const viewers = builtinViewers()
|
|
const code = viewers.find(viewer => viewer.id === 'code')!
|
|
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()
|
|
unmount()
|
|
})
|
|
|
|
it('a registered chunk makes the built-in viewer render through the real descriptor path', async () => {
|
|
registerChunkForTests('editor', async () => ({ TextEditor: Marker }))
|
|
const viewers = builtinViewers()
|
|
const markdown = viewers.find(viewer => viewer.id === 'markdown')!
|
|
// EditorHost renders viewer components via createElement(component, props).
|
|
const { container, unmount } = renderRoot(createElement(markdown.component, {
|
|
ctx: {},
|
|
store: undefined,
|
|
scope: { sessionId: 's1', cwd: '/p' },
|
|
path: '/p/a.md',
|
|
title: 'a.md',
|
|
viewerId: 'markdown',
|
|
} as unknown as FileViewerProps))
|
|
await act(async () => {})
|
|
expect(container.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
|
|
unmount()
|
|
})
|
|
|
|
it('the terminal descriptor maps tab.id → tabId (TerminalView props are not TabComponentProps)', async () => {
|
|
let received: unknown
|
|
registerChunkForTests('terminal', async () => ({
|
|
TerminalView: ((props: { tabId: string }) => {
|
|
received = props.tabId
|
|
return createElement('div', { 'data-testid': 'terminal-tabid' }, props.tabId)
|
|
}) as unknown as ComponentType<Record<string, never>>,
|
|
}))
|
|
const tabs = builtinTabs({} as Context)
|
|
const terminal = tabs.find(tab => tab.id === 'terminal')!
|
|
const props = {
|
|
ctx: {},
|
|
store: undefined,
|
|
scope: { sessionId: 's1', cwd: '/p' },
|
|
tab: { id: 'terminal:2', type: 'terminal', title: '终端 2' },
|
|
visible: true,
|
|
} as unknown as TabComponentProps
|
|
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')
|
|
unmount()
|
|
})
|
|
|
|
it('the loader cache survives across descriptor mounts (chunk fetched once)', async () => {
|
|
let calls = 0
|
|
registerChunkForTests('terminal', async () => {
|
|
calls += 1
|
|
return { TerminalView: Marker }
|
|
})
|
|
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 } = renderRoot(createElement(terminal.component, props))
|
|
await act(async () => {})
|
|
expect(first.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
|
|
unmountFirst()
|
|
const { container: second, unmount: unmountSecond } = renderRoot(createElement(terminal.component, props))
|
|
await act(async () => {})
|
|
expect(second.querySelector('[data-testid="chunk-rendered"]')).not.toBeNull()
|
|
unmountSecond()
|
|
expect(calls).toBe(1)
|
|
})
|
|
})
|