From b5650c53bab9af87cabf002fbae593be4040f9e4 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Mon, 22 Jun 2026 02:23:24 -0700 Subject: [PATCH] test: add JS test framework using Node built-in test runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracted pure utility functions (esc, sanitize, formatBytes, formatTime) into web/ui/util.js with CommonJS export for Node testing compatibility. Added 19 tests across 4 suites using node:test + node:assert (zero deps). - util.js: browser-compatible utility functions - util.test.js: 19 tests (5 esc, 3 sanitize, 6 formatBytes, 5 formatTime) - package.json: 'test' script → node --test util.test.js - mise.toml: js-test task - app.js: removed duplicate helper functions (now in util.js) - index.html: load util.js before app.js --- mise.toml | 1 + web/ui/app.js | 26 ------------- web/ui/index.html | 3 +- web/ui/package.json | 3 +- web/ui/util.js | 29 +++++++++++++++ web/ui/util.test.js | 91 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 125 insertions(+), 28 deletions(-) create mode 100644 web/ui/util.js create mode 100644 web/ui/util.test.js diff --git a/mise.toml b/mise.toml index 881ee5b..3221f7d 100644 --- a/mise.toml +++ b/mise.toml @@ -13,6 +13,7 @@ all = { run = "go build -buildvcs=false -o fetch . && make -C third_party/akaiut serve = { run = "go build -buildvcs=false -o fetch . && ./fetch serve" } lint = "golangci-lint run" js-lint = "cd web/ui && npm run lint" +js-test = "cd web/ui && npm test" electron-deps = "cd electron && npm install" electron-dev = { run = "go build -buildvcs=false -o fetch . && cd electron && npx electron ." } electron-build = { run = "go build -buildvcs=false -o fetch . && make -C third_party/akaiutil && cd electron && npx electron-builder --dir" } diff --git a/web/ui/app.js b/web/ui/app.js index 6d3ed62..29d57f2 100644 --- a/web/ui/app.js +++ b/web/ui/app.js @@ -307,12 +307,6 @@ function updateProgress() { } } -function formatTime(secs) { - const m = Math.floor(secs / 60); - const s = Math.floor(secs % 60); - return m + ':' + s.toString().padStart(2, '0'); -} - function setActiveItem(filename) { document.querySelectorAll('.wav-item').forEach(el => { el.classList.toggle('playing', el.dataset.file === filename); @@ -325,26 +319,6 @@ function clearActiveItem() { }); } -// ─── Helpers ────────────────────────────────────────────────────── - -function esc(s) { - if (!s) return ''; - return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); -} - -function sanitize(s) { - return s.replace(/[^a-zA-Z0-9_-]/g, '_'); -} - -function formatBytes(bytes) { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; -} - // Initial load doSearch(); diff --git a/web/ui/index.html b/web/ui/index.html index 3a6e677..1794e9b 100644 --- a/web/ui/index.html +++ b/web/ui/index.html @@ -60,6 +60,7 @@
- + + diff --git a/web/ui/package.json b/web/ui/package.json index 30e3e05..8e99ccc 100644 --- a/web/ui/package.json +++ b/web/ui/package.json @@ -3,7 +3,8 @@ "version": "1.0.0", "description": "Web UI for AKAI Utils", "scripts": { - "lint": "eslint *.js" + "lint": "eslint *.js", + "test": "node --test util.test.js" }, "devDependencies": { "eslint": "^9.0.0" diff --git a/web/ui/util.js b/web/ui/util.js new file mode 100644 index 0000000..a40a83c --- /dev/null +++ b/web/ui/util.js @@ -0,0 +1,29 @@ +// AKAI Utils — shared utility functions + +function esc(s) { + if (!s) return '' + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') +} + +function sanitize(s) { + return s.replace(/[^a-zA-Z0-9_-]/g, '_') +} + +function formatBytes(bytes) { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] +} + +function formatTime(secs) { + const m = Math.floor(secs / 60) + const s = Math.floor(secs % 60) + return m + ':' + s.toString().padStart(2, '0') +} + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { esc, sanitize, formatBytes, formatTime } +} \ No newline at end of file diff --git a/web/ui/util.test.js b/web/ui/util.test.js new file mode 100644 index 0000000..26b461b --- /dev/null +++ b/web/ui/util.test.js @@ -0,0 +1,91 @@ +const { describe, it } = require('node:test') +const assert = require('node:assert/strict') +const { esc, sanitize, formatBytes, formatTime } = require('./util.js') + +describe('esc', () => { + it('escapes HTML entities', () => { + assert.strictEqual(esc(''), + '<script>alert("xss")</script>') + }) + + it('handles ampersands', () => { + assert.strictEqual(esc('a & b'), 'a & b') + }) + + it('handles single quotes', () => { + assert.strictEqual(esc("it's"), 'it's') + }) + + it('returns empty string for falsy input', () => { + assert.strictEqual(esc(''), '') + assert.strictEqual(esc(null), '') + assert.strictEqual(esc(undefined), '') + }) + + it('passes through safe strings', () => { + assert.strictEqual(esc('Hello World'), 'Hello World') + }) +}) + +describe('sanitize', () => { + it('replaces special characters with underscores', () => { + assert.strictEqual(sanitize('hello world!'), 'hello_world_') + }) + + it('preserves alphanumeric and dashes', () => { + assert.strictEqual(sanitize('test-123_abc'), 'test-123_abc') + }) + + it('sanitizes dots and slashes', () => { + assert.strictEqual(sanitize('path/to/file.name'), + 'path_to_file_name') + }) +}) + +describe('formatBytes', () => { + it('formats zero', () => { + assert.strictEqual(formatBytes(0), '0 B') + }) + + it('formats bytes', () => { + assert.strictEqual(formatBytes(500), '500 B') + }) + + it('formats KB', () => { + assert.strictEqual(formatBytes(1024), '1 KB') + }) + + it('formats MB', () => { + assert.strictEqual(formatBytes(1048576), '1 MB') + }) + + it('formats GB', () => { + assert.strictEqual(formatBytes(1073741824), '1 GB') + }) + + it('formats fractional', () => { + assert.strictEqual(formatBytes(1536), '1.5 KB') + }) +}) + +describe('formatTime', () => { + it('formats zero', () => { + assert.strictEqual(formatTime(0), '0:00') + }) + + it('formats seconds', () => { + assert.strictEqual(formatTime(5), '0:05') + }) + + it('formats minutes and seconds', () => { + assert.strictEqual(formatTime(65), '1:05') + }) + + it('formats double-digit seconds', () => { + assert.strictEqual(formatTime(75), '1:15') + }) + + it('formats large times', () => { + assert.strictEqual(formatTime(3661), '61:01') + }) +}) \ No newline at end of file