feat: WebRTC MVP to Blax standard (namespace, README, tests, scaffolding)

- Rename namespace BlaxSoftware\LaravelWebRtc -> Blax\WebRtc (house standard) +
  update kernel imports to Blax\ReactPhpKernel
- README rewritten to the Blax OSS package principles: OSS banner, title+badges,
  emoji feature list, quick start, wire shape, config, architecture, star history
- Add PHPUnit suite (8 tests / 18 assertions, green): SignalingHandler routing
  (offer/ice/record/connect/bridge/close/error paths via a fake engine) +
  NullMediaEngine behaviour
- Add pint.json (laravel preset, applied), composer scripts, .gitattributes,
  test.sh (nix), CHANGELOG
- Verified end-to-end: composer install resolves blax-software/reactphp-kernel
  from Forgejo VCS and the suite runs green on it

rel learn-atc #1055 #1051 #1019

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blax Software 2026-07-07 13:28:49 +02:00
parent 4d79aee2cc
commit ac5d89111e
18 changed files with 362 additions and 91 deletions

7
.gitattributes vendored Normal file
View File

@ -0,0 +1,7 @@
/.gitattributes export-ignore
/.gitignore export-ignore
/phpunit.xml.dist export-ignore
/pint.json export-ignore
/test.sh export-ignore
/tests export-ignore
/.github export-ignore

24
CHANGELOG.md Normal file
View File

