Refactor load testing scripts to support Bash and PowerShell
- 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.
This commit is contained in:
1 parent
9f60087a4c
commit
33b6296c5e
82 files changed
+19682
-21133
No files matched your search
+121
-121
@@ -1,122 +1,122 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Linear Void - 24/7 Synchronized Channel</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Doto:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Space+Grotesk:wght@300..700&family=Tektur:wght@400..900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/src/style.css" />
|
||||
</head>
|
||||
|
||||
<body class="lv-body">
|
||||
<main class="viewer-shell" aria-label="Live player and chat">
|
||||
<header class="viewer-header">
|
||||
<div class="viewer-brand-group">
|
||||
<div class="brand-badge" aria-hidden="true">LV</div>
|
||||
<div>
|
||||
<h1 class="viewer-title">Linear Void</h1>
|
||||
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
|
||||
</div>
|
||||
</div>
|
||||
<button id="chat-toggle" type="button" class="btn btn-secondary chat-toggle-btn" onclick="toggleChatPanel()" aria-controls="chat-panel" aria-expanded="true">
|
||||
Hide Chat
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="viewer-content" aria-label="Live stream and chat layout">
|
||||
<section class="player-stage" aria-label="Video stage">
|
||||
<video id="player" class="player" controls autoplay muted playsinline></video>
|
||||
<div id="now-playing" class="now-playing-overlay" 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" aria-label="Chat panel">
|
||||
<header class="chat-header">
|
||||
<div class="chat-room-title">
|
||||
<span class="chat-live-marker" aria-hidden="true">●</span>
|
||||
<span id="room-name">Main Channel</span>
|
||||
</div>
|
||||
<div class="chat-header-actions">
|
||||
<span id="chat-user-label" class="chat-user-label" hidden></span>
|
||||
<button id="auth-toggle-btn" type="button" class="btn btn-secondary">Sign in</button>
|
||||
<button id="auth-logout-btn" type="button" class="btn btn-secondary" hidden>Sign out</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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" class="btn auth-close" aria-label="Close sign in">×</button>
|
||||
<h2 class="auth-modal-title">Sign in</h2>
|
||||
<input id="auth-username" type="text" class="input" placeholder="Username" autocomplete="username" />
|
||||
<input id="auth-password" type="password" class="input" placeholder="Password" autocomplete="current-password" />
|
||||
<div class="button-row">
|
||||
<button id="auth-login-btn" type="button" class="btn btn-primary">Log in</button>
|
||||
<button id="auth-register-btn" type="button" class="btn btn-secondary">Register</button>
|
||||
</div>
|
||||
<p id="auth-status" class="status-line" aria-live="polite"></p>
|
||||
</div>
|
||||
</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">
|
||||
<div class="chat-input-row">
|
||||
<label for="message-input" class="sr-only">Message input</label>
|
||||
<input id="message-input" type="text" placeholder="Type a message..." class="input" onkeydown="if (event.key === 'Enter') sendMessage();" />
|
||||
<button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
|
||||
</div>
|
||||
<p class="chat-note">Synced with video • Moderation active</p>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="reconnecting-overlay" class="reconnecting-overlay hidden" role="alert" aria-live="assertive">
|
||||
<div class="reconnecting-spinner"></div>
|
||||
<span id="reconnecting-text">Reconnecting...</span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// Load Matomo analytics when configured via the admin runtime config.
|
||||
fetch("/api/matomo")
|
||||
.then(function (r) {
|
||||
return r.ok ? r.json() : null;
|
||||
})
|
||||
.then(function (cfg) {
|
||||
if (!cfg || !cfg.matomoUrl || !cfg.matomoSiteId) return;
|
||||
var _paq = (window._paq = window._paq || []);
|
||||
_paq.push(["trackPageView"]);
|
||||
_paq.push(["enableLinkTracking"]);
|
||||
var base = String(cfg.matomoUrl).replace(/\/+$/, "");
|
||||
_paq.push(["setTrackerUrl", base + "/matomo.php"]);
|
||||
_paq.push(["setSiteId", String(cfg.matomoSiteId)]);
|
||||
var d = document;
|
||||
var g = d.createElement("script");
|
||||
var s = d.getElementsByTagName("script")[0];
|
||||
g.async = true;
|
||||
g.src = base + "/matomo.js";
|
||||
s.parentNode.insertBefore(g, s);
|
||||
})
|
||||
.catch(function () {
|
||||
/* analytics is optional */
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Linear Void - 24/7 Synchronized Channel</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Doto:wght@100..900&family=IBM+Plex+Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Space+Grotesk:wght@300..700&family=Tektur:wght@400..900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/src/style.css" />
|
||||
</head>
|
||||
|
||||
<body class="lv-body">
|
||||
<main class="viewer-shell" aria-label="Live player and chat">
|
||||
<header class="viewer-header">
|
||||
<div class="viewer-brand-group">
|
||||
<div class="brand-badge" aria-hidden="true">LV</div>
|
||||
<div>
|
||||
<h1 class="viewer-title">Linear Void</h1>
|
||||
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
|
||||
</div>
|
||||
</div>
|
||||
<button id="chat-toggle" type="button" class="btn btn-secondary chat-toggle-btn" onclick="toggleChatPanel()" aria-controls="chat-panel" aria-expanded="true">
|
||||
Hide Chat
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="viewer-content" aria-label="Live stream and chat layout">
|
||||
<section class="player-stage" aria-label="Video stage">
|
||||
<video id="player" class="player" controls autoplay muted playsinline></video>
|
||||
<div id="now-playing" class="now-playing-overlay" 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" aria-label="Chat panel">
|
||||
<header class="chat-header">
|
||||
<div class="chat-room-title">
|
||||
<span class="chat-live-marker" aria-hidden="true">●</span>
|
||||
<span id="room-name">Main Channel</span>
|
||||
</div>
|
||||
<div class="chat-header-actions">
|
||||
<span id="chat-user-label" class="chat-user-label" hidden></span>
|
||||
<button id="auth-toggle-btn" type="button" class="btn btn-secondary">Sign in</button>
|
||||
<button id="auth-logout-btn" type="button" class="btn btn-secondary" hidden>Sign out</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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" class="btn auth-close" aria-label="Close sign in">×</button>
|
||||
<h2 class="auth-modal-title">Sign in</h2>
|
||||
<input id="auth-username" type="text" class="input" placeholder="Username" autocomplete="username" />
|
||||
<input id="auth-password" type="password" class="input" placeholder="Password" autocomplete="current-password" />
|
||||
<div class="button-row">
|
||||
<button id="auth-login-btn" type="button" class="btn btn-primary">Log in</button>
|
||||
<button id="auth-register-btn" type="button" class="btn btn-secondary">Register</button>
|
||||
</div>
|
||||
<p id="auth-status" class="status-line" aria-live="polite"></p>
|
||||
</div>
|
||||
</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">
|
||||
<div class="chat-input-row">
|
||||
<label for="message-input" class="sr-only">Message input</label>
|
||||
<input id="message-input" type="text" placeholder="Type a message..." class="input" onkeydown="if (event.key === 'Enter') sendMessage();" />
|
||||
<button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
|
||||
</div>
|
||||
<p class="chat-note">Synced with video • Moderation active</p>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="reconnecting-overlay" class="reconnecting-overlay hidden" role="alert" aria-live="assertive">
|
||||
<div class="reconnecting-spinner"></div>
|
||||
<span id="reconnecting-text">Reconnecting...</span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// Load Matomo analytics when configured via the admin runtime config.
|
||||
fetch("/api/matomo")
|
||||
.then(function (r) {
|
||||
return r.ok ? r.json() : null;
|
||||
})
|
||||
.then(function (cfg) {
|
||||
if (!cfg || !cfg.matomoUrl || !cfg.matomoSiteId) return;
|
||||
var _paq = (window._paq = window._paq || []);
|
||||
_paq.push(["trackPageView"]);
|
||||
_paq.push(["enableLinkTracking"]);
|
||||
var base = String(cfg.matomoUrl).replace(/\/+$/, "");
|
||||
_paq.push(["setTrackerUrl", base + "/matomo.php"]);
|
||||
_paq.push(["setSiteId", String(cfg.matomoSiteId)]);
|
||||
var d = document;
|
||||
var g = d.createElement("script");
|
||||
var s = d.getElementsByTagName("script")[0];
|
||||
g.async = true;
|
||||
g.src = base + "/matomo.js";
|
||||
s.parentNode.insertBefore(g, s);
|
||||
})
|
||||
.catch(function () {
|
||||
/* analytics is optional */
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Generated
+2140
-2140
File diff suppressed because it is too large.
Load diff
+29
-29
@@ -1,29 +1,29 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.0.13",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"@vitest/ui": "^4.1.10",
|
||||
"jsdom": "^30.0.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.0",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"hls.js": "^1.6.16"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "1.0.13",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"@vitest/ui": "^4.1.10",
|
||||
"jsdom": "^30.0.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.2.0",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"hls.js": "^1.6.16"
|
||||
}
|
||||
}
|
||||
+378
-378
@@ -1,378 +1,378 @@
|
||||
/// <reference types="vitest" />
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { refreshAnalytics, refreshOverview, renderPlaylistSortable, savePlaylist } from "../src/admin";
|
||||
|
||||
// Mock fetch globally
|
||||
let fetchCalls: { url: string; options?: RequestInit }[] = [];
|
||||
let mockFetchResponse: any = { ok: true, payload: {} };
|
||||
|
||||
beforeEach(() => {
|
||||
fetchCalls = [];
|
||||
globalThis.fetch = vi.fn(async (url: string, options?: RequestInit) => {
|
||||
fetchCalls.push({ url, options });
|
||||
const payload = mockFetchResponse.payload ?? {};
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(payload), {
|
||||
status: mockFetchResponse.ok ? 200 : mockFetchResponse.status || 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}) as any;
|
||||
|
||||
// Mock localStorage
|
||||
const storage = new Map<string, string>();
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: {
|
||||
getItem: vi.fn((key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, val: string) => storage.set(key, val)),
|
||||
removeItem: vi.fn((key: string) => storage.delete(key)),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock window.confirm/prompt
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
vi.spyOn(window, "prompt").mockReturnValue(null);
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Import after mocks are set
|
||||
import "../src/admin";
|
||||
|
||||
describe("admin.ts initialization", () => {
|
||||
it("loads and exposes tab switching", () => {
|
||||
// After import, init() runs on DOMContentLoaded
|
||||
// Test that the document has admin page structure
|
||||
expect(document.body).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("upload progress helpers", () => {
|
||||
it("renders upload progress correctly", async () => {
|
||||
// Set up DOM for upload page
|
||||
document.body.innerHTML = `
|
||||
<div id="media-upload-progress-wrap" hidden>
|
||||
<progress id="media-upload-progress" max="100" value="0"></progress>
|
||||
<span id="media-upload-progress-text"></span>
|
||||
</div>
|
||||
<div id="tab-content"></div>
|
||||
`;
|
||||
|
||||
// Re-import triggers init again — but we need fresh module state
|
||||
// For now test internal helper access via body
|
||||
const wrap = document.getElementById("media-upload-progress-wrap")!;
|
||||
const bar = document.getElementById("media-upload-progress") as HTMLProgressElement;
|
||||
const text = document.getElementById("media-upload-progress-text")!;
|
||||
|
||||
expect(wrap.hidden).toBe(true);
|
||||
|
||||
// Simulate what setUploadProgress would do
|
||||
wrap.hidden = false;
|
||||
bar.max = 100;
|
||||
bar.value = 50;
|
||||
text.textContent = "Uploading (50%)";
|
||||
|
||||
expect(wrap.hidden).toBe(false);
|
||||
expect(bar.value).toBe(50);
|
||||
expect(text.textContent).toBe("Uploading (50%)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("file fingerprint", () => {
|
||||
it("generates unique fingerprints for different files", () => {
|
||||
const f1 = new File(["a"], "video1.mp4", { lastModified: 1 });
|
||||
const f2 = new File(["b"], "video2.mp4", { lastModified: 2 });
|
||||
|
||||
const fp1 = `${f1.name}:${f1.size}:${f1.lastModified}`;
|
||||
const fp2 = `${f2.name}:${f2.size}:${f2.lastModified}`;
|
||||
|
||||
expect(fp1).not.toBe(fp2);
|
||||
expect(fp1).toBe("video1.mp4:1:1");
|
||||
expect(fp2).toBe("video2.mp4:1:2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MAX_UPLOAD_BYTES constant", () => {
|
||||
it("equals 2GB", () => {
|
||||
expect(2 * 1024 * 1024 * 1024).toBe(2147483648);
|
||||
});
|
||||
});
|
||||
|
||||
describe("API calls on tab load", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div id="tab-content"><div id="overview-content"></div></div>
|
||||
<nav class="admin-nav">
|
||||
<button data-tab="dashboard">Dashboard</button>
|
||||
<button data-tab="analytics">Analytics</button>
|
||||
<button data-tab="playlist">Playlist</button>
|
||||
<button data-tab="schedule">Schedule</button>
|
||||
<button data-tab="system">System</button>
|
||||
<button data-tab="upload">Upload</button>
|
||||
<button data-tab="users">Users</button>
|
||||
<button data-tab="moderation">Moderation</button>
|
||||
<button data-tab="chat-log">Chat Log</button>
|
||||
</nav>
|
||||
<div id="overview-stats"></div>
|
||||
<div id="analytics-stats"></div>
|
||||
<div id="playlist-editor"></div>
|
||||
<div id="schedule-list"></div>
|
||||
<div id="system-summary"></div>
|
||||
<div id="media-upload-progress-wrap" hidden>
|
||||
<progress id="media-upload-progress"></progress>
|
||||
<span id="media-upload-progress-text"></span>
|
||||
</div>
|
||||
<div id="media-table-body"></div>
|
||||
<div id="users-table-body"></div>
|
||||
<div id="moderation-log-table-body"></div>
|
||||
<div id="chat-log-table-body"></div>
|
||||
<div id="chat-log-pagination"></div>
|
||||
<div id="config-editor"></div>
|
||||
<span id="restart-status"></span>
|
||||
<div id="schedule-override-list"></div>
|
||||
`;
|
||||
});
|
||||
|
||||
it("can be imported without crashing", () => {
|
||||
// admin.ts initialization should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin data refresh", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div id="overview-status"></div>
|
||||
<span id="stat-viewers"></span>
|
||||
<span id="stat-stored-messages"></span>
|
||||
<span id="stat-moderation-actions"></span>
|
||||
<span id="stat-media-runtime"></span>
|
||||
<span id="stat-media-count"></span>
|
||||
<span id="system-stored-messages"></span>
|
||||
<span id="system-redis-clients"></span>
|
||||
<span id="system-redis-enabled"></span>
|
||||
<span id="system-redis-memory"></span>
|
||||
<span id="system-db-driver"></span>
|
||||
<span id="system-generated-at"></span>
|
||||
<div id="analytics-status"></div>
|
||||
<span id="analytics-total-messages"></span>
|
||||
<span id="analytics-messages-24h"></span>
|
||||
<span id="analytics-distinct-rooms"></span>
|
||||
<span id="analytics-moderation-actions"></span>
|
||||
<span id="analytics-generated-at"></span>
|
||||
<table><tbody id="analytics-table-body"></tbody></table>
|
||||
`;
|
||||
});
|
||||
|
||||
it("renders overview stats from the admin overview API", async () => {
|
||||
mockFetchResponse = {
|
||||
ok: true,
|
||||
payload: {
|
||||
viewers: 12,
|
||||
storedMessages: 345,
|
||||
moderationActions: 9,
|
||||
mediaTotalRuntime: "01:23:45",
|
||||
mediaCount: 7,
|
||||
redisClients: 4,
|
||||
redisEnabled: true,
|
||||
redisMemoryUsed: "18 MB",
|
||||
databaseDriver: "sqlite",
|
||||
generatedAt: "2026-08-08T12:00:00Z",
|
||||
},
|
||||
};
|
||||
|
||||
await refreshOverview();
|
||||
|
||||
expect(document.getElementById("stat-viewers")?.textContent).toBe("12");
|
||||
expect(document.getElementById("stat-stored-messages")?.textContent).toBe("345");
|
||||
expect(document.getElementById("system-redis-enabled")?.textContent).toBe("yes");
|
||||
expect(document.getElementById("overview-status")?.textContent).toContain("2026-08-08T12:00:00Z");
|
||||
});
|
||||
|
||||
it("renders analytics rows from the analytics API", async () => {
|
||||
mockFetchResponse = {
|
||||
ok: true,
|
||||
payload: {
|
||||
totalMessages: 1000,
|
||||
messagesLast24Hours: 75,
|
||||
distinctRooms: 3,
|
||||
moderationActions: 11,
|
||||
generatedAt: "2026-08-08T12:30:00Z",
|
||||
topRooms: [
|
||||
{ room: "main", messageCount: 55 },
|
||||
{ room: "lobby", messageCount: 20 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await refreshAnalytics();
|
||||
|
||||
expect(document.getElementById("analytics-total-messages")?.textContent).toBe("1000");
|
||||
expect(document.getElementById("analytics-messages-24h")?.textContent).toBe("75");
|
||||
expect(document.getElementById("analytics-table-body")?.textContent).toContain("main");
|
||||
expect(document.getElementById("analytics-table-body")?.textContent).toContain("lobby");
|
||||
});
|
||||
});
|
||||
|
||||
describe("playlist management", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div id="playlist-status"></div>
|
||||
<textarea id="playlist-content">file '/app/media/demo1.mp4'
|
||||
file '/app/media/demo2.mp4'
|
||||
</textarea>
|
||||
<ul id="playlist-sortable"></ul>
|
||||
<div id="playlist-warnings" hidden></div>
|
||||
`;
|
||||
});
|
||||
|
||||
it("saves playlist via PUT request", async () => {
|
||||
mockFetchResponse = { ok: true, payload: {} };
|
||||
|
||||
await savePlaylist();
|
||||
|
||||
const putCall = fetchCalls.find((c) => c.options?.method === "PUT");
|
||||
expect(putCall).toBeDefined();
|
||||
expect(putCall?.url).toBe("/api/admin/playlist");
|
||||
expect(putCall?.options?.headers).toEqual({ "Content-Type": "application/json" });
|
||||
const body = JSON.parse(putCall?.options?.body as string);
|
||||
expect(body.content).toContain("demo1.mp4");
|
||||
});
|
||||
|
||||
it("sets error status on save failure", async () => {
|
||||
mockFetchResponse = { ok: false, status: 500, payload: {} };
|
||||
|
||||
await savePlaylist();
|
||||
|
||||
const status = document.getElementById("playlist-status");
|
||||
expect(status?.textContent).toContain("Failed");
|
||||
});
|
||||
|
||||
it("renders sortable playlist with indexes and paths", () => {
|
||||
renderPlaylistSortable("file '/app/media/demo1.mp4'\nfile '/app/media/demo2.mp4'\n");
|
||||
|
||||
const list = document.getElementById("playlist-sortable")!;
|
||||
expect(list.children).toHaveLength(2);
|
||||
expect(list.innerHTML).toContain("demo1.mp4");
|
||||
expect(list.innerHTML).toContain("demo2.mp4");
|
||||
expect(list.querySelector(".sortable-index")?.textContent).toBe("1");
|
||||
expect(list.querySelector("[data-remove]")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("skips comment and blank lines in sortable", () => {
|
||||
renderPlaylistSortable("# comment\n\nfile '/app/media/demo1.mp4'\n");
|
||||
|
||||
const list = document.getElementById("playlist-sortable")!;
|
||||
expect(list.children).toHaveLength(1);
|
||||
expect(list.innerHTML).toContain("demo1.mp4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin accessibility", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<nav class="admin-nav" aria-label="Admin navigation">
|
||||
<button data-tab="dashboard" aria-current="page">Dashboard</button>
|
||||
<button data-tab="playlist">Playlist</button>
|
||||
<button data-tab="users">Users</button>
|
||||
<button data-tab="moderation">Moderation</button>
|
||||
</nav>
|
||||
<div id="tab-content"></div>
|
||||
`;
|
||||
});
|
||||
|
||||
it("nav has aria-label for screen readers", () => {
|
||||
const nav = document.querySelector(".admin-nav");
|
||||
expect(nav?.getAttribute("aria-label")).toBe("Admin navigation");
|
||||
});
|
||||
|
||||
it("active tab has aria-current attribute", () => {
|
||||
const active = document.querySelector("[aria-current]");
|
||||
expect(active).toBeTruthy();
|
||||
expect(active?.getAttribute("aria-current")).toBe("page");
|
||||
});
|
||||
|
||||
it("all nav buttons are focusable", () => {
|
||||
const buttons = document.querySelectorAll(".admin-nav button");
|
||||
buttons.forEach((btn) => {
|
||||
expect(btn.getAttribute("tabindex")).toBeNull();
|
||||
expect(btn.getAttribute("disabled")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin responsive layout", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<nav class="admin-nav"><button data-tab="dashboard">Dashboard</button></nav>
|
||||
<div id="tab-content"><div id="overview-content">Overview data</div></div>
|
||||
`;
|
||||
});
|
||||
|
||||
it("tab content is present and visible", () => {
|
||||
const tabContent = document.getElementById("tab-content");
|
||||
expect(tabContent).toBeTruthy();
|
||||
const style = window.getComputedStyle(tabContent!);
|
||||
expect(style.display).not.toBe("none");
|
||||
});
|
||||
|
||||
it("admin nav contains essential tabs", () => {
|
||||
const buttons = document.querySelectorAll(".admin-nav button");
|
||||
const tabs = Array.from(buttons).map((b) => b.textContent?.trim());
|
||||
expect(tabs).toContain("Dashboard");
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin data refresh failures", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div id="overview-status"></div>
|
||||
<div id="analytics-status"></div>
|
||||
`;
|
||||
});
|
||||
|
||||
it("sets error status when overview fails", async () => {
|
||||
mockFetchResponse = { ok: false, status: 500, payload: {} };
|
||||
await refreshOverview();
|
||||
expect(document.getElementById("overview-status")?.textContent).toContain("Failed");
|
||||
});
|
||||
|
||||
it("sets error status when analytics fails", async () => {
|
||||
mockFetchResponse = { ok: false, status: 500, payload: {} };
|
||||
await refreshAnalytics();
|
||||
expect(document.getElementById("analytics-status")?.textContent).toContain("Failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("playlist status badges", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `<ul id="playlist-sortable"></ul>`;
|
||||
});
|
||||
|
||||
it("renders badges for every media status", () => {
|
||||
const statusMap = new Map<string, string>([
|
||||
["/app/media/ready.mp4", "ready"],
|
||||
["/app/media/needs.mp4", "needs_normalization"],
|
||||
["/app/media/normalizing.mp4", "normalizing"],
|
||||
["/app/media/failed.mp4", "normalization_failed"],
|
||||
["/app/media/other.mp4", "weird"],
|
||||
]);
|
||||
|
||||
renderPlaylistSortable(
|
||||
"file '/app/media/ready.mp4'\nfile '/app/media/needs.mp4'\nfile '/app/media/normalizing.mp4'\nfile '/app/media/failed.mp4'\nfile '/app/media/other.mp4'\nfile '/app/media/nopath.mp4'\n",
|
||||
statusMap,
|
||||
);
|
||||
|
||||
const list = document.getElementById("playlist-sortable")!;
|
||||
expect(list.innerHTML).toContain("badge-ok");
|
||||
expect(list.innerHTML).toContain("badge-warn");
|
||||
expect(list.innerHTML).toContain("badge-info");
|
||||
expect(list.innerHTML).toContain("badge-danger");
|
||||
expect(list.innerHTML).toContain("badge-muted"); // default + unknown fall back to muted
|
||||
expect(list.children).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
/// <reference types="vitest" />
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { refreshAnalytics, refreshOverview, renderPlaylistSortable, savePlaylist } from "../src/admin";
|
||||
|
||||
// Mock fetch globally
|
||||
let fetchCalls: { url: string; options?: RequestInit }[] = [];
|
||||
let mockFetchResponse: any = { ok: true, payload: {} };
|
||||
|
||||
beforeEach(() => {
|
||||
fetchCalls = [];
|
||||
globalThis.fetch = vi.fn(async (url: string, options?: RequestInit) => {
|
||||
fetchCalls.push({ url, options });
|
||||
const payload = mockFetchResponse.payload ?? {};
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(payload), {
|
||||
status: mockFetchResponse.ok ? 200 : mockFetchResponse.status || 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
}) as any;
|
||||
|
||||
// Mock localStorage
|
||||
const storage = new Map<string, string>();
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: {
|
||||
getItem: vi.fn((key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, val: string) => storage.set(key, val)),
|
||||
removeItem: vi.fn((key: string) => storage.delete(key)),
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock window.confirm/prompt
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
vi.spyOn(window, "prompt").mockReturnValue(null);
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Import after mocks are set
|
||||
import "../src/admin";
|
||||
|
||||
describe("admin.ts initialization", () => {
|
||||
it("loads and exposes tab switching", () => {
|
||||
// After import, init() runs on DOMContentLoaded
|
||||
// Test that the document has admin page structure
|
||||
expect(document.body).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("upload progress helpers", () => {
|
||||
it("renders upload progress correctly", async () => {
|
||||
// Set up DOM for upload page
|
||||
document.body.innerHTML = `
|
||||
<div id="media-upload-progress-wrap" hidden>
|
||||
<progress id="media-upload-progress" max="100" value="0"></progress>
|
||||
<span id="media-upload-progress-text"></span>
|
||||
</div>
|
||||
<div id="tab-content"></div>
|
||||
`;
|
||||
|
||||
// Re-import triggers init again — but we need fresh module state
|
||||
// For now test internal helper access via body
|
||||
const wrap = document.getElementById("media-upload-progress-wrap")!;
|
||||
const bar = document.getElementById("media-upload-progress") as HTMLProgressElement;
|
||||
const text = document.getElementById("media-upload-progress-text")!;
|
||||
|
||||
expect(wrap.hidden).toBe(true);
|
||||
|
||||
// Simulate what setUploadProgress would do
|
||||
wrap.hidden = false;
|
||||
bar.max = 100;
|
||||
bar.value = 50;
|
||||
text.textContent = "Uploading (50%)";
|
||||
|
||||
expect(wrap.hidden).toBe(false);
|
||||
expect(bar.value).toBe(50);
|
||||
expect(text.textContent).toBe("Uploading (50%)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("file fingerprint", () => {
|
||||
it("generates unique fingerprints for different files", () => {
|
||||
const f1 = new File(["a"], "video1.mp4", { lastModified: 1 });
|
||||
const f2 = new File(["b"], "video2.mp4", { lastModified: 2 });
|
||||
|
||||
const fp1 = `${f1.name}:${f1.size}:${f1.lastModified}`;
|
||||
const fp2 = `${f2.name}:${f2.size}:${f2.lastModified}`;
|
||||
|
||||
expect(fp1).not.toBe(fp2);
|
||||
expect(fp1).toBe("video1.mp4:1:1");
|
||||
expect(fp2).toBe("video2.mp4:1:2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("MAX_UPLOAD_BYTES constant", () => {
|
||||
it("equals 2GB", () => {
|
||||
expect(2 * 1024 * 1024 * 1024).toBe(2147483648);
|
||||
});
|
||||
});
|
||||
|
||||
describe("API calls on tab load", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div id="tab-content"><div id="overview-content"></div></div>
|
||||
<nav class="admin-nav">
|
||||
<button data-tab="dashboard">Dashboard</button>
|
||||
<button data-tab="analytics">Analytics</button>
|
||||
<button data-tab="playlist">Playlist</button>
|
||||
<button data-tab="schedule">Schedule</button>
|
||||
<button data-tab="system">System</button>
|
||||
<button data-tab="upload">Upload</button>
|
||||
<button data-tab="users">Users</button>
|
||||
<button data-tab="moderation">Moderation</button>
|
||||
<button data-tab="chat-log">Chat Log</button>
|
||||
</nav>
|
||||
<div id="overview-stats"></div>
|
||||
<div id="analytics-stats"></div>
|
||||
<div id="playlist-editor"></div>
|
||||
<div id="schedule-list"></div>
|
||||
<div id="system-summary"></div>
|
||||
<div id="media-upload-progress-wrap" hidden>
|
||||
<progress id="media-upload-progress"></progress>
|
||||
<span id="media-upload-progress-text"></span>
|
||||
</div>
|
||||
<div id="media-table-body"></div>
|
||||
<div id="users-table-body"></div>
|
||||
<div id="moderation-log-table-body"></div>
|
||||
<div id="chat-log-table-body"></div>
|
||||
<div id="chat-log-pagination"></div>
|
||||
<div id="config-editor"></div>
|
||||
<span id="restart-status"></span>
|
||||
<div id="schedule-override-list"></div>
|
||||
`;
|
||||
});
|
||||
|
||||
it("can be imported without crashing", () => {
|
||||
// admin.ts initialization should not throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin data refresh", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div id="overview-status"></div>
|
||||
<span id="stat-viewers"></span>
|
||||
<span id="stat-stored-messages"></span>
|
||||
<span id="stat-moderation-actions"></span>
|
||||
<span id="stat-media-runtime"></span>
|
||||
<span id="stat-media-count"></span>
|
||||
<span id="system-stored-messages"></span>
|
||||
<span id="system-redis-clients"></span>
|
||||
<span id="system-redis-enabled"></span>
|
||||
<span id="system-redis-memory"></span>
|
||||
<span id="system-db-driver"></span>
|
||||
<span id="system-generated-at"></span>
|
||||
<div id="analytics-status"></div>
|
||||
<span id="analytics-total-messages"></span>
|
||||
<span id="analytics-messages-24h"></span>
|
||||
<span id="analytics-distinct-rooms"></span>
|
||||
<span id="analytics-moderation-actions"></span>
|
||||
<span id="analytics-generated-at"></span>
|
||||
<table><tbody id="analytics-table-body"></tbody></table>
|
||||
`;
|
||||
});
|
||||
|
||||
it("renders overview stats from the admin overview API", async () => {
|
||||
mockFetchResponse = {
|
||||
ok: true,
|
||||
payload: {
|
||||
viewers: 12,
|
||||
storedMessages: 345,
|
||||
moderationActions: 9,
|
||||
mediaTotalRuntime: "01:23:45",
|
||||
mediaCount: 7,
|
||||
redisClients: 4,
|
||||
redisEnabled: true,
|
||||
redisMemoryUsed: "18 MB",
|
||||
databaseDriver: "sqlite",
|
||||
generatedAt: "2026-08-08T12:00:00Z",
|
||||
},
|
||||
};
|
||||
|
||||
await refreshOverview();
|
||||
|
||||
expect(document.getElementById("stat-viewers")?.textContent).toBe("12");
|
||||
expect(document.getElementById("stat-stored-messages")?.textContent).toBe("345");
|
||||
expect(document.getElementById("system-redis-enabled")?.textContent).toBe("yes");
|
||||
expect(document.getElementById("overview-status")?.textContent).toContain("2026-08-08T12:00:00Z");
|
||||
});
|
||||
|
||||
it("renders analytics rows from the analytics API", async () => {
|
||||
mockFetchResponse = {
|
||||
ok: true,
|
||||
payload: {
|
||||
totalMessages: 1000,
|
||||
messagesLast24Hours: 75,
|
||||
distinctRooms: 3,
|
||||
moderationActions: 11,
|
||||
generatedAt: "2026-08-08T12:30:00Z",
|
||||
topRooms: [
|
||||
{ room: "main", messageCount: 55 },
|
||||
{ room: "lobby", messageCount: 20 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await refreshAnalytics();
|
||||
|
||||
expect(document.getElementById("analytics-total-messages")?.textContent).toBe("1000");
|
||||
expect(document.getElementById("analytics-messages-24h")?.textContent).toBe("75");
|
||||
expect(document.getElementById("analytics-table-body")?.textContent).toContain("main");
|
||||
expect(document.getElementById("analytics-table-body")?.textContent).toContain("lobby");
|
||||
});
|
||||
});
|
||||
|
||||
describe("playlist management", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div id="playlist-status"></div>
|
||||
<textarea id="playlist-content">file '/app/media/demo1.mp4'
|
||||
file '/app/media/demo2.mp4'
|
||||
</textarea>
|
||||
<ul id="playlist-sortable"></ul>
|
||||
<div id="playlist-warnings" hidden></div>
|
||||
`;
|
||||
});
|
||||
|
||||
it("saves playlist via PUT request", async () => {
|
||||
mockFetchResponse = { ok: true, payload: {} };
|
||||
|
||||
await savePlaylist();
|
||||
|
||||
const putCall = fetchCalls.find((c) => c.options?.method === "PUT");
|
||||
expect(putCall).toBeDefined();
|
||||
expect(putCall?.url).toBe("/api/admin/playlist");
|
||||
expect(putCall?.options?.headers).toEqual({ "Content-Type": "application/json" });
|
||||
const body = JSON.parse(putCall?.options?.body as string);
|
||||
expect(body.content).toContain("demo1.mp4");
|
||||
});
|
||||
|
||||
it("sets error status on save failure", async () => {
|
||||
mockFetchResponse = { ok: false, status: 500, payload: {} };
|
||||
|
||||
await savePlaylist();
|
||||
|
||||
const status = document.getElementById("playlist-status");
|
||||
expect(status?.textContent).toContain("Failed");
|
||||
});
|
||||
|
||||
it("renders sortable playlist with indexes and paths", () => {
|
||||
renderPlaylistSortable("file '/app/media/demo1.mp4'\nfile '/app/media/demo2.mp4'\n");
|
||||
|
||||
const list = document.getElementById("playlist-sortable")!;
|
||||
expect(list.children).toHaveLength(2);
|
||||
expect(list.innerHTML).toContain("demo1.mp4");
|
||||
expect(list.innerHTML).toContain("demo2.mp4");
|
||||
expect(list.querySelector(".sortable-index")?.textContent).toBe("1");
|
||||
expect(list.querySelector("[data-remove]")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("skips comment and blank lines in sortable", () => {
|
||||
renderPlaylistSortable("# comment\n\nfile '/app/media/demo1.mp4'\n");
|
||||
|
||||
const list = document.getElementById("playlist-sortable")!;
|
||||
expect(list.children).toHaveLength(1);
|
||||
expect(list.innerHTML).toContain("demo1.mp4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin accessibility", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<nav class="admin-nav" aria-label="Admin navigation">
|
||||
<button data-tab="dashboard" aria-current="page">Dashboard</button>
|
||||
<button data-tab="playlist">Playlist</button>
|
||||
<button data-tab="users">Users</button>
|
||||
<button data-tab="moderation">Moderation</button>
|
||||
</nav>
|
||||
<div id="tab-content"></div>
|
||||
`;
|
||||
});
|
||||
|
||||
it("nav has aria-label for screen readers", () => {
|
||||
const nav = document.querySelector(".admin-nav");
|
||||
expect(nav?.getAttribute("aria-label")).toBe("Admin navigation");
|
||||
});
|
||||
|
||||
it("active tab has aria-current attribute", () => {
|
||||
const active = document.querySelector("[aria-current]");
|
||||
expect(active).toBeTruthy();
|
||||
expect(active?.getAttribute("aria-current")).toBe("page");
|
||||
});
|
||||
|
||||
it("all nav buttons are focusable", () => {
|
||||
const buttons = document.querySelectorAll(".admin-nav button");
|
||||
buttons.forEach((btn) => {
|
||||
expect(btn.getAttribute("tabindex")).toBeNull();
|
||||
expect(btn.getAttribute("disabled")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin responsive layout", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<nav class="admin-nav"><button data-tab="dashboard">Dashboard</button></nav>
|
||||
<div id="tab-content"><div id="overview-content">Overview data</div></div>
|
||||
`;
|
||||
});
|
||||
|
||||
it("tab content is present and visible", () => {
|
||||
const tabContent = document.getElementById("tab-content");
|
||||
expect(tabContent).toBeTruthy();
|
||||
const style = window.getComputedStyle(tabContent!);
|
||||
expect(style.display).not.toBe("none");
|
||||
});
|
||||
|
||||
it("admin nav contains essential tabs", () => {
|
||||
const buttons = document.querySelectorAll(".admin-nav button");
|
||||
const tabs = Array.from(buttons).map((b) => b.textContent?.trim());
|
||||
expect(tabs).toContain("Dashboard");
|
||||
});
|
||||
});
|
||||
|
||||
describe("admin data refresh failures", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `
|
||||
<div id="overview-status"></div>
|
||||
<div id="analytics-status"></div>
|
||||
`;
|
||||
});
|
||||
|
||||
it("sets error status when overview fails", async () => {
|
||||
mockFetchResponse = { ok: false, status: 500, payload: {} };
|
||||
await refreshOverview();
|
||||
expect(document.getElementById("overview-status")?.textContent).toContain("Failed");
|
||||
});
|
||||
|
||||
it("sets error status when analytics fails", async () => {
|
||||
mockFetchResponse = { ok: false, status: 500, payload: {} };
|
||||
await refreshAnalytics();
|
||||
expect(document.getElementById("analytics-status")?.textContent).toContain("Failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("playlist status badges", () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = `<ul id="playlist-sortable"></ul>`;
|
||||
});
|
||||
|
||||
it("renders badges for every media status", () => {
|
||||
const statusMap = new Map<string, string>([
|
||||
["/app/media/ready.mp4", "ready"],
|
||||
["/app/media/needs.mp4", "needs_normalization"],
|
||||
["/app/media/normalizing.mp4", "normalizing"],
|
||||
["/app/media/failed.mp4", "normalization_failed"],
|
||||
["/app/media/other.mp4", "weird"],
|
||||
]);
|
||||
|
||||
renderPlaylistSortable(
|
||||
"file '/app/media/ready.mp4'\nfile '/app/media/needs.mp4'\nfile '/app/media/normalizing.mp4'\nfile '/app/media/failed.mp4'\nfile '/app/media/other.mp4'\nfile '/app/media/nopath.mp4'\n",
|
||||
statusMap,
|
||||
);
|
||||
|
||||
const list = document.getElementById("playlist-sortable")!;
|
||||
expect(list.innerHTML).toContain("badge-ok");
|
||||
expect(list.innerHTML).toContain("badge-warn");
|
||||
expect(list.innerHTML).toContain("badge-info");
|
||||
expect(list.innerHTML).toContain("badge-danger");
|
||||
expect(list.innerHTML).toContain("badge-muted"); // default + unknown fall back to muted
|
||||
expect(list.children).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,23 @@
|
||||
/// <reference types="vitest" />
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { setupCounter } from "../src/counter";
|
||||
|
||||
describe("setupCounter", () => {
|
||||
it("initializes the button with count 0", () => {
|
||||
const btn = document.createElement("button");
|
||||
setupCounter(btn);
|
||||
expect(btn.innerHTML).toBe("Count is 0");
|
||||
});
|
||||
|
||||
it("increments the counter on each click", () => {
|
||||
const btn = document.createElement("button");
|
||||
setupCounter(btn);
|
||||
|
||||
btn.click();
|
||||
expect(btn.innerHTML).toBe("Count is 1");
|
||||
|
||||
btn.click();
|
||||
btn.click();
|
||||
expect(btn.innerHTML).toBe("Count is 3");
|
||||
});
|
||||
});
|
||||
/// <reference types="vitest" />
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { setupCounter } from "../src/counter";
|
||||
|
||||
describe("setupCounter", () => {
|
||||
it("initializes the button with count 0", () => {
|
||||
const btn = document.createElement("button");
|
||||
setupCounter(btn);
|
||||
expect(btn.innerHTML).toBe("Count is 0");
|
||||
});
|
||||
|
||||
it("increments the counter on each click", () => {
|
||||
const btn = document.createElement("button");
|
||||
setupCounter(btn);
|
||||
|
||||
btn.click();
|
||||
expect(btn.innerHTML).toBe("Count is 1");
|
||||
|
||||
btn.click();
|
||||
btn.click();
|
||||
expect(btn.innerHTML).toBe("Count is 3");
|
||||
});
|
||||
});
|
||||
+598
-598
File diff suppressed because it is too large.
Load diff
+519
-519
File diff suppressed because it is too large.
Load diff
+1244
-1244
File diff suppressed because it is too large.
Load diff
+20
-20
@@ -1,20 +1,20 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["src/**/*.test.ts"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "text-summary"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["src/**/*.test.ts"],
|
||||
thresholds: {
|
||||
statements: 20,
|
||||
branches: 15,
|
||||
functions: 15,
|
||||
lines: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["src/**/*.test.ts"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "text-summary"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["src/**/*.test.ts"],
|
||||
thresholds: {
|
||||
statements: 20,
|
||||
branches: 15,
|
||||
functions: 15,
|
||||
lines: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
name = "linearvoid-frontend"
|
||||
compatibility_date = "2025-08-01"
|
||||
|
||||
[assets]
|
||||
directory = "./dist"
|
||||
not_found_handling = "single-page-application"
|
||||
name = "linearvoid-frontend"
|
||||
compatibility_date = "2025-08-01"
|
||||
|
||||
[assets]
|
||||
directory = "./dist"
|
||||
not_found_handling = "single-page-application"
|
||||
Reference in new issue
Block a user