Files
menu/scripts/check-dnd.mjs
T
tanshu 0667110886 Lint with oxlint instead of ESLint
ESLint cannot parse this project's TypeScript: typescript@7 is the native port
with no JavaScript compiler API, which every ESLint TypeScript parser needs, and
typescript-eslint refuses to install with a >=4.8.4 <6.1.0 peer. oxlint parses
TypeScript without the compiler API, so it lints the .ts and the .js together in
about 20ms over the whole repo, and it runs the correctness category as errors.

It found one real thing on the first run: the drag harness built its fetch
options with a body key even for GET. The four opinionated rules it also flagged
are off with their reasoning in the README, since oxlint's config takes no
comments. lint is back in deploy.sh's checks, and the editor has lint tasks and
the oxc extension recommendation. Prettier is unchanged.
2026-09-22 23:21:19 +05:30

176 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 init = { method };
if (body) {
init.headers = { 'content-type': 'application/json' };
init.body = JSON.stringify(body);
}
const response = await fetch(GECKO + path, init);
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}`);
}