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.
Files changed (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +177 -0
  3. package/bin/commands/config.js +145 -0
  4. package/bin/commands/login.js +313 -0
  5. package/bin/commands/logout.js +75 -0
  6. package/bin/commands/status.js +197 -0
  7. package/bin/commands/whoami.js +161 -0
  8. package/bin/configure-claude.js +298 -0
  9. package/bin/mstro.js +581 -0
  10. package/bin/postinstall.js +45 -0
  11. package/bin/release.sh +110 -0
  12. package/dist/server/cli/headless/claude-invoker.d.ts +17 -0
  13. package/dist/server/cli/headless/claude-invoker.d.ts.map +1 -0
  14. package/dist/server/cli/headless/claude-invoker.js +311 -0
  15. package/dist/server/cli/headless/claude-invoker.js.map +1 -0
  16. package/dist/server/cli/headless/index.d.ts +13 -0
  17. package/dist/server/cli/headless/index.d.ts.map +1 -0
  18. package/dist/server/cli/headless/index.js +10 -0
  19. package/dist/server/cli/headless/index.js.map +1 -0
  20. package/dist/server/cli/headless/mcp-config.d.ts +11 -0
  21. package/dist/server/cli/headless/mcp-config.d.ts.map +1 -0
  22. package/dist/server/cli/headless/mcp-config.js +76 -0
  23. package/dist/server/cli/headless/mcp-config.js.map +1 -0
  24. package/dist/server/cli/headless/output-utils.d.ts +33 -0
  25. package/dist/server/cli/headless/output-utils.d.ts.map +1 -0
  26. package/dist/server/cli/headless/output-utils.js +101 -0
  27. package/dist/server/cli/headless/output-utils.js.map +1 -0
  28. package/dist/server/cli/headless/prompt-utils.d.ts +21 -0
  29. package/dist/server/cli/headless/prompt-utils.d.ts.map +1 -0
  30. package/dist/server/cli/headless/prompt-utils.js +84 -0
  31. package/dist/server/cli/headless/prompt-utils.js.map +1 -0
  32. package/dist/server/cli/headless/runner.d.ts +24 -0
  33. package/dist/server/cli/headless/runner.d.ts.map +1 -0
  34. package/dist/server/cli/headless/runner.js +99 -0
  35. package/dist/server/cli/headless/runner.js.map +1 -0
  36. package/dist/server/cli/headless/types.d.ts +106 -0
  37. package/dist/server/cli/headless/types.d.ts.map +1 -0
  38. package/dist/server/cli/headless/types.js +4 -0
  39. package/dist/server/cli/headless/types.js.map +1 -0
  40. package/dist/server/cli/improvisation-session-manager.d.ts +155 -0
  41. package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -0
  42. package/dist/server/cli/improvisation-session-manager.js +415 -0
  43. package/dist/server/cli/improvisation-session-manager.js.map +1 -0
  44. package/dist/server/index.d.ts +2 -0
  45. package/dist/server/index.d.ts.map +1 -0
  46. package/dist/server/index.js +386 -0
  47. package/dist/server/index.js.map +1 -0
  48. package/dist/server/mcp/bouncer-cli.d.ts +3 -0
  49. package/dist/server/mcp/bouncer-cli.d.ts.map +1 -0
  50. package/dist/server/mcp/bouncer-cli.js +99 -0
  51. package/dist/server/mcp/bouncer-cli.js.map +1 -0
  52. package/dist/server/mcp/bouncer-integration.d.ts +36 -0
  53. package/dist/server/mcp/bouncer-integration.d.ts.map +1 -0
  54. package/dist/server/mcp/bouncer-integration.js +301 -0
  55. package/dist/server/mcp/bouncer-integration.js.map +1 -0
  56. package/dist/server/mcp/security-audit.d.ts +52 -0
  57. package/dist/server/mcp/security-audit.d.ts.map +1 -0
  58. package/dist/server/mcp/security-audit.js +118 -0
  59. package/dist/server/mcp/security-audit.js.map +1 -0
  60. package/dist/server/mcp/security-patterns.d.ts +73 -0
  61. package/dist/server/mcp/security-patterns.d.ts.map +1 -0
  62. package/dist/server/mcp/security-patterns.js +247 -0
  63. package/dist/server/mcp/security-patterns.js.map +1 -0
  64. package/dist/server/mcp/server.d.ts +3 -0
  65. package/dist/server/mcp/server.d.ts.map +1 -0
  66. package/dist/server/mcp/server.js +146 -0
  67. package/dist/server/mcp/server.js.map +1 -0
  68. package/dist/server/routes/files.d.ts +9 -0
  69. package/dist/server/routes/files.d.ts.map +1 -0
  70. package/dist/server/routes/files.js +24 -0
  71. package/dist/server/routes/files.js.map +1 -0
  72. package/dist/server/routes/improvise.d.ts +3 -0
  73. package/dist/server/routes/improvise.d.ts.map +1 -0
  74. package/dist/server/routes/improvise.js +72 -0
  75. package/dist/server/routes/improvise.js.map +1 -0
  76. package/dist/server/routes/index.d.ts +10 -0
  77. package/dist/server/routes/index.d.ts.map +1 -0
  78. package/dist/server/routes/index.js +12 -0
  79. package/dist/server/routes/index.js.map +1 -0
  80. package/dist/server/routes/instances.d.ts +10 -0
  81. package/dist/server/routes/instances.d.ts.map +1 -0
  82. package/dist/server/routes/instances.js +47 -0
  83. package/dist/server/routes/instances.js.map +1 -0
  84. package/dist/server/routes/notifications.d.ts +3 -0
  85. package/dist/server/routes/notifications.d.ts.map +1 -0
  86. package/dist/server/routes/notifications.js +136 -0
  87. package/dist/server/routes/notifications.js.map +1 -0
  88. package/dist/server/services/analytics.d.ts +56 -0
  89. package/dist/server/services/analytics.d.ts.map +1 -0
  90. package/dist/server/services/analytics.js +240 -0
  91. package/dist/server/services/analytics.js.map +1 -0
  92. package/dist/server/services/auth.d.ts +26 -0
  93. package/dist/server/services/auth.d.ts.map +1 -0
  94. package/dist/server/services/auth.js +71 -0
  95. package/dist/server/services/auth.js.map +1 -0
  96. package/dist/server/services/client-id.d.ts +10 -0
  97. package/dist/server/services/client-id.d.ts.map +1 -0
  98. package/dist/server/services/client-id.js +61 -0
  99. package/dist/server/services/client-id.js.map +1 -0
  100. package/dist/server/services/credentials.d.ts +39 -0
  101. package/dist/server/services/credentials.d.ts.map +1 -0
  102. package/dist/server/services/credentials.js +110 -0
  103. package/dist/server/services/credentials.js.map +1 -0
  104. package/dist/server/services/files.d.ts +119 -0
  105. package/dist/server/services/files.d.ts.map +1 -0
  106. package/dist/server/services/files.js +560 -0
  107. package/dist/server/services/files.js.map +1 -0
  108. package/dist/server/services/instances.d.ts +52 -0
  109. package/dist/server/services/instances.d.ts.map +1 -0
  110. package/dist/server/services/instances.js +241 -0
  111. package/dist/server/services/instances.js.map +1 -0
  112. package/dist/server/services/pathUtils.d.ts +47 -0
  113. package/dist/server/services/pathUtils.d.ts.map +1 -0
  114. package/dist/server/services/pathUtils.js +124 -0
  115. package/dist/server/services/pathUtils.js.map +1 -0
  116. package/dist/server/services/platform.d.ts +72 -0
  117. package/dist/server/services/platform.d.ts.map +1 -0
  118. package/dist/server/services/platform.js +368 -0
  119. package/dist/server/services/platform.js.map +1 -0
  120. package/dist/server/services/sentry.d.ts +5 -0
  121. package/dist/server/services/sentry.d.ts.map +1 -0
  122. package/dist/server/services/sentry.js +71 -0
  123. package/dist/server/services/sentry.js.map +1 -0
  124. package/dist/server/services/terminal/pty-manager.d.ts +149 -0
  125. package/dist/server/services/terminal/pty-manager.d.ts.map +1 -0
  126. package/dist/server/services/terminal/pty-manager.js +377 -0
  127. package/dist/server/services/terminal/pty-manager.js.map +1 -0
  128. package/dist/server/services/terminal/tmux-manager.d.ts +82 -0
  129. package/dist/server/services/terminal/tmux-manager.d.ts.map +1 -0
  130. package/dist/server/services/terminal/tmux-manager.js +352 -0
  131. package/dist/server/services/terminal/tmux-manager.js.map +1 -0
  132. package/dist/server/services/websocket/autocomplete.d.ts +50 -0
  133. package/dist/server/services/websocket/autocomplete.d.ts.map +1 -0
  134. package/dist/server/services/websocket/autocomplete.js +361 -0
  135. package/dist/server/services/websocket/autocomplete.js.map +1 -0
  136. package/dist/server/services/websocket/file-utils.d.ts +44 -0
  137. package/dist/server/services/websocket/file-utils.d.ts.map +1 -0
  138. package/dist/server/services/websocket/file-utils.js +272 -0
  139. package/dist/server/services/websocket/file-utils.js.map +1 -0
  140. package/dist/server/services/websocket/handler.d.ts +246 -0
  141. package/dist/server/services/websocket/handler.d.ts.map +1 -0
  142. package/dist/server/services/websocket/handler.js +1771 -0
  143. package/dist/server/services/websocket/handler.js.map +1 -0
  144. package/dist/server/services/websocket/index.d.ts +11 -0
  145. package/dist/server/services/websocket/index.d.ts.map +1 -0
  146. package/dist/server/services/websocket/index.js +14 -0
  147. package/dist/server/services/websocket/index.js.map +1 -0
  148. package/dist/server/services/websocket/types.d.ts +214 -0
  149. package/dist/server/services/websocket/types.d.ts.map +1 -0
  150. package/dist/server/services/websocket/types.js +4 -0
  151. package/dist/server/services/websocket/types.js.map +1 -0
  152. package/dist/server/utils/agent-manager.d.ts +69 -0
  153. package/dist/server/utils/agent-manager.d.ts.map +1 -0
  154. package/dist/server/utils/agent-manager.js +269 -0
  155. package/dist/server/utils/agent-manager.js.map +1 -0
  156. package/dist/server/utils/paths.d.ts +25 -0
  157. package/dist/server/utils/paths.d.ts.map +1 -0
  158. package/dist/server/utils/paths.js +38 -0
  159. package/dist/server/utils/paths.js.map +1 -0
  160. package/dist/server/utils/port-manager.d.ts +10 -0
  161. package/dist/server/utils/port-manager.d.ts.map +1 -0
  162. package/dist/server/utils/port-manager.js +60 -0
  163. package/dist/server/utils/port-manager.js.map +1 -0
  164. package/dist/server/utils/port.d.ts +26 -0
  165. package/dist/server/utils/port.d.ts.map +1 -0
  166. package/dist/server/utils/port.js +83 -0
  167. package/dist/server/utils/port.js.map +1 -0
  168. package/hooks/bouncer.sh +138 -0
  169. package/package.json +74 -0
  170. package/server/README.md +191 -0
  171. package/server/cli/headless/claude-invoker.ts +415 -0
  172. package/server/cli/headless/index.ts +39 -0
  173. package/server/cli/headless/mcp-config.ts +87 -0
  174. package/server/cli/headless/output-utils.ts +109 -0
  175. package/server/cli/headless/prompt-utils.ts +108 -0
  176. package/server/cli/headless/runner.ts +133 -0
  177. package/server/cli/headless/types.ts +118 -0
  178. package/server/cli/improvisation-session-manager.ts +531 -0
  179. package/server/index.ts +456 -0
  180. package/server/mcp/README.md +122 -0
  181. package/server/mcp/bouncer-cli.ts +127 -0
  182. package/server/mcp/bouncer-integration.ts +430 -0
  183. package/server/mcp/security-audit.ts +180 -0
  184. package/server/mcp/security-patterns.ts +290 -0
  185. package/server/mcp/server.ts +174 -0
  186. package/server/routes/files.ts +29 -0
  187. package/server/routes/improvise.ts +82 -0
  188. package/server/routes/index.ts +13 -0
  189. package/server/routes/instances.ts +54 -0
  190. package/server/routes/notifications.ts +158 -0
  191. package/server/services/analytics.ts +277 -0
  192. package/server/services/auth.ts +80 -0
  193. package/server/services/client-id.ts +68 -0
  194. package/server/services/credentials.ts +134 -0
  195. package/server/services/files.ts +710 -0
  196. package/server/services/instances.ts +275 -0
  197. package/server/services/pathUtils.ts +158 -0
  198. package/server/services/platform.test.ts +1314 -0
  199. package/server/services/platform.ts +435 -0
  200. package/server/services/sentry.ts +81 -0
  201. package/server/services/terminal/pty-manager.ts +464 -0
  202. package/server/services/terminal/tmux-manager.ts +426 -0
  203. package/server/services/websocket/autocomplete.ts +438 -0
  204. package/server/services/websocket/file-utils.ts +305 -0
  205. package/server/services/websocket/handler.test.ts +20 -0
  206. package/server/services/websocket/handler.ts +2047 -0
  207. package/server/services/websocket/index.ts +40 -0
  208. package/server/services/websocket/types.ts +339 -0
  209. package/server/tsconfig.json +19 -0
  210. package/server/utils/agent-manager.ts +323 -0
  211. package/server/utils/paths.ts +45 -0
  212. package/server/utils/port-manager.ts +70 -0
  213. 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
+ })