@wetspace/wetrtc 3.0.2 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +156 -29
- package/dist/es/__test__/codec-preference.test.js +36 -0
- package/dist/es/__test__/codec-preference.test.js.map +1 -0
- package/dist/es/__test__/data-manager.test.js +60 -0
- package/dist/es/__test__/data-manager.test.js.map +1 -0
- package/dist/es/__test__/fsm.test.js +33 -0
- package/dist/es/__test__/fsm.test.js.map +1 -0
- package/dist/es/__test__/media-manager.test.js +41 -0
- package/dist/es/__test__/media-manager.test.js.map +1 -0
- package/dist/es/__test__/signal-manager.test.js +100 -0
- package/dist/es/__test__/signal-manager.test.js.map +1 -0
- package/dist/es/__test__/wetrtc-lifecycle.test.js +152 -0
- package/dist/es/__test__/wetrtc-lifecycle.test.js.map +1 -0
- package/dist/es/data/data-manager.d.ts +37 -0
- package/dist/es/data/data-manager.d.ts.map +1 -0
- package/dist/es/data/data-manager.js +282 -0
- package/dist/es/data/data-manager.js.map +1 -0
- package/dist/es/data/types.d.ts +34 -0
- package/dist/es/data/types.d.ts.map +1 -0
- package/dist/es/data/types.js +0 -0
- package/dist/es/disposable.d.ts +12 -0
- package/dist/es/disposable.d.ts.map +1 -0
- package/dist/es/disposable.js +36 -0
- package/dist/es/disposable.js.map +1 -0
- package/dist/es/fsm.d.ts +26 -0
- package/dist/es/fsm.d.ts.map +1 -0
- package/dist/es/fsm.js +63 -0
- package/dist/es/fsm.js.map +1 -0
- package/dist/es/index.d.ts +22 -0
- package/dist/es/index.d.ts.map +1 -0
- package/dist/es/index.js +48 -0
- package/dist/es/index.js.map +1 -0
- package/dist/es/media/audio-encoding.d.ts +10 -0
- package/dist/es/media/audio-encoding.d.ts.map +1 -0
- package/dist/es/media/audio-encoding.js +41 -0
- package/dist/es/media/audio-encoding.js.map +1 -0
- package/dist/es/media/codec-preference.d.ts +11 -0
- package/dist/es/media/codec-preference.d.ts.map +1 -0
- package/dist/es/media/codec-preference.js +77 -0
- package/dist/es/media/codec-preference.js.map +1 -0
- package/dist/es/media/encoding-utils.d.ts +2 -0
- package/dist/es/media/encoding-utils.d.ts.map +1 -0
- package/dist/es/media/encoding-utils.js +8 -0
- package/dist/es/media/encoding-utils.js.map +1 -0
- package/dist/es/media/media-manager.d.ts +39 -0
- package/dist/es/media/media-manager.d.ts.map +1 -0
- package/dist/es/media/media-manager.js +121 -0
- package/dist/es/media/media-manager.js.map +1 -0
- package/dist/es/media/types.d.ts +25 -0
- package/dist/es/media/types.d.ts.map +1 -0
- package/dist/es/media/types.js +0 -0
- package/dist/es/media/video-encoding.d.ts +12 -0
- package/dist/es/media/video-encoding.d.ts.map +1 -0
- package/dist/es/media/video-encoding.js +60 -0
- package/dist/es/media/video-encoding.js.map +1 -0
- package/dist/es/signal/signal-manager.d.ts +45 -0
- package/dist/es/signal/signal-manager.d.ts.map +1 -0
- package/dist/es/signal/signal-manager.js +250 -0
- package/dist/es/signal/signal-manager.js.map +1 -0
- package/dist/es/signal/types.d.ts +26 -0
- package/dist/es/signal/types.d.ts.map +1 -0
- package/dist/es/signal/types.js +8 -0
- package/dist/es/signal/types.js.map +1 -0
- package/dist/es/stats/stats-monitor.d.ts +32 -0
- package/dist/es/stats/stats-monitor.d.ts.map +1 -0
- package/dist/es/stats/stats-monitor.js +191 -0
- package/dist/es/stats/stats-monitor.js.map +1 -0
- package/dist/es/stats/types.d.ts +33 -0
- package/dist/es/stats/types.d.ts.map +1 -0
- package/dist/es/stats/types.js +0 -0
- package/dist/es/utils/types.d.ts +46 -0
- package/dist/es/utils/types.d.ts.map +1 -0
- package/dist/es/utils/types.js +80 -0
- package/dist/es/utils/types.js.map +1 -0
- package/dist/es/wetrtc.d.ts +92 -0
- package/dist/es/wetrtc.d.ts.map +1 -0
- package/dist/es/wetrtc.js +403 -0
- package/dist/es/wetrtc.js.map +1 -0
- package/dist/lib/__test__/codec-preference.test.js +34 -0
- package/dist/lib/__test__/codec-preference.test.js.map +1 -0
- package/dist/lib/__test__/data-manager.test.js +61 -0
- package/dist/lib/__test__/data-manager.test.js.map +1 -0
- package/dist/lib/__test__/fsm.test.js +34 -0
- package/dist/lib/__test__/fsm.test.js.map +1 -0
- package/dist/lib/__test__/media-manager.test.js +42 -0
- package/dist/lib/__test__/media-manager.test.js.map +1 -0
- package/dist/lib/__test__/signal-manager.test.js +101 -0
- package/dist/lib/__test__/signal-manager.test.js.map +1 -0
- package/dist/lib/__test__/wetrtc-lifecycle.test.js +153 -0
- package/dist/lib/__test__/wetrtc-lifecycle.test.js.map +1 -0
- package/dist/lib/data/data-manager.js +306 -0
- package/dist/lib/data/data-manager.js.map +1 -0
- package/dist/lib/data/types.js +18 -0
- package/dist/lib/data/types.js.map +1 -0
- package/dist/lib/disposable.js +60 -0
- package/dist/lib/disposable.js.map +1 -0
- package/dist/lib/fsm.js +87 -0
- package/dist/lib/fsm.js.map +1 -0
- package/dist/lib/index.js +75 -0
- package/dist/lib/index.js.map +1 -0
- package/dist/lib/media/audio-encoding.js +66 -0
- package/dist/lib/media/audio-encoding.js.map +1 -0
- package/dist/lib/media/codec-preference.js +106 -0
- package/dist/lib/media/codec-preference.js.map +1 -0
- package/dist/lib/media/encoding-utils.js +32 -0
- package/dist/lib/media/encoding-utils.js.map +1 -0
- package/dist/lib/media/media-manager.js +145 -0
- package/dist/lib/media/media-manager.js.map +1 -0
- package/dist/lib/media/types.js +18 -0
- package/dist/lib/media/types.js.map +1 -0
- package/dist/lib/media/video-encoding.js +87 -0
- package/dist/lib/media/video-encoding.js.map +1 -0
- package/dist/lib/signal/signal-manager.js +274 -0
- package/dist/lib/signal/signal-manager.js.map +1 -0
- package/dist/lib/signal/types.js +32 -0
- package/dist/lib/signal/types.js.map +1 -0
- package/dist/lib/stats/stats-monitor.js +215 -0
- package/dist/lib/stats/stats-monitor.js.map +1 -0
- package/dist/lib/stats/types.js +18 -0
- package/dist/lib/stats/types.js.map +1 -0
- package/dist/lib/utils/types.js +108 -0
- package/dist/lib/utils/types.js.map +1 -0
- package/dist/lib/wetrtc.js +415 -0
- package/dist/lib/wetrtc.js.map +1 -0
- package/package.json +38 -42
- package/es/core/constant.d.ts +0 -6
- package/es/core/hook.d.ts +0 -31
- package/es/core/index.d.ts +0 -39
- package/es/index.d.ts +0 -6
- package/es/index.js +0 -1
- package/es/libs/index.d.ts +0 -41
- package/es/libs/record.d.ts +0 -8
- package/lib/core/constant.d.ts +0 -6
- package/lib/core/hook.d.ts +0 -31
- package/lib/core/index.d.ts +0 -39
- package/lib/index.d.ts +0 -6
- package/lib/index.js +0 -1
- package/lib/libs/index.d.ts +0 -41
- package/lib/libs/record.d.ts +0 -8
package/README.md
CHANGED
|
@@ -1,29 +1,156 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
1
|
+
# @wetspace/wetrtc
|
|
2
|
+
|
|
3
|
+
框架无关的 WebRTC 连接库 — 简化 SDP/ICE 协商、媒体管理与 DataChannel,信令传输由你自选。
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@wetspace/wetrtc)
|
|
6
|
+
[](./LICENSE)
|
|
7
|
+
|
|
8
|
+
## 特性
|
|
9
|
+
|
|
10
|
+
- **框架无关** — 不绑定 React / Vue / Angular,浏览器、Electron、Node.js 均可使用
|
|
11
|
+
- **信令解耦** — 实现 `SignalChannel` 即可接入 WebSocket、Socket.IO、HTTP 长轮询等
|
|
12
|
+
- **完整子系统** — 媒体捕获、DataChannel、连接统计与诊断
|
|
13
|
+
- **Perfect Negotiation** — 内置 `polite` / `impolite` 模式,避免 glare 冲突
|
|
14
|
+
- **低延迟屏幕共享** — H.264 / Opus 偏好、编码参数与 playout delay 调优
|
|
15
|
+
- **自动重连** — 可配置指数退避,ICE 断开后自动恢复
|
|
16
|
+
- **TypeScript** — 完整类型定义与强类型事件
|
|
17
|
+
|
|
18
|
+
## 安装
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install @wetspace/wetrtc
|
|
22
|
+
# or
|
|
23
|
+
pnpm add @wetspace/wetrtc
|
|
24
|
+
# or
|
|
25
|
+
yarn add @wetspace/wetrtc
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### 浏览器兼容性
|
|
29
|
+
|
|
30
|
+
若需兼容旧版浏览器,建议额外安装 [webrtc-adapter](https://github.com/webrtchacks/adapter):
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install webrtc-adapter
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
并在入口最早处引入:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import 'webrtc-adapter'
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
> **Electron** 应用通常无需 adapter,较新版本 Chromium 对 WebRTC 支持已足够完善。
|
|
43
|
+
|
|
44
|
+
## 快速开始
|
|
45
|
+
|
|
46
|
+
WetRTC 需要一个**信令通道**交换 SDP 与 ICE。你只需实现 `SignalChannel` 接口:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import type { SignalChannel } from '@wetspace/wetrtc'
|
|
50
|
+
|
|
51
|
+
const ws = new WebSocket('wss://signal.example.com')
|
|
52
|
+
|
|
53
|
+
export const signal: SignalChannel = {
|
|
54
|
+
async send(data) {
|
|
55
|
+
ws.send(JSON.stringify(data))
|
|
56
|
+
},
|
|
57
|
+
onMessage(handler) {
|
|
58
|
+
const listener = (e: MessageEvent) => handler(JSON.parse(e.data))
|
|
59
|
+
ws.addEventListener('message', listener)
|
|
60
|
+
return () => ws.removeEventListener('message', listener)
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
创建连接并监听远端媒体:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
import { WetRTC } from '@wetspace/wetrtc'
|
|
69
|
+
|
|
70
|
+
const rtc = new WetRTC({
|
|
71
|
+
signal,
|
|
72
|
+
direction: 'sendrecv',
|
|
73
|
+
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
rtc.on('track', (ev) => {
|
|
77
|
+
videoEl.srcObject = ev.streams[0]
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
rtc.on('statechange', (state) => {
|
|
81
|
+
console.log('connection:', state)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
await rtc.connect()
|
|
85
|
+
|
|
86
|
+
// 使用完毕后释放
|
|
87
|
+
rtc.dispose()
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## 连接方向
|
|
91
|
+
|
|
92
|
+
| 方向 | 说明 |
|
|
93
|
+
|------|------|
|
|
94
|
+
| `sendrecv` | 双向(默认),可发送和接收媒体 |
|
|
95
|
+
| `sendonly` | 仅发送,如屏幕共享主机 |
|
|
96
|
+
| `recvonly` | 仅接收,如手机观看端 |
|
|
97
|
+
|
|
98
|
+
## 屏幕共享示例
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
const rtc = new WetRTC({
|
|
102
|
+
signal,
|
|
103
|
+
direction: 'sendonly',
|
|
104
|
+
preferredVideoCodec: 'h264',
|
|
105
|
+
videoEncoding: {
|
|
106
|
+
maxBitrate: 4_000_000,
|
|
107
|
+
maxFramerate: 30,
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true })
|
|
112
|
+
for (const track of stream.getTracks()) {
|
|
113
|
+
rtc.peerConnection.addTrack(track, stream)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await rtc.connect()
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## 主要导出
|
|
120
|
+
|
|
121
|
+
| 导出 | 说明 |
|
|
122
|
+
|------|------|
|
|
123
|
+
| `WetRTC` | 主入口,门面类 |
|
|
124
|
+
| `SignalManager` | 信令子系统(高级用法) |
|
|
125
|
+
| `MediaManager` | 媒体设备管理 |
|
|
126
|
+
| `DataManager` | DataChannel 与文件传输 |
|
|
127
|
+
| `StatsMonitor` | 连接质量统计 |
|
|
128
|
+
| `applyH264CodecPreference` 等 | 编解码器与编码调优工具 |
|
|
129
|
+
|
|
130
|
+
完整 API 见[在线文档](https://www.wetspace.top/docs/wetrtc/)。
|
|
131
|
+
|
|
132
|
+
## 文档
|
|
133
|
+
|
|
134
|
+
- [快速开始](https://www.wetspace.top/docs/wetrtc/guide/getting-started)
|
|
135
|
+
- [核心概念](https://www.wetspace.top/docs/wetrtc/guide/core-concepts)
|
|
136
|
+
- [信令](https://www.wetspace.top/docs/wetrtc/guide/signaling)
|
|
137
|
+
- [低延迟屏幕共享](https://www.wetspace.top/docs/wetrtc/guide/low-latency-screenshare)
|
|
138
|
+
- [WET 扩展屏](https://www.wetspace.top/docs/wetrtc/wet-screen/) — 基于本库的产品示例
|
|
139
|
+
|
|
140
|
+
## 开发
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
pnpm install
|
|
144
|
+
pnpm build # 输出到 dist/
|
|
145
|
+
pnpm test
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## 相关链接
|
|
149
|
+
|
|
150
|
+
- **npm**: https://www.npmjs.com/package/@wetspace/wetrtc
|
|
151
|
+
- **源码**: https://gitee.com/wetspace/wetrtc
|
|
152
|
+
- **文档**: https://www.wetspace.top/docs/wetrtc/
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT — 详见仓库根目录 [LICENSE](https://gitee.com/wetspace/wetrtc/blob/master/LICENSE)。
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
sortVideoCodecsH264First,
|
|
4
|
+
sortAudioCodecsOpusFirst
|
|
5
|
+
} from "../media/codec-preference";
|
|
6
|
+
describe("sortVideoCodecsH264First", () => {
|
|
7
|
+
it("places H264 codecs before others", () => {
|
|
8
|
+
const codecs = [
|
|
9
|
+
{ mimeType: "video/VP8", clockRate: 9e4, channels: void 0, sdpFmtpLine: void 0 },
|
|
10
|
+
{ mimeType: "video/H264", clockRate: 9e4, channels: void 0, sdpFmtpLine: "level-asymmetry-allowed=1;packetization-mode=0" },
|
|
11
|
+
{ mimeType: "video/VP9", clockRate: 9e4, channels: void 0, sdpFmtpLine: void 0 },
|
|
12
|
+
{ mimeType: "video/H264", clockRate: 9e4, channels: void 0, sdpFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1" }
|
|
13
|
+
];
|
|
14
|
+
const sorted = sortVideoCodecsH264First(codecs);
|
|
15
|
+
expect(sorted[0].mimeType).toBe("video/H264");
|
|
16
|
+
expect(sorted[0].sdpFmtpLine).toContain("packetization-mode=1");
|
|
17
|
+
expect(sorted[1].mimeType).toBe("video/H264");
|
|
18
|
+
expect(sorted[2].mimeType).toBe("video/VP8");
|
|
19
|
+
expect(sorted[3].mimeType).toBe("video/VP9");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
describe("sortAudioCodecsOpusFirst", () => {
|
|
23
|
+
it("places Opus codecs before others", () => {
|
|
24
|
+
const codecs = [
|
|
25
|
+
{ mimeType: "audio/PCMU", clockRate: 8e3, channels: 1, sdpFmtpLine: void 0 },
|
|
26
|
+
{ mimeType: "audio/opus", clockRate: 48e3, channels: 2, sdpFmtpLine: "minptime=10;useinbandfec=1" },
|
|
27
|
+
{ mimeType: "audio/PCMA", clockRate: 8e3, channels: 1, sdpFmtpLine: void 0 }
|
|
28
|
+
];
|
|
29
|
+
const sorted = sortAudioCodecsOpusFirst(codecs);
|
|
30
|
+
expect(sorted[0].mimeType).toBe("audio/opus");
|
|
31
|
+
expect(sorted[1].mimeType).toBe("audio/PCMU");
|
|
32
|
+
expect(sorted[2].mimeType).toBe("audio/PCMA");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
//# sourceMappingURL=codec-preference.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"mappings":"AAAA,SAAS,UAAU,QAAQ,UAAU;AACrC;AAAA,EACE;AAAA,EACA;AAAA,OAGK;AAEP,SAAS,4BAA4B,MAAM;AACzC,KAAG,oCAAoC,MAAM;AAC3C,UAAM,SAAS;AAAA,MACb,EAAE,UAAU,aAAa,WAAW,KAAO,UAAU,QAAW,aAAa,OAAU;AAAA,MACvF,EAAE,UAAU,cAAc,WAAW,KAAO,UAAU,QAAW,aAAa,iDAAiD;AAAA,MAC/H,EAAE,UAAU,aAAa,WAAW,KAAO,UAAU,QAAW,aAAa,OAAU;AAAA,MACvF,EAAE,UAAU,cAAc,WAAW,KAAO,UAAU,QAAW,aAAa,iDAAiD;AAAA,IACjI;AAEA,UAAM,SAAS,yBAAyB,MAAM;AAE9C,WAAO,OAAO,CAAC,EAAE,QAAQ,EAAE,KAAK,YAAY;AAC5C,WAAO,OAAO,CAAC,EAAE,WAAW,EAAE,UAAU,sBAAsB;AAC9D,WAAO,OAAO,CAAC,EAAE,QAAQ,EAAE,KAAK,YAAY;AAC5C,WAAO,OAAO,CAAC,EAAE,QAAQ,EAAE,KAAK,WAAW;AAC3C,WAAO,OAAO,CAAC,EAAE,QAAQ,EAAE,KAAK,WAAW;AAAA,EAC7C,CAAC;AACH,CAAC;AAED,SAAS,4BAA4B,MAAM;AACzC,KAAG,oCAAoC,MAAM;AAC3C,UAAM,SAAS;AAAA,MACb,EAAE,UAAU,cAAc,WAAW,KAAM,UAAU,GAAG,aAAa,OAAU;AAAA,MAC/E,EAAE,UAAU,cAAc,WAAW,MAAO,UAAU,GAAG,aAAa,6BAA6B;AAAA,MACnG,EAAE,UAAU,cAAc,WAAW,KAAM,UAAU,GAAG,aAAa,OAAU;AAAA,IACjF;AAEA,UAAM,SAAS,yBAAyB,MAAM;AAE9C,WAAO,OAAO,CAAC,EAAE,QAAQ,EAAE,KAAK,YAAY;AAC5C,WAAO,OAAO,CAAC,EAAE,QAAQ,EAAE,KAAK,YAAY;AAC5C,WAAO,OAAO,CAAC,EAAE,QAAQ,EAAE,KAAK,YAAY;AAAA,EAC9C,CAAC;AACH,CAAC","names":[],"ignoreList":[],"sources":["../../../src/__test__/codec-preference.test.ts"],"sourcesContent":["import { describe, expect, it } from 'vitest';\r\nimport {\r\n sortVideoCodecsH264First,\r\n sortAudioCodecsOpusFirst,\r\n type VideoRtpCodec,\r\n type AudioRtpCodec,\r\n} from '../media/codec-preference';\r\n\r\ndescribe('sortVideoCodecsH264First', () => {\r\n it('places H264 codecs before others', () => {\r\n const codecs = [\r\n { mimeType: 'video/VP8', clockRate: 90000, channels: undefined, sdpFmtpLine: undefined },\r\n { mimeType: 'video/H264', clockRate: 90000, channels: undefined, sdpFmtpLine: 'level-asymmetry-allowed=1;packetization-mode=0' },\r\n { mimeType: 'video/VP9', clockRate: 90000, channels: undefined, sdpFmtpLine: undefined },\r\n { mimeType: 'video/H264', clockRate: 90000, channels: undefined, sdpFmtpLine: 'level-asymmetry-allowed=1;packetization-mode=1' },\r\n ] as VideoRtpCodec[];\r\n\r\n const sorted = sortVideoCodecsH264First(codecs);\r\n\r\n expect(sorted[0].mimeType).toBe('video/H264');\r\n expect(sorted[0].sdpFmtpLine).toContain('packetization-mode=1');\r\n expect(sorted[1].mimeType).toBe('video/H264');\r\n expect(sorted[2].mimeType).toBe('video/VP8');\r\n expect(sorted[3].mimeType).toBe('video/VP9');\r\n });\r\n});\r\n\r\ndescribe('sortAudioCodecsOpusFirst', () => {\r\n it('places Opus codecs before others', () => {\r\n const codecs = [\r\n { mimeType: 'audio/PCMU', clockRate: 8000, channels: 1, sdpFmtpLine: undefined },\r\n { mimeType: 'audio/opus', clockRate: 48000, channels: 2, sdpFmtpLine: 'minptime=10;useinbandfec=1' },\r\n { mimeType: 'audio/PCMA', clockRate: 8000, channels: 1, sdpFmtpLine: undefined },\r\n ] as AudioRtpCodec[];\r\n\r\n const sorted = sortAudioCodecsOpusFirst(codecs);\r\n\r\n expect(sorted[0].mimeType).toBe('audio/opus');\r\n expect(sorted[1].mimeType).toBe('audio/PCMU');\r\n expect(sorted[2].mimeType).toBe('audio/PCMA');\r\n });\r\n});\r\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJtYXBwaW5ncyI6IiIsIm5hbWVzIjpbXSwiaWdub3JlTGlzdCI6W10sInNvdXJjZXMiOltdLCJzb3VyY2VzQ29udGVudCI6W119"]}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { DataManager } from "../data/data-manager";
|
|
3
|
+
function createChannel(label = "file-test") {
|
|
4
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
5
|
+
return {
|
|
6
|
+
label,
|
|
7
|
+
readyState: "open",
|
|
8
|
+
bufferedAmount: 0,
|
|
9
|
+
bufferedAmountLowThreshold: 0,
|
|
10
|
+
send: vi.fn(),
|
|
11
|
+
close: vi.fn(),
|
|
12
|
+
addEventListener: vi.fn((type, fn) => {
|
|
13
|
+
if (!listeners.has(type))
|
|
14
|
+
listeners.set(type, /* @__PURE__ */ new Set());
|
|
15
|
+
listeners.get(type).add(fn);
|
|
16
|
+
}),
|
|
17
|
+
removeEventListener: vi.fn((type, fn) => {
|
|
18
|
+
listeners.get(type)?.delete(fn);
|
|
19
|
+
}),
|
|
20
|
+
onmessage: null,
|
|
21
|
+
onopen: null,
|
|
22
|
+
onclose: null,
|
|
23
|
+
onerror: null
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function createPc(channel) {
|
|
27
|
+
return {
|
|
28
|
+
createDataChannel: vi.fn(() => channel)
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
describe("DataManager", () => {
|
|
32
|
+
it("receives a complete file", async () => {
|
|
33
|
+
const channel = createChannel();
|
|
34
|
+
const manager = new DataManager(createPc(channel));
|
|
35
|
+
const handler = vi.fn();
|
|
36
|
+
manager.onFile(handler);
|
|
37
|
+
manager.createChannel("file-test");
|
|
38
|
+
channel.onmessage?.({ data: JSON.stringify({
|
|
39
|
+
type: "file-meta",
|
|
40
|
+
name: "hello.txt",
|
|
41
|
+
size: 5,
|
|
42
|
+
chunks: 1,
|
|
43
|
+
mimeType: "text/plain"
|
|
44
|
+
}) });
|
|
45
|
+
channel.onmessage?.({ data: new Blob(["hello"]) });
|
|
46
|
+
channel.onmessage?.({ data: JSON.stringify({ type: "file-complete", name: "hello.txt" }) });
|
|
47
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
48
|
+
const [meta, blob] = handler.mock.calls[0];
|
|
49
|
+
expect(meta).toEqual({ name: "hello.txt", size: 5, type: "text/plain" });
|
|
50
|
+
expect(await blob.text()).toBe("hello");
|
|
51
|
+
});
|
|
52
|
+
it("rejects sendFile when file exceeds maxFileSize", async () => {
|
|
53
|
+
const channel = createChannel();
|
|
54
|
+
const manager = new DataManager(createPc(channel));
|
|
55
|
+
const file = new File(["too large"], "large.txt");
|
|
56
|
+
await expect(manager.sendFile(file, { maxFileSize: 1 })).rejects.toThrow(/maxFileSize/);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
//# sourceMappingURL=data-manager.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"mappings":"AAAA,SAAS,UAAU,QAAQ,IAAI,UAAU;AACzC,SAAS,mBAAmB;AAE5B,SAAS,cAAc,QAAQ,aAA6B;AAC1D,QAAM,YAAY,oBAAI,IAA2C;AACjE,SAAO;AAAA,IACL;AAAA,IACA,YAAY;AAAA,IACZ,gBAAgB;AAAA,IAChB,4BAA4B;AAAA,IAC5B,MAAM,GAAG,GAAG;AAAA,IACZ,OAAO,GAAG,GAAG;AAAA,IACb,kBAAkB,GAAG,GAAG,CAAC,MAAc,OAAiC;AACtE,UAAI,CAAC,UAAU,IAAI,IAAI;AAAG,kBAAU,IAAI,MAAM,oBAAI,IAAI,CAAC;AACvD,gBAAU,IAAI,IAAI,EAAG,IAAI,EAAE;AAAA,IAC7B,CAAC;AAAA,IACD,qBAAqB,GAAG,GAAG,CAAC,MAAc,OAAiC;AACzE,gBAAU,IAAI,IAAI,GAAG,OAAO,EAAE;AAAA,IAChC,CAAC;AAAA,IACD,WAAW;AAAA,IACX,QAAQ;AAAA,IACR,SAAS;AAAA,IACT,SAAS;AAAA,EACX;AACF;AAEA,SAAS,SAAS,SAA4C;AAC5D,SAAO;AAAA,IACL,mBAAmB,GAAG,GAAG,MAAM,OAAO;AAAA,EACxC;AACF;AAEA,SAAS,eAAe,MAAM;AAC5B,KAAG,4BAA4B,YAAY;AACzC,UAAM,UAAU,cAAc;AAC9B,UAAM,UAAU,IAAI,YAAY,SAAS,OAAO,CAAC;AACjD,UAAM,UAAU,GAAG,GAAG;AAEtB,YAAQ,OAAO,OAAO;AACtB,YAAQ,cAAc,WAAW;AAEjC,YAAQ,YAAY,EAAE,MAAM,KAAK,UAAU;AAAA,MACzC,MAAM;AAAA,MACN,MAAM;AAAA,MACN,MAAM;AAAA,MACN,QAAQ;AAAA,MACR,UAAU;AAAA,IACZ,CAAC,EAAE,CAAiB;AACpB,YAAQ,YAAY,EAAE,MAAM,IAAI,KAAK,CAAC,OAAO,CAAC,EAAE,CAAiB;AACjE,YAAQ,YAAY,EAAE,MAAM,KAAK,UAAU,EAAE,MAAM,iBAAiB,MAAM,YAAY,CAAC,EAAE,CAAiB;AAE1G,WAAO,OAAO,EAAE,sBAAsB,CAAC;AACvC,UAAM,CAAC,MAAM,IAAI,IAAI,QAAQ,KAAK,MAAM,CAAC;AACzC,WAAO,IAAI,EAAE,QAAQ,EAAE,MAAM,aAAa,MAAM,GAAG,MAAM,aAAa,CAAC;AACvE,WAAO,MAAM,KAAK,KAAK,CAAC,EAAE,KAAK,OAAO;AAAA,EACxC,CAAC;AAED,KAAG,kDAAkD,YAAY;AAC/D,UAAM,UAAU,cAAc;AAC9B,UAAM,UAAU,IAAI,YAAY,SAAS,OAAO,CAAC;AACjD,UAAM,OAAO,IAAI,KAAK,CAAC,WAAW,GAAG,WAAW;AAEhD,UAAM,OAAO,QAAQ,SAAS,MAAM,EAAE,aAAa,EAAE,CAAC,CAAC,EAAE,QAAQ,QAAQ,aAAa;AAAA,EACxF,CAAC;AACH,CAAC","names":[],"ignoreList":[],"sources":["../../../src/__test__/data-manager.test.ts"],"sourcesContent":["import { describe, expect, it, vi } from 'vitest';\r\nimport { DataManager } from '../data/data-manager';\r\n\r\nfunction createChannel(label = 'file-test'): RTCDataChannel {\r\n const listeners = new Map<string, Set<(...args: any[]) => void>>();\r\n return {\r\n label,\r\n readyState: 'open',\r\n bufferedAmount: 0,\r\n bufferedAmountLowThreshold: 0,\r\n send: vi.fn(),\r\n close: vi.fn(),\r\n addEventListener: vi.fn((type: string, fn: (...args: any[]) => void) => {\r\n if (!listeners.has(type)) listeners.set(type, new Set());\r\n listeners.get(type)!.add(fn);\r\n }),\r\n removeEventListener: vi.fn((type: string, fn: (...args: any[]) => void) => {\r\n listeners.get(type)?.delete(fn);\r\n }),\r\n onmessage: null,\r\n onopen: null,\r\n onclose: null,\r\n onerror: null,\r\n } as unknown as RTCDataChannel;\r\n}\r\n\r\nfunction createPc(channel: RTCDataChannel): RTCPeerConnection {\r\n return {\r\n createDataChannel: vi.fn(() => channel),\r\n } as unknown as RTCPeerConnection;\r\n}\r\n\r\ndescribe('DataManager', () => {\r\n it('receives a complete file', async () => {\r\n const channel = createChannel();\r\n const manager = new DataManager(createPc(channel));\r\n const handler = vi.fn();\r\n\r\n manager.onFile(handler);\r\n manager.createChannel('file-test');\r\n\r\n channel.onmessage?.({ data: JSON.stringify({\r\n type: 'file-meta',\r\n name: 'hello.txt',\r\n size: 5,\r\n chunks: 1,\r\n mimeType: 'text/plain',\r\n }) } as MessageEvent);\r\n channel.onmessage?.({ data: new Blob(['hello']) } as MessageEvent);\r\n channel.onmessage?.({ data: JSON.stringify({ type: 'file-complete', name: 'hello.txt' }) } as MessageEvent);\r\n\r\n expect(handler).toHaveBeenCalledTimes(1);\r\n const [meta, blob] = handler.mock.calls[0];\r\n expect(meta).toEqual({ name: 'hello.txt', size: 5, type: 'text/plain' });\r\n expect(await blob.text()).toBe('hello');\r\n });\r\n\r\n it('rejects sendFile when file exceeds maxFileSize', async () => {\r\n const channel = createChannel();\r\n const manager = new DataManager(createPc(channel));\r\n const file = new File(['too large'], 'large.txt');\r\n\r\n await expect(manager.sendFile(file, { maxFileSize: 1 })).rejects.toThrow(/maxFileSize/);\r\n });\r\n});\r\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJtYXBwaW5ncyI6IiIsIm5hbWVzIjpbXSwiaWdub3JlTGlzdCI6W10sInNvdXJjZXMiOltdLCJzb3VyY2VzQ29udGVudCI6W119"]}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { ConnectionStateMachine } from "../fsm";
|
|
3
|
+
describe("ConnectionStateMachine", () => {
|
|
4
|
+
it("starts in idle", () => {
|
|
5
|
+
const fsm = new ConnectionStateMachine();
|
|
6
|
+
expect(fsm.state).toBe("idle");
|
|
7
|
+
});
|
|
8
|
+
it("allows idle → signaling → connecting → connected", () => {
|
|
9
|
+
const fsm = new ConnectionStateMachine();
|
|
10
|
+
fsm.transition("signaling");
|
|
11
|
+
fsm.transition("connecting");
|
|
12
|
+
fsm.transition("connected");
|
|
13
|
+
expect(fsm.state).toBe("connected");
|
|
14
|
+
});
|
|
15
|
+
it("throws on illegal transition idle → connected", () => {
|
|
16
|
+
const fsm = new ConnectionStateMachine();
|
|
17
|
+
expect(() => fsm.transition("connected")).toThrow(/Invalid state transition/);
|
|
18
|
+
});
|
|
19
|
+
it("force sets state without validation", () => {
|
|
20
|
+
const fsm = new ConnectionStateMachine();
|
|
21
|
+
fsm.force("connected");
|
|
22
|
+
expect(fsm.state).toBe("connected");
|
|
23
|
+
});
|
|
24
|
+
it("failed can return to idle", () => {
|
|
25
|
+
const fsm = new ConnectionStateMachine();
|
|
26
|
+
fsm.transition("signaling");
|
|
27
|
+
fsm.transition("failed");
|
|
28
|
+
fsm.transition("idle");
|
|
29
|
+
expect(fsm.state).toBe("idle");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
//# sourceMappingURL=fsm.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"mappings":"AAAA,SAAS,UAAU,IAAI,cAAc;AACrC,SAAS,8BAA8B;AAEvC,SAAS,0BAA0B,MAAM;AACvC,KAAG,kBAAkB,MAAM;AACzB,UAAM,MAAM,IAAI,uBAAuB;AACvC,WAAO,IAAI,KAAK,EAAE,KAAK,MAAM;AAAA,EAC/B,CAAC;AAED,KAAG,oDAAoD,MAAM;AAC3D,UAAM,MAAM,IAAI,uBAAuB;AACvC,QAAI,WAAW,WAAW;AAC1B,QAAI,WAAW,YAAY;AAC3B,QAAI,WAAW,WAAW;AAC1B,WAAO,IAAI,KAAK,EAAE,KAAK,WAAW;AAAA,EACpC,CAAC;AAED,KAAG,iDAAiD,MAAM;AACxD,UAAM,MAAM,IAAI,uBAAuB;AACvC,WAAO,MAAM,IAAI,WAAW,WAAW,CAAC,EAAE,QAAQ,0BAA0B;AAAA,EAC9E,CAAC;AAED,KAAG,uCAAuC,MAAM;AAC9C,UAAM,MAAM,IAAI,uBAAuB;AACvC,QAAI,MAAM,WAAW;AACrB,WAAO,IAAI,KAAK,EAAE,KAAK,WAAW;AAAA,EACpC,CAAC;AAED,KAAG,6BAA6B,MAAM;AACpC,UAAM,MAAM,IAAI,uBAAuB;AACvC,QAAI,WAAW,WAAW;AAC1B,QAAI,WAAW,QAAQ;AACvB,QAAI,WAAW,MAAM;AACrB,WAAO,IAAI,KAAK,EAAE,KAAK,MAAM;AAAA,EAC/B,CAAC;AACH,CAAC","names":[],"ignoreList":[],"sources":["../../../src/__test__/fsm.test.ts"],"sourcesContent":["import { describe, it, expect } from 'vitest';\r\nimport { ConnectionStateMachine } from '../fsm';\r\n\r\ndescribe('ConnectionStateMachine', () => {\r\n it('starts in idle', () => {\r\n const fsm = new ConnectionStateMachine();\r\n expect(fsm.state).toBe('idle');\r\n });\r\n\r\n it('allows idle → signaling → connecting → connected', () => {\r\n const fsm = new ConnectionStateMachine();\r\n fsm.transition('signaling');\r\n fsm.transition('connecting');\r\n fsm.transition('connected');\r\n expect(fsm.state).toBe('connected');\r\n });\r\n\r\n it('throws on illegal transition idle → connected', () => {\r\n const fsm = new ConnectionStateMachine();\r\n expect(() => fsm.transition('connected')).toThrow(/Invalid state transition/);\r\n });\r\n\r\n it('force sets state without validation', () => {\r\n const fsm = new ConnectionStateMachine();\r\n fsm.force('connected');\r\n expect(fsm.state).toBe('connected');\r\n });\r\n\r\n it('failed can return to idle', () => {\r\n const fsm = new ConnectionStateMachine();\r\n fsm.transition('signaling');\r\n fsm.transition('failed');\r\n fsm.transition('idle');\r\n expect(fsm.state).toBe('idle');\r\n });\r\n});\r\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJtYXBwaW5ncyI6IiIsIm5hbWVzIjpbXSwiaWdub3JlTGlzdCI6W10sInNvdXJjZXMiOltdLCJzb3VyY2VzQ29udGVudCI6W119"]}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { MediaManager } from "../media/media-manager";
|
|
3
|
+
describe("MediaManager", () => {
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
vi.unstubAllGlobals();
|
|
6
|
+
});
|
|
7
|
+
it("maps display options to getDisplayMedia constraints", async () => {
|
|
8
|
+
const stream = {};
|
|
9
|
+
const getDisplayMedia = vi.fn().mockResolvedValue(stream);
|
|
10
|
+
vi.stubGlobal("navigator", {
|
|
11
|
+
mediaDevices: {
|
|
12
|
+
getDisplayMedia,
|
|
13
|
+
getUserMedia: vi.fn()
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
await MediaManager.getDisplayMedia({
|
|
17
|
+
width: 1920,
|
|
18
|
+
height: 1080,
|
|
19
|
+
frameRate: 30,
|
|
20
|
+
audio: true
|
|
21
|
+
});
|
|
22
|
+
expect(getDisplayMedia).toHaveBeenCalledWith({
|
|
23
|
+
video: { width: 1920, height: 1080, frameRate: 30 },
|
|
24
|
+
audio: true
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
it("instance getDisplayMedia delegates to static implementation", async () => {
|
|
28
|
+
const stream = {};
|
|
29
|
+
const getDisplayMedia = vi.fn().mockResolvedValue(stream);
|
|
30
|
+
vi.stubGlobal("navigator", {
|
|
31
|
+
mediaDevices: {
|
|
32
|
+
getDisplayMedia,
|
|
33
|
+
getUserMedia: vi.fn()
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
const media = new MediaManager();
|
|
37
|
+
await expect(media.getDisplayMedia({ audio: false })).resolves.toBe(stream);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
//# sourceMappingURL=media-manager.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"mappings":"AAAA,SAAS,WAAW,UAAU,QAAQ,IAAI,UAAU;AACpD,SAAS,oBAAoB;AAE7B,SAAS,gBAAgB,MAAM;AAC7B,YAAU,MAAM;AACd,OAAG,iBAAiB;AAAA,EACtB,CAAC;AAED,KAAG,uDAAuD,YAAY;AACpE,UAAM,SAAS,CAAC;AAChB,UAAM,kBAAkB,GAAG,GAAG,EAAE,kBAAkB,MAAM;AACxD,OAAG,WAAW,aAAa;AAAA,MACzB,cAAc;AAAA,QACZ;AAAA,QACA,cAAc,GAAG,GAAG;AAAA,MACtB;AAAA,IACF,CAAC;AAED,UAAM,aAAa,gBAAgB;AAAA,MACjC,OAAO;AAAA,MACP,QAAQ;AAAA,MACR,WAAW;AAAA,MACX,OAAO;AAAA,IACT,CAAC;AAED,WAAO,eAAe,EAAE,qBAAqB;AAAA,MAC3C,OAAO,EAAE,OAAO,MAAM,QAAQ,MAAM,WAAW,GAAG;AAAA,MAClD,OAAO;AAAA,IACT,CAAC;AAAA,EACH,CAAC;AAED,KAAG,+DAA+D,YAAY;AAC5E,UAAM,SAAS,CAAC;AAChB,UAAM,kBAAkB,GAAG,GAAG,EAAE,kBAAkB,MAAM;AACxD,OAAG,WAAW,aAAa;AAAA,MACzB,cAAc;AAAA,QACZ;AAAA,QACA,cAAc,GAAG,GAAG;AAAA,MACtB;AAAA,IACF,CAAC;AAED,UAAM,QAAQ,IAAI,aAAa;AAC/B,UAAM,OAAO,MAAM,gBAAgB,EAAE,OAAO,MAAM,CAAC,CAAC,EAAE,SAAS,KAAK,MAAM;AAAA,EAC5E,CAAC;AACH,CAAC","names":[],"ignoreList":[],"sources":["../../../src/__test__/media-manager.test.ts"],"sourcesContent":["import { afterEach, describe, expect, it, vi } from 'vitest';\r\nimport { MediaManager } from '../media/media-manager';\r\n\r\ndescribe('MediaManager', () => {\r\n afterEach(() => {\r\n vi.unstubAllGlobals();\r\n });\r\n\r\n it('maps display options to getDisplayMedia constraints', async () => {\r\n const stream = {} as MediaStream;\r\n const getDisplayMedia = vi.fn().mockResolvedValue(stream);\r\n vi.stubGlobal('navigator', {\r\n mediaDevices: {\r\n getDisplayMedia,\r\n getUserMedia: vi.fn(),\r\n },\r\n });\r\n\r\n await MediaManager.getDisplayMedia({\r\n width: 1920,\r\n height: 1080,\r\n frameRate: 30,\r\n audio: true,\r\n });\r\n\r\n expect(getDisplayMedia).toHaveBeenCalledWith({\r\n video: { width: 1920, height: 1080, frameRate: 30 },\r\n audio: true,\r\n });\r\n });\r\n\r\n it('instance getDisplayMedia delegates to static implementation', async () => {\r\n const stream = {} as MediaStream;\r\n const getDisplayMedia = vi.fn().mockResolvedValue(stream);\r\n vi.stubGlobal('navigator', {\r\n mediaDevices: {\r\n getDisplayMedia,\r\n getUserMedia: vi.fn(),\r\n },\r\n });\r\n\r\n const media = new MediaManager();\r\n await expect(media.getDisplayMedia({ audio: false })).resolves.toBe(stream);\r\n });\r\n});\r\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJtYXBwaW5ncyI6IiIsIm5hbWVzIjpbXSwiaWdub3JlTGlzdCI6W10sInNvdXJjZXMiOltdLCJzb3VyY2VzQ29udGVudCI6W119"]}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { ConnectionStateMachine } from "../fsm";
|
|
3
|
+
import { SignalManager } from "../signal/signal-manager";
|
|
4
|
+
function createMockPc(overrides = {}) {
|
|
5
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
6
|
+
return {
|
|
7
|
+
signalingState: "stable",
|
|
8
|
+
localDescription: null,
|
|
9
|
+
remoteDescription: null,
|
|
10
|
+
iceConnectionState: "new",
|
|
11
|
+
addEventListener: vi.fn((type, fn) => {
|
|
12
|
+
if (!listeners.has(type))
|
|
13
|
+
listeners.set(type, /* @__PURE__ */ new Set());
|
|
14
|
+
listeners.get(type).add(fn);
|
|
15
|
+
}),
|
|
16
|
+
removeEventListener: vi.fn((type, fn) => {
|
|
17
|
+
listeners.get(type)?.delete(fn);
|
|
18
|
+
}),
|
|
19
|
+
createOffer: vi.fn().mockResolvedValue({ type: "offer", sdp: "mock-offer" }),
|
|
20
|
+
createAnswer: vi.fn().mockResolvedValue({ type: "answer", sdp: "mock-answer" }),
|
|
21
|
+
setLocalDescription: vi.fn().mockImplementation(async (desc) => {
|
|
22
|
+
mock.localDescription = desc;
|
|
23
|
+
}),
|
|
24
|
+
setRemoteDescription: vi.fn().mockImplementation(async (desc) => {
|
|
25
|
+
mock.remoteDescription = desc;
|
|
26
|
+
}),
|
|
27
|
+
addIceCandidate: vi.fn().mockResolvedValue(void 0),
|
|
28
|
+
...overrides
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
let mock;
|
|
32
|
+
function createSignalChannel() {
|
|
33
|
+
const handlers = [];
|
|
34
|
+
return {
|
|
35
|
+
handlers,
|
|
36
|
+
send: vi.fn().mockResolvedValue(void 0),
|
|
37
|
+
onMessage(handler) {
|
|
38
|
+
handlers.push(handler);
|
|
39
|
+
return () => {
|
|
40
|
+
const idx = handlers.indexOf(handler);
|
|
41
|
+
if (idx >= 0)
|
|
42
|
+
handlers.splice(idx, 1);
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
describe("SignalManager", () => {
|
|
48
|
+
let fsm;
|
|
49
|
+
let channel;
|
|
50
|
+
beforeEach(() => {
|
|
51
|
+
mock = createMockPc();
|
|
52
|
+
fsm = new ConnectionStateMachine();
|
|
53
|
+
channel = createSignalChannel();
|
|
54
|
+
});
|
|
55
|
+
it("createOffer sends offer and enters signaling", async () => {
|
|
56
|
+
const sm = new SignalManager(channel, mock, fsm, true);
|
|
57
|
+
await sm.createOffer();
|
|
58
|
+
expect(channel.send).toHaveBeenCalledWith(
|
|
59
|
+
expect.objectContaining({ type: "offer", sdp: "mock-offer" })
|
|
60
|
+
);
|
|
61
|
+
expect(fsm.state).toBe("signaling");
|
|
62
|
+
});
|
|
63
|
+
it("createOffer does not repeat signaling transition", async () => {
|
|
64
|
+
fsm.transition("signaling");
|
|
65
|
+
const sm = new SignalManager(channel, mock, fsm, true);
|
|
66
|
+
await expect(sm.createOffer()).resolves.toBeUndefined();
|
|
67
|
+
expect(fsm.state).toBe("signaling");
|
|
68
|
+
});
|
|
69
|
+
it("handleOffer sends answer", async () => {
|
|
70
|
+
const sm = new SignalManager(channel, mock, fsm, true);
|
|
71
|
+
await sm.handleOffer("remote-offer-sdp");
|
|
72
|
+
expect(mock.setRemoteDescription).toHaveBeenCalled();
|
|
73
|
+
expect(mock.createAnswer).toHaveBeenCalled();
|
|
74
|
+
expect(channel.send).toHaveBeenCalledWith(
|
|
75
|
+
expect.objectContaining({ type: "answer" })
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
it("setPeerConnection rebinds without throwing", () => {
|
|
79
|
+
const sm = new SignalManager(channel, mock, fsm, true);
|
|
80
|
+
const newPc = createMockPc();
|
|
81
|
+
expect(() => sm.setPeerConnection(newPc)).not.toThrow();
|
|
82
|
+
});
|
|
83
|
+
it("impolite peer ignores colliding offer", async () => {
|
|
84
|
+
mock = createMockPc({ signalingState: "have-local-offer" });
|
|
85
|
+
const sm = new SignalManager(channel, mock, fsm, false);
|
|
86
|
+
sm.makingOffer = true;
|
|
87
|
+
await sm.handleOffer("colliding-offer");
|
|
88
|
+
expect(mock.setRemoteDescription).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
it("polite peer rolls back colliding offer", async () => {
|
|
91
|
+
mock = createMockPc({ signalingState: "have-local-offer" });
|
|
92
|
+
const sm = new SignalManager(channel, mock, fsm, true);
|
|
93
|
+
sm.makingOffer = true;
|
|
94
|
+
await sm.handleOffer("colliding-offer");
|
|
95
|
+
expect(mock.setLocalDescription).toHaveBeenCalledWith({ type: "rollback" });
|
|
96
|
+
expect(mock.setRemoteDescription).toHaveBeenCalledWith({ type: "offer", sdp: "colliding-offer" });
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
//# sourceMappingURL=signal-manager.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"mappings":"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","names":[],"ignoreList":[],"sources":["../../../src/__test__/signal-manager.test.ts"],"sourcesContent":["import { describe, it, expect, vi, beforeEach } from 'vitest';\r\nimport { ConnectionStateMachine } from '../fsm';\r\nimport { SignalManager } from '../signal/signal-manager';\r\nimport type { SignalChannel } from '../signal/types';\r\n\r\nfunction createMockPc(overrides: Partial<RTCPeerConnection> = {}): RTCPeerConnection {\r\n const listeners = new Map<string, Set<EventListener>>();\r\n return {\r\n signalingState: 'stable',\r\n localDescription: null,\r\n remoteDescription: null,\r\n iceConnectionState: 'new',\r\n addEventListener: vi.fn((type: string, fn: EventListener) => {\r\n if (!listeners.has(type)) listeners.set(type, new Set());\r\n listeners.get(type)!.add(fn);\r\n }),\r\n removeEventListener: vi.fn((type: string, fn: EventListener) => {\r\n listeners.get(type)?.delete(fn);\r\n }),\r\n createOffer: vi.fn().mockResolvedValue({ type: 'offer', sdp: 'mock-offer' }),\r\n createAnswer: vi.fn().mockResolvedValue({ type: 'answer', sdp: 'mock-answer' }),\r\n setLocalDescription: vi.fn().mockImplementation(async (desc) => {\r\n (mock as any).localDescription = desc;\r\n }),\r\n setRemoteDescription: vi.fn().mockImplementation(async (desc) => {\r\n (mock as any).remoteDescription = desc;\r\n }),\r\n addIceCandidate: vi.fn().mockResolvedValue(undefined),\r\n ...overrides,\r\n } as unknown as RTCPeerConnection;\r\n}\r\n\r\nlet mock: RTCPeerConnection;\r\n\r\nfunction createSignalChannel(): SignalChannel & { handlers: Array<(msg: unknown) => void> } {\r\n const handlers: Array<(msg: unknown) => void> = [];\r\n return {\r\n handlers,\r\n send: vi.fn().mockResolvedValue(undefined),\r\n onMessage(handler: (msg: unknown) => void) {\r\n handlers.push(handler);\r\n return () => {\r\n const idx = handlers.indexOf(handler);\r\n if (idx >= 0) handlers.splice(idx, 1);\r\n };\r\n },\r\n };\r\n}\r\n\r\ndescribe('SignalManager', () => {\r\n let fsm: ConnectionStateMachine;\r\n let channel: ReturnType<typeof createSignalChannel>;\r\n\r\n beforeEach(() => {\r\n mock = createMockPc();\r\n fsm = new ConnectionStateMachine();\r\n channel = createSignalChannel();\r\n });\r\n\r\n it('createOffer sends offer and enters signaling', async () => {\r\n const sm = new SignalManager(channel, mock, fsm, true);\r\n await sm.createOffer();\r\n\r\n expect(channel.send).toHaveBeenCalledWith(\r\n expect.objectContaining({ type: 'offer', sdp: 'mock-offer' }),\r\n );\r\n expect(fsm.state).toBe('signaling');\r\n });\r\n\r\n it('createOffer does not repeat signaling transition', async () => {\r\n fsm.transition('signaling');\r\n const sm = new SignalManager(channel, mock, fsm, true);\r\n\r\n await expect(sm.createOffer()).resolves.toBeUndefined();\r\n expect(fsm.state).toBe('signaling');\r\n });\r\n\r\n it('handleOffer sends answer', async () => {\r\n const sm = new SignalManager(channel, mock, fsm, true);\r\n await sm.handleOffer('remote-offer-sdp');\r\n\r\n expect(mock.setRemoteDescription).toHaveBeenCalled();\r\n expect(mock.createAnswer).toHaveBeenCalled();\r\n expect(channel.send).toHaveBeenCalledWith(\r\n expect.objectContaining({ type: 'answer' }),\r\n );\r\n });\r\n\r\n it('setPeerConnection rebinds without throwing', () => {\r\n const sm = new SignalManager(channel, mock, fsm, true);\r\n const newPc = createMockPc();\r\n expect(() => sm.setPeerConnection(newPc)).not.toThrow();\r\n });\r\n\r\n it('impolite peer ignores colliding offer', async () => {\r\n mock = createMockPc({ signalingState: 'have-local-offer' as RTCSignalingState });\r\n const sm = new SignalManager(channel, mock, fsm, false);\r\n (sm as any).makingOffer = true;\r\n\r\n await sm.handleOffer('colliding-offer');\r\n\r\n expect(mock.setRemoteDescription).not.toHaveBeenCalled();\r\n });\r\n\r\n it('polite peer rolls back colliding offer', async () => {\r\n mock = createMockPc({ signalingState: 'have-local-offer' as RTCSignalingState });\r\n const sm = new SignalManager(channel, mock, fsm, true);\r\n (sm as any).makingOffer = true;\r\n\r\n await sm.handleOffer('colliding-offer');\r\n\r\n expect(mock.setLocalDescription).toHaveBeenCalledWith({ type: 'rollback' });\r\n expect(mock.setRemoteDescription).toHaveBeenCalledWith({ type: 'offer', sdp: 'colliding-offer' });\r\n });\r\n});\r\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJtYXBwaW5ncyI6IiIsIm5hbWVzIjpbXSwiaWdub3JlTGlzdCI6W10sInNvdXJjZXMiOltdLCJzb3VyY2VzQ29udGVudCI6W119"]}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { WetRTC } from "../wetrtc";
|
|
3
|
+
const pcs = [];
|
|
4
|
+
class MockPeerConnection {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.iceConnectionState = "new";
|
|
7
|
+
this.connectionState = "new";
|
|
8
|
+
this.signalingState = "stable";
|
|
9
|
+
this.localDescription = null;
|
|
10
|
+
this.remoteDescription = null;
|
|
11
|
+
this.senders = [];
|
|
12
|
+
this.transceivers = [];
|
|
13
|
+
this.closed = false;
|
|
14
|
+
this.addEventListener = vi.fn();
|
|
15
|
+
this.removeEventListener = vi.fn();
|
|
16
|
+
this.createDataChannel = vi.fn(() => ({ close: vi.fn(), readyState: "open" }));
|
|
17
|
+
this.createOffer = vi.fn().mockResolvedValue({ type: "offer", sdp: "offer" });
|
|
18
|
+
this.createAnswer = vi.fn().mockResolvedValue({ type: "answer", sdp: "answer" });
|
|
19
|
+
this.setLocalDescription = vi.fn().mockImplementation(async (desc) => {
|
|
20
|
+
this.localDescription = desc;
|
|
21
|
+
});
|
|
22
|
+
this.setRemoteDescription = vi.fn().mockImplementation(async (desc) => {
|
|
23
|
+
this.remoteDescription = desc;
|
|
24
|
+
});
|
|
25
|
+
this.addIceCandidate = vi.fn().mockResolvedValue(void 0);
|
|
26
|
+
this.getStats = vi.fn().mockResolvedValue({ forEach: vi.fn() });
|
|
27
|
+
this.close = vi.fn(() => {
|
|
28
|
+
this.closed = true;
|
|
29
|
+
});
|
|
30
|
+
this.getSenders = vi.fn(() => this.senders);
|
|
31
|
+
this.getTransceivers = vi.fn(() => this.transceivers);
|
|
32
|
+
this.addTransceiver = vi.fn((kind, init) => {
|
|
33
|
+
const transceiver = {
|
|
34
|
+
direction: init?.direction ?? "sendrecv",
|
|
35
|
+
sender: {},
|
|
36
|
+
receiver: {},
|
|
37
|
+
mid: null,
|
|
38
|
+
currentDirection: null,
|
|
39
|
+
stop: vi.fn()
|
|
40
|
+
};
|
|
41
|
+
this.transceivers.push(transceiver);
|
|
42
|
+
return transceiver;
|
|
43
|
+
});
|
|
44
|
+
this.addTrack = vi.fn((track) => {
|
|
45
|
+
const sender = {
|
|
46
|
+
track,
|
|
47
|
+
replaceTrack: vi.fn().mockResolvedValue(void 0)
|
|
48
|
+
};
|
|
49
|
+
const transceiver = {
|
|
50
|
+
direction: "sendrecv",
|
|
51
|
+
sender,
|
|
52
|
+
receiver: {},
|
|
53
|
+
mid: null,
|
|
54
|
+
currentDirection: null,
|
|
55
|
+
stop: vi.fn()
|
|
56
|
+
};
|
|
57
|
+
this.senders.push(sender);
|
|
58
|
+
this.transceivers.push(transceiver);
|
|
59
|
+
return sender;
|
|
60
|
+
});
|
|
61
|
+
this.removeTrack = vi.fn();
|
|
62
|
+
pcs.push(this);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function signal() {
|
|
66
|
+
return {
|
|
67
|
+
send: vi.fn().mockResolvedValue(void 0),
|
|
68
|
+
onMessage: vi.fn(() => () => {
|
|
69
|
+
})
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function streamWithTrack(kind = "video") {
|
|
73
|
+
const track = {
|
|
74
|
+
id: `${kind}-1`,
|
|
75
|
+
kind,
|
|
76
|
+
stop: vi.fn(),
|
|
77
|
+
addEventListener: vi.fn()
|
|
78
|
+
};
|
|
79
|
+
const tracks = [track];
|
|
80
|
+
const stream = {
|
|
81
|
+
getTracks: () => tracks,
|
|
82
|
+
getVideoTracks: () => kind === "video" ? tracks : [],
|
|
83
|
+
addTrack: vi.fn((next) => {
|
|
84
|
+
if (!tracks.includes(next))
|
|
85
|
+
tracks.push(next);
|
|
86
|
+
}),
|
|
87
|
+
removeTrack: vi.fn((next) => {
|
|
88
|
+
const idx = tracks.indexOf(next);
|
|
89
|
+
if (idx >= 0)
|
|
90
|
+
tracks.splice(idx, 1);
|
|
91
|
+
})
|
|
92
|
+
};
|
|
93
|
+
return { stream, track };
|
|
94
|
+
}
|
|
95
|
+
describe("WetRTC lifecycle", () => {
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
pcs.length = 0;
|
|
98
|
+
vi.stubGlobal("RTCPeerConnection", MockPeerConnection);
|
|
99
|
+
});
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
vi.unstubAllGlobals();
|
|
102
|
+
});
|
|
103
|
+
it("disconnect rebinds peer connection and restores local tracks", async () => {
|
|
104
|
+
const channel = signal();
|
|
105
|
+
const rtc = new WetRTC({ signal: channel, reconnect: false });
|
|
106
|
+
const { stream, track } = streamWithTrack();
|
|
107
|
+
const firstPc = rtc.peerConnection;
|
|
108
|
+
rtc.addTrack(track, stream);
|
|
109
|
+
await rtc.disconnect();
|
|
110
|
+
expect(channel.send).toHaveBeenCalledWith({ type: "bye" });
|
|
111
|
+
const nextPc = rtc.peerConnection;
|
|
112
|
+
expect(nextPc).not.toBe(firstPc);
|
|
113
|
+
expect(firstPc.close).toHaveBeenCalled();
|
|
114
|
+
expect(nextPc.addTrack).toHaveBeenCalledWith(track, stream);
|
|
115
|
+
expect(rtc.state).toBe("idle");
|
|
116
|
+
});
|
|
117
|
+
it("recvonly connect creates recvonly transceivers", async () => {
|
|
118
|
+
const rtc = new WetRTC({ signal: signal(), direction: "recvonly", reconnect: false });
|
|
119
|
+
await rtc.connect();
|
|
120
|
+
const pc = rtc.peerConnection;
|
|
121
|
+
expect(pc.addTransceiver).toHaveBeenCalledWith("video", { direction: "recvonly" });
|
|
122
|
+
expect(pc.addTransceiver).toHaveBeenCalledWith("audio", { direction: "recvonly" });
|
|
123
|
+
});
|
|
124
|
+
it("recvonly initiator sends offer", async () => {
|
|
125
|
+
const ch = signal();
|
|
126
|
+
const rtc = new WetRTC({
|
|
127
|
+
signal: ch,
|
|
128
|
+
direction: "recvonly",
|
|
129
|
+
initiator: true,
|
|
130
|
+
reconnect: false
|
|
131
|
+
});
|
|
132
|
+
await rtc.connect();
|
|
133
|
+
expect(ch.send).toHaveBeenCalledWith(
|
|
134
|
+
expect.objectContaining({ type: "offer" })
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
it("sendonly passive does not send offer", async () => {
|
|
138
|
+
const ch = signal();
|
|
139
|
+
const rtc = new WetRTC({
|
|
140
|
+
signal: ch,
|
|
141
|
+
direction: "sendonly",
|
|
142
|
+
initiator: false,
|
|
143
|
+
reconnect: false
|
|
144
|
+
});
|
|
145
|
+
await rtc.connect();
|
|
146
|
+
expect(ch.send).not.toHaveBeenCalledWith(
|
|
147
|
+
expect.objectContaining({ type: "offer" })
|
|
148
|
+
);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
//# sourceMappingURL=wetrtc-lifecycle.test.js.map
|