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

323 lines
14 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.
// One finger on the photo, while a chip's ruler is open — the knob moved off
// the picture instead of out of the row.
//
// 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 hand leaves the number to go and look
// at what the number is doing. What this holds down is the trade the stage makes
// instead: with a ruler open, one finger dragged across the photo IS that
// ruler's track — right for up the scale, left for down it, the range travelling
// the photo's whole width, the same travel the row's own slider has — and the
// number stops at its own ends. It holds down the two things that could be lost
// to it as well: two fingers are still the phone's own zoom (a pinch is not a
// value, so the finger's hold is given up the moment the second one lands), and
// with the row put away the same drag changes nothing at all.
//
// 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.
const scale = () =>
page.$eval('.canvas-wrap img', (e) => {
const t = getComputedStyle(e).transform;
return t === 'none' ? 1 : Number(new DOMMatrixReadOnly(t).a);
});
// 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));
const chipText = (key) => page.$eval(`[data-key="${key}"]`, (e) => e.textContent ?? '');
// 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 in the column 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 photo is about to become', (await rulerValue()) === 0, `CONTRAST ${await rulerValue()}`);
// --- one finger across the photo ---------------------------------------------
// CONTRAST is one of the bipolar knobs the spec draws from -100 to +100 (see
// `deepen` in shared/utils/paramDefs), so a quarter of the photo's width is
// fifty of them — and the number is on the row, not under the hand.
const box = await stageBox();
await drag([box.cx, box.cy], [box.w * 0.25, 0]);
const quarter = await rulerValue();
step('a finger dragged to the right lifts the knob with it', Math.abs(quarter - 50) <= 1, `CONTRAST ${quarter} for a quarter of the width`);
step('and the photo stayed where it was — the drag is the value, not the view', (await scale()) === 1, `scale ${await scale()}`);
// Past the far edge the number stops at its own end rather than running on.
await drag([box.cx, box.cy], [box.w * 0.6, 0]);
step('dragged past the edge, the knob stops at its own end', (await rulerValue()) === 100, `CONTRAST ${await rulerValue()}`);
// --- two fingers are still the zoom -----------------------------------------
// The second finger is not a second value: the zoom wins, and the knob it was
// holding is left exactly where the pinch found it.
const held = await rulerValue();
await pinch([box.cx, box.cy], 30, 150);
const zoomed = await scale();
step('two fingers on the photo are still the phone’s own zoom', zoomed >= 2, `scale ${zoomed.toFixed(2)}`);
step('and a pinch is not a value — the knob it let go did not move', (await rulerValue()) === held, `CONTRAST ${held} -> ${await rulerValue()}`);
await pinch([box.cx, box.cy], 150, 30);
await page.waitForTimeout(200);
step('and two fingers together put the photo back', (await scale()) === 1, `scale ${(await scale()).toFixed(2)}`);
// --- and back down the scale -------------------------------------------------
await drag([box.cx, box.cy], [-box.w * 0.25, 0]);
const down = await rulerValue();
step('dragged to the left the knob comes down with it', Math.abs(down - 50) <= 1, `CONTRAST ${down}`);
// --- with the row put away ---------------------------------------------------
// The drag belongs to the ruler, not to the photo, and a phone puts the row away
// through the path under the strip — the strip it came from is off the screen
// while the row stands (see the rule that hides the options column). The same
// finger across the same picture then moves nothing at all.
const before = await chipText('contrast');
await page.click('[data-key="crumb-strip"]');
await page.waitForFunction(() => !document.querySelector('[data-col="slider"] input[type="range"]'), null, { timeout: 30_000 });
await drag([box.cx, box.cy], [box.w * 0.3, 0]);
step('with the ruler put away the same drag changes nothing', (await chipText('contrast')) === before, `${before} -> ${await chipText('contrast')}`);
step('and the photo is still fitted, neither panned nor zoomed', (await scale()) === 1, `scale ${await scale()}`);
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);