mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-19 06:30:10 +08:00
fix: pr review
This commit is contained in:
parent
0dc9658846
commit
fc92f356ed
@ -43,7 +43,7 @@ const logger = loggerService.withContext('LanTransferClientService')
|
|||||||
* LAN Transfer Client Service
|
* LAN Transfer Client Service
|
||||||
*
|
*
|
||||||
* Handles outgoing file transfers to LAN peers via TCP.
|
* Handles outgoing file transfers to LAN peers via TCP.
|
||||||
* Protocol v3 with streaming mode (no per-chunk acknowledgment).
|
* Protocol v1 with streaming mode (no per-chunk acknowledgment).
|
||||||
*/
|
*/
|
||||||
class LanTransferClientService {
|
class LanTransferClientService {
|
||||||
private socket: Socket | null = null
|
private socket: Socket | null = null
|
||||||
|
|||||||
@ -0,0 +1,137 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
// Mock dependencies before importing the service
|
||||||
|
vi.mock('node:net', async (importOriginal) => {
|
||||||
|
const actual = (await importOriginal()) as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
createConnection: vi.fn()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('electron', () => ({
|
||||||
|
app: {
|
||||||
|
getName: vi.fn(() => 'Cherry Studio'),
|
||||||
|
getVersion: vi.fn(() => '1.0.0')
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../LocalTransferService', () => ({
|
||||||
|
localTransferService: {
|
||||||
|
getPeerById: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../../WindowService', () => ({
|
||||||
|
windowService: {
|
||||||
|
getMainWindow: vi.fn(() => ({
|
||||||
|
isDestroyed: () => false,
|
||||||
|
webContents: {
|
||||||
|
send: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Import after mocks
|
||||||
|
import { localTransferService } from '../../LocalTransferService'
|
||||||
|
|
||||||
|
describe('LanTransferClientService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.resetModules()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('connectAndHandshake - validation', () => {
|
||||||
|
it('should throw error when peer is not found', async () => {
|
||||||
|
vi.mocked(localTransferService.getPeerById).mockReturnValue(undefined)
|
||||||
|
|
||||||
|
const { lanTransferClientService } = await import('../LanTransferClientService')
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
lanTransferClientService.connectAndHandshake({
|
||||||
|
peerId: 'non-existent',
|
||||||
|
type: 'connect'
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Selected LAN peer is no longer available')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw error when peer has no port', async () => {
|
||||||
|
vi.mocked(localTransferService.getPeerById).mockReturnValue({
|
||||||
|
id: 'test-peer',
|
||||||
|
name: 'Test Peer',
|
||||||
|
addresses: ['192.168.1.100'],
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
const { lanTransferClientService } = await import('../LanTransferClientService')
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
lanTransferClientService.connectAndHandshake({
|
||||||
|
peerId: 'test-peer',
|
||||||
|
type: 'connect'
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Selected peer does not expose a TCP port')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw error when no reachable host', async () => {
|
||||||
|
vi.mocked(localTransferService.getPeerById).mockReturnValue({
|
||||||
|
id: 'test-peer',
|
||||||
|
name: 'Test Peer',
|
||||||
|
port: 12345,
|
||||||
|
addresses: [],
|
||||||
|
updatedAt: Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
const { lanTransferClientService } = await import('../LanTransferClientService')
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
lanTransferClientService.connectAndHandshake({
|
||||||
|
peerId: 'test-peer',
|
||||||
|
type: 'connect'
|
||||||
|
})
|
||||||
|
).rejects.toThrow('Unable to resolve a reachable host for the peer')
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('cancelTransfer', () => {
|
||||||
|
it('should not throw when no active transfer', async () => {
|
||||||
|
const { lanTransferClientService } = await import('../LanTransferClientService')
|
||||||
|
|
||||||
|
// Should not throw, just log warning
|
||||||
|
expect(() => lanTransferClientService.cancelTransfer()).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('dispose', () => {
|
||||||
|
it('should clean up resources without throwing', async () => {
|
||||||
|
const { lanTransferClientService } = await import('../LanTransferClientService')
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
expect(() => lanTransferClientService.dispose()).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sendFile', () => {
|
||||||
|
it('should throw error when not connected', async () => {
|
||||||
|
const { lanTransferClientService } = await import('../LanTransferClientService')
|
||||||
|
|
||||||
|
await expect(lanTransferClientService.sendFile('/path/to/file.zip')).rejects.toThrow(
|
||||||
|
'No active connection. Please connect to a peer first.'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('HANDSHAKE_PROTOCOL_VERSION', () => {
|
||||||
|
it('should export protocol version', async () => {
|
||||||
|
const { HANDSHAKE_PROTOCOL_VERSION } = await import('../LanTransferClientService')
|
||||||
|
|
||||||
|
expect(HANDSHAKE_PROTOCOL_VERSION).toBe('1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -13,6 +13,8 @@ describe('binaryProtocol', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
writtenBuffers = []
|
writtenBuffers = []
|
||||||
mockSocket = Object.assign(new EventEmitter(), {
|
mockSocket = Object.assign(new EventEmitter(), {
|
||||||
|
destroyed: false,
|
||||||
|
writable: true,
|
||||||
write: vi.fn((buffer: Buffer) => {
|
write: vi.fn((buffer: Buffer) => {
|
||||||
writtenBuffers.push(Buffer.from(buffer))
|
writtenBuffers.push(Buffer.from(buffer))
|
||||||
return true
|
return true
|
||||||
@ -79,6 +81,18 @@ describe('binaryProtocol', () => {
|
|||||||
const expectedTotalLen = 1 + 2 + Buffer.from(transferId).length + 4 + data.length
|
const expectedTotalLen = 1 + 2 + Buffer.from(transferId).length + 4 + data.length
|
||||||
expect(totalLen).toBe(expectedTotalLen)
|
expect(totalLen).toBe(expectedTotalLen)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should throw error when socket is not writable', () => {
|
||||||
|
mockSocket.writable = false
|
||||||
|
|
||||||
|
expect(() => sendBinaryChunk(mockSocket, 'test-id', 0, Buffer.from('data'))).toThrow('Socket is not writable')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw error when socket is destroyed', () => {
|
||||||
|
mockSocket.destroyed = true
|
||||||
|
|
||||||
|
expect(() => sendBinaryChunk(mockSocket, 'test-id', 0, Buffer.from('data'))).toThrow('Socket is not writable')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('BINARY_TYPE_FILE_CHUNK', () => {
|
describe('BINARY_TYPE_FILE_CHUNK', () => {
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { EventEmitter } from 'node:events'
|
||||||
|
import type { Socket } from 'node:net'
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildHandshakeMessage,
|
buildHandshakeMessage,
|
||||||
createDataHandler,
|
createDataHandler,
|
||||||
getAbortError,
|
getAbortError,
|
||||||
HANDSHAKE_PROTOCOL_VERSION,
|
HANDSHAKE_PROTOCOL_VERSION,
|
||||||
pickHost
|
pickHost,
|
||||||
|
waitForSocketDrain
|
||||||
} from '../../handlers/connection'
|
} from '../../handlers/connection'
|
||||||
|
|
||||||
// Mock electron app
|
// Mock electron app
|
||||||
@ -28,7 +32,7 @@ describe('connection handlers', () => {
|
|||||||
expect(typeof message.platform).toBe('string')
|
expect(typeof message.platform).toBe('string')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should use protocol version 3', () => {
|
it('should use protocol version 1', () => {
|
||||||
expect(HANDSHAKE_PROTOCOL_VERSION).toBe('1')
|
expect(HANDSHAKE_PROTOCOL_VERSION).toBe('1')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@ -137,6 +141,105 @@ describe('connection handlers', () => {
|
|||||||
|
|
||||||
expect(lines).toEqual(['{"type":"test"}'])
|
expect(lines).toEqual(['{"type":"test"}'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should throw error when buffer exceeds MAX_LINE_BUFFER_SIZE', () => {
|
||||||
|
const handler = createDataHandler(vi.fn())
|
||||||
|
|
||||||
|
// Create a buffer larger than 1MB (MAX_LINE_BUFFER_SIZE)
|
||||||
|
const largeData = 'x'.repeat(1024 * 1024 + 1)
|
||||||
|
|
||||||
|
expect(() => handler.handleData(Buffer.from(largeData))).toThrow('Control message too large')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reset buffer after exceeding MAX_LINE_BUFFER_SIZE', () => {
|
||||||
|
const lines: string[] = []
|
||||||
|
const handler = createDataHandler((line) => lines.push(line))
|
||||||
|
|
||||||
|
// Create a buffer larger than 1MB
|
||||||
|
const largeData = 'x'.repeat(1024 * 1024 + 1)
|
||||||
|
|
||||||
|
try {
|
||||||
|
handler.handleData(Buffer.from(largeData))
|
||||||
|
} catch {
|
||||||
|
// Expected error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffer should be reset, so lineBuffer should be empty
|
||||||
|
expect(handler.lineBuffer).toBe('')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('waitForSocketDrain', () => {
|
||||||
|
let mockSocket: Socket & EventEmitter
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockSocket = Object.assign(new EventEmitter(), {
|
||||||
|
destroyed: false,
|
||||||
|
writable: true,
|
||||||
|
write: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
removeAllListeners: vi.fn()
|
||||||
|
}) as unknown as Socket & EventEmitter
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw error when abort signal is already aborted', async () => {
|
||||||
|
const abortController = new AbortController()
|
||||||
|
abortController.abort(new Error('Already aborted'))
|
||||||
|
|
||||||
|
await expect(waitForSocketDrain(mockSocket, abortController.signal)).rejects.toThrow('Already aborted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw error when socket is destroyed', async () => {
|
||||||
|
mockSocket.destroyed = true
|
||||||
|
const abortController = new AbortController()
|
||||||
|
|
||||||
|
await expect(waitForSocketDrain(mockSocket, abortController.signal)).rejects.toThrow('Socket is closed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should resolve when drain event is emitted', async () => {
|
||||||
|
const abortController = new AbortController()
|
||||||
|
|
||||||
|
const drainPromise = waitForSocketDrain(mockSocket, abortController.signal)
|
||||||
|
|
||||||
|
// Emit drain event after a short delay
|
||||||
|
setImmediate(() => mockSocket.emit('drain'))
|
||||||
|
|
||||||
|
await expect(drainPromise).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject when close event is emitted', async () => {
|
||||||
|
const abortController = new AbortController()
|
||||||
|
|
||||||
|
const drainPromise = waitForSocketDrain(mockSocket, abortController.signal)
|
||||||
|
|
||||||
|
setImmediate(() => mockSocket.emit('close'))
|
||||||
|
|
||||||
|
await expect(drainPromise).rejects.toThrow('Socket closed while waiting for drain')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject when error event is emitted', async () => {
|
||||||
|
const abortController = new AbortController()
|
||||||
|
|
||||||
|
const drainPromise = waitForSocketDrain(mockSocket, abortController.signal)
|
||||||
|
|
||||||
|
setImmediate(() => mockSocket.emit('error', new Error('Network error')))
|
||||||
|
|
||||||
|
await expect(drainPromise).rejects.toThrow('Network error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject when abort signal is triggered', async () => {
|
||||||
|
const abortController = new AbortController()
|
||||||
|
|
||||||
|
const drainPromise = waitForSocketDrain(mockSocket, abortController.signal)
|
||||||
|
|
||||||
|
setImmediate(() => abortController.abort(new Error('User cancelled')))
|
||||||
|
|
||||||
|
await expect(drainPromise).rejects.toThrow('User cancelled')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getAbortError', () => {
|
describe('getAbortError', () => {
|
||||||
|
|||||||
@ -1,10 +1,28 @@
|
|||||||
|
import { EventEmitter } from 'node:events'
|
||||||
import type * as fs from 'node:fs'
|
import type * as fs from 'node:fs'
|
||||||
|
import type { Socket } from 'node:net'
|
||||||
|
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { abortTransfer, cleanupTransfer, createTransferState, formatFileSize } from '../../handlers/fileTransfer'
|
import { abortTransfer, cleanupTransfer, createTransferState, formatFileSize, streamFileChunks } from '../../handlers/fileTransfer'
|
||||||
import type { ActiveFileTransfer } from '../../types'
|
import type { ActiveFileTransfer } from '../../types'
|
||||||
|
|
||||||
|
// Mock binaryProtocol
|
||||||
|
vi.mock('../../binaryProtocol', () => ({
|
||||||
|
sendBinaryChunk: vi.fn().mockReturnValue(true)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock connection handlers
|
||||||
|
vi.mock('./connection', () => ({
|
||||||
|
waitForSocketDrain: vi.fn().mockResolvedValue(undefined),
|
||||||
|
getAbortError: vi.fn((signal, fallback) => {
|
||||||
|
const reason = (signal as AbortSignal & { reason?: unknown }).reason
|
||||||
|
if (reason instanceof Error) return reason
|
||||||
|
if (typeof reason === 'string' && reason.length > 0) return new Error(reason)
|
||||||
|
return new Error(fallback)
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
// Note: validateFile and calculateFileChecksum tests are skipped because
|
// Note: validateFile and calculateFileChecksum tests are skipped because
|
||||||
// the test environment has globally mocked node:fs and node:os modules.
|
// the test environment has globally mocked node:fs and node:os modules.
|
||||||
// These functions are tested through integration tests instead.
|
// These functions are tested through integration tests instead.
|
||||||
@ -149,4 +167,44 @@ describe('fileTransfer handlers', () => {
|
|||||||
expect(formatFileSize(1.5 * 1024 * 1024)).toBe('1.5 MB')
|
expect(formatFileSize(1.5 * 1024 * 1024)).toBe('1.5 MB')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Note: streamFileChunks tests require careful mocking of fs.createReadStream
|
||||||
|
// which is globally mocked in the test environment. These tests verify the
|
||||||
|
// streaming logic works correctly with mock streams.
|
||||||
|
describe('streamFileChunks', () => {
|
||||||
|
let mockSocket: Socket & EventEmitter
|
||||||
|
let mockProgress: ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
mockSocket = Object.assign(new EventEmitter(), {
|
||||||
|
destroyed: false,
|
||||||
|
writable: true,
|
||||||
|
write: vi.fn().mockReturnValue(true),
|
||||||
|
cork: vi.fn(),
|
||||||
|
uncork: vi.fn()
|
||||||
|
}) as unknown as Socket & EventEmitter
|
||||||
|
|
||||||
|
mockProgress = vi.fn()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw when abort signal is already aborted', async () => {
|
||||||
|
const transfer = createTransferState('test-id', 'test.zip', 1024, 'checksum')
|
||||||
|
transfer.abortController.abort(new Error('Already cancelled'))
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
streamFileChunks(mockSocket, '/fake/path.zip', transfer, transfer.abortController.signal, mockProgress)
|
||||||
|
).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Note: Full integration testing of streamFileChunks with actual file streaming
|
||||||
|
// requires a real file system, which cannot be easily mocked in ESM.
|
||||||
|
// The abort signal test above verifies the early abort path.
|
||||||
|
// Additional streaming tests are covered through integration tests.
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import type { Socket } from 'node:net'
|
import type { Socket } from 'node:net'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Binary protocol constants (v3)
|
* Binary protocol constants (v1)
|
||||||
*/
|
*/
|
||||||
export const BINARY_TYPE_FILE_CHUNK = 0x01
|
export const BINARY_TYPE_FILE_CHUNK = 0x01
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send file chunk as binary frame (protocol v3 - streaming mode)
|
* Send file chunk as binary frame (protocol v1 - streaming mode)
|
||||||
*
|
*
|
||||||
* Frame format:
|
* Frame format:
|
||||||
* ```
|
* ```
|
||||||
|
|||||||
@ -175,7 +175,7 @@ export function sendFileEnd(ctx: FileTransferContext, transferId: string): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stream file chunks to the receiver (v3 streaming mode - no per-chunk acknowledgment).
|
* Stream file chunks to the receiver (v1 streaming mode - no per-chunk acknowledgment).
|
||||||
*/
|
*/
|
||||||
export async function streamFileChunks(
|
export async function streamFileChunks(
|
||||||
socket: Socket,
|
socket: Socket,
|
||||||
@ -201,7 +201,7 @@ export async function streamFileChunks(
|
|||||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
||||||
bytesSent += buffer.length
|
bytesSent += buffer.length
|
||||||
|
|
||||||
// Send chunk as binary frame (v3 streaming) with backpressure handling
|
// Send chunk as binary frame (v1 streaming) with backpressure handling
|
||||||
const canContinue = sendBinaryChunk(socket, transferId, chunkIndex, buffer)
|
const canContinue = sendBinaryChunk(socket, transferId, chunkIndex, buffer)
|
||||||
if (!canContinue) {
|
if (!canContinue) {
|
||||||
await waitForSocketDrain(socket, abortSignal)
|
await waitForSocketDrain(socket, abortSignal)
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* LAN Transfer Client Module
|
* LAN Transfer Client Module
|
||||||
*
|
*
|
||||||
* Protocol: v3.0 (streaming mode)
|
* Protocol: v1.0 (streaming mode)
|
||||||
*
|
*
|
||||||
* Features:
|
* Features:
|
||||||
* - Binary frame format for file chunks (no base64 overhead)
|
* - Binary frame format for file chunks (no base64 overhead)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user