feat: release OpsXShell 2.1.0 native Tauri edition

This commit is contained in:
Logan.Li committed 2026-07-30 23:32:51 +08:00
1 parent e9a0d21758
commit a3dba0c6ed
311 files changed
+31748 -14897

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { extractBashBlocks, singleLineTerminalInsert, terminalContextLineCount } from '../../src/utils/aiResponse'
describe('terminal AI response guards', () => {
it('extracts shell code without treating prose as a command', () => {
expect(extractBashBlocks('检查:\n```bash\nuname -a\n```')).toEqual(['uname -a'])
expect(extractBashBlocks('请运行 uname -a')).toEqual([])
})
it('only permits a single line and never adds enter', () => {
expect(singleLineTerminalInsert('date\n')).toBe('date')
expect(singleLineTerminalInsert('date\nuname -a')).toBeNull()
expect(singleLineTerminalInsert('')).toBeNull()
})
it('counts exactly the context lines shown to the user', () => {
expect(terminalContextLineCount('a\nb\n')).toBe(2)
expect(terminalContextLineCount('')).toBe(0)
})
})
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it, vi } from 'vitest'
import { ErrorCode, handleError } from '../../src/utils/error-handler'
vi.mock('../../src/utils/logger', () => ({
logger: { error: vi.fn() },
}))
describe('connection error classification', () => {
it.each([
'connection timed out',
'SSH handshake timeout',
'连接超时',
])('classifies timeout: %s', (message) => {
expect(handleError(new Error(message)).code).toBe(ErrorCode.CONNECTION_TIMEOUT)
})
it.each([
'getaddrinfo ENOTFOUND host.invalid',
'无法解析主机 host.invalid',
])('classifies DNS failures: %s', (message) => {
expect(handleError(new Error(message)).code).toBe(ErrorCode.HOST_UNREACHABLE)
})
it('classifies connection refusal separately from timeout', () => {
expect(handleError(new Error('Connection refused')).code).toBe(ErrorCode.CONNECTION_FAILED)
})
})
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { guessLanguageFromPath } from '../../src/utils/fileLanguage'
describe('guessLanguageFromPath', () => {
it('detects common file extensions for the SFTP editor', () => {
expect(guessLanguageFromPath('/a/b/c.ts')).toBe('typescript')
expect(guessLanguageFromPath('Dockerfile')).toBe('dockerfile')
expect(guessLanguageFromPath('app.py')).toBe('python')
expect(guessLanguageFromPath('readme.md')).toBe('markdown')
})
})
+22
View File
@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest'
import type { JumpHostConfig, SSHConnectionConfig } from '../../shared/types/session.types'
describe('JumpHost types', () => {
it('allows jumpHost on connection config', () => {
const jump: JumpHostConfig = {
host: 'bastion.example.com',
port: 22,
username: 'ops',
authType: 'key',
privateKey: '~/.ssh/id_ed25519',
}
const config: SSHConnectionConfig = {
host: '10.0.0.5',
port: 22,
username: 'root',
jumpHost: jump,
}
expect(config.jumpHost?.host).toBe('bastion.example.com')
expect(config.host).toBe('10.0.0.5')
})
})
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { redactForLog } from '../../src/utils/logger'
describe('redactForLog', () => {
it('redacts nested credentials without mutating the source', () => {
const source = {
host: 'opsbase.cn',
password: 'hunter2',
auth: { passphrase: 'secret', username: 'root' },
}
expect(redactForLog(source)).toEqual({
host: 'opsbase.cn',
password: '[REDACTED]',
auth: { passphrase: '[REDACTED]', username: 'root' },
})
expect(source.password).toBe('hunter2')
})
it('redacts private keys, authorization values and URL passwords', () => {
expect(redactForLog('-----BEGIN OPENSSH PRIVATE KEY-----\nabc')).toBe('[REDACTED]')
expect(redactForLog('Bearer abc.def_123')).toBe('Bearer [REDACTED]')
expect(redactForLog('ssh://root:password@example.com')).toBe('ssh://root:[REDACTED]@example.com')
})
it('handles circular structures', () => {
const value: Record<string, unknown> = {}
value.self = value
expect(redactForLog(value)).toEqual({ self: '[Circular]' })
})
})
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { allowsNativeContextMenu, suppressWebViewContextMenu } from '../../src/utils/nativeContextMenu'
describe('native context menu policy', () => {
it('suppresses the WebView menu on ordinary application surfaces', () => {
const surface = document.createElement('div')
document.body.append(surface)
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true })
surface.addEventListener('contextmenu', suppressWebViewContextMenu)
surface.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
})
it('keeps native copy and paste actions for editable fields', () => {
const input = document.createElement('input')
const editor = document.createElement('div')
editor.setAttribute('contenteditable', 'true')
expect(allowsNativeContextMenu(input)).toBe(true)
expect(allowsNativeContextMenu(editor)).toBe(true)
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true })
input.addEventListener('contextmenu', suppressWebViewContextMenu)
input.dispatchEvent(event)
expect(event.defaultPrevented).toBe(false)
})
})
+40
View File
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest'
import { parseSshTarget } from '../../src/components/sessions/QuickConnectDialog'
describe('parseSshTarget', () => {
it('解析 host', () => {
expect(parseSshTarget('192.168.1.1')).toEqual({
host: '192.168.1.1',
port: 22,
username: 'root',
})
})
it('解析 user@host', () => {
expect(parseSshTarget('ubuntu@opsbase.cn')).toEqual({
host: 'opsbase.cn',
port: 22,
username: 'ubuntu',
})
})
it('解析 user@host:port', () => {
expect(parseSshTarget('root@opsbase.cn:5555')).toEqual({
host: 'opsbase.cn',
port: 5555,
username: 'root',
})
})
it('解析 host:port', () => {
expect(parseSshTarget('example.com:2222')).toEqual({
host: 'example.com',
port: 2222,
username: 'root',
})
})
it('空串返回 null', () => {
expect(parseSshTarget(' ')).toBeNull()
})
})
+31
View File
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest'
import { decodeShellPayload, shellPayloadByteLength } from '../../src/utils/shellData'
describe('decodeShellPayload', () => {
it('decodes number arrays as UTF-8 bytes (not Array.join)', () => {
// ESC [ ? 2 0 0 4 l → bracketed paste off
const bytes = [27, 91, 63, 50, 48, 48, 52, 108]
const text = decodeShellPayload(bytes)
expect(text).toBe('\x1b[?2004l')
expect(text).not.toContain(',')
})
it('decodes Buffer-like objects', () => {
const payload = { type: 'Buffer', data: [114, 111, 111, 116] } // "root"
expect(decodeShellPayload(payload)).toBe('root')
})
it('passes through strings', () => {
expect(decodeShellPayload('hello')).toBe('hello')
})
it('decodes Uint8Array', () => {
expect(decodeShellPayload(new Uint8Array([65, 66, 67]))).toBe('ABC')
})
})
describe('shellPayloadByteLength', () => {
it('counts array length', () => {
expect(shellPayloadByteLength([1, 2, 3])).toBe(3)
})
})
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest'
import { expandSnippetVariables } from '../../src/utils/snippetVariables'
import { applyKeywordHighlights, DEFAULT_KEYWORD_HIGHLIGHT_RULES } from '../../src/utils/keywordHighlight'
describe('expandSnippetVariables', () => {
it('replaces known placeholders', () => {
const out = expandSnippetVariables('ssh {{username}}@{{host}}:{{port}}', {
username: 'root',
host: '10.0.0.1',
port: 22,
})
expect(out).toBe('ssh root@10.0.0.1:22')
})
it('uses default when missing', () => {
const out = expandSnippetVariables('cd {{path:/tmp}}', {})
expect(out).toBe('cd /tmp')
})
it('keeps unknown placeholders without default', () => {
const out = expandSnippetVariables('echo {{unknown}}', {})
expect(out).toBe('echo {{unknown}}')
})
})
describe('applyKeywordHighlights', () => {
it('highlights error keywords', () => {
const out = applyKeywordHighlights('FATAL error happened', DEFAULT_KEYWORD_HIGHLIGHT_RULES)
expect(out).toContain('\x1b[')
expect(out).toContain('FATAL')
})
it('returns original when disabled rules only', () => {
const rules = DEFAULT_KEYWORD_HIGHLIGHT_RULES.map((r) => ({ ...r, enabled: false }))
const text = 'no highlight'
expect(applyKeywordHighlights(text, rules)).toBe(text)
})
})
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { parseSshConfig } from '../../src/utils/sshConfig'
describe('parseSshConfig', () => {
it('imports concrete hosts and common connection fields', () => {
const sessions = parseSshConfig(`
Host bastion
HostName gateway.example.com
User ops
IdentityFile ~/.ssh/ops_ed25519
Host production prod-short
HostName 10.10.0.8
Port 2222
User root
ProxyJump bastion
`)
expect(sessions).toEqual([
{
name: 'bastion',
host: 'gateway.example.com',
port: 22,
username: 'ops',
authType: 'key',
privateKey: '~/.ssh/ops_ed25519',
proxyJumpAlias: undefined,
group: 'SSH Config',
},
expect.objectContaining({
name: 'production',
host: '10.10.0.8',
port: 2222,
username: 'root',
authType: 'agent',
proxyJumpAlias: 'bastion',
}),
expect.objectContaining({ name: 'prod-short', proxyJumpAlias: 'bastion' }),
])
})
it('skips wildcard and negated host patterns', () => {
const sessions = parseSshConfig(`
Host *
ServerAliveInterval 30
Host !blocked *.internal
User deploy
Host usable
HostName usable.example.com
`)
expect(sessions).toHaveLength(1)
expect(sessions[0].name).toBe('usable')
})
})
+14
View File
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest'
import { formatSshLinkTarget } from '../../src/utils/sshLink'
describe('formatSshLinkTarget', () => {
it('formats default and custom ports', () => {
expect(formatSshLinkTarget({ host: 'example.com', port: 22, username: 'root' })).toBe('root@example.com')
expect(formatSshLinkTarget({ host: 'example.com', port: 2222, username: 'admin' })).toBe('admin@example.com:2222')
})
it('keeps IPv6 unambiguous', () => {
expect(formatSshLinkTarget({ host: '2001:db8::1', port: 22, username: 'root' })).toBe('root@[2001:db8::1]')
})
})
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest'
import { TerminalOutputBuffer } from '../../src/utils/terminalOutputBuffer'
function harness(options: { maxChunkChars?: number; maxPendingChars?: number } = {}) {
const callbacks: Array<() => void> = []
const write = vi.fn()
const buffer = new TerminalOutputBuffer({
write,
schedule: (callback) => {
callbacks.push(callback)
return callbacks.length
},
cancel: vi.fn(),
...options,
})
return { buffer, callbacks, write }
}
describe('TerminalOutputBuffer', () => {
it('coalesces events into one write per frame', () => {
const { buffer, callbacks, write } = harness()
buffer.enqueue('one')
buffer.enqueue('two')
expect(callbacks).toHaveLength(1)
callbacks.shift()?.()
expect(write).toHaveBeenCalledWith('onetwo')
})
it('preserves UTF-8 characters split across native events', () => {
const { buffer, callbacks, write } = harness()
const bytes = new TextEncoder().encode('中文')
buffer.enqueue(bytes.slice(0, 2))
buffer.enqueue(bytes.slice(2, 4))
buffer.enqueue(bytes.slice(4))
callbacks.shift()?.()
expect(write).toHaveBeenCalledWith('中文')
})
it('limits work per frame and schedules remaining output', () => {
const { buffer, callbacks, write } = harness({ maxChunkChars: 4 })
buffer.enqueue('abcdefgh')
callbacks.shift()?.()
expect(write).toHaveBeenLastCalledWith('abcd')
expect(callbacks).toHaveLength(1)
callbacks.shift()?.()
expect(write).toHaveBeenLastCalledWith('efgh')
})
it('bounds queued output and reports truncation', () => {
const { buffer, callbacks, write } = harness({ maxPendingChars: 4 })
buffer.enqueue('abcdefgh')
callbacks.shift()?.()
const output = write.mock.calls[0][0] as string
expect(output).toContain('output truncated')
expect(output).toContain('efgh')
expect(output).not.toContain('abcd')
})
})
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { resolveTerminalInputTargets } from '../../src/utils/terminalPanes'
const panes = [
{ id: 'left', shellId: 'shell-1' },
{ id: 'starting', shellId: null },
{ id: 'right', shellId: 'shell-3' },
]
describe('terminal pane input routing', () => {
it('routes normal input only to the source pane', () => {
expect(resolveTerminalInputTargets(panes, 'right', false)).toEqual(['shell-3'])
})
it('routes broadcast input to every ready shell in pane order', () => {
expect(resolveTerminalInputTargets(panes, 'left', true)).toEqual([
'shell-1',
'shell-3',
])
})
it('does not write when the source pane has no ready shell', () => {
expect(resolveTerminalInputTargets(panes, 'starting', false)).toEqual([])
expect(resolveTerminalInputTargets(panes, 'missing', false)).toEqual([])
})
})
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it, vi } from 'vitest'
import { TerminalPipeline } from '../../src/utils/terminalPipeline'
describe('TerminalPipeline', () => {
it('routes input and reports output bytes through one boundary', () => {
const input = vi.fn()
const write = vi.fn()
const onOutputBytes = vi.fn()
const scheduled: Array<() => void> = []
const pipeline = new TerminalPipeline({
input,
output: {
write,
schedule: (callback) => {
scheduled.push(callback)
return scheduled.length
},
cancel: vi.fn(),
},
onOutputBytes,
})
pipeline.input('date\r')
pipeline.output(Uint8Array.from([0x68, 0x69]))
scheduled.shift()?.()
expect(input).toHaveBeenCalledWith('date\r')
expect(onOutputBytes).toHaveBeenCalledWith(2)
expect(write).toHaveBeenCalledWith('hi')
})
it('keeps split UTF-8 output intact and catches async input failures', async () => {
const write = vi.fn()
const onError = vi.fn()
const scheduled: Array<() => void> = []
const pipeline = new TerminalPipeline({
input: () => Promise.reject(new Error('closed')),
output: {
write,
schedule: (callback) => {
scheduled.push(callback)
return scheduled.length
},
cancel: vi.fn(),
},
onError,
})
pipeline.input('x')
pipeline.output(Uint8Array.from([0xe4, 0xb8]))
pipeline.output(Uint8Array.from([0xad]))
scheduled.shift()?.()
await Promise.resolve()
expect(write).toHaveBeenCalledWith('中')
expect(onError).toHaveBeenCalledOnce()
})
})
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import {
isTerminalCopyShortcut,
isTerminalPasteShortcut,
isTerminalSplitShortcut,
} from '../../src/utils/terminalShortcuts'
function keyEvent(key: string, init: KeyboardEventInit = {}): KeyboardEvent {
return new KeyboardEvent('keydown', { key, ...init })
}
describe('terminal shortcuts', () => {
it('uses Cmd+C and Cmd+V on macOS', () => {
expect(isTerminalCopyShortcut(keyEvent('c', { metaKey: true }))).toBe(true)
expect(isTerminalPasteShortcut(keyEvent('v', { metaKey: true }))).toBe(true)
})
it('uses Ctrl+Shift+C and Ctrl+Shift+V outside macOS', () => {
expect(isTerminalCopyShortcut(keyEvent('C', { ctrlKey: true, shiftKey: true }))).toBe(true)
expect(isTerminalPasteShortcut(keyEvent('V', { ctrlKey: true, shiftKey: true }))).toBe(true)
})
it('uses Cmd+D for splitting', () => {
expect(isTerminalSplitShortcut(keyEvent('d', { metaKey: true }))).toBe(true)
})
it('does not consume Ctrl+D, which must reach the remote shell as EOF', () => {
expect(isTerminalSplitShortcut(keyEvent('d', { ctrlKey: true }))).toBe(false)
})
})