feat(i18n): add multi-language support (ja/ko/vi/ru/zh-TW) with system auto-detection
- Extend AppLanguage to 7 locales: zh-CN, zh-TW, en, ja, ko, vi, ru - Auto-detect system language via navigator.languages; fallback to English - Add BCP47 locale mapping for date formatting and sorting - Create 50 translation JSON files (5 languages × 10 namespaces, 1137 keys each) - Update language selector with native language names - Extend locale key parity test to cover all 7 languages (70 assertions) - Add VNC/RDP desktop, serial terminal, AI agent, port forwarding, and SFTP features - Add credential encryption, host metrics, session backup crypto, and zmodem bridge - Update Rust backend: desktop services, host metrics, SSH/SFTP enhancements
This commit is contained in:
1 parent
fea6d8db04
commit
b0d0e3a5f3
301 files changed
+28002
-2705
No files matched your search
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AI_PROVIDER_FAMILY_META,
|
||||
AI_PROVIDER_FAMILY_ORDER,
|
||||
AI_PROVIDER_PRESETS,
|
||||
createProviderFromPreset,
|
||||
presetsByFamily,
|
||||
resolveProviderFamily,
|
||||
} from '../../src/ai/providerPresets'
|
||||
|
||||
describe('AI provider presets grouping', () => {
|
||||
it('matches the expected UI group order', () => {
|
||||
expect(AI_PROVIDER_FAMILY_ORDER).toEqual([
|
||||
'openai_compatible',
|
||||
'standalone',
|
||||
'plan',
|
||||
'local',
|
||||
'custom',
|
||||
])
|
||||
expect(AI_PROVIDER_FAMILY_META.openai_compatible.label).toBe('OpenAI兼容')
|
||||
expect(AI_PROVIDER_FAMILY_META.plan.label).toBe('Plan计划')
|
||||
expect(AI_PROVIDER_FAMILY_META.local.label).toBe('私有化本地')
|
||||
expect(AI_PROVIDER_FAMILY_META.custom.label).toBe('Custom自定义')
|
||||
})
|
||||
|
||||
it('lists OpenAI-compatible payg presets', () => {
|
||||
expect(presetsByFamily('openai_compatible').map((item) => item.name)).toEqual([
|
||||
'DeepSeek',
|
||||
'OpenAI',
|
||||
'Qwen',
|
||||
'Kimi',
|
||||
'Xiaomi MiMo',
|
||||
'MiniMax',
|
||||
])
|
||||
})
|
||||
|
||||
it('lists Anthropic and Google Gemini after the separator group', () => {
|
||||
expect(presetsByFamily('standalone').map((item) => item.name)).toEqual([
|
||||
'Anthropic',
|
||||
'Google Gemini',
|
||||
])
|
||||
})
|
||||
|
||||
it('lists Plan presets as GLM / MiniMax / Kimi', () => {
|
||||
expect(presetsByFamily('plan').map((item) => item.name)).toEqual(['GLM', 'MiniMax', 'Kimi'])
|
||||
expect(createProviderFromPreset('glm').defaultModelId).toBe('glm-5.2-plan')
|
||||
expect(createProviderFromPreset('minimax-plan').defaultModelId).toBe('MiniMax-M3-plan')
|
||||
expect(createProviderFromPreset('kimi-plan').defaultModelId).toBe('kimi-k3-plan')
|
||||
expect(createProviderFromPreset('zai').presetId).toBe('glm')
|
||||
})
|
||||
|
||||
it('defaults DeepSeek to V4 Pro / Flash', () => {
|
||||
const deepseek = createProviderFromPreset('deepseek')
|
||||
expect(deepseek.defaultModelId).toBe('deepseek-v4-pro')
|
||||
expect(deepseek.models.map((item) => item.model)).toEqual(['deepseek-v4-pro', 'deepseek-v4-flash'])
|
||||
})
|
||||
|
||||
it('uses current frontier model IDs from official catalogs', () => {
|
||||
expect(createProviderFromPreset('openai').models.map((item) => item.model)).toEqual([
|
||||
'gpt-5.6-sol',
|
||||
'gpt-5.6-terra',
|
||||
'gpt-5.6-luna',
|
||||
'gpt-5.5',
|
||||
])
|
||||
expect(createProviderFromPreset('anthropic').defaultModelId).toBe('claude-opus-5')
|
||||
expect(createProviderFromPreset('google').defaultModelId).toBe('gemini-3.1-pro-preview')
|
||||
expect(createProviderFromPreset('qwen').defaultModelId).toBe('qwen3.8-max')
|
||||
expect(createProviderFromPreset('kimi').defaultModelId).toBe('kimi-k3')
|
||||
})
|
||||
|
||||
it('lists local and custom at the bottom', () => {
|
||||
expect(presetsByFamily('local').map((item) => item.name)).toEqual(['Ollama', 'VLLM'])
|
||||
expect(presetsByFamily('custom').map((item) => item.name)).toEqual(['Custom自定义'])
|
||||
expect(createProviderFromPreset('custom').family).toBe('custom')
|
||||
})
|
||||
|
||||
it('resolves legacy zhipu/zai to plan family', () => {
|
||||
expect(
|
||||
resolveProviderFamily({
|
||||
presetId: 'zai',
|
||||
protocol: 'openai-chat',
|
||||
authStyle: 'bearer',
|
||||
baseUrl: 'https://api.z.ai/api/paas/v4',
|
||||
})
|
||||
).toBe('plan')
|
||||
expect(AI_PROVIDER_PRESETS.some((item) => item.id === 'mimo')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { looksLikeMarkdown } from '../../src/components/ai/AgentMarkdown'
|
||||
|
||||
describe('looksLikeMarkdown', () => {
|
||||
it('detects tables, headings and lists', () => {
|
||||
expect(looksLikeMarkdown('| a | b |\n| - | - |\n| 1 | 2 |')).toBe(true)
|
||||
expect(looksLikeMarkdown('## 总结\n完成')).toBe(true)
|
||||
expect(looksLikeMarkdown('- nginx\n- sshd')).toBe(true)
|
||||
expect(looksLikeMarkdown('负载正常。')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -27,7 +27,8 @@ describe('AI entry points', () => {
|
||||
const entry = screen.getByTestId('settings-tab-ai')
|
||||
expect(entry).toBeVisible()
|
||||
expect(entry).toHaveTextContent('大模型')
|
||||
expect(entry).toHaveTextContent('Provider、模型与测试')
|
||||
expect(entry).toHaveTextContent('Provider')
|
||||
expect(entry).not.toHaveTextContent('模型与测试')
|
||||
|
||||
fireEvent.click(entry)
|
||||
expect(screen.getByRole('heading', { name: '大模型' })).toBeVisible()
|
||||
|
||||
@@ -33,11 +33,11 @@ describe('AI provider settings', () => {
|
||||
expect(save).toBeDisabled()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('API Key'), { target: { value: 'sk-test-only' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试连接' }))
|
||||
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(await screen.findByText(/可用/)).toBeVisible()
|
||||
expect(save).toBeEnabled()
|
||||
|
||||
fireEvent.click(save)
|
||||
@@ -50,12 +50,13 @@ describe('AI provider settings', () => {
|
||||
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: '测试连接' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试' }))
|
||||
const save = screen.getByRole('button', { name: '保存' })
|
||||
await waitFor(() => expect(save).toBeEnabled())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '高级' }))
|
||||
fireEvent.change(screen.getByLabelText('Endpoint'), { target: { value: 'https://proxy.example.com/v1' } })
|
||||
expect(save).toBeDisabled()
|
||||
expect(screen.getByText('连接字段已变更,请重新测试')).toBeVisible()
|
||||
expect(screen.getByText('请重新测试')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,8 @@ import { useAiProviderStore } from '../../src/stores/aiProviderStore'
|
||||
import { useSettingsStore } from '../../src/stores/settingsStore'
|
||||
import { mockDesktopApi } from '../setup'
|
||||
|
||||
const PLACEHOLDER = '任务或追问…(Enter 发送,Shift+Enter 换行)'
|
||||
|
||||
describe('terminal Agent CLI panel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -29,11 +31,11 @@ describe('terminal Agent CLI panel', () => {
|
||||
catalogUpdatedAt: null,
|
||||
})
|
||||
useSettingsStore.setState((state) => ({
|
||||
settings: { ...state.settings, agentSafetyMode: 'balanced' },
|
||||
settings: { ...state.settings, agentSafetyMode: 'relaxed' },
|
||||
}))
|
||||
})
|
||||
|
||||
it('uses one task entry, Enter to run, Shift+Enter for a newline, and selected context only', async () => {
|
||||
it('keeps a sticky composer and uses Enter to send with selected context only', async () => {
|
||||
vi.mocked(mockDesktopApi.aiComplete).mockResolvedValueOnce({
|
||||
success: true,
|
||||
text: 'FINAL: 这是远端 Bash TMOUT 策略导致的自动退出。',
|
||||
@@ -50,10 +52,10 @@ describe('terminal Agent CLI panel', () => {
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button', { name: '解释' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '生成命令' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('自然语言驱动 SSH 自主排障与部署')).toBeVisible()
|
||||
expect(screen.queryByText('自然语言驱动 SSH 自主排障与部署')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '发送' })).toBeVisible()
|
||||
|
||||
const input = screen.getByPlaceholderText('输入任务描述,回车发送(Shift+回车换行)')
|
||||
const input = screen.getByPlaceholderText(PLACEHOLDER)
|
||||
fireEvent.change(input, { target: { value: '解释这个超时' } })
|
||||
fireEvent.keyDown(input, { key: 'Enter', shiftKey: true })
|
||||
expect(mockDesktopApi.aiComplete).not.toHaveBeenCalled()
|
||||
@@ -63,9 +65,10 @@ describe('terminal Agent CLI panel', () => {
|
||||
expect(vi.mocked(mockDesktopApi.aiComplete).mock.calls[0][0].context).toContain('auto-logout')
|
||||
expect(vi.mocked(mockDesktopApi.aiComplete).mock.calls[0][0].context).not.toContain('whole screen')
|
||||
expect(await screen.findByText('这是远端 Bash TMOUT 策略导致的自动退出。')).toBeVisible()
|
||||
expect(screen.getByPlaceholderText(PLACEHOLDER)).toHaveValue('')
|
||||
})
|
||||
|
||||
it('runs safe diagnostic commands automatically in the default balanced mode', async () => {
|
||||
it('runs safe diagnostic commands automatically in the default relaxed mode', async () => {
|
||||
vi.mocked(mockDesktopApi.aiComplete)
|
||||
.mockResolvedValueOnce({ success: true, text: '```bash\nuptime\n```', model: 'test', redactedCount: 0 })
|
||||
.mockResolvedValueOnce({ success: true, text: 'FINAL: 负载正常,任务完成。', model: 'test', redactedCount: 0 })
|
||||
@@ -84,10 +87,10 @@ describe('terminal Agent CLI panel', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('输入任务描述,回车发送(Shift+回车换行)'), {
|
||||
fireEvent.change(screen.getByPlaceholderText(PLACEHOLDER), {
|
||||
target: { value: '检查服务器负载' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '执行' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }))
|
||||
|
||||
await waitFor(() => expect(mockDesktopApi.sshExec).toHaveBeenCalledWith('ssh-active-1', 'uptime'))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
@@ -97,7 +100,7 @@ describe('terminal Agent CLI panel', () => {
|
||||
expect(vi.mocked(mockDesktopApi.aiComplete).mock.calls[1][0].context).toContain('[exit_code=0]')
|
||||
})
|
||||
|
||||
it('asks for confirmation for high-risk commands in the default balanced mode', async () => {
|
||||
it('asks for confirmation for high-risk commands in the default relaxed mode', async () => {
|
||||
vi.mocked(mockDesktopApi.aiComplete)
|
||||
.mockResolvedValueOnce({ success: true, text: '```bash\nsystemctl restart nginx\n```', model: 'test', redactedCount: 0 })
|
||||
.mockResolvedValueOnce({ success: true, text: 'FINAL: nginx 已重启。', model: 'test', redactedCount: 0 })
|
||||
@@ -111,19 +114,49 @@ describe('terminal Agent CLI panel', () => {
|
||||
targetLabel="root@opsbase.cn"
|
||||
/>
|
||||
)
|
||||
fireEvent.change(screen.getByPlaceholderText('输入任务描述,回车发送(Shift+回车换行)'), {
|
||||
fireEvent.change(screen.getByPlaceholderText(PLACEHOLDER), {
|
||||
target: { value: '重启 nginx' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '执行' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(within(dialog).getByText(/均衡/)).toBeVisible()
|
||||
expect(within(dialog).getByText(/宽松/)).toBeVisible()
|
||||
expect(within(dialog).getByText(/systemctl restart nginx/)).toBeVisible()
|
||||
expect(mockDesktopApi.sshExec).not.toHaveBeenCalled()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: '确认风险并执行' }))
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Once' }))
|
||||
await waitFor(() => expect(mockDesktopApi.sshExec).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('Always skips confirmation for the same command shape in the current panel session', async () => {
|
||||
vi.mocked(mockDesktopApi.aiComplete)
|
||||
.mockResolvedValueOnce({ success: true, text: '```bash\nrm /tmp/tmp.AAA111\n```', model: 'test', redactedCount: 0 })
|
||||
.mockResolvedValueOnce({ success: true, text: '```bash\nrm /tmp/tmp.BBB222\n```', model: 'test', redactedCount: 0 })
|
||||
.mockResolvedValueOnce({ success: true, text: 'FINAL: 清理完成。', model: 'test', redactedCount: 0 })
|
||||
vi.mocked(mockDesktopApi.sshExec)
|
||||
.mockResolvedValueOnce({ success: true, result: 'ok', exitCode: 0 })
|
||||
.mockResolvedValueOnce({ success: true, result: 'ok', exitCode: 0 })
|
||||
|
||||
render(
|
||||
<AiTerminalPanel
|
||||
capture={() => ({ selection: '', viewport: '' })}
|
||||
onClose={vi.fn()}
|
||||
connectionId="ssh-active-1"
|
||||
targetLabel="root@opsbase.cn"
|
||||
/>
|
||||
)
|
||||
fireEvent.change(screen.getByPlaceholderText(PLACEHOLDER), {
|
||||
target: { value: '清理临时文件' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Always' }))
|
||||
await waitFor(() => expect(mockDesktopApi.sshExec).toHaveBeenCalledWith('ssh-active-1', 'rm /tmp/tmp.AAA111'))
|
||||
await waitFor(() => expect(mockDesktopApi.sshExec).toHaveBeenCalledWith('ssh-active-1', 'rm /tmp/tmp.BBB222'))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(await screen.findByText('清理完成。')).toBeVisible()
|
||||
})
|
||||
|
||||
it('lets strict mode require confirmation from medium risk upward', async () => {
|
||||
vi.mocked(mockDesktopApi.aiComplete).mockResolvedValueOnce({
|
||||
success: true,
|
||||
@@ -140,10 +173,10 @@ describe('terminal Agent CLI panel', () => {
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('radio', { name: '严格' }))
|
||||
fireEvent.change(screen.getByPlaceholderText('输入任务描述,回车发送(Shift+回车换行)'), {
|
||||
fireEvent.change(screen.getByPlaceholderText(PLACEHOLDER), {
|
||||
target: { value: '创建临时文件' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '执行' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(within(dialog).getByText(/touch \/tmp\/opsxshell-agent-test/)).toBeVisible()
|
||||
@@ -166,10 +199,10 @@ describe('terminal Agent CLI panel', () => {
|
||||
targetLabel="root@opsbase.cn"
|
||||
/>
|
||||
)
|
||||
fireEvent.change(screen.getByPlaceholderText('输入任务描述,回车发送(Shift+回车换行)'), {
|
||||
fireEvent.change(screen.getByPlaceholderText(PLACEHOLDER), {
|
||||
target: { value: '删除整台主机' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '执行' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('破坏性操作硬红线')
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
@@ -194,10 +227,10 @@ describe('terminal Agent CLI panel', () => {
|
||||
targetLabel="root@opsbase.cn"
|
||||
/>
|
||||
)
|
||||
fireEvent.change(screen.getByPlaceholderText('输入任务描述,回车发送(Shift+回车换行)'), {
|
||||
fireEvent.change(screen.getByPlaceholderText(PLACEHOLDER), {
|
||||
target: { value: '给出结论' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '执行' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }))
|
||||
|
||||
expect(await screen.findByText('已根据已有上下文完成分析。')).toBeVisible()
|
||||
expect(mockDesktopApi.aiComplete).toHaveBeenCalledTimes(2)
|
||||
@@ -205,7 +238,7 @@ describe('terminal Agent CLI panel', () => {
|
||||
expect(mockDesktopApi.sshExec).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('automatically executes multiple read-only commands in the same balanced task', async () => {
|
||||
it('automatically executes multiple read-only commands in the same relaxed task', async () => {
|
||||
vi.mocked(mockDesktopApi.aiComplete)
|
||||
.mockResolvedValueOnce({ success: true, text: '```bash\ndate\n```', model: 'test', redactedCount: 0 })
|
||||
.mockResolvedValueOnce({ success: true, text: '```bash\nuname -a\n```', model: 'test', redactedCount: 0 })
|
||||
@@ -222,13 +255,114 @@ describe('terminal Agent CLI panel', () => {
|
||||
targetLabel="root@opsbase.cn"
|
||||
/>
|
||||
)
|
||||
fireEvent.change(screen.getByPlaceholderText('输入任务描述,回车发送(Shift+回车换行)'), {
|
||||
fireEvent.change(screen.getByPlaceholderText(PLACEHOLDER), {
|
||||
target: { value: '连续检查时间和系统' },
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '执行' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }))
|
||||
|
||||
await waitFor(() => expect(mockDesktopApi.sshExec).toHaveBeenCalledTimes(2))
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(await screen.findByText('两项检查已完成。')).toBeVisible()
|
||||
})
|
||||
|
||||
it('keeps follow-up turns in memory and can start a new topic', async () => {
|
||||
vi.mocked(mockDesktopApi.aiComplete)
|
||||
.mockResolvedValueOnce({ success: true, text: 'FINAL: 负载正常。', model: 'test', redactedCount: 0 })
|
||||
.mockResolvedValueOnce({ success: true, text: 'FINAL: 内存也正常。', model: 'test', redactedCount: 0 })
|
||||
|
||||
render(
|
||||
<AiTerminalPanel
|
||||
capture={() => ({ selection: '', viewport: '' })}
|
||||
onClose={vi.fn()}
|
||||
connectionId="ssh-active-1"
|
||||
targetLabel="root@opsbase.cn"
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(PLACEHOLDER), { target: { value: '看负载' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }))
|
||||
expect(await screen.findByText('负载正常。')).toBeVisible()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(PLACEHOLDER), { target: { value: '再看内存' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }))
|
||||
expect(await screen.findByText('内存也正常。')).toBeVisible()
|
||||
expect(mockDesktopApi.aiComplete).toHaveBeenCalledTimes(2)
|
||||
expect(vi.mocked(mockDesktopApi.aiComplete).mock.calls[1][0].prompt).toContain('用户追问')
|
||||
expect(vi.mocked(mockDesktopApi.aiComplete).mock.calls[1][0].context).toContain('负载正常')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '新话题' }))
|
||||
expect(screen.queryByText('负载正常。')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('内存也正常。')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/可连续追问/)).toBeVisible()
|
||||
})
|
||||
|
||||
it('renders markdown tables in FINAL conclusions', async () => {
|
||||
vi.mocked(mockDesktopApi.aiComplete).mockResolvedValueOnce({
|
||||
success: true,
|
||||
text: [
|
||||
'FINAL: 当前监听端口如下:',
|
||||
'',
|
||||
'| 端口 | 服务 |',
|
||||
'|------|------|',
|
||||
'| 22 | sshd |',
|
||||
'| 80 | nginx |',
|
||||
].join('\n'),
|
||||
model: 'test',
|
||||
redactedCount: 0,
|
||||
})
|
||||
|
||||
render(
|
||||
<AiTerminalPanel
|
||||
capture={() => ({ selection: '', viewport: '' })}
|
||||
onClose={vi.fn()}
|
||||
connectionId="ssh-active-1"
|
||||
targetLabel="root@opsbase.cn"
|
||||
/>
|
||||
)
|
||||
fireEvent.change(screen.getByPlaceholderText(PLACEHOLDER), { target: { value: '列出端口' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }))
|
||||
|
||||
expect(await screen.findByRole('table')).toBeVisible()
|
||||
expect(screen.getByText('sshd')).toBeVisible()
|
||||
expect(screen.getByText('nginx')).toBeVisible()
|
||||
})
|
||||
|
||||
it('stops the agent and cancels the in-flight ssh_exec', async () => {
|
||||
type ExecResult = { success: boolean; result?: string; exitCode?: number; error?: string }
|
||||
const deferred: { resolve: (value: ExecResult) => void } = {
|
||||
resolve: () => undefined,
|
||||
}
|
||||
vi.mocked(mockDesktopApi.aiComplete).mockResolvedValueOnce({
|
||||
success: true,
|
||||
text: '```bash\ndate\n```',
|
||||
model: 'test',
|
||||
redactedCount: 0,
|
||||
})
|
||||
vi.mocked(mockDesktopApi.sshExec).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<ExecResult>((resolve) => {
|
||||
deferred.resolve = resolve
|
||||
})
|
||||
)
|
||||
|
||||
render(
|
||||
<AiTerminalPanel
|
||||
capture={() => ({ selection: '', viewport: '' })}
|
||||
onClose={vi.fn()}
|
||||
connectionId="ssh-active-1"
|
||||
targetLabel="root@opsbase.cn"
|
||||
/>
|
||||
)
|
||||
fireEvent.change(screen.getByPlaceholderText(PLACEHOLDER), { target: { value: '查看时间' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }))
|
||||
|
||||
await waitFor(() => expect(mockDesktopApi.sshExec).toHaveBeenCalledWith('ssh-active-1', 'date'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '停止' }))
|
||||
|
||||
expect(mockDesktopApi.sshExecCancel).toHaveBeenCalledWith('ssh-active-1')
|
||||
expect(await screen.findByText('已终止。正在执行的远端命令已请求中断。')).toBeVisible()
|
||||
deferred.resolve({ success: false, error: '命令已终止' })
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '发送' })).toBeVisible())
|
||||
expect(mockDesktopApi.aiComplete).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,10 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import App from '../../src/app/App'
|
||||
import { useSessionStore } from '../../src/stores/sessionStore'
|
||||
import { decodeBase64ToText } from '../../src/utils/textBase64'
|
||||
import { decryptSessionBackup } from '../../src/utils/sessionBackupCrypto'
|
||||
import { mockDesktopApi } from '../setup'
|
||||
import { unlockProForTests } from '../helpers/entitlement'
|
||||
|
||||
@@ -16,8 +19,9 @@ describe('App foundation acceptance', () => {
|
||||
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')
|
||||
const titleBar = document.querySelector('header[data-tauri-drag-region]')
|
||||
expect(titleBar).toBeTruthy()
|
||||
expect(titleBar).toHaveAttribute('data-tauri-drag-region')
|
||||
})
|
||||
|
||||
it('renders the shell while the native bridge is unavailable', async () => {
|
||||
@@ -50,8 +54,8 @@ describe('App foundation acceptance', () => {
|
||||
window.opsxApi = mockDesktopApi
|
||||
render(<App />)
|
||||
|
||||
const title = await screen.findByText('OpsXShell', { selector: 'span' })
|
||||
const titleBar = title.closest('header')
|
||||
await screen.findByRole('heading', { name: 'OpsXShell' })
|
||||
const titleBar = document.querySelector('header[data-tauri-drag-region]')
|
||||
expect(titleBar).toHaveAttribute('data-tauri-drag-region')
|
||||
expect(titleBar).not.toHaveClass('titlebar-drag')
|
||||
|
||||
@@ -59,7 +63,8 @@ describe('App foundation acceptance', () => {
|
||||
expect(mockDesktopApi.toggleMaximize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('writes exported sessions to the selected JSON file', async () => {
|
||||
it('writes encrypted exported sessions to the selected backup file', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.clearAllMocks()
|
||||
unlockProForTests()
|
||||
vi.mocked(mockDesktopApi.licenseGet).mockResolvedValue({
|
||||
@@ -83,17 +88,39 @@ describe('App foundation acceptance', () => {
|
||||
})
|
||||
vi.mocked(mockDesktopApi.showSaveDialog).mockResolvedValueOnce({
|
||||
canceled: false,
|
||||
filePath: '/tmp/opsxshell-sessions.json',
|
||||
filePath: '/tmp/opsxshell-sessions.bak',
|
||||
})
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => expect(mockDesktopApi.onMenuExportSessions).toHaveBeenCalled())
|
||||
const callback = vi.mocked(mockDesktopApi.onMenuExportSessions).mock.calls[0][0]
|
||||
await callback()
|
||||
const exportPromise = callback()
|
||||
|
||||
expect(mockDesktopApi.writeFile).toHaveBeenCalledWith(
|
||||
'/tmp/opsxshell-sessions.json',
|
||||
expect.stringContaining('"prod.example.com"')
|
||||
const passwordInput = await screen.findByPlaceholderText('输入备份密码')
|
||||
await user.type(passwordInput, 'backup-secret')
|
||||
await user.click(screen.getByRole('button', { name: '确认' }))
|
||||
|
||||
const confirmInput = await screen.findByPlaceholderText('再次输入备份密码')
|
||||
await user.type(confirmInput, 'backup-secret')
|
||||
await user.click(screen.getByRole('button', { name: '确认' }))
|
||||
|
||||
await exportPromise
|
||||
|
||||
expect(mockDesktopApi.showSaveDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
defaultPath: 'opsxshell-sessions.bak',
|
||||
})
|
||||
)
|
||||
expect(mockDesktopApi.writeFile).toHaveBeenCalledWith(
|
||||
'/tmp/opsxshell-sessions.bak',
|
||||
expect.any(String)
|
||||
)
|
||||
const [, encoded] = vi.mocked(mockDesktopApi.writeFile).mock.calls[0]
|
||||
const decoded = decodeBase64ToText(String(encoded))
|
||||
expect(decoded).toContain('"format": "opsxshell-backup"')
|
||||
expect(decoded).not.toContain('prod.example.com')
|
||||
const envelope = JSON.parse(decoded)
|
||||
const plaintext = await decryptSessionBackup(envelope, 'backup-secret')
|
||||
expect(plaintext).toContain('"prod.example.com"')
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ 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 { unlockProForTests } from '../helpers/entitlement'
|
||||
import { mockDesktopApi } from '../setup'
|
||||
|
||||
const sessions: Session[] = [
|
||||
@@ -14,6 +15,7 @@ const sessions: Session[] = [
|
||||
describe('batch session operations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
unlockProForTests()
|
||||
window.opsxApi = mockDesktopApi
|
||||
vi.mocked(mockDesktopApi.sessionGetAll).mockResolvedValueOnce({ success: true, sessions })
|
||||
useSessionStore.setState({ sessions: [] })
|
||||
@@ -23,7 +25,7 @@ describe('batch session operations', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('selects multiple saved sessions and opens connected terminal tabs', async () => {
|
||||
it('selects multiple saved sessions and opens batch exec', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Sidebar
|
||||
@@ -38,13 +40,20 @@ describe('batch session operations', () => {
|
||||
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(screen.getByText(/已选 2 个 SSH/)).toBeVisible()
|
||||
expect(screen.queryByRole('button', { name: /打开终端/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /打开 SFTP/ })).not.toBeInTheDocument()
|
||||
|
||||
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 }),
|
||||
]))
|
||||
await user.click(screen.getByRole('button', { name: '批量执行' }))
|
||||
|
||||
expect(useTabStore.getState().tabs).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'batch',
|
||||
batchSessionIds: ['prod-a', 'prod-b'],
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('opens SFTP only from the saved session context menu', async () => {
|
||||
@@ -59,10 +68,10 @@ describe('batch session operations', () => {
|
||||
|
||||
const sessionName = await screen.findByText('Prod A')
|
||||
expect(screen.queryByTitle('SFTP')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('SFTP 文件')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('SFTP')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.contextMenu(sessionName)
|
||||
await user.click(screen.getByRole('button', { name: 'SFTP 文件' }))
|
||||
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,52 @@
|
||||
/**
|
||||
* HostKeyDialog — first-connect / changed-key confirmation
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import HostKeyDialog from '../../src/components/terminal/HostKeyDialog'
|
||||
|
||||
describe('HostKeyDialog', () => {
|
||||
it('emits accept-always for a new host key', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onDecision = vi.fn()
|
||||
render(
|
||||
<HostKeyDialog
|
||||
open
|
||||
host="ops.example.com"
|
||||
port={22}
|
||||
keyType="ssh-ed25519"
|
||||
fingerprint="AAAA"
|
||||
isChanged={false}
|
||||
onDecision={onDecision}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('确认主机密钥')).toBeVisible()
|
||||
await user.click(screen.getByRole('button', { name: '始终信任' }))
|
||||
expect(onDecision).toHaveBeenCalledWith('accept-always')
|
||||
})
|
||||
|
||||
it('shows previous fingerprint when the key changed', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onDecision = vi.fn()
|
||||
render(
|
||||
<HostKeyDialog
|
||||
open
|
||||
host="ops.example.com"
|
||||
port={2222}
|
||||
keyType="ssh-rsa"
|
||||
fingerprint="NEW"
|
||||
isChanged
|
||||
previousFingerprint="OLD"
|
||||
onDecision={onDecision}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('主机密钥已变更')).toBeVisible()
|
||||
expect(screen.getByText(/SHA256:OLD/)).toBeVisible()
|
||||
await user.click(screen.getByRole('button', { name: '仍要信任并更新' }))
|
||||
expect(onDecision).toHaveBeenCalledWith('accept-always')
|
||||
})
|
||||
})
|
||||
@@ -95,6 +95,8 @@ describe('Quick launcher and serial acceptance', () => {
|
||||
stopBits: 2,
|
||||
parity: 'even',
|
||||
flowControl: 'hardware',
|
||||
lineEnding: 'lf',
|
||||
localEcho: false,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ describe('SFTP basic transfer acceptance', () => {
|
||||
await screen.findByRole('button', { name: '上传' })
|
||||
await user.click(screen.getByRole('button', { name: '上传' }))
|
||||
|
||||
expect(await screen.findByText(/Permission denied/)).toBeVisible()
|
||||
expect(await screen.findByText(/权限不足|Permission denied/)).toBeVisible()
|
||||
})
|
||||
|
||||
it('uploads by dragging a local row onto the remote panel', async () => {
|
||||
|
||||
@@ -25,13 +25,13 @@ describe('Session workflow acceptance', () => {
|
||||
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')
|
||||
await user.type(screen.getByPlaceholderText('自动用 user@host 或 vnc:host'), 'OpsBase 验收')
|
||||
await user.clear(screen.getByPlaceholderText('192.168.1.10'))
|
||||
await user.type(screen.getByPlaceholderText('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.selectOptions(screen.getByLabelText('分组'), '__new__')
|
||||
await user.type(screen.getByPlaceholderText('如:生产环境'), '验收环境')
|
||||
await user.click(screen.getByRole('button', { name: '创建' }))
|
||||
await user.click(screen.getByRole('button', { name: '创建并保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -64,8 +64,8 @@ describe('Session workflow acceptance', () => {
|
||||
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.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: '创建并保存' }))
|
||||
|
||||
@@ -88,9 +88,11 @@ describe('Session workflow acceptance', () => {
|
||||
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.clear(screen.getByLabelText('主机'))
|
||||
await user.type(screen.getByLabelText('主机'), 'root@opsbase.cn')
|
||||
await user.click(screen.getByRole('button', { name: '密钥' }))
|
||||
expect(screen.getByRole('button', { name: '文件' })).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: '文本' })).toBeVisible()
|
||||
await user.clear(screen.getByLabelText('私钥路径'))
|
||||
await user.type(screen.getByLabelText('私钥路径'), '~/.ssh/id_ed25519')
|
||||
await user.type(screen.getByLabelText('密钥口令(可选)'), 'key-secret')
|
||||
@@ -109,12 +111,39 @@ describe('Session workflow acceptance', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('saves pasted PEM private key as text content', async () => {
|
||||
const user = userEvent.setup()
|
||||
const pem = [
|
||||
'-----BEGIN OPENSSH PRIVATE KEY-----',
|
||||
'b3BlbnNzaC1rZXktdjEAAAAABG5vbmU=',
|
||||
'-----END OPENSSH PRIVATE KEY-----',
|
||||
].join('\n')
|
||||
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.click(screen.getByRole('button', { name: '文本' }))
|
||||
await user.click(screen.getByLabelText('私钥内容'))
|
||||
await user.paste(pem)
|
||||
await user.click(screen.getByRole('button', { name: '创建并保存' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDesktopApi.sessionCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authType: 'key',
|
||||
privateKey: pem,
|
||||
password: undefined,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
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.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')
|
||||
@@ -153,8 +182,8 @@ describe('Session workflow acceptance', () => {
|
||||
})
|
||||
render(<SessionDialog isOpen onClose={vi.fn()} editSession={null} />)
|
||||
|
||||
await user.clear(screen.getByLabelText('目标'))
|
||||
await user.type(screen.getByLabelText('目标'), 'root@internal.example.com')
|
||||
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: '创建并保存' }))
|
||||
@@ -201,7 +230,7 @@ describe('Session workflow acceptance', () => {
|
||||
|
||||
quick.unmount()
|
||||
render(<SessionDialog isOpen onClose={vi.fn()} editSession={null} />)
|
||||
const sessionTarget = screen.getByLabelText('目标')
|
||||
const sessionTarget = screen.getByLabelText('主机')
|
||||
await user.clear(sessionTarget)
|
||||
await user.paste('admin@example.com:2200')
|
||||
expect(sessionTarget).toHaveValue('admin@example.com:2200')
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
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', 'conn-9')).toBe('portForwards_conn_conn-9')
|
||||
expect(portForwardStorageKey(undefined, 'tab-1')).toBe('portForwards_tab_tab-1')
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { I18N_NAMESPACES } from '../../src/i18n'
|
||||
import { APP_LANGUAGES, type AppLanguage } from '../../src/i18n/detectLocale'
|
||||
|
||||
function flattenKeys(value: unknown, prefix = ''): string[] {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return prefix ? [prefix] : []
|
||||
}
|
||||
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
if (entries.length === 0) return prefix ? [prefix] : []
|
||||
|
||||
return entries.flatMap(([key, nested]) => {
|
||||
const next = prefix ? `${prefix}.${key}` : key
|
||||
if (nested !== null && typeof nested === 'object' && !Array.isArray(nested)) {
|
||||
const childKeys = flattenKeys(nested, next)
|
||||
return childKeys.length > 0 ? childKeys : [next]
|
||||
}
|
||||
return [next]
|
||||
})
|
||||
}
|
||||
|
||||
async function loadLocale(namespace: string, language: AppLanguage) {
|
||||
const mod = await import(`../../src/i18n/locales/${language}/${namespace}.json`)
|
||||
return mod.default as Record<string, unknown>
|
||||
}
|
||||
|
||||
describe('i18n locale key parity', () => {
|
||||
for (const namespace of I18N_NAMESPACES) {
|
||||
for (const language of APP_LANGUAGES) {
|
||||
it(`${language} keys match en for ${namespace}`, async () => {
|
||||
const [locale, en] = await Promise.all([
|
||||
loadLocale(namespace, language),
|
||||
loadLocale(namespace, 'en'),
|
||||
])
|
||||
|
||||
const localeKeys = flattenKeys(locale).sort()
|
||||
const enKeys = flattenKeys(en).sort()
|
||||
|
||||
expect(localeKeys).toEqual(enKeys)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
+16
-1
@@ -4,8 +4,16 @@
|
||||
|
||||
import '@testing-library/jest-dom'
|
||||
import { vi } from 'vitest'
|
||||
import i18n from '../src/i18n'
|
||||
import { useSettingsStore } from '../src/stores/settingsStore'
|
||||
import type { DesktopApiContract } from '../src/desktop'
|
||||
|
||||
void i18n.changeLanguage('zh-CN')
|
||||
useSettingsStore.setState((state) => ({
|
||||
settings: { ...state.settings, language: 'zh-CN' },
|
||||
isLoaded: state.isLoaded,
|
||||
}))
|
||||
|
||||
// Mock native desktop API
|
||||
export const mockDesktopApi: DesktopApiContract = {
|
||||
takePendingSshLinks: vi.fn().mockResolvedValue([]),
|
||||
@@ -16,14 +24,17 @@ export const mockDesktopApi: DesktopApiContract = {
|
||||
nativeBackend: 'Tauri 2 / Rust',
|
||||
sshBackend: 'libssh2 / ssh2 0.9',
|
||||
dataPath: '/tmp/opsxshell-data.json',
|
||||
credentialPath: '/tmp/.opsxshsell/master.key',
|
||||
credentialPath: '/tmp/.opsxshell/master.key',
|
||||
}),
|
||||
// SSH
|
||||
sshConnect: vi.fn().mockResolvedValue({ success: true, connectionId: 'test-conn-1' }),
|
||||
sshDisconnect: vi.fn().mockResolvedValue({ success: true }),
|
||||
sshExec: vi.fn().mockResolvedValue({ success: true, result: '', exitCode: 0 }),
|
||||
sshExecCancel: vi.fn().mockResolvedValue({ success: true }),
|
||||
sshHostMetrics: vi.fn().mockResolvedValue({ success: true, metrics: { disks: [] } }),
|
||||
sshKeepalive: vi.fn().mockResolvedValue({ success: true }),
|
||||
sshCreateShell: vi.fn().mockResolvedValue({ success: true, shellId: 'test-shell-1' }),
|
||||
sshShellOpenOutput: vi.fn().mockResolvedValue({ success: true, data: [] }),
|
||||
sshShellWrite: vi.fn().mockResolvedValue({ success: true }),
|
||||
sshShellResize: vi.fn().mockResolvedValue({ success: true }),
|
||||
sshCloseShell: vi.fn().mockResolvedValue({ success: true }),
|
||||
@@ -37,7 +48,9 @@ export const mockDesktopApi: DesktopApiContract = {
|
||||
sftpConnect: vi.fn().mockResolvedValue({ success: true, sftpId: 'test-sftp-1' }),
|
||||
sftpListDirectory: vi.fn().mockResolvedValue({ success: true, files: [] }),
|
||||
sftpUpload: vi.fn().mockResolvedValue({ success: true }),
|
||||
sftpUploadDir: vi.fn().mockResolvedValue({ success: true }),
|
||||
sftpDownload: vi.fn().mockResolvedValue({ success: true }),
|
||||
sftpDownloadDir: vi.fn().mockResolvedValue({ success: true }),
|
||||
sftpRename: vi.fn().mockResolvedValue({ success: true }),
|
||||
sftpDelete: vi.fn().mockResolvedValue({ success: true }),
|
||||
sftpMkdir: vi.fn().mockResolvedValue({ success: true }),
|
||||
@@ -106,6 +119,7 @@ export const mockDesktopApi: DesktopApiContract = {
|
||||
localMkdir: vi.fn().mockResolvedValue({ success: true }),
|
||||
getHomePath: vi.fn().mockResolvedValue({ success: true, path: '/Users/test' }),
|
||||
getDownloadsPath: vi.fn().mockResolvedValue('/Users/test/Downloads'),
|
||||
openDownloadsFolder: vi.fn().mockResolvedValue(undefined),
|
||||
|
||||
// App
|
||||
getVersion: vi.fn().mockResolvedValue('1.0.0'),
|
||||
@@ -132,6 +146,7 @@ export const mockDesktopApi: DesktopApiContract = {
|
||||
localResize: vi.fn().mockResolvedValue({ success: true }),
|
||||
localKill: vi.fn().mockResolvedValue({ success: true }),
|
||||
onLocalData: vi.fn(),
|
||||
onLocalExit: vi.fn(),
|
||||
|
||||
// Event listeners
|
||||
onShellData: vi.fn(),
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { normalizeAppSettings } from '../../src/stores/settingsStore'
|
||||
import { isAppLanguage } from '../../src/i18n/detectLocale'
|
||||
|
||||
describe('settings migration', () => {
|
||||
it('drops obsolete legacy settings while preserving supported values', () => {
|
||||
beforeEach(() => {
|
||||
// language default depends on navigator; tests assert explicit values
|
||||
})
|
||||
|
||||
it('keeps supported language and drops obsolete legacy settings', () => {
|
||||
const settings = normalizeAppSettings({
|
||||
theme: 'light',
|
||||
timeout: 45,
|
||||
language: 'en-US',
|
||||
language: 'en',
|
||||
showNotifications: true,
|
||||
encryptPasswords: false,
|
||||
lockOnSleep: true,
|
||||
@@ -14,9 +19,14 @@ describe('settings migration', () => {
|
||||
|
||||
expect(settings.theme).toBe('light')
|
||||
expect(settings.timeout).toBe(45)
|
||||
expect(settings).not.toHaveProperty('language')
|
||||
expect(settings.language).toBe('en')
|
||||
expect(settings).not.toHaveProperty('showNotifications')
|
||||
expect(settings).not.toHaveProperty('encryptPasswords')
|
||||
expect(settings).not.toHaveProperty('lockOnSleep')
|
||||
})
|
||||
|
||||
it('falls back to a valid language when stored value is invalid', () => {
|
||||
const settings = normalizeAppSettings({ language: 'fr-FR' })
|
||||
expect(isAppLanguage(settings.language)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getTerminalBroadcastTargets,
|
||||
selectBroadcastTargetCount,
|
||||
terminalBroadcastTargetCount,
|
||||
useTerminalBroadcastStore,
|
||||
} from '../../src/stores/terminalBroadcastStore'
|
||||
@@ -8,7 +9,40 @@ import {
|
||||
describe('terminal broadcast store', () => {
|
||||
beforeEach(() => useTerminalBroadcastStore.getState().reset())
|
||||
|
||||
it('collects ready shells from every open terminal workspace and removes closed tabs', () => {
|
||||
it('defaults scope to current-tab', () => {
|
||||
expect(useTerminalBroadcastStore.getState().scope).toBe('current-tab')
|
||||
})
|
||||
|
||||
it('collects ready shells from every open terminal workspace when scope is all', () => {
|
||||
const store = useTerminalBroadcastStore.getState()
|
||||
store.setScope('all')
|
||||
store.registerWorkspace({
|
||||
tabId: 'tab-a',
|
||||
targets: [
|
||||
{ tabId: 'tab-a', paneId: 'left', shellId: 'shell-a', connectionId: 'conn-a', label: 'Host A' },
|
||||
{ tabId: 'tab-a', paneId: 'right', shellId: 'shell-b', connectionId: 'conn-a', label: 'Host A' },
|
||||
],
|
||||
})
|
||||
store.registerWorkspace({
|
||||
tabId: 'tab-b',
|
||||
targets: [
|
||||
{ tabId: 'tab-b', paneId: 'main', shellId: 'shell-c', connectionId: 'local', label: 'Local' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(getTerminalBroadcastTargets().map((target) => target.shellId)).toEqual([
|
||||
'shell-a',
|
||||
'shell-b',
|
||||
'shell-c',
|
||||
])
|
||||
expect(terminalBroadcastTargetCount(useTerminalBroadcastStore.getState())).toBe(3)
|
||||
expect(selectBroadcastTargetCount('tab-a')(useTerminalBroadcastStore.getState())).toBe(3)
|
||||
|
||||
useTerminalBroadcastStore.getState().unregisterWorkspace('tab-a')
|
||||
expect(getTerminalBroadcastTargets().map((target) => target.shellId)).toEqual(['shell-c'])
|
||||
})
|
||||
|
||||
it('limits targets to the source tab when scope is current-tab', () => {
|
||||
const store = useTerminalBroadcastStore.getState()
|
||||
store.registerWorkspace({
|
||||
tabId: 'tab-a',
|
||||
@@ -24,15 +58,13 @@ describe('terminal broadcast store', () => {
|
||||
],
|
||||
})
|
||||
|
||||
expect(getTerminalBroadcastTargets().map((target) => target.shellId)).toEqual([
|
||||
'shell-a',
|
||||
'shell-b',
|
||||
'shell-c',
|
||||
])
|
||||
expect(terminalBroadcastTargetCount(useTerminalBroadcastStore.getState())).toBe(3)
|
||||
|
||||
useTerminalBroadcastStore.getState().unregisterWorkspace('tab-a')
|
||||
expect(getTerminalBroadcastTargets().map((target) => target.shellId)).toEqual(['shell-c'])
|
||||
expect(getTerminalBroadcastTargets('tab-a').map((t) => t.shellId)).toEqual(['shell-a', 'shell-b'])
|
||||
expect(getTerminalBroadcastTargets('tab-b').map((t) => t.shellId)).toEqual(['shell-c'])
|
||||
const latest = useTerminalBroadcastStore.getState()
|
||||
expect(selectBroadcastTargetCount('tab-a')(latest)).toBe(2)
|
||||
expect(selectBroadcastTargetCount('tab-b')(latest)).toBe(1)
|
||||
// Without source tab id, current-tab must not silently expand to all tabs
|
||||
expect(getTerminalBroadcastTargets()).toEqual([])
|
||||
})
|
||||
|
||||
it('deduplicates a shell defensively', () => {
|
||||
@@ -43,6 +75,6 @@ describe('terminal broadcast store', () => {
|
||||
{ tabId: 'tab-a', paneId: 'b', shellId: 'shared', connectionId: 'conn-a', label: 'A' },
|
||||
],
|
||||
})
|
||||
expect(getTerminalBroadcastTargets()).toHaveLength(1)
|
||||
expect(getTerminalBroadcastTargets('tab-a')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { analyzeAgentOutput } from '../../src/utils/agentOutput'
|
||||
import {
|
||||
agentCommandAllowFingerprint,
|
||||
agentCommandNeedsConfirmation,
|
||||
assessAgentCommandRisk,
|
||||
} from '../../src/utils/agentCommandRisk'
|
||||
@@ -21,13 +22,22 @@ describe('Agent command guardrail', () => {
|
||||
expect(assessAgentCommandRisk(command)).toBe(expected)
|
||||
})
|
||||
|
||||
it('uses strict, balanced and permissive confirmation thresholds', () => {
|
||||
it('uses strict and relaxed confirmation thresholds', () => {
|
||||
expect(agentCommandNeedsConfirmation('safe', 'strict')).toBe(false)
|
||||
expect(agentCommandNeedsConfirmation('medium', 'strict')).toBe(true)
|
||||
expect(agentCommandNeedsConfirmation('medium', 'balanced')).toBe(false)
|
||||
expect(agentCommandNeedsConfirmation('high', 'balanced')).toBe(true)
|
||||
expect(agentCommandNeedsConfirmation('high', 'permissive')).toBe(false)
|
||||
expect(agentCommandNeedsConfirmation('critical', 'permissive')).toBe(false)
|
||||
expect(agentCommandNeedsConfirmation('medium', 'relaxed')).toBe(false)
|
||||
expect(agentCommandNeedsConfirmation('high', 'relaxed')).toBe(true)
|
||||
expect(agentCommandNeedsConfirmation('critical', 'relaxed')).toBe(false)
|
||||
expect(agentCommandNeedsConfirmation('critical', 'strict')).toBe(false)
|
||||
})
|
||||
|
||||
it('fingerprints similar temp-path commands for session Always', () => {
|
||||
expect(agentCommandAllowFingerprint('rm /tmp/tmp.3RUZRHTwk2', 'high')).toBe(
|
||||
agentCommandAllowFingerprint('rm /tmp/tmp.ABCDEFG123', 'high')
|
||||
)
|
||||
expect(agentCommandAllowFingerprint('rm /tmp/tmp.3RUZRHTwk2', 'high')).not.toBe(
|
||||
agentCommandAllowFingerprint('rm -rf /var/log/nginx', 'high')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { normalizeDistroId, parseOsPrettyName, parseOsReleaseOrUname, resolveSessionDistro } from '../../src/utils/hostDistro'
|
||||
|
||||
describe('hostDistro', () => {
|
||||
it('normalizes common distro ids', () => {
|
||||
expect(normalizeDistroId('ubuntu')).toBe('ubuntu')
|
||||
expect(normalizeDistroId('deepin')).toBe('deepin')
|
||||
expect(normalizeDistroId('uos')).toBe('deepin')
|
||||
expect(normalizeDistroId('rocky')).toBe('rocky')
|
||||
expect(normalizeDistroId('rhel')).toBe('redhat')
|
||||
expect(normalizeDistroId('openEuler')).toBe('openeuler')
|
||||
expect(normalizeDistroId('anolis')).toBe('anolis')
|
||||
expect(normalizeDistroId('alinux')).toBe('alinux')
|
||||
expect(normalizeDistroId('amzn2')).toBe('amazon')
|
||||
expect(normalizeDistroId('Amazon Linux')).toBe('amazon')
|
||||
expect(normalizeDistroId('Alibaba Cloud Linux')).toBe('alinux')
|
||||
expect(normalizeDistroId('Linux')).toBe('linux')
|
||||
})
|
||||
|
||||
it('parses cloud linux from os-release NAME when needed', () => {
|
||||
expect(
|
||||
parseOsReleaseOrUname(`NAME="Alibaba Cloud Linux"
|
||||
ID=alinux
|
||||
VERSION_ID="3"`)
|
||||
).toBe('alinux')
|
||||
expect(
|
||||
parseOsReleaseOrUname(`NAME="Amazon Linux"
|
||||
ID=amzn
|
||||
VERSION_ID="2023"`)
|
||||
).toBe('amazon')
|
||||
})
|
||||
|
||||
it('parses os-release ID', () => {
|
||||
const sample = `NAME="Ubuntu"
|
||||
VERSION="22.04"
|
||||
ID=ubuntu
|
||||
ID_LIKE=debian
|
||||
`
|
||||
expect(parseOsReleaseOrUname(sample)).toBe('ubuntu')
|
||||
})
|
||||
|
||||
it('maps TencentOS to centos icon family', () => {
|
||||
expect(normalizeDistroId('tencentos')).toBe('centos')
|
||||
expect(
|
||||
parseOsReleaseOrUname(`NAME="TencentOS Server"
|
||||
ID="tencentos"
|
||||
VERSION_ID="4"`)
|
||||
).toBe('centos')
|
||||
})
|
||||
|
||||
it('parses PRETTY_NAME', () => {
|
||||
expect(
|
||||
parseOsPrettyName(`NAME="Ubuntu"
|
||||
PRETTY_NAME="Ubuntu 24.04.4 LTS"
|
||||
ID=ubuntu`)
|
||||
).toBe('Ubuntu 24.04.4 LTS')
|
||||
})
|
||||
|
||||
it('resolves protocol defaults for desktop sessions', () => {
|
||||
expect(resolveSessionDistro({ protocol: 'rdp' })).toBe('windows')
|
||||
expect(resolveSessionDistro({ protocol: 'vnc' })).toBe('vnc')
|
||||
expect(resolveSessionDistro({ protocol: 'ssh', distro: 'debian' })).toBe('debian')
|
||||
expect(resolveSessionDistro({ protocol: 'ssh' })).toBe('linux')
|
||||
expect(resolveSessionDistro({})).toBe('linux')
|
||||
})
|
||||
})
|
||||
@@ -49,6 +49,6 @@ describe('platformShells', () => {
|
||||
it('uses CRLF enter for windows shells', () => {
|
||||
expect(localShellEnterKey('cmd')).toBe('\r\n')
|
||||
expect(localShellEnterKey('pwsh')).toBe('\r\n')
|
||||
expect(localShellEnterKey('bash')).toBe('\n')
|
||||
expect(localShellEnterKey('bash')).toBe('\r')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
decryptSessionBackup,
|
||||
encryptSessionBackup,
|
||||
isEncryptedSessionBackup,
|
||||
} from '../../src/utils/sessionBackupCrypto'
|
||||
|
||||
describe('sessionBackupCrypto', () => {
|
||||
it('encrypts and decrypts UTF-8 session payload', async () => {
|
||||
const plaintext = JSON.stringify({
|
||||
version: 1,
|
||||
sessions: [{ name: '生产', host: 'ops.example.com', privateKey: '-----BEGIN KEY-----\n密\n' }],
|
||||
})
|
||||
const encrypted = await encryptSessionBackup(plaintext, 's3cret!')
|
||||
const envelope = JSON.parse(encrypted)
|
||||
expect(isEncryptedSessionBackup(envelope)).toBe(true)
|
||||
expect(encrypted).not.toContain('ops.example.com')
|
||||
expect(encrypted).not.toContain('BEGIN KEY')
|
||||
|
||||
const restored = await decryptSessionBackup(envelope, 's3cret!')
|
||||
expect(restored).toBe(plaintext)
|
||||
})
|
||||
|
||||
it('rejects wrong password', async () => {
|
||||
const encrypted = await encryptSessionBackup('{"sessions":[]}', 'right-password')
|
||||
const envelope = JSON.parse(encrypted)
|
||||
await expect(decryptSessionBackup(envelope, 'wrong-password')).rejects.toThrow(
|
||||
'BACKUP_PASSWORD_INVALID'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { expandSnippetVariables } from '../../src/utils/snippetVariables'
|
||||
import { applyKeywordHighlights, DEFAULT_KEYWORD_HIGHLIGHT_RULES } from '../../src/utils/keywordHighlight'
|
||||
import {
|
||||
StreamingKeywordHighlight,
|
||||
trailingIncompleteEscape,
|
||||
} from '../../src/utils/streamingKeywordHighlight'
|
||||
import { localShellEnterKey, normalizeTerminalInject } from '../../src/utils/platformShells'
|
||||
|
||||
describe('expandSnippetVariables', () => {
|
||||
it('replaces known placeholders', () => {
|
||||
@@ -35,4 +40,87 @@ describe('applyKeywordHighlights', () => {
|
||||
const text = 'no highlight'
|
||||
expect(applyKeywordHighlights(text, rules)).toBe(text)
|
||||
})
|
||||
|
||||
it('does not inject SGR inside OSC window-title sequences (SSH title leak)', () => {
|
||||
const rules = [
|
||||
{
|
||||
id: 'ipv4',
|
||||
label: 'IPv4',
|
||||
pattern: '\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b',
|
||||
isRegex: true,
|
||||
color: '#8be9fd',
|
||||
enabled: true,
|
||||
},
|
||||
]
|
||||
const osc = '\x1b]0;ssh root@165.154.155.48\x07'
|
||||
const out = applyKeywordHighlights(`${osc}Welcome to Ubuntu`, rules)
|
||||
expect(out).toContain(osc)
|
||||
expect(out).not.toMatch(/\x1b\]0;.*\x1b\[38/)
|
||||
expect(out).toContain('Welcome to Ubuntu')
|
||||
})
|
||||
|
||||
it('still highlights IPs in plain text when the ipv4 rule is enabled', () => {
|
||||
const rules = [
|
||||
{
|
||||
id: 'ipv4',
|
||||
label: 'IPv4',
|
||||
pattern: '\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b',
|
||||
isRegex: true,
|
||||
color: '#8be9fd',
|
||||
enabled: true,
|
||||
},
|
||||
]
|
||||
const out = applyKeywordHighlights('peer 10.0.0.1 online', rules)
|
||||
expect(out).toContain('\x1b[38;2;')
|
||||
expect(out).toContain('10.0.0.1')
|
||||
expect(out).toContain('\x1b[39m')
|
||||
})
|
||||
|
||||
it('leaves existing CSI color sequences intact', () => {
|
||||
const colored = '\x1b[01;32mroot\x1b[00m@host'
|
||||
const out = applyKeywordHighlights(colored, DEFAULT_KEYWORD_HIGHLIGHT_RULES)
|
||||
expect(out).toContain('\x1b[01;32mroot\x1b[00m')
|
||||
})
|
||||
})
|
||||
|
||||
describe('StreamingKeywordHighlight', () => {
|
||||
const ipv4Rules = [
|
||||
{
|
||||
id: 'ipv4',
|
||||
label: 'IPv4',
|
||||
pattern: '\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b',
|
||||
isRegex: true,
|
||||
color: '#8be9fd',
|
||||
enabled: true,
|
||||
},
|
||||
]
|
||||
|
||||
it('holds incomplete OSC across chunks', () => {
|
||||
expect(trailingIncompleteEscape('\x1b]0;ssh root@1.2.3.4')).toBe('\x1b]0;ssh root@1.2.3.4')
|
||||
const stream = new StreamingKeywordHighlight()
|
||||
expect(stream.push('\x1b]0;ssh root@1.2.3.4', ipv4Rules)).toBe('')
|
||||
const done = stream.push('\x07Welcome', ipv4Rules)
|
||||
expect(done).toContain('\x1b]0;ssh root@1.2.3.4\x07')
|
||||
expect(done).toContain('Welcome')
|
||||
expect(done).not.toMatch(/\x1b\]0;.*\x1b\[38/)
|
||||
})
|
||||
|
||||
it('holds incomplete CSI across chunks', () => {
|
||||
const stream = new StreamingKeywordHighlight()
|
||||
expect(stream.push('\x1b[38;2;139', ipv4Rules)).toBe('')
|
||||
const out = stream.push(';233;253m10.0.0.1 done', ipv4Rules)
|
||||
expect(out.startsWith('\x1b[38;2;139;233;253m')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeTerminalInject', () => {
|
||||
it('converts LF to CR like xterm paste', () => {
|
||||
expect(normalizeTerminalInject('echo hi\n', '')).toBe('echo hi\r')
|
||||
expect(normalizeTerminalInject('echo hi', '\r')).toBe('echo hi\r')
|
||||
})
|
||||
|
||||
it('uses CR enter for unix local shells', () => {
|
||||
expect(localShellEnterKey('zsh')).toBe('\r')
|
||||
expect(localShellEnterKey('pwsh')).toBe('\r\n')
|
||||
})
|
||||
})
|
||||
@@ -27,10 +27,10 @@ describe('terminal pane input routing', () => {
|
||||
expect(resolveTerminalInputTargets(panes, 'missing', false)).toEqual([])
|
||||
})
|
||||
|
||||
it('never broadcasts input originating from a local terminal', () => {
|
||||
expect(shouldBroadcastTerminalInput(true, true, 'local')).toBe(false)
|
||||
expect(shouldBroadcastTerminalInput(true, true, 'ssh-connection')).toBe(false)
|
||||
it('broadcasts from both SSH and local terminals when enabled', () => {
|
||||
expect(shouldBroadcastTerminalInput(true, true, 'local')).toBe(true)
|
||||
expect(shouldBroadcastTerminalInput(true, false, 'ssh-connection')).toBe(true)
|
||||
expect(shouldBroadcastTerminalInput(true, false, null)).toBe(false)
|
||||
expect(shouldBroadcastTerminalInput(false, false, 'ssh-connection')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decodeBase64ToText, encodeTextToBase64 } from '../../src/utils/textBase64'
|
||||
|
||||
describe('textBase64', () => {
|
||||
it('round-trips UTF-8 including CJK', () => {
|
||||
const sample = '{"name":"运维主机","host":"ops.example.com"}'
|
||||
expect(decodeBase64ToText(encodeTextToBase64(sample))).toBe(sample)
|
||||
})
|
||||
|
||||
it('produces standard base64 without raw JSON', () => {
|
||||
const encoded = encodeTextToBase64('{"version":1}')
|
||||
expect(encoded).not.toContain('{')
|
||||
expect(encoded).toMatch(/^[A-Za-z0-9+/=]+$/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseSzFiles } from '../../src/utils/zmodemSftpBridge'
|
||||
|
||||
describe('parseSzFiles', () => {
|
||||
it('parses simple sz command', () => {
|
||||
expect(parseSzFiles('sz app.log\r')).toEqual(['app.log'])
|
||||
})
|
||||
|
||||
it('parses multiple files and skips flags', () => {
|
||||
expect(parseSzFiles('sz -y a.txt b.bin\n')).toEqual(['a.txt', 'b.bin'])
|
||||
})
|
||||
|
||||
it('uses the latest command line', () => {
|
||||
expect(parseSzFiles('sz old\rsz newer.tar.gz\r')).toEqual(['newer.tar.gz'])
|
||||
})
|
||||
|
||||
it('returns empty when no sz command', () => {
|
||||
expect(parseSzFiles('ls\rrz\r')).toEqual([])
|
||||
})
|
||||
|
||||
it('strips bracketed-paste markers', () => {
|
||||
expect(parseSzFiles('\x1b[200~sz report.txt\x1b[201~\r')).toEqual(['report.txt'])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user