test(webrtc): add M2 integration smoke test
End-to-end exercise of the M2 pipeline — subsystem hook, port allocation, two-track forwarding, WHEP handshake — without spinning up a full Core HTTP server: - Fire onProcessStart directly to get the two RTP legs back - Parse video + audio UDP ports out of the leg addresses, assert adjacency - Mount the Handler on an Echo httptest server - Build a Pion PeerConnection (recvonly video + audio), POST its offer, feed the answer back in - Spray synthetic RTP packets at both loopback sockets - Assert both OnTrack callbacks fire and each delivers at least one RTP packet within 10s - DELETE via the returned Location header to confirm teardown Passes cleanly under -race in ~1s. Catches regressions across the whole M2 wiring from a single fixture. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
83eaa28601
commit
b030102611
1 changed files with 275 additions and 0 deletions
275
app/webrtc/integration_test.go
Normal file
275
app/webrtc/integration_test.go
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
package webrtc
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/pion/rtp"
|
||||
pionwebrtc "github.com/pion/webrtc/v4"
|
||||
|
||||
"github.com/datarhei/core/v16/config"
|
||||
appcfg "github.com/datarhei/core/v16/restream/app"
|
||||
)
|
||||
|
||||
// TestIntegration_SyntheticRTPToWHEP wires the full M2 subsystem end to
|
||||
// end using in-process UDP sockets and a Pion WHEP subscriber:
|
||||
//
|
||||
// 1. Build a Subsystem and Handler (no Core/HTTP server needed).
|
||||
// 2. Fire the OnStart hook directly — this allocates two adjacent
|
||||
// loopback UDP ports and registers a process stream.
|
||||
// 3. Extract the allocated video + audio ports from the returned
|
||||
// ConfigIO legs.
|
||||
// 4. Build a Pion PeerConnection (recvonly video + audio) and POST its
|
||||
// SDP offer through the Echo Handler.
|
||||
// 5. Plumb the returned answer into the PC.
|
||||
// 6. Spray synthetic RTP packets at both UDP ports.
|
||||
// 7. Assert that the PC sees OnTrack for both kinds and at least one
|
||||
// RTP packet arrives on each track inside the timeout budget.
|
||||
//
|
||||
// This is the single highest-leverage integration test for M2 — it
|
||||
// catches the whole stack: port allocation, hook contract, two-track
|
||||
// forwarding, WHEP handshake, and JWT-mounted routing doesn't interfere
|
||||
// with the handler's internal flow.
|
||||
func TestIntegration_SyntheticRTPToWHEP(t *testing.T) {
|
||||
// --- 1. Construct subsystem + handler. ---
|
||||
sub, err := New(config.DataWebRTC{Enable: true}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("subsystem New: %v", err)
|
||||
}
|
||||
defer sub.Close()
|
||||
|
||||
h := NewHandler(sub, 0)
|
||||
defer h.Close()
|
||||
|
||||
// --- 2. Fire OnStart directly to populate the stream registry
|
||||
// and allocate ports. We bypass the restream manager by
|
||||
// invoking the hook the subsystem would have registered.
|
||||
processID := "integration-probe"
|
||||
legs, err := sub.onProcessStart(processID, &appcfg.Config{
|
||||
ID: processID,
|
||||
WebRTC: appcfg.ConfigWebRTC{
|
||||
Enabled: true,
|
||||
VideoPT: 102,
|
||||
AudioPT: 111,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("onProcessStart: %v", err)
|
||||
}
|
||||
if len(legs) != 2 {
|
||||
t.Fatalf("expected 2 output legs, got %d", len(legs))
|
||||
}
|
||||
defer sub.onProcessStop(processID)
|
||||
|
||||
// --- 3. Extract UDP ports from leg addresses. ---
|
||||
videoPort, err := portFromLegAddress(legs[0].Address)
|
||||
if err != nil {
|
||||
t.Fatalf("video leg address %q: %v", legs[0].Address, err)
|
||||
}
|
||||
audioPort, err := portFromLegAddress(legs[1].Address)
|
||||
if err != nil {
|
||||
t.Fatalf("audio leg address %q: %v", legs[1].Address, err)
|
||||
}
|
||||
if audioPort != videoPort+1 {
|
||||
t.Fatalf("expected adjacent ports, got video=%d audio=%d", videoPort, audioPort)
|
||||
}
|
||||
|
||||
// --- 4. Mount the handler in an Echo server (httptest) so we
|
||||
// exercise the real route registration path. ---
|
||||
e := echo.New()
|
||||
g := e.Group("")
|
||||
h.Register(g)
|
||||
srv := httptest.NewServer(e)
|
||||
defer srv.Close()
|
||||
|
||||
// --- 5. Build the WHEP subscriber PeerConnection. ---
|
||||
me := &pionwebrtc.MediaEngine{}
|
||||
if err := me.RegisterDefaultCodecs(); err != nil {
|
||||
t.Fatalf("register default codecs: %v", err)
|
||||
}
|
||||
api := pionwebrtc.NewAPI(pionwebrtc.WithMediaEngine(me))
|
||||
pc, err := api.NewPeerConnection(pionwebrtc.Configuration{})
|
||||
if err != nil {
|
||||
t.Fatalf("new PC: %v", err)
|
||||
}
|
||||
defer pc.Close()
|
||||
|
||||
if _, err := pc.AddTransceiverFromKind(pionwebrtc.RTPCodecTypeVideo,
|
||||
pionwebrtc.RTPTransceiverInit{Direction: pionwebrtc.RTPTransceiverDirectionRecvonly}); err != nil {
|
||||
t.Fatalf("add video transceiver: %v", err)
|
||||
}
|
||||
if _, err := pc.AddTransceiverFromKind(pionwebrtc.RTPCodecTypeAudio,
|
||||
pionwebrtc.RTPTransceiverInit{Direction: pionwebrtc.RTPTransceiverDirectionRecvonly}); err != nil {
|
||||
t.Fatalf("add audio transceiver: %v", err)
|
||||
}
|
||||
|
||||
// Signal when each track has produced its first RTP packet.
|
||||
var videoGot, audioGot atomic.Bool
|
||||
videoCh := make(chan struct{}, 1)
|
||||
audioCh := make(chan struct{}, 1)
|
||||
|
||||
pc.OnTrack(func(tr *pionwebrtc.TrackRemote, _ *pionwebrtc.RTPReceiver) {
|
||||
// Read a single RTP packet and signal the appropriate channel.
|
||||
go func() {
|
||||
if _, _, readErr := tr.ReadRTP(); readErr != nil {
|
||||
return
|
||||
}
|
||||
switch tr.Kind() {
|
||||
case pionwebrtc.RTPCodecTypeVideo:
|
||||
if videoGot.CompareAndSwap(false, true) {
|
||||
videoCh <- struct{}{}
|
||||
}
|
||||
case pionwebrtc.RTPCodecTypeAudio:
|
||||
if audioGot.CompareAndSwap(false, true) {
|
||||
audioCh <- struct{}{}
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
offer, err := pc.CreateOffer(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create offer: %v", err)
|
||||
}
|
||||
gatherLocal := pionwebrtc.GatheringCompletePromise(pc)
|
||||
if err := pc.SetLocalDescription(offer); err != nil {
|
||||
t.Fatalf("set local: %v", err)
|
||||
}
|
||||
select {
|
||||
case <-gatherLocal:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("local ICE gathering timeout")
|
||||
}
|
||||
offerSDP := pc.LocalDescription().SDP
|
||||
|
||||
// --- 6. POST the offer to the WHEP endpoint. ---
|
||||
resp, err := http.Post(srv.URL+"/whep/"+processID, "application/sdp",
|
||||
strings.NewReader(offerSDP))
|
||||
if err != nil {
|
||||
t.Fatalf("POST /whep: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("POST /whep status = %d, want 201", resp.StatusCode)
|
||||
}
|
||||
|
||||
answerBuf := make([]byte, 1<<15)
|
||||
n, _ := resp.Body.Read(answerBuf)
|
||||
answerSDP := string(answerBuf[:n])
|
||||
if !strings.Contains(answerSDP, "v=0") {
|
||||
t.Fatalf("answer SDP malformed: %q", answerSDP)
|
||||
}
|
||||
|
||||
loc := resp.Header.Get("Location")
|
||||
if loc == "" || !strings.HasPrefix(loc, "/whep/"+processID+"/") {
|
||||
t.Fatalf("Location header bad: %q", loc)
|
||||
}
|
||||
|
||||
if err := pc.SetRemoteDescription(pionwebrtc.SessionDescription{
|
||||
Type: pionwebrtc.SDPTypeAnswer,
|
||||
SDP: answerSDP,
|
||||
}); err != nil {
|
||||
t.Fatalf("set remote: %v", err)
|
||||
}
|
||||
|
||||
// --- 7. Spray synthetic RTP into both UDP ports. ---
|
||||
videoSender, err := net.Dial("udp", "127.0.0.1:"+strconv.Itoa(videoPort))
|
||||
if err != nil {
|
||||
t.Fatalf("dial video: %v", err)
|
||||
}
|
||||
defer videoSender.Close()
|
||||
audioSender, err := net.Dial("udp", "127.0.0.1:"+strconv.Itoa(audioPort))
|
||||
if err != nil {
|
||||
t.Fatalf("dial audio: %v", err)
|
||||
}
|
||||
defer audioSender.Close()
|
||||
|
||||
stopSend := make(chan struct{})
|
||||
defer close(stopSend)
|
||||
go func() {
|
||||
ticker := time.NewTicker(20 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
var vseq, aseq uint16
|
||||
for {
|
||||
select {
|
||||
case <-stopSend:
|
||||
return
|
||||
case <-ticker.C:
|
||||
vseq++
|
||||
aseq++
|
||||
vpkt := synthRTPPacket(102, vseq, uint32(vseq)*3000, 0xcafe0000, []byte("vvvvvvvv"))
|
||||
_, _ = videoSender.Write(vpkt)
|
||||
apkt := synthRTPPacket(111, aseq, uint32(aseq)*960, 0xbeef0000, []byte("aaaaaaaa"))
|
||||
_, _ = audioSender.Write(apkt)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// --- 8. Wait for both tracks' first packet. ---
|
||||
waitFor := func(name string, ch chan struct{}) {
|
||||
select {
|
||||
case <-ch:
|
||||
// success
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("%s: no RTP received via WHEP within 10s", name)
|
||||
}
|
||||
}
|
||||
waitFor("video", videoCh)
|
||||
waitFor("audio", audioCh)
|
||||
|
||||
// Sanity: the Location path should DELETE cleanly.
|
||||
parsedLoc, err := url.Parse(loc)
|
||||
if err != nil {
|
||||
t.Fatalf("parse Location: %v", err)
|
||||
}
|
||||
deleteReq, _ := http.NewRequest(http.MethodDelete, srv.URL+parsedLoc.Path, nil)
|
||||
delResp, err := http.DefaultClient.Do(deleteReq)
|
||||
if err != nil {
|
||||
t.Fatalf("DELETE /whep/.../resource: %v", err)
|
||||
}
|
||||
_ = delResp.Body.Close()
|
||||
if delResp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("DELETE status = %d, want 204", delResp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// portFromLegAddress pulls the UDP port out of a leg Address like
|
||||
// "udp://127.0.0.1:49200?pkt_size=1316".
|
||||
func portFromLegAddress(addr string) (int, error) {
|
||||
re := regexp.MustCompile(`udp://[^:]+:(\d+)`)
|
||||
m := re.FindStringSubmatch(addr)
|
||||
if len(m) != 2 {
|
||||
return 0, &portParseError{addr: addr}
|
||||
}
|
||||
return strconv.Atoi(m[1])
|
||||
}
|
||||
|
||||
type portParseError struct{ addr string }
|
||||
|
||||
func (e *portParseError) Error() string { return "cannot parse port from " + e.addr }
|
||||
|
||||
// synthRTPPacket builds a minimal valid RTP packet for injection testing.
|
||||
func synthRTPPacket(pt uint8, seq uint16, ts uint32, ssrc uint32, payload []byte) []byte {
|
||||
p := &rtp.Packet{
|
||||
Header: rtp.Header{
|
||||
Version: 2,
|
||||
PayloadType: pt,
|
||||
SequenceNumber: seq,
|
||||
Timestamp: ts,
|
||||
SSRC: ssrc,
|
||||
Marker: false,
|
||||
},
|
||||
Payload: payload,
|
||||
}
|
||||
b, _ := p.Marshal()
|
||||
return b
|
||||
}
|
||||
Loading…
Reference in a new issue