Files
RecipesCam/docker/frontend/scripts/tree-scan-check.mjs
T
3dtours 45fc852d04 fix(library): land a reading's rows as they are read, and keep the copy the reader reads offline
- scan: rows go down on a clock as well as on a full batch. The reader's folder
  of 26 RAWs is under one batch of 50, so nothing of it reached the screen until
  the reading was over — a wall that stood empty for the whole scan and then
  filled in one go. Frames now reach the screen every 400ms while the roll is
  still being read, so the count climbs and the first tiles are drawn as the
  frames land.
- tiles: a HEIC or a RAW has its tile written the first time it is drawn, so
  every later visit reads the tile back instead of the file.
- resume: a reading a reload cut off is offered by the next visit with a chip
  instead of being run behind the reader's back.
- offline listing: the desk's listing is kept whole in IndexedDB under
  `recipescam-offline`/`reads`, past the 200 000 characters localStorage held,
  so an offline visit opens on the listing it kept instead of on nothing.
- offline writes: a vote or a view cast with no server is held and goes out when
  one answers again, with a bar that says how many are waiting.
- sw: the shell precaches its own bundle, stylesheet and icon, read out of the
  document — a worker that precached only the document answered an offline
  launch with a page whose JavaScript the machine did not have.
- checks in docker/frontend/scripts: tree-scan-check (counts while reading,
  resume after a reload, tiles of a long roll), offline-read-check,
  offline-queue-check.
2026-10-09 12:02:05 +07:00

