mstro-app 0.1.47
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/LICENSE +21 -0
- package/README.md +177 -0
- package/bin/commands/config.js +145 -0
- package/bin/commands/login.js +313 -0
- package/bin/commands/logout.js +75 -0
- package/bin/commands/status.js +197 -0
- package/bin/commands/whoami.js +161 -0
- package/bin/configure-claude.js +298 -0
- package/bin/mstro.js +581 -0
- package/bin/postinstall.js +45 -0
- package/bin/release.sh +110 -0
- package/dist/server/cli/headless/claude-invoker.d.ts +17 -0
- package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -0
- package/dist/server/cli/headless/claude-invoker.js +311 -0
- package/dist/server/cli/headless/claude-invoker.js.map +1 -0
- package/dist/server/cli/headless/index.d.ts +13 -0
- package/dist/server/cli/headless/index.d.ts.map +1 -0
- package/dist/server/cli/headless/index.js +10 -0
- package/dist/server/cli/headless/index.js.map +1 -0
- package/dist/server/cli/headless/mcp-config.d.ts +11 -0
- package/dist/server/cli/headless/mcp-config.d.ts.map +1 -0
- package/dist/server/cli/headless/mcp-config.js +76 -0
- package/dist/server/cli/headless/mcp-config.js.map +1 -0
- package/dist/server/cli/headless/output-utils.d.ts +33 -0
- package/dist/server/cli/headless/output-utils.d.ts.map +1 -0
- package/dist/server/cli/headless/output-utils.js +101 -0
- package/dist/server/cli/headless/output-utils.js.map +1 -0
- package/dist/server/cli/headless/prompt-utils.d.ts +21 -0
- package/dist/server/cli/headless/prompt-utils.d.ts.map +1 -0
- package/dist/server/cli/headless/prompt-utils.js +84 -0
- package/dist/server/cli/headless/prompt-utils.js.map +1 -0
- package/dist/server/cli/headless/runner.d.ts +24 -0
- package/dist/server/cli/headless/runner.d.ts.map +1 -0
- package/dist/server/cli/headless/runner.js +99 -0
- package/dist/server/cli/headless/runner.js.map +1 -0
- package/dist/server/cli/headless/types.d.ts +106 -0
- package/dist/server/cli/headless/types.d.ts.map +1 -0
- package/dist/server/cli/headless/types.js +4 -0
- package/dist/server/cli/headless/types.js.map +1 -0
- package/dist/server/cli/improvisation-session-manager.d.ts +155 -0
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -0
- package/dist/server/cli/improvisation-session-manager.js +415 -0
- package/dist/server/cli/improvisation-session-manager.js.map +1 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +386 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/mcp/bouncer-cli.d.ts +3 -0
- package/dist/server/mcp/bouncer-cli.d.ts.map +1 -0
- package/dist/server/mcp/bouncer-cli.js +99 -0
- package/dist/server/mcp/bouncer-cli.js.map +1 -0
- package/dist/server/mcp/bouncer-integration.d.ts +36 -0
- package/dist/server/mcp/bouncer-integration.d.ts.map +1 -0
- package/dist/server/mcp/bouncer-integration.js +301 -0
- package/dist/server/mcp/bouncer-integration.js.map +1 -0
- package/dist/server/mcp/security-audit.d.ts +52 -0
- package/dist/server/mcp/security-audit.d.ts.map +1 -0
- package/dist/server/mcp/security-audit.js +118 -0
- package/dist/server/mcp/security-audit.js.map +1 -0
- package/dist/server/mcp/security-patterns.d.ts +73 -0
- package/dist/server/mcp/security-patterns.d.ts.map +1 -0
- package/dist/server/mcp/security-patterns.js +247 -0
- package/dist/server/mcp/security-patterns.js.map +1 -0
- package/dist/server/mcp/server.d.ts +3 -0
- package/dist/server/mcp/server.d.ts.map +1 -0
- package/dist/server/mcp/server.js +146 -0
- package/dist/server/mcp/server.js.map +1 -0
- package/dist/server/routes/files.d.ts +9 -0
- package/dist/server/routes/files.d.ts.map +1 -0
- package/dist/server/routes/files.js +24 -0
- package/dist/server/routes/files.js.map +1 -0
- package/dist/server/routes/improvise.d.ts +3 -0
- package/dist/server/routes/improvise.d.ts.map +1 -0
- package/dist/server/routes/improvise.js +72 -0
- package/dist/server/routes/improvise.js.map +1 -0
- package/dist/server/routes/index.d.ts +10 -0
- package/dist/server/routes/index.d.ts.map +1 -0
- package/dist/server/routes/index.js +12 -0
- package/dist/server/routes/index.js.map +1 -0
- package/dist/server/routes/instances.d.ts +10 -0
- package/dist/server/routes/instances.d.ts.map +1 -0
- package/dist/server/routes/instances.js +47 -0
- package/dist/server/routes/instances.js.map +1 -0
- package/dist/server/routes/notifications.d.ts +3 -0
- package/dist/server/routes/notifications.d.ts.map +1 -0
- package/dist/server/routes/notifications.js +136 -0
- package/dist/server/routes/notifications.js.map +1 -0
- package/dist/server/services/analytics.d.ts +56 -0
- package/dist/server/services/analytics.d.ts.map +1 -0
- package/dist/server/services/analytics.js +240 -0
- package/dist/server/services/analytics.js.map +1 -0
- package/dist/server/services/auth.d.ts +26 -0
- package/dist/server/services/auth.d.ts.map +1 -0
- package/dist/server/services/auth.js +71 -0
- package/dist/server/services/auth.js.map +1 -0
- package/dist/server/services/client-id.d.ts +10 -0
- package/dist/server/services/client-id.d.ts.map +1 -0
- package/dist/server/services/client-id.js +61 -0
- package/dist/server/services/client-id.js.map +1 -0
- package/dist/server/services/credentials.d.ts +39 -0
- package/dist/server/services/credentials.d.ts.map +1 -0
- package/dist/server/services/credentials.js +110 -0
- package/dist/server/services/credentials.js.map +1 -0
- package/dist/server/services/files.d.ts +119 -0
- package/dist/server/services/files.d.ts.map +1 -0
- package/dist/server/services/files.js +560 -0
- package/dist/server/services/files.js.map +1 -0
- package/dist/server/services/instances.d.ts +52 -0
- package/dist/server/services/instances.d.ts.map +1 -0
- package/dist/server/services/instances.js +241 -0
- package/dist/server/services/instances.js.map +1 -0
- package/dist/server/services/pathUtils.d.ts +47 -0
- package/dist/server/services/pathUtils.d.ts.map +1 -0
- package/dist/server/services/pathUtils.js +124 -0
- package/dist/server/services/pathUtils.js.map +1 -0
- package/dist/server/services/platform.d.ts +72 -0
- package/dist/server/services/platform.d.ts.map +1 -0
- package/dist/server/services/platform.js +368 -0
- package/dist/server/services/platform.js.map +1 -0
- package/dist/server/services/sentry.d.ts +5 -0
- package/dist/server/services/sentry.d.ts.map +1 -0
- package/dist/server/services/sentry.js +71 -0
- package/dist/server/services/sentry.js.map +1 -0
- package/dist/server/services/terminal/pty-manager.d.ts +149 -0
- package/dist/server/services/terminal/pty-manager.d.ts.map +1 -0
- package/dist/server/services/terminal/pty-manager.js +377 -0
- package/dist/server/services/terminal/pty-manager.js.map +1 -0
- package/dist/server/services/terminal/tmux-manager.d.ts +82 -0
- package/dist/server/services/terminal/tmux-manager.d.ts.map +1 -0
- package/dist/server/services/terminal/tmux-manager.js +352 -0
- package/dist/server/services/terminal/tmux-manager.js.map +1 -0
- package/dist/server/services/websocket/autocomplete.d.ts +50 -0
- package/dist/server/services/websocket/autocomplete.d.ts.map +1 -0
- package/dist/server/services/websocket/autocomplete.js +361 -0
- package/dist/server/services/websocket/autocomplete.js.map +1 -0
- package/dist/server/services/websocket/file-utils.d.ts +44 -0
- package/dist/server/services/websocket/file-utils.d.ts.map +1 -0
- package/dist/server/services/websocket/file-utils.js +272 -0
- package/dist/server/services/websocket/file-utils.js.map +1 -0
- package/dist/server/services/websocket/handler.d.ts +246 -0
- package/dist/server/services/websocket/handler.d.ts.map +1 -0
- package/dist/server/services/websocket/handler.js +1771 -0
- package/dist/server/services/websocket/handler.js.map +1 -0
- package/dist/server/services/websocket/index.d.ts +11 -0
- package/dist/server/services/websocket/index.d.ts.map +1 -0
- package/dist/server/services/websocket/index.js +14 -0
- package/dist/server/services/websocket/index.js.map +1 -0
- package/dist/server/services/websocket/types.d.ts +214 -0
- package/dist/server/services/websocket/types.d.ts.map +1 -0
- package/dist/server/services/websocket/types.js +4 -0
- package/dist/server/services/websocket/types.js.map +1 -0
- package/dist/server/utils/agent-manager.d.ts +69 -0
- package/dist/server/utils/agent-manager.d.ts.map +1 -0
- package/dist/server/utils/agent-manager.js +269 -0
- package/dist/server/utils/agent-manager.js.map +1 -0
- package/dist/server/utils/paths.d.ts +25 -0
- package/dist/server/utils/paths.d.ts.map +1 -0
- package/dist/server/utils/paths.js +38 -0
- package/dist/server/utils/paths.js.map +1 -0
- package/dist/server/utils/port-manager.d.ts +10 -0
- package/dist/server/utils/port-manager.d.ts.map +1 -0
- package/dist/server/utils/port-manager.js +60 -0
- package/dist/server/utils/port-manager.js.map +1 -0
- package/dist/server/utils/port.d.ts +26 -0
- package/dist/server/utils/port.d.ts.map +1 -0
- package/dist/server/utils/port.js +83 -0
- package/dist/server/utils/port.js.map +1 -0
- package/hooks/bouncer.sh +138 -0
- package/package.json +74 -0
- package/server/README.md +191 -0
- package/server/cli/headless/claude-invoker.ts +415 -0
- package/server/cli/headless/index.ts +39 -0
- package/server/cli/headless/mcp-config.ts +87 -0
- package/server/cli/headless/output-utils.ts +109 -0
- package/server/cli/headless/prompt-utils.ts +108 -0
- package/server/cli/headless/runner.ts +133 -0
- package/server/cli/headless/types.ts +118 -0
- package/server/cli/improvisation-session-manager.ts +531 -0
- package/server/index.ts +456 -0
- package/server/mcp/README.md +122 -0
- package/server/mcp/bouncer-cli.ts +127 -0
- package/server/mcp/bouncer-integration.ts +430 -0
- package/server/mcp/security-audit.ts +180 -0
- package/server/mcp/security-patterns.ts +290 -0
- package/server/mcp/server.ts +174 -0
- package/server/routes/files.ts +29 -0
- package/server/routes/improvise.ts +82 -0
- package/server/routes/index.ts +13 -0
- package/server/routes/instances.ts +54 -0
- package/server/routes/notifications.ts +158 -0
- package/server/services/analytics.ts +277 -0
- package/server/services/auth.ts +80 -0
- package/server/services/client-id.ts +68 -0
- package/server/services/credentials.ts +134 -0
- package/server/services/files.ts +710 -0
- package/server/services/instances.ts +275 -0
- package/server/services/pathUtils.ts +158 -0
- package/server/services/platform.test.ts +1314 -0
- package/server/services/platform.ts +435 -0
- package/server/services/sentry.ts +81 -0
- package/server/services/terminal/pty-manager.ts +464 -0
- package/server/services/terminal/tmux-manager.ts +426 -0
- package/server/services/websocket/autocomplete.ts +438 -0
- package/server/services/websocket/file-utils.ts +305 -0
- package/server/services/websocket/handler.test.ts +20 -0
- package/server/services/websocket/handler.ts +2047 -0
- package/server/services/websocket/index.ts +40 -0
- package/server/services/websocket/types.ts +339 -0
- package/server/tsconfig.json +19 -0
- package/server/utils/agent-manager.ts +323 -0
- package/server/utils/paths.ts +45 -0
- package/server/utils/port-manager.ts +70 -0
- package/server/utils/port.ts +102 -0
|
@@ -0,0 +1,1314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform Connection Service Tests
|
|
3
|
+
*
|
|
4
|
+
* Comprehensive test suite for due diligence validation.
|
|
5
|
+
* Tests critical security features, connection lifecycle, message handling, and token refresh.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { EventEmitter } from 'node:events'
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
10
|
+
|
|
11
|
+
// Mock modules before importing the module under test
|
|
12
|
+
const mockFs = {
|
|
13
|
+
existsSync: vi.fn(),
|
|
14
|
+
readFileSync: vi.fn(),
|
|
15
|
+
writeFileSync: vi.fn(),
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const mockOs = {
|
|
19
|
+
hostname: vi.fn(),
|
|
20
|
+
type: vi.fn(),
|
|
21
|
+
arch: vi.fn(),
|
|
22
|
+
homedir: vi.fn(),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const mockPath = {
|
|
26
|
+
join: vi.fn((...args: string[]) => args.join('/')),
|
|
27
|
+
basename: vi.fn((path: string) => path.split('/').pop() || ''),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const mockClientId = {
|
|
31
|
+
getClientId: vi.fn(),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const mockTmux = {
|
|
35
|
+
isTmuxAvailable: vi.fn(),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Mock fetch globally
|
|
39
|
+
global.fetch = vi.fn()
|
|
40
|
+
|
|
41
|
+
// Mock WebSocket class
|
|
42
|
+
class MockWebSocket extends EventEmitter {
|
|
43
|
+
static CONNECTING = 0
|
|
44
|
+
static OPEN = 1
|
|
45
|
+
static CLOSING = 2
|
|
46
|
+
static CLOSED = 3
|
|
47
|
+
|
|
48
|
+
public readyState: number = MockWebSocket.CONNECTING
|
|
49
|
+
public url: string
|
|
50
|
+
public onopen: ((event: any) => void) | null = null
|
|
51
|
+
public onclose: ((event: any) => void) | null = null
|
|
52
|
+
public onerror: ((event: any) => void) | null = null
|
|
53
|
+
public onmessage: ((event: any) => void) | null = null
|
|
54
|
+
|
|
55
|
+
constructor(url: string) {
|
|
56
|
+
super()
|
|
57
|
+
this.url = url
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
send(data: string): void {
|
|
61
|
+
this.emit('send', data)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
close(): void {
|
|
65
|
+
this.readyState = MockWebSocket.CLOSED
|
|
66
|
+
if (this.onclose) {
|
|
67
|
+
this.onclose({ code: 1000, reason: 'Normal closure' })
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Helper methods for testing
|
|
72
|
+
triggerOpen(): void {
|
|
73
|
+
this.readyState = MockWebSocket.OPEN
|
|
74
|
+
if (this.onopen) {
|
|
75
|
+
this.onopen({})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
triggerMessage(data: any): void {
|
|
80
|
+
if (this.onmessage) {
|
|
81
|
+
this.onmessage({ data: JSON.stringify(data) })
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
triggerClose(code: number = 1000, reason: string = ''): void {
|
|
86
|
+
this.readyState = MockWebSocket.CLOSED
|
|
87
|
+
if (this.onclose) {
|
|
88
|
+
this.onclose({ code, reason })
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
triggerError(): void {
|
|
93
|
+
if (this.onerror) {
|
|
94
|
+
this.onerror({})
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Store WebSocket instance for test access
|
|
100
|
+
let lastWebSocketInstance: MockWebSocket | null = null
|
|
101
|
+
|
|
102
|
+
function createMockWebSocket(url: string): MockWebSocket {
|
|
103
|
+
lastWebSocketInstance = new MockWebSocket(url)
|
|
104
|
+
return lastWebSocketInstance
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const WebSocketConstructor = vi.fn(createMockWebSocket) as any
|
|
108
|
+
WebSocketConstructor.OPEN = MockWebSocket.OPEN
|
|
109
|
+
WebSocketConstructor.CONNECTING = MockWebSocket.CONNECTING
|
|
110
|
+
WebSocketConstructor.CLOSING = MockWebSocket.CLOSING
|
|
111
|
+
WebSocketConstructor.CLOSED = MockWebSocket.CLOSED
|
|
112
|
+
|
|
113
|
+
// Mock modules
|
|
114
|
+
vi.mock('fs', () => mockFs)
|
|
115
|
+
vi.mock('os', () => mockOs)
|
|
116
|
+
vi.mock('path', () => mockPath)
|
|
117
|
+
vi.mock('./client-id.js', () => mockClientId)
|
|
118
|
+
vi.mock('./terminal/tmux-manager.js', () => mockTmux)
|
|
119
|
+
|
|
120
|
+
// Mock undici WebSocket for Node 18-20 compatibility
|
|
121
|
+
vi.mock('undici', () => ({
|
|
122
|
+
WebSocket: WebSocketConstructor,
|
|
123
|
+
}))
|
|
124
|
+
|
|
125
|
+
// Configure homedir before import so module-level constants are correct
|
|
126
|
+
mockOs.homedir.mockReturnValue('/home/testuser')
|
|
127
|
+
|
|
128
|
+
// Set global WebSocket to the mock constructor so platform.ts uses it directly
|
|
129
|
+
;(global as any).WebSocket = WebSocketConstructor
|
|
130
|
+
|
|
131
|
+
// Now import the module under test
|
|
132
|
+
const platformModule = await import('./platform.js')
|
|
133
|
+
const { PlatformConnection, getMachineIdentifier } = platformModule
|
|
134
|
+
|
|
135
|
+
describe('Platform Connection Service', () => {
|
|
136
|
+
beforeEach(() => {
|
|
137
|
+
vi.clearAllMocks()
|
|
138
|
+
lastWebSocketInstance = null
|
|
139
|
+
|
|
140
|
+
// Restore WebSocketConstructor implementation and static properties after clearAllMocks
|
|
141
|
+
WebSocketConstructor.mockImplementation(createMockWebSocket)
|
|
142
|
+
WebSocketConstructor.OPEN = MockWebSocket.OPEN
|
|
143
|
+
WebSocketConstructor.CONNECTING = MockWebSocket.CONNECTING
|
|
144
|
+
WebSocketConstructor.CLOSING = MockWebSocket.CLOSING
|
|
145
|
+
WebSocketConstructor.CLOSED = MockWebSocket.CLOSED
|
|
146
|
+
|
|
147
|
+
// Set up default mocks
|
|
148
|
+
mockOs.homedir.mockReturnValue('/home/testuser')
|
|
149
|
+
mockOs.hostname.mockReturnValue('test-machine')
|
|
150
|
+
mockOs.type.mockReturnValue('Linux')
|
|
151
|
+
mockOs.arch.mockReturnValue('x64')
|
|
152
|
+
mockClientId.getClientId.mockReturnValue('test-client-id-123')
|
|
153
|
+
mockTmux.isTmuxAvailable.mockReturnValue(true)
|
|
154
|
+
|
|
155
|
+
// Mock process.version
|
|
156
|
+
Object.defineProperty(process, 'version', {
|
|
157
|
+
value: 'v22.0.0',
|
|
158
|
+
writable: true,
|
|
159
|
+
configurable: true,
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// Mock console methods to reduce noise
|
|
163
|
+
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
164
|
+
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
165
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
afterEach(() => {
|
|
169
|
+
vi.clearAllTimers()
|
|
170
|
+
vi.restoreAllMocks()
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe('CRITICAL SECURITY: Auth Token NOT in URL', () => {
|
|
174
|
+
it('should NOT include token in WebSocket URL query parameters', () => {
|
|
175
|
+
const credentials = {
|
|
176
|
+
token: 'secret-auth-token-12345',
|
|
177
|
+
userId: 'user-123',
|
|
178
|
+
email: 'test@example.com',
|
|
179
|
+
clientId: 'test-client-id-123',
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
183
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(credentials))
|
|
184
|
+
|
|
185
|
+
const connection = new PlatformConnection('/test/dir', {}, 'https://api.test.com')
|
|
186
|
+
connection.connect()
|
|
187
|
+
|
|
188
|
+
expect(WebSocketConstructor).toHaveBeenCalled()
|
|
189
|
+
const wsUrl = WebSocketConstructor.mock.calls[0][0]
|
|
190
|
+
|
|
191
|
+
// CRITICAL: Token must NOT be in URL
|
|
192
|
+
expect(wsUrl).not.toContain('token=')
|
|
193
|
+
expect(wsUrl).not.toContain('secret-auth-token')
|
|
194
|
+
expect(wsUrl).not.toContain(credentials.token)
|
|
195
|
+
|
|
196
|
+
// URL should contain other params but NOT token
|
|
197
|
+
expect(wsUrl).toContain('name=')
|
|
198
|
+
expect(wsUrl).toContain('clientId=')
|
|
199
|
+
expect(wsUrl).toContain('machineHostname=')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('should send auth token as first message after connection opens', () => {
|
|
203
|
+
const credentials = {
|
|
204
|
+
token: 'secret-auth-token-12345',
|
|
205
|
+
userId: 'user-123',
|
|
206
|
+
email: 'test@example.com',
|
|
207
|
+
clientId: 'test-client-id-123',
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
211
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(credentials))
|
|
212
|
+
|
|
213
|
+
const connection = new PlatformConnection('/test/dir', {}, 'https://api.test.com')
|
|
214
|
+
connection.connect()
|
|
215
|
+
|
|
216
|
+
expect(lastWebSocketInstance).toBeTruthy()
|
|
217
|
+
|
|
218
|
+
const sentMessages: string[] = []
|
|
219
|
+
lastWebSocketInstance!.on('send', (data: string) => {
|
|
220
|
+
sentMessages.push(data)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
// Trigger connection open
|
|
224
|
+
lastWebSocketInstance!.triggerOpen()
|
|
225
|
+
|
|
226
|
+
// First message should be auth with token
|
|
227
|
+
expect(sentMessages.length).toBeGreaterThan(0)
|
|
228
|
+
const firstMessage = JSON.parse(sentMessages[0])
|
|
229
|
+
expect(firstMessage).toEqual({
|
|
230
|
+
type: 'auth',
|
|
231
|
+
token: 'secret-auth-token-12345',
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('should send auth token before any other messages', () => {
|
|
236
|
+
const credentials = {
|
|
237
|
+
token: 'secret-token',
|
|
238
|
+
userId: 'user-123',
|
|
239
|
+
email: 'test@example.com',
|
|
240
|
+
clientId: 'test-client-id-123',
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
244
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(credentials))
|
|
245
|
+
|
|
246
|
+
const connection = new PlatformConnection('/test/dir', {}, 'https://api.test.com')
|
|
247
|
+
connection.connect()
|
|
248
|
+
|
|
249
|
+
const sentMessages: string[] = []
|
|
250
|
+
lastWebSocketInstance!.on('send', (data: string) => {
|
|
251
|
+
sentMessages.push(data)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
lastWebSocketInstance!.triggerOpen()
|
|
255
|
+
|
|
256
|
+
// Send a regular message
|
|
257
|
+
connection.send({ type: 'test', data: 'hello' })
|
|
258
|
+
|
|
259
|
+
// Auth message should be first, before any other messages
|
|
260
|
+
expect(sentMessages.length).toBe(2)
|
|
261
|
+
expect(JSON.parse(sentMessages[0]).type).toBe('auth')
|
|
262
|
+
expect(JSON.parse(sentMessages[1]).type).toBe('test')
|
|
263
|
+
})
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
describe('getMachineIdentifier', () => {
|
|
267
|
+
it('should return correct format: "hostname @ node-vX.X.X os (arch)"', () => {
|
|
268
|
+
mockOs.hostname.mockReturnValue('TestMachine')
|
|
269
|
+
mockOs.type.mockReturnValue('Darwin')
|
|
270
|
+
mockOs.arch.mockReturnValue('arm64')
|
|
271
|
+
|
|
272
|
+
Object.defineProperty(process, 'version', {
|
|
273
|
+
value: 'v22.1.0',
|
|
274
|
+
writable: true,
|
|
275
|
+
configurable: true,
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
const identifier = getMachineIdentifier()
|
|
279
|
+
|
|
280
|
+
expect(identifier).toBe('TestMachine @ node-v22.1.0 darwin (arm64)')
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('should include actual hostname from os.hostname()', () => {
|
|
284
|
+
mockOs.hostname.mockReturnValue('Jessica')
|
|
285
|
+
mockOs.type.mockReturnValue('Linux')
|
|
286
|
+
mockOs.arch.mockReturnValue('x64')
|
|
287
|
+
|
|
288
|
+
const identifier = getMachineIdentifier()
|
|
289
|
+
|
|
290
|
+
expect(identifier).toContain('Jessica @')
|
|
291
|
+
expect(mockOs.hostname).toHaveBeenCalled()
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('should include Node.js version from process.version', () => {
|
|
295
|
+
mockOs.hostname.mockReturnValue('test')
|
|
296
|
+
mockOs.type.mockReturnValue('Linux')
|
|
297
|
+
mockOs.arch.mockReturnValue('x64')
|
|
298
|
+
|
|
299
|
+
Object.defineProperty(process, 'version', {
|
|
300
|
+
value: 'v20.5.1',
|
|
301
|
+
writable: true,
|
|
302
|
+
configurable: true,
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
const identifier = getMachineIdentifier()
|
|
306
|
+
|
|
307
|
+
expect(identifier).toContain('node-v20.5.1')
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('should include lowercased OS type', () => {
|
|
311
|
+
mockOs.hostname.mockReturnValue('test')
|
|
312
|
+
mockOs.type.mockReturnValue('DARWIN')
|
|
313
|
+
mockOs.arch.mockReturnValue('x64')
|
|
314
|
+
|
|
315
|
+
const identifier = getMachineIdentifier()
|
|
316
|
+
|
|
317
|
+
expect(identifier).toContain('darwin')
|
|
318
|
+
expect(identifier).not.toContain('DARWIN')
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('should include CPU architecture', () => {
|
|
322
|
+
mockOs.hostname.mockReturnValue('test')
|
|
323
|
+
mockOs.type.mockReturnValue('Linux')
|
|
324
|
+
mockOs.arch.mockReturnValue('arm64')
|
|
325
|
+
|
|
326
|
+
const identifier = getMachineIdentifier()
|
|
327
|
+
|
|
328
|
+
expect(identifier).toContain('(arm64)')
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
describe('Connection Lifecycle', () => {
|
|
333
|
+
describe('connect()', () => {
|
|
334
|
+
it('should read credentials from disk', () => {
|
|
335
|
+
const credentials = {
|
|
336
|
+
token: 'test-token',
|
|
337
|
+
userId: 'user-123',
|
|
338
|
+
email: 'test@example.com',
|
|
339
|
+
clientId: 'client-123',
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
343
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(credentials))
|
|
344
|
+
|
|
345
|
+
const connection = new PlatformConnection('/test/dir')
|
|
346
|
+
connection.connect()
|
|
347
|
+
|
|
348
|
+
expect(mockFs.existsSync).toHaveBeenCalledWith('/home/testuser/.mstro/credentials.json')
|
|
349
|
+
expect(mockFs.readFileSync).toHaveBeenCalledWith(
|
|
350
|
+
'/home/testuser/.mstro/credentials.json',
|
|
351
|
+
'utf-8'
|
|
352
|
+
)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('should show error when credentials file does not exist', () => {
|
|
356
|
+
mockFs.existsSync.mockReturnValue(false)
|
|
357
|
+
|
|
358
|
+
const onError = vi.fn()
|
|
359
|
+
const connection = new PlatformConnection('/test/dir', { onError })
|
|
360
|
+
connection.connect()
|
|
361
|
+
|
|
362
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
363
|
+
expect.stringContaining('Not logged in')
|
|
364
|
+
)
|
|
365
|
+
expect(onError).toHaveBeenCalledWith('Not logged in - run `mstro login` first')
|
|
366
|
+
expect(WebSocketConstructor).not.toHaveBeenCalled()
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('should show error when credentials are invalid', () => {
|
|
370
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
371
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify({ userId: 'user-123' })) // Missing token
|
|
372
|
+
|
|
373
|
+
const onError = vi.fn()
|
|
374
|
+
const connection = new PlatformConnection('/test/dir', { onError })
|
|
375
|
+
connection.connect()
|
|
376
|
+
|
|
377
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
378
|
+
expect.stringContaining('Not logged in')
|
|
379
|
+
)
|
|
380
|
+
expect(onError).toHaveBeenCalledWith('Not logged in - run `mstro login` first')
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('should include working directory name in connection params', () => {
|
|
384
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
385
|
+
mockFs.readFileSync.mockReturnValue(
|
|
386
|
+
JSON.stringify({
|
|
387
|
+
token: 'test-token',
|
|
388
|
+
userId: 'user-123',
|
|
389
|
+
email: 'test@example.com',
|
|
390
|
+
clientId: 'client-123',
|
|
391
|
+
})
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
const connection = new PlatformConnection('/home/user/my-project')
|
|
395
|
+
connection.connect()
|
|
396
|
+
|
|
397
|
+
const wsUrl = WebSocketConstructor.mock.calls[0][0]
|
|
398
|
+
expect(wsUrl).toContain('name=my-project')
|
|
399
|
+
expect(wsUrl).toContain('workingDirectory=%2Fhome%2Fuser%2Fmy-project')
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
it('should include machine capabilities in connection params', () => {
|
|
403
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
404
|
+
mockFs.readFileSync.mockReturnValue(
|
|
405
|
+
JSON.stringify({
|
|
406
|
+
token: 'test-token',
|
|
407
|
+
userId: 'user-123',
|
|
408
|
+
email: 'test@example.com',
|
|
409
|
+
clientId: 'client-123',
|
|
410
|
+
})
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
mockTmux.isTmuxAvailable.mockReturnValue(true)
|
|
414
|
+
|
|
415
|
+
const connection = new PlatformConnection('/test/dir')
|
|
416
|
+
connection.connect()
|
|
417
|
+
|
|
418
|
+
const wsUrl = WebSocketConstructor.mock.calls[0][0]
|
|
419
|
+
expect(wsUrl).toContain('capabilities=')
|
|
420
|
+
expect(wsUrl).toContain('tmux')
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
it('should use custom platform URL when provided', () => {
|
|
424
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
425
|
+
mockFs.readFileSync.mockReturnValue(
|
|
426
|
+
JSON.stringify({
|
|
427
|
+
token: 'test-token',
|
|
428
|
+
userId: 'user-123',
|
|
429
|
+
email: 'test@example.com',
|
|
430
|
+
clientId: 'client-123',
|
|
431
|
+
})
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
const connection = new PlatformConnection(
|
|
435
|
+
'/test/dir',
|
|
436
|
+
{},
|
|
437
|
+
'https://custom.platform.com'
|
|
438
|
+
)
|
|
439
|
+
connection.connect()
|
|
440
|
+
|
|
441
|
+
const wsUrl = WebSocketConstructor.mock.calls[0][0]
|
|
442
|
+
expect(wsUrl).toContain('wss://custom.platform.com')
|
|
443
|
+
})
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
describe('reconnection behavior', () => {
|
|
447
|
+
beforeEach(() => {
|
|
448
|
+
vi.useFakeTimers()
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
afterEach(() => {
|
|
452
|
+
vi.useRealTimers()
|
|
453
|
+
})
|
|
454
|
+
|
|
455
|
+
it('should schedule reconnect on connection failure', () => {
|
|
456
|
+
const credentials = {
|
|
457
|
+
token: 'test-token',
|
|
458
|
+
userId: 'user-123',
|
|
459
|
+
email: 'test@example.com',
|
|
460
|
+
clientId: 'client-123',
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
464
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(credentials))
|
|
465
|
+
|
|
466
|
+
const connection = new PlatformConnection('/test/dir')
|
|
467
|
+
connection.connect()
|
|
468
|
+
|
|
469
|
+
lastWebSocketInstance!.triggerOpen()
|
|
470
|
+
lastWebSocketInstance!.triggerMessage({ type: 'paired', connectionId: 'conn-123' })
|
|
471
|
+
|
|
472
|
+
// Clear initial call
|
|
473
|
+
WebSocketConstructor.mockClear()
|
|
474
|
+
|
|
475
|
+
// Simulate unexpected disconnection
|
|
476
|
+
lastWebSocketInstance!.triggerClose(1006, '')
|
|
477
|
+
|
|
478
|
+
// Should schedule reconnect
|
|
479
|
+
expect(WebSocketConstructor).not.toHaveBeenCalled()
|
|
480
|
+
|
|
481
|
+
// Advance timers to trigger reconnect
|
|
482
|
+
vi.advanceTimersByTime(1000)
|
|
483
|
+
|
|
484
|
+
expect(WebSocketConstructor).toHaveBeenCalled()
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
it('should use exponential backoff for reconnection delays', () => {
|
|
488
|
+
const credentials = {
|
|
489
|
+
token: 'test-token',
|
|
490
|
+
userId: 'user-123',
|
|
491
|
+
email: 'test@example.com',
|
|
492
|
+
clientId: 'client-123',
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
496
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(credentials))
|
|
497
|
+
|
|
498
|
+
const connection = new PlatformConnection('/test/dir')
|
|
499
|
+
connection.connect()
|
|
500
|
+
|
|
501
|
+
lastWebSocketInstance!.triggerOpen()
|
|
502
|
+
lastWebSocketInstance!.triggerMessage({ type: 'paired', connectionId: 'conn-123' })
|
|
503
|
+
WebSocketConstructor.mockClear()
|
|
504
|
+
|
|
505
|
+
// First reconnect attempt - delay should be 1000ms (2^0 * 1000)
|
|
506
|
+
// Use code 1001 (going away) to avoid auth failure detection on 1006
|
|
507
|
+
lastWebSocketInstance!.triggerClose(1001, '')
|
|
508
|
+
expect(WebSocketConstructor).not.toHaveBeenCalled()
|
|
509
|
+
vi.advanceTimersByTime(999)
|
|
510
|
+
expect(WebSocketConstructor).not.toHaveBeenCalled()
|
|
511
|
+
vi.advanceTimersByTime(1)
|
|
512
|
+
expect(WebSocketConstructor).toHaveBeenCalledTimes(1)
|
|
513
|
+
|
|
514
|
+
// Don't triggerOpen to avoid resetting reconnectAttempts in onopen
|
|
515
|
+
// Close immediately to trigger second backoff
|
|
516
|
+
WebSocketConstructor.mockClear()
|
|
517
|
+
lastWebSocketInstance!.triggerClose(1001, '')
|
|
518
|
+
|
|
519
|
+
// Second reconnect attempt - delay should be 2000ms (2^1 * 1000)
|
|
520
|
+
vi.advanceTimersByTime(1999)
|
|
521
|
+
expect(WebSocketConstructor).not.toHaveBeenCalled()
|
|
522
|
+
vi.advanceTimersByTime(1)
|
|
523
|
+
expect(WebSocketConstructor).toHaveBeenCalledTimes(1)
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
it('should stop reconnecting after max attempts', () => {
|
|
527
|
+
const credentials = {
|
|
528
|
+
token: 'test-token',
|
|
529
|
+
userId: 'user-123',
|
|
530
|
+
email: 'test@example.com',
|
|
531
|
+
clientId: 'client-123',
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
535
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(credentials))
|
|
536
|
+
|
|
537
|
+
const connection = new PlatformConnection('/test/dir')
|
|
538
|
+
connection.connect()
|
|
539
|
+
|
|
540
|
+
// Trigger 10 failed connection attempts
|
|
541
|
+
// Don't triggerOpen to avoid resetting reconnectAttempts
|
|
542
|
+
// Use code 1001 to avoid auth failure detection
|
|
543
|
+
for (let i = 0; i < 10; i++) {
|
|
544
|
+
if (lastWebSocketInstance) {
|
|
545
|
+
lastWebSocketInstance.triggerClose(1001, '')
|
|
546
|
+
}
|
|
547
|
+
vi.advanceTimersByTime(60000)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
WebSocketConstructor.mockClear()
|
|
551
|
+
|
|
552
|
+
// 11th attempt should not happen
|
|
553
|
+
vi.advanceTimersByTime(60000)
|
|
554
|
+
expect(WebSocketConstructor).not.toHaveBeenCalled()
|
|
555
|
+
expect(console.log).toHaveBeenCalledWith(
|
|
556
|
+
expect.stringContaining('Max reconnection attempts reached')
|
|
557
|
+
)
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
it('should reset reconnect attempts on successful connection', () => {
|
|
561
|
+
const credentials = {
|
|
562
|
+
token: 'test-token',
|
|
563
|
+
userId: 'user-123',
|
|
564
|
+
email: 'test@example.com',
|
|
565
|
+
clientId: 'client-123',
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
569
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(credentials))
|
|
570
|
+
|
|
571
|
+
const connection = new PlatformConnection('/test/dir')
|
|
572
|
+
connection.connect()
|
|
573
|
+
|
|
574
|
+
// First connection succeeds
|
|
575
|
+
lastWebSocketInstance!.triggerOpen()
|
|
576
|
+
lastWebSocketInstance!.triggerMessage({ type: 'paired', connectionId: 'conn-1' })
|
|
577
|
+
WebSocketConstructor.mockClear()
|
|
578
|
+
|
|
579
|
+
// Disconnect and reconnect
|
|
580
|
+
lastWebSocketInstance!.triggerClose(1006, '')
|
|
581
|
+
vi.advanceTimersByTime(1000)
|
|
582
|
+
|
|
583
|
+
// Second connection succeeds - this should reset the counter
|
|
584
|
+
lastWebSocketInstance!.triggerOpen()
|
|
585
|
+
lastWebSocketInstance!.triggerMessage({ type: 'paired', connectionId: 'conn-2' })
|
|
586
|
+
WebSocketConstructor.mockClear()
|
|
587
|
+
|
|
588
|
+
// Next disconnect should use first-attempt delay again (1000ms)
|
|
589
|
+
lastWebSocketInstance!.triggerClose(1006, '')
|
|
590
|
+
vi.advanceTimersByTime(999)
|
|
591
|
+
expect(WebSocketConstructor).not.toHaveBeenCalled()
|
|
592
|
+
vi.advanceTimersByTime(1)
|
|
593
|
+
expect(WebSocketConstructor).toHaveBeenCalled()
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
it('should not reconnect on authentication failure', () => {
|
|
597
|
+
const credentials = {
|
|
598
|
+
token: 'test-token',
|
|
599
|
+
userId: 'user-123',
|
|
600
|
+
email: 'test@example.com',
|
|
601
|
+
clientId: 'client-123',
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
605
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(credentials))
|
|
606
|
+
|
|
607
|
+
const onError = vi.fn()
|
|
608
|
+
const connection = new PlatformConnection('/test/dir', { onError })
|
|
609
|
+
connection.connect()
|
|
610
|
+
|
|
611
|
+
WebSocketConstructor.mockClear()
|
|
612
|
+
|
|
613
|
+
// Auth failure (code 4001)
|
|
614
|
+
lastWebSocketInstance!.triggerClose(4001, 'Unauthorized')
|
|
615
|
+
|
|
616
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
617
|
+
expect.stringContaining('Authentication failed')
|
|
618
|
+
)
|
|
619
|
+
expect(onError).toHaveBeenCalledWith(
|
|
620
|
+
'Authentication failed - run `mstro login --force`'
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
// Should not schedule reconnect
|
|
624
|
+
vi.advanceTimersByTime(10000)
|
|
625
|
+
expect(WebSocketConstructor).not.toHaveBeenCalled()
|
|
626
|
+
})
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
describe('disconnect()', () => {
|
|
630
|
+
beforeEach(() => {
|
|
631
|
+
vi.useFakeTimers()
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
afterEach(() => {
|
|
635
|
+
vi.useRealTimers()
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
it('should close WebSocket connection cleanly', () => {
|
|
639
|
+
const credentials = {
|
|
640
|
+
token: 'test-token',
|
|
641
|
+
userId: 'user-123',
|
|
642
|
+
email: 'test@example.com',
|
|
643
|
+
clientId: 'client-123',
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
647
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(credentials))
|
|
648
|
+
|
|
649
|
+
const connection = new PlatformConnection('/test/dir')
|
|
650
|
+
connection.connect()
|
|
651
|
+
|
|
652
|
+
lastWebSocketInstance!.triggerOpen()
|
|
653
|
+
|
|
654
|
+
const closeSpy = vi.spyOn(lastWebSocketInstance!, 'close')
|
|
655
|
+
connection.disconnect()
|
|
656
|
+
|
|
657
|
+
expect(closeSpy).toHaveBeenCalled()
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
it('should not trigger reconnection after intentional disconnect', () => {
|
|
661
|
+
const credentials = {
|
|
662
|
+
token: 'test-token',
|
|
663
|
+
userId: 'user-123',
|
|
664
|
+
email: 'test@example.com',
|
|
665
|
+
clientId: 'client-123',
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
669
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(credentials))
|
|
670
|
+
|
|
671
|
+
const connection = new PlatformConnection('/test/dir')
|
|
672
|
+
connection.connect()
|
|
673
|
+
|
|
674
|
+
lastWebSocketInstance!.triggerOpen()
|
|
675
|
+
WebSocketConstructor.mockClear()
|
|
676
|
+
|
|
677
|
+
connection.disconnect()
|
|
678
|
+
|
|
679
|
+
// Advance timers - should not reconnect
|
|
680
|
+
vi.advanceTimersByTime(10000)
|
|
681
|
+
expect(WebSocketConstructor).not.toHaveBeenCalled()
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
it('should clear reconnect timeout on disconnect', () => {
|
|
685
|
+
const credentials = {
|
|
686
|
+
token: 'test-token',
|
|
687
|
+
userId: 'user-123',
|
|
688
|
+
email: 'test@example.com',
|
|
689
|
+
clientId: 'client-123',
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
693
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(credentials))
|
|
694
|
+
|
|
695
|
+
const connection = new PlatformConnection('/test/dir')
|
|
696
|
+
connection.connect()
|
|
697
|
+
|
|
698
|
+
lastWebSocketInstance!.triggerOpen()
|
|
699
|
+
|
|
700
|
+
// Trigger disconnection to schedule reconnect
|
|
701
|
+
lastWebSocketInstance!.triggerClose(1006, '')
|
|
702
|
+
|
|
703
|
+
// Now disconnect intentionally
|
|
704
|
+
connection.disconnect()
|
|
705
|
+
|
|
706
|
+
WebSocketConstructor.mockClear()
|
|
707
|
+
|
|
708
|
+
// Advance timers - reconnect should be cancelled
|
|
709
|
+
vi.advanceTimersByTime(60000)
|
|
710
|
+
expect(WebSocketConstructor).not.toHaveBeenCalled()
|
|
711
|
+
})
|
|
712
|
+
|
|
713
|
+
it('should stop heartbeat on disconnect', () => {
|
|
714
|
+
const credentials = {
|
|
715
|
+
token: 'test-token',
|
|
716
|
+
userId: 'user-123',
|
|
717
|
+
email: 'test@example.com',
|
|
718
|
+
clientId: 'client-123',
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
722
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(credentials))
|
|
723
|
+
|
|
724
|
+
const connection = new PlatformConnection('/test/dir')
|
|
725
|
+
connection.connect()
|
|
726
|
+
|
|
727
|
+
lastWebSocketInstance!.triggerOpen()
|
|
728
|
+
lastWebSocketInstance!.triggerMessage({ type: 'paired', connectionId: 'conn-123' })
|
|
729
|
+
|
|
730
|
+
const sentMessages: string[] = []
|
|
731
|
+
lastWebSocketInstance!.on('send', (data: string) => {
|
|
732
|
+
sentMessages.push(data)
|
|
733
|
+
})
|
|
734
|
+
|
|
735
|
+
// Clear auth message
|
|
736
|
+
sentMessages.length = 0
|
|
737
|
+
|
|
738
|
+
// Advance time to trigger heartbeat
|
|
739
|
+
vi.advanceTimersByTime(2 * 60 * 1000)
|
|
740
|
+
expect(sentMessages.some((msg) => JSON.parse(msg).type === 'ping')).toBe(true)
|
|
741
|
+
|
|
742
|
+
sentMessages.length = 0
|
|
743
|
+
|
|
744
|
+
// Disconnect
|
|
745
|
+
connection.disconnect()
|
|
746
|
+
|
|
747
|
+
// Advance time - no more heartbeats
|
|
748
|
+
vi.advanceTimersByTime(10 * 60 * 1000)
|
|
749
|
+
expect(sentMessages.length).toBe(0)
|
|
750
|
+
})
|
|
751
|
+
})
|
|
752
|
+
})
|
|
753
|
+
|
|
754
|
+
describe('Message Handling', () => {
|
|
755
|
+
beforeEach(() => {
|
|
756
|
+
vi.useFakeTimers()
|
|
757
|
+
|
|
758
|
+
const credentials = {
|
|
759
|
+
token: 'test-token',
|
|
760
|
+
userId: 'user-123',
|
|
761
|
+
email: 'test@example.com',
|
|
762
|
+
clientId: 'client-123',
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
766
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(credentials))
|
|
767
|
+
})
|
|
768
|
+
|
|
769
|
+
afterEach(() => {
|
|
770
|
+
vi.useRealTimers()
|
|
771
|
+
})
|
|
772
|
+
|
|
773
|
+
it('should trigger onConnected callback on "paired" message', () => {
|
|
774
|
+
const onConnected = vi.fn()
|
|
775
|
+
const connection = new PlatformConnection('/test/dir', { onConnected })
|
|
776
|
+
connection.connect()
|
|
777
|
+
|
|
778
|
+
lastWebSocketInstance!.triggerOpen()
|
|
779
|
+
lastWebSocketInstance!.triggerMessage({ type: 'paired', connectionId: 'conn-456' })
|
|
780
|
+
|
|
781
|
+
expect(onConnected).toHaveBeenCalledWith('conn-456')
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
it('should start heartbeat after "paired" message', () => {
|
|
785
|
+
const connection = new PlatformConnection('/test/dir')
|
|
786
|
+
connection.connect()
|
|
787
|
+
|
|
788
|
+
lastWebSocketInstance!.triggerOpen()
|
|
789
|
+
|
|
790
|
+
const sentMessages: string[] = []
|
|
791
|
+
lastWebSocketInstance!.on('send', (data: string) => {
|
|
792
|
+
sentMessages.push(data)
|
|
793
|
+
})
|
|
794
|
+
|
|
795
|
+
// Clear auth message
|
|
796
|
+
sentMessages.length = 0
|
|
797
|
+
|
|
798
|
+
lastWebSocketInstance!.triggerMessage({ type: 'paired', connectionId: 'conn-123' })
|
|
799
|
+
|
|
800
|
+
// No heartbeat yet
|
|
801
|
+
expect(sentMessages.length).toBe(0)
|
|
802
|
+
|
|
803
|
+
// Advance time to heartbeat interval (2 minutes)
|
|
804
|
+
vi.advanceTimersByTime(2 * 60 * 1000)
|
|
805
|
+
|
|
806
|
+
// Should have sent ping
|
|
807
|
+
expect(sentMessages.length).toBe(1)
|
|
808
|
+
expect(JSON.parse(sentMessages[0])).toEqual({ type: 'ping' })
|
|
809
|
+
})
|
|
810
|
+
|
|
811
|
+
it('should send heartbeat every 2 minutes', () => {
|
|
812
|
+
const connection = new PlatformConnection('/test/dir')
|
|
813
|
+
connection.connect()
|
|
814
|
+
|
|
815
|
+
lastWebSocketInstance!.triggerOpen()
|
|
816
|
+
|
|
817
|
+
const sentMessages: string[] = []
|
|
818
|
+
lastWebSocketInstance!.on('send', (data: string) => {
|
|
819
|
+
sentMessages.push(data)
|
|
820
|
+
})
|
|
821
|
+
|
|
822
|
+
lastWebSocketInstance!.triggerMessage({ type: 'paired', connectionId: 'conn-123' })
|
|
823
|
+
|
|
824
|
+
// Clear auth message
|
|
825
|
+
sentMessages.length = 0
|
|
826
|
+
|
|
827
|
+
// First ping at 2 minutes
|
|
828
|
+
vi.advanceTimersByTime(2 * 60 * 1000)
|
|
829
|
+
expect(sentMessages.length).toBe(1)
|
|
830
|
+
|
|
831
|
+
// Second ping at 4 minutes
|
|
832
|
+
vi.advanceTimersByTime(2 * 60 * 1000)
|
|
833
|
+
expect(sentMessages.length).toBe(2)
|
|
834
|
+
|
|
835
|
+
// Third ping at 6 minutes
|
|
836
|
+
vi.advanceTimersByTime(2 * 60 * 1000)
|
|
837
|
+
expect(sentMessages.length).toBe(3)
|
|
838
|
+
|
|
839
|
+
sentMessages.forEach((msg) => {
|
|
840
|
+
expect(JSON.parse(msg)).toEqual({ type: 'ping' })
|
|
841
|
+
})
|
|
842
|
+
})
|
|
843
|
+
|
|
844
|
+
it('should trigger onWebConnected callback on "web_connected" message', () => {
|
|
845
|
+
const onWebConnected = vi.fn()
|
|
846
|
+
const connection = new PlatformConnection('/test/dir', { onWebConnected })
|
|
847
|
+
connection.connect()
|
|
848
|
+
|
|
849
|
+
lastWebSocketInstance!.triggerOpen()
|
|
850
|
+
lastWebSocketInstance!.triggerMessage({ type: 'web_connected' })
|
|
851
|
+
|
|
852
|
+
expect(onWebConnected).toHaveBeenCalled()
|
|
853
|
+
})
|
|
854
|
+
|
|
855
|
+
it('should trigger onWebDisconnected callback on "web_disconnected" message', () => {
|
|
856
|
+
const onWebDisconnected = vi.fn()
|
|
857
|
+
const connection = new PlatformConnection('/test/dir', { onWebDisconnected })
|
|
858
|
+
connection.connect()
|
|
859
|
+
|
|
860
|
+
lastWebSocketInstance!.triggerOpen()
|
|
861
|
+
lastWebSocketInstance!.triggerMessage({ type: 'web_disconnected' })
|
|
862
|
+
|
|
863
|
+
expect(onWebDisconnected).toHaveBeenCalled()
|
|
864
|
+
})
|
|
865
|
+
|
|
866
|
+
it('should pass unknown messages to onRelayedMessage callback', () => {
|
|
867
|
+
const onRelayedMessage = vi.fn()
|
|
868
|
+
const connection = new PlatformConnection('/test/dir', { onRelayedMessage })
|
|
869
|
+
connection.connect()
|
|
870
|
+
|
|
871
|
+
lastWebSocketInstance!.triggerOpen()
|
|
872
|
+
|
|
873
|
+
const customMessage = {
|
|
874
|
+
type: 'custom_event',
|
|
875
|
+
data: { foo: 'bar' },
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
lastWebSocketInstance!.triggerMessage(customMessage)
|
|
879
|
+
|
|
880
|
+
expect(onRelayedMessage).toHaveBeenCalledWith(customMessage)
|
|
881
|
+
})
|
|
882
|
+
|
|
883
|
+
it('should relay "execute" messages to onRelayedMessage', () => {
|
|
884
|
+
const onRelayedMessage = vi.fn()
|
|
885
|
+
const connection = new PlatformConnection('/test/dir', { onRelayedMessage })
|
|
886
|
+
connection.connect()
|
|
887
|
+
|
|
888
|
+
lastWebSocketInstance!.triggerOpen()
|
|
889
|
+
|
|
890
|
+
const executeMessage = {
|
|
891
|
+
type: 'execute',
|
|
892
|
+
command: 'ls -la',
|
|
893
|
+
terminalId: 'term-1',
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
lastWebSocketInstance!.triggerMessage(executeMessage)
|
|
897
|
+
|
|
898
|
+
expect(onRelayedMessage).toHaveBeenCalledWith(executeMessage)
|
|
899
|
+
})
|
|
900
|
+
|
|
901
|
+
it('should handle "pong" messages silently', () => {
|
|
902
|
+
const onRelayedMessage = vi.fn()
|
|
903
|
+
const connection = new PlatformConnection('/test/dir', { onRelayedMessage })
|
|
904
|
+
connection.connect()
|
|
905
|
+
|
|
906
|
+
lastWebSocketInstance!.triggerOpen()
|
|
907
|
+
lastWebSocketInstance!.triggerMessage({ type: 'pong' })
|
|
908
|
+
|
|
909
|
+
// Should not trigger any callback
|
|
910
|
+
expect(onRelayedMessage).not.toHaveBeenCalled()
|
|
911
|
+
})
|
|
912
|
+
|
|
913
|
+
it('should handle malformed JSON messages gracefully', () => {
|
|
914
|
+
const onRelayedMessage = vi.fn()
|
|
915
|
+
const connection = new PlatformConnection('/test/dir', { onRelayedMessage })
|
|
916
|
+
connection.connect()
|
|
917
|
+
|
|
918
|
+
lastWebSocketInstance!.triggerOpen()
|
|
919
|
+
|
|
920
|
+
// Manually trigger with invalid JSON
|
|
921
|
+
if (lastWebSocketInstance!.onmessage) {
|
|
922
|
+
lastWebSocketInstance!.onmessage({ data: 'invalid json {' } as any)
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
926
|
+
'Failed to parse platform message:',
|
|
927
|
+
expect.any(Error)
|
|
928
|
+
)
|
|
929
|
+
expect(onRelayedMessage).not.toHaveBeenCalled()
|
|
930
|
+
})
|
|
931
|
+
})
|
|
932
|
+
|
|
933
|
+
describe('Token Refresh', () => {
|
|
934
|
+
beforeEach(() => {
|
|
935
|
+
vi.useFakeTimers()
|
|
936
|
+
})
|
|
937
|
+
|
|
938
|
+
afterEach(() => {
|
|
939
|
+
vi.useRealTimers()
|
|
940
|
+
})
|
|
941
|
+
|
|
942
|
+
it('should check if token should be refreshed on connect', async () => {
|
|
943
|
+
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000) // 31 days ago
|
|
944
|
+
const credentials = {
|
|
945
|
+
token: 'test-token',
|
|
946
|
+
userId: 'user-123',
|
|
947
|
+
email: 'test@example.com',
|
|
948
|
+
clientId: 'client-123',
|
|
949
|
+
lastRefreshedAt: oldDate.toISOString(),
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
953
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(credentials))
|
|
954
|
+
|
|
955
|
+
const mockFetch = global.fetch as any
|
|
956
|
+
mockFetch.mockResolvedValue({
|
|
957
|
+
ok: true,
|
|
958
|
+
json: async () => ({ accessToken: 'new-token-123' }),
|
|
959
|
+
})
|
|
960
|
+
|
|
961
|
+
const connection = new PlatformConnection('/test/dir', {}, 'https://api.test.com')
|
|
962
|
+
connection.connect()
|
|
963
|
+
|
|
964
|
+
lastWebSocketInstance!.triggerOpen()
|
|
965
|
+
|
|
966
|
+
// Wait for async refresh to complete
|
|
967
|
+
await vi.advanceTimersByTimeAsync(1)
|
|
968
|
+
|
|
969
|
+
expect(global.fetch).toHaveBeenCalledWith(
|
|
970
|
+
'https://api.test.com/api/auth/device/refresh',
|
|
971
|
+
expect.objectContaining({
|
|
972
|
+
method: 'POST',
|
|
973
|
+
headers: expect.objectContaining({
|
|
974
|
+
Authorization: 'Bearer test-token',
|
|
975
|
+
}),
|
|
976
|
+
})
|
|
977
|
+
)
|
|
978
|
+
})
|
|
979
|
+
|
|
980
|
+
it('should refresh token when lastRefreshedAt is older than 30 days', async () => {
|
|
981
|
+
const oldDate = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000) // 35 days ago
|
|
982
|
+
const credentials = {
|
|
983
|
+
token: 'old-token',
|
|
984
|
+
userId: 'user-123',
|
|
985
|
+
email: 'test@example.com',
|
|
986
|
+
clientId: 'client-123',
|
|
987
|
+
lastRefreshedAt: oldDate.toISOString(),
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
991
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(credentials))
|
|
992
|
+
|
|
993
|
+
const mockFetch = global.fetch as any
|
|
994
|
+
mockFetch.mockResolvedValue({
|
|
995
|
+
ok: true,
|
|
996
|
+
json: async () => ({ accessToken: 'refreshed-token-456' }),
|
|
997
|
+
})
|
|
998
|
+
|
|
999
|
+
const connection = new PlatformConnection('/test/dir', {}, 'https://api.test.com')
|
|
1000
|
+
connection.connect()
|
|
1001
|
+
|
|
1002
|
+
lastWebSocketInstance!.triggerOpen()
|
|
1003
|
+
|
|
1004
|
+
await vi.advanceTimersByTimeAsync(1)
|
|
1005
|
+
|
|
1006
|
+
expect(mockFs.writeFileSync).toHaveBeenCalledWith(
|
|
1007
|
+
'/home/testuser/.mstro/credentials.json',
|
|
1008
|
+
expect.stringContaining('refreshed-token-456'),
|
|
1009
|
+
expect.objectContaining({ mode: 0o600 })
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
expect(mockFs.writeFileSync).toHaveBeenCalledWith(
|
|
1013
|
+
'/home/testuser/.mstro/credentials.json',
|
|
1014
|
+
expect.stringContaining('lastRefreshedAt'),
|
|
1015
|
+
expect.anything()
|
|
1016
|
+
)
|
|
1017
|
+
})
|
|
1018
|
+
|
|
1019
|
+
it('should NOT refresh token when lastRefreshedAt is recent', async () => {
|
|
1020
|
+
const recentDate = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000) // 5 days ago
|
|
1021
|
+
const credentials = {
|
|
1022
|
+
token: 'recent-token',
|
|
1023
|
+
userId: 'user-123',
|
|
1024
|
+
email: 'test@example.com',
|
|
1025
|
+
clientId: 'client-123',
|
|
1026
|
+
lastRefreshedAt: recentDate.toISOString(),
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
1030
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(credentials))
|
|
1031
|
+
|
|
1032
|
+
const connection = new PlatformConnection('/test/dir', {}, 'https://api.test.com')
|
|
1033
|
+
connection.connect()
|
|
1034
|
+
|
|
1035
|
+
lastWebSocketInstance!.triggerOpen()
|
|
1036
|
+
|
|
1037
|
+
await vi.advanceTimersByTimeAsync(1)
|
|
1038
|
+
|
|
1039
|
+
expect(global.fetch).not.toHaveBeenCalled()
|
|
1040
|
+
})
|
|
1041
|
+
|
|
1042
|
+
it('should refresh token when lastRefreshedAt is missing', async () => {
|
|
1043
|
+
const credentials = {
|
|
1044
|
+
token: 'unrefreshed-token',
|
|
1045
|
+
userId: 'user-123',
|
|
1046
|
+
email: 'test@example.com',
|
|
1047
|
+
clientId: 'client-123',
|
|
1048
|
+
// No lastRefreshedAt field
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
1052
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(credentials))
|
|
1053
|
+
|
|
1054
|
+
const mockFetch = global.fetch as any
|
|
1055
|
+
mockFetch.mockResolvedValue({
|
|
1056
|
+
ok: true,
|
|
1057
|
+
json: async () => ({ accessToken: 'first-refresh-token' }),
|
|
1058
|
+
})
|
|
1059
|
+
|
|
1060
|
+
const connection = new PlatformConnection('/test/dir', {}, 'https://api.test.com')
|
|
1061
|
+
connection.connect()
|
|
1062
|
+
|
|
1063
|
+
lastWebSocketInstance!.triggerOpen()
|
|
1064
|
+
|
|
1065
|
+
await vi.advanceTimersByTimeAsync(1)
|
|
1066
|
+
|
|
1067
|
+
expect(global.fetch).toHaveBeenCalled()
|
|
1068
|
+
expect(mockFs.writeFileSync).toHaveBeenCalledWith(
|
|
1069
|
+
'/home/testuser/.mstro/credentials.json',
|
|
1070
|
+
expect.stringContaining('first-refresh-token'),
|
|
1071
|
+
expect.anything()
|
|
1072
|
+
)
|
|
1073
|
+
})
|
|
1074
|
+
|
|
1075
|
+
it('should handle token refresh failure gracefully', async () => {
|
|
1076
|
+
const oldDate = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000)
|
|
1077
|
+
const credentials = {
|
|
1078
|
+
token: 'test-token',
|
|
1079
|
+
userId: 'user-123',
|
|
1080
|
+
email: 'test@example.com',
|
|
1081
|
+
clientId: 'client-123',
|
|
1082
|
+
lastRefreshedAt: oldDate.toISOString(),
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
1086
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(credentials))
|
|
1087
|
+
|
|
1088
|
+
const mockFetch = global.fetch as any
|
|
1089
|
+
mockFetch.mockResolvedValue({
|
|
1090
|
+
ok: false,
|
|
1091
|
+
status: 401,
|
|
1092
|
+
})
|
|
1093
|
+
|
|
1094
|
+
const connection = new PlatformConnection('/test/dir', {}, 'https://api.test.com')
|
|
1095
|
+
connection.connect()
|
|
1096
|
+
|
|
1097
|
+
lastWebSocketInstance!.triggerOpen()
|
|
1098
|
+
|
|
1099
|
+
await vi.advanceTimersByTimeAsync(1)
|
|
1100
|
+
|
|
1101
|
+
expect(console.warn).toHaveBeenCalledWith(
|
|
1102
|
+
expect.stringContaining('Token refresh failed')
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1105
|
+
// Should not update credentials on failure
|
|
1106
|
+
expect(mockFs.writeFileSync).not.toHaveBeenCalled()
|
|
1107
|
+
})
|
|
1108
|
+
|
|
1109
|
+
it('should handle token refresh network error gracefully', async () => {
|
|
1110
|
+
const oldDate = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000)
|
|
1111
|
+
const credentials = {
|
|
1112
|
+
token: 'test-token',
|
|
1113
|
+
userId: 'user-123',
|
|
1114
|
+
email: 'test@example.com',
|
|
1115
|
+
clientId: 'client-123',
|
|
1116
|
+
lastRefreshedAt: oldDate.toISOString(),
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
1120
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(credentials))
|
|
1121
|
+
|
|
1122
|
+
const mockFetch = global.fetch as any
|
|
1123
|
+
mockFetch.mockRejectedValue(new Error('Network error'))
|
|
1124
|
+
|
|
1125
|
+
const connection = new PlatformConnection('/test/dir', {}, 'https://api.test.com')
|
|
1126
|
+
connection.connect()
|
|
1127
|
+
|
|
1128
|
+
lastWebSocketInstance!.triggerOpen()
|
|
1129
|
+
|
|
1130
|
+
await vi.advanceTimersByTimeAsync(1)
|
|
1131
|
+
|
|
1132
|
+
expect(console.warn).toHaveBeenCalledWith(
|
|
1133
|
+
'[Platform] Token refresh error:',
|
|
1134
|
+
expect.any(Error)
|
|
1135
|
+
)
|
|
1136
|
+
})
|
|
1137
|
+
|
|
1138
|
+
it('should periodically check for token refresh every 24 hours', async () => {
|
|
1139
|
+
const credentials = {
|
|
1140
|
+
token: 'test-token',
|
|
1141
|
+
userId: 'user-123',
|
|
1142
|
+
email: 'test@example.com',
|
|
1143
|
+
clientId: 'client-123',
|
|
1144
|
+
lastRefreshedAt: new Date().toISOString(),
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
1148
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(credentials))
|
|
1149
|
+
|
|
1150
|
+
const connection = new PlatformConnection('/test/dir', {}, 'https://api.test.com')
|
|
1151
|
+
connection.connect()
|
|
1152
|
+
|
|
1153
|
+
lastWebSocketInstance!.triggerOpen()
|
|
1154
|
+
|
|
1155
|
+
const mockFetch = global.fetch as any
|
|
1156
|
+
mockFetch.mockClear()
|
|
1157
|
+
|
|
1158
|
+
// Advance 23 hours - should not refresh
|
|
1159
|
+
vi.advanceTimersByTime(23 * 60 * 60 * 1000)
|
|
1160
|
+
await vi.advanceTimersByTimeAsync(1)
|
|
1161
|
+
expect(global.fetch).not.toHaveBeenCalled()
|
|
1162
|
+
|
|
1163
|
+
// Advance 1 more hour - should trigger check
|
|
1164
|
+
mockFs.readFileSync.mockReturnValue(
|
|
1165
|
+
JSON.stringify({
|
|
1166
|
+
...credentials,
|
|
1167
|
+
lastRefreshedAt: new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString(),
|
|
1168
|
+
})
|
|
1169
|
+
)
|
|
1170
|
+
|
|
1171
|
+
mockFetch.mockResolvedValue({
|
|
1172
|
+
ok: true,
|
|
1173
|
+
json: async () => ({ accessToken: 'periodic-refresh-token' }),
|
|
1174
|
+
})
|
|
1175
|
+
|
|
1176
|
+
vi.advanceTimersByTime(1 * 60 * 60 * 1000)
|
|
1177
|
+
await vi.advanceTimersByTimeAsync(1)
|
|
1178
|
+
|
|
1179
|
+
expect(global.fetch).toHaveBeenCalled()
|
|
1180
|
+
})
|
|
1181
|
+
})
|
|
1182
|
+
|
|
1183
|
+
describe('Additional Connection Methods', () => {
|
|
1184
|
+
beforeEach(() => {
|
|
1185
|
+
const credentials = {
|
|
1186
|
+
token: 'test-token',
|
|
1187
|
+
userId: 'user-123',
|
|
1188
|
+
email: 'test@example.com',
|
|
1189
|
+
clientId: 'client-123',
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
1193
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(credentials))
|
|
1194
|
+
})
|
|
1195
|
+
|
|
1196
|
+
it('send() should send JSON message through WebSocket', () => {
|
|
1197
|
+
const connection = new PlatformConnection('/test/dir')
|
|
1198
|
+
connection.connect()
|
|
1199
|
+
|
|
1200
|
+
lastWebSocketInstance!.triggerOpen()
|
|
1201
|
+
|
|
1202
|
+
const sentMessages: string[] = []
|
|
1203
|
+
lastWebSocketInstance!.on('send', (data: string) => {
|
|
1204
|
+
sentMessages.push(data)
|
|
1205
|
+
})
|
|
1206
|
+
|
|
1207
|
+
connection.send({ type: 'test', data: 'hello' })
|
|
1208
|
+
|
|
1209
|
+
expect(sentMessages[sentMessages.length - 1]).toBe(
|
|
1210
|
+
JSON.stringify({ type: 'test', data: 'hello' })
|
|
1211
|
+
)
|
|
1212
|
+
})
|
|
1213
|
+
|
|
1214
|
+
it('send() should not send if WebSocket is not open', () => {
|
|
1215
|
+
const connection = new PlatformConnection('/test/dir')
|
|
1216
|
+
connection.connect()
|
|
1217
|
+
|
|
1218
|
+
// Don't trigger open
|
|
1219
|
+
const sentMessages: string[] = []
|
|
1220
|
+
lastWebSocketInstance!.on('send', (data: string) => {
|
|
1221
|
+
sentMessages.push(data)
|
|
1222
|
+
})
|
|
1223
|
+
|
|
1224
|
+
connection.send({ type: 'test', data: 'hello' })
|
|
1225
|
+
|
|
1226
|
+
// Should only have auth message from onopen, not our test message
|
|
1227
|
+
expect(sentMessages.length).toBe(0)
|
|
1228
|
+
})
|
|
1229
|
+
|
|
1230
|
+
it('isConnectedToPlatform() should return true when connected and paired', () => {
|
|
1231
|
+
const connection = new PlatformConnection('/test/dir')
|
|
1232
|
+
connection.connect()
|
|
1233
|
+
|
|
1234
|
+
expect(connection.isConnectedToPlatform()).toBe(false)
|
|
1235
|
+
|
|
1236
|
+
lastWebSocketInstance!.triggerOpen()
|
|
1237
|
+
expect(connection.isConnectedToPlatform()).toBe(false)
|
|
1238
|
+
|
|
1239
|
+
lastWebSocketInstance!.triggerMessage({ type: 'paired', connectionId: 'conn-123' })
|
|
1240
|
+
expect(connection.isConnectedToPlatform()).toBe(true)
|
|
1241
|
+
})
|
|
1242
|
+
|
|
1243
|
+
it('isConnectedToPlatform() should return false after disconnection', () => {
|
|
1244
|
+
const connection = new PlatformConnection('/test/dir')
|
|
1245
|
+
connection.connect()
|
|
1246
|
+
|
|
1247
|
+
lastWebSocketInstance!.triggerOpen()
|
|
1248
|
+
lastWebSocketInstance!.triggerMessage({ type: 'paired', connectionId: 'conn-123' })
|
|
1249
|
+
|
|
1250
|
+
expect(connection.isConnectedToPlatform()).toBe(true)
|
|
1251
|
+
|
|
1252
|
+
connection.disconnect()
|
|
1253
|
+
|
|
1254
|
+
expect(connection.isConnectedToPlatform()).toBe(false)
|
|
1255
|
+
})
|
|
1256
|
+
})
|
|
1257
|
+
|
|
1258
|
+
describe('Connection Timeout', () => {
|
|
1259
|
+
beforeEach(() => {
|
|
1260
|
+
vi.useFakeTimers()
|
|
1261
|
+
|
|
1262
|
+
const credentials = {
|
|
1263
|
+
token: 'test-token',
|
|
1264
|
+
userId: 'user-123',
|
|
1265
|
+
email: 'test@example.com',
|
|
1266
|
+
clientId: 'client-123',
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
mockFs.existsSync.mockReturnValue(true)
|
|
1270
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(credentials))
|
|
1271
|
+
})
|
|
1272
|
+
|
|
1273
|
+
afterEach(() => {
|
|
1274
|
+
vi.useRealTimers()
|
|
1275
|
+
})
|
|
1276
|
+
|
|
1277
|
+
it('should show timeout error if connection takes longer than 10 seconds', () => {
|
|
1278
|
+
const onError = vi.fn()
|
|
1279
|
+
const connection = new PlatformConnection('/test/dir', { onError })
|
|
1280
|
+
connection.connect()
|
|
1281
|
+
|
|
1282
|
+
// Don't trigger open - simulate hanging connection
|
|
1283
|
+
const closeSpy = vi.spyOn(lastWebSocketInstance!, 'close')
|
|
1284
|
+
|
|
1285
|
+
// Advance time by 10 seconds
|
|
1286
|
+
vi.advanceTimersByTime(10000)
|
|
1287
|
+
|
|
1288
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
1289
|
+
expect.stringContaining('Connection timeout')
|
|
1290
|
+
)
|
|
1291
|
+
expect(onError).toHaveBeenCalledWith(
|
|
1292
|
+
'Connection timeout - run `mstro login --force`'
|
|
1293
|
+
)
|
|
1294
|
+
expect(closeSpy).toHaveBeenCalled()
|
|
1295
|
+
})
|
|
1296
|
+
|
|
1297
|
+
it('should not timeout if connection opens within 10 seconds', () => {
|
|
1298
|
+
const onError = vi.fn()
|
|
1299
|
+
const connection = new PlatformConnection('/test/dir', { onError })
|
|
1300
|
+
connection.connect()
|
|
1301
|
+
|
|
1302
|
+
// Trigger open before timeout
|
|
1303
|
+
vi.advanceTimersByTime(5000)
|
|
1304
|
+
lastWebSocketInstance!.triggerOpen()
|
|
1305
|
+
|
|
1306
|
+
// Advance past timeout threshold
|
|
1307
|
+
vi.advanceTimersByTime(6000)
|
|
1308
|
+
|
|
1309
|
+
expect(onError).not.toHaveBeenCalledWith(
|
|
1310
|
+
expect.stringContaining('Connection timeout')
|
|
1311
|
+
)
|
|
1312
|
+
})
|
|
1313
|
+
})
|
|
1314
|
+
})
|