From 3e6af1bff2264f23c0d17fae5b8e248c38dc16d3 Mon Sep 17 00:00:00 2001 From: Gronod Date: Thu, 21 May 2026 00:07:41 +0100 Subject: [PATCH] Add frontend unit tests with Vitest + jsdom - 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) --- tests/frontend/ui/downloads.test.js | 100 ++++++++++++++++++++++ tests/frontend/utils/format.test.js | 127 ++++++++++++++++++++++++++++ vitest.config.js | 10 ++- 3 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 tests/frontend/ui/downloads.test.js create mode 100644 tests/frontend/utils/format.test.js diff --git a/tests/frontend/ui/downloads.test.js b/tests/frontend/ui/downloads.test.js new file mode 100644 index 0000000..39dea22 --- /dev/null +++ b/tests/frontend/ui/downloads.test.js @@ -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); + }); +}); diff --git a/tests/frontend/utils/format.test.js b/tests/frontend/utils/format.test.js new file mode 100644 index 0000000..b7072c2 --- /dev/null +++ b/tests/frontend/utils/format.test.js @@ -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('')).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'); + }); +}); diff --git a/vitest.config.js b/vitest.config.js index c7e1888..7d0efce 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -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',