spck 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.oxlintrc.json +49 -0
- package/LICENSE +21 -0
- package/README.md +631 -0
- package/bin/cli.js +20 -0
- package/bin/validate-cwd.js +41 -0
- package/dist/config/__tests__/config.test.d.ts +2 -0
- package/dist/config/__tests__/config.test.js +262 -0
- package/dist/config/__tests__/credentials.test.d.ts +2 -0
- package/dist/config/__tests__/credentials.test.js +360 -0
- package/dist/config/config.d.ts +33 -0
- package/dist/config/config.js +185 -0
- package/dist/config/credentials.d.ts +75 -0
- package/dist/config/credentials.js +259 -0
- package/dist/config/server-selection.d.ts +40 -0
- package/dist/config/server-selection.js +130 -0
- package/dist/connection/__tests__/firebase-auth.test.d.ts +2 -0
- package/dist/connection/__tests__/firebase-auth.test.js +96 -0
- package/dist/connection/__tests__/hmac.test.d.ts +2 -0
- package/dist/connection/__tests__/hmac.test.js +372 -0
- package/dist/connection/auth.d.ts +13 -0
- package/dist/connection/auth.js +91 -0
- package/dist/connection/firebase-auth.d.ts +40 -0
- package/dist/connection/firebase-auth.js +429 -0
- package/dist/connection/hmac.d.ts +24 -0
- package/dist/connection/hmac.js +109 -0
- package/dist/i18n/index.d.ts +25 -0
- package/dist/i18n/index.js +101 -0
- package/dist/i18n/locales/en.json +313 -0
- package/dist/i18n/locales/es.json +302 -0
- package/dist/i18n/locales/fr.json +302 -0
- package/dist/i18n/locales/id.json +302 -0
- package/dist/i18n/locales/ja.json +302 -0
- package/dist/i18n/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/en.json +309 -0
- package/dist/i18n/locales/locales/es.json +302 -0
- package/dist/i18n/locales/locales/fr.json +302 -0
- package/dist/i18n/locales/locales/id.json +302 -0
- package/dist/i18n/locales/locales/ja.json +302 -0
- package/dist/i18n/locales/locales/ko.json +302 -0
- package/dist/i18n/locales/locales/pt.json +302 -0
- package/dist/i18n/locales/locales/zh-Hans.json +302 -0
- package/dist/i18n/locales/pt.json +302 -0
- package/dist/i18n/locales/zh-Hans.json +302 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +493 -0
- package/dist/proxy/ProxyClient.d.ts +125 -0
- package/dist/proxy/ProxyClient.js +781 -0
- package/dist/proxy/ProxySocketWrapper.d.ts +43 -0
- package/dist/proxy/ProxySocketWrapper.js +98 -0
- package/dist/proxy/__tests__/ProxyClient.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxyClient.test.js +445 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.d.ts +2 -0
- package/dist/proxy/__tests__/ProxySocketWrapper.test.js +190 -0
- package/dist/proxy/__tests__/handshake-validation.test.d.ts +2 -0
- package/dist/proxy/__tests__/handshake-validation.test.js +282 -0
- package/dist/proxy/__tests__/token-refresh-race.test.d.ts +14 -0
- package/dist/proxy/__tests__/token-refresh-race.test.js +173 -0
- package/dist/proxy/chunking.d.ts +53 -0
- package/dist/proxy/chunking.js +127 -0
- package/dist/proxy/handshake-validation.d.ts +21 -0
- package/dist/proxy/handshake-validation.js +49 -0
- package/dist/rpc/__tests__/router.test.d.ts +2 -0
- package/dist/rpc/__tests__/router.test.js +262 -0
- package/dist/rpc/router.d.ts +37 -0
- package/dist/rpc/router.js +132 -0
- package/dist/services/BrowserProxyService.d.ts +13 -0
- package/dist/services/BrowserProxyService.js +139 -0
- package/dist/services/FilesystemService.d.ts +99 -0
- package/dist/services/FilesystemService.js +742 -0
- package/dist/services/GitService.d.ts +243 -0
- package/dist/services/GitService.js +1439 -0
- package/dist/services/SearchService.d.ts +93 -0
- package/dist/services/SearchService.js +670 -0
- package/dist/services/TerminalService.d.ts +62 -0
- package/dist/services/TerminalService.js +337 -0
- package/dist/services/__tests__/BrowserProxyService.test.d.ts +2 -0
- package/dist/services/__tests__/BrowserProxyService.test.js +145 -0
- package/dist/services/__tests__/FilesystemService.test.d.ts +2 -0
- package/dist/services/__tests__/FilesystemService.test.js +609 -0
- package/dist/services/__tests__/GitService.test.d.ts +2 -0
- package/dist/services/__tests__/GitService.test.js +953 -0
- package/dist/services/__tests__/SearchService.test.d.ts +2 -0
- package/dist/services/__tests__/SearchService.test.js +384 -0
- package/dist/services/__tests__/TerminalService.test.d.ts +2 -0
- package/dist/services/__tests__/TerminalService.test.js +513 -0
- package/dist/setup/wizard.d.ts +10 -0
- package/dist/setup/wizard.js +172 -0
- package/dist/types.d.ts +196 -0
- package/dist/types.js +44 -0
- package/dist/utils/__tests__/gitignore.test.d.ts +2 -0
- package/dist/utils/__tests__/gitignore.test.js +127 -0
- package/dist/utils/gitignore.d.ts +24 -0
- package/dist/utils/gitignore.js +77 -0
- package/dist/utils/logger.d.ts +96 -0
- package/dist/utils/logger.js +456 -0
- package/dist/utils/project-dir.d.ts +51 -0
- package/dist/utils/project-dir.js +191 -0
- package/dist/utils/ripgrep.d.ts +34 -0
- package/dist/utils/ripgrep.js +148 -0
- package/dist/utils/tool-detection.d.ts +17 -0
- package/dist/utils/tool-detection.js +126 -0
- package/dist/watcher/FileWatcher.d.ts +10 -0
- package/dist/watcher/FileWatcher.js +42 -0
- package/package.json +70 -0
- package/src/config/__tests__/config.test.ts +318 -0
- package/src/config/__tests__/credentials.test.ts +494 -0
- package/src/config/config.ts +206 -0
- package/src/config/credentials.ts +302 -0
- package/src/config/server-selection.ts +150 -0
- package/src/connection/__tests__/firebase-auth.test.ts +121 -0
- package/src/connection/__tests__/hmac.test.ts +509 -0
- package/src/connection/auth.ts +140 -0
- package/src/connection/firebase-auth.ts +504 -0
- package/src/connection/hmac.ts +139 -0
- package/src/i18n/index.ts +119 -0
- package/src/i18n/locales/en.json +313 -0
- package/src/i18n/locales/es.json +302 -0
- package/src/i18n/locales/fr.json +302 -0
- package/src/i18n/locales/id.json +302 -0
- package/src/i18n/locales/ja.json +302 -0
- package/src/i18n/locales/ko.json +302 -0
- package/src/i18n/locales/pt.json +302 -0
- package/src/i18n/locales/zh-Hans.json +302 -0
- package/src/index.ts +542 -0
- package/src/proxy/ProxyClient.ts +968 -0
- package/src/proxy/ProxySocketWrapper.ts +113 -0
- package/src/proxy/__tests__/ProxyClient.test.ts +575 -0
- package/src/proxy/__tests__/ProxySocketWrapper.test.ts +251 -0
- package/src/proxy/__tests__/handshake-validation.test.ts +367 -0
- package/src/proxy/chunking.ts +162 -0
- package/src/proxy/handshake-validation.ts +64 -0
- package/src/rpc/__tests__/router.test.ts +400 -0
- package/src/rpc/router.ts +183 -0
- package/src/services/BrowserProxyService.ts +179 -0
- package/src/services/FilesystemService.ts +841 -0
- package/src/services/GitService.ts +1639 -0
- package/src/services/SearchService.ts +809 -0
- package/src/services/TerminalService.ts +413 -0
- package/src/services/__tests__/BrowserProxyService.test.ts +155 -0
- package/src/services/__tests__/FilesystemService.test.ts +1002 -0
- package/src/services/__tests__/GitService.test.ts +1552 -0
- package/src/services/__tests__/SearchService.test.ts +484 -0
- package/src/services/__tests__/TerminalService.test.ts +702 -0
- package/src/setup/wizard.ts +242 -0
- package/src/types/fossil-delta.d.ts +4 -0
- package/src/types.ts +287 -0
- package/src/utils/__tests__/gitignore.test.ts +174 -0
- package/src/utils/gitignore.ts +91 -0
- package/src/utils/logger.ts +578 -0
- package/src/utils/project-dir.ts +218 -0
- package/src/utils/ripgrep.ts +180 -0
- package/src/utils/tool-detection.ts +141 -0
- package/src/watcher/FileWatcher.ts +53 -0
- package/tsconfig.json +24 -0
- package/vitest.config.ts +19 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message Chunking Utility
|
|
3
|
+
*
|
|
4
|
+
* Handles chunking of large payloads to avoid Socket.IO buffer limits
|
|
5
|
+
* Chunks messages over 800kB into smaller pieces with reassembly metadata
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const CHUNK_SIZE = 800 * 1024; // 800kB
|
|
9
|
+
const CHUNK_THRESHOLD = 800 * 1024; // Start chunking at 800kB
|
|
10
|
+
|
|
11
|
+
export interface ChunkMetadata {
|
|
12
|
+
type: 'chunk';
|
|
13
|
+
chunkId: string;
|
|
14
|
+
index: number;
|
|
15
|
+
total: number;
|
|
16
|
+
originalEvent: string;
|
|
17
|
+
data: any;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ChunkedMessage {
|
|
21
|
+
needsChunking: boolean;
|
|
22
|
+
chunks?: ChunkMetadata[];
|
|
23
|
+
originalMessage?: any;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Check if a message needs chunking
|
|
28
|
+
*/
|
|
29
|
+
export function needsChunking(data: any): boolean {
|
|
30
|
+
const size = estimateSize(data);
|
|
31
|
+
return size > CHUNK_THRESHOLD;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Chunk a large message into smaller pieces
|
|
36
|
+
*/
|
|
37
|
+
export function chunkMessage(event: string, data: any): ChunkMetadata[] {
|
|
38
|
+
// Serialize the data
|
|
39
|
+
const serialized = JSON.stringify(data);
|
|
40
|
+
const totalBytes = Buffer.byteLength(serialized, 'utf8');
|
|
41
|
+
|
|
42
|
+
// Calculate number of chunks needed
|
|
43
|
+
const numChunks = Math.ceil(totalBytes / CHUNK_SIZE);
|
|
44
|
+
|
|
45
|
+
// Generate unique chunk ID
|
|
46
|
+
const chunkId = generateChunkId();
|
|
47
|
+
|
|
48
|
+
// Split into chunks
|
|
49
|
+
const chunks: ChunkMetadata[] = [];
|
|
50
|
+
let offset = 0;
|
|
51
|
+
|
|
52
|
+
for (let i = 0; i < numChunks; i++) {
|
|
53
|
+
const chunkData = serialized.slice(offset, offset + CHUNK_SIZE);
|
|
54
|
+
offset += CHUNK_SIZE;
|
|
55
|
+
|
|
56
|
+
chunks.push({
|
|
57
|
+
type: 'chunk',
|
|
58
|
+
chunkId,
|
|
59
|
+
index: i,
|
|
60
|
+
total: numChunks,
|
|
61
|
+
originalEvent: event,
|
|
62
|
+
data: chunkData,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return chunks;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Estimate size of an object in bytes
|
|
71
|
+
*/
|
|
72
|
+
function estimateSize(obj: any): number {
|
|
73
|
+
const serialized = JSON.stringify(obj);
|
|
74
|
+
return Buffer.byteLength(serialized, 'utf8');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Generate unique chunk ID
|
|
79
|
+
*/
|
|
80
|
+
function generateChunkId(): string {
|
|
81
|
+
return `chunk_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Chunk reassembler (for client side)
|
|
86
|
+
*/
|
|
87
|
+
export class ChunkReassembler {
|
|
88
|
+
private chunks: Map<string, Map<number, string>> = new Map();
|
|
89
|
+
private totalChunks: Map<string, number> = new Map();
|
|
90
|
+
private originalEvents: Map<string, string> = new Map();
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Add a chunk and check if message is complete
|
|
94
|
+
* @returns Reassembled data if complete, null otherwise
|
|
95
|
+
*/
|
|
96
|
+
addChunk(chunk: ChunkMetadata): { complete: boolean; event?: string; data?: any } {
|
|
97
|
+
const { chunkId, index, total, originalEvent, data } = chunk;
|
|
98
|
+
|
|
99
|
+
// Initialize chunk storage for this chunkId
|
|
100
|
+
if (!this.chunks.has(chunkId)) {
|
|
101
|
+
this.chunks.set(chunkId, new Map());
|
|
102
|
+
this.totalChunks.set(chunkId, total);
|
|
103
|
+
this.originalEvents.set(chunkId, originalEvent);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Store this chunk
|
|
107
|
+
const chunkMap = this.chunks.get(chunkId)!;
|
|
108
|
+
chunkMap.set(index, data);
|
|
109
|
+
|
|
110
|
+
// Check if we have all chunks
|
|
111
|
+
const expectedTotal = this.totalChunks.get(chunkId)!;
|
|
112
|
+
if (chunkMap.size === expectedTotal) {
|
|
113
|
+
// Reassemble message
|
|
114
|
+
const reassembled = this.reassemble(chunkId);
|
|
115
|
+
|
|
116
|
+
// Clean up
|
|
117
|
+
this.chunks.delete(chunkId);
|
|
118
|
+
this.totalChunks.delete(chunkId);
|
|
119
|
+
this.originalEvents.delete(chunkId);
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
complete: true,
|
|
123
|
+
event: originalEvent,
|
|
124
|
+
data: reassembled,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { complete: false };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Reassemble chunks into original message
|
|
133
|
+
*/
|
|
134
|
+
private reassemble(chunkId: string): any {
|
|
135
|
+
const chunkMap = this.chunks.get(chunkId)!;
|
|
136
|
+
const total = this.totalChunks.get(chunkId)!;
|
|
137
|
+
|
|
138
|
+
// Concatenate chunks in order
|
|
139
|
+
let reassembled = '';
|
|
140
|
+
for (let i = 0; i < total; i++) {
|
|
141
|
+
const chunk = chunkMap.get(i);
|
|
142
|
+
if (!chunk) {
|
|
143
|
+
throw new Error(`Missing chunk ${i} for chunkId ${chunkId}`);
|
|
144
|
+
}
|
|
145
|
+
reassembled += chunk;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Parse back to object
|
|
149
|
+
return JSON.parse(reassembled);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Clean up stale chunks (older than 30 seconds)
|
|
154
|
+
*/
|
|
155
|
+
cleanup(_maxAge: number = 30000): void {
|
|
156
|
+
// Note: This is a simple implementation that clears all chunks
|
|
157
|
+
// A more sophisticated version could track timestamps
|
|
158
|
+
this.chunks.clear();
|
|
159
|
+
this.totalChunks.clear();
|
|
160
|
+
this.originalEvents.clear();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handshake validation utilities
|
|
3
|
+
* Provides timestamp validation for replay attack prevention
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Validate timestamp from client handshake message
|
|
8
|
+
* Prevents replay attacks by ensuring messages are recent
|
|
9
|
+
*
|
|
10
|
+
* @param timestamp - Unix timestamp in milliseconds from client message
|
|
11
|
+
* @param options - Validation options
|
|
12
|
+
* @returns Validation result with error message if invalid
|
|
13
|
+
*/
|
|
14
|
+
export function validateHandshakeTimestamp(
|
|
15
|
+
timestamp: number,
|
|
16
|
+
options: {
|
|
17
|
+
maxAge?: number; // Maximum age in milliseconds (default: 1 minute)
|
|
18
|
+
clockSkewTolerance?: number; // Future tolerance in milliseconds (default: 1 minute)
|
|
19
|
+
now?: number; // Current time for testing (default: Date.now())
|
|
20
|
+
} = {}
|
|
21
|
+
): { valid: boolean; error?: string } {
|
|
22
|
+
const {
|
|
23
|
+
maxAge = 60 * 1000, // 1 minute default
|
|
24
|
+
clockSkewTolerance = 60 * 1000, // 1 minute default
|
|
25
|
+
now = Date.now(),
|
|
26
|
+
} = options;
|
|
27
|
+
|
|
28
|
+
// Validate timestamp is a number
|
|
29
|
+
if (typeof timestamp !== 'number' || !isFinite(timestamp)) {
|
|
30
|
+
return {
|
|
31
|
+
valid: false,
|
|
32
|
+
error: 'Invalid timestamp format',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Validate timestamp is positive
|
|
37
|
+
if (timestamp <= 0) {
|
|
38
|
+
return {
|
|
39
|
+
valid: false,
|
|
40
|
+
error: 'Timestamp must be positive',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Calculate age
|
|
45
|
+
const age = now - timestamp;
|
|
46
|
+
|
|
47
|
+
// Check if message is too old
|
|
48
|
+
if (age > maxAge) {
|
|
49
|
+
return {
|
|
50
|
+
valid: false,
|
|
51
|
+
error: `Message too old (age: ${Math.floor(age / 1000)}s, max: ${Math.floor(maxAge / 1000)}s)`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Check if timestamp is too far in the future (clock skew)
|
|
56
|
+
if (age < -clockSkewTolerance) {
|
|
57
|
+
return {
|
|
58
|
+
valid: false,
|
|
59
|
+
error: `Timestamp too far in future (skew: ${Math.floor(-age / 1000)}s, max: ${Math.floor(clockSkewTolerance / 1000)}s)`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { valid: true };
|
|
64
|
+
}
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi, type Mock, type MockedClass } from 'vitest';
|
|
2
|
+
/**
|
|
3
|
+
* Tests for JSON-RPC Router
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Mock xterm headless modules BEFORE importing TerminalService
|
|
7
|
+
vi.mock('@xterm/headless', () => ({
|
|
8
|
+
default: { Terminal: vi.fn() },
|
|
9
|
+
Terminal: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
vi.mock('@xterm/addon-serialize', () => ({
|
|
12
|
+
default: { SerializeAddon: vi.fn() },
|
|
13
|
+
SerializeAddon: vi.fn(),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
import * as crypto from 'crypto';
|
|
17
|
+
import { RPCRouter } from '../router.js';
|
|
18
|
+
import { ErrorCode, createRPCError, JSONRPCRequest } from '../../types.js';
|
|
19
|
+
import { FilesystemService } from '../../services/FilesystemService.js';
|
|
20
|
+
import { GitService } from '../../services/GitService.js';
|
|
21
|
+
import { TerminalService } from '../../services/TerminalService.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Helper function to create a valid JSONRPCRequest with HMAC and nonce
|
|
25
|
+
*/
|
|
26
|
+
function createRequest(method: string, params?: any, id?: number | string): JSONRPCRequest {
|
|
27
|
+
const timestamp = Date.now();
|
|
28
|
+
const nonce = crypto.randomBytes(16).toString('hex');
|
|
29
|
+
const signingKey = 'test-key';
|
|
30
|
+
|
|
31
|
+
const payload = {
|
|
32
|
+
jsonrpc: '2.0' as const,
|
|
33
|
+
method,
|
|
34
|
+
params,
|
|
35
|
+
id,
|
|
36
|
+
nonce,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const messageToSign = timestamp + JSON.stringify(payload);
|
|
40
|
+
const hmac = crypto
|
|
41
|
+
.createHmac('sha256', signingKey)
|
|
42
|
+
.update(messageToSign)
|
|
43
|
+
.digest('hex');
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
...payload,
|
|
47
|
+
timestamp,
|
|
48
|
+
hmac,
|
|
49
|
+
nonce,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Mock services
|
|
54
|
+
vi.mock('../../services/FilesystemService', () => {
|
|
55
|
+
const MockFS = vi.fn();
|
|
56
|
+
MockFS.prototype.handle = vi.fn();
|
|
57
|
+
return { FilesystemService: MockFS };
|
|
58
|
+
});
|
|
59
|
+
vi.mock('../../services/GitService', () => {
|
|
60
|
+
const MockGit = vi.fn();
|
|
61
|
+
MockGit.prototype.handle = vi.fn();
|
|
62
|
+
return { GitService: MockGit };
|
|
63
|
+
});
|
|
64
|
+
vi.mock('../../services/TerminalService', () => {
|
|
65
|
+
const MockTerm = vi.fn();
|
|
66
|
+
MockTerm.prototype.handle = vi.fn();
|
|
67
|
+
MockTerm.prototype.cleanup = vi.fn();
|
|
68
|
+
return { TerminalService: MockTerm };
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const MockFilesystemService = FilesystemService as MockedClass<typeof FilesystemService>;
|
|
72
|
+
const MockGitService = GitService as MockedClass<typeof GitService>;
|
|
73
|
+
const MockTerminalService = TerminalService as MockedClass<typeof TerminalService>;
|
|
74
|
+
|
|
75
|
+
describe('RPCRouter', () => {
|
|
76
|
+
let mockSocket: any;
|
|
77
|
+
let mockFsHandle: Mock;
|
|
78
|
+
let mockGitHandle: Mock;
|
|
79
|
+
let mockTerminalHandle: Mock;
|
|
80
|
+
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
mockSocket = {
|
|
83
|
+
id: 'test-socket-id',
|
|
84
|
+
data: {
|
|
85
|
+
uid: 'test-user-123',
|
|
86
|
+
},
|
|
87
|
+
emit: vi.fn(),
|
|
88
|
+
on: vi.fn(),
|
|
89
|
+
off: vi.fn(),
|
|
90
|
+
broadcast: {
|
|
91
|
+
emit: vi.fn(),
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Clear all mocks
|
|
96
|
+
vi.clearAllMocks();
|
|
97
|
+
|
|
98
|
+
// Clear router state between tests
|
|
99
|
+
(RPCRouter as any).terminalServices = new Map();
|
|
100
|
+
(RPCRouter as any).currentSockets = new Map();
|
|
101
|
+
|
|
102
|
+
// Set up default mock implementations
|
|
103
|
+
mockFsHandle = vi.fn().mockResolvedValue({ success: true });
|
|
104
|
+
mockGitHandle = vi.fn().mockResolvedValue({ success: true });
|
|
105
|
+
mockTerminalHandle = vi.fn().mockResolvedValue('term-123');
|
|
106
|
+
|
|
107
|
+
// Mock the service prototypes
|
|
108
|
+
MockFilesystemService.prototype.handle = mockFsHandle;
|
|
109
|
+
MockGitService.prototype.handle = mockGitHandle;
|
|
110
|
+
|
|
111
|
+
// Create mock instance for terminal service that will be returned by constructor
|
|
112
|
+
// Use a single tracked cleanup function across all instances
|
|
113
|
+
const mockCleanupFn = vi.fn();
|
|
114
|
+
MockTerminalService.mockImplementation(() => ({
|
|
115
|
+
handle: mockTerminalHandle,
|
|
116
|
+
cleanup: mockCleanupFn,
|
|
117
|
+
}) as any);
|
|
118
|
+
|
|
119
|
+
// Initialize router
|
|
120
|
+
RPCRouter.initialize('/test/root', {
|
|
121
|
+
filesystem: { maxFileSize: '100MB', watchIgnorePatterns: [] },
|
|
122
|
+
}, {
|
|
123
|
+
git: true,
|
|
124
|
+
ripgrep: true,
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('Route Method Parsing', () => {
|
|
129
|
+
it('should parse method correctly for fs service', async () => {
|
|
130
|
+
await RPCRouter.route(
|
|
131
|
+
createRequest('fs.readFile', { path: '/test.txt' }, 1),
|
|
132
|
+
mockSocket
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(mockFsHandle).toHaveBeenCalledWith('readFile', { path: '/test.txt' }, mockSocket);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should parse method correctly for git service', async () => {
|
|
139
|
+
await RPCRouter.route(
|
|
140
|
+
createRequest('git.log', { dir: '/project' }, 2),
|
|
141
|
+
mockSocket
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
expect(mockGitHandle).toHaveBeenCalledWith('log', { dir: '/project' }, mockSocket);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should parse method correctly for terminal service', async () => {
|
|
148
|
+
await RPCRouter.route(
|
|
149
|
+
createRequest('terminal.create', { cols: 80, rows: 24 }, 3),
|
|
150
|
+
mockSocket
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
expect(mockTerminalHandle).toHaveBeenCalledWith('create', { cols: 80, rows: 24 });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should throw error for invalid method format', async () => {
|
|
157
|
+
await expect(
|
|
158
|
+
RPCRouter.route(
|
|
159
|
+
createRequest('invalidmethod', {}, 4),
|
|
160
|
+
mockSocket
|
|
161
|
+
)
|
|
162
|
+
).rejects.toMatchObject({
|
|
163
|
+
code: ErrorCode.INVALID_REQUEST,
|
|
164
|
+
message: expect.stringContaining('Invalid method format'),
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should throw error for unknown service', async () => {
|
|
169
|
+
await expect(
|
|
170
|
+
RPCRouter.route(
|
|
171
|
+
createRequest('unknown.method', {}, 5),
|
|
172
|
+
mockSocket
|
|
173
|
+
)
|
|
174
|
+
).rejects.toMatchObject({
|
|
175
|
+
code: ErrorCode.METHOD_NOT_FOUND,
|
|
176
|
+
message: expect.stringContaining('Unknown service'),
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe('Service Routing', () => {
|
|
182
|
+
it('should route to FilesystemService', async () => {
|
|
183
|
+
const mockResult = { content: 'file contents' };
|
|
184
|
+
mockFsHandle.mockResolvedValueOnce(mockResult);
|
|
185
|
+
|
|
186
|
+
const result = await RPCRouter.route(
|
|
187
|
+
createRequest('fs.readFile', { path: '/test.txt', encoding: 'utf8' }, 10),
|
|
188
|
+
mockSocket
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
expect(result).toEqual(mockResult);
|
|
192
|
+
expect(mockFsHandle).toHaveBeenCalledTimes(1);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should route to GitService', async () => {
|
|
196
|
+
const mockResult = { oid: 'abc123', commit: {} };
|
|
197
|
+
mockGitHandle.mockResolvedValueOnce(mockResult);
|
|
198
|
+
|
|
199
|
+
const result = await RPCRouter.route(
|
|
200
|
+
createRequest('git.readCommit', { dir: '/project', oid: 'abc123' }, 11),
|
|
201
|
+
mockSocket
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
expect(result).toEqual(mockResult);
|
|
205
|
+
expect(mockGitHandle).toHaveBeenCalledTimes(1);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should route to TerminalService', async () => {
|
|
209
|
+
const result = await RPCRouter.route(
|
|
210
|
+
createRequest('terminal.create', { cols: 120, rows: 30 }, 12),
|
|
211
|
+
mockSocket
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// Should return the default mock result
|
|
215
|
+
expect(result).toEqual('term-123');
|
|
216
|
+
expect(mockTerminalHandle).toHaveBeenCalledTimes(1);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('should create separate terminal services per device', async () => {
|
|
220
|
+
const socket1 = { ...mockSocket, id: 'socket-1', data: { uid: 'user-1', deviceId: 'device-1' } };
|
|
221
|
+
const socket2 = { ...mockSocket, id: 'socket-2', data: { uid: 'user-2', deviceId: 'device-2' } };
|
|
222
|
+
|
|
223
|
+
const result1 = await RPCRouter.route(
|
|
224
|
+
createRequest('terminal.create', {}, 1),
|
|
225
|
+
socket1
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
const result2 = await RPCRouter.route(
|
|
229
|
+
createRequest('terminal.create', {}, 2),
|
|
230
|
+
socket2
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
// Both should succeed (indicating separate services were created)
|
|
234
|
+
expect(result1).toBeTruthy();
|
|
235
|
+
expect(result2).toBeTruthy();
|
|
236
|
+
expect(mockTerminalHandle).toHaveBeenCalledTimes(2);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should reuse terminal service for same device', async () => {
|
|
240
|
+
const socket1 = { ...mockSocket, id: 'socket-1', data: { uid: 'user-same', deviceId: 'device-same' } };
|
|
241
|
+
const socket2 = { ...mockSocket, id: 'socket-2', data: { uid: 'user-same', deviceId: 'device-same' } };
|
|
242
|
+
|
|
243
|
+
const result1 = await RPCRouter.route(
|
|
244
|
+
createRequest('terminal.create', {}, 1),
|
|
245
|
+
socket1
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const result2 = await RPCRouter.route(
|
|
249
|
+
createRequest('terminal.create', {}, 2),
|
|
250
|
+
socket2
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
// Both should succeed using the same service instance
|
|
254
|
+
expect(result1).toBeTruthy();
|
|
255
|
+
expect(result2).toBeTruthy();
|
|
256
|
+
expect(mockTerminalHandle).toHaveBeenCalledTimes(2);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe('Error Handling', () => {
|
|
261
|
+
it('should re-throw RPC errors from services', async () => {
|
|
262
|
+
const rpcError = createRPCError(ErrorCode.FILE_NOT_FOUND, 'File not found');
|
|
263
|
+
mockFsHandle.mockRejectedValueOnce(rpcError);
|
|
264
|
+
|
|
265
|
+
await expect(
|
|
266
|
+
RPCRouter.route(
|
|
267
|
+
createRequest('fs.readFile', { path: '/missing.txt' }, 20),
|
|
268
|
+
mockSocket
|
|
269
|
+
)
|
|
270
|
+
).rejects.toMatchObject({
|
|
271
|
+
code: ErrorCode.FILE_NOT_FOUND,
|
|
272
|
+
message: 'File not found',
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('should wrap non-RPC errors in internal error', async () => {
|
|
277
|
+
const genericError = new Error('Something went wrong');
|
|
278
|
+
mockFsHandle.mockRejectedValueOnce(genericError);
|
|
279
|
+
|
|
280
|
+
await expect(
|
|
281
|
+
RPCRouter.route(
|
|
282
|
+
createRequest('fs.readFile', { path: '/test.txt' }, 21),
|
|
283
|
+
mockSocket
|
|
284
|
+
)
|
|
285
|
+
).rejects.toMatchObject({
|
|
286
|
+
code: ErrorCode.INTERNAL_ERROR,
|
|
287
|
+
message: expect.stringContaining('Internal error'),
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('should include method name in wrapped errors', async () => {
|
|
292
|
+
const genericError = new Error('Unexpected error');
|
|
293
|
+
mockGitHandle.mockRejectedValueOnce(genericError);
|
|
294
|
+
|
|
295
|
+
await expect(
|
|
296
|
+
RPCRouter.route(
|
|
297
|
+
createRequest('git.commit', { dir: '/project', message: 'test' }, 22),
|
|
298
|
+
mockSocket
|
|
299
|
+
)
|
|
300
|
+
).rejects.toMatchObject({
|
|
301
|
+
code: ErrorCode.INTERNAL_ERROR,
|
|
302
|
+
data: {
|
|
303
|
+
method: 'git.commit',
|
|
304
|
+
originalError: expect.any(String),
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe('Terminal Service Cleanup', () => {
|
|
311
|
+
it('should cleanup terminal service on disconnect', async () => {
|
|
312
|
+
// Setup a mock cleanup function that will be tracked
|
|
313
|
+
const mockCleanup = vi.fn();
|
|
314
|
+
|
|
315
|
+
// Create a new mock instance with cleanup
|
|
316
|
+
const mockServiceWithCleanup = {
|
|
317
|
+
handle: mockTerminalHandle,
|
|
318
|
+
cleanup: mockCleanup,
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// Override the mock to return our instance
|
|
322
|
+
MockTerminalService.mockImplementationOnce(() => mockServiceWithCleanup as any);
|
|
323
|
+
|
|
324
|
+
// Create a terminal service - this will instantiate the mock
|
|
325
|
+
await RPCRouter.route(
|
|
326
|
+
createRequest('terminal.create', {}, 1),
|
|
327
|
+
mockSocket
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
// Now cleanup - this should call the cleanup method
|
|
331
|
+
RPCRouter.cleanupTerminalService(mockSocket);
|
|
332
|
+
|
|
333
|
+
expect(mockCleanup).toHaveBeenCalledTimes(1);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('should handle cleanup when no terminal service exists', () => {
|
|
337
|
+
const otherSocket = { ...mockSocket, data: { uid: 'different-user', deviceId: 'different-device' } };
|
|
338
|
+
|
|
339
|
+
expect(() => {
|
|
340
|
+
RPCRouter.cleanupTerminalService(otherSocket);
|
|
341
|
+
}).not.toThrow();
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
describe('Edge Cases', () => {
|
|
346
|
+
it('should handle method with multiple dots', async () => {
|
|
347
|
+
// Method "fs.some.deep.method" splits into service="fs", methodName="some.deep.method"
|
|
348
|
+
// Only the first dot is used to separate service from method; sub-namespaces are preserved
|
|
349
|
+
const result = await RPCRouter.route(
|
|
350
|
+
createRequest('fs.some.deep.method', {}, 30),
|
|
351
|
+
mockSocket
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
// Should route to fs service with the full sub-method "some.deep.method"
|
|
355
|
+
expect(mockFsHandle).toHaveBeenCalledWith('some.deep.method', {}, mockSocket);
|
|
356
|
+
expect(result).toEqual({ success: true });
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('should handle empty method name', async () => {
|
|
360
|
+
await expect(
|
|
361
|
+
RPCRouter.route(
|
|
362
|
+
createRequest('', {}, 31),
|
|
363
|
+
mockSocket
|
|
364
|
+
)
|
|
365
|
+
).rejects.toMatchObject({
|
|
366
|
+
code: ErrorCode.INVALID_REQUEST,
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('should handle method with only service name', async () => {
|
|
371
|
+
await expect(
|
|
372
|
+
RPCRouter.route(
|
|
373
|
+
createRequest('fs.', {}, 32),
|
|
374
|
+
mockSocket
|
|
375
|
+
)
|
|
376
|
+
).rejects.toMatchObject({
|
|
377
|
+
code: ErrorCode.INVALID_REQUEST,
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('should handle undefined params', async () => {
|
|
382
|
+
const result = await RPCRouter.route(
|
|
383
|
+
createRequest('fs.stat', undefined, 33),
|
|
384
|
+
mockSocket
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
expect(mockFsHandle).toHaveBeenCalledWith('stat', undefined, mockSocket);
|
|
388
|
+
expect(result).toEqual({ success: true });
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('should handle requests with no id', async () => {
|
|
392
|
+
const result = await RPCRouter.route(
|
|
393
|
+
createRequest('fs.stat', { path: '/test.txt' }),
|
|
394
|
+
mockSocket
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
expect(result).toEqual({ success: true });
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
});
|