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,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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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]' })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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]')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user