feat: release OpsXShell 2.1.0 native Tauri edition
This commit is contained in:
1 parent
e9a0d21758
commit
a3dba0c6ed
311 files changed
+31748
-14897
No files matched your search
@@ -0,0 +1,61 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AiProviderSettings from '../../src/components/settings/AiProviderSettings'
|
||||
import { useAiProviderStore } from '../../src/stores/aiProviderStore'
|
||||
import { mockDesktopApi } from '../setup'
|
||||
|
||||
describe('AI provider settings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useAiProviderStore.setState({
|
||||
providers: [],
|
||||
activeProviderId: '',
|
||||
isLoaded: true,
|
||||
catalogProviders: [],
|
||||
catalogUpdatedAt: null,
|
||||
})
|
||||
vi.mocked(mockDesktopApi.aiTestProvider).mockResolvedValue({
|
||||
success: true,
|
||||
status: 200,
|
||||
model: 'deepseek-chat',
|
||||
latencyMs: 92,
|
||||
answer: 'OK',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a new provider transient until a real connection test passes', async () => {
|
||||
render(<AiProviderSettings />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加' }))
|
||||
|
||||
expect(useAiProviderStore.getState().providers).toHaveLength(0)
|
||||
expect(mockDesktopApi.settingsSet).not.toHaveBeenCalled()
|
||||
const save = screen.getByRole('button', { name: '保存' })
|
||||
expect(save).toBeDisabled()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('API Key'), { target: { value: 'sk-test-only' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试连接' }))
|
||||
|
||||
await waitFor(() => expect(mockDesktopApi.aiTestProvider).toHaveBeenCalled())
|
||||
expect(mockDesktopApi.aiProviderSaveKey).not.toHaveBeenCalled()
|
||||
expect(await screen.findByText(/测试成功 · HTTP 200/)).toBeVisible()
|
||||
expect(save).toBeEnabled()
|
||||
|
||||
fireEvent.click(save)
|
||||
await waitFor(() => expect(useAiProviderStore.getState().providers).toHaveLength(1))
|
||||
expect(mockDesktopApi.aiProviderSaveKey).toHaveBeenCalledWith(expect.any(String), 'sk-test-only')
|
||||
expect(JSON.stringify(vi.mocked(mockDesktopApi.settingsSet).mock.calls)).not.toContain('sk-test-only')
|
||||
})
|
||||
|
||||
it('invalidates a successful test whenever a connection field changes', async () => {
|
||||
render(<AiProviderSettings />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '添加' }))
|
||||
fireEvent.change(screen.getByLabelText('API Key'), { target: { value: 'sk-test-only' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试连接' }))
|
||||
const save = screen.getByRole('button', { name: '保存' })
|
||||
await waitFor(() => expect(save).toBeEnabled())
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Endpoint'), { target: { value: 'https://proxy.example.com/v1' } })
|
||||
expect(save).toBeDisabled()
|
||||
expect(screen.getByText('连接字段已变更,请重新测试')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AiTerminalPanel from '../../src/components/ai/AiTerminalPanel'
|
||||
import { useAiProviderStore } from '../../src/stores/aiProviderStore'
|
||||
import { mockDesktopApi } from '../setup'
|
||||
|
||||
describe('terminal AI panel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
useAiProviderStore.setState({
|
||||
providers: [
|
||||
{
|
||||
id: 'local-test',
|
||||
name: 'Local Test',
|
||||
protocol: 'openai-chat',
|
||||
authStyle: 'none',
|
||||
baseUrl: 'http://127.0.0.1:11434/v1',
|
||||
allowInsecureHttp: true,
|
||||
models: [{ id: 'test', model: 'test', label: 'Test', capability: 'chat', billing: 'local' }],
|
||||
defaultModelId: 'test',
|
||||
enabled: true,
|
||||
hasApiKey: true,
|
||||
},
|
||||
],
|
||||
activeProviderId: 'local-test',
|
||||
isLoaded: true,
|
||||
catalogProviders: [],
|
||||
catalogUpdatedAt: null,
|
||||
})
|
||||
vi.mocked(mockDesktopApi.aiComplete).mockResolvedValue({
|
||||
success: true,
|
||||
text: '建议:\n```bash\ndate\n```',
|
||||
model: 'test',
|
||||
redactedCount: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers selection and fills one line without pressing enter', async () => {
|
||||
const insert = vi.fn()
|
||||
render(
|
||||
<AiTerminalPanel
|
||||
capture={() => ({ selection: 'selected error', viewport: 'whole screen' })}
|
||||
insert={insert}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('例如:分析这段报错,并给出最少的只读检查命令'), {
|
||||
target: { value: '怎么排查' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成建议' }))
|
||||
|
||||
await waitFor(() => expect(mockDesktopApi.aiComplete).toHaveBeenCalled())
|
||||
expect(vi.mocked(mockDesktopApi.aiComplete).mock.calls[0][0].context).toBe('selected error')
|
||||
fireEvent.click(await screen.findByRole('button', { name: '填入终端' }))
|
||||
expect(insert).toHaveBeenCalledWith('date')
|
||||
})
|
||||
|
||||
it('executes one confirmed command through the current SSH connection and analyzes real output', async () => {
|
||||
vi.mocked(mockDesktopApi.aiComplete)
|
||||
.mockResolvedValueOnce({ success: true, text: '```bash\nuptime\n```', model: 'test', redactedCount: 0 })
|
||||
.mockResolvedValueOnce({ success: true, text: '负载正常。', model: 'test', redactedCount: 0 })
|
||||
vi.mocked(mockDesktopApi.sshExec).mockResolvedValueOnce({ success: true, result: 'load average: 0.01' })
|
||||
|
||||
render(
|
||||
<AiTerminalPanel
|
||||
capture={() => ({ selection: '', viewport: '' })}
|
||||
insert={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
connectionId="ssh-active-1"
|
||||
targetLabel="root@opsbase.cn"
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '执行任务' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('例如:分析这段报错,并给出最少的只读检查命令'), {
|
||||
target: { value: '检查服务器负载' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '准备执行方案' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: '执行一次' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(within(dialog).getByText(/root@opsbase.cn/)).toBeVisible()
|
||||
expect(mockDesktopApi.sshExec).not.toHaveBeenCalled()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '执行一次' }))
|
||||
|
||||
await waitFor(() => expect(mockDesktopApi.sshExec).toHaveBeenCalledWith('ssh-active-1', 'uptime'))
|
||||
expect(await screen.findByText('load average: 0.01')).toBeVisible()
|
||||
expect(await screen.findByText('负载正常。')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import App from '../../src/app/App'
|
||||
import { useSessionStore } from '../../src/stores/sessionStore'
|
||||
import { mockDesktopApi } from '../setup'
|
||||
|
||||
describe('App foundation acceptance', () => {
|
||||
afterEach(() => {
|
||||
window.opsxApi = mockDesktopApi
|
||||
})
|
||||
|
||||
it('renders the application shell with the native desktop contract', async () => {
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'OpsXShell' })).toBeVisible()
|
||||
expect(mockDesktopApi.onMenuOpenSettings).toHaveBeenCalled()
|
||||
|
||||
const title = screen.getByText('OpsXShell', { selector: 'span' })
|
||||
expect(title.closest('header')).toHaveAttribute('data-tauri-drag-region')
|
||||
})
|
||||
|
||||
it('renders the shell while the native bridge is unavailable', async () => {
|
||||
Object.defineProperty(window, 'opsxApi', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
writable: true,
|
||||
})
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'OpsXShell' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('opens quick connect with a queued ssh:// target', async () => {
|
||||
vi.clearAllMocks()
|
||||
window.opsxApi = mockDesktopApi
|
||||
vi.mocked(mockDesktopApi.takePendingSshLinks).mockResolvedValueOnce([
|
||||
{ host: 'opsbase.cn', port: 55555, username: 'root' },
|
||||
])
|
||||
|
||||
render(<App />)
|
||||
|
||||
expect(await screen.findByDisplayValue('root@opsbase.cn:55555')).toBeVisible()
|
||||
expect(mockDesktopApi.onSshLinkAvailable).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('delegates title-bar double click to a single native drag-region handler', async () => {
|
||||
vi.clearAllMocks()
|
||||
window.opsxApi = mockDesktopApi
|
||||
render(<App />)
|
||||
|
||||
const title = await screen.findByText('OpsXShell', { selector: 'span' })
|
||||
const titleBar = title.closest('header')
|
||||
expect(titleBar).toHaveAttribute('data-tauri-drag-region')
|
||||
expect(titleBar).not.toHaveClass('titlebar-drag')
|
||||
|
||||
fireEvent.doubleClick(titleBar!)
|
||||
expect(mockDesktopApi.toggleMaximize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('writes exported sessions to the selected JSON file', async () => {
|
||||
vi.clearAllMocks()
|
||||
window.opsxApi = mockDesktopApi
|
||||
useSessionStore.setState({
|
||||
sessions: [
|
||||
{
|
||||
id: 'session-1',
|
||||
name: 'Production',
|
||||
host: 'prod.example.com',
|
||||
port: 22,
|
||||
username: 'root',
|
||||
authType: 'agent',
|
||||
},
|
||||
],
|
||||
})
|
||||
vi.mocked(mockDesktopApi.showSaveDialog).mockResolvedValueOnce({
|
||||
canceled: false,
|
||||
filePath: '/tmp/opsxshell-sessions.json',
|
||||
})
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => expect(mockDesktopApi.onMenuExportSessions).toHaveBeenCalled())
|
||||
const callback = vi.mocked(mockDesktopApi.onMenuExportSessions).mock.calls[0][0]
|
||||
await callback()
|
||||
|
||||
expect(mockDesktopApi.writeFile).toHaveBeenCalledWith(
|
||||
'/tmp/opsxshell-sessions.json',
|
||||
expect.stringContaining('"prod.example.com"')
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import BatchCommandDialog from '../../src/components/sessions/BatchCommandDialog'
|
||||
import type { Session } from '../../src/stores/sessionStore'
|
||||
import { mockDesktopApi } from '../setup'
|
||||
|
||||
const sessions: Session[] = [
|
||||
{ id: 'host-a', name: 'Host A', host: '10.0.0.1', port: 22, username: 'root', authType: 'key' },
|
||||
{ id: 'host-b', name: 'Host B', host: '10.0.0.2', port: 2222, username: 'ops', authType: 'password' },
|
||||
]
|
||||
|
||||
describe('BatchCommandDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
window.opsxApi = mockDesktopApi
|
||||
vi.mocked(mockDesktopApi.sessionGetById).mockImplementation(async (id) => ({
|
||||
success: true,
|
||||
session: sessions.find((session) => session.id === id),
|
||||
}))
|
||||
vi.mocked(mockDesktopApi.sshConnect).mockImplementation(async (config) => ({
|
||||
success: true,
|
||||
connectionId: `connection-${config.host}`,
|
||||
}))
|
||||
vi.mocked(mockDesktopApi.sshExec).mockImplementation(async (connectionId, command) => ({
|
||||
success: true,
|
||||
result: `${connectionId}: ${command}`,
|
||||
}))
|
||||
})
|
||||
|
||||
it('executes the same command on every selected host and disconnects transient sessions', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<BatchCommandDialog isOpen sessions={sessions} onClose={vi.fn()} />)
|
||||
|
||||
await user.type(screen.getByLabelText('远程命令'), 'date')
|
||||
await user.click(screen.getByRole('button', { name: '执行命令' }))
|
||||
|
||||
await waitFor(() => expect(mockDesktopApi.sshExec).toHaveBeenCalledTimes(2))
|
||||
expect(mockDesktopApi.sshExec).toHaveBeenCalledWith('connection-10.0.0.1', 'date')
|
||||
expect(mockDesktopApi.sshExec).toHaveBeenCalledWith('connection-10.0.0.2', 'date')
|
||||
await waitFor(() => expect(mockDesktopApi.sshDisconnect).toHaveBeenCalledTimes(2))
|
||||
expect(await screen.findByText('成功 2')).toBeVisible()
|
||||
expect(screen.getByText('connection-10.0.0.1: date')).toBeVisible()
|
||||
expect(screen.getByText('connection-10.0.0.2: date')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Sidebar from '../../src/components/layout/Sidebar'
|
||||
import { useSessionStore, type Session } from '../../src/stores/sessionStore'
|
||||
import { useTabStore } from '../../src/stores/tabStore'
|
||||
import { mockDesktopApi } from '../setup'
|
||||
|
||||
const sessions: Session[] = [
|
||||
{ id: 'prod-a', name: 'Prod A', host: '10.0.0.1', port: 22, username: 'root', authType: 'key' },
|
||||
{ id: 'prod-b', name: 'Prod B', host: '10.0.0.2', port: 22, username: 'root', authType: 'key' },
|
||||
]
|
||||
|
||||
describe('batch session operations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
window.opsxApi = mockDesktopApi
|
||||
vi.mocked(mockDesktopApi.sessionGetAll).mockResolvedValueOnce({ success: true, sessions })
|
||||
useSessionStore.setState({ sessions: [] })
|
||||
useTabStore.setState({
|
||||
tabs: [{ id: 'welcome', title: '欢迎', type: 'welcome', isActive: true }],
|
||||
activeTabId: 'welcome',
|
||||
})
|
||||
})
|
||||
|
||||
it('selects multiple saved sessions and opens connected terminal tabs', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Sidebar
|
||||
onNewSession={vi.fn()}
|
||||
onQuickConnect={vi.fn()}
|
||||
onEditSession={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
await screen.findByText('Prod A')
|
||||
await user.click(screen.getByRole('button', { name: '批量操作' }))
|
||||
await user.click(screen.getByText('Prod A'))
|
||||
await user.click(screen.getByText('Prod B'))
|
||||
|
||||
expect(screen.getByText('已选 2 个会话')).toBeVisible()
|
||||
await user.click(screen.getByRole('button', { name: /打开终端/ }))
|
||||
|
||||
expect(useTabStore.getState().tabs).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ sessionId: 'prod-a', type: 'terminal', connectOnMount: true }),
|
||||
expect.objectContaining({ sessionId: 'prod-b', type: 'terminal', connectOnMount: true }),
|
||||
]))
|
||||
})
|
||||
|
||||
it('opens SFTP only from the saved session context menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Sidebar
|
||||
onNewSession={vi.fn()}
|
||||
onQuickConnect={vi.fn()}
|
||||
onEditSession={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
const sessionName = await screen.findByText('Prod A')
|
||||
expect(screen.queryByTitle('SFTP')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('SFTP 文件')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.contextMenu(sessionName)
|
||||
await user.click(screen.getByRole('button', { name: 'SFTP 文件' }))
|
||||
|
||||
expect(useTabStore.getState().tabs).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ sessionId: 'prod-a', type: 'sftp' }),
|
||||
]))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import App from '../../src/app/App'
|
||||
import QuickLauncher from '../../src/components/sessions/QuickLauncher'
|
||||
import SerialConnectDialog from '../../src/components/serial/SerialConnectDialog'
|
||||
import { useSessionStore } from '../../src/stores/sessionStore'
|
||||
import { useTabStore } from '../../src/stores/tabStore'
|
||||
import { mockDesktopApi } from '../setup'
|
||||
|
||||
const serialMocks = vi.hoisted(() => ({
|
||||
listSerialPorts: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../src/platform/serial', () => ({
|
||||
listSerialPorts: serialMocks.listSerialPorts,
|
||||
}))
|
||||
|
||||
describe('Quick launcher and serial acceptance', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
window.opsxApi = mockDesktopApi
|
||||
serialMocks.listSerialPorts.mockResolvedValue([
|
||||
{ path: '/dev/tty.debug-console', portType: 'Serial' },
|
||||
{ path: '/dev/tty.Bluetooth-Incoming-Port', portType: 'Bluetooth' },
|
||||
])
|
||||
useSessionStore.setState({ sessions: [] })
|
||||
useTabStore.setState({
|
||||
tabs: [{ id: 'welcome', title: '欢迎', type: 'welcome', isActive: true }],
|
||||
activeTabId: 'welcome',
|
||||
})
|
||||
})
|
||||
|
||||
it('opens the unified launcher from the tab-bar plus button', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<App />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '快速启动' }))
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '快速启动' })).toBeVisible()
|
||||
const launcher = within(screen.getByRole('dialog'))
|
||||
expect(launcher.getByRole('button', { name: /串口终端/ })).toBeVisible()
|
||||
expect(launcher.getByRole('button', { name: /快速连接/ })).toBeVisible()
|
||||
})
|
||||
|
||||
it('starts the system-default zsh directly from the launcher', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<QuickLauncher
|
||||
isOpen
|
||||
onClose={vi.fn()}
|
||||
onNewSession={vi.fn()}
|
||||
onQuickConnect={vi.fn()}
|
||||
onSerialConnect={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
const zshButton = await screen.findByRole('button', {
|
||||
name: /Zsh.*\/bin\/zsh.*系统默认/,
|
||||
})
|
||||
await user.click(zshButton)
|
||||
|
||||
expect(useTabStore.getState().tabs).toContainEqual(
|
||||
expect.objectContaining({
|
||||
title: 'Zsh',
|
||||
type: 'terminal',
|
||||
isLocal: true,
|
||||
localShellPath: '/bin/zsh',
|
||||
localShellKind: 'zsh',
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('maps the selected serial device and line settings into a serial config', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onConnect = vi.fn()
|
||||
render(<SerialConnectDialog isOpen onClose={vi.fn()} onConnect={onConnect} />)
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: /\/dev\/tty\.Bluetooth-Incoming-Port/ })
|
||||
)
|
||||
await user.selectOptions(screen.getByLabelText('波特率'), '921600')
|
||||
await user.selectOptions(screen.getByLabelText('数据位'), '7')
|
||||
await user.selectOptions(screen.getByLabelText('停止位'), '2')
|
||||
await user.selectOptions(screen.getByLabelText('校验'), 'even')
|
||||
await user.selectOptions(screen.getByLabelText('流控'), 'hardware')
|
||||
await user.click(screen.getByRole('button', { name: '打开串口' }))
|
||||
|
||||
expect(onConnect).toHaveBeenCalledWith({
|
||||
path: '/dev/tty.Bluetooth-Incoming-Port',
|
||||
baudRate: 921600,
|
||||
dataBits: 7,
|
||||
stopBits: 2,
|
||||
parity: 'even',
|
||||
flowControl: 'hardware',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the documented manual serial path when enumeration returns no devices', async () => {
|
||||
serialMocks.listSerialPorts.mockResolvedValue([])
|
||||
render(<SerialConnectDialog isOpen onClose={vi.fn()} onConnect={vi.fn()} />)
|
||||
|
||||
await waitFor(() => expect(serialMocks.listSerialPorts).toHaveBeenCalled())
|
||||
expect(screen.getByLabelText('设备')).toHaveValue('/dev/tty.debug-console')
|
||||
expect(screen.getByText(/\/dev\/tty\.Bluetooth-Incoming-Port/)).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import SFTPView from '../../src/components/sftp/SFTPView'
|
||||
import { usePoolStore } from '../../src/stores/connection/poolStore'
|
||||
import { useSessionStore } from '../../src/stores/sessionStore'
|
||||
import { useTabStore } from '../../src/stores/tabStore'
|
||||
import { mockDesktopApi } from '../setup'
|
||||
|
||||
const session = {
|
||||
id: 'opsbase',
|
||||
name: 'OpsBase',
|
||||
host: 'opsbase.cn',
|
||||
port: 55555,
|
||||
username: 'root',
|
||||
authType: 'agent' as const,
|
||||
}
|
||||
|
||||
const remoteFile = {
|
||||
filename: 'remote.txt',
|
||||
longname: '-rw-r--r-- 1 root root 12 Jul 30 12:00 remote.txt',
|
||||
attrs: { size: 12, mtime: 1_700_000_000, mode: 0o100644 },
|
||||
}
|
||||
|
||||
function createDataTransfer(): DataTransfer {
|
||||
const values = new Map<string, string>()
|
||||
const transfer = {
|
||||
effectAllowed: 'all',
|
||||
dropEffect: 'none',
|
||||
files: [] as unknown as FileList,
|
||||
types: [] as string[],
|
||||
setData(type: string, value: string) {
|
||||
values.set(type, value)
|
||||
if (!this.types.includes(type)) this.types.push(type)
|
||||
},
|
||||
getData(type: string) {
|
||||
return values.get(type) || ''
|
||||
},
|
||||
}
|
||||
return transfer as unknown as DataTransfer
|
||||
}
|
||||
|
||||
describe('SFTP basic transfer acceptance', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
window.opsxApi = mockDesktopApi
|
||||
usePoolStore.setState({ connections: new Map() })
|
||||
useSessionStore.setState({ sessions: [session] })
|
||||
useTabStore.setState({
|
||||
tabs: [{ id: 'sftp-tab', title: 'SFTP: OpsBase', type: 'sftp', sessionId: session.id, isActive: true }],
|
||||
activeTabId: 'sftp-tab',
|
||||
})
|
||||
vi.mocked(mockDesktopApi.sshConnect).mockResolvedValue({ success: true, connectionId: 'conn-sftp' })
|
||||
vi.mocked(mockDesktopApi.sessionGetById).mockResolvedValue({ success: true, session })
|
||||
vi.mocked(mockDesktopApi.sftpConnect).mockResolvedValue({ success: true, sftpId: 'sftp-native' })
|
||||
vi.mocked(mockDesktopApi.sftpListDirectory).mockResolvedValue({ success: true, files: [remoteFile] })
|
||||
vi.mocked(mockDesktopApi.getHomePath).mockResolvedValue({ success: true, path: '/Users/test' })
|
||||
vi.mocked(mockDesktopApi.localReadDir).mockResolvedValue({ success: true, files: [] })
|
||||
})
|
||||
|
||||
it('uploads the absolute path returned by the native file picker', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(mockDesktopApi.showOpenDialog).mockResolvedValueOnce({
|
||||
canceled: false,
|
||||
filePaths: ['/tmp/actual-selected.txt'],
|
||||
})
|
||||
vi.mocked(mockDesktopApi.localGetStats).mockResolvedValueOnce({
|
||||
success: true,
|
||||
stats: { name: 'actual-selected.txt', path: '/tmp/actual-selected.txt', type: 'file', size: 17 },
|
||||
})
|
||||
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
|
||||
await screen.findByText('已连接')
|
||||
await user.click(screen.getByRole('button', { name: '上传' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sftpUpload).toHaveBeenCalledWith(
|
||||
'sftp-native',
|
||||
'/tmp/actual-selected.txt',
|
||||
'/root/actual-selected.txt'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('downloads a selected remote file into the current local directory', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
|
||||
|
||||
await screen.findByText('已连接')
|
||||
await user.click(await screen.findByText('remote.txt'))
|
||||
await user.click(screen.getByRole('button', { name: '下载' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sftpDownload).toHaveBeenCalledWith(
|
||||
'sftp-native',
|
||||
'/root/remote.txt',
|
||||
'/Users/test/remote.txt'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the native upload failure instead of reporting success', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(mockDesktopApi.showOpenDialog).mockResolvedValueOnce({
|
||||
canceled: false,
|
||||
filePaths: ['/tmp/denied.txt'],
|
||||
})
|
||||
vi.mocked(mockDesktopApi.localGetStats).mockResolvedValueOnce({
|
||||
success: true,
|
||||
stats: { name: 'denied.txt', path: '/tmp/denied.txt', type: 'file', size: 4 },
|
||||
})
|
||||
vi.mocked(mockDesktopApi.sftpUpload).mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'Permission denied',
|
||||
})
|
||||
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
|
||||
await screen.findByText('已连接')
|
||||
await user.click(screen.getByRole('button', { name: '上传' }))
|
||||
|
||||
expect(await screen.findByText(/Permission denied/)).toBeVisible()
|
||||
})
|
||||
|
||||
it('uploads by dragging a local row onto the remote panel', async () => {
|
||||
vi.mocked(mockDesktopApi.localReadDir).mockResolvedValue({
|
||||
success: true,
|
||||
files: [{ name: 'local.txt', path: '/Users/test/local.txt', type: 'file', size: 8, modified: 1_700_000_000 }],
|
||||
})
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
|
||||
await screen.findByText('已连接')
|
||||
|
||||
const transfer = createDataTransfer()
|
||||
fireEvent.dragStart(await screen.findByText('local.txt'), { dataTransfer: transfer })
|
||||
fireEvent.drop(screen.getByLabelText('远程文件面板'), { dataTransfer: transfer })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sftpUpload).toHaveBeenCalledWith(
|
||||
'sftp-native',
|
||||
'/Users/test/local.txt',
|
||||
'/root/local.txt'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('downloads by dragging a remote row onto the local panel', async () => {
|
||||
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
|
||||
await screen.findByText('已连接')
|
||||
|
||||
const transfer = createDataTransfer()
|
||||
fireEvent.dragStart(await screen.findByText('remote.txt'), { dataTransfer: transfer })
|
||||
fireEvent.drop(screen.getByLabelText('本地文件面板'), { dataTransfer: transfer })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sftpDownload).toHaveBeenCalledWith(
|
||||
'sftp-native',
|
||||
'/root/remote.txt',
|
||||
'/Users/test/remote.txt'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,208 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import SessionDialog from '../../src/components/sessions/SessionDialog'
|
||||
import QuickConnectDialog from '../../src/components/sessions/QuickConnectDialog'
|
||||
import { useSessionStore } from '../../src/stores/sessionStore'
|
||||
import { useTabStore } from '../../src/stores/tabStore'
|
||||
import { mockDesktopApi } from '../setup'
|
||||
|
||||
describe('Session workflow acceptance', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
window.opsxApi = mockDesktopApi
|
||||
useSessionStore.setState({ sessions: [] })
|
||||
useTabStore.setState({
|
||||
tabs: [{ id: 'welcome', title: '欢迎', type: 'welcome', isActive: true }],
|
||||
activeTabId: 'welcome',
|
||||
})
|
||||
})
|
||||
|
||||
it('creates and saves a session in the selected group', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
render(<SessionDialog isOpen onClose={onClose} editSession={null} />)
|
||||
|
||||
await user.type(screen.getByPlaceholderText('自动用 user@host'), 'OpsBase 验收')
|
||||
await user.clear(screen.getByPlaceholderText('root@192.168.1.10'))
|
||||
await user.type(screen.getByPlaceholderText('root@192.168.1.10'), 'root@opsbase.cn:55555')
|
||||
expect(screen.getByLabelText('端口')).toHaveValue(55555)
|
||||
await user.click(screen.getByRole('button', { name: /高级选项/ }))
|
||||
await user.clear(screen.getByPlaceholderText('default'))
|
||||
await user.type(screen.getByPlaceholderText('default'), '验收环境')
|
||||
await user.click(screen.getByRole('button', { name: '创建并保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sessionCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
host: 'opsbase.cn',
|
||||
port: 55555,
|
||||
username: 'root',
|
||||
group: '验收环境',
|
||||
})
|
||||
)
|
||||
})
|
||||
expect(useSessionStore.getState().sessions).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'OpsBase 验收',
|
||||
host: 'opsbase.cn',
|
||||
port: 55555,
|
||||
group: '验收环境',
|
||||
}),
|
||||
])
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('defaults new sessions to port 22 and password authentication', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SessionDialog isOpen onClose={vi.fn()} editSession={null} />)
|
||||
|
||||
expect(screen.getByLabelText('端口')).toHaveValue(22)
|
||||
expect(screen.getByRole('button', { name: '密码' })).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: '密钥' })).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: 'Agent' })).not.toBeInTheDocument()
|
||||
|
||||
await user.clear(screen.getByLabelText('目标'))
|
||||
await user.type(screen.getByLabelText('目标'), 'admin@example.com')
|
||||
await user.type(screen.getByLabelText('密码'), 'session-secret')
|
||||
await user.click(screen.getByRole('button', { name: '创建并保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sessionCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
host: 'example.com',
|
||||
port: 22,
|
||||
username: 'admin',
|
||||
authType: 'password',
|
||||
password: 'session-secret',
|
||||
privateKey: undefined,
|
||||
passphrase: undefined,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('saves key authentication without retaining a password', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SessionDialog isOpen onClose={vi.fn()} editSession={null} />)
|
||||
|
||||
await user.clear(screen.getByLabelText('目标'))
|
||||
await user.type(screen.getByLabelText('目标'), 'root@opsbase.cn')
|
||||
await user.click(screen.getByRole('button', { name: '密钥' }))
|
||||
await user.clear(screen.getByLabelText('私钥路径'))
|
||||
await user.type(screen.getByLabelText('私钥路径'), '~/.ssh/id_ed25519')
|
||||
await user.type(screen.getByLabelText('密钥口令(可选)'), 'key-secret')
|
||||
await user.click(screen.getByRole('button', { name: '创建并保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sessionCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
port: 22,
|
||||
authType: 'key',
|
||||
password: undefined,
|
||||
privateKey: '~/.ssh/id_ed25519',
|
||||
passphrase: 'key-secret',
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('saves a SOCKS5 connection proxy with optional credentials', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<SessionDialog isOpen onClose={vi.fn()} editSession={null} />)
|
||||
|
||||
await user.clear(screen.getByLabelText('目标'))
|
||||
await user.type(screen.getByLabelText('目标'), 'admin@internal.example.com')
|
||||
await user.click(screen.getByRole('button', { name: /高级选项/ }))
|
||||
await user.click(screen.getByRole('button', { name: 'SOCKS5' }))
|
||||
await user.type(screen.getByPlaceholderText('代理地址'), '127.0.0.1')
|
||||
await user.type(screen.getByPlaceholderText('用户名(可选)'), 'proxy-user')
|
||||
await user.type(screen.getByPlaceholderText('密码(可选)'), 'proxy-secret')
|
||||
await user.click(screen.getByRole('button', { name: '创建并保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sessionCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
proxy: {
|
||||
type: 'socks5',
|
||||
host: '127.0.0.1',
|
||||
port: 1080,
|
||||
username: 'proxy-user',
|
||||
password: 'proxy-secret',
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('saves one existing session as a ProxyJump host', async () => {
|
||||
const user = userEvent.setup()
|
||||
useSessionStore.setState({
|
||||
sessions: [
|
||||
{
|
||||
id: 'jump-1',
|
||||
name: 'Bastion',
|
||||
host: 'gateway.example.com',
|
||||
port: 22,
|
||||
username: 'ops',
|
||||
authType: 'agent',
|
||||
},
|
||||
],
|
||||
})
|
||||
render(<SessionDialog isOpen onClose={vi.fn()} editSession={null} />)
|
||||
|
||||
await user.clear(screen.getByLabelText('目标'))
|
||||
await user.type(screen.getByLabelText('目标'), 'root@internal.example.com')
|
||||
await user.click(screen.getByRole('button', { name: /高级选项/ }))
|
||||
await user.selectOptions(screen.getByLabelText('跳板机(可选)'), 'jump-1')
|
||||
await user.click(screen.getByRole('button', { name: '创建并保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sessionCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
jumpSessionId: 'jump-1',
|
||||
jumpSessionIds: ['jump-1'],
|
||||
proxy: undefined,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a quick-connect terminal tab with the parsed target', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
render(<QuickConnectDialog isOpen onClose={onClose} />)
|
||||
|
||||
await user.type(screen.getByPlaceholderText('root@192.168.1.10:22'), 'root@opsbase.cn:55555')
|
||||
await user.click(screen.getByRole('button', { name: '连接' }))
|
||||
|
||||
const terminalTab = useTabStore
|
||||
.getState()
|
||||
.tabs.find((tab) => tab.type === 'terminal' && tab.title === 'root@opsbase.cn')
|
||||
|
||||
expect(terminalTab?.connectionConfig).toEqual({
|
||||
host: 'opsbase.cn',
|
||||
port: 55555,
|
||||
username: 'root',
|
||||
password: undefined,
|
||||
})
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('accepts pasted targets in quick connect and new session inputs', async () => {
|
||||
const user = userEvent.setup()
|
||||
const quick = render(<QuickConnectDialog isOpen onClose={vi.fn()} />)
|
||||
const quickTarget = screen.getByPlaceholderText('root@192.168.1.10:22')
|
||||
await user.click(quickTarget)
|
||||
await user.paste('root@opsbase.cn:55555')
|
||||
expect(quickTarget).toHaveValue('root@opsbase.cn:55555')
|
||||
|
||||
quick.unmount()
|
||||
render(<SessionDialog isOpen onClose={vi.fn()} editSession={null} />)
|
||||
const sessionTarget = screen.getByLabelText('目标')
|
||||
await user.clear(sessionTarget)
|
||||
await user.paste('admin@example.com:2200')
|
||||
expect(sessionTarget).toHaveValue('admin@example.com:2200')
|
||||
expect(screen.getByLabelText('端口')).toHaveValue(2200)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
duplicateForwardRule,
|
||||
formatTransferBytes,
|
||||
parseForwardRules,
|
||||
portForwardStorageKey,
|
||||
runtimeMap,
|
||||
} from '../../../src/components/connection/forwardRules'
|
||||
|
||||
describe('forward rule persistence', () => {
|
||||
it('uses stable session ids instead of runtime connection ids', () => {
|
||||
expect(portForwardStorageKey('saved-1', 'tab-1')).toBe('portForwards_session_saved-1')
|
||||
expect(portForwardStorageKey(undefined, 'tab-1')).toBe('portForwards_tab_tab-1')
|
||||
})
|
||||
|
||||
it('migrates JSON strings and drops runtime state', () => {
|
||||
const rules = parseForwardRules(
|
||||
JSON.stringify([
|
||||
{
|
||||
id: 'proxy-1',
|
||||
type: 'dynamic',
|
||||
name: 'SOCKS5',
|
||||
localPort: 1080,
|
||||
isActive: true,
|
||||
},
|
||||
])
|
||||
)
|
||||
expect(rules).toEqual([{ id: 'proxy-1', type: 'dynamic', name: 'SOCKS5', localPort: 1080 }])
|
||||
expect(rules[0]).not.toHaveProperty('isActive')
|
||||
})
|
||||
|
||||
it('indexes process-local tunnel status independently', () => {
|
||||
const map = runtimeMap([
|
||||
{
|
||||
forwardId: 'proxy-1',
|
||||
connectionId: 'conn-1',
|
||||
kind: 'dynamic',
|
||||
bindHost: '127.0.0.1',
|
||||
boundPort: 1080,
|
||||
startedAt: 1,
|
||||
running: true,
|
||||
activeConnections: 2,
|
||||
bytesUp: 1024,
|
||||
bytesDown: 2048,
|
||||
},
|
||||
])
|
||||
expect(map.get('proxy-1')?.activeConnections).toBe(2)
|
||||
expect(formatTransferBytes(1536)).toBe('1.5 KB')
|
||||
})
|
||||
|
||||
it('duplicates configuration with a fresh id and no runtime state', () => {
|
||||
const original = { id: 'proxy-1', type: 'dynamic' as const, name: 'SOCKS5', localPort: 1080 }
|
||||
const first = duplicateForwardRule(original, [original], 'proxy-2')
|
||||
const second = duplicateForwardRule(original, [original, first], 'proxy-3')
|
||||
|
||||
expect(first).toEqual({ ...original, id: 'proxy-2', name: 'SOCKS5 副本' })
|
||||
expect(second.name).toBe('SOCKS5 副本 2')
|
||||
expect(second).not.toHaveProperty('isActive')
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,16 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { formatSize, formatSpeed, formatDate, getFileExtension, sortFiles } from '../../../src/components/sftp/utils'
|
||||
import {
|
||||
formatSize,
|
||||
formatSpeed,
|
||||
formatDate,
|
||||
getFileExtension,
|
||||
sortFiles,
|
||||
filterFilesByName,
|
||||
isHiddenFileName,
|
||||
filterHiddenFiles,
|
||||
} from '../../../src/components/sftp/utils'
|
||||
import type { FileItem } from '../../../src/components/sftp/types'
|
||||
|
||||
describe('SFTP Utils', () => {
|
||||
@@ -111,4 +120,67 @@ describe('SFTP Utils', () => {
|
||||
expect(mockFiles).toEqual(original)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterFilesByName', () => {
|
||||
const mockFiles: FileItem[] = [
|
||||
{ name: 'test.txt', path: '/test.txt', type: 'file', size: 100, modified: 1000, permissions: '-rw-r--r--' },
|
||||
{ name: 'docs', path: '/docs', type: 'directory', size: 0, modified: 2000, permissions: 'drwxr-xr-x' },
|
||||
{ name: 'image.png', path: '/image.png', type: 'file', size: 500, modified: 500, permissions: '-rw-r--r--' },
|
||||
{ name: 'backup.zip', path: '/backup.zip', type: 'file', size: 1000, modified: 1500, permissions: '-rw-r--r--' },
|
||||
{ name: 'Test_Report.PDF', path: '/Test_Report.PDF', type: 'file', size: 200, modified: 1200, permissions: '-rw-r--r--' },
|
||||
]
|
||||
|
||||
it('空查询应返回原列表引用', () => {
|
||||
expect(filterFilesByName(mockFiles, '')).toBe(mockFiles)
|
||||
expect(filterFilesByName(mockFiles, ' ')).toBe(mockFiles)
|
||||
})
|
||||
|
||||
it('应按子串匹配文件名', () => {
|
||||
const result = filterFilesByName(mockFiles, 'image')
|
||||
expect(result.map((f) => f.name)).toEqual(['image.png'])
|
||||
})
|
||||
|
||||
it('应不区分大小写', () => {
|
||||
const result = filterFilesByName(mockFiles, 'test')
|
||||
expect(result.map((f) => f.name).sort()).toEqual(['Test_Report.PDF', 'test.txt'])
|
||||
})
|
||||
|
||||
it('可匹配扩展名', () => {
|
||||
const result = filterFilesByName(mockFiles, '.png')
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('image.png')
|
||||
})
|
||||
|
||||
it('无匹配时返回空数组', () => {
|
||||
expect(filterFilesByName(mockFiles, 'xyz-not-exist')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isHiddenFileName / filterHiddenFiles', () => {
|
||||
const mockFiles: FileItem[] = [
|
||||
{ name: 'readme.md', path: '/readme.md', type: 'file', size: 10, modified: 1, permissions: '-rw-r--r--' },
|
||||
{ name: '.gitignore', path: '/.gitignore', type: 'file', size: 20, modified: 2, permissions: '-rw-r--r--' },
|
||||
{ name: '.config', path: '/.config', type: 'directory', size: 0, modified: 3, permissions: 'drwxr-xr-x' },
|
||||
{ name: '.', path: '/.', type: 'directory', size: 0, modified: 4, permissions: 'drwxr-xr-x' },
|
||||
{ name: '..', path: '/..', type: 'directory', size: 0, modified: 5, permissions: 'drwxr-xr-x' },
|
||||
{ name: 'src', path: '/src', type: 'directory', size: 0, modified: 6, permissions: 'drwxr-xr-x' },
|
||||
]
|
||||
|
||||
it('应识别点文件与 . / ..', () => {
|
||||
expect(isHiddenFileName('.gitignore')).toBe(true)
|
||||
expect(isHiddenFileName('.')).toBe(true)
|
||||
expect(isHiddenFileName('..')).toBe(true)
|
||||
expect(isHiddenFileName('readme.md')).toBe(false)
|
||||
})
|
||||
|
||||
it('默认应隐藏点文件,并始终排除 . / ..', () => {
|
||||
const result = filterHiddenFiles(mockFiles, false)
|
||||
expect(result.map((f) => f.name).sort()).toEqual(['readme.md', 'src'])
|
||||
})
|
||||
|
||||
it('显示隐藏文件时仍排除 . / ..', () => {
|
||||
const result = filterHiddenFiles(mockFiles, true)
|
||||
expect(result.map((f) => f.name).sort()).toEqual(['.config', '.gitignore', 'readme.md', 'src'])
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user