327 lines
14 KiB
JavaScript
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);
|