From ad83dac5b3163b52332fa9d8145e79bd74c20a03 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sun, 13 Sep 2026 10:37:18 -0700 Subject: [PATCH] test: prove unpublished dev API from a moq checkout Add a from-dev channel that installs JS packages from a moq path or git `dev` and runs Connection, catalog Snapshot, and stats Snapshot vs Window cases. The published-package matrix is unchanged. Co-Authored-By: grok-4.6 --- .github/workflows/smoke.yml | 30 +++++++ README.md | 12 ++- clients/dev/catalog.ts | 105 ++++++++++++++++++++++ clients/dev/lib.ts | 75 ++++++++++++++++ clients/dev/live.ts | 97 ++++++++++++++++++++ clients/dev/run.ts | 34 ++++++++ clients/dev/stats.ts | 95 ++++++++++++++++++++ dev.sh | 170 ++++++++++++++++++++++++++++++++++++ freshness.sh | 9 ++ justfile | 7 ++ 10 files changed, 632 insertions(+), 2 deletions(-) create mode 100644 clients/dev/catalog.ts create mode 100644 clients/dev/lib.ts create mode 100644 clients/dev/live.ts create mode 100644 clients/dev/run.ts create mode 100644 clients/dev/stats.ts create mode 100755 dev.sh diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 99524ba..425c478 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -274,3 +274,33 @@ jobs: # failing the job; remove this once that protocol mismatch is fixed. continue-on-error: true run: ./moxygen.sh + + from-dev: + name: Unpublished dev API + runs-on: ubuntu-latest + permissions: + contents: read + timeout-minutes: 45 + + steps: + - name: Checkout smoke + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Checkout moq (dev) + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + repository: moq-dev/moq + ref: dev + path: moq + persist-credentials: false + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + - uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + + - name: System deps + run: sudo apt-get update && sudo apt-get install -y pkg-config cmake g++ + + - name: From-dev contract cases + run: ./dev.sh --src "$PWD/moq" --timeout 30 diff --git a/README.md b/README.md index 8442928..ac98e66 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Cross-language interop smoke test for the **public** [Media over QUIC](https://g The [moq-dev/moq](https://github.com/moq-dev/moq) monorepo has its own in-tree smoke test, but it builds every client from workspace source. That proves the code in the tree works; it does **not** prove a real user can install the published artifacts and have them talk to each other. A missing wheel, a stale Homebrew formula, a broken `.deb`, an export that didn't survive packaging, a Go module missing its header. none of that shows up until someone installs from a registry. -This repo installs each client straight from its public package registry, stands up a relay, and runs the interop matrix: +This repo installs each client straight from its public package registry, stands up a relay, and runs the interop matrix. A second **from-dev** channel (`./dev.sh`) installs the unpublished `dev` surface from a moq checkout (path or git `dev`) and runs contract cases the published matrix cannot see yet: reconnecting `Connection` handles, announcements, credential refresh, publication replacement, catalog snapshots-then-deltas, and stats Snapshot versus Window. Embedded relay ownership stays in moq-relay; it is not a smoke client. - A relay (`moq-relay`) routes broadcasts. - For each publisher language, publish an H.264 broadcast. @@ -84,14 +84,21 @@ RELAY_BIN=/path/to/moq-relay MOQ_BIN=/path/to/moq ./smoke.sh # prove the harness can fail: no publisher, every subscriber must time out. ./smoke.sh --negative --subscribers rust,python + +# unpublished dev API (JS packages + relay from a moq checkout, not npm/crates.io): +just dev --src /path/to/moq +just dev # clones github.com/moq-dev/moq (dev) ``` `smoke.sh` installs the language clients (PyPI / Go proxy / npm) into a scratch dir on each run, so you always test the latest published versions. It does **not** install the Rust binaries; that is the channel under test. +`dev.sh` is the other way around: it builds `moq-relay` from `MOQ_SRC` (or clones `dev`) and resolves `@moq/net`, `@moq/hang`, and `@moq/json` from that checkout's `js/` tree so the contract cases exercise the unpublished surface. + ## Layout ``` -smoke.sh orchestrator: relay + media interop matrix +smoke.sh orchestrator: relay + media interop matrix (published packages) +dev.sh orchestrator: unpublished dev API contract cases (path/git `dev`) cloudflare.sh orchestrator: Cloudflare client through both projects' relays moxygen.sh orchestrator: moxygen protocol client through the moq-dev relay smoke.toml relay config (anonymous, self-signed localhost) @@ -105,6 +112,7 @@ clients/ kotlin/ subscribe via dev.moq:moq (Gradle/JVM) c/subscribe.c subscribe via libmoq (prebuilt release) js-native/subscribe.ts subscribe via @moq/net + @moq/hang + WebTransport polyfill (node, bun) + dev/ from-dev contract cases: Connection, catalog Snapshot, stats Snapshot vs Window (gst) subscribe via the moq-gst plugin (moqsrc); no client dir, driven by gst-launch docker/ moq-relay + moq wrappers: docker run the moqdev/* images (the docker channel) token/js/ installs @moq/token (npm) for token.sh to drive under node + bun diff --git a/clients/dev/catalog.ts b/clients/dev/catalog.ts new file mode 100644 index 0000000..6ff4ea1 --- /dev/null +++ b/clients/dev/catalog.ts @@ -0,0 +1,105 @@ +// Catalog-only reading: Json.Snapshot.Consumer plus the hang catalog schema. +// A full snapshot is followed by a merge-patch delta. Parsing every frame as a +// catalog is the old consumer path and must fail on the delta frame. +import * as Catalog from "@moq/hang/catalog"; +import * as Json from "@moq/json"; +import * as Moq from "@moq/net"; +import { connected, equal, handle, REPLAY_MS, waitActive, waitAnnounce } from "./lib.ts"; + +const PATH = "dev.catalog"; + +const SNAPSHOT: Catalog.Root = { + json: { + tracks: { + status: { mode: "snapshot" }, + }, + }, +}; + +const UPDATED: Catalog.Root = { + json: { + tracks: { + extra: { mode: "snapshot" }, + }, + }, +}; + +export async function catalog(url: URL): Promise { + const pub = handle(url); + const sub = handle(url); + try { + const pubOrigin = await connected(pub); + const subOrigin = await connected(sub); + const announced = sub.announced(); + + const broadcast = pubOrigin.createBroadcast(Moq.Path.from(PATH)); + const track = broadcast.createTrack(Catalog.TRACK); + const producer = new Json.Snapshot.Producer({ + track, + schema: Catalog.RootSchema, + deltaRatio: 100, + }); + broadcast.announce(); + + await waitAnnounce(announced, PATH, true); + const request = subOrigin.request(Moq.Path.from(PATH)); + const consumer = await waitActive(request, "catalog broadcast"); + + const snapshot = new Json.Snapshot.Consumer({ + track: consumer.track(Catalog.TRACK).subscribe({ + priority: Catalog.PRIORITY.catalog, + maxAge: REPLAY_MS, + }), + schema: Catalog.RootSchema, + }); + + producer.update(SNAPSHOT); + const first = await snapshot.next(); + if (!first || !equal(first, SNAPSHOT)) { + throw new Error(`first catalog was ${JSON.stringify(first)}`); + } + + producer.update(UPDATED); + const second = await snapshot.next(); + if (!second || !equal(second, UPDATED)) { + throw new Error(`delta did not reconstruct ${JSON.stringify(second)}`); + } + + producer.finish(); + + const raw = consumer + .track(Catalog.TRACK) + .subscribe({ priority: Catalog.PRIORITY.catalog, maxAge: REPLAY_MS }) + .ordered(); + const group = await raw.nextGroup(); + if (!group) throw new Error("catalog group missing"); + const frames: Uint8Array[] = []; + for (;;) { + const frame = await group.readFrame(); + if (!frame) break; + frames.push(frame.payload); + } + if (frames.length < 2) { + throw new Error(`expected a snapshot frame then a delta, got ${frames.length} frame(s)`); + } + + const decoder = new TextDecoder(); + const root = Catalog.RootSchema.parse(JSON.parse(decoder.decode(frames[0]))); + if (!equal(root, SNAPSHOT)) throw new Error("frame 0 was not the full catalog snapshot"); + + let deltaParsedAsCatalog = false; + try { + Catalog.RootSchema.parse(JSON.parse(decoder.decode(frames[1]))); + deltaParsedAsCatalog = true; + } catch { + // The delta is an RFC 7396 merge patch, not a catalog root. + } + if (deltaParsedAsCatalog) { + throw new Error("frame 1 parsed as a full catalog; the consumer is not reconstructing deltas"); + } + } finally { + pub.close(); + sub.close(); + } + console.log(" catalog: ok"); +} diff --git a/clients/dev/lib.ts b/clients/dev/lib.ts new file mode 100644 index 0000000..61c16c5 --- /dev/null +++ b/clients/dev/lib.ts @@ -0,0 +1,75 @@ +// Shared helpers for the from-dev API contract cases. +import * as Moq from "@moq/net"; + +export const REPLAY_MS = 30_000; + +export function parseUrl(): { url: URL; timeoutMs: number } { + const args = process.argv.slice(2); + let url: string | undefined; + let timeout = "30"; + for (let i = 0; i < args.length; i++) { + if (args[i] === "--url") url = args[++i]; + else if (args[i] === "--timeout") timeout = args[++i] ?? timeout; + } + if (!url) { + console.error("usage: run.ts --url URL [--timeout S]"); + process.exit(2); + } + return { url: new URL(url), timeoutMs: Number.parseFloat(timeout) * 1000 }; +} + +export async function waitUntil(pred: () => boolean, label: string, ms = 10_000): Promise { + const deadline = Date.now() + ms; + for (;;) { + if (pred()) return; + if (Date.now() > deadline) throw new Error(`timeout waiting for ${label}`); + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} + +export async function connected(conn: Moq.Connection, ms = 10_000): Promise { + await waitUntil( + () => conn.status.peek() === "connected" && conn.origin.peek() !== undefined, + "connection established", + ms, + ); + const origin = conn.origin.peek(); + if (!origin) throw new Error("connected without an origin"); + return origin; +} + +export function announcedPath(entry: Moq.Announce.Event): string | undefined { + return entry.pattern.isLiteral ? entry.pattern.text : entry.pattern.asPrefix(); +} + +export async function waitAnnounce( + announced: Moq.Announce.Consumer, + path: string, + active: boolean, +): Promise { + for (;;) { + const entry = await announced.next(); + if (!entry) throw new Error(`announce stream ended before ${path} ${active ? "appeared" : "retracted"}`); + if (announcedPath(entry) === path && entry.active === active) return; + } +} + +export async function waitActive( + request: Moq.Origin.Request, + label: string, + ms = 10_000, +): Promise { + await waitUntil(() => request.active.peek() !== undefined, label, ms); + const broadcast = request.active.peek(); + if (!broadcast) throw new Error(`${label}: request resolved then vanished`); + return broadcast; +} + +export function equal(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +export function handle(url: URL): Moq.Connection { + // Private loops so a publisher and a subscriber do not share an origin and skip the relay. + return new Moq.Connection({ url, share: false, linger: 0 }); +} diff --git a/clients/dev/live.ts b/clients/dev/live.ts new file mode 100644 index 0000000..dfbffb2 --- /dev/null +++ b/clients/dev/live.ts @@ -0,0 +1,97 @@ +// Connection, announcements, credential refresh, and publication replacement. +// Models the moq.pro live session (reconnecting handle, URL swap, announce cursor) +// without copying that app. +import * as Moq from "@moq/net"; +import { connected, handle, waitActive, waitAnnounce, waitUntil } from "./lib.ts"; + +const PATH = "dev.live"; + +export async function live(url: URL): Promise { + await refresh(url); + await announceAndReplace(url); + console.log(" live: ok"); +} + +async function refresh(url: URL): Promise { + const first = new URL(url.href); + first.searchParams.set("jwt", "first"); + const second = new URL(url.href); + second.searchParams.set("jwt", "second"); + + const conn = handle(first); + try { + await connected(conn); + if (conn.closed.peek() !== undefined) { + throw new Error("closed settled before the handle was released"); + } + + conn.url.set(second); + await waitUntil( + () => conn.url.peek()?.href === second.href && conn.status.peek() === "connected", + "connected at the refreshed URL", + ); + if (conn.closed.peek() !== undefined) { + throw new Error("closed settled on a URL swap; it is handle disposal, not session end"); + } + if (conn.error.peek() !== undefined) { + throw new Error(`error after a recoverable URL swap: ${conn.error.peek()}`); + } + } finally { + conn.close(); + } + + const closed = await conn.closed; + if (closed !== null) throw new Error(`close() settled with ${closed}`); +} + +async function announceAndReplace(url: URL): Promise { + const pub = handle(url); + const sub = handle(url); + try { + const pubOrigin = await connected(pub); + const subOrigin = await connected(sub); + const announced = sub.announced(); + + const first = publish(pubOrigin, PATH, "v1"); + await waitAnnounce(announced, PATH, true); + + const request = subOrigin.request(Moq.Path.from(PATH)); + const consumer = await waitActive(request, "first publication"); + await expectFrame(consumer, "v1"); + + first.broadcast.close(); + await waitAnnounce(announced, PATH, false); + + const second = publish(pubOrigin, PATH, "v2"); + await waitAnnounce(announced, PATH, true); + const replaced = await waitActive(request, "replaced publication"); + await expectFrame(replaced, "v2"); + + second.broadcast.close(); + } finally { + pub.close(); + sub.close(); + } +} + +function publish(origin: Moq.Origin.Producer, path: string, payload: string) { + const broadcast = origin.createBroadcast(Moq.Path.from(path)); + const track = broadcast.createTrack("messages"); + const group = track.appendGroup(); + group.writeString(payload); + group.close(); + broadcast.announce(); + return { broadcast, track }; +} + +async function expectFrame(broadcast: Moq.Broadcast.Consumer, payload: string): Promise { + const track = broadcast.track("messages").subscribe({ priority: 0, maxAge: 30_000 }); + try { + const group = await track.recvGroup(); + if (!group) throw new Error("track ended before a group arrived"); + const frame = await group.readString(); + if (frame !== payload) throw new Error(`expected ${payload}, got ${frame}`); + } finally { + track.close(); + } +} diff --git a/clients/dev/run.ts b/clients/dev/run.ts new file mode 100644 index 0000000..ab6e7f5 --- /dev/null +++ b/clients/dev/run.ts @@ -0,0 +1,34 @@ +// From-dev API contract cases. Run against a local relay with JS packages +// resolved from a moq checkout, not npm latest. +import { install } from "@moq/web-transport"; +import { catalog } from "./catalog.ts"; +import { parseUrl } from "./lib.ts"; +import { live } from "./live.ts"; +import { stats } from "./stats.ts"; + +install(); + +const { url, timeoutMs } = parseUrl(); + +let timeoutId: ReturnType | undefined; +const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error("timed out waiting for the from-dev cases")), timeoutMs); +}); + +try { + await Promise.race([ + (async () => { + console.log("from-dev cases against", url.href); + await live(url); + await catalog(url); + await stats(url); + console.log("from-dev: ok"); + })(), + timeout, + ]); +} catch (err) { + console.error(`error: ${err instanceof Error ? err.message : String(err)}`); + process.exitCode = 1; +} finally { + if (timeoutId !== undefined) clearTimeout(timeoutId); +} diff --git a/clients/dev/stats.ts b/clients/dev/stats.ts new file mode 100644 index 0000000..a583795 --- /dev/null +++ b/clients/dev/stats.ts @@ -0,0 +1,95 @@ +// Stats snapshots versus retained rollup groups. +// Live counters are a lossy latest-value Snapshot. Billing rollups are a Window: +// a late joiner still sees the retained buckets, and pops drop the front. +import * as Json from "@moq/json"; +import * as Moq from "@moq/net"; +import { connected, handle, REPLAY_MS, waitActive, waitAnnounce } from "./lib.ts"; + +const PATH = "dev.stats"; + +type Snapshot = { bytes: number }; +type Bucket = { start: number; bytes: number }; + +function pushedBytes(event: Json.Window.Event | undefined): number | undefined { + return event && "push" in event ? event.push.value.bytes : undefined; +} + +export async function stats(url: URL): Promise { + const pub = handle(url); + const sub = handle(url); + try { + const pubOrigin = await connected(pub); + const subOrigin = await connected(sub); + const announced = sub.announced(); + + const broadcast = pubOrigin.createBroadcast(Moq.Path.from(PATH)); + const liveTrack = broadcast.createTrack("live.json"); + const rollupTrack = broadcast.createTrack("minute"); + const live = new Json.Snapshot.Producer({ track: liveTrack, deltaRatio: 100 }); + const rollup = new Json.Window.Producer({ track: rollupTrack }); + broadcast.announce(); + + await waitAnnounce(announced, PATH, true); + const request = subOrigin.request(Moq.Path.from(PATH)); + const consumer = await waitActive(request, "stats broadcast"); + + const liveReader = new Json.Snapshot.Consumer({ + track: consumer.track("live.json").subscribe({ priority: 0, maxAge: REPLAY_MS }), + }); + const rollupReader = new Json.Window.Consumer({ + track: consumer.track("minute").subscribe({ priority: 0, maxAge: REPLAY_MS }), + }); + + live.update({ bytes: 1 }); + const firstLive = await liveReader.next(); + if (firstLive?.bytes !== 1) throw new Error(`live snapshot 1: ${JSON.stringify(firstLive)}`); + + live.update({ bytes: 2 }); + const secondLive = await liveReader.next(); + if (secondLive?.bytes !== 2) throw new Error(`live snapshot 2: ${JSON.stringify(secondLive)}`); + + rollup.push({ start: 0, bytes: 10 }); + const push0 = await rollupReader.next(); + if (!push0 || !("push" in push0) || push0.push.value.bytes !== 10) { + throw new Error(`rollup push 0: ${JSON.stringify(push0)}`); + } + + rollup.push({ start: 1, bytes: 20 }); + const push1 = await rollupReader.next(); + if (!push1 || !("push" in push1) || push1.push.index !== 1) { + throw new Error(`rollup push 1: ${JSON.stringify(push1)}`); + } + + rollup.pop(1); + const pop = await rollupReader.next(); + if (!pop || !("pop" in pop) || pop.pop.start !== 0 || pop.pop.end !== 1) { + throw new Error(`rollup pop: ${JSON.stringify(pop)}`); + } + + // A late snapshot joiner collapses to the latest value. A late window + // joiner is restated the retained suffix, not only the live tail. + const lateLive = new Json.Snapshot.Consumer({ + track: consumer.track("live.json").subscribe({ priority: 0, maxAge: REPLAY_MS }), + }); + const lateLiveValue = await lateLive.next(); + if (lateLiveValue?.bytes !== 2) { + throw new Error(`late snapshot joiner: ${JSON.stringify(lateLiveValue)}`); + } + + const lateRollup = new Json.Window.Consumer({ + track: consumer.track("minute").subscribe({ priority: 0, maxAge: REPLAY_MS }), + }); + const first = await lateRollup.next(); + const second = pushedBytes(first) === 20 ? first : await lateRollup.next(); + if (pushedBytes(first) !== 20 && pushedBytes(second) !== 20) { + throw new Error(`late window joiner never saw the retained bucket: ${JSON.stringify([first, second])}`); + } + + live.finish(); + rollup.finish(); + } finally { + pub.close(); + sub.close(); + } + console.log(" stats: ok"); +} diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..a39bb79 --- /dev/null +++ b/dev.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# Prove the unpublished *dev* API by installing JS packages from a moq checkout +# (path or git `dev`) and running contract cases a published-package matrix cannot +# see yet. The cargo/apt/brew/nix/docker media matrix is unchanged: this is a +# second channel, not a replacement. +# +# MOQ_SRC=/path/to/moq ./dev.sh +# ./dev.sh --src /path/to/moq +# ./dev.sh # clones github.com/moq-dev/moq (dev) into a temp dir +set -euo pipefail + +SMOKE_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +CLIENTS="$SMOKE_DIR/clients/dev" +TIMEOUT="${SMOKE_TIMEOUT:-30}" +PORT="${SMOKE_PORT:-}" +MOQ_SRC="${MOQ_SRC:-}" +MOQ_GIT="${MOQ_GIT:-https://github.com/moq-dev/moq.git}" +MOQ_REF="${MOQ_REF:-dev}" + +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 + --src) + require_value "$@" + MOQ_SRC="$2" + shift 2 + ;; + --git) + require_value "$@" + MOQ_GIT="$2" + shift 2 + ;; + --ref) + require_value "$@" + MOQ_REF="$2" + shift 2 + ;; + --timeout) + require_value "$@" + TIMEOUT="$2" + shift 2 + ;; + --port) + require_value "$@" + PORT="$2" + shift 2 + ;; + *) + echo "unknown arg: $1" >&2 + exit 2 + ;; + esac +done + +have() { command -v "$1" >/dev/null 2>&1; } + +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 +} + +TMP=$(mktemp -d) +RELAY_PID="" +cleanup() { + [[ -n "${RELAY_PID:-}" ]] && kill_tree "$RELAY_PID" + rm -rf "$TMP" +} +trap cleanup EXIT + +for t in bun cargo curl git pgrep; do + have "$t" || { + echo "error: missing $t" >&2 + exit 1 + } +done + +if [[ -z "$MOQ_SRC" ]]; then + echo "cloning $MOQ_GIT ($MOQ_REF)..." + git clone --depth 1 --branch "$MOQ_REF" "$MOQ_GIT" "$TMP/moq" + MOQ_SRC="$TMP/moq" +elif [[ ! -d "$MOQ_SRC" ]]; then + echo "error: --src is not a directory: $MOQ_SRC" >&2 + exit 1 +fi +MOQ_SRC=$(cd "$MOQ_SRC" && pwd) + +echo "moq: $MOQ_SRC" +echo "revision: $(git -C "$MOQ_SRC" rev-parse HEAD)" +echo "ref: $(git -C "$MOQ_SRC" rev-parse --abbrev-ref HEAD 2>/dev/null || echo detached)" + +RELAY="${RELAY_BIN:-}" +if [[ -z "$RELAY" ]]; then + if [[ -x "$MOQ_SRC/target/debug/moq-relay" ]]; then + RELAY="$MOQ_SRC/target/debug/moq-relay" + else + echo "building moq-relay from source..." + (cd "$MOQ_SRC" && cargo build -p moq-relay) >"$TMP/cargo.log" 2>&1 || { + sed 's/^/ cargo: /' "$TMP/cargo.log" >&2 + exit 1 + } + RELAY="$MOQ_SRC/target/debug/moq-relay" + fi +fi +if [[ ! -x "$RELAY" ]]; then + echo "error: moq-relay not found at $RELAY" >&2 + exit 1 +fi +echo "relay: $RELAY" + +# Resolve unpublished @moq/* from the moq workspace, and @moq/web-transport +# from npm. bun workspace globs skip the js/* symlinks, so link the packages +# into this client's node_modules after bun install in both trees. +echo "installing JS packages from $MOQ_SRC/js ..." +if ! (cd "$MOQ_SRC" && bun install) >"$TMP/bun-moq.log" 2>&1; then + sed 's/^/ bun moq: /' "$TMP/bun-moq.log" >&2 + exit 1 +fi +mkdir -p "$TMP/cases" +cp "$CLIENTS"/*.ts "$TMP/cases/" +cat >"$TMP/cases/package.json" <<'EOF' +{ + "name": "moq-dev-api", + "private": true, + "type": "module", + "dependencies": { + "@moq/web-transport": "latest" + } +} +EOF +if ! (cd "$TMP/cases" && bun install) >"$TMP/bun-cases.log" 2>&1; then + sed 's/^/ bun cases: /' "$TMP/bun-cases.log" >&2 + exit 1 +fi +mkdir -p "$TMP/cases/node_modules/@moq" +for pkg in net hang json flate signals pattern loc; do + [[ -d "$MOQ_SRC/js/$pkg" ]] || { + echo "error: missing $MOQ_SRC/js/$pkg" >&2 + exit 1 + } + ln -sfn "$MOQ_SRC/js/$pkg" "$TMP/cases/node_modules/@moq/$pkg" +done + +if [[ -z "$PORT" ]]; then + PORT=$(bun -e 'const n=require("node:net"); const s=n.createServer(); s.listen(0,"127.0.0.1",()=>{console.log(s.address().port);s.close()})') +fi +URL="http://127.0.0.1:${PORT}" + +sed "s/4443/${PORT}/g" "$SMOKE_DIR/smoke.toml" >"$TMP/relay.toml" +echo "starting relay on 127.0.0.1:${PORT}..." +"$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 + +echo "running from-dev cases..." +(cd "$TMP/cases" && bun run.ts --url "$URL" --timeout "$TIMEOUT") diff --git a/freshness.sh b/freshness.sh index a32361d..6fc489f 100755 --- a/freshness.sh +++ b/freshness.sh @@ -139,6 +139,15 @@ else note FAIL "dev.moq:moq is not a dynamic latest version in build.gradle.kts" fail=1 fi +# From-dev consumes a moq checkout, not npm/crates.io latest. The published +# matrix above stays on latest; this channel is what proves unpublished `dev`. +# shellcheck disable=SC2016 +if grep -q 'ln -sfn "$MOQ_SRC/js/$pkg"' dev.sh && grep -q 'MOQ_REF:-dev}' dev.sh; then + note ok "from-dev JS packages -> MOQ_SRC/js (git ref default: dev)" +else + note FAIL "dev.sh no longer installs unpublished JS packages from a moq checkout" + fail=1 +fi # C: smoke.sh resolves the newest libmoq-v* release, never a fixed version. if grep -q "grep '\^libmoq-v' | head -1" smoke.sh; then note ok "libmoq -> latest release" diff --git a/justfile b/justfile index ca00948..fcce766 100644 --- a/justfile +++ b/justfile @@ -72,6 +72,13 @@ nix-channel *args: negative *args: ./smoke.sh --negative {{ args }} +# Unpublished *dev* API: install JS packages from a moq checkout (path or git +# `dev`) and run the contract cases. The published-package matrix is unchanged. +# just dev --src /path/to/moq +# just dev # clones github.com/moq-dev/moq (dev) +dev *args: + ./dev.sh {{ args }} + # Assert we pin nothing stale: no committed lock files, and any version we are # forced to pin (npm playwright) matches what the toolchain provides. freshness: