// Immich as a folder beside the folders on the disk: the dialog that adds a // server, what the column draws for it, what an album tick means, what the // album-ID field makes of a whole address and of rubbish, and the one thing that // must never happen — the page talking to Immich itself. // // The app answers `/api` on its own origin, and the Immich side of it is stood in // for here the way `backend/src/immich.ts` answers: two keys that see two // different sets of albums, one of them without the right to download originals, // and a share link, which is one album and no picker. // // npm run build && node scripts/immich-check.mjs // APP_PORT=4410 PLAYWRIGHT_CORE= CHROME= \ // node scripts/immich-check.mjs import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const FRONTEND = fileURLToPath(new URL('..', import.meta.url)); const APP_PORT = Number(process.env.APP_PORT ?? 4410); let failures = 0; function step(name, ok, detail = '') { if (!ok) failures++; console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); return ok; } const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null); if (!playwright) { console.log('SKIP playwright-core is not installed'); process.exit(0); } // --- the server side of the check ------------------------------------------- // A uuid per thing, spelled the way the backend spells one: eight, four, four, // four, twelve, all hex. The routes that take an id refuse anything else. const uid = (n) => `${String(n).padStart(8, '0')}-0000-4000-8000-${String(n).padStart(12, '0')}`; const A1 = uid(101); const A2 = uid(102); const B1 = uid(201); const ALBUM_NAME = { [A1]: 'A1', [A2]: 'A2', [B1]: 'B1' }; // What each album holds: five frames over the first two, one over the third. const ASSETS = []; let nth = 0; for (const [album, names] of [ [A1, ['A1_1.JPG', 'A1_2.JPG']], [A2, ['A2_1.JPG', 'A2_2.JPG', 'A2_3.JPG']], [B1, ['B1_1.JPG']], ]) { for (const name of names) ASSETS.push({ id: uid(1000 + ++nth), album, name }); } // The three boxes an address can name, and the key each of them answers to. const HOSTS = { 'https://photos.example.com': { key: 'good-key', version: '1.120.0', canDownload: true, albums: [A1, A2] }, 'https://view.example.com': { key: 'view-only', version: '1.119.0', canDownload: false, albums: [B1] }, 'https://share.example.com': { key: 'share-xyz', version: null, canDownload: false, albums: [B1] }, }; // What the backend holds: a saved server, by the id it minted. const saved = []; let minted = 0; // Album ids as the backend cleans them: a uuid out of whatever was typed, once // each, at most two hundred of them. function cleanAlbums(raw) { const out = []; for (const one of Array.isArray(raw) ? raw : []) { const hit = String(one).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i); const id = hit ? hit[0].toLowerCase() : ''; if (id && !out.includes(id)) out.push(id); } return out.slice(0, 200); } // One pixel of PNG, so a tile that lands is a picture the browser decodes. const PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64' ); // Anything the page asks a photos host for directly. Nothing may: the address and // the key live on the backend, and the page only ever calls its own origin. let direct = 0; let thumbs = 0; const app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], { cwd: FRONTEND, stdio: 'ignore', }); const base = `http://127.0.0.1:${APP_PORT}`; let up = false; for (let i = 0; i < 60 && !up; i++) { up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false); if (!up) await new Promise((r) => setTimeout(r, 500)); } if (!up) { app.kill('SIGTERM'); console.log(`FAIL no preview on ${base} — run \`npm run build\` first`); process.exit(1); } const browser = await playwright.chromium.launch({ executablePath: process.env.CHROME || undefined, args: ['--no-sandbox', '--enable-unsafe-swiftshader'], }); const context = await browser.newContext(); await context.route(/^https:\/\/[a-z]+\.example\.com\//, (route) => { direct++; return route.fulfill({ status: 200, contentType: 'image/png', body: PNG }); }); await context.route('**/api/**', (route) => { const req = route.request(); const url = new URL(req.url()); const path = url.pathname; const send = (status, body) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); const body = () => { try { return JSON.parse(req.postData() ?? '{}'); } catch { return {}; } }; const host = (serverId) => HOSTS[saved.find((s) => s.id === serverId)?.url ?? '']; const view = (s) => ({ ...s }); if (path.includes('/auth/me')) return send(200, { user: { id: 1, email: 'check@example.com', name: 'check' } }); if (path.includes('/photos')) return send(200, { photos: [] }); if (path.includes('/recipes')) return send(200, { recipes: [] }); if (path === '/api/immich/config') { if (req.method() === 'GET') { return send(200, { ok: true, defaultUrl: 'https://photos.example.com', servers: saved.map(view) }); } if (req.method() === 'DELETE') { const at = saved.findIndex((s) => s.id === url.searchParams.get('id')); if (at < 0) return send(404, { ok: false, error: 'invalid' }); saved.splice(at, 1); return send(200, { ok: true, servers: saved.map(view) }); } const b = body(); if (!b.url || !b.key) return send(400, { ok: false, error: 'invalid' }); const box = HOSTS[b.url]; if (!box) return send(200, { ok: false, error: 'unreachable' }); if (box.key !== b.key) return send(200, { ok: false, error: 'auth' }); const server = { id: `s${++minted}`, name: String(b.name ?? '').trim() || new URL(b.url).hostname, url: b.url, type: b.type === 'share' ? 'share' : 'api', version: box.version, canDownload: box.canDownload, albums: cleanAlbums(b.albums), keyMasked: `••••${String(b.key).slice(-4)}`, }; saved.push(server); return send(200, { ok: true, server: view(server) }); } if (path === '/api/immich/probe') { const b = body(); if (!b.url || !b.key) return send(400, { ok: false, error: 'invalid' }); const box = HOSTS[b.url]; if (!box) return send(200, { ok: false, error: 'unreachable' }); if (box.key !== b.key) return send(200, { ok: false, error: 'auth' }); return send(200, { ok: true, version: box.version, albums: box.albums.map((id) => ({ id, albumName: ALBUM_NAME[id], assetCount: ASSETS.filter((a) => a.album === id).length, coverId: null, shared: false, })), canDownload: box.canDownload, }); } if (path === '/api/immich/albums') { if (req.method() === 'PUT') { const b = body(); const server = saved.find((s) => s.id === b.id); if (!server) return send(404, { ok: false, error: 'invalid' }); server.albums = cleanAlbums(b.selected); return send(200, { ok: true, albums: server.albums }); } const box = host(url.searchParams.get('id') ?? ''); if (!box) return send(502, { ok: false, error: 'unreachable' }); return send(200, { ok: true, albums: box.albums.map((id) => ({ id, albumName: ALBUM_NAME[id], assetCount: ASSETS.filter((a) => a.album === id).length, coverId: null, shared: false, })), }); } if (path === '/api/immich/assets') { const box = host(url.searchParams.get('server') ?? ''); if (!box) return send(502, { ok: false, error: 'unreachable' }); const album = url.searchParams.get('album'); const page = Number(url.searchParams.get('page') ?? 1); const size = Number(url.searchParams.get('size') ?? 200); const rows = ASSETS.filter((a) => (album && album !== 'all' ? a.album === album : box.albums.includes(a.album))); const items = rows.slice((page - 1) * size, page * size).map((a) => ({ id: a.id, name: a.name, takenAt: '2026-04-01T10:00:00.000Z', width: 6000, height: 4000, size: 12345678, })); return send(200, { ok: true, page, size, total: rows.length, hasMore: items.length === size, items }); } if (path === '/api/immich/thumb' || path === '/api/immich/original') { const id = url.searchParams.get('id') ?? ''; if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) { return send(400, { ok: false, error: 'invalid' }); } const box = host(url.searchParams.get('server') ?? ''); if (!box) return send(502, { ok: false, error: 'unreachable' }); if (path.endsWith('original') && !box.canDownload) return send(403, { ok: false, error: 'auth' }); if (path.endsWith('thumb')) thumbs++; return route.fulfill({ status: 200, contentType: 'image/png', body: PNG }); } return send(404, { ok: false, error: 'no' }); }); const page = await context.newPage(); await page.goto(`${base}/library`); // The rows the column draws, and the number beside each name. const counts = () => page.$$eval('[data-key^="lib-node-"]', (els) => { const out = {}; for (const e of els) { out[e.dataset.key.replace('lib-node-', '')] = (e.querySelector('.adm-album-count')?.textContent ?? '').trim(); } return out; }); const same = (a, b) => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort()); const settle = (key, n) => page .waitForFunction( ({ key, n }) => { const row = document.querySelector(`[data-key="lib-node-${key}"]`); return (row?.querySelector('.adm-album-count')?.textContent ?? '').trim() === String(n); }, { key, n }, { timeout: 30_000, polling: 50 } ) .catch(() => {}); const open = async (name) => { // A node's albums are rows under it, and the column folds a folder away until // it is opened — the same click a reader makes. const row = `[data-key="lib-node-${name}"]`; if ((await page.getAttribute(row, 'aria-expanded')) === 'false') await page.click(row); await page.waitForTimeout(300); }; const addDialog = async (url, key, share = false) => { await page.click('[data-key="lib-add-immich"]'); await page.waitForSelector('[data-key="immich-url"]'); await page.fill('[data-key="immich-url"]', url); await page.fill('[data-key="immich-key"]', key); if (share) await page.check('[data-key="immich-share"]'); await page.click('[data-key="immich-check"]'); await page.waitForSelector('[data-key="immich-status"]'); }; const editDialog = async (node) => { await page.click(`[data-key="lib-node-${node}"]`, { button: 'right' }); await page.waitForSelector(`[data-key="lib-immich-albums-${node}"]`); await page.click(`[data-key="lib-immich-albums-${node}"]`); await page.waitForSelector('[data-key="immich-ids"]'); // The albums are asked for live, so the list may land a moment after the form. await page.waitForSelector('[data-key="immich-albums"]').catch(() => {}); }; // --- adding a server --------------------------------------------------------- await page.waitForSelector('[data-key="lib-add-immich"]', { timeout: 60_000 }); await page.click('[data-key="lib-add-immich"]'); await page.waitForSelector('[data-key="immich-url"]'); step( 'the dialog opens with the address the deployment suggests already in it', (await page.inputValue('[data-key="immich-url"]')) === 'https://photos.example.com', await page.inputValue('[data-key="immich-url"]') ); await page.fill('[data-key="immich-key"]', 'good-key'); await page.click('[data-key="immich-check"]'); await page.waitForSelector('[data-key="immich-status"]'); const saidOk = await page.$eval('[data-key="immich-status"]', (e) => !e.className.includes('err')); step('a key that answers is said to be good', saidOk); step( 'and what it offers is that server’s albums and no other server’s', (await page.$('[data-key="immich-album-' + A1 + '"]')) !== null && (await page.$('[data-key="immich-album-' + A2 + '"]')) !== null && (await page.$('[data-key="immich-album-' + B1 + '"]')) === null ); step( 'with every one of them ticked to begin with', (await page.isChecked('[data-key="immich-album-' + A1 + '"]')) && (await page.isChecked('[data-key="immich-album-' + A2 + '"]')) ); await page.click('[data-key="immich-save"]'); await settle('Immich', 5); await open('Immich'); step( 'the server becomes a folder on the column, counted album by album', same(await counts(), { Immich: '5', 'Immich/A1': '2', 'Immich/A2': '3' }), JSON.stringify(await counts()) ); step( 'and its node wears the source and the name it was given', (await page.$eval('[data-key="lib-node-Immich"] .adm-album-name', (e) => e.textContent)) === 'Immich · photos.example.com' ); step('and its frames were tiled off the backend, not off Immich', thumbs > 0, `${thumbs} tiles`); // --- an album unticked ------------------------------------------------------- await editDialog('Immich'); await page.uncheck('[data-key="immich-album-' + A2 + '"]'); await page.click('[data-key="immich-save"]'); await settle('Immich', 2); await open('Immich'); step( 'an album unticked is an album the column stops drawing', same(await counts(), { Immich: '2', 'Immich/A1': '2' }), JSON.stringify(await counts()) ); // --- the album-ID field ------------------------------------------------------ // A whole address is an id, and a line that is not a uuid is nothing at all: the // reader copies an album out of their browser and pastes what they got. await editDialog('Immich'); await page.uncheck('[data-key="immich-album-' + A1 + '"]'); await page.fill('[data-key="immich-ids"]', `https://photos.example.com/albums/${A2}?x=1\nkhong-phai-uuid`); await page.click('[data-key="immich-save"]'); await settle('Immich', 3); await open('Immich'); step( 'an album pasted as a whole address is read as its id, and a line that is not one is dropped', same(await counts(), { Immich: '3', 'Immich/A2': '3' }), JSON.stringify(await counts()) ); // Nothing ticked and nothing typed is the empty state, which means every album // the key can see — a decision, not a missing value. await editDialog('Immich'); await page.uncheck('[data-key="immich-album-' + A2 + '"]'); await page.fill('[data-key="immich-ids"]', ''); await page.click('[data-key="immich-save"]'); await settle('Immich', 5); await open('Immich'); step( 'nothing ticked means every album the key can see, not none of them', same(await counts(), { Immich: '5', 'Immich/A1': '2', 'Immich/A2': '3' }), JSON.stringify(await counts()) ); // --- a share link ------------------------------------------------------------ await addDialog('https://share.example.com', 'share-xyz', true); step('a share link is not offered an album picker: it is one album', (await page.$('[data-key="immich-albums"]')) === null); await page.click('[data-key="immich-save"]'); await page.waitForSelector('[data-key="lib-node-Immich 2"]', { timeout: 30_000 }); await settle('Immich 2', 1); step( 'and it becomes a second node beside the first, named by its own server', (await page.$eval('[data-key="lib-node-Immich 2"] .adm-album-name', (e) => e.textContent)) === 'Immich · share.example.com', JSON.stringify(await counts()) ); await page.click('[data-key="lib-node-Immich 2"]', { button: 'right' }); await page.waitForSelector('[data-key="lib-immich-sync-Immich 2"]'); step( 'and its menu carries no album picker', (await page.$('[data-key="lib-immich-albums-Immich 2"]')) === null ); step( 'nor a rescan, a rename or a tile job — those are the disk’s', (await page.$('[data-key="lib-rescan-Immich 2"]')) === null && (await page.$('[data-key="lib-rename-Immich 2"]')) === null && (await page.$('[data-key="lib-thumbs-Immich 2"]')) === null ); await page.keyboard.press('Escape'); // --- a key that does not work ------------------------------------------------ await addDialog('https://photos.example.com', 'not-the-key'); const saidBad = await page.$eval('[data-key="immich-status"]', (e) => e.className.includes('err')); step('a key that is refused is said to be refused', saidBad); await page.click('[data-key="immich-save"]'); await page.waitForTimeout(500); const stillSaving = (await page.$('[data-key="immich-save"]')) !== null; step( 'and a refused key is never a node: nothing is stored and the dialog stays open', stillSaving && !('Immich 3' in (await counts())), JSON.stringify(await counts()) ); await page.click('[data-key="immich-cancel"]'); // --- what must never happen -------------------------------------------------- step('the page never talks to Immich itself', direct === 0, `${direct} calls off-origin`); await browser.close(); app.kill('SIGTERM'); console.log(failures ? `\n${failures} failed` : '\nall ok'); process.exit(failures ? 1 : 0);