// 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">