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.
175 lines
6.9 KiB
JavaScript
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}`);
|
|
}
|