Compare commits

...

10 Commits

Author SHA1 Message Date
david b5650c53ba test: add JS test framework using Node built-in test runner
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
2026-06-22 02:23:24 -07:00
david d7a17eb054 fix: show extract API errors in UI instead of false 'Extraction complete'
doExtract() only checked data.message, so JSON responses with
error key (e.g. 'no ISO files found') would show green success.
Now checks data.error first and displays it in red.
2026-06-22 01:10:40 -07:00
david fe3e92ce33 feat: recursive WAV browsing in subdirectories
Handle ListWavs now uses filepath.WalkDir to find .wav files
in all subdirectories. Returns relative paths (e.g. Kicks/kick.wav).
Serves them correctly via /wavs/ prefix.

Fixed: WalkDir swallows root errors differently than ReadDir —
now detects and reports non-existent root directory errors.

Added test for recursive subdirectory traversal.
2026-06-22 01:05:44 -07:00
david 371feaf0cb fix: initialize files as empty slice instead of nil var
var files []string marshals to JSON null, breaking the frontend
which expects an array. Using files := []string{} ensures [].
2026-06-22 00:46:30 -07:00
david b69e1d81ab debug: add console logging to browseWavs and playWav 2026-06-22 00:42:18 -07:00
david cd8484de0c fix: pin electron to exact version 33.4.11
electron-builder requires an exact version (not a range like ^33.0.0)
because it downloads platform-specific binaries for a specific release.
2026-06-22 00:22:19 -07:00
david d194a25bf8 chore: pin node to 22.12 to fix electron-build engine requirements
@electron/rebuild and node-abi require node >=22.12.0.
System node (22.9.0) caused EBADENGINE errors during electron-build.
2026-06-22 00:16:42 -07:00
david 4f03524668 chore: add JS/ESLint toolchain, refactor onclick to event delegation
- Add ESLint config for web/ui (flat config, golangci-lint-compatible)
- Add js-lint task to mise.toml (npm run lint in web/ui)
- Add ESLint check to check-toolchain.sh
- Add .golangci.yml for golangci-lint v2 with correct exclusions
- Refactor app.js: replace inline onclick with data-action + event delegation
- Add null checks and HTTP status checks to browseWavs
- Add web/ui/node_modules/ to .gitignore
2026-06-22 00:10:49 -07:00
david f716902580 fix: handle null data in browseWavs, add HTTP status check
Avoid 'Cannot read properties of null (reading error)' when server
returns null or non-JSON. Also check resp.ok before parsing JSON.
2026-06-22 00:02:32 -07:00
david cf53912a33 feat: Audio Preview Player — browse and play WAV samples via Web Audio API
Issue: #9

Backend:
- GET /api/list-wavs?dir=<path> — lists .wav files in a directory
- GET /wavs/<file> — static file serving from ./output/wavs

Web UI:
- WAV Browser section with path input + Browse button
- AudioContext-based playback (zero deps) — progress bar, time display
- WAV file grid with Play buttons, active item highlighting

Tests:
- 3 new tests for handleListWavs (wav files, empty dir, nonexistent dir)
- Fix TestFindAkaiutil: add t.Chdir(tmp) so relative path lookups don't
  accidentally find the repo's third_party/akaiutil/akaiutil binary
