Files
RecipesCam/docker/frontend/scripts/walk-share-check.mjs
T

219 lines
9.2 KiB
JavaScript

// 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);