From 6d7f0788d0aa389b7b339cb143222f5d2e190c69 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 21:41:54 -0700 Subject: [PATCH 1/5] test(smoke): import moq-dev/smoke at 66dd9f4 Verbatim copy of https://github.com/moq-dev/smoke/tree/66dd9f4 into test/smoke/, adapted to the monorepo in the following commits. Co-Authored-By: Claude Opus 5.5 --- test/smoke/.editorconfig | 35 + test/smoke/.envrc | 22 + test/smoke/.github/dependabot.yml | 12 + test/smoke/.github/workflows/smoke.yml | 465 ++++++++ test/smoke/.gitignore | 36 + test/smoke/AGENTS.md | 1 + test/smoke/CLAUDE.md | 70 ++ test/smoke/PROMPTING.md | 45 + test/smoke/README.md | 64 + test/smoke/clients/c/CMakeLists.txt | 16 + test/smoke/clients/c/subscribe.c | 173 +++ test/smoke/clients/cloudflare/Cargo.toml | 17 + test/smoke/clients/cloudflare/src/main.rs | 440 +++++++ test/smoke/clients/docker/moq | 14 + test/smoke/clients/docker/moq-relay | 36 + test/smoke/clients/go/go.mod | 7 + test/smoke/clients/go/smoke.go | 179 +++ test/smoke/clients/js-native/package.json | 14 + test/smoke/clients/js-native/subscribe.ts | 148 +++ test/smoke/clients/js/build-esbuild.ts | 25 + test/smoke/clients/js/diagnostics.test.ts | 110 ++ test/smoke/clients/js/diagnostics.ts | 116 ++ test/smoke/clients/js/driver.ts | 156 +++ test/smoke/clients/js/index.html | 10 + test/smoke/clients/js/jsdelivr/index.html | 18 + test/smoke/clients/js/jsdelivr/setup.js | 47 + test/smoke/clients/js/package.json | 14 + test/smoke/clients/js/src/main.ts | 6 + test/smoke/clients/js/vite.config.ts | 8 + test/smoke/clients/kotlin/build.gradle.kts | 20 + test/smoke/clients/kotlin/settings.gradle.kts | 1 + .../clients/kotlin/src/main/kotlin/Main.kt | 81 ++ test/smoke/clients/python/smoke.py | 117 ++ test/smoke/clients/swift/Package.swift | 20 + .../clients/swift/Sources/smoke/main.swift | 103 ++ test/smoke/clients/token/js/package.json | 9 + test/smoke/clients/token/js/resolve-bin.mjs | 23 + test/smoke/cloudflare.sh | 276 +++++ test/smoke/flake.lock | 61 + test/smoke/flake.nix | 84 ++ test/smoke/freshness.sh | 183 +++ test/smoke/justfile | 102 ++ test/smoke/moxygen.sh | 157 +++ test/smoke/relays.sh | 257 ++++ test/smoke/report/build.sh | 50 + test/smoke/report/index.html | 479 ++++++++ test/smoke/smoke-legacy.toml | 20 + test/smoke/smoke.sh | 1029 +++++++++++++++++ test/smoke/smoke.toml | 20 + test/smoke/token.sh | 411 +++++++ 50 files changed, 5807 insertions(+) create mode 100644 test/smoke/.editorconfig create mode 100644 test/smoke/.envrc create mode 100644 test/smoke/.github/dependabot.yml create mode 100644 test/smoke/.github/workflows/smoke.yml create mode 100644 test/smoke/.gitignore create mode 120000 test/smoke/AGENTS.md create mode 100644 test/smoke/CLAUDE.md create mode 100644 test/smoke/PROMPTING.md create mode 100644 test/smoke/README.md create mode 100644 test/smoke/clients/c/CMakeLists.txt create mode 100644 test/smoke/clients/c/subscribe.c create mode 100644 test/smoke/clients/cloudflare/Cargo.toml create mode 100644 test/smoke/clients/cloudflare/src/main.rs create mode 100755 test/smoke/clients/docker/moq create mode 100755 test/smoke/clients/docker/moq-relay create mode 100644 test/smoke/clients/go/go.mod create mode 100644 test/smoke/clients/go/smoke.go create mode 100644 test/smoke/clients/js-native/package.json create mode 100644 test/smoke/clients/js-native/subscribe.ts create mode 100644 test/smoke/clients/js/build-esbuild.ts create mode 100644 test/smoke/clients/js/diagnostics.test.ts create mode 100644 test/smoke/clients/js/diagnostics.ts create mode 100644 test/smoke/clients/js/driver.ts create mode 100644 test/smoke/clients/js/index.html create mode 100644 test/smoke/clients/js/jsdelivr/index.html create mode 100644 test/smoke/clients/js/jsdelivr/setup.js create mode 100644 test/smoke/clients/js/package.json create mode 100644 test/smoke/clients/js/src/main.ts create mode 100644 test/smoke/clients/js/vite.config.ts create mode 100644 test/smoke/clients/kotlin/build.gradle.kts create mode 100644 test/smoke/clients/kotlin/settings.gradle.kts create mode 100644 test/smoke/clients/kotlin/src/main/kotlin/Main.kt create mode 100644 test/smoke/clients/python/smoke.py create mode 100644 test/smoke/clients/swift/Package.swift create mode 100644 test/smoke/clients/swift/Sources/smoke/main.swift create mode 100644 test/smoke/clients/token/js/package.json create mode 100644 test/smoke/clients/token/js/resolve-bin.mjs create mode 100755 test/smoke/cloudflare.sh create mode 100644 test/smoke/flake.lock create mode 100644 test/smoke/flake.nix create mode 100755 test/smoke/freshness.sh create mode 100644 test/smoke/justfile create mode 100755 test/smoke/moxygen.sh create mode 100755 test/smoke/relays.sh create mode 100755 test/smoke/report/build.sh create mode 100644 test/smoke/report/index.html create mode 100644 test/smoke/smoke-legacy.toml create mode 100755 test/smoke/smoke.sh create mode 100644 test/smoke/smoke.toml create mode 100755 test/smoke/token.sh diff --git a/test/smoke/.editorconfig b/test/smoke/.editorconfig new file mode 100644 index 0000000000..359f71cb37 --- /dev/null +++ b/test/smoke/.editorconfig @@ -0,0 +1,35 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +# shfmt reads these natively (indent + switch-case indent), so `just check` +# drives shfmt with no CLI flags and the config lives here, matching moq. +[*.sh] +indent_style = space +indent_size = 4 +switch_case_indent = true + +# The Docker channel wrappers are bash but carry no .sh extension; shfmt finds +# them by shebang, so give them the same style as the *.sh scripts. +[clients/docker/*] +indent_style = space +indent_size = 4 +switch_case_indent = true + +# YAML doesn't support hard tabs. +[*.{yml,yaml}] +indent_style = space +indent_size = 2 + +# Nix defaults to spaces. +[*.nix] +indent_style = space +indent_size = 2 + +# Skip vendored / build output trees when shfmt walks the repo. +[{node_modules,target,dist,.venv,.direnv}/**] +ignore = true diff --git a/test/smoke/.envrc b/test/smoke/.envrc new file mode 100644 index 0000000000..257f32d9ff --- /dev/null +++ b/test/smoke/.envrc @@ -0,0 +1,22 @@ +# Automatically load the Nix development shell when entering this directory. +# Requires direnv: https://direnv.net/ +# +# To enable: +# 1. Install direnv +# 2. Run: direnv allow +# +# This installs every client toolchain (ffmpeg, uv, go, bun, Chromium, ...). +if ! has nix_direnv_version || ! nix_direnv_version 3.1.0; then + source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.1.0/direnvrc" "sha256-yMJ2OVMzrFaDPn7q8nCBZFRYpL/f0RcHzhmw/i6btJM=" +fi +use_flake + +# Reclaim stale Nix store paths in the background, at most once per week. +# nix-direnv pins the current dev shell, so only older versions are freed. +if has nix-collect-garbage; then + _gc_stamp="$PWD/.direnv/nix-gc-stamp" + if [ ! -f "$_gc_stamp" ] || [ -n "$(find "$_gc_stamp" -mtime +7 2>/dev/null)" ]; then + touch "$_gc_stamp" + (nix-collect-garbage --delete-older-than 7d >/dev/null 2>&1 &) + fi +fi diff --git a/test/smoke/.github/dependabot.yml b/test/smoke/.github/dependabot.yml new file mode 100644 index 0000000000..0b33cc01c5 --- /dev/null +++ b/test/smoke/.github/dependabot.yml @@ -0,0 +1,12 @@ +# Keep the SHA-pinned GitHub Actions in .github/workflows current. Dependabot +# bumps each `uses:` pin (and its `# vX` comment) as new releases land, so the +# Node-24 migration and future runtime/security bumps land automatically. +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + cooldown: + default-days: 7 diff --git a/test/smoke/.github/workflows/smoke.yml b/test/smoke/.github/workflows/smoke.yml new file mode 100644 index 0000000000..1bf3a1deab --- /dev/null +++ b/test/smoke/.github/workflows/smoke.yml @@ -0,0 +1,465 @@ +name: Smoke + +on: + # Manual on-demand runs. + workflow_dispatch: + inputs: + history_url: + description: Seed the relay report history from this URL instead of the deployed site + required: false + # On every PR: the harness itself (smoke.sh / token.sh / clients) is what + # changes here, so run the matrix against the published packages to catch a + # broken cell before it merges. Published-package regressions still surface + # nightly; this guards changes to the test. + pull_request: + # Merges: the build caches below (mbx, cargo binaries) only save from the + # default branch's push or dispatch runs, never from the nightly schedule, so + # this is what keeps them warm for pull requests. + push: + branches: [main] + # Nightly: catches breakage from a freshly published wheel / formula / .deb / + # npm / go module that a developer wouldn't notice until a user hit it. + schedule: + - cron: "0 8 * * *" + +concurrency: + group: smoke-${{ github.ref }} + cancel-in-progress: true + +jobs: + smoke: + name: ${{ matrix.os }} / ${{ matrix.channel }} + runs-on: ${{ matrix.os }} + # The matrix runs in real time (ffmpeg -re pacing + per-subscriber timeouts); + # this is just a safety net against a hung relay/client. The nix channel + # gets longer: compiling a moq HEAD that isn't in cachix yet has taken 43 + # minutes on its own, and that build is what seeds the cache (see below). + timeout-minutes: ${{ matrix.channel == 'nix' && 75 || 45 }} + + # smoke.sh resolves the latest libmoq release via the GitHub API. Without a + # token that call is anonymous (60 req/hr per IP), which the heavily-NAT'd + # macOS runners routinely exhaust -> "no libmoq-v* release found" -> the C + # subscriber drops out and every "* -> c" case fails. Authenticating lifts + # the limit to 1000 req/hr per token. + env: + GITHUB_TOKEN: ${{ github.token }} + SMOKE_BROWSER_ARTIFACTS: ${{ github.workspace }}/clients/js/artifacts + + strategy: + fail-fast: false + matrix: + include: + # The Rust binaries ship through three channels that install the same + # thing; we test each separately where the OS supports it. The Python / + # Go / browser clients always come from their own registries. + - os: ubuntu-latest + channel: cargo + - os: ubuntu-latest + channel: apt + # The moq flake is itself a public channel (nix run github:moq-dev/moq). + - os: ubuntu-latest + channel: nix + # Docker Hub (moqdev/moq-relay + moqdev/moq). Linux only: the + # wrappers use `--network host`, which needs a native Docker daemon + # (GitHub's macOS runners have none). + - os: ubuntu-latest + channel: docker + # macOS cells temporarily disabled to cut CI spend (over budget this + # month). Re-enable by uncommenting; nothing else depends on them. + # - os: macos-latest + # channel: cargo + # - os: macos-latest + # channel: brew + # - os: macos-latest + # channel: nix + + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + # ── client toolchains ────────────────────────────────────────────── + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version: stable + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 + with: + distribution: temurin + java-version: "21" + - uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + with: + gradle-version: "8.14.4" + + - name: System deps (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + # gcc (cc) for the C client is already on the runner, but g++ isn't: + # libmoq.a bundles openh264, so static consumers need libstdc++ to link. + # libva-dev is not a real dependency (moq-vaapi dlopen's libva and + # libmoq.a has no libva symbols); it's here only because libmoq's + # published moq.pc and CMake package still advertise -lva/-lva-drm, so + # the c-pkgconfig and c-cmake variants pass them to the linker. Drop it + # once a libmoq release ships without those. The hand-rolled `c` variant + # already omits them, see LIBMOQ_OS_LIBS in smoke.sh. + # gstreamer1.0-* provides gst-launch/gst-inspect + the core elements + # (filesink) the moq-gst subscriber loads the prebuilt plugin against. + # pkg-config + cmake drive the c-pkgconfig / c-cmake build variants + # (they consume the prebuilt libmoq's moq.pc and lib/cmake/moq package). + sudo apt-get install -y ffmpeg curl procps coreutils jq pkg-config cmake g++ libva-dev \ + gstreamer1.0-tools gstreamer1.0-plugins-base gstreamer1.0-plugins-good + + - name: System deps (macOS) + if: runner.os == 'macOS' + run: | + # clang (cc) for the C client and swift for the Swift client ship with Xcode. + # pkg-config + cmake drive the c-pkgconfig / c-cmake build variants. + # gstreamer is the system runtime the prebuilt moq-gst plugin links against. + brew install ffmpeg coreutils jq pkg-config cmake gstreamer + # GNU coreutils provides `timeout` (macOS ships neither it nor pgrep + # under those names by default; pgrep exists, timeout does not). + echo "$(brew --prefix)/opt/coreutils/libexec/gnubin" >> "$GITHUB_PATH" + + # ── Rust binaries, per channel (left on PATH for smoke.sh) ────────── + # Key the installed binaries on the newest published versions (from the + # crates.io sparse index) and the compiler. An exact hit is already the + # latest install, so it skips `cargo install`; a new release or rustc + # misses and compiles for real, which is exactly when this channel has + # something new to prove. + - name: Resolve latest crate versions (cargo) + id: crates + if: matrix.channel == 'cargo' + # A failed lookup must fail the step: a `null` key could otherwise + # exact-hit a stale entry and skip the install after a new release. + run: | + latest() { + curl -sfL --retry 3 "https://index.crates.io/mo/q-/$1" | + jq -ers '[.[] | select((.yanked | not) and (.vers | contains("-") | not))] | last.vers' + } + relay=$(latest moq-relay) + cli=$(latest moq-cli) + echo "key=cargo-bin-${RUNNER_OS}-moq-relay-$relay-moq-cli-$cli-$(rustc -V | tr -c 'a-zA-Z0-9.\n' -)" >> "$GITHUB_OUTPUT" + + - name: Cache installed moq binaries (cargo) + id: cargo-cache + if: matrix.channel == 'cargo' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.cargo/bin/moq-relay + ~/.cargo/bin/moq + ~/.cargo/.crates.toml + ~/.cargo/.crates2.json + key: ${{ steps.crates.outputs.key }} + + - name: Install moq Rust packages (cargo) + if: matrix.channel == 'cargo' && steps.cargo-cache.outputs.cache-hit != 'true' + # Honor each published binary crate's Cargo.lock. Without --locked, the + # cargo channel can select a newly broken transitive dependency even when + # the crate was published and tested against a working resolution. + run: cargo install --locked moq-relay moq-cli + + - name: Install moq Rust packages (apt) + if: matrix.channel == 'apt' + run: | + curl -fsSL https://apt.moq.dev/moq-keyring.gpg \ + | sudo tee /usr/share/keyrings/moq-keyring.gpg > /dev/null + echo "deb [signed-by=/usr/share/keyrings/moq-keyring.gpg] https://apt.moq.dev stable main" \ + | sudo tee /etc/apt/sources.list.d/moq.list + sudo apt-get update + sudo apt-get install -y moq-relay moq-cli + + - name: Install moq Rust packages (brew) + if: matrix.channel == 'brew' + run: | + brew tap moq-dev/tap + brew install moq-dev/tap/moq-relay moq-dev/tap/moq-cli + + - name: Install Nix (nix channel) + if: matrix.channel == 'nix' + uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 + with: + extra-conf: | + extra-substituters = https://kixelated.cachix.org + extra-trusted-public-keys = kixelated.cachix.org-1:CmFcV0lyM6KuVM2m9mih0q4SrAa0XyCsiM7GHrz3KKk= + + - name: Build moq from the flake (nix channel) + if: matrix.channel == 'nix' + # --refresh so we always build the latest moq, never a cached eval; the + # moq flake is referenced ad-hoc here, so its version is never locked. + run: | + relay=$(nix build --refresh --no-link --print-out-paths 'github:moq-dev/moq#moq-relay') + cli=$(nix build --refresh --no-link --print-out-paths 'github:moq-dev/moq#moq') + { + echo "RELAY_BIN=$relay/bin/moq-relay" + echo "MOQ_BIN=$cli/bin/moq" + } >> "$GITHUB_ENV" + + # moq-dev/moq's cachix.yml only pushes release tags, so each new HEAD + # compiles here for ~20 minutes. Push what we just built, so later runs + # (and anyone doing `nix run github:moq-dev/moq#moq`) substitute it until + # moq's main moves. Only the two outputs' runtime closures, never the + # multi-GB build dependencies; unpinned, so cachix's LRU evicts them + # before any pinned release. Skipped on pull requests and until the repo + # has a CACHIX_AUTH_TOKEN secret. + - name: Push moq build to cachix (nix channel) + if: matrix.channel == 'nix' && github.event_name != 'pull_request' + env: + CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }} + run: | + if [ -z "$CACHIX_AUTH_TOKEN" ]; then + echo "no CACHIX_AUTH_TOKEN secret; not pushing" + exit 0 + fi + nix run nixpkgs#cachix -- push kixelated "${RELAY_BIN%/bin/*}" "${MOQ_BIN%/bin/*}" + + - name: Pull moqdev images (docker channel) + if: matrix.channel == 'docker' + # Pull :latest fresh, then point smoke.sh at the wrapper scripts (which + # `docker run --network host` the images) via RELAY_BIN/MOQ_BIN. Docker is + # preinstalled on the Linux runners. + run: | + docker pull moqdev/moq-relay + docker pull moqdev/moq + { + echo "RELAY_BIN=$PWD/clients/docker/moq-relay" + echo "MOQ_BIN=$PWD/clients/docker/moq" + } >> "$GITHUB_ENV" + + # ── browser deps (Chromium runtime libs on Linux) ────────────────── + - name: Playwright system deps (Linux) + if: runner.os == 'Linux' + run: | + cd clients/js + bun install + bun test diagnostics.test.ts + bunx playwright install-deps chromium + + # Fail loudly if we've drifted off "latest": a committed lock file, a moq + # package that stopped being requested at latest, or a stale playwright pin. + - name: Freshness + run: ./freshness.sh + + # ── run the full interop matrix ──────────────────────────────────── + # Publishers: rust, python, go, and the three browser variants publish today; + # swift/kotlin/c/gst subscribe only. Go builds on Linux against the + # published moq-go module; gst resolves the latest moq-gst-v* release. + # Browser variants: js-web, js-esbuild (bundlers), js-jsdelivr (CDN). + # Swift needs the Xcode toolchain, so it's a macOS-only subscriber. + # --timeout 30 gives headless Chromium cold-start headroom. + - name: Smoke + run: | + browser="js-web,js-esbuild,js-jsdelivr" + # Both run moq's @moq/web-transport polyfill. node briefly failed on the + # polyfill's napi CJS named-import; fixed in @moq/web-transport 0.1.2. + native="js-node,js-bun" + # c / c-pkgconfig / c-cmake: same libmoq client built three ways, to + # exercise the moq.pc and CMake-package consumer paths, not just -I/-L. + subs="rust,python,go,kotlin,c,c-pkgconfig,c-cmake,gst,$browser,$native" + if [ "$RUNNER_OS" = "macOS" ]; then subs="$subs,swift"; fi + ./smoke.sh \ + --publishers "rust,python,go,$browser" \ + --subscribers "$subs" \ + --timeout 30 + + - name: Upload browser failure diagnostics + if: ${{ !cancelled() && hashFiles('clients/js/artifacts/*') != '' }} + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: browser-failures-${{ matrix.os }}-${{ matrix.channel }} + path: clients/js/artifacts/ + if-no-files-found: error + + # Quick proof the harness can actually report failure. + - name: Negative control + run: ./smoke.sh --negative --subscribers rust,python + + # ── token interop ────────────────────────────────────────────────── + # Independent of the media matrix: prove the published token tooling + # cross-verifies. `moq auth` rides the same channel as moq-relay/moq + # (cargo/apt/brew/nix, on PATH or MOQ_BIN); @moq/auth comes from npm and + # runs under both node and bun; rust-docker runs `auth` in the + # moqdev/moq image. The negative pass inside token.sh confirms each verifier rejects + # tampered tokens and the wrong key. + - name: Token interop + # Independent of the media matrix, so a red media cell mustn't hide it. + if: ${{ !cancelled() }} + run: | + # The `rust` impl runs a native `moq auth`, which only the + # cargo/apt/brew/nix channels install. The docker channel's moq wrapper + # doesn't mount token.sh's scratch dir, so it exercises the Rust + # verifier through the rust-docker cell instead of plain `rust`. + if [ "${{ matrix.channel }}" = "docker" ]; then + impls="js-node,js-bun,rust-docker" + else + impls="rust,js-node,js-bun" + # The Docker cell needs a daemon; GitHub's macOS runners have none, so + # it's Linux-only. (Docker is preinstalled on the Linux runners.) + if [ "$RUNNER_OS" = "Linux" ]; then impls="$impls,rust-docker"; fi + fi + ./token.sh --generators "$impls" --verifiers "$impls" + + relays: + name: External relay interop + runs-on: ubuntu-latest + permissions: + contents: read + # ~20 public endpoints x 2 publishers, each bounded by --timeout. + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + + - name: System deps + run: | + sudo apt-get update + sudo apt-get install -y ffmpeg curl procps coreutils jq + + # apt is the quickest channel to install; the relay binary isn't needed + # because every endpoint is remote. + - name: Install moq CLI (apt) + run: | + curl -fsSL https://apt.moq.dev/moq-keyring.gpg \ + | sudo tee /usr/share/keyrings/moq-keyring.gpg > /dev/null + echo "deb [signed-by=/usr/share/keyrings/moq-keyring.gpg] https://apt.moq.dev stable main" \ + | sudo tee /etc/apt/sources.list.d/moq.list + sudo apt-get update + sudo apt-get install -y moq-cli + + - name: Playwright system deps + run: | + cd clients/js + bun install + bunx playwright install-deps chromium + + # The relay list comes live from the moq-interop-runner registry. Only + # moq-dev's own relay gates the job; every other endpoint lands in the job + # summary table as optional. Rust publishes and subscribes on every transport. + # js-web is the browser and js-bun is the WebTransport polyfill. Both run + # only on WebTransport. The polyfill has no raw-QUIC (moqt://) mode, so + # neither is listed on native QUIC or on the WebSocket column. + - name: External relay interop + run: ./relays.sh --publishers rust,js-web --subscribers rust,js-web,js-bun --timeout 30 --json results.json --logs logs + + # The HTML report (report/index.html) on GitHub Pages. Built even when a + # required relay failed, since that's exactly the run worth looking at. + # History carries forward from the currently deployed site. A failed build + # uploads nothing, so a broken history never replaces the deployed one. + - name: Build report + id: report + if: ${{ !cancelled() && hashFiles('results.json') != '' }} + env: + HISTORY_URL: ${{ inputs.history_url }} + run: | + owner="${GITHUB_REPOSITORY%%/*}" + report/build.sh results.json logs _site "${HISTORY_URL:-https://${owner}.github.io/${GITHUB_REPOSITORY#*/}/history.json}" + + - name: Upload report + if: ${{ !cancelled() && steps.report.outcome == 'success' }} + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + + # Publish the relay report from main only (nightly or on demand), never from a PR. + relays-report: + name: Publish relay report + needs: relays + if: ${{ !cancelled() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' }} + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deploy + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 + + cloudflare: + name: Cloudflare interoperability + runs-on: ubuntu-latest + permissions: + contents: read + # Both projects are built from Git HEAD; the six runtime checks are quick. + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + # Both relays build from Git HEAD, so only the tip changes day to day. mbx + # (as in moq-dev/moq) caches each compilation; `objects` mode because the + # checkouts land in fresh temp dirs. The shim routes the scripts' plain + # `cargo build` through it; without mise, setup only reports where the + # shim lives, so put that directory first on PATH (as moq's dev shell + # does). `--status` exits non-zero when there is no shim. + - uses: jdx/mr-boxington-action@9df1d4b18b2147788a7ee7a2c7b84ecf62fd89d3 # v1.5.0 + with: + version: 1.17.0 + github-cache-mode: objects + cache-key: ${{ runner.os }}-${{ runner.arch }}-mbx-objects-cloudflare-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: ${{ runner.os }}-${{ runner.arch }}-mbx-objects-cloudflare- + - name: Cargo shim + run: | + mbx setup --yes + shim=$(mbx setup --status | sed -n '1s/.*: //p') + [ -x "$shim" ] + dirname "$shim" >> "$GITHUB_PATH" + + - name: Cloudflare interoperability + run: ./cloudflare.sh + + moxygen: + name: Moxygen interoperability + runs-on: ubuntu-latest + permissions: + contents: read + packages: read + # The published client image and moq-dev relay both follow Git HEAD. Building + # the Rust relay is the long pole; the 12 protocol cases are bounded and quick. + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + # Both relays build from Git HEAD, so only the tip changes day to day. mbx + # (as in moq-dev/moq) caches each compilation; `objects` mode because the + # checkouts land in fresh temp dirs. The shim routes the scripts' plain + # `cargo build` through it; without mise, setup only reports where the + # shim lives, so put that directory first on PATH (as moq's dev shell + # does). `--status` exits non-zero when there is no shim. + - uses: jdx/mr-boxington-action@9df1d4b18b2147788a7ee7a2c7b84ecf62fd89d3 # v1.5.0 + with: + version: 1.17.0 + github-cache-mode: objects + cache-key: ${{ runner.os }}-${{ runner.arch }}-mbx-objects-moxygen-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: ${{ runner.os }}-${{ runner.arch }}-mbx-objects-moxygen- + - name: Cargo shim + run: | + mbx setup --yes + shim=$(mbx setup --status | sed -n '1s/.*: //p') + [ -x "$shim" ] + dirname "$shim" >> "$GITHUB_PATH" + + - name: Moxygen interoperability + # Current moq-dev HEAD negotiates successfully but fails moxygen's + # announce-subscribe case. Keep the full diagnostic visible without + # failing the job; remove this once that protocol mismatch is fixed. + continue-on-error: true + run: ./moxygen.sh diff --git a/test/smoke/.gitignore b/test/smoke/.gitignore new file mode 100644 index 0000000000..bb47a0e286 --- /dev/null +++ b/test/smoke/.gitignore @@ -0,0 +1,36 @@ +# Per-client build/install artifacts. Everything here is fetched from a public +# package registry at test time, never committed. +clients/js/node_modules/ +clients/js/dist-vite/ +clients/js/dist-esbuild/ +clients/js/artifacts/ +clients/js-native/node_modules/ +clients/token/js/node_modules/ +clients/go/bin/ +clients/swift/.build/ +clients/kotlin/.gradle/ +clients/kotlin/build/ +clients/cloudflare/target/ +*.log + +# No committed PACKAGE lock files: every run must re-resolve the moq packages +# under test to their LATEST published versions. freshness.sh enforces this. +# (flake.lock is allowed: it pins the dev toolchain, not the moq packages, and +# the moq "nix" channel references the moq flake ad-hoc so it's never locked.) +go.sum +clients/js/bun.lock +clients/js/bun.lockb +clients/js-native/bun.lock +clients/js-native/bun.lockb +clients/token/js/bun.lock +clients/token/js/bun.lockb +Cargo.lock +uv.lock +package-lock.json +yarn.lock +pnpm-lock.yaml +clients/swift/Package.resolved +clients/kotlin/gradle.lockfile + +# direnv / nix-direnv working dir. +.direnv/ diff --git a/test/smoke/AGENTS.md b/test/smoke/AGENTS.md new file mode 120000 index 0000000000..681311eb9c --- /dev/null +++ b/test/smoke/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/test/smoke/CLAUDE.md b/test/smoke/CLAUDE.md new file mode 100644 index 0000000000..fb4c2b61f0 --- /dev/null +++ b/test/smoke/CLAUDE.md @@ -0,0 +1,70 @@ +Cross-language interop smoke for the published MoQ packages. +The implementations live in [moq-dev/moq](https://github.com/moq-dev/moq). This repo installs the public artifacts and checks that they talk. + +# Context + +- Read `PROMPTING.md` before instructing other agents via context, memory, plans, or reviews. +- `README.md` describes the lanes. Keep this file to rules. + +# Required + +- Pull the latest origin changes before working. +- Dig into the root cause and fix it at the source. Never work around a fixable bug with a retry, sleep, or timeout. +- Fail loud and early. Error on unsupported or malformed input rather than warn and continue: supported or refused. +- Reproduce bugs before fixing them. Land each fix with a regression test that fails without it, when one is easy. +- Keep the PR focused. No unrelated refactors, formatting churn, or drive-by changes; split when in doubt. +- Refactor aggressively for long-term maintainability, but re-evaluate the direction as you learn. +- Propose a course change, even suggest abandoning a PR, rather than finish a half-solution. +- When a decision is the maintainer's (scope, naming, which lane), ask with 2-3 options and a recommendation. +- Wire new lanes into `.github/workflows/smoke.yml`. CI runs the matrix on pull requests and nightly. +- Never edit a `CLAUDE.md`, `CONTRIBUTING.md`, or skill without being prompted, and read `PROMPTING.md` first. +- No em dashes. +- Match the existing conventions, patterns, and naming when possible. +- Fix any outdated docs and comments inline; don't add a separate PR for it. +- Add the AI marker `(Written by )` to any posts on GitHub, excluding commit messages that contain `Co-Authored-By:` trailers. +- Any AI comments may be challenged, and not confused with human maintainers. +- Prompt the user to decide when unsure, but always provide recommendations. +- Don't open a PR, issue, or review comment outside the `moq-dev` and `kixelated` orgs without approval. + +# What this repo tests + +- Install the published artifact each cell claims to test. +- MoQ package versions stay at latest: `@moq/*` on the `latest` tag, PyPI `moq-rs`, `moq.dev/moq`, `moqdev/*` images, and GitHub release tarballs. `cloudflare.sh` and `moxygen.sh` build from unpinned Git HEAD. +- No package lock files (`go.sum`, `bun.lock`, `Cargo.lock`, `uv.lock`, and the rest of the list in `.gitignore`). `flake.lock` pins the dev toolchain. The moq flake is not a flake input; `just nix-channel` builds `github:moq-dev/moq#moq-relay` and `#moq` with `--refresh`. +- npm `playwright` is the one version pin, and it matches the flake's `PLAYWRIGHT_VERSION`. Bump the toolchain with `nix flake update` and the pin together. +- `moq-relay` and `moq` come from a channel (PATH, `RELAY_BIN`/`MOQ_BIN`, `just nix-channel`, or `clients/docker`). The harness leaves installing them to that channel. +- A red cell is the result. `mark_broken` drops that package's cells and lets the rest of the matrix run. Retries belong on transient registry and release downloads. A client that failed to exchange media gets no retry, no longer timeout, and no skip. +- A published-package bug is fixed in its own repo, then goes green here on the next release. +- Hang media (`smoke.sh`), Cloudflare, and moxygen stay on separate lanes. +- In `relays.sh`, only `--required` relays fail the run. The default required relay is moq-dev's own. + +# Guidelines + +- Prefer a maintained tool over hand-rolling non-core functionality. +- New dependencies use the newest stable version, except the pins `freshness.sh` enforces. +- Comments explain the non-obvious why, and never the history. +- Inline simple helpers. +- Question whether functionality is needed at all before adding it. +- Deleting code is better than adding code. +- Prefer the simple solution. +- Say it once, in the fewest words that hold up. +- Suggest follow-up sessions when finished, and use an interactive prompt. +- Try to do stuff asynchronously. ex. ask about follow-ups while a lane runs. +- Stop when progress stalls. +- If the core problem is addressed, ship it. + +# Development + +PRs target `main`. +Before starting, `git fetch origin` and set the upstream to the base branch. + +Use the Nix dev shell so tooling matches local lint. direnv loads it. Otherwise: `nix develop --command ...`. +The shell carries client toolchains. The moq binaries under test come from a channel. + +```bash +just check # shfmt, shellcheck, actionlint, freshness +just smoke # rust-only matrix; flags pass through to smoke.sh +just full # cross-language matrix +``` + +`just check` does not run the matrix. Run the lane you changed (`just smoke`, `just token`, `just cloudflare`, `just moxygen`, `just relays`). diff --git a/test/smoke/PROMPTING.md b/test/smoke/PROMPTING.md new file mode 100644 index 0000000000..5d886bc253 --- /dev/null +++ b/test/smoke/PROMPTING.md @@ -0,0 +1,45 @@ +Read this before indirectly instructing agents. + +LLMs are not trained on how to write prompts. +They're trained on the outputs, not the inputs, and thus generate slop when chained. +So when you're drafting instructions for a future agent, please keep these rules in mind. + +# General + +**Do not over-prescribe**. +The facts will evolve over time. + +Focus on articulating the goal and/or root problems. +Keep things flexible so an agent can adjust as it digs deeper into the possible problems or solutions. + +You can (and should) include recommendations. +But focus on best-practices and things to look out for, not specific lines of code. +Mocking up an API makes a lot of sense but again, it should be flexible enough to adjust with the implementation. + +# Context + +This includes editing any `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`, etc. + +These files need to be light because they're loaded into every prompt. +More specific context (ex. per language or project) should exist in optional files/folders. + +These base prompts are meant to fix regular agent mistakes. +If there is a history of bad practices, or sub-optimal behavior, update `CLAUDE.md` to steer away from them. +If you think a slight tweak to the base prompt would help, propose it. + +Focus on best practices and conventions. +Don't document the repository; the code and documentation can handle that. + +Before changing agent-instruction files, check the relevant official Claude/Codex guidance: + +- +- +- +- +- +- + +# Skills + +Skills are meant to avoid repeated duplicate prompts. +If the usage of a skill is unclear, offer to update to skill to reduce the ambiguity. diff --git a/test/smoke/README.md b/test/smoke/README.md new file mode 100644 index 0000000000..3f9276164e --- /dev/null +++ b/test/smoke/README.md @@ -0,0 +1,64 @@ +# moq smoke + +### [Latest smoke results](https://moq-dev.github.io/smoke/) + +Nightly publish/subscribe results for the published clients through public MoQ relays. One row per relay, sorted by name ignoring case, with a passing pair count per transport (WebTransport, QUIC, WebSocket). An empty cell means that relay has no endpoint there. Hover a count for the failure, click it for the logs. The page keeps the last 60 runs and updates from `main`. + +This repo installs the public [moq](https://github.com/moq-dev/moq) packages and checks that they exchange media. A missing wheel, a stale Homebrew formula, a broken `.deb`, or an export lost in packaging shows up as a red cell. A cell passes when the subscriber receives a non-empty frame. + +## Lanes + +| Lane | Run | Coverage | +| --- | --- | --- | +| Packages | `just smoke`, `just full` | Published clients through a local `moq-relay`. `smoke` is Rust only. `full` is the cross-language matrix. | +| Public relays | `just relays` | Rust publisher and subscriber through every relay in the [moq-interop-runner](https://github.com/englishm/moq-interop-runner) registry. CI publishes with Rust and the browser (`js-web`) and subscribes with Rust, `js-web`, and `js-bun`. On those relays `js-web` and `js-bun` run only on WebTransport (`@moq/web-transport` has no raw QUIC mode); a client that does not belong is omitted. WebSocket is its own column for the relays in `WEBSOCKET_KEYS` (`moq-dev-rs`): each of their `http(s)` endpoints also runs as `ws(s)`, and every other relay keeps that fallback off. A relay that refuses anonymous root broadcasts is dialed on the path `publish_path` names (`stitcher-moq` publishes under `/moq-test`). That run is what the [results page](https://moq-dev.github.io/smoke/) shows. Only `--required` relays fail the run; the default is moq-dev's `cdn.moq.dev`, including its WebSocket endpoint. | +| Cloudflare | `just cloudflare` | Relays built from the default branches of [cloudflare/moq-rs](https://github.com/cloudflare/moq-rs) and moq-dev/moq, driven by a Cloudflare client over WebTransport and raw QUIC. | +| moxygen | `just moxygen` | Meta's [`moxygen`](https://github.com/facebookexperimental/moxygen) interop client through the latest moq-dev relay. Needs Linux Docker. | +| Tokens | `just token`, `just token-full` | `moq auth` and npm [`@moq/auth`](https://www.npmjs.com/package/@moq/auth) mint and verify each other's JWTs, and each verifier rejects a tampered token. | + +Hang media, Cloudflare, and moxygen each have their own lane. + +CI runs the matrix on pull requests and nightly: [`.github/workflows/smoke.yml`](.github/workflows/smoke.yml). + +## Clients + +`moq-relay` and `moq` come from `PATH` or from `RELAY_BIN` and `MOQ_BIN`. Every other client installs from its registry on each run. + +| Client | Role | Install | +| --- | --- | --- | +| Rust | publish, subscribe | cargo, Homebrew, apt, `nix build github:moq-dev/moq`, or [`moqdev/*`](https://hub.docker.com/u/moqdev) images | +| Python | publish, subscribe | PyPI [`moq-rs`](https://pypi.org/project/moq-rs/) | +| Go | publish, subscribe | [`moq.dev/moq`](https://pkg.go.dev/moq.dev/moq) | +| Browser | publish, subscribe | npm [`@moq/watch`](https://www.npmjs.com/package/@moq/watch) + [`@moq/publish`](https://www.npmjs.com/package/@moq/publish), via vite, esbuild, or [jsDelivr](https://www.jsdelivr.com/) | +| Native JS | subscribe | npm [`@moq/net`](https://www.npmjs.com/package/@moq/net) + [`@moq/hang`](https://www.npmjs.com/package/@moq/hang) under node and bun | +| Swift | subscribe | SPM [`moq-dev/moq-swift`](https://github.com/moq-dev/moq-swift), macOS | +| Kotlin | subscribe | Maven Central [`dev.moq:moq`](https://central.sonatype.com/artifact/dev.moq/moq) | +| C | subscribe | [`libmoq`](https://github.com/moq-dev/moq/releases) release tarball, linked by hand, pkg-config, or CMake | +| GStreamer | subscribe | [`moq-gst`](https://github.com/moq-dev/moq/releases?q=moq-gst) plugin against system GStreamer | + +## Run + +The Nix flake carries the client toolchains (direnv loads `.envrc`). Put `moq-relay` and `moq` on `PATH` (cargo, Homebrew, or apt) before the lanes below. + +```bash +nix develop +cargo install moq-relay moq-cli # or brew / apt +just full +just relays +just cloudflare +just moxygen +just token +just check # shfmt, shellcheck, actionlint, freshness +``` + +`just nix-channel` is a run, not an install. It builds `github:moq-dev/moq#moq-relay` and `#moq` with `nix build --refresh --no-link`, then runs `smoke.sh` with `RELAY_BIN` and `MOQ_BIN` pointed at those outputs. Nothing is left on `PATH`, so a later `just full` does not see those binaries. The Docker images work the same way: point `RELAY_BIN` and `MOQ_BIN` at the wrappers in [`clients/docker`](clients/docker). Those wrappers are not an install. + +`just check` lints the harness. Run the lane you changed. + +## Versions + +Packages resolve to latest on every run: `@moq/*` on the `latest` tag, PyPI `moq-rs`, `moq.dev/moq`, `moqdev/*` images, and GitHub release tarballs. `cloudflare.sh` and `moxygen.sh` build from unpinned Git HEAD. The repo commits no package lock files. `flake.lock` pins the dev toolchain. + +npm `playwright` is pinned to the flake's `PLAYWRIGHT_VERSION`. `just freshness` fails when that pin drifts or a lock file is committed. Bump the toolchain with `nix flake update` and the pin together. + +A broken package fails only its own cells. diff --git a/test/smoke/clients/c/CMakeLists.txt b/test/smoke/clients/c/CMakeLists.txt new file mode 100644 index 0000000000..4ac1e1e98e --- /dev/null +++ b/test/smoke/clients/c/CMakeLists.txt @@ -0,0 +1,16 @@ +# Builds the C smoke subscriber against the prebuilt libmoq using the CMake +# package config it ships (lib/cmake/moq/moq-config.cmake). This is the CMake +# consumer path: find_package locates the moq::moq imported target, which +# carries the include dir, the static archive, and the per-platform system +# libraries. A broken config (wrong paths, missing target or system libs) fails +# configure/build here -- breakage the hand-rolled cc build can't surface. +# +# smoke.sh points CMAKE_PREFIX_PATH at the extracted tarball root, so this is +# never built standalone; it's driven by c_build_cmake. +cmake_minimum_required(VERSION 3.16) +project(moq_c_smoke C) + +find_package(moq REQUIRED CONFIG) + +add_executable(smoke subscribe.c) +target_link_libraries(smoke PRIVATE moq::moq) diff --git a/test/smoke/clients/c/subscribe.c b/test/smoke/clients/c/subscribe.c new file mode 100644 index 0000000000..5138d2c452 --- /dev/null +++ b/test/smoke/clients/c/subscribe.c @@ -0,0 +1,173 @@ +// Subscribe-only cross-language interop client for the smoke test, linked +// against the prebuilt libmoq (the C bindings, libmoq-v* release assets). +// +// libmoq is a handle + callback API: connect, consume a broadcast, get a +// catalog snapshot via callback, start the video track, and a frame callback +// fires as frames arrive. We exit 0 the moment a non-empty frame lands, 1 on +// timeout. Publishing isn't wired up: the raw-stream importer that the other +// clients use to publish isn't part of this subscribe-only client. +// +// c-smoke subscribe --url http://127.0.0.1:4443 --broadcast b.hang --timeout 20 +#include + +#include +#include +#include +#include +#include +#include +#include + +typedef struct { + pthread_mutex_t mu; + pthread_cond_t cv; + int got; // a non-empty frame arrived + int video_started; // guard: start the video track only once + int32_t broadcast; // handle delivered by moq_origin_announced_broadcast (0 until it arrives) +} ctx_t; + +// Callbacks run on libmoq's runtime thread; main waits on the condvar. ctx +// lives on main's stack and outlives the callbacks (main blocks until got/ +// timeout, then the process exits), so there's no use-after-free. +static void on_status(void *ud, int32_t code) { + (void)ud; + fprintf(stderr, "session status: %d\n", code); +} + +static void on_frame(void *ud, int32_t frame) { + ctx_t *c = (ctx_t *)ud; + if (frame <= 0) return; // 0 = ended, negative = error + moq_frame f; + memset(&f, 0, sizeof(f)); + if (moq_consume_frame((uint32_t)frame, &f) == 0 && f.payload_size > 0) { + pthread_mutex_lock(&c->mu); + c->got = 1; + pthread_cond_signal(&c->cv); + pthread_mutex_unlock(&c->mu); + } + moq_consume_frame_free((uint32_t)frame); +} + +// Delivers the broadcast handle once it's announced, then once more with a +// terminal code (<= 0) we ignore. Store the first positive handle and wake main. +static void on_broadcast(void *ud, int32_t broadcast) { + ctx_t *c = (ctx_t *)ud; + if (broadcast <= 0) return; // 0 = ended, negative = error + pthread_mutex_lock(&c->mu); + if (c->broadcast <= 0) { + c->broadcast = broadcast; + pthread_cond_signal(&c->cv); + } + pthread_mutex_unlock(&c->mu); +} + +static void on_catalog(void *ud, int32_t catalog) { + ctx_t *c = (ctx_t *)ud; + if (catalog <= 0) return; + + pthread_mutex_lock(&c->mu); + int start = !c->video_started; + pthread_mutex_unlock(&c->mu); + + if (start) { + // A lazy publisher may announce video in a later catalog update, so this + // just no-ops (config returns < 0) until a video track exists at index 0. + moq_video_config vcfg; + memset(&vcfg, 0, sizeof(vcfg)); + if (moq_consume_video_config((uint32_t)catalog, 0, &vcfg) == 0) { + int32_t track = moq_consume_video((uint32_t)catalog, 0, 1000000, on_frame, ud); // max_age_us + if (track > 0) { + pthread_mutex_lock(&c->mu); + c->video_started = 1; + pthread_mutex_unlock(&c->mu); + } + } + } + moq_consume_catalog_free((uint32_t)catalog); +} + +int main(int argc, char **argv) { + const char *url = NULL, *broadcast = NULL; + double timeout_s = 20.0; + for (int i = 1; i < argc; i++) { + if (!strcmp(argv[i], "--url") && i + 1 < argc) url = argv[++i]; + else if (!strcmp(argv[i], "--broadcast") && i + 1 < argc) broadcast = argv[++i]; + else if (!strcmp(argv[i], "--timeout") && i + 1 < argc) timeout_s = atof(argv[++i]); + // a leading "subscribe" positional (and anything else) is ignored. + } + if (!url || !broadcast) { + fprintf(stderr, "usage: c-smoke subscribe --url U --broadcast B [--timeout S]\n"); + return 2; + } + + ctx_t c; + pthread_mutex_init(&c.mu, NULL); + pthread_cond_init(&c.cv, NULL); + c.got = 0; + c.video_started = 0; + c.broadcast = 0; + + int32_t origin = moq_origin_create(); + if (origin <= 0) { + fprintf(stderr, "error: moq_origin_create failed: %d\n", origin); + return 1; + } + + // NULL config dials with the defaults; origin_publish = 0 disables + // publishing; consume via our origin. + int32_t session = moq_session_connect(url, strlen(url), NULL, 0, (uint32_t)origin, on_status, &c); + if (session <= 0) { + fprintf(stderr, "error: moq_session_connect failed: %d\n", session); + return 1; + } + + struct timespec deadline; + clock_gettime(CLOCK_REALTIME, &deadline); + deadline.tv_sec += (time_t)timeout_s; + + // The broadcast arrives over the network after connect, so wait for it to be + // announced. moq_origin_announced_broadcast resolves via on_broadcast once it's + // available; we block on the condvar until then (or the deadline). + int32_t wait = moq_origin_announced_broadcast((uint32_t)origin, broadcast, strlen(broadcast), on_broadcast, &c); + if (wait <= 0) { + fprintf(stderr, "error: moq_origin_announced_broadcast failed: %d\n", wait); + return 1; + } + + pthread_mutex_lock(&c.mu); + while (c.broadcast <= 0) { + if (pthread_cond_timedwait(&c.cv, &c.mu, &deadline) != 0) break; // timed out + } + int32_t bc = c.broadcast; + pthread_mutex_unlock(&c.mu); + if (bc <= 0) { + fprintf(stderr, "error: broadcast never announced\n"); + return 1; + } + + int32_t cat = moq_consume_catalog((uint32_t)bc, on_catalog, &c); + if (cat <= 0) { + fprintf(stderr, "error: moq_consume_catalog failed: %d\n", cat); + return 1; + } + + pthread_mutex_lock(&c.mu); + while (!c.got) { + if (pthread_cond_timedwait(&c.cv, &c.mu, &deadline) != 0) break; // timed out + } + int got = c.got; + pthread_mutex_unlock(&c.mu); + + if (got) { + fprintf(stderr, "received a frame from %s\n", broadcast); + // The data path succeeded, which is all this smoke client verifies. + // libmoq statically bundles moq-video (openh264/vaapi/cuda), whose + // worker threads use priority-protected mutexes; tearing them down at + // normal exit can trip a glibc pthread priority assertion and abort. + // Skip that atexit teardown with _exit now that we have our result. + fflush(stderr); + _exit(0); + } + fprintf(stderr, "error: timed out waiting for data\n"); + return 1; +} diff --git a/test/smoke/clients/cloudflare/Cargo.toml b/test/smoke/clients/cloudflare/Cargo.toml new file mode 100644 index 0000000000..e74cbc8a45 --- /dev/null +++ b/test/smoke/clients/cloudflare/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "cloudflare-smoke" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +anyhow = "1" +bytes = "1" +clap = { version = "4", features = ["derive"] } +moq-native-ietf = { git = "https://github.com/cloudflare/moq-rs" } +moq-transport = { git = "https://github.com/cloudflare/moq-rs" } +tokio = { version = "1", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +url = "2" +web-transport = "0.10" diff --git a/test/smoke/clients/cloudflare/src/main.rs b/test/smoke/clients/cloudflare/src/main.rs new file mode 100644 index 0000000000..6fb082c8e6 --- /dev/null +++ b/test/smoke/clients/cloudflare/src/main.rs @@ -0,0 +1,440 @@ +use std::collections::HashSet; +use std::net; +use std::path::PathBuf; +use std::process::ExitCode; +use std::time::Duration; + +use anyhow::{bail, Context}; +use bytes::Bytes; +use clap::{Parser, Subcommand, ValueEnum}; +use moq_native_ietf::quic; +use moq_transport::message::RequestErrorCode; +use moq_transport::{ + coding::TrackNamespace, + serve::{Datagram, ServeError, Track, TrackReader, TrackReaderMode, Tracks}, + session::{Publisher, SessionError, Subscriber}, +}; +use url::Url; + +const MAGIC: &[u8; 8] = b"MOQSMOKE"; +const HEADER_SIZE: usize = 20; + +#[derive(Parser)] +#[command(about = "Sustained cloudflare/moq-rs interoperability smoke client")] +struct Args { + /// Relay URL (https:// for WebTransport, moqt:// for raw QUIC). + #[arg(long)] + url: Url, + + /// Namespace containing the smoke track. + #[arg(long)] + namespace: String, + + /// Track carrying deterministic smoke payloads. + #[arg(long, default_value = "payload")] + track: String, + + /// Listen address for the client's UDP socket. + #[arg(long, default_value = "[::]:0")] + bind: net::SocketAddr, + + /// Danger: disable TLS certificate verification for the local smoke relay. + #[arg(long)] + tls_disable_verify: bool, + + /// Root certificate used to avoid dependence on the host trust store. + #[arg(long)] + tls_root: Option, + + /// Object delivery mode to exercise. + #[arg(long, value_enum, default_value_t = Delivery::Subgroup)] + delivery: Delivery, + + /// Bytes in each deterministic object. + #[arg(long)] + object_size: usize, + + /// Delay between published objects. + #[arg(long, default_value_t = 5)] + interval_ms: u64, + + /// Delay before publishing the first object, allowing relay registration and subscription. + #[arg(long, default_value_t = 1_500)] + start_delay_ms: u64, + + #[command(subcommand)] + command: Command, +} + +#[derive(Clone, Copy, Debug, ValueEnum)] +enum Delivery { + Subgroup, + Datagram, +} + +#[derive(Subcommand)] +enum Command { + /// Publish deterministic objects until the orchestrator stops the process. + Publish, + /// Exit 0 once the relay accepts the subscription, or 75 while its namespace is unknown. + Probe, + /// Subscribe and validate the requested number of complete objects. + Subscribe { + #[arg(long, default_value_t = 32)] + objects: usize, + }, +} + +enum Outcome { + Complete, + NamespaceUnavailable, +} + +fn retryable_probe_request_error(err: &ServeError) -> bool { + matches!(err, ServeError::NotFound | ServeError::NotFoundWithId(_, _)) + || matches!( + err, + ServeError::Closed(code) if *code == RequestErrorCode::DoesNotExist as u64 + ) +} + +fn retryable_probe_session_error(err: &SessionError) -> bool { + matches!(err, SessionError::Serve(ServeError::Cancel)) +} + +#[tokio::main] +async fn main() -> ExitCode { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + match run().await { + Ok(Outcome::Complete) => ExitCode::SUCCESS, + Ok(Outcome::NamespaceUnavailable) => ExitCode::from(75), + Err(err) => { + eprintln!("{err:#}"); + ExitCode::FAILURE + } + } +} + +async fn run() -> anyhow::Result { + let args = Args::parse(); + anyhow::ensure!( + args.object_size >= HEADER_SIZE, + "object size must be at least {HEADER_SIZE} bytes" + ); + anyhow::ensure!( + args.object_size <= u32::MAX as usize, + "object size exceeds the encoded length field" + ); + if let Command::Subscribe { objects } = &args.command { + anyhow::ensure!(*objects > 0, "subscriber must validate at least one object"); + } + + let tls = moq_native_ietf::tls::Args { + disable_verify: args.tls_disable_verify, + root: args.tls_root.clone().into_iter().collect(), + ..Default::default() + } + .load()?; + let endpoint = quic::Endpoint::new(quic::Config::new(args.bind, None, tls)?)?; + let (session, _, transport) = endpoint.client.connect(&args.url, None).await?; + + match args.command { + Command::Publish => publish(args, session, transport) + .await + .map(|_| Outcome::Complete), + Command::Probe => probe(args, session, transport).await, + Command::Subscribe { objects } => subscribe(args, objects, session, transport) + .await + .map(|_| Outcome::Complete), + } +} + +async fn publish( + args: Args, + session: web_transport::Session, + transport: moq_transport::session::Transport, +) -> anyhow::Result<()> { + let (session, mut publisher) = Publisher::connect(session, transport) + .await + .context("publisher SETUP failed")?; + let namespace = TrackNamespace::from_utf8_path(&args.namespace); + let (mut tracks, _, tracks_reader) = Tracks::new(namespace).produce(); + let track = tracks + .create(&args.track) + .context("failed to create smoke track")?; + + let send = async move { + // Give the namespace advertisement and downstream subscription time to + // reach the publisher before opening the first group. This avoids making + // the smoke result depend on how a relay handles joining a partial group. + tokio::time::sleep(Duration::from_millis(args.start_delay_ms)).await; + match args.delivery { + Delivery::Subgroup => { + publish_subgroups(track, args.object_size, args.interval_ms).await + } + Delivery::Datagram => { + publish_datagrams(track, args.object_size, args.interval_ms).await + } + } + }; + + tokio::select! { + res = session.run() => res.context("publisher session failed"), + res = publisher.publish_namespace(tracks_reader) => res.context("namespace publication failed"), + res = send => res, + } +} + +async fn publish_subgroups( + track: moq_transport::serve::TrackWriter, + object_size: usize, + interval_ms: u64, +) -> anyhow::Result<()> { + let mut groups = track.subgroups()?; + let mut sequence = 0_u64; + loop { + let mut group = groups.append(127)?; + for _ in 0..8 { + group.write(payload(sequence, object_size))?; + sequence = sequence.wrapping_add(1); + tokio::time::sleep(Duration::from_millis(interval_ms)).await; + } + } +} + +async fn publish_datagrams( + track: moq_transport::serve::TrackWriter, + object_size: usize, + interval_ms: u64, +) -> anyhow::Result<()> { + let mut datagrams = track.datagrams()?; + let mut sequence = 0_u64; + loop { + datagrams.write(Datagram { + group_id: sequence, + object_id: 0, + priority: 127, + payload: payload(sequence, object_size), + extension_headers: Default::default(), + })?; + sequence = sequence.wrapping_add(1); + tokio::time::sleep(Duration::from_millis(interval_ms)).await; + } +} + +async fn subscribe( + args: Args, + objects: usize, + session: web_transport::Session, + transport: moq_transport::session::Transport, +) -> anyhow::Result<()> { + let (session, mut subscriber) = Subscriber::connect(session, transport) + .await + .context("subscriber SETUP failed")?; + let namespace = TrackNamespace::from_utf8_path(&args.namespace); + let (track_writer, track_reader) = Track::new(namespace, args.track).produce(); + + let receive = receive(track_reader, args.delivery, args.object_size, objects); + let accept_namespaces = accept_namespaces(subscriber.clone()); + tokio::select! { + res = session.run() => { + res.context("subscriber session failed")?; + bail!("subscriber session ended before validation completed") + }, + res = subscriber.subscribe(track_writer) => { + res.context("subscription ended before validation completed")?; + bail!("subscription ended before validation completed") + }, + res = receive => res, + res = accept_namespaces => res, + } +} + +async fn probe( + args: Args, + session: web_transport::Session, + transport: moq_transport::session::Transport, +) -> anyhow::Result { + let (session, mut subscriber) = Subscriber::connect(session, transport) + .await + .context("probe SETUP failed")?; + let namespace = TrackNamespace::from_utf8_path(&args.namespace); + let (track_writer, _) = Track::new(namespace, args.track).produce(); + let accept_namespaces = accept_namespaces(subscriber.clone()); + + tokio::select! { + res = session.run() => match res { + // An early SUBSCRIBE can race PUBLISH_NAMESPACE. Depending on which + // side closes first, moq-rs surfaces that rejection either on the + // request or as a cancelled probe session. Both mean "retry the + // readiness probe"; the publisher-liveness/deadline checks remain + // in cloudflare.sh. + Err(err) if retryable_probe_session_error(&err) => Ok(Outcome::NamespaceUnavailable), + res => { + res.context("probe session failed")?; + bail!("probe session ended before subscription acceptance") + } + }, + res = subscriber.subscribe_open(track_writer) => match res { + Ok(_subscription) => { + println!("subscription accepted"); + Ok(Outcome::Complete) + }, + Err(err) if retryable_probe_request_error(&err) => { + Ok(Outcome::NamespaceUnavailable) + }, + Err(err) => Err(err).context("probe subscription failed"), + }, + res = accept_namespaces => { + res?; + bail!("probe namespace acceptance loop ended") + }, + } +} + +async fn accept_namespaces(mut subscriber: Subscriber) -> anyhow::Result<()> { + let mut active = Vec::new(); + loop { + let mut namespace = subscriber + .published_namespace() + .await + .context("published namespace queue closed")?; + namespace + .ok() + .context("failed to accept published namespace")?; + active.push(namespace); + } +} + +async fn receive( + track: TrackReader, + delivery: Delivery, + object_size: usize, + wanted: usize, +) -> anyhow::Result<()> { + let mut seen = HashSet::with_capacity(wanted); + match (delivery, track.mode().await?) { + (Delivery::Subgroup, TrackReaderMode::Subgroups(mut groups)) => { + while seen.len() < wanted { + let mut group = groups + .next() + .await? + .context("subgroup stream ended before validation completed")?; + while let Some(object) = group.read_next().await? { + let sequence = validate(&object, object_size)?; + seen.insert(sequence); + if seen.len() == wanted { + break; + } + } + } + } + (Delivery::Datagram, TrackReaderMode::Datagrams(mut datagrams)) => { + while seen.len() < wanted { + let datagram = datagrams + .read() + .await? + .context("datagram stream ended before validation completed")?; + seen.insert(validate(&datagram.payload, object_size)?); + } + } + (expected, _) => bail!("relay returned a different delivery mode than {expected:?}"), + } + + println!( + "validated {} {} objects ({} bytes)", + seen.len(), + match delivery { + Delivery::Subgroup => "subgroup", + Delivery::Datagram => "datagram", + }, + seen.len() * object_size + ); + Ok(()) +} + +fn payload(sequence: u64, size: usize) -> Bytes { + let mut payload = vec![0; size]; + payload[..8].copy_from_slice(MAGIC); + payload[8..16].copy_from_slice(&sequence.to_be_bytes()); + payload[16..20].copy_from_slice(&(size as u32).to_be_bytes()); + for (offset, byte) in payload[HEADER_SIZE..].iter_mut().enumerate() { + *byte = pattern(sequence, offset); + } + payload.into() +} + +fn validate(payload: &[u8], expected_size: usize) -> anyhow::Result { + anyhow::ensure!(payload.len() == expected_size, "object size mismatch"); + anyhow::ensure!(&payload[..8] == MAGIC, "object magic mismatch"); + let sequence = u64::from_be_bytes(payload[8..16].try_into().unwrap()); + let declared_size = u32::from_be_bytes(payload[16..20].try_into().unwrap()) as usize; + anyhow::ensure!( + declared_size == expected_size, + "declared object size mismatch" + ); + for (offset, byte) in payload[HEADER_SIZE..].iter().enumerate() { + anyhow::ensure!( + *byte == pattern(sequence, offset), + "object payload corruption" + ); + } + Ok(sequence) +} + +fn pattern(sequence: u64, offset: usize) -> u8 { + sequence.to_be_bytes()[offset % 8] ^ (offset as u8).wrapping_mul(17) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn payload_round_trip() { + let payload = payload(42, 65_536); + assert_eq!(validate(&payload, 65_536).unwrap(), 42); + } + + #[test] + fn corruption_is_rejected() { + let mut payload = payload(7, 900).to_vec(); + payload[100] ^= 1; + assert!(validate(&payload, 900).is_err()); + } + + #[test] + fn sequence_header_corruption_is_rejected() { + let mut payload = payload(7, 900).to_vec(); + payload[8] ^= 1; + assert!(validate(&payload, 900).is_err()); + } + + #[test] + fn readiness_race_errors_are_retryable() { + assert!(retryable_probe_session_error(&SessionError::Serve( + ServeError::Cancel + ))); + assert!(retryable_probe_request_error(&ServeError::NotFound)); + assert!(retryable_probe_request_error(&ServeError::Closed( + RequestErrorCode::DoesNotExist as u64 + ))); + } + + #[test] + fn unrelated_probe_errors_are_not_retryable() { + assert!(!retryable_probe_session_error(&SessionError::Internal)); + assert!(!retryable_probe_request_error(&ServeError::Closed( + RequestErrorCode::Unauthorized as u64 + ))); + assert!(!retryable_probe_request_error(&ServeError::Internal( + "test".into() + ))); + } +} diff --git a/test/smoke/clients/docker/moq b/test/smoke/clients/docker/moq new file mode 100755 index 0000000000..f90c38b938 --- /dev/null +++ b/test/smoke/clients/docker/moq @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Run the published moqdev/moq Docker image as the moq-under-test (the +# publisher and the rust subscriber), the moq half of the Docker channel. +# +# Host networking so it reaches the relay on 127.0.0.1; -i so the publisher can +# pipe ffmpeg's Annex-B stream to `moq publish` over the container's stdin. +# Linux-only for the same reason as the moq-relay wrapper. Override with +# SMOKE_DOCKER=podman / MOQ_CLI_IMAGE. +set -euo pipefail + +runtime="${SMOKE_DOCKER:-docker}" +image="${MOQ_CLI_IMAGE:-moqdev/moq}" + +exec "$runtime" run --rm -i --network host "$image" "$@" diff --git a/test/smoke/clients/docker/moq-relay b/test/smoke/clients/docker/moq-relay new file mode 100755 index 0000000000..d855a3dd29 --- /dev/null +++ b/test/smoke/clients/docker/moq-relay @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Run the published moqdev/moq-relay Docker image as the relay-under-test, so the +# media smoke exercises the Docker Hub channel alongside cargo/brew/apt/nix. +# +# smoke.sh points RELAY_BIN at this script and invokes it with the relay config +# path. We bind-mount that file's directory and use host networking so the relay +# binds the ports the orchestrator (curl on 127.0.0.1) and the moq containers +# reach -- the committed smoke.toml's 127.0.0.1 listeners work unchanged. +# +# Linux-only: --network host needs a native Docker daemon sharing the host's +# network namespace, which GitHub's macOS runners (and Docker Desktop / podman +# machine VMs) don't provide. Override the runtime with SMOKE_DOCKER=podman and +# the image with MOQ_RELAY_IMAGE. +set -euo pipefail + +runtime="${SMOKE_DOCKER:-docker}" +image="${MOQ_RELAY_IMAGE:-moqdev/moq-relay}" + +# Give the container a stable name so smoke.sh can reap it. A SIGKILL of this +# wrapper (how smoke.sh tears the relay down) kills the runtime client but leaves +# the detached container running and still holding the relay's port, which then +# blocks the next smoke.sh run in the same job. smoke.sh's cleanup removes it by +# this name; clear any leftover from a crashed run here too, before we rebind. +name="${MOQ_RELAY_CONTAINER:-moq-relay-smoke}" +"$runtime" rm -f "$name" >/dev/null 2>&1 || true + +# smoke.sh passes the config path as the final argument (or just --help, to +# probe which config layout this relay accepts). +mount=() +cfg="${*: -1}" +if [[ -f "$cfg" ]]; then + dir=$(cd "$(dirname -- "$cfg")" && pwd) + mount=(-v "$dir:$dir") +fi + +exec "$runtime" run --rm -i --name "$name" --network host "${mount[@]}" "$image" "$@" diff --git a/test/smoke/clients/go/go.mod b/test/smoke/clients/go/go.mod new file mode 100644 index 0000000000..bf9c0e5b07 --- /dev/null +++ b/test/smoke/clients/go/go.mod @@ -0,0 +1,7 @@ +module moqsmoke + +go 1.23 + +require moq.dev/moq v0.7.1 + +require moq.dev/moq-ffi v0.4.1 // indirect diff --git a/test/smoke/clients/go/smoke.go b/test/smoke/clients/go/smoke.go new file mode 100644 index 0000000000..ac6f05ac45 --- /dev/null +++ b/test/smoke/clients/go/smoke.go @@ -0,0 +1,179 @@ +// Cross-language interop client for the smoke test, built against the ergonomic +// moq.dev/moq wrapper (package moq: Dial, CreateBroadcast, +// PublishVideoStream, SubscribeMedia, range-over-func frame iterators). The raw +// uniffi-bindgen-go surface lives in moq.dev/moq-ffi; this client +// exercises the idiomatic wrapper a real Go user would reach for. +// +// publish reads raw Annex-B H.264 from stdin (e.g. piped from ffmpeg) and feeds +// it to a streaming importer, which infers frame boundaries. subscribe connects, +// finds the video track in the catalog, and exits 0 as soon as any non-empty +// frame arrives (exit 1 on timeout / no data). Usage: +// +// ffmpeg ... -f h264 - | go-smoke publish --url http://127.0.0.1:4443 --broadcast b.hang +// go-smoke subscribe --url http://127.0.0.1:4443 --broadcast b.hang --timeout 20 +package main + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "time" + + "moq.dev/moq" +) + +const readChunk = 64 * 1024 + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "usage: go-smoke publish|subscribe --url U --broadcast B [--timeout S]") + os.Exit(2) + } + role := os.Args[1] + + fs := flag.NewFlagSet(role, flag.ExitOnError) + url := fs.String("url", "", "MoQ server URL") + broadcast := fs.String("broadcast", "", "broadcast name") + timeout := fs.Float64("timeout", 20, "subscribe timeout in seconds") + _ = fs.Parse(os.Args[2:]) + + if *url == "" || *broadcast == "" { + fmt.Fprintln(os.Stderr, "error: --url and --broadcast are required") + os.Exit(2) + } + + var err error + switch role { + case "publish": + err = publish(*url, *broadcast) + case "subscribe": + err = subscribe(*url, *broadcast, *timeout) + default: + fmt.Fprintf(os.Stderr, "unknown role: %s\n", role) + os.Exit(2) + } + + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} + +func publish(url, broadcast string) error { + // The relay uses a self-signed cert, so verification is off. With no origin + // options, Dial shares one origin for publish + consume. + client, err := moq.Dial(context.Background(), url, moq.WithTLSVerify(false)) + if err != nil { + return err + } + defer client.Close() + + producer, err := client.CreateBroadcast(broadcast) + if err != nil { + return err + } + defer producer.Finish() + + // avc3: a self-describing Annex-B H.264 stream the importer can frame on its + // own. PublishVideoStream feeds the raw byte stream; whole frames are emitted + // as they complete. + media, err := producer.PublishVideoStream(moq.VideoFormatAvc3) + if err != nil { + return err + } + defer media.Finish() + + // CreateBroadcast only registers the path locally; Announce advertises it to + // the relay, once its tracks exist. + if err := producer.Announce(moq.Route{}); err != nil { + return err + } + + fmt.Fprintf(os.Stderr, "publishing %q (Annex-B H.264 from stdin) to %s\n", broadcast, url) + + buf := make([]byte, readChunk) + for { + n, rerr := os.Stdin.Read(buf) + if n > 0 { + if werr := media.Write(buf[:n]); werr != nil { + return werr + } + } + if rerr == io.EOF { + break + } + if rerr != nil { + return rerr + } + } + return media.Finish() +} + +func subscribe(url, broadcast string, timeoutS float64) error { + // The whole subscribe must complete within the timeout; a cancelled context + // aborts any in-flight wrapper call and unblocks the frame iterator. + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutS*float64(time.Second))) + defer cancel() + + client, err := moq.Dial(ctx, url, moq.WithTLSVerify(false)) + if err != nil { + return err + } + defer client.Close() + + announced, err := client.AnnouncedBroadcast(broadcast) + if err != nil { + return err + } + defer announced.Cancel() + + bc, err := announced.Available(ctx) + if err != nil { + return err + } + + name, video, err := videoTrack(ctx, bc) + if err != nil { + return err + } + + media, err := bc.SubscribeMedia(ctx, name, video.Container, nil) + if err != nil { + return err + } + defer media.Cancel() + + for frame, err := range media.Frames(ctx) { + if err != nil { + return err + } + if len(frame.Payload) > 0 { + fmt.Fprintf(os.Stderr, "received %d bytes from %q\n", len(frame.Payload), broadcast) + return nil + } + } + return fmt.Errorf("no frame data received") +} + +// videoTrack waits for a catalog update that actually carries a video track. A +// lazy publisher (e.g. the browser, which only encodes on demand) may announce +// video in a later update, not the first snapshot. +func videoTrack(ctx context.Context, bc *moq.BroadcastConsumer) (string, moq.Video, error) { + cat, err := bc.SubscribeCatalog(ctx) + if err != nil { + return "", moq.Video{}, err + } + defer cat.Cancel() + + for catalog, err := range cat.Updates(ctx) { + if err != nil { + return "", moq.Video{}, err + } + for name, video := range catalog.Video { + return name, video, nil + } + } + return "", moq.Video{}, fmt.Errorf("catalog stream ended without a video track") +} diff --git a/test/smoke/clients/js-native/package.json b/test/smoke/clients/js-native/package.json new file mode 100644 index 0000000000..1be66c4c85 --- /dev/null +++ b/test/smoke/clients/js-native/package.json @@ -0,0 +1,14 @@ +{ + "name": "@moq/smoke-native", + "private": true, + "type": "module", + "dependencies": { + "@moq/hang": "latest", + "@moq/net": "latest", + "@moq/web-transport": "latest", + "zod": "^4.4.3" + }, + "devDependencies": { + "tsx": "^4.23.1" + } +} diff --git a/test/smoke/clients/js-native/subscribe.ts b/test/smoke/clients/js-native/subscribe.ts new file mode 100644 index 0000000000..9f36d63f66 --- /dev/null +++ b/test/smoke/clients/js-native/subscribe.ts @@ -0,0 +1,148 @@ +// Native-JS (non-browser) subscriber: run the published @moq/net + @moq/hang +// packages under a JS runtime that has no native WebTransport, using moq's own +// @moq/web-transport polyfill (a prebuilt NAPI QUIC/HTTP3 addon). Runs under both +// node and bun -- see the smoke harness. +// +// Connect, read the .hang catalog to find the video track, subscribe it, and +// exit 0 as soon as any non-empty frame arrives (1 on timeout). +// +// node --import tsx subscribe.ts subscribe --url http://127.0.0.1:4443 --broadcast b.hang --timeout 20 +// +// Subscribe-only: publishing media needs a WebCodecs encoder, which a native JS +// runtime doesn't have. Reading raw container frames needs no codec. +import { parseArgs } from "node:util"; +import * as Catalog from "@moq/hang/catalog"; +import * as Moq from "@moq/net"; +import { install } from "@moq/web-transport"; + +// globalThis.WebTransport = the polyfill (no-op if a native one already exists). +// @moq/net's connect() reads globalThis.WebTransport at call time, so this just +// has to run before run() below. +install(); + +const { positionals, values } = parseArgs({ + allowPositionals: true, + options: { + url: { type: "string" }, + broadcast: { type: "string" }, + timeout: { type: "string", default: "20" }, + }, +}); + +const role = positionals[0]; +const url = values.url; +const broadcast = values.broadcast; +const timeoutMs = Number.parseFloat(values.timeout ?? "20") * 1000; +if (role !== "subscribe" || !url || !broadcast) { + console.error("usage: subscribe.ts subscribe --url U --broadcast B [--timeout S]"); + process.exit(2); +} + +const abort = new AbortController(); +let activeConnection: Awaited> | undefined; + +function closeActiveConnection(): void { + const connection = activeConnection; + activeConnection = undefined; + connection?.close(); // returns void, not a promise +} + +async function run(): Promise { + // Since @moq/net 0.4.0 a session doesn't consume broadcasts itself: it feeds the + // peer's announcements into an origin, and consumers request paths from that. + const origin = new Moq.Origin.Producer(); + // smoke.sh sets this on external relays that are not the WebSocket column. + const websocket = process.env.SMOKE_WEBSOCKET === "0" ? { enabled: false as const } : undefined; + const connection = await Moq.Connection.connect({ + url: new URL(url as string), + consume: origin, + signal: abort.signal, + websocket, + }); + activeConnection = connection; + // Same shape as the Rust clients' log line, so the harness records the version. + console.error(`connected version=${connection.version}`); + + // Wait for the broadcast to be announced before subscribing. Subscribing to a + // track on a broadcast the publisher hasn't announced yet races the relay, + // which resets the catalog stream (RESET_STREAM). `announced: true` folds that + // wait into the request; the outer timeout below bounds it. + const request = origin.request(Moq.Path.from(broadcast as string), { announced: true }); + try { + let bc = request.active.peek(); + while (!bc) bc = await request.active.changed(); + + // The .hang catalog lives on the Catalog.TRACK ("catalog.json") track, one + // JSON snapshot per frame validated against RootSchema. (@moq/hang 0.3.0 + // dropped the Catalog.Consumer helper, so read the frames directly.) A lazy + // publisher may announce video in a later update, so keep pulling until one + // carries a video track. + const catalog = bc.track(Catalog.TRACK).subscribe({ priority: Catalog.PRIORITY.catalog }); + let videoTrack: string | undefined; + while (!videoTrack) { + const group = await catalog.recvGroup(); + if (!group) throw new Error("catalog ended without a video track"); + const frame = await group.readFrame(); + if (!frame) continue; + const root = Catalog.RootSchema.parse(JSON.parse(new TextDecoder().decode(frame.payload))); + const renditions = root.video?.renditions; + if (renditions) videoTrack = Object.keys(renditions)[0]; + } + + const video = bc.track(videoTrack).subscribe({ priority: 0 }); + let total = 0; + for (;;) { + const group = await video.recvGroup(); + if (!group) break; + try { + for (;;) { + const frame = await group.readFrame(); + if (!frame) break; + total += frame.payload.byteLength; + if (total > 0) { + console.error(`received ${total} bytes from ${broadcast}`); + return; + } + } + } catch (err) { + // A relay may reset a group stream it has given up on (e.g. a stale + // group once a newer one starts). Players skip to the next group, so + // do the same; the track itself failing still ends in recvGroup(). + console.error(`group reset, waiting for the next: ${err instanceof Error ? err.message : String(err)}`); + } + } + throw new Error("no frame data received"); + } finally { + request.close(); + if (activeConnection === connection) closeActiveConnection(); + origin.close(); + } +} + +let timeoutId: ReturnType | undefined; +const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + const err = new Error("timed out waiting for data"); + abort.abort(err); + closeActiveConnection(); + reject(err); + }, timeoutMs); +}); + +// Let pending napi-rs cleanup finish instead of forcing environment teardown +// while native reads may still be releasing references. +try { + await Promise.race([run(), timeout]); +} catch (err) { + // AggregateError from Promise.any has an empty message; the cause is in .errors. + const message = + err instanceof AggregateError + ? err.errors.map((e) => (e instanceof Error ? e.message : String(e))).filter(Boolean).join("; ") + : err instanceof Error + ? err.message + : String(err); + console.error(`error: ${message || String(err)}`); + process.exitCode = 1; +} finally { + if (timeoutId !== undefined) clearTimeout(timeoutId); +} diff --git a/test/smoke/clients/js/build-esbuild.ts b/test/smoke/clients/js/build-esbuild.ts new file mode 100644 index 0000000000..8ab8e2f4f9 --- /dev/null +++ b/test/smoke/clients/js/build-esbuild.ts @@ -0,0 +1,25 @@ +// The "another bundler" variant: bundle the same entry point with esbuild +// instead of vite, to confirm the published packages consume cleanly under a +// different bundler. Writes dist-esbuild/{main.js,index.html}. +import { mkdirSync, writeFileSync } from "node:fs"; +import { build } from "esbuild"; + +await build({ + entryPoints: ["src/main.ts"], + bundle: true, + format: "esm", + target: "esnext", + outfile: "dist-esbuild/main.js", + logLevel: "info", +}); + +mkdirSync("dist-esbuild", { recursive: true }); +writeFileSync( + "dist-esbuild/index.html", + ` + + moq smoke (esbuild) + + +`, +); diff --git a/test/smoke/clients/js/diagnostics.test.ts b/test/smoke/clients/js/diagnostics.test.ts new file mode 100644 index 0000000000..745ae2ba0b --- /dev/null +++ b/test/smoke/clients/js/diagnostics.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ConsoleMessage, Page, Request, Response } from "playwright"; +import { Diagnostics, responseBody } from "./diagnostics"; + +describe("responseBody", () => { + test("retains a CDN error body", () => { + expect(responseBody(Buffer.from("upstream unavailable"))).toEqual({ + body: "dXBzdHJlYW0gdW5hdmFpbGFibGU=", + }); + }); + + test("bounds diagnostic artifacts", () => { + expect(responseBody(Buffer.alloc(256 * 1024 + 1))).toEqual({ + bodyError: "body exceeds 262144 bytes", + }); + }); +}); + +function fakePage() { + const handlers = new Map void>>(); + const page = { + on(name: string, handler: (value: never) => void) { + handlers.set(name, [...(handlers.get(name) ?? []), handler]); + }, + } as unknown as Page; + const emit = (name: string, value: unknown) => { + for (const handler of handlers.get(name) ?? []) handler(value as never); + }; + return { page, emit }; +} + +function fakeExchange(body: () => Promise) { + const url = "https://cdn.jsdelivr.net/npm/@moq/watch/element/+esm"; + const request = { + method: () => "GET", + url: () => url, + resourceType: () => "script", + headers: () => ({ origin: "http://localhost:1234" }), + failure: () => ({ errorText: "net::ERR_FAILED" }), + response: async () => response, + } as unknown as Request; + const response = { + request: () => request, + url: () => url, + status: () => 503, + statusText: () => "Service Unavailable", + headers: () => ({ "content-type": "text/plain" }), + body, + } as unknown as Response; + return { request, response }; +} + +async function written(diagnostics: Diagnostics, error?: unknown) { + const dir = await mkdtemp(join(tmpdir(), "smoke-diagnostics-")); + try { + const path = await diagnostics.write(dir, "failure", error); + return JSON.parse(await readFile(path, "utf8")); + } finally { + await rm(dir, { recursive: true }); + } +} + +test("records the failed exchange and page context", async () => { + const { page, emit } = fakePage(); + const { request, response } = fakeExchange(async () => Buffer.from("upstream unavailable")); + const message = { + type: () => "error", + text: () => "blocked by CORS policy", + location: () => ({ url: request.url(), lineNumber: 0, columnNumber: 0 }), + } as unknown as ConsoleMessage; + + const diagnostics = new Diagnostics(page, "subscribe", "jsdelivr", "http://localhost:1234/"); + emit("request", request); + emit("response", response); + emit("requestfailed", request); + emit("console", message); + emit("pageerror", new Error("module load failed")); + + const artifact = await written(diagnostics, new Error("goto failed")); + expect(artifact.requests[0]).toMatchObject({ + url: request.url(), + failure: "net::ERR_FAILED", + response: { + status: 503, + headers: { "content-type": "text/plain" }, + body: "dXBzdHJlYW0gdW5hdmFpbGFibGU=", + }, + }); + expect(artifact.console[0].text).toBe("blocked by CORS policy"); + expect(artifact.pageErrors[0]).toContain("module load failed"); + expect(artifact.error).toContain("goto failed"); +}); + +test("writes despite a stalled CDN body", async () => { + const { page, emit } = fakePage(); + const { request, response } = fakeExchange(() => new Promise(() => {})); + + const diagnostics = new Diagnostics(page, "subscribe", "jsdelivr", "http://localhost:1234/"); + emit("request", request); + emit("response", response); + + const artifact = await written(diagnostics); + expect(artifact.requests[0].response).toMatchObject({ + status: 503, + bodyError: "body incomplete when the cell ended", + }); +}); diff --git a/test/smoke/clients/js/diagnostics.ts b/test/smoke/clients/js/diagnostics.ts new file mode 100644 index 0000000000..6eca79dfef --- /dev/null +++ b/test/smoke/clients/js/diagnostics.ts @@ -0,0 +1,116 @@ +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import type { ConsoleMessage, Page, Request, Response } from "playwright"; + +interface Exchange { + method: string; + url: string; + resourceType: string; + requestHeaders: Record; + response?: { + status: number; + statusText: string; + headers: Record; + body?: string; + bodyError?: string; + }; + failure?: string; +} + +interface Diagnostic { + role: string; + variant: string; + pageUrl: string; + error?: string; + requests: Exchange[]; + console: Array<{ type: string; text: string; location: ReturnType }>; + pageErrors: string[]; +} + +const CDN = "https://cdn.jsdelivr.net/"; +const MAX_BODY = 256 * 1024; + +export function responseBody(body: Buffer): { body?: string; bodyError?: string } { + if (body.byteLength > MAX_BODY) return { bodyError: `body exceeds ${MAX_BODY} bytes` }; + return { body: body.toString("base64") }; +} + +export class Diagnostics { + readonly #data: Diagnostic; + readonly #requests = new Map(); + readonly #pending: Promise[] = []; + + constructor(page: Page, role: string, variant: string, pageUrl: string) { + this.#data = { role, variant, pageUrl, requests: [], console: [], pageErrors: [] }; + page.on("request", (request) => this.#request(request)); + page.on("response", (response) => this.#response(response)); + page.on("requestfinished", (request) => this.#body(request)); + page.on("requestfailed", (request) => { + this.#exchange(request).failure = request.failure()?.errorText ?? "unknown request failure"; + this.#body(request); + }); + page.on("console", (message) => this.console(message)); + page.on("pageerror", (error) => this.#data.pageErrors.push(error.stack ?? error.message)); + } + + console(message: ConsoleMessage): void { + this.#data.console.push({ type: message.type(), text: message.text(), location: message.location() }); + } + + async write(dir: string, name: string, error?: unknown): Promise { + this.#data.error = error instanceof Error ? (error.stack ?? error.message) : error === undefined ? undefined : String(error); + await Promise.allSettled(this.#pending); + for (const exchange of this.#data.requests) { + const response = exchange.response; + if (exchange.url.startsWith(CDN) && response && response.body === undefined && response.bodyError === undefined) { + response.bodyError = "body incomplete when the cell ended"; + } + } + await mkdir(dir, { recursive: true }); + const path = join(dir, `${name}.json`); + await Bun.write(path, `${JSON.stringify(this.#data, undefined, 2)}\n`); + return path; + } + + #request(request: Request): Exchange { + const exchange: Exchange = { + method: request.method(), + url: request.url(), + resourceType: request.resourceType(), + requestHeaders: request.headers(), + }; + this.#requests.set(request, exchange); + this.#data.requests.push(exchange); + return exchange; + } + + #response(response: Response): void { + this.#exchange(response.request()).response = { + status: response.status(), + statusText: response.statusText(), + headers: response.headers(), + }; + } + + // Read only after the request ends, so a stalled CDN body cannot hang write(). + #body(request: Request): void { + const exchange = this.#exchange(request); + if (!exchange.url.startsWith(CDN)) return; + this.#pending.push( + (async () => { + const meta = exchange.response; + if (!meta) return; + try { + const response = await request.response(); + if (response) Object.assign(meta, responseBody(await response.body())); + } catch (error) { + meta.bodyError = error instanceof Error ? error.message : String(error); + } + })(), + ); + } + + #exchange(request: Request): Exchange { + return this.#requests.get(request) ?? this.#request(request); + } +} diff --git a/test/smoke/clients/js/driver.ts b/test/smoke/clients/js/driver.ts new file mode 100644 index 0000000000..9256815762 --- /dev/null +++ b/test/smoke/clients/js/driver.ts @@ -0,0 +1,156 @@ +// Drives a headless Chromium (channel "chromium" for WebTransport + WebCodecs) +// against a prebuilt page. publish streams a fake camera until killed; subscribe +// exits 0 once the watch element decodes a frame. +// +// --variant selects how the published packages are delivered to the page: +// vite bundled by vite -> dist-vite/ +// esbuild bundled by esbuild -> dist-esbuild/ +// jsdelivr imported from the CDN -> jsdelivr/ (no build) +// The smoke harness builds the bundler variants first; jsdelivr needs no build. +// +// bun driver.ts publish --variant vite --url http://127.0.0.1:4443 --broadcast b.hang +// bun driver.ts subscribe --variant jsdelivr --url http://127.0.0.1:4443 --broadcast b.hang --timeout 20 +import { join } from "node:path"; +import { parseArgs } from "node:util"; +import { chromium } from "playwright"; +import { Diagnostics } from "./diagnostics"; + +const { positionals, values } = parseArgs({ + allowPositionals: true, + options: { + url: { type: "string" }, + broadcast: { type: "string" }, + variant: { type: "string", default: "vite" }, + timeout: { type: "string", default: "20" }, + }, +}); + +const role = positionals[0]; +const url = values.url; +const broadcast = values.broadcast; +const variant = values.variant ?? "vite"; +const dirByVariant: Record = { + vite: "dist-vite", + esbuild: "dist-esbuild", + jsdelivr: "jsdelivr", +}; +const dir = dirByVariant[variant]; +if ((role !== "publish" && role !== "subscribe") || !url || !broadcast || !dir) { + console.error("usage: driver.ts publish|subscribe --variant vite|esbuild|jsdelivr --url U --broadcast B [--timeout S]"); + process.exit(2); +} +const timeoutMs = Number.parseFloat(values.timeout ?? "20") * 1000; + +// Serve the prebuilt page on localhost (a secure context, so WebTransport / +// WebCodecs are enabled). A plain static server avoids running concurrent Vite +// dev servers, which deadlock on the shared dep-optimizer cache. +const root = join(new URL(".", import.meta.url).pathname, dir); +const server = Bun.serve({ + port: 0, + async fetch(req) { + let path = new URL(req.url).pathname; + if (path === "/") path = "/index.html"; + const file = Bun.file(join(root, path)); + if (await file.exists()) return new Response(file); + return new Response(Bun.file(join(root, "index.html"))); // SPA fallback + }, +}); +const pageUrl = `http://localhost:${server.port}/?role=${role}&url=${encodeURIComponent(url)}&broadcast=${encodeURIComponent(broadcast)}`; + +const browser = await chromium.launch({ + channel: "chromium", // full Chromium (new headless); the headless shell lacks these APIs + headless: true, + args: [ + "--use-fake-device-for-media-stream", + "--use-fake-ui-for-media-stream", + "--autoplay-policy=no-user-gesture-required", + ], +}); + +let code = 1; +let error: unknown; +try { + const context = await browser.newContext(); + const page = await context.newPage(); + const diagnostics = new Diagnostics(page, role, variant, pageUrl); + const artifactDir = process.env.SMOKE_BROWSER_ARTIFACTS; + const artifactName = [role, variant, broadcast].join("-").replaceAll(/[^A-Za-z0-9._-]/g, "_"); + if (artifactDir) await context.tracing.start({ screenshots: true, snapshots: true, sources: true }); + // The published elements race a WebSocket fallback and expose no switch for it. + // smoke.sh sets this on external relays that are not the WebSocket column, so + // the page cannot open one. A ws(s) URL leaves the constructor alone. + if (process.env.SMOKE_WEBSOCKET === "0") { + await page.addInitScript(` + const dead = function WebSocket() { + throw new Error("WebSocket is disabled for this relay"); + }; + globalThis.WebSocket = dead; + globalThis.WebSocketStream = dead; + `); + } + page.on("console", (m) => console.error(`[page] ${m.text()}`)); + page.on("pageerror", (e) => console.error(`[page error] ${e.message}`)); + // smoke.sh SIGKILLs a publisher, which skips the artifact writes below, so a + // publisher whose scripts fail to load exits here instead of streaming nothing. + const scriptFailed = new Promise((_, reject) => { + page.on("requestfailed", (r) => { + if (r.resourceType() === "script") reject(new Error(`script failed: ${r.url()}: ${r.failure()?.errorText}`)); + }); + page.on("response", (r) => { + if (r.request().resourceType() === "script" && r.status() >= 400) { + reject(new Error(`script failed: ${r.url()}: HTTP ${r.status()}`)); + } + }); + }); + scriptFailed.catch(() => {}); // only the publisher awaits it + try { + await page.goto(pageUrl, { waitUntil: "load" }); + + if (role === "publish") { + console.error(`publishing ${broadcast} (fake camera, ${variant}) to ${url}`); + await scriptFailed; // otherwise stream until the orchestrator kills us + } else { + const start = Date.now(); + const deadline = start + timeoutMs; + let frames = 0; + let reloaded = false; + while (Date.now() < deadline) { + frames = await page.evaluate(() => { + const w = document.querySelector("moq-watch") as unknown as { + video?: { out?: { stats?: { peek(): { frameCount?: number } | undefined } } }; + } | null; + return w?.video?.out?.stats?.peek()?.frameCount ?? 0; + }); + if (frames > 0) break; + // The watch element gives up if it subscribes to the catalog before the + // publisher has announced it (RESET_STREAM). If nothing has decoded by the + // halfway mark, reload once to re-subscribe now that the publisher is up. + if (!reloaded && Date.now() - start > timeoutMs / 2) { + reloaded = true; + await page.reload({ waitUntil: "load" }).catch(() => {}); + } + await new Promise((r) => setTimeout(r, 250)); + } + console.error(`decoded ${frames} frames from ${broadcast} (${variant})`); + code = frames > 0 ? 0 : 1; + } + } catch (caught) { + error = caught; + } finally { + if (artifactDir) { + if (code === 0) await context.tracing.stop(); + else { + // Save both artifacts even if one fails, and keep the run error as the cause. + await diagnostics.write(artifactDir, artifactName, error).catch((e) => console.error("diagnostics write failed:", e)); + await context.tracing + .stop({ path: join(artifactDir, `${artifactName}.zip`) }) + .catch((e) => console.error("trace save failed:", e)); + } + } + } +} finally { + await browser.close().catch(() => {}); + server.stop(true); +} +if (error) throw error; +process.exit(code); diff --git a/test/smoke/clients/js/index.html b/test/smoke/clients/js/index.html new file mode 100644 index 0000000000..7647eb9cf5 --- /dev/null +++ b/test/smoke/clients/js/index.html @@ -0,0 +1,10 @@ + + + + + moq smoke + + + + + diff --git a/test/smoke/clients/js/jsdelivr/index.html b/test/smoke/clients/js/jsdelivr/index.html new file mode 100644 index 0000000000..ebb9390b51 --- /dev/null +++ b/test/smoke/clients/js/jsdelivr/index.html @@ -0,0 +1,18 @@ + + + + + moq smoke (jsdelivr) + + + + + + diff --git a/test/smoke/clients/js/jsdelivr/setup.js b/test/smoke/clients/js/jsdelivr/setup.js new file mode 100644 index 0000000000..a87dc9e030 --- /dev/null +++ b/test/smoke/clients/js/jsdelivr/setup.js @@ -0,0 +1,47 @@ +// Shared role logic for every browser variant (vite, esbuild, jsdelivr). Pure +// DOM, no package imports: each entry point registers the / +// elements first (bundled or from the CDN), then imports this to +// wire one up based on ?role=. The Playwright driver polls the watch element's +// decoded-frame stats. +// Log the WebTransport subprotocol each session negotiates (moqt-18, moq-lite-04, +// ...) so smoke.sh can report the wire version. @moq/net only logs it in dev +// builds, and its shared connection doesn't expose it. Drafts that negotiate in +// SETUP rather than ALPN leave it empty. +const NativeWebTransport = globalThis.WebTransport; +if (NativeWebTransport) { + globalThis.WebTransport = class extends NativeWebTransport { + constructor(...args) { + super(...args); + this.ready.then( + () => this.protocol && console.log(`negotiated protocol: ${this.protocol}`), + () => {}, + ); + } + }; +} + +const params = new URLSearchParams(location.search); +const role = params.get("role"); +const url = params.get("url") ?? ""; +const broadcast = params.get("broadcast") ?? ""; + +if (role === "publish") { + const el = document.createElement("moq-publish"); + el.setAttribute("url", url); + el.setAttribute("name", broadcast); + el.setAttribute("muted", ""); // video only; keep the audio encoder out of it + // Chromium's --use-fake-device-for-media-stream feeds getUserMedia a pattern. + el.setAttribute("source", "camera"); + document.body.appendChild(el); +} else if (role === "subscribe") { + const el = document.createElement("moq-watch"); + el.setAttribute("url", url); + el.setAttribute("name", broadcast); + // A render target is what makes actually subscribe to and decode + // the video track. @moq/publish only encodes on subscriber demand, so without + // this the publisher never produces frames. + el.appendChild(document.createElement("canvas")); + document.body.appendChild(el); +} else { + throw new Error("missing ?role=publish|subscribe"); +} diff --git a/test/smoke/clients/js/package.json b/test/smoke/clients/js/package.json new file mode 100644 index 0000000000..becdaaf74a --- /dev/null +++ b/test/smoke/clients/js/package.json @@ -0,0 +1,14 @@ +{ + "name": "@moq/smoke-browser", + "private": true, + "type": "module", + "dependencies": { + "@moq/publish": "latest", + "@moq/watch": "latest" + }, + "devDependencies": { + "esbuild": "^0.28.1", + "playwright": "1.59.1", + "vite": "^8.1.5" + } +} diff --git a/test/smoke/clients/js/src/main.ts b/test/smoke/clients/js/src/main.ts new file mode 100644 index 0000000000..6b97d42adf --- /dev/null +++ b/test/smoke/clients/js/src/main.ts @@ -0,0 +1,6 @@ +// Bundler entry point for the vite and esbuild variants. Registers the public +// custom elements from the installed npm packages, then runs the shared role +// logic. The jsdelivr variant does the same via CDN imports in its index.html. +import "@moq/publish/element"; +import "@moq/watch/element"; +import "../jsdelivr/setup.js"; diff --git a/test/smoke/clients/js/vite.config.ts b/test/smoke/clients/js/vite.config.ts new file mode 100644 index 0000000000..356a73d43b --- /dev/null +++ b/test/smoke/clients/js/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vite"; + +// The published @moq/publish already inlines its audio worklets at build time, +// so a consumer needs no special plugin. esnext keeps WebCodecs/WebTransport +// syntax intact. Output to dist-vite/ so the esbuild variant can sit beside it. +export default defineConfig({ + build: { target: "esnext", outDir: "dist-vite" }, +}); diff --git a/test/smoke/clients/kotlin/build.gradle.kts b/test/smoke/clients/kotlin/build.gradle.kts new file mode 100644 index 0000000000..cf2f71cd4f --- /dev/null +++ b/test/smoke/clients/kotlin/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + kotlin("jvm") version "2.4.10" + application +} + +repositories { mavenCentral() } + +dependencies { + // Dynamic version: always resolve the latest published dev.moq:moq. No + // dependency lockfile is committed, and caches of dynamic versions are + // disabled below, so each run re-resolves to the newest release. + implementation("dev.moq:moq:latest.release") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0") +} + +configurations.all { + resolutionStrategy.cacheDynamicVersionsFor(0, "seconds") +} + +application { mainClass.set("MainKt") } diff --git a/test/smoke/clients/kotlin/settings.gradle.kts b/test/smoke/clients/kotlin/settings.gradle.kts new file mode 100644 index 0000000000..a27ece4070 --- /dev/null +++ b/test/smoke/clients/kotlin/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "smoke" diff --git a/test/smoke/clients/kotlin/src/main/kotlin/Main.kt b/test/smoke/clients/kotlin/src/main/kotlin/Main.kt new file mode 100644 index 0000000000..edb860c59e --- /dev/null +++ b/test/smoke/clients/kotlin/src/main/kotlin/Main.kt @@ -0,0 +1,81 @@ +// Subscribe-only cross-language interop client for the smoke test, built on the +// ergonomic Kotlin API of the published dev.moq:moq package (the `dev.moq.Moq` +// connect facade + the `dev.moq` Flow extensions), not the raw uniffi.moq +// handles. Connect, find the video track in the catalog, and exit 0 as soon as +// any non-empty frame arrives (1 on timeout). +// +// smoke subscribe --url http://127.0.0.1:4443 --broadcast b.hang --timeout 20 +// +// Publishing isn't wired up: the raw-stream importer the other clients publish +// with isn't exercised here, so this client only subscribes. +import dev.moq.BroadcastConsumer +import dev.moq.Moq +import dev.moq.Video +import dev.moq.frames +import dev.moq.updates +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.system.exitProcess + +// A catalog update that actually carries a video track. A lazy publisher may +// announce video in a later update, not the first snapshot. +private suspend fun videoTrack(bc: BroadcastConsumer): Pair = + bc.subscribeCatalog().use { catalog -> + catalog.updates() + .mapNotNull { it.video.entries.firstOrNull() } + .first() + .let { it.key to it.value } + } + +private suspend fun subscribe(url: String, broadcast: String) { + // tlsVerify = false: the smoke relay uses a self-signed cert. `use` shuts the + // session down (graceful close) on the way out. + Moq.connect(url, tlsVerify = false).use { moq -> + val announced = moq.announcedBroadcast(broadcast) + val bc = announced.available() + + val (name, video) = videoTrack(bc) + val media = bc.subscribeMedia(name, video.container, null) + + // Suspends until the first non-empty frame, or throws if the flow ends. + val frame = media.frames().first { it.payload.isNotEmpty() } + System.err.println("received ${frame.payload.size} bytes from $broadcast") + } +} + +fun main(args: Array) { + var url = "" + var broadcast = "" + var timeout = 20.0 + var i = 0 + while (i < args.size) { + when (args[i]) { + "--url" -> url = args.getOrElse(++i) { "" } + "--broadcast" -> broadcast = args.getOrElse(++i) { "" } + "--timeout" -> timeout = args.getOrElse(++i) { "20" }.toDoubleOrNull() ?: 20.0 + // leading "subscribe" positional and anything else ignored + } + i++ + } + if (url.isEmpty() || broadcast.isEmpty()) { + System.err.println("usage: smoke subscribe --url U --broadcast B [--timeout S]") + exitProcess(2) + } + + val code = runBlocking { + try { + withTimeout((timeout * 1000).toLong()) { subscribe(url, broadcast) } + 0 + } catch (e: TimeoutCancellationException) { + System.err.println("error: timed out waiting for data") + 1 + } catch (e: Exception) { + System.err.println("error: ${e.message}") + 1 + } + } + exitProcess(code) +} diff --git a/test/smoke/clients/python/smoke.py b/test/smoke/clients/python/smoke.py new file mode 100644 index 0000000000..c57adca2b6 --- /dev/null +++ b/test/smoke/clients/python/smoke.py @@ -0,0 +1,117 @@ +"""Cross-language interop client for the smoke test (PyPI `moq-rs`, import `moq`). + +publish: read raw Annex-B H.264 from stdin (e.g. piped from ffmpeg) and feed + it to a streaming importer, which infers frame boundaries. +subscribe: connect, find the video track in the catalog, and exit 0 as soon as + any non-empty frame arrives (exit 1 on timeout / no data). + + ffmpeg ... -f h264 - | python smoke.py publish --url http://localhost:4443 --broadcast b.hang + python smoke.py subscribe --url http://localhost:4443 --broadcast b.hang --timeout 20 +""" + +import argparse +import asyncio +import sys + +import moq + +READ_CHUNK = 64 * 1024 + + +async def publish(url: str, broadcast: str) -> None: + async with moq.Client(url, tls_verify=False) as client: + # create_broadcast registers the broadcast and hands back its producer + # (the old client.publish(broadcast, producer) split was removed in the + # ergonomic moq-rs API). publish_video_stream feeds a raw Annex-B pipe to + # the streaming importer, which infers frame boundaries. + producer = client.create_broadcast(broadcast) + media = producer.publish_video_stream(moq.VideoFormat.AVC3) + # create_broadcast only registers the path locally; announce advertises it + # to the relay, once its tracks exist. + producer.announce() + print(f"publishing {broadcast!r} (Annex-B H.264 from stdin) to {url}") + + loop = asyncio.get_running_loop() + stdin = sys.stdin.buffer + # read1 returns as soon as any bytes are available (read() would block + # for a full chunk and batch up ffmpeg's real-time output). getattr both + # keeps pyright happy (BinaryIO doesn't declare read1) and falls back if + # the stream lacks it. + read = getattr(stdin, "read1", stdin.read) + while True: + # Blocking read off the event loop so the client keeps flushing. + chunk = await loop.run_in_executor(None, read, READ_CHUNK) + if not chunk: + break + media.write(chunk) + media.finish() + + +async def _catalog_with_video(consumer: moq.BroadcastConsumer) -> moq.Catalog: + # The catalog is a live track. A lazy publisher (e.g. the browser, which only + # encodes on demand) may announce video in a *later* update, not the first + # snapshot, so wait for a catalog that actually has a video track. + # These live consumers are async context managers. Cancel them explicitly + # before the parent client shuts down; otherwise an in-flight FFI task can + # race teardown after this helper returns from the async iterator. + async with await consumer.subscribe_catalog() as catalog_consumer: + async for catalog in catalog_consumer: + if catalog.video: + return catalog + raise RuntimeError("catalog stream ended without a video track") + + +async def subscribe(url: str, broadcast: str, timeout: float) -> None: + async with moq.Client(url, tls_verify=False) as client: + # announced_broadcast yields a handle; available() resolves it to a live + # consumer once the broadcast is announced. subscribe_catalog/_media are + # coroutines in the ergonomic API, so they're awaited (below and above). + async with client.announced_broadcast(broadcast) as announced: + consumer = await asyncio.wait_for(announced.available(), timeout) + catalog = await asyncio.wait_for(_catalog_with_video(consumer), timeout) + + track_name = next(iter(catalog.video)) + video = catalog.video[track_name] + + total = 0 + async with await consumer.subscribe_media(track_name, video.container) as media: + + async def drain() -> None: + nonlocal total + async for frame in media: + total += len(frame.payload) + if total > 0: + return + + await asyncio.wait_for(drain(), timeout) + + if total <= 0: + raise RuntimeError("no frame data received") + print(f"received {total} bytes from {broadcast!r}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("role", choices=["publish", "subscribe"]) + parser.add_argument("--url", required=True) + parser.add_argument("--broadcast", required=True) + parser.add_argument("--timeout", type=float, default=20.0) + args = parser.parse_args() + + try: + if args.role == "publish": + asyncio.run(publish(args.url, args.broadcast)) + else: + asyncio.run(subscribe(args.url, args.broadcast, args.timeout)) + except KeyboardInterrupt: + pass + except (TimeoutError, asyncio.TimeoutError): + print("error: timed out waiting for data", file=sys.stderr) + sys.exit(1) + except Exception as err: # noqa: BLE001 - smoke client: any failure is a failure + print(f"error: {err}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/test/smoke/clients/swift/Package.swift b/test/smoke/clients/swift/Package.swift new file mode 100644 index 0000000000..e8372b9e63 --- /dev/null +++ b/test/smoke/clients/swift/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version:5.9 +import PackageDescription + +// Subscribe-only smoke client built on the published Swift package. `from:` +// resolves to the latest 0.x at `swift package update` time (no Package.resolved +// is committed, so it never pins). The package pulls a prebuilt MoqFFI.xcframework. +let package = Package( + name: "smoke", + platforms: [.macOS(.v12)], + dependencies: [ + .package(url: "https://github.com/moq-dev/moq-swift", from: "0.2.0"), + ], + targets: [ + .executableTarget( + name: "smoke", + dependencies: [.product(name: "Moq", package: "moq-swift")], + path: "Sources/smoke" + ), + ] +) diff --git a/test/smoke/clients/swift/Sources/smoke/main.swift b/test/smoke/clients/swift/Sources/smoke/main.swift new file mode 100644 index 0000000000..969c4c12e6 --- /dev/null +++ b/test/smoke/clients/swift/Sources/smoke/main.swift @@ -0,0 +1,103 @@ +// Subscribe-only cross-language interop client for the smoke test, built on the +// ergonomic Swift API of the published moq-dev/moq-swift package (the `Moq` +// module's Client/Session/BroadcastConsumer classes and AsyncSequence streams), +// not the raw MoqFFI handles. Connect, find the video track in the catalog, and +// exit 0 as soon as any non-empty frame arrives (1 on timeout). +// +// smoke subscribe --url http://127.0.0.1:4443 --broadcast b.hang --timeout 20 +// +// Publishing isn't wired up: the raw-stream importer the other clients publish +// with isn't exercised here, so this client only subscribes. +import Foundation +import Moq + +enum SmokeError: Error { case timeout, noVideo, noData } + +struct Args { + var url = "" + var broadcast = "" + var timeout = 20.0 +} + +func parseArgs() -> Args { + var a = Args() + let argv = CommandLine.arguments + var i = 1 + while i < argv.count { + switch argv[i] { + case "--url": i += 1; if i < argv.count { a.url = argv[i] } + case "--broadcast": i += 1; if i < argv.count { a.broadcast = argv[i] } + case "--timeout": i += 1; if i < argv.count { a.timeout = Double(argv[i]) ?? 20.0 } + default: break // leading "subscribe" positional and anything else ignored + } + i += 1 + } + return a +} + +func warn(_ s: String) { FileHandle.standardError.write((s + "\n").data(using: .utf8)!) } + +// A catalog update that actually carries a video track. A lazy publisher may +// announce video in a later update, not the first snapshot. CatalogConsumer is +// an AsyncSequence of catalog snapshots. +func videoTrack(_ bc: BroadcastConsumer) async throws -> (String, Video) { + for try await update in try await bc.subscribeCatalog() { + if let first = update.video.first { return (first.key, first.value) } + } + throw SmokeError.noVideo +} + +func subscribe(_ args: Args) async throws { + // The smoke relay uses a self-signed cert. connect(to:) wires an auto-created + // origin, so session.consumer discovers announcements without touching MoqFFI. + let client = Client() + client.setTlsVerify(false) + + let session = try await client.connect(to: args.url) + defer { session.shutdown() } // graceful close + + let announced = try session.consumer.announcedBroadcast(path: args.broadcast) + let bc = try await announced.available() + + let (name, video) = try await videoTrack(bc) + let media = try await bc.subscribeMedia(name: name, container: video.container) + + var total = 0 + for try await frame in media { + total += frame.payload.count + if total > 0 { + warn("received \(total) bytes from \(args.broadcast)") + return + } + } + throw SmokeError.noData +} + +func withTimeout(_ seconds: Double, _ op: @escaping () async throws -> Void) async throws { + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { try await op() } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw SmokeError.timeout + } + try await group.next() + group.cancelAll() + } +} + +let args = parseArgs() +if args.url.isEmpty || args.broadcast.isEmpty { + warn("usage: smoke subscribe --url U --broadcast B [--timeout S]") + exit(2) +} + +do { + try await withTimeout(args.timeout) { try await subscribe(args) } + exit(0) +} catch SmokeError.timeout { + warn("error: timed out waiting for data") + exit(1) +} catch { + warn("error: \(error)") + exit(1) +} diff --git a/test/smoke/clients/token/js/package.json b/test/smoke/clients/token/js/package.json new file mode 100644 index 0000000000..57c62cc49b --- /dev/null +++ b/test/smoke/clients/token/js/package.json @@ -0,0 +1,9 @@ +{ + "name": "moq-smoke-token", + "private": true, + "type": "module", + "description": "Installs the published @moq/auth package so token.sh can drive its CLI under node and bun.", + "dependencies": { + "@moq/auth": "latest" + } +} diff --git a/test/smoke/clients/token/js/resolve-bin.mjs b/test/smoke/clients/token/js/resolve-bin.mjs new file mode 100644 index 0000000000..abcf0c6762 --- /dev/null +++ b/test/smoke/clients/token/js/resolve-bin.mjs @@ -0,0 +1,23 @@ +// Print the absolute path to the published @moq/auth CLI entrypoint. +// +// token.sh runs this with BOTH node and bun so each runtime resolves the same +// installed package and we drive the *published* bin (compiled dist), not the +// in-tree TypeScript source. We read the installed package.json straight off +// disk rather than via module resolution: @moq/auth's `exports` map doesn't +// expose ./package.json, which Node's strict ESM resolver refuses (bun allows +// it), so require.resolve would work under bun but throw under node. Reading the +// file keeps both runtimes on the same path, and the bin name is still taken +// from the published manifest so a rename surfaces here. +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const pkgDir = resolve(process.cwd(), "node_modules/@moq/auth"); +const pkg = JSON.parse(readFileSync(resolve(pkgDir, "package.json"), "utf8")); + +const bin = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.["moq-auth"]; +if (!bin) { + console.error("@moq/auth exposes no moq-auth bin; published package changed shape"); + process.exit(1); +} + +console.log(resolve(pkgDir, bin)); diff --git a/test/smoke/cloudflare.sh b/test/smoke/cloudflare.sh new file mode 100755 index 0000000000..db4dff1001 --- /dev/null +++ b/test/smoke/cloudflare.sh @@ -0,0 +1,276 @@ +#!/usr/bin/env bash +# End-to-end smoke test using the current cloudflare/moq-rs default branch. +# +# This is intentionally separate from smoke.sh's public-package media matrix: +# cloudflare/moq-rs uses a different application catalog/track layout. We build +# a purpose-built Cloudflare client and both projects' relays from their latest +# Git source. The client validates every byte of sustained subgroup payloads +# through both relays; Cloudflare's relay also exercises datagrams. +set -euo pipefail + +SMOKE_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +CLOUDFLARE_CLIENT="$SMOKE_DIR/clients/cloudflare" +CLOUDFLARE_MOQ_REPO="https://github.com/cloudflare/moq-rs" +MOQ_REPO="https://github.com/moq-dev/moq" +PORT="${CLOUDFLARE_SMOKE_PORT:-4443}" +TIMEOUT="${CLOUDFLARE_SMOKE_TIMEOUT:-20}" +HTTPS_URL="https://127.0.0.1:${PORT}" +MOQT_URL="moqt://127.0.0.1:${PORT}" + +TMP=$(mktemp -d) +RELAY_PID="" +PUB_PID="" +CLOUDFLARE_RELAY="${CLOUDFLARE_RELAY_BIN:-}" +MOQ_RELAY="${MOQ_RELAY_BIN:-}" +CLIENT="${CLOUDFLARE_CLIENT_BIN:-}" + +have() { command -v "$1" >/dev/null 2>&1; } + +# build_git : build one package from a repo's default +# branch with its committed Cargo.lock, into /target/release. A plain +# `cargo build` in a checkout rather than `cargo install --git`: a compile cache +# (mbx in CI) reuses unchanged crates across builds, but not across installs. +build_git() { + local repo="$1" package="$2" dir="$3" + git clone --quiet --depth 1 "$repo" "$dir" && + cargo build --quiet --release --locked --manifest-path "$dir/Cargo.toml" -p "$package" \ + --target-dir "$dir/target" +} + +kill_tree() { + local pid="$1" child + for child in $(pgrep -P "$pid" 2>/dev/null || true); do kill_tree "$child"; done + kill -KILL "$pid" 2>/dev/null || true +} + +# shellcheck disable=SC2329 # invoked indirectly via 'trap cleanup EXIT' +cleanup() { + [[ -n "$PUB_PID" ]] && kill_tree "$PUB_PID" + [[ -n "$RELAY_PID" ]] && kill_tree "$RELAY_PID" + rm -rf "$TMP" +} +trap cleanup EXIT + +missing=() +for tool in curl openssl pgrep timeout; do + have "$tool" || missing+=("$tool") +done +if [[ -z "$CLOUDFLARE_RELAY" || -z "$MOQ_RELAY" || -z "$CLIENT" ]]; then + have cargo || missing+=("cargo") + have git || missing+=("git") +fi +if [[ ${#missing[@]} -gt 0 ]]; then + echo "error: missing required tools: ${missing[*]}" >&2 + exit 1 +fi + +if [[ -z "$CLOUDFLARE_RELAY" ]]; then + echo "building moq-relay-ietf from $CLOUDFLARE_MOQ_REPO (latest default branch)..." + # Follow Git HEAD, but honor that checkout's committed Cargo.lock. Without + # --locked Cargo currently selects a newer hyper-util that does not compile + # with moq-rs's hyper-serve dependency. + if build_git "$CLOUDFLARE_MOQ_REPO" moq-relay-ietf "$TMP/cloudflare-moq" >"$TMP/relay-build.log" 2>&1; then + CLOUDFLARE_RELAY="$TMP/cloudflare-moq/target/release/moq-relay-ietf" + else + echo "error: failed to build moq-relay-ietf" >&2 + sed 's/^/ /' "$TMP/relay-build.log" >&2 || true + exit 1 + fi +fi + +if [[ -z "$MOQ_RELAY" ]]; then + echo "building moq-relay from $MOQ_REPO (latest default branch)..." + if build_git "$MOQ_REPO" moq-relay "$TMP/moq" >"$TMP/moq-relay-build.log" 2>&1; then + MOQ_RELAY="$TMP/moq/target/release/moq-relay" + else + echo "error: failed to build moq-relay" >&2 + sed 's/^/ /' "$TMP/moq-relay-build.log" >&2 || true + exit 1 + fi +fi + +if [[ -z "$CLIENT" ]]; then + echo "building cloudflare smoke client against $CLOUDFLARE_MOQ_REPO..." + mkdir -p "$TMP/client" + cp "$CLOUDFLARE_CLIENT/Cargo.toml" "$TMP/client/Cargo.toml" + cp -R "$CLOUDFLARE_CLIENT/src" "$TMP/client/src" + if cargo build --quiet --release --manifest-path "$TMP/client/Cargo.toml" \ + --target-dir "$TMP/target" >"$TMP/client-build.log" 2>&1; then + CLIENT="$TMP/target/release/cloudflare-smoke" + else + echo "error: failed to build cloudflare smoke client" >&2 + sed 's/^/ /' "$TMP/client-build.log" >&2 || true + exit 1 + fi +fi + +[[ -x "$CLOUDFLARE_RELAY" ]] || { + echo "error: Cloudflare relay is not executable: $CLOUDFLARE_RELAY" >&2 + exit 1 +} +[[ -x "$MOQ_RELAY" ]] || { + echo "error: moq-dev relay is not executable: $MOQ_RELAY" >&2 + exit 1 +} +[[ -x "$CLIENT" ]] || { + echo "error: client is not executable: $CLIENT" >&2 + exit 1 +} + +echo "Cloudflare relay: $CLOUDFLARE_RELAY" +echo "moq-dev relay: $MOQ_RELAY" +echo "client: $CLIENT" + +# The upstream relay requires certificate files. Generate a throwaway localhost +# certificate; clients explicitly disable verification only for this loopback test. +if ! openssl req -x509 -newkey rsa:2048 -nodes \ + -keyout "$TMP/localhost.key" -out "$TMP/localhost.crt" -days 1 \ + -subj /CN=localhost -addext subjectAltName=DNS:localhost,IP:127.0.0.1 \ + >"$TMP/openssl.log" 2>&1; then + echo "error: failed to generate the temporary relay certificate" >&2 + sed 's/^/ /' "$TMP/openssl.log" >&2 || true + exit 1 +fi + +start_relay() { + local implementation="$1" ready + echo "starting $implementation relay on 127.0.0.1:${PORT}..." + case "$implementation" in + cloudflare) + "$CLOUDFLARE_RELAY" --bind "127.0.0.1:${PORT}" \ + --tls-cert "$TMP/localhost.crt" --tls-key "$TMP/localhost.key" \ + --tls-root "$TMP/localhost.crt" --coordinator-file "$TMP/coordinator.json" --dev \ + >"$TMP/relay-cloudflare.log" 2>&1 & + ready="$HTTPS_URL/fingerprint" + ;; + moq-dev) + # Git HEAD relay: the >= 0.15 flag names and pattern-based public auth. + "$MOQ_RELAY" --listen "127.0.0.1:${PORT}" \ + --listen-tls-cert "$TMP/localhost.crt" --listen-tls-key "$TMP/localhost.key" \ + --web-https-listen "127.0.0.1:${PORT}" \ + --web-https-cert "$TMP/localhost.crt" --web-https-key "$TMP/localhost.key" \ + --auth-public "**" >"$TMP/relay-moq-dev.log" 2>&1 & + ready="$HTTPS_URL/certificate.sha256" + ;; + *) + echo "error: unknown relay implementation: $implementation" >&2 + exit 1 + ;; + esac + RELAY_PID=$! + + for _ in $(seq 1 60); do + curl --cacert "$TMP/localhost.crt" -sf "$ready" >/dev/null 2>&1 && return + kill -0 "$RELAY_PID" 2>/dev/null || break + sleep 0.5 + done + + echo "error: $implementation relay never became ready" >&2 + sed 's/^/ relay: /' "$TMP/relay-$implementation.log" >&2 || true + exit 1 +} + +stop_relay() { + kill_tree "$RELAY_PID" + wait "$RELAY_PID" 2>/dev/null || true + RELAY_PID="" +} + +overall=0 +run_round() { + local implementation="$1" transport="$2" url="$3" delivery="$4" + local object_size="$5" objects="$6" interval_ms="$7" + local namespace="smoke-${implementation}-${transport}-${delivery}-$$-${RANDOM}" + local name="$implementation / $transport / $delivery" + + "$CLIENT" --url "$url" --namespace "$namespace" --tls-disable-verify \ + --tls-root "$TMP/localhost.crt" \ + --delivery "$delivery" --object-size "$object_size" \ + --interval-ms "$interval_ms" --start-delay-ms 4000 publish \ + >"$TMP/pub-${implementation}-${transport}-${delivery}.log" 2>&1 & + PUB_PID=$! + + # Both relays reject an unknown namespace instead of holding an early + # subscription. Probe until PUBLISH_NAMESPACE is accepted; exit 75 is the + # client's specific unknown-namespace signal. A probe can also remain pending + # while publication is settling, so bound each attempt to two seconds and + # retry timeout(1)'s exit 124 within the outer deadline. Every semantic error + # still fails immediately. Payload validation then runs exactly once and + # cannot be masked. + local subscribe_deadline=$((SECONDS + 10)) probe_ok=0 probe_status + while true; do + if timeout -k 1 2 "$CLIENT" --url "$url" --namespace "$namespace" \ + --tls-disable-verify --tls-root "$TMP/localhost.crt" \ + --delivery "$delivery" --object-size "$object_size" \ + probe >"$TMP/probe-${implementation}-${transport}-${delivery}.log" 2>&1; then + probe_ok=1 + break + else + probe_status=$? + fi + [[ "$probe_status" -eq 75 || "$probe_status" -eq 124 ]] || break + kill -0 "$PUB_PID" 2>/dev/null || break + ((SECONDS >= subscribe_deadline)) && break + sleep 0.25 + done + + if [[ "$probe_ok" -ne 1 ]]; then + echo " FAIL $name (namespace publication was not accepted; probe exit $probe_status)" + sed 's/^/ publisher: /' "$TMP/pub-${implementation}-${transport}-${delivery}.log" >&2 || true + sed 's/^/ probe: /' "$TMP/probe-${implementation}-${transport}-${delivery}.log" >&2 || true + sed 's/^/ relay: /' "$TMP/relay-$implementation.log" >&2 || true + overall=1 + elif timeout -k 3 "$TIMEOUT" "$CLIENT" --url "$url" --namespace "$namespace" \ + --tls-disable-verify --tls-root "$TMP/localhost.crt" \ + --delivery "$delivery" --object-size "$object_size" \ + subscribe --objects "$objects" >"$TMP/sub-${implementation}-${transport}-${delivery}.log" 2>&1; then + echo " PASS $name" + sed 's/^/ /' "$TMP/sub-${implementation}-${transport}-${delivery}.log" + else + echo " FAIL $name" + sed 's/^/ publisher: /' "$TMP/pub-${implementation}-${transport}-${delivery}.log" >&2 || true + sed 's/^/ subscriber: /' "$TMP/sub-${implementation}-${transport}-${delivery}.log" >&2 || true + sed 's/^/ relay: /' "$TMP/relay-$implementation.log" >&2 || true + overall=1 + fi + + kill_tree "$PUB_PID" + wait "$PUB_PID" 2>/dev/null || true + PUB_PID="" +} + +echo "=== cloudflare/moq-rs relay payload integrity ===" +start_relay cloudflare +for transport in webtransport quic; do + case "$transport" in + webtransport) url="$HTTPS_URL" ;; + quic) url="$MOQT_URL" ;; + esac + # Large subgroup objects force multi-packet/stream transfer. Datagrams stay + # below a typical path MTU and are validated independently because loss and + # reordering are legal for that delivery mode. + run_round cloudflare "$transport" "$url" subgroup 65536 32 5 + run_round cloudflare "$transport" "$url" datagram 900 32 5 +done +stop_relay + +echo "=== moq-dev/moq relay interoperability ===" +start_relay moq-dev +for transport in webtransport quic; do + case "$transport" in + webtransport) url="$HTTPS_URL" ;; + quic) url="$MOQT_URL" ;; + esac + # moq-dev's IETF adapter currently forwards subgroup streams, not datagrams. + # A moderate cadence avoids conflating lossy live-edge turnover with protocol + # interoperability while still validating sustained multi-group delivery. + run_round moq-dev "$transport" "$url" subgroup 65536 32 25 +done +stop_relay + +if [[ "$overall" -eq 0 ]]; then + echo "cloudflare interop smoke: all checks passed" +else + echo "cloudflare interop smoke: FAILURES detected" >&2 +fi +exit "$overall" diff --git a/test/smoke/flake.lock b/test/smoke/flake.lock new file mode 100644 index 0000000000..0780c74c62 --- /dev/null +++ b/test/smoke/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1779877693, + "narHash": "sha256-NOF9NAREhxr50bbBfVcVOq+ArCMSoe8dP79Pk2uyARk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "4100e830e085863741bc69b156ec4ccd53ab5be0", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/test/smoke/flake.nix b/test/smoke/flake.nix new file mode 100644 index 0000000000..6de0f1e499 --- /dev/null +++ b/test/smoke/flake.nix @@ -0,0 +1,84 @@ +{ + description = "moq smoke - interop test for the public moq packages"; + + # nixpkgs-unstable, intentionally NOT locked: this repo ships no flake.lock so + # every `nix develop` resolves the latest toolchain (and the latest Chromium), + # matching the "always test latest" policy. The one version that has to line up + # (npm `playwright` <-> this Chromium) is exported below as PLAYWRIGHT_VERSION + # and enforced by smoke.sh, so a nixpkgs bump can't silently drift. + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { nixpkgs, flake-utils, ... }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { inherit system; }; + in + { + devShells.default = pkgs.mkShell { + # smoke.sh installs the clients from public registries; this shell only + # provides the toolchains those installs need. The Rust binaries under + # test (moq-relay, moq) come from a channel (cargo/brew/apt), not from + # here, though `cargo` is included so `cargo install moq-cli` works. + packages = with pkgs; [ + # orchestrator + harness tools + just + git + ffmpeg + curl + openssl # throwaway localhost certificate for cloudflare.sh + jq + coreutils # GNU `timeout` (macOS lacks it) + procps # `pgrep` + + # rust client + a way to `cargo install` the binaries under test + cargo + rustc + + # python client (PyPI moq-rs) + uv + python3 + + # go client (go get moq.dev/moq); cgo links the prebuilt libmoq_ffi.a + go + + # kotlin client (dev.moq:moq from Maven Central) on the JVM + jdk + gradle + + # c client (libmoq prebuilt release): cc comes from stdenv; these fetch + # + extract the tarball. (The swift client uses the system Xcode + # toolchain, not nix: nixpkgs swift on Darwin clashes with the Xcode SDK.) + gnutar + + # browser client (npm @moq/watch + @moq/publish) + bun + nodejs_24 + # Headless Chromium. The npm `playwright` must match this build; rather + # than hardcode it, smoke.sh reads $PLAYWRIGHT_VERSION (below) and fails + # if clients/js/package.json pins anything else. + playwright-driver.browsers + + # `just check` linters + shellcheck + shfmt + actionlint + ]; + + shellHook = '' + # Use the nix-provided Chromium instead of letting Playwright download one. + export PLAYWRIGHT_BROWSERS_PATH="${pkgs.playwright-driver.browsers}" + export PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true + # The exact npm `playwright` version that matches the Chromium above. + # smoke.sh asserts clients/js/package.json pins this, so a floating + # nixpkgs can never leave the pin stale. + export PLAYWRIGHT_VERSION="${pkgs.playwright-driver.version}" + ''; + }; + } + ); +} diff --git a/test/smoke/freshness.sh b/test/smoke/freshness.sh new file mode 100755 index 0000000000..0165ce8fa2 --- /dev/null +++ b/test/smoke/freshness.sh @@ -0,0 +1,183 @@ +#!/usr/bin/env bash +# Guard the "always test the latest published packages" policy: +# 1. no committed PACKAGE lock files (go.sum, bun.lock, Cargo.lock, ...), so +# every run re-resolves the moq packages to their latest. flake.lock is +# fine: it pins the dev toolchain, not the moq packages, and the moq "nix" +# channel references the moq flake ad-hoc so the moq version is never locked; +# 2. the moq packages under test are requested as "latest", never pinned; +# 3. the one unavoidable pin (npm `playwright`, which must match the toolchain's +# Chromium build) equals what the toolchain ships, so a toolchain bump can't +# quietly leave it stale. +# +# Run standalone (`just freshness`) or as the opening step of smoke.sh. +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" +PKG=clients/js/package.json +fail=0 +note() { printf ' %-5s %s\n' "$1" "$2"; } +json_dep() { grep -oE "\"$1\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$PKG" | sed -E 's/.*"([^"]*)"$/\1/'; } + +echo "== no committed package lock files ==" +# flake.lock is intentionally excluded: it locks the toolchain, not moq packages. +locks=$(git ls-files 2>/dev/null | grep -E '(^|/)(go\.sum|bun\.lock|bun\.lockb|Cargo\.lock|uv\.lock|package-lock\.json|yarn\.lock|pnpm-lock\.yaml|poetry\.lock|Pipfile\.lock|Package\.resolved|gradle\.lockfile)$' || true) +if [[ -n "$locks" ]]; then + note FAIL "package lock files are checked in (delete them and add to .gitignore):" + printf ' %s\n' "${locks//$'\n'/$'\n' }" + fail=1 +else + note ok "none tracked" +fi + +echo "== moq packages requested at latest ==" +for dep in @moq/watch @moq/publish; do + ver=$(json_dep "$dep") + if [[ "$ver" == "latest" ]]; then note ok "$dep -> \"$ver\""; else + note FAIL "$dep pinned to \"$ver\" (want \"latest\")" + fail=1 + fi +done +# The native-JS client (@moq/net + @moq/hang + @moq/web-transport) must also be latest. +for dep in @moq/net @moq/hang @moq/web-transport; do + ver=$(grep -oE "\"$dep\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" clients/js-native/package.json | sed -E 's/.*"([^"]*)"$/\1/') + if [[ "$ver" == "latest" ]]; then note ok "$dep -> \"$ver\""; else + note FAIL "$dep pinned to \"$ver\" (want \"latest\")" + fail=1 + fi +done +# The token client (@moq/auth, driven by token.sh under node and bun) must be latest. +ver=$(grep -oE "\"@moq/auth\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" clients/token/js/package.json | sed -E 's/.*"([^"]*)"$/\1/') +if [[ "$ver" == "latest" ]]; then note ok "@moq/auth -> \"$ver\""; else + note FAIL "@moq/auth pinned to \"$ver\" (want \"latest\")" + fail=1 +fi +# The token Docker image must be the unpinned (:latest) tag, pulled fresh each run. +# shellcheck disable=SC2016 # grepping for these literal strings in token.sh; the $vars must NOT expand here +if grep -qF 'DOCKER_TOKEN_IMAGE:-moqdev/moq}' token.sh && grep -qF '"$DOCKER" pull "$DOCKER_TOKEN_IMAGE"' token.sh; then + note ok "moqdev/moq (token cell) -> :latest (pulled each run)" +else + note FAIL "token.sh no longer pulls an unpinned moqdev/moq :latest" + fail=1 +fi +# The media Docker channel (relay + cli wrappers) must use the unpinned (:latest) +# images, and CI must pull them fresh. +if grep -qF 'moqdev/moq-relay}' clients/docker/moq-relay && grep -qF 'moqdev/moq}' clients/docker/moq && + grep -qF 'docker pull moqdev/moq-relay' .github/workflows/smoke.yml; then + note ok "moqdev/moq-relay + moqdev/moq -> :latest (pulled each run)" +else + note FAIL "the moqdev relay/cli Docker images are no longer unpinned :latest + pulled fresh" + fail=1 +fi +# The jsDelivr CDN variant must not pin @version, so it serves the latest release. +if grep -qE 'cdn\.jsdelivr\.net/npm/@moq/[a-z-]+@[0-9]' clients/js/jsdelivr/index.html; then + note FAIL "jsdelivr/index.html pins a @moq version (drop @x.y.z so the CDN serves latest)" + fail=1 +else + note ok "jsdelivr @moq/* -> unpinned (latest)" +fi +# shellcheck disable=SC2016 # grepping for this literal line in smoke.sh; $PY must NOT expand here +if grep -q 'uv pip install --quiet --python "$PY" moq-rs' smoke.sh; then + note ok "moq-rs -> uv pip install (unpinned)" +else + note FAIL "smoke.sh no longer installs moq-rs unpinned" + fail=1 +fi +if grep -q 'go get "moq.dev/moq@latest"' smoke.sh; then + note ok "moq.dev/moq -> go get @latest" +else + note FAIL "smoke.sh no longer go-gets moq.dev/moq @latest" + fail=1 +fi +# Both relays and the local integrity client deliberately follow their +# repositories' default branches. A rev/tag/branch would silently turn this +# into a stale snapshot rather than a Git-head smoke test. +# shellcheck disable=SC2016 # the greps match literal variable references +builds_head() { + # builds_head + + \ No newline at end of file diff --git a/test/smoke/smoke-legacy.toml b/test/smoke/smoke-legacy.toml new file mode 100644 index 0000000000..32b7eeae05 --- /dev/null +++ b/test/smoke/smoke-legacy.toml @@ -0,0 +1,20 @@ +# Relay config for the cross-language interop smoke test. +# Anonymous access, self-signed localhost cert, QUIC + WebSocket on 127.0.0.1:4443. +# Pre-0.15 layout, used by smoke.sh for relays without --listen. Delete once every +# channel ships moq-relay >= 0.15. + +[log] +level = "info" + +[server] +# QUIC on UDP. 127.0.0.1 avoids IPv6 flakiness on CI runners. +listen = "127.0.0.1:4443" +tls.generate = ["localhost", "127.0.0.1"] + +[web.http] +# HTTP + WebSocket on TCP, also serving /certificate.sha256 for cert pinning. +listen = "127.0.0.1:4443" + +[auth] +# Allow anonymous access to everything. +public = "" diff --git a/test/smoke/smoke.sh b/test/smoke/smoke.sh new file mode 100755 index 0000000000..228f1611b7 --- /dev/null +++ b/test/smoke/smoke.sh @@ -0,0 +1,1029 @@ +#!/usr/bin/env bash +# Cross-language media interop smoke test against the PUBLIC packages. +# +# Unlike the in-repo smoke test (which builds from the moq-dev/moq workspace), +# this stands up a relay and clients installed straight from public package +# registries: cargo / brew / apt for the Rust binaries, PyPI for Python, the +# Go module proxy for Go, and npm for the browser. The point is to catch breakage +# that only a real consumer would see: a missing wheel, a stale formula, an +# export that didn't survive packaging. +# +# It stands up a real moq-relay, then for each publisher language publishes an +# H.264 broadcast and confirms every subscriber sees data flowing (a non-empty +# frame before the timeout). We check that bytes move end-to-end across +# implementations, not that H.264 decodes. +# +# With --relay URL (repeatable) it skips the local relay and runs the same matrix +# through each external relay instead; relays.sh drives that mode against the +# public relays in the moq-interop-runner registry. An external http(s) or moqt +# URL is that transport alone: the WebSocket fallback stays off. A ws(s) URL is +# the WebSocket column, which relays.sh only adds for relays that opted in. +# On those external URLs the browser (js-web) and bun (js-bun) run only on +# WebTransport. The bun polyfill has no raw-QUIC mode. A client that does not +# belong is left out of the results, not recorded as a skip. +set -euo pipefail + +SMOKE_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +CLIENTS="$SMOKE_DIR/clients" + +PUBLISHERS="rust" +SUBSCRIBERS="rust" +TIMEOUT="${SMOKE_TIMEOUT:-20}" +FPS="${SMOKE_FPS:-30}" +SIZE="${SMOKE_SIZE:-320x240}" +PORT="${SMOKE_PORT:-4443}" +URL="http://127.0.0.1:${PORT}" +NEGATIVE=0 +EXTERNAL_RELAYS=() # --relay URLs; empty means stand up the local moq-relay +RESULTS="" # --results FILE: append one TSV row per cell (see record()) +LOGS="" # --logs DIR: keep each cell's subscriber + publisher log (see record()) + +# Binaries under test. Whatever channel installed them (cargo/brew/apt) just has +# to leave them on PATH; override here to point at a specific build. +RELAY="${RELAY_BIN:-moq-relay}" +# Name of the docker-channel relay container, shared with clients/docker/moq-relay +# so cleanup() can reap it after a SIGKILL of the wrapper (see cleanup()). Exported +# so the wrapper binds the same name; ignored by the non-docker channels. +RELAY_CONTAINER="${MOQ_RELAY_CONTAINER:-moq-relay-smoke}" +export MOQ_RELAY_CONTAINER="$RELAY_CONTAINER" +# The CLI binary under test. The package/crate is still distributed as moq-cli in +# some channels, but the installed executable is `moq`. Honor MOQ_BIN if set. +MOQ="${MOQ_BIN:-}" + +require_value() { + # require_value "$@": the flag plus the rest of the argv. Ensures a + # non-flag value follows, so `set -u` doesn't abort on a bare `--timeout`. + if [[ $# -lt 2 || -z "${2:-}" || "$2" == -* ]]; then + echo "error: $1 requires a value" >&2 + exit 2 + fi +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --publishers) + require_value "$@" + PUBLISHERS="$2" + shift 2 + ;; + --subscribers) + require_value "$@" + SUBSCRIBERS="$2" + shift 2 + ;; + --timeout) + require_value "$@" + TIMEOUT="$2" + shift 2 + ;; + --negative) + NEGATIVE=1 + shift + ;; + --relay) + require_value "$@" + EXTERNAL_RELAYS+=("$2") + shift 2 + ;; + --results) + require_value "$@" + RESULTS="$2" + shift 2 + ;; + --logs) + require_value "$@" + LOGS="$2" + shift 2 + ;; + *) + echo "unknown arg: $1" >&2 + exit 2 + ;; + esac +done + +IFS=',' read -r -a PUB_LIST <<<"$PUBLISHERS" +IFS=',' read -r -a SUB_LIST <<<"$SUBSCRIBERS" + +needs() { + # needs : true if appears in either list. + local lang="$1" x + for x in "${PUB_LIST[@]}" "${SUB_LIST[@]}"; do [[ "$x" == "$lang" ]] && return 0; done + return 1 +} + +TMP=$(mktemp -d) +RELAY_PID="" +PY="" # python interpreter with moq-rs installed (set in prepare) +GO_SMOKE="" # compiled Go client binary (set in prepare) +SWIFT_SMOKE="" # compiled Swift client binary (set in prepare) +KOTLIN_SMOKE="" # Kotlin run script from `gradle installDist` (set in prepare) +C_SMOKE="" # C client, hand-rolled -I/-L/-l build (set in prepare) +C_PC_SMOKE="" # C client, built via pkg-config + moq.pc (set in prepare) +C_CMAKE_SMOKE="" # C client, built via the shipped CMake package (set in prepare) +LIBMOQ_ROOT="" # extracted libmoq prebuilt tarball root, shared by the 3 C builds +LIBMOQ_OS_LIBS="" # system libs for the hand-rolled C build (set in libmoq_fetch) +GST_PLUGIN_DIR="" # dir holding the moq-gst plugin (libgstmoq.{so,dylib}; set in prepare) +BROKEN_LANGS="" # clients whose public package failed to install/build + +mark_broken() { + # A client whose published package won't install/build fails only its own + # matrix cells, instead of aborting the whole run. That's the point: a broken + # registry artifact should show up as a red cell, not hide every other result. + BROKEN_LANGS="$BROKEN_LANGS $1" + echo " WARN $1 client unavailable: $2" +} + +is_broken() { + local lang="$1" x + for x in $BROKEN_LANGS; do [[ "$x" == "$lang" ]] && return 0; done + return 1 +} + +kill_tree() { + # SIGKILL, depth-first. moq ignores SIGTERM (handles only SIGINT), so a + # polite kill would leak it; these are ephemeral test processes, so -9 is fine. + local pid="$1" child + for child in $(pgrep -P "$pid" 2>/dev/null || true); do kill_tree "$child"; done + kill -KILL "$pid" 2>/dev/null || true +} + +# shellcheck disable=SC2329 # invoked indirectly via 'trap cleanup EXIT' +cleanup() { + # Reap the last publisher too; subscribers self-terminate via their timeouts. + [[ -n "${PUB_PID:-}" ]] && kill_tree "$PUB_PID" + [[ -n "$RELAY_PID" ]] && kill_tree "$RELAY_PID" + # The docker-channel relay runs in a container that outlives a SIGKILL of its + # wrapper's runtime client, so kill_tree alone leaves it holding the port and + # blocking the next run in the same job. Reap it by name -- but only when the + # docker wrapper is actually the relay, so a non-docker run on a host that + # happens to have docker doesn't delete an unrelated container of that name. + if [[ "$RELAY" == */clients/docker/moq-relay ]] && have "${SMOKE_DOCKER:-docker}"; then + "${SMOKE_DOCKER:-docker}" rm -f "$RELAY_CONTAINER" >/dev/null 2>&1 || true + fi + rm -rf "$TMP" +} +trap cleanup EXIT + +have() { command -v "$1" >/dev/null 2>&1; } + +# Run a command up to N times with a short growing backoff. For fetches from +# registries/proxies that fail transiently (sum.golang.org stream resets, etc.); +# without it one blip marks a client broken and fails its whole column. +retry() { + local n="$1" i + shift + for ((i = 1; i <= n; i++)); do + "$@" && return 0 + [[ $i -lt $n ]] && sleep $((i * 2)) + done + return 1 +} + +# Download a release tarball to a file and extract it into a directory. Two +# deliberate choices: curl retries every failure (--retry alone skips partial +# transfers, and a truncated body is exactly the failure GitHub's CDN produces), +# and the archive lands on disk before tar sees it, so a short read can't leave a +# half-extracted tree behind while `tar xz` reports only "unexpected end of file". +fetch_tarball() { + local url="$1" dir="$2" + local archive="$dir/.download.tar.gz" + mkdir -p "$dir" + if ! curl -sfL --retry 5 --retry-delay 2 --retry-all-errors -o "$archive" "$url" || + ! tar xzf "$archive" -C "$dir"; then + echo "download/extract failed: $url" >&2 + return 1 + fi + rm -f "$archive" +} + +require_tools() { + # Resolve the CLI binary name unless MOQ_BIN pinned it. + if [[ -z "$MOQ" ]]; then + MOQ=moq + fi + # Only the relay, CLI, and harness essentials are hard requirements. A missing + # per-client toolchain (uv / go / bun / swift / gradle / cc) just marks that + # client broken in prepare, so it fails its own cells instead of the whole run. + local missing=() t + for t in ffmpeg curl pgrep timeout; do + have "$t" || missing+=("$t") + done + if [[ ${#EXTERNAL_RELAYS[@]} -eq 0 ]]; then + have "$RELAY" || missing+=("$RELAY (cargo/brew/apt/nix install moq-relay)") + fi + have "$MOQ" || missing+=("moq (cargo/brew/apt install moq-cli, or nix build github:moq-dev/moq#moq)") + if [[ ${#missing[@]} -gt 0 ]]; then + echo "error: missing required tools: ${missing[*]}" >&2 + exit 1 + fi +} + +# Run swift with the Xcode toolchain. A loaded nix devShell exports SDKROOT / +# DEVELOPER_DIR pointing at a nix Apple SDK, which the Xcode swiftc rejects; +# clear those so swift picks its own matching SDK. +run_swift() { + env -u SDKROOT -u DEVELOPER_DIR -u NIX_CFLAGS_COMPILE -u NIX_CFLAGS_LINK \ + -u CPATH -u LIBRARY_PATH -u MACOSX_DEPLOYMENT_TARGET swift "$@" +} + +# Download the latest libmoq prebuilt release for this platform. Sets LIBMOQ_ROOT +# (extracted tarball root) and LIBMOQ_OS_LIBS (system libs for the hand-rolled +# build). Idempotent: the three C build variants share one download. The tarball +# ships include/moq.h, lib/libmoq.a, lib/pkgconfig/moq.pc and lib/cmake/moq/. +libmoq_fetch() { + [[ -n "$LIBMOQ_ROOT" && -d "$LIBMOQ_ROOT" ]] && return 0 + local target tag ver url hdr=() + have curl || { + echo "curl required" >&2 + return 1 + } + have jq || { + echo "jq required" >&2 + return 1 + } + case "$(uname -s)/$(uname -m)" in + Darwin/arm64) target=aarch64-apple-darwin ;; + Darwin/x86_64) target=x86_64-apple-darwin ;; + Linux/x86_64) target=x86_64-unknown-linux-gnu ;; + Linux/aarch64 | Linux/arm64) target=aarch64-unknown-linux-gnu ;; + *) + echo "unsupported platform: $(uname -s)/$(uname -m)" >&2 + return 1 + ;; + esac + case "$(uname -s)" in + Darwin) LIBMOQ_OS_LIBS="-framework CoreFoundation -framework Security -framework CoreServices -framework Foundation -framework AVFoundation -framework CoreMedia -framework CoreVideo -framework VideoToolbox -framework ScreenCaptureKit -framework CoreGraphics -lc++" ;; + # No -lva: moq-vaapi dlopen's libva at runtime, so libmoq.a carries no + # libva symbols and a libva-less host just falls back to another encoder. + *) LIBMOQ_OS_LIBS="-lpthread -ldl -lm -lstdc++" ;; + esac + # Latest libmoq-v* release, never pinned. Authenticated if a token is around + # (CI) to dodge the 60/hr anonymous GitHub API limit. + [[ -n "${GITHUB_TOKEN:-}" ]] && hdr=(-H "Authorization: Bearer $GITHUB_TOKEN") + # macOS ships bash 3.2, where "${hdr[@]}" on an empty array trips `set -u`; + # the ${arr[@]+...} guard expands to nothing when the array is unset/empty. + tag=$(curl -sf --retry 3 --retry-all-errors ${hdr[@]+"${hdr[@]}"} "https://api.github.com/repos/moq-dev/moq/releases?per_page=100" | + jq -r '.[].tag_name' | grep '^libmoq-v' | head -1) + [[ -n "$tag" ]] || { + echo "no libmoq-v* release found" >&2 + return 1 + } + ver=${tag#libmoq-v} + url="https://github.com/moq-dev/moq/releases/download/$tag/moq-$ver-$target.tar.gz" + echo "libmoq $tag ($target)" + fetch_tarball "$url" "$TMP/libmoq" || return 1 + LIBMOQ_ROOT="$TMP/libmoq/moq-$ver-$target" +} + +# Hand-rolled build: -I/-L/-l with system libs we hardcode here. The baseline +# "does the static archive link at all" check; ignores moq.pc and the CMake +# config entirely. +c_build_direct() { + local cc="${CC:-cc}" out="$1" + have "$cc" || { + echo "no C compiler ($cc) on PATH" >&2 + return 1 + } + # shellcheck disable=SC2086 # LIBMOQ_OS_LIBS is a deliberate multi-flag word list + "$cc" "$CLIENTS/c/subscribe.c" -I"$LIBMOQ_ROOT/include" -L"$LIBMOQ_ROOT/lib" -lmoq \ + $LIBMOQ_OS_LIBS -o "$out" +} + +# pkg-config build: resolve cflags/libs from the shipped moq.pc, the way a real +# consumer (e.g. FFmpeg --enable-libmoq) does. libmoq is a static archive, so +# --static pulls Libs.private (the system frameworks/libs) too. A wrong +# includedir/libdir or a missing framework in moq.pc fails here -- exactly the +# breakage the hand-rolled build above can't see. +c_build_pkgconfig() { + local cc="${CC:-cc}" out="$1" cflags libs + have "$cc" || { + echo "no C compiler ($cc) on PATH" >&2 + return 1 + } + have pkg-config || { + echo "pkg-config not on PATH (apt install pkg-config / brew install pkgconf)" >&2 + return 1 + } + export PKG_CONFIG_PATH="$LIBMOQ_ROOT/lib/pkgconfig" + cflags=$(pkg-config --cflags moq) || { + echo "pkg-config --cflags moq failed" >&2 + return 1 + } + libs=$(pkg-config --static --libs moq) || { + echo "pkg-config --static --libs moq failed" >&2 + return 1 + } + # shellcheck disable=SC2086 # cflags/libs are deliberate multi-flag word lists + "$cc" "$CLIENTS/c/subscribe.c" $cflags $libs -o "$out" +} + +# CMake build: find_package(moq) against the shipped lib/cmake/moq package, the +# way a CMake consumer imports the moq::moq target. A broken config (wrong paths, +# missing target or system libs) fails configure/build here. CMAKE_PREFIX_PATH +# points at the extracted tarball root; the binary lands at /smoke. +c_build_cmake() { + local builddir="$1" + have cmake || { + echo "cmake not on PATH (apt install cmake / brew install cmake)" >&2 + return 1 + } + rm -rf "$builddir" + cmake -S "$CLIENTS/c" -B "$builddir" -DCMAKE_PREFIX_PATH="$LIBMOQ_ROOT" \ + -DCMAKE_BUILD_TYPE=Release >/dev/null || { + echo "cmake configure failed" >&2 + return 1 + } + cmake --build "$builddir" >/dev/null || { + echo "cmake build failed" >&2 + return 1 + } +} + +# Download the latest moq-gst prebuilt release for this platform and confirm the +# plugin loads against the host's *system* GStreamer. The plugin (libgstmoq.so / +# .dylib) dynamic-links libgstreamer, so this is the customer-facing scenario: +# install the .deb / brew tap / tarball, then `gst-inspect-1.0 moq`. Sets +# GST_PLUGIN_DIR to the dir holding the plugin. moqsrc connects over the network, +# so it's relay-channel-agnostic; GST_PLUGIN_DIR + a system GStreamer is all it +# needs. (Local nix shells have no GStreamer, and a prebuilt plugin wouldn't link +# against nixpkgs' gst anyway, so this cell wants a real system gst install.) +gst_prepare() { + local target tag asset_name url root releases hdr=() + have gst-launch-1.0 || { + echo "gst-launch-1.0 not on PATH (apt install gstreamer1.0-tools / brew install gstreamer)" >&2 + return 1 + } + have gst-inspect-1.0 || { + echo "gst-inspect-1.0 not on PATH" >&2 + return 1 + } + # Escape hatch: point at a locally-built plugin dir (e.g. target/release after + # `cargo build -p moq-gst`) instead of the published tarball, mirroring the + # RELAY_BIN/MOQ_BIN overrides. Skips the download but still load-checks it. + if [[ -n "${MOQ_GST_PLUGIN_DIR:-}" ]]; then + GST_PLUGIN_DIR="$MOQ_GST_PLUGIN_DIR" + echo "moq-gst (local: $GST_PLUGIN_DIR)" + [[ -d "$GST_PLUGIN_DIR" ]] || { + echo "MOQ_GST_PLUGIN_DIR is not a directory: $GST_PLUGIN_DIR" >&2 + return 1 + } + GST_PLUGIN_PATH_1_0="$GST_PLUGIN_DIR" GST_PLUGIN_SYSTEM_PATH_1_0="" \ + GST_REGISTRY_1_0="$TMP/gst-registry.bin" \ + gst-inspect-1.0 moq 2>/dev/null | grep -qE '^[[:space:]]+moqsrc:' || + { + echo "moqsrc not exposed (plugin failed to load against this GStreamer)" >&2 + return 1 + } + return 0 + fi + have curl || { + echo "curl required" >&2 + return 1 + } + have jq || { + echo "jq required" >&2 + return 1 + } + case "$(uname -s)/$(uname -m)" in + Darwin/arm64) target=aarch64-apple-darwin ;; + Darwin/x86_64) target=x86_64-apple-darwin ;; + Linux/x86_64) target=x86_64-unknown-linux-gnu ;; + Linux/aarch64 | Linux/arm64) target=aarch64-unknown-linux-gnu ;; + *) + echo "unsupported platform: $(uname -s)/$(uname -m)" >&2 + return 1 + ;; + esac + # Latest moq-gst-v* release, never pinned. Authenticated if a token is around + # (CI) to dodge the 60/hr anonymous GitHub API limit. + [[ -n "${GITHUB_TOKEN:-}" ]] && hdr=(-H "Authorization: Bearer $GITHUB_TOKEN") + releases="$TMP/moq-gst-releases.json" + curl -sf --retry 3 --retry-all-errors ${hdr[@]+"${hdr[@]}"} \ + "https://api.github.com/repos/moq-dev/moq/releases?per_page=100" -o "$releases" || { + echo "failed to query moq-gst releases" >&2 + return 1 + } + tag=$(jq -r '.[].tag_name' "$releases" | grep '^moq-gst-v' | head -1) + [[ -n "$tag" ]] || { + echo "no moq-gst-v* release found" >&2 + return 1 + } + # Release asset names are part of the release metadata, not a derivation of + # the crate version. In particular, 0.3.7 added the tag's `v` to the archive + # and extracted-directory name. Select the platform asset that was actually + # published so future naming changes fail as a missing asset, not a guessed URL. + asset_name=$(jq -r --arg tag "$tag" --arg suffix "-$target.tar.gz" \ + '.[] | select(.tag_name == $tag) | .assets[].name | + select(startswith("moq-gst-") and endswith($suffix))' "$releases" | head -1) + [[ -n "$asset_name" ]] || { + echo "no moq-gst asset for $target in release $tag" >&2 + return 1 + } + url=$(jq -r --arg tag "$tag" --arg name "$asset_name" \ + '.[] | select(.tag_name == $tag) | .assets[] | + select(.name == $name) | .browser_download_url' "$releases" | head -1) + [[ -n "$url" ]] || { + echo "moq-gst asset has no download URL: $asset_name" >&2 + return 1 + } + echo "moq-gst $tag ($target)" + fetch_tarball "$url" "$TMP/moq-gst" || return 1 + root="$TMP/moq-gst/${asset_name%.tar.gz}" + GST_PLUGIN_DIR="$root/lib/gstreamer-1.0" + [[ -d "$GST_PLUGIN_DIR" ]] || { + echo "plugin dir missing in tarball: $GST_PLUGIN_DIR" >&2 + return 1 + } + # gst-inspect exits 0 even when the .so fails to load, so grep for the + # factory. Isolate discovery to our dir + a temp registry so a system-wide moq + # plugin can't shadow it (mirrors moq-gst's own smoke.sh). + GST_PLUGIN_PATH_1_0="$GST_PLUGIN_DIR" GST_PLUGIN_SYSTEM_PATH_1_0="" \ + GST_REGISTRY_1_0="$TMP/gst-registry.bin" \ + gst-inspect-1.0 moq 2>/dev/null | grep -qE '^[[:space:]]+moqsrc:' || + { + echo "moqsrc not exposed (plugin failed to load against this GStreamer)" >&2 + return 1 + } +} + +# ── setup ─────────────────────────────────────────────────────────────────── +require_tools + +# Surface any "we've drifted off latest" problem up front. Non-fatal here so the +# matrix still runs; `just freshness` / CI enforce it hard. +"$SMOKE_DIR/freshness.sh" || echo "WARN: freshness check failed (see above); continuing" >&2 + +if [[ ${#EXTERNAL_RELAYS[@]} -eq 0 ]]; then + echo "relay: $(command -v "$RELAY")" +else + echo "relays: ${EXTERNAL_RELAYS[*]}" +fi +echo "moq: $(command -v "$MOQ")" + +# moq-cli 0.12 renamed --client-connect to --connect and rejects the old name. +# Pick whichever this build accepts until every channel ships 0.12. +# `--connect-websocket-enabled` contains `--connect`, and `-` is a word +# boundary, so the match has to end on the option that takes the URL. +moq_help=$("$MOQ" --help 2>&1 || true) +if grep -qE -- '(^|[[:space:]])--connect([[:space:]=]|$)' <<<"$moq_help"; then + MOQ_CONNECT=--connect +else + MOQ_CONNECT=--client-connect +fi +# Present once moq-cli grew `--connect-websocket-enabled`. Older binaries ignore +# the env var below and still race the fallback; they reject an unknown flag. +MOQ_WS_OFF=() +if grep -qF -- '--connect-websocket-enabled' <<<"$moq_help"; then + MOQ_WS_OFF=(--connect-websocket-enabled=false) +fi + +# The local relay may race its WebSocket binding. An external ws(s) URL is the +# WebSocket column. Every other external URL is WebTransport or raw QUIC. +allow_websocket() { + [[ ${#EXTERNAL_RELAYS[@]} -eq 0 || "$URL" == ws://* || "$URL" == wss://* ]] +} + +# Run a client. When this URL must not race WebSocket, say so in the environment. +# The Rust CLI reads MOQ_CONNECT_WEBSOCKET_ENABLED. The browser page and native JS +# read SMOKE_WEBSOCKET. Python, Go, Swift, Kotlin, and C have no published switch +# for it yet; the relays lane does not use them. +run_client() { + if allow_websocket; then + "$@" + else + SMOKE_WEBSOCKET=0 MOQ_CONNECT_WEBSOCKET_ENABLED=false "$@" + fi +} + +rust_moq() { + # Clap takes the argv after --connect as the URL. A flag in that position + # is not a value, so the websocket switch goes before --connect. + if ! allow_websocket && [[ ${#MOQ_WS_OFF[@]} -gt 0 ]]; then + run_client "$MOQ" "${MOQ_WS_OFF[@]}" "$MOQ_CONNECT" "$@" + else + run_client "$MOQ" "$MOQ_CONNECT" "$@" + fi +} + +if needs python; then + echo "installing python client (moq-rs from PyPI)..." + PY="$TMP/venv/bin/python" + # Latest published wheel; sdist fallback needs a Rust toolchain + C compiler. + if ! have uv; then + mark_broken python "uv not found" + elif uv venv --quiet "$TMP/venv" && uv pip install --quiet --python "$PY" moq-rs; then :; else + mark_broken python "uv pip install moq-rs failed" + fi +fi + +if needs go; then + echo "building go client (moq.dev/moq from the module proxy)..." + GO_SMOKE="$TMP/go-smoke" + # Pull the latest published module, then build the client against it. The + # ergonomic moq.dev/moq wrapper pulls a transitive moq.dev/moq-ffi; `go get` + # records only the wrapper's own checksum, so `go mod tidy` fetches the rest (no + # go.sum is committed -- freshness bans lockfiles -- so it's regenerated here). + # The module proxy / sum.golang.org occasionally reset mid-stream; retry the + # fetch (idempotent) and build only once it succeeds. + # shellcheck disable=SC2329 # invoked via retry + go_fetch() (cd "$CLIENTS/go" && go get "moq.dev/moq@latest" && go mod tidy) + if ! have go; then + mark_broken go "go not found" + elif (retry 3 go_fetch && cd "$CLIENTS/go" && CGO_ENABLED=1 go build -o "$GO_SMOKE" .) >"$TMP/go-build.log" 2>&1; then :; else + mark_broken go "go get/build of moq.dev/moq failed" + sed 's/^/ /' "$TMP/go-build.log" >&2 || true + fi +fi + +# The browser client ships three delivery variants (js-web = vite, js-esbuild, +# js-jsdelivr) that all drive the same / elements; they +# differ only in how the published npm packages reach the page. +if needs js-web || needs js-esbuild || needs js-jsdelivr; then + echo "installing browser clients (@moq/watch + @moq/publish from npm)..." + if ! have bun; then + for v in js-web js-esbuild js-jsdelivr; do mark_broken "$v" "bun not found"; done + else + js_base() { + (cd "$CLIENTS/js" && bun install) || return 1 + # Nix provides Chromium via PLAYWRIGHT_BROWSERS_PATH; otherwise fetch it. + [[ -n "${PLAYWRIGHT_BROWSERS_PATH:-}" ]] || (cd "$CLIENTS/js" && bunx playwright install chromium) || return 1 + } + if js_base >"$TMP/js-base.log" 2>&1; then + # jsdelivr imports from the CDN at runtime, so it needs no build. The + # bundler variants each build their own page; a build failure fails + # only that variant. + if needs js-web && ! (cd "$CLIENTS/js" && bunx vite build) >"$TMP/js.log" 2>&1; then + mark_broken js-web "vite build failed" + sed 's/^/ /' "$TMP/js.log" >&2 || true + fi + if needs js-esbuild && ! (cd "$CLIENTS/js" && bun build-esbuild.ts) >"$TMP/js-esbuild.log" 2>&1; then + mark_broken js-esbuild "esbuild build failed" + sed 's/^/ /' "$TMP/js-esbuild.log" >&2 || true + fi + else + for v in js-web js-esbuild js-jsdelivr; do mark_broken "$v" "bun install / playwright failed"; done + sed 's/^/ /' "$TMP/js-base.log" >&2 || true + fi + fi +fi + +# Native (non-browser) JS: the published @moq/net + @moq/hang under a runtime +# with no native WebTransport, using moq's own @moq/web-transport polyfill. +# Run under bun (js-bun) and node (js-node). +if needs js-bun || needs js-node; then + echo "installing native-js client (@moq/net + @moq/hang + webtransport polyfill)..." + if ! have bun; then + for v in js-bun js-node; do mark_broken "$v" "bun not found (needed to install)"; done + elif (cd "$CLIENTS/js-native" && bun install) >"$TMP/js-native.log" 2>&1; then + if needs js-node && ! have node; then + mark_broken js-node "node not found" + fi + else + for v in js-bun js-node; do mark_broken "$v" "bun install failed"; done + sed 's/^/ /' "$TMP/js-native.log" >&2 || true + fi +fi + +if needs swift; then + echo "building swift client (moq-dev/moq-swift via SPM)..." + SWIFT_SMOKE="$CLIENTS/swift/.build/debug/smoke" + # `from: 0.2.0` + `swift package update` resolves the latest 0.x each run. + if ! have swift; then + mark_broken swift "swift not found (needs the Xcode toolchain; macOS only)" + elif (cd "$CLIENTS/swift" && run_swift package update && run_swift build) >"$TMP/swift-build.log" 2>&1; then :; else + mark_broken swift "swift package update / build failed" + sed 's/^/ /' "$TMP/swift-build.log" >&2 || true + fi +fi + +if needs kotlin; then + echo "building kotlin client (dev.moq:moq from Maven Central)..." + KOTLIN_SMOKE="$CLIENTS/kotlin/build/install/smoke/bin/smoke" + # `latest.release` + disabled dynamic-version caching resolves the newest each run. + if ! have gradle; then + mark_broken kotlin "gradle not found" + elif (cd "$CLIENTS/kotlin" && gradle --quiet --console=plain installDist) >"$TMP/kotlin-build.log" 2>&1; then :; else + mark_broken kotlin "gradle installDist failed" + sed 's/^/ /' "$TMP/kotlin-build.log" >&2 || true + fi +fi + +# The C client ships three build variants (c, c-pkgconfig, c-cmake) that all +# produce the same subscribe-only binary from the same prebuilt libmoq; they +# differ only in how the build discovers it: hand-rolled flags, pkg-config (the +# moq.pc consumer path), or find_package (the CMake package consumer path). +if needs c || needs c-pkgconfig || needs c-cmake; then + echo "building c client(s) against the libmoq prebuilt release..." + if libmoq_fetch >"$TMP/c-fetch.log" 2>&1; then + cat "$TMP/c-fetch.log" + if needs c; then + C_SMOKE="$TMP/c-smoke" + if c_build_direct "$C_SMOKE" >"$TMP/c-build.log" 2>&1; then :; else + mark_broken c "direct compile failed" + sed 's/^/ /' "$TMP/c-build.log" >&2 || true + fi + fi + if needs c-pkgconfig; then + C_PC_SMOKE="$TMP/c-pc-smoke" + if c_build_pkgconfig "$C_PC_SMOKE" >"$TMP/c-pc-build.log" 2>&1; then :; else + mark_broken c-pkgconfig "pkg-config build failed" + sed 's/^/ /' "$TMP/c-pc-build.log" >&2 || true + fi + fi + if needs c-cmake; then + C_CMAKE_SMOKE="$TMP/c-cmake/smoke" + if c_build_cmake "$TMP/c-cmake" >"$TMP/c-cmake-build.log" 2>&1; then :; else + mark_broken c-cmake "cmake build failed" + sed 's/^/ /' "$TMP/c-cmake-build.log" >&2 || true + fi + fi + else + sed 's/^/ /' "$TMP/c-fetch.log" >&2 || true + for v in c c-pkgconfig c-cmake; do mark_broken "$v" "libmoq download failed"; done + fi +fi + +if needs gst; then + echo "installing gstreamer client (moq-gst prebuilt release)..." + if gst_prepare >"$TMP/gst-build.log" 2>&1; then :; else + mark_broken gst "moq-gst download / plugin load failed" + sed 's/^/ /' "$TMP/gst-build.log" >&2 || true + fi +fi + +if [[ ${#EXTERNAL_RELAYS[@]} -eq 0 ]]; then + # A relay from a previous run in the same job is reaped by cleanup(), but the + # kernel can take a moment to release the (host-network) socket afterwards, so + # wait briefly for the port to clear before treating it as a real conflict. + for _ in $(seq 1 20); do + curl -sf "$URL/certificate.sha256" >/dev/null 2>&1 || break + sleep 0.5 + done + if curl -sf "$URL/certificate.sha256" >/dev/null 2>&1; then + echo "error: something is already listening on 127.0.0.1:4443 (stale relay?)" >&2 + exit 1 + fi + + echo "starting relay on 127.0.0.1:${PORT}..." + # smoke.toml is the source of truth; rewrite its port into a scratch copy so a + # busy 4443 (a dev relay, a parallel run) doesn't require editing the committed file. + # Relays before 0.15 (no --listen flag) reject the renamed keys, so they get the + # legacy layout until every channel ships 0.15. + relay_config="$SMOKE_DIR/smoke.toml" + relay_help=$("$RELAY" --help 2>&1 || true) + if ! grep -q -- '--listen\b' <<<"$relay_help"; then + relay_config="$SMOKE_DIR/smoke-legacy.toml" + fi + echo "relay config: $(basename "$relay_config")" + sed "s/4443/${PORT}/g" "$relay_config" >"$TMP/relay.toml" + "$RELAY" "$TMP/relay.toml" >"$TMP/relay.log" 2>&1 & + RELAY_PID=$! + for _ in $(seq 1 60); do + curl -sf "$URL/certificate.sha256" >/dev/null 2>&1 && break + sleep 0.5 + done + if ! curl -sf "$URL/certificate.sha256" >/dev/null 2>&1; then + echo "relay never became ready" >&2 + sed 's/^/ relay: /' "$TMP/relay.log" >&2 || true + exit 1 + fi +fi + +# ── client dispatch ───────────────────────────────────────────────────────── +# Encode an endless H.264 Annex-B stream from a synthetic source to stdout. +# Paced with -re so the broadcast streams in real time until the reader closes. +# Baseline + repeat-headers re-emits SPS/PPS before every keyframe so a late +# subscriber (or the stream importer) can initialize without the first packet. +ffmpeg_h264() { + ffmpeg -hide_banner -loglevel error -re -f lavfi -i "testsrc=size=${SIZE}:rate=${FPS}" \ + -an -c:v libx264 -profile:v baseline -preset ultrafast -pix_fmt yuv420p \ + -x264-params "keyint=${FPS}:min-keyint=${FPS}:scenecut=0:repeat-headers=1" \ + -f h264 - +} + +# The driver.ts --variant for a browser cell. js-web is the vite bundle. +js_variant() { + case "$1" in + js-web) echo vite ;; + js-esbuild) echo esbuild ;; + js-jsdelivr) echo jsdelivr ;; + *) + echo "unknown browser variant: $1" >&2 + return 1 + ;; + esac +} + +# Sets global PUB_PID. Called in the current shell (no command substitution) so +# $! refers to the backgrounded job and kill_tree can reap the whole pipeline. +# Every publisher just consumes the same ffmpeg Annex-B stream on stdin; the +# client frames it (moq / the FFI importers only frame-and-forward, ffmpeg +# encodes). +PUB_PID="" +start_publisher() { + local lang="$1" broadcast="$2" log="$TMP/pub-$1.log" + case "$lang" in + rust) + (ffmpeg_h264 | rust_moq "$URL" --broadcast "$broadcast" import avc3) >"$log" 2>&1 & + ;; + python) + (ffmpeg_h264 | run_client "$PY" "$CLIENTS/python/smoke.py" \ + publish --url "$URL" --broadcast "$broadcast") >"$log" 2>&1 & + ;; + go) + (ffmpeg_h264 | run_client "$GO_SMOKE" publish --url "$URL" --broadcast "$broadcast") >"$log" 2>&1 & + ;; + js-web | js-esbuild | js-jsdelivr) + # Headless Chromium encodes its own H.264 from a fake camera via + # WebCodecs (lazily, once a subscriber creates demand). The variant + # selects how the published packages reach the page. + (cd "$CLIENTS/js" && run_client bun driver.ts publish --variant "$(js_variant "$lang")" \ + --url "$URL" --broadcast "$broadcast") >"$log" 2>&1 & + ;; + *) + echo "unknown publisher: $lang" >&2 + return 1 + ;; + esac + PUB_PID=$! +} + +run_subscriber() { + local lang="$1" broadcast="$2" + case "$lang" in + rust) + # moq only handles SIGINT, so -k forces SIGKILL if it ignores the + # SIGTERM that fires when no data arrives within the timeout. + local n + local -a ws=() + # stderr stays in the cell log: it names the negotiated version. + # timeout is a binary, so the flag has to be on its argv; the env + # prefix still reaches moq through it. The websocket switch goes + # before --connect so clap takes the URL as that option's value. + if ! allow_websocket && [[ ${#MOQ_WS_OFF[@]} -gt 0 ]]; then + ws=("${MOQ_WS_OFF[@]}") + fi + n=$(run_client timeout -k 3 "$TIMEOUT" "$MOQ" ${ws[@]+"${ws[@]}"} "$MOQ_CONNECT" "$URL" --broadcast "$broadcast" \ + export fmp4 | head -c 1 | wc -c | tr -d ' ' || true) + [[ "${n:-0}" -ge 1 ]] + ;; + python) + # moq-rs occasionally SIGABRTs from its dedicated runtime thread + # during process teardown, after the subscriber has received data. + # Retry only that abnormal exit once; ordinary protocol/time-out + # failures remain failures, and a repeated abort is still surfaced. + local status + if RUST_BACKTRACE="${RUST_BACKTRACE:-1}" run_client "$PY" "$CLIENTS/python/smoke.py" \ + subscribe --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT"; then + return 0 + else + status=$? + fi + [[ "$status" -eq 134 ]] || return "$status" + echo "warning: moq-rs subscriber aborted during teardown; retrying once" >&2 + RUST_BACKTRACE="${RUST_BACKTRACE:-1}" run_client "$PY" "$CLIENTS/python/smoke.py" \ + subscribe --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT" + ;; + go) + run_client "$GO_SMOKE" subscribe --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT" + ;; + swift) + run_client "$SWIFT_SMOKE" subscribe --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT" + ;; + kotlin) + run_client "$KOTLIN_SMOKE" subscribe --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT" + ;; + c | c-pkgconfig | c-cmake) + # Same subscribe-only client; the three cells differ only in how it + # was built (hand-rolled, pkg-config, or CMake) against the same + # prebuilt libmoq. The run is identical. + local bin + case "$lang" in + c) bin="$C_SMOKE" ;; + c-pkgconfig) bin="$C_PC_SMOKE" ;; + c-cmake) bin="$C_CMAKE_SMOKE" ;; + esac + run_client "$bin" subscribe --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT" + ;; + gst) + # moqsrc exposes the broadcast's video as a Sometimes pad (video_%u, + # ANY caps); gst-launch links it to filesink once it appears. We pipe + # the raw frames to stdout and grab one byte, the same "bytes moved" + # bar (and the same head -c 1 early-exit idiom) as the rust subscriber + # -- no decode. head closing the pipe SIGPIPEs gst-launch, so success + # returns at once; no data just runs out the timeout. Our plugin dir + # rides on top of the system path (which provides filesink); a private + # registry keeps the scan off the user's cache. buffer-mode=2 makes + # filesink unbuffered so the first frame reaches head immediately. + local n + n=$(GST_PLUGIN_PATH_1_0="$GST_PLUGIN_DIR" GST_REGISTRY_1_0="$TMP/gst-run-registry.bin" \ + run_client timeout -k 3 "$TIMEOUT" gst-launch-1.0 -q \ + moqsrc url="$URL" broadcast="$broadcast" ! filesink location=/dev/stdout buffer-mode=2 \ + 2>/dev/null | head -c 1 | wc -c | tr -d ' ' || true) + [[ "${n:-0}" -ge 1 ]] + ;; + js-web | js-esbuild | js-jsdelivr) + # Headless Chromium decodes via WebCodecs; exits 0 once a frame lands. + (cd "$CLIENTS/js" && run_client bun driver.ts subscribe --variant "$(js_variant "$lang")" \ + --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT") + ;; + js-bun) + # Native @moq/net via the WebTransport polyfill, under bun. + (cd "$CLIENTS/js-native" && run_client bun subscribe.ts subscribe \ + --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT") + ;; + js-node) + # Same, under node (tsx runs the TS directly). + (cd "$CLIENTS/js-native" && run_client node --import tsx subscribe.ts subscribe \ + --url "$URL" --broadcast "$broadcast" --timeout "$TIMEOUT") + ;; + *) + echo "unknown subscriber: $lang" >&2 + return 1 + ;; + esac +} + +# ── matrix ────────────────────────────────────────────────────────────────── +overall=0 + +negotiated() { + # negotiated : the wire version a client reported, or nothing. The Rust + # and native-JS clients log `connected version=moq-transport-18`; the browser page logs the + # WebTransport subprotocol it was handed (see clients/js/jsdelivr/setup.js), + # whose `moqt-16` ALPN spelling is normalized to match. + [[ -f "$1" ]] || return 0 + sed 's/\x1b\[[0-9;]*m//g' "$1" | + grep -oE 'connected version=[^ ]+|negotiated protocol: [^ ]+' | tail -1 | + sed -E 's/.*(=|: )//; s/^moqt-/moq-transport-/' || true +} + +failure_reason() { + # failure_reason : the first error-looking line of a cell log, minus its + # timestamp/level/target prefix and the browser's [page] tag. The first error is + # usually the cause; later ones tend to be the teardown it triggered. + [[ -f "$1" ]] || return 0 + sed 's/\x1b\[[0-9;]*m//g' "$1" | + grep -m1 -E '(^|[^a-z])(err|error|Error|ERROR|failed|Failed|timed out)([^a-z]|$)' | + sed -E 's/^[0-9T:.-]+Z +[A-Z]+ +[a-z0-9_:]+: //; s/^\[page\] //; s/^[[:space:]]+//' | + tr '\t' ' ' | cut -c1-240 || true +} + +log_dir() { + # log_dir: where --logs keeps the current relay's cell logs, relative to $LOGS. + sed -E 's#[^A-Za-z0-9._-]+#_#g; s#_+$##' <<<"$URL" +} + +record() { + # record [note]: print the cell and, with + # --results, append a TSV row keyed by the relay URL: relay, pub, sub, status, + # pub version, sub version, reason (why it failed or was skipped), and the + # subscriber / publisher log paths under --logs. relays.sh summarises these. + local pub="$1" sub="$2" status="$3" note="${4:-}" pub_ver="" sub_ver="" + local reason="$note" sub_log="" pub_log="" dir + if [[ "$status" != SKIP ]]; then + pub_ver=$(negotiated "$TMP/pub-$pub.log") + sub_ver=$(negotiated "$TMP/$pub-$sub.log") + fi + if [[ "$status" == FAIL && -z "$reason" ]]; then + reason=$(failure_reason "$TMP/$pub-$sub.log") + [[ -n "$reason" ]] || reason=$(failure_reason "$TMP/pub-$pub.log" | sed 's/^/publisher: /') + [[ -n "$reason" ]] || reason="no data within ${TIMEOUT}s" + fi + # The publisher log is copied once its round ends (see run_round). + if [[ -n "$LOGS" && -z "$note" && "$status" != SKIP ]]; then + dir=$(log_dir) + mkdir -p "$LOGS/$dir" + if [[ -f "$TMP/$pub-$sub.log" ]]; then + sub_log="$dir/$pub-$sub.log" + cp "$TMP/$pub-$sub.log" "$LOGS/$sub_log" + fi + [[ "$pub" == none ]] || pub_log="$dir/pub-$pub.log" + fi + echo " $status $pub -> $sub${note:+ ($note)}${sub_ver:+ [$sub_ver]}" + [[ "$status" == FAIL ]] && overall=1 + if [[ -n "$RESULTS" ]]; then + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$URL" "$pub" "$sub" "$status" \ + "$pub_ver" "$sub_ver" "$reason" "$sub_log" "$pub_log" >>"$RESULTS" + fi + return 0 +} + +belongs() { + # belongs : this client is part of the matrix for $URL. + # The local relay runs whoever was requested. On external relays the browser + # and bun run only on WebTransport (http/https). @moq/web-transport is HTTP/3 + # and has no raw-QUIC mode, so bun is not dialed at moqt://. Anything else is + # left out, not recorded as a skip. + [[ ${#EXTERNAL_RELAYS[@]} -eq 0 ]] && return 0 + case "$1" in + js-web | js-esbuild | js-jsdelivr | js-node | js-bun) + [[ "$URL" == http://* || "$URL" == https://* ]] + ;; + *) return 0 ;; + esac +} + +run_round() { + local pub="$1" broadcast="$2" pub_pid="$3" + # pids[i] is the subscriber's PID, or "STATUS:note" for a cell decided + # without running it; either way cells are reported in --subscribers order. + local pids=() names=() i sub + for sub in "${SUB_LIST[@]}"; do + if ! belongs "$sub"; then + continue + fi + names+=("$sub") + if is_broken "$sub"; then + pids+=("FAIL:subscriber client unavailable") + else + (run_subscriber "$sub" "$broadcast") >"$TMP/$pub-$sub.log" 2>&1 & + pids+=("$!") + fi + done + # A publisher that streams forever should still be alive; if it died, the + # subscriber failures below are a publisher bug, so surface its log. + if [[ -n "$pub_pid" ]] && ! kill -0 "$pub_pid" 2>/dev/null; then + echo " WARN publisher '$pub' exited early:" + sed 's/^/ /' "$TMP/pub-$pub.log" 2>/dev/null || true + fi + local want_pass=1 got + [[ "$NEGATIVE" -eq 1 ]] && want_pass=0 + # ${arr[@]+...} guard: --subscribers may be empty, + # and bash 3.2 (macOS) errors on "${!pids[@]}" for an empty array under `set -u`. + for i in ${pids[@]+"${!pids[@]}"}; do + if [[ "${pids[$i]}" == *:* ]]; then + record "$pub" "${names[$i]}" "${pids[$i]%%:*}" "${pids[$i]#*:}" + continue + fi + if wait "${pids[$i]}"; then got=1; else got=0; fi + if [[ "$got" -eq "$want_pass" ]]; then + record "$pub" "${names[$i]}" PASS + else + record "$pub" "${names[$i]}" FAIL + sed 's/^/ /' "$TMP/$pub-${names[$i]}.log" 2>/dev/null || true + fi + done + if [[ -n "$pub_pid" ]]; then + kill_tree "$pub_pid" + wait "$pub_pid" 2>/dev/null || true + if [[ -n "$LOGS" && -f "$TMP/pub-$pub.log" ]]; then + mkdir -p "$LOGS/$(log_dir)" + cp "$TMP/pub-$pub.log" "$LOGS/$(log_dir)/pub-$pub.log" + fi + # Don't let cleanup() later signal this now-reaped (possibly recycled) PID. + [[ "${PUB_PID:-}" == "$pub_pid" ]] && PUB_PID="" + fi + return 0 +} + +run_matrix() { + local pub sub broadcast + if [[ "$NEGATIVE" -eq 1 ]]; then + # Negative control: no publisher. Every subscriber must FAIL (time out with + # no data), proving the harness can actually report failure. + echo "=== negative control: subscribers expect NO data ===" + run_round "none" "smoke-missing-$$-$RANDOM.hang" "" + return + fi + for pub in "${PUB_LIST[@]}"; do + broadcast="smoke-${pub}-$$-${RANDOM}.hang" + if ! belongs "$pub"; then + continue + fi + echo "=== publisher: $pub broadcast: $broadcast ===" + if is_broken "$pub"; then + # Same filter as run_round: a client that does not belong on this + # URL stays omitted, not a failure invented by the broken publisher. + for sub in "${SUB_LIST[@]}"; do + if ! belongs "$sub"; then + continue + fi + record "$pub" "$sub" FAIL "publisher client unavailable" + done + continue + fi + start_publisher "$pub" "$broadcast" + run_round "$pub" "$broadcast" "$PUB_PID" + done +} + +if [[ ${#EXTERNAL_RELAYS[@]} -eq 0 ]]; then + run_matrix +else + for URL in "${EXTERNAL_RELAYS[@]}"; do + echo "##### relay: $URL #####" + run_matrix + # Completion marker: lets relays.sh tell a finished matrix from one cut + # short after its first cells. + if [[ -n "$RESULTS" ]]; then printf '%s\tDONE\n' "$URL" >>"$RESULTS"; fi + done +fi + +if [[ "$overall" -eq 0 ]]; then + echo "smoke: all checks passed" +else + echo "smoke: FAILURES detected" >&2 +fi +exit "$overall" diff --git a/test/smoke/smoke.toml b/test/smoke/smoke.toml new file mode 100644 index 0000000000..369ad71619 --- /dev/null +++ b/test/smoke/smoke.toml @@ -0,0 +1,20 @@ +# Relay config for the cross-language interop smoke test. +# Anonymous access, self-signed localhost cert, QUIC + WebSocket on 127.0.0.1:4443. +# Mirrors moq-dev/moq's test/smoke/smoke.toml. smoke-legacy.toml carries the +# pre-0.15 layout for relays that predate the [server] -> [listen] rename. + +[log] +level = "info" + +[listen] +# QUIC on UDP. 127.0.0.1 avoids IPv6 flakiness on CI runners. +bind = "127.0.0.1:4443" +tls.generate = ["localhost", "127.0.0.1"] + +[web.http] +# HTTP + WebSocket on TCP, also serving /certificate.sha256 for cert pinning. +listen = "127.0.0.1:4443" + +[auth] +# Allow anonymous access to everything. +public = "**" diff --git a/test/smoke/token.sh b/test/smoke/token.sh new file mode 100755 index 0000000000..7986f04ec0 --- /dev/null +++ b/test/smoke/token.sh @@ -0,0 +1,411 @@ +#!/usr/bin/env bash +# Cross-implementation token interop smoke test against the PUBLIC packages. +# +# moq-relay authenticates with JWTs minted by the moq auth tooling, which ships +# in several flavours from several registries: +# +# - rust : `moq auth` from moq-cli (cargo / brew / apt) or the moq flake (nix) +# - js-node : the @moq/auth npm package's `moq-auth` CLI, run under node +# - js-bun : the same published npm package, run under bun +# +# A token minted by any one of these must verify under every other one, or a +# relay keyed by implementation A would reject a perfectly valid token from a +# publisher using implementation B. This script proves that cross-verification +# holds for the *published* artifacts: it installs each, then for every +# (generator x verifier x algorithm) cell it has the generator mint a key and +# sign a token, and the verifier check it. A negative pass confirms each verifier +# actually rejects tampered tokens and the wrong key, so a green cell means +# "accepts the valid one AND refuses the bad ones", not "accepts everything". +# +# This is the install/packaging companion to moq's in-tree token unit tests: +# those run against workspace source with hardcoded fixtures; this runs the +# real CLIs a user installs, live on both sides. +set -euo pipefail + +SMOKE_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +JS_DIR="$SMOKE_DIR/clients/token/js" + +GENERATORS="rust" +VERIFIERS="rust" +# Cover both code paths in each impl: symmetric (HS256) and every asymmetric +# family they support — EdDSA (OKP/Ed25519), ES256 (EC), RS256 (RSA) — so the +# default matrix (and `just token-full` / CI, which don't pass --algorithms) +# can't silently stop exercising one. Override with --algorithms / TOKEN_ALGORITHMS. +ALGORITHMS="${TOKEN_ALGORITHMS:-HS256,EdDSA,ES256,RS256}" + +# The Rust CLI under test, as a command prefix: the same `moq` binary smoke.sh +# tests (MOQ_BIN, else PATH), since moq-token-cli was folded into `moq auth` +# (moq-dev/moq#3684). TOKEN_BIN overrides the whole prefix. +TOKEN="${TOKEN_BIN:-${MOQ_BIN:-moq} auth}" + +# The published Docker image for the `rust-docker` cell. Untagged = :latest, the +# tag the release pipeline moves to the newest version; pulled fresh each run. +DOCKER_TOKEN_IMAGE="${DOCKER_TOKEN_IMAGE:-moqdev/moq}" +# Container runtime for that cell. `docker` by default (what GitHub's Linux +# runners ship); set TOKEN_DOCKER=podman to use a drop-in-compatible one. +DOCKER="${TOKEN_DOCKER:-docker}" + +# Canary claims. Distinctive values so a verifier's claim dump can be grepped for +# them regardless of output format (Rust prints a debug struct, JS prints JSON; +# both contain these literal strings if the claims survived the round trip). +ROOT="smoke-root" + +require_value() { + if [[ $# -lt 2 || -z "${2:-}" || "$2" == -* ]]; then + echo "error: $1 requires a value" >&2 + exit 2 + fi +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --generators) + require_value "$@" + GENERATORS="$2" + shift 2 + ;; + --verifiers) + require_value "$@" + VERIFIERS="$2" + shift 2 + ;; + --algorithms) + require_value "$@" + ALGORITHMS="$2" + shift 2 + ;; + *) + echo "unknown arg: $1" >&2 + exit 2 + ;; + esac +done + +IFS=',' read -r -a GEN_LIST <<<"$GENERATORS" +IFS=',' read -r -a VER_LIST <<<"$VERIFIERS" +IFS=',' read -r -a ALGO_LIST <<<"$ALGORITHMS" + +needs() { + # needs : true if appears as a generator or verifier. + local impl="$1" x + for x in "${GEN_LIST[@]}" "${VER_LIST[@]}"; do [[ "$x" == "$impl" ]] && return 0; done + return 1 +} + +TMP=$(mktemp -d) +CLI_NODE="" # node + @moq/auth CLI path (set in prepare) +CLI_BUN="" # bun + @moq/auth CLI path (set in prepare) +BROKEN_IMPLS="" + +mark_broken() { + # An implementation whose published package won't install fails only its own + # matrix cells, instead of aborting the run: a broken registry artifact should + # show up as a red cell, not hide every other result. + BROKEN_IMPLS="$BROKEN_IMPLS $1" + echo " WARN $1 unavailable: $2" +} + +is_broken() { + local impl="$1" x + for x in $BROKEN_IMPLS; do [[ "$x" == "$impl" ]] && return 0; done + return 1 +} + +# shellcheck disable=SC2329 # invoked indirectly via 'trap cleanup EXIT' +cleanup() { rm -rf "$TMP"; } +trap cleanup EXIT + +have() { command -v "$1" >/dev/null 2>&1; } + +# ── per-implementation adapters ────────────────────────────────────────────── +# Each implementation's CLI differs (flag names, key encoding, verify output), +# so every operation is funnelled through an adapter that normalises it. The +# contract: +# gen -> writes /sign.jwk (key that signs) and +# /verify.jwk (key that verifies) +# sign -> prints a token to stdout +# verify -> exit 0 + claim dump on stdout if +# accepted, non-zero if rejected +# +# Symmetric (HS256): sign.jwk == verify.jwk (shared secret). +# Asymmetric (EdDSA/ES256/RS256): verify.jwk is the public half. +# Key encodings cross over fine: the Rust CLI writes base64url-JSON and reads +# either; @moq/auth writes plain JSON and reads either. + +cli_for() { + # The command prefix that runs each implementation's token CLI. The word + # split is deliberate (runtime + path, or a whole `docker run ...` line), so + # callers expand it unquoted. + case "$1" in + rust) echo "$TOKEN" ;; + # Mount TMP at its real path so the in-container CLI reads/writes the same + # key/token files token.sh hands it. The image bundles the nix store, so + # the binary's libiconv deps resolve (the brew bottle's bug doesn't apply). + rust-docker) echo "$DOCKER run --rm --user $(id -u):$(id -g) -v $TMP:$TMP -w $TMP $DOCKER_TOKEN_IMAGE auth" ;; + js-node) echo "node $CLI_NODE" ;; + js-bun) echo "bun $CLI_BUN" ;; + *) return 1 ;; + esac +} + +gen() { + local impl="$1" algo="$2" dir="$3" + mkdir -p "$dir" + local cli + cli=$(cli_for "$impl") || { + echo "unknown generator: $impl" >&2 + return 1 + } + case "$impl" in + rust | rust-docker) + if [[ "$algo" == HS* ]]; then + # shellcheck disable=SC2086 # cli is a deliberate multi-word prefix + $cli generate --algorithm "$algo" --out "$dir/sign.jwk" + cp "$dir/sign.jwk" "$dir/verify.jwk" + else + # shellcheck disable=SC2086 + $cli generate --algorithm "$algo" --out "$dir/sign.jwk" --public "$dir/verify.jwk" + fi + ;; + js-node | js-bun) + if [[ "$algo" == HS* ]]; then + # shellcheck disable=SC2086 + $cli generate --out "$dir/sign.jwk" --algorithm "$algo" >/dev/null + cp "$dir/sign.jwk" "$dir/verify.jwk" + else + # shellcheck disable=SC2086 + $cli generate --out "$dir/sign.jwk" --algorithm "$algo" --public "$dir/verify.jwk" >/dev/null + fi + ;; + esac +} + +sign() { + local impl="$1" signkey="$2" algo="$3" cli + cli=$(cli_for "$impl") || { + echo "unknown signer: $impl" >&2 + return 1 + } + # Same flags for the PATH binary and the Docker image; JS uses the same ones too. + # shellcheck disable=SC2086 + $cli sign --key "$signkey" --root "$ROOT" \ + --publish "pub-canary-$algo" --subscribe "sub-canary-$algo" +} + +verify() { + local impl="$1" verifykey="$2" tokenfile="$3" cli + cli=$(cli_for "$impl") || { + echo "unknown verifier: $impl" >&2 + return 1 + } + case "$impl" in + rust | rust-docker) + # Rust verify reads the token from --in and ignores root (it just + # decodes); it prints a debug dump of the claims on success. + # shellcheck disable=SC2086 + $cli verify --key "$verifykey" --in "$tokenfile" + ;; + js-node | js-bun) + # JS verify reads the token from stdin and enforces --root, so pass + # the same root the token was signed with; it prints claims as JSON. + # shellcheck disable=SC2086 + $cli verify --key "$verifykey" --root "$ROOT" <"$tokenfile" + ;; + esac +} + +# ── setup ──────────────────────────────────────────────────────────────────── +rust_probe() { + # shellcheck disable=SC2086 # TOKEN is a deliberate command prefix + $TOKEN generate --algorithm HS256 --out "$TMP/rust-probe.jwk" >"$TMP/rust-probe.log" 2>&1 +} + +"$SMOKE_DIR/freshness.sh" || echo "WARN: freshness check failed (see above); continuing" >&2 + +if needs rust; then + if ! have "${TOKEN%% *}"; then + mark_broken rust "${TOKEN%% *} not found (cargo/brew/apt install moq-cli, or nix build github:moq-dev/moq#moq)" + # `have` only checks the file exists; actually run it once, since a broken + # published binary (e.g. a Homebrew bottle that baked in a /nix/store rpath + # and aborts on launch) is exactly the packaging failure this test exists to + # catch. A broken CLI marks the whole rust row unavailable instead of crashing + # mid-matrix. + elif rust_probe; then + echo "rust: $TOKEN ($(command -v "${TOKEN%% *}"))" + else + mark_broken rust "$TOKEN on PATH but won't run (see below)" + sed 's/^/ /' "$TMP/rust-probe.log" >&2 || true + fi +fi + +if needs rust-docker; then + if ! have "$DOCKER"; then + mark_broken rust-docker "$DOCKER not found" + elif ! "$DOCKER" info >"$TMP/docker-info.log" 2>&1; then + mark_broken rust-docker "$DOCKER daemon not running" + # Pull :latest fresh (the published-package equivalent of the other channels' + # always-latest install), then run it once to confirm the image works. + elif "$DOCKER" pull "$DOCKER_TOKEN_IMAGE" >"$TMP/docker-pull.log" 2>&1 && + "$DOCKER" run --rm --user "$(id -u):$(id -g)" -v "$TMP:$TMP" -w "$TMP" \ + "$DOCKER_TOKEN_IMAGE" auth generate --algorithm HS256 --out "$TMP/docker-probe.jwk" >"$TMP/docker-probe.log" 2>&1; then + echo "rust-docker: $DOCKER_TOKEN_IMAGE (latest, via $DOCKER)" + else + mark_broken rust-docker "$DOCKER pull/run $DOCKER_TOKEN_IMAGE failed (see below)" + cat "$TMP/docker-pull.log" "$TMP/docker-probe.log" 2>/dev/null | sed 's/^/ /' >&2 || true + fi +fi + +if needs js-node || needs js-bun; then + echo "installing js token client (@moq/auth from npm)..." + if ! have bun; then + for v in js-node js-bun; do needs "$v" && mark_broken "$v" "bun not found (needed to install)"; done + elif (cd "$JS_DIR" && bun install) >"$TMP/js-install.log" 2>&1; then + # Resolve the published CLI path under each runtime we actually need. + if needs js-bun; then + if CLI_BUN=$(cd "$JS_DIR" && bun resolve-bin.mjs 2>"$TMP/js-bun-resolve.log"); then :; else + mark_broken js-bun "could not resolve @moq/auth CLI under bun" + sed 's/^/ /' "$TMP/js-bun-resolve.log" >&2 || true + fi + fi + if needs js-node; then + if ! have node; then + mark_broken js-node "node not found" + elif CLI_NODE=$(cd "$JS_DIR" && node resolve-bin.mjs 2>"$TMP/js-node-resolve.log"); then :; else + mark_broken js-node "could not resolve @moq/auth CLI under node" + sed 's/^/ /' "$TMP/js-node-resolve.log" >&2 || true + fi + fi + else + for v in js-node js-bun; do needs "$v" && mark_broken "$v" "bun install failed"; done + sed 's/^/ /' "$TMP/js-install.log" >&2 || true + fi +fi + +# ── matrix ─────────────────────────────────────────────────────────────────── +overall=0 + +# claims_ok : the claim dump must carry every canary value, so a +# verifier that accepts the signature but mangles the payload still fails. +claims_ok() { + local file="$1" algo="$2" + grep -q "$ROOT" "$file" && grep -q "pub-canary-$algo" "$file" && grep -q "sub-canary-$algo" "$file" +} + +echo "=== positive: generator mints, every verifier must ACCEPT ===" +for algo in "${ALGO_LIST[@]}"; do + for g in "${GEN_LIST[@]}"; do + if is_broken "$g"; then + for v in "${VER_LIST[@]}"; do + echo " FAIL $g -> $v ($algo, generator unavailable)" + overall=1 + done + continue + fi + keydir="$TMP/$g-$algo" + token="$keydir/token.jwt" + if ! gen "$g" "$algo" "$keydir" >"$keydir.gen.log" 2>&1; then + for v in "${VER_LIST[@]}"; do echo " FAIL $g -> $v ($algo, key generation failed)"; done + sed 's/^/ /' "$keydir.gen.log" >&2 || true + overall=1 + continue + fi + if ! sign "$g" "$keydir/sign.jwk" "$algo" >"$token" 2>"$keydir.sign.log"; then + for v in "${VER_LIST[@]}"; do echo " FAIL $g -> $v ($algo, signing failed)"; done + sed 's/^/ /' "$keydir.sign.log" >&2 || true + overall=1 + continue + fi + for v in "${VER_LIST[@]}"; do + if is_broken "$v"; then + echo " FAIL $g -> $v ($algo, verifier unavailable)" + overall=1 + continue + fi + out="$TMP/verify-$g-$v-$algo.log" + if verify "$v" "$keydir/verify.jwk" "$token" >"$out" 2>&1 && claims_ok "$out" "$algo"; then + echo " PASS $g -> $v ($algo)" + else + echo " FAIL $g -> $v ($algo)" + sed 's/^/ /' "$out" >&2 || true + overall=1 + fi + done + done +done + +echo "=== negative: every verifier must REJECT a tampered token and the wrong key ===" +# Use the first non-broken generator as the canonical minter for the bad tokens. +canon="" +for g in "${GEN_LIST[@]}"; do is_broken "$g" || { + canon="$g" + break +}; done +if [[ -z "$canon" ]]; then + echo " WARN no working generator; skipping negative pass" +else + for algo in "${ALGO_LIST[@]}"; do + # Mint the canonical key + token here rather than reusing the positive + # pass's artifacts: `canon` is only "not marked broken", and its positive + # gen/sign for this algo may have failed (missing token.jwt), which would + # abort the whole negative pass under `set -e` and hide every later cell. + keydir="$TMP/$canon-neg-$algo" + token="$keydir/token.jwt" + if ! gen "$canon" "$algo" "$keydir" >"$keydir.gen.log" 2>&1; then + echo " FAIL reject(*, $algo): canonical key generation ($canon) failed" + sed 's/^/ /' "$keydir.gen.log" >&2 || true + overall=1 + continue + fi + if ! sign "$canon" "$keydir/sign.jwk" "$algo" >"$token" 2>"$keydir.sign.log"; then + echo " FAIL reject(*, $algo): canonical signing ($canon) failed" + sed 's/^/ /' "$keydir.sign.log" >&2 || true + overall=1 + continue + fi + # Tampered token: flip the FIRST character of the signature segment. + # The first base64url char of a segment carries 6 significant bits, so + # flipping it always changes the decoded signature bytes; appending or + # touching the last char would not (a short HS256 sig has slack bits at + # the tail that some decoders ignore, leaving the signature still valid). + tampered="$keydir/tampered.jwt" + trimmed=$(tr -d '[:space:]' <"$token") + head=${trimmed%.*} # header.payload + sig=${trimmed##*.} # signature + first=${sig:0:1} + flip=$([[ "$first" == "A" ]] && echo B || echo A) + printf '%s.%s%s\n' "$head" "$flip" "${sig:1}" >"$tampered" + for v in "${VER_LIST[@]}"; do + if is_broken "$v"; then + echo " FAIL reject($v, $algo): verifier unavailable" + overall=1 + continue + fi + # (a) tampered token must be refused. + if verify "$v" "$keydir/verify.jwk" "$tampered" >"$TMP/neg-tamper-$v-$algo.log" 2>&1; then + echo " FAIL reject($v, $algo): accepted a tampered token" + overall=1 + else + echo " PASS reject($v, $algo): tampered token refused" + fi + # (b) a valid token verified against an unrelated key must be refused. + wrongdir="$TMP/wrong-$v-$algo" + if ! gen "$v" "$algo" "$wrongdir" >"$wrongdir.gen.log" 2>&1; then + echo " WARN reject($v, $algo): could not mint a wrong key; skipping key check" + continue + fi + if verify "$v" "$wrongdir/verify.jwk" "$token" >"$TMP/neg-wrongkey-$v-$algo.log" 2>&1; then + echo " FAIL reject($v, $algo): accepted a token signed by a different key" + overall=1 + else + echo " PASS reject($v, $algo): wrong key refused" + fi + done + done +fi + +if [[ "$overall" -eq 0 ]]; then + echo "token: all checks passed" +else + echo "token: FAILURES detected" >&2 +fi +exit "$overall" From 086321962d8cd863a175140e40abc3542e157e18 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 21:48:10 -0700 Subject: [PATCH 2/5] test(smoke): run the published-package smoke from the monorepo Adapt the imported moq-dev/smoke harness to test/smoke: its own nested flake and stable toolchain, a lockfile ban scoped to the directory, the Cloudflare client outside the cargo workspace, a path-filtered Smoke workflow, and the relay report published under /relays/ on moq's single gh-pages site. Co-Authored-By: Claude Opus 5.5 --- .editorconfig | 2 +- .github/workflows/alert.yml | 3 +- .github/workflows/interop.yml | 2 +- .github/workflows/nightly.yml | 7 +- .../.github => .github}/workflows/smoke.yml | 139 ++++++++++++------ quest/m1/smoke-moxygen.md | 6 +- quest/m2/release/catalog-readers.md | 2 +- test/interop/README.md | 4 +- test/interop/interop.sh | 4 +- test/justfile | 12 +- test/smoke/.editorconfig | 35 ----- test/smoke/.github/dependabot.yml | 12 -- test/smoke/AGENTS.md | 31 +++- test/smoke/CLAUDE.md | 70 --------- test/smoke/PROMPTING.md | 45 ------ test/smoke/README.md | 13 +- test/smoke/clients/cloudflare/Cargo.toml | 4 + test/smoke/clients/js-native/package.json | 24 +-- test/smoke/clients/js-native/subscribe.ts | 5 +- test/smoke/clients/js/diagnostics.ts | 10 +- test/smoke/clients/js/driver.ts | 11 +- test/smoke/clients/js/index.html | 2 +- test/smoke/clients/js/jsdelivr/index.html | 2 +- test/smoke/clients/js/package.json | 24 +-- test/smoke/flake.nix | 12 +- test/smoke/freshness.sh | 5 +- test/smoke/justfile | 32 +++- test/smoke/report/index.html | 11 +- test/smoke/report/publish.sh | 28 ++++ test/smoke/rust-toolchain.toml | 4 + test/tsconfig.json | 2 +- 31 files changed, 287 insertions(+), 276 deletions(-) rename {test/smoke/.github => .github}/workflows/smoke.yml (82%) delete mode 100644 test/smoke/.editorconfig delete mode 100644 test/smoke/.github/dependabot.yml mode change 120000 => 100644 test/smoke/AGENTS.md delete mode 100644 test/smoke/CLAUDE.md delete mode 100644 test/smoke/PROMPTING.md create mode 100755 test/smoke/report/publish.sh create mode 100644 test/smoke/rust-toolchain.toml diff --git a/.editorconfig b/.editorconfig index f982778b08..3d44dce18a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -31,7 +31,7 @@ indent_style = space indent_size = 4 switch_case_indent = true -[demo/throttle/enable] +[{demo/throttle/enable,test/smoke/clients/docker/*}] indent_style = space indent_size = 4 switch_case_indent = true diff --git a/.github/workflows/alert.yml b/.github/workflows/alert.yml index acf165f40d..a562187dbd 100644 --- a/.github/workflows/alert.yml +++ b/.github/workflows/alert.yml @@ -39,6 +39,7 @@ on: - Release Swift Lib - release-winget - rpm-repo + - Smoke - TS - Video types: @@ -64,7 +65,7 @@ jobs: github.event.workflow_run.event != 'pull_request_target' && github.event.workflow_run.event != 'merge_group' && (github.event.workflow_run.event == 'schedule' || - !contains(fromJSON('["Interop", "Nightly", "TS", "Video"]'), github.event.workflow_run.name)) + !contains(fromJSON('["Interop", "Nightly", "Smoke", "TS", "Video"]'), github.event.workflow_run.name)) runs-on: ubuntu-latest steps: diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml index 0e08767d93..7a6c56ba09 100644 --- a/.github/workflows/interop.yml +++ b/.github/workflows/interop.yml @@ -1,7 +1,7 @@ name: Interop # Cross-language interop test built from THIS checkout (not the published -# packages, unlike the moq-dev/smoke repo). Stands up a relay and runs the +# packages, unlike smoke.yml). Stands up a relay and runs the # publish x subscribe matrix across rust / python / go / browser / native-js / c, # then browser-to-browser media QA. TS validation has its own workflow. on: diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 39f0f083ca..ac9ae04b46 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -202,7 +202,10 @@ jobs: path: ${{ matrix.artifact-path }} retention-days: 45 - # One writer owns gh-pages, including future Criterion regression history. + # gh-pages is the one Pages site, including future Criterion regression + # history. smoke.yml also commits its relay report there under relays/; each + # writer touches only its own directory, deploys the whole branch, and holds + # the benchmark-pages group so they never race. # Explicit Pages deployment works with GITHUB_TOKEN; a branch push alone does not. benchmark-site: name: Benchmark site @@ -223,7 +226,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - # Only this main-branch publisher pushes the shared history branch. + # Only main-branch publishers push the shared history branch. persist-credentials: true - uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main with: diff --git a/test/smoke/.github/workflows/smoke.yml b/.github/workflows/smoke.yml similarity index 82% rename from test/smoke/.github/workflows/smoke.yml rename to .github/workflows/smoke.yml index 1bf3a1deab..13165fe6fc 100644 --- a/test/smoke/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -1,5 +1,8 @@ name: Smoke +# Installs each moq package from its PUBLIC registry and checks that the +# published clients interoperate, catching packaging breakage in a release +# (test/smoke). The in-tree companion, interop.yml, builds from this checkout. on: # Manual on-demand runs. workflow_dispatch: @@ -7,16 +10,22 @@ on: history_url: description: Seed the relay report history from this URL instead of the deployed site required: false - # On every PR: the harness itself (smoke.sh / token.sh / clients) is what - # changes here, so run the matrix against the published packages to catch a - # broken cell before it merges. Published-package regressions still surface - # nightly; this guards changes to the test. + # On PRs that touch the harness itself (smoke.sh / token.sh / clients), run + # the matrix against the published packages to catch a broken cell before it + # merges. Published-package regressions still surface nightly; this guards + # changes to the test. Other PRs don't change what is published. pull_request: - # Merges: the build caches below (mbx, cargo binaries) only save from the - # default branch's push or dispatch runs, never from the nightly schedule, so - # this is what keeps them warm for pull requests. + paths: + - "test/smoke/**" + - ".github/workflows/smoke.yml" + # Merges of the same: the build caches below (mbx, cargo binaries) only save + # from the default branch's push or dispatch runs, never from the nightly + # schedule, so this is what keeps them warm for pull requests. push: branches: [main] + paths: + - "test/smoke/**" + - ".github/workflows/smoke.yml" # Nightly: catches breakage from a freshly published wheel / formula / .deb / # npm / go module that a developer wouldn't notice until a user hit it. schedule: @@ -26,6 +35,13 @@ concurrency: group: smoke-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read + +defaults: + run: + working-directory: test/smoke + jobs: smoke: name: ${{ matrix.os }} / ${{ matrix.channel }} @@ -43,7 +59,7 @@ jobs: # the limit to 1000 req/hr per token. env: GITHUB_TOKEN: ${{ github.token }} - SMOKE_BROWSER_ARTIFACTS: ${{ github.workspace }}/clients/js/artifacts + SMOKE_BROWSER_ARTIFACTS: ${{ github.workspace }}/test/smoke/clients/js/artifacts strategy: fail-fast: false @@ -75,10 +91,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - uses: ./.github/actions/just + # ── client toolchains ────────────────────────────────────────────── - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: @@ -201,7 +219,7 @@ jobs: echo "MOQ_BIN=$cli/bin/moq" } >> "$GITHUB_ENV" - # moq-dev/moq's cachix.yml only pushes release tags, so each new HEAD + # cachix.yml only pushes release tags, so each new HEAD # compiles here for ~20 minutes. Push what we just built, so later runs # (and anyone doing `nix run github:moq-dev/moq#moq`) substitute it until # moq's main moves. Only the two outputs' runtime closures, never the @@ -235,16 +253,12 @@ jobs: # ── browser deps (Chromium runtime libs on Linux) ────────────────── - name: Playwright system deps (Linux) if: runner.os == 'Linux' - run: | - cd clients/js - bun install - bun test diagnostics.test.ts - bunx playwright install-deps chromium + run: just playwright-deps # Fail loudly if we've drifted off "latest": a committed lock file, a moq # package that stopped being requested at latest, or a stale playwright pin. - name: Freshness - run: ./freshness.sh + run: just freshness # ── run the full interop matrix ──────────────────────────────────── # Publishers: rust, python, go, and the three browser variants publish today; @@ -263,22 +277,22 @@ jobs: # exercise the moq.pc and CMake-package consumer paths, not just -I/-L. subs="rust,python,go,kotlin,c,c-pkgconfig,c-cmake,gst,$browser,$native" if [ "$RUNNER_OS" = "macOS" ]; then subs="$subs,swift"; fi - ./smoke.sh \ + just smoke \ --publishers "rust,python,go,$browser" \ --subscribers "$subs" \ --timeout 30 - name: Upload browser failure diagnostics - if: ${{ !cancelled() && hashFiles('clients/js/artifacts/*') != '' }} + if: ${{ !cancelled() && hashFiles('test/smoke/clients/js/artifacts/*') != '' }} uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: browser-failures-${{ matrix.os }}-${{ matrix.channel }} - path: clients/js/artifacts/ + path: test/smoke/clients/js/artifacts/ if-no-files-found: error # Quick proof the harness can actually report failure. - name: Negative control - run: ./smoke.sh --negative --subscribers rust,python + run: just negative --subscribers rust,python # ── token interop ────────────────────────────────────────────────── # Independent of the media matrix: prove the published token tooling @@ -303,22 +317,26 @@ jobs: # it's Linux-only. (Docker is preinstalled on the Linux runners.) if [ "$RUNNER_OS" = "Linux" ]; then impls="$impls,rust-docker"; fi fi - ./token.sh --generators "$impls" --verifiers "$impls" + just token --generators "$impls" --verifiers "$impls" relays: name: External relay interop runs-on: ubuntu-latest permissions: contents: read + outputs: + report: ${{ steps.report.outcome }} # ~20 public endpoints x 2 publishers, each bounded by --timeout. timeout-minutes: 45 steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - uses: ./.github/actions/just + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - name: System deps @@ -338,10 +356,7 @@ jobs: sudo apt-get install -y moq-cli - name: Playwright system deps - run: | - cd clients/js - bun install - bunx playwright install-deps chromium + run: just playwright-deps # The relay list comes live from the moq-interop-runner registry. Only # moq-dev's own relay gates the job; every other endpoint lands in the job @@ -350,38 +365,74 @@ jobs: # only on WebTransport. The polyfill has no raw-QUIC (moqt://) mode, so # neither is listed on native QUIC or on the WebSocket column. - name: External relay interop - run: ./relays.sh --publishers rust,js-web --subscribers rust,js-web,js-bun --timeout 30 --json results.json --logs logs + run: just relays --publishers rust,js-web --subscribers rust,js-web,js-bun --timeout 30 --json results.json --logs logs - # The HTML report (report/index.html) on GitHub Pages. Built even when a - # required relay failed, since that's exactly the run worth looking at. - # History carries forward from the currently deployed site. A failed build - # uploads nothing, so a broken history never replaces the deployed one. + # The HTML report (report/index.html) at /relays/ on moq's GitHub Pages + # site. Built even when a required relay failed, since that's exactly the + # run worth looking at. History carries forward from the currently + # deployed site. A failed build uploads nothing, so a broken history never + # replaces the deployed one. - name: Build report id: report - if: ${{ !cancelled() && hashFiles('results.json') != '' }} + if: ${{ !cancelled() && hashFiles('test/smoke/results.json') != '' }} env: HISTORY_URL: ${{ inputs.history_url }} run: | owner="${GITHUB_REPOSITORY%%/*}" - report/build.sh results.json logs _site "${HISTORY_URL:-https://${owner}.github.io/${GITHUB_REPOSITORY#*/}/history.json}" + just report results.json logs _site "${HISTORY_URL:-https://${owner}.github.io/${GITHUB_REPOSITORY#*/}/relays/history.json}" - name: Upload report if: ${{ !cancelled() && steps.report.outcome == 'success' }} - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: relay-report + path: test/smoke/_site/ + if-no-files-found: error - # Publish the relay report from main only (nightly or on demand), never from a PR. + # Publish the relay report from main only (nightly or on demand), never from a + # PR. moq has one Pages site, so this commits under relays/ on the gh-pages + # branch the nightly benchmark charts also write, then deploys that whole + # branch. Sharing nightly.yml's concurrency group keeps the two writers from + # racing the branch or each other's deploy. relays-report: name: Publish relay report needs: relays - if: ${{ !cancelled() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' }} + if: ${{ !cancelled() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && needs.relays.outputs.report == 'success' }} runs-on: ubuntu-latest + timeout-minutes: 15 + concurrency: + group: benchmark-pages + cancel-in-progress: false permissions: + contents: write pages: write id-token: write environment: name: github-pages url: ${{ steps.deploy.outputs.page_url }} steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # This job pushes the shared gh-pages branch. + persist-credentials: true + + - uses: ./.github/actions/just + + - name: Download report + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8 + with: + name: relay-report + path: ${{ runner.temp }}/relay-report + + - name: Commit report + run: just publish "$RUNNER_TEMP/relay-report" "$RUNNER_TEMP/pages" + + - name: Upload site + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: ${{ runner.temp }}/pages + - name: Deploy to GitHub Pages id: deploy uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 @@ -396,12 +447,14 @@ jobs: steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - uses: ./.github/actions/just + # Both relays build from Git HEAD, so only the tip changes day to day. mbx - # (as in moq-dev/moq) caches each compilation; `objects` mode because the + # (as in .github/actions/rust-cache) caches each compilation; `objects` mode because the # checkouts land in fresh temp dirs. The shim routes the scripts' plain # `cargo build` through it; without mise, setup only reports where the # shim lives, so put that directory first on PATH (as moq's dev shell @@ -420,7 +473,7 @@ jobs: dirname "$shim" >> "$GITHUB_PATH" - name: Cloudflare interoperability - run: ./cloudflare.sh + run: just cloudflare moxygen: name: Moxygen interoperability @@ -434,12 +487,14 @@ jobs: steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - uses: ./.github/actions/just + # Both relays build from Git HEAD, so only the tip changes day to day. mbx - # (as in moq-dev/moq) caches each compilation; `objects` mode because the + # (as in .github/actions/rust-cache) caches each compilation; `objects` mode because the # checkouts land in fresh temp dirs. The shim routes the scripts' plain # `cargo build` through it; without mise, setup only reports where the # shim lives, so put that directory first on PATH (as moq's dev shell @@ -462,4 +517,4 @@ jobs: # announce-subscribe case. Keep the full diagnostic visible without # failing the job; remove this once that protocol mismatch is fixed. continue-on-error: true - run: ./moxygen.sh + run: just moxygen diff --git a/quest/m1/smoke-moxygen.md b/quest/m1/smoke-moxygen.md index 3497134f0b..2c8a3fbe31 100644 --- a/quest/m1/smoke-moxygen.md +++ b/quest/m1/smoke-moxygen.md @@ -2,12 +2,12 @@ ## Goal -Waiting on upstream Moxygen. moq-dev/smoke's Moxygen lane passes all six +Waiting on upstream Moxygen. test/smoke's Moxygen lane passes all six cases through moq-relay on WebTransport and raw QUIC at draft-16, and its `continue-on-error` exception is removed. -Check by running `just moxygen` in moq-dev/smoke, or by reading the latest -Moxygen job there. Delete this quest once the lane passes and the exception +Check by running `just test smoke moxygen`, or by reading the latest +Moxygen job in the Smoke workflow. Delete this quest once the lane passes and the exception is gone. ## Plan diff --git a/quest/m2/release/catalog-readers.md b/quest/m2/release/catalog-readers.md index 8c53e8c448..3dd0973a6a 100644 --- a/quest/m2/release/catalog-readers.md +++ b/quest/m2/release/catalog-readers.md @@ -3,7 +3,7 @@ ## Goal The latest published Go and JavaScript clients read live media from the -current Rust publisher in moq-dev/smoke's Nix channel. Incompatible optional +current Rust publisher in test/smoke's Nix channel. Incompatible optional archive metadata does not make an otherwise playable catalog unreadable. ## Plan diff --git a/test/interop/README.md b/test/interop/README.md index 6084bcc5e3..fa61d47190 100644 --- a/test/interop/README.md +++ b/test/interop/README.md @@ -3,8 +3,8 @@ Cross-language interop test that builds every client from **this checkout** and runs them against each other. -This is the in-tree companion to [moq-dev/smoke](https://github.com/moq-dev/smoke). -That repo installs each client from its public package registry (crates.io, PyPI, +This is the in-tree companion to [`test/smoke`](../smoke). +That suite installs each client from its public package registry (crates.io, PyPI, npm, ...) to catch *packaging* breakage in a release. This one builds each client from the workspace source (`cargo`, `bun`, `uv`, `cc`) to catch *interop* regressions before anything is published. No apt/brew/npm/PyPI, and no diff --git a/test/interop/interop.sh b/test/interop/interop.sh index efd67d555f..c691d545df 100755 --- a/test/interop/interop.sh +++ b/test/interop/interop.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Cross-language media interop test against THIS checkout. # -# Unlike the standalone moq-dev/smoke repo (which installs each client from its +# Unlike test/smoke (which installs each client from its # public registry to catch packaging breakage), this builds every client from # the workspace source. It proves the code in the tree interoperates across # implementations before anything is published: a relay built from rs/moq-relay, @@ -69,7 +69,7 @@ TAIL_ONLY=0 PROFILE="${INTEROP_PROFILE:-debug}" # Binaries under test. Built from source below unless overridden to point at a -# prebuilt (mirrors the standalone moq-dev/smoke repo's RELAY_BIN/MOQ_BIN escape hatch). +# prebuilt (mirrors test/smoke's RELAY_BIN/MOQ_BIN escape hatch). RELAY="${RELAY_BIN:-}" MOQ="${MOQ_BIN:-}" diff --git a/test/justfile b/test/justfile index 08e1719746..81d5a5d64f 100644 --- a/test/justfile +++ b/test/justfile @@ -4,7 +4,7 @@ # in the root `just check`. # # Two cross-language interop tests exist: -# - https://github.com/moq-dev/smoke installs each client from its PUBLIC +# - `just test smoke` (smoke/) installs each client from its PUBLIC # package registry to catch packaging breakage in a release. # - `just test interop` (interop/, below) builds every client from THIS checkout # to catch interop regressions before anything is published. @@ -51,6 +51,16 @@ interop *args: wire-compat *args: ./interop/compat.sh "$@" +# Installs each client from its public registry, inside test/smoke's own Nix +# shell rather than this repo's toolchain. The first argument picks a smoke +# recipe and the rest pass through, e.g. `just test smoke full` or `just test +# smoke relays --only moq-dev-rs`. With none, the Rust-only matrix. See smoke/README.md. + +# Published-package interop smoke. +[positional-arguments] +smoke *args: + nix develop ./smoke --command just --justfile smoke/justfile "$@" + # Paint a short-side transcode rung from a coded portrait through moq-watch. portrait: ./portrait/run.sh diff --git a/test/smoke/.editorconfig b/test/smoke/.editorconfig deleted file mode 100644 index 359f71cb37..0000000000 --- a/test/smoke/.editorconfig +++ /dev/null @@ -1,35 +0,0 @@ -root = true - -[*] -charset = utf-8 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true - -# shfmt reads these natively (indent + switch-case indent), so `just check` -# drives shfmt with no CLI flags and the config lives here, matching moq. -[*.sh] -indent_style = space -indent_size = 4 -switch_case_indent = true - -# The Docker channel wrappers are bash but carry no .sh extension; shfmt finds -# them by shebang, so give them the same style as the *.sh scripts. -[clients/docker/*] -indent_style = space -indent_size = 4 -switch_case_indent = true - -# YAML doesn't support hard tabs. -[*.{yml,yaml}] -indent_style = space -indent_size = 2 - -# Nix defaults to spaces. -[*.nix] -indent_style = space -indent_size = 2 - -# Skip vendored / build output trees when shfmt walks the repo. -[{node_modules,target,dist,.venv,.direnv}/**] -ignore = true diff --git a/test/smoke/.github/dependabot.yml b/test/smoke/.github/dependabot.yml deleted file mode 100644 index 0b33cc01c5..0000000000 --- a/test/smoke/.github/dependabot.yml +++ /dev/null @@ -1,12 +0,0 @@ -# Keep the SHA-pinned GitHub Actions in .github/workflows current. Dependabot -# bumps each `uses:` pin (and its `# vX` comment) as new releases land, so the -# Node-24 migration and future runtime/security bumps land automatically. -# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file -version: 2 -updates: - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - cooldown: - default-days: 7 diff --git a/test/smoke/AGENTS.md b/test/smoke/AGENTS.md deleted file mode 120000 index 681311eb9c..0000000000 --- a/test/smoke/AGENTS.md +++ /dev/null @@ -1 +0,0 @@ -CLAUDE.md \ No newline at end of file diff --git a/test/smoke/AGENTS.md b/test/smoke/AGENTS.md new file mode 100644 index 0000000000..c1149ddf78 --- /dev/null +++ b/test/smoke/AGENTS.md @@ -0,0 +1,30 @@ +Cross-language interop smoke for the published MoQ packages. +`test/interop` builds every client from this checkout; this suite installs the public artifacts instead and checks that they talk. + +# Required + +- Install the published artifact each cell claims to test. +- MoQ package versions stay at latest: `@moq/*` on the `latest` tag, PyPI `moq-rs`, `moq.dev/moq`, `moqdev/*` images, and GitHub release tarballs. `cloudflare.sh` and `moxygen.sh` build from unpinned Git HEAD. +- No package lock files under `test/smoke/` (`go.sum`, `bun.lock`, `Cargo.lock`, `uv.lock`, and the rest of the list in `.gitignore`). `flake.lock` pins the dev toolchain. The moq flake is not a flake input; `just nix-channel` builds `github:moq-dev/moq#moq-relay` and `#moq` with `--refresh`. +- npm `playwright` is the one version pin, and it matches the flake's `PLAYWRIGHT_VERSION`. Bump the toolchain with `nix flake update` and the pin together. +- `moq-relay` and `moq` come from a channel (PATH, `RELAY_BIN`/`MOQ_BIN`, `just nix-channel`, or `clients/docker`). The harness leaves installing them to that channel. +- A red cell is the result. `mark_broken` drops that package's cells and lets the rest of the matrix run. Retries belong on transient registry and release downloads. A client that failed to exchange media gets no retry, no longer timeout, and no skip. +- A published-package bug is fixed at its source, then goes green here on the next release. +- Hang media (`smoke.sh`), Cloudflare, and moxygen stay on separate lanes. +- In `relays.sh`, only `--required` relays fail the run. The default required relay is moq-dev's own. +- Wire new lanes into `.github/workflows/smoke.yml`. It runs on pull requests that touch `test/smoke/`, and nightly. +- New dependencies use the newest stable version, except the pins `freshness.sh` enforces. + +# Development + +This directory has its own flake, separate from moq's, so its toolchain floats on its own schedule. +direnv loads it here. Otherwise: `nix develop ./test/smoke --command ...`, or `just test smoke ` from the root. +The shell carries client toolchains. The moq binaries under test come from a channel. + +```bash +just check # shfmt, shellcheck, actionlint, freshness +just smoke # rust-only matrix; flags pass through to smoke.sh +just full # cross-language matrix +``` + +`just check` does not run the matrix. Run the lane you changed (`just smoke`, `just token`, `just cloudflare`, `just moxygen`, `just relays`). diff --git a/test/smoke/CLAUDE.md b/test/smoke/CLAUDE.md deleted file mode 100644 index fb4c2b61f0..0000000000 --- a/test/smoke/CLAUDE.md +++ /dev/null @@ -1,70 +0,0 @@ -Cross-language interop smoke for the published MoQ packages. -The implementations live in [moq-dev/moq](https://github.com/moq-dev/moq). This repo installs the public artifacts and checks that they talk. - -# Context - -- Read `PROMPTING.md` before instructing other agents via context, memory, plans, or reviews. -- `README.md` describes the lanes. Keep this file to rules. - -# Required - -- Pull the latest origin changes before working. -- Dig into the root cause and fix it at the source. Never work around a fixable bug with a retry, sleep, or timeout. -- Fail loud and early. Error on unsupported or malformed input rather than warn and continue: supported or refused. -- Reproduce bugs before fixing them. Land each fix with a regression test that fails without it, when one is easy. -- Keep the PR focused. No unrelated refactors, formatting churn, or drive-by changes; split when in doubt. -- Refactor aggressively for long-term maintainability, but re-evaluate the direction as you learn. -- Propose a course change, even suggest abandoning a PR, rather than finish a half-solution. -- When a decision is the maintainer's (scope, naming, which lane), ask with 2-3 options and a recommendation. -- Wire new lanes into `.github/workflows/smoke.yml`. CI runs the matrix on pull requests and nightly. -- Never edit a `CLAUDE.md`, `CONTRIBUTING.md`, or skill without being prompted, and read `PROMPTING.md` first. -- No em dashes. -- Match the existing conventions, patterns, and naming when possible. -- Fix any outdated docs and comments inline; don't add a separate PR for it. -- Add the AI marker `(Written by )` to any posts on GitHub, excluding commit messages that contain `Co-Authored-By:` trailers. -- Any AI comments may be challenged, and not confused with human maintainers. -- Prompt the user to decide when unsure, but always provide recommendations. -- Don't open a PR, issue, or review comment outside the `moq-dev` and `kixelated` orgs without approval. - -# What this repo tests - -- Install the published artifact each cell claims to test. -- MoQ package versions stay at latest: `@moq/*` on the `latest` tag, PyPI `moq-rs`, `moq.dev/moq`, `moqdev/*` images, and GitHub release tarballs. `cloudflare.sh` and `moxygen.sh` build from unpinned Git HEAD. -- No package lock files (`go.sum`, `bun.lock`, `Cargo.lock`, `uv.lock`, and the rest of the list in `.gitignore`). `flake.lock` pins the dev toolchain. The moq flake is not a flake input; `just nix-channel` builds `github:moq-dev/moq#moq-relay` and `#moq` with `--refresh`. -- npm `playwright` is the one version pin, and it matches the flake's `PLAYWRIGHT_VERSION`. Bump the toolchain with `nix flake update` and the pin together. -- `moq-relay` and `moq` come from a channel (PATH, `RELAY_BIN`/`MOQ_BIN`, `just nix-channel`, or `clients/docker`). The harness leaves installing them to that channel. -- A red cell is the result. `mark_broken` drops that package's cells and lets the rest of the matrix run. Retries belong on transient registry and release downloads. A client that failed to exchange media gets no retry, no longer timeout, and no skip. -- A published-package bug is fixed in its own repo, then goes green here on the next release. -- Hang media (`smoke.sh`), Cloudflare, and moxygen stay on separate lanes. -- In `relays.sh`, only `--required` relays fail the run. The default required relay is moq-dev's own. - -# Guidelines - -- Prefer a maintained tool over hand-rolling non-core functionality. -- New dependencies use the newest stable version, except the pins `freshness.sh` enforces. -- Comments explain the non-obvious why, and never the history. -- Inline simple helpers. -- Question whether functionality is needed at all before adding it. -- Deleting code is better than adding code. -- Prefer the simple solution. -- Say it once, in the fewest words that hold up. -- Suggest follow-up sessions when finished, and use an interactive prompt. -- Try to do stuff asynchronously. ex. ask about follow-ups while a lane runs. -- Stop when progress stalls. -- If the core problem is addressed, ship it. - -# Development - -PRs target `main`. -Before starting, `git fetch origin` and set the upstream to the base branch. - -Use the Nix dev shell so tooling matches local lint. direnv loads it. Otherwise: `nix develop --command ...`. -The shell carries client toolchains. The moq binaries under test come from a channel. - -```bash -just check # shfmt, shellcheck, actionlint, freshness -just smoke # rust-only matrix; flags pass through to smoke.sh -just full # cross-language matrix -``` - -`just check` does not run the matrix. Run the lane you changed (`just smoke`, `just token`, `just cloudflare`, `just moxygen`, `just relays`). diff --git a/test/smoke/PROMPTING.md b/test/smoke/PROMPTING.md deleted file mode 100644 index 5d886bc253..0000000000 --- a/test/smoke/PROMPTING.md +++ /dev/null @@ -1,45 +0,0 @@ -Read this before indirectly instructing agents. - -LLMs are not trained on how to write prompts. -They're trained on the outputs, not the inputs, and thus generate slop when chained. -So when you're drafting instructions for a future agent, please keep these rules in mind. - -# General - -**Do not over-prescribe**. -The facts will evolve over time. - -Focus on articulating the goal and/or root problems. -Keep things flexible so an agent can adjust as it digs deeper into the possible problems or solutions. - -You can (and should) include recommendations. -But focus on best-practices and things to look out for, not specific lines of code. -Mocking up an API makes a lot of sense but again, it should be flexible enough to adjust with the implementation. - -# Context - -This includes editing any `CLAUDE.md`, `AGENTS.md`, `CONTRIBUTING.md`, etc. - -These files need to be light because they're loaded into every prompt. -More specific context (ex. per language or project) should exist in optional files/folders. - -These base prompts are meant to fix regular agent mistakes. -If there is a history of bad practices, or sub-optimal behavior, update `CLAUDE.md` to steer away from them. -If you think a slight tweak to the base prompt would help, propose it. - -Focus on best practices and conventions. -Don't document the repository; the code and documentation can handle that. - -Before changing agent-instruction files, check the relevant official Claude/Codex guidance: - -- -- -- -- -- -- - -# Skills - -Skills are meant to avoid repeated duplicate prompts. -If the usage of a skill is unclear, offer to update to skill to reduce the ambiguity. diff --git a/test/smoke/README.md b/test/smoke/README.md index 3f9276164e..a24b77b2cf 100644 --- a/test/smoke/README.md +++ b/test/smoke/README.md @@ -1,24 +1,24 @@ # moq smoke -### [Latest smoke results](https://moq-dev.github.io/smoke/) +### [Latest smoke results](https://moq-dev.github.io/moq/relays/) Nightly publish/subscribe results for the published clients through public MoQ relays. One row per relay, sorted by name ignoring case, with a passing pair count per transport (WebTransport, QUIC, WebSocket). An empty cell means that relay has no endpoint there. Hover a count for the failure, click it for the logs. The page keeps the last 60 runs and updates from `main`. -This repo installs the public [moq](https://github.com/moq-dev/moq) packages and checks that they exchange media. A missing wheel, a stale Homebrew formula, a broken `.deb`, or an export lost in packaging shows up as a red cell. A cell passes when the subscriber receives a non-empty frame. +This suite installs the published moq packages and checks that they exchange media. [`test/interop`](../interop) is its in-tree companion: it builds each client from this checkout instead, to catch interop regressions before anything is published. A missing wheel, a stale Homebrew formula, a broken `.deb`, or an export lost in packaging shows up as a red cell. A cell passes when the subscriber receives a non-empty frame. ## Lanes | Lane | Run | Coverage | | --- | --- | --- | | Packages | `just smoke`, `just full` | Published clients through a local `moq-relay`. `smoke` is Rust only. `full` is the cross-language matrix. | -| Public relays | `just relays` | Rust publisher and subscriber through every relay in the [moq-interop-runner](https://github.com/englishm/moq-interop-runner) registry. CI publishes with Rust and the browser (`js-web`) and subscribes with Rust, `js-web`, and `js-bun`. On those relays `js-web` and `js-bun` run only on WebTransport (`@moq/web-transport` has no raw QUIC mode); a client that does not belong is omitted. WebSocket is its own column for the relays in `WEBSOCKET_KEYS` (`moq-dev-rs`): each of their `http(s)` endpoints also runs as `ws(s)`, and every other relay keeps that fallback off. A relay that refuses anonymous root broadcasts is dialed on the path `publish_path` names (`stitcher-moq` publishes under `/moq-test`). That run is what the [results page](https://moq-dev.github.io/smoke/) shows. Only `--required` relays fail the run; the default is moq-dev's `cdn.moq.dev`, including its WebSocket endpoint. | +| Public relays | `just relays` | Rust publisher and subscriber through every relay in the [moq-interop-runner](https://github.com/englishm/moq-interop-runner) registry. CI publishes with Rust and the browser (`js-web`) and subscribes with Rust, `js-web`, and `js-bun`. On those relays `js-web` and `js-bun` run only on WebTransport (`@moq/web-transport` has no raw QUIC mode); a client that does not belong is omitted. WebSocket is its own column for the relays in `WEBSOCKET_KEYS` (`moq-dev-rs`): each of their `http(s)` endpoints also runs as `ws(s)`, and every other relay keeps that fallback off. A relay that refuses anonymous root broadcasts is dialed on the path `publish_path` names (`stitcher-moq` publishes under `/moq-test`). That run is what the [results page](https://moq-dev.github.io/moq/relays/) shows. Only `--required` relays fail the run; the default is moq-dev's `cdn.moq.dev`, including its WebSocket endpoint. | | Cloudflare | `just cloudflare` | Relays built from the default branches of [cloudflare/moq-rs](https://github.com/cloudflare/moq-rs) and moq-dev/moq, driven by a Cloudflare client over WebTransport and raw QUIC. | | moxygen | `just moxygen` | Meta's [`moxygen`](https://github.com/facebookexperimental/moxygen) interop client through the latest moq-dev relay. Needs Linux Docker. | | Tokens | `just token`, `just token-full` | `moq auth` and npm [`@moq/auth`](https://www.npmjs.com/package/@moq/auth) mint and verify each other's JWTs, and each verifier rejects a tampered token. | Hang media, Cloudflare, and moxygen each have their own lane. -CI runs the matrix on pull requests and nightly: [`.github/workflows/smoke.yml`](.github/workflows/smoke.yml). +CI runs the matrix nightly and on pull requests that touch `test/smoke/`: [`.github/workflows/smoke.yml`](../../.github/workflows/smoke.yml). ## Clients @@ -38,9 +38,10 @@ CI runs the matrix on pull requests and nightly: [`.github/workflows/smoke.yml`] ## Run -The Nix flake carries the client toolchains (direnv loads `.envrc`). Put `moq-relay` and `moq` on `PATH` (cargo, Homebrew, or apt) before the lanes below. +This directory's own Nix flake carries the client toolchains, separate from moq's (direnv loads `.envrc` here). Put `moq-relay` and `moq` on `PATH` (cargo, Homebrew, or apt) before the lanes below. ```bash +cd test/smoke nix develop cargo install moq-relay moq-cli # or brew / apt just full @@ -57,7 +58,7 @@ just check # shfmt, shellcheck, actionlint, freshness ## Versions -Packages resolve to latest on every run: `@moq/*` on the `latest` tag, PyPI `moq-rs`, `moq.dev/moq`, `moqdev/*` images, and GitHub release tarballs. `cloudflare.sh` and `moxygen.sh` build from unpinned Git HEAD. The repo commits no package lock files. `flake.lock` pins the dev toolchain. +Packages resolve to latest on every run: `@moq/*` on the `latest` tag, PyPI `moq-rs`, `moq.dev/moq`, `moqdev/*` images, and GitHub release tarballs. `cloudflare.sh` and `moxygen.sh` build from unpinned Git HEAD. `test/smoke/` commits no package lock files. `flake.lock` pins the dev toolchain. npm `playwright` is pinned to the flake's `PLAYWRIGHT_VERSION`. `just freshness` fails when that pin drifts or a lock file is committed. Bump the toolchain with `nix flake update` and the pin together. diff --git a/test/smoke/clients/cloudflare/Cargo.toml b/test/smoke/clients/cloudflare/Cargo.toml index e74cbc8a45..46c995a321 100644 --- a/test/smoke/clients/cloudflare/Cargo.toml +++ b/test/smoke/clients/cloudflare/Cargo.toml @@ -4,6 +4,10 @@ version = "0.0.0" edition = "2021" publish = false +# Its own workspace: moq's root workspace builds from source and locks, while +# this client floats on cloudflare/moq-rs Git HEAD. +[workspace] + [dependencies] anyhow = "1" bytes = "1" diff --git a/test/smoke/clients/js-native/package.json b/test/smoke/clients/js-native/package.json index 1be66c4c85..2bde42b4fd 100644 --- a/test/smoke/clients/js-native/package.json +++ b/test/smoke/clients/js-native/package.json @@ -1,14 +1,14 @@ { - "name": "@moq/smoke-native", - "private": true, - "type": "module", - "dependencies": { - "@moq/hang": "latest", - "@moq/net": "latest", - "@moq/web-transport": "latest", - "zod": "^4.4.3" - }, - "devDependencies": { - "tsx": "^4.23.1" - } + "name": "@moq/smoke-native", + "private": true, + "type": "module", + "dependencies": { + "@moq/hang": "latest", + "@moq/net": "latest", + "@moq/web-transport": "latest", + "zod": "^4.4.3" + }, + "devDependencies": { + "tsx": "^4.23.1" + } } diff --git a/test/smoke/clients/js-native/subscribe.ts b/test/smoke/clients/js-native/subscribe.ts index 9f36d63f66..d95b6b970f 100644 --- a/test/smoke/clients/js-native/subscribe.ts +++ b/test/smoke/clients/js-native/subscribe.ts @@ -137,7 +137,10 @@ try { // AggregateError from Promise.any has an empty message; the cause is in .errors. const message = err instanceof AggregateError - ? err.errors.map((e) => (e instanceof Error ? e.message : String(e))).filter(Boolean).join("; ") + ? err.errors + .map((e) => (e instanceof Error ? e.message : String(e))) + .filter(Boolean) + .join("; ") : err instanceof Error ? err.message : String(err); diff --git a/test/smoke/clients/js/diagnostics.ts b/test/smoke/clients/js/diagnostics.ts index 6eca79dfef..4bd4e2438e 100644 --- a/test/smoke/clients/js/diagnostics.ts +++ b/test/smoke/clients/js/diagnostics.ts @@ -58,11 +58,17 @@ export class Diagnostics { } async write(dir: string, name: string, error?: unknown): Promise { - this.#data.error = error instanceof Error ? (error.stack ?? error.message) : error === undefined ? undefined : String(error); + this.#data.error = + error instanceof Error ? (error.stack ?? error.message) : error === undefined ? undefined : String(error); await Promise.allSettled(this.#pending); for (const exchange of this.#data.requests) { const response = exchange.response; - if (exchange.url.startsWith(CDN) && response && response.body === undefined && response.bodyError === undefined) { + if ( + exchange.url.startsWith(CDN) && + response && + response.body === undefined && + response.bodyError === undefined + ) { response.bodyError = "body incomplete when the cell ended"; } } diff --git a/test/smoke/clients/js/driver.ts b/test/smoke/clients/js/driver.ts index 9256815762..aa616a417f 100644 --- a/test/smoke/clients/js/driver.ts +++ b/test/smoke/clients/js/driver.ts @@ -36,7 +36,9 @@ const dirByVariant: Record = { }; const dir = dirByVariant[variant]; if ((role !== "publish" && role !== "subscribe") || !url || !broadcast || !dir) { - console.error("usage: driver.ts publish|subscribe --variant vite|esbuild|jsdelivr --url U --broadcast B [--timeout S]"); + console.error( + "usage: driver.ts publish|subscribe --variant vite|esbuild|jsdelivr --url U --broadcast B [--timeout S]", + ); process.exit(2); } const timeoutMs = Number.parseFloat(values.timeout ?? "20") * 1000; @@ -94,7 +96,8 @@ try { // publisher whose scripts fail to load exits here instead of streaming nothing. const scriptFailed = new Promise((_, reject) => { page.on("requestfailed", (r) => { - if (r.resourceType() === "script") reject(new Error(`script failed: ${r.url()}: ${r.failure()?.errorText}`)); + if (r.resourceType() === "script") + reject(new Error(`script failed: ${r.url()}: ${r.failure()?.errorText}`)); }); page.on("response", (r) => { if (r.request().resourceType() === "script" && r.status() >= 400) { @@ -141,7 +144,9 @@ try { if (code === 0) await context.tracing.stop(); else { // Save both artifacts even if one fails, and keep the run error as the cause. - await diagnostics.write(artifactDir, artifactName, error).catch((e) => console.error("diagnostics write failed:", e)); + await diagnostics + .write(artifactDir, artifactName, error) + .catch((e) => console.error("diagnostics write failed:", e)); await context.tracing .stop({ path: join(artifactDir, `${artifactName}.zip`) }) .catch((e) => console.error("trace save failed:", e)); diff --git a/test/smoke/clients/js/index.html b/test/smoke/clients/js/index.html index 7647eb9cf5..cb2646aaaa 100644 --- a/test/smoke/clients/js/index.html +++ b/test/smoke/clients/js/index.html @@ -1,5 +1,5 @@ - + moq smoke diff --git a/test/smoke/clients/js/jsdelivr/index.html b/test/smoke/clients/js/jsdelivr/index.html index ebb9390b51..acb3ded38f 100644 --- a/test/smoke/clients/js/jsdelivr/index.html +++ b/test/smoke/clients/js/jsdelivr/index.html @@ -1,5 +1,5 @@ - + moq smoke (jsdelivr) diff --git a/test/smoke/clients/js/package.json b/test/smoke/clients/js/package.json index becdaaf74a..699b3da162 100644 --- a/test/smoke/clients/js/package.json +++ b/test/smoke/clients/js/package.json @@ -1,14 +1,14 @@ { - "name": "@moq/smoke-browser", - "private": true, - "type": "module", - "dependencies": { - "@moq/publish": "latest", - "@moq/watch": "latest" - }, - "devDependencies": { - "esbuild": "^0.28.1", - "playwright": "1.59.1", - "vite": "^8.1.5" - } + "name": "@moq/smoke-browser", + "private": true, + "type": "module", + "dependencies": { + "@moq/publish": "latest", + "@moq/watch": "latest" + }, + "devDependencies": { + "esbuild": "^0.28.1", + "playwright": "1.59.1", + "vite": "^8.1.5" + } } diff --git a/test/smoke/flake.nix b/test/smoke/flake.nix index 6de0f1e499..cee1d797f0 100644 --- a/test/smoke/flake.nix +++ b/test/smoke/flake.nix @@ -1,11 +1,11 @@ { - description = "moq smoke - interop test for the public moq packages"; + description = "moq smoke: interop test for the public moq packages"; - # nixpkgs-unstable, intentionally NOT locked: this repo ships no flake.lock so - # every `nix develop` resolves the latest toolchain (and the latest Chromium), - # matching the "always test latest" policy. The one version that has to line up - # (npm `playwright` <-> this Chromium) is exported below as PLAYWRIGHT_VERSION - # and enforced by smoke.sh, so a nixpkgs bump can't silently drift. + # Separate from moq's root flake so this toolchain floats on its own schedule. + # flake.lock pins only the toolchain, never a moq package under test; bump it + # with `nix flake update`. The one version that has to line up (npm + # `playwright` <-> this Chromium) is exported below as PLAYWRIGHT_VERSION and + # enforced by freshness.sh, so a nixpkgs bump can't silently drift. inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; flake-utils.url = "github:numtide/flake-utils"; diff --git a/test/smoke/freshness.sh b/test/smoke/freshness.sh index 0165ce8fa2..c5580a0dfd 100755 --- a/test/smoke/freshness.sh +++ b/test/smoke/freshness.sh @@ -20,7 +20,8 @@ json_dep() { grep -oE "\"$1\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$PKG" | sed - echo "== no committed package lock files ==" # flake.lock is intentionally excluded: it locks the toolchain, not moq packages. -locks=$(git ls-files 2>/dev/null | grep -E '(^|/)(go\.sum|bun\.lock|bun\.lockb|Cargo\.lock|uv\.lock|package-lock\.json|yarn\.lock|pnpm-lock\.yaml|poetry\.lock|Pipfile\.lock|Package\.resolved|gradle\.lockfile)$' || true) +# Only this directory: the rest of the monorepo builds from source and locks. +locks=$(git ls-files -- . 2>/dev/null | grep -E '(^|/)(go\.sum|bun\.lock|bun\.lockb|Cargo\.lock|uv\.lock|package-lock\.json|yarn\.lock|pnpm-lock\.yaml|poetry\.lock|Pipfile\.lock|Package\.resolved|gradle\.lockfile)$' || true) if [[ -n "$locks" ]]; then note FAIL "package lock files are checked in (delete them and add to .gitignore):" printf ' %s\n' "${locks//$'\n'/$'\n' }" @@ -62,7 +63,7 @@ fi # The media Docker channel (relay + cli wrappers) must use the unpinned (:latest) # images, and CI must pull them fresh. if grep -qF 'moqdev/moq-relay}' clients/docker/moq-relay && grep -qF 'moqdev/moq}' clients/docker/moq && - grep -qF 'docker pull moqdev/moq-relay' .github/workflows/smoke.yml; then + grep -qF 'docker pull moqdev/moq-relay' ../../.github/workflows/smoke.yml; then note ok "moqdev/moq-relay + moqdev/moq -> :latest (pulled each run)" else note FAIL "the moqdev relay/cli Docker images are no longer unpinned :latest + pulled fresh" diff --git a/test/smoke/justfile b/test/smoke/justfile index dd00a628bd..43ff1a1c73 100644 --- a/test/smoke/justfile +++ b/test/smoke/justfile @@ -38,6 +38,18 @@ moxygen: relays *args: ./relays.sh {{ args }} +# HISTORY_URL is the deployed history.json this run appends to. See report/build.sh. + +# Assemble the relay report site from a `relays --json` run. +report results logs out history_url="": + report/build.sh {{ quote(results) }} {{ quote(logs) }} {{ quote(out) }} {{ quote(history_url) }} + +# CI only: leaves gh-pages checked out in SITE for the Pages deploy. See report/publish.sh. + +# Commit a built relay report to moq's gh-pages under relays/. +publish report site: + report/publish.sh {{ quote(report) }} {{ quote(site) }} + # Token interop: install moq auth in each published flavour and cross-verify. # The Rust `moq auth` comes from a channel (PATH); @moq/auth comes from npm, # driven under both node and bun. Default: rust only. Pass flags through, e.g. @@ -85,16 +97,26 @@ negative *args: freshness: ./freshness.sh -# Lint the harness. shfmt discovers every shell script (incl. the extensionless -# clients/docker/* wrappers) and reads .editorconfig for style; shellcheck lints -# the same set. Each tool is guarded so it skips when not on PATH (nix develop -# provides them all). +# moq's root `just check` lints these scripts too. shfmt discovers every shell +# script (incl. the extensionless clients/docker/* wrappers) and reads moq's +# root .editorconfig for style; shellcheck lints the same set. Each tool is +# guarded so it skips when not on PATH (nix develop provides them all). + +# Lint the harness. check: @if command -v shfmt >/dev/null 2>&1; then shfmt --diff $(shfmt -f .); else echo "shfmt: skipped"; fi @if command -v shellcheck >/dev/null 2>&1 && command -v shfmt >/dev/null 2>&1; then shellcheck $(shfmt -f .); else echo "shellcheck: skipped"; fi - @command -v actionlint >/dev/null && actionlint || echo "actionlint: skipped" + @command -v actionlint >/dev/null && actionlint ../../.github/workflows/smoke.yml || echo "actionlint: skipped" just freshness +# Unit-tests the failure diagnostics first. Linux, needs sudo. + +# Install the browser clients and Chromium's system libraries. +playwright-deps: + cd clients/js && bun install + cd clients/js && bun test diagnostics.test.ts + cd clients/js && bunx playwright install-deps chromium + # Compile-check and unit-test the local integrity client against the current # cloudflare/moq-rs Git dependencies. The end-to-end relay check is `just cloudflare`. cloudflare-check: diff --git a/test/smoke/report/index.html b/test/smoke/report/index.html index ece3495ce2..840a060fb9 100644 --- a/test/smoke/report/index.html +++ b/test/smoke/report/index.html @@ -85,6 +85,9 @@ tr:last-child td { border-bottom: 0; } th { font-weight: 600; font-size: 12px; color: var(--muted); } th.score-col, td.score-col { text-align: center; width: 1%; } +tr.ok td:first-child { box-shadow: inset 3px 0 0 var(--pass); } +tr.partial td:first-child { box-shadow: inset 3px 0 0 var(--partial); } +tr.bad td:first-child { box-shadow: inset 3px 0 0 var(--fail); } #latest td:first-child { white-space: normal; overflow-wrap: anywhere; } @media (max-width: 600px) { th, td { padding: 8px; } @@ -109,9 +112,6 @@ .PASS { color: var(--pass); } .FAIL { color: var(--fail); } .SKIP { color: var(--skip); } -tr.ok td:first-child { box-shadow: inset 3px 0 0 var(--pass); } -tr.partial td:first-child { box-shadow: inset 3px 0 0 var(--partial); } -tr.bad td:first-child { box-shadow: inset 3px 0 0 var(--fail); } .score { display: inline-block; min-width: 56px; @@ -214,7 +214,7 @@

