2b45419ec4
The tree column now walks every folder under the one you picked instead of its first level, so a roll of dated folders inside dated folders is walked to the bottom. A folder being read turns a ring on its own row, the column is titled with the folder the tree belongs to, and a divider beside it drags the width, which is remembered along with the folder the screen was left on.
367 lines
17 KiB
JavaScript
367 lines
17 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));
|
||
// A beat per frame: the catalogue reads two files faster than a poll, and the
|
||
// ring that says "this roll is being read" needs a window to be caught in.
|
||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||
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`, each `{ files, dirs }` — a roll really is a folder of dated
|
||
// directories inside dated directories, which is what the tree column walks.
|
||
const dirHandle = (label, node) => {
|
||
const handle = { kind: 'directory', name: label };
|
||
Object.defineProperties(handle, {
|
||
values: {
|
||
value: () =>
|
||
[
|
||
...(node.files ?? []).map(fileHandle),
|
||
...Object.entries(node.dirs ?? {}).map(([name, kid]) => dirHandle(name, kid)),
|
||
][Symbol.iterator](),
|
||
},
|
||
queryPermission: { value: async () => 'granted' },
|
||
requestPermission: { value: async () => 'granted' },
|
||
});
|
||
return handle;
|
||
};
|
||
const root = () => dirHandle(folder, { files: names, dirs: 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],
|
||
// One frame at the top, one a folder down, one two folders down: the walk has
|
||
// to reach the bottom of the roll, not just its first level.
|
||
subs: { 2026: { files: [RAW], dirs: { '04': { files: [JPG], dirs: {} } } } },
|
||
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"]');
|
||
// A folder that is being read says so on its own row, not only in the toolbar.
|
||
const ringed = await page
|
||
.waitForFunction(
|
||
() => {
|
||
const row = document.querySelector('.adm-album[aria-busy="true"]');
|
||
return row?.querySelector('.lib-spin') ? row.dataset.key : null;
|
||
},
|
||
null,
|
||
{ timeout: 10_000, polling: 50 }
|
||
)
|
||
.then((handle) => handle.jsonValue())
|
||
.catch(() => null);
|
||
step('the folder being read turns a ring on its row', ringed === 'lib-node-CheckRoll', String(ringed));
|
||
const thumbs = '[data-key^="lib-thumb-"]';
|
||
await page.waitForSelector(thumbs, { timeout: 120_000 });
|
||
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 3, thumbs, { timeout: 120_000 });
|
||
step('every frame of the roll is indexed, two folders down', true, `3 tiles from ${JPG} ×2 + ${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(
|
||
'the tree draws the roll as folders under folders',
|
||
JSON.stringify(nodes) ===
|
||
JSON.stringify([
|
||
['lib-node-CheckRoll', '3'],
|
||
['lib-node-CheckRoll/2026', '2'],
|
||
['lib-node-CheckRoll/2026/04', '1'],
|
||
]),
|
||
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 === 2, 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|JPG)/.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 === 2, 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)) === '3',
|
||
`${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)
|
||
);
|
||
step(
|
||
'the column is titled with the folder the tree belongs to',
|
||
(await page.$eval('[data-key="lib-root-name"]', (e) => e.textContent)) === 'Roll A',
|
||
await page.$eval('[data-key="lib-root-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);
|
||
|
||
// The divider drags the column, and what the visit leaves behind — the width the
|
||
// column was left at and the folder that was open — is what the next visit opens
|
||
// with. The right click on the empty column answered with a folder, not with a
|
||
// folder opened, so the open node is still the subfolder chosen above.
|
||
const gripBox = await (await page.$('[data-key="lib-grip"]')).boundingBox();
|
||
await page.mouse.move(gripBox.x + 4, gripBox.y + 120);
|
||
await page.mouse.down();
|
||
await page.mouse.move(gripBox.x + 84, gripBox.y + 120, { steps: 6 });
|
||
await page.mouse.up();
|
||
const dragged = await page.$eval('[data-key="lib-tree"]', (el) => Math.round(el.getBoundingClientRect().width));
|
||
await page.reload();
|
||
await page.waitForSelector(thumbs, { timeout: 120_000 });
|
||
const reopened = await page.$eval('[data-key="lib-tree"]', (el) => Math.round(el.getBoundingClientRect().width));
|
||
const where = await page.$eval('[data-key^="lib-node-"][aria-current="true"]', (e) => e.dataset.key);
|
||
step('the column drags and is the same width next time', dragged >= 160 && reopened === dragged, `${dragged}px, then ${reopened}px`);
|
||
step('the screen reopens on the folder it was left on', where === 'lib-node-CheckRoll/2026', where);
|
||
|
||
// 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);
|