624 lines
22 KiB
TypeScript
624 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" hidden>
|
|
<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>
|
|
<button id="users-toggle-btn" type="button" aria-expanded="true">Users</button>
|
|
<div id="typing-indicator" hidden></div>
|
|
<div id="dm-search-row" hidden>
|
|
<input id="dm-search-input" type="text" />
|
|
<button id="dm-search-btn" type="button">Search</button>
|
|
</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 and user list 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");
|
|
});
|
|
|
|
it("toggles the user list visibility", async () => {
|
|
await import("../src/main");
|
|
|
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ authenticated: false, entries: [] }),
|
|
text: async () => "",
|
|
}) as any;
|
|
|
|
document.dispatchEvent(new Event("DOMContentLoaded"));
|
|
|
|
const panel = document.getElementById("presence-panel")!;
|
|
const btn = document.getElementById("users-toggle-btn")!;
|
|
expect(panel.hidden).toBe(false);
|
|
|
|
btn.click();
|
|
expect(panel.hidden).toBe(true);
|
|
expect(btn.getAttribute("aria-expanded")).toBe("false");
|
|
|
|
btn.click();
|
|
expect(panel.hidden).toBe(false);
|
|
expect(btn.getAttribute("aria-expanded")).toBe("true");
|
|
});
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|