242 lines
9.7 KiB
JavaScript
242 lines
9.7 KiB
JavaScript
// 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(' | ')}`);
|
|
|
|
// 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);
|