389 lines
16 KiB
JavaScript
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);
|