219 lines
9.2 KiB
JavaScript
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);
|