Files
RecipesCam/docker/frontend/scripts/immich-check.mjs
T
3dtours c557108eee chore(immich): drop the IMMICH_URL hook
A deployment no longer suggests an address for the first server: every
address is each account's own and is typed in the app, so the field opens
blank with a placeholder. The env var, its compose passthrough, the example
line and the `defaultUrl` field that carried it are all gone; the backend's
config route now answers with the saved list alone.
2026-10-10 17:10:30 +07:00

467 lines
20 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;
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',
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 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`);
// --- 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-newkey"]', '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-newkey"]', '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);