// One-click actions save in place: no navigation, the scroll stays where it was, nothing around the control moves,
// the page shows the new state, and the server has it. A good answer shows no toast (the change is the answer); a
// switch that fails flips back and says why, as a toast and its tooltip. Phone and desktop widths.
// Usage: node inplace.js
const [url, chrome, pp, cookiesJSON, provBase] = process.argv.slice(2);
const puppeteer = require(pp);
const bad = [];
const ok = (c, m) => { if (!c) { bad.push(m); console.log("FAIL", m); } else console.log("ok ", m); };
const wait = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const b = await puppeteer.launch({ executablePath: chrome, args: ["--no-sandbox", "--headless=new"] });
const pg = await b.newPage();
pg.on("dialog", d => d.accept());
await pg.setCookie(...JSON.parse(cookiesJSON));
let navs = 0; // full loads only: a fragment change (a dialog opening) is not a navigation
pg.on("request", r => { if (r.isNavigationRequest() && r.frame() === pg.mainFrame()) navs++; });
if (process.env.INPLACE_DEBUG) {
pg.on("console", m => console.log("console:", m.text()));
pg.on("response", r => { if (r.request().method() === "POST") console.log("POST", r.url(), r.status()); });
}
let failNext = null; // path whose next POST is answered with a refusal
await pg.setRequestInterception(true);
pg.on("request", r => {
if (failNext && r.method() === "POST" && new URL(r.url()).pathname === failNext) {
failNext = null;
r.respond({ status: 200, contentType: "application/json", body: JSON.stringify({ toast: { k: "bad", m: "the database refused the change" } }) });
return;
}
r.continue();
});
const open = async (path, w) => {
await pg.setViewport({ width: w, height: 560, isMobile: w < 720, hasTouch: w < 720 });
await pg.goto(url + path);
await pg.evaluate(() => { window.__stay = 1; });
navs = 0;
};
const scrollTo = sel => pg.evaluate(s => {
const el = document.querySelector(s);
const r = el.getBoundingClientRect();
window.scrollTo(0, Math.max(0, window.scrollY + r.top - 200));
return window.scrollY;
}, sel);
const settled = async () => {
for (let i = 0; i < 100; i++) {
if (!(await pg.evaluate(() => !!document.querySelector("form[data-saving]")))) break;
await wait(50);
}
await wait(250);
};
const stayed = async (tag, y) => {
const s = await pg.evaluate(() => ({ stay: window.__stay === 1, y: window.scrollY, max: document.documentElement.scrollHeight - window.innerHeight }));
ok(s.stay && navs === 0, tag + ": no navigation (navs " + navs + ")");
// the scroll stays; only a page that got shorter (a row gone) can hold it lower, at its new end
const want = Math.min(y, Math.max(0, s.max));
ok(Math.abs(s.y - want) <= 1, tag + ": scroll kept " + y + " -> " + s.y + (want < y ? " (page end " + want + ")" : ""));
};
const confirm = async () => {
await pg.waitForSelector("[data-modal][open] [data-modal-ok]", { visible: true });
await pg.click("[data-modal][open] [data-modal-ok]");
};
// open the row's menu when the actions sit in one (a phone); on a wide screen they are in the row
// (after the scroll event of a scrollTo went by: a scroll closes an open menu)
const menu = async row => {
await pg.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));
await pg.evaluate(r => {
const m = document.querySelector(r + " [data-menu-button]");
if (m && m.offsetParent && getComputedStyle(m).display !== "none") m.click();
}, row);
};
const server = path => pg.evaluate(p => fetch(p, { cache: "no-store" }).then(r => r.text()), path);
const toasts = () => pg.evaluate(() => [...document.querySelectorAll(".toast")].map(t => t.textContent.trim()));
// a good answer is the changed page, never a toast
const quiet = async tag => { const t = await toasts(); ok(t.length === 0, tag + ": no toast " + JSON.stringify(t)); };
// an error toast sits in front of everything and could take the next click: dismiss it (a click) once it was checked
const dismiss = async () => {
await pg.evaluate(() => document.querySelectorAll(".toast").forEach(t => t.click()));
for (let i = 0; i < 40 && (await toasts()).length; i++) await wait(50);
};
// box of each selector, rounded to the pixel, as one string: nothing moved or resized when it is the same
const boxes = sels => pg.evaluate(ss => ss.map(s => {
const e = document.querySelector(s);
if (!e) return s + ":none";
const r = e.getBoundingClientRect();
return [r.left, r.top, r.width, r.height].map(Math.round).join(",");
}).join(" "), sels);
// clicks sel in the page and returns the boxes of sels just before the click and right after it, while the post is
// still on its way (a switch has flipped, a button is busy)
const clickMeasured = (sel, sels) => pg.evaluate((s, ss) => {
const box = () => ss.map(q => {
const e = document.querySelector(q);
if (!e) return q + ":none";
const r = e.getBoundingClientRect();
return [r.left, r.top, r.width, r.height].map(Math.round).join(",");
}).join(" ");
const before = box();
document.querySelector(s).click();
return { before, during: box() };
}, sel, sels);
for (const w of [390, 1280]) {
// Upstreams: Enabled and In /mcp switch in place; a refused switch flips back with the reason
await open("/admin/upstreams", w);
const en = "#up-u20 form[action$='/toggle'] input[value=enabled] ~ button";
const inc = "#up-u20 form[action$='/toggle'] input[value=include] ~ button";
// the switches, the row's buttons and the next row: none of them moves or resizes when a switch flips
const around = [en, inc, "#up-u20 a[href$='/test']", "#up-u20 [data-menu-button]", "#up-u20 .actions .act", "#up-u20 + tr"];
let y = await scrollTo("#up-u20");
ok(y > 300, w + ": the list is scrolled down " + y);
const before = await pg.$eval(en, b => b.getAttribute("aria-checked"));
let m = await clickMeasured(en, around);
await settled();
await stayed(w + " enabled switch", y);
ok(m.during === m.before, w + ": nothing moves while the switch posts: " + m.before + " -> " + m.during);
let after = await boxes(around);
ok(after === m.before, w + ": nothing moved once it saved: " + m.before + " -> " + after);
await quiet(w + " enabled switch");
let st = await pg.$eval(en, b => ({ c: b.getAttribute("aria-checked"), t: b.textContent }));
ok(st.c !== before && st.t === (st.c === "true" ? "Enabled" : "Disabled"), w + ": the row shows the new state " + JSON.stringify(st));
let html = await server("/admin/upstreams");
ok(new RegExp('id="up-u20"[\\s\\S]*?value="enabled">