DEV Community

AiLaoHuYu
AiLaoHuYu

Posted on

Testing WebRTC without a camera: a loopback self-test that proves the whole path

The hardest part of testing a WebRTC pipeline is that the real one needs a camera, a microphone, a user who clicks allow, and HTTPS. On a build machine none of that exists, so the pipeline tests get skipped and the first honest test is a human on a laptop.

There is a better fixture. Put a canvas on one end and a <video> element on the other, and send the canvas through the actual stack — publisher, signaling, SFU, viewer — so what the test exercises is the real path and not a mock.

The whole thing in one line: a canvas is an acceptable substitute for a camera, a timer is a better driver than an animation frame, and a moving timestamp is what proves the picture is live rather than merely present.

Replace the camera with a canvas

canvas.captureStream() returns a MediaStream with the same shape as a camera's, so everything downstream is unchanged:

const stream = cv.captureStream(30);
for (const track of stream.getVideoTracks()) {
  pc.addTrack(track, stream);
}
Enter fullscreen mode Exit fullscreen mode

No permission prompt, no device enumeration, no browser variation in which camera it picked. The test runs headless, on a server, in CI, and on a machine whose only camera is already in use by a call.

Draw something that can only be live

A static image is a trap. If the encoder dies and the last frame stays on screen, a static test pattern passes. Draw motion and a clock:

function draw() {
  const t = performance.now();
  ctx.fillStyle = '#101418';
  ctx.fillRect(0, 0, cv.width, cv.height);

  const x = ((t / 8) % (cv.width + 80)) - 80;   // a moving block
  ctx.fillStyle = '#5b8cff';
  ctx.fillRect(x, 120, 80, 80);

  ctx.fillStyle = '#e6e9ef';
  ctx.font = '20px monospace';
  ctx.fillText(new Date().toLocaleTimeString() + '.' + String(Math.floor(t % 1000)).padStart(3, '0'), 16, 34);
}
Enter fullscreen mode Exit fullscreen mode

The timestamp is the assertion. A frozen stream shows a stopped clock, and a stream that is alive shows a clock that keeps moving, so the test can tell "connected and carrying video" from "connected and showing the last frame forever" without a human looking at it.

Drive the canvas on a timer, not on requestAnimationFrame

This is the detail that costs an afternoon if you get it wrong. A background tab gets no animation frames, so a requestAnimationFrame draw loop stops the moment the tab is not visible — and the self-test then fails with a black or frozen viewer that has nothing to do with the network:

// Timer-driven, not requestAnimationFrame: a background tab gets no animation
// frames, which would silently stop the source and make the test look broken.
draw();
setInterval(draw, 33);
Enter fullscreen mode Exit fullscreen mode

A 33 ms interval is roughly 30 fps and keeps producing frames while the tab is hidden, which is exactly when people leave a test running.

Count liveness two ways, because one of them stops in a hidden tab

requestVideoFrameCallback is the most accurate way to count painted frames, and it also stops firing when the tab is backgrounded. So count both painted frames and decoded frames, and let the test pass if either one is advancing:

if (video.requestVideoFrameCallback) {
  video.requestVideoFrameCallback(function tick() {
    painted += 1;            // frames the user could actually see
    video.requestVideoFrameCallback(tick);
  });
}

// getStats framesDecoded keeps advancing in a background tab.
setInterval(async () => {
  const s = await sample(pc, prev);
  prev = s;
  decoded = s.framesDecoded || 0;
}, 1000);
Enter fullscreen mode Exit fullscreen mode

One counter answers "can the user see it", the other answers "is the decoder still receiving". Using only the first produces a test that fails whenever it is not being watched, which trains everyone to ignore it.

Give every run its own room

If the server enforces one publisher per room — and the simple SFU design does — then two concurrent self-tests collide: the second publisher evicts the first, and both tests report a failure that is really a scheduling coincidence.

Derive the room name per visit instead of hardcoding it:

const ROOM = 'selftest-' + (
  globalThis.crypto?.randomUUID?.() ||
  Math.random().toString(36).slice(2, 10)
);
Enter fullscreen mode Exit fullscreen mode

That one line makes the test safe to run in parallel across browser tabs, machines and CI jobs with no coordination.

Mirror the state so automation can assert it

A test whose only output is pixels cannot be automated. Publish the state on a global object and assert on numbers:

const st = {
  publisher: 'connecting',
  viewer: 'connecting',
  frames: 0,      // frames painted, via requestVideoFrameCallback
  decoded: 0,     // frames decoded, via getStats - works in a background tab
  w: 0,
  h: 0,
  kbps: null,
  ice: '',
};
window.__st = st;
Enter fullscreen mode Exit fullscreen mode

Now the assertion is boring and reliable: wait for ice to be connected, wait for decoded to exceed a threshold, and confirm the resolution is what you asked for. A driver script can do that in a few lines, and a failure tells you which of the three did not happen.

What this test does and does not prove

It proves the path end to end: track creation, offer/answer through the server, the SFU's fan-out, decoding, and the resolution and bitrate the session settled on. It catches the majority of regressions, because most of them break the path rather than a corner of it.

It does not prove TURN works from outside your network — that needs a host with a different NAT, which is why the relay probe is a separate test. It does not prove camera or microphone behaviour, because there is no camera. And it does not prove quality under loss; for that you need a network emulator, not a loopback.

Those boundaries are worth stating in the test itself. A self-test that claims to cover everything gets trusted for everything and then quietly lies.

The fixture, in order

  1. Source from a canvas, not a camera — headless, no permissions, no HTTPS requirement.
  2. Draw a moving element and a monotonic timestamp so a frozen frame cannot pass.
  3. Drive the canvas with a timer, because a hidden tab stops animation frames.
  4. Count painted frames and decoded frames, and accept either as liveness.
  5. Give each run a unique room so parallel tests cannot evict each other.
  6. Expose the state on a global so assertions are numeric, not visual.
  7. Be explicit about what the test does not cover, and keep the relay and loss tests separate.

This loopback test ships with the self-hosted WebRTC kit — canvas publisher, server relay, viewer, and the same stats panel the production viewer uses — so the whole path can be verified before a camera or a network is involved:

https://lowlatencykit.com/

The free relay probe, which covers the one thing the loopback test cannot, needs no account:

https://lowlatencykit.com/test/turn/

Top comments (0)