Add frontend unit tests with Vitest + jsdom
Some checks failed
Licence Check / Licence compatibility and copyright header verification (push) Successful in 38s
CI / Security audit (push) Successful in 53s
CI / Tests & coverage (push) Successful in 1m9s
Build and Push Docker Image / build (push) Failing after 32s

- Create tests/frontend/utils/format.test.js with 24 tests for formatting utilities
- Create tests/frontend/ui/downloads.test.js with 10 tests for DOM rendering functions
- Update vitest.config.js to support jsdom environment for frontend tests
- All 34 tests pass and cover edge cases (null, zero, large numbers, DOM structure)
This commit is contained in:
2026-05-21 00:07:41 +01:00
parent d9897ff0d2
commit 3e6af1bff2
3 changed files with 235 additions and 2 deletions

View File

@@ -0,0 +1,100 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* @vitest-environment jsdom
* Tests for client/src/ui/downloads.js
*
* Verifies DOM rendering functions for tag badges and client logos.
* Uses jsdom to create and assert DOM structure.
*/
import { renderTagBadges } from '../../../client/src/ui/downloads.js';
describe('renderTagBadges', () => {
it('returns empty fragment when showAll is false and no matchedUserTag', () => {
const result = renderTagBadges([], false, null);
expect(result).toBeTruthy();
expect(result.childNodes.length).toBe(0);
});
it('returns empty fragment when tagBadges is empty', () => {
const result = renderTagBadges([], true, null);
expect(result).toBeTruthy();
expect(result.childNodes.length).toBe(0);
});
it('renders single matched badge when matchedUserTag is provided', () => {
const result = renderTagBadges([], false, 'user1');
expect(result.childNodes.length).toBe(1);
const badge = result.childNodes[0];
expect(badge.className).toBe('download-user-badge');
expect(badge.textContent).toBe('user1');
});
it('renders unmatched badges when showAll is true', () => {
const tagBadges = [{ label: 'tag1', matchedUser: null }];
const result = renderTagBadges(tagBadges, true, null);
expect(result.childNodes.length).toBe(1);
const badge = result.childNodes[0];
expect(badge.className).toBe('download-user-badge unmatched');
expect(badge.textContent).toBe('tag1');
});
it('renders matched badges when showAll is true', () => {
const tagBadges = [{ label: 'tag1', matchedUser: 'user1' }];
const result = renderTagBadges(tagBadges, true, null);
expect(result.childNodes.length).toBe(1);
const badge = result.childNodes[0];
expect(badge.className).toBe('download-user-badge');
expect(badge.textContent).toBe('user1');
});
it('renders multiple badges in correct order (unmatched first)', () => {
const tagBadges = [
{ label: 'tag1', matchedUser: 'user1' },
{ label: 'tag2', matchedUser: null }
];
const result = renderTagBadges(tagBadges, true, null);
expect(result.childNodes.length).toBe(2);
expect(result.childNodes[0].textContent).toBe('tag2');
expect(result.childNodes[0].className).toBe('download-user-badge unmatched');
expect(result.childNodes[1].textContent).toBe('user1');
expect(result.childNodes[1].className).toBe('download-user-badge');
});
it('handles mixed matched and unmatched badges', () => {
const tagBadges = [
{ label: 'tag1', matchedUser: null },
{ label: 'tag2', matchedUser: 'user2' },
{ label: 'tag3', matchedUser: null }
];
const result = renderTagBadges(tagBadges, true, null);
expect(result.childNodes.length).toBe(3);
// Unmatched badges come first
expect(result.childNodes[0].className).toBe('download-user-badge unmatched');
expect(result.childNodes[0].textContent).toBe('tag1');
expect(result.childNodes[1].className).toBe('download-user-badge unmatched');
expect(result.childNodes[1].textContent).toBe('tag3');
// Matched badges come after
expect(result.childNodes[2].className).toBe('download-user-badge');
expect(result.childNodes[2].textContent).toBe('user2');
});
it('prefers matchedUserTag over tagBadges when showAll is false', () => {
const tagBadges = [{ label: 'tag1', matchedUser: 'user1' }];
const result = renderTagBadges(tagBadges, false, 'override');
expect(result.childNodes.length).toBe(1);
expect(result.childNodes[0].textContent).toBe('override');
});
it('handles null tagBadges gracefully', () => {
const result = renderTagBadges(null, true, null);
expect(result).toBeTruthy();
expect(result.childNodes.length).toBe(0);
});
it('handles undefined tagBadges gracefully', () => {
const result = renderTagBadges(undefined, true, null);
expect(result).toBeTruthy();
expect(result.childNodes.length).toBe(0);
});
});

View File

