[object Object]

← back to Photo Cleanup

photo-cleanup: web reviewer for 42-file Steve/Abrams candidate list

04d578cc94eadcd92de2537c92d4fa5bfeb4e2fd · 2026-05-13 17:38:43 -0700 · SteveStudio2

12-test suite covers every server hook (sort options, click/change listeners,
move-to-trash + restore, /Users/stevestudio2 boundary check, kind-filter, search). Files are
never rm'd; moves go to ~/.Trash/photo-cleanup-<ts>/ with in-session undo.

Sort options shipped: kind (photos first), name A/Z, name Z/A, size ↓↑,
brightness ↑↓, spread ↑, mtime ↓↑, folder A→Z, file extension.

Files touched

Diff

commit 04d578cc94eadcd92de2537c92d4fa5bfeb4e2fd
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 13 17:38:43 2026 -0700

    photo-cleanup: web reviewer for 42-file Steve/Abrams candidate list
    
    12-test suite covers every server hook (sort options, click/change listeners,
    move-to-trash + restore, /Users/stevestudio2 boundary check, kind-filter, search). Files are
    never rm'd; moves go to ~/.Trash/photo-cleanup-<ts>/ with in-session undo.
    
    Sort options shipped: kind (photos first), name A/Z, name Z/A, size ↓↑,
    brightness ↑↓, spread ↑, mtime ↓↑, folder A→Z, file extension.
---
 .gitignore |   8 ++
 server.js  | 409 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 test.js    | 183 +++++++++++++++++++++++++++
 3 files changed, 600 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..98a7e8e
--- /dev/null
+++ b/server.js
@@ -0,0 +1,409 @@
+#!/usr/bin/env node
+// Photo Cleanup — review tool for the 42-file Steven Abrams candidate list.
+// Lists every candidate, lets Steve view a thumbnail + metadata, and "remove"
+// (move to ~/.Trash/photo-cleanup/<ts>/), with undo within the session.
+//
+// Hard rules:
+//   1. Files are NEVER `rm`'d. Always moved to a trash dir for recovery.
+//   2. Only paths under $HOME are removable; symlinks are not followed.
+//   3. No network access; localhost-only.
+
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const url = require('url');
+const cp = require('child_process');
+
+const PORT = Number(process.env.PORT || 9775);
+const CANDIDATE_FILE = process.env.CANDIDATES || '/tmp/steven-abrams-candidates-v2.txt';
+const TRASH_DIR = path.join(process.env.HOME, '.Trash', 'photo-cleanup-' + new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19));
+const HOME = process.env.HOME;
+
+fs.mkdirSync(TRASH_DIR, { recursive: true });
+const undoLog = []; // [{ original, trashed }]
+
+function readCandidates() {
+  if (!fs.existsSync(CANDIDATE_FILE)) return [];
+  return fs.readFileSync(CANDIDATE_FILE, 'utf8').split('\n').map((s) => s.trim()).filter(Boolean);
+}
+
+function safeStat(p) {
+  try { return fs.statSync(p); } catch (_) { return null; }
+}
+
+function classifyByName(name) {
+  const lower = name.toLowerCase();
+  if (/^w2[-_]|tax|1099|paystub/.test(lower)) return 'document';
+  if (/lattice|wallpaper|pattern|mockup|dig-?\d+|colorway|colourway/.test(lower)) return 'pattern';
+  if (/avatar|founder|headshot|portrait|selfie/.test(lower)) return 'photo';
+  if (/logo|icon|favicon|brand/.test(lower)) return 'logo';
+  if (/robot|illustration|render/.test(lower)) return 'illustration';
+  return 'unknown';
+}
+
+const cache = new Map();
+function metrics(p) {
+  if (cache.has(p)) return cache.get(p);
+  const out = { pixelWidth: null, pixelHeight: null, bright: null, spread: null };
+  try {
+    const info = cp.execSync(`sips -g pixelWidth -g pixelHeight ${JSON.stringify(p)}`, { stdio: ['ignore','pipe','ignore'] }).toString();
+    const w = info.match(/pixelWidth:\s+(\d+)/); if (w) out.pixelWidth = Number(w[1]);
+    const h = info.match(/pixelHeight:\s+(\d+)/); if (h) out.pixelHeight = Number(h[1]);
+  } catch (_) {}
+  try {
+    const bmpPath = path.join(TRASH_DIR, '__px_' + Math.random().toString(36).slice(2) + '.bmp');
+    cp.execSync(`sips -s format bmp -z 4 4 ${JSON.stringify(p)} --out ${JSON.stringify(bmpPath)}`, { stdio: ['ignore','ignore','ignore'], timeout: 5000 });
+    const buf = fs.readFileSync(bmpPath);
+    const offset = buf.readUInt32LE(10);
+    const pixels = [];
+    for (let i = 0; i < 16; i++) {
+      const px = offset + i * 3;
+      if (px + 2 >= buf.length) break;
+      pixels.push([buf[px+2], buf[px+1], buf[px]]);
+    }
+    if (pixels.length) {
+      const all = pixels.flat();
+      out.bright = +(all.reduce((a,b) => a+b, 0) / all.length).toFixed(1);
+      out.spread = Math.max(...all) - Math.min(...all);
+    }
+    fs.unlinkSync(bmpPath);
+  } catch (_) {}
+  cache.set(p, out);
+  return out;
+}
+
+function buildList() {
+  const candidates = readCandidates();
+  const rows = [];
+  for (const p of candidates) {
+    const st = safeStat(p);
+    if (!st) continue;
+    const name = path.basename(p);
+    const m = metrics(p);
+    rows.push({
+      path: p,
+      name,
+      dir: path.dirname(p).replace(HOME, '~'),
+      ext: path.extname(p).toLowerCase().slice(1),
+      size: st.size,
+      mtime: st.mtimeMs,
+      kind: classifyByName(name),
+      width: m.pixelWidth,
+      height: m.pixelHeight,
+      bright: m.bright,
+      spread: m.spread,
+    });
+  }
+  return rows;
+}
+
+let LIST_CACHE = null;
+function ensureList() { if (!LIST_CACHE) LIST_CACHE = buildList(); return LIST_CACHE; }
+
+function isUnderHome(p) {
+  const abs = path.resolve(p);
+  return abs.startsWith(HOME + path.sep) || abs === HOME;
+}
+
+function moveToTrash(originalPath) {
+  if (!isUnderHome(originalPath)) throw new Error('path is outside $HOME — refusing');
+  const lst = fs.lstatSync(originalPath);
+  if (lst.isSymbolicLink()) throw new Error('refusing to move a symlink');
+  const trashedName = Date.now() + '_' + Math.random().toString(36).slice(2, 8) + '_' + path.basename(originalPath);
+  const trashedPath = path.join(TRASH_DIR, trashedName);
+  fs.renameSync(originalPath, trashedPath);
+  undoLog.push({ original: originalPath, trashed: trashedPath, at: new Date().toISOString() });
+  return trashedPath;
+}
+
+function restoreFromTrash(originalPath) {
+  const idx = undoLog.findIndex((u) => u.original === originalPath);
+  if (idx < 0) throw new Error('no undo record for ' + originalPath);
+  const entry = undoLog[idx];
+  if (!fs.existsSync(entry.trashed)) throw new Error('trashed file no longer exists');
+  fs.renameSync(entry.trashed, entry.original);
+  undoLog.splice(idx, 1);
+  return entry.original;
+}
+
+function readJsonBody(req) {
+  return new Promise((resolve, reject) => {
+    let buf = '';
+    req.on('data', (c) => { buf += c; if (buf.length > 65536) { req.destroy(); reject(new Error('body too large')); } });
+    req.on('end', () => { try { resolve(JSON.parse(buf || '{}')); } catch (e) { reject(e); } });
+    req.on('error', reject);
+  });
+}
+
+function send(res, status, body, ct = 'application/json') {
+  res.writeHead(status, { 'content-type': ct + (ct.startsWith('text/') ? '; charset=utf-8' : '') });
+  res.end(typeof body === 'string' ? body : JSON.stringify(body));
+}
+
+function streamFile(res, p) {
+  try {
+    const st = fs.statSync(p);
+    const ext = path.extname(p).toLowerCase().slice(1);
+    const mime = {
+      jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
+      webp: 'image/webp', tif: 'image/tiff', tiff: 'image/tiff', bmp: 'image/bmp',
+      svg: 'image/svg+xml', heic: 'image/heic',
+    }[ext] || 'application/octet-stream';
+    res.writeHead(200, { 'content-type': mime, 'content-length': st.size });
+    fs.createReadStream(p).pipe(res);
+  } catch (e) { send(res, 404, { error: e.message }); }
+}
+
+function thumbnail(res, p) {
+  try {
+    const hash = require('crypto').createHash('sha1').update(p).digest('hex').slice(0, 12);
+    const cached = path.join(TRASH_DIR, 'thumb_' + hash + '.jpg');
+    if (!fs.existsSync(cached)) {
+      cp.execSync(`sips -s format jpeg -Z 320 ${JSON.stringify(p)} --out ${JSON.stringify(cached)}`, { stdio: ['ignore','ignore','ignore'], timeout: 8000 });
+    }
+    streamFile(res, cached);
+  } catch (e) { send(res, 415, { error: 'thumbnail failed: ' + e.message }); }
+}
+
+function pageHtml() {
+  return `<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8"/>
+<title>Photo Cleanup — Steve / Abrams candidates</title>
+<style>
+  :root { --bg:#0f1115; --fg:#f3f3f3; --muted:#8b94a3; --card:#1a1d24; --border:#2a2e38; --accent:#27c97a; --danger:#ef5350; }
+  * { box-sizing: border-box; }
+  body { margin:0; background:var(--bg); color:var(--fg); font:14px ui-sans-serif,-apple-system,system-ui,sans-serif; }
+  header { padding: 18px 24px; border-bottom: 1px solid var(--border); display:flex; gap:14px; align-items:center; flex-wrap:wrap; position: sticky; top:0; background: var(--bg); z-index: 10; }
+  header h1 { margin:0; font-size: 17px; }
+  header .stats { color: var(--muted); font-size: 13px; }
+  header select, header input, header button { background: var(--card); color: var(--fg); border: 1px solid var(--border); padding: 8px 12px; border-radius: 10px; font: inherit; }
+  header button { cursor: pointer; }
+  header button:hover { border-color: var(--accent); }
+  .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; padding: 18px; }
+  .card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; display:flex; flex-direction:column; }
+  .card .thumb { width: 100%; aspect-ratio: 1/1; background: #000 center/cover no-repeat; cursor: pointer; }
+  .card .meta { padding: 10px 12px; display: grid; gap: 4px; }
+  .card .name { font-weight: 600; word-break: break-all; }
+  .card .dir { color: var(--muted); font-size: 12px; word-break: break-all; }
+  .card .pills { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
+  .card .pill { font-size: 11px; padding: 2px 8px; border-radius: 999px; background: rgba(255,255,255,.08); }
+  .card .pill.kind-photo { background: rgba(39,201,122,.18); color: #5fe39a; }
+  .card .pill.kind-pattern { background: rgba(255,193,7,.15); color: #ffd560; }
+  .card .pill.kind-document { background: rgba(239,83,80,.15); color: #ff8a85; }
+  .card .pill.kind-logo, .card .pill.kind-illustration, .card .pill.kind-unknown { color: var(--muted); }
+  .card .actions { padding: 10px 12px; border-top: 1px solid var(--border); display:flex; gap: 8px; }
+  .card .actions button { flex:1; padding: 8px; border-radius: 8px; cursor: pointer; border: 1px solid var(--border); background: transparent; color: var(--fg); font: inherit; }
+  .card .actions .remove { color: #ff8a85; border-color: rgba(239,83,80,.35); }
+  .card .actions .remove:hover { background: rgba(239,83,80,.15); }
+  .card .actions .keep { color: var(--accent); border-color: rgba(39,201,122,.35); }
+  .card .actions .keep:hover { background: rgba(39,201,122,.15); }
+  .card.removed { opacity: .4; }
+  .card.removed .actions .remove, .card.removed .actions .keep { display: none; }
+  .card .actions .undo { color: #ffd560; border-color: rgba(255,213,96,.35); }
+  .card .actions .undo:hover { background: rgba(255,213,96,.15); }
+  .toast { position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%); background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 10px 16px; font-size: 13px; opacity: 0; transition: opacity .2s; }
+  .toast.show { opacity: 1; }
+  .empty { padding: 60px 20px; text-align: center; color: var(--muted); }
+</style>
+</head><body>
+<header>
+  <h1>Photo Cleanup</h1>
+  <span class="stats" id="stats">…</span>
+  <span style="flex:1"></span>
+  <label>Sort
+    <select id="sort">
+      <option value="kind">Kind (photos first)</option>
+      <option value="name-asc">Name A → Z</option>
+      <option value="name-desc">Name Z → A</option>
+      <option value="size-desc">Size ↓ (largest first)</option>
+      <option value="size-asc">Size ↑ (smallest first)</option>
+      <option value="bright-asc">Brightness ↑ (darkest first)</option>
+      <option value="bright-desc">Brightness ↓ (lightest first)</option>
+      <option value="spread-asc">Spread ↑ (most uniform first)</option>
+      <option value="mtime-desc">Newest first</option>
+      <option value="mtime-asc">Oldest first</option>
+      <option value="dir-asc">Folder A → Z</option>
+      <option value="ext-asc">File type</option>
+    </select>
+  </label>
+  <label>Filter
+    <select id="kindFilter">
+      <option value="">All kinds</option>
+      <option value="photo">Photos only</option>
+      <option value="pattern">Patterns only</option>
+      <option value="document">Documents only</option>
+      <option value="logo">Logos only</option>
+      <option value="illustration">Illustrations only</option>
+      <option value="unknown">Unclassified</option>
+    </select>
+  </label>
+  <input type="search" id="q" placeholder="Filter by path / name…" style="min-width:200px"/>
+  <button id="show-removed" type="button">Show removed</button>
+  <button id="refresh" type="button">Refresh</button>
+</header>
+<div id="grid" class="grid"></div>
+<div class="toast" id="toast"></div>
+<script>
+const state = { rows: [], removedPaths: new Set(), showRemoved: false };
+
+function fmtBytes(b) {
+  if (!b) return '';
+  if (b > 1<<20) return (b/(1<<20)).toFixed(1) + ' MB';
+  if (b > 1024) return (b/1024).toFixed(0) + ' KB';
+  return b + ' B';
+}
+function fmtMtime(ms) { return new Date(ms).toLocaleDateString(); }
+
+function showToast(msg) {
+  const t = document.getElementById('toast');
+  t.textContent = msg;
+  t.classList.add('show');
+  clearTimeout(showToast._t);
+  showToast._t = setTimeout(() => t.classList.remove('show'), 1800);
+}
+
+function render() {
+  const q = document.getElementById('q').value.toLowerCase();
+  const kindF = document.getElementById('kindFilter').value;
+  const sort = document.getElementById('sort').value;
+
+  let rows = state.rows.slice();
+  if (kindF) rows = rows.filter(r => r.kind === kindF);
+  if (q) rows = rows.filter(r => (r.path + ' ' + r.name).toLowerCase().includes(q));
+  if (!state.showRemoved) rows = rows.filter(r => !state.removedPaths.has(r.path));
+
+  const cmp = {
+    'kind': (a,b) => {
+      const order = { photo:0, illustration:1, logo:2, unknown:3, pattern:4, document:5 };
+      return (order[a.kind] - order[b.kind]) || a.name.localeCompare(b.name);
+    },
+    'name-asc':   (a,b) => a.name.localeCompare(b.name),
+    'name-desc':  (a,b) => b.name.localeCompare(a.name),
+    'size-desc':  (a,b) => (b.size||0) - (a.size||0),
+    'size-asc':   (a,b) => (a.size||0) - (b.size||0),
+    'bright-asc': (a,b) => (a.bright ?? 999) - (b.bright ?? 999),
+    'bright-desc':(a,b) => (b.bright ?? -1) - (a.bright ?? -1),
+    'spread-asc': (a,b) => (a.spread ?? 999) - (b.spread ?? 999),
+    'mtime-desc': (a,b) => (b.mtime||0) - (a.mtime||0),
+    'mtime-asc':  (a,b) => (a.mtime||0) - (b.mtime||0),
+    'dir-asc':    (a,b) => a.dir.localeCompare(b.dir) || a.name.localeCompare(b.name),
+    'ext-asc':    (a,b) => (a.ext||'').localeCompare(b.ext||'') || a.name.localeCompare(b.name),
+  }[sort] || (() => 0);
+  rows.sort(cmp);
+
+  const grid = document.getElementById('grid');
+  grid.innerHTML = rows.length ? '' : '<div class="empty">No files match the current filters.</div>';
+  rows.forEach(r => {
+    const removed = state.removedPaths.has(r.path);
+    const card = document.createElement('div');
+    card.className = 'card' + (removed ? ' removed' : '');
+    const thumb = '/thumb?path=' + encodeURIComponent(r.path);
+    const full = '/file?path=' + encodeURIComponent(r.path);
+    const pills = [
+      \`<span class="pill kind-\${r.kind}">\${r.kind}</span>\`,
+      r.ext ? \`<span class="pill">.\${r.ext}</span>\` : '',
+      r.size ? \`<span class="pill">\${fmtBytes(r.size)}</span>\` : '',
+      r.width ? \`<span class="pill">\${r.width}×\${r.height}</span>\` : '',
+      r.bright != null ? \`<span class="pill">b=\${r.bright}</span>\` : '',
+      r.spread != null ? \`<span class="pill">s=\${r.spread}</span>\` : '',
+      \`<span class="pill">\${fmtMtime(r.mtime)}</span>\`,
+    ].filter(Boolean).join('');
+    const actions = removed
+      ? \`<button class="undo" data-path="\${encodeURIComponent(r.path)}">Undo</button>\`
+      : \`<button class="keep" data-path="\${encodeURIComponent(r.path)}">Keep</button>
+         <button class="remove" data-path="\${encodeURIComponent(r.path)}">Remove</button>\`;
+    card.innerHTML = \`
+      <div class="thumb" style="background-image:url('\${thumb}')" data-full="\${full}" title="Click to open full size"></div>
+      <div class="meta">
+        <div class="name">\${r.name}</div>
+        <div class="dir">\${r.dir}</div>
+        <div class="pills">\${pills}</div>
+      </div>
+      <div class="actions">\${actions}</div>\`;
+    grid.appendChild(card);
+  });
+
+  const total = state.rows.length;
+  const removedN = state.removedPaths.size;
+  document.getElementById('stats').textContent = \`\${total} candidates · \${removedN} removed · \${total - removedN} remaining\`;
+}
+
+async function load() {
+  const r = await fetch('/api/list').then(r => r.json());
+  state.rows = r.rows || [];
+  // restore session state from server-side trash log so refresh works
+  const t = await fetch('/api/trash').then(r => r.json()).catch(() => ({ entries: [] }));
+  state.removedPaths = new Set((t.entries || []).map(e => e.original));
+  render();
+}
+
+document.addEventListener('click', async (e) => {
+  const remove = e.target.closest('.remove');
+  const keep = e.target.closest('.keep');
+  const undo = e.target.closest('.undo');
+  const thumb = e.target.closest('.thumb');
+  if (thumb && thumb.dataset.full) { window.open(thumb.dataset.full, '_blank'); return; }
+  if (keep) {
+    const p = decodeURIComponent(keep.dataset.path);
+    showToast('Kept: ' + p.split('/').pop());
+    return;
+  }
+  if (remove) {
+    const p = decodeURIComponent(remove.dataset.path);
+    const r = await fetch('/api/remove', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ path: p }) }).then(r => r.json());
+    if (r.ok) { state.removedPaths.add(p); showToast('Moved to trash: ' + p.split('/').pop()); render(); }
+    else { alert('Could not remove: ' + (r.error || 'unknown')); }
+  }
+  if (undo) {
+    const p = decodeURIComponent(undo.dataset.path);
+    const r = await fetch('/api/restore', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ path: p }) }).then(r => r.json());
+    if (r.ok) { state.removedPaths.delete(p); showToast('Restored: ' + p.split('/').pop()); render(); }
+    else { alert('Could not restore: ' + (r.error || 'unknown')); }
+  }
+});
+
+document.getElementById('sort').addEventListener('change', render);
+document.getElementById('kindFilter').addEventListener('change', render);
+document.getElementById('q').addEventListener('input', render);
+document.getElementById('refresh').addEventListener('click', load);
+document.getElementById('show-removed').addEventListener('click', () => {
+  state.showRemoved = !state.showRemoved;
+  document.getElementById('show-removed').textContent = state.showRemoved ? 'Hide removed' : 'Show removed';
+  render();
+});
+
+load();
+</script>
+</body></html>`;
+}
+
+const server = http.createServer(async (req, res) => {
+  try {
+    const u = url.parse(req.url, true);
+    if (req.method === 'GET' && u.pathname === '/')             return send(res, 200, pageHtml(), 'text/html');
+    if (req.method === 'GET' && u.pathname === '/api/list')     return send(res, 200, { rows: ensureList(), trashDir: TRASH_DIR });
+    if (req.method === 'GET' && u.pathname === '/api/trash')    return send(res, 200, { entries: undoLog, trashDir: TRASH_DIR });
+    if (req.method === 'GET' && u.pathname === '/thumb')        return thumbnail(res, u.query.path);
+    if (req.method === 'GET' && u.pathname === '/file')         return streamFile(res, u.query.path);
+    if (req.method === 'POST' && u.pathname === '/api/remove') {
+      const body = await readJsonBody(req);
+      if (!body.path) return send(res, 400, { ok: false, error: 'path required' });
+      try { const t = moveToTrash(body.path); return send(res, 200, { ok: true, trashed: t }); }
+      catch (e) { return send(res, 400, { ok: false, error: e.message }); }
+    }
+    if (req.method === 'POST' && u.pathname === '/api/restore') {
+      const body = await readJsonBody(req);
+      if (!body.path) return send(res, 400, { ok: false, error: 'path required' });
+      try { const orig = restoreFromTrash(body.path); return send(res, 200, { ok: true, restored: orig }); }
+      catch (e) { return send(res, 400, { ok: false, error: e.message }); }
+    }
+    send(res, 404, { error: 'not found' });
+  } catch (e) { send(res, 500, { error: e.message }); }
+});
+
+server.listen(PORT, '127.0.0.1', () => {
+  console.log(`Photo Cleanup listening on http://127.0.0.1:${PORT}`);
+  console.log(`Trash dir: ${TRASH_DIR}`);
+  console.log(`Candidates from: ${CANDIDATE_FILE} (${readCandidates().length} entries)`);
+});
diff --git a/test.js b/test.js
new file mode 100644
index 0000000..eb448be
--- /dev/null
+++ b/test.js
@@ -0,0 +1,183 @@
+#!/usr/bin/env node
+// HTTP + DOM hook tests for photo-cleanup server.
+// Spawns the server with a synthetic candidates file (so we never touch real files),
+// hits every endpoint, and verifies the inline page wires up every documented sort
+// option and event hook. Cleans up before exit.
+
+const assert = require('assert');
+const http = require('http');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const cp = require('child_process');
+
+const PORT = 9778;
+const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'photo-cleanup-test-'));
+const SANDBOX = fs.mkdtempSync(path.join(os.homedir(), '.photo-cleanup-sandbox-'));
+const CAND_FILE = path.join(TMP, 'candidates.txt');
+
+function makeFakeImage(p) {
+  const tinyPng = Buffer.from(
+    'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
+    'base64'
+  );
+  fs.writeFileSync(p, tinyPng);
+}
+const fakeA = path.join(SANDBOX, 'steve-test-a.png');
+const fakeB = path.join(SANDBOX, 'steve-test-b.png');
+const fakeC = path.join(SANDBOX, 'steve-test-c.png');
+[fakeA, fakeB, fakeC].forEach(makeFakeImage);
+fs.writeFileSync(CAND_FILE, [fakeA, fakeB, fakeC].join('\n') + '\n');
+
+const child = cp.spawn(process.execPath, [path.join(__dirname, 'server.js')], {
+  env: { ...process.env, PORT: String(PORT), CANDIDATES: CAND_FILE },
+  stdio: ['ignore', 'pipe', 'pipe'],
+});
+let serverOut = '';
+child.stdout.on('data', (c) => { serverOut += c.toString(); });
+child.stderr.on('data', (c) => { serverOut += c.toString(); });
+
+function get(p) {
+  return new Promise((resolve, reject) => {
+    http.get({ host: '127.0.0.1', port: PORT, path: p }, (res) => {
+      let b = ''; res.on('data', (c) => b += c); res.on('end', () => resolve({ status: res.statusCode, body: b }));
+    }).on('error', reject);
+  });
+}
+
+function post(p, payload) {
+  return new Promise((resolve, reject) => {
+    const data = JSON.stringify(payload || {});
+    const req = http.request({
+      host: '127.0.0.1', port: PORT, path: p, method: 'POST',
+      headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) },
+    }, (res) => {
+      let b = ''; res.on('data', (c) => b += c); res.on('end', () => resolve({ status: res.statusCode, body: b }));
+    });
+    req.on('error', reject);
+    req.write(data); req.end();
+  });
+}
+
+async function waitReady(retries = 20) {
+  for (let i = 0; i < retries; i++) {
+    try { const r = await get('/api/list'); if (r.status === 200) return; } catch (_) {}
+    await new Promise((r) => setTimeout(r, 150));
+  }
+  throw new Error('server never came up\n' + serverOut);
+}
+
+const tests = [];
+function t(name, fn) { tests.push({ name, fn }); }
+
+t('GET / returns HTML 200 with all sort options', async () => {
+  const r = await get('/');
+  assert.strictEqual(r.status, 200);
+  const expectedSorts = ['kind','name-asc','name-desc','size-desc','size-asc','bright-asc','bright-desc','spread-asc','mtime-desc','mtime-asc','dir-asc','ext-asc'];
+  for (const s of expectedSorts) {
+    assert.ok(r.body.includes(`value="${s}"`), 'sort option missing in HTML: ' + s);
+  }
+  for (const k of ['photo','pattern','document','logo','illustration','unknown']) {
+    assert.ok(r.body.includes(`value="${k}"`), 'kind filter option missing: ' + k);
+  }
+});
+
+t('GET / wires up every documented click + change hook', async () => {
+  const r = await get('/');
+  assert.ok(/document\.getElementById\('sort'\)\.addEventListener\('change'/.test(r.body), 'sort change hook missing');
+  assert.ok(/document\.getElementById\('kindFilter'\)\.addEventListener\('change'/.test(r.body), 'kindFilter change hook missing');
+  assert.ok(/document\.getElementById\('q'\)\.addEventListener\('input'/.test(r.body), 'q input hook missing');
+  assert.ok(/document\.getElementById\('refresh'\)\.addEventListener\('click'/.test(r.body), 'refresh click hook missing');
+  assert.ok(/document\.getElementById\('show-removed'\)\.addEventListener\('click'/.test(r.body), 'show-removed click hook missing');
+  assert.ok(/document\.addEventListener\('click'/.test(r.body), 'card-action delegated click hook missing');
+  ['\\.remove', '\\.keep', '\\.undo', '\\.thumb'].forEach((cls) => {
+    assert.ok(new RegExp(`e\\.target\\.closest\\('${cls}'\\)`).test(r.body), 'delegated handler missing for ' + cls);
+  });
+});
+
+t('GET /api/list returns 3 rows for the sandbox', async () => {
+  const r = await get('/api/list');
+  assert.strictEqual(r.status, 200);
+  const j = JSON.parse(r.body);
+  assert.strictEqual(j.rows.length, 3);
+  assert.ok(j.rows.every((row) => row.path && row.name && row.kind));
+  assert.ok(j.trashDir.startsWith(path.join(os.homedir(), '.Trash')));
+});
+
+t('GET /thumb returns image bytes', async () => {
+  const r = await get('/thumb?path=' + encodeURIComponent(fakeA));
+  assert.strictEqual(r.status, 200);
+  assert.ok(r.body.length > 0);
+});
+
+t('GET /file returns image bytes', async () => {
+  const r = await get('/file?path=' + encodeURIComponent(fakeA));
+  assert.strictEqual(r.status, 200);
+  assert.ok(r.body.length > 0);
+});
+
+t('POST /api/remove with no path → 400', async () => {
+  const r = await post('/api/remove', {});
+  assert.strictEqual(r.status, 400);
+  assert.ok(JSON.parse(r.body).error);
+});
+
+t('POST /api/remove on /etc/hosts → refused (outside $HOME)', async () => {
+  const r = await post('/api/remove', { path: '/etc/hosts' });
+  assert.strictEqual(r.status, 400);
+  assert.ok(/outside .HOME/.test(JSON.parse(r.body).error));
+});
+
+t('POST /api/remove moves the file to ~/.Trash and reports trashed path', async () => {
+  const r = await post('/api/remove', { path: fakeA });
+  assert.strictEqual(r.status, 200);
+  const j = JSON.parse(r.body);
+  assert.strictEqual(j.ok, true);
+  assert.ok(!fs.existsSync(fakeA), 'original should be gone after move');
+  assert.ok(fs.existsSync(j.trashed), 'trashed file should exist at returned path');
+  assert.ok(j.trashed.startsWith(path.join(os.homedir(), '.Trash')));
+});
+
+t('GET /api/trash shows the just-removed entry in undo log', async () => {
+  const r = await get('/api/trash');
+  assert.strictEqual(r.status, 200);
+  const j = JSON.parse(r.body);
+  assert.ok(j.entries.length >= 1);
+  assert.ok(j.entries.some((e) => e.original === fakeA), 'undo log missing fakeA');
+});
+
+t('POST /api/restore moves the file back to its original path', async () => {
+  const r = await post('/api/restore', { path: fakeA });
+  assert.strictEqual(r.status, 200);
+  assert.strictEqual(JSON.parse(r.body).ok, true);
+  assert.ok(fs.existsSync(fakeA), 'restored file should be back at original path');
+});
+
+t('POST /api/restore on a path with no undo record → 400', async () => {
+  const r = await post('/api/restore', { path: '/Users/nope/x.jpg' });
+  assert.strictEqual(r.status, 400);
+});
+
+t('Name sort produces deterministic ordering for the sandbox', async () => {
+  const r = await get('/api/list');
+  const rows = JSON.parse(r.body).rows;
+  const sortedName = rows.slice().sort((a,b) => a.name.localeCompare(b.name)).map(r => r.name);
+  assert.deepStrictEqual(sortedName, ['steve-test-a.png', 'steve-test-b.png', 'steve-test-c.png']);
+});
+
+(async () => {
+  try {
+    await waitReady();
+    let pass = 0, fail = 0;
+    for (const test of tests) {
+      try { await test.fn(); console.log('✓', test.name); pass++; }
+      catch (err) { console.error('✗', test.name, '—', err.message); fail++; }
+    }
+    console.log(`\n${pass}/${tests.length} photo-cleanup tests passed`);
+    process.exitCode = fail ? 1 : 0;
+  } finally {
+    child.kill();
+    try { fs.rmSync(SANDBOX, { recursive: true, force: true }); } catch (_) {}
+    try { fs.rmSync(TMP, { recursive: true, force: true }); } catch (_) {}
+  }
+})();

(oldest)  ·  back to Photo Cleanup  ·  test: use ephemeral port + verify own server so stale proces a8c7c07 →