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>
3589 lines
114 KiB
TypeScript
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();
|
|
});
|
|
});
|