Files
menu/scripts/check-dnd.mjs
T
tanshu e64f65642f Add ESLint and Prettier
Prettier owns formatting: .prettierrc.json, format and format:check scripts, and
a one-off pass over the repo so the check is green from the start. Embedded
language formatting is off deliberately - the renderers build HTML in html
tagged templates and letting Prettier reflow those would change the markup
customers receive. docs/ is excluded because it is hand-wrapped prose, and shell
scripts have no Prettier parser.

ESLint covers the JavaScript: the two browser scripts, the build and check
scripts, and its own config. no-var is scoped out for the browser scripts on
purpose; they are plain ES5-shaped IIFEs and a hoisting difference would only
show up in a browser.

ESLint cannot lint the TypeScript here: typescript@7 is the native port with no
JavaScript compiler API, and typescript-eslint requires a 4.8-6.1 peer, so it
refuses to install and forcing it through would leave a parser that cannot read
the compiler. tsc --noEmit stays the TypeScript gate; README records the two
ways to change that.

lint and format:check now run in deploy.sh, and VS Code formats on save with
Prettier and surfaces ESLint findings.
2026-09-22 23:15:08 +05:30

175 lines
6.9 KiB
JavaScript

/* Drives the real admin editor in Firefox and reports whether dragging a Page
actually reorders it and reaches the server. This is the only automated check
that reaches the drag path: the order is committed from dragover, because
browsers suppress mouse events during a drag and dragend is not guaranteed.
Needs a running dev server and geckodriver on PATH:
npm run dev # in one terminal
geckodriver --port 4444 & # in another
npm run check-dnd -- <setId> [native|synthetic|none]
Use "none" to inspect a set's affordances without dragging: an Archive should
report no draggable rows and no grab handles.
*/
const GECKO = 'http://127.0.0.1:4444';
const SITE = process.env.SITE ?? 'http://127.0.0.1:3000';
const PASSWORD = process.env.DEV_PASSWORD ?? 'menu-dev-password';
const setId = process.argv[2];
const gesture = process.argv[3] ?? 'native';
if (!setId) throw new Error('usage: node .tmp/dnd.mjs <setId> [native|synthetic|none]');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const KEY = 'element-6066-11e4-a52e-4f735466cecf';
async function cmd(method, path, body) {
const response = await fetch(GECKO + path, {
method,
headers: body ? { 'content-type': 'application/json' } : {},
body: body ? JSON.stringify(body) : undefined,
});
const json = JSON.parse(await response.text());
if (json.value && json.value.error) throw new Error(`${json.value.error}: ${json.value.message}`);
return json.value;
}
const find = async (sid, selector) =>
(await cmd('POST', `/session/${sid}/element`, { using: 'css selector', value: selector }))[KEY];
const findAll = async (sid, selector) =>
(await cmd('POST', `/session/${sid}/elements`, { using: 'css selector', value: selector })).map(
(e) => e[KEY],
);
const click = (sid, element) => cmd('POST', `/session/${sid}/element/${element}/click`, {});
const type = (sid, element, value) =>
cmd('POST', `/session/${sid}/element/${element}/value`, { text: value });
const script = (sid, body, args = []) =>
cmd('POST', `/session/${sid}/execute/sync`, { script: body, args });
async function pageState(sid) {
return script(
sid,
`return {
rows: Array.from(document.querySelectorAll('[data-page-id]')).map(function (li) {
var handle = li.querySelector('.page-admin__handle');
return {
id: li.getAttribute('data-page-id'),
draggable: li.draggable,
hasHandle: !!handle,
cursor: handle ? getComputedStyle(handle).cursor : null,
formCount: li.querySelectorAll('form').length
};
}),
hasList: !!document.querySelector('[data-pages-admin]'),
scriptLoaded: !!document.querySelector('script[src="/assets/admin.js"]'),
dnd: window.__dnd || null
};`,
);
}
const session = await cmd('POST', '/session', {
capabilities: { alwaysMatch: { 'moz:firefoxOptions': { args: ['-headless'] } } },
});
const sid = session.sessionId;
try {
await cmd('POST', `/session/${sid}/window/rect`, { width: 430, height: 1000 });
await cmd('POST', `/session/${sid}/url`, { url: `${SITE}/admin/login` });
await sleep(500);
await type(sid, await find(sid, 'input[name=password]'), PASSWORD);
await click(sid, await find(sid, 'button[type=submit]'));
await sleep(800);
await cmd('POST', `/session/${sid}/url`, { url: `${SITE}/admin/sets/${setId}` });
await sleep(1000);
await script(
sid,
`window.__dnd = { dragstart: 0, dragover: 0, drop: 0, dragend: 0 };
['dragstart','dragover','drop','dragend'].forEach(function (name) {
document.addEventListener(name, function () { window.__dnd[name] += 1; }, true);
});
return true;`,
);
const before = await pageState(sid);
console.log(`rows: ${before.rows.length}`);
console.log(`draggable rows: ${before.rows.filter((r) => r.draggable).length}`);
console.log(`rows with a grab handle: ${before.rows.filter((r) => r.hasHandle).length}`);
console.log(`handle cursor: ${before.rows[0]?.cursor}`);
console.log(`per-row up/down/delete forms: ${before.rows[0]?.formCount}`);
console.log(`[data-pages-admin] list present: ${before.hasList}`);
console.log(`admin.js included: ${before.scriptLoaded}`);
const beforeIds = before.rows.map((r) => r.id);
if (gesture === 'native' && before.rows.length >= 3) {
const elements = await findAll(sid, '[data-page-id]');
const origin = (element) => ({ [KEY]: element });
const from = elements[0];
const to = elements[2];
await cmd('POST', `/session/${sid}/actions`, {
actions: [
{
type: 'pointer',
id: 'mouse',
parameters: { pointerType: 'mouse' },
actions: [
{ type: 'pointerMove', duration: 0, origin: origin(from), x: 5, y: 5 },
{ type: 'pointerDown', button: 0 },
{ type: 'pause', duration: 200 },
{ type: 'pointerMove', duration: 300, origin: origin(to), x: 5, y: 40 },
{ type: 'pause', duration: 300 },
{ type: 'pointerUp', button: 0 },
],
},
],
});
await sleep(1200);
}
if (gesture === 'synthetic' && before.rows.length >= 3) {
// Exercises our handlers without the browser's own drag machinery.
await script(
sid,
`var rows = document.querySelectorAll('[data-page-id]');
var from = rows[0], to = rows[2];
var rect = to.getBoundingClientRect();
function fire(target, type, extra) {
var event = new DragEvent(type, Object.assign({ bubbles: true, cancelable: true, dataTransfer: new DataTransfer() }, extra || {}));
target.dispatchEvent(event);
}
fire(from, 'dragstart');
fire(to, 'dragover', { clientY: rect.bottom - 2 });
fire(to, 'drop', { clientY: rect.bottom - 2 });
fire(from, 'dragend');
return true;`,
);
await sleep(1200);
}
const after = await pageState(sid);
const afterIds = after.rows.map((r) => r.id);
console.log(`\nevents seen: ${JSON.stringify(after.dnd)}`);
const domChanged = JSON.stringify(beforeIds) !== JSON.stringify(afterIds);
console.log(`DOM order changed by the drag: ${domChanged}`);
if (domChanged) {
console.log(` was: ${beforeIds.slice(0, 3).join(', ')} ...`);
console.log(` now: ${afterIds.slice(0, 3).join(', ')} ...`);
}
// What the server holds now, read back through the editor.
await cmd('POST', `/session/${sid}/url`, { url: `${SITE}/admin/sets/${setId}` });
await sleep(800);
const reloaded = await pageState(sid);
const serverIds = reloaded.rows.map((r) => r.id);
const persisted = JSON.stringify(serverIds) !== JSON.stringify(beforeIds);
console.log(`server order changed (after reload): ${persisted}`);
if (persisted) console.log(` server now: ${serverIds.slice(0, 3).join(', ')} ...`);
console.log(
`\nVERDICT: ${domChanged && persisted ? 'PASS' : 'FAIL'} (drag reorders and persists)`,
);
} finally {
await cmd('DELETE', `/session/${sid}`);
}