Skip to content

Commit ac23d61

Browse files
committed
refactor(test): extract shared utilities for concurrent test execution
Move ManagedTestClient and ManagedTestServer classes to test/utils.ts and use a shared server instance across websocket and web-server tests. This prevents port conflicts during concurrent execution and improves test reliability.
1 parent 5908f54 commit ac23d61

3 files changed

Lines changed: 182 additions & 204 deletions

File tree

test/utils.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { initManager, manager, sessionUpdateCallbacks, rawOutputCallbacks } from "../src/plugin/pty/manager"
2+
import { PTYServer } from "../src/web/server/server"
3+
import type { WSMessageServer, WSMessageServerSubscribedSession, WSMessageServerUnsubscribedSession, WSMessageServerSessionUpdate, WSMessageServerRawData, WSMessageServerData, WSMessageServerReadRawResponse, WSMessageServerSessionList, WSMessageServerError, WSMessageClientInput, WSMessageClientSessionList, WSMessageClientSpawnSession, WSMessageClientSubscribeSession, WSMessageClientUnsubscribeSession } from "../src/web/shared/types"
4+
5+
export class ManagedTestClient implements Disposable {
6+
public readonly ws: WebSocket
7+
private readonly stack = new DisposableStack()
8+
9+
public readonly messages: WSMessageServer[] = []
10+
public readonly subscribedCallbacks: Array<(message: WSMessageServerSubscribedSession) => void> =
11+
[]
12+
public readonly unsubscribedCallbacks: Array<
13+
(message: WSMessageServerUnsubscribedSession) => void
14+
> = []
15+
public readonly sessionUpdateCallbacks: Array<(message: WSMessageServerSessionUpdate) => void> =
16+
[]
17+
public readonly rawDataCallbacks: Array<(message: WSMessageServerRawData) => void> = []
18+
public readonly dataCallbacks: Array<(message: WSMessageServerData) => void> = []
19+
public readonly readRawResponseCallbacks: Array<
20+
(message: WSMessageServerReadRawResponse) => void
21+
> = []
22+
public readonly sessionListCallbacks: Array<(message: WSMessageServerSessionList) => void> = []
23+
public readonly errorCallbacks: Array<(message: WSMessageServerError) => void> = []
24+
25+
26+
private constructor() {
27+
this.ws = new WebSocket(managedTestServer.server.getWsUrl()!)
28+
this.ws.onerror = (error) => {
29+
throw error
30+
}
31+
this.ws.onmessage = (event) => {
32+
const message = JSON.parse(event.data) as WSMessageServer
33+
this.messages.push(message)
34+
switch (message.type) {
35+
case 'subscribed':
36+
this.subscribedCallbacks.forEach((callback) =>
37+
callback(message as WSMessageServerSubscribedSession)
38+
)
39+
break
40+
case 'unsubscribed':
41+
this.unsubscribedCallbacks.forEach((callback) =>
42+
callback(message as WSMessageServerUnsubscribedSession)
43+
)
44+
break
45+
case 'session_update':
46+
this.sessionUpdateCallbacks.forEach((callback) =>
47+
callback(message as WSMessageServerSessionUpdate)
48+
)
49+
break
50+
case 'raw_data':
51+
this.rawDataCallbacks.forEach((callback) => callback(message as WSMessageServerRawData))
52+
break
53+
case 'data':
54+
this.dataCallbacks.forEach((callback) => callback(message as WSMessageServerData))
55+
break
56+
case 'readRawResponse':
57+
this.readRawResponseCallbacks.forEach((callback) =>
58+
callback(message as WSMessageServerReadRawResponse)
59+
)
60+
break
61+
case 'session_list':
62+
this.sessionListCallbacks.forEach((callback) =>
63+
callback(message as WSMessageServerSessionList)
64+
)
65+
break
66+
case 'error':
67+
this.errorCallbacks.forEach((callback) => callback(message as WSMessageServerError))
68+
break
69+
}
70+
}
71+
}
72+
[Symbol.dispose]() {
73+
this.ws.close()
74+
this.stack.dispose()
75+
}
76+
/**
77+
* Waits until the WebSocket connection is open.
78+
*
79+
* The onopen event is broken so we need to wait manually.
80+
* Problem: if onopen is set after the WebSocket is opened,
81+
* it will never be called. So we wait here until the readyState is OPEN.
82+
* This prevents flakiness.
83+
*/
84+
public async waitOpen() {
85+
while (this.ws.readyState !== WebSocket.OPEN) {
86+
await new Promise(setImmediate)
87+
}
88+
}
89+
public static async create() {
90+
const client = new ManagedTestClient()
91+
await client.waitOpen()
92+
return client
93+
}
94+
95+
public send(
96+
message:
97+
| WSMessageClientInput
98+
| WSMessageClientSessionList
99+
| WSMessageClientSpawnSession
100+
| WSMessageClientSubscribeSession
101+
| WSMessageClientUnsubscribeSession
102+
) {
103+
this.ws.send(JSON.stringify(message))
104+
}
105+
}
106+
107+
class ManagedTestServer implements Disposable {
108+
public readonly server: PTYServer
109+
private readonly stack = new DisposableStack()
110+
public readonly sessionId: string
111+
112+
public static async create() {
113+
const server = await PTYServer.createServer()
114+
115+
return new ManagedTestServer(server)
116+
}
117+
118+
private readonly fakeClient = {
119+
app: {
120+
log: async (_opts: any) => {
121+
// Mock logger
122+
},
123+
},
124+
} as any
125+
126+
127+
private constructor(server: PTYServer) {
128+
initManager(this.fakeClient)
129+
this.server = server
130+
this.stack.use(this.server)
131+
this.sessionId = crypto.randomUUID()
132+
}
133+
[Symbol.dispose]() {
134+
this.stack.dispose()
135+
manager.clearAllSessions()
136+
sessionUpdateCallbacks.length = 0
137+
rawOutputCallbacks.length = 0
138+
}
139+
}
140+
141+
export const managedTestServer = await ManagedTestServer.create()
142+
const stack = new DisposableStack()
143+
stack.use(managedTestServer)

test/web-server.test.ts

Lines changed: 37 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,38 @@
1-
import { describe, it, expect, beforeAll, afterAll } from 'bun:test'
1+
import { describe, it, expect } from 'bun:test'
22
import {
3-
initManager,
43
manager,
5-
rawOutputCallbacks,
64
registerRawOutputCallback,
75
registerSessionUpdateCallback,
8-
sessionUpdateCallbacks,
96
} from '../src/plugin/pty/manager.ts'
107
import { PTYServer } from '../src/web/server/server.ts'
118
import type { PTYSessionInfo } from '../src/plugin/pty/types.ts'
9+
import { managedTestServer } from './utils.ts'
1210

1311
describe('Web Server', () => {
14-
const fakeClient = {
15-
app: {
16-
log: async (_opts: any) => {
17-
// Mock logger - do nothing
18-
},
19-
},
20-
} as any
21-
22-
let server: PTYServer
23-
let disposableStack: DisposableStack
24-
25-
beforeAll(async () => {
26-
disposableStack = new DisposableStack()
27-
initManager(fakeClient)
28-
server = await PTYServer.createServer()
29-
disposableStack.use(server)
30-
})
31-
32-
afterAll(() => {
33-
manager.clearAllSessions()
34-
disposableStack.dispose()
35-
sessionUpdateCallbacks.length = 0
36-
rawOutputCallbacks.length = 0
37-
})
12+
// const fakeClient = {
13+
// app: {
14+
// log: async (_opts: any) => {
15+
// // Mock logger - do nothing
16+
// },
17+
// },
18+
// } as any
19+
20+
// let server: PTYServer
21+
// let disposableStack: DisposableStack
22+
23+
// beforeAll(async () => {
24+
// disposableStack = new DisposableStack()
25+
// initManager(fakeClient)
26+
// server = await PTYServer.createServer()
27+
// disposableStack.use(server)
28+
// })
29+
30+
// afterAll(() => {
31+
// manager.clearAllSessions()
32+
// disposableStack.dispose()
33+
// sessionUpdateCallbacks.length = 0
34+
// rawOutputCallbacks.length = 0
35+
// })
3836

3937
describe('Server Lifecycle', () => {
4038
it('should start server successfully', async () => {
@@ -61,7 +59,7 @@ describe('Web Server', () => {
6159

6260
describe('HTTP Endpoints', () => {
6361
it('should serve built assets', async () => {
64-
const response = await fetch(server.server.url)
62+
const response = await fetch(managedTestServer.server.server.url)
6563
expect(response.status).toBe(200)
6664
const html = await response.text()
6765

@@ -84,21 +82,21 @@ describe('Web Server', () => {
8482
}
8583

8684
const jsAsset = jsMatch[1]
87-
const jsResponse = await fetch(`${server.server.url}/assets/${jsAsset}`)
85+
const jsResponse = await fetch(`${managedTestServer.server.server.url}/assets/${jsAsset}`)
8886
expect(jsResponse.status).toBe(200)
8987
const ct = jsResponse.headers.get('content-type')
9088
expect((ct || '').toLowerCase()).toMatch(/^(application|text)\/javascript(;.*)?$/)
9189

9290
const cssAsset = cssMatch[1]
93-
const cssResponse = await fetch(`${server.server.url}/assets/${cssAsset}`)
91+
const cssResponse = await fetch(`${managedTestServer.server.server.url}/assets/${cssAsset}`)
9492
expect(cssResponse.status).toBe(200)
9593
expect((cssResponse.headers.get('content-type') || '').toLowerCase()).toMatch(
9694
/^text\/css(;.*)?$/
9795
)
9896
})
9997

10098
it('should serve HTML on root path', async () => {
101-
const response = await fetch(server.server.url)
99+
const response = await fetch(managedTestServer.server.server.url)
102100
expect(response.status).toBe(200)
103101
expect(response.headers.get('content-type')).toContain('text/html')
104102

@@ -108,7 +106,7 @@ describe('Web Server', () => {
108106
})
109107

110108
it('should return sessions list', async () => {
111-
const response = await fetch(`${server.server.url}/api/sessions`)
109+
const response = await fetch(`${managedTestServer.server.server.url}/api/sessions`)
112110
expect(response.status).toBe(200)
113111
expect(response.headers.get('content-type')).toContain('application/json')
114112

@@ -140,7 +138,7 @@ describe('Web Server', () => {
140138

141139
await rawDataPromise
142140

143-
const response = await fetch(`${server.server.url}/api/sessions/${session.id}`)
141+
const response = await fetch(`${managedTestServer.server.server.url}/api/sessions/${session.id}`)
144142
expect(response.status).toBe(200)
145143

146144
const sessionData = await response.json()
@@ -151,7 +149,7 @@ describe('Web Server', () => {
151149

152150
it('should return 404 for non-existent session', async () => {
153151
const nonexistentId = crypto.randomUUID()
154-
const response = await fetch(`${server.server.url}/api/sessions/${nonexistentId}`)
152+
const response = await fetch(`${managedTestServer.server.server.url}/api/sessions/${nonexistentId}`)
155153
expect(response.status).toBe(404)
156154
}, 200)
157155

@@ -176,7 +174,7 @@ describe('Web Server', () => {
176174
// Wait for PTY to start
177175
await sessionUpdatePromise
178176

179-
const response = await fetch(`${server.server.url}/api/sessions/${session.id}/input`, {
177+
const response = await fetch(`${managedTestServer.server.server.url}/api/sessions/${session.id}/input`, {
180178
method: 'POST',
181179
headers: { 'Content-Type': 'application/json' },
182180
body: JSON.stringify({ data: 'test input\n' }),
@@ -215,7 +213,7 @@ describe('Web Server', () => {
215213
// Wait for PTY to start
216214
await sessionRunningPromise
217215

218-
const response = await fetch(`${server.server.url}/api/sessions/${session.id}`, {
216+
const response = await fetch(`${managedTestServer.server.server.url}/api/sessions/${session.id}`, {
219217
method: 'DELETE',
220218
})
221219

@@ -247,7 +245,7 @@ describe('Web Server', () => {
247245
// Wait a bit for output to be captured
248246
await sessionExitedPromise
249247

250-
const response = await fetch(`${server.server.url}/api/sessions/${session.id}/buffer/raw`)
248+
const response = await fetch(`${managedTestServer.server.server.url}/api/sessions/${session.id}/buffer/raw`)
251249
expect(response.status).toBe(200)
252250

253251
const bufferData = await response.json()
@@ -260,7 +258,7 @@ describe('Web Server', () => {
260258
})
261259

262260
it('should return index.html for non-existent endpoints', async () => {
263-
const response = await fetch(`${server.server.url}/api/nonexistent`)
261+
const response = await fetch(`${managedTestServer.server.server.url}/api/nonexistent`)
264262
expect(response.status).toBe(200)
265263
const text = await response.text()
266264
expect(text).toContain('<div id=\"root\"></div>')

0 commit comments

Comments
 (0)