Files
apes-sidebar/tests/add-plugin-modal.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

196 lines
7.9 KiB
TypeScript

/**
* "Add plugin" modal tests: the body component (PluginListBody) renders the
* GitHub topic BUTTON + the recommended plugin catalog of the matching kind
* with per-entry jump/copy buttons. The copy button COPIES the install
* script to the clipboard (writeClipboard) with a transient "Copied"
* feedback — nothing is opened, nothing is closed, nothing can fail
* outward.
*
* The body is tested directly — the Modal primitive runs hooks
* unconditionally, so an open Modal must never be renderToString'd (same
* rule as the settingsFor popup). The click paths are exercised with
* createRoot + act().
*/
// @vitest-environment jsdom
import { describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { renderToString } from 'react-dom/server'
import { createRoot } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
import { type PluginEntry } from '../src/client/plugins-shared.ts'
import { builtinViewerPlugins } from '../src/client/plugins-viewers.ts'
import { AddPluginModal, PluginListBody } from '../src/client/add-plugin-modal.tsx'
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).
import { setupReactAct } from './test-utils.ts'
setupReactAct()
const officeEntry = builtinViewerPlugins[0] as PluginEntry
describe('PluginListBody (render)', () => {
it('viewer kind renders the topic button and the office catalog entry', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const html = renderToString(createElement(PluginListBody, { service, kind: 'viewer' }))
// The topic is a BUTTON (window.open in a new tab), not an anchor.
expect(html).toContain('Browse more plugins on GitHub')
// The name is a BUTTON on the same window.open path (an anchor would be
// caught by the sidebar link takeover) — no href anywhere in the body.
expect(html).not.toContain('href=')
// The seeded catalog entry: name, description, install script,
// a jump button (opens the repo in a NEW browser tab) and a copy button.
expect(html).toContain(officeEntry.name)
expect(html).toContain(officeEntry.install.replaceAll('&', '&amp;'))
expect(html).toContain('Open')
expect(html).toContain('Copy')
})
it('tab kind renders the sentinel entry (its own catalog, no office entry)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const html = renderToString(createElement(PluginListBody, { service, kind: 'tab' }))
expect(html).toContain('dsh-sentinel 唤醒系统')
expect(html).toContain('github:fuhefei/dsh-sentinel')
expect(html).not.toContain(officeEntry.name)
})
})
describe('AddPluginModal wiring', () => {
it('mounts the body through the Modal for each kind (client render)', () => {
// The Modal primitive renders a portal into document.body — the server
// renderer refuses portals, so mount it client-side (createRoot + act
// in jsdom) and assert on the portaled body HTML.
const store = createSidebarStore()
const service = createBetterSidebarService(store)
for (const kind of ['tab', 'viewer'] as const) {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
act(() => {
root.render(createElement(AddPluginModal, { service, onClose: () => {}, kind }))
})
const html = document.body.innerHTML
if (kind === 'viewer') {
expect(html).toContain(officeEntry.name)
} else {
expect(html).toContain('dsh-sentinel 唤醒系统')
}
act(() => { root.unmount() })
container.remove()
document.body.innerHTML = ''
}
})
})
/** Mount PluginListBody and click its copy button (the first one). */
function mountBody(service: ReturnType<typeof createBetterSidebarService>, kind: 'tab' | 'viewer' = 'viewer'):
{ clickCopy: () => void; buttonLabel: () => string | null; unmount: () => void } {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
act(() => {
root.render(createElement(PluginListBody, { service, kind }))
})
return {
clickCopy: async () => {
const button = container.querySelector('button[aria-label^="Copy install command:"]')!
await act(async () => { (button as HTMLButtonElement).click() })
},
buttonLabel: () => {
const button = container.querySelector('button[aria-label^="Copy install command:"]')
return button?.textContent ?? null
},
unmount: () => {
act(() => { root.unmount() })
container.remove()
},
}
}
describe('PluginListBody copy click (interactive)', () => {
it('clicking Copy writes the install script to the clipboard and flashes "Copied"', async () => {
vi.spyOn(primitives, 'writeClipboard').mockResolvedValue(true)
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const body = mountBody(service)
expect(body.buttonLabel()).toBe('Copy')
await body.clickCopy()
expect(primitives.writeClipboard).toHaveBeenCalledWith(officeEntry.install)
expect(body.buttonLabel()).toBe('Copied')
body.unmount()
vi.restoreAllMocks()
})
it('copying does NOT close anything and the modal body stays mounted', async () => {
vi.spyOn(primitives, 'writeClipboard').mockResolvedValue(true)
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const body = mountBody(service)
await body.clickCopy()
// The entry is still rendered (nothing closed, no navigation, no open).
expect(body.buttonLabel()).toBe('Copied')
expect(service.getSnapshot().state).toBeUndefined()
body.unmount()
vi.restoreAllMocks()
})
it('a DENIED clipboard write shows no "Copied" feedback (never claims a copy that did not happen)', async () => {
vi.spyOn(primitives, 'writeClipboard').mockResolvedValue(false)
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const body = mountBody(service)
await body.clickCopy()
expect(primitives.writeClipboard).toHaveBeenCalledWith(officeEntry.install)
expect(body.buttonLabel()).toBe('Copy')
body.unmount()
vi.restoreAllMocks()
})
it('the name and jump buttons open each catalog entry in a NEW browser tab (window.open, not a link)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
const opened: Array<[string, string, string]> = []
const original = window.open
window.open = ((url: string, target: string, features: string) => {
opened.push([url, target, features])
return null
}) as typeof window.open
try {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
act(() => {
root.render(createElement(PluginListBody, { service, kind: 'viewer' }))
})
// Two window.open buttons per catalog entry — the name button and the
// jump button. The expected count is catalog-driven so adding or
// removing recommended plugins keeps the test honest instead of
// pinning a hardcoded total.
const jumpButtons = [...container.querySelectorAll('button[aria-label^="Open:"]')]
expect(jumpButtons).toHaveLength(builtinViewerPlugins.length * 2)
for (const button of jumpButtons) {
act(() => { (button as HTMLButtonElement).click() })
}
expect(opened).toEqual(
builtinViewerPlugins.flatMap((entry) => [
[entry.url, '_blank', 'noopener'],
[entry.url, '_blank', 'noopener'],
]),
)
act(() => { root.unmount() })
container.remove()
} finally {
window.open = original
}
})
})