Files
SwipeAnything/test/folder-adapter.test.js
T
ilia ce43c5de6d
CI / Gitleaks (pull_request) Successful in 17s
CI / Unit tests (pull_request) Successful in 21s
Add organize folders, details sheet, and richer previews.
Map keys 0–9 to destinations, inspect files before deciding, and preview
PDF/RAW/ZIP/video with an OLED dark UI polish plus demo seed and tests.
2026-07-26 21:38:36 -04:00

251 lines
10 KiB
JavaScript

'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { FolderAdapter } = require('../adapters/folder');
function makeFixture() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'swipeanything-test-'));
fs.writeFileSync(path.join(dir, 'keep-me.txt'), 'hello world');
fs.writeFileSync(path.join(dir, 'reject-me.txt'), 'bye world');
fs.mkdirSync(path.join(dir, 'sub'));
fs.writeFileSync(path.join(dir, 'sub', 'nested.txt'), 'nested');
return dir;
}
test('list() finds top-level files and skips dotfiles/hidden trash dir', async () => {
const dir = makeFixture();
const adapter = new FolderAdapter({ folderPath: dir, extensions: '' });
await adapter.init();
const items = await adapter.list();
const titles = items.map((i) => i.title).sort();
assert.deepEqual(titles, ['keep-me.txt', 'reject-me.txt']);
fs.rmSync(dir, { recursive: true, force: true });
});
test('list() with recursive:true includes subfolders', async () => {
const dir = makeFixture();
const adapter = new FolderAdapter({ folderPath: dir, extensions: '', recursive: true });
await adapter.init();
const items = await adapter.list();
assert.ok(items.some((i) => i.title === 'nested.txt' && i.subtitle === 'sub'));
fs.rmSync(dir, { recursive: true, force: true });
});
test('list() filters by extension allowlist', async () => {
const dir = makeFixture();
fs.writeFileSync(path.join(dir, 'photo.png'), 'not-really-a-png');
const adapter = new FolderAdapter({ folderPath: dir, extensions: 'png' });
await adapter.init();
const items = await adapter.list();
assert.deepEqual(items.map((i) => i.title), ['photo.png']);
fs.rmSync(dir, { recursive: true, force: true });
});
test('list() sets previewType for video, audio, csv, pdf, raw, and unknown binary', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'swipeanything-preview-'));
fs.writeFileSync(path.join(dir, 'clip.mp4'), 'fake-mp4-bytes');
fs.writeFileSync(path.join(dir, 'clip.MOV'), 'fake-mov-bytes'); // case-insensitive
fs.writeFileSync(path.join(dir, 'song.mp3'), 'fake-mp3-bytes');
fs.writeFileSync(path.join(dir, 'rows.csv'), 'a,b\n1,2\n');
fs.writeFileSync(path.join(dir, 'scan.pdf'), '%PDF-1.0');
fs.writeFileSync(path.join(dir, 'shot.dng'), 'fake-dng');
fs.writeFileSync(path.join(dir, 'shot.CR2'), 'fake-cr2');
fs.writeFileSync(path.join(dir, 'bundle.zip'), 'PK\x03\x04');
fs.writeFileSync(path.join(dir, 'sheet.xlsx'), 'PK\x03\x04-not-really-xlsx');
fs.writeFileSync(path.join(dir, 'photo.webp'), 'fake-webp');
const adapter = new FolderAdapter({ folderPath: dir, extensions: '' });
await adapter.init();
const byTitle = Object.fromEntries((await adapter.list()).map((i) => [i.title, i.previewType]));
assert.equal(byTitle['clip.mp4'], 'video');
assert.equal(byTitle['clip.MOV'], 'video');
assert.equal(byTitle['song.mp3'], 'audio');
assert.equal(byTitle['rows.csv'], 'text');
assert.equal(byTitle['scan.pdf'], 'pdf');
assert.equal(byTitle['shot.dng'], 'image');
assert.equal(byTitle['shot.CR2'], 'image');
assert.equal(byTitle['bundle.zip'], 'archive');
assert.equal(byTitle['photo.webp'], 'image');
// Excel is a zip container — no in-browser preview yet (generic card).
assert.equal(byTitle['sheet.xlsx'], 'none');
fs.rmSync(dir, { recursive: true, force: true });
});
test('reject moves the file to the trash dir, undo restores it', async () => {
const dir = makeFixture();
const adapter = new FolderAdapter({ folderPath: dir, extensions: '' });
await adapter.init();
const items = await adapter.list();
const target = items.find((i) => i.title === 'reject-me.txt');
const record = await adapter.applyAction(target, 'reject');
assert.equal(record.type, 'move');
assert.ok(!fs.existsSync(path.join(dir, 'reject-me.txt')));
assert.ok(fs.existsSync(record.to));
const trash = await adapter.describeTrash();
assert.equal(trash.count, 1);
await adapter.undo(record);
assert.ok(fs.existsSync(path.join(dir, 'reject-me.txt')));
fs.rmSync(dir, { recursive: true, force: true });
});
test('keep and skip have no filesystem effect', async () => {
const dir = makeFixture();
const adapter = new FolderAdapter({ folderPath: dir, extensions: '' });
await adapter.init();
const items = await adapter.list();
const target = items[0];
const keepRecord = await adapter.applyAction(target, 'keep');
const skipRecord = await adapter.applyAction(target, 'skip');
assert.equal(keepRecord, null);
assert.equal(skipRecord, null);
assert.ok(fs.existsSync(path.join(dir, target.title)));
fs.rmSync(dir, { recursive: true, force: true });
});
test('emptyTrash permanently deletes trashed files', async () => {
const dir = makeFixture();
const adapter = new FolderAdapter({ folderPath: dir, extensions: '' });
await adapter.init();
const items = await adapter.list();
const target = items.find((i) => i.title === 'reject-me.txt');
await adapter.applyAction(target, 'reject');
assert.equal((await adapter.describeTrash()).count, 1);
await adapter.emptyTrash();
assert.equal((await adapter.describeTrash()).count, 0);
fs.rmSync(dir, { recursive: true, force: true });
});
test('streamPreview rejects item ids that escape the folder', async () => {
const dir = makeFixture();
const adapter = new FolderAdapter({ folderPath: dir, extensions: '' });
await adapter.init();
const evilId = Buffer.from('../../etc/passwd').toString('base64url');
const fakeRes = { sendFile: () => assert.fail('should not have sent a file'), type: () => {} };
await assert.rejects(() => adapter.streamPreview(evilId, fakeRes), /Invalid item id/);
fs.rmSync(dir, { recursive: true, force: true });
});
test('streamPreview serves an existing file via res.sendFile', async () => {
const dir = makeFixture();
const adapter = new FolderAdapter({ folderPath: dir, extensions: '' });
await adapter.init();
const items = await adapter.list();
const target = items[0];
let sentPath = null;
const fakeRes = { sendFile: (p) => (sentPath = p), type: () => {} };
const handled = await adapter.streamPreview(target.id, fakeRes);
assert.equal(handled, true);
assert.equal(sentPath, path.join(dir, target.title));
fs.rmSync(dir, { recursive: true, force: true });
});
test('init() throws a clear error for a missing folder', async () => {
const adapter = new FolderAdapter({ folderPath: '/definitely/does/not/exist' });
await assert.rejects(() => adapter.init(), /Folder not found/);
});
test('getActions() adds organize actions only for configured destination keys', async () => {
const dir = makeFixture();
const destA = fs.mkdtempSync(path.join(os.tmpdir(), 'swipeanything-dest-a-'));
const destB = fs.mkdtempSync(path.join(os.tmpdir(), 'swipeanything-dest-b-'));
const adapter = new FolderAdapter({
folderPath: dir,
extensions: '',
destinations: {
1: { path: destA, label: 'Vacation' },
7: destB,
},
});
await adapter.init();
const actions = adapter.getActions();
assert.ok(actions.some((a) => a.id === 'keep'));
assert.ok(actions.some((a) => a.id === 'move-1' && a.label === 'Vacation' && a.key === '1'));
assert.ok(actions.some((a) => a.id === 'move-7' && a.key === '7'));
assert.equal(actions.filter((a) => a.group === 'organize').length, 2);
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(destA, { recursive: true, force: true });
fs.rmSync(destB, { recursive: true, force: true });
});
test('move-N moves the file into the destination folder and undo restores it', async () => {
const dir = makeFixture();
const dest = fs.mkdtempSync(path.join(os.tmpdir(), 'swipeanything-dest-'));
const adapter = new FolderAdapter({
folderPath: dir,
extensions: '',
destinations: { 2: { path: dest, label: 'Work' } },
});
await adapter.init();
const items = await adapter.list();
const target = items.find((i) => i.title === 'keep-me.txt');
const record = await adapter.applyAction(target, 'move-2');
assert.equal(record.type, 'move');
assert.equal(record.destKey, '2');
assert.ok(!fs.existsSync(path.join(dir, 'keep-me.txt')));
assert.ok(fs.existsSync(path.join(dest, 'keep-me.txt')));
await adapter.undo(record);
assert.ok(fs.existsSync(path.join(dir, 'keep-me.txt')));
assert.ok(!fs.existsSync(path.join(dest, 'keep-me.txt')));
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dest, { recursive: true, force: true });
});
test('list() skips destination folders that sit inside the source tree', async () => {
const dir = makeFixture();
const nestedDest = path.join(dir, 'sorted');
fs.mkdirSync(nestedDest);
fs.writeFileSync(path.join(nestedDest, 'already-sorted.txt'), 'done');
const adapter = new FolderAdapter({
folderPath: dir,
extensions: '',
recursive: true,
destinations: { 0: { path: nestedDest, label: 'Sorted' } },
});
await adapter.init();
const titles = (await adapter.list()).map((i) => i.title);
assert.ok(!titles.includes('already-sorted.txt'));
assert.ok(titles.includes('keep-me.txt'));
fs.rmSync(dir, { recursive: true, force: true });
});
test('move-N refuses an unconfigured key', async () => {
const dir = makeFixture();
const adapter = new FolderAdapter({ folderPath: dir, extensions: '', destinations: {} });
await adapter.init();
const items = await adapter.list();
await assert.rejects(() => adapter.applyAction(items[0], 'move-3'), /No destination configured/);
fs.rmSync(dir, { recursive: true, force: true });
});
test('getDetails() returns path, size, and type fields', async () => {
const dir = makeFixture();
const adapter = new FolderAdapter({ folderPath: dir, extensions: '' });
await adapter.init();
const items = await adapter.list();
const target = items.find((i) => i.title === 'keep-me.txt');
const details = await adapter.getDetails(target.id);
const labels = details.fields.map((f) => f.label);
assert.ok(labels.includes('Path'));
assert.ok(labels.includes('Size'));
assert.ok(labels.includes('Type'));
assert.equal(details.path, path.join(dir, 'keep-me.txt'));
fs.rmSync(dir, { recursive: true, force: true });
});
test('uiHints() reflects showDetailsByDefault', async () => {
const dir = makeFixture();
const adapter = new FolderAdapter({ folderPath: dir, extensions: '', showDetailsByDefault: true });
await adapter.init();
assert.deepEqual(adapter.uiHints(), { showDetailsByDefault: true, supportsDetails: true });
fs.rmSync(dir, { recursive: true, force: true });
});