Files

327 lines
14 KiB
JavaScript

// The catalogue's own copy, end to end: a roll of two frames is read, the
// catalogue is written into a folder the visitor picked, the browser's storage is
// taken away from under it, and the catalogue is read back out — with the frames
// never read a second time.
//
// The folder of frames is a stand-in served off a local server, because the check
// cannot hand a real directory to a page. The backup folder is not: the picker
// answers with a directory out of the origin's own file system (OPFS), which is a
// real `FileSystemDirectoryHandle` with a real `createWritable`, so what is
// written is written by the code under test through the API it will use on a
// visitor's disk — and what the check then reads back is the bytes on it.
//
// What the run claims, in order:
// * the catalogue file holds the rows and neither of the two things a file
// cannot hold — a handle and a tile;
// * the tiles are written beside it, one per frame, under `thumbs/<frame id>`;
// * a second run writes no tile at all;
// * a catalogue with nothing in it gets every row back, tiles included, and the
// frames themselves are never read from the disk again — not by the restore,
// which takes the tiles off the backup folder, and not by the reading that
// follows it, which matches each frame on its size and its write time.
//
// npm run build && node scripts/backup-check.mjs
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> node scripts/backup-check.mjs
//
// Playwright is not a dependency of this package, so a run without it says SKIP
// and exits 0. APP_PORT says where the preview goes, CHROME says which browser.
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 ?? 4198);
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4321);
// Two frames of one folder, both read off the sample roll.
const FILES = ['P1010256.JPG', 'DSCF1701.JPG'];
const FOLDER = 'TestRoll';
const BACKUP = 'BackupRoll';
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 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 (!FILES.includes(name)) {
res.writeHead(404).end();
return;
}
const bytes = await readFile(path.join(SAMPLES, name));
res.writeHead(200, {
'Content-Type': 'image/jpeg',
'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);
}
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 words this screen says are read below, so the run is pinned to one of the
// two dictionaries rather than to whatever the browser's own language is.
await context.addInitScript(() => localStorage.setItem('rc.lang', 'en'));
// The picker, twice over: the roll of frames is a stand-in — a page cannot be
// handed a real directory by a test — and the backup folder is the origin's own
// file system, which is a real one. Which of the two a call is for is the `id`
// the code passes, the same one it passes to a visitor's browser.
await context.addInitScript(
({ origin, files, folder, backup }) => {
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);
};
// What a frame costs is its bytes, and the bytes of a frame are read through
// `arrayBuffer` — on the file, or on a slice of it, which is how a scan reads
// the head of a JPEG. Only the stand-in's own files are counted: the tiles
// that come back out of the backup folder are read the same way, and they are
// named after the frames.
window.__frameReads = 0;
const off = new WeakMap();
const part = Blob.prototype.slice;
Blob.prototype.slice = function (...args) {
const cut = part.apply(this, args);
off.set(cut, off.get(this) ?? this);
return cut;
};
const pull = Blob.prototype.arrayBuffer;
Blob.prototype.arrayBuffer = function () {
if ((off.get(this) ?? this).__fake) window.__frameReads++;
return pull.apply(this);
};
const fileHandle = (name) => {
const handle = { kind: 'file', name };
Object.defineProperty(handle, 'getFile', {
value: async () => {
const file = new File([await load(name)], name, {
type: 'image/jpeg',
// One write time per frame, and the same one on every read: a fresh
// `Date.now()` would make every frame look touched and hide what is
// being checked — the reading after the restore has to match.
lastModified: Date.UTC(2016, 1, 15, 9, 30),
});
Object.defineProperty(file, '__fake', { value: true });
return file;
},
});
return handle;
};
const dirHandle = (label, node) => {
const handle = { kind: 'directory', name: label };
Object.defineProperties(handle, {
values: { value: () => [...(node.files ?? []).map(fileHandle)][Symbol.iterator]() },
queryPermission: { value: async () => 'granted' },
requestPermission: { value: async () => 'granted' },
getDirectoryHandle: { value: async (name) => {
throw new Error(`no folder named ${name}`);
} },
getFileHandle: {
value: async (name) => {
if (!(node.files ?? []).includes(name)) throw new Error(`no frame named ${name}`);
return fileHandle(name);
},
},
});
return handle;
};
const root = (label) => dirHandle(label, { files });
window.showDirectoryPicker = async (opts) => {
if (opts?.id !== 'recipescam-backup') return root(folder);
const opfs = await navigator.storage.getDirectory();
// A folder of the origin's own, emptied at the start of the run and named
// as the picker would name it.
await opfs.removeEntry(backup, { recursive: true }).catch(() => {});
const dir = await opfs.getDirectoryHandle(backup, { create: true });
// OPFS handles are always writable and say so only through the API the
// picker's handles carry: the two methods are stood in where the real
// handle leaves them out, so the code under test takes the same road it
// would with a folder off a disk.
for (const mode of ['queryPermission', 'requestPermission']) {
if (typeof dir[mode] !== 'function') Object.defineProperty(dir, mode, { value: async () => 'granted' });
}
return dir;
};
},
{ origin: `http://127.0.0.1:${SAMPLE_PORT}`, files: FILES, folder: FOLDER, backup: BACKUP }
);
const page = await context.newPage();
// The restore asks before it replaces anything, and a dismissed dialog is a
// cancel — the run says yes the way a reader would.
page.on('dialog', (dialog) => void dialog.accept());
const note = () => page.$eval('.adm-note', (el) => el.textContent ?? '').catch(() => '');
const waitNote = (re) =>
page.waitForFunction(
(src) => new RegExp(src).test(document.querySelector('.adm-note')?.textContent ?? ''),
re.source,
{ timeout: 120_000 }
);
const count = () => page.$eval('[data-key="lib-count"]', (el) => el.textContent ?? '');
// The bytes of a frame actually read off the disk, counted inside the page: the
// whole claim of the catalogued size and write time is this number staying put.
const framesRead = () => page.evaluate(() => window.__frameReads ?? 0);
// The catalogue the page itself keeps, read straight out of the browser: what the
// restore puts back is a row in there, not a picture on the screen.
const rows = () =>
page.evaluate(
() =>
new Promise((resolve) => {
const open = indexedDB.open('recipescam-library');
open.onsuccess = () => {
const req = open.result.transaction('photos', 'readonly').objectStore('photos').getAll();
req.onsuccess = () =>
resolve(
req.result.map((r) => ({
id: r.id,
tile: r.thumb ? r.thumb.size : 0,
handle: !!r.handle,
star: r.star ?? null,
}))
);
req.onerror = () => resolve([]);
};
open.onerror = () => resolve([]);
})
);
// Everything the backup folder holds, walked the way a person looking at it in a
// file manager would.
const backupTree = () =>
page.evaluate(async (name) => {
const opfs = await navigator.storage.getDirectory();
const root = await opfs.getDirectoryHandle(name);
const out = [];
const walk = async (dir, at) => {
for await (const [child, handle] of dir.entries()) {
const rel = at ? `${at}/${child}` : child;
if (handle.kind === 'directory') await walk(handle, rel);
else out.push({ rel, size: (await handle.getFile()).size });
}
};
await walk(root, '');
return out;
}, BACKUP);
let code = 0;
try {
// --- the roll is read ------------------------------------------------------
await page.goto(`${base}/library`);
await page.click('[data-key="lib-add"]');
await waitNote(/Scanned TestRoll/);
const read = await rows();
step('the roll was read and both frames are in the catalogue', read.length === 2, `${read.length} rows`);
step('every frame has its tile', read.every((r) => r.tile > 0));
step('the count on the bar says two', /2/.test(await count()), await count());
const firstRead = await framesRead();
step('reading a roll of two frames costs two frames', firstRead === 2, `${firstRead} reads`);
// --- the catalogue is written ---------------------------------------------
await page.click('[data-key="lib-backup"]');
await waitNote(/Backed up 2 frames, 2 tiles written/);
const tree = await backupTree();
const cat = await page.evaluate(async (name) => {
const opfs = await navigator.storage.getDirectory();
const dir = await opfs.getDirectoryHandle(name);
return JSON.parse(await (await (await dir.getFileHandle('recipescam-catalogue.json')).getFile()).text());
}, BACKUP);
step('the catalogue file holds both rows', cat.photos.length === 2, `${cat.photos.length}`);
step(
'a row carries no handle and no tile',
cat.photos.every((r) => !('handle' in r) && !('thumb' in r)),
Object.keys(cat.photos[0]).join(',')
);
step(
'a row carries the size and the write time of its frame',
cat.photos.every((r) => r.size > 0 && r.mtime === Date.UTC(2016, 1, 15, 9, 30))
);
const tiles = tree.filter((f) => f.rel.startsWith('thumbs/'));
step(
'the tiles are beside it, one file per frame',
tiles.length === 2 && tiles.every((f) => f.size > 0) && tiles.every((f) => /^thumbs\/TestRoll\/[^/]+$/.test(f.rel)),
tiles.map((f) => f.rel).join(' ')
);
// --- a second run writes no tile ------------------------------------------
await page.click('[data-key="lib-backup"]');
await waitNote(/Backed up 2 frames, 0 tiles written/);
// --- the browser's storage is taken away ----------------------------------
await page.goto('about:blank');
await page.goto(`${base}/`);
const gone = await page.evaluate(
() =>
new Promise((resolve) => {
const req = indexedDB.deleteDatabase('recipescam-library');
req.onsuccess = () => resolve(true);
req.onerror = req.onblocked = () => resolve(false);
})
);
step('the catalogue is gone from the browser', gone === true);
await page.goto(`${base}/library`);
step('the library comes up empty', /^0\b/.test((await count()).trim()), await count());
step('and has read nothing', (await framesRead()) === 0);
// --- and it is put back ----------------------------------------------------
await page.click('[data-key="lib-restore"]');
await waitNote(/Restored 2 frames/);
const back = await rows();
step('both rows are back', back.length === 2, `${back.length} rows`);
step('each row came back with its tile', back.every((r) => r.tile > 0));
step('no row came back with a handle — a handle is not a file', back.every((r) => !r.handle));
step('the restore read no frame from the disk', (await framesRead()) === 0, `${await framesRead()} reads`);
step('the count on the bar says two again', /2/.test(await count()), await count());
// --- the folder is picked again, and no frame is read for it ---------------
await page.click('[data-key="lib-add"]');
await waitNote(/Scanned TestRoll/);
const joined = await rows();
step('the reading gave every row its handle back', joined.length === 2 && joined.every((r) => r.handle));
step('and read no frame to do it', (await framesRead()) === 0, `${await framesRead()} reads`);
} catch (err) {
step(`the run finished`, false, String(err).split('\n')[0]);
code = 1;
}
console.log(failures ? `FAIL ${failures} step(s)` : 'ok the catalogue survives its browser');
await browser.close();
app.kill('SIGTERM');
sampleServer.close();
process.exit(failures || code ? 1 : 0);