monal-rebuild/test/jingle.test.ts
pijukka e9adbf9c5d Calls: video — video-call button, camera on/off and switch, self-view
Video calls announce video in the JMI propose (Monal aborts video it
wasn't told about); without a camera the call stays audio. Video calls
start on the loudspeaker, like Monal. Monal's duplicate H.264
profile_level_id is written once into the SDP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 14:13:38 +02:00

228 lines
12 KiB
TypeScript

import { test } from "node:test";
import assert from "node:assert/strict";
import parse from "ltx/lib/parse.js";
import { xml } from "@xmpp/client";
import { candidateToElement, candidateToSdp, iceCredentials, jingleToSdp, mediaOf, sdpToJingle } from "../src/call/jingle.ts";
const el = (s: string) => parse(s) as any;
// createOffer() of HeadlessChrome 154 with one audio track (2026-09-29).
const chromeOffer = [
"v=0",
"o=- 4672937091243051060 2 IN IP4 127.0.0.1",
"s=-",
"t=0 0",
"a=group:BUNDLE 0",
"a=extmap-allow-mixed",
"a=msid-semantic: WMS 03a9ddca-6bcd-4716-96fe-c8c235f860dc",
"m=audio 9 UDP/TLS/RTP/SAVPF 111 63 9 0 8 13 110 126",
"c=IN IP4 0.0.0.0",
"a=rtcp:9 IN IP4 0.0.0.0",
"a=ice-ufrag:qk6w",
"a=ice-pwd:ZG57QvXY+/NrUiHOgyhdPPGf",
"a=ice-options:trickle",
"a=fingerprint:sha-256 AA:3E:52:FB:2D:C7:B9:98:4D:28:B7:A3:36:B4:A8:91:5D:91:83:C6:99:71:2F:06:7A:B1:55:81:B1:C2:2B:48",
"a=setup:actpass",
"a=mid:0",
"a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level",
"a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time",
"a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01",
"a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid",
"a=sendrecv",
"a=msid:03a9ddca-6bcd-4716-96fe-c8c235f860dc afdb8a22-1c3e-4d29-87c4-243624a64a4b",
"a=rtcp-mux",
"a=rtcp-rsize",
"a=rtpmap:111 opus/48000/2",
"a=rtcp-fb:111 transport-cc",
"a=fmtp:111 minptime=10;useinbandfec=1",
"a=rtpmap:63 red/48000/2",
"a=fmtp:63 111/111",
"a=rtpmap:9 G722/8000",
"a=rtpmap:0 PCMU/8000",
"a=rtpmap:8 PCMA/8000",
"a=rtpmap:13 CN/8000",
"a=rtpmap:110 telephone-event/48000",
"a=rtpmap:126 telephone-event/8000",
"a=ssrc:743182082 cname:m9CPfpHn7vEwqprQ",
"a=ssrc:743182082 msid:03a9ddca-6bcd-4716-96fe-c8c235f860dc afdb8a22-1c3e-4d29-87c4-243624a64a4b",
"",
].join("\r\n");
// A session-initiate as Monal's sdp-to-jingle writes it (shape from its
// SDPTest.m), audio only, with a relay candidate on a documentation address.
const monalInitiate = `<jingle xmlns="urn:xmpp:jingle:1" action="session-initiate" sid="0d6e9a52-8c5b-4c3e-9f5e-2a1b3c4d5e6f">
<group xmlns="urn:xmpp:jingle:apps:grouping:0" semantics="BUNDLE"><content name="0"/></group>
<content xmlns="urn:xmpp:jingle:1" creator="initiator" senders="both" name="0">
<transport xmlns="urn:xmpp:jingle:transports:ice-udp:1" pwd="+g6eHJ3YWHoXNdOM60q0LH85" ufrag="LlJY">
<candidate xmlns="urn:xmpp:jingle:transports:ice-udp:1" id="563352000" component="1" foundation="1076231993" generation="0" ip="198.51.100.52" port="50002" priority="41885694" protocol="udp" rel-addr="0.0.0.0" rel_port="0" type="relay"/>
<trickle xmlns="http://gultsch.de/xmpp/drafts/jingle/transports/ice-udp/option"/>
<renomination xmlns="http://gultsch.de/xmpp/drafts/jingle/transports/ice-udp/option"/>
<fingerprint xmlns="urn:xmpp:jingle:apps:dtls:0" hash="sha-256" setup="actpass">3D:7C:7D:EC:CC:84:39:F2:46:A9:10:03:9E:09:FD:4D:E7:9A:49:6D:54:84:F5:5A:10:C1:09:A3:1F:B1:68:D2</fingerprint>
</transport>
<description xmlns="urn:xmpp:jingle:apps:rtp:1" media="audio">
<extmap-allow-mixed/>
<rtp-hdrext xmlns="urn:xmpp:jingle:apps:rtp:rtp-hdrext:0" id="1" uri="urn:ietf:params:rtp-hdrext:ssrc-audio-level"/>
<rtp-hdrext xmlns="urn:xmpp:jingle:apps:rtp:rtp-hdrext:0" id="4" uri="urn:ietf:params:rtp-hdrext:sdes:mid"/>
<rtcp-mux/>
<payload-type id="111" name="opus" clockrate="48000" channels="2">
<rtcp-fb xmlns="urn:xmpp:jingle:apps:rtp:rtcp-fb:0" type="transport-cc"/>
<parameter name="profile_level_id" value="4325392"/>
<parameter name="useinbandfec" value="true"/>
<parameter name="minptime" value="10"/>
</payload-type>
<payload-type id="63" name="red" clockrate="48000" channels="2">
<parameter name="profile_level_id" value="4325392"/>
<parameter name="encodings" value="111"/>
<parameter name="encodings" value="111"/>
</payload-type>
<payload-type id="0" name="PCMU" clockrate="8000"/>
<payload-type id="126" name="telephone-event" clockrate="8000"/>
<source xmlns="urn:xmpp:jingle:apps:rtp:ssma:0" ssrc="388312053">
<parameter name="cname" value="EKc2Iz0i3z5nyiVy"/>
<parameter name="msid" value="stream audio0"/>
</source>
</description>
</content>
</jingle>`;
const wrap = (children: any[]) => {
const j = xml("jingle", { xmlns: "urn:xmpp:jingle:1", action: "session-initiate", sid: "s1" });
j.append(...children);
// Through a string, as the peer would receive it.
return el(j.toString());
};
test("Chrome offer as Jingle", () => {
const [group, content] = sdpToJingle(chromeOffer, true);
assert.equal(group.attrs.semantics, "BUNDLE");
assert.deepEqual(group.getChildren("content").map((c: any) => c.attrs.name), ["0"]);
assert.equal(content.attrs.creator, "initiator");
assert.equal(content.attrs.name, "0");
assert.equal(content.attrs.senders, "both");
const desc = content.getChild("description", "urn:xmpp:jingle:apps:rtp:1");
assert.equal(desc.attrs.media, "audio");
const pts = desc.getChildren("payload-type");
assert.deepEqual(pts.map((p: any) => p.attrs.id), ["111", "63", "9", "0", "8", "13", "110", "126"]);
const opus = pts[0];
assert.deepEqual(opus.attrs, { id: "111", name: "opus", clockrate: "48000", channels: "2" });
assert.deepEqual(
opus.getChildren("parameter").map((p: any) => `${p.attrs.name}=${p.attrs.value}`),
["minptime=10", "useinbandfec=1"],
);
assert.equal(opus.getChild("rtcp-fb").attrs.type, "transport-cc");
assert.deepEqual(pts[1].getChildren("parameter").map((p: any) => p.attrs.value), ["111", "111"]);
assert.equal(pts[2].attrs.channels, undefined);
assert.equal(desc.getChildren("rtp-hdrext").length, 4);
assert.ok(desc.getChild("rtcp-mux"));
const source = desc.getChild("source", "urn:xmpp:jingle:apps:rtp:ssma:0");
assert.equal(source.attrs.ssrc, "743182082");
assert.equal(source.getChildren("parameter")[0].attrs.value, "m9CPfpHn7vEwqprQ");
const transport = content.getChild("transport", "urn:xmpp:jingle:transports:ice-udp:1");
assert.equal(transport.attrs.ufrag, "qk6w");
assert.equal(transport.attrs.pwd, "ZG57QvXY+/NrUiHOgyhdPPGf");
const fp = transport.getChild("fingerprint", "urn:xmpp:jingle:apps:dtls:0");
assert.equal(fp.attrs.hash, "sha-256");
assert.equal(fp.attrs.setup, "actpass");
assert.match(fp.text(), /^AA:3E:52/);
});
test("Chrome offer survives the round trip through Jingle", () => {
const sdp = jingleToSdp(wrap(sdpToJingle(chromeOffer, true)), "s1", true);
for (const line of [
"a=group:BUNDLE 0",
"m=audio 9 UDP/TLS/RTP/SAVPF 111 63 9 0 8 13 110 126",
"a=ice-ufrag:qk6w",
"a=ice-pwd:ZG57QvXY+/NrUiHOgyhdPPGf",
"a=setup:actpass",
"a=mid:0",
"a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01",
"a=sendrecv",
"a=rtcp-mux",
"a=rtpmap:111 opus/48000/2",
"a=rtcp-fb:111 transport-cc",
"a=fmtp:111 minptime=10;useinbandfec=1",
"a=fmtp:63 111/111",
"a=rtpmap:9 G722/8000",
"a=ssrc:743182082 cname:m9CPfpHn7vEwqprQ",
]) {
assert.ok(sdp.split("\r\n").includes(line), `missing ${line}\n${sdp}`);
}
assert.match(sdp, /^v=0\r\n/);
assert.match(sdp, /a=fingerprint:sha-256 AA:3E:52:FB/);
});
test("Monal's session-initiate as SDP for the browser", () => {
const sdp = jingleToSdp(el(monalInitiate), "0d6e9a52-8c5b-4c3e-9f5e-2a1b3c4d5e6f", true).split("\r\n");
assert.ok(sdp.includes("m=audio 9 UDP/TLS/RTP/SAVPF 111 63 0 126"));
// Booleans back as 1, the H.264 default dropped from audio codecs.
assert.ok(sdp.includes("a=fmtp:111 useinbandfec=1;minptime=10"));
assert.ok(sdp.includes("a=fmtp:63 111/111"));
assert.ok(sdp.includes("a=ice-options:trickle renomination"));
assert.ok(sdp.includes("a=fingerprint:sha-256 3D:7C:7D:EC:CC:84:39:F2:46:A9:10:03:9E:09:FD:4D:E7:9A:49:6D:54:84:F5:5A:10:C1:09:A3:1F:B1:68:D2"));
assert.ok(sdp.includes("a=candidate:1076231993 1 udp 41885694 198.51.100.52 50002 typ relay raddr 0.0.0.0 rport 0 generation 0"));
assert.ok(sdp.includes("a=ssrc:388312053 msid:stream audio0"));
assert.ok(sdp.includes("a=extmap-allow-mixed"));
assert.ok(!sdp.some((l) => l.includes("profile")));
});
test("Monal's H.264 parameters as one fmtp line each", () => {
const video = el(`<jingle xmlns="urn:xmpp:jingle:1" action="session-initiate" sid="x">
<content xmlns="urn:xmpp:jingle:1" creator="initiator" senders="both" name="1">
<description xmlns="urn:xmpp:jingle:apps:rtp:1" media="video">
<payload-type id="102" name="H264" clockrate="90000">
<parameter name="packetization_mode" value="1"/>
<parameter name="level_asymmetry_allowed" value="true"/>
<parameter name="profile_level_id" value="4325407"/>
<parameter name="profile_level_id" value="4325407"/>
</payload-type>
<payload-type id="103" name="rtx" clockrate="90000"><parameter name="apt" value="102"/></payload-type>
</description>
<transport xmlns="urn:xmpp:jingle:transports:ice-udp:1" ufrag="u" pwd="p"/>
</content>
</jingle>`);
const sdp = jingleToSdp(video, "x", true).split("\r\n");
assert.ok(sdp.includes("m=video 9 UDP/TLS/RTP/SAVPF 102 103"));
assert.ok(sdp.includes("a=fmtp:102 packetization-mode=1;level-asymmetry-allowed=1;profile-level-id=42001f"));
assert.ok(sdp.includes("a=fmtp:103 apt=102"));
});
test("senders follow who sends, relative to the initiator", () => {
const answer = chromeOffer.replace("a=sendrecv", "a=recvonly").replace("a=setup:actpass", "a=setup:active");
const [, content] = sdpToJingle(answer, false);
// The responder only receives: only the initiator sends.
assert.equal(content.attrs.senders, "initiator");
assert.equal(content.getChild("transport").getChild("fingerprint").attrs.setup, "active");
// Read back by the initiator, it is the responder's recvonly again.
assert.ok(jingleToSdp(wrap([content]), "s1", false).includes("a=recvonly"));
assert.ok(jingleToSdp(wrap([content]), "s1", true).includes("a=sendonly"));
});
test("candidates both ways", () => {
const srflx = "candidate:842163049 1 udp 1677729535 203.0.113.7 46154 typ srflx raddr 192.0.2.10 rport 46154 generation 0 ufrag qk6w network-id 1 network-cost 10";
const c = candidateToElement(srflx)!;
assert.equal(c.attrs.type, "srflx");
assert.equal(c.attrs.ip, "203.0.113.7");
assert.equal(c.attrs["rel-addr"], "192.0.2.10");
assert.equal(c.attrs["rel-port"], "46154");
assert.equal(c.attrs.generation, "0");
assert.equal(c.attrs.network, "1");
assert.ok(c.attrs.id);
assert.equal(candidateToSdp(c), "candidate:842163049 1 udp 1677729535 203.0.113.7 46154 typ srflx raddr 192.0.2.10 rport 46154 generation 0");
const tcp = candidateToElement("a=candidate:4250347086 1 tcp 1518214911 192.0.2.10 9 typ host tcptype active generation 0 network-id 1")!;
assert.equal(tcp.attrs["tcp-type"], "active");
assert.equal(candidateToSdp(tcp), "candidate:4250347086 1 tcp 1518214911 192.0.2.10 9 typ host tcptype active generation 0");
assert.equal(candidateToElement("candidate:bogus"), null);
});
test("ICE credentials per content and media of a propose", () => {
assert.deepEqual([...iceCredentials(chromeOffer)], [["0", { ufrag: "qk6w", pwd: "ZG57QvXY+/NrUiHOgyhdPPGf" }]]);
const propose = el(`<propose xmlns="urn:xmpp:jingle-message:0" id="x"><description xmlns="urn:xmpp:jingle:apps:rtp:1" media="audio"/><description xmlns="urn:xmpp:jingle:apps:rtp:1" media="video"/></propose>`);
assert.deepEqual(mediaOf(propose), ["audio", "video"]);
assert.deepEqual(mediaOf(el(monalInitiate)), ["audio"]);
});
test("Opus comes first, whatever order the peer listed", () => {
const j = el(monalInitiate.replace(/(<payload-type id="111"[\s\S]*?<\/payload-type>)([\s\S]*?)(<payload-type id="0")/, "$2$1$3"));
const m = jingleToSdp(j, "s", true).split("\r\n").find((l) => l.startsWith("m=audio"));
assert.equal(m, "m=audio 9 UDP/TLS/RTP/SAVPF 111 63 0 126");
});