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

219 lines
9.3 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.
// TEMP repro: UPDATE on a folder row must not write the frames it already holds
// a second time. Root handle is named `\` — a Windows drive root, which is what
// the reader right-clicked.
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 SAMPLE_FILE = process.env.SAMPLE_FILE ?? path.resolve(FRONTEND, '../sample/_3279791-develop-check.jpg');
const APP_PORT = Number(process.env.APP_PORT ?? 5920);
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 5921);
const ROOT = '\\';
const ROOT_KEY = process.env.ROOT_KEY ?? ROOT;
const names = ['a1.jpg', 'a2.jpg', 'a3.jpg', 'a4.jpg'];
const inner = ['b1.jpg', 'b2.jpg'];
let fails = 0;
const step = (what, ok, detail = '') => {
if (!ok) fails++;
console.log(`${ok ? 'ok ' : 'FAIL'} ${what}${detail ? ` — ${detail}` : ''}`);
};
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);
}
const jpg = await readFile(SAMPLE_FILE);
const all = [...names, ...inner];
const sampleServer = createServer((req, res) => {
const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0]));
if (!all.includes(name)) {
res.writeHead(404).end();
return;
}
res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Content-Length': jpg.length, 'Access-Control-Allow-Origin': '*' });
res.end(jpg);
});
await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve));
const app = process.env.BASE_URL
? null
: spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
cwd: FRONTEND,
stdio: 'ignore',
detached: true,
});
const stopApp = () => {
if (!app?.pid) return;
try {
process.kill(-app.pid, 'SIGTERM');
} catch {}
};
const base = process.env.BASE_URL ?? `http://127.0.0.1:${APP_PORT}`;
let up = !app;
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) {
stopApp();
sampleServer.close();
console.log(`FAIL no preview on ${base}`);
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({ viewport: { width: 1500, height: 950 } });
await context.route('**/api/**', (route) => {
const url = route.request().url();
const json = (body) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
if (url.includes('/auth/me')) return json({ user: { id: 1, email: 'check@example.com', name: 'check', verified: true, pro: true, admin: false } });
if (url.includes('/photos')) return json({ photos: [] });
if (url.includes('/place')) return json({ place: 'Hanoi' });
if (url.includes('/ratings')) return json({ ratings: [] });
if (url.includes('/highlights')) return json({ highlights: { day: [], week: [], ever: [] } });
if (url.includes('/recipes')) return json({ recipes: [] });
return json({});
});
await context.addInitScript(
({ origin, names, inner, root }) => {
const fileHandle = (name) => {
const handle = { kind: 'file', name, __fake: true };
Object.defineProperty(handle, 'getFile', {
value: async () => new File([await (await fetch(`${origin}/${name}`)).arrayBuffer()], name, { type: 'image/jpeg', lastModified: Date.UTC(2024, 3, 2, 12) }),
});
return handle;
};
const valuesOf = (node, label) => {
const it = [];
for (const f of node.files) it.push(fileHandle(f));
for (const d of node.dirs ?? []) it.push(dirHandle(d.label, d, label));
return it[Symbol.iterator]();
};
const dirHandle = (label, node, parent = '') => {
const handle = { kind: 'directory', name: label, __fake: true };
const self = () => dirHandle(label, node, parent);
Object.defineProperties(handle, {
values: { value: () => valuesOf(node, label) },
queryPermission: { value: async () => 'granted' },
requestPermission: { value: async () => 'granted' },
getDirectoryHandle: {
value: async (name) => {
const d = (node.dirs ?? []).find((x) => x.label === name);
if (!d) throw new Error(`no folder ${name}`);
return dirHandle(name, d, label);
},
},
getFileHandle: {
value: async (name) => {
if (!node.files.includes(name)) throw new Error(`no frame ${name}`);
return fileHandle(name);
},
},
__self: { value: self },
});
return handle;
};
const rootNode = { files: names, dirs: [{ label: 'Early', files: inner }] };
const rootHandle = () => dirHandle(root, rootNode);
window.showDirectoryPicker = async () => rootHandle();
const revive = (row) => {
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) {
row.handle = row.handle.kind === 'directory' ? rootHandle() : 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, inner, root: ROOT }
);
const page = await context.newPage();
const errors = [];
page.on('pageerror', (e) => errors.push(String(e.message).slice(0, 200)));
const rows = () =>
page.evaluate(
(db) =>
new Promise((resolve) => {
const open = indexedDB.open(db);
open.onsuccess = () => {
const tx = open.result.transaction('photos', 'readonly');
const req = tx.objectStore('photos').getAll();
req.onsuccess = () => {
const photos = req.result.map((r) => ({ id: r.id, folder: r.folder, dir: r.dir, name: r.name, size: r.size, mtime: r.mtime }));
const t = open.result.transaction('thumbs', 'readonly');
const tr = t.objectStore('thumbs').getAll();
tr.onsuccess = () =>
resolve({ photos, thumbs: tr.result.map((x) => x.id), stores: [...open.result.objectStoreNames] });
};
};
open.onerror = () => resolve({ photos: [], thumbs: [], stores: [] });
}),
'recipescam-library'
);
await page.goto(`${base}/library`);
await page.click('[data-key="lib-add"]');
await page.waitForSelector('[data-key^="lib-node-"]', { timeout: 60_000 });
await page
.waitForFunction((n) => (document.querySelector('[data-key="lib-count"]')?.textContent ?? '').includes(String(n)), all.length, { timeout: 240_000, polling: 250 })
.catch(() => {});
const first = await rows();
step('the drive is read once', first.photos.length === all.length, `${first.photos.length} rows for ${all.length} frames`);
step('every frame has one row', new Set(first.photos.map((p) => p.id)).size === first.photos.length, first.photos.map((p) => p.id).join(' | '));
// The reader right-clicks the drive row and asks for UPDATE.
await page.locator('[data-key^="lib-node-"]').first().click({ button: 'right' });
await page.waitForSelector('[data-key^="lib-update-"]', { timeout: 20_000 });
await page.click('[data-key^="lib-update-"]');
await page.waitForFunction(() => !document.querySelector('.lib-scan-live, [data-key="lib-scan-stop"]'), { timeout: 240_000, polling: 250 }).catch(() => {});
await page.waitForTimeout(4000);
const after = await rows();
const byId = new Map();
for (const p of after.photos) byId.set(p.id, (byId.get(p.id) ?? 0) + 1);
const dupIds = [...byId].filter(([, n]) => n > 1);
const byName = new Map();
for (const p of after.photos) byName.set(p.name, (byName.get(p.name) ?? 0) + 1);
const dupNames = [...byName].filter(([, n]) => n > 1);
step('UPDATE adds nothing', after.photos.length === all.length, `${after.photos.length} rows for ${all.length} frames`);
step('no frame carries two rows', dupIds.length === 0, dupIds.map(([id, n]) => `${id}×${n}`).join(' | '));
step('no frame carries two thumbnails', dupNames.length === 0 && after.thumbs.length === all.length, `rows=${after.photos.length} thumbs=${after.thumbs.length} | ${dupNames.map(([k, n]) => `${k}×${n}`).join(' ')}`);
const tiles = await page.evaluate(() => document.querySelectorAll('.lib-tile, .mob-lib-thumb-cell').length);
step('the wall draws each frame once', tiles <= all.length + 2, `tiles=${tiles}`);
step('no page error', errors.length === 0, errors.join(' | '));
console.log('ids after:', after.photos.map((p) => `${p.id} [folder=${p.folder}]`).join(' | '));
await browser.close();
stopApp();
sampleServer.close();
console.log(fails ? `${fails} FAIL` : 'all ok');
process.exit(fails ? 1 : 0);