Files
Piik/tests/client-state.test.ts
AAAandPedro Antonio c18502fd18 feat: improve microphone, chat and platform support
This release adds microphone processing controls and local chat controls, expands platform packages, and synchronizes the website and guides. It also fixes native H.264 input/format handling, signaling recovery, fresh STUN discovery, and UDP ownership during overlapping connections.

This is **v1.8.0**. Browser/Server v23, Native control v9 and capture v7 remain unchanged, with the existing 1440p ceiling. Additional resolutions, capability negotiation and Magicsock are separate follow-up work.

## Validation

- Full local Web check: 79 files / 1,317 tests, TypeScript and production build.
- Full Go checks, platform builds and Windows native capture regressions/probe.
- Native H.264 Host lifecycle, Browser-to-App fanout and App-exit recovery, and Native H.264 through embedded SFU: all passed with owned process/port/profile cleanup.
- Website build, documentation and repository hygiene passed. Existing UI acceptance covers narrow screens, both languages/themes, architecture downloads and all 23 film checks; final packaging and Go race checks run on the merged revision in CI.
- Physical macOS/Linux capture, representative public networks and unmatched field reports retain their documented evidence limits. No claim that every blur/disconnection report is resolved.

## Release notes

### 中文

这是一次功能与兼容性更新:麦克风和聊天增加了实用的小选项,App 与 Server 支持更多设备,同时修复了原生分享和连接恢复中的若干问题。现有 1.x 协议保持不变。

#### 新增与改进

- 浏览器麦克风新增「降噪与回声消除」开关,默认开启回声消除、降噪和自动增益;分享音乐或使用虚拟音频输入时,可按需关闭。
- 可调整弹幕字号和透明度,并将当前保留的聊天记录导出为 TXT。当前会话最多保留 1,000 条文字消息,刷新页面后清空;需要保存时请先导出。
- 新增 Intel Mac、Linux ARM64 App,以及 Linux ARM64 Server 和容器镜像。官网下载区可直接选择对应架构,并显示最新版本和发布时间。
- 更新官网演示、README 截图和中英文教程,明确浏览器分享也可选择窗口或屏幕;开始分享前显示房间号占位,保持底栏控件位置稳定。
- 官网新增直达问题排查的入口,补充浏览器 WebRTC 限制、校园网和 Windows 显卡相关的排查步骤。
- App 模式选择页显示免费开源说明、作者与贡献者署名,以及官方网站和源码链接。

#### 修复

