// TEMP repro: a drive root whose catalogue holds one frame twice — the row an // older build filed under its raw `\`-spelled id, and the row this build filed // beside it — must come out of UPDATE as one row, and the frame keeps the id // this build computes. This is the state the reader was left in: two tiles for // every frame of the disk, and an UPDATE that only ever added more. 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 ?? 5930); const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 5931); const ROOT = '\\'; const names = ['a1.jpg', 'a2.jpg', 'a3.jpg', 'a4.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 sampleServer = createServer((req, res) => { const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0])); if (!names.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, 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) => [...node.files.map(fileHandle)][Symbol.iterator](); const dirHandle = (label, node) => { const handle = { kind: 'directory', name: label, __fake: true }; Object.defineProperties(handle, { values: { value: () => valuesOf(node) }, queryPermission: { value: async () => 'granted' }, requestPermission: { value: async () => 'granted' }, getDirectoryHandle: { value: async () => { throw new Error('no folder'); } }, getFileHandle: { value: async (name) => { if (!node.files.includes(name)) throw new Error(`no frame ${name}`); return fileHandle(name); }, }, }); return handle; }; const rootNode = { files: names }; window.showDirectoryPicker = async () => dirHandle(root, rootNode); const revive = (row) => { if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) { row.handle = row.handle.kind === 'directory' ? dirHandle(root, rootNode) : 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, root: ROOT } ); const page = await context.newPage(); const errors = []; page.on('pageerror', (e) => errors.push(String(e.message).slice(0, 200))); if (process.env.LOG) page.on('console', (m) => console.log('PAGE:', m.text().slice(0, 300))); 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 })); const t = open.result.transaction('thumbs', 'readonly'); const tr = t.objectStore('thumbs').getAll(); tr.onsuccess = () => resolve({ photos, thumbs: tr.result.map((x) => x.id) }); }; }; open.onerror = () => resolve({ photos: [], thumbs: [] }); }), 'recipescam-library' ); await page.goto(`${base}/library`); // The catalogue the reader is left with: a drive of frames, each filed twice. await page.evaluate( async ({ names, root }) => { const DB = 'recipescam-library'; const STORES = { folders: 'name', photos: 'id', thumbs: 'id', edits: 'photoId', dirs: 'name' }; const open = (version) => new Promise((resolve, reject) => { const req = version ? indexedDB.open(DB, version) : indexedDB.open(DB); req.onupgradeneeded = () => { for (const [store, keyPath] of Object.entries(STORES)) { if (!req.result.objectStoreNames.contains(store)) req.result.createObjectStore(store, { keyPath }); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); let db = await open(); if (!db.objectStoreNames.contains('thumbs')) { const at = db.version; db.close(); db = await open(at + 1); } const handle = await window.showDirectoryPicker(); const thumb = new Blob([new Uint8Array([1, 2, 3])], { type: 'image/jpeg' }); await new Promise((resolve, reject) => { const tx = db.transaction(['folders', 'photos', 'thumbs'], 'readwrite'); tx.objectStore('folders').put({ name: root, label: 'D:', handle }); for (const name of names) { // The row the older build filed, and the row this build filed beside it // when an UPDATE read the frame as new: one frame, two rows, two tiles. for (const id of [`${root}/${name}`, `/${name}`]) { tx.objectStore('photos').put({ id, folder: id.startsWith('/') ? '' : root, name, taken: Date.UTC(2024, 3, 2, 12), size: 1, addedAt: Date.now(), }); tx.objectStore('thumbs').put({ id, thumb }); } } tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); }, { names, root: ROOT } ); await page.reload(); await page.waitForSelector('[data-key^="lib-node-"]', { timeout: 60_000 }); const before = await rows(); step('the doubled catalogue is read', before.photos.length === names.length * 2, `${before.photos.length} rows: ${before.photos.map((p) => p.id).join(' | ')}`); // The wall, before any repair: the frames of the drive are drawn once each, even // while the catalogue holds two rows for every one of them. await page.locator('[data-key^="lib-node-"]').first().click(); await page.click('[data-key="lib-view-grid"]'); await page.waitForTimeout(3000); const tiles = await page.evaluate(() => document.querySelectorAll('.lib-tile, .mob-lib-thumb-cell').length); step('the wall draws each frame once', tiles === names.length, `tiles=${tiles}`); // UPDATE on the drive row. 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.waitForTimeout(8000); const after = await rows(); step('UPDATE adds nothing', after.photos.length === names.length, `${after.photos.length} rows: ${after.photos.map((p) => p.id).join(' | ')}`); step('one thumbnail per frame', after.thumbs.length === names.length, `${after.thumbs.length} thumbs: ${after.thumbs.join(' | ')}`); step( 'the doubled rows are dropped for the id this build computes', after.photos.every((p) => p.id === `/${p.name}`) && after.thumbs.every((id) => id.startsWith('/')), `rows: ${after.photos.map((p) => p.id).join(' | ')}` ); step('no page error', errors.length === 0, errors.join(' | ')); await browser.close(); stopApp(); sampleServer.close(); console.log(fails ? `${fails} FAIL` : 'all ok'); process.exit(fails ? 1 : 0);