feat: harden local shell, SFTP, and desktop session reliability

Finder-launched terminals now inherit a login PATH so tools like ollama resolve, destructive SFTP/editor actions require confirmation, and terminal search plus window dragging work again. SSH forwards and RDP sessions keep listeners and survive reconnect without FastPath crashes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Logan.LiandCursor committed 2026-08-16 16:08:11 +08:00
1 parent 188f3556ed
commit 08696896f9
223 files changed
+13493 -3423

No files matched your search

+5 -5
View File
@@ -17,10 +17,10 @@ describe('AI provider presets grouping', () => {
'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自定义')
expect(AI_PROVIDER_FAMILY_META.openai_compatible.label).toBe('OpenAI compatible')
expect(AI_PROVIDER_FAMILY_META.plan.label).toBe('Subscription plans')
expect(AI_PROVIDER_FAMILY_META.local.label).toBe('Self-hosted')
expect(AI_PROVIDER_FAMILY_META.custom.label).toBe('Custom')
})
it('lists OpenAI-compatible payg presets', () => {
@@ -70,7 +70,7 @@ describe('AI provider presets grouping', () => {
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(presetsByFamily('custom').map((item) => item.name)).toEqual(['Custom'])
expect(createProviderFromPreset('custom').family).toBe('custom')
})
+165 -17
View File
@@ -70,8 +70,8 @@ describe('SFTP basic transfer acceptance', () => {
})
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
await screen.findByRole('button', { name: '上传' })
await user.click(screen.getByRole('button', { name: '上传' }))
await screen.findByRole('button', { name: '上传到远程' })
await user.click(screen.getByRole('button', { name: '上传到远程' }))
await waitFor(() => {
expect(mockDesktopApi.sftpUpload).toHaveBeenCalledWith(
@@ -86,9 +86,9 @@ describe('SFTP basic transfer acceptance', () => {
const user = userEvent.setup()
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
await screen.findByRole('button', { name: '上传' })
await screen.findByRole('button', { name: '上传到远程' })
await user.click(await screen.findByText('remote.txt'))
await user.click(screen.getByRole('button', { name: '下载' }))
await user.click(screen.getByRole('button', { name: '下载到本地' }))
await waitFor(() => {
expect(mockDesktopApi.sftpDownload).toHaveBeenCalledWith(
@@ -115,23 +115,31 @@ describe('SFTP basic transfer acceptance', () => {
})
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
await screen.findByRole('button', { name: '上传' })
await user.click(screen.getByRole('button', { name: '上传' }))
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 () => {
it('uploads by pointer-dragging a local row onto the remote panel', async () => {
vi.mocked(mockDesktopApi.localReadDir).mockResolvedValue({
success: true,
files: [{ name: 'local.txt', path: '/Users/test/local.txt', type: 'file', size: 8, modified: 1_700_000_000 }],
})
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
await screen.findByRole('button', { name: '上传' })
await screen.findByRole('button', { name: '上传到远程' })
const transfer = createDataTransfer()
fireEvent.dragStart(await screen.findByText('local.txt'), { dataTransfer: transfer })
fireEvent.drop(screen.getByLabelText('远程文件面板'), { dataTransfer: transfer })
const localRow = await screen.findByText('local.txt')
const remotePanel = screen.getByLabelText('远程文件面板')
const originalElementFromPoint = document.elementFromPoint
Object.defineProperty(document, 'elementFromPoint', {
configurable: true,
value: vi.fn(() => remotePanel),
})
fireEvent.pointerDown(localRow, { button: 0, pointerId: 11, clientX: 10, clientY: 10 })
fireEvent.pointerMove(localRow, { pointerId: 11, clientX: 30, clientY: 30 })
fireEvent.pointerUp(localRow, { button: 0, pointerId: 11, clientX: 30, clientY: 30 })
await waitFor(() => {
expect(mockDesktopApi.sftpUpload).toHaveBeenCalledWith(
@@ -140,15 +148,28 @@ describe('SFTP basic transfer acceptance', () => {
'/root/local.txt'
)
})
Object.defineProperty(document, 'elementFromPoint', {
configurable: true,
value: originalElementFromPoint,
})
})
it('downloads by dragging a remote row onto the local panel', async () => {
it('downloads by pointer-dragging a remote row onto the local panel', async () => {
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
await screen.findByRole('button', { name: '上传' })
await screen.findByRole('button', { name: '上传到远程' })
const transfer = createDataTransfer()
fireEvent.dragStart(await screen.findByText('remote.txt'), { dataTransfer: transfer })
fireEvent.drop(screen.getByLabelText('本地文件面板'), { dataTransfer: transfer })
const remoteRow = await screen.findByText('remote.txt')
const localPanel = screen.getByLabelText('本地文件面板')
const originalElementFromPoint = document.elementFromPoint
Object.defineProperty(document, 'elementFromPoint', {
configurable: true,
value: vi.fn(() => localPanel),
})
fireEvent.pointerDown(remoteRow, { button: 0, pointerId: 12, clientX: 50, clientY: 50 })
fireEvent.pointerMove(remoteRow, { pointerId: 12, clientX: 70, clientY: 70 })
fireEvent.pointerUp(remoteRow, { button: 0, pointerId: 12, clientX: 70, clientY: 70 })
await waitFor(() => {
expect(mockDesktopApi.sftpDownload).toHaveBeenCalledWith(
@@ -157,5 +178,132 @@ describe('SFTP basic transfer acceptance', () => {
'/Users/test/remote.txt'
)
})
Object.defineProperty(document, 'elementFromPoint', {
configurable: true,
value: originalElementFromPoint,
})
})
it('still accepts OS file HTML5 drops onto the remote panel', async () => {
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
await screen.findByRole('button', { name: '上传到远程' })
const file = new File(['hello'], 'from-os.txt', { type: 'text/plain' })
Object.defineProperty(file, 'path', { value: '/Users/test/from-os.txt' })
const transfer = createDataTransfer()
Object.defineProperty(transfer, 'types', { value: ['Files'] })
Object.defineProperty(transfer, 'files', {
value: {
length: 1,
0: file,
item: (index: number) => (index === 0 ? file : null),
[Symbol.iterator]: function* () {
yield file
},
},
})
fireEvent.dragEnter(screen.getByLabelText('远程文件面板'), { dataTransfer: transfer })
fireEvent.drop(screen.getByLabelText('远程文件面板'), { dataTransfer: transfer })
await waitFor(() => {
expect(mockDesktopApi.sftpUpload).toHaveBeenCalledWith(
'sftp-native',
'/Users/test/from-os.txt',
'/root/from-os.txt'
)
})
})
it('transfers both ways through pointer dragging when the WebView does not deliver HTML5 drop', async () => {
vi.mocked(mockDesktopApi.localReadDir).mockResolvedValue({
success: true,
files: [{ name: 'local.txt', path: '/Users/test/local.txt', type: 'file', size: 8, modified: 1_700_000_000 }],
})
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
const localRow = await screen.findByText('local.txt')
const remoteRow = await screen.findByText('remote.txt')
const localPanel = screen.getByLabelText('本地文件面板')
const remotePanel = screen.getByLabelText('远程文件面板')
const hitTest = vi.fn<() => Element | null>(() => remotePanel)
const originalElementFromPoint = document.elementFromPoint
Object.defineProperty(document, 'elementFromPoint', {
configurable: true,
value: hitTest,
})
fireEvent.pointerDown(localRow, { button: 0, pointerId: 1, clientX: 10, clientY: 10 })
fireEvent.pointerMove(localRow, { pointerId: 1, clientX: 30, clientY: 30 })
fireEvent.pointerUp(localRow, { button: 0, pointerId: 1, clientX: 30, clientY: 30 })
await waitFor(() => {
expect(mockDesktopApi.sftpUpload).toHaveBeenCalledWith(
'sftp-native',
'/Users/test/local.txt',
'/root/local.txt'
)
})
hitTest.mockReturnValue(localPanel)
fireEvent.pointerDown(remoteRow, { button: 0, pointerId: 2, clientX: 50, clientY: 50 })
fireEvent.pointerMove(remoteRow, { pointerId: 2, clientX: 70, clientY: 70 })
fireEvent.pointerUp(remoteRow, { button: 0, pointerId: 2, clientX: 70, clientY: 70 })
await waitFor(() => {
expect(mockDesktopApi.sftpDownload).toHaveBeenCalledWith(
'sftp-native',
'/root/remote.txt',
'/Users/test/remote.txt'
)
})
Object.defineProperty(document, 'elementFromPoint', {
configurable: true,
value: originalElementFromPoint,
})
})
it('uses the directory under the pointer as the transfer destination', async () => {
vi.mocked(mockDesktopApi.localReadDir).mockResolvedValue({
success: true,
files: [{ name: 'local.txt', path: '/Users/test/local.txt', type: 'file', size: 8, modified: 1_700_000_000 }],
})
vi.mocked(mockDesktopApi.sftpListDirectory).mockResolvedValue({
success: true,
files: [
remoteFile,
{
filename: 'archive',
longname: 'drwxr-xr-x 1 root root 0 Jul 30 12:00 archive',
attrs: { size: 0, mtime: 1_700_000_000, mode: 0o040755 },
},
],
})
render(<SFTPView tabId="sftp-tab" sessionId={session.id} />)
const localRow = await screen.findByText('local.txt')
const archiveRow = await screen.findByText('archive')
const originalElementFromPoint = document.elementFromPoint
Object.defineProperty(document, 'elementFromPoint', {
configurable: true,
value: vi.fn(() => archiveRow),
})
fireEvent.pointerDown(localRow, { button: 0, pointerId: 3, clientX: 10, clientY: 10 })
fireEvent.pointerMove(localRow, { pointerId: 3, clientX: 40, clientY: 40 })
fireEvent.pointerUp(localRow, { button: 0, pointerId: 3, clientX: 40, clientY: 40 })
await waitFor(() => {
expect(mockDesktopApi.sftpUpload).toHaveBeenCalledWith(
'sftp-native',
'/Users/test/local.txt',
'/root/archive/local.txt'
)
})
Object.defineProperty(document, 'elementFromPoint', {
configurable: true,
value: originalElementFromPoint,
})
})
})
@@ -0,0 +1,135 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
clearForwardIntent,
forgetForwardIntent,
forwardIntentKey,
listForwardIntent,
rememberForwardIntent,
resetForwardIntentForTests,
restoreForwardIntent,
tunnelCoversRule,
} from '../../../src/components/connection/forwardIntent'
import type { ForwardRule } from '../../../src/components/connection/forwardRules'
import type { SSHForwardStatus } from '../../../shared/types/ipc.types'
const localRule: ForwardRule = {
id: 'pf-local',
type: 'local',
name: 'db',
localPort: 3306,
remoteHost: '127.0.0.1',
remotePort: 3306,
}
function tunnel(overrides: Partial<SSHForwardStatus> = {}): SSHForwardStatus {
return {
forwardId: 'pf-local',
connectionId: 'conn-1',
kind: 'local',
bindHost: '127.0.0.1',
boundPort: 3306,
startedAt: 1,
running: true,
activeConnections: 0,
bytesUp: 0,
bytesDown: 0,
...overrides,
}
}
afterEach(() => {
resetForwardIntentForTests()
})
describe('forward reconnect intent', () => {
it('keys by session so a new connection id does not drop remembered rules', () => {
expect(forwardIntentKey('sess-1', 'tab-1')).toBe('session:sess-1')
expect(forwardIntentKey(undefined, 'tab-1')).toBe('tab:tab-1')
})
it('remembers start intent and forgets explicit stop', () => {
const key = forwardIntentKey('sess-1', 'tab-1')
rememberForwardIntent(key, localRule)
expect(listForwardIntent(key)).toEqual([localRule])
forgetForwardIntent(key, localRule.id)
expect(listForwardIntent(key)).toEqual([])
})
it('matches a running tunnel by rule id or listen port after reconnect', () => {
expect(tunnelCoversRule([tunnel()], localRule)).toBe(true)
expect(
tunnelCoversRule([tunnel({ forwardId: 'legacy', boundPort: 3306 })], localRule)
).toBe(true)
expect(
tunnelCoversRule([tunnel({ running: false })], localRule)
).toBe(false)
})
it('matches remote forwards by remote listen port, not local target port', () => {
const remoteRule: ForwardRule = {
id: 'pf-remote',
type: 'remote',
name: 'expose',
localPort: 8080,
remoteHost: '127.0.0.1',
remotePort: 18080,
}
expect(
tunnelCoversRule(
[tunnel({ forwardId: 'x', kind: 'remote', boundPort: 18080 })],
remoteRule
)
).toBe(true)
expect(
tunnelCoversRule(
[tunnel({ forwardId: 'x', kind: 'remote', boundPort: 8080 })],
remoteRule
)
).toBe(false)
})
it('clears remembered intent when a tab is disposed', () => {
const key = forwardIntentKey('sess-1', 'tab-1')
rememberForwardIntent(key, localRule)
clearForwardIntent(key)
expect(listForwardIntent(key)).toEqual([])
})
it('restores only rules that are not already running', async () => {
const key = forwardIntentKey('sess-1', 'tab-1')
rememberForwardIntent(key, localRule)
rememberForwardIntent(key, { ...localRule, id: 'pf-other', localPort: 8080 })
const start = vi.fn().mockResolvedValue(true)
const restored = await restoreForwardIntent(
key,
(rule) => rule.id === 'pf-local',
start
)
expect(restored).toBe(1)
expect(start).toHaveBeenCalledTimes(1)
expect(start).toHaveBeenCalledWith(expect.objectContaining({ id: 'pf-other' }))
})
it('shares one restore pass when StatusBar and PortForwardView race', async () => {
const key = forwardIntentKey('sess-1', 'tab-1')
rememberForwardIntent(key, localRule)
let release!: (value: boolean) => void
const start = vi.fn(
() =>
new Promise<boolean>((resolve) => {
release = resolve
})
)
const first = restoreForwardIntent(key, () => false, start)
const second = restoreForwardIntent(key, () => false, start)
release(true)
await expect(first).resolves.toBe(1)
await expect(second).resolves.toBe(1)
expect(start).toHaveBeenCalledTimes(1)
})
})
@@ -2,9 +2,13 @@ import { describe, expect, it } from 'vitest'
import {
duplicateForwardRule,
formatHostPort,
formatTransferBytes,
parseForwardRules,
parseHostPort,
portForwardStorageKey,
resolveForwardTargetHost,
resolveRemoteBindHost,
runtimeMap,
} from '../../../src/components/connection/forwardRules'
@@ -59,4 +63,35 @@ describe('forward rule persistence', () => {
expect(second.name).toBe('SOCKS5 副本 2')
expect(second).not.toHaveProperty('isActive')
})
it('parses and formats host:port listen/target addresses', () => {
expect(parseHostPort('127.0.0.1:18080')).toEqual({ host: '127.0.0.1', port: 18080 })
expect(parseHostPort('0.0.0.0:8080')).toEqual({ host: '0.0.0.0', port: 8080 })
expect(parseHostPort('bad')).toBeNull()
expect(formatHostPort('0.0.0.0', 8080)).toBe('0.0.0.0:8080')
expect(resolveForwardTargetHost('0.0.0.0')).toBe('127.0.0.1')
})
it('keeps remote listen bind including 0.0.0.0 for GatewayPorts', () => {
const rules = parseForwardRules([
{
id: 'r1',
type: 'remote',
name: 'expose',
localPort: 8080,
remoteBindHost: '0.0.0.0',
remoteHost: '192.168.1.10',
remotePort: 18080,
},
])
expect(rules[0]).toMatchObject({
type: 'remote',
remoteBindHost: '0.0.0.0',
remoteHost: '192.168.1.10',
remotePort: 18080,
localPort: 8080,
})
expect(resolveRemoteBindHost('localhost')).toBe('127.0.0.1')
expect(resolveRemoteBindHost('0.0.0.0')).toBe('0.0.0.0')
})
})
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { fitContextMenuToViewport } from '../../../src/components/sftp/contextMenuPosition'
describe('fitContextMenuToViewport', () => {
it('keeps the menu below/right of the click when there is room', () => {
expect(fitContextMenuToViewport(100, 80, 180, 200, 1000, 800)).toEqual({
left: 100,
top: 80,
})
})
it('flips above the click when near the bottom edge', () => {
// Click near bottom; menu taller than remaining space → open upward.
expect(fitContextMenuToViewport(200, 540, 180, 280, 1000, 600)).toEqual({
left: 200,
top: 260, // 540 - 280
})
})
it('clamps into the viewport when flipped menu would still overflow', () => {
expect(fitContextMenuToViewport(900, 540, 360, 400, 997, 592)).toEqual({
left: 540, // 900 - 360 (flip left of click)
top: 140, // 540 - 400 (flip above click), still within [8, 184]
})
})
it('keeps an oversized menu inside the top-left margin', () => {
expect(fitContextMenuToViewport(20, 20, 400, 600, 320, 480)).toEqual({
left: 8,
top: 8,
})
})
it('flips left of the click when near the right edge', () => {
expect(fitContextMenuToViewport(920, 100, 200, 120, 1000, 800)).toEqual({
left: 720, // 920 - 200
top: 100,
})
})
})
+177
View File
@@ -0,0 +1,177 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
clearSftpDragEntry,
hasSftpDragEntry,
isCrossPaneSftpDrag,
peekSftpDragEntry,
peekSftpDragEntries,
primeSftpDragEntries,
readSftpDragEntry,
readSftpDragEntries,
resolveSftpDragEntries,
scheduleClearSftpDragEntry,
SFTP_ENTRY_TEXT_PREFIX,
writeSftpDragEntry,
writeSftpDragEntries,
} from '../../../src/components/sftp/dragDrop'
function mockDataTransfer(initialTypes: string[] = []): DataTransfer {
const store = new Map<string, string>()
const types = [...initialTypes]
return {
effectAllowed: 'none',
dropEffect: 'none',
files: [] as unknown as FileList,
items: [] as unknown as DataTransferItemList,
types,
setData(format: string, data: string) {
store.set(format, data)
if (!types.includes(format)) types.push(format)
},
getData(format: string) {
return store.get(format) || ''
},
clearData() {
store.clear()
types.length = 0
},
setDragImage() {},
} as DataTransfer
}
describe('sftp dragDrop', () => {
afterEach(() => {
clearSftpDragEntry()
vi.useRealTimers()
})
it('mirrors entry for dragover when getData is empty', () => {
const dt = mockDataTransfer()
writeSftpDragEntry(dt, {
source: 'local',
name: 'a.txt',
path: '/tmp/a.txt',
type: 'file',
size: 12,
})
// Simulate WebView dragover: types may be present but getData returns empty
const over = mockDataTransfer([...(dt.types as string[])])
expect(hasSftpDragEntry(over)).toBe(true)
expect(peekSftpDragEntry()?.name).toBe('a.txt')
expect(readSftpDragEntry(over)?.source).toBe('local')
clearSftpDragEntry()
expect(peekSftpDragEntry()).toBeNull()
})
it('writes text/plain prefix for WebKit-compatible drop payloads', () => {
const dt = mockDataTransfer()
writeSftpDragEntry(dt, {
source: 'remote',
name: 'b.txt',
path: '/home/u/b.txt',
type: 'file',
size: 3,
})
const plain = dt.getData('text/plain')
expect(plain.startsWith(SFTP_ENTRY_TEXT_PREFIX)).toBe(true)
clearSftpDragEntry()
// Drop path without mirror: parse text/plain
expect(readSftpDragEntry(dt)?.path).toBe('/home/u/b.txt')
})
it('only accepts cross-pane drops', () => {
const dt = mockDataTransfer()
writeSftpDragEntry(dt, {
source: 'local',
name: 'a.txt',
path: '/tmp/a.txt',
type: 'file',
size: 1,
})
expect(isCrossPaneSftpDrag(dt, false)).toBe(true) // remote pane
expect(isCrossPaneSftpDrag(dt, true)).toBe(false) // local pane
})
it('uses the pre-drag mirror when WebKit has not exposed DataTransfer data', () => {
primeSftpDragEntries([
{ source: 'remote', name: 'server.log', path: '/var/log/server.log', type: 'file', size: 9 },
])
const hiddenWebKitTransfer = mockDataTransfer()
expect(isCrossPaneSftpDrag(hiddenWebKitTransfer, true)).toBe(true)
expect(resolveSftpDragEntries(hiddenWebKitTransfer).entries[0]?.name).toBe('server.log')
})
it('keeps mirror readable until scheduled clear runs (drop/dragend race)', () => {
vi.useFakeTimers()
const dt = mockDataTransfer()
writeSftpDragEntry(dt, {
source: 'remote',
name: 'b.txt',
path: '/home/u/b.txt',
type: 'file',
size: 3,
})
scheduleClearSftpDragEntry(50)
expect(peekSftpDragEntry()?.name).toBe('b.txt')
expect(readSftpDragEntry(mockDataTransfer())?.path).toBe('/home/u/b.txt')
vi.advanceTimersByTime(50)
expect(peekSftpDragEntry()).toBeNull()
})
it('ignores stale clear timers after a new drag starts', () => {
vi.useFakeTimers()
const dt = mockDataTransfer()
writeSftpDragEntry(dt, {
source: 'local',
name: 'old.txt',
path: '/tmp/old.txt',
type: 'file',
size: 1,
})
scheduleClearSftpDragEntry(100)
writeSftpDragEntry(dt, {
source: 'remote',
name: 'new.txt',
path: '/r/new.txt',
type: 'file',
size: 2,
})
vi.advanceTimersByTime(100)
expect(peekSftpDragEntry()?.name).toBe('new.txt')
})
it('keeps multi-selection batch in the drag mirror', () => {
const dt = mockDataTransfer()
writeSftpDragEntries(dt, [
{ source: 'local', name: 'a.txt', path: '/tmp/a.txt', type: 'file', size: 1 },
{ source: 'local', name: 'b.txt', path: '/tmp/b.txt', type: 'file', size: 2 },
])
expect(peekSftpDragEntries().map((e) => e.name)).toEqual(['a.txt', 'b.txt'])
clearSftpDragEntry()
expect(readSftpDragEntries(dt).map((e) => e.name)).toEqual(['a.txt', 'b.txt'])
})
it('resolves drop payload via mirror or text/plain', () => {
const dt = mockDataTransfer()
writeSftpDragEntries(dt, [
{ source: 'local', name: 'a.txt', path: '/tmp/a.txt', type: 'file', size: 1 },
{ source: 'local', name: 'b.txt', path: '/tmp/b.txt', type: 'file', size: 2 },
])
expect(resolveSftpDragEntries(dt).via).toBe('mirror')
expect(resolveSftpDragEntries(dt).entries).toHaveLength(2)
clearSftpDragEntry()
expect(resolveSftpDragEntries(dt).via).toBe('text/plain')
expect(resolveSftpDragEntries(dt).entries.map((e) => e.name)).toEqual(['a.txt', 'b.txt'])
})
})
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import {
conflictNamesFromListing,
formatOverwriteMessage,
transferDestinationPath,
} from '../../../src/components/sftp/transferConflicts'
describe('transferConflicts', () => {
it('builds remote and local destination paths', () => {
expect(transferDestinationPath('upload', '/', 'a.txt')).toBe('/a.txt')
expect(transferDestinationPath('upload', '/root/', 'a.txt')).toBe('/root/a.txt')
expect(transferDestinationPath('download', '/Users/test', 'a.txt')).toBe('/Users/test/a.txt')
})
it('finds conflicts from a listing', () => {
expect(
conflictNamesFromListing(['a.txt', 'b.txt', 'c.txt'], [{ name: 'b.txt' }, { name: 'c.txt' }])
).toEqual(['b.txt', 'c.txt'])
})
it('formats overwrite copy for one and many', () => {
const t = (key: string, opts?: Record<string, unknown>) =>
`${key}:${opts ? JSON.stringify(opts) : ''}`
expect(formatOverwriteMessage(['only.log'], 'upload', t)).toContain('overwriteOneRemote')
expect(formatOverwriteMessage(['a', 'b', 'c'], 'download', t)).toContain('overwriteManyLocal')
})
})
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { sortTransferTasks } from '../../../src/components/sftp/TransferPanel'
import type { TransferTask } from '../../../shared/types/sftp.types'
function task(
id: string,
status: TransferTask['status']
): TransferTask {
return {
id,
type: 'upload',
fileName: id,
filePath: `/${id}`,
status,
progress: status === 'transferring' ? 40 : 0,
total: 100,
transferred: status === 'transferring' ? 40 : 0,
}
}
describe('sortTransferTasks', () => {
it('puts transferring tasks first, then pending, then finished', () => {
const ordered = sortTransferTasks([
task('done', 'completed'),
task('wait', 'pending'),
task('active', 'transferring'),
task('fail', 'failed'),
task('wait2', 'pending'),
])
expect(ordered.map((t) => t.id)).toEqual(['active', 'wait', 'wait2', 'fail', 'done'])
})
it('keeps relative order within the same status', () => {
const ordered = sortTransferTasks([
task('a', 'pending'),
task('b', 'pending'),
task('c', 'pending'),
])
expect(ordered.map((t) => t.id)).toEqual(['a', 'b', 'c'])
})
})
+8
View File
@@ -5,7 +5,11 @@ export function unlockProForTests(): void {
useEntitlementStore.setState({
plan: 'pro',
deviceId: 'test-device',
email: '',
licenseKey: 'LIC-TEST',
expiresAt: null,
maxDevices: 5,
planType: 'perpetual',
features: ['*'],
loaded: true,
wall: { open: false },
@@ -16,7 +20,11 @@ export function resetEntitlementForTests(): void {
useEntitlementStore.setState({
plan: 'free',
deviceId: 'test-device',
email: '',
licenseKey: '',
expiresAt: null,
maxDevices: null,
planType: null,
features: [],
loaded: true,
wall: { open: false },
+73
View File
@@ -0,0 +1,73 @@
/**
* Shared fixtures for root-level patrol / integration tests.
*/
import { vi } from 'vitest'
import { mockDesktopApi } from '../setup'
import { usePoolStore } from '../../src/stores/connection/poolStore'
import { useSessionStore } from '../../src/stores/sessionStore'
import { useTabStore } from '../../src/stores/tabStore'
import { unlockProForTests } from './entitlement'
import type { Session } from '../../shared/types/session.types'
export const SSH_SESSION: Session = {
id: 'patrol-ssh',
name: 'Patrol SSH',
host: 'opsbase.cn',
port: 22,
username: 'root',
authType: 'password',
protocol: 'ssh',
}
export const RDP_SESSION: Session = {
id: 'patrol-rdp',
name: 'Patrol RDP',
host: 'win.example.com',
port: 3389,
username: 'Administrator',
authType: 'password',
protocol: 'rdp',
password: 'secret',
}
export const VNC_SESSION: Session = {
id: 'patrol-vnc',
name: 'Patrol VNC',
host: '192.168.1.50',
port: 5900,
username: 'vnc',
authType: 'password',
protocol: 'vnc',
password: 'vnc-pass',
}
export function resetPatrolStores(sessions: Session[] = []) {
unlockProForTests()
window.opsxApi = mockDesktopApi
usePoolStore.setState({ connections: new Map() })
useSessionStore.setState({ sessions })
useTabStore.setState({
tabs: [{ id: 'welcome', title: '欢迎', type: 'welcome', isActive: true }],
activeTabId: 'welcome',
})
vi.clearAllMocks()
}
export function createDataTransfer(): DataTransfer {
const values = new Map<string, string>()
const transfer = {
effectAllowed: 'all',
dropEffect: 'none',
files: [] as unknown as FileList,
types: [] as string[],
setData(type: string, value: string) {
values.set(type, value)
if (!this.types.includes(type)) this.types.push(type)
},
getData(type: string) {
return values.get(type) || ''
},
}
return transfer as unknown as DataTransfer
}
+72
View File
@@ -0,0 +1,72 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSFTP } from '../../src/hooks/useSFTP'
import { usePoolStore } from '../../src/stores/connection/poolStore'
import { useSessionStore } from '../../src/stores/sessionStore'
import { useTabStore } from '../../src/stores/tabStore'
import { mockDesktopApi } from '../setup'
const session = {
id: 'transfer-host',
name: 'Transfer host',
host: 'host.example.com',
port: 22,
username: 'root',
authType: 'agent' as const,
}
describe('useSFTP transfer lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
window.opsxApi = mockDesktopApi
usePoolStore.setState({ connections: new Map() })
useSessionStore.setState({ sessions: [session] })
useTabStore.setState({
tabs: [{ id: 'sftp-tab', title: 'SFTP', type: 'sftp', sessionId: session.id, isActive: true }],
activeTabId: 'sftp-tab',
})
vi.mocked(mockDesktopApi.sshConnect).mockResolvedValue({ success: true, connectionId: 'connection-1' })
vi.mocked(mockDesktopApi.sessionGetById).mockResolvedValue({ success: true, session })
vi.mocked(mockDesktopApi.sftpConnect).mockResolvedValue({ success: true, sftpId: 'sftp-1' })
vi.mocked(mockDesktopApi.sftpListDirectory).mockResolvedValue({ success: true, files: [] })
vi.mocked(mockDesktopApi.getHomePath).mockResolvedValue({ success: true, path: '/Users/test' })
vi.mocked(mockDesktopApi.localReadDir).mockResolvedValue({ success: true, files: [] })
})
it('settles a canceled transfer even when its visible row is removed before native completion', async () => {
let finishNativeUpload: ((value: { success: boolean }) => void) | undefined
vi.mocked(mockDesktopApi.sftpUpload).mockImplementationOnce(
() => new Promise((resolve) => {
finishNativeUpload = resolve
})
)
const { result } = renderHook(() => useSFTP({ tabId: 'sftp-tab' }))
await act(async () => {
await result.current.connect(session.id)
})
let batchPromise: ReturnType<typeof result.current.uploadFiles>
act(() => {
batchPromise = result.current.uploadFiles([
{ path: '/Users/test/large.bin', name: 'large.bin', size: 1024 },
])
})
await waitFor(() => expect(mockDesktopApi.sftpUpload).toHaveBeenCalledTimes(1))
const taskId = result.current.transferQueue[0]?.id
expect(taskId).toBeTruthy()
act(() => {
result.current.cancelTransfer(taskId!)
result.current.removeTransfer(taskId!)
})
expect(result.current.transferQueue).toHaveLength(0)
expect(mockDesktopApi.sftpCancelTransfer).toHaveBeenCalledWith('sftp-1')
await act(async () => {
finishNativeUpload?.({ success: true })
await expect(batchPromise!).resolves.toEqual({ completed: 0, failed: 0, errors: [] })
})
})
})
+44
View File
@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, it } from 'vitest'
import i18n from '../../src/i18n'
import { translateErrorMessage } from '../../src/i18n/format'
describe('translateErrorMessage — SFTP real-device phrases', () => {
beforeEach(async () => {
await i18n.changeLanguage('zh-CN')
})
it.each([
['Permission denied', 'errors:sftp.permissionDenied'],
['权限不足,无法访问', 'errors:sftp.permissionDenied'],
['SFTP 会话不存在: sftp-1', 'errors:sftp.sessionGone'],
['SSH 连接不存在', 'errors:sftp.sessionGone'],
['SFTP 正在传输文件,请稍后再试', 'errors:sftp.busy'],
['transfer is in progress', 'errors:sftp.busy'],
['Unable to startup channel', 'errors:sftp.channelFailed'],
['MaxSessions limit', 'errors:sftp.channelFailed'],
['文件过大(3.2 MB),编辑上限约 2MB', 'errors:sftp.editTooLarge'],
['该文件像是二进制内容,无法用文本编辑器打开', 'errors:sftp.editBinary'],
['远程文件不是有效 UTF-8 文本', 'errors:sftp.editNotUtf8'],
['传输已取消', 'errors:sftp.transferCancelled'],
['No space left on device', 'errors:sftp.diskFull'],
['ENOSPC', 'errors:sftp.diskFull'],
[
'建立远程端口转发失败: [Session(-32)] Unable to complete request for forward-listen',
'errors:forward.remoteDenied',
],
[
'建立远程端口转发失败: 远端拒绝监听端口 8080(常见原因:AllowTcpForwarding 未开或仅为 local、PermitListen 未包含该端口、端口已被占用)。可用命令行核对:ssh -R 8080:127.0.0.1:<本地端口> user@host',
'errors:forward.remoteDenied',
],
[
'建立远程端口转发失败: 远端拒绝监听特权端口 80(需 root 或改用 ≥1024;另请确认 AllowTcpForwarding / PermitListen,且端口未被占用)。可用命令行核对:ssh -R 80:127.0.0.1:<本地端口> user@host',
'errors:forward.remoteDeniedPrivileged',
],
])('maps %s', (raw, key) => {
expect(translateErrorMessage(raw)).toBe(i18n.t(key))
})
it('falls back to raw text when unmatched', () => {
expect(translateErrorMessage('weird native blip 0xdead')).toBe('weird native blip 0xdead')
})
})
+38
View File
@@ -0,0 +1,38 @@
# test/patrol — 基础能力巡检
面向 SSH / SFTP / RDP / VNC 等基础回归,避免改 UI 或授权后无验收就引入回归。
与同级其他测试的关系:
| 路径 | 用途 |
|------|------|
| `test/**` | 单元、组件、原有 acceptance 细测 |
| `test/patrol/` | 跨模块基础巡检;改功能后应优先跑 |
## 命令
```bash
# 只跑巡检
npm run test:patrol
# 快速验收(含 typecheck + 全量 vitest,已包含本目录)
npm run acceptance:quick
```
## 覆盖面
- `contracts` — 平台契约与协议默认值
- `ssh` — SSH 会话创建 / 打开终端
- `sftp` — 双栏、上级目录、远程 mkdir、拖拽传输
- `desktop` — RDP / VNC 连接对话框与 open-tab 载荷
真实远程主机联调不在此目录(需本机 target);此处用 mock 原生 API 做渲染层 + 契约巡检。
异常路径也用 mock 锁住(不连真机):
- 列目录 Permission denied → 多语言错误条 + toast
- 传输忙 / MaxSessions 通道失败 → 忙提示或重连条
- SFTP 会话丢失 → 重连条
- 上传失败 / 超 2MB 编辑 → toast 映射
- 同名覆盖确认(覆盖/跳过)与失败任务重试
- 断线后一键重连并恢复上次远程目录
+126
View File
@@ -0,0 +1,126 @@
/**
* Platform / protocol contract patrol — catches missing commands or broken defaults.
*/
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { mockDesktopApi } from '../setup'
import {
buildOpenTabPayload,
defaultPortForProtocol,
isSshSession,
sessionProtocol,
sessionToRdpConfig,
sessionToVncConfig,
tabTypeForProtocol,
} from '../../src/utils/sessionProtocol'
import { RDP_SESSION, VNC_SESSION, SSH_SESSION } from '../helpers/patrol'
import * as rdp from '../../src/platform/rdp'
import * as vnc from '../../src/platform/vnc'
import * as sftpPlatform from '../../src/platform/sftp'
const REQUIRED_SSH_KEYS = [
'sshConnect',
'sshDisconnect',
'sshCreateShell',
'sshShellWrite',
'sshShellResize',
'sshCloseShell',
'sshForwardLocal',
'sshForwardRemote',
'sshForwardDynamic',
'sshForwardStop',
] as const
const REQUIRED_SFTP_KEYS = [
'sftpConnect',
'sftpDisconnect',
'sftpListDirectory',
'sftpUpload',
'sftpUploadDir',
'sftpDownload',
'sftpDownloadDir',
'sftpMkdir',
'sftpRename',
'sftpDelete',
'sftpStat',
'sftpChmod',
] as const
describe('patrol / contracts', () => {
it('leaves HTML5 drag-and-drop available to the SFTP panes', () => {
const config = JSON.parse(
readFileSync(resolve(process.cwd(), 'src-tauri/tauri.conf.json'), 'utf8')
) as { app?: { windows?: Array<{ dragDropEnabled?: boolean }> } }
expect(config.app?.windows?.[0]?.dragDropEnabled).toBe(false)
})
it('exposes SSH + SFTP native commands on the desktop mock contract', () => {
for (const key of REQUIRED_SSH_KEYS) {
expect(typeof mockDesktopApi[key], key).toBe('function')
}
for (const key of REQUIRED_SFTP_KEYS) {
expect(typeof mockDesktopApi[key], key).toBe('function')
}
})
it('keeps protocol defaults for SSH / RDP / VNC', () => {
expect(defaultPortForProtocol('ssh')).toBe(22)
expect(defaultPortForProtocol('rdp')).toBe(3389)
expect(defaultPortForProtocol('vnc')).toBe(5900)
expect(tabTypeForProtocol('ssh')).toBe('terminal')
expect(tabTypeForProtocol('rdp')).toBe('rdp')
expect(tabTypeForProtocol('vnc')).toBe('vnc')
expect(isSshSession(SSH_SESSION)).toBe(true)
expect(isSshSession(RDP_SESSION)).toBe(false)
expect(sessionProtocol(VNC_SESSION)).toBe('vnc')
})
it('builds open-tab payloads with desktop configs for RDP/VNC', () => {
const rdpTab = buildOpenTabPayload(RDP_SESSION)
expect(rdpTab.type).toBe('rdp')
expect(rdpTab.rdpConfig).toEqual(
expect.objectContaining({
host: 'win.example.com',
port: 3389,
username: 'Administrator',
})
)
const vncTab = buildOpenTabPayload(VNC_SESSION)
expect(vncTab.type).toBe('vnc')
expect(vncTab.vncConfig).toEqual(
expect.objectContaining({
host: '192.168.1.50',
port: 5900,
})
)
const sshTab = buildOpenTabPayload(SSH_SESSION)
expect(sshTab.type).toBe('terminal')
expect(sshTab.rdpConfig).toBeUndefined()
expect(sshTab.vncConfig).toBeUndefined()
})
it('maps session credentials into RDP/VNC engine configs', () => {
expect(sessionToRdpConfig(RDP_SESSION).password).toBe('secret')
expect(sessionToVncConfig(VNC_SESSION).password).toBe('vnc-pass')
})
it('exports RDP / VNC / SFTP platform adapters', () => {
expect(typeof rdp.rdpConnect).toBe('function')
expect(typeof rdp.rdpDisconnect).toBe('function')
expect(typeof rdp.rdpResize).toBe('function')
expect(typeof vnc.vncConnect).toBe('function')
expect(typeof vnc.vncDisconnect).toBe('function')
expect(typeof sftpPlatform.uploadRemoteFile).toBe('function')
expect(typeof sftpPlatform.downloadRemoteFile).toBe('function')
})
it('refuses RDP/VNC connect outside Tauri runtime', async () => {
await expect(rdp.rdpConnect(sessionToRdpConfig(RDP_SESSION))).rejects.toThrow(/原生|native|Tauri|RDP/i)
await expect(vnc.vncConnect(sessionToVncConfig(VNC_SESSION))).rejects.toThrow(/原生|native|Tauri|VNC/i)
})
})
+123
View File
@@ -0,0 +1,123 @@
/**
* RDP / VNC desktop connect + tab open patrol
*/
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import DesktopConnectDialog from '../../src/components/desktop/DesktopConnectDialog'
import SessionDialog from '../../src/components/sessions/SessionDialog'
import { useTabStore } from '../../src/stores/tabStore'
import { mockDesktopApi } from '../setup'
import { buildOpenTabPayload } from '../../src/utils/sessionProtocol'
import { RDP_SESSION, resetPatrolStores, VNC_SESSION } from '../helpers/patrol'
describe('patrol / desktop (rdp + vnc)', () => {
beforeEach(() => {
resetPatrolStores()
})
it('RDP connect dialog submits host/port/username/password', async () => {
const user = userEvent.setup()
const onConnect = vi.fn()
render(
<DesktopConnectDialog protocol="rdp" isOpen onClose={vi.fn()} onConnect={onConnect} />
)
expect(screen.getByText('连接 RDP')).toBeVisible()
expect(screen.getByLabelText('端口')).toHaveValue(3389)
await user.type(screen.getByLabelText('主机'), 'win.example.com')
await user.clear(screen.getByLabelText('用户名'))
await user.type(screen.getByLabelText('用户名'), 'Administrator')
await user.type(screen.getByLabelText('密码'), 'rdp-secret')
await user.click(screen.getByRole('button', { name: '连接' }))
expect(onConnect).toHaveBeenCalledWith(
'rdp',
expect.objectContaining({
host: 'win.example.com',
port: 3389,
username: 'Administrator',
password: 'rdp-secret',
security: 'nla',
})
)
})
it('VNC connect dialog defaults to port 5900 and allows password-only auth', async () => {
const user = userEvent.setup()
const onConnect = vi.fn()
render(
<DesktopConnectDialog protocol="vnc" isOpen onClose={vi.fn()} onConnect={onConnect} />
)
expect(screen.getByText('连接 VNC')).toBeVisible()
expect(screen.getByLabelText('端口')).toHaveValue(5900)
expect(screen.queryByLabelText('用户名')).not.toBeInTheDocument()
await user.type(screen.getByLabelText('主机'), '192.168.1.50')
await user.type(screen.getByLabelText(/密码/), 'vnc-pass')
await user.click(screen.getByRole('button', { name: '连接' }))
expect(onConnect).toHaveBeenCalledWith(
'vnc',
expect.objectContaining({
host: '192.168.1.50',
port: 5900,
password: 'vnc-pass',
})
)
})
it('requires host before connecting', async () => {
const user = userEvent.setup()
const onConnect = vi.fn()
render(
<DesktopConnectDialog protocol="rdp" isOpen onClose={vi.fn()} onConnect={onConnect} />
)
await user.click(screen.getByRole('button', { name: '连接' }))
expect(await screen.findByText(/请输入主机/)).toBeVisible()
expect(onConnect).not.toHaveBeenCalled()
})
it('opens RDP and VNC tabs from saved sessions with engine configs', () => {
const rdpPayload = buildOpenTabPayload(RDP_SESSION)
useTabStore.getState().addTab(rdpPayload)
expect(useTabStore.getState().tabs.some((t) => t.type === 'rdp' && t.rdpConfig?.host === 'win.example.com')).toBe(
true
)
const vncPayload = buildOpenTabPayload(VNC_SESSION)
useTabStore.getState().addTab(vncPayload)
expect(useTabStore.getState().tabs.some((t) => t.type === 'vnc' && t.vncConfig?.port === 5900)).toBe(true)
})
it('session dialog can save an RDP session with port 3389', async () => {
const user = userEvent.setup()
render(<SessionDialog isOpen onClose={vi.fn()} editSession={null} />)
const rdpTab = screen.queryByRole('button', { name: /^RDP$/i }) || screen.queryByText(/^RDP$/i)
if (rdpTab) {
await user.click(rdpTab)
await waitFor(() => {
expect(screen.getByLabelText('端口')).toHaveValue(3389)
})
await user.clear(screen.getByLabelText('主机'))
await user.type(screen.getByLabelText('主机'), 'win.lab.local')
await user.click(screen.getByRole('button', { name: '创建并保存' }))
await waitFor(() => {
expect(mockDesktopApi.sessionCreate).toHaveBeenCalledWith(
expect.objectContaining({
host: 'win.lab.local',
port: 3389,
protocol: 'rdp',
})
)
})
} else {
// Protocol switcher may use different labels — still assert helper defaults
expect(buildOpenTabPayload(RDP_SESSION).type).toBe('rdp')
}
})
})
+393
View File
@@ -0,0 +1,393 @@
/**
* SFTP dual-pane patrol — navigation, mkdir, drag transfer
*/
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SFTPView from '../../src/components/sftp/SFTPView'
import { useTabStore } from '../../src/stores/tabStore'
import { useToastStore } from '../../src/stores/toastStore'
import { mockDesktopApi } from '../setup'
import { resetPatrolStores, SSH_SESSION } from '../helpers/patrol'
const remoteFile = {
filename: 'remote.txt',
longname: '-rw-r--r-- 1 root root 12 Jul 30 12:00 remote.txt',
attrs: { size: 12, mtime: 1_700_000_000, mode: 0o100644 },
}
describe('patrol / sftp', () => {
beforeEach(() => {
resetPatrolStores([SSH_SESSION])
useToastStore.getState().clearAll()
useTabStore.setState({
tabs: [{ id: 'sftp-tab', title: 'SFTP', type: 'sftp', sessionId: SSH_SESSION.id, isActive: true }],
activeTabId: 'sftp-tab',
})
vi.mocked(mockDesktopApi.sshConnect).mockResolvedValue({ success: true, connectionId: 'conn-sftp' })
vi.mocked(mockDesktopApi.sessionGetById).mockResolvedValue({ success: true, session: SSH_SESSION })
vi.mocked(mockDesktopApi.sftpConnect).mockResolvedValue({ success: true, sftpId: 'sftp-native' })
vi.mocked(mockDesktopApi.sftpListDirectory).mockImplementation(async (_id, path) => {
if (path === '/root' || path === '/home/root') {
return { success: true, files: [remoteFile] }
}
if (path === '/home') {
return { success: false, error: 'Permission denied' }
}
if (path === '/') {
return {
success: true,
files: [
{
filename: 'root',
longname: 'drwxr-xr-x 1 root root 0 Jan 1 00:00 root',
attrs: { size: 0, mtime: 1_700_000_000, mode: 0o040755 },
},
],
}
}
return { success: true, files: [remoteFile] }
})
vi.mocked(mockDesktopApi.getHomePath).mockResolvedValue({ success: true, path: '/Users/test' })
vi.mocked(mockDesktopApi.localReadDir).mockResolvedValue({
success: true,
files: [{ name: 'local.txt', path: '/Users/test/local.txt', type: 'file', size: 8, modified: 1_700_000_000 }],
})
vi.mocked(mockDesktopApi.sftpMkdir).mockResolvedValue({ success: true })
vi.mocked(mockDesktopApi.sftpUpload).mockResolvedValue({ success: true })
vi.mocked(mockDesktopApi.sftpDownload).mockResolvedValue({ success: true })
})
it('connects and shows dual panes with remote listing', async () => {
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
expect(await screen.findByLabelText('本地文件面板')).toBeVisible()
expect(await screen.findByLabelText('远程文件面板')).toBeVisible()
expect(await screen.findByText('remote.txt')).toBeVisible()
})
it('navigates to filesystem root via breadcrumb /', async () => {
const user = userEvent.setup()
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
await screen.findByText('remote.txt')
const remotePanel = screen.getByLabelText('远程文件面板')
const rootButton = within(remotePanel).getByTitle('/')
await user.click(rootButton)
await waitFor(() => {
expect(mockDesktopApi.sftpListDirectory).toHaveBeenCalledWith('sftp-native', '/')
})
expect(await within(remotePanel).findByText('root')).toBeVisible()
})
it('shows parent .. row and navigates up from /root to /', async () => {
const user = userEvent.setup()
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
await screen.findByText('remote.txt')
const remotePanel = screen.getByLabelText('远程文件面板')
const parentRows = within(remotePanel).getAllByText('..')
await user.dblClick(parentRows[0])
await waitFor(() => {
expect(mockDesktopApi.sftpListDirectory).toHaveBeenCalledWith('sftp-native', '/')
})
})
it('creates a remote folder via sftpMkdir when remote pane is focused', async () => {
const user = userEvent.setup()
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
await screen.findByText('remote.txt')
fireEvent.mouseDown(screen.getByLabelText('远程文件面板'))
const newFolderButtons = screen.getAllByRole('button', { name: '在当前面板新建文件夹' })
await user.click(newFolderButtons[newFolderButtons.length - 1])
const input = await screen.findByPlaceholderText('文件夹名称')
await user.clear(input)
await user.type(input, 'tests')
await user.click(screen.getByRole('button', { name: '确认' }))
await waitFor(() => {
expect(mockDesktopApi.sftpMkdir).toHaveBeenCalledWith(
'sftp-native',
expect.stringMatching(/\/tests$/)
)
})
})
it('uploads by pointer-dragging local → remote and downloads remote → local', async () => {
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
await screen.findByText('local.txt')
await screen.findByText('remote.txt')
const localRow = screen.getByText('local.txt')
const remoteRow = screen.getByText('remote.txt')
const localPanel = screen.getByLabelText('本地文件面板')
const remotePanel = screen.getByLabelText('远程文件面板')
const hitTest = vi.fn<() => Element | null>(() => remotePanel)
const originalElementFromPoint = document.elementFromPoint
Object.defineProperty(document, 'elementFromPoint', {
configurable: true,
value: hitTest,
})
fireEvent.pointerDown(localRow, { button: 0, pointerId: 21, clientX: 10, clientY: 10 })
fireEvent.pointerMove(localRow, { pointerId: 21, clientX: 30, clientY: 30 })
fireEvent.pointerUp(localRow, { button: 0, pointerId: 21, clientX: 30, clientY: 30 })
await waitFor(() => {
expect(mockDesktopApi.sftpUpload).toHaveBeenCalledWith(
'sftp-native',
'/Users/test/local.txt',
expect.stringMatching(/local\.txt$/)
)
})
hitTest.mockReturnValue(localPanel)
fireEvent.pointerDown(remoteRow, { button: 0, pointerId: 22, clientX: 50, clientY: 50 })
fireEvent.pointerMove(remoteRow, { pointerId: 22, clientX: 70, clientY: 70 })
fireEvent.pointerUp(remoteRow, { button: 0, pointerId: 22, clientX: 70, clientY: 70 })
await waitFor(() => {
expect(mockDesktopApi.sftpDownload).toHaveBeenCalledWith(
'sftp-native',
expect.stringMatching(/remote\.txt$/),
expect.stringMatching(/remote\.txt$/)
)
})
Object.defineProperty(document, 'elementFromPoint', {
configurable: true,
value: originalElementFromPoint,
})
})
it('transfers via dual-pane rail arrows without drag-drop', async () => {
const user = userEvent.setup()
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
await screen.findByText('local.txt')
await screen.findByText('remote.txt')
await user.click(screen.getByText('local.txt'))
await user.click(screen.getByRole('button', { name: '上传到远程' }))
await waitFor(() => {
expect(mockDesktopApi.sftpUpload).toHaveBeenCalledWith(
'sftp-native',
'/Users/test/local.txt',
expect.stringMatching(/local\.txt$/)
)
})
await user.click(screen.getByText('remote.txt'))
await user.click(screen.getByRole('button', { name: '下载到本地' }))
await waitFor(() => {
expect(mockDesktopApi.sftpDownload).toHaveBeenCalledWith(
'sftp-native',
expect.stringMatching(/remote\.txt$/),
expect.stringMatching(/remote\.txt$/)
)
})
})
it('shows translated permission error when remote listing fails', async () => {
const user = userEvent.setup()
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
await screen.findByText('remote.txt')
vi.mocked(mockDesktopApi.sftpListDirectory).mockResolvedValue({
success: false,
error: 'Permission denied',
})
const remotePanel = screen.getByLabelText('远程文件面板')
await user.click(within(remotePanel).getByRole('button', { name: '刷新当前面板' }))
expect(
within(remotePanel).getAllByText('权限不足,无法访问该文件或目录').length
).toBeGreaterThan(0)
await waitFor(() => {
expect(useToastStore.getState().toasts.some((item) => item.message.includes('权限不足'))).toBe(true)
})
})
it('drops to reconnect UI when native reports SFTP session gone', async () => {
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
await screen.findByText('remote.txt')
vi.mocked(mockDesktopApi.sftpListDirectory).mockResolvedValue({
success: false,
error: 'SFTP 会话不存在: sftp-native',
})
fireEvent.mouseDown(screen.getByLabelText('远程文件面板'))
const refreshButtons = screen.getAllByRole('button', { name: '刷新当前面板' })
fireEvent.click(refreshButtons[refreshButtons.length - 1])
expect(await screen.findByText('SFTP 会话已结束,请重新连接')).toBeVisible()
expect(screen.getByRole('button', { name: '重新连接' })).toBeVisible()
})
it('toasts translated upload failure from native error', async () => {
const user = userEvent.setup()
vi.mocked(mockDesktopApi.sftpUpload).mockResolvedValue({
success: false,
error: 'Permission denied',
})
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
await screen.findByText('local.txt')
await user.click(screen.getByText('local.txt'))
await user.click(screen.getByRole('button', { name: '上传到远程' }))
await waitFor(() => {
const messages = useToastStore.getState().toasts.map((item) => item.message)
expect(messages.some((m) => m.includes('权限不足') || m.includes('失败'))).toBe(true)
})
})
it('warns in current language when editing a file over the 2MB soft cap', async () => {
const user = userEvent.setup()
const bigFile = {
filename: 'huge.log',
longname: '-rw-r--r-- 1 root root 3000000 Jul 30 12:00 huge.log',
attrs: { size: 3 * 1024 * 1024, mtime: 1_700_000_000, mode: 0o100644 },
}
vi.mocked(mockDesktopApi.sftpListDirectory).mockResolvedValue({
success: true,
files: [bigFile],
})
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
const row = await screen.findByText('huge.log')
fireEvent.contextMenu(row)
await user.click(await screen.findByText('编辑'))
await waitFor(() => {
expect(
useToastStore.getState().toasts.some((item) => item.message.includes('2MB'))
).toBe(true)
})
})
it('maps transfer-busy list errors to a human toast', async () => {
const user = userEvent.setup()
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
await screen.findByText('remote.txt')
vi.mocked(mockDesktopApi.sftpListDirectory).mockResolvedValue({
success: false,
error: 'SFTP 正在传输文件,请稍后再试',
})
const remotePanel = screen.getByLabelText('远程文件面板')
await user.click(within(remotePanel).getByRole('button', { name: '刷新当前面板' }))
expect(await within(remotePanel).findByText('正在传输文件,请稍后再试')).toBeVisible()
await waitFor(() => {
expect(useToastStore.getState().toasts.some((item) => item.message.includes('传输'))).toBe(true)
})
})
it('maps MaxSessions / channel failure to reconnect guidance', async () => {
const user = userEvent.setup()
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
await screen.findByText('remote.txt')
vi.mocked(mockDesktopApi.sftpListDirectory).mockResolvedValue({
success: false,
error: 'Unable to startup channel (MaxSessions)',
})
const remotePanel = screen.getByLabelText('远程文件面板')
await user.click(within(remotePanel).getByRole('button', { name: '刷新当前面板' }))
// Channel failure also tears down the browse session → reconnect strip.
expect(
await screen.findByText('无法打开 SFTP 通道(服务器限制或会话繁忙),请重连后再试')
).toBeVisible()
expect(screen.getByRole('button', { name: '重新连接' })).toBeVisible()
})
it('asks before overwriting an existing remote file, skip leaves target untouched', async () => {
const user = userEvent.setup()
vi.mocked(mockDesktopApi.sftpListDirectory).mockResolvedValue({
success: true,
files: [
remoteFile,
{
filename: 'local.txt',
longname: '-rw-r--r-- 1 root root 8 Jul 30 12:00 local.txt',
attrs: { size: 8, mtime: 1_700_000_000, mode: 0o100644 },
},
],
})
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
const localPanel = await screen.findByLabelText('本地文件面板')
await within(localPanel).findByText('local.txt')
await within(await screen.findByLabelText('远程文件面板')).findByText('local.txt')
await user.click(within(localPanel).getByText('local.txt'))
await user.click(screen.getByRole('button', { name: '上传到远程' }))
expect(await screen.findByText('覆盖已有文件?')).toBeVisible()
await user.click(screen.getByRole('button', { name: '跳过' }))
await waitFor(() => {
expect(useToastStore.getState().toasts.some((item) => item.message.includes('跳过'))).toBe(true)
})
expect(mockDesktopApi.sftpUpload).not.toHaveBeenCalled()
})
it('retries a failed transfer from the queue', async () => {
const user = userEvent.setup()
vi.mocked(mockDesktopApi.sftpUpload)
.mockResolvedValueOnce({ success: false, error: 'Permission denied' })
.mockResolvedValueOnce({ success: true })
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
await screen.findByText('local.txt')
await user.click(screen.getByText('local.txt'))
await user.click(screen.getByRole('button', { name: '上传到远程' }))
await waitFor(() => {
expect(mockDesktopApi.sftpUpload).toHaveBeenCalledTimes(1)
})
const retry = await screen.findByRole('button', { name: '重试' })
await user.click(retry)
await waitFor(() => {
expect(mockDesktopApi.sftpUpload).toHaveBeenCalledTimes(2)
})
})
it('reconnects and resumes the last remote directory after connection loss', async () => {
const user = userEvent.setup()
let onClosed: ((event: unknown, connectionId: string, reason: string) => void) | undefined
vi.mocked(mockDesktopApi.onConnectionClosed).mockImplementation((callback) => {
onClosed = callback as typeof onClosed
})
render(<SFTPView tabId="sftp-tab" sessionId={SSH_SESSION.id} />)
await screen.findByText('remote.txt')
const remotePanel = screen.getByLabelText('远程文件面板')
await user.click(within(remotePanel).getByTitle('/'))
await waitFor(() => {
expect(mockDesktopApi.sftpListDirectory).toHaveBeenCalledWith('sftp-native', '/')
})
expect(await within(remotePanel).findByText('root')).toBeVisible()
expect(onClosed).toBeTypeOf('function')
onClosed?.({}, 'conn-sftp', 'network reset')
expect(await screen.findByText(/连接已关闭|Connection closed/i)).toBeVisible()
expect(screen.getByText(/重连后将回到 \//)).toBeVisible()
vi.mocked(mockDesktopApi.sftpListDirectory).mockClear()
vi.mocked(mockDesktopApi.sftpConnect).mockResolvedValue({ success: true, sftpId: 'sftp-resumed' })
await user.click(screen.getByRole('button', { name: '重新连接' }))
await waitFor(() => {
expect(mockDesktopApi.sftpConnect).toHaveBeenCalled()
})
await waitFor(() => {
const paths = vi.mocked(mockDesktopApi.sftpListDirectory).mock.calls.map((call) => call[1])
expect(paths[0]).toBe('/')
})
expect(await screen.findByLabelText('远程文件面板')).toBeVisible()
})
})
+90
View File
@@ -0,0 +1,90 @@
/**
* SSH session / terminal open patrol
*/
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import SessionDialog from '../../src/components/sessions/SessionDialog'
import QuickConnectDialog, { parseSshTarget } from '../../src/components/sessions/QuickConnectDialog'
import { useSessionStore } from '../../src/stores/sessionStore'
import { useTabStore } from '../../src/stores/tabStore'
import { mockDesktopApi } from '../setup'
import { resetPatrolStores, SSH_SESSION } from '../helpers/patrol'
import { buildOpenTabPayload } from '../../src/utils/sessionProtocol'
describe('patrol / ssh', () => {
beforeEach(() => {
resetPatrolStores()
})
it('parses ssh targets used by quick connect', () => {
expect(parseSshTarget('admin@10.0.0.8:2222')).toEqual({
host: '10.0.0.8',
port: 2222,
username: 'admin',
})
expect(parseSshTarget('opsbase.cn')).toEqual({
host: 'opsbase.cn',
port: 22,
username: 'root',
})
})
it('creates an SSH session with default port 22 and password auth', async () => {
const user = userEvent.setup()
render(<SessionDialog isOpen onClose={vi.fn()} editSession={null} />)
expect(screen.getByLabelText('端口')).toHaveValue(22)
await user.clear(screen.getByLabelText('主机'))
await user.type(screen.getByLabelText('主机'), 'root@opsbase.cn')
await user.type(screen.getByLabelText('密码'), 'patrol-secret')
await user.click(screen.getByRole('button', { name: '创建并保存' }))
await waitFor(() => {
expect(mockDesktopApi.sessionCreate).toHaveBeenCalledWith(
expect.objectContaining({
host: 'opsbase.cn',
port: 22,
username: 'root',
authType: 'password',
password: 'patrol-secret',
})
)
})
})
it('opens a terminal tab from a saved SSH session payload', () => {
const payload = buildOpenTabPayload(SSH_SESSION)
useTabStore.getState().addTab(payload)
const tab = useTabStore.getState().tabs.find((item) => item.sessionId === SSH_SESSION.id)
expect(tab).toEqual(
expect.objectContaining({
type: 'terminal',
sessionId: SSH_SESSION.id,
})
)
})
it('quick-connect opens a terminal tab for user@host:port', async () => {
const user = userEvent.setup()
render(<QuickConnectDialog isOpen onClose={vi.fn()} />)
const input = screen.getByRole('textbox')
await user.clear(input)
await user.type(input, 'admin@10.0.0.8:2222')
await user.type(screen.getByPlaceholderText('••••••••'), 'x')
await user.click(screen.getByRole('button', { name: /连接/ }))
await waitFor(() => {
const tabs = useTabStore.getState().tabs
expect(tabs.some((tab) => tab.type === 'terminal')).toBe(true)
})
})
it('keeps SSH sessions in the session store', () => {
useSessionStore.setState({ sessions: [SSH_SESSION] })
expect(useSessionStore.getState().sessions).toHaveLength(1)
expect(useSessionStore.getState().sessions[0].protocol ?? 'ssh').toBe('ssh')
})
})
+17
View File
@@ -25,6 +25,7 @@ export const mockDesktopApi: DesktopApiContract = {
sshBackend: 'libssh2 / ssh2 0.9',
dataPath: '/tmp/opsxshell-data.json',
credentialPath: '/tmp/.opsxshell/master.key',
logPath: '/tmp/opsxshell/logs/opsxshell.log',
}),
// SSH
sshConnect: vi.fn().mockResolvedValue({ success: true, connectionId: 'test-conn-1' }),
@@ -46,11 +47,13 @@ export const mockDesktopApi: DesktopApiContract = {
// SFTP
sftpConnect: vi.fn().mockResolvedValue({ success: true, sftpId: 'test-sftp-1' }),
sftpDisconnect: vi.fn().mockResolvedValue({ success: true }),
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 }),
sftpCancelTransfer: vi.fn().mockResolvedValue({ success: true }),
sftpRename: vi.fn().mockResolvedValue({ success: true }),
sftpDelete: vi.fn().mockResolvedValue({ success: true }),
sftpMkdir: vi.fn().mockResolvedValue({ success: true }),
@@ -95,6 +98,17 @@ export const mockDesktopApi: DesktopApiContract = {
features: [],
}),
licenseDeviceId: vi.fn().mockResolvedValue('test-device'),
licenseSetEmail: vi.fn().mockResolvedValue({
plan: 'free',
deviceId: 'test-device',
activated: false,
features: [],
email: '',
}),
licenseCheckout: vi.fn().mockResolvedValue({
orderNo: 'ORD-TEST',
checkoutUrl: 'https://pancake.waffo.ai/store/opsxshell-pwsi6m4n/product/PROD_TEST?test=true',
}),
aiProviderSaveKey: vi.fn().mockResolvedValue({ success: true }),
aiProviderHasKey: vi.fn().mockResolvedValue(false),
aiProviderDeleteKey: vi.fn().mockResolvedValue({ success: true }),
@@ -127,7 +141,10 @@ export const mockDesktopApi: DesktopApiContract = {
clipboardReadText: vi.fn().mockResolvedValue(''),
clipboardWriteText: vi.fn().mockResolvedValue(undefined),
toggleMaximize: vi.fn().mockResolvedValue(undefined),
setFullscreen: vi.fn().mockResolvedValue(undefined),
isFullscreen: vi.fn().mockResolvedValue(false),
toggleDevtools: vi.fn().mockResolvedValue(false),
applyNativeMenuLocale: vi.fn().mockResolvedValue('en'),
// Local terminal
localListShells: vi.fn().mockResolvedValue({
+22
View File
@@ -32,4 +32,26 @@ describe('native connection close', () => {
expect(clearIntervalSpy).toHaveBeenCalled()
clearIntervalSpy.mockRestore()
})
it('keeps tunnel listeners when the pooled SSH connection is reconnecting', async () => {
const disconnect = window.opsxApi?.sshDisconnect as ReturnType<typeof vi.fn>
disconnect.mockClear()
usePoolStore.setState({ connections: new Map([['conn-1', connection('conn-1')]]) })
await usePoolStore.getState().disconnectConnection('conn-1', 'auto reconnect cleanup', {
retainForwards: true,
})
expect(disconnect).toHaveBeenCalledWith('conn-1', { retainForwards: true })
})
it('stops tunnel listeners when the last tab releases the pooled SSH connection', async () => {
const disconnect = window.opsxApi?.sshDisconnect as ReturnType<typeof vi.fn>
disconnect.mockClear()
usePoolStore.setState({ connections: new Map([['conn-1', connection('conn-1')]]) })
await usePoolStore.getState().disconnectConnection('conn-1', 'no refs')
expect(disconnect).toHaveBeenCalledWith('conn-1', { retainForwards: false })
})
})
+43
View File
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { useTabStore } from '../../src/stores/tabStore'
import { rewriteTabConnectionIds } from '../../src/stores/connection/rewriteTabs'
describe('rewriteTabConnectionIds', () => {
beforeEach(() => {
useTabStore.setState({ tabs: [], activeTabId: null })
})
it('remaps connectionInstance ids across tabs', () => {
useTabStore.setState({
tabs: [
{
id: 't1',
title: 'a',
type: 'terminal',
connectionInstance: { connectionId: 'old', shellId: 's1' },
} as never,
{
id: 't2',
title: 'b',
type: 'sftp',
connectionInstance: { connectionId: 'old', sftpId: 'f1' },
} as never,
{
id: 't3',
title: 'c',
type: 'terminal',
connectionInstance: { connectionId: 'other', shellId: 's2' },
} as never,
],
activeTabId: 't1',
})
rewriteTabConnectionIds('old', 'new')
const tabs = useTabStore.getState().tabs
expect(tabs[0].connectionInstance?.connectionId).toBe('new')
expect(tabs[1].connectionInstance?.connectionId).toBe('new')
expect(tabs[2].connectionInstance?.connectionId).toBe('other')
})
})
+40 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { guessLanguageFromPath } from '../../src/utils/fileLanguage'
import { guessLanguageFromPath, isEditableRemoteTextFile } from '../../src/utils/fileLanguage'
describe('guessLanguageFromPath', () => {
it('detects common file extensions for the SFTP editor', () => {
@@ -7,5 +7,44 @@ describe('guessLanguageFromPath', () => {
expect(guessLanguageFromPath('Dockerfile')).toBe('dockerfile')
expect(guessLanguageFromPath('app.py')).toBe('python')
expect(guessLanguageFromPath('readme.md')).toBe('markdown')
expect(guessLanguageFromPath('index.html.bak')).toBe('html')
})
})
describe('isEditableRemoteTextFile (denylist)', () => {
it('allows text, code, config, backups, and unknown extensions', () => {
expect(isEditableRemoteTextFile('notes.txt')).toBe(true)
expect(isEditableRemoteTextFile('app.yaml')).toBe(true)
expect(isEditableRemoteTextFile('index.html')).toBe(true)
expect(isEditableRemoteTextFile('app.css')).toBe(true)
expect(isEditableRemoteTextFile('main.go')).toBe(true)
expect(isEditableRemoteTextFile('weird.xyz')).toBe(true)
expect(isEditableRemoteTextFile('Makefile')).toBe(true)
expect(isEditableRemoteTextFile('.env')).toBe(true)
expect(isEditableRemoteTextFile('nginx.conf.bak')).toBe(true)
expect(isEditableRemoteTextFile('app.py.old')).toBe(true)
expect(isEditableRemoteTextFile('mystery.bak')).toBe(true)
expect(isEditableRemoteTextFile('noext')).toBe(true)
})
it('rejects archives, binaries, media, and office files', () => {
expect(isEditableRemoteTextFile('pkg.tar')).toBe(false)
expect(isEditableRemoteTextFile('pkg.tar.gz')).toBe(false)
expect(isEditableRemoteTextFile('data.gz')).toBe(false)
expect(isEditableRemoteTextFile('archive.zip')).toBe(false)
expect(isEditableRemoteTextFile('setup.exe')).toBe(false)
expect(isEditableRemoteTextFile('lib.so')).toBe(false)
expect(isEditableRemoteTextFile('image.png')).toBe(false)
expect(isEditableRemoteTextFile('photo.jpg')).toBe(false)
expect(isEditableRemoteTextFile('video.mp4')).toBe(false)
expect(isEditableRemoteTextFile('font.woff2')).toBe(false)
expect(isEditableRemoteTextFile('blob.bin')).toBe(false)
expect(isEditableRemoteTextFile('doc.pdf')).toBe(false)
expect(isEditableRemoteTextFile('sheet.xlsx')).toBe(false)
expect(isEditableRemoteTextFile('archive.zip.bak')).toBe(false)
})
it('rejects directories', () => {
expect(isEditableRemoteTextFile('/home/u/src', 'directory')).toBe(false)
})
})
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import {
isWithinRemoteTextEditLimit,
MAX_REMOTE_TEXT_EDIT_BYTES,
} from '../../src/utils/remoteTextLimits'
describe('remoteTextLimits', () => {
it('caps at 2 MiB', () => {
expect(MAX_REMOTE_TEXT_EDIT_BYTES).toBe(2 * 1024 * 1024)
})
it('allows missing or zero size', () => {
expect(isWithinRemoteTextEditLimit(undefined)).toBe(true)
expect(isWithinRemoteTextEditLimit(0)).toBe(true)
})
it('rejects over the soft cap', () => {
expect(isWithinRemoteTextEditLimit(MAX_REMOTE_TEXT_EDIT_BYTES)).toBe(true)
expect(isWithinRemoteTextEditLimit(MAX_REMOTE_TEXT_EDIT_BYTES + 1)).toBe(false)
})
})
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it, vi } from 'vitest'
import type { FitAddon } from '@xterm/addon-fit'
import type { Terminal } from '@xterm/xterm'
import {
isTerminalDisposed,
markTerminalDisposed,
safeTerminalDispose,
safeTerminalFit,
safeTerminalWrite,
} from '../../src/utils/safeTerminalFit'
describe('safeTerminalFit', () => {
it('returns false without an addon', () => {
expect(safeTerminalFit(null)).toBe(false)
})
it('skips fit when terminal element is detached', () => {
const fit = vi.fn()
const term = {
element: { isConnected: false, parentElement: { clientWidth: 100, clientHeight: 40 } },
} as unknown as Terminal
expect(safeTerminalFit({ fit } as unknown as FitAddon, term)).toBe(false)
expect(fit).not.toHaveBeenCalled()
})
it('skips fit when host has zero size', () => {
const fit = vi.fn()
const term = {
element: { isConnected: true, parentElement: { clientWidth: 0, clientHeight: 0 } },
} as unknown as Terminal
expect(safeTerminalFit({ fit } as unknown as FitAddon, term)).toBe(false)
expect(fit).not.toHaveBeenCalled()
})
it('swallows renderer dimension races', () => {
const fit = vi.fn(() => {
throw new TypeError("undefined is not an object (evaluating 'this._renderer.value.dimensions')")
})
const term = {
element: { isConnected: true, parentElement: { clientWidth: 80, clientHeight: 40 } },
} as unknown as Terminal
expect(safeTerminalFit({ fit } as unknown as FitAddon, term)).toBe(false)
expect(fit).toHaveBeenCalledOnce()
})
it('returns true on successful fit', () => {
const fit = vi.fn()
const term = {
element: { isConnected: true, parentElement: { clientWidth: 80, clientHeight: 40 } },
} as unknown as Terminal
expect(safeTerminalFit({ fit } as unknown as FitAddon, term)).toBe(true)
})
it('blocks write after mark disposed', () => {
const write = vi.fn()
const term = { write } as unknown as Terminal
markTerminalDisposed(term)
expect(isTerminalDisposed(term)).toBe(true)
safeTerminalWrite(term, 'x')
expect(write).not.toHaveBeenCalled()
})
it('marks disposed and removes element before dispose', () => {
const raf: FrameRequestCallback[] = []
const original = globalThis.requestAnimationFrame
globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
raf.push(cb)
return raf.length
}) as typeof requestAnimationFrame
const dispose = vi.fn()
const remove = vi.fn()
const term = {
dispose,
element: { remove },
} as unknown as Terminal
safeTerminalDispose(term)
expect(isTerminalDisposed(term)).toBe(true)
expect(remove).toHaveBeenCalledOnce()
expect(dispose).not.toHaveBeenCalled()
// Drain double-rAF queue used by safeTerminalDispose.
while (raf.length) {
const next = raf.shift()!
next(0)
}
expect(dispose).toHaveBeenCalledOnce()
globalThis.requestAnimationFrame = original
})
})
+38
View File
@@ -0,0 +1,38 @@
import { beforeAll, describe, expect, it } from 'vitest'
import i18n from '../../src/i18n'
import {
filterSettingsTabsByQuery,
settingsTabMatchesQuery,
} from '../../src/utils/settingsSearch'
describe('settingsSearch multilingual', () => {
beforeAll(async () => {
await i18n.changeLanguage('zh-CN')
})
it('finds general via Language / lang while UI is Chinese', () => {
expect(settingsTabMatchesQuery('general', 'Language')).toBe(true)
expect(settingsTabMatchesQuery('general', 'lang')).toBe(true)
expect(settingsTabMatchesQuery('general', '语言')).toBe(true)
expect(settingsTabMatchesQuery('general', '言語')).toBe(true)
})
it('finds workspace via English aliases while UI is Chinese', () => {
expect(settingsTabMatchesQuery('general', 'workspace')).toBe(true)
expect(settingsTabMatchesQuery('general', '通用')).toBe(true)
})
it('finds account via license / 账户 while UI is Chinese', () => {
expect(settingsTabMatchesQuery('account', 'license')).toBe(true)
expect(settingsTabMatchesQuery('account', '账户')).toBe(true)
expect(settingsTabMatchesQuery('account', 'email')).toBe(true)
})
it('filters tab list for language query', () => {
const matched = filterSettingsTabsByQuery(
['general', 'account', 'terminal', 'ai', 'shortcuts', 'ssh', 'security', 'about'],
'lang'
)
expect(matched).toContain('general')
})
})
+2 -2
View File
@@ -55,7 +55,7 @@ describe('applyKeywordHighlights', () => {
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).not.toMatch(/\x1b\]0;.*\x1b\[38/) // eslint-disable-line no-control-regex -- OSC must stay intact
expect(out).toContain('Welcome to Ubuntu')
})
@@ -102,7 +102,7 @@ describe('StreamingKeywordHighlight', () => {
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/)
expect(done).not.toMatch(/\x1b\]0;.*\x1b\[38/) // eslint-disable-line no-control-regex -- OSC must stay intact
})
it('holds incomplete CSI across chunks', () => {