- 修正部分 H.264 原生分享的格式兼容性校验,以及 Windows 编码器输入长度和输出缓冲区对齐问题。
- 清理已失效的重连任务和 Native 接收信令,避免旧连接继续影响当前会话。
- 修复 App 重连时可能复用过期公网地址的问题;重新探测使用当前地址,并处理探测丢包和过期响应,保留其他正常连接。
- 修复 App 新旧媒体连接重叠时共用端口造成的冲突,避免准备或撤销新连接打断仍在使用的画面;关闭连接时及时取消未完成的端口映射请求。
- 无站点密码时,连接恢复不再误引导用户输入站点密码。
- 房间失效或会话终止时,开播前后的原因提示统一显示在电视状态图标中,避免额外提示条。
- 修正 Linux 原生采集的时间戳与关键帧时间基准。感谢 Pedro Antonio 在 [#449](https://github.com/TNTcraftHIM/Piik/pull/449) 的贡献。

#### 使用说明

下载对应平台的完整压缩包,退出旧 App 后解压使用。Windows 继续提供 x64;macOS 提供 Apple 芯片与 Intel 版本,原生采集需要 macOS 13 或更新版本。Linux 提供 x64 与 ARM64,原生采集还需安装[系统组件](https://github.com/TNTcraftHIM/Piik/blob/main/native/capture/linux/README.md)。macOS 和 Linux 的实机覆盖仍有限,欢迎反馈。

### English

This update adds useful microphone and chat controls, brings Piik to more devices, and fixes several native-sharing and connection-recovery issues. The existing 1.x protocols remain unchanged.

#### New and improved

- Browser microphones now have a “Noise & echo reduction” toggle. Echo cancellation, noise suppression and automatic gain control are on by default; turn them off when appropriate for music or virtual audio inputs.
- Adjust chat-overlay text size and opacity, and export retained messages as TXT. The current session keeps up to 1,000 text messages and clears on page refresh; export first if you want to keep them.
- Added Intel Mac and Linux ARM64 App packages, plus Linux ARM64 Server and container images. The website provides architecture selectors and shows the latest release and publication date.
- Updated the website demonstration, README screenshots and bilingual guides, clarifying that browser capture can also select windows or screens. A room-code placeholder keeps the bottom bar stable before sharing starts.
- Added direct website links to troubleshooting, with steps for browser WebRTC restrictions, campus networks and Windows graphics issues.
- The App mode-selection page now shows its free and open-source status, author and contributor credit, and links to the official website and source.

#### Fixes

- Corrected H.264 format compatibility checks and Windows encoder input-length and output-buffer alignment handling.
- Retired stale reconnect tasks and Native receive signals so they cannot continue affecting the current session.
- Fixed App reconnection reusing an expired public address. Fresh discovery handles lost probes and stale responses while preserving other working connections.
- Fixed UDP socket conflicts between overlapping App media connections so preparing or canceling a replacement preserves the active picture. Closing a connection now cancels unfinished gateway mapping requests.
- Connection recovery on passwordless sites no longer incorrectly asks for a site passphrase.
- Expired-room and session-termination reasons now use the same TV status indicator before and during sharing, without an extra notice strip.
- Corrected Linux native capture timestamps and keyframe time bases. Thanks to Pedro Antonio for [#449](https://github.com/TNTcraftHIM/Piik/pull/449).

#### Updating

Download the complete archive for your platform, quit the old App, and extract the new package. Windows remains x64-only. macOS packages support Apple silicon and Intel; native capture requires macOS 13 or newer. Linux supports x64 and ARM64 and requires additional [system components](https://github.com/TNTcraftHIM/Piik/blob/main/native/capture/linux/README.md) for native capture. Physical-device coverage on macOS and Linux remains limited; feedback is welcome.

## Attribution

Co-authored-by: Pedro Antonio <wrenchwastaken@gmail.com>
2026-10-04 16:21:01 +08:00

3589 lines
114 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import {
DEFAULT_QUALITY_SETTINGS,
DEFAULT_ROUTE_POLICY,
SIGNALING_PROTOCOL,
type QualitySettings,
} from "../src/shared/protocol.ts";
import {
ApiError,
authenticateSiteAccess,
createRoom,
getSiteAccess,
getOptionalRuntimeCapabilities,
replaceOwnedRoom,
updateRoomAccess,
} from "../src/client/lib/api.ts";
import {
defaultHostDisplayName,
defaultViewerDisplayName,
readDisplayName,
saveDisplayName,
} from "../src/client/lib/display-name.ts";
import { setCopy } from "../src/client/ui/copy.ts";
import {
clearHostRoom,
clientLaunchURL,
getStableClientId,
isValidRoomId,
parseAppRoute,
mergeAuthenticatedHostRoom,
readHostRoom,
releaseHostRoom,
readPreferredRoomId,
readViewerGrant,
readViewerRoute,
watchViewerInvites,
replaceViewerInvite,
roomRouteForExplicitEntry,
roomRouteFromInput,
takeClientLaunchBootstrap,
writeHostRoom,
writePreferredRoom,
} from "../src/client/lib/session.ts";
import {
shouldReconnectSignaling,
SignalingClient,
} from "../src/client/lib/signaling.ts";
import { deriveParticipantTopology } from "../src/client/lib/participant-topology.ts";
import { labelParticipantSnapshot } from "../src/client/lib/viewer-presence.ts";
import { qualityEvidenceWindowFromMetrics } from "../src/client/media/viewer-quality-evidence.ts";
import {
collectConnectionMetrics,
collectConnectionMetricsFromReport,
collectNativeSenderQualityFromReport,
createNativeSenderQualityAccumulator,
createStatsAccumulator,
} from "../src/client/webrtc/stats.ts";
afterEach(() => {
releaseHostRoom();
vi.useRealTimers();
vi.unstubAllGlobals();
setCopy({ lang: "zh", vis: false });
});
describe("optional Viewer capability discovery", () => {
it("returns supported progress and tolerates an unavailable or malformed descriptor", async () => {
for (const [body, status, expected] of [
[{ connectionAttemptProgress4: true }, 200, true],
[{}, 200, undefined],
[{}, 503, undefined],
[{ connectionAttemptProgress4: 4 }, 200, undefined],
] as const) {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(body), { status })));
expect((await getOptionalRuntimeCapabilities())?.connectionAttemptProgress4).toBe(expected);
}
});
it("abandons stalled discovery without preventing viewing", async () => {
vi.useFakeTimers();
let observed: AbortSignal | undefined;
vi.stubGlobal("fetch", vi.fn((_url: string, options: RequestInit) => new Promise((_resolve, reject) => {
observed = options.signal!;
observed.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")));
})));
const discovery = getOptionalRuntimeCapabilities();
await vi.advanceTimersByTimeAsync(2_000);
await expect(discovery).resolves.toBeNull();
expect(observed?.aborted).toBe(true);
expect(vi.getTimerCount()).toBe(0);
});
});
function routeAuthenticated(
shareGeneration: string | null = null,
qualitySettings: QualitySettings = DEFAULT_QUALITY_SETTINGS,
) {
return {
mediaMode: "peer-assisted" as const,
shareGeneration,
routeRevision: 0,
routeAssignment: {
upstream: { kind: "none" as const },
childPeerIds: [],
sfuPublicationGeneration: null,
},
qualitySettings,
routePolicy: DEFAULT_ROUTE_POLICY,
};
}
describe("browser-local display name", () => {
it("localizes text defaults and uses visual emoji identities", () => {
setCopy({ lang: "zh", vis: false });
expect(defaultViewerDisplayName(false)).toBe("观众");
expect(defaultHostDisplayName(false)).toBe("房主");
setCopy({ lang: "en", vis: false });
expect(defaultViewerDisplayName(false)).toBe("Viewer");
expect(defaultHostDisplayName(false)).toBe("Host");
expect(defaultViewerDisplayName(true)).toBe("👤");
expect(defaultHostDisplayName(true)).toBe("📺");
});
it("stores only the canonical preference and falls back when cleared", () => {
const values = new Map<string, string>();
vi.stubGlobal("window", {
localStorage: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
},
});
expect(saveDisplayName(" Cafe\u0301\u00a0玩家 ")).toBe("Café 玩家");
expect(readDisplayName()).toBe("Café 玩家");
expect(saveDisplayName("\u202ehidden")).toBeNull();
expect(saveDisplayName("\n")).toBeNull();
expect(readDisplayName()).toBe("Café 玩家");
expect(saveDisplayName(" ")).toBe("观众");
expect(readDisplayName()).toBe("观众");
});
it("extends only colliding room-scoped peer ID suffixes", () => {
const { viewers: labeled } = labelParticipantSnapshot([
{
role: "viewer",
peerId: "viewer_AAAAAAsuffix",
displayName: "同名",
upstream: { kind: "peer", peerId: "host_12345678" },
},
{
role: "viewer",
peerId: "viewer_BBBBBBsuffix",
displayName: "同名",
upstream: { kind: "peer", peerId: "viewer_AAAAAAsuffix" },
},
{
role: "viewer",
peerId: "viewer_independent",
displayName: "朋友",
upstream: { kind: "sfu" },
},
]);
expect(labeled[0].peerIdSuffix.length).toBeGreaterThan(6);
expect(labeled[1].peerIdSuffix.length).toBeGreaterThan(6);
expect(labeled[0].peerIdSuffix).not.toBe(labeled[1].peerIdSuffix);
expect(labeled[2].peerIdSuffix).toHaveLength(6);
expect(labeled[2].label).toBe("朋友");
expect(labeled[0].label).toContain("同名 (");
});
it("keeps the shortest hidden suffix for distinct display names", () => {
const { viewers: labeled } = labelParticipantSnapshot([
{
role: "viewer",
peerId: "viewer_AAAAAAsuffix",
displayName: "甲",
upstream: { kind: "sfu" },
},
{
role: "viewer",
peerId: "viewer_BBBBBBsuffix",
displayName: "乙",
upstream: { kind: "sfu" },
},
]);
expect(labeled.map((viewer) => viewer.label)).toEqual(["甲", "乙"]);
expect(labeled.map((viewer) => viewer.peerIdSuffix.length)).toEqual([6, 6]);
});
it("uses the minimum suffix when duplicate names are already distinct", () => {
const { viewers: labeled } = labelParticipantSnapshot([
{
role: "viewer",
peerId: "viewer_AAAAAA111111",
displayName: "同名",
upstream: { kind: "none" },
},
{
role: "viewer",
peerId: "viewer_BBBBBB222222",
displayName: "同名",
upstream: { kind: "none" },
},
]);
expect(labeled.map((viewer) => viewer.peerIdSuffix)).toEqual([
"111111",
"222222",
]);
expect(labeled.map((viewer) => viewer.label)).toEqual([
"同名 (111111)",
"同名 (222222)",
]);
});
it("labels every Viewer in a participant snapshot without including the Host", () => {
const { viewers: labeled } = labelParticipantSnapshot([
{
role: "host",
peerId: "host_12345678",
displayName: "分享者",
upstream: { kind: "none" },
},
{
role: "viewer",
peerId: "viewer_111111",
displayName: "朋友甲",
upstream: { kind: "peer", peerId: "host_12345678" },
},
{
role: "viewer",
peerId: "viewer_222222",
displayName: "朋友乙",
upstream: { kind: "sfu" },
},
]);
expect(labeled.map((viewer) => viewer.label)).toEqual([
"朋友甲",
"朋友乙",
]);
});
it("labels Host and Viewer name collisions before splitting roles", () => {
const labeled = labelParticipantSnapshot([
{
role: "host",
peerId: "host_123456",
displayName: "同名",
upstream: { kind: "none" },
},
{
role: "viewer",
peerId: "viewer_654321",
displayName: "同名",
upstream: { kind: "peer", peerId: "host_123456" },
},
{
role: "viewer",
peerId: "viewer_789012",
displayName: "唯一名称",
upstream: { kind: "sfu" },
},
]);
expect(labeled.host?.label).toBe("同名 (123456)");
expect(labeled.viewers.map((viewer) => viewer.label)).toEqual([
"同名 (654321)",
"唯一名称",
]);
});
it("derives exact peer and SFU branches without guessing orphaned routes", () => {
const viewer = (
peerId: string,
upstream:
| { kind: "none" }
| { kind: "sfu" }
| { kind: "peer"; peerId: string },
) => ({ role: "viewer" as const, peerId, displayName: peerId, upstream });
const topology = deriveParticipantTopology(
"host_12345678",
labelParticipantSnapshot([
viewer("viewer_root_1", { kind: "peer", peerId: "host_12345678" }),
viewer("viewer_child_1", { kind: "peer", peerId: "viewer_root_1" }),
viewer("viewer_sfu_1", { kind: "sfu" }),
viewer("viewer_sfu_child", {
kind: "peer",
peerId: "viewer_sfu_1",
}),
viewer("viewer_pending", { kind: "none" }),
viewer("viewer_orphan", { kind: "peer", peerId: "viewer_offline" }),
]).viewers,
);
expect(topology.peerRoots[0].viewer.peerId).toBe("viewer_root_1");
expect(topology.peerRoots[0].children[0].viewer.peerId).toBe(
"viewer_child_1",
);
expect(topology.sfuRoots[0].children[0].viewer.peerId).toBe(
"viewer_sfu_child",
);
expect(topology.pending.map((entry) => entry.peerId)).toEqual([
"viewer_orphan",
"viewer_pending",
]);
});
});
function hostRoomStorage(lockState: "available" | "held" | "absent" = "available") {
const local = new Map<string, string>();
const session = new Map<string, string>();
const held = new Set<string>();
const storage = (values: Map<string, string>) => ({
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
});
const request = vi.fn(async (name: string, _options: LockOptions, callback: (lock: Lock | null) => Promise<void>) => {
if (lockState === "held" || held.has(name)) return callback(null);
held.add(name);
try { await callback({ name, mode: "exclusive" } as Lock); }
finally { held.delete(name); }
});
vi.stubGlobal("window", {
localStorage: storage(local), sessionStorage: storage(session),
navigator: { locks: lockState === "absent" ? undefined : { request } },
});
return { local, session, held, request };
}
const storedHost = (roomId = "1234", hostToken = "a".repeat(32)) => ({
roomId, hostToken, inviteUrl: `https://share.test/r/${roomId}`,
});
describe("client session identity", () => {
it("works without secure-context-only crypto.randomUUID", () => {
const values = new Map<string, string>();
vi.stubGlobal("window", {
localStorage: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
},
});
vi.stubGlobal("crypto", {
getRandomValues: (bytes: Uint8Array) => {
bytes.fill(0xab);
return bytes;
},
});
const first = getStableClientId("viewer", "room-id-1234");
expect(first).toBe("ab".repeat(16));
expect(getStableClientId("viewer", "room-id-1234")).toBe(first);
});
it("persists a tab room and one validated origin resume hint without Viewer grants", async () => {
const { local: values, session } = hostRoomStorage();
const room = {
roomId: "1234",
hostToken: "a".repeat(32),
inviteUrl: `https://share.test/r/1234#v=${"b".repeat(21)}A`,
codeEntryPolicy: "open" as const,
};
await writeHostRoom(room);
expect(await readHostRoom()).toEqual({
roomId: "1234",
hostToken: "a".repeat(32),
canonicalUrl: "https://share.test/r/1234",
});
expect(values.get("piik:host-room:v1")).toBe(
JSON.stringify({
roomId: "1234",
hostToken: "a".repeat(32),
inviteUrl: "https://share.test/r/1234",
}),
);
expect([...values.values()].join(" ")).not.toContain(`${"b".repeat(21)}A`);
expect(session.get("piik:host-room:v1")).toBe(values.get("piik:host-room:v1"));
clearHostRoom();
expect(await readHostRoom()).toBeNull();
});
it("restores an origin resume hint only after claiming its room", async () => {
const { local, session, request } = hostRoomStorage();
local.set("piik:host-room:v1", JSON.stringify(storedHost("1234", "h".repeat(32))));
expect(await readHostRoom()).toEqual({
roomId: "1234",
hostToken: "h".repeat(32),
canonicalUrl: "https://share.test/r/1234",
});
expect(JSON.parse(session.get("piik:host-room:v1")!)).toEqual(JSON.parse(local.get("piik:host-room:v1")!));
expect(request).toHaveBeenCalledWith(expect.stringMatching(/^piik:host-room:1234:[a-f0-9]{64}$/), { ifAvailable: true }, expect.any(Function));
expect(request.mock.calls[0]![0]).not.toContain("h".repeat(32));
});
it("keeps stored room authority despite unowned metadata and discards malformed records", async () => {
const { local: values, session } = hostRoomStorage();
values.set("piik:host-room:v1", JSON.stringify({ ...storedHost(), unusedMetadata: 123 }));
expect(await readHostRoom()).toEqual({
roomId: "1234", hostToken: "a".repeat(32), canonicalUrl: "https://share.test/r/1234",
});
expect(JSON.parse(session.get("piik:host-room:v1")!)).toEqual(storedHost());
releaseHostRoom();
session.delete("piik:host-room:v1");
values.set("piik:host-room:v1", "not-json");
expect(await readHostRoom()).toBeNull();
expect(values.has("piik:host-room:v1")).toBe(false);
});
it("rejects a copied tab room while another tab holds it without deleting that hint", async () => {
const { local, session } = hostRoomStorage("held");
const saved = JSON.stringify(storedHost());
local.set("piik:host-room:v1", saved);
session.set("piik:host-room:v1", saved);
expect(await readHostRoom()).toBeNull();
expect(session.has("piik:host-room:v1")).toBe(false);
expect(local.get("piik:host-room:v1")).toBe(saved);
});
it("prefers this tab's room over another tab's latest hint and preserves that hint when forgotten", async () => {
const { local, session } = hostRoomStorage();
session.set("piik:host-room:v1", JSON.stringify(storedHost()));
const other = JSON.stringify(storedHost("5678", "b".repeat(32)));
local.set("piik:host-room:v1", other);
expect(await readHostRoom()).toMatchObject({ roomId: "1234" });
clearHostRoom();
expect(session.has("piik:host-room:v1")).toBe(false);
expect(local.get("piik:host-room:v1")).toBe(other);
});
it("retains both records on unmount, reclaims on refresh, and releases the old claim on rotation", async () => {
const { local, session, held, request } = hostRoomStorage();
const room = storedHost();
await writeHostRoom({ ...room, codeEntryPolicy: "open" });
const stored = local.get("piik:host-room:v1");
releaseHostRoom();
expect(session.get("piik:host-room:v1")).toBe(stored);
expect(local.get("piik:host-room:v1")).toBe(stored);
expect(await readHostRoom()).toMatchObject({ roomId: "1234" });
expect(request).toHaveBeenCalledTimes(2);
await writeHostRoom({ ...storedHost("5678", "b".repeat(32)), codeEntryPolicy: "open" });
expect(held.size).toBe(1);
expect([...held][0]).toContain(":5678:");
});
it("coalesces concurrent restoration and preserves records when an unmounted restore is cancelled", async () => {
const { local, session, request } = hostRoomStorage();
local.set("piik:host-room:v1", JSON.stringify(storedHost()));
const first = await Promise.all([readHostRoom(), readHostRoom()]);
expect(first[0]).toEqual(first[1]);
expect(request).toHaveBeenCalledOnce();
releaseHostRoom();
const cancelled = readHostRoom();
releaseHostRoom();
expect(await cancelled).toBeNull();
expect(session.has("piik:host-room:v1")).toBe(true);
expect(await readHostRoom()).toMatchObject({ roomId: "1234" });
});
it("does not reuse the origin hint without Web Locks but keeps same-tab refresh authority", async () => {
const { local, session } = hostRoomStorage("absent");
const saved = JSON.stringify(storedHost());
local.set("piik:host-room:v1", saved);
expect(await readHostRoom()).toBeNull();
session.set("piik:host-room:v1", saved);
expect(await readHostRoom()).toMatchObject({ roomId: "1234" });
releaseHostRoom();
expect(await readHostRoom()).toMatchObject({ roomId: "1234" });
});
it("does not erase the resume hint when the server replaces this Host session", async () => {
const { local, session } = hostRoomStorage();
await writeHostRoom({ ...storedHost(), codeEntryPolicy: "open" });
const saved = local.get("piik:host-room:v1");
clearHostRoom(true);
expect(session.has("piik:host-room:v1")).toBe(false);
expect(local.get("piik:host-room:v1")).toBe(saved);
});
it("keeps same-tab authority when Web Locks are denied, without restoring the origin hint", async () => {
const { local, session, request } = hostRoomStorage();
request.mockRejectedValue(new DOMException("Blocked", "SecurityError"));
const saved = JSON.stringify(storedHost());
local.set("piik:host-room:v1", saved);
expect(await readHostRoom()).toBeNull();
session.set("piik:host-room:v1", saved);
expect(await readHostRoom()).toMatchObject({ roomId: "1234" });
expect(session.get("piik:host-room:v1")).toBeTruthy();
});
it("does not persist an obsolete creation and gives a reused code a distinct incarnation claim", async () => {
const { local, held, request } = hostRoomStorage();
let owns = true;
const cancelled = writeHostRoom({ ...storedHost(), codeEntryPolicy: "open" }, () => owns);
owns = false;
expect(await cancelled).toBe(false);
expect(local.size).toBe(0);
await writeHostRoom({ ...storedHost(), codeEntryPolicy: "open" });
const first = request.mock.calls.at(-1)![0];
await writeHostRoom({ ...storedHost("1234", "b".repeat(32)), codeEntryPolicy: "open" });
expect(request.mock.calls.at(-1)![0]).not.toBe(first);
expect(held.size).toBe(1);
});
it("retains the tab claim when a share's metadata update becomes obsolete", async () => {
const { held, request, local } = hostRoomStorage();
const room = { ...storedHost(), codeEntryPolicy: "open" as const };
await writeHostRoom(room);
const saved = local.get("piik:host-room:v1");
let owns = true;
const writing = writeHostRoom({ ...room, inviteUrl: "https://new.test/r/1234" }, () => owns);
owns = false;
expect(await writing).toBe(false);
expect(held.size).toBe(1);
expect(local.get("piik:host-room:v1")).toBe(saved);
expect(await readHostRoom()).toMatchObject({ roomId: "1234", canonicalUrl: "https://share.test/r/1234" });
expect(request).toHaveBeenCalledOnce();
});
it("consumes a Viewer grant fragment once into room-scoped session storage", () => {
const values = new Map<string, string>();
const replaceState = vi.fn();
const grant = `${"c".repeat(21)}g`;
vi.stubGlobal("window", {
location: new URL(`https://share.test/r/1234#v=${grant}`),
history: { state: { navigation: 1 }, replaceState },
sessionStorage: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
},
});
expect(readViewerRoute()).toEqual({ roomId: "1234", viewerGrant: grant });
expect(values.get("piik:viewer-grant:1234")).toBe(grant);
expect(replaceState).toHaveBeenCalledWith(
{ navigation: 1 },
"",
"/r/1234",
);
expect(readViewerGrant("8")).toBeNull();
});
it("fails malformed Viewer fragments closed without leaking across rooms", () => {
const values = new Map<string, string>();
const oldGrant = `${"d".repeat(21)}w`;
values.set("piik:viewer-grant:1234", oldGrant);
vi.stubGlobal("window", {
location: new URL("https://share.test/r/1234#v=malformed"),
history: { state: null, replaceState: vi.fn() },
sessionStorage: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
},
});
expect(readViewerRoute()).toEqual({ roomId: "1234", invalidGrant: true });
expect(readViewerGrant("1234")).toBeNull();
replaceViewerInvite("1234", `https://share.test/r/1234#v=${"e".repeat(21)}Q`);
expect(readViewerGrant("1234")).toBe(`${"e".repeat(21)}Q`);
replaceViewerInvite("1234", `https://share.test/r/5678#v=${"f".repeat(21)}A`);
expect(readViewerGrant("1234")).toBeNull();
expect(readViewerGrant("5678")).toBeNull();
});
it("keeps the stored Viewer grant when the URL carries an unrelated fragment", () => {
const values = new Map<string, string>();
const grant = `${"d".repeat(21)}w`;
values.set("piik:viewer-grant:1234", grant);
const replaceState = vi.fn();
vi.stubGlobal("window", {
location: new URL("https://share.test/r/1234#:~:text=hello"),
history: { state: null, replaceState },
sessionStorage: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
},
});
expect(readViewerRoute()).toEqual({ roomId: "1234", viewerGrant: grant });
expect(readViewerGrant("1234")).toBe(grant);
expect(replaceState).not.toHaveBeenCalled();
});
it("consumes renewed same-page invitations and ignores unrelated fragments", () => {
const events = new EventTarget();
const values = new Map<string, string>();
const location = new URL("https://share.test/r/1234");
vi.stubGlobal("window", {
location,
addEventListener: events.addEventListener.bind(events),
removeEventListener: events.removeEventListener.bind(events),
history: { state: null, replaceState: (_state: unknown, _unused: string, url: string) => {
location.href = new URL(url, location).href;
} },
sessionStorage: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
},
});
const changed = vi.fn();
const stop = watchViewerInvites(changed);
const first = `${"g".repeat(21)}g`;
const renewed = `${"h".repeat(21)}w`;
for (const grant of [first, renewed]) {
location.hash = `v=${grant}`;
events.dispatchEvent(new Event("hashchange"));
expect(changed).toHaveBeenLastCalledWith({ roomId: "1234", viewerGrant: grant });
expect(readViewerGrant("1234")).toBe(grant);
expect(location.hash).toBe("");
}
location.hash = "section";
events.dispatchEvent(new Event("hashchange"));
expect(changed).toHaveBeenCalledTimes(2);
location.hash = "v=invalid";
events.dispatchEvent(new Event("hashchange"));
expect(changed).toHaveBeenLastCalledWith({ roomId: "1234", invalidGrant: true });
expect(readViewerGrant("1234")).toBeNull();
stop();
location.hash = `v=${renewed}`;
events.dispatchEvent(new Event("hashchange"));
expect(changed).toHaveBeenCalledTimes(3);
});
it("persists a rotated Viewer invitation across reload and clears it on revoke", () => {
const values = new Map<string, string>();
vi.stubGlobal("window", {
sessionStorage: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
},
});
const firstGrant = `${"g".repeat(21)}g`;
const rotatedGrant = `${"h".repeat(21)}w`;
replaceViewerInvite("1234", `https://share.test/r/1234#v=${firstGrant}`);
replaceViewerInvite("1234", `https://share.test/r/1234#v=${rotatedGrant}`);
expect(readViewerGrant("1234")).toBe(rotatedGrant);
expect(readViewerGrant("1234")).toBe(rotatedGrant);
replaceViewerInvite("1234", null);
expect(readViewerGrant("1234")).toBeNull();
});
it("keeps the latest rotated or revoked invite through Host re-authentication", () => {
const values = new Map<string, string>();
vi.stubGlobal("window", {
sessionStorage: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
},
});
const oldInvite = `https://share.test/r/1234#v=${"i".repeat(21)}Q`;
const rotatedInvite = `https://share.test/r/1234#v=${"j".repeat(21)}A`;
replaceViewerInvite("1234", oldInvite);
const activeRoom = {
roomId: "1234",
hostToken: "h".repeat(32),
canonicalUrl: "https://share.test/r/1234",
codeEntryPolicy: "open" as const,
inviteUrl: oldInvite,
};
expect(
mergeAuthenticatedHostRoom(
{ ...activeRoom, inviteUrl: rotatedInvite },
activeRoom.roomId,
"open",
)?.inviteUrl,
).toBe(rotatedInvite);
expect(
mergeAuthenticatedHostRoom(
{ ...activeRoom, inviteUrl: null },
activeRoom.roomId,
"open",
)?.inviteUrl,
).toBe(oldInvite);
replaceViewerInvite(activeRoom.roomId, null);
expect(
mergeAuthenticatedHostRoom(
{ ...activeRoom, inviteUrl: null },
activeRoom.roomId,
"open",
)?.inviteUrl,
).toBeNull();
});
it("keeps the latest preferred room without a client expiry", () => {
const values = new Map<string, string>();
vi.stubGlobal("window", {
localStorage: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
},
});
writePreferredRoom("4321");
clearHostRoom();
expect(readPreferredRoomId()).toBe("4321");
expect(values.get("piik:host-room-preference:v1")).toBe(
JSON.stringify({ roomId: "4321" }),
);
});
it("removes a legacy Viewer grant from room-scoped session storage", () => {
const values = new Map<string, string>([
["piik:viewer-grant:1234", `g1.1234.1.${"f".repeat(43)}`],
]);
const removeItem = vi.fn((key: string) => values.delete(key));
vi.stubGlobal("window", {
sessionStorage: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem,
},
});
expect(readViewerGrant("1234")).toBeNull();
expect(removeItem).toHaveBeenCalledWith("piik:viewer-grant:1234");
});
});
describe("site access API", () => {
it.each(["zh", "en"] as const)("explains rejected origins across write APIs in %s", async (lang) => {
setCopy({ lang, vis: false });
vi.stubGlobal("fetch", vi.fn<typeof fetch>().mockImplementation(async () =>
new Response(JSON.stringify({ error: "Origin not allowed" }), { status: 403 }),
));
const message = lang === "zh"
? "服务器未允许当前网址 (403)。请联系站点管理员检查 PUBLIC_BASE_URL 和 ALLOWED_ORIGINS。"
: "This address is not allowed by the server (403). Ask the site administrator to check PUBLIC_BASE_URL and ALLOWED_ORIGINS.";
for (const request of [
() => authenticateSiteAccess("password"),
() => createRoom("open", null),
() => replaceOwnedRoom("4321", "host-token", "open", null),
() => updateRoomAccess("4321", "host-token", { action: "rotate-viewer-grant" }),
]) {
await expect(request()).rejects.toMatchObject({ status: 403, message });
}
});
it("does not diagnose arbitrary proxy rejection bodies as an origin mismatch", async () => {
for (const body of [JSON.stringify({ error: "Forbidden" }), "<html>Forbidden</html>"]) {
vi.stubGlobal("fetch", vi.fn<typeof fetch>().mockResolvedValue(new Response(body, { status: 403 })));
await expect(createRoom("open", null)).rejects.toMatchObject({
status: 403,
message: expect.not.stringContaining("ALLOWED_ORIGINS"),
});
}
});
it("checks site access and sends the exact password in a Unicode-capable JSON body", async () => {
const fetchMock = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(
new Response(JSON.stringify({ required: true, authenticated: false }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ required: true, authenticated: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
await expect(getSiteAccess()).resolves.toEqual({
required: true,
authenticated: false,
});
await expect(authenticateSiteAccess(" 中文 +& ")).resolves.toEqual({
required: true,
authenticated: true,
});
expect(fetchMock.mock.calls[0]).toEqual([
"/api/site-access",
{ headers: { Accept: "application/json" }, cache: "no-store", signal: undefined },
]);
const post = fetchMock.mock.calls[1][1];
expect(post?.method).toBe("POST");
expect(JSON.parse(String(post?.body))).toEqual({ password: " 中文 +& " });
expect(new Headers(post?.headers).get("Content-Type")).toBe("application/json");
expect(new Headers(post?.headers).has("Authorization")).toBe(false);
});
it("uses a clear Chinese message for a rejected password", async () => {
vi.stubGlobal(
"fetch",
vi.fn<typeof fetch>().mockResolvedValue(
new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
}),
),
);
await expect(authenticateSiteAccess("wrong-password")).rejects.toMatchObject({
status: 401,
message: "站点口令不正确,请重试",
});
});
it("does not expose an English server error body to the access page", async () => {
vi.stubGlobal(
"fetch",
vi.fn<typeof fetch>().mockResolvedValue(
new Response(JSON.stringify({ error: "Internal Server Error" }), {
status: 503,
headers: { "Content-Type": "application/json" },
}),
),
);
await expect(getSiteAccess()).rejects.toMatchObject({
status: 503,
message: "站点验证服务暂时不可用 (503)",
});
});
it("does not send a raw bearer token when creating a room by default", async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(
new Response(JSON.stringify({ error: "test response" }), {
status: 500,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
await expect(createRoom("open", null)).rejects.toBeInstanceOf(ApiError);
const headers = new Headers(fetchMock.mock.calls[0][1]?.headers);
expect(headers.has("Authorization")).toBe(false);
expect(fetchMock.mock.calls[0][1]?.body).toBe(
JSON.stringify({ codeEntryPolicy: "open" }),
);
});
it("sends only the preferred room code when one is current", async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(
new Response(JSON.stringify({ error: "test response" }), {
status: 500,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
await expect(createRoom("open", null, "4321")).rejects.toBeInstanceOf(
ApiError,
);
expect(fetchMock.mock.calls[0][1]?.body).toBe(
JSON.stringify({ codeEntryPolicy: "open", preferredRoomId: "4321" }),
);
});
it("sends exact Host authority and no preferred code for room replacement", async () => {
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(
new Response(JSON.stringify({ error: "test response" }), {
status: 500,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
await expect(
replaceOwnedRoom("4321", "host-token", "private", "room-password"),
).rejects.toBeInstanceOf(ApiError);
expect(fetchMock.mock.calls[0][0]).toBe("/api/rooms/4321/replacement");
const headers = new Headers(fetchMock.mock.calls[0][1]?.headers);
expect(headers.get("Authorization")).toBe("Bearer host-token");
expect(fetchMock.mock.calls[0][1]?.body).toBe(
JSON.stringify({
codeEntryPolicy: "private",
roomPassword: "room-password",
}),
);
});
it("accepts room creation with permanent authority", async () => {
const room = {
roomId: "1234",
hostToken: "c".repeat(32),
inviteUrl: `https://share.test/r/1234#v=${"f".repeat(21)}A`,
codeEntryPolicy: "open",
};
vi.stubGlobal(
"fetch",
vi.fn<typeof fetch>().mockResolvedValue(
new Response(JSON.stringify(room), {
status: 201,
headers: { "Content-Type": "application/json" },
}),
),
);
await expect(createRoom("open", null)).resolves.toEqual(room);
});
});
describe("room codes", () => {
it("accepts exactly four decimal digits without a leading zero", () => {
expect(isValidRoomId("1000")).toBe(true);
expect(isValidRoomId("9999")).toBe(true);
expect(isValidRoomId("123")).toBe(false);
expect(isValidRoomId("12345")).toBe(false);
expect(isValidRoomId("0")).toBe(false);
expect(isValidRoomId("0123")).toBe(false);
expect(isValidRoomId("123a")).toBe(false);
expect(roomRouteFromInput("1234")).toBe("/r/1234");
expect(roomRouteFromInput(" 1234 ")).toBeNull();
expect(roomRouteFromInput("1234")).toBeNull();
expect(roomRouteFromInput("0123")).toBeNull();
});
it("drops only the stored grant for an explicit valid room-code entry", () => {
const values = new Map<string, string>([
["piik:viewer-grant:1234", "AAAAAAAAAAAAAAAAAAAAAA"],
["piik:viewer-grant:5678", "AAAAAAAAAAAAAAAAAAAAAA"],
]);
vi.stubGlobal("window", {
sessionStorage: {
removeItem: (key: string) => values.delete(key),
},
});
expect(roomRouteForExplicitEntry("1234")).toBe("/r/1234");
expect(values.has("piik:viewer-grant:1234")).toBe(false);
expect(values.has("piik:viewer-grant:5678")).toBe(true);
expect(roomRouteForExplicitEntry(" 5678 ")).toBeNull();
expect(values.has("piik:viewer-grant:5678")).toBe(true);
});
it("classifies routes without normalizing malformed room input", () => {
expect(parseAppRoute("/client")).toEqual({ kind: "client" });
expect(parseAppRoute("/client/")).toEqual({ kind: "client" });
expect(parseAppRoute("/")).toEqual({ kind: "host" });
expect(parseAppRoute("/join")).toEqual({ kind: "join" });
expect(parseAppRoute("/join/")).toEqual({ kind: "join" });
expect(parseAppRoute("/r/1000")).toEqual({
kind: "viewer",
roomId: "1000",
});
expect(parseAppRoute("/r/9999/")).toEqual({
kind: "viewer",
roomId: "9999",
});
for (const pathname of [
"/r",
"/r/",
"/r/123",
"/r/12345",
"/r/0000",
"/r/abcd",
"/r/1234",
"/r/1234/extra",
]) {
expect(parseAppRoute(pathname)).toEqual({ kind: "malformed-room" });
}
expect(parseAppRoute("/other")).toEqual({ kind: "unknown" });
});
it("consumes one exact App access bootstrap without retaining it in the URL", () => {
const replaceState = vi.fn();
vi.stubGlobal("window", {
location: {
hash: `#client-access=${"a".repeat(32)}`,
pathname: "/",
search: "?mode=local",
},
history: { state: { current: true }, replaceState },
});
expect(takeClientLaunchBootstrap()).toMatchObject({
accessToken: "a".repeat(32),
launchedByClient: false,
});
expect(replaceState).toHaveBeenCalledWith(
{ current: true },
"",
"/?mode=local",
);
});
it("removes an empty App bootstrap without authenticating it", () => {
const replaceState = vi.fn();
vi.stubGlobal("window", {
location: { hash: "#client-access=", pathname: "/", search: "" },
history: { state: null, replaceState },
});
expect(takeClientLaunchBootstrap().accessToken).toBeNull();
expect(replaceState).toHaveBeenCalledWith(null, "", "/");
});
it.each(["x", "中文", " ", " 中文 +& ", "x".repeat(256)])("preserves a chosen App access value: %s", (password) => {
const replaceState = vi.fn();
vi.stubGlobal("window", {
location: {
hash: `#${new URLSearchParams({ "client-access": password })}`,
pathname: "/",
search: "",
},
history: { state: null, replaceState },
});
expect(takeClientLaunchBootstrap().accessToken).toBe(password);
expect(replaceState).toHaveBeenCalledWith(null, "", "/");
});
it("consumes the App launch marker from a server-private fragment", () => {
const replaceState = vi.fn();
const session = new Map<string, string>();
vi.stubGlobal("window", {
location: {
hash: `#client-access=${"a".repeat(32)}&piik-client=1&retained=yes`,
pathname: "/",
search: "?room=6020",
},
history: { state: null, replaceState },
localStorage: {
getItem: (key: string) => session.get(key) ?? null,
setItem: (key: string, value: string) => session.set(key, value),
},
});
expect(takeClientLaunchBootstrap()).toEqual({
accessToken: "a".repeat(32),
launchedByClient: true,
presentation: null,
});
expect(replaceState).toHaveBeenCalledWith(
null,
"",
"/?room=6020#retained=yes",
);
window.location.hash = "";
expect(takeClientLaunchBootstrap()).toEqual({
accessToken: null,
launchedByClient: true,
presentation: null,
});
});
it.each([
{ lang: "zh", vis: false, theme: "light" },
{ lang: "en", vis: true, theme: null },
] as const)("carries launcher presentation across origins: %j", (presentation) => {
const target = new URL(clientLaunchURL(
"https://site.example/?room=9527#piik-client=1&client-access=a%2Bb%26c%3Fd%3De&retained=yes",
presentation,
));
const replaceState = vi.fn();
vi.stubGlobal("window", {
location: target,
history: { state: null, replaceState },
});
expect(target.origin).toBe("https://site.example");
expect(takeClientLaunchBootstrap()).toEqual({
accessToken: "a+b&c?d=e",
launchedByClient: true,
presentation: { ...presentation, explicit: [] },
});
expect(replaceState).toHaveBeenCalledWith(null, "", "/?room=9527#retained=yes");
});
it.each([
["piik-lang=zh&piik-mode=text&piik-theme=light", null],
["piik-client=1&piik-lang=other&piik-mode=text&piik-theme=light", { vis: false, theme: "light", explicit: [] }],
["piik-client=1&piik-lang=zh&piik-mode=other&piik-theme=light", { lang: "zh", theme: "light", explicit: [] }],
["piik-client=1&piik-lang=zh&piik-mode=text&piik-theme=other", { lang: "zh", vis: false, explicit: [] }],
])("ignores invalid preference fields without losing App access: %s", (fragment, presentation) => {
const replaceState = vi.fn();
vi.stubGlobal("window", {
location: { hash: `#${fragment}&client-access=${"a".repeat(32)}`, pathname: "/", search: "" },
history: { state: null, replaceState },
});
expect(takeClientLaunchBootstrap()).toMatchObject({
accessToken: "a".repeat(32),
presentation,
});
expect(replaceState).toHaveBeenCalledWith(null, "", "/");
});
});
describe("client signaling recovery policy", () => {
it("reconciles a stop requested before Host authentication", () => {
vi.useFakeTimers();
const sockets: FakeWebSocket[] = [];
class FakeWebSocket extends EventTarget {
static readonly OPEN = 1;
static readonly CLOSING = 2;
readyState = FakeWebSocket.OPEN;
readonly send = vi.fn();
readonly close = vi.fn();
constructor() { super(); sockets.push(this); }
}
vi.stubGlobal("WebSocket", FakeWebSocket);
vi.stubGlobal("window", {
location: new URL("https://share.test/"),
setTimeout,
clearTimeout,
});
const signal = new SignalingClient(
{
roomId: "1234",
role: "host",
token: "h".repeat(43),
clientId: "host-client",
roomSession: true,
shareGeneration: "share_generation_12345678",
routePolicy: DEFAULT_ROUTE_POLICY,
},
{
onMessage: () => undefined,
onStatus: () => undefined,
onTerminated: () => undefined,
onAccessRequired: () => undefined,
},
);
const receive = (value: object) => {
const event = new Event("message");
Object.defineProperty(event, "data", { value: JSON.stringify(value) });
sockets[0]!.dispatchEvent(event);
};
signal.start();
sockets[0]!.dispatchEvent(new Event("open"));
expect(signal.stopSharing("share_generation_12345678")).toBe(false);
receive({
type: "authenticated",
protocol: SIGNALING_PROTOCOL,
role: "host",
peerId: "host_12345678",
maxViewers: 20,
endpointMediaCopyCapacity: 2,
hostOnline: true,
connectionId: null,
...routeAuthenticated("share_generation_12345678"),
iceConfig: { iceServers: [], natPredictionStunUrls: [] },
codeEntryPolicy: "open",
viewerPasswordEnabled: false,
viewerAuthorizationGeneration: "viewer_generation_12345678",
});
expect(
sockets[0]!.send.mock.calls.map(([value]) => JSON.parse(String(value))),
).toEqual([
expect.objectContaining({ type: "authenticate", roomSession: true }),
{ type: "stop-sharing", shareGeneration: "share_generation_12345678" },
]);
signal.stop();
});
it("does not resend a start already claimed by Host authentication", () => {
vi.useFakeTimers();
const sockets: FakeWebSocket[] = [];
class FakeWebSocket extends EventTarget {
static readonly OPEN = 1;
static readonly CLOSING = 2;
readyState = FakeWebSocket.OPEN;
readonly send = vi.fn();
readonly close = vi.fn();
constructor() { super(); sockets.push(this); }
}
vi.stubGlobal("WebSocket", FakeWebSocket);
vi.stubGlobal("window", {
location: new URL("https://share.test/"),
setTimeout,
clearTimeout,
});
const signal = new SignalingClient(
{
roomId: "1234",
role: "host",
token: "h".repeat(43),
clientId: "host-client",
roomSession: true,
routePolicy: DEFAULT_ROUTE_POLICY,
},
{
onMessage: () => undefined,
onStatus: () => undefined,
onTerminated: () => undefined,
onAccessRequired: () => undefined,
},
);
const receive = (value: object) => {
const event = new Event("message");
Object.defineProperty(event, "data", { value: JSON.stringify(value) });
sockets[0]!.dispatchEvent(event);
};
const generation = "share_generation_12345678";
signal.startSharing(generation, DEFAULT_QUALITY_SETTINGS, DEFAULT_ROUTE_POLICY);
signal.start();
sockets[0]!.dispatchEvent(new Event("open"));
receive({
type: "authenticated",
protocol: SIGNALING_PROTOCOL,
role: "host",
peerId: "host_12345678",
maxViewers: 20,
endpointMediaCopyCapacity: 2,
hostOnline: true,
connectionId: null,
...routeAuthenticated(generation),
iceConfig: { iceServers: [], natPredictionStunUrls: [] },
codeEntryPolicy: "open",
viewerPasswordEnabled: false,
viewerAuthorizationGeneration: "viewer_generation_12345678",
});
expect(
sockets[0]!.send.mock.calls.map(([value]) => JSON.parse(String(value))),
).toHaveLength(1);
expect(JSON.parse(String(sockets[0]!.send.mock.calls[0]![0]))).toMatchObject({
type: "authenticate",
shareGeneration: generation,
roomSession: true,
});
signal.stop();
});
it.each([false, true])("serializes a stop/start when an old Host authentication reply arrives late (rejected=%s)", (rejected) => {
vi.useFakeTimers();
const sockets: FakeWebSocket[] = [];
class FakeWebSocket extends EventTarget {
static readonly OPEN = 1;
static readonly CLOSING = 2;
readyState = FakeWebSocket.OPEN;
readonly send = vi.fn();
readonly close = vi.fn();
constructor() { super(); sockets.push(this); }
}
vi.stubGlobal("WebSocket", FakeWebSocket);
vi.stubGlobal("window", {
location: new URL("https://share.test/"),
setTimeout,
clearTimeout,
});
const signal = new SignalingClient(
{
roomId: "1234",
role: "host",
token: "h".repeat(43),
clientId: "host-client",
roomSession: true,
routePolicy: DEFAULT_ROUTE_POLICY,
},
{
onMessage: () => undefined,
onStatus: () => undefined,
onTerminated: () => undefined,
onAccessRequired: () => undefined,
},
);
const receive = (value: object) => {
const event = new Event("message");
Object.defineProperty(event, "data", { value: JSON.stringify(value) });
sockets[0]!.dispatchEvent(event);
};
const first = "first_share_generation_12345678";
const next = "next_share_generation_12345678";
signal.startSharing(first, DEFAULT_QUALITY_SETTINGS, DEFAULT_ROUTE_POLICY);
signal.start();
sockets[0]!.dispatchEvent(new Event("open"));
expect(signal.stopSharing(first)).toBe(false);
expect(signal.startSharing(next, DEFAULT_QUALITY_SETTINGS, DEFAULT_ROUTE_POLICY)).toBe(true);
receive({
type: "authenticated",
protocol: SIGNALING_PROTOCOL,
role: "host",
peerId: "host_12345678",
maxViewers: 20,
endpointMediaCopyCapacity: 2,
hostOnline: true,
connectionId: null,
...routeAuthenticated(first),
iceConfig: { iceServers: [], natPredictionStunUrls: [] },
codeEntryPolicy: "open",
viewerPasswordEnabled: false,
viewerAuthorizationGeneration: "viewer_generation_12345678",
});
const messages = sockets[0]!.send.mock.calls.map(([value]) => JSON.parse(String(value)));
expect(messages).toEqual([
expect.objectContaining({ type: "authenticate", shareGeneration: first }),
{ type: "stop-sharing", shareGeneration: first },
expect.objectContaining({ type: "start-sharing", shareGeneration: next }),
]);
if (rejected) {
receive({ type: "sharing-start-failed", shareGeneration: first, code: "FORBIDDEN" });
expect(signal.wantsHostPublication(next)).toBe(true);
receive({ type: "sharing-start-failed", shareGeneration: next, code: "FORBIDDEN" });
expect(signal.wantsHostPublication(next)).toBe(false);
expect(signal.stopSharing(next)).toBe(false);
expect(sockets[0]!.send).toHaveBeenCalledTimes(messages.length);
expect(sockets[0]!.close).not.toHaveBeenCalled();
} else {
expect(signal.stopSharing(next)).toBe(true);
expect(JSON.parse(String(sockets[0]!.send.mock.calls.at(-1)![0]))).toEqual({
type: "stop-sharing", shareGeneration: next,
});
}
signal.stop();
});
it("enables negotiated room data after authentication without replacing signaling or exposing it to media handlers", () => {
vi.useFakeTimers();
const sockets: FakeWebSocket[] = [];
class FakeWebSocket extends EventTarget {
static readonly OPEN = 1;
static readonly CLOSING = 2;
readyState = 1;
bufferedAmount = 0;
send = vi.fn();
close = vi.fn();
constructor() { super(); sockets.push(this); }
}
vi.stubGlobal("WebSocket", FakeWebSocket);
vi.stubGlobal("window", { location: new URL("https://share.test/r/1234"), setTimeout, clearTimeout });
const onMessage = vi.fn();
const signal = new SignalingClient({ roomId: "1234", role: "viewer", clientId: "viewer-client" }, {
onMessage, onStatus: vi.fn(), onTerminated: vi.fn(), onAccessRequired: vi.fn(),
});
const receive = (body: object) => {
const event = new Event("message");
Object.defineProperty(event, "data", { value: JSON.stringify(body) });
sockets[0].dispatchEvent(event);
};
signal.start();
sockets[0].dispatchEvent(new Event("open"));
receive({ type: "authenticated", protocol: SIGNALING_PROTOCOL, role: "viewer", peerId: "viewer_12345678",
maxViewers: 20, endpointMediaCopyCapacity: 2, hostOnline: false, connectionId: null,
iceConfig: { iceServers: [], natPredictionStunUrls: [] }, codeEntryPolicy: "open",
viewerAuthorizationGeneration: "viewer_generation_12345678", ...routeAuthenticated(),
});
expect(signal.interactions).toBeNull();
expect(sockets[0].send).toHaveBeenCalledTimes(1);
const data = signal.enableRoomInteractions()!;
expect(sockets[0].send).toHaveBeenLastCalledWith(JSON.stringify({ type: "subscribe-room-interactions" }));
receive({ type: "room-interactions-ready", serverTime: Date.now() });
expect(data.getSnapshot().ready).toBe(true);
expect(sockets).toHaveLength(1);
expect(sockets[0].close).not.toHaveBeenCalled();
expect(onMessage).toHaveBeenCalledTimes(1); // Only authentication reaches media handling.
sockets[0].bufferedAmount = 20 * 1024;
expect(data.send({ kind: "chat", text: "hello" })).toBe(false);
expect(data.getSnapshot().pending).toBeNull();
signal.stop();
expect(signal.enableRoomInteractions()).toBeNull();
expect(vi.getTimerCount()).toBe(0);
});
it("does not reconnect a session that another tab replaced", () => {
expect(shouldReconnectSignaling(4001)).toBe(false);
expect(shouldReconnectSignaling(4003)).toBe(false);
expect(shouldReconnectSignaling(4004)).toBe(false);
expect(shouldReconnectSignaling(1008)).toBe(false);
expect(shouldReconnectSignaling(1012)).toBe(true);
expect(shouldReconnectSignaling(4002)).toBe(true);
expect(shouldReconnectSignaling(1006)).toBe(true);
});
it("uses the common reconnect state for a recoverable close", () => {
vi.useFakeTimers();
const sockets: FakeWebSocket[] = [];
class FakeWebSocket extends EventTarget {
static readonly OPEN = 1;
static readonly CLOSING = 2;
readyState = FakeWebSocket.OPEN;
readonly send = vi.fn();
readonly close = vi.fn();
constructor(readonly url: string) {
super();
sockets.push(this);
}
}
vi.stubGlobal("WebSocket", FakeWebSocket);
vi.stubGlobal("window", {
location: new URL("https://share.test/r/1234"),
setTimeout,
clearTimeout,
});
const statuses: string[] = [];
const signal = new SignalingClient(
{
roomId: "1234",
role: "viewer",
clientId: "viewer-client",
},
{
onMessage: () => undefined,
onStatus: (status) => statuses.push(status),
onTerminated: () => undefined,
onAccessRequired: () => undefined,
},
);
signal.start();
sockets[0]!.dispatchEvent(new Event("open"));
const closed = new Event("close");
Object.defineProperty(closed, "code", { value: 1012 });
sockets[0]!.dispatchEvent(closed);
expect(statuses.at(-1)).toBe("reconnecting");
signal.stop();
});
it("retains a terminal command until an authenticated socket accepts it", () => {
vi.useFakeTimers();
const sockets: FakeWebSocket[] = [];
class FakeWebSocket extends EventTarget {
static readonly OPEN = 1;
static readonly CLOSING = 2;
readyState = FakeWebSocket.OPEN;
readonly send = vi.fn();
readonly close = vi.fn(() => {
this.readyState = FakeWebSocket.CLOSING;
});
constructor(readonly url: string) {
super();
sockets.push(this);
}
}
vi.stubGlobal("WebSocket", FakeWebSocket);
vi.stubGlobal("window", {
location: new URL("https://share.test/r/1234"),
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
});
const signal = new SignalingClient(
{
roomId: "1234",
role: "viewer",
clientId: "viewer-client",
},
{
onMessage: () => undefined,
onStatus: () => undefined,
onTerminated: () => undefined,
onAccessRequired: () => undefined,
},
);
const receiveAuthenticated = (socket: FakeWebSocket) => {
const event = new Event("message");
Object.defineProperty(event, "data", {
value: JSON.stringify({
type: "authenticated",
protocol: SIGNALING_PROTOCOL,
role: "viewer",
peerId: "viewer_12345678",
maxViewers: 8,
endpointMediaCopyCapacity: 2,
hostOnline: true,
connectionId: null,
...routeAuthenticated(),
iceConfig: { iceServers: [], natPredictionStunUrls: [] },
codeEntryPolicy: "open",
viewerAuthorizationGeneration: "viewer_generation_12345678",
}),
});
socket.dispatchEvent(event);
};
signal.start();
signal.sendThenStop({ type: "abandon-room" });
sockets[0]!.dispatchEvent(new Event("open"));
sockets[0]!.readyState = FakeWebSocket.CLOSING;
receiveAuthenticated(sockets[0]!);
expect(sockets[0]!.send).toHaveBeenCalledOnce();
const closed = new Event("close");
Object.defineProperties(closed, {
code: { value: 1006 },
reason: { value: "network interrupted" },
});
sockets[0]!.dispatchEvent(closed);
vi.advanceTimersByTime(750);
sockets[1]!.dispatchEvent(new Event("open"));
receiveAuthenticated(sockets[1]!);
expect(
sockets[1]!.send.mock.calls.map(([value]) =>
JSON.parse(String(value)),
),
).toEqual([
expect.objectContaining({ type: "authenticate" }),
{ type: "abandon-room" },
]);
expect(sockets[1]!.close).toHaveBeenCalledWith(1000, "client closed");
});
it("classifies a close-only authentication failure without claiming replacement", () => {
const sockets: FakeWebSocket[] = [];
class FakeWebSocket extends EventTarget {
static readonly OPEN = 1;
static readonly CLOSING = 2;
readyState = FakeWebSocket.OPEN;
readonly send = vi.fn();
readonly close = vi.fn();
constructor(readonly url: string) {
super();
sockets.push(this);
}
}
vi.stubGlobal("WebSocket", FakeWebSocket);
vi.stubGlobal("window", {
location: new URL("https://share.test/r/1234"),
setTimeout,
clearTimeout,
});
const onTerminated = vi.fn();
const signal = new SignalingClient(
{
roomId: "1234",
role: "viewer",
clientId: "viewer-client",
},
{
onMessage: () => undefined,
onStatus: () => undefined,
onTerminated,
onAccessRequired: () => undefined,
},
);
signal.start();
sockets[0]!.dispatchEvent(new Event("open"));
const closed = new Event("close");
Object.defineProperties(closed, {
code: { value: 4003 },
reason: { value: "Authentication required" },
});
sockets[0]!.dispatchEvent(closed);
expect(onTerminated).toHaveBeenCalledOnce();
expect(onTerminated).toHaveBeenCalledWith("SIGNAL_TERMINATED");
expect(sockets).toHaveLength(1);
});
it("keeps a Host paused across reconnect until an explicit Resume is sent", () => {
vi.useFakeTimers();
const sockets: FakeWebSocket[] = [];
class FakeWebSocket extends EventTarget {
static readonly OPEN = 1;
static readonly CLOSING = 2;
readyState = FakeWebSocket.OPEN;
readonly send = vi.fn();
readonly close = vi.fn(() => {
this.readyState = FakeWebSocket.CLOSING;
});
constructor(readonly url: string) {
super();
sockets.push(this);
}
}
vi.stubGlobal("WebSocket", FakeWebSocket);
vi.stubGlobal("window", {
location: new URL("https://share.test/r/1234"),
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
});
const signal = new SignalingClient(
{
roomId: "1234",
role: "host",
token: "h".repeat(43),
clientId: "host-client",
routePolicy: DEFAULT_ROUTE_POLICY,
shareGeneration: "share_generation_12345678",
sharingPaused: true,
},
{
onMessage: () => undefined,
onStatus: () => undefined,
onTerminated: () => undefined,
onAccessRequired: () => undefined,
},
);
const receive = (socket: FakeWebSocket, value: object) => {
const event = new Event("message");
Object.defineProperty(event, "data", { value: JSON.stringify(value) });
socket.dispatchEvent(event);
};
const authenticate = (socket: FakeWebSocket) => {
socket.dispatchEvent(new Event("open"));
receive(socket, {
type: "authenticated",
protocol: SIGNALING_PROTOCOL,
role: "host",
peerId: "host_12345678",
maxViewers: 8,
endpointMediaCopyCapacity: 2,
hostOnline: true,
hostPaused: true,
connectionId: null,
...routeAuthenticated("share_generation_12345678"),
iceConfig: { iceServers: [], natPredictionStunUrls: [] },
codeEntryPolicy: "open",
viewerPasswordEnabled: false,
viewerAuthorizationGeneration: "viewer_generation_12345678",
});
};
const reconnect = (socket: FakeWebSocket) => {
const close = new Event("close");
Object.defineProperties(close, {
code: { value: 1006 },
reason: { value: "network interrupted" },
});
socket.dispatchEvent(close);
vi.advanceTimersByTime(750);
};
signal.start();
authenticate(sockets[0]!);
reconnect(sockets[0]!);
sockets[1]!.dispatchEvent(new Event("open"));
expect(JSON.parse(String(sockets[1]!.send.mock.calls[0]![0]))).toMatchObject({
type: "authenticate",
sharingPaused: true,
});
receive(sockets[1]!, {
type: "authenticated",
protocol: SIGNALING_PROTOCOL,
role: "host",
peerId: "host_12345678",
maxViewers: 8,
endpointMediaCopyCapacity: 2,
hostOnline: true,
hostPaused: true,
connectionId: null,
...routeAuthenticated("share_generation_12345678"),
iceConfig: { iceServers: [], natPredictionStunUrls: [] },
codeEntryPolicy: "open",
viewerPasswordEnabled: false,
viewerAuthorizationGeneration: "viewer_generation_12345678",
});
expect(signal.setSharingPaused(false)).toBe(true);
expect(
JSON.parse(String(sockets[1]!.send.mock.calls.at(-1)![0])),
).toMatchObject({
type: "set-sharing-paused",
shareGeneration: "share_generation_12345678",
paused: false,
});
reconnect(sockets[1]!);
sockets[2]!.dispatchEvent(new Event("open"));
expect(JSON.parse(String(sockets[2]!.send.mock.calls[0]![0]))).toMatchObject({
type: "authenticate",
sharingPaused: false,
});
signal.stop();
});
it("replays the latest exact Host quality intent after reauthentication", () => {
vi.useFakeTimers();
const sockets: FakeWebSocket[] = [];
class FakeWebSocket extends EventTarget {
static readonly OPEN = 1;
static readonly CLOSING = 2;
readyState = FakeWebSocket.OPEN;
readonly send = vi.fn();
readonly close = vi.fn(() => {
this.readyState = FakeWebSocket.CLOSING;
});
constructor(readonly url: string) {
super();
sockets.push(this);
}
}
vi.stubGlobal("WebSocket", FakeWebSocket);
vi.stubGlobal("window", {
location: new URL("https://share.test/"),
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
});
const shareGeneration = "share_generation_12345678";
const initialQuality = {
resolution: "1080p",
maxFramerate: 30,
maxBitrate: 5_000_000,
degradationPreference: "balanced",
screenAudioQuality: "music",
} as const;
const firstPending = {
...initialQuality,
resolution: "720p",
maxBitrate: 3_000_000,
} as const;
const latestPending = {
...firstPending,
maxFramerate: 60,
maxBitrate: 8_000_000,
} as const;
let authenticatedCount = 0;
let pendingDuringReauthentication: typeof latestPending | null = null;
let signal!: SignalingClient;
signal = new SignalingClient(
{
roomId: "1234",
role: "host",
token: "h".repeat(43),
clientId: "host-client",
routePolicy: DEFAULT_ROUTE_POLICY,
shareGeneration,
qualitySettings: initialQuality,
},
{
onMessage: (message) => {
if (message.type !== "authenticated") {
return;
}
authenticatedCount += 1;
if (authenticatedCount === 2) {
pendingDuringReauthentication =
signal.pendingHostQualitySettings(shareGeneration) as
| typeof latestPending
| null;
}
},
onStatus: () => undefined,
onTerminated: () => undefined,
onAccessRequired: () => undefined,
},
);
const receiveAuthenticated = (
socket: FakeWebSocket,
qualitySettings: QualitySettings = initialQuality,
) => {
const event = new Event("message");
Object.defineProperty(event, "data", {
value: JSON.stringify({
type: "authenticated",
protocol: SIGNALING_PROTOCOL,
role: "host",
peerId: "host_12345678",
maxViewers: 8,
endpointMediaCopyCapacity: 2,
hostOnline: true,
hostPaused: false,
connectionId: null,
iceConfig: { iceServers: [], natPredictionStunUrls: [] },
codeEntryPolicy: "open",
viewerPasswordEnabled: false,
viewerAuthorizationGeneration: "viewer_generation_12345678",
mediaMode: "peer-assisted",
shareGeneration,
routeRevision: 1,
routeAssignment: {
upstream: { kind: "none" },
childPeerIds: [],
sfuPublicationGeneration: null,
},
qualitySettings,
routePolicy: DEFAULT_ROUTE_POLICY,
}),
});
socket.dispatchEvent(event);
};
signal.start();
sockets[0]!.dispatchEvent(new Event("open"));
receiveAuthenticated(sockets[0]!);
const close = new Event("close");
Object.defineProperties(close, {
code: { value: 1006 },
reason: { value: "network interrupted" },
});
sockets[0]!.dispatchEvent(close);
expect(signal.setHostQualitySettings(firstPending)).toBe(false);
expect(signal.setHostQualitySettings(latestPending)).toBe(false);
expect(
signal.pendingHostQualitySettings("other_share_generation_12345678"),
).toBeNull();
expect(signal.pendingHostQualitySettings(shareGeneration)).toEqual(
latestPending,
);
vi.advanceTimersByTime(750);
sockets[1]!.dispatchEvent(new Event("open"));
expect(JSON.parse(String(sockets[1]!.send.mock.calls[0]![0]))).toMatchObject({
type: "authenticate",
shareGeneration,
qualitySettings: latestPending,
});
receiveAuthenticated(sockets[1]!);
expect(pendingDuringReauthentication).toEqual(latestPending);
expect(sockets[1]!.send).toHaveBeenCalledTimes(2);
expect(
JSON.parse(String(sockets[1]!.send.mock.calls[1]![0])),
).toEqual({
type: "set-quality-settings",
qualitySettings: latestPending,
});
expect(signal.pendingHostQualitySettings(shareGeneration)).toEqual(
latestPending,
);
receiveAuthenticated(sockets[1]!, latestPending);
expect(sockets[1]!.send).toHaveBeenCalledTimes(2);
expect(signal.pendingHostQualitySettings(shareGeneration)).toBeNull();
signal.stop();
});
it("confirms an exact silent partition before replacing the socket immediately", () => {
vi.useFakeTimers();
let now = 0;
const sockets: FakeWebSocket[] = [];
class FakeWebSocket extends EventTarget {
static readonly OPEN = 1;
static readonly CLOSING = 2;
readyState = FakeWebSocket.OPEN;
readonly send = vi.fn();
readonly close = vi.fn((code?: number, reason?: string) => {
void code;
void reason;
this.readyState = FakeWebSocket.CLOSING;
});
constructor(readonly url: string) {
super();
sockets.push(this);
}
}
vi.stubGlobal("WebSocket", FakeWebSocket);
vi.stubGlobal("window", {
location: new URL("https://share.test/r/1234"),
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
});
const statuses: string[] = [];
const onMessage = vi.fn();
const onTerminated = vi.fn();
const signal = new SignalingClient(
{
roomId: "1234",
role: "host",
token: "h".repeat(43),
clientId: "host-client",
routePolicy: DEFAULT_ROUTE_POLICY,
},
{
onMessage,
onStatus: (status) => statuses.push(status),
onTerminated,
onAccessRequired: () => undefined,
},
() => now,
);
const receive = (socket: FakeWebSocket, value: object) => {
const event = new Event("message");
Object.defineProperty(event, "data", { value: JSON.stringify(value) });
socket.dispatchEvent(event);
};
const authenticate = (socket: FakeWebSocket) => {
socket.dispatchEvent(new Event("open"));
receive(socket, {
type: "authenticated",
protocol: SIGNALING_PROTOCOL,
role: "host",
peerId: "host_12345678",
maxViewers: 8,
endpointMediaCopyCapacity: 2,
hostOnline: true,
connectionId: null,
...routeAuthenticated(),
iceConfig: { iceServers: [], natPredictionStunUrls: [] },
codeEntryPolicy: "open",
viewerPasswordEnabled: false,
viewerAuthorizationGeneration: "viewer_generation_12345678",
});
};
const advance = (milliseconds: number) => {
now += milliseconds;
vi.advanceTimersByTime(milliseconds);
};
signal.start();
authenticate(sockets[0]!);
advance(5_000);
const first = JSON.parse(String(sockets[0]!.send.mock.calls.at(-1)![0]));
expect(first).toEqual({ type: "signaling-challenge", sequence: 1 });
advance(2_000);
const confirm = JSON.parse(String(sockets[0]!.send.mock.calls.at(-1)![0]));
expect(confirm).toEqual({ type: "signaling-challenge", sequence: 2 });
receive(sockets[0]!, {
type: "signaling-challenge-response",
sequence: first.sequence,
});
advance(2_000);
expect(sockets).toHaveLength(1);
expect(sockets[0]!.close).not.toHaveBeenCalled();
expect(statuses.at(-1)).toBe("connected");
expect(onTerminated).not.toHaveBeenCalled();
expect(
sockets[0]!.send.mock.calls
.map(([value]) => JSON.parse(String(value)).type)
.includes("route-failed"),
).toBe(false);
advance(3_000);
const next = JSON.parse(String(sockets[0]!.send.mock.calls.at(-1)![0]));
expect(next).toEqual({ type: "signaling-challenge", sequence: 3 });
receive(sockets[0]!, {
type: "signaling-challenge-response",
sequence: next.sequence,
});
advance(2_000);
expect(sockets).toHaveLength(1);
expect(confirm.sequence).toBe(2);
expect(onMessage).toHaveBeenCalledTimes(1);
signal.stop();
});
it("rebases inactive, hidden, and obviously delayed challenge windows", () => {
vi.useFakeTimers();
let now = 0;
let visibilityState: DocumentVisibilityState = "visible";
const documentTarget = new EventTarget();
Object.defineProperty(documentTarget, "visibilityState", {
get: () => visibilityState,
});
const sockets: FakeWebSocket[] = [];
class FakeWebSocket extends EventTarget {
static readonly OPEN = 1;
static readonly CLOSING = 2;
readyState = FakeWebSocket.OPEN;
readonly send = vi.fn();
readonly close = vi.fn();
constructor(readonly url: string) {
super();
sockets.push(this);
}
}
vi.stubGlobal("WebSocket", FakeWebSocket);
vi.stubGlobal("document", documentTarget);
vi.stubGlobal("window", {
location: new URL("https://share.test/r/1234"),
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
});
const onMessage = vi.fn();
const signal = new SignalingClient(
{
roomId: "1234",
role: "viewer",
clientId: "viewer-client",
},
{
onMessage,
onStatus: () => undefined,
onTerminated: () => undefined,
onAccessRequired: () => undefined,
},
() => now,
);
const receive = (value: object) => {
const event = new Event("message");
Object.defineProperty(event, "data", { value: JSON.stringify(value) });
sockets[0]!.dispatchEvent(event);
};
const advance = (milliseconds: number) => {
now += milliseconds;
vi.advanceTimersByTime(milliseconds);
};
signal.start();
sockets[0]!.dispatchEvent(new Event("open"));
receive({
type: "authenticated",
protocol: SIGNALING_PROTOCOL,
role: "viewer",
peerId: "viewer_12345678",
maxViewers: 8,
endpointMediaCopyCapacity: 2,
hostOnline: true,
connectionId: "connection_12345678",
iceConfig: { iceServers: [], natPredictionStunUrls: [] },
codeEntryPolicy: "open",
viewerAuthorizationGeneration: "viewer_generation_12345678",
mediaMode: "peer-assisted",
shareGeneration: "share_generation_12345678",
routeRevision: 1,
routeAssignment: {
upstream: { kind: "none" },
childPeerIds: [],
sfuPublicationGeneration: null,
},
qualitySettings: {
resolution: "1080p",
maxFramerate: 30,
maxBitrate: 5_000_000,
degradationPreference: "balanced",
screenAudioQuality: "music",
},
routePolicy: DEFAULT_ROUTE_POLICY,
});
advance(5_000);
expect(sockets[0]!.send).toHaveBeenCalledTimes(2);
const waiting = JSON.parse(String(sockets[0]!.send.mock.calls.at(-1)![0]));
expect(waiting.type).toBe("signaling-challenge");
receive({
type: "signaling-challenge-response",
sequence: waiting.sequence,
});
receive({
type: "route-update",
revision: 2,
phase: "active",
assignment: {
upstream: { kind: "peer", peerId: "host_12345678" },
childPeerIds: [],
sfuPublicationGeneration: null,
},
});
advance(5_000);
const first = JSON.parse(String(sockets[0]!.send.mock.calls.at(-1)![0]));
expect(first.type).toBe("signaling-challenge");
visibilityState = "hidden";
documentTarget.dispatchEvent(new Event("visibilitychange"));
advance(20_000);
expect(sockets).toHaveLength(1);
expect(sockets[0]!.send).toHaveBeenCalledTimes(3);
visibilityState = "visible";
documentTarget.dispatchEvent(new Event("visibilitychange"));
advance(5_000);
const visible = JSON.parse(String(sockets[0]!.send.mock.calls.at(-1)![0]));
receive({
type: "signaling-challenge-response",
sequence: visible.sequence,
});
now += 7_000;
vi.advanceTimersByTime(5_000);
expect(sockets[0]!.send).toHaveBeenCalledTimes(4);
advance(5_000);
expect(sockets[0]!.send).toHaveBeenCalledTimes(5);
expect(onMessage).toHaveBeenCalledTimes(2);
signal.stop();
});
it.each([
JSON.stringify({
type: "error",
code: "INVALID_MESSAGE",
message: "Message is invalid",
}),
JSON.stringify({
type: "authenticated",
role: "viewer",
peerId: "viewer_12345678",
maxViewers: 8,
hostOnline: true,
connectionId: null,
...routeAuthenticated(),
iceConfig: { iceServers: [], natPredictionStunUrls: [] },
}),
])(
"terminates once when a server payload is incompatible with the current wire",
(payload) => {
const sockets: FakeWebSocket[] = [];
class FakeWebSocket extends EventTarget {
static readonly CLOSING = 2;
readyState = 1;
readonly send = vi.fn();
readonly close = vi.fn();
constructor(readonly url: string) {
super();
sockets.push(this);
}
}
vi.stubGlobal("WebSocket", FakeWebSocket);
vi.stubGlobal("window", {
location: new URL("https://share.test/r/1234"),
setTimeout,
clearTimeout,
});
const onTerminated = vi.fn();
const signal = new SignalingClient(
{
roomId: "1234",
role: "viewer",
clientId: "viewer-client",
},
{
onMessage: () => undefined,
onStatus: () => undefined,
onTerminated,
onAccessRequired: () => undefined,
},
);
signal.start();
sockets[0]!.dispatchEvent(new Event("open"));
expect(
JSON.parse(String(sockets[0]!.send.mock.calls[0]![0])),
).toMatchObject({
type: "authenticate",
protocol: SIGNALING_PROTOCOL,
});
const message = new Event("message");
Object.defineProperty(message, "data", { value: payload });
sockets[0]!.dispatchEvent(message);
const close = new Event("close");
Object.defineProperties(close, {
code: { value: 1008 },
reason: { value: "Invalid message" },
});
sockets[0]!.dispatchEvent(close);
expect(onTerminated).toHaveBeenCalledOnce();
expect(onTerminated).toHaveBeenCalledWith("STALE_CLIENT");
expect(sockets[0]!.close).toHaveBeenCalledOnce();
expect(sockets).toHaveLength(1);
},
);
it("returns a Host to the admission gate after confirmed site-access denial", async () => {
vi.useFakeTimers();
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ required: true, authenticated: false })));
const sockets: FakeWebSocket[] = [];
class FakeWebSocket extends EventTarget {
static readonly CLOSING = 2;
readyState = 1;
readonly send = vi.fn();
readonly close = vi.fn();
constructor(readonly url: string) {
super();
sockets.push(this);
}
}
vi.stubGlobal("WebSocket", FakeWebSocket);
vi.stubGlobal("window", {
location: new URL("https://share.test/r/1234"),
setTimeout,
clearTimeout,
});
const onAccessRequired = vi.fn();
const statuses: string[] = [];
const onMessage = vi.fn();
const signal = new SignalingClient(
{
roomId: "1234",
role: "host",
token: "h".repeat(43),
clientId: "host-client",
routePolicy: DEFAULT_ROUTE_POLICY,
},
{
onMessage,
onStatus: (status) => statuses.push(status),
onTerminated: () => undefined,
onAccessRequired,
},
);
signal.start();
sockets[0]!.dispatchEvent(new Event("open"));
const message = new Event("message");
Object.defineProperties(message, {
data: {
value: JSON.stringify({
type: "error",
code: "AUTH_REQUIRED",
message: "Site access is required",
}),
},
});
sockets[0]!.dispatchEvent(message);
await vi.advanceTimersByTimeAsync(0);
expect(onAccessRequired).toHaveBeenCalledOnce();
expect(onMessage).not.toHaveBeenCalled();
expect(statuses.at(-1)).toBe("offline");
expect(sockets).toHaveLength(1);
});
});
describe("WebRTC stats parsing", () => {
it("sums sender layers and rebases incomplete bitrate windows", () => {
const accumulator = createNativeSenderQualityAccumulator();
const sample = (
timestamp: number,
bytesByLayer: Readonly<Record<string, number>>,
): number | null => {
const records: Array<[string, Record<string, unknown>]> = [
[
"source",
{
id: "source",
type: "media-source",
timestamp,
trackIdentifier: "track",
},
],
];
for (const [id, bytesSent] of Object.entries(bytesByLayer)) {
records.push([
id,
{
id,
type: "outbound-rtp",
kind: "video",
timestamp,
mediaSourceId: "source",
bytesSent,
framesEncoded: timestamp / 20,
qualityLimitationReason: "none",
qualityLimitationDurations: {
none: timestamp / 1_000,
bandwidth: 0,
cpu: 0,
other: 0,
},
},
]);
}
return collectNativeSenderQualityFromReport(
new Map(records) as unknown as RTCStatsReport,
"track",
accumulator,
).bitrateKbps;
};
expect(sample(1_000, { low: 10_000, high: 20_000 })).toBeNull();
expect(sample(3_000, { low: 110_000, high: 220_000 })).toBe(1_200);
expect(
sample(5_000, { low: 210_000, high: 420_000, mid: 50_000 }),
).toBeNull();
expect(
sample(7_000, { low: 310_000, high: 620_000, mid: 150_000 }),
).toBe(1_600);
expect(
sample(9_000, { low: 10_000, high: 820_000, mid: 250_000 }),
).toBeNull();
expect(
sample(11_000, {
low: 110_000,
high: 1_020_000,
mid: 350_000,
}),
).toBe(1_600);
});
it("binds media, remote evidence, transport, and pair without guessing", async () => {
const entry = (
id: string,
type: string,
values: Record<string, unknown>,
): [string, Record<string, unknown>] => [
id,
{ id, type, timestamp: 2_000, ...values },
];
const report = new Map<string, Record<string, unknown>>([
entry("transport-a", "transport", {
selectedCandidatePairId: "pair-a",
}),
entry("transport-z", "transport", {
selectedCandidatePairId: "pair-z",
}),
entry("pair-a", "candidate-pair", {
transportId: "transport-a",
localCandidateId: "local-a",
remoteCandidateId: "remote-candidate-a",
currentRoundTripTime: 0.02,
}),
entry("pair-z", "candidate-pair", {
transportId: "transport-z",
localCandidateId: "local-z",
remoteCandidateId: "remote-candidate-z",
}),
entry("local-a", "local-candidate", {
candidateType: "host",
protocol: "udp",
address: "192.0.2.10",
port: 50_000,
}),
entry("remote-candidate-a", "remote-candidate", {
candidateType: "srflx",
foundation: "sp1",
protocol: "udp",
address: "2001:db8::10",
port: 50_001,
}),
entry("local-z", "local-candidate", {
candidateType: "relay",
protocol: "tcp",
}),
entry("remote-candidate-z", "remote-candidate", {
candidateType: "relay",
protocol: "tcp",
}),
entry("outbound-a", "outbound-rtp", {
kind: "video",
ssrc: 111,
mid: "0",
transportId: "transport-a",
mediaSourceId: "source-a",
remoteId: "remote-inbound-a",
codecId: "codec-a",
rid: "main",
scalabilityMode: "L2T3_KEY",
bytesSent: 2_000,
framesEncoded: 60,
totalEncodeTime: 0.3,
encoderImplementation: "ExternalEncoder",
powerEfficientEncoder: true,
}),
entry("outbound-z", "outbound-rtp", {
kind: "video",
ssrc: 999,
transportId: "transport-z",
mediaSourceId: "source-z",
remoteId: "remote-inbound-z",
codecId: "codec-z",
bytesSent: 99_000,
framesEncoded: 99,
}),
entry("source-a", "media-source", {
kind: "video",
trackIdentifier: "capture-track-a",
framesPerSecond: 58.5,
}),
entry("source-z", "media-source", {
kind: "video",
trackIdentifier: "capture-track-z",
}),
entry("remote-inbound-a", "remote-inbound-rtp", {
kind: "video",
packetsLost: 2,
jitter: 0.004,
}),
entry("remote-inbound-z", "remote-inbound-rtp", {
kind: "video",
packetsLost: 999,
jitter: 0.9,
}),
entry("codec-a", "codec", {
transportId: "transport-a",
mimeType: "video/H264",
sdpFmtpLine:
"profile-level-id=42E01F; packetization-mode=1; " +
"level-asymmetry-allowed=1; sprop-parameter-sets=do-not-expose; " +
"x-google-start-bitrate=99999",
}),
entry("codec-z", "codec", {
transportId: "transport-z",
mimeType: "video/VP9",
sdpFmtpLine: "profile-id=3; max-fr=15; max-fs=1200",
}),
]);
const connection = {
getStats: async () => report as unknown as RTCStatsReport,
} as unknown as RTCPeerConnection;
const accumulator = createStatsAccumulator();
const metrics = await collectConnectionMetrics(
connection,
"send",
accumulator,
{ trackIdentifier: "capture-track-a" },
);
expect(metrics).toMatchObject({
sampleTimestampMs: 2_000,
sampleWindowMs: null,
rtpStatsId: "outbound-a",
rtpSsrc: 111,
rtpMid: "0",
rtpRid: "main",
trackIdentifier: "capture-track-a",
mediaSourceFramesPerSecond: 58.5,
selectedCandidatePairId: "pair-a",
path: "direct",
natTraversalPath: "predicted",
iceProtocol: "udp",
localCandidateAddress: "192.0.2.10",
localCandidatePort: 50_000,
remoteCandidateAddress: "2001:db8::10",
remoteCandidatePort: 50_001,
packetsLost: 2,
jitterMs: 4,
rttMs: 20,
codec: "video/H264",
codecProfile: "profile-level-id=42e01f",
codecParameters:
"packetization-mode=1; level-asymmetry-allowed=1",
scalabilityMode: "L2T3_KEY",
encoderImplementation: "ExternalEncoder",
powerEfficientEncoder: true,
intervalFramesEncoded: null,
intervalEncodeTimeMs: null,
});
expect(JSON.stringify(metrics)).not.toContain("sprop-parameter-sets");
expect(JSON.stringify(metrics)).not.toContain("x-google-start-bitrate");
report.get("remote-candidate-a")!.address = null;
delete report.get("remote-candidate-a")!.port;
expect(
await collectConnectionMetrics(connection, "send", accumulator, {
trackIdentifier: "capture-track-a",
}),
).toMatchObject({
selectedCandidatePairId: "pair-a",
localCandidateAddress: "192.0.2.10",
localCandidatePort: 50_000,
remoteCandidateAddress: null,
remoteCandidatePort: null,
});
report.get("remote-candidate-a")!.address = "2001:db8::10";
report.get("remote-candidate-a")!.port = 50_001;
report.get("remote-candidate-a")!.foundation = "ordinary";
expect(
await collectConnectionMetrics(connection, "send", accumulator, {
trackIdentifier: "capture-track-a",
}),
).toMatchObject({ natTraversalPath: "ordinary" });
report.get("transport-a")!.selectedCandidatePairId = "pair-z";
const wrongTransportPair = await collectConnectionMetrics(
connection,
"send",
accumulator,
{ trackIdentifier: "capture-track-a" },
);
expect(wrongTransportPair).toMatchObject({
rtpStatsId: "outbound-a",
selectedCandidatePairId: null,
path: "unknown",
});
report.get("transport-a")!.selectedCandidatePairId = "pair-a";
report.get("codec-a")!.transportId = "transport-z";
const wrongTransportCodec = await collectConnectionMetrics(
connection,
"send",
accumulator,
{ trackIdentifier: "capture-track-a" },
);
expect(wrongTransportCodec).toMatchObject({
rtpStatsId: "outbound-a",
codec: null,
codecProfile: null,
codecParameters: null,
});
report.get("codec-a")!.transportId = "transport-a";
report.get("local-a")!.type = "remote-candidate";
report.get("remote-candidate-a")!.type = "local-candidate";
const malformedCandidateReferences = await collectConnectionMetrics(
connection,
"send",
accumulator,
{ trackIdentifier: "capture-track-a" },
);
expect(malformedCandidateReferences).toMatchObject({
selectedCandidatePairId: null,
path: "unknown",
localCandidateType: null,
remoteCandidateType: null,
});
report.get("local-a")!.type = "local-candidate";
report.get("remote-candidate-a")!.type = "remote-candidate";
delete report.get("outbound-a")!.transportId;
const unboundTransport = await collectConnectionMetrics(
connection,
"send",
accumulator,
{ trackIdentifier: "capture-track-a" },
);
expect(unboundTransport).toMatchObject({
rtpStatsId: "outbound-a",
selectedCandidatePairId: null,
path: "unknown",
codec: null,
codecProfile: null,
codecParameters: null,
});
report.get("outbound-a")!.transportId = "transport-a";
report.set("outbound-a-layer-2", {
id: "outbound-a-layer-2",
type: "outbound-rtp",
timestamp: 3_000,
kind: "video",
ssrc: 112,
transportId: "transport-a",
mediaSourceId: "source-a",
bytesSent: 3_000,
framesEncoded: 90,
});
const ambiguous = await collectConnectionMetrics(
connection,
"send",
accumulator,
{ trackIdentifier: "capture-track-a" },
);
expect(ambiguous).toMatchObject({
rtpStatsId: null,
selectedCandidatePairId: null,
packetsLost: null,
codec: null,
codecProfile: null,
codecParameters: null,
scalabilityMode: null,
});
});
it.each([
{
mimeType: "video/VP8",
fmtp: "max-fr=60; max-fs=3600",
profile: null,
parameters: "max-fr=60; max-fs=3600",
},
{
mimeType: "video/VP9",
fmtp: "profile-id=2; max-fr=60; max-fs=3600",
profile: "profile-id=2",
parameters: "max-fr=60; max-fs=3600",
},
{
mimeType: "video/AV1",
fmtp: "profile=1; level-idx=8; tier=0",
profile: "profile=1",
parameters: "level-idx=8; tier=0",
},
{
mimeType: "video/H264",
fmtp:
"profile-level-id=42e01f; profile-level-id=invalid; " +
"packetization-mode=1",
profile: null,
parameters: "packetization-mode=1",
},
{
mimeType: "video/H264",
fmtp: `profile-level-id=42e01f; ignored=${"x".repeat(2_048)}`,
profile: null,
parameters: null,
},
{
mimeType: "video/H264",
fmtp: [
"profile-level-id=42e01f",
...Array.from({ length: 32 }, (_, index) => `ignored-${index}=1`),
].join(";"),
profile: null,
parameters: null,
},
{
mimeType: "video/H265",
fmtp: "profile-id=1; tier-flag=0; level-id=93",
profile: null,
parameters: null,
},
])(
"derives only allowlisted $mimeType format parameters",
async ({ mimeType, fmtp, profile, parameters }) => {
const report = new Map<string, unknown>([
[
"transport",
{ id: "transport", type: "transport", timestamp: 1_000 },
],
[
"outbound",
{
id: "outbound",
type: "outbound-rtp",
timestamp: 1_000,
kind: "video",
ssrc: 101,
transportId: "transport",
codecId: "codec",
bytesSent: 1_000,
framesEncoded: 30,
},
],
[
"codec",
{
id: "codec",
type: "codec",
timestamp: 1_000,
transportId: "transport",
mimeType,
sdpFmtpLine: fmtp,
},
],
]) as unknown as RTCStatsReport;
const connection = {
getStats: async () => report,
} as unknown as RTCPeerConnection;
const metrics = await collectConnectionMetrics(
connection,
"send",
createStatsAccumulator(),
);
expect(metrics).toMatchObject({
codec: mimeType,
codecProfile: profile,
codecParameters: parameters,
scalabilityMode: null,
});
expect(
qualityEvidenceWindowFromMetrics({
...metrics,
sampleWindowMs: 2_000,
})?.metrics,
).toMatchObject({
codec: mimeType,
codecProfile: profile,
codecParameters: parameters,
});
},
);
it("rebases sender retransmission deltas after counter rollback", async () => {
const outbound = (
timestamp: number,
retransmittedPacketsSent: number,
retransmittedBytesSent: number,
packetsSent: number,
packetsLost: number,
) =>
new Map<string, unknown>([
[
"outbound",
{
id: "outbound",
type: "outbound-rtp",
timestamp,
kind: "video",
ssrc: 101,
remoteId: "remote-inbound",
bytesSent: timestamp * 10,
framesEncoded: timestamp / 10,
packetsSent,
retransmittedPacketsSent,
retransmittedBytesSent,
},
],
[
"remote-inbound",
{
id: "remote-inbound",
type: "remote-inbound-rtp",
timestamp,
kind: "video",
packetsLost,
},
],
]) as unknown as RTCStatsReport;
const reports = [
outbound(1_000, 2, 200, 100, 1),
outbound(2_000, 5, 800, 400, 3),
outbound(3_000, 1, 100, 50, 1),
outbound(4_000, 4, 700, 350, 4),
];
const connection = {
getStats: async () => reports.shift()!,
} as unknown as RTCPeerConnection;
const accumulator = createStatsAccumulator();
const sample = () =>
collectConnectionMetrics(connection, "send", accumulator);
const first = await sample();
const stable = await sample();
const reset = await sample();
const afterReset = await sample();
expect(first.intervalRetransmittedPackets).toBeNull();
expect(stable).toMatchObject({
intervalPacketsSent: 300,
intervalPacketsLost: 2,
intervalRetransmittedPackets: 3,
intervalRetransmittedBytes: 600,
});
expect(reset).toMatchObject({
intervalPacketsSent: null,
intervalPacketsLost: null,
intervalRetransmittedPackets: null,
intervalRetransmittedBytes: null,
});
expect(afterReset).toMatchObject({
intervalPacketsSent: 300,
intervalPacketsLost: 3,
intervalRetransmittedPackets: 3,
intervalRetransmittedBytes: 600,
});
});
it("keeps an out-of-contract relay candidate unclassified", async () => {
const report = new Map<string, Record<string, unknown>>([
[
"transport",
{
id: "transport",
type: "transport",
timestamp: 2_000,
selectedCandidatePairId: "pair",
},
],
[
"pair",
{
id: "pair",
type: "candidate-pair",
timestamp: 2_000,
transportId: "transport",
state: "succeeded",
nominated: true,
localCandidateId: "local",
remoteCandidateId: "remote",
},
],
[
"local",
{
id: "local",
type: "local-candidate",
timestamp: 2_000,
candidateType: "relay",
protocol: "udp",
},
],
[
"remote",
{
id: "remote",
type: "remote-candidate",
timestamp: 2_000,
candidateType: "host",
protocol: "udp",
},
],
[
"inbound",
{
id: "inbound",
type: "inbound-rtp",
timestamp: 2_000,
kind: "video",
transportId: "transport",
bytesReceived: 2_000,
framesDecoded: 100,
totalDecodeTime: 0.5,
},
],
]);
const secondReport = new Map(report);
secondReport.set("inbound", {
id: "inbound",
type: "inbound-rtp",
timestamp: 4_000,
kind: "video",
transportId: "transport",
bytesReceived: 4_000,
framesDecoded: 150,
totalDecodeTime: 1.5,
});
const reports = [report, secondReport];
const connection = {
getStats: async () =>
reports.shift() as unknown as RTCStatsReport,
} as unknown as RTCPeerConnection;
const accumulator = createStatsAccumulator();
const firstMetrics = await collectConnectionMetrics(
connection,
"receive",
accumulator,
);
const metrics = await collectConnectionMetrics(
connection,
"receive",
accumulator,
);
expect(firstMetrics.intervalDecodeMs).toBeNull();
expect(metrics).toMatchObject({
path: "unknown",
iceProtocol: "udp",
localCandidateType: "relay",
remoteCandidateType: "host",
intervalDecodeMs: 20,
});
});
it("rebases interval metrics when the RTP stream or its counters reset", async () => {
const inbound = (
id: string,
timestamp: number,
bytesReceived: number,
framesDecoded: number,
totalDecodeTime: number,
) =>
new Map<string, unknown>([
[
id,
{
id,
type: "inbound-rtp",
timestamp,
kind: "video",
bytesReceived,
framesDecoded,
totalDecodeTime,
},
],
]) as unknown as RTCStatsReport;
const reports = [
inbound("inbound-a", 1_000, 10_000, 100, 0.5),
inbound("inbound-b", 2_000, 20_000, 150, 1.5),
inbound("inbound-b", 3_000, 22_000, 160, 1.7),
inbound("inbound-b", 4_000, 1_000, 10, 0.1),
inbound("inbound-b", 5_000, 3_000, 20, 0.3),
];
const connection = {
getStats: async () => reports.shift()!,
} as unknown as RTCPeerConnection;
const accumulator = createStatsAccumulator();
await collectConnectionMetrics(connection, "receive", accumulator);
const streamChanged = await collectConnectionMetrics(
connection,
"receive",
accumulator,
);
const afterStreamChange = await collectConnectionMetrics(
connection,
"receive",
accumulator,
);
const countersReset = await collectConnectionMetrics(
connection,
"receive",
accumulator,
);
const afterCounterReset = await collectConnectionMetrics(
connection,
"receive",
accumulator,
);
expect(streamChanged).toMatchObject({
bitrateKbps: null,
framesPerSecond: null,
intervalDecodeMs: null,
});
expect(afterStreamChange).toMatchObject({
bitrateKbps: 16,
framesPerSecond: 10,
});
expect(afterStreamChange.intervalDecodeMs).toBeCloseTo(20);
expect(countersReset).toMatchObject({
bitrateKbps: null,
framesPerSecond: null,
intervalDecodeMs: null,
});
expect(afterCounterReset).toMatchObject({
bitrateKbps: 16,
framesPerSecond: 10,
});
expect(afterCounterReset.intervalDecodeMs).toBeCloseTo(20);
});
it("rebases receiver event deltas on SSRC changes and counter resets", async () => {
const inbound = (
timestamp: number,
ssrc: number,
framesDropped: number,
freezeCount: number,
totalFreezesDuration: number,
pauseCount: number,
totalPausesDuration: number,
retransmittedPacketsReceived: number,
retransmittedBytesReceived: number,
) =>
new Map<string, unknown>([
[
"inbound",
{
id: "inbound",
type: "inbound-rtp",
timestamp,
kind: "video",
ssrc,
bytesReceived: timestamp * 10,
framesDecoded: timestamp / 10,
framesDropped,
freezeCount,
totalFreezesDuration,
pauseCount,
totalPausesDuration,
retransmittedPacketsReceived,
retransmittedBytesReceived,
},
],
]) as unknown as RTCStatsReport;
const reports = [
inbound(1_000, 101, 2, 1, 0.25, 0, 0, 3, 300),
inbound(2_000, 101, 5, 2, 2.75, 1, 5.5, 7, 900),
inbound(3_000, 202, 20, 8, 4, 3, 16, 30, 4_000),
inbound(4_000, 202, 22, 9, 6.25, 4, 21.5, 33, 4_600),
inbound(5_000, 202, 1, 0, 0.25, 0, 0.25, 2, 200),
inbound(6_000, 202, 4, 1, 2.5, 1, 6.25, 6, 1_000),
];
const connection = {
getStats: async () => reports.shift()!,
} as unknown as RTCPeerConnection;
const accumulator = createStatsAccumulator();
const sample = () =>
collectConnectionMetrics(connection, "receive", accumulator);
const first = await sample();
const stable = await sample();
const changedSsrc = await sample();
const afterSsrcChange = await sample();
const reset = await sample();
const afterReset = await sample();
expect(first).toMatchObject({
sampleWindowMs: null,
intervalFramesDropped: null,
intervalFreezeCount: null,
intervalFreezeDurationMs: null,
intervalPauseCount: null,
intervalPauseDurationMs: null,
intervalRetransmittedPackets: null,
intervalRetransmittedBytes: null,
});
expect(stable).toMatchObject({
sampleWindowMs: 1_000,
intervalFramesDropped: 3,
intervalFreezeCount: 1,
intervalFreezeDurationMs: 2_500,
intervalPauseCount: 1,
intervalPauseDurationMs: 5_500,
intervalRetransmittedPackets: 4,
intervalRetransmittedBytes: 600,
});
expect(changedSsrc).toMatchObject({
rtpSsrc: 202,
sampleWindowMs: null,
intervalFramesDropped: null,
intervalFreezeCount: null,
intervalFreezeDurationMs: null,
intervalPauseCount: null,
intervalPauseDurationMs: null,
intervalRetransmittedPackets: null,
intervalRetransmittedBytes: null,
});
expect(afterSsrcChange).toMatchObject({
sampleWindowMs: 1_000,
intervalFramesDropped: 2,
intervalFreezeCount: 1,
intervalFreezeDurationMs: 2_250,
intervalPauseCount: 1,
intervalPauseDurationMs: 5_500,
intervalRetransmittedPackets: 3,
intervalRetransmittedBytes: 600,
});
expect(reset).toMatchObject({
sampleWindowMs: 1_000,
intervalFramesDropped: null,
intervalFreezeCount: null,
intervalFreezeDurationMs: null,
intervalPauseCount: null,
intervalPauseDurationMs: null,
intervalRetransmittedPackets: null,
intervalRetransmittedBytes: null,
});
expect(afterReset).toMatchObject({
sampleWindowMs: 1_000,
intervalFramesDropped: 3,
intervalFreezeCount: 1,
intervalFreezeDurationMs: 2_250,
intervalPauseCount: 1,
intervalPauseDurationMs: 6_000,
intervalRetransmittedPackets: 4,
intervalRetransmittedBytes: 800,
});
});
it("keeps unavailable pause counters unknown and receive-only", async () => {
const report = (
type: "inbound-rtp" | "outbound-rtp",
timestamp: number,
) =>
new Map<string, unknown>([
[
"video",
{
id: "video",
type,
timestamp,
kind: "video",
ssrc: 101,
...(type === "inbound-rtp"
? { bytesReceived: timestamp, framesDecoded: timestamp / 100 }
: {
bytesSent: timestamp,
framesEncoded: timestamp / 100,
pauseCount: timestamp / 1_000,
totalPausesDuration: timestamp / 1_000,
}),
},
],
]) as unknown as RTCStatsReport;
const receiveAccumulator = createStatsAccumulator();
collectConnectionMetricsFromReport(
report("inbound-rtp", 1_000),
"receive",
receiveAccumulator,
);
const receive = collectConnectionMetricsFromReport(
report("inbound-rtp", 2_000),
"receive",
receiveAccumulator,
);
expect(receive).toMatchObject({
intervalPauseCount: null,
intervalPauseDurationMs: null,
});
const sendAccumulator = createStatsAccumulator();
collectConnectionMetricsFromReport(
report("outbound-rtp", 1_000),
"send",
sendAccumulator,
);
const send = collectConnectionMetricsFromReport(
report("outbound-rtp", 2_000),
"send",
sendAccumulator,
);
expect(send).toMatchObject({
intervalPauseCount: null,
intervalPauseDurationMs: null,
});
});
it("derives a bounded inbound C window from adjacent RTP counters", async () => {
const inbound = (
timestamp: number,
packetsReceived: number,
packetsLost: number,
framesDecoded: number,
totalDecodeTime: number,
) =>
new Map<string, unknown>([
[
"transport",
{ id: "transport", type: "transport", timestamp },
],
[
"codec",
{
id: "codec",
type: "codec",
timestamp,
transportId: "transport",
mimeType: "video/H264",
sdpFmtpLine:
"profile-level-id=42e01f; packetization-mode=1; " +
"sprop-parameter-sets=must-not-leave-stats",
},
],
[
"inbound",
{
id: "inbound",
type: "inbound-rtp",
timestamp,
kind: "video",
ssrc: 101,
transportId: "transport",
codecId: "codec",
bytesReceived: timestamp * 10,
packetsReceived,
packetsLost,
framesDecoded,
framesDropped: 3,
freezeCount: 1,
totalFreezesDuration: 0.25,
totalDecodeTime,
frameWidth: 1_920,
frameHeight: 1_080,
scalabilityMode: "L3T3_KEY",
},
],
]) as unknown as RTCStatsReport;
const reports = [
inbound(1_000, 1_000, 10, 60, 0.12),
inbound(3_000, 2_500, 12, 180, 0.42),
inbound(5_000, 4_000, 11, 300, 0.72),
];
const connection = {
getStats: async () => reports.shift()!,
} as unknown as RTCPeerConnection;
const accumulator = createStatsAccumulator();
await collectConnectionMetrics(connection, "receive", accumulator);
const stable = await collectConnectionMetrics(
connection,
"receive",
accumulator,
);
const correctedLoss = await collectConnectionMetrics(
connection,
"receive",
accumulator,
);
expect(stable).toMatchObject({
sampleWindowMs: 2_000,
frameWidth: 1_920,
frameHeight: 1_080,
intervalPacketsReceived: 1_500,
intervalPacketsLost: 2,
packetLossPercent: (2 / 1_502) * 100,
intervalFramesDecoded: 120,
intervalDecodeMs: 2.5,
codec: "video/H264",
codecProfile: "profile-level-id=42e01f",
codecParameters: "packetization-mode=1",
scalabilityMode: null,
});
expect(JSON.stringify(stable)).not.toContain("sprop-parameter-sets");
expect(correctedLoss).toMatchObject({
intervalPacketsLost: null,
packetLossPercent: null,
});
});
it("reports verified inbound audio codec and interval transport facts", async () => {
const report = (
timestamp: number,
videoPacketsReceived: number,
videoPacketsLost: number,
audioBytesReceived: number,
audioPacketsReceived: number,
audioPacketsLost: number,
audioCodecTransportId = "transport",
) =>
new Map<string, unknown>([
[
"transport",
{ id: "transport", type: "transport", timestamp },
],
[
"video-codec",
{
id: "video-codec",
type: "codec",
timestamp,
transportId: "transport",
mimeType: "video/VP8",
},
],
[
"audio-codec",
{
id: "audio-codec",
type: "codec",
timestamp,
transportId: audioCodecTransportId,
mimeType: "audio/opus",
clockRate: 48_000,
channels: 2,
sdpFmtpLine: "minptime=10;useinbandfec=1",
},
],
[
"video-inbound",
{
id: "video-inbound",
type: "inbound-rtp",
timestamp,
kind: "video",
ssrc: 101,
transportId: "transport",
codecId: "video-codec",
bytesReceived: timestamp * 10,
packetsReceived: videoPacketsReceived,
packetsLost: videoPacketsLost,
framesDecoded: timestamp / 20,
},
],
[
"audio-inbound",
{
id: "audio-inbound",
type: "inbound-rtp",
timestamp,
kind: "audio",
ssrc: 202,
trackIdentifier: "shared-audio",
transportId: "transport",
codecId: "audio-codec",
bytesReceived: audioBytesReceived,
packetsReceived: audioPacketsReceived,
packetsLost: audioPacketsLost,
jitter: 0.003,
},
],
]) as unknown as RTCStatsReport;
const reports = [
report(1_000, 100, 5, 10_000, 1_000, 5),
report(3_000, 190, 15, 50_000, 1_180, 10),
report(5_000, 190, 15, 50_000, 1_180, 10, "other-transport"),
];
const connection = {
getStats: async () => reports.shift()!,
} as unknown as RTCPeerConnection;
const accumulator = createStatsAccumulator();
const first = await collectConnectionMetrics(
connection,
"receive",
accumulator,
);
const stable = await collectConnectionMetrics(
connection,
"receive",
accumulator,
);
const zeroDenominator = await collectConnectionMetrics(
connection,
"receive",
accumulator,
);
expect(first).toMatchObject({
packetLossPercent: null,
audioBitrateKbps: null,
audioPacketLossPercent: null,
audioJitterMs: 3,
audioCodec: "audio/opus",
audioCodecClockRate: 48_000,
audioCodecChannels: 2,
audioCodecParameters: "minptime=10;useinbandfec=1",
});
expect(stable.packetLossPercent).toBeCloseTo(10);
expect(stable.audioBitrateKbps).toBeCloseTo(160);
expect(stable.audioPacketLossPercent).toBeCloseTo((5 / 185) * 100);
expect(zeroDenominator).toMatchObject({
packetLossPercent: null,
audioBitrateKbps: 0,
audioPacketLossPercent: null,
audioCodec: null,
audioCodecClockRate: null,
audioCodecChannels: null,
audioCodecParameters: null,
});
});
it("derives local inbound playout, jitter-buffer, and concealment evidence safely", async () => {
const report = (
timestamp: number,
video: Record<string, unknown> = {},
audio: Record<string, unknown> = {},
) =>
new Map<string, unknown>([
[
"video-inbound",
{
id: "video-inbound",
type: "inbound-rtp",
timestamp,
kind: "video",
ssrc: 101,
trackIdentifier: "shared-video",
bytesReceived: timestamp * 10,
framesDecoded: timestamp / 20,
...video,
},
],
[
"audio-inbound",
{
id: "audio-inbound",
type: "inbound-rtp",
timestamp,
kind: "audio",
ssrc: 202,
trackIdentifier: "shared-audio",
bytesReceived: timestamp * 2,
...audio,
},
],
]) as unknown as RTCStatsReport;
const reports = [
report(
1_000,
{
estimatedPlayoutTimestamp: 10_000,
jitterBufferDelay: 1.2,
jitterBufferEmittedCount: 60,
},
{
estimatedPlayoutTimestamp: 10_012.5,
jitterBufferDelay: 960,
jitterBufferEmittedCount: 48_000,
totalSamplesReceived: 48_000,
concealedSamples: 480,
concealmentEvents: 2,
},
),
report(
3_000,
{
estimatedPlayoutTimestamp: 12_000,
jitterBufferDelay: 4.2,
jitterBufferEmittedCount: 180,
},
{
estimatedPlayoutTimestamp: 12_008.5,
jitterBufferDelay: 3_120,
jitterBufferEmittedCount: 144_000,
totalSamplesReceived: 144_000,
concealedSamples: 1_440,
concealmentEvents: 5,
},
),
report(
5_000,
{
estimatedPlayoutTimestamp: 14_000,
jitterBufferDelay: 7.8,
jitterBufferEmittedCount: 300,
},
{
ssrc: 303,
trackIdentifier: "replacement-audio",
estimatedPlayoutTimestamp: 13_997,
jitterBufferDelay: 2_000,
jitterBufferEmittedCount: 96_000,
totalSamplesReceived: 96_000,
concealedSamples: 5_000,
concealmentEvents: 30,
},
),
report(
7_000,
{
estimatedPlayoutTimestamp: 16_000,
jitterBufferDelay: 1,
jitterBufferEmittedCount: 20,
},
{
ssrc: 303,
trackIdentifier: "replacement-audio",
estimatedPlayoutTimestamp: 15_996,
jitterBufferDelay: 480,
jitterBufferEmittedCount: 24_000,
totalSamplesReceived: 24_000,
concealedSamples: 100,
concealmentEvents: 1,
},
),
report(9_000, {}, { ssrc: 303, trackIdentifier: "replacement-audio" }),
];
const connection = {
getStats: async () => reports.shift()!,
} as unknown as RTCPeerConnection;
const accumulator = createStatsAccumulator();
const first = await collectConnectionMetrics(
connection,
"receive",
accumulator,
);
const stable = await collectConnectionMetrics(
connection,
"receive",
accumulator,
);
const changedAudio = await collectConnectionMetrics(
connection,
"receive",
accumulator,
);
const reset = await collectConnectionMetrics(
connection,
"receive",
accumulator,
);
const unsupported = await collectConnectionMetrics(
connection,
"receive",
accumulator,
);
expect(first).toMatchObject({
audioVideoPlayoutDeltaMs: 12.5,
videoJitterBufferDelayMs: null,
audioJitterBufferDelayMs: null,
audioConcealedSamplesPercent: null,
intervalAudioConcealmentEvents: null,
});
expect(stable.audioVideoPlayoutDeltaMs).toBeCloseTo(8.5);
expect(stable.videoJitterBufferDelayMs).toBeCloseTo(25);
expect(stable.audioJitterBufferDelayMs).toBeCloseTo(22.5);
expect(stable.audioConcealedSamplesPercent).toBeCloseTo(1);
expect(stable.intervalAudioConcealmentEvents).toBe(3);
expect(changedAudio).toMatchObject({
audioVideoPlayoutDeltaMs: -3,
audioJitterBufferDelayMs: null,
audioConcealedSamplesPercent: null,
intervalAudioConcealmentEvents: null,
});
expect(changedAudio.videoJitterBufferDelayMs).toBeCloseTo(30);
expect(reset).toMatchObject({
audioVideoPlayoutDeltaMs: -4,
videoJitterBufferDelayMs: null,
audioJitterBufferDelayMs: null,
audioConcealedSamplesPercent: null,
intervalAudioConcealmentEvents: null,
});
expect(unsupported).toMatchObject({
audioVideoPlayoutDeltaMs: null,
videoJitterBufferDelayMs: null,
audioJitterBufferDelayMs: null,
audioConcealedSamplesPercent: null,
intervalAudioConcealmentEvents: null,
});
});
it("uses linked remote inbound counters for outbound audio loss", async () => {
const report = (
timestamp: number,
audioBytesSent: number,
packetsReceived: number | undefined,
packetsLost: number | undefined,
) =>
new Map<string, unknown>([
[
"transport",
{ id: "transport", type: "transport", timestamp },
],
[
"video-outbound",
{
id: "video-outbound",
type: "outbound-rtp",
timestamp,
kind: "video",
ssrc: 101,
bytesSent: timestamp * 10,
packetsSent: timestamp / 10,
framesEncoded: timestamp / 20,
},
],
[
"audio-codec",
{
id: "audio-codec",
type: "codec",
timestamp,
transportId: "transport",
mimeType: "audio/opus",
clockRate: 48_000,
channels: 2,
},
],
[
"audio-outbound",
{
id: "audio-outbound",
type: "outbound-rtp",
timestamp,
kind: "audio",
ssrc: 202,
transportId: "transport",
codecId: "audio-codec",
remoteId: "audio-remote-inbound",
bytesSent: audioBytesSent,
},
],
[
"audio-remote-inbound",
{
id: "audio-remote-inbound",
type: "remote-inbound-rtp",
timestamp,
kind: "audio",
packetsReceived,
packetsLost,
jitter: 0.004,
},
],
]) as unknown as RTCStatsReport;
const firstReport = report(1_000, 20_000, 500, 1);
(firstReport as unknown as Map<string, unknown>).delete(
"audio-remote-inbound",
);
const reports = [
firstReport,
report(3_000, 50_000, 500, 1),
report(5_000, 80_000, 650, 4),
report(7_000, 110_000, 50, 1),
report(9_000, 140_000, 200, undefined),
];
const connection = {
getStats: async () => reports.shift()!,
} as unknown as RTCPeerConnection;
const accumulator = createStatsAccumulator();
await collectConnectionMetrics(connection, "send", accumulator);
const remoteAppeared = await collectConnectionMetrics(
connection,
"send",
accumulator,
);
const stable = await collectConnectionMetrics(
connection,
"send",
accumulator,
);
const reset = await collectConnectionMetrics(
connection,
"send",
accumulator,
);
const missing = await collectConnectionMetrics(
connection,
"send",
accumulator,
);
expect(remoteAppeared).toMatchObject({
audioBitrateKbps: 120,
audioPacketLossPercent: null,
});
expect(stable).toMatchObject({
audioBitrateKbps: 120,
audioJitterMs: 4,
audioCodec: "audio/opus",
audioCodecClockRate: 48_000,
audioCodecChannels: 2,
});
expect(stable.audioPacketLossPercent).toBeCloseTo((3 / 153) * 100);
expect(reset.audioPacketLossPercent).toBeNull();
expect(missing.audioPacketLossPercent).toBeNull();
});
});