Add frontend unit tests with Vitest + jsdom
Some checks failed
Some checks failed
- 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:
100
tests/frontend/ui/downloads.test.js
Normal file
100
tests/frontend/ui/downloads.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
127
tests/frontend/utils/format.test.js
Normal file
127
tests/frontend/utils/format.test.js
Normal 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('<script>alert("xss")</script>');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user