library: the walk position belongs to the origin, so the app beside the browser carries a reading on instead of listing the roll again
This commit is contained in:
@@ -893,13 +893,14 @@ step('and taking it reads the folder again', (await page.$$eval(thumbs, (els) =>
|
||||
// the roll again, every folder is listed a second time, and the counter starts
|
||||
// from zero while the frames it already holds are read again to be skipped.
|
||||
//
|
||||
// The position belongs to the tab now, so the reading carries on instead of
|
||||
// The position belongs to the origin now, so the reading carries on instead of
|
||||
// starting over. `check.hold` stops the reading on the frame it is reading, which
|
||||
// is where a reload lands — and the reading it left behind is still there when the
|
||||
// key goes, so the same reading finishes the roll. Everything is taken out of the
|
||||
// catalogue first, so the reading has frames on both sides of the reload.
|
||||
await page.evaluate(async () => {
|
||||
sessionStorage.clear();
|
||||
localStorage.removeItem('recipescam.library.walk.CheckRoll');
|
||||
localStorage.setItem('check.hold', '1');
|
||||
const db = await new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open('recipescam-library');
|
||||
@@ -913,10 +914,10 @@ await page.evaluate(async () => {
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
});
|
||||
// The position the tab is holding, the line the toolbar reads out, and the wait
|
||||
// The position the origin is holding, the line the toolbar reads out, and the wait
|
||||
// for it: the line is how far the reading has come, and it is the one thing on
|
||||
// screen that says whether a reading started over.
|
||||
const position = () => page.evaluate(() => JSON.parse(sessionStorage.getItem('recipescam.library.walk.CheckRoll') ?? 'null'));
|
||||
const position = () => page.evaluate(() => JSON.parse(localStorage.getItem('recipescam.library.walk.CheckRoll') ?? 'null'));
|
||||
const readout = () => page.$eval('[data-key="lib-progress"]', (e) => e.textContent ?? '').catch(() => '');
|
||||
const counting = () =>
|
||||
page.waitForFunction(
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// Where a reading had got to is written down for the whole origin, not for the
|
||||
// tab. The tab that reads a roll for minutes is often not the one that finishes
|
||||
// it: the app is closed and the installed one opens, or the tab is thrown away
|
||||
// and the reader comes back. The position used to live in sessionStorage, which
|
||||
// is the tab's own, so the second context found nothing, walked the roll from the
|
||||
// top and listed every folder again — the frame counter starting over while the
|
||||
// frames it already held were read again only to be skipped.
|
||||
//
|
||||
// Two pages of one browser context are two contexts of one origin: the first
|
||||
// picks the stand-in roll and is closed mid-walk, the second opens and takes the
|
||||
// reading up. What it costs is what says whether it did — a reading that took the
|
||||
// position up lists nothing, since the folders behind it came back with it, where
|
||||
// one that started at the top lists the whole roll a second time.
|
||||
//
|
||||
// npm run build && node scripts/walk-share-check.mjs
|
||||
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> node scripts/walk-share-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 { fileURLToPath } from 'node:url';
|
||||
|
||||
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
|
||||
const PORT = Number(process.env.APP_PORT ?? 4197);
|
||||
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);
|
||||
}
|
||||
|
||||
// `--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(PORT), '--strictPort', '--host', '127.0.0.1'], {
|
||||
cwd: FRONTEND,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
const base = `http://127.0.0.1:${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 (vite preview serves dist/)`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Dated folders of five frames each: a roll large enough that the reading is
|
||||
// still going when the first page is taken away, and small enough to finish.
|
||||
const DIRS = {};
|
||||
for (let i = 0; i < 40; i++) {
|
||||
DIRS[`d${String(i).padStart(2, '0')}`] = { files: [1, 2, 3, 4, 5].map((n) => `F${i}_${n}.JPG`), t: 1_700_000_000_000 };
|
||||
}
|
||||
|
||||
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 picker stands in for the person: a folder of handles over frames made in
|
||||
// the page (a beat apiece, so the reading can be caught in the middle of the
|
||||
// roll), and the same handles put back on the rows IndexedDB hands out — a cloned
|
||||
// handle is a plain object with no `getFile` on it.
|
||||
await context.addInitScript(
|
||||
({ dirs }) => {
|
||||
let bytes = null;
|
||||
const jpeg = () =>
|
||||
(bytes ??= new Promise((resolve) => {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = 24;
|
||||
c.height = 16;
|
||||
const g = c.getContext('2d');
|
||||
g.fillStyle = '#b3412a';
|
||||
g.fillRect(0, 0, 24, 16);
|
||||
c.toBlob(async (b) => resolve(new Uint8Array(await b.arrayBuffer())), 'image/jpeg', 0.8);
|
||||
}));
|
||||
const fileHandle = (name, t) => {
|
||||
const handle = { kind: 'file', name, __fake: true };
|
||||
Object.defineProperty(handle, 'getFile', {
|
||||
value: async () => {
|
||||
await new Promise((r) => setTimeout(r, 120));
|
||||
return new File([await jpeg()], name, { type: 'image/jpeg', lastModified: t });
|
||||
},
|
||||
});
|
||||
return handle;
|
||||
};
|
||||
const dirHandle = (label, node) => {
|
||||
const handle = { kind: 'directory', name: label, __fake: true };
|
||||
Object.defineProperties(handle, {
|
||||
values: {
|
||||
value: () => {
|
||||
// Every listing a reading asks for, counted for the whole origin: a
|
||||
// reading that starts over lists the same folder a second time, and
|
||||
// that second listing is the whole of what the position saves.
|
||||
localStorage.setItem('walkcheck.lists', String(Number(localStorage.getItem('walkcheck.lists') ?? 0) + 1));
|
||||
return [
|
||||
...(node.files ?? []).map((f) => fileHandle(f, node.t)),
|
||||
...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, node.t);
|
||||
},
|
||||
},
|
||||
});
|
||||
return handle;
|
||||
};
|
||||
const root = () => dirHandle('BigRoll', { files: [], dirs });
|
||||
window.showDirectoryPicker = async () => root();
|
||||
const revive = (row) => {
|
||||
if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) {
|
||||
row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name, 1_700_000_000_000);
|
||||
}
|
||||
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;
|
||||
};
|
||||
}
|
||||
},
|
||||
{ dirs: DIRS }
|
||||
);
|
||||
|
||||
const progress = (p) => p.$eval('[data-key="lib-progress"]', (e) => e.textContent ?? '').catch(() => '');
|
||||
const at = (text) => {
|
||||
const m = /(\d+)\/(\d+)/.exec(text);
|
||||
return m ? { n: Number(m[1]), t: Number(m[2]) } : null;
|
||||
};
|
||||
const listed = (p) => p.evaluate(() => Number(localStorage.getItem('walkcheck.lists') ?? 0));
|
||||
const held = (p) =>
|
||||
p.evaluate(() => {
|
||||
const raw = localStorage.getItem('recipescam.library.walk.BigRoll');
|
||||
if (!raw) return null;
|
||||
const s = JSON.parse(raw);
|
||||
return { done: s.progress?.done, walked: s.walked?.length, frames: s.frames?.length };
|
||||
});
|
||||
|
||||
// --- the first context: it starts the reading and is taken away -------------
|
||||
const first = await context.newPage();
|
||||
await first.goto(`${base}/library`);
|
||||
await first.evaluate(() => {
|
||||
localStorage.removeItem('walkcheck.lists');
|
||||
localStorage.removeItem('recipescam.library.walk.BigRoll');
|
||||
sessionStorage.clear();
|
||||
});
|
||||
await first.click('[data-key="lib-add"]');
|
||||
let stopped = null;
|
||||
for (let i = 0; i < 600; i++) {
|
||||
const now = at(await progress(first));
|
||||
if (now && now.n >= 40 && now.t >= 200) {
|
||||
stopped = now;
|
||||
break;
|
||||
}
|
||||
await first.waitForTimeout(50);
|
||||
}
|
||||
const wrote = await held(first);
|
||||
const listsA = await listed(first);
|
||||
await first.close();
|
||||
if (!stopped || !wrote) {
|
||||
await browser.close();
|
||||
app.kill('SIGTERM');
|
||||
console.log(`FAIL the first reading never got going — ${stopped ? `${stopped.n}/${stopped.t}` : 'no counter'}, position ${JSON.stringify(wrote)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- the next context: it opens the app and reads where it starts -----------
|
||||
const second = await context.newPage();
|
||||
await second.goto(`${base}/library`);
|
||||
let opened = null;
|
||||
for (let i = 0; i < 1200; i++) {
|
||||
const now = at(await progress(second));
|
||||
if (now) {
|
||||
opened = now;
|
||||
break;
|
||||
}
|
||||
await second.waitForTimeout(25);
|
||||
}
|
||||
// Let it work for a moment, then look at what it was: a reading that took the
|
||||
// position up has nothing to walk — the queue and the folders behind it came
|
||||
// back with it — where one that started at the top lists the whole roll again.
|
||||
await second.waitForTimeout(2500);
|
||||
const listsB = await listed(second);
|
||||
const session = await second.evaluate(() => sessionStorage.getItem('recipescam.library.walk.BigRoll') !== null);
|
||||
|
||||
await browser.close();
|
||||
app.kill('SIGTERM');
|
||||
|
||||
const ok = opened !== null && listsB === listsA && !session;
|
||||
console.log(`${ok ? 'ok ' : 'FAIL'} walk share — a reading another context left behind carries on`);
|
||||
console.log(` first context stopped at ${stopped.n}/${stopped.t}, ${listsA} folders listed, wrote ${JSON.stringify(wrote)}`);
|
||||
console.log(` next context opened at ${opened ? `${opened.n}/${opened.t}` : 'nothing — it never started'}, ${listsB} folders listed, position under the tab's own key=${session}`);
|
||||
process.exit(ok ? 0 : 1);
|
||||
@@ -361,10 +361,13 @@ export interface ScanProgress {
|
||||
// the catalogue only holds the frames that were read — so the reading would
|
||||
// start again at the top of the roll and walk every folder a second time.
|
||||
//
|
||||
// Session storage holds exactly that position, and holds it because it is the
|
||||
// tab's: the tab that reloads carries on from the frame it stopped at, and a new
|
||||
// tab starts a reading of its own from the top. The key goes when the reading
|
||||
// finishes.
|
||||
// Local storage holds exactly that position, and holds it for the whole origin,
|
||||
// which is the point: the tab that reloads carries on from the frame it stopped
|
||||
// at, and so does the next context to open the app — the installed one beside the
|
||||
// browser, where session storage, being the tab's own, handed it nothing and the
|
||||
// walk started at the top of the roll a second time, every folder listed again
|
||||
// and every frame it already held read again only to be skipped. The key goes
|
||||
// when the reading finishes.
|
||||
const WALK_KEY = (folder: string) => `recipescam.library.walk.${folder}`;
|
||||
|
||||
interface WalkSaved {
|
||||
@@ -383,8 +386,14 @@ interface WalkSaved {
|
||||
}
|
||||
|
||||
function loadWalk(folder: string): WalkSaved | null {
|
||||
// One reading at a time: a peer that has announced this folder is walking it
|
||||
// right now, and what it has written down is a step of a walk still in
|
||||
// progress. Taken up here, two readings would write the same roll down at
|
||||
// once, each undoing the other's queue. In the tab's own storage this could not
|
||||
// come up; now that the position is the origin's, it can.
|
||||
if (peer?.folder === folder) return null;
|
||||
try {
|
||||
const raw = sessionStorage.getItem(WALK_KEY(folder));
|
||||
const raw = localStorage.getItem(WALK_KEY(folder));
|
||||
if (!raw) return null;
|
||||
const saved = JSON.parse(raw) as WalkSaved;
|
||||
return Array.isArray(saved?.walked) &&
|
||||
@@ -410,7 +419,7 @@ function saveWalk(
|
||||
progress: ScanProgress
|
||||
): void {
|
||||
try {
|
||||
sessionStorage.setItem(WALK_KEY(folder), JSON.stringify({ walked, pending, frames, restored, progress } satisfies WalkSaved));
|
||||
localStorage.setItem(WALK_KEY(folder), JSON.stringify({ walked, pending, frames, restored, progress } satisfies WalkSaved));
|
||||
} catch {
|
||||
// Nothing to write down: the reading starts from the top, as it did before.
|
||||
}
|
||||
@@ -418,7 +427,7 @@ function saveWalk(
|
||||
|
||||
function clearWalk(folder: string): void {
|
||||
try {
|
||||
sessionStorage.removeItem(WALK_KEY(folder));
|
||||
localStorage.removeItem(WALK_KEY(folder));
|
||||
} catch {
|
||||
// Nothing to clear.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user