Files
RecipesCam/docker/frontend/scripts/studio-pinch-check.mjs
T

305 lines
13 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.
// Two fingers on the phone's stage — the zoom FIX and HEAL are aimed with.
//
// FIX's brushes are aimed at a zoom: a speck of dust is a few pixels wide on a
// frame fitted to a phone's screen, and a repair laid on the fitted photo is a
// smear. A phone has no wheel to notch, so two fingers are the whole of how a
// reader gets close enough — apart to zoom in, together to zoom out, their
// midpoint travelling to move the photo, and one finger left over to paint with.
//
// What this holds down is that the gesture reaches the stage *with the brush's
// own layer over the photo* (the layer takes the pointer for HEAL, so a listener
// bound to the photo would never hear it), that the pointer the browser would
// otherwise take for its own page zoom is taken by the stage instead (see
// `touch-action` on `.canvas-wrap`), that the pinch lays no repair down on its
// way, and that the finger left when it ends is the one that paints.
//
// npm run build && node scripts/studio-pinch-check.mjs
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> CHROME=<path to a chromium> \
// APP_PORT=6150 SAMPLE_PORT=6151 node scripts/studio-pinch-check.mjs
//
// 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 `studio-export-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 ?? 4310);
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4311);
const A_NAME = 'P1010256.JPG';
const B_NAME = 'D0004128.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 — 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, on a phone -------------------------------------------------
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;
};
}
};
// The phone the gesture is made on: a width under the 860px the app calls
// mobile at, a touch screen, and nothing else changed.
const context = await browser.newContext({
viewport: { width: 412, height: 900 },
hasTouch: true,
isMobile: true,
deviceScaleFactor: 1,
});
// 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.
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));
// --- the fingers -------------------------------------------------------------
// Touch, through the browser's own input pipeline rather than as a hand-made
// `PointerEvent`: the point of the check is that a real pinch is not swallowed
// by the browser's page zoom, and that is the pipeline's business, not a script's.
const cdp = await context.newCDPSession(page);
const touch = async (type, points) => {
await cdp.send('Input.dispatchTouchEvent', {
type,
touchPoints: points.map(([x, y], i) => ({ x, y, id: i + 1 })),
});
};
// The stage's own zoom, read the way the reader sees it: the transform on the
// photo. Untransformed is the fitted photo, which is 1.
const scale = () =>
page.$eval('.canvas-wrap img', (e) => {
const t = getComputedStyle(e).transform;
return t === 'none' ? 1 : Number(new DOMMatrixReadOnly(t).a);
});
const stageAt = () =>
page.$eval('.canvas-wrap', (e) => {
const r = e.getBoundingClientRect();
return [r.left + r.width / 2, r.top + r.height / 2];
});
// The gesture itself: two fingers put down `from` either side of the middle of
// the stage, walked apart to `to`, and lifted together.
const pinch = async ([cx, cy], from, to) => {
await touch('touchStart', [
[cx - from, cy],
[cx + from, cy],
]);
for (const d of [1 / 3, 2 / 3, 1]) {
await touch('touchMove', [
[cx - (from + (to - from) * d), cy],
[cx + (from + (to - from) * d), cy],
]);
}
await touch('touchEnd', []);
await page.waitForTimeout(300);
};
// --- the frame on the stage, with HEAL armed ---------------------------------
await page.goto(`${base}/library`);
// The library has a phone's own screen at this width, and the catalogue is
// picked on the other one; the studio the frame opens into is the phone's either
// way, because that is the window's width and not this switch (see `isMobile`).
await page.click('button:has-text("Desktop")').catch(() => {});
await page.click('[data-key="lib-add"]');
await page.waitForFunction((sel) => document.querySelectorAll(sel).length >= 2, '[data-key^="lib-thumb-"]', {
timeout: 120_000,
});
await page.click('[data-key="lib-open"]');
// The phone's studio is the stage and the bar of tabs: the chips of a tab stand
// behind it until one of them is opened, so what says the frame has landed is
// the photo on the stage.
await page.waitForFunction(() => {
const img = document.querySelector('.canvas-wrap img');
return !!img && img.getBoundingClientRect().width > 0;
}, null, { timeout: 120_000 });
await page.waitForTimeout(1500);
step('the catalogue frame is on the phone’s stage, fitted', (await scale()) === 1, `${page.url()}`);
// --- the photo itself, with no tool armed ------------------------------------
// The fingers are the same two wherever they land, and what they are on is the
// photo until a tool is armed. The browser would take the gesture for its own
// page zoom and cancel the pointers under it, which is what `touch-action` on
// the stage's own box is there to stop.
const [cx, cy] = await stageAt();
await pinch([cx, cy], 30, 150);
const bare = await scale();
step('two fingers on the photo bring it up close', bare >= 2, `scale ${bare.toFixed(2)}`);
await pinch([cx, cy], 150, 30);
step('and two fingers together put it back', (await scale()) === 1, `scale ${(await scale()).toFixed(2)}`);
await page.click('nav.rail button:has-text("TOOLS")');
await page.waitForSelector('[data-key="fix"]', { timeout: 30_000 });
await page.click('[data-key="fix"]');
await page.waitForSelector('[data-key="heal"]', { timeout: 30_000 });
await page.click('[data-key="heal"]');
await page.waitForSelector('[data-key="brush-layer"]', { timeout: 30_000 });
step('and HEAL is armed, its own layer over the photo', true);
// --- apart again, with HEAL's own layer over the photo ------------------------
await pinch([cx, cy], 30, 150);
const zoomed = await scale();
step('two fingers apart bring the photo up close', zoomed >= 2, `scale ${zoomed.toFixed(2)}`);
step(
'and the fingers that zoomed laid no repair down on the way',
(await page.$('[data-key="heal-clear"]')) === null,
(await page.$('[data-key="heal-clear"]')) ? 'a repair landed' : 'nothing laid'
);
// --- one finger: the tool the reader came for --------------------------------
await touch('touchStart', [[cx - 40, cy]]);
for (let i = 1; i <= 6; i++) await touch('touchMove', [[cx - 40 + i * 8, cy + 4]]);
await touch('touchEnd', []);
await page.waitForSelector('[data-key="heal-clear"]', { timeout: 10_000 }).catch(() => {});
step(
'and one finger up close paints — the repair the zoom was for',
(await page.$('[data-key="heal-clear"]')) !== null,
(await page.$('[data-key="heal"]')) ? await page.$eval('[data-key="heal"]', (e) => e.textContent) : 'no chip'
);
// --- together: the photo goes back ------------------------------------------
await pinch([cx, cy], 150, 30);
const back = await scale();
step('two fingers together put the photo back on the screen', back === 1, `scale ${back.toFixed(2)}`);
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);