Files
apes-sidebar/tests/markdown-manual-refresh.spec.tsx
T
Menghuan1918 5a1e75e46b fix(lint): 清零存量 lint 告警并接入 CI / Makefile 门禁
存量清理(首跑 141 errors / 21 warnings → 0 / 0):
- 删除孤儿 disable:SideChatView / Sidebar / selection-popup 三处 exhaustive-deps 豁免与 chunk-artifact 的 no-new-func 豁免(多行理由注释把 disable-next-line 顶离了实报行;selection-popup 改为提取 popupOpen 标志真修复)
- 死代码:未用导入(Sidebar×7、TextEditor、SideCardSection、config、fs-operations 及 6 个 spec)、sidechat-core 死累加器 total、chunk-loader.spec 死助手 registry、sidechat-seed-validation 死助手 toolResultMessage
- 真实小修:agent-opens 三处 throw 补 cause;lazy-chunk any → ComponentType<P>(P extends object);service.ts claimed 初值改定值赋值;chunk-loader task / spec 内 etag / pendingLoad / snapshot 改 const;terminal-links 去掉字符类内多余转义;SubagentView catalogs 空兜底 useMemo 稳定标识;editor-host.spec 组件提出大写 FakeViewer(rules-of-hooks);pty-helpers.spec 内联 require 提升为静态导入;panel-host-css 注释里的零宽空格改为可见写法
- 依赖数组豁免(均带理由):granular scope 字段(EditorHost / PdfView / ChangesTab / GitLens×2)、job.status 粒度(SubagentView×2)、清理期读 ref、mount-only 注册等;ctx(挂载期恒定)直接入 deps(Sidebar×3)、TextEditor previewScroll 补 scope(幂等)
- 刻意控制字符正则(conversation-draft / markdown-images)与宿主槽位 any 签名(context-types)以带理由豁免保留

接入:
- Makefile:lint 目标(typecheck 旁),check 聚合补 lint 保持「对齐 CI」语义
- ci.yml ci / ci-windows 两 job 与 release.yml 在 Typecheck 后加 Lint 步骤(release 注释同步顺序)
2026-09-03 20:00:08 +08:00

166 lines
5.7 KiB
TypeScript

/**
* Markdown refresh behavior: external edits are picked up only when the user
* explicitly clicks the refresh button, and unsaved drafts stay protected.
*/
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createElement, useEffect } from 'react'
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'
setupReactAct()
function toolbar(dirty: boolean) {
return {
modes: true,
mode: 'preview' as const,
dirty,
editable: true,
saveState: 'idle' as const,
}
}
let markDirty: (() => void) | undefined
/** A tiny viewer that exposes the host toolbar and renders the loaded source. */
function FakeMarkdownViewer(props: FileViewerProps) {
useEffect(() => {
props.onToolbarControls?.({ setMode: () => {}, save: () => {} })
props.onToolbarState?.(toolbar(false))
markDirty = () => { props.onToolbarState?.(toolbar(true)) }
return () => {
markDirty = undefined
props.onToolbarControls?.(null)
}
// Only the callback identities must re-wire; content is read at render time.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.onToolbarControls, props.onToolbarState])
return createElement('div', { 'data-testid': 'markdown-content' }, props.content ?? '')
}
function setup(): {
store: ReturnType<typeof createSidebarStore>
ctx: Context
tab: SidebarTab
} {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
service.registerFileViewer({
id: 'markdown',
exts: ['md'],
fetchStrategy: 'fsRead',
component: FakeMarkdownViewer,
})
store.setSession('markdown-manual-refresh-session')
const home = allLeaves(store.getSnapshot().state!.splits)
.flatMap(leaf => leaf.tabs)
.find(candidate => candidate.type === 'editor')!
const tab: SidebarTab = { ...home, path: '/tmp/notes.md', title: 'notes.md' }
const ctx = {
betterSidebar: service,
get: (name: string) => name === 'betterSidebar' ? service : undefined,
} as unknown as Context
return { store, ctx, tab }
}
function mount(ctx: Context, store: ReturnType<typeof createSidebarStore>, tab: SidebarTab) {
return renderRoot(createElement(EditorHost, {
ctx,
store,
scope: { sessionId: 'markdown-manual-refresh-session', cwd: '/tmp' },
tab,
expanded: [],
revealed: [],
onToggleDir: () => {},
onReferenceFile: () => {},
}))
}
async function flush(): Promise<void> {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
afterEach(() => {
markDirty = undefined
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('Markdown manual refresh', () => {
it('does not poll after an external edit and reloads only after clicking refresh', async () => {
vi.useFakeTimers()
let disk = 'before'
const read = vi.spyOn(api, 'fsRead').mockImplementation(async () => ({
kind: 'text', content: disk, truncated: false,
}))
const { store, ctx, tab } = setup()
const view = mount(ctx, store, tab)
try {
await flush()
expect(view.container.querySelector('[data-testid="markdown-content"]')?.textContent).toBe('before')
const initialReads = read.mock.calls.length
disk = 'after'
await act(async () => {
vi.advanceTimersByTime(3000)
await Promise.resolve()
await Promise.resolve()
})
expect(read.mock.calls.length).toBe(initialReads)
expect(view.container.querySelector('[data-testid="markdown-content"]')?.textContent).toBe('before')
const refresh = view.container.querySelector<HTMLButtonElement>('button[aria-label="刷新"], button[aria-label="Refresh"]')
expect(refresh).not.toBeNull()
act(() => { refresh!.click() })
await flush()
expect(read.mock.calls.length).toBeGreaterThan(initialReads)
expect(view.container.querySelector('[data-testid="markdown-content"]')?.textContent).toBe('after')
} finally {
view.unmount()
}
})
it('protects dirty content until the user confirms manual refresh', async () => {
let disk = 'before'
const read = vi.spyOn(api, 'fsRead').mockImplementation(async () => ({
kind: 'text', content: disk, truncated: false,
}))
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false)
const { store, ctx, tab } = setup()
const view = mount(ctx, store, tab)
try {
await flush()
expect(markDirty).toBeDefined()
act(() => { markDirty?.() })
await flush()
const before = read.mock.calls.length
disk = 'external'
const refresh = view.container.querySelector<HTMLButtonElement>('button[aria-label="刷新"], button[aria-label="Refresh"]')
expect(refresh).not.toBeNull()
act(() => { refresh!.click() })
await flush()
expect(confirm).toHaveBeenCalled()
expect(read.mock.calls.length).toBe(before)
expect(view.container.querySelector('[data-testid="markdown-content"]')?.textContent).toBe('before')
confirm.mockReturnValue(true)
act(() => { refresh!.click() })
await flush()
expect(read.mock.calls.length).toBeGreaterThan(before)
expect(view.container.querySelector('[data-testid="markdown-content"]')?.textContent).toBe('external')
} finally {
view.unmount()
}
})
})