MoQ relay interop

through each public relay in the moq-interop-runner registry. A cell passes when media bytes arrive at the subscriber. Only required relays can fail the - smoke run. + smoke run.

Loading…

@@ -238,7 +238,7 @@

History

-
+
@@ -388,6 +388,7 @@

History

if (!logCache.has(path)) { logCache.set(path, fetch(`logs/${path}`, { cache: "no-store" }) .then((res) => (res.ok ? res.text() : Promise.reject(new Error(`HTTP ${res.status}`)))) + // biome-ignore lint/suspicious/noControlCharactersInRegex: strips ANSI color escapes from the logs .then((text) => text.replace(/\x1b\[[0-9;]*m/g, "").trimEnd() || "(empty)") .catch((err) => `(log unavailable: ${err.message})`)); } diff --git a/test/smoke/report/publish.sh b/test/smoke/report/publish.sh new file mode 100755 index 0000000000..a7f9dade85 --- /dev/null +++ b/test/smoke/report/publish.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Publish a built relay report (report/build.sh's OUT_DIR) to moq's GitHub +# Pages branch under relays/, and leave that branch checked out in SITE_DIR for +# the Pages deploy. +# +# report/publish.sh REPORT_DIR SITE_DIR +# +# gh-pages is one site shared with the nightly benchmark charts (bench/). Each +# writer commits only its own directory and deploys the whole branch, so +# neither clobbers the other; the workflows serialize them through the +# `benchmark-pages` concurrency group. +set -euo pipefail + +report=$(realpath "$1") +site=$(realpath -m "$2") +cd "$(git rev-parse --show-toplevel)" + +sh/gh/bench-publish.sh init +git worktree add "$site" gh-pages +rm -rf "$site/relays" +cp -r "$report" "$site/relays" +touch "$site/.nojekyll" +git -C "$site" add -A relays .nojekyll +if ! git -C "$site" diff --cached --quiet; then + git -C "$site" -c user.name=github-actions -c user.email=github-actions@users.noreply.github.com \ + commit -m 'Update relay interop report' +fi +git -C "$site" push origin HEAD:gh-pages diff --git a/test/smoke/rust-toolchain.toml b/test/smoke/rust-toolchain.toml new file mode 100644 index 0000000000..52cb27a72c --- /dev/null +++ b/test/smoke/rust-toolchain.toml @@ -0,0 +1,4 @@ +# Overrides moq's pinned root toolchain: a user's `cargo install moq-relay` runs +# on whatever stable they have, so the cargo channel tests the newest stable. +[toolchain] +channel = "stable" diff --git a/test/tsconfig.json b/test/tsconfig.json index 5bd3f1db12..e9cbae324f 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -5,5 +5,5 @@ "noEmit": true }, "include": ["**/*", "../js/common/worklet.d.ts"], - "exclude": ["**/node_modules", "**/dist", "**/target", "wasm/uniffi"] + "exclude": ["**/node_modules", "**/dist", "**/target", "wasm/uniffi", "smoke"] } From b905a72e26959a8a45c3fbca18257d5cd893b676 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 22:04:39 -0700 Subject: [PATCH 3/5] ci(smoke): keep script names out of workflow run blocks Co-Authored-By: Claude Opus 5.5 --- .github/workflows/smoke.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 13165fe6fc..b6e9f2a4ad 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -125,7 +125,7 @@ jobs: # published moq.pc and CMake package still advertise -lva/-lva-drm, so # the c-pkgconfig and c-cmake variants pass them to the linker. Drop it # once a libmoq release ships without those. The hand-rolled `c` variant - # already omits them, see LIBMOQ_OS_LIBS in smoke.sh. + # already omits them, see LIBMOQ_OS_LIBS in the smoke harness. # gstreamer1.0-* provides gst-launch/gst-inspect + the core elements # (filesink) the moq-gst subscriber loads the prebuilt plugin against. # pkg-config + cmake drive the c-pkgconfig / c-cmake build variants @@ -307,7 +307,7 @@ jobs: run: | # The `rust` impl runs a native `moq auth`, which only the # cargo/apt/brew/nix channels install. The docker channel's moq wrapper - # doesn't mount token.sh's scratch dir, so it exercises the Rust + # doesn't mount the token lane's scratch dir, so it exercises the Rust # verifier through the rust-docker cell instead of plain `rust`. if [ "${{ matrix.channel }}" = "docker" ]; then impls="js-node,js-bun,rust-docker" From 01d30e130b520d27d4ea095cecb0634889c27c97 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 22:05:38 -0700 Subject: [PATCH 4/5] style(smoke): apply just --fmt Co-Authored-By: Claude Opus 5.5 --- test/smoke/justfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/smoke/justfile b/test/smoke/justfile index 43ff1a1c73..8554f6985b 100644 --- a/test/smoke/justfile +++ b/test/smoke/justfile @@ -34,7 +34,7 @@ moxygen: # registry (Cloudflare, moxygen, imquic, ...). Only moq-dev's relay is required; # the rest are reported in a summary table. WebSocket is a separate column for # the relays named in relays.sh WEBSOCKET_KEYS. Pass flags through, e.g. -# just relays --only moq-rs-draft-18 --publishers rust,js-web --subscribers rust,js-web,js-bun +# just relays --only moq-rs-draft-18 --publishers rust,js-web --subscribers rust,js-web,js-bun relays *args: ./relays.sh {{ args }} @@ -53,7 +53,7 @@ publish report site: # Token interop: install moq auth in each published flavour and cross-verify. # The Rust `moq auth` comes from a channel (PATH); @moq/auth comes from npm, # driven under both node and bun. Default: rust only. Pass flags through, e.g. -# just token --generators rust,js-node --verifiers rust,js-bun --algorithms HS256 +# just token --generators rust,js-node --verifiers rust,js-bun --algorithms HS256 token *args: ./token.sh {{ args }} From 602d83770a401f96dc2891b2fa875c5728d72352 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 19 Oct 2026 22:15:24 -0700 Subject: [PATCH 5/5] fix(smoke): fail the token wrong-key check when its key can't be minted The negative pass minted the unrelated key with the verifier under test and only warned when that failed, so a verifier could pass without ever being shown a foreign key. Mint it with the canonical generator, which just worked, and fail the cell otherwise. Co-Authored-By: Claude Opus 5.5 --- test/smoke/token.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/smoke/token.sh b/test/smoke/token.sh index 7986f04ec0..445e45e1da 100755 --- a/test/smoke/token.sh +++ b/test/smoke/token.sh @@ -388,9 +388,13 @@ else echo " PASS reject($v, $algo): tampered token refused" fi # (b) a valid token verified against an unrelated key must be refused. + # The canonical generator mints that key: it just worked above, and + # the positive matrix proves every verifier reads its keys. wrongdir="$TMP/wrong-$v-$algo" - if ! gen "$v" "$algo" "$wrongdir" >"$wrongdir.gen.log" 2>&1; then - echo " WARN reject($v, $algo): could not mint a wrong key; skipping key check" + if ! gen "$canon" "$algo" "$wrongdir" >"$wrongdir.gen.log" 2>&1; then + echo " FAIL reject($v, $algo): unrelated key generation ($canon) failed" + sed 's/^/ /' "$wrongdir.gen.log" >&2 || true + overall=1 continue fi if verify "$v" "$wrongdir/verify.jwk" "$token" >"$TMP/neg-wrongkey-$v-$algo.log" 2>&1; then