@ -0,0 +1,24 @@
# Changelog
All notable changes to `blax-software/laravel-webrtc` are documented here. This
project adheres to [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
Blax Software's backward-compatibility guarantee.
## [Unreleased]
### Added
- `WebRtcServiceProvider` + `config/webrtc.php` + the `webrtc:serve` command
(boots signaling on the shared `blax-software/reactphp-kernel`).
- `Contracts\MediaEngine` — the pluggable media backend
(`offer`/`addIceCandidate`/`startRecording`/`stopRecording`/`connectPeers`/`bridge`/`close`).
- `Media\NullMediaEngine` — signaling-only engine for dev/tests.
- `Media\Str0mMediaEngine` — seam for the Rust media core (str0m via ext-php-rs);
throws until the `blax_webrtc` extension is built (see `rust/`).
- `Signaling\SignalingHandler` — transport-agnostic router from a signaling
message to the media engine.
- `Server\SignalingServer` — a minimal kernel-attached (newline-JSON) signaling
transport; browsers ride the `laravel-websockets` WS transport into the same
handler.
- `rust/` — the `blax_webrtc` crate scaffold (str0m + ext-php-rs) documenting the
media plan.

123
README.md
View File

@ -1,73 +1,98 @@
# blax-software/laravel-webrtc [![Blax Software OSS](https://raw.githubusercontent.com/blax-software/laravel-workkit/master/art/oss-initiative-banner.svg)](https://github.com/blax-software)
WebRTC for Laravel, built on the shared # Laravel WebRTC
[`blax-software/reactphp-kernel`](https://git.blax.at/blax-software/reactphp-kernel)
backbone. PHP owns **signaling + orchestration**; a pluggable **media engine**
owns the real-time media (ICE/DTLS/SRTP/RTP/Opus) — with the production engine
backed by a **Rust core (`str0m`) via `ext-php-rs`**, so PHP never runs per-20ms
DSP.
``` [![PHP Version](https://img.shields.io/badge/php-%5E8.1-blue?style=flat-square)](https://php.net)
reactphp-kernel shared backbone (loop, IPC, signals) [![Laravel](https://img.shields.io/badge/laravel-10.x--12.x-orange?style=flat-square)](https://laravel.com)
└─ laravel-webrtc (this) signaling on the kernel [![Built on](https://img.shields.io/badge/on-reactphp--kernel-4b275f?style=flat-square)](https://github.com/blax-software/reactphp-kernel)
├─ NullMediaEngine signaling-only (dev/tests) [![Tests](https://img.shields.io/badge/tests-8%20passing-success?style=flat-square)](#testing)
└─ Str0mMediaEngine Rust/str0m core via ext-php-rs (rust/) [![Assertions](https://img.shields.io/badge/assertions-18-blue?style=flat-square)](#testing)
``` [![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)](LICENSE)
## What it's for WebRTC for Laravel on the shared [`reactphp-kernel`](https://github.com/blax-software/reactphp-kernel) backbone. PHP owns **signaling + orchestration**; a pluggable **media engine** owns the real-time media (ICE/DTLS/SRTP/RTP/Opus) — with the production engine backed by a **Rust core (`str0m`) via `ext-php-rs`**, so PHP never runs per-20ms DSP.
One media path that terminates server-side unlocks all of: ## Features
- **Record / intercept / log** every participant's audio (human or AI). - 🎙️ **Record / intercept / log** — the engine terminates media server-side, so every participant's audio (human or AI) can be captured
- **AI realtime, provider hidden** — the engine bridges a caller to an external - 🤖 **AI realtime, provider hidden** — bridge a caller to an external realtime model (e.g. OpenAI Realtime) over a server-side socket; the browser only ever talks to *you*, and the model is a config swap
realtime model (e.g. OpenAI Realtime) over a server-side socket; the browser - 👥 **Party-to-party & group calls** — instructor↔student, controller↔pilot, guest↔admin are just peers the engine routes between
only ever talks to *us*, and the model is a server-side config swap. - 🧩 **On the shared kernel** — signaling runs on `reactphp-kernel` alongside your WebSockets, one process, one loop
- **Party-to-party & group calls** — instructor↔student, controller↔pilot, - 🔌 **Pluggable `MediaEngine`**`NullMediaEngine` for signaling-only dev; `Str0mMediaEngine` for the real Rust-backed media
guest↔admin: participants are just peers the engine routes between. - 🦀 **Rust media core**`str0m` (sans-IO WebRTC) via `ext-php-rs`, shipped in `rust/`
- 🎛️ **Transport-agnostic signaling** — route browser frames from this package's raw server or from `laravel-websockets` into the same handler
## Status ## Installation
**Scaffold.** Signaling + the engine abstraction are real and runnable with
`NullMediaEngine`; the `Str0mMediaEngine` + `rust/` core are the seam to build
next (see `rust/README.md`). This package is the home for the WebRTC half of the
blax realtime stack (rel learn-atc #1051/#1019).
## Install
```bash ```bash
composer require blax-software/laravel-webrtc:dev-master composer require blax-software/laravel-webrtc
php artisan vendor:publish --tag=webrtc-config php artisan vendor:publish --tag="webrtc-config"
``` ```
It depends on `blax-software/reactphp-kernel` (resolved from git.blax.at via the It depends on [`blax-software/reactphp-kernel`](https://github.com/blax-software/reactphp-kernel), resolved from git.blax.at via the `repositories` entry in `composer.json`.
`repositories` entry in `composer.json`).
## Run ## Quick Start
Run the signaling server (on the shared kernel):
```bash ```bash
php artisan webrtc:serve # signaling on config(webrtc.host:port) php artisan webrtc:serve
``` ```
With `NullMediaEngine` (default) signaling runs and the media engine refuses With the default `NullMediaEngine`, signaling runs and the engine refuses `offer()` loudly. Build the Rust core (see [`rust/README.md`](rust/README.md)) and switch the engine for real calls:
`offer()` loudly. Build the Rust core and set
`WEBRTC_MEDIA_ENGINE=BlaxSoftware\LaravelWebRtc\Media\Str0mMediaEngine` for real
calls.
## Signaling wire shape (JSON, newline-delimited) ```dotenv
WEBRTC_MEDIA_ENGINE="Blax\WebRtc\Media\Str0mMediaEngine"
```
### Signaling wire shape (newline-delimited JSON)
```jsonc ```jsonc
{ "type": "offer", "peer": "p1", "sdp": "..." } // -> { "type": "answer", "peer": "p1", "sdp": "..." } { "type": "offer", "peer": "p1", "sdp": "..." } // -> { "type": "answer", "peer": "p1", "sdp": "..." }
{ "type": "ice", "peer": "p1", "candidate": {...} } // (no reply) { "type": "ice", "peer": "p1", "candidate": {...} } // (no reply)
{ "type": "record", "peer": "p1", "path": "..." } // -> { "type": "ack", ... } { "type": "record", "peer": "p1", "path": "..." } // -> { "type": "ack", ... }
{ "type": "connect", "peer": "p1", "other": "p2" } // party-to-party { "type": "connect", "peer": "p1", "other": "p2" } // party-to-party
{ "type": "bridge", "peer": "p1", "options": {...} } // dial an external realtime model { "type": "bridge", "peer": "p1", "options": {...} } // dial an external realtime model
{ "type": "close", "peer": "p1" } { "type": "close", "peer": "p1" }
``` ```
`SignalingHandler` is transport-agnostic — feed it frames from this package's raw `SignalingHandler` is transport-agnostic — feed it frames from the built-in raw `SignalingServer` today, or from the `laravel-websockets` WS transport (for browsers) later, without changing the media wiring.
`SignalingServer` today, or from the `laravel-websockets` WS transport (for
browsers) later, without changing the media wiring. ## Configuration
`config/webrtc.php` covers the signaling bind (`host`/`port`/`tls`), the `media_engine` binding, `recording` disk/path, advertised `ice_servers`, and the external `bridge` (e.g. OpenAI model + key). Swapping the realtime model — or the whole provider — is a server-side config change the browser never sees.
## Status
**MVP scaffold.** Signaling + the engine abstraction are real, tested, and runnable with `NullMediaEngine`; the `Str0mMediaEngine` + `rust/` core are the seam to build next. This is the WebRTC half of the blax realtime stack.
## Architecture
```
reactphp-kernel shared backbone (loop, IPC, signals)
└─ laravel-webrtc (this) signaling on the kernel
├─ NullMediaEngine signaling-only (dev/tests)
└─ Str0mMediaEngine Rust/str0m core via ext-php-rs (rust/)
```
## Testing
```bash
composer install
composer test
```
The signaling router and engines are unit-tested against a fake media engine — no browser, sockets, or external services required.
## License ## License
MIT © Blax Software MIT. See [LICENSE](LICENSE).
## Star History
<a href="https://www.star-history.com/?repos=blax-software%2Flaravel-webrtc&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=blax-software/laravel-webrtc&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=blax-software/laravel-webrtc&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=blax-software/laravel-webrtc&type=date&legend=top-left" />
</picture>
</a>

View File

@ -11,7 +11,7 @@
} }
], ],
"require": { "require": {
"php": ">=8.1", "php": "^8.1|^8.2|^8.3|^8.4",
"blax-software/reactphp-kernel": "dev-master", "blax-software/reactphp-kernel": "dev-master",
"react/event-loop": "^1.5", "react/event-loop": "^1.5",
"react/socket": "^1.15", "react/socket": "^1.15",
@ -19,7 +19,8 @@
"illuminate/console": "^10.0|^11.0|^12.0" "illuminate/console": "^10.0|^11.0|^12.0"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^10.5" "phpunit/phpunit": "^10.5|^11.0",
"laravel/pint": "^1.13"
}, },
"suggest": { "suggest": {
"ext-ffi": "Required by the Str0m media engine (Rust core exposed to PHP).", "ext-ffi": "Required by the Str0m media engine (Rust core exposed to PHP).",
@ -33,18 +34,23 @@
], ],
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"BlaxSoftware\\LaravelWebRtc\\": "src/" "Blax\\WebRtc\\": "src/"
} }
}, },
"autoload-dev": { "autoload-dev": {
"psr-4": { "psr-4": {
"BlaxSoftware\\LaravelWebRtc\\Tests\\": "tests/" "Blax\\WebRtc\\Tests\\": "tests/"
} }
}, },
"scripts": {
"test": "vendor/bin/phpunit",
"lint": "vendor/bin/pint",
"lint-test": "vendor/bin/pint --test"
},
"extra": { "extra": {
"laravel": { "laravel": {
"providers": [ "providers": [
"BlaxSoftware\\LaravelWebRtc\\WebRtcServiceProvider" "Blax\\WebRtc\\WebRtcServiceProvider"
] ]
} }
}, },

View File

@ -1,6 +1,6 @@
<?php <?php
use BlaxSoftware\LaravelWebRtc\Media\NullMediaEngine; use Blax\WebRtc\Media\NullMediaEngine;
return [ return [
/* /*

21
phpunit.xml.dist Normal file
View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
processIsolation="false"
stopOnFailure="false"
cacheDirectory=".phpunit.cache"
>
<testsuites>
<testsuite name="Laravel WebRTC Test Suite">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">./src</directory>
</include>
</source>
</phpunit>

4
pint.json Normal file
View File

@ -0,0 +1,4 @@
{
"preset": "laravel",
"rules": {}
}

View File

@ -2,13 +2,13 @@
declare(strict_types=1); declare(strict_types=1);
namespace BlaxSoftware\LaravelWebRtc\Console\Commands; namespace Blax\WebRtc\Console\Commands;
use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine; use Blax\ReactPhpKernel\Kernel;
use BlaxSoftware\LaravelWebRtc\Media\NullMediaEngine; use Blax\WebRtc\Contracts\MediaEngine;
use BlaxSoftware\LaravelWebRtc\Server\SignalingServer; use Blax\WebRtc\Media\NullMediaEngine;
use BlaxSoftware\LaravelWebRtc\Signaling\SignalingHandler; use Blax\WebRtc\Server\SignalingServer;
use BlaxSoftware\ReactPhpKernel\Kernel; use Blax\WebRtc\Signaling\SignalingHandler;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class StartWebRtcServer extends Command class StartWebRtcServer extends Command
@ -24,12 +24,12 @@ class StartWebRtcServer extends Command
$engine = app(config('webrtc.media_engine', NullMediaEngine::class)); $engine = app(config('webrtc.media_engine', NullMediaEngine::class));
if (! $engine instanceof MediaEngine) { if (! $engine instanceof MediaEngine) {
$this->error('Configured webrtc.media_engine is not a ' . MediaEngine::class); $this->error('Configured webrtc.media_engine is not a '.MediaEngine::class);
return self::FAILURE; return self::FAILURE;
} }
$kernel = (new Kernel())->reapChildren(); $kernel = (new Kernel)->reapChildren();
$kernel->register(new SignalingServer( $kernel->register(new SignalingServer(
$host, $host,
$port, $port,

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace BlaxSoftware\LaravelWebRtc\Contracts; namespace Blax\WebRtc\Contracts;
/** /**
* The media backend for a WebRTC call. * The media backend for a WebRTC call.
@ -31,7 +31,7 @@ interface MediaEngine
* *
* @param string $peerId caller-assigned id, unique within a room/call * @param string $peerId caller-assigned id, unique within a room/call
* @param string $sdpOffer the browser's offer SDP * @param string $sdpOffer the browser's offer SDP
* @return string the answer SDP to send back through signaling * @return string the answer SDP to send back through signaling
*/ */
public function offer(string $peerId, string $sdpOffer): string; public function offer(string $peerId, string $sdpOffer): string;

View File

@ -2,9 +2,9 @@
declare(strict_types=1); declare(strict_types=1);
namespace BlaxSoftware\LaravelWebRtc\Media; namespace Blax\WebRtc\Media;
use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine; use Blax\WebRtc\Contracts\MediaEngine;
/** /**
* A no-op media engine so the signaling stack is runnable and testable before * A no-op media engine so the signaling stack is runnable and testable before
@ -22,7 +22,7 @@ final class NullMediaEngine implements MediaEngine
public function offer(string $peerId, string $sdpOffer): string public function offer(string $peerId, string $sdpOffer): string
{ {
throw new \RuntimeException( throw new \RuntimeException(
'NullMediaEngine cannot terminate media. Configure webrtc.media_engine ' . 'NullMediaEngine cannot terminate media. Configure webrtc.media_engine '.
'to Str0mMediaEngine (requires the blax_webrtc Rust extension) for real calls.' 'to Str0mMediaEngine (requires the blax_webrtc Rust extension) for real calls.'
); );
} }

View File

@ -2,9 +2,9 @@
declare(strict_types=1); declare(strict_types=1);
namespace BlaxSoftware\LaravelWebRtc\Media; namespace Blax\WebRtc\Media;
use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine; use Blax\WebRtc\Contracts\MediaEngine;
/** /**
* Production media engine backed by a RUST core. * Production media engine backed by a RUST core.
@ -26,8 +26,8 @@ final class Str0mMediaEngine implements MediaEngine
{ {
if (! \extension_loaded('blax_webrtc')) { if (! \extension_loaded('blax_webrtc')) {
throw new \RuntimeException( throw new \RuntimeException(
'The blax_webrtc extension (Rust/str0m media core) is not loaded. ' . 'The blax_webrtc extension (Rust/str0m media core) is not loaded. '.
'Build it from this package\'s rust/ directory (see rust/README.md), ' . 'Build it from this package\'s rust/ directory (see rust/README.md), '.
'or set webrtc.media_engine to NullMediaEngine for signaling-only runs.' 'or set webrtc.media_engine to NullMediaEngine for signaling-only runs.'
); );
} }

View File

@ -2,11 +2,11 @@
declare(strict_types=1); declare(strict_types=1);
namespace BlaxSoftware\LaravelWebRtc\Server; namespace Blax\WebRtc\Server;
use BlaxSoftware\LaravelWebRtc\Signaling\SignalingHandler; use Blax\ReactPhpKernel\Contracts\Server;
use BlaxSoftware\ReactPhpKernel\Contracts\Server; use Blax\ReactPhpKernel\Server\SocketServerFactory;
use BlaxSoftware\ReactPhpKernel\Server\SocketServerFactory; use Blax\WebRtc\Signaling\SignalingHandler;
use React\EventLoop\LoopInterface; use React\EventLoop\LoopInterface;
use React\Socket\ConnectionInterface; use React\Socket\ConnectionInterface;
use React\Socket\SocketServer; use React\Socket\SocketServer;
@ -33,8 +33,7 @@ final class SignalingServer implements Server
private int $port, private int $port,
private SignalingHandler $handler, private SignalingHandler $handler,
private array $tls = [], private array $tls = [],
) { ) {}
}
public function name(): string public function name(): string
{ {
@ -64,14 +63,14 @@ final class SignalingServer implements Server
$message = json_decode($line, true); $message = json_decode($line, true);
if (! is_array($message)) { if (! is_array($message)) {
$conn->write(json_encode(['type' => 'error', 'error' => 'invalid json']) . "\n"); $conn->write(json_encode(['type' => 'error', 'error' => 'invalid json'])."\n");
continue; continue;
} }
$response = $this->handler->handle($message); $response = $this->handler->handle($message);
if ($response !== null) { if ($response !== null) {
$conn->write(json_encode($response) . "\n"); $conn->write(json_encode($response)."\n");
} }
} }
}); });

View File

@ -2,9 +2,9 @@
declare(strict_types=1); declare(strict_types=1);
namespace BlaxSoftware\LaravelWebRtc\Signaling; namespace Blax\WebRtc\Signaling;
use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine; use Blax\WebRtc\Contracts\MediaEngine;
/** /**
* Transport-agnostic signaling router: decodes a signaling message and drives * Transport-agnostic signaling router: decodes a signaling message and drives
@ -19,9 +19,7 @@ use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine;
*/ */
final class SignalingHandler final class SignalingHandler
{ {
public function __construct(private MediaEngine $engine) public function __construct(private MediaEngine $engine) {}
{
}
/** /**
* @param array<string,mixed> $message * @param array<string,mixed> $message

View File

@ -2,18 +2,18 @@
declare(strict_types=1); declare(strict_types=1);
namespace BlaxSoftware\LaravelWebRtc; namespace Blax\WebRtc;
use BlaxSoftware\LaravelWebRtc\Console\Commands\StartWebRtcServer; use Blax\WebRtc\Console\Commands\StartWebRtcServer;
use BlaxSoftware\LaravelWebRtc\Contracts\MediaEngine; use Blax\WebRtc\Contracts\MediaEngine;
use BlaxSoftware\LaravelWebRtc\Media\NullMediaEngine; use Blax\WebRtc\Media\NullMediaEngine;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class WebRtcServiceProvider extends ServiceProvider class WebRtcServiceProvider extends ServiceProvider
{ {
public function register(): void public function register(): void
{ {
$this->mergeConfigFrom(__DIR__ . '/../config/webrtc.php', 'webrtc'); $this->mergeConfigFrom(__DIR__.'/../config/webrtc.php', 'webrtc');
// Resolve the configured media engine wherever a MediaEngine is type-hinted. // Resolve the configured media engine wherever a MediaEngine is type-hinted.
$this->app->bind(MediaEngine::class, function ($app) { $this->app->bind(MediaEngine::class, function ($app) {
@ -27,7 +27,7 @@ class WebRtcServiceProvider extends ServiceProvider
{ {
if ($this->app->runningInConsole()) { if ($this->app->runningInConsole()) {
$this->publishes([ $this->publishes([
__DIR__ . '/../config/webrtc.php' => $this->app->configPath('webrtc.php'), __DIR__.'/../config/webrtc.php' => $this->app->configPath('webrtc.php'),
], 'webrtc-config'); ], 'webrtc-config');
$this->commands([ $this->commands([

10
test.sh Normal file
View File

@ -0,0 +1,10 @@
#!/usr/bin/env nix-shell
#!nix-shell -i bash -p php83 php83Extensions.sockets php83Extensions.mbstring php83Extensions.xml php83Extensions.xmlwriter php83Extensions.tokenizer
# Test script for NixOS — runs PHPUnit for the WebRTC signaling + engine layer.
echo "Running Laravel WebRTC tests..."
echo "PHP version: $(php --version | head -n 1)"
echo ""
vendor/bin/phpunit "$@"

View File

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Tests\Fixtures;
use Blax\WebRtc\Contracts\MediaEngine;
/**
* A recording media-engine double: captures every call so the SignalingHandler
* routing can be asserted, returns a deterministic answer, and can be told to
* throw so the handler's error path is exercised.
*/
final class FakeMediaEngine implements MediaEngine
{
/** @var array<int,array<int,mixed>> */
public array $calls = [];
public ?\Throwable $throwOnOffer = null;
public function name(): string
{
return 'fake';
}
public function offer(string $peerId, string $sdpOffer): string
{
$this->calls[] = ['offer', $peerId, $sdpOffer];
if ($this->throwOnOffer !== null) {
throw $this->throwOnOffer;
}
return "answer-for-{$peerId}";
}
public function addIceCandidate(string $peerId, array $candidate): void
{
$this->calls[] = ['ice', $peerId, $candidate];
}
public function startRecording(string $peerId, string $path): void
{
$this->calls[] = ['record', $peerId, $path];
}
public function stopRecording(string $peerId): void
{
$this->calls[] = ['stopRecording', $peerId];
}
public function connectPeers(string $peerA, string $peerB): void
{
$this->calls[] = ['connect', $peerA, $peerB];
}
public function bridge(string $peerId, array $options): void
{
$this->calls[] = ['bridge', $peerId, $options];
}
public function close(string $peerId): void
{
$this->calls[] = ['close', $peerId];
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Tests;
use Blax\WebRtc\Media\NullMediaEngine;
use PHPUnit\Framework\TestCase;
final class NullMediaEngineTest extends TestCase
{
public function test_it_refuses_to_terminate_media(): void
{
$this->expectException(\RuntimeException::class);
(new NullMediaEngine)->offer('p1', 'sdp');
}
public function test_its_other_operations_are_harmless_no_ops(): void
{
$engine = new NullMediaEngine;
$engine->addIceCandidate('p1', ['candidate' => 'x']);
$engine->startRecording('p1', '/tmp/x.opus');
$engine->stopRecording('p1');
$engine->connectPeers('p1', 'p2');
$engine->bridge('p1', []);
$engine->close('p1');
$this->assertSame('null', $engine->name());
}
}

View File

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Blax\WebRtc\Tests;
use Blax\WebRtc\Signaling\SignalingHandler;
use Blax\WebRtc\Tests\Fixtures\FakeMediaEngine;
use PHPUnit\Framework\TestCase;
final class SignalingHandlerTest extends TestCase
{
public function test_offer_returns_the_engine_answer(): void
{
$engine = new FakeMediaEngine;
$result = (new SignalingHandler($engine))->handle([
'type' => 'offer',
'peer' => 'p1',
'sdp' => 'OFFER-SDP',
]);
$this->assertSame(['type' => 'answer', 'peer' => 'p1', 'sdp' => 'answer-for-p1'], $result);
$this->assertSame(['offer', 'p1', 'OFFER-SDP'], $engine->calls[0]);
}
public function test_ice_is_forwarded_and_needs_no_reply(): void
{
$engine = new FakeMediaEngine;
$result = (new SignalingHandler($engine))->handle([
'type' => 'ice',
'peer' => 'p1',
'candidate' => ['candidate' => 'a=x'],
]);
$this->assertNull($result);
$this->assertSame(['ice', 'p1', ['candidate' => 'a=x']], $engine->calls[0]);
}
public function test_record_connect_bridge_close_are_acked_and_routed(): void
{
$engine = new FakeMediaEngine;
$handler = new SignalingHandler($engine);
$this->assertSame(['type' => 'ack', 'peer' => 'p1'], $handler->handle(['type' => 'record', 'peer' => 'p1', 'path' => '/tmp/a.opus']));
$this->assertSame(['type' => 'ack', 'peer' => 'p1'], $handler->handle(['type' => 'connect', 'peer' => 'p1', 'other' => 'p2']));
$this->assertSame(['type' => 'ack', 'peer' => 'p1'], $handler->handle(['type' => 'bridge', 'peer' => 'p1', 'options' => ['model' => 'gpt-realtime']]));
$this->assertSame(['type' => 'ack', 'peer' => 'p1'], $handler->handle(['type' => 'close', 'peer' => 'p1']));
$this->assertSame(['record', 'p1', '/tmp/a.opus'], $engine->calls[0]);
$this->assertSame(['connect', 'p1', 'p2'], $engine->calls[1]);
$this->assertSame(['bridge', 'p1', ['model' => 'gpt-realtime']], $engine->calls[2]);
$this->assertSame(['close', 'p1'], $engine->calls[3]);
}
public function test_a_missing_peer_is_an_error(): void
{
$result = (new SignalingHandler(new FakeMediaEngine))->handle(['type' => 'offer']);
$this->assertSame('error', $result['type']);
}
public function test_an_unknown_type_is_an_error(): void
{
$result = (new SignalingHandler(new FakeMediaEngine))->handle(['type' => 'wat', 'peer' => 'p1']);
$this->assertSame('error', $result['type']);
$this->assertStringContainsString('wat', $result['error']);
}
public function test_an_engine_exception_is_caught_as_an_error_reply(): void
{
$engine = new FakeMediaEngine;
$engine->throwOnOffer = new \RuntimeException('boom');
$result = (new SignalingHandler($engine))->handle(['type' => 'offer', 'peer' => 'p1', 'sdp' => 'x']);
$this->assertSame(['type' => 'error', 'peer' => 'p1', 'error' => 'boom'], $result);
}
}