747 lines
32 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.
// The three things the reader reported, checked in a browser: the counts beside
// the folder names while a reading is running, what the column holds after the
// tab comes back — every folder the walk went into, the empty ones included, with
// an interrupted reading picking itself up — and a long roll's tiles, every frame
// the wall draws read out of the reading that brought them.
//
// The app is behind an account and answers `/api` on its own origin, so the check
// stands in for the API the way the container's nginx does; without it the page
// is a guest and there is no folder to pick. The catalogue is read back out of
// IndexedDB rather than out of the strip, which draws one node's own frames.
//
// npm run build && node scripts/tree-scan-check.mjs
// APP_PORT=4395 SAMPLE_PORT=4396 SAMPLE_DIR=<folder of sample frames> \
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> CHROME=<path to chrome> \
// node scripts/tree-scan-check.mjs
import { spawn } from 'node:child_process';
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, '..', 'sample');
const APP_PORT = Number(process.env.APP_PORT ?? 4185);
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4319);
const JPG = 'P1010256.JPG';
const RAW = 'P1010256.RW2';
// What the frames of the long roll are served: a small JPEG, because a hundred
// copies of an eight-megabyte one is eight hundred megabytes to prove a point
// about tiles.
const SMALL = 'Main.jpg';
const LONG = 60;
const LONG_NAMES = Array.from({ length: LONG }, (_, i) => `B${String(i).padStart(3, '0')}.JPG`);
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');
process.exit(0);
}
// Every frame the page asks for, by extension: one real JPEG and one real RAW — see
// the route that hands them over, below.
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');
console.log(`FAIL no preview on ${base} — run \`npm run build\` first`);
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();
// The sample frames are handed over inside the browser rather than off a socket of
// its own: a page is given six sockets to a host, and the walk's sixty reads fill
// all six, so the shelf's own reads would queue behind the reading they are meant
// to keep up with — a queue no real disk puts in the way.
await context.route(`http://127.0.0.1:${SAMPLE_PORT}/**`, async (route) => {
const name = path.basename(decodeURIComponent(new URL(route.request().url()).pathname));
const file = /^B\d+\.JPG$/.test(name) ? SMALL : /\.jpe?g$/i.test(name) ? JPG : /\.rw2$/i.test(name) ? RAW : null;
if (!file) return route.fulfill({ status: 404, body: '' });
return route.fulfill({
status: 200,
contentType: 'application/octet-stream',
headers: { 'Access-Control-Allow-Origin': '*' },
body: await readFile(path.join(SAMPLES, file)),
});
});
// The catalogue is behind an account, and the header reads the account's own
// folder: the API answers for both, the way the container beside the page does.
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' } });
if (url.includes('/photos')) return json({ photos: [] });
if (url.includes('/recipes')) return json({ recipes: [] });
return route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'no' }) });
});
await context.addInitScript(
({ origin, folder, rolls, small }) => {
const bytes = new Map();
// One fetch per real file whatever the handle is called: the frames of the
// long roll are one small JPEG served under sixty names.
const load = async (name) => {
const file = /^B\d+\.JPG$/.test(name) ? small : name;
if (!bytes.has(file)) bytes.set(file, fetch(`${origin}/${file}`).then((r) => r.arrayBuffer()));
return bytes.get(file);
};
const fileHandle = (name) => {
const handle = { kind: 'file', name, __fake: true };
Object.defineProperty(handle, 'getFile', {
value: async () => {
const gets = JSON.parse(sessionStorage.getItem('check.gets') ?? '{}');
gets[name] = (gets[name] ?? 0) + 1;
sessionStorage.setItem('check.gets', JSON.stringify(gets));
while (localStorage.getItem('check.hold') === '1') await new Promise((r) => setTimeout(r, 25));
// A read of a frame is nearly all waiting on a real disk, and a reading
// that is over in a blink is one the wall's own work cannot be watched
// against. `check.slow` is that wait, in milliseconds.
const slow = Number(localStorage.getItem('check.slow') ?? 0);
if (slow) await new Promise((r) => setTimeout(r, slow));
return new File([await load(name)], name, { type: 'application/octet-stream', lastModified: Date.UTC(2026, 0, 1) });
},
});
return handle;
};
const dirHandle = (label, node) => {
const handle = { kind: 'directory', name: label, __fake: true };
Object.defineProperties(handle, {
values: {
value: () => {
const lists = JSON.parse(sessionStorage.getItem('check.lists') ?? '{}');
lists[label] = (lists[label] ?? 0) + 1;
sessionStorage.setItem('check.lists', JSON.stringify(lists));
if (node.fail && localStorage.getItem('check.broken') !== 'open') throw new Error(`${label} will not open`);
return [
...(node.files ?? []).map(fileHandle),
...Object.entries(node.dirs ?? {}).map(([name, kid]) => dirHandle(name, kid)),
][Symbol.iterator]();
},
},
queryPermission: { value: async () => 'granted' },
requestPermission: { value: async () => 'granted' },
getDirectoryHandle: {
value: async (name) => {
const kid = node.dirs?.[name];
if (!kid) throw new Error(`no folder named ${name}`);
return dirHandle(name, kid);
},
},
getFileHandle: {
value: async (name) => {
if (!(node.files ?? []).includes(name)) throw new Error(`no frame named ${name}`);
return fileHandle(name);
},
},
});
return handle;
};
const pick = (name) => dirHandle(name, { files: rolls[name].files, dirs: rolls[name].dirs });
// The roll in play, not the one this script was handed: a handle that comes
// back out of the catalogue belongs to the folder being read, and the reading
// a reload takes up is of that one.
const root = () => pick(localStorage.getItem('check.roll') ?? folder);
window.showDirectoryPicker = async () => root();
// What the disk holds, for the check to change behind the app's back: a frame
// taken off the disk is a folder the walk meets one frame fewer in.
window.__rolls = rolls;
const revive = (row) => {
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) {
row.handle = row.handle.kind === 'directory' ? root() : 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}`,
folder: 'CheckRoll',
small: SMALL,
rolls: {
// One frame at the top, one a folder down, one two folders down, one in a
// second branch — and a folder holding no frame at all.
CheckRoll: {
files: [JPG],
dirs: {
2026: { files: [RAW], dirs: { '04': { files: [JPG], dirs: {} } } },
2025: { files: [JPG], dirs: {} },
Empty: { files: ['notes.txt'], dirs: {} },
},
},
// A roll with a folder that will not open: a permission taken back, a
// volume pulled out — a folder the walk meets and cannot list.
BrokenRoll: {
files: [JPG],
dirs: { Broken: { fail: true, files: [JPG], dirs: {} } },
},
// A roll long enough that its reading outlasts the wall's own work: sixty
// frames read at a walking pace, so the wall has time to tile them while
// the reading is still going.
BigRoll: {
files: LONG_NAMES,
dirs: {},
},
// The same roll read for the first time, on a tab that goes away.
CutRoll: {
files: [JPG],
dirs: { Broken: { fail: true, files: [JPG], dirs: {} } },
},
// A roll picked at a drive root, which Chromium hands over named `\` — the
// one name a folder under it is spelled from and that is itself no folder
// name at all. See the check at the foot of this file.
'\\': {
files: [JPG],
dirs: { Roll: { files: [RAW], dirs: {} } },
},
},
}
);
const page = await context.newPage();
const rowStates = () =>
page.$$eval('[data-key^="lib-node-"]', (els) =>
els.map((e) => `${e.dataset.key.replace('lib-node-', '')}:${(e.querySelector('.adm-album-count')?.textContent ?? '').trim()}`)
);
const progress = () => page.$eval('[data-key="lib-progress"]', (e) => e.textContent ?? '').catch(() => '');
const counts = async () => {
const out = {};
for (const row of await rowStates()) {
const [key, n] = row.split(':');
out[key] = n;
}
return out;
};
// Row order is the tree's path order, not the order of this file's expectations.
const same = (a, b) => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort());
// What the reading has stored, asked of the catalogue rather than of the strip:
// the strip draws the open node's own frames, and three of these four are one
// folder down.
const stored = () =>
page
.evaluate(async () => {
const db = await new Promise((resolve, reject) => {
const req = indexedDB.open('recipescam-library');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
return new Promise((resolve, reject) => {
const req = db.transaction('photos', 'readonly').objectStore('photos').count();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
})
.catch(() => -1);
// What the walk found, which is the whole of what the column is asked to say.
const WANT = {
CheckRoll: '4',
'CheckRoll/2025': '1',
'CheckRoll/2026': '2',
'CheckRoll/2026/04': '1',
'CheckRoll/Empty': '0',
};
// --- a reading, watched while it runs ---------------------------------------
await page.goto(`${base}/library`);
// Held on the first frame it reads, so the walk is through its first pass — the
// names and their numbers are the reading's, and the reading is not done.
await page.evaluate(() => localStorage.setItem('check.hold', '1'));
await page.click('[data-key="lib-add"]');
await page.waitForSelector('[data-key="lib-node-CheckRoll"]', { timeout: 30_000 });
// Every row of the pass is a row now, and a count beside each name is what the
// walk met — the reading itself has read nothing.
await page
.waitForFunction(() => {
const rows = [...document.querySelectorAll('[data-key^="lib-node-"]')];
return rows.length === 4 && /\d+\/\d+/.test(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '');
}, null, { timeout: 60_000, polling: 50 })
.catch(() => {});
const midFloat = await counts();
const midLine = (await progress()).replace(/\s+/g, ' ').trim();
const midStored = await stored();
// The whole roll is one pass of four folders, so the numbers are the walk's the
// moment the reading has listed the tree — before a single frame is read.
step(
'the counts beside the folder names are up while the reading runs',
same(midFloat, WANT),
`${JSON.stringify(midFloat)} at "${midLine}"`
);
step(
'and they are the walk’s own, not the catalogue’s: nothing has been read yet',
midStored === 0,
`${midStored} rows stored`
);
step('and the reading is in flight while the column already says so', Boolean(await page.$('[data-key="lib-progress"]')), `"${midLine}"`);
// --- the reading runs out ----------------------------------------------------
await page.evaluate(() => localStorage.removeItem('check.hold'));
// The progress span is in the page whether or not a reading is, and its text is
// empty until the first pass has said what the roll is: silence is the reading
// being through.
await page
.waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, {
timeout: 120_000,
polling: 50,
})
.catch(() => {});
const done = await counts();
step(
'a reading that ran out leaves the roll counted, empty folders and all',
same(done, WANT),
`${JSON.stringify(done)}, ${await stored()} rows stored`
);
// --- the tab comes back ------------------------------------------------------
await page.reload();
await page.waitForSelector('[data-key="lib-node-CheckRoll"]', { timeout: 60_000 });
await page.waitForTimeout(1000);
const back = await counts();
step(
'and the next visit draws the same column with nothing right-clicked',
same(back, done),
JSON.stringify(back)
);
step(
'a folder the walk went into and met no frame in keeps its row',
typeof back['CheckRoll/Empty'] === 'string',
`Empty: ${back['CheckRoll/Empty']}`
);
// Nothing is left to read, so this visit offers nothing and starts nothing: the
// catalogue comes back off the disk and the screen waits to be asked.
step(
'and a visit with nothing left to read reads nothing at all',
!(await page.$('[data-key="lib-resume"]')) && (await progress()).trim() === '',
`resume ${Boolean(await page.$('[data-key="lib-resume"]'))}, line "${(await progress()).trim()}"`
);
// --- a reading cut off -------------------------------------------------------
// The catalogue is emptied first, so every frame is one this reading has to read
// itself: a reading that does not go on by itself leaves the strip at nothing.
await page.evaluate(async () => {
sessionStorage.clear();
const db = await new Promise((resolve, reject) => {
const req = indexedDB.open('recipescam-library');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
await new Promise((resolve, reject) => {
const tx = db.transaction('photos', 'readwrite');
tx.objectStore('photos').clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
localStorage.setItem('check.hold', '1');
});
const counting = () =>
page
.waitForFunction(() => /\d+\s*\/\s*\d+/.test(document.querySelector('[data-key="lib-progress"]')?.textContent ?? ''), null, {
timeout: 60_000,
polling: 50,
})
.then(() => progress())
.then((line) => line.replace(/\s+/g, ' ').trim())
.catch(() => '');
// A reading of the whole roll, held on the first frame it reads: what a tab that
// is closed mid-reading leaves behind is exactly this.
await page.click('[data-key="lib-node-CheckRoll"]', { button: 'right' });
await page.waitForSelector('[data-key="lib-rescan-CheckRoll"]', { timeout: 10_000 });
await page.click('[data-key="lib-rescan-CheckRoll"]');
const cut = await counting();
// The walk writes its own position out at most once a second, and the reload is
// what has to be cut off mid-reading rather than before it started.
await page.waitForTimeout(2000);
// Nothing here asks for another reading, and neither does the visit that comes
// back: the roll the reload cut off is put on offer beside the progress line, and
// the press of that offer is the only thing that starts the second reading. The
// first frame is still held, so a visit that went on by itself would hang on it
// rather than pass.
await page.reload();
await page.waitForSelector('[data-key="lib-resume"]', { timeout: 60_000 });
const offered = (await progress()).trim();
step(
'a reading a reload cut off is offered by the next visit, not run by it',
/\d+\/\d+/.test(cut) && offered === '',
`"${cut}" then idle "${offered}"`
);
await page.click('[data-key="lib-resume"]');
const cutAgain = await counting();
step('and the offer is that reading taken up where it stopped', /\d+\/\d+/.test(cutAgain), `"${cutAgain}"`);
await page.evaluate(() => localStorage.removeItem('check.hold'));
// The progress span is in the page whether or not a reading is, and its text is
// empty until the first pass has said what the roll is: silence is the reading
// being through.
await page
.waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, {
timeout: 120_000,
polling: 50,
})
.catch(() => {});
const resumed = await stored();
const lists = await page.evaluate(() => JSON.parse(sessionStorage.getItem('check.lists') ?? '{}'));
step(
'and the visit after it finishes the roll on its own, without listing a folder twice',
resumed === 4 && lists.CheckRoll === 1 && lists['2026'] === 1 && lists.Empty === 1 && lists['2025'] === 1,
`${resumed} rows stored from ${JSON.stringify(lists)}`
);
const keys = (store) =>
page
.evaluate(
(store) =>
new Promise((resolve, reject) => {
const req = indexedDB.open('recipescam-library');
req.onsuccess = () => {
const db = req.result;
const get = db.transaction(store, 'readonly').objectStore(store).getAllKeys();
get.onsuccess = () => resolve(get.result);
get.onerror = () => reject(get.error);
};
req.onerror = () => reject(req.error);
}),
store
)
.catch(() => []);
const quiet = () =>
page
.waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, {
timeout: 120_000,
polling: 50,
})
.catch(() => {});
// A reading, watched out: the line is up once it has told the column what the
// roll is, and silence is the reading being through. Waiting for silence alone
// would read the line before the reading has written it.
const readOut = async () => {
await page
.waitForFunction(() => /\d+\s*\/\s*\d+/.test(document.querySelector('[data-key="lib-progress"]')?.textContent ?? ''), null, {
timeout: 20_000,
polling: 50,
})
.catch(() => {});
await quiet();
};
// A row for a frame the disk has not got, with the tile and the marks that go
// with it — the state a catalogue is left in when a frame is taken off the disk.
const seed = (ids) =>
page.evaluate(
(ids) =>
new Promise((resolve, reject) => {
const req = indexedDB.open('recipescam-library');
req.onsuccess = () => {
const db = req.result;
const tx = db.transaction(['photos', 'thumbs', 'edits'], 'readwrite');
for (const id of ids) {
const parts = id.split('/');
tx.objectStore('photos').put({
id,
folder: parts[0],
dir: parts.slice(1, -1).join('/'),
name: parts[parts.length - 1],
handle: { kind: 'file', name: parts[parts.length - 1], __fake: true },
taken: 1,
size: 1,
mtime: 1,
addedAt: 1,
});
tx.objectStore('thumbs').put({ id, thumb: new Blob(['x']) });
tx.objectStore('edits').put({ photoId: id, recipe: {}, updatedAt: 1 });
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
};
req.onerror = () => reject(req.error);
}),
ids
);
// --- the catalogue brought to the disk --------------------------------------
// The reader's report: a folder's frames and the number kept for it disagree, and
// a rescan of the parent does not put them right. Both halves are here — a frame
// taken off the disk while its row stayed in the catalogue, and a row for a frame
// that was never on the disk at all — and one rescan of the roll has to settle
// both, the numbers under the subfolders included.
await seed(['CheckRoll/2025/Gone.JPG', 'BrokenRoll/Broken/x.JPG']);
await page.evaluate(() => {
window.__rolls.CheckRoll.dirs['2025'].files.length = 0;
});
step(
'the catalogue is holding two rows the disk has nothing for',
(await keys('photos')).includes('CheckRoll/2025/Gone.JPG') &&
(await keys('photos')).includes('CheckRoll/2025/P1010256.JPG'),
JSON.stringify(await keys('photos'))
);
await page.click('[data-key="lib-node-CheckRoll"]', { button: 'right' });
await page.waitForSelector('[data-key="lib-rescan-CheckRoll"]', { timeout: 10_000 });
await page.click('[data-key="lib-rescan-CheckRoll"]');
await readOut();
const repaired = await counts();
const rows = (await keys('photos')).filter((k) => k.startsWith('CheckRoll/'));
step(
'a rescan of the parent brings every number under it to the disk',
same(repaired, {
...WANT,
CheckRoll: '3',
'CheckRoll/2025': '0',
}),
JSON.stringify(repaired)
);
step(
'and the frames the reading did not meet are gone from the catalogue',
rows.length === 3 && !rows.includes('CheckRoll/2025/P1010256.JPG') && !rows.includes('CheckRoll/2025/Gone.JPG'),
JSON.stringify(rows)
);
step(
'with their tiles and their marks',
!(await keys('thumbs')).includes('CheckRoll/2025/Gone.JPG') &&
!(await keys('edits')).includes('CheckRoll/2025/Gone.JPG'),
JSON.stringify(await keys('edits'))
);
// --- a folder the walk could not open ---------------------------------------
// The other side of the same rule: the walk met nothing under `Broken`, and a
// reading that read silence for absence would empty a folder it never listed.
// The number beside its name is the catalogue's, which is the last reading that
// could open it: a reading that could not open a folder writes no nought there —
// a nought takes the place of the frames the catalogue holds and reads as a
// folder gone empty until the reader rescans the roll.
await page.evaluate(() => localStorage.setItem('check.roll', 'BrokenRoll'));
await page.click('[data-key="lib-add"]');
await page.waitForSelector('[data-key="lib-node-BrokenRoll"]', { timeout: 60_000 });
await readOut();
const broken = await counts();
step('a folder the walk could not open keeps its row', broken['BrokenRoll/Broken'] === '1', JSON.stringify(broken));
step(
'and nothing under it is dropped: silence is not absence',
(await keys('photos')).includes('BrokenRoll/Broken/x.JPG'),
JSON.stringify(await keys('photos'))
);
step(
'while the frame the walk did meet is stored',
(await keys('photos')).includes('BrokenRoll/P1010256.JPG'),
JSON.stringify(await keys('photos'))
);
// --- a folder the reading could not open, and a tab that goes away -----------
// Why the number beside a folder stays at nothing through update after update,
// which is the state the reader photographed. A roll is read for the first time
// and the tab goes while the reading is in it: the position it leaves behind must
// not say it walked a folder it could not open, because an update takes that
// position up — a folder filed as read is a folder no update opens again, and its
// number stays at nothing however many times the reader asks. The folder opens on
// the visit that takes the reading up, and the reading is there to open it.
const opens = () => page.evaluate(() => JSON.parse(sessionStorage.getItem('check.lists') ?? '{}'));
const opened = (m, name) => m[name] ?? 0;
await page.evaluate(async () => {
sessionStorage.removeItem('check.lists');
localStorage.setItem('check.roll', 'CutRoll');
localStorage.setItem('check.hold', '1');
// Nothing in hand: the frame is one this reading has to read itself, which is
// what the hold is holding — and a reading held on the frame it is reading is
// the reading a closed tab leaves behind.
const db = await new Promise((resolve, reject) => {
const req = indexedDB.open('recipescam-library');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
await new Promise((resolve, reject) => {
const tx = db.transaction('photos', 'readwrite');
tx.objectStore('photos').clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
});
await page.click('[data-key="lib-add"]');
await page.waitForSelector('[data-key="lib-node-CutRoll"]', { timeout: 60_000 });
await counting();
await new Promise((r) => setTimeout(r, 1500));
const askedBroken = opened(await opens(), 'Broken');
const held = await page.evaluate(async () => {
const root = await navigator.storage.getDirectory();
try {
const dir = await root.getDirectoryHandle('walk', { create: false });
const fh = await dir.getFileHandle(`${encodeURIComponent('CutRoll')}.json`, { create: false });
return JSON.parse(await (await fh.getFile()).text());
} catch {
return null;
}
});
step(
'the position a closed tab leaves files no folder it could not open as walked',
!!held && !held.walked.some((r) => r.startsWith('Broken')) && held.pending.includes('Broken'),
JSON.stringify(held && { walked: held.walked, pending: held.pending })
);
// The tab goes, and the roll it cut off is offered on the visit that comes back:
// the offer is pressed, and the reading it takes up is the reading that opens the
// folder the last one could not.
await page.evaluate(() => localStorage.setItem('check.broken', 'open'));
await page.reload();
await page.waitForSelector('[data-key="lib-resume"]', { timeout: 60_000 });
await page.click('[data-key="lib-resume"]');
const again = await counting();
await page.evaluate(() => localStorage.removeItem('check.hold'));
await readOut();
const restarted = await counts();
step(
'and the visit that takes the reading up is the visit that opens it',
opened(await opens(), 'Broken') > askedBroken,
`Broken asked ${askedBroken} times, then ${opened(await opens(), 'Broken')}`
);
step(
'so the folder is counted on the disk, not left at nothing',
restarted['CutRoll'] === '2' && restarted['CutRoll/Broken'] === '1',
JSON.stringify(restarted)
);
// --- a roll picked at a drive root ------------------------------------------
// The reader picks a whole drive and every number beside a folder name stands at
// nought. The picker hands that folder over named `\`, and a row's number is
// looked up by the path that name spells: swapped for a bare backslash the roll
// is `/` and its folders `//Roll`, while the walk files its frames — and writes
// its counts — under the empty root a `normPath` fold leaves behind. Two
// spellings of one folder, and a column of noughts over a roll full of frames.
await page.evaluate(async () => {
sessionStorage.removeItem('check.lists');
localStorage.removeItem('check.hold');
localStorage.setItem('check.roll', '\\');
const db = await new Promise((resolve, reject) => {
const req = indexedDB.open('recipescam-library');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
await new Promise((resolve, reject) => {
const tx = db.transaction('photos', 'readwrite');
tx.objectStore('photos').clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
});
await page.click('[data-key="lib-add"]');
await page.waitForSelector('[data-key="lib-node-/Roll"]', { timeout: 60_000 }).catch(() => {});
// The reading runs out on its own, and silence is the reading being through.
await page
.waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, {
timeout: 120_000,
polling: 50,
})
.catch(() => {});
const drive = await counts();
step(
'a roll picked at a drive root counts its frames beside its own name',
drive[''] === '2' && drive['/Roll'] === '1',
JSON.stringify(drive)
);
// --- the shelf, out of a reading ---------------------------------------------
// The reading hands the screen the frames it found a batch at a time, and a batch
// used to be handed on only when it was full: the reader's folder of 26 RAWs is
// under one batch, so the screen stood empty for the whole of the reading and then
// filled at once, and the tiles had nothing to be drawn from until the reading was
// over. Frames go down on the clock as well now, so the count climbs and the first
// tiles appear while the roll is still being read. Sixty frames read at a walking
// pace is a long roll read the way the reader read theirs.
await page.evaluate(async () => {
sessionStorage.removeItem('check.lists');
localStorage.removeItem('check.hold');
localStorage.setItem('check.slow', '150');
localStorage.setItem('check.roll', 'BigRoll');
const db = await new Promise((resolve, reject) => {
const req = indexedDB.open('recipescam-library');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
await new Promise((resolve, reject) => {
const tx = db.transaction(['photos', 'thumbs'], 'readwrite');
tx.objectStore('photos').clear();
tx.objectStore('thumbs').clear();
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
});
await page.click('[data-key="lib-add"]');
await page.waitForSelector('[data-key="lib-node-BigRoll"]', { timeout: 60_000 });
await page
.waitForFunction(() => (document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim().length > 0, null, {
timeout: 60_000,
polling: 25,
})
.catch(() => {});
// The reading is in flight and the frames are being read: what is asked of it here
// is whether the screen is told of them as they land. A roll under one `BATCH`
// reached the screen in a single lump at the end of the reading, however long the
// reading was — the reader's folder of 26 RAWs was one batch, and so one empty
// screen for the whole of it. Every count between none and all is a reading that
// said something on the way past.
const landed = [];
for (let i = 0; i < 120; i++) {
const reading = await page
.$eval('[data-key="lib-progress"]', (e) => (e.textContent ?? '').trim())
.catch(() => '');
const listed = Number(
(await page.$eval('[data-key="lib-listed"]', (e) => e.textContent ?? '').catch(() => '0')).replace(/\D+/g, '')
);
if (reading && listed > 0) landed.push(listed);
if (!reading) break;
await new Promise((r) => setTimeout(r, 150));
}
const seen = [...new Set(landed)];
step(
'the frames the reading lands are on screen as it lands them',
seen.length >= 2,
`${seen.length} counts while the reading ran: ${seen.join(', ') || 'none'}`
);
await page
.waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, {
timeout: 120_000,
polling: 25,
})
.catch(() => {});
// The frames the wall draws are read one behind another and the tiles land in
// starts and stops, so the count is taken once it has stood still for a while.
let atEnd = 0;
for (let i = 0, same = 0; i < 160 && same < 10; i++) {
await new Promise((r) => setTimeout(r, 250));
const n = (await keys('thumbs')).length;
same = n === atEnd ? same + 1 : 0;
atEnd = n;
}
step('the wall tiles the frames a reading brought it', atEnd >= 10, `${atEnd} tiles of ${LONG} drawn`);
await page.evaluate(() => localStorage.removeItem('check.slow'));
await browser.close();
app.kill('SIGTERM');
console.log(failures ? `\n${failures} failed` : '\nall checks passed');
process.exit(failures ? 1 : 0);