2026-06-22 00:01:15 -07:00
16 changed files with 1731 additions and 19 deletions
+3
View File
@@ -25,5 +25,8 @@ wavs
.DS_Store
Thumbs.db
# JS dependencies
web/ui/node_modules/
# Temp
/tmp/
+14
View File
@@ -0,0 +1,14 @@
version: "2"
linters:
default: none
enable:
- errcheck
- govet
- staticcheck
- unused
exclusions:
paths:
- web/ui
- electron
- third_party/akaiutil/
+1 -1
View File
@@ -60,7 +60,7 @@
"build:linux": "electron-builder --linux"
},
"devDependencies": {
"electron": "^33.0.0",
"electron": "33.4.11",
"electron-builder": "^25.0.0"
}
}
+4 -2
View File
@@ -1,7 +1,7 @@
[tools]
go = "1.26"
golangci-lint = "latest"
node = "22"
node = "22.12"
[env]
CGO_ENABLED = "0"
@@ -11,7 +11,9 @@ build = "go build -buildvcs=false -o fetch ."
akaiutil = "make -C third_party/akaiutil"
all = { run = "go build -buildvcs=false -o fetch . && make -C third_party/akaiutil" }
serve = { run = "go build -buildvcs=false -o fetch . && ./fetch serve" }
lint = "go vet ./..."
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" }
+11
View File
@@ -123,5 +123,16 @@ else
fi
fi
echo ""
echo "=== JS Linting (ESLint) ==="
if [ -f web/ui/node_modules/.bin/eslint ]; then
ok "ESLint installed at web/ui/node_modules/.bin/eslint"
eslint_ver=$(cd web/ui && ./node_modules/.bin/eslint --version 2>/dev/null)
ok "ESLint: $eslint_ver"
else
warn "ESLint not installed (needed for web UI linting)"
echo " Install: cd web/ui && npm install"
fi
echo ""
echo "=== Core toolchain check complete ==="
+46
View File
@@ -54,10 +54,20 @@ func cmdServe(args []string) {
mux.HandleFunc("/api/search", handleSearch)
mux.HandleFunc("/api/list", handleList)
mux.HandleFunc("/api/list-wavs", handleListWavs)
mux.HandleFunc("/api/download", handleAPIDownload)
mux.HandleFunc("/api/extract", handleAPIExtract)
mux.HandleFunc("/api/progress", handleProgress)
wavsBase := "./output/wavs"
if abs, err := filepath.Abs(wavsBase); err == nil {
wavsBase = abs
}
if err := os.MkdirAll(wavsBase, 0755); err != nil {
fatal("create wavs dir: %v", err)
}
mux.Handle("/wavs/", http.StripPrefix("/wavs/", http.FileServer(http.Dir(wavsBase))))
addr := fmt.Sprintf("%s:%d", host, port)
listener, err := net.Listen("tcp", addr)
if err != nil {
@@ -254,6 +264,42 @@ func handleAPIDownload(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"jobs": jobIDs})
}
func handleListWavs(w http.ResponseWriter, r *http.Request) {
dir := r.URL.Query().Get("dir")
if dir == "" {
dir = "./output/wavs"
}
if abs, err := filepath.Abs(dir); err == nil {
dir = abs
}
files := []string{}
var walkErr error
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
if path == dir {
walkErr = err
return filepath.SkipAll
}
return nil
}
if !d.IsDir() && strings.HasSuffix(strings.ToLower(d.Name()), ".wav") {
rel, _ := filepath.Rel(dir, path)
files = append(files, rel)
}
return nil
})
if walkErr != nil {
writeJSON(w, map[string]any{"error": walkErr.Error()})
return
}
if err != nil {
writeJSON(w, map[string]any{"error": err.Error()})
return
}
writeJSON(w, files)
}
func handleProgress(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
+102
View File
@@ -443,3 +443,105 @@ func TestHandleAPIExtract_ExtractISOError(t *testing.T) {
t.Errorf("expected FAIL message, got: %s", msg)
}
}
func TestHandleListWavs_ReturnsWavFiles(t *testing.T) {
tmp := t.TempDir()
wavDir := filepath.Join(tmp, "wavs")
if err := os.MkdirAll(wavDir, 0755); err != nil {
t.Fatal(err)
}
for _, name := range []string{"kick.wav", "snare.wav", "hihat.wav"} {
fd, _ := os.Create(filepath.Join(wavDir, name))
_ = fd.Close()
}
fd, _ := os.Create(filepath.Join(wavDir, "readme.txt"))
_ = fd.Close()
req := httptest.NewRequest("GET", "/api/list-wavs?dir="+wavDir, nil)
rr := httptest.NewRecorder()
handleListWavs(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var files []string
if err := json.Unmarshal(rr.Body.Bytes(), &files); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(files) != 3 {
t.Errorf("expected 3 wav files, got %d: %v", len(files), files)
}
}
func TestHandleListWavs_RecursiveSubdirs(t *testing.T) {
tmp := t.TempDir()
wavDir := filepath.Join(tmp, "wavs")
subDir := filepath.Join(wavDir, "Kicks")
if err := os.MkdirAll(subDir, 0755); err != nil {
t.Fatal(err)
}
fd, _ := os.Create(filepath.Join(wavDir, "root.wav"))
_ = fd.Close()
fd2, _ := os.Create(filepath.Join(subDir, "kick1.wav"))
_ = fd2.Close()
req := httptest.NewRequest("GET", "/api/list-wavs?dir="+wavDir, nil)
rr := httptest.NewRecorder()
handleListWavs(rr, req)
var files []string
if err := json.Unmarshal(rr.Body.Bytes(), &files); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if len(files) != 2 {
t.Errorf("expected 2 wav files, got %d: %v", len(files), files)
}
found := make(map[string]bool)
for _, f := range files {
found[f] = true
}
if !found["root.wav"] {
t.Errorf("missing root.wav, got %v", files)
}
if !found[filepath.Join("Kicks", "kick1.wav")] {
t.Errorf("missing Kicks/kick1.wav, got %v", files)
}
}
func TestHandleListWavs_EmptyDir(t *testing.T) {
tmp := t.TempDir()
req := httptest.NewRequest("GET", "/api/list-wavs?dir="+tmp, nil)
rr := httptest.NewRecorder()
handleListWavs(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var files []string
if err := json.Unmarshal(rr.Body.Bytes(), &files); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if files == nil {
t.Fatal("expected empty array [], got null")
}
if len(files) != 0 {
t.Errorf("expected 0 files, got %d", len(files))
}
}
func TestHandleListWavs_NonExistentDir(t *testing.T) {
req := httptest.NewRequest("GET", "/api/list-wavs?dir=/nonexistent/wavs", nil)
rr := httptest.NewRecorder()
handleListWavs(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rr.Code)
}
var resp map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if _, ok := resp["error"]; !ok {
t.Error("expected error for nonexistent dir")
}
}
+7
View File
@@ -99,6 +99,8 @@ func TestFindAkaiutil(t *testing.T) {
}()
t.Run("env var set to missing file", func(t *testing.T) {
tmp := t.TempDir()
t.Chdir(tmp)
_ = os.Setenv("AKAIUTIL", "/nonexistent/akaiutil")
os.Args[0] = "/some/binary"
_, err := findAkaiutil()
@@ -109,6 +111,7 @@ func TestFindAkaiutil(t *testing.T) {
t.Run("env var set to existing file", func(t *testing.T) {
tmp := t.TempDir()
t.Chdir(tmp)
f := tmp + "/myutil"
fd, _ := os.Create(f)
defer func() { _ = fd.Close() }()
@@ -124,6 +127,7 @@ func TestFindAkaiutil(t *testing.T) {
t.Run("finds binary next to argv[0]", func(t *testing.T) {
tmp := t.TempDir()
t.Chdir(tmp)
exe := tmp + "/mybinary"
fd, _ := os.Create(exe)
defer func() { _ = fd.Close() }()
@@ -143,6 +147,7 @@ func TestFindAkaiutil(t *testing.T) {
t.Run("finds binary in third_party relative to argv[0]", func(t *testing.T) {
tmp := t.TempDir()
t.Chdir(tmp)
exe := tmp + "/somebin"
fd, _ := os.Create(exe)
defer func() { _ = fd.Close() }()
@@ -166,6 +171,7 @@ func TestFindAkaiutil(t *testing.T) {
t.Run("env var takes priority over path candidates", func(t *testing.T) {
tmp := t.TempDir()
t.Chdir(tmp)
envFile := tmp + "/envutil"
pathFile := tmp + "/pathutil"
fd1, _ := os.Create(envFile)
@@ -185,6 +191,7 @@ func TestFindAkaiutil(t *testing.T) {
t.Run("no akaiutil found returns error", func(t *testing.T) {
tmp := t.TempDir()
t.Chdir(tmp)
exe := tmp + "/nobinaryhere"
fd, _ := os.Create(exe)
defer func() { _ = fd.Close() }()
+145 -15
View File
@@ -46,8 +46,8 @@ function renderResults(items) {
<span>${r.downloads.toLocaleString()} downloads</span>
</div>
<div class="actions">
<button class="btn-dl" onclick="downloadItem('${esc(r.identifier)}')">Download ISOs</button>
<button class="btn-list" onclick="listItem('${esc(r.identifier)}')">List Files</button>
<button class="btn-dl" data-action="download" data-identifier="${esc(r.identifier)}">Download ISOs</button>
<button class="btn-list" data-action="list" data-identifier="${esc(r.identifier)}">List Files</button>
</div>
<div id="list-${sanitize(r.identifier)}" class="file-list"></div>
</div>
@@ -175,6 +175,11 @@ async function doExtract() {
try {
const resp = await fetch(`${API}/extract?dir=${encodeURIComponent(isoDir)}&out=${encodeURIComponent(wavDir)}`);
const data = await resp.json();
if (data.error) {
status.textContent = data.error;
status.style.color = 'var(--accent)';
return;
}
status.textContent = data.message || 'Extraction complete';
status.style.color = 'var(--green)';
} catch (e) {
@@ -183,25 +188,150 @@ async function doExtract() {
}
}
// ─── Helpers ──────────────────────────────────────────────────────
// ─── WAV Browser ──────────────────────────────────────────────────
function esc(s) {
if (!s) return '';
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
let audioCtx = null;
let currentSource = null;
let currentAudioBuffer = null;
let startTime = 0;
let pauseOffset = 0;
let isPlaying = false;
let animationFrame = null;
document.getElementById('wav-browse-btn').addEventListener('click', browseWavs);
async function browseWavs() {
const dir = document.getElementById('wav-dir').value;
const list = document.getElementById('wav-list');
list.innerHTML = 'Loading...';
console.log('browseWavs: dir =', dir);
try {
const url = `${API}/list-wavs?dir=${encodeURIComponent(dir)}`;
console.log('browseWavs: fetching', url);
const resp = await fetch(url);
console.log('browseWavs: response status', resp.status, 'ok', resp.ok);
if (!resp.ok) {
list.innerHTML = '<span class="no-files">HTTP ' + resp.status + '</span>';
return;
}
const data = await resp.json();
console.log('browseWavs: parsed data', data, 'type', typeof data, 'isArray', Array.isArray(data));
if (!data || data.error) {
list.innerHTML = '<span class="no-files">Error: ' + (data && data.error ? data.error : 'server error') + '</span>';
return;
}
if (!Array.isArray(data) || data.length === 0) {
list.innerHTML = '<span class="no-files">No WAV files found</span>';
return;
}
console.log('browseWavs: found', data.length, 'wavs');
list.innerHTML = data.map(f => `
<div class="wav-item" data-file="${esc(f)}">
<span class="wav-name" title="${esc(f)}">${esc(f)}</span>
<button class="wav-play-btn" data-action="play" data-wav="${esc(f)}">Play</button>
</div>
`).join('');
} catch (e) {
console.error('browseWavs: error', e);
list.innerHTML = 'Failed: ' + e.message;
}
}
function sanitize(s) {
return s.replace(/[^a-zA-Z0-9_-]/g, '_');
async function playWav(filename) {
console.log('playWav:', filename);
if (!audioCtx) {
audioCtx = new AudioContext();
console.log('playWav: created AudioContext');
}
if (currentSource) {
currentSource.stop();
currentSource = null;
}
if (animationFrame) {
cancelAnimationFrame(animationFrame);
animationFrame = null;
}
const nowPlaying = document.getElementById('wav-now-playing');
nowPlaying.textContent = 'Loading: ' + filename;
try {
const resp = await fetch(`/wavs/${encodeURIComponent(filename)}`);
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const arrayBuffer = await resp.arrayBuffer();
currentAudioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
currentSource = audioCtx.createBufferSource();
currentSource.buffer = currentAudioBuffer;
currentSource.connect(audioCtx.destination);
currentSource.onended = () => {
if (isPlaying) {
isPlaying = false;
pauseOffset = 0;
nowPlaying.textContent = '';
document.getElementById('wav-progress-fill').style.width = '0%';
document.getElementById('wav-time').textContent = formatTime(0);
clearActiveItem();
}
};
startTime = audioCtx.currentTime - pauseOffset;
currentSource.start(0, pauseOffset);
isPlaying = true;
pauseOffset = 0;
nowPlaying.textContent = '▶ ' + filename;
setActiveItem(filename);
updateProgress();
} catch (e) {
nowPlaying.textContent = 'Error: ' + e.message;
}
}
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 updateProgress() {
if (!isPlaying || !currentAudioBuffer) return;
const elapsed = audioCtx.currentTime - startTime;
const duration = currentAudioBuffer.duration;
const pct = Math.min((elapsed / duration) * 100, 100);
document.getElementById('wav-progress-fill').style.width = pct + '%';
document.getElementById('wav-time').textContent = formatTime(elapsed);
if (elapsed < duration) {
animationFrame = requestAnimationFrame(updateProgress);
}
}
function setActiveItem(filename) {
document.querySelectorAll('.wav-item').forEach(el => {
el.classList.toggle('playing', el.dataset.file === filename);
});
}
function clearActiveItem() {
document.querySelectorAll('.wav-item').forEach(el => {
el.classList.remove('playing');
});
}
// Initial load
doSearch();
// Event delegation for dynamically generated buttons
document.addEventListener('click', function(e) {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
if (action === 'download') {
downloadItem(btn.dataset.identifier);
} else if (action === 'list') {
listItem(btn.dataset.identifier);
} else if (action === 'play') {
playWav(btn.dataset.wav);
}
});
+45
View File
@@ -0,0 +1,45 @@
const globals = require('globals');
module.exports = [
{
files: ['**/*.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'script',
globals: {
...globals.browser,
AudioContext: 'readonly',
requestAnimationFrame: 'readonly',
cancelAnimationFrame: 'readonly',
fetch: 'readonly',
EventSource: 'readonly',
document: 'readonly',
console: 'readonly',
setTimeout: 'readonly',
Math: 'readonly',
JSON: 'readonly',
location: 'readonly',
encodeURIComponent: 'readonly',
decodeURIComponent: 'readonly',
parseInt: 'readonly',
Number: 'readonly',
Promise: 'readonly',
Object: 'readonly',
Array: 'readonly',
Map: 'readonly',
process: 'readonly',
__dirname: 'readonly',
require: 'readonly',
module: 'readonly',
exports: 'readonly'
}
},
rules: {
"no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
"no-redeclare": "warn",
"no-unreachable": "warn",
"eqeqeq": ["error", "always"],
"prefer-const": "warn"
}
}
];
+18 -1
View File
@@ -39,11 +39,28 @@
<div id="extract-status"></div>
</section>
<section id="wav-section">
<h2>WAV Browser</h2>
<div class="wav-controls">
<input type="text" id="wav-dir" placeholder="WAV directory" value="./output/wavs">
<button id="wav-browse-btn">Browse</button>
</div>
<div id="wav-player" class="wav-player">
<div id="wav-now-playing" class="now-playing"></div>
<div class="audio-bar">
<div class="audio-progress"><div id="wav-progress-fill" class="audio-progress-fill"></div></div>
<span id="wav-time">0:00</span>
</div>
<div id="wav-list" class="wav-list"></div>
</div>
</section>
<section id="results-section">
<h2>Results <span id="result-count"></span></h2>
<div id="search-results"></div>
</section>
</div>
<script src="app.js"></script>
<script src="util.js"></script>
<script src="app.js"></script>
</body>
</html>
+1098
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"name": "akai-utils-web-ui",
"version": "1.0.0",
"description": "Web UI for AKAI Utils",
"scripts": {
"lint": "eslint *.js",
"test": "node --test util.test.js"
},
"devDependencies": {
"eslint": "^9.0.0"
}
}
+105
View File
@@ -206,3 +206,108 @@ h2 {
font-size: 13px;
color: var(--text2);
}
.wav-controls {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.wav-controls input {
flex: 1;
min-width: 200px;
padding: 10px 14px;
border: 1px solid var(--bg3);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font-size: 14px;
}
.wav-controls button {
padding: 10px 20px;
border: none;
border-radius: var(--radius);
background: var(--accent);
color: white;
font-size: 14px;
cursor: pointer;
}
.wav-player {
margin-top: 14px;
}
.now-playing {
font-size: 13px;
color: var(--green);
margin-bottom: 6px;
min-height: 18px;
}
.audio-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.audio-progress {
flex: 1;
height: 6px;
background: var(--bg3);
border-radius: 3px;
overflow: hidden;
}
.audio-progress-fill {
height: 100%;
background: var(--green);
border-radius: 3px;
width: 0%;
transition: width 0.1s;
}
#wav-time {
font-size: 12px;
color: var(--text2);
min-width: 40px;
}
.wav-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 8px;
}
.wav-item {
background: var(--bg);
border: 1px solid var(--bg3);
border-radius: var(--radius);
padding: 10px 12px;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.wav-item:hover {
border-color: var(--accent);
}
.wav-item.playing {
border-color: var(--green);
background: rgba(78, 204, 163, 0.1);
}
.wav-item .wav-name {
flex: 1;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wav-item .wav-play-btn {
background: var(--accent2);
color: white;
border: none;
border-radius: 4px;
padding: 4px 10px;
font-size: 11px;
cursor: pointer;
}
+29
View File
@@ -0,0 +1,29 @@
// AKAI Utils — shared utility functions
function esc(s) {
if (!s) return ''
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')
}
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 }
}
+91
View File
@@ -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>'),
'&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;')
})
it('handles ampersands', () => {
assert.strictEqual(esc('a & b'), 'a &amp; b')
})
it('handles single quotes', () => {
assert.strictEqual(esc("it's"), 'it&#39;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')
})
})