Two defects, both found by driving the real editor in Firefox: 1. The editor drew grab handles with a grab cursor on sets it refuses to reorder, so the live Food menu looked draggable and did nothing. Handles and the move buttons are now rendered only when reordering is possible, and the notice says what can still be done. 2. Reordering was saved only from dragend. Browsers suppress mouse events during a drag and dragend is not guaranteed to arrive, which left the rows visibly reordered while the server kept the old order — the drag looked like it worked and then reverted on reload. The order now commits from dragover after a pause in the drag, with drop/dragend/mouse events as faster paths, deduped against the last confirmed order and reported in the page. To make (1) coherent the Current set may now be reordered while its Pages stay fixed: order is presentation, media URLs are content-hashed and pages are rendered per request, so nothing stale can be served (ADR 0006). Archives stay frozen. Admin pages are no-store and assets cache for 5 minutes, so a fix like this one is not masked by a stale admin.js. scripts/check-dnd.mjs drives the real editor and asserts the drag persists; it is the only automated check that reaches this path.
173 lines
6.9 KiB
JavaScript
173 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}`);
|
|
}
|