370 lines
17 KiB
JavaScript
370 lines
17 KiB
JavaScript
// 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);
|