@@ -0,0 +1,127 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
/**
* @vitest-environment jsdom
* Tests for client/src/utils/format.js
*
* Verifies formatting utilities for sizes, speeds, dates, and HTML escaping.
* These are pure functions that handle edge cases like null, zero, and large numbers.
*/
import { formatSize, formatSpeed, formatDate, formatTimeAgo, escapeHtml } from '../../../client/src/utils/format.js';
describe('formatSize', () => {
it('returns N/A for null/undefined', () => {
expect(formatSize(null)).toBe('N/A');
expect(formatSize(undefined)).toBe('N/A');
});
it('returns string as-is when already formatted', () => {
expect(formatSize('21.5 GB')).toBe('21.5 GB');
});
it('formats bytes correctly', () => {
expect(formatSize(512)).toBe('512 B');
});
it('formats kilobytes correctly', () => {
expect(formatSize(1024)).toBe('1 KB');
});
it('formats megabytes correctly', () => {
expect(formatSize(1024 * 1024)).toBe('1 MB');
});
it('formats gigabytes correctly', () => {
expect(formatSize(1024 * 1024 * 1024)).toBe('1 GB');
});
it('handles zero', () => {
expect(formatSize(0)).toBe('N/A');
});
});
describe('formatSpeed', () => {
it('returns 0 B/s for zero', () => {
expect(formatSpeed(0)).toBe('0 B/s');
});
it('returns 0 B/s for null/undefined', () => {
expect(formatSpeed(null)).toBe('0 B/s');
expect(formatSpeed(undefined)).toBe('0 B/s');
});
it('formats bytes per second correctly', () => {
expect(formatSpeed(512)).toBe('512.00 B/s');
});
it('formats kilobytes per second correctly', () => {
expect(formatSpeed(1024)).toBe('1.00 KB/s');
});
it('formats megabytes per second correctly', () => {
expect(formatSpeed(1024 * 1024)).toBe('1.00 MB/s');
});
it('handles large numbers', () => {
expect(formatSpeed(1024 * 1024 * 1024)).toBe('1.00 GB/s');
});
});
describe('formatDate', () => {
it('returns N/A for null/undefined', () => {
expect(formatDate(null)).toBe('N/A');
expect(formatDate(undefined)).toBe('N/A');
});
it('formats valid date string', () => {
const dateStr = '2024-01-15T10:30:00Z';
const result = formatDate(dateStr);
expect(result).toBeTruthy();
expect(result).not.toBe('N/A');
});
});
describe('formatTimeAgo', () => {
it('returns Never for null/undefined', () => {
expect(formatTimeAgo(null)).toBe('Never');
expect(formatTimeAgo(undefined)).toBe('Never');
});
it('returns seconds ago for recent timestamps', () => {
const now = Date.now();
expect(formatTimeAgo(now - 30000)).toBe('30s ago');
});
it('returns minutes ago for older timestamps', () => {
const now = Date.now();
expect(formatTimeAgo(now - 60000 * 5)).toBe('5m ago');
});
it('returns hours ago for hours-old timestamps', () => {
const now = Date.now();
expect(formatTimeAgo(now - 60000 * 60 * 3)).toBe('3h ago');
});
it('returns days ago for day-old timestamps', () => {
const now = Date.now();
expect(formatTimeAgo(now - 60000 * 60 * 24 * 2)).toBe('2d ago');
});
});
describe('escapeHtml', () => {
it('escapes HTML special characters', () => {
expect(escapeHtml('<script>alert("xss")</script>')).toBe('&lt;script&gt;alert("xss")&lt;/script&gt;');
});
it('escapes quotes', () => {
expect(escapeHtml('"test"')).toBe('"test"');
});
it('handles empty string', () => {
expect(escapeHtml('')).toBe('');
});
it('handles normal text without special chars', () => {
expect(escapeHtml('normal text')).toBe('normal text');
});
});

View File

@@ -3,8 +3,6 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
// Node environment for all tests (server-side CJS modules, no browser APIs needed)
environment: 'node',
// Global test helpers (describe, it, expect, vi) without per-file imports
globals: true,
// Run each test file in an isolated module registry so module-level state
@@ -12,6 +10,14 @@ export default defineConfig({
isolate: true,
// Give each file its own data directory so tokenStore file I/O doesn't collide
setupFiles: ['./tests/setup.js'],
// Environment configuration based on test type
environmentMatchGlobs: [
// Server tests use node environment (must come first - more specific)
['tests/unit/**/*.js', 'node'],
['tests/integration/**/*.js', 'node'],
// Frontend tests need jsdom for DOM APIs (broader pattern comes last)
['tests/frontend/**/*.js', 'jsdom']
],
// Coverage via V8 (built into Node — no babel transform needed)
coverage: {
provider: 'v8',