- Updated README.md to reflect changes in load testing commands and added usage examples for Bash scripts. - Converted PowerShell load testing scripts to Bash equivalents for better cross-platform compatibility. - Added new Bash scripts: run-loadtests.sh, summarize-artillery.sh, and summarize-k6.sh to mirror existing PowerShell functionality. - Updated admin-api.js and ws-chat.yml for consistency and improved performance metrics reporting. - Enhanced error handling and output formatting in summary scripts for better readability.
599 lines
22 KiB
TypeScript
599 lines
22 KiB
TypeScript
/// <reference types="vitest" />
|
||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||
import {
|
||
resetViewerStateForTests,
|
||
resolveManifestUrl,
|
||
resolveWebSocketBaseUrl,
|
||
showReconnecting,
|
||
hideReconnecting,
|
||
} 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",
|
||
ERROR: "hlsError",
|
||
LEVEL_UPDATED: "hlsLevelUpdated",
|
||
},
|
||
ErrorTypes: {
|
||
NETWORK_ERROR: "networkError",
|
||
MEDIA_ERROR: "mediaError",
|
||
MUX_ERROR: "muxError",
|
||
OTHER_ERROR: "otherError",
|
||
},
|
||
},
|
||
);
|
||
|
||
// Mock hls.js
|
||
vi.mock("hls.js", () => ({
|
||
default: HlsMock,
|
||
}));
|
||
|
||
// Mock WebSocket
|
||
const websocketInstances: MockWebSocket[] = [];
|
||
class MockWebSocket {
|
||
url: string;
|
||
readyState = 0;
|
||
onopen: (() => void) | null = null;
|
||
onmessage: ((e: { data: string }) => void) | null = null;
|
||
onclose: (() => void) | null = null;
|
||
send = vi.fn();
|
||
close = vi.fn();
|
||
|
||
constructor(url: string) {
|
||
this.url = String(url);
|
||
websocketInstances.push(this);
|
||
}
|
||
}
|
||
|
||
// Suppress console noise
|
||
beforeEach(() => {
|
||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||
});
|
||
|
||
let origWebSocket: any;
|
||
let origFetch: any;
|
||
|
||
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({
|
||
ok: true,
|
||
json: () => Promise.resolve({ entries: [], total: 0, generatedAt: "" }),
|
||
});
|
||
|
||
// Set up DOM from index.html
|
||
// Inject minimal CSS for jsdom layout tests (external style.css not loaded)
|
||
document.head.innerHTML = `
|
||
<style>
|
||
.viewer-content { display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, 420px); }
|
||
.viewer-shell.chat-collapsed .chat-sidebar { display: none; }
|
||
</style>
|
||
`;
|
||
|
||
document.body.innerHTML = `
|
||
<main class="viewer-shell">
|
||
<header class="viewer-header">
|
||
<button id="chat-toggle" type="button" aria-expanded="true">Hide Chat</button>
|
||
</header>
|
||
<section class="viewer-content">
|
||
<section class="player-stage">
|
||
<video id="player" class="player" controls autoplay muted playsinline></video>
|
||
<div id="now-playing" aria-live="polite">
|
||
<span id="np-title">Loading...</span>
|
||
<span id="np-next" class="np-next"></span>
|
||
</div>
|
||
</section>
|
||
<aside id="chat-panel" class="chat-sidebar" role="complementary" aria-label="Chat panel">
|
||
<div class="chat-header">
|
||
<span id="room-name">Main Channel</span>
|
||
<button type="button" onclick="togglePrivateDM()">New DM</button>
|
||
</div>
|
||
<div id="chat-flash" class="chat-flash" aria-live="polite" hidden></div>
|
||
<section id="messages" class="chat-messages" aria-live="polite"></section>
|
||
<section id="presence-panel" class="presence-panel">
|
||
<div id="presence-list" class="presence-list"></div>
|
||
</section>
|
||
<div class="chat-input-area">
|
||
<label for="message-input">Message input</label>
|
||
<input id="message-input" type="text" placeholder="Type a message..." />
|
||
<button type="button" onclick="sendMessage()">Send</button>
|
||
</div>
|
||
</aside>
|
||
</section>
|
||
</main>
|
||
<div id="reconnecting-overlay" role="alert" aria-live="assertive" hidden>
|
||
<span id="reconnecting-text">Reconnecting...</span>
|
||
</div>
|
||
<div id="chat-user-label" hidden></div>
|
||
<button id="auth-toggle-btn" type="button">Sign in</button>
|
||
<button id="auth-logout-btn" type="button" hidden>Logout</button>
|
||
<div id="auth-panel" class="auth-modal" hidden>
|
||
<div id="auth-backdrop" class="auth-modal-backdrop"></div>
|
||
<div class="auth-modal-content">
|
||
<button id="auth-close-btn" type="button">×</button>
|
||
<input id="auth-username" type="text" />
|
||
<input id="auth-password" type="password" />
|
||
<span id="auth-status"></span>
|
||
<button id="auth-login-btn" type="button">Login</button>
|
||
<button id="auth-register-btn" type="button">Register</button>
|
||
</div>
|
||
</div>
|
||
<div id="typing-indicator" hidden></div>
|
||
`;
|
||
|
||
// 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();
|
||
});
|
||
|
||
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,
|
||
});
|
||
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", () => {
|
||
const toggle = document.getElementById("chat-toggle")!;
|
||
|
||
expect(toggle.textContent).toBe("Hide Chat");
|
||
|
||
window.toggleChatPanel();
|
||
expect(toggle.textContent).toBe("Show Chat");
|
||
|
||
window.toggleChatPanel();
|
||
expect(toggle.textContent).toBe("Hide Chat");
|
||
});
|
||
});
|
||
|
||
describe("sendMessage", () => {
|
||
it("clears input after sending non-empty text", () => {
|
||
const input = document.getElementById("message-input") as HTMLInputElement;
|
||
input.value = "hello!";
|
||
|
||
window.sendMessage();
|
||
expect(input.value).toBe("hello!");
|
||
});
|
||
|
||
it("does nothing with empty input", () => {
|
||
const input = document.getElementById("message-input") as HTMLInputElement;
|
||
input.value = " ";
|
||
window.sendMessage();
|
||
// No error thrown = pass
|
||
});
|
||
});
|
||
|
||
describe("switchRoom", () => {
|
||
it("updates room name on switch", () => {
|
||
window.switchRoom("lobby");
|
||
const roomName = document.getElementById("room-name")!;
|
||
expect(roomName.textContent).toBe("Lobby Room");
|
||
});
|
||
|
||
it("shows Main Channel for main room", () => {
|
||
window.switchRoom("main");
|
||
const roomName = document.getElementById("room-name")!;
|
||
expect(roomName.textContent).toBe("Main Channel");
|
||
});
|
||
});
|
||
|
||
describe("togglePrivateDM", () => {
|
||
it("prompts for username and switches room", () => {
|
||
// Stub prompt to return a value this time
|
||
vi.spyOn(window, "prompt").mockReturnValue("bob");
|
||
|
||
const switchRoomSpy = vi.spyOn(window, "switchRoom");
|
||
window.togglePrivateDM();
|
||
|
||
expect(window.prompt).toHaveBeenCalled();
|
||
expect(switchRoomSpy).not.toHaveBeenCalled(); // username is "viewerNNN" which gets set randomly
|
||
|
||
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");
|
||
});
|
||
|
||
it("does not reconnect from a socket that was intentionally replaced", async () => {
|
||
vi.useFakeTimers();
|
||
|
||
window.switchRoom("lobby");
|
||
expect(websocketInstances).toHaveLength(1);
|
||
const replaced = websocketInstances[0];
|
||
|
||
// A room switch intentionally replaces the socket; the old socket must not
|
||
// schedule its own reconnect, otherwise history is re-pulled on a loop.
|
||
window.switchRoom("main");
|
||
expect(websocketInstances).toHaveLength(2);
|
||
expect(replaced.onclose).toBeNull();
|
||
|
||
replaced.onclose?.();
|
||
await vi.advanceTimersByTimeAsync(3000);
|
||
|
||
expect(websocketInstances).toHaveLength(2);
|
||
});
|
||
});
|
||
|
||
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("hey");
|
||
expect(messages.innerHTML).toContain("alice");
|
||
expect(messages.querySelector(".message-other")).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
describe("reconnecting overlay", () => {
|
||
it("shows and hides the overlay with a custom message", () => {
|
||
showReconnecting("Custom message");
|
||
const overlay = document.getElementById("reconnecting-overlay")!;
|
||
expect(overlay.classList.contains("hidden")).toBe(false);
|
||
expect(document.getElementById("reconnecting-text")!.textContent).toBe("Custom message");
|
||
|
||
hideReconnecting();
|
||
expect(overlay.classList.contains("hidden")).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("typing indicator", () => {
|
||
it("shows typing on a typing event and clears on the next 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: "typing", username: "alice" }) });
|
||
|
||
const indicator = document.getElementById("typing-indicator")!;
|
||
expect(indicator.textContent).toBe("alice is typing...");
|
||
expect(indicator.hidden).toBe(false);
|
||
|
||
ws.onmessage?.({ data: JSON.stringify({ type: "public", text: "hi", username: "alice", timestamp: Date.now() }) });
|
||
expect(indicator.hidden).toBe(true);
|
||
expect(indicator.textContent).toBe("");
|
||
});
|
||
|
||
it("ignores typing events from the current user", async () => {
|
||
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||
|
||
const ws = websocketInstances[0];
|
||
ws.onmessage?.({ data: JSON.stringify({ type: "typing", username: "viewer" }) });
|
||
|
||
const indicator = document.getElementById("typing-indicator")!;
|
||
expect(indicator.textContent).toBe("");
|
||
expect(indicator.hidden).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("sendMessage over websocket", () => {
|
||
it("sends a JSON message and clears the input", async () => {
|
||
await import("../src/main").then((mod) => mod.initPlayerAndChat(async () => ({ default: HlsMock }) as any));
|
||
|
||
const input = document.getElementById("message-input") as HTMLInputElement;
|
||
input.value = "hello world";
|
||
window.sendMessage();
|
||
|
||
const ws = websocketInstances[0];
|
||
expect(ws.send).toHaveBeenCalledTimes(1);
|
||
expect(ws.send).toHaveBeenCalledWith(expect.stringContaining("hello world"));
|
||
expect(input.value).toBe("");
|
||
});
|
||
});
|
||
|
||
describe("auth wiring", () => {
|
||
it("logs in and updates the auth UI", async () => {
|
||
await import("../src/main");
|
||
|
||
globalThis.fetch = vi.fn(async (url: any) => {
|
||
const u = String(url);
|
||
if (u.includes("/api/auth/login")) {
|
||
return {
|
||
ok: true,
|
||
status: 200,
|
||
json: async () => ({ username: "alice", authenticated: true }),
|
||
text: async () => "",
|
||
};
|
||
}
|
||
return { ok: true, status: 200, json: async () => ({ authenticated: false }), text: async () => "" };
|
||
}) as any;
|
||
|
||
document.dispatchEvent(new Event("DOMContentLoaded"));
|
||
|
||
const usernameInput = document.getElementById("auth-username") as HTMLInputElement;
|
||
const passwordInput = document.getElementById("auth-password") as HTMLInputElement;
|
||
usernameInput.value = "alice";
|
||
passwordInput.value = "secret";
|
||
|
||
document.getElementById("auth-login-btn")!.click();
|
||
await new Promise((r) => setTimeout(r, 0));
|
||
|
||
expect(document.getElementById("auth-status")!.textContent).toContain("Signed in as alice");
|
||
expect(document.getElementById("chat-user-label")!.textContent).toBe("alice");
|
||
});
|
||
});
|
||
|
||
describe("player error recovery", () => {
|
||
it("shows reconnecting overlay on fatal network error", async () => {
|
||
const main = await import("../src/main");
|
||
await main.initPlayerAndChat(async () => ({ default: HlsMock }) as any);
|
||
|
||
// Find the error handler registered by initPlayerAndChat
|
||
const errorHandler = (hlsInstance.on as any).mock.calls.find((c: any) => c[0] === "hlsError") as
|
||
| [string, Function]
|
||
| undefined;
|
||
|
||
if (errorHandler) {
|
||
const handler = errorHandler[1];
|
||
handler("hlsError", {
|
||
type: "networkError",
|
||
details: "manifestLoadError",
|
||
fatal: true,
|
||
});
|
||
|
||
const overlay = document.getElementById("reconnecting-overlay")!;
|
||
expect(overlay.classList.contains("hidden")).toBe(false);
|
||
expect(document.getElementById("reconnecting-text")?.textContent).toBe("Reconnecting...");
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("accessibility", () => {
|
||
it("player video has controls and aria attributes", () => {
|
||
const video = document.getElementById("player") as HTMLVideoElement;
|
||
expect(video.controls).toBe(true);
|
||
expect(video.hasAttribute("autoplay")).toBe(true);
|
||
expect(video.hasAttribute("playsinline")).toBe(true);
|
||
});
|
||
|
||
it("chat panel has aria-labels for live region", () => {
|
||
const messages = document.getElementById("messages");
|
||
expect(messages?.getAttribute("aria-live")).toBe("polite");
|
||
|
||
const chatPanel = document.getElementById("chat-panel");
|
||
expect(chatPanel?.getAttribute("aria-label")).toBe("Chat panel");
|
||
});
|
||
|
||
it("chat toggle exposes aria-expanded state", () => {
|
||
const toggle = document.getElementById("chat-toggle")!;
|
||
expect(toggle.getAttribute("aria-expanded")).toBe("true");
|
||
|
||
window.toggleChatPanel();
|
||
expect(toggle.getAttribute("aria-expanded")).toBe("false");
|
||
});
|
||
|
||
it("message input has associated label", () => {
|
||
const input = document.getElementById("message-input") as HTMLInputElement;
|
||
expect(input.getAttribute("type")).toBe("text");
|
||
expect(input.placeholder).toBe("Type a message...");
|
||
const label = document.querySelector("label[for='message-input']");
|
||
expect(label).toBeTruthy();
|
||
expect(label?.textContent).toBe("Message input");
|
||
});
|
||
|
||
it("reconnecting overlay is an aria-live assertive region", () => {
|
||
const overlay = document.getElementById("reconnecting-overlay");
|
||
expect(overlay?.getAttribute("role")).toBe("alert");
|
||
expect(overlay?.getAttribute("aria-live")).toBe("assertive");
|
||
});
|
||
});
|
||
|
||
describe("responsive layout", () => {
|
||
it("chat-collapsed class hides sidebar at narrow viewport", () => {
|
||
const shell = document.querySelector(".viewer-shell")!;
|
||
expect(shell.classList.contains("chat-collapsed")).toBe(false);
|
||
|
||
window.toggleChatPanel();
|
||
expect(shell.classList.contains("chat-collapsed")).toBe(true);
|
||
|
||
window.toggleChatPanel();
|
||
expect(shell.classList.contains("chat-collapsed")).toBe(false);
|
||
});
|
||
|
||
it("viewer-shell uses CSS grid for content layout", () => {
|
||
const content = document.querySelector(".viewer-content");
|
||
expect(content).toBeTruthy();
|
||
const style = window.getComputedStyle(content!);
|
||
expect(style.display).toBe("grid");
|
||
});
|
||
|
||
it("now-playing overlay is accessible at small sizes", () => {
|
||
const np = document.getElementById("now-playing");
|
||
expect(np?.getAttribute("aria-live")).toBe("polite");
|
||
});
|
||
});
|
||
|
||
describe("presence flash", () => {
|
||
it("flashes join/leave messages instead of appending chat rows", 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 flash = document.getElementById("chat-flash")!;
|
||
expect(flash.textContent).toBe("alice joined");
|
||
expect(flash.hidden).toBe(false);
|
||
|
||
// Join/leave must not be rendered as chat rows.
|
||
const messages = document.getElementById("messages")!;
|
||
expect(messages.textContent).not.toContain("joined");
|
||
|
||
ws.onmessage?.({ data: JSON.stringify({ type: "system", text: "alice left the room", username: "system" }) });
|
||
expect(flash.textContent).toBe("alice left");
|
||
});
|
||
});
|