Files
RecipesCam/docker/frontend/scripts/immich-check.mjs
T

542 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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=<playwright-core/index.mjs> CHROME=<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;
// What was asked of the server, a frame at a time: a tile is fetched once and kept
// (`useThumbUrls` mints it and the store holds it), so a frame this map counts
// twice is a frame the wall went back to Immich for.
const thumbIds = new Map();
// The mint itself: the fetch `tileFrom` makes for a server's frame, as against the
// picture the wall paints straight off the address while the mint is on its way.
const mints = new Map();
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, 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();
// An edit names the node and says only what changes — a new key, or nothing
// at all — so what it does not repeat is what the node already has.
const at = b.id ? saved.findIndex((s) => s.id === b.id) : -1;
if (b.id && at < 0) return send(404, { ok: false, error: 'invalid' });
const had = at < 0 ? null : saved[at];
const url0 = b.url ?? had?.url;
const key0 = b.key ?? (had ? HOSTS[had.url]?.key : undefined);
if (!url0 || !key0) return send(400, { ok: false, error: 'invalid' });
const box = HOSTS[url0];
if (!box) return send(200, { ok: false, error: 'unreachable' });
if (box.key !== key0) return send(200, { ok: false, error: 'auth' });
const server = {
id: had?.id ?? `s${++minted}`,
name: String(b.name ?? had?.name ?? '').trim() || new URL(url0).hostname,
url: url0,
type: (b.type ?? had?.type) === 'share' ? 'share' : 'api',
version: box.version,
canDownload: box.canDownload,
albums: had && b.albums === undefined ? had.albums : cleanAlbums(b.albums),
keyMasked: `••••${String(key0).slice(-4)}`,
};
if (had) saved[at] = server;
else 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',
// Portrait, and deliberately unlike the square thumbnail above it: a wall
// laid out off the server's own numbers and a wall laid out off the pictures
// it is handed read as two different shapes here.
width: 2000,
height: 3000,
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++;
thumbIds.set(id, (thumbIds.get(id) ?? 0) + 1);
if (req.resourceType() === 'fetch') mints.set(id, (mints.get(id) ?? 0) + 1);
}
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 blank: the address is each reader’s to type, with a nudge in the field',
(await page.inputValue('[data-key="immich-url"]')) === '' &&
(await page.getAttribute('[data-key="immich-url"]', 'placeholder')) === 'https://photos.example.com',
await page.inputValue('[data-key="immich-url"]')
);
await page.fill('[data-key="immich-url"]', 'https://photos.example.com');
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 the door asks for nothing else: a fresh server is every album its key can see',
(await page.$('[data-key="immich-albums"]')) === null &&
(await page.$('[data-key="immich-album-' + A1 + '"]')) === null
);
// The ID field is the only place an album can be named at the door, and it says
// what it made of the lines: one with no id in it, and one this key never named.
await page.fill('[data-key="immich-ids"]', `khong-phai-uuid\n${B1}`);
await page.waitForSelector('[data-key="immich-bad-ids"]', { timeout: 5_000 }).catch(() => {});
step(
'a line with no album id in it is a line the field says it dropped',
(await page.$('[data-key="immich-bad-ids"]')) !== null &&
(await page.$eval('[data-key="immich-bad-ids"]', (e) => e.textContent)).includes('1'),
await page.$eval('[data-key="immich-bad-ids"]', (e) => e.textContent).catch(() => 'no warning')
);
step(
'and an id this key cannot see is an id the field says it does not know',
(await page.$('[data-key="immich-unknown-ids"]')) !== null,
await page.$eval('[data-key="immich-unknown-ids"]', (e) => e.textContent).catch(() => 'no warning')
);
await page.fill('[data-key="immich-ids"]', '');
await page.waitForTimeout(200);
step(
'and clearing the field takes both warnings with it',
(await page.$('[data-key="immich-bad-ids"]')) === null &&
(await page.$('[data-key="immich-unknown-ids"]')) === null
);
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`);
// --- the wall of a server's frames, and the tiles it keeps -------------------
// A server's frames are read up and down the roll and the reader jumps the ruler
// to a year and back, so the frames of one visit are the frames of the next. What
// a wall drawn again must not do is go to Immich for a picture this machine
// already drew: a tile is minted once off the backend (`useThumbUrls`), the store
// keeps it, and the visit after reads it back out of the store — a wall that asks
// again is a wall that stands grey on every jump until the server answers.
await page.click('[data-key="lib-node-Immich"]');
await page.click('[data-key="lib-view-grid"]');
const tiles = () => page.$$eval('.lib-tile', (els) => els.length);
const stored = () =>
page.$$eval('.lib-tile img', (els) => els.filter((e) => (e.getAttribute('src') ?? '').startsWith('blob:')).length);
await page.waitForSelector('.lib-tile', { timeout: 30_000 });
await page.waitForFunction(() => document.querySelector('.lib-tile img[src^="blob:"]') !== null, null, { timeout: 30_000 }).catch(() => {});
const firstLook = new Map(mints);
step(
'the wall draws a server’s frames, and their tiles are minted off the backend',
firstLook.size > 0 && (await stored()) > 0,
`${firstLook.size} frames minted, ${await stored()} of ${await tiles()} tiles kept`
);
// The shape a row is laid at. A row is justified to the wall's own width, which is
// a sum of the shapes of the frames in it, and the wall takes those shapes off the
// row Immich listed — `width` and `height` come with every frame — rather than
// standing a whole node at one guessed rectangle and laying the shelf out again
// picture by picture as the pictures arrive, which is every row below the one that
// landed moving and the ruler's needle moving with them. The fixture says 2000x3000
// where the thumbnail it hands out is square, so a wall measured off the pictures
// reads as 1.00 here and a wall standing on the guess reads as 1.50.
const statedAr = 2000 / 3000;
const rowArs = await page.$$eval('.lib-tile', (els) =>
els.map((e) => {
const r = e.getBoundingClientRect();
return r.height ? +(r.width / r.height).toFixed(2) : 0;
})
);
step(
'and a frame stands at the shape the server stated, not at the guess nor at the picture',
rowArs.length > 0 && rowArs.every((ar) => Math.abs(ar - statedAr) < 0.1),
`${rowArs.slice(0, 5).join(', ')} — the server states ${statedAr.toFixed(2)}`
);
// The visit after — the page again, on the same wall. Every tile the visit before
// drew is in this machine's store by now, so every tile is drawn out of it and the
// backend is not asked for one of them a second time.
await page.reload();
await page.waitForFunction(
() => {
const imgs = [...document.querySelectorAll('.lib-tile img')];
return imgs.length > 0 && imgs.every((i) => (i.getAttribute('src') ?? '').startsWith('blob:'));
},
null,
{ timeout: 30_000 }
).catch(() => {});
const again = [...mints].filter(([id, n]) => n > (firstLook.get(id) ?? 0));
step(
'and a frame drawn once is drawn out of this machine, not minted off Immich again',
(await tiles()) > 0 && (await stored()) === (await tiles()) && again.length === 0,
`${await stored()} of ${await tiles()} tiles out of the store, ${again.length} minted twice${again.length ? `: ${again.slice(0, 3).map(([id, n]) => id.slice(0, 8) + 'x' + n).join(' ')}` : ''}`
);
// --- 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 key given to a node that is already there -----------------------------
// The one place a node's key is replaced: the same dialog that narrows its
// albums. It is probed before it is stored, so a key that does not answer is a
// dialog still open on a node unchanged.
await editDialog('Immich');
await page.fill('[data-key="immich-key"]', 'not-the-key');
await page.click('[data-key="immich-save"]');
await page.waitForSelector('[data-key="immich-error"]', { timeout: 5_000 }).catch(() => {});
step(
'a key given in place of the one it has is refused out loud, dialog still open',
(await page.$('[data-key="immich-error"]')) !== null &&
(await page.$('[data-key="immich-modal"]')) !== null,
await page.$eval('[data-key="immich-error"]', (e) => e.textContent).catch(() => 'no word said')
);
await page.fill('[data-key="immich-key"]', 'good-key');
await page.click('[data-key="immich-save"]');
await page.waitForSelector('[data-key="immich-modal"]', { state: 'detached', timeout: 10_000 }).catch(() => {});
await settle('Immich', 5);
await open('Immich');
step(
'and one that answers is taken, the node and its albums exactly as they were',
(await page.$('[data-key="immich-modal"]')) === null &&
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);