← back to Photo Cleanup
test.js
203 lines
#!/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');
// Grab a free ephemeral port so the test never collides with a stale server
// from a prior run (a hardcoded port silently routes POSTs to a stranger).
function freePort() {
const srv = http.createServer();
return new Promise((resolve, reject) => {
srv.once('error', reject);
srv.listen(0, '127.0.0.1', () => {
const p = srv.address().port;
srv.close(() => resolve(p));
});
});
}
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');
let PORT; // resolved to a free ephemeral port before the server spawns
let child; // server child process
let serverOut = '';
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');
// Confirm it is OUR server: it must serve the 3-file sandbox, not a stranger.
if (r.status === 200 && JSON.parse(r.body).rows.length === 3) return;
} catch (_) {}
await new Promise((r) => setTimeout(r, 150));
}
throw new Error('server never came up (or wrong server on port)\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 {
PORT = await freePort();
child = cp.spawn(process.execPath, [path.join(__dirname, 'server.js')], {
env: { ...process.env, PORT: String(PORT), CANDIDATES: CAND_FILE },
stdio: ['ignore', 'pipe', 'pipe'],
});
child.stdout.on('data', (c) => { serverOut += c.toString(); });
child.stderr.on('data', (c) => { serverOut += c.toString(); });
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 {
if (child) child.kill();
try { fs.rmSync(SANDBOX, { recursive: true, force: true }); } catch (_) {}
try { fs.rmSync(TMP, { recursive: true, force: true }); } catch (_) {}
}
})();