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

389 lines
16 KiB
JavaScript

// The catalogue, checked where it actually lives: in a browser. A directory
// handle, IndexedDB, a grid of tiles and a handover into the studio have no
// meaning outside one, so this is a Playwright run rather than a pure-node assert.
//
// What it holds down is the two faults that read the same on the screen and have
// nothing to do with each other:
//
// 1. The catalogue is a folder the visitor picked, and the browser keeps the
// handle across a reload while dropping the permission on it. Only a gesture
// can hand it back, so the click on the folder is where it is asked for —
// both for the folder's own handle and for the handle a row of an older build
// carries. Without the ask every frame of a folder read yesterday answers
// "moved or deleted" while the file sits untouched on the disk.
// 2. Two builds behind one domain — a test server and a release server — open
// the one database the origin owns. A build that names a version older than
// the one on the disk is answered `VersionError` and every read falls back on
// an empty column, with no note and nothing to read off the screen.
//
// npm run build && node scripts/library-check.mjs
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> node scripts/library-check.mjs
// CHROME=<path to a chromium> PLAYWRIGHT_CORE=... node scripts/library-check.mjs
// BASE=http://127.0.0.1:8090 node scripts/library-check.mjs # a server already up
//
// Playwright is not a dependency of this package (a browser run needs it, a build
// does not), so a run without it says SKIP and exits 0. Only Chromium can hand a
// page a folder, and only a person can answer the picker: the check stands in for
// both, serving two real frames off a local server and answering
// `showDirectoryPicker` with a folder made of them.
import { spawn } from 'node:child_process';
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
const SAMPLES = process.env.SAMPLE_DIR ?? path.resolve(FRONTEND, '..');
const APP_PORT = Number(process.env.APP_PORT ?? 4185);
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4319);
const JPG = 'P1010256.JPG';
const RAW = 'P1010256.RW2';
const DB = 'recipescam-library';
const STORES = ['folders', 'photos', 'thumbs', 'edits', 'dirs'];
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 — pass PLAYWRIGHT_CORE=<path to its index.mjs>');
process.exit(0);
}
// --- the two frames, off a local server (the page fetches them by CORS) ------
const sampleServer = createServer(async (req, res) => {
const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0]));
if (name !== JPG && name !== RAW) {
res.writeHead(404).end();
return;
}
const bytes = await readFile(path.join(SAMPLES, name));
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Length': bytes.length,
'Access-Control-Allow-Origin': '*',
'Cross-Origin-Resource-Policy': 'cross-origin',
});
res.end(bytes);
});
await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve));
// A server already up, when one is named; otherwise the build in `dist/`.
let app = null;
const base = process.env.BASE ?? `http://127.0.0.1:${APP_PORT}`;
if (!process.env.BASE) {
app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
cwd: FRONTEND,
stdio: 'ignore',
});
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');
sampleServer.close();
console.log(`FAIL no preview on ${base} — run \`npm run build\` first (vite preview serves dist/)`);
process.exit(1);
}
}
// --- the browser ------------------------------------------------------------
const browser = await playwright.chromium.launch({
// Playwright's own Chromium when it has one; CHROME points at any other.
executablePath: process.env.CHROME || undefined,
args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'],
});
// A folder of one frame, handed over the way the browser hands one over: the
// handle is kept across a reload, its permission is not, and only a gesture can
// hand the permission back. `check.allow` stands in for the gesture, since a run
// has no hand; `check.prompt` is the reload that dropped the permission.
const INIT = ({ origin, names, folder }) => {
const asked = () => localStorage.getItem('check.prompt') === '1';
const granted = () => localStorage.getItem('check.granted') === '1';
const bump = (key) => localStorage.setItem(key, String(Number(localStorage.getItem(key) ?? 0) + 1));
const perm = () => (!asked() || granted() ? 'granted' : 'prompt');
const refuse = (what) => {
bump('check.refused');
localStorage.setItem(`check.refused.${what}`, '1');
throw new DOMException('no permission', 'NotAllowedError');
};
const bytes = new Map();
const load = async (n) => {
if (!bytes.has(n)) bytes.set(n, fetch(`${origin}/${n}`).then((r) => r.arrayBuffer()));
return bytes.get(n);
};
// The browser's answer to an ask for permission: granted with nothing to ask,
// refused while the screen is drawing itself, and granted off the reader's own
// gesture.
const askPerm = async () => {
bump('check.requested');
if (!asked()) return 'granted';
if (localStorage.getItem('check.allow') !== '1') return 'denied';
bump('check.answered');
localStorage.setItem('check.granted', '1');
return 'granted';
};
const fileHandle = (name) => {
const h = { kind: 'file', name, __fake: true };
Object.defineProperties(h, {
getFile: {
value: async () => {
if (perm() !== 'granted') refuse('getFile');
return new File([await load(name)], name, {
type: name.endsWith('.JPG') ? 'image/jpeg' : 'application/octet-stream',
lastModified: Date.UTC(2026, 0, 1),
});
},
},
// A real file handle answers these as well, and the row a catalogue of an
// older build wrote is opened off its own handle.
queryPermission: { value: async () => perm() },
requestPermission: { value: askPerm },
});
return h;
};
const dirHandle = (label, node) => {
const h = { kind: 'directory', name: label, __fake: true };
Object.defineProperties(h, {
values: {
value: () =>
[
...(node.files ?? []).map(fileHandle),
...Object.entries(node.dirs ?? {}).map(([n, k]) => dirHandle(n, k)),
][Symbol.iterator](),
},
queryPermission: { value: async () => perm() },
requestPermission: { value: askPerm },
getDirectoryHandle: {
value: async (name) => {
const k = node.dirs?.[name];
if (!k) throw new DOMException('no folder', 'NotFoundError');
return dirHandle(name, k);
},
},
getFileHandle: {
value: async (name) => {
if (!(node.files ?? []).includes(name)) throw new DOMException('no frame', 'NotFoundError');
if (perm() !== 'granted') refuse('getFileHandle');
return fileHandle(name);
},
},
});
return h;
};
const root = () => dirHandle(folder, { files: names, dirs: {} });
window.showDirectoryPicker = async () => root();
// A handle out of IndexedDB is a plain object with no methods; put them back.
const revive = (row) => {
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile'))
row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name);
return row;
};
for (const m of ['get', 'getAll']) {
const original = IDBObjectStore.prototype[m];
IDBObjectStore.prototype[m] = function (...a) {
const req = original.apply(this, a);
const d = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result');
Object.defineProperty(req, 'result', {
configurable: true,
get() {
const v = d.get.call(this);
return Array.isArray(v) ? v.map(revive) : revive(v);
},
});
return req;
};
}
};
// The library is signed-in ground and neither `vite preview` nor a bare container
// has an API behind it, so a run is a guest the app bounces back to the landing
// page before the column is ever drawn. The one call that decides which it is is
// answered here; the rest of `/api` fails the way a serverless build's does.
const open = async () => {
const context = await browser.newContext();
await context.route('**/api/auth/me', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ user: { id: 1, email: 'check@local', admin: true, verified: true, pro: true } }),
})
);
await context.addInitScript(INIT, { origin: `http://127.0.0.1:${SAMPLE_PORT}`, names: [JPG], folder: 'CheckRoll' });
const page = await context.newPage();
const errors = [];
const logs = [];
page.on('pageerror', (e) => errors.push(e.message));
page.on('console', (m) => m.type() === 'error' && logs.push(m.text()));
return { context, page, errors, logs };
};
const tiles = '[data-key^="lib-thumb-"]';
const status = (page) =>
page
.$eval('[data-key="lib-status"]', (e) => e.textContent.trim())
.catch(() => '');
// The handover into the studio is an address; the studio reads it and clears it,
// so a run that opened a frame lands on `/app`.
const inStudio = (page) => /^\/app(\?|$)/.test(new URL(page.url()).pathname + new URL(page.url()).search);
const openFrame = async (page) => {
await page.evaluate(() => localStorage.setItem('check.allow', '1'));
await page.click('[data-key="lib-open"]');
await page.waitForTimeout(3000);
};
// --- 1. a folder whose permission the reload dropped -------------------------
{
const { context, page, errors } = await open();
await page.goto(`${base}/library`);
await page.click('[data-key="lib-add"]');
await page.waitForSelector(tiles, { timeout: 120_000 });
step('the roll is indexed', true);
// The tab closed: the browser kept the handle, not the permission.
await page.evaluate(() => {
localStorage.setItem('check.prompt', '1');
for (const k of ['check.granted', 'check.requested', 'check.refused', 'check.allow', 'check.answered'])
localStorage.removeItem(k);
});
await page.reload();
await page.waitForSelector(tiles, { timeout: 60_000 });
const noOneToAnswer = await page.evaluate(() => localStorage.getItem('check.granted'));
const startupAsks = await page.evaluate(() => Number(localStorage.getItem('check.requested') ?? 0));
const before = errors.length;
await openFrame(page);
const answered = Number(await page.evaluate(() => localStorage.getItem('check.answered') ?? 0));
const asked = Number(await page.evaluate(() => localStorage.getItem('check.requested') ?? 0));
const granted = await page.evaluate(() => localStorage.getItem('check.granted'));
const note = await status(page);
step('the folder really has no permission while the screen draws itself', noOneToAnswer === null, `start-up asked x${startupAsks}, granted=${noOneToAnswer}`);
step('the click asks the browser for the folder back', answered > 0 && granted === '1', `answered x${answered} of x${asked}`);
step('the frame opens once the click has handed the permission back', inStudio(page), page.url());
step(
'no "moved or deleted" note for a frame that is still on the disk',
inStudio(page) && !/moved or deleted|di chuyển|lib\.missing/.test(note),
note || '(no note)'
);
step('no uncaught error while opening', errors.length === before, errors.slice(before).join(' | '));
await context.close();
}
// --- 2. a catalogue a newer build wrote -------------------------------------
{
const { context, page, errors, logs } = await open();
await page.goto(`${base}/`);
await page.evaluate(
({ DB, STORES }) =>
new Promise((resolve, reject) => {
const req = indexedDB.open(DB, 99);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORES[0])) db.createObjectStore(STORES[0], { keyPath: 'name' });
if (!db.objectStoreNames.contains(STORES[1])) {
const s = db.createObjectStore(STORES[1], { keyPath: 'id' });
s.createIndex('folder', 'folder');
s.createIndex('taken', 'taken');
}
if (!db.objectStoreNames.contains(STORES[2])) db.createObjectStore(STORES[2], { keyPath: 'id' });
if (!db.objectStoreNames.contains(STORES[3])) db.createObjectStore(STORES[3], { keyPath: 'photoId' });
if (!db.objectStoreNames.contains(STORES[4])) db.createObjectStore(STORES[4], { keyPath: 'folder' });
};
req.onsuccess = () => {
req.result.close();
resolve();
};
req.onerror = () => reject(req.error);
}),
{ DB, STORES }
);
step('a catalogue stands at version 99', true);
const before = errors.length;
await page.goto(`${base}/library`);
await page.click('[data-key="lib-add"]');
await page.waitForSelector(tiles, { timeout: 120_000 });
step('the older build reads and writes the newer catalogue', true);
const version = await page.evaluate(
async (DB) => (await indexedDB.databases()).find((d) => d.name === DB)?.version,
DB
);
step('it was taken as it stood, not upgraded', version === 99, `version ${version}`);
step('no uncaught error', errors.length === before, errors.slice(before).join(' | '));
step(
'no "the database is missing"',
!logs.some((l) => l.includes('the database is missing')),
logs.join(' | ')
);
await context.close();
}
// --- 3. a frame out of a catalogue an older build wrote ---------------------
// That build kept a handle on every row, and a handle out of IndexedDB comes back
// without its permission. The shortcut that opens a frame off its own row has to
// ask for it back the same way the folder's does, or the frame answers "nothing
// at" while it sits untouched on the disk.
{
const { context, page, errors } = await open();
await page.goto(`${base}/library`);
await page.click('[data-key="lib-add"]');
await page.waitForSelector(tiles, { timeout: 120_000 });
const legacy = await page.evaluate(
(name) =>
new Promise((resolve, reject) => {
const req = indexedDB.open(name);
req.onsuccess = () => {
const db = req.result;
const tx = db.transaction('photos', 'readwrite');
const store = tx.objectStore('photos');
const all = store.getAll();
all.onsuccess = () => {
const rows = all.result;
// A record, not a live handle: the browser hands it back with its
// methods and with no permission.
for (const row of rows) store.put({ ...row, handle: { kind: 'file', name: row.name, __fake: true } });
resolve(rows.map((r) => r.id));
};
all.onerror = () => reject(all.error);
};
req.onerror = () => reject(req.error);
}),
DB
);
step('the catalogue keeps a handle on every row, as the older build wrote it', legacy.length === 1, legacy.join());
await page.evaluate(() => {
localStorage.setItem('check.prompt', '1');
for (const k of ['check.granted', 'check.requested', 'check.allow', 'check.answered']) localStorage.removeItem(k);
});
await page.reload();
await page.waitForSelector(tiles, { timeout: 60_000 });
const before = errors.length;
await openFrame(page);
const note = await status(page);
step(
'a frame off a legacy row opens instead of reading "nothing at"',
inStudio(page) && !/nothing at|lib\.missing/.test(note),
note || page.url()
);
step('no uncaught error', errors.length === before, errors.slice(before).join(' | '));
await context.close();
}
await browser.close();
sampleServer.close();
app?.kill('SIGTERM');
console.log(failures ? `\n${failures} FAILED` : '\nall ok');
process.exit(failures ? 1 : 0);