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