Prove video flows: an e2e suite with a real WebRTC viewer

e2e-edge.sh proves the camera signalling relay round-trips, and says so
honestly - it posts a stub SDP with go2rtc absent and accepts a 503 that
came back from the gateway. But "the offer round-trips" says nothing
about whether anyone would see a picture, and it once read as more than
it was: a stray go2rtc left over from an earlier run answered the stub
with an HTTP 500 and the test still printed ok. Video had never been
shown to flow.

scripts/e2e-camera.sh runs the real backend, the real proxy and the real
go2rtc against the H2C, and scripts/e2e-camera-peer.ts plays the
browser's part with werift: createOffer, relay through the backend,
setRemoteDescription, then ICE, DTLS and SRTP, counting RTP out the far
end. Measured: 124-149 packets and ~119-147 KB of payload within a
second or two. It sends no printer command; the camera is a pull and the
MQTT subscription is passive. It refuses to start if a go2rtc is already
running, and fails if one survives cleanup.

werift is a devDependency for that one file; nothing under src/ imports
it. Its 30 resolved packages were audited against LICENSES.md: 26 MIT,
Apache-2.0, BSD-3-Clause, Unlicense, and one MPL-2.0 (mediabunny), which
the policy allows while we do not edit its files.

Smaller fixes in the same pass:

- e2e-edge.sh now unsets the TURN variables. It asserts TURN is off, but
  sourcing dev-secrets.sh turns it on, and the failure message then
  printed the live Cloudflare username and credential. The new script
  prints only the camera id and a count, never the /stream body.
- the last hardcoded `psql -h localhost` in e2e-edge.sh now uses
  DATABASE_URL, like the rest.
This commit is contained in:
2026-10-23 16:01:47 +00:00
parent c312e37b4a
commit ac484a99ba
4 changed files with 365 additions and 3 deletions
+128
View File
@@ -0,0 +1,128 @@
/**
* A WebRTC receiver that is not a browser, for scripts/e2e-camera.sh.
*
* What it proves that nothing else here does: that video actually arrives.
* The signalling relay has always been testable with a canned SDP, and the
* go2rtc-to-printer leg was confirmed by hand, but "the offer round-trips"
* says nothing about whether a viewer would see a picture. This runs the
* browser's half of the exchange for real - createOffer, relay through the
* backend, setRemoteDescription, then ICE, DTLS and SRTP to go2rtc - and
* counts the RTP packets that come out the other end.
*
* It also exists because the suite's earlier stub offer (`v=0 ... t=0 0`, no
* media section, no ice-ufrag) could only ever be refused, and the test
* counted that refusal as success. A real offer has to be accepted.
*
* Results are printed as `ok<TAB>label` / `FAILED<TAB>label`, the convention
* run_probe in e2e-real-services.sh also reads. werift is a devDependency of
* this repo for this file alone; nothing under src/ imports it.
*
* Environment: BASE, PRINTER_UUID, ACCESS_TOKEN. The token never reaches a
* log line.
*/
import { RTCPeerConnection, RTCRtpCodecParameters } from "werift";
const base = process.env.BASE!;
const printer = process.env.PRINTER_UUID!;
const token = process.env.ACCESS_TOKEN!;
const MIN_PACKETS = Number(process.env.MIN_RTP_PACKETS ?? 50);
const WAIT_MS = Number(process.env.MEDIA_WAIT_MS ?? 25_000);
let failed = false;
const say = (good: boolean, label: string, detail = "") => {
if (!good) failed = true;
console.log(`${good ? "ok" : "FAILED"}\t${label}${detail ? ` (${detail})` : ""}`);
};
// werift offers only VP8 by default, and a Bambu camera is H.264 (High, level
// 4.1), so go2rtc refused the first version of this offer with "codecs not
// matched: video:H264 => video:VP8" - correctly. Every browser offers H.264,
// so offering it here is what a real viewer does, not a concession to the test.
// The parameters are Chrome's: packetization-mode=1, constrained baseline.
const pc = new RTCPeerConnection({
iceServers: [],
codecs: {
video: [
new RTCRtpCodecParameters({
mimeType: "video/H264",
clockRate: 90000,
rtcpFeedback: [{ type: "nack" }, { type: "nack", parameter: "pli" }, { type: "goog-remb" }],
parameters: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f",
}),
],
},
});
const transceiver = pc.addTransceiver("video", { direction: "recvonly" });
let packets = 0;
let bytes = 0;
transceiver.onTrack.subscribe((track) => {
track.onReceiveRtp.subscribe((rtp) => {
packets += 1;
bytes += rtp.payload.length;
});
});
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const offerSdp = pc.localDescription!.sdp;
say(/^m=video /m.test(offerSdp) && /^a=ice-ufrag:/m.test(offerSdp),
"the offer has a video section and an ice-ufrag, unlike the stub it replaces");
// A player that gets "stream not found" just asks again, and so does this: the
// gateway advertises a camera as soon as the printer reports its URL, a moment
// before go2rtc has been restarted with the stream in its config. Bounded, and
// the attempt count is reported, so a window that grows shows up as a number
// rather than as a flaky pass. Only that one reason is retried - a codec
// mismatch or any other refusal is a real failure and must fail at once.
const RETRY_MS = Number(process.env.OFFER_RETRY_MS ?? 20_000);
const started = Date.now();
let attempts = 0;
let res: Response;
let bodyText = "";
do {
attempts += 1;
res = await fetch(`${base}/printers/${printer}/webrtc`, {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify({ sdp: offerSdp }),
});
bodyText = await res.text();
if (res.status !== 503 || !bodyText.includes("stream not found")) break;
await Bun.sleep(1000);
} while (Date.now() - started < RETRY_MS);
say(res.status === 200, "the relayed offer is answered 200",
`HTTP ${res.status} after ${attempts} attempt(s), ${Math.round((Date.now() - started) / 1000)}s`);
if (res.status !== 200) {
// The gateway's reason is the whole diagnosis, and it carries no secrets.
console.log(`# ${bodyText.slice(0, 300).replace(/\s+/g, " ")}`);
await pc.close();
process.exit(1);
}
const answer = (JSON.parse(bodyText) as { sdp?: string }).sdp ?? "";
say(/^m=video /m.test(answer) && /H264\/90000/i.test(answer),
"the answer negotiates H264 video, which is what the printer sends");
await pc.setRemoteDescription({ type: "answer", sdp: answer });
const connected = await new Promise<boolean>((resolve) => {
const timer = setTimeout(() => resolve(false), WAIT_MS);
pc.connectionStateChange.subscribe((state) => {
if (state === "connected") { clearTimeout(timer); resolve(true); }
if (state === "failed") { clearTimeout(timer); resolve(false); }
});
if (pc.connectionState === "connected") { clearTimeout(timer); resolve(true); }
});
say(connected, "ICE and DTLS completed with go2rtc", `state=${pc.connectionState}`);
if (connected) {
const deadline = Date.now() + WAIT_MS;
while (packets < MIN_PACKETS && Date.now() < deadline) await Bun.sleep(250);
}
say(packets >= MIN_PACKETS,
`video RTP reached the viewer: at least ${MIN_PACKETS} packets`,
`${packets} packets, ${bytes} payload bytes`);
await pc.close();
process.exit(failed ? 1 : 0);