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

370 lines
17 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.
// The phone's own gestures in the studio, once a chip's ruler is open — and the
// one the ruler is not allowed to take.
//
// A phone's ruler stands in a column at the foot of the screen and the corner a
// reader is judging sits above it, so the ruler is what the hand has to reach:
// the number is taken by the touch that lands on the track itself, wherever on
// it that is. WebKit begins a slider drag only inside the thumb's own box — a
// 3px line here, see `SliderThumbElement::handleTouchStart` — so the middle of
// the ruler used to answer nothing on a phone, and the row reads the value from
// the touch's own place on the track on every engine instead. What is held down
// besides that is that the photo stays the photo's: two fingers zoom it, one
// finger dragged across it pans it once it is bigger than its box, and the open
// knob moves under none of them.
//
// npm run build && node scripts/studio-drag-check.mjs
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> CHROME=<path to a chromium> \
// APP_PORT=6164 SAMPLE_PORT=6165 node scripts/studio-drag-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-pinch-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 ?? 4312);
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4313);
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 what a real finger does on the
// browser's own pointers, not what a script can make one look like.
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.
// The stage's own transform, read the way the reader sees it: the photo's own
// box. Fitted, untransformed is scale 1 and nowhere to pan to.
const view = () =>
page.$eval('.canvas-wrap img', (e) => {
const t = getComputedStyle(e).transform;
const m = t === 'none' ? new DOMMatrixReadOnly() : new DOMMatrixReadOnly(t);
return { s: m.a, x: m.e, y: m.f };
});
const scale = async () => (await view()).s;
// The box the photo is fitted to: the drag is measured against its width, the
// way the stage measures it.
const stageBox = () =>
page.$eval('.canvas-wrap', (e) => {
const r = e.getBoundingClientRect();
return { w: r.width, cx: r.left + r.width / 2, cy: r.top + r.height / 2 };
});
// The open ruler's number, read off the row's own track: the slider is what the
// drag writes, so asking the slider is asking what landed.
const rulerValue = () => page.$eval('[data-col="slider"] input[type="range"]', (e) => Number(e.value));
// One finger: down, walked across in even steps, lifted.
const drag = async (from, [dx, dy], steps = 8) => {
await touch('touchStart', [from]);
for (let i = 1; i <= steps; i++) await touch('touchMove', [[from[0] + (dx * i) / steps, from[1] + (dy * i) / steps]]);
await touch('touchEnd', []);
await page.waitForTimeout(250);
};
// Two fingers put down `from` either side of the middle of the stage and walked
// apart to `to` — the zoom, unchanged from `studio-pinch-check.mjs`.
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 --------------------------------------------------
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"]');
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()}`);
// --- a ruler, open over the photo --------------------------------------------
// LIGHT's panels are the phone's chips, and a panel's row is a knob: the tap
// opens its ruler above the strip (see .col-slider).
await page.click('nav.rail button:has-text("LIGHT")');
await page.waitForSelector('[data-key="dev-tone"]', { timeout: 30_000 });
await page.click('[data-key="dev-tone"]');
await page.waitForSelector('[data-key="contrast"]', { timeout: 30_000 });
await page.click('[data-key="contrast"]');
await page.waitForSelector('[data-col="slider"] input[type="range"]', { timeout: 30_000 });
step('a tap on a knob opens its ruler, the row the number is read off', (await rulerValue()) === 0, `CONTRAST ${await rulerValue()}`);
// --- the hand on the ruler itself --------------------------------------------
// The number is had by the hand that lands on the ruler, and that is an
// engine's business, not the reader's: WebKit begins a slider drag only when
// the touch falls inside the thumb's own box — a 3px line here (see
// `SliderThumbElement::handleTouchStart`) — so on a phone the ticks across the
// track answered a finger while the middle of the ruler answered nothing. The
// row reads the value from the touch's own place on the track instead, on every
// engine, so what is made here is the gesture a browser's slider would take: an
// untrusted `PointerEvent`, which Blink's own slider ignores and the row's own
// listener does not. The whole gesture is put down on one pixel of the box it
// started on, the way a hand is.
const trackTouch = (rail, fraction, type = 'pointerdown', pointerId = 7, pointerType = 'touch') =>
page.$eval(
'[data-col="slider"] input[type="range"]',
(e, p) => {
e.dispatchEvent(
new PointerEvent(p.type, {
bubbles: true,
cancelable: true,
composed: true,
pointerId: p.pointerId,
pointerType: p.pointerType,
isPrimary: p.pointerId === 7,
buttons: 1,
clientX: p.rail.left + p.rail.width * p.fraction,
clientY: p.rail.mid,
})
);
},
{ rail, fraction, type, pointerId, pointerType }
);
// The ruler's own box, taken once: a hand on it is a fixed pixel, so the whole
// gesture is measured against the box it started on.
const trackRect = () =>
page.$eval('[data-col="slider"] input[type="range"]', (e) => {
const r = e.getBoundingClientRect();
return { left: r.left, width: r.width, mid: r.top + r.height / 2, value: Number(e.value) };
});
// CONTRAST is one of the bipolar knobs the spec draws from -100 to +100 (see
// `deepen` in shared/utils/paramDefs), so 85% of the track is +70.
const rail = await trackRect();
await trackTouch(rail, 0.85);
const onTrack = await rulerValue();
step('a touch on the ruler’s own track takes the number from where it landed', Math.abs(onTrack - 70) <= 1, `CONTRAST ${onTrack} at 85% of the track`);
const steady = (await trackRect()).width;
await trackTouch(rail, 0.5, 'pointermove');
step('and the number walks with the hand along the ruler', Math.abs(await rulerValue()) <= 1, `CONTRAST ${await rulerValue()} at the middle`);
step('and the track stays under the hand while the number changes length', (await trackRect()).width === steady, `${steady} -> ${(await trackRect()).width}`);
const sole = await rulerValue();
await trackTouch(rail, 0.25, 'pointermove', 9);
step('a second finger is not a second value', (await rulerValue()) === sole, `CONTRAST ${sole} -> ${await rulerValue()}`);
await trackTouch(rail, 0.2, 'pointermove');
step('and the number comes down as the hand goes back', Math.abs((await rulerValue()) + 60) <= 1, `CONTRAST ${await rulerValue()} at a fifth`);
await trackTouch(rail, 0.5, 'pointerup');
const left = await rulerValue();
await trackTouch(rail, 0.95, 'pointermove');
step('the number stands where the finger was lifted', (await rulerValue()) === left, `CONTRAST ${left} -> ${await rulerValue()}`);
await trackTouch(rail, 0.1, 'pointerdown', 11, 'mouse');
step('and the mouse keeps the drag the engines already give it', (await rulerValue()) === left, `CONTRAST ${left} -> ${await rulerValue()}`);
// --- the photo is still the photo's ------------------------------------------
// The ruler is not what a finger on the picture moves: the picture is. Zoomed
// in, one finger drags it across the box — which is the drag a ruler used to
// take for itself, and the reason the view could not be moved by hand while a
// ruler was open.
const box = await stageBox();
await pinch([box.cx, box.cy], 30, 150);
const zoomed = await view();
step('two fingers on the photo are still the phone’s own zoom', zoomed.s >= 2, `scale ${zoomed.s.toFixed(2)}`);
step('and a pinch is not a value — the knob it let go did not move', (await rulerValue()) === left, `CONTRAST ${left} -> ${await rulerValue()}`);
await drag([box.cx, box.cy], [box.w * 0.2, 0]);
const panned = await view();
step('with the photo up close one finger drags it across the box', Math.abs(panned.x - zoomed.x) > 20, `x ${Math.round(zoomed.x)} -> ${Math.round(panned.x)}`);
step('and the open ruler’s number is not what that hand was moving', (await rulerValue()) === left, `CONTRAST ${left} -> ${await rulerValue()}`);
// The view is put back, and a fitted photo has nowhere to go: the same drag
// leaves both the picture and the number exactly where they were.
await pinch([box.cx, box.cy], 150, 30);
await page.waitForTimeout(200);
step('and two fingers together put the photo back', (await view()).s === 1, `scale ${(await view()).s.toFixed(2)}`);
const fitted = await view();
await drag([box.cx, box.cy], [box.w * 0.3, 0]);
const still = await view();
step('a fitted photo has nowhere to pan to, ruler open or not', still.x === fitted.x && still.s === 1, `x ${Math.round(fitted.x)} -> ${Math.round(still.x)}`);
step('and the number the ruler is on did not move either', (await rulerValue()) === left, `CONTRAST ${left} -> ${await rulerValue()}`);
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);