test(webrtc): add Pion WHEP subscriber client + e2e test
whep-client/main.go: minimal Pion subscriber that POSTs a recvonly offer, applies the answer, and waits for one RTP packet on each of the video and audio tracks. Used as M1's end-to-end verifier. whep-client/main_test.go: in-process e2e wiring — stands up Source, Registry, PeerFactory and WHEPHandler behind an httptest server, injects synthetic PT=102/111 RTP on the Source's UDP port and calls Subscribe. Validates the full egress pipeline without requiring FFmpeg or external network. Skipped under -short.
This commit is contained in:
parent
e471bd02b2
commit
413d0f24b6
2 changed files with 262 additions and 0 deletions
152
test/whep-client/main.go
Normal file
152
test/whep-client/main.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
// Command whep-client is a minimal Pion-based WHEP subscriber used for
|
||||
// M1 end-to-end verification. It POSTs a recvonly SDP offer to a WHEP
|
||||
// endpoint, applies the answer, then reports whether the video and
|
||||
// audio tracks receive at least one RTP packet before a timeout.
|
||||
//
|
||||
// This is a test helper; it is NOT part of the Core binary.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
whepURL = flag.String("url", "http://127.0.0.1:8787/whep/test", "WHEP endpoint URL")
|
||||
timeout = flag.Duration("timeout", 10*time.Second, "overall subscribe+receive timeout")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
|
||||
defer cancel()
|
||||
|
||||
if err := Subscribe(ctx, *whepURL); err != nil {
|
||||
log.Fatalf("subscribe failed: %v", err)
|
||||
}
|
||||
fmt.Println("OK: received video and audio RTP")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
// Subscribe performs a full WHEP subscribe against whepURL and returns
|
||||
// nil when both a video and an audio RTP packet have been observed
|
||||
// before ctx expires. It is exported so tests can exercise it.
|
||||
func Subscribe(ctx context.Context, whepURL string) error {
|
||||
me := &webrtc.MediaEngine{}
|
||||
if err := me.RegisterDefaultCodecs(); err != nil {
|
||||
return fmt.Errorf("register codecs: %w", err)
|
||||
}
|
||||
api := webrtc.NewAPI(webrtc.WithMediaEngine(me))
|
||||
|
||||
pc, err := api.NewPeerConnection(webrtc.Configuration{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("new peer connection: %w", err)
|
||||
}
|
||||
defer pc.Close()
|
||||
|
||||
if _, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo,
|
||||
webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}); err != nil {
|
||||
return fmt.Errorf("add video transceiver: %w", err)
|
||||
}
|
||||
if _, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio,
|
||||
webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionRecvonly}); err != nil {
|
||||
return fmt.Errorf("add audio transceiver: %w", err)
|
||||
}
|
||||
|
||||
videoDone := make(chan struct{})
|
||||
audioDone := make(chan struct{})
|
||||
pc.OnTrack(func(t *webrtc.TrackRemote, _ *webrtc.RTPReceiver) {
|
||||
kind := t.Kind()
|
||||
log.Printf("OnTrack: kind=%s codec=%s pt=%d", kind, t.Codec().MimeType, t.PayloadType())
|
||||
go func() {
|
||||
buf := make([]byte, 1500)
|
||||
// One successful ReadRTP is enough to prove egress.
|
||||
if _, _, err := t.Read(buf); err != nil {
|
||||
log.Printf("read %s: %v", kind, err)
|
||||
return
|
||||
}
|
||||
switch kind {
|
||||
case webrtc.RTPCodecTypeVideo:
|
||||
select {
|
||||
case <-videoDone:
|
||||
default:
|
||||
close(videoDone)
|
||||
}
|
||||
case webrtc.RTPCodecTypeAudio:
|
||||
select {
|
||||
case <-audioDone:
|
||||
default:
|
||||
close(audioDone)
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
offer, err := pc.CreateOffer(nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create offer: %w", err)
|
||||
}
|
||||
gather := webrtc.GatheringCompletePromise(pc)
|
||||
if err := pc.SetLocalDescription(offer); err != nil {
|
||||
return fmt.Errorf("set local: %w", err)
|
||||
}
|
||||
select {
|
||||
case <-gather:
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("ice gather: %w", ctx.Err())
|
||||
}
|
||||
|
||||
answerSDP, err := postOffer(ctx, whepURL, pc.LocalDescription().SDP)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := pc.SetRemoteDescription(webrtc.SessionDescription{
|
||||
Type: webrtc.SDPTypeAnswer,
|
||||
SDP: answerSDP,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("set remote: %w", err)
|
||||
}
|
||||
|
||||
// Wait for one RTP packet on each track or ctx timeout.
|
||||
select {
|
||||
case <-videoDone:
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("waiting for video: %w", ctx.Err())
|
||||
}
|
||||
select {
|
||||
case <-audioDone:
|
||||
case <-ctx.Done():
|
||||
return fmt.Errorf("waiting for audio: %w", ctx.Err())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func postOffer(ctx context.Context, url, sdp string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url,
|
||||
bytes.NewReader([]byte(sdp)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("new request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/sdp")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("POST %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
return "", fmt.Errorf("WHEP %s: %d %s", url, resp.StatusCode, string(body))
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
110
test/whep-client/main_test.go
Normal file
110
test/whep-client/main_test.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
coreweb "github.com/datarhei/core/v16/core/webrtc"
|
||||
"github.com/pion/rtp"
|
||||
)
|
||||
|
||||
// TestSubscribe_EndToEnd stands up an in-process webrtc-poc stack,
|
||||
// injects synthetic H.264(PT=102) + Opus(PT=111) RTP into the Source's
|
||||
// UDP port, and asserts Subscribe returns nil within the timeout.
|
||||
//
|
||||
// Network-heavy; skipped under -short.
|
||||
func TestSubscribe_EndToEnd(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping end-to-end subscribe test in short mode")
|
||||
}
|
||||
|
||||
src, err := coreweb.NewSource("stream-e2e", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSource: %v", err)
|
||||
}
|
||||
src.Start()
|
||||
defer src.Close()
|
||||
|
||||
reg := coreweb.NewRegistry()
|
||||
if err := reg.Register("stream-e2e", src); err != nil {
|
||||
t.Fatalf("Register: %v", err)
|
||||
}
|
||||
|
||||
factory, err := coreweb.NewPeerFactory(coreweb.DefaultConfig())
|
||||
if err != nil {
|
||||
t.Fatalf("NewPeerFactory: %v", err)
|
||||
}
|
||||
|
||||
handler := coreweb.NewWHEPHandler(reg, factory, coreweb.DefaultConfig())
|
||||
ts := httptest.NewServer(http.StripPrefix("", handler))
|
||||
defer ts.Close()
|
||||
|
||||
// Begin injecting RTP into the source.
|
||||
rtpAddr := src.LocalAddr()
|
||||
conn, err := net.DialUDP("udp", nil, rtpAddr)
|
||||
if err != nil {
|
||||
t.Fatalf("dial udp: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
tick := time.NewTicker(20 * time.Millisecond)
|
||||
defer tick.Stop()
|
||||
var seq uint16
|
||||
var ts uint32
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-tick.C:
|
||||
// Video packet (PT=102).
|
||||
pkt := &rtp.Packet{
|
||||
Header: rtp.Header{
|
||||
Version: 2,
|
||||
PayloadType: 102,
|
||||
SequenceNumber: seq,
|
||||
Timestamp: ts,
|
||||
SSRC: 0x1234,
|
||||
},
|
||||
Payload: []byte{0x00, 0x00, 0x00, 0x01, 0x09, 0x10},
|
||||
}
|
||||
if b, err := pkt.Marshal(); err == nil {
|
||||
_, _ = conn.Write(b)
|
||||
}
|
||||
// Audio packet (PT=111).
|
||||
pkt.PayloadType = 111
|
||||
pkt.SSRC = 0x5678
|
||||
pkt.SequenceNumber = seq
|
||||
if b, err := pkt.Marshal(); err == nil {
|
||||
_, _ = conn.Write(b)
|
||||
}
|
||||
seq++
|
||||
ts += 3000
|
||||
}
|
||||
}
|
||||
}()
|
||||
defer func() {
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
}()
|
||||
|
||||
// We don't care whether the test client's Subscribe can actually
|
||||
// decode H.264 — just that it observed *some* RTP on both tracks.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
whepURL := strings.TrimRight(ts.URL, "/") + "/whep/stream-e2e"
|
||||
if err := Subscribe(ctx, whepURL); err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue