feat: add metrics, observability, test fixtures, and scaling playbook artifacts
This commit is contained in:
1 parent
926a663c23
commit
f9aba11b90
32 files changed
+1788
-124
No files matched your search
+194
-16
@@ -1,18 +1,29 @@
|
||||
/// <reference types="vitest" />
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { resetViewerStateForTests, resolveManifestUrl, resolveWebSocketBaseUrl } from "../src/main";
|
||||
|
||||
const hlsInstance = {
|
||||
loadSource: vi.fn(),
|
||||
attachMedia: vi.fn(),
|
||||
on: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
};
|
||||
|
||||
const HlsMock = Object.assign(
|
||||
vi.fn().mockImplementation(() => hlsInstance),
|
||||
{
|
||||
isSupported: vi.fn(() => true),
|
||||
Events: { MANIFEST_PARSED: "hlsManifestParsed" },
|
||||
},
|
||||
);
|
||||
|
||||
// Mock hls.js
|
||||
vi.mock("hls.js", () => ({
|
||||
default: vi.fn().mockImplementation(() => ({
|
||||
loadSource: vi.fn(),
|
||||
attachMedia: vi.fn(),
|
||||
on: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
})),
|
||||
Events: { MANIFEST_PARSED: "hlsManifestParsed" },
|
||||
default: HlsMock,
|
||||
}));
|
||||
|
||||
// Mock WebSocket
|
||||
const websocketInstances: MockWebSocket[] = [];
|
||||
class MockWebSocket {
|
||||
url: string;
|
||||
readyState = 0;
|
||||
@@ -23,7 +34,8 @@ class MockWebSocket {
|
||||
close = vi.fn();
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
this.url = String(url);
|
||||
websocketInstances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +53,8 @@ beforeEach(() => {
|
||||
origWebSocket = (globalThis as any).WebSocket;
|
||||
origFetch = globalThis.fetch;
|
||||
(globalThis as any).WebSocket = MockWebSocket;
|
||||
websocketInstances.length = 0;
|
||||
resetViewerStateForTests();
|
||||
|
||||
// Mock fetch (schedule, etc.) — return empty schedule
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
@@ -82,16 +96,44 @@ beforeEach(() => {
|
||||
|
||||
// Stub prompt for DM
|
||||
(globalThis as any).prompt = vi.fn();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as any).WebSocket = origWebSocket;
|
||||
globalThis.fetch = origFetch;
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Must import after DOM setup
|
||||
import "../src/main";
|
||||
describe("player startup", () => {
|
||||
it("starts native HLS playback on loadedmetadata", async () => {
|
||||
const video = document.getElementById("player") as HTMLVideoElement;
|
||||
vi.spyOn(video, "canPlayType").mockReturnValue("probably");
|
||||
const playSpy = vi.spyOn(video, "play").mockResolvedValue(undefined as any);
|
||||
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat());
|
||||
|
||||
expect(video.src).toContain("/hls/playlist.m3u8?stream=");
|
||||
video.dispatchEvent(new Event("loadedmetadata"));
|
||||
expect(playSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts hls.js playback when native HLS is unavailable", async () => {
|
||||
const video = document.getElementById("player") as HTMLVideoElement;
|
||||
Object.defineProperty(video, "canPlayType", {
|
||||
value: vi.fn(() => ""),
|
||||
configurable: true,
|
||||
});
|
||||
const playSpy = vi.spyOn(video, "play").mockResolvedValue(undefined as any);
|
||||
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||
|
||||
expect(HlsMock.isSupported).toHaveBeenCalled();
|
||||
expect(HlsMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat toggle", () => {
|
||||
it("toggles chat panel visibility", () => {
|
||||
@@ -112,13 +154,7 @@ describe("sendMessage", () => {
|
||||
const input = document.getElementById("message-input") as HTMLInputElement;
|
||||
input.value = "hello!";
|
||||
|
||||
// sendMessage requires ws to be non-null — ws set in initPlayerAndChat
|
||||
// The DOMContentLoaded triggers initPlayerAndChat which creates ws
|
||||
// Wait for async init, then set ws manually
|
||||
// For now just verify input state logic
|
||||
window.sendMessage();
|
||||
// ws is null at this point, so message won't send but input won't clear either
|
||||
// Verify input not cleared (ws check fails before clear)
|
||||
expect(input.value).toBe("hello!");
|
||||
});
|
||||
|
||||
@@ -158,3 +194,145 @@ describe("togglePrivateDM", () => {
|
||||
switchRoomSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebSocket reconnect", () => {
|
||||
it("reconnects after onclose", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
window.switchRoom("lobby");
|
||||
expect(websocketInstances).toHaveLength(1);
|
||||
|
||||
websocketInstances[0].onclose?.();
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
|
||||
expect(websocketInstances).toHaveLength(2);
|
||||
expect(websocketInstances[1].url).toContain("room=lobby");
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL resolution", () => {
|
||||
it("falls back to same-origin manifest and websocket URLs", () => {
|
||||
vi.stubEnv("VITE_PUBLIC_HLS_URL", "");
|
||||
vi.stubEnv("VITE_PUBLIC_WS_URL", "");
|
||||
vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "");
|
||||
|
||||
Object.defineProperty(window, "location", {
|
||||
value: new URL("https://linearvoid.tv/viewer"),
|
||||
writable: true,
|
||||
});
|
||||
|
||||
expect(resolveManifestUrl()).toMatch(/^https:\/\/linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/);
|
||||
expect(resolveWebSocketBaseUrl()).toBe("wss://linearvoid.tv/ws");
|
||||
});
|
||||
|
||||
it("preserves an allowed cross-origin manifest URL", () => {
|
||||
vi.stubEnv("VITE_PUBLIC_HLS_URL", "https://media.linearvoid.tv/hls/playlist.m3u8");
|
||||
vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "true");
|
||||
|
||||
expect(resolveManifestUrl()).toMatch(/^https:\/\/media\.linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/);
|
||||
});
|
||||
|
||||
it("falls back to same-origin manifest when cross-origin is not allowed", () => {
|
||||
vi.stubEnv("VITE_PUBLIC_HLS_URL", "https://media.linearvoid.tv/hls/playlist.m3u8");
|
||||
vi.stubEnv("VITE_PUBLIC_HLS_CROSS_ORIGIN", "false");
|
||||
|
||||
expect(resolveManifestUrl()).toMatch(/^https:\/\/linearvoid\.tv\/hls\/playlist\.m3u8\?stream=/);
|
||||
});
|
||||
|
||||
it("uses configured websocket URL when present", () => {
|
||||
vi.stubEnv("VITE_PUBLIC_WS_URL", "wss://chat.linearvoid.tv/ws");
|
||||
|
||||
expect(resolveWebSocketBaseUrl()).toBe("wss://chat.linearvoid.tv/ws");
|
||||
});
|
||||
});
|
||||
|
||||
describe("presence updates", () => {
|
||||
it("adds user to presence list on join system message", async () => {
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||
|
||||
const ws = websocketInstances[0];
|
||||
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) });
|
||||
|
||||
const presenceList = document.getElementById("presence-list")!;
|
||||
expect(presenceList.textContent).toContain("alice");
|
||||
});
|
||||
|
||||
it("removes user from presence list on leave system message", async () => {
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||
|
||||
const ws = websocketInstances[0];
|
||||
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) });
|
||||
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice left the room", username: "system" }) });
|
||||
|
||||
const presenceList = document.getElementById("presence-list")!;
|
||||
expect(presenceList.textContent).not.toContain("alice");
|
||||
});
|
||||
|
||||
it("renders multiple users sorted", async () => {
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||
|
||||
const ws = websocketInstances[0];
|
||||
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "zoe joined the room", username: "system" }) });
|
||||
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice joined the room", username: "system" }) });
|
||||
|
||||
const presenceList = document.getElementById("presence-list")!;
|
||||
const text = presenceList.textContent ?? "";
|
||||
const aliceIdx = text.indexOf("alice");
|
||||
const zoeIdx = text.indexOf("zoe");
|
||||
expect(aliceIdx).toBeLessThan(zoeIdx);
|
||||
});
|
||||
});
|
||||
|
||||
describe("private DM flow", () => {
|
||||
it("creates dm: room name from sorted usernames", async () => {
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||
|
||||
vi.spyOn(window, "prompt").mockReturnValue("bob");
|
||||
window.togglePrivateDM();
|
||||
|
||||
const roomName = document.getElementById("room-name")!;
|
||||
// username is "viewer" (reset by tests), bob is target -> sorted = "bob-viewer"
|
||||
// switchRoom capitalizes first char: "Dm:bob-viewer Room"
|
||||
expect(roomName.textContent).toContain("bob-viewer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("message rendering", () => {
|
||||
it("renders system messages", async () => {
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||
|
||||
const ws = websocketInstances[0];
|
||||
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "Server restarting", username: "system" }) });
|
||||
|
||||
const messages = document.getElementById("messages")!;
|
||||
expect(messages.innerHTML).toContain("Server restarting");
|
||||
expect(messages.querySelector(".message-system")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders self messages as message-self", async () => {
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||
|
||||
const ws = websocketInstances[0];
|
||||
ws.onmessage?.({
|
||||
data: JSON.stringify({ type: "public", text: "hello world", username: "viewer", timestamp: Date.now() }),
|
||||
});
|
||||
|
||||
const messages = document.getElementById("messages")!;
|
||||
expect(messages.innerHTML).toContain("hello world");
|
||||
expect(messages.querySelector(".message-self")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders other-user messages as message-other", async () => {
|
||||
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||||
|
||||
const ws = websocketInstances[0];
|
||||
ws.onmessage?.({
|
||||
data: JSON.stringify({ type: "public", text: "hey", username: "alice", timestamp: Date.now() }),
|
||||
});
|
||||
|
||||
const messages = document.getElementById("messages")!;
|
||||
expect(messages.innerHTML).toContain("alice");
|
||||
expect(messages.innerHTML).toContain("hey");
|
||||
expect(messages.querySelector(".message-other")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user