Public invitations could stop after an early tunnel-process exit, and connection progress and Viewer status did not consistently describe the current attempt or Host state. This maintenance release adds bounded startup retries, aligns enhanced NAT retry feedback across entry paths, and fixes camera-preview and room-entry layout. The existing route owner and published contracts remain in place. Four-attempt progress is advertised and opted into; older readers retain supported messages. Interaction/chat experiments are outside this PR. Validation: npm run check (74 files, 1072 tests and production build), npm run check:go (including Windows capture compile), documentation/repository hygiene, 10 production Viewer-entry discovery cases, and a real public-link gate with remote HTTP/WebSocket access and shutdown cleanup all passed. Existing device/network evidence limits remain documented. ## Release notes ### 中文 这是一次维护更新,重点改善公网邀请启动、连接重试反馈和分享状态提示,并修正两处界面布局问题。 - 公网邀请服务在连接就绪前意外退出时,会自动重试,最多重试 3 次;所有尝试仍共用 30 秒启动预算。 - 增强穿透连接在首次尝试失败后最多重试 3 次,重试进度显示为 1/3、2/3、3/3。邀请链接和房间号加入都支持新版进度提示。 - 修正观众状态提示:房主离线时不再误报“没有可用的媒体线路”,服务器连接恢复与媒体恢复也会分别呈现。 - 修正电视内房间号输入界面的居中位置,以及摄像头选择预览被裁切的问题。 - 尚未确认来源声音状态时,不再提前提示“未分享来源声音”。 本次保持现有版本兼容;新版重试进度需要页面与服务端均支持。额外重试不能保证所有网络都能直连。 ### English This maintenance update improves public invitation startup, connection retry feedback, and sharing status messages, with two layout fixes. - If the public invitation service exits before connecting, Piik retries up to three times within the existing shared 30-second startup budget. - Enhanced NAT traversal now allows up to three retries after the initial attempt, shown as 1/3, 2/3, and 3/3. Both invitation links and room-code entry support the updated progress display. - Viewer status now distinguishes an offline host from an unavailable media route, and reports server reconnection separately from media recovery. - Fixed the alignment of room-code entry inside the TV and cropping in camera-selection previews. - The missing-source-audio notice now waits until the source's audio availability is known. Existing version compatibility is preserved. Updated retry progress requires support from both the page and server. Additional retries cannot guarantee a direct connection on every network.
713 lines
29 KiB
Go
713 lines
29 KiB
Go
package protocol
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestRuntimeCapabilities(t *testing.T) {
|
|
for _, test := range []struct {
|
|
data string
|
|
want RuntimeCapabilities
|
|
}{
|
|
{`{}`, RuntimeCapabilities{}},
|
|
{`{"connectionAttemptProgress4":true}`, RuntimeCapabilities{ConnectionAttemptProgress4: true}},
|
|
{`{"sfu":true,"extra":{"enabled":true}}`, RuntimeCapabilities{Sfu: true}},
|
|
{`{"natPrediction":true,"SFU":true}`, RuntimeCapabilities{NatPrediction: true}},
|
|
{`{"sfu":true,"natPrediction":true}`, RuntimeCapabilities{Sfu: true, NatPrediction: true}},
|
|
{`{"sfu":"ignored duplicate","sfu":true}`, RuntimeCapabilities{Sfu: true}},
|
|
} {
|
|
got := RuntimeCapabilities{Sfu: true, NatPrediction: true}
|
|
if err := json.Unmarshal([]byte(test.data), &got); err != nil || got != test.want {
|
|
t.Errorf("decode %s = %+v, %v; want %+v", test.data, got, err, test.want)
|
|
}
|
|
}
|
|
for _, data := range []string{
|
|
`null`, `[]`, `true`, `{"sfu":null}`, `{"sfu":"true"}`,
|
|
`{"natPrediction":null}`, `{"natPrediction":1}`,
|
|
`{"connectionAttemptProgress4":null}`, `{"connectionAttemptProgress4":4}`,
|
|
} {
|
|
var got RuntimeCapabilities
|
|
if err := json.Unmarshal([]byte(data), &got); err == nil {
|
|
t.Errorf("expected %s to be rejected", data)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStrictMemberNamesCannotPopulateForbiddenOrOptionalFields(t *testing.T) {
|
|
for _, sample := range []struct {
|
|
name string
|
|
data string
|
|
target any
|
|
}{
|
|
{"none upstream with folded empty peer", `{"kind":"none","peerid":""}`, &MediaRouteUpstream{}},
|
|
{"host diagnostic with folded zero ordinal", `{"kind":"host","Ordinal":0}`, &RouteDiagnosticParent{}},
|
|
{"host presence with folded false ready", `{"role":"host","peerId":"host_12345678","displayName":"Host","upstream":{"kind":"none"},"mediaready":false}`, &ParticipantPresenceEntry{}},
|
|
{"viewer presence with folded false ready", `{"role":"viewer","peerId":"viewer_12345678","displayName":"Viewer","upstream":{"kind":"none"},"mediaready":false}`, &ParticipantPresenceEntry{}},
|
|
} {
|
|
t.Run(sample.name, func(t *testing.T) {
|
|
if err := json.Unmarshal([]byte(sample.data), sample.target); err == nil {
|
|
t.Fatal("a noncanonical member must not populate a schema field")
|
|
}
|
|
})
|
|
}
|
|
assertServerMessage(t, "folded route policy cannot be ignored by its default",
|
|
object(with(drop(authenticatedHost(), "routePolicy"), member{"routepolicy", testRoutePolicy})), false)
|
|
}
|
|
|
|
// member is one JSON object key with its raw encoded value, so the tests can
|
|
// reproduce the exact wire shapes from tests/protocol.test.ts (including key
|
|
// presence, which a Go map cannot express).
|
|
type member struct {
|
|
key string
|
|
raw string
|
|
}
|
|
|
|
func object(members []member) []byte {
|
|
var builder strings.Builder
|
|
builder.WriteByte('{')
|
|
for index, entry := range members {
|
|
if index > 0 {
|
|
builder.WriteByte(',')
|
|
}
|
|
fmt.Fprintf(&builder, "%q:%s", entry.key, entry.raw)
|
|
}
|
|
builder.WriteByte('}')
|
|
return []byte(builder.String())
|
|
}
|
|
|
|
func with(base []member, changes ...member) []member {
|
|
out := append([]member(nil), base...)
|
|
for _, change := range changes {
|
|
replaced := false
|
|
for index := range out {
|
|
if out[index].key == change.key {
|
|
out[index] = change
|
|
replaced = true
|
|
break
|
|
}
|
|
}
|
|
if !replaced {
|
|
out = append(out, change)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func drop(base []member, keys ...string) []member {
|
|
out := make([]member, 0, len(base))
|
|
for _, entry := range base {
|
|
remove := false
|
|
for _, key := range keys {
|
|
if entry.key == key {
|
|
remove = true
|
|
}
|
|
}
|
|
if !remove {
|
|
out = append(out, entry)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
const (
|
|
testAssignmentNone = `{"upstream":{"kind":"none"},"childPeerIds":[],` +
|
|
`"sfuPublicationGeneration":null}`
|
|
testQualitySettings = `{"resolution":"1080p","maxFramerate":60,"maxBitrate":8000000,` +
|
|
`"degradationPreference":"maintain-resolution"}`
|
|
testRoutePolicy = `{"peerOnly":false,"topologyOptimization":true,"natPrediction":false}`
|
|
testIceConfig = `{"iceServers":[],"natPredictionStunUrls":[]}`
|
|
)
|
|
|
|
func authenticatedHost() []member {
|
|
return []member{
|
|
{"type", `"authenticated"`},
|
|
{"protocol", `"piik-v23"`},
|
|
{"peerId", `"host_12345678"`},
|
|
{"maxViewers", `8`},
|
|
{"endpointMediaCopyCapacity", `2`},
|
|
{"hostOnline", `true`},
|
|
{"connectionId", `null`},
|
|
{"iceConfig", testIceConfig},
|
|
{"routePolicy", testRoutePolicy},
|
|
{"codeEntryPolicy", `"open"`},
|
|
{"viewerAuthorizationGeneration", `"viewer_generation_12345678"`},
|
|
{"mediaMode", `"peer-assisted"`},
|
|
{"shareGeneration", `"share_generation_12345678"`},
|
|
{"routeRevision", `0`},
|
|
{"routeAssignment", testAssignmentNone},
|
|
{"qualitySettings", testQualitySettings},
|
|
{"role", `"host"`},
|
|
{"viewerPasswordEnabled", `false`},
|
|
}
|
|
}
|
|
|
|
func assertServerMessage(t *testing.T, name string, data []byte, want bool) {
|
|
t.Helper()
|
|
t.Run(name, func(t *testing.T) {
|
|
_, err := DecodeServerMessage(data)
|
|
if want && err != nil {
|
|
t.Fatalf("expected %s to decode, got %v", data, err)
|
|
}
|
|
if !want && err == nil {
|
|
t.Fatalf("expected %s to be rejected", data)
|
|
}
|
|
})
|
|
}
|
|
|
|
func assertClientMessage(t *testing.T, name string, data []byte, want bool) {
|
|
t.Helper()
|
|
t.Run(name, func(t *testing.T) {
|
|
_, err := DecodeClientMessage(data)
|
|
if want && err != nil {
|
|
t.Fatalf("expected %s to decode, got %v", data, err)
|
|
}
|
|
if !want && err == nil {
|
|
t.Fatalf("expected %s to be rejected", data)
|
|
}
|
|
})
|
|
}
|
|
|
|
// "accepts dynamic viewer limits within the protocol boundary" and
|
|
// "rejects viewer limit %s outside the protocol boundary".
|
|
func TestAuthenticatedViewerLimits(t *testing.T) {
|
|
for _, sample := range []struct {
|
|
raw string
|
|
want bool
|
|
}{{`1`, true}, {`8`, true}, {`20`, true}, {`0`, false}, {`1.5`, false}, {`21`, false}} {
|
|
assertServerMessage(t, "maxViewers "+sample.raw,
|
|
object(with(authenticatedHost(), member{"maxViewers", sample.raw})), sample.want)
|
|
}
|
|
}
|
|
|
|
// "requires the authenticated endpoint capacity to be 1, 2, or 3".
|
|
func TestAuthenticatedEndpointCapacity(t *testing.T) {
|
|
for _, sample := range []struct {
|
|
raw string
|
|
want bool
|
|
}{{`1`, true}, {`2`, true}, {`3`, true}, {`0`, false}, {`4`, false}, {`1.5`, false}} {
|
|
assertServerMessage(t, "capacity "+sample.raw,
|
|
object(with(authenticatedHost(),
|
|
member{"endpointMediaCopyCapacity", sample.raw})), sample.want)
|
|
}
|
|
assertServerMessage(t, "capacity absent",
|
|
object(drop(authenticatedHost(), "endpointMediaCopyCapacity")), false)
|
|
}
|
|
|
|
// "scopes password configuration state to authenticated Hosts".
|
|
func TestAuthenticatedPasswordStateIsHostScoped(t *testing.T) {
|
|
assertServerMessage(t, "host carries the flag", object(authenticatedHost()), true)
|
|
assertServerMessage(t, "host without the flag",
|
|
object(drop(authenticatedHost(), "viewerPasswordEnabled")), false)
|
|
|
|
viewer := drop(with(authenticatedHost(),
|
|
member{"role", `"viewer"`}, member{"peerId", `"viewer_12345678"`}),
|
|
"viewerPasswordEnabled")
|
|
assertServerMessage(t, "viewer without the flag", object(viewer), true)
|
|
assertServerMessage(t, "viewer with the flag",
|
|
object(with(viewer, member{"viewerPasswordEnabled", `true`})), false)
|
|
assertServerMessage(t, "viewer with hostPaused",
|
|
object(with(viewer, member{"hostPaused", `true`})), true)
|
|
}
|
|
|
|
func TestAuthenticatedRoomRevision(t *testing.T) {
|
|
assertServerMessage(t, "revision at the ceiling",
|
|
object(with(authenticatedHost(), member{"routeRevision", `9007199254740991`})), true)
|
|
assertServerMessage(t, "revision past the ceiling",
|
|
object(with(authenticatedHost(), member{"routeRevision", `9007199254740992`})), false)
|
|
assertServerMessage(t, "absent route assignment",
|
|
object(drop(authenticatedHost(), "routeAssignment")), false)
|
|
assertServerMessage(t, "absent media mode",
|
|
object(drop(authenticatedHost(), "mediaMode")), false)
|
|
assertServerMessage(t, "quality settings past the bitrate ceiling",
|
|
object(with(authenticatedHost(), member{"qualitySettings",
|
|
`{"resolution":"1080p","maxFramerate":60,"maxBitrate":20000000,` +
|
|
`"degradationPreference":"maintain-resolution"}`})), false)
|
|
}
|
|
|
|
// "accepts only STUN URLs in the authenticated peer ICE config" and
|
|
// "requires the v20 NAT observation configuration".
|
|
func TestAuthenticatedIceConfig(t *testing.T) {
|
|
for _, sample := range []struct {
|
|
name string
|
|
raw string
|
|
want bool
|
|
}{
|
|
{"empty", `{"iceServers":[],"natPredictionStunUrls":[]}`, true},
|
|
{"one stun url", `{"iceServers":[{"urls":"stun:stun.example.test:3478"}],` +
|
|
`"natPredictionStunUrls":[]}`, true},
|
|
{"a list of stun urls", `{"iceServers":[{"urls":["stun:a.example.test:3478",` +
|
|
`"stun:b.example.test:3478"]}],"natPredictionStunUrls":[]}`, true},
|
|
{"missing nat prediction urls", `{"iceServers":[]}`, false},
|
|
{"turn url", `{"iceServers":[{"urls":"turn:relay.example.test:3478?transport=udp"}],` +
|
|
`"natPredictionStunUrls":[]}`, false},
|
|
{"turn credentials", `{"iceServers":[{"urls":"turn:relay.example.test:3478",` +
|
|
`"username":"1787076000","credential":"credential"}],` +
|
|
`"natPredictionStunUrls":[]}`, false},
|
|
{"stuns url", `{"iceServers":[{"urls":"stuns:stun.example.test:5349"}],` +
|
|
`"natPredictionStunUrls":[]}`, false},
|
|
{"too many nat prediction urls", `{"iceServers":[],"natPredictionStunUrls":` +
|
|
`["stun:a.example.test:3478","stun:b.example.test:3478",` +
|
|
`"stun:c.example.test:3478"]}`, false},
|
|
} {
|
|
assertServerMessage(t, "iceConfig "+sample.name,
|
|
object(with(authenticatedHost(), member{"iceConfig", sample.raw})), sample.want)
|
|
}
|
|
}
|
|
|
|
// "keeps Viewer route status as a strict crossed union".
|
|
func TestRouteStatusCrossedUnion(t *testing.T) {
|
|
for _, sample := range []struct {
|
|
name string
|
|
raw string
|
|
want bool
|
|
}{
|
|
{"waiting on admission",
|
|
`{"type":"route-status","revision":3,"state":"waiting","reason":"sfu-admission"}`, true},
|
|
{"failed on exhaustion",
|
|
`{"type":"route-status","revision":4,"state":"failed","reason":"route-exhausted"}`, true},
|
|
{"waiting on exhaustion",
|
|
`{"type":"route-status","revision":3,"state":"waiting","reason":"route-exhausted"}`, false},
|
|
{"failed on admission",
|
|
`{"type":"route-status","revision":3,"state":"failed","reason":"sfu-admission"}`, false},
|
|
{"with a retry hint",
|
|
`{"type":"route-status","revision":3,"state":"waiting","reason":"sfu-admission",` +
|
|
`"retryAfterMs":1000}`, false},
|
|
{"with a peer id",
|
|
`{"type":"route-status","revision":3,"state":"waiting","reason":"sfu-admission",` +
|
|
`"peerId":"private-peer-id"}`, false},
|
|
} {
|
|
assertServerMessage(t, sample.name, []byte(sample.raw), sample.want)
|
|
}
|
|
}
|
|
|
|
// "accepts a strict, unique and bounded Viewer presence snapshot".
|
|
func TestViewerPresence(t *testing.T) {
|
|
const viewer = `{"role":"viewer","peerId":"viewer_12345678","displayName":"小明",` +
|
|
`"upstream":{"kind":"peer","peerId":"viewer_parent_12345678"}}`
|
|
for _, sample := range []struct {
|
|
name string
|
|
raw string
|
|
want bool
|
|
}{
|
|
{"sfu upstream with mediaReady",
|
|
`{"type":"viewer-presence","viewers":[{"role":"viewer","peerId":"viewer_12345678",` +
|
|
`"displayName":"小明","upstream":{"kind":"sfu"},"mediaReady":true}]}`, true},
|
|
{"mediaReady false", `{"type":"viewer-presence","viewers":[{"role":"viewer",` +
|
|
`"peerId":"viewer_12345678","displayName":"小明",` +
|
|
`"upstream":{"kind":"peer","peerId":"viewer_parent_12345678"},` +
|
|
`"mediaReady":false}]}`, false},
|
|
{"duplicate peer ids",
|
|
`{"type":"viewer-presence","viewers":[` + viewer + `,` + viewer + `]}`, false},
|
|
{"leaked topology", `{"type":"viewer-presence","viewers":[{"role":"viewer",` +
|
|
`"peerId":"viewer_12345678","displayName":"小明",` +
|
|
`"upstream":{"kind":"none"},"mediaTopology":"peer-relay"}]}`, false},
|
|
{"leaked address", `{"type":"viewer-presence","viewers":[{"role":"viewer",` +
|
|
`"peerId":"viewer_12345678","displayName":"小明",` +
|
|
`"upstream":{"kind":"none"},"ip":"203.0.113.1"}]}`, false},
|
|
{"host entry", `{"type":"viewer-presence","viewers":[` + viewer + `,` +
|
|
`{"role":"host","peerId":"host_12345678","displayName":"分享者",` +
|
|
`"upstream":{"kind":"none"}}]}`, true},
|
|
{"host entry with an upstream", `{"type":"viewer-presence","viewers":[` +
|
|
`{"role":"host","peerId":"host_12345678","displayName":"分享者",` +
|
|
`"upstream":{"kind":"sfu"}}]}`, false},
|
|
{"host entry with mediaReady", `{"type":"viewer-presence","viewers":[` +
|
|
`{"role":"host","peerId":"host_12345678","displayName":"分享者",` +
|
|
`"upstream":{"kind":"none"},"mediaReady":true}]}`, false},
|
|
} {
|
|
assertServerMessage(t, sample.name, []byte(sample.raw), sample.want)
|
|
}
|
|
}
|
|
|
|
func TestViewerPresenceConstructors(t *testing.T) {
|
|
host := HostPresenceEntry("host_12345678", "分享者")
|
|
if host.Upstream != NoUpstream() || host.MediaReady {
|
|
t.Fatalf("host presence entry must carry {kind:\"none\"} and no mediaReady: %+v", host)
|
|
}
|
|
encoded, err := marshalJSON(NewViewerPresenceEntry(
|
|
"viewer_12345678", "小明", SfuUpstream(), false))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bytes.Contains(encoded, []byte("mediaReady")) {
|
|
t.Fatalf("mediaReady must be omitted when false: %s", encoded)
|
|
}
|
|
encoded, err = marshalJSON(NewViewerPresenceEntry(
|
|
"viewer_12345678", "小明", SfuUpstream(), true))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Contains(encoded, []byte(`"mediaReady":true`)) {
|
|
t.Fatalf("mediaReady must be emitted when true: %s", encoded)
|
|
}
|
|
}
|
|
|
|
// "accepts only the privacy-safe route snapshot payload" plus the superRefine.
|
|
func TestRouteDiagnosticSnapshot(t *testing.T) {
|
|
const childOne = `{"ordinal":1,"parent":{"kind":"host"},"effectiveCapacity":2,` +
|
|
`"childCount":1,"demandAgeMs":100,"queueWaitMs":10,"candidateStartMs":20,` +
|
|
`"firstDecodedFrameMs":80,"finalMs":80,"finalRoute":"direct","rejectionBucket":"none",` +
|
|
`"quality":{"eligibleWindows":3,"eligibleDurationMs":6000,"freezeWindows":1,` +
|
|
`"freezeCount":1,"freezeDurationMs":250,"pauseCount":0,"pauseDurationMs":0}}`
|
|
childTwo := func(parent string) string {
|
|
return `{"ordinal":2,"parent":` + parent + `,"effectiveCapacity":0,"childCount":0,` +
|
|
`"demandAgeMs":90,"queueWaitMs":null,"candidateStartMs":null,` +
|
|
`"firstDecodedFrameMs":null,"finalMs":null,"finalRoute":"waiting",` +
|
|
`"rejectionBucket":"sfu-admission","quality":null}`
|
|
}
|
|
snapshot := func(children, operation string) []byte {
|
|
return []byte(`{"type":"route-diagnostic-snapshot","snapshot":{"children":` +
|
|
children + `,"operation":` + operation + `}}`)
|
|
}
|
|
const operation = `{"childOrdinal":2,"reason":"join","stage":"admission",` +
|
|
`"cursor":1,"candidateCount":2}`
|
|
|
|
assertServerMessage(t, "full snapshot",
|
|
snapshot(`[`+childOne+`,`+childTwo(`{"kind":"viewer","ordinal":1}`)+`]`, operation), true)
|
|
assertServerMessage(t, "empty snapshot", snapshot(`[]`, `null`), true)
|
|
assertServerMessage(t, "leaked peer id",
|
|
[]byte(`{"type":"route-diagnostic-snapshot","snapshot":{"children":[],`+
|
|
`"operation":null,"peerId":"viewer_private_12345678"}}`), false)
|
|
assertServerMessage(t, "leaked connection id",
|
|
snapshot(`[{"ordinal":1,"parent":{"kind":"host"},"effectiveCapacity":2,"childCount":1,`+
|
|
`"demandAgeMs":100,"queueWaitMs":10,"candidateStartMs":20,`+
|
|
`"firstDecodedFrameMs":80,"finalMs":80,"finalRoute":"direct",`+
|
|
`"rejectionBucket":"none","quality":null,`+
|
|
`"connectionId":"connection_private_12345678"}]`, `null`), false)
|
|
assertServerMessage(t, "negative freeze count",
|
|
snapshot(`[{"ordinal":1,"parent":{"kind":"host"},"effectiveCapacity":2,"childCount":1,`+
|
|
`"demandAgeMs":100,"queueWaitMs":10,"candidateStartMs":20,`+
|
|
`"firstDecodedFrameMs":80,"finalMs":80,"finalRoute":"direct",`+
|
|
`"rejectionBucket":"none","quality":{"eligibleWindows":3,`+
|
|
`"eligibleDurationMs":6000,"freezeWindows":1,"freezeCount":-1,`+
|
|
`"freezeDurationMs":250,"pauseCount":0,"pauseDurationMs":0}}]`, `null`), false)
|
|
assertServerMessage(t, "self parent",
|
|
snapshot(`[`+childOne+`,`+childTwo(`{"kind":"viewer","ordinal":2}`)+`]`, `null`), false)
|
|
assertServerMessage(t, "unknown parent ordinal",
|
|
snapshot(`[`+childOne+`,`+childTwo(`{"kind":"viewer","ordinal":7}`)+`]`, `null`), false)
|
|
assertServerMessage(t, "duplicate ordinals",
|
|
snapshot(`[`+childOne+`,`+childOne+`]`, `null`), false)
|
|
assertServerMessage(t, "operation names an absent child",
|
|
snapshot(`[`+childOne+`]`, operation), false)
|
|
assertServerMessage(t, "cursor past the candidate count",
|
|
snapshot(`[`+childOne+`,`+childTwo(`{"kind":"viewer","ordinal":1}`)+`]`,
|
|
`{"childOrdinal":2,"reason":"join","stage":"admission","cursor":2,`+
|
|
`"candidateCount":2}`), false)
|
|
assertServerMessage(t, "raw rejection bucket on the operation",
|
|
snapshot(`[`+childOne+`,`+childTwo(`{"kind":"viewer","ordinal":1}`)+`]`,
|
|
`{"childOrdinal":2,"reason":"join","stage":"admission","cursor":1,`+
|
|
`"candidateCount":2,"rejectionBucket":"raw-error"}`), false)
|
|
assertServerMessage(t, "parent ordinal on a host parent",
|
|
snapshot(`[{"ordinal":1,"parent":{"kind":"host","ordinal":1},"effectiveCapacity":2,`+
|
|
`"childCount":1,"demandAgeMs":null,"queueWaitMs":null,"candidateStartMs":null,`+
|
|
`"firstDecodedFrameMs":null,"finalMs":null,"finalRoute":"direct",`+
|
|
`"rejectionBucket":"none","quality":null}]`, `null`), false)
|
|
}
|
|
|
|
// "keeps route messages strict alongside the single authenticated mode" and
|
|
// "accepts every bounded hybrid upstream shape".
|
|
func TestParticipantRouteAssignment(t *testing.T) {
|
|
routeUpdate := func(assignment string) []byte {
|
|
return []byte(`{"type":"route-update","revision":9,"phase":"active","assignment":` +
|
|
assignment + `}`)
|
|
}
|
|
for _, sample := range []struct {
|
|
name string
|
|
assignment string
|
|
want bool
|
|
}{
|
|
{"no upstream", testAssignmentNone, true},
|
|
{"peer upstream", `{"upstream":{"kind":"peer","peerId":"parent_12345678"},` +
|
|
`"childPeerIds":["child_12345678","child_87654321"],` +
|
|
`"sfuPublicationGeneration":null}`, true},
|
|
{"sfu upstream", `{"upstream":{"kind":"sfu"},"childPeerIds":[],` +
|
|
`"sfuPublicationGeneration":"generation_12345678"}`, true},
|
|
{"sfu upstream without a generation", `{"upstream":{"kind":"sfu"},` +
|
|
`"childPeerIds":[],"sfuPublicationGeneration":null}`, false},
|
|
{"peer upstream owning a generation",
|
|
`{"upstream":{"kind":"peer","peerId":"parent_12345678"},"childPeerIds":[],` +
|
|
`"sfuPublicationGeneration":"generation_12345678"}`, false},
|
|
{"too many children", `{"upstream":{"kind":"none"},"childPeerIds":` +
|
|
`["child_12345678","child_87654321","child_third_1234","child_overflow_1"],` +
|
|
`"sfuPublicationGeneration":null}`, false},
|
|
{"duplicate children", `{"upstream":{"kind":"none"},` +
|
|
`"childPeerIds":["child_12345678","child_12345678"],` +
|
|
`"sfuPublicationGeneration":null}`, false},
|
|
{"absent generation key", `{"upstream":{"kind":"none"},"childPeerIds":[]}`, false},
|
|
{"peer id on a none upstream", `{"upstream":{"kind":"none",` +
|
|
`"peerId":"parent_12345678"},"childPeerIds":[],` +
|
|
`"sfuPublicationGeneration":null}`, false},
|
|
} {
|
|
assertServerMessage(t, "assignment "+sample.name,
|
|
routeUpdate(sample.assignment), sample.want)
|
|
}
|
|
|
|
assertServerMessage(t, "prepare without a candidate",
|
|
[]byte(`{"type":"route-update","revision":9,"phase":"prepare","assignment":`+
|
|
testAssignmentNone+`}`), false)
|
|
assertServerMessage(t, "active with a candidate",
|
|
[]byte(`{"type":"route-update","revision":9,"phase":"active","assignment":`+
|
|
testAssignmentNone+`,"candidate":{"childPeerId":"child_12345678",`+
|
|
`"connectionId":"connection_12345678","transport":"direct","qualityProbe":false}}`),
|
|
false)
|
|
assertServerMessage(t, "prepare with a room-wide field",
|
|
[]byte(`{"type":"route-update","revision":9,"phase":"prepare","assignment":`+
|
|
testAssignmentNone+`,"roomAssignments":[]}`), false)
|
|
}
|
|
|
|
// "accepts only bounded connection-attempt progress on a prepared route".
|
|
func TestPreparedRouteCandidate(t *testing.T) {
|
|
prepared := func(candidate string) []byte {
|
|
return []byte(`{"type":"route-update","revision":9,"phase":"prepare","assignment":` +
|
|
testAssignmentNone + `,"candidate":` + candidate + `}`)
|
|
}
|
|
const base = `"childPeerId":"child_12345678","connectionId":"connection_12345678",` +
|
|
`"transport":"direct","qualityProbe":false`
|
|
for _, sample := range []struct {
|
|
attempt string
|
|
want bool
|
|
}{
|
|
{`{"current":1,"total":3}`, true},
|
|
{`{"current":2,"total":3}`, true},
|
|
{`{"current":3,"total":3}`, true},
|
|
{`{"current":0,"total":3}`, false},
|
|
{`{"current":4,"total":3}`, false},
|
|
{`{"current":1,"total":4}`, true},
|
|
{`{"current":4,"total":4}`, true},
|
|
{`{"current":5,"total":4}`, false},
|
|
{`{"current":1,"total":5}`, false},
|
|
{`{"current":1.5,"total":3}`, false},
|
|
} {
|
|
assertServerMessage(t, "connectionAttempt "+sample.attempt,
|
|
prepared(`{`+base+`,"connectionAttempt":`+sample.attempt+`}`), sample.want)
|
|
}
|
|
assertServerMessage(t, "candidate without an attempt", prepared(`{`+base+`}`), true)
|
|
assertServerMessage(t, "candidate with an unknown key",
|
|
prepared(`{`+base+`,"unexpected":null}`), false)
|
|
assertServerMessage(t, "candidate with a null attempt",
|
|
prepared(`{`+base+`,"connectionAttempt":null}`), false)
|
|
}
|
|
|
|
// "accepts only canonical server-derived viewer evidence envelopes" and
|
|
// "keeps route messages strict" for sfu-config.
|
|
func TestForwardedEvidenceAndSfuConfig(t *testing.T) {
|
|
const metrics = `{"natTraversalPath":"ordinary","width":null,"height":null,` +
|
|
`"framesPerSecond":null,"bitrateKbps":null,"packetsReceivedDelta":null,` +
|
|
`"packetsLostDelta":null,"rttMs":null,"jitterMs":null,"framesDecodedDelta":null,` +
|
|
`"framesDroppedDelta":null,"decodeMsPerFrame":null,"freezeCountDelta":null,` +
|
|
`"freezeDurationMsDelta":null,"pauseCountDelta":null,"pauseDurationMsDelta":null,` +
|
|
`"codec":null,"codecProfile":null,"codecParameters":null,"audioBitrateKbps":null,` +
|
|
`"audioPacketLossPercent":null,"audioJitterMs":null,"audioVideoPlayoutDeltaMs":null,` +
|
|
`"videoJitterBufferDelayMs":null,"audioJitterBufferDelayMs":null,` +
|
|
`"audioConcealedSamplesPercent":null,"audioConcealmentEventsDelta":null,` +
|
|
`"audioCodec":null}`
|
|
forwarded := func(extra string, upstream string) []byte {
|
|
return []byte(`{"type":"viewer-quality-evidence","viewerPeerId":"viewer_12345678",` +
|
|
`"upstream":` + upstream + `,"guard":{"connectionId":"connection_12345678",` +
|
|
`"routeRevision":0,"presentationEpoch":0},"sequence":0,"windowMs":2000,` +
|
|
`"metrics":` + metrics + extra + `}`)
|
|
}
|
|
assertServerMessage(t, "peer upstream",
|
|
forwarded("", `{"kind":"peer","peerId":"host_12345678"}`), true)
|
|
assertServerMessage(t, "sfu upstream", forwarded("", `{"kind":"sfu"}`), true)
|
|
assertServerMessage(t, "no upstream", forwarded("", `{"kind":"none"}`), false)
|
|
assertServerMessage(t, "leaked room id",
|
|
forwarded(`,"roomId":"1234"`, `{"kind":"sfu"}`), false)
|
|
assertServerMessage(t, "missing upstream",
|
|
[]byte(`{"type":"viewer-quality-evidence","viewerPeerId":"viewer_12345678",`+
|
|
`"guard":{"connectionId":"connection_12345678","routeRevision":0,`+
|
|
`"presentationEpoch":0},"sequence":0,"windowMs":2000,"metrics":`+metrics+`}`), false)
|
|
|
|
assertServerMessage(t, "sfu config",
|
|
[]byte(`{"type":"sfu-config","revision":9,"publicationGeneration":"publication_12345678","connectionId":"connection_12345678"}`), true)
|
|
assertServerMessage(t, "sfu config missing connection fence",
|
|
[]byte(`{"type":"sfu-config","revision":9,"publicationGeneration":"publication_12345678"}`), false)
|
|
}
|
|
|
|
// zod's z.number().int() accepts an integral JSON number in any spelling.
|
|
func TestIntAcceptsIntegralNumberForms(t *testing.T) {
|
|
for _, sample := range []struct {
|
|
raw string
|
|
want bool
|
|
}{
|
|
{`7`, true}, {`7.0`, true}, {`7e0`, true}, {`1e3`, true},
|
|
{`7.5`, false}, {`"7"`, false}, {`true`, false},
|
|
} {
|
|
assertClientMessage(t, "revision "+sample.raw,
|
|
[]byte(`{"type":"route-media-unavailable","revision":`+sample.raw+`}`), sample.want)
|
|
}
|
|
}
|
|
|
|
// JSON.parse keeps the last occurrence of a duplicate key.
|
|
func TestDuplicateKeysKeepTheLastOccurrence(t *testing.T) {
|
|
message, err := DecodeClientMessage(
|
|
[]byte(`{"type":"relay-capacity","downstreamEdges":9,"downstreamEdges":2}`))
|
|
if err != nil {
|
|
t.Fatalf("the last occurrence must win: %v", err)
|
|
}
|
|
if capacity, ok := message.(RelayCapacityMessage); !ok || capacity.DownstreamEdges != 2 {
|
|
t.Fatalf("decoded %#v, want downstreamEdges 2", message)
|
|
}
|
|
if _, err := DecodeClientMessage(
|
|
[]byte(`{"type":"relay-capacity","downstreamEdges":2,"downstreamEdges":9}`)); err == nil {
|
|
t.Fatal("the last occurrence must be the one validated")
|
|
}
|
|
// A duplicated object is replaced, not merged.
|
|
if _, err := DecodeClientMessage(
|
|
[]byte(`{"type":"set-quality-settings","qualitySettings":{"resolution":"480p"},` +
|
|
`"qualitySettings":` + testQualitySettings + `}`)); err != nil {
|
|
t.Fatalf("a duplicated object must be replaced wholesale: %v", err)
|
|
}
|
|
}
|
|
|
|
// JSON.stringify does not escape "<", ">" or "&"; display names may hold them.
|
|
func TestEncodeServerMessageDoesNotEscapeHTML(t *testing.T) {
|
|
encoded, err := EncodeServerMessage(ViewerPresenceMessage{
|
|
Type: "viewer-presence",
|
|
Viewers: []ParticipantPresenceEntry{
|
|
HostPresenceEntry("host_12345678", "a<b>&c"),
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Contains(encoded, []byte(`"displayName":"a<b>&c"`)) {
|
|
t.Fatalf("display name must survive unescaped: %s", encoded)
|
|
}
|
|
if bytes.HasSuffix(encoded, []byte("\n")) {
|
|
t.Fatalf("the encoder newline must be dropped: %q", encoded)
|
|
}
|
|
}
|
|
|
|
// Empty collections encode as [] the way the TypeScript literals do.
|
|
func TestEncodeServerMessageEmitsEmptyArrays(t *testing.T) {
|
|
encoded, err := EncodeServerMessage(ViewerPresenceMessage{Type: "viewer-presence"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(encoded) != `{"type":"viewer-presence","viewers":[]}` {
|
|
t.Fatalf("got %s", encoded)
|
|
}
|
|
encoded, err = EncodeServerMessage(RouteDiagnosticSnapshotMessage{
|
|
Type: "route-diagnostic-snapshot"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(encoded) !=
|
|
`{"type":"route-diagnostic-snapshot","snapshot":{"children":[],"operation":null}}` {
|
|
t.Fatalf("got %s", encoded)
|
|
}
|
|
}
|
|
|
|
// The authenticated key order follows the object literal in signaling.ts.
|
|
func TestAuthenticatedKeyOrder(t *testing.T) {
|
|
paused := true
|
|
encoded, err := EncodeServerMessage(AuthenticatedViewerMessage{
|
|
Type: "authenticated", Protocol: SignalingProtocol, PeerID: "viewer_12345678",
|
|
MaxViewers: 8, EndpointMediaCopyCapacity: 2, HostOnline: true, HostPaused: &paused,
|
|
IceConfig: IceConfig{}, RoutePolicy: DefaultRoutePolicy, CodeEntryPolicy: CodeEntryOpen,
|
|
ViewerAuthorizationGeneration: "viewer_generation_12345678",
|
|
MediaMode: "peer-assisted", RouteRevision: 0,
|
|
QualitySettings: DefaultQualitySettings, Role: RoleViewer,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var order []string
|
|
decoder := json.NewDecoder(bytes.NewReader(encoded))
|
|
if _, err := decoder.Token(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for decoder.More() {
|
|
key, err := decoder.Token()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
order = append(order, key.(string))
|
|
var skip json.RawMessage
|
|
if err := decoder.Decode(&skip); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
want := []string{
|
|
"type", "protocol", "peerId", "maxViewers",
|
|
"endpointMediaCopyCapacity", "hostOnline", "hostPaused", "connectionId",
|
|
"iceConfig", "routePolicy", "codeEntryPolicy", "viewerAuthorizationGeneration",
|
|
"mediaMode", "shareGeneration", "routeRevision", "routeAssignment",
|
|
"qualitySettings", "role",
|
|
}
|
|
if strings.Join(order, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("key order\n got: %v\nwant: %v", order, want)
|
|
}
|
|
}
|
|
|
|
// Optional keys reject an explicit null; nullable keys reject absence.
|
|
func TestOptionalAndNullableAreExact(t *testing.T) {
|
|
for _, sample := range []struct {
|
|
name string
|
|
raw string
|
|
want bool
|
|
}{
|
|
{"null optional shareGeneration",
|
|
`{"type":"stop-sharing","shareGeneration":null}`, false},
|
|
{"absent optional shareGeneration", `{"type":"stop-sharing"}`, true},
|
|
{"null optional targetPeerId",
|
|
`{"type":"restart-request","targetPeerId":null,` +
|
|
`"connectionId":"connection_12345678","rebuild":true}`, false},
|
|
{"null optional qualityApproved",
|
|
`{"type":"route-ready","revision":1,"phase":"prepare","qualityApproved":null}`, false},
|
|
{"null nullable connectionId",
|
|
`{"type":"route-failed","revision":1,"phase":"active","connectionId":null}`, true},
|
|
{"absent nullable connectionId",
|
|
`{"type":"route-failed","revision":1,"phase":"active"}`, false},
|
|
} {
|
|
assertClientMessage(t, sample.name, []byte(sample.raw), sample.want)
|
|
}
|
|
}
|
|
|
|
// A host authenticate without routePolicy takes DEFAULT_ROUTE_POLICY.
|
|
func TestAuthenticateConnectionProgressOptIn(t *testing.T) {
|
|
for _, role := range []string{"host", "viewer"} {
|
|
base := `"type":"authenticate","protocol":"` + SignalingProtocol + `","roomId":"1234","clientId":"client_12345678","role":"` + role + `"`
|
|
if role == "host" {
|
|
base += `,"token":"` + repeat("a", 43) + `"`
|
|
}
|
|
for _, value := range []string{"true", "false", "null", "4", `"true"`} {
|
|
assertClientMessage(t, role+value, []byte(`{`+base+`,"connectionAttemptProgress4":`+value+`}`), value == "true")
|
|
}
|
|
assertClientMessage(t, role+" baseline", []byte(`{`+base+`}`), true)
|
|
}
|
|
}
|
|
|
|
func TestAuthenticateAppliesTheRoutePolicyDefault(t *testing.T) {
|
|
message, err := DecodeClientMessage([]byte(
|
|
`{"type":"authenticate","protocol":"piik-v23","roomId":"1234","role":"host",` +
|
|
`"token":"` + repeat("a", 43) + `","clientId":"client_12345678"}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
host, ok := message.(AuthenticateHostMessage)
|
|
if !ok {
|
|
t.Fatalf("decoded %#v, want AuthenticateHostMessage", message)
|
|
}
|
|
if host.RoutePolicy != DefaultRoutePolicy {
|
|
t.Fatalf("routePolicy = %+v, want %+v", host.RoutePolicy, DefaultRoutePolicy)
|
|
}
|
|
}
|
|
|
|
// The sealed unions accept nothing outside their own message structs.
|
|
func TestDecodeRejectsUnknownEnvelopes(t *testing.T) {
|
|
for _, raw := range []string{`{}`, `[]`, `null`, `"authenticate"`, `{"type":42}`,
|
|
`{"type":"unknown"}`, `{"kind":"signal"}`} {
|
|
if _, err := DecodeClientMessage([]byte(raw)); err == nil {
|
|
t.Errorf("client decode accepted %s", raw)
|
|
}
|
|
if _, err := DecodeServerMessage([]byte(raw)); err == nil {
|
|
t.Errorf("server decode accepted %s", raw)
|
|
}
|
|
}
|
|
}
|