4c65923eff
A folder's own menu gains a rename, which paints a label over the folder without moving it — the frame ids and the recipes hang off the directory's name, not the label. The empty part of the column answers a right click with ADD FOLDER, the FOLDERS heading goes (the count already rides on each row), and the column narrows to 118px so the stage takes the room.
316 lines
15 KiB
JavaScript
316 lines
15 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.
|
|
//
|
|
// Only Chromium can hand a page a folder, and only a person can answer the
|
|
// picker. The check stands in for both: it serves two real frames (one JPEG, one
|
|
// RAW) off a local server and answers `showDirectoryPicker` with a folder made of
|
|
// them. The stand-in handles carry their methods as *non-enumerable* properties,
|
|
// which is what lets them go through IndexedDB's structured clone at all (a
|
|
// clone copies enumerable own keys and drops the rest, and a function anywhere
|
|
// else is a DataCloneError); the methods are put back where the catalogue reads
|
|
// them, because a cloned handle is a plain object with no `getFile`.
|
|
//
|
|
// npm run build && node scripts/library-check.mjs
|
|
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> node scripts/library-check.mjs
|
|
//
|
|
// 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.
|
|
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';
|
|
|
|
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': '*',
|
|
});
|
|
res.end(bytes);
|
|
});
|
|
await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve));
|
|
|
|
// `--host 127.0.0.1`: without it vite's preview binds the name `localhost`, which
|
|
// on a dual-stack box can be ::1 alone and every 127.0.0.1 fetch below fails.
|
|
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');
|
|
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'],
|
|
});
|
|
const context = await browser.newContext();
|
|
|
|
// The folder the picker would have returned, plus the revival of its handles on
|
|
// the way back out of IndexedDB.
|
|
await context.addInitScript(
|
|
({ origin, names, subs, folder }) => {
|
|
const bytes = new Map();
|
|
const load = async (name) => {
|
|
if (!bytes.has(name)) bytes.set(name, fetch(`${origin}/${name}`).then((r) => r.arrayBuffer()));
|
|
return bytes.get(name);
|
|
};
|
|
const fileHandle = (name) => {
|
|
const handle = { kind: 'file', name, __fake: true };
|
|
Object.defineProperty(handle, 'getFile', {
|
|
value: async () => new File([await load(name)], name, { type: name.endsWith('.JPG') ? 'image/jpeg' : 'application/octet-stream' }),
|
|
});
|
|
return handle;
|
|
};
|
|
// The picked folder holds `names` at its top level and the subfolders in
|
|
// `subs` — a roll really is a folder of dated directories, which is what the
|
|
// tree column walks.
|
|
const dirHandle = (label, files, dirs) => {
|
|
const handle = { kind: 'directory', name: label };
|
|
Object.defineProperties(handle, {
|
|
values: {
|
|
value: () =>
|
|
[...files.map(fileHandle), ...Object.entries(dirs).map(([name, kids]) => dirHandle(name, kids, {}))][
|
|
Symbol.iterator
|
|
](),
|
|
},
|
|
queryPermission: { value: async () => 'granted' },
|
|
requestPermission: { value: async () => 'granted' },
|
|
});
|
|
return handle;
|
|
};
|
|
const root = () => dirHandle(folder, names, subs);
|
|
window.showDirectoryPicker = async () => root();
|
|
|
|
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 method of ['get', 'getAll']) {
|
|
const original = IDBObjectStore.prototype[method];
|
|
IDBObjectStore.prototype[method] = function (...args) {
|
|
const request = original.apply(this, args);
|
|
const descriptor = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result');
|
|
Object.defineProperty(request, 'result', {
|
|
configurable: true,
|
|
get() {
|
|
const value = descriptor.get.call(this);
|
|
return Array.isArray(value) ? value.map(revive) : revive(value);
|
|
},
|
|
});
|
|
return request;
|
|
};
|
|
}
|
|
},
|
|
{ origin: `http://127.0.0.1:${SAMPLE_PORT}`, names: [JPG], subs: { 2026: [RAW] }, folder: 'CheckRoll' }
|
|
);
|
|
|
|
const page = await context.newPage();
|
|
const catalogue = () =>
|
|
page.evaluate(async () => {
|
|
const db = await new Promise((resolve, reject) => {
|
|
const req = indexedDB.open('recipescam-library');
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
const all = (store) =>
|
|
new Promise((resolve, reject) => {
|
|
const req = db.transaction(store).objectStore(store).getAll();
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
const [photos, edits] = await Promise.all([all('photos'), all('edits')]);
|
|
const thumbs = [];
|
|
for (const photo of photos) {
|
|
const head = photo.thumb ? new Uint8Array(await photo.thumb.slice(0, 2).arrayBuffer()) : null;
|
|
thumbs.push({
|
|
id: photo.id,
|
|
bytes: photo.thumb?.size ?? 0,
|
|
jpeg: head ? head[0] === 0xff && head[1] === 0xd8 : false,
|
|
});
|
|
}
|
|
return { thumbs, edits: edits.map((e) => ({ id: e.photoId, filter: e.recipe?.baseFilter, knobs: Object.keys(e.recipe?.adjustments ?? {}).length })) };
|
|
});
|
|
|
|
// --- the run ----------------------------------------------------------------
|
|
await page.goto(`${base}/library`);
|
|
await page.click('[data-key="lib-add"]');
|
|
const thumbs = '[data-key^="lib-thumb-"]';
|
|
await page.waitForSelector(thumbs, { timeout: 120_000 });
|
|
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 2, thumbs, { timeout: 120_000 });
|
|
step('both frames indexed as thumbnails', true, `2 tiles from ${JPG} + ${RAW}`);
|
|
|
|
const indexed = await catalogue();
|
|
for (const name of [JPG, RAW]) {
|
|
const row = indexed.thumbs.find((t) => t.id.endsWith(`/${name}`));
|
|
step(`thumbnail for ${name}`, !!row && row.bytes > 1000 && row.jpeg, `${row?.bytes ?? 0} bytes, jpeg=${row?.jpeg}`);
|
|
}
|
|
|
|
// The tree: the picked folder, with the subfolder a frame sits in under it, each
|
|
// carrying the frames at or below it. The RAW is the nested one.
|
|
const nodes = await page.$$eval('[data-key^="lib-node-"]', (els) =>
|
|
els.map((e) => [e.dataset.key, e.querySelector('.adm-album-count')?.textContent])
|
|
);
|
|
step(
|
|
'tree lists the folder and its subfolder',
|
|
nodes.length === 2 && nodes[0][0] === 'lib-node-CheckRoll' && nodes[1][0] === 'lib-node-CheckRoll/2026',
|
|
JSON.stringify(nodes)
|
|
);
|
|
|
|
// Opening the subfolder redraws the strip and the frame above it.
|
|
await page.click('[data-key="lib-node-CheckRoll/2026"]');
|
|
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 1, thumbs, { timeout: 30_000 });
|
|
const staged = await page.locator('[data-key="lib-stage-meta"]').innerText();
|
|
step('the open subfolder draws its own strip and frame', /P1010256\.RW2/.test(staged), staged.replace(/\n/g, ' · '));
|
|
|
|
// The switch: the middle becomes every thumbnail of the open node at once.
|
|
await page.click('[data-key="lib-view-grid"]');
|
|
await page.waitForSelector('[data-key^="lib-photo-"]', { timeout: 30_000 });
|
|
const cards = await page.$$eval('[data-key^="lib-photo-"]', (els) => els.map((e) => e.dataset.key));
|
|
step('the grid view draws a card per frame', cards.length === 1, JSON.stringify(cards));
|
|
await page.click('[data-key="lib-view-preview"]');
|
|
await page.waitForSelector(thumbs, { timeout: 30_000 });
|
|
|
|
// The toolbar carries the screen's one button and the two view icons on a single
|
|
// row; a folder's own rescan and remove exist only once it has been right-clicked.
|
|
const bar = await page.$eval('.lib-bar', (el) => ({
|
|
rows: new Set(
|
|
[...el.children].map((c) => {
|
|
const box = c.getBoundingClientRect();
|
|
return Math.round(box.top + box.height / 2);
|
|
})
|
|
).size,
|
|
icons: [...el.querySelectorAll('[data-key^="lib-view-"]')].every((b) => b.querySelector('svg')),
|
|
}));
|
|
step('one toolbar row holding the two view icons', bar.rows === 1 && bar.icons, JSON.stringify(bar));
|
|
step('no rescan/remove button in the pane', (await page.$$('[data-key^="lib-rescan-"], [data-key^="lib-drop-"]')).length === 0);
|
|
|
|
await page.click('[data-key="lib-node-CheckRoll/2026"]', { button: 'right' });
|
|
await page.waitForSelector('[data-key="lib-menu"]', { timeout: 10_000 });
|
|
const menu = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key));
|
|
step('a right click on a folder opens its menu', menu.join(',') === 'lib-rename-CheckRoll,lib-rescan-CheckRoll,lib-drop-CheckRoll', JSON.stringify(menu));
|
|
await page.keyboard.press('Escape');
|
|
step('escape closes the menu', (await page.$('[data-key="lib-menu"]')) === null);
|
|
|
|
// The column is names and counts, nothing else: the count rides on the row.
|
|
const column = await page.$eval('[data-key="lib-tree"]', (el) => Math.round(el.getBoundingClientRect().width));
|
|
step(
|
|
'no FOLDERS heading, the count on the folder row',
|
|
(await page.$$('.adm-row-title')).length === 0 && (await page.$eval('[data-key="lib-node-CheckRoll"] .adm-album-count', (e) => e.textContent)) === '2',
|
|
`${column}px column, stage ${await page.$eval('[data-key="lib-stage"]', (e) => Math.round(e.getBoundingClientRect().width))}px`
|
|
);
|
|
step('the folder column stays out of the stage\'s way', column <= 130, `${column}px`);
|
|
|
|
// A rename paints a label over the folder: the row it belongs to, and the frames
|
|
// under it, do not move — the node key is still the directory's own name.
|
|
page.once('dialog', (dialog) => dialog.accept('Roll A'));
|
|
await page.click('[data-key="lib-node-CheckRoll"]', { button: 'right' });
|
|
await page.waitForSelector('[data-key="lib-rename-CheckRoll"]', { timeout: 10_000 });
|
|
await page.click('[data-key="lib-rename-CheckRoll"]');
|
|
await page.waitForFunction(() => document.querySelector('[data-key="lib-node-CheckRoll"] .adm-album-name')?.textContent === 'Roll A', null, {
|
|
timeout: 10_000,
|
|
});
|
|
step(
|
|
'a folder can be renamed without being moved',
|
|
(await page.$('[data-key="lib-node-CheckRoll/2026"]')) !== null,
|
|
await page.$eval('[data-key="lib-node-CheckRoll"] .adm-album-name', (e) => e.textContent)
|
|
);
|
|
|
|
// The empty part of the column is where one more folder comes from.
|
|
await page.click('[data-key="lib-tree"]', { button: 'right', position: { x: 10, y: 200 } });
|
|
await page.waitForSelector('[data-key="lib-menu"]', { timeout: 10_000 });
|
|
const blank = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key));
|
|
step('a right click on the empty column offers a folder', blank.join(',') === 'lib-menu-add', JSON.stringify(blank));
|
|
await page.keyboard.press('Escape');
|
|
step('escape closes the folder menu too', (await page.$('[data-key="lib-menu"]')) === null);
|
|
|
|
// One tap on a thumbnail raises the frame, and the button under it hands it to
|
|
// the studio. The RAW is the harder half — it has to be read back off the
|
|
// (stand-in) handle and developed — so it is the one that gets opened.
|
|
const rawId = indexed.thumbs.find((t) => t.id.endsWith(`/${RAW}`)).id;
|
|
await page.click(`[data-key="lib-thumb-${rawId}"]`);
|
|
await page.click('[data-key="lib-open"]');
|
|
await page.waitForURL((url) => url.pathname.startsWith('/app'), { timeout: 120_000 });
|
|
await page.waitForFunction(() => !document.querySelector('.dropzone'), null, { timeout: 180_000 });
|
|
step('studio developed the frame from its handle', true, 'stage no longer on the drop zone');
|
|
|
|
// The handover is an address, not a state: the studio puts the frame into its
|
|
// own store and drops the query, so a reload reopens it like any other photo.
|
|
step('the address was handed back', !page.url().includes('lib='), `url=${page.url()}`);
|
|
|
|
const editsIn = () =>
|
|
page.evaluate(async () => {
|
|
const db = await new Promise((resolve, reject) => {
|
|
const req = indexedDB.open('recipescam-library');
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
return await new Promise((resolve) => {
|
|
const req = db.transaction('edits').objectStore('edits').getAll();
|
|
req.onsuccess = () => resolve(req.result);
|
|
});
|
|
});
|
|
// The recipe is written 600ms after the last change (a slider drag fires it per
|
|
// frame), so the row arrives a beat after the frame does.
|
|
let filed = [];
|
|
for (let i = 0; i < 40 && !filed.length; i++) {
|
|
filed = await editsIn();
|
|
if (!filed.length) await new Promise((r) => setTimeout(r, 250));
|
|
}
|
|
step('the look was filed back against the frame', filed.length === 1, JSON.stringify(filed));
|
|
|
|
await page.goto(`${base}/library`);
|
|
await page.waitForSelector(`[data-key="lib-thumb-${rawId}"]`);
|
|
await page.click(`[data-key="lib-thumb-${rawId}"]`);
|
|
const meta = await page.locator('[data-key="lib-stage-meta"]').innerText();
|
|
step('the frame says it was edited', /đã chỉnh|edited/.test(meta), meta.replace(/\n/g, ' · '));
|
|
|
|
await browser.close();
|
|
sampleServer.close();
|
|
app.kill('SIGTERM');
|
|
console.log(failures ? `\n${failures} failed` : '\nall checks passed');
|
|
process.exit(failures ? 1 : 0);
|