Skip to content

Commit 6f76977

Browse files
committed
chore: add socket recorder
1 parent 83ba74f commit 6f76977

File tree

3 files changed

+290
-5
lines changed

3 files changed

+290
-5
lines changed

src/interceptors/net/mock-socket.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,30 @@ import {
33
normalizeSocketWriteArgs,
44
WriteArgs,
55
} from '../Socket/utils/normalizeSocketWriteArgs'
6+
import { createSocketRecorder, type SocketRecorder } from './socket-recorder'
67

78
export class MockSocket extends net.Socket {
89
public connecting: boolean
910

10-
constructor() {
11-
super()
11+
#recorder: SocketRecorder<MockSocket>
12+
13+
constructor(protected readonly options?: net.SocketConstructorOpts) {
14+
super(options)
1215
this.connecting = false
1316
this.connect()
1417

1518
this._final = (callback) => callback(null)
19+
20+
this.#recorder = createSocketRecorder(this)
21+
return this.#recorder.socket
1622
}
1723

1824
public connect() {
1925
this.connecting = true
2026
return this
2127
}
2228

23-
public write(...args: Array<unknown>): boolean {
29+
public write(...args: any): boolean {
2430
const [chunk, encoding, callback] = normalizeSocketWriteArgs(
2531
args as WriteArgs
2632
)
@@ -33,11 +39,28 @@ export class MockSocket extends net.Socket {
3339
return super.push(chunk, encoding)
3440
}
3541

36-
public end(...args: Array<unknown>) {
42+
public end(...args: any) {
3743
const [chunk, encoding, callback] = normalizeSocketWriteArgs(
3844
args as WriteArgs
3945
)
46+
4047
this.emit('write', chunk, encoding, callback)
41-
return super.end.apply(this, args as any)
48+
return super.end.apply(this, args)
49+
}
50+
51+
public passthrough(): net.Socket {
52+
/**
53+
* @fixme Get the means of creating a passthrough socket instance.
54+
*/
55+
const socket = foo(this.options)
56+
this.#recorder.replay(socket)
57+
58+
/**
59+
* @todo Implement the inverse recorder: changes on the passthrough socket
60+
* must be reflected on this MockSocket. Consumers getting properties from
61+
* this MockSocket must receive their values from the passthrough socket.
62+
*/
63+
64+
return socket
4265
}
4366
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// @vitest-environment node
2+
import net from 'node:net'
3+
import { vi, describe, it, expect } from 'vitest'
4+
import {
5+
createSocketRecorder,
6+
inspectSocketRecorder,
7+
SocketRecorderEntry,
8+
} from './socket-recorder'
9+
10+
describe('set', () => {
11+
it('ignores unknown property setters', () => {
12+
const { socket } = createSocketRecorder(new net.Socket())
13+
Object.defineProperty(socket, 'foo', { value: 'abc' })
14+
15+
expect(
16+
inspectSocketRecorder(socket),
17+
'Must not record unknown property setters'
18+
).not.toEqual(
19+
expect.arrayContaining<SocketRecorderEntry>([
20+
{
21+
type: 'set',
22+
metadata: expect.objectContaining({ property: 'foo' }),
23+
replay: expect.any(Function),
24+
},
25+
])
26+
)
27+
})
28+
29+
it('ignores symbol setters', () => {
30+
const { socket } = createSocketRecorder(new net.Socket())
31+
// Calling `.setTimeout()` updates the value of the internal `[kTimeout]` symbol.
32+
socket.setTimeout(1000)
33+
34+
expect(
35+
inspectSocketRecorder(socket),
36+
'Must not record symbol setters'
37+
).not.toEqual(
38+
expect.arrayContaining<SocketRecorderEntry>([
39+
{
40+
type: 'set',
41+
metadata: expect.objectContaining({
42+
property: expect.any(Symbol),
43+
}),
44+
replay: expect.any(Function),
45+
},
46+
])
47+
)
48+
})
49+
50+
it('ignores internal setters', () => {
51+
const { socket } = createSocketRecorder(new net.Socket())
52+
// Calling `.setTimeout()` updates the value of the internal `._timeout` property.
53+
socket.setTimeout(1000)
54+
55+
expect(
56+
inspectSocketRecorder(socket),
57+
'Must not record implied internal setter'
58+
).not.toEqual(
59+
expect.arrayContaining<SocketRecorderEntry>([
60+
{
61+
type: 'set',
62+
metadata: expect.objectContaining({ property: '_timeout' }),
63+
replay: expect.any(Function),
64+
},
65+
])
66+
)
67+
})
68+
})
69+
70+
describe('apply', () => {
71+
it('records a single method call', () => {
72+
const { socket } = createSocketRecorder(new net.Socket())
73+
socket.setTimeout(1000)
74+
75+
expect(inspectSocketRecorder(socket)).toEqual<SocketRecorderEntry[]>([
76+
{
77+
type: 'apply',
78+
metadata: { property: 'setTimeout' },
79+
replay: expect.any(Function),
80+
},
81+
])
82+
})
83+
84+
it('records multiple method calls', () => {
85+
const { socket } = createSocketRecorder(new net.Socket())
86+
socket.setTimeout(1000)
87+
socket.setKeepAlive(true)
88+
socket.setEncoding('base64')
89+
90+
expect(inspectSocketRecorder(socket)).toEqual<SocketRecorderEntry[]>([
91+
{
92+
type: 'apply',
93+
metadata: { property: 'setTimeout' },
94+
replay: expect.any(Function),
95+
},
96+
{
97+
type: 'apply',
98+
metadata: { property: 'setKeepAlive' },
99+
replay: expect.any(Function),
100+
},
101+
{
102+
type: 'apply',
103+
metadata: { property: 'setEncoding' },
104+
replay: expect.any(Function),
105+
},
106+
])
107+
})
108+
109+
it('ignores internal method calls', () => {
110+
const { socket } = createSocketRecorder(new net.Socket())
111+
// Calling `.write()` triggers the internal `._write()`.
112+
socket.write('hello')
113+
socket.on('error', () => void 0)
114+
115+
expect(
116+
inspectSocketRecorder(socket),
117+
'Must not record internal method calls'
118+
).not.toEqual(
119+
expect.arrayContaining<SocketRecorderEntry>([
120+
{
121+
type: 'apply',
122+
metadata: { property: '_write' },
123+
replay: expect.any(Function),
124+
},
125+
])
126+
)
127+
})
128+
})
129+
130+
describe('replay', () => {
131+
it('replays method recordings', () => {
132+
const { socket, replay } = createSocketRecorder(new net.Socket())
133+
socket.setTimeout(123)
134+
135+
const target = new net.Socket()
136+
replay(target)
137+
138+
expect(target.timeout, 'Must replay the recorded method call').toBe(123)
139+
expect(
140+
inspectSocketRecorder(socket),
141+
'Must exhaust the records array'
142+
).toEqual([])
143+
})
144+
145+
it('replays attached listeners', () => {
146+
const { socket, replay } = createSocketRecorder(new net.Socket())
147+
const connectListener = vi.fn()
148+
socket.on('connect', connectListener)
149+
150+
const target = new net.Socket()
151+
replay(target)
152+
target.emit('connect')
153+
154+
expect(
155+
target.listeners('connect'),
156+
'Must replay attached listener'
157+
).toEqual([connectListener])
158+
expect(connectListener).toHaveBeenCalledOnce()
159+
})
160+
})
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import net from 'node:net'
2+
3+
const kSocketRecorder = Symbol('kSocketRecorder')
4+
5+
export interface SocketRecorder<T extends net.Socket> {
6+
socket: T
7+
replay: (newSocket: net.Socket) => void
8+
}
9+
10+
export interface SocketRecorderEntry {
11+
type: 'get' | 'set' | 'apply'
12+
metadata: Record<string, any>
13+
replay: (newSocket: net.Socket) => void
14+
}
15+
16+
/**
17+
* Creates a proxy over the given mock `Socket` instance
18+
* that records all the property setters and methods calls
19+
* so they can later be replayed on the passthrough socket.
20+
*/
21+
export function createSocketRecorder<T extends net.Socket>(
22+
socket: T
23+
): SocketRecorder<T> {
24+
const entries: Array<SocketRecorderEntry> = []
25+
26+
Object.defineProperty(socket, kSocketRecorder, {
27+
value: entries,
28+
configurable: true,
29+
enumerable: false,
30+
})
31+
32+
const proxy = new Proxy(socket, {
33+
get(target, property, receiver) {
34+
if (
35+
typeof property === 'string' &&
36+
!property.startsWith('_') &&
37+
typeof target[property as keyof T] === 'function'
38+
) {
39+
return new Proxy(target[property as keyof T] as Function, {
40+
apply(target, thisArg, argArray) {
41+
if (target.name === 'destroy') {
42+
entries.length = 0
43+
}
44+
45+
if (target.name !== 'push') {
46+
entries.push({
47+
type: 'apply',
48+
metadata: { property },
49+
replay(newSocket) {
50+
Reflect.apply(target, newSocket, argArray)
51+
},
52+
})
53+
}
54+
return Reflect.apply(target, thisArg, argArray)
55+
},
56+
})
57+
}
58+
59+
return Reflect.get(target, property, receiver)
60+
},
61+
set(target, property, newValue, receiver) {
62+
const defaultSetter = () => {
63+
return Reflect.set(target, property, newValue, receiver)
64+
}
65+
66+
if (typeof property === 'symbol') {
67+
return defaultSetter()
68+
}
69+
70+
const attributes = Object.getOwnPropertyDescriptor(target, property)
71+
if (attributes == null || !attributes.writable) {
72+
return defaultSetter()
73+
}
74+
75+
entries.push({
76+
type: 'set',
77+
metadata: { property, newValue },
78+
replay(newSocket) {
79+
Reflect.set(newSocket, property, newValue, newSocket)
80+
},
81+
})
82+
83+
return defaultSetter()
84+
},
85+
})
86+
87+
return {
88+
socket: proxy,
89+
replay(newSocket) {
90+
for (const entry of entries) {
91+
entry.replay(newSocket)
92+
}
93+
entries.length = 0
94+
},
95+
}
96+
}
97+
98+
export function inspectSocketRecorder<T extends net.Socket>(
99+
socket: net.Socket
100+
): SocketRecorder<T> | undefined {
101+
return Reflect.get(socket, kSocketRecorder) as SocketRecorder<T>
102+
}

0 commit comments

Comments
 (0)