Add second adapter, thumbnails, a11y pass, and a real test suite
- Immich adapter (adapters/immich.js): swipe a self-hosted photo library over its REST API, proving the adapter contract works for remote sources, not just the filesystem. - Refactor the preview contract from resolvePreviewPath() to streamPreview()/streamThumbnail(), so adapters can serve previews from anywhere (local file, proxied fetch, etc). - macOS Quick Look-backed thumbnail cache (lib/thumbnails.js): real resized thumbnails, HEIC/HEIF, and video poster frames, with a hard timeout and graceful fallback to the original file everywhere else. - Native "Browse..." folder picker (osascript) in Settings, with manual typing as the fallback on other platforms. - Confirm-guarded "Empty trash" action -- the only place this project permanently deletes anything. - Session resume: progress now survives a server restart and an unchanged Rescan via .swipeanything-session.json. - Accessibility pass: live region item announcements, aria-labels on action buttons, focus-trapped shortcuts dialog with focus restore, visible focus rings, a real role="progressbar", and <button>s instead of <a href="#"> in the header. - node:test suite (23 tests) covering the folder adapter, the Immich adapter (mocked fetch, no live server needed), and the HTTP API end-to-end; wired up as `npm test`. - Also includes the keyboard shortcuts (up/down undo/skip), swipe stamps, and shortcuts modal from the previous session that hadn't been committed yet.
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { ImmichAdapter } = require('../adapters/immich');
|
||||
|
||||
/** Installs a fake global.fetch for the duration of `fn`, then restores it. */
|
||||
async function withMockFetch(handler, fn) {
|
||||
const original = global.fetch;
|
||||
const calls = [];
|
||||
global.fetch = async (url, options = {}) => {
|
||||
calls.push({ url, options });
|
||||
return handler(url, options);
|
||||
};
|
||||
try {
|
||||
await fn(calls);
|
||||
} finally {
|
||||
global.fetch = original;
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(body, ok = true, status = 200) {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
statusText: ok ? 'OK' : 'Error',
|
||||
headers: { get: () => 'application/json' },
|
||||
json: async () => body,
|
||||
text: async () => JSON.stringify(body),
|
||||
};
|
||||
}
|
||||
|
||||
test('init() calls /users/me with the API key header and throws on failure', async () => {
|
||||
await withMockFetch(
|
||||
() => jsonResponse({}, false, 401),
|
||||
async () => {
|
||||
const adapter = new ImmichAdapter({ serverUrl: 'https://immich.example.com', apiKey: 'bad-key' });
|
||||
await assert.rejects(() => adapter.init(), /Immich API \/users\/me failed: 401/);
|
||||
}
|
||||
);
|
||||
|
||||
await withMockFetch(
|
||||
() => jsonResponse({ id: 'me' }),
|
||||
async (calls) => {
|
||||
const adapter = new ImmichAdapter({ serverUrl: 'https://immich.example.com', apiKey: 'good-key' });
|
||||
await adapter.init();
|
||||
assert.equal(calls[0].url, 'https://immich.example.com/api/users/me');
|
||||
assert.equal(calls[0].options.headers['x-api-key'], 'good-key');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('list() in chronological mode POSTs /search/metadata and maps assets', async () => {
|
||||
await withMockFetch(
|
||||
(url) => {
|
||||
if (String(url).endsWith('/search/metadata')) {
|
||||
return jsonResponse({
|
||||
assets: {
|
||||
items: [
|
||||
{ id: 'a1', originalFileName: 'a.jpg', type: 'IMAGE', isFavorite: false },
|
||||
{ id: 'a2', originalFileName: 'b.mov', type: 'VIDEO', isFavorite: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
return jsonResponse({}, false, 404);
|
||||
},
|
||||
async (calls) => {
|
||||
const adapter = new ImmichAdapter({ serverUrl: 'https://immich.example.com', apiKey: 'k', mode: 'chronological' });
|
||||
const items = await adapter.list();
|
||||
assert.equal(items.length, 2);
|
||||
assert.equal(items[0].previewType, 'image');
|
||||
assert.equal(items[1].previewType, 'video');
|
||||
const searchCall = calls.find((c) => String(c.url).endsWith('/search/metadata'));
|
||||
assert.equal(searchCall.options.method, 'POST');
|
||||
assert.deepEqual(JSON.parse(searchCall.options.body), { take: 100, order: 'desc' });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('list() with skipVideos filters out video assets', async () => {
|
||||
await withMockFetch(
|
||||
() =>
|
||||
jsonResponse({
|
||||
assets: {
|
||||
items: [
|
||||
{ id: 'a1', originalFileName: 'a.jpg', type: 'IMAGE' },
|
||||
{ id: 'a2', originalFileName: 'b.mov', type: 'VIDEO' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
async () => {
|
||||
const adapter = new ImmichAdapter({ serverUrl: 'https://immich.example.com', apiKey: 'k', skipVideos: true });
|
||||
const items = await adapter.list();
|
||||
assert.deepEqual(items.map((i) => i.id), ['a1']);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('list() in random mode GETs /assets/random', async () => {
|
||||
await withMockFetch(
|
||||
(url) => {
|
||||
assert.ok(String(url).includes('/assets/random?count=50'));
|
||||
return jsonResponse([{ id: 'r1', originalFileName: 'r.jpg', type: 'IMAGE' }]);
|
||||
},
|
||||
async () => {
|
||||
const adapter = new ImmichAdapter({ serverUrl: 'https://immich.example.com', apiKey: 'k', mode: 'random', take: 50 });
|
||||
const items = await adapter.list();
|
||||
assert.equal(items[0].id, 'r1');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('applyAction("reject") trashes the asset; undo restores it', async () => {
|
||||
await withMockFetch(
|
||||
() => jsonResponse({ ok: true }),
|
||||
async (calls) => {
|
||||
const adapter = new ImmichAdapter({ serverUrl: 'https://immich.example.com', apiKey: 'k' });
|
||||
const record = await adapter.applyAction({ id: 'asset-1' }, 'reject');
|
||||
assert.deepEqual(record, { type: 'trash', assetId: 'asset-1' });
|
||||
const deleteCall = calls.find((c) => c.options.method === 'DELETE');
|
||||
assert.equal(deleteCall.url, 'https://immich.example.com/api/assets');
|
||||
assert.deepEqual(JSON.parse(deleteCall.options.body), { ids: ['asset-1'], force: false });
|
||||
|
||||
await adapter.undo(record);
|
||||
const restoreCall = calls.find((c) => String(c.url).endsWith('/trash/restore/assets'));
|
||||
assert.deepEqual(JSON.parse(restoreCall.options.body), { ids: ['asset-1'] });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('applyAction("keep"/"skip") makes no API calls', async () => {
|
||||
await withMockFetch(
|
||||
() => assert.fail('should not call the API for keep/skip'),
|
||||
async () => {
|
||||
const adapter = new ImmichAdapter({ serverUrl: 'https://immich.example.com', apiKey: 'k' });
|
||||
assert.equal(await adapter.applyAction({ id: 'x' }, 'keep'), null);
|
||||
assert.equal(await adapter.applyAction({ id: 'x' }, 'skip'), null);
|
||||
}
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user