375 lines
16 KiB
JavaScript
375 lines
16 KiB
JavaScript
// The studio's filing clock, checked where it actually runs: in a browser.
|
|
//
|
|
// What it holds down is the lag a reader feels while grading a catalogue frame
|
|
// in the studio. The look a frame is left at goes back to the shelf as two
|
|
// things — the look itself (the `edits` row the studio reopens it from) and the
|
|
// tile the shelf draws for it (a develop of that look, written into the store
|
|
// the shelf reads) — and both used to be filed behind *every* frame of a slider
|
|
// drag. A drag is a preview per frame; a develop and a store write behind each
|
|
// of those is the drag.
|
|
//
|
|
// So they go back on an idle clock instead: ten seconds with nothing asked of
|
|
// the stage. What this check holds is that clock and the two ways it is cut
|
|
// short — a frame left behind and a page left behind, both of which must file
|
|
// the waiting look at once rather than throw the last seconds of it away.
|
|
//
|
|
// npm run build && node scripts/studio-export-check.mjs
|
|
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> node scripts/studio-export-check.mjs
|
|
// CHROME=<path to a chromium> PLAYWRIGHT_CORE=... node scripts/studio-export-check.mjs
|
|
// BASE=http://127.0.0.1:8090 node scripts/studio-export-check.mjs # a server already up
|
|
//
|
|
// Playwright is not a dependency of this package (a browser run needs it, a
|
|
// build does not), so a run without it says SKIP and exits 0. Two real frames
|
|
// are served off a local server and `showDirectoryPicker` is answered with a
|
|
// folder made of them, the same stand-in `library-check.mjs` uses.
|
|
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 ?? 4225);
|
|
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4339);
|
|
const A_NAME = 'P1010256.JPG';
|
|
const B_NAME = 'D0004128.JPG';
|
|
const DB = 'recipescam-library';
|
|
const STORES = ['folders', 'photos', 'thumbs', 'edits', 'dirs'];
|
|
// The stage's own idle clock, as the studio spells it: ten seconds.
|
|
const IDLE_MS = 10_000;
|
|
|
|
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 two 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 (name !== A_NAME && name !== B_NAME) {
|
|
res.writeHead(404).end();
|
|
return;
|
|
}
|
|
const bytes = await readFile(path.join(SAMPLES, name));
|
|
res.writeHead(200, {
|
|
'Content-Type': 'application/octet-stream',
|
|
'Content-Length': bytes.length,
|
|
'Access-Control-Allow-Origin': '*',
|
|
'Cross-Origin-Resource-Policy': 'cross-origin',
|
|
});
|
|
res.end(bytes);
|
|
});
|
|
await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve));
|
|
|
|
// A server already up, when one is named; otherwise the build in `dist/`.
|
|
let app = null;
|
|
const base = process.env.BASE ?? `http://127.0.0.1:${APP_PORT}`;
|
|
if (!process.env.BASE) {
|
|
app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
|
|
cwd: FRONTEND,
|
|
stdio: 'ignore',
|
|
});
|
|
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);
|
|
}
|
|
}
|
|
|
|
// --- the browser ------------------------------------------------------------
|
|
const browser = await playwright.chromium.launch({
|
|
executablePath: process.env.CHROME || undefined,
|
|
args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'],
|
|
});
|
|
|
|
// A folder of two frames, handed over the way the browser hands one over.
|
|
const INIT = ({ origin, names, folder }) => {
|
|
const bytes = new Map();
|
|
const load = async (n) => {
|
|
if (!bytes.has(n)) bytes.set(n, fetch(`${origin}/${n}`).then((r) => r.arrayBuffer()));
|
|
return bytes.get(n);
|
|
};
|
|
const fileHandle = (name) => {
|
|
const h = { kind: 'file', name, __fake: true };
|
|
Object.defineProperties(h, {
|
|
getFile: {
|
|
value: async () =>
|
|
new File([await load(name)], name, {
|
|
type: name.endsWith('.JPG') ? 'image/jpeg' : 'application/octet-stream',
|
|
lastModified: Date.UTC(2026, 0, 1),
|
|
}),
|
|
},
|
|
queryPermission: { value: async () => 'granted' },
|
|
requestPermission: { value: async () => 'granted' },
|
|
});
|
|
return h;
|
|
};
|
|
const dirHandle = (label) => {
|
|
const h = { kind: 'directory', name: label, __fake: true };
|
|
Object.defineProperties(h, {
|
|
values: { value: () => names.map(fileHandle)[Symbol.iterator]() },
|
|
queryPermission: { value: async () => 'granted' },
|
|
requestPermission: { value: async () => 'granted' },
|
|
getDirectoryHandle: { value: async () => { throw new DOMException('no folder', 'NotFoundError'); } },
|
|
getFileHandle: {
|
|
value: async (name) => {
|
|
if (!names.includes(name)) throw new DOMException('no frame', 'NotFoundError');
|
|
return fileHandle(name);
|
|
},
|
|
},
|
|
});
|
|
return h;
|
|
};
|
|
const root = () => dirHandle(folder);
|
|
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);
|
|
return row;
|
|
};
|
|
for (const m of ['get', 'getAll']) {
|
|
const original = IDBObjectStore.prototype[m];
|
|
IDBObjectStore.prototype[m] = function (...a) {
|
|
const req = original.apply(this, a);
|
|
const d = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result');
|
|
Object.defineProperty(req, 'result', {
|
|
configurable: true,
|
|
get() {
|
|
const v = d.get.call(this);
|
|
return Array.isArray(v) ? v.map(revive) : revive(v);
|
|
},
|
|
});
|
|
return req;
|
|
};
|
|
}
|
|
};
|
|
|
|
const open = async () => {
|
|
const context = await browser.newContext({ viewport: { width: 1500, height: 950 } });
|
|
// No server is up behind this build, and a vite preview answers an API path
|
|
// with the SPA's own `index.html` — which parses to `{}` and so reads as an
|
|
// empty *answer* rather than as "no server", taking the studio down with it.
|
|
// The reads the two screens make on load are answered here; anything else is
|
|
// refused, the way a missing route is.
|
|
const replies = {
|
|
'/api/auth/me': { user: { id: 1, email: 'check@local', admin: true, verified: true, pro: true } },
|
|
'/api/recipes': { recipes: [] },
|
|
'/api/photos/mine': { photos: [] },
|
|
'/api/photos': { photos: [] },
|
|
};
|
|
await context.route('**/api/**', (route) => {
|
|
const body = replies[new URL(route.request().url()).pathname];
|
|
return route.fulfill(
|
|
body
|
|
? { status: 200, contentType: 'application/json', body: JSON.stringify(body) }
|
|
: { status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'HTTP 404' }) }
|
|
);
|
|
});
|
|
await context.addInitScript(INIT, { origin: `http://127.0.0.1:${SAMPLE_PORT}`, names: [A_NAME, B_NAME], folder: 'CheckRoll' });
|
|
const page = await context.newPage();
|
|
const errors = [];
|
|
page.on('pageerror', (e) => errors.push(e.message));
|
|
return { context, page, errors };
|
|
};
|
|
|
|
// The shelf's own strip of frames: one thumb per frame the catalogue holds, and
|
|
// the click that puts a frame on the stage (see Library.tsx).
|
|
const tiles = '[data-key^="lib-thumb-"]';
|
|
const inStudio = (page) => /^\/app(\?|$)/.test(new URL(page.url()).pathname + new URL(page.url()).search);
|
|
|
|
// The `edits` store as the shelf and the studio read it: what look each frame
|
|
// the catalogue holds was left at, and when it was written.
|
|
const readEdits = (page) =>
|
|
page.evaluate(
|
|
(name) =>
|
|
new Promise((resolve, reject) => {
|
|
const req = indexedDB.open(name);
|
|
req.onsuccess = () => {
|
|
const db = req.result;
|
|
const all = db.transaction('edits', 'readonly').objectStore('edits').getAll();
|
|
all.onsuccess = () => {
|
|
db.close();
|
|
resolve(all.result.map((r) => ({ photoId: r.photoId, simId: r.state?.simId, updatedAt: r.updatedAt })));
|
|
};
|
|
all.onerror = () => reject(all.error);
|
|
};
|
|
req.onerror = () => reject(req.error);
|
|
}),
|
|
DB
|
|
);
|
|
|
|
// A fingerprint of every tile the store holds: the develop in a tile is not
|
|
// byte-stable across runs, so what is held down is that it *moved*.
|
|
const readThumbs = (page) =>
|
|
page.evaluate(
|
|
(name) =>
|
|
new Promise((resolve, reject) => {
|
|
const fp = (v) => {
|
|
let h = 2166136261 >>> 0;
|
|
for (let i = 0; i < v.length; i += 97) h = Math.imul(h ^ v[i], 16777619) >>> 0;
|
|
return `${v.length}:${h}`;
|
|
};
|
|
const req = indexedDB.open(name);
|
|
req.onsuccess = () => {
|
|
const db = req.result;
|
|
const all = db.transaction('thumbs', 'readonly').objectStore('thumbs').getAll();
|
|
all.onsuccess = async () => {
|
|
db.close();
|
|
const out = {};
|
|
for (const row of all.result) {
|
|
const blob = row.thumb ?? row.blob;
|
|
out[row.id] = blob ? fp(new Uint8Array(await blob.arrayBuffer())) : 'none';
|
|
}
|
|
resolve(out);
|
|
};
|
|
all.onerror = () => reject(all.error);
|
|
};
|
|
req.onerror = () => reject(req.error);
|
|
}),
|
|
DB
|
|
);
|
|
|
|
// The tile is a develop of the look, made after the look itself is filed, so
|
|
// the shelf hears about the look first and the tile a beat later. Wait for it.
|
|
const waitForTileMove = async (page, before, ms) => {
|
|
const t0 = Date.now();
|
|
for (;;) {
|
|
const now = await readThumbs(page);
|
|
const moved = Object.keys(now).filter((id) => now[id] !== before[id]);
|
|
if (moved.length || Date.now() - t0 >= ms) return { moved, now };
|
|
await page.waitForTimeout(250);
|
|
}
|
|
};
|
|
|
|
// A change of the whole look, made the way a reader makes one: one pick in
|
|
// PHOTO STYLE, which is the rail's own strip on the tab the studio opens on. Any
|
|
// chip but the one in force is a different look, and every one of them moves the
|
|
// stage the way a turn or a knob does.
|
|
const pickStyle = async (page) => {
|
|
if (!(await page.$('[data-key^="style:"]'))) await page.click('[data-key="grp-style"]');
|
|
const keys = await page.$$eval('[data-key^="style:"]', (els) =>
|
|
els.filter((e) => !e.classList.contains('on')).map((e) => e.dataset.key)
|
|
);
|
|
if (!keys.length) throw new Error('no look left to pick in the PHOTO STYLE strip');
|
|
await page.click(`[data-key="${keys[0]}"]`);
|
|
// The chip's key carries its group (`style:<sim>`); what the studio files is
|
|
// the sim alone, which is what this is watched by.
|
|
return keys[0].slice('style:'.length);
|
|
};
|
|
// Wait for the look to reach the shelf, and say how long that took. Only a
|
|
// filing made after `since` counts: a look picked a second time is a look the
|
|
// store already holds, and the row it holds from the last time is not a filing.
|
|
const waitForLook = async (page, simId, ms, since = 0) => {
|
|
const t0 = Date.now();
|
|
for (;;) {
|
|
const row = (await readEdits(page)).find((r) => r.simId === simId && r.updatedAt >= since);
|
|
if (row) return { row, ms: Date.now() - t0 };
|
|
if (Date.now() - t0 >= ms) return { row: null, ms: Date.now() - t0 };
|
|
await page.waitForTimeout(250);
|
|
}
|
|
};
|
|
|
|
// --- 1. the filing clock -----------------------------------------------------
|
|
{
|
|
const { context, page, errors } = await open();
|
|
await page.goto(`${base}/library`);
|
|
await page.click('[data-key="lib-add"]');
|
|
await page.waitForFunction((t) => document.querySelectorAll(t.sel).length >= t.n, { sel: tiles, n: 2 }, {
|
|
timeout: 120_000,
|
|
});
|
|
step('the roll is indexed', true);
|
|
|
|
// The shelf's own hand-over: the frame the stage opens is the one its panel is
|
|
// on, which is what `lib-open` asks for.
|
|
const held = await page.$$eval(tiles, (els) => els.map((e) => e.dataset.key.slice('lib-thumb-'.length)));
|
|
const openName = await page.$eval('[data-key="lib-stage-meta"] b', (e) => e.textContent.trim());
|
|
const firstId = held.find((id) => id.endsWith(openName)) ?? held[0];
|
|
const otherId = held.find((id) => id !== firstId) ?? held[1];
|
|
await page.click('[data-key="lib-open"]');
|
|
await page.waitForSelector('[data-key="grp-style"]', { timeout: 120_000 });
|
|
await page.waitForTimeout(1500);
|
|
step('the catalogue frame is on the stage', inStudio(page), `${page.url()} — ${openName}`);
|
|
|
|
const edits0 = await readEdits(page);
|
|
const thumbs0 = await readThumbs(page);
|
|
step('the shelf holds one tile per frame and no look filed yet', Object.keys(thumbs0).length === 2, JSON.stringify(thumbs0));
|
|
|
|
// One pick: a change of the whole look, which is what a drag of a knob is too
|
|
// — the stage is asked for a preview per frame of it.
|
|
const t0 = Date.now();
|
|
const look = await pickStyle(page);
|
|
await page.waitForTimeout(1500);
|
|
const early = await readEdits(page);
|
|
const thumbsEarly = await readThumbs(page);
|
|
step(
|
|
'nothing is filed while the stage is being worked',
|
|
!early.some((r) => r.simId === look),
|
|
`+${Date.now() - t0}ms: ${JSON.stringify(early)}`
|
|
);
|
|
step(
|
|
'no develop behind the slider either',
|
|
JSON.stringify(thumbsEarly) === JSON.stringify(thumbs0),
|
|
`${JSON.stringify(thumbs0)} -> ${JSON.stringify(thumbsEarly)}`
|
|
);
|
|
|
|
// Ten seconds of nothing asked of the stage is a look the reader stopped
|
|
// making; the shelf hears about it once.
|
|
const waited = await waitForLook(page, look, IDLE_MS + 6000, t0);
|
|
const filedAt = Date.now() - t0;
|
|
step('the look goes back once the stage has gone quiet', !!waited.row, `after ${filedAt}ms`);
|
|
const tile = await waitForTileMove(page, thumbs0, 15_000);
|
|
const moved = tile.moved;
|
|
step('the shelf gets a fresh tile for that look', moved.length === 1, moved.join() || 'none moved');
|
|
const filed = waited.row?.photoId ?? moved[0] ?? null;
|
|
step('the look went back to the frame the stage is on', filed !== null && filed.endsWith(openName), String(filed));
|
|
|
|
// --- 2. a page left behind ------------------------------------------------
|
|
const look2 = await pickStyle(page);
|
|
const t2 = Date.now();
|
|
await page.evaluate(() => window.dispatchEvent(new Event('pagehide')));
|
|
const left = await waitForLook(page, look2, 5000, t2);
|
|
step(
|
|
'a page left behind files the waiting look at once',
|
|
!!left.row && left.row.photoId === filed,
|
|
`after ${left.ms}ms, ${JSON.stringify(left.row)}`
|
|
);
|
|
|
|
// --- 3. a frame left behind ----------------------------------------------
|
|
const look3 = await pickStyle(page);
|
|
const t3 = Date.now();
|
|
await page.click('[data-key="nav-library"]');
|
|
await page.waitForSelector(tiles, { timeout: 60_000 });
|
|
await page.click(`[data-key="lib-thumb-${otherId}"]`);
|
|
await page.click('[data-key="lib-open"]');
|
|
await page.waitForSelector('[data-key="grp-style"]', { timeout: 120_000 });
|
|
const switched = await waitForLook(page, look3, IDLE_MS - 1000, t3);
|
|
step(
|
|
'a frame left behind files its waiting look at once',
|
|
!!switched.row && switched.row.photoId === filed,
|
|
`${otherId} opened ${switched.ms}ms after the pick: ${JSON.stringify(switched.row)}`
|
|
);
|
|
step('no uncaught error', errors.length === 0, errors.join(' | '));
|
|
await context.close();
|
|
}
|
|
|
|
await browser.close();
|
|
sampleServer.close();
|
|
app?.kill('SIGTERM');
|
|
console.log(failures ? `\n${failures} FAILED` : '\nall ok');
|
|
process.exit(failures ? 1 